From 69690a1c4842651cd923f874bb2059d90b44ec5e Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 17 Jul 2026 18:32:10 +0000 Subject: [PATCH 01/10] feat(#283): Dashboard v1 contracts, codecs, canonical encoding, resource limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of #280 (Dashboard as a first-class module): pure contracts and codecs only — no persistence, UI, or import planner. Schemas (through the existing manifest pipeline, generated types + Ajv validators committed): - stored-workspace-v1, dashboard-v1 (tile/filter/layout defs + DashboardLayoutFallbackV1 = flow@1), dashboard-layout-flow-v1 (normative flow@1, closed FlowTilePlacementV1), portable-bundle-v1 (queries + dashboards, both required even when empty). - query-spec-v1 gains QueryDashboardPresentationV1 + QueryPresentationPatchV1 as an in-place additive revision (pinned decision 1); Spec-editor autocomplete picks the new fields up. Pure src/ modules (100% statements/functions/lines per file): - dashboard/model: canonical-json (one deterministic encoder for export, persistence, hashing, equality), workspace-diagnostics (deterministic numeric-aware sorter), portable-limits (PORTABLE_LIMITS verbatim; 10 MiB cap kept for the IndexedDB-backed phase-2 repo — pinned decision 2), json-limits (UTF-8 byte + depth guards before parsing), bundle-order (dependency-closure + multi-dashboard ordering), workspace-semantics (whole-workspace cross-resource validation), portable-bundle-codec. - workspace/stored-workspace: aggregate codec + validation. Boundary rules added for src/dashboard + src/workspace (model <- application <- UI direction). check:arch, check:schemas, tsc, build all green. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 22 + build/check-boundaries.mjs | 19 + build/emit-schema-types.mjs | 2 +- build/schema-manifest.mjs | 27 + schemas/dashboard-layout-flow-v1.schema.json | 65 + schemas/dashboard-v1.schema.json | 226 ++ .../generated/library-v2.bundle.schema.json | 628 ++++- schemas/generated/schema-catalog.json | 24 + schemas/portable-bundle-v1.schema.json | 68 + schemas/query-spec-v1.schema.json | 59 +- schemas/stored-workspace-v1.schema.json | 49 + src/core/json-schema-validation.ts | 7 + src/core/saved-query.ts | 6 +- src/dashboard/model/bundle-order.ts | 100 + src/dashboard/model/canonical-json.ts | 158 ++ src/dashboard/model/json-limits.ts | 93 + src/dashboard/model/portable-bundle-codec.ts | 141 + src/dashboard/model/portable-limits.ts | 33 + src/dashboard/model/workspace-diagnostics.ts | 76 + src/dashboard/model/workspace-semantics.ts | 437 +++ src/generated/json-schema-validators.js | 2387 ++++++++++++++++- src/generated/json-schema.types.ts | 396 ++- src/generated/json-schemas.js | 636 ++++- src/workspace/stored-workspace.ts | 119 + tests/helpers/saved-query.ts | 4 +- tests/unit/bundle-order.test.ts | 73 + tests/unit/canonical-json.test.ts | 124 + tests/unit/json-limits.test.ts | 78 + tests/unit/json-schema-validation.test.ts | 3 + tests/unit/portable-bundle-codec.test.ts | 127 + tests/unit/portable-limits.test.ts | 28 + tests/unit/saved-query.test.ts | 8 +- tests/unit/schema-build.test.js | 34 +- tests/unit/spec-completion.test.ts | 12 + tests/unit/stored-workspace.test.ts | 104 + tests/unit/workspace-diagnostics.test.ts | 78 + tests/unit/workspace-semantics.test.ts | 334 +++ 37 files changed, 6706 insertions(+), 79 deletions(-) create mode 100644 schemas/dashboard-layout-flow-v1.schema.json create mode 100644 schemas/dashboard-v1.schema.json create mode 100644 schemas/portable-bundle-v1.schema.json create mode 100644 schemas/stored-workspace-v1.schema.json create mode 100644 src/dashboard/model/bundle-order.ts create mode 100644 src/dashboard/model/canonical-json.ts create mode 100644 src/dashboard/model/json-limits.ts create mode 100644 src/dashboard/model/portable-bundle-codec.ts create mode 100644 src/dashboard/model/portable-limits.ts create mode 100644 src/dashboard/model/workspace-diagnostics.ts create mode 100644 src/dashboard/model/workspace-semantics.ts create mode 100644 src/workspace/stored-workspace.ts create mode 100644 tests/unit/bundle-order.test.ts create mode 100644 tests/unit/canonical-json.test.ts create mode 100644 tests/unit/json-limits.test.ts create mode 100644 tests/unit/portable-bundle-codec.test.ts create mode 100644 tests/unit/portable-limits.test.ts create mode 100644 tests/unit/stored-workspace.test.ts create mode 100644 tests/unit/workspace-diagnostics.test.ts create mode 100644 tests/unit/workspace-semantics.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ca121c..340530dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,28 @@ 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). + ### 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..10e54e48 100644 --- a/build/check-boundaries.mjs +++ b/build/check-boundaries.mjs @@ -49,6 +49,25 @@ 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', + }, + { + 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/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/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/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/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/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/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/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/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-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/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/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-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([]); + }); +}); From 101037fddc1ff84da74d0a7cf65b9333c8574e91 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 17 Jul 2026 19:06:05 +0000 Subject: [PATCH 02/10] =?UTF-8?q?feat(#284):=20atomic=20StoredWorkspaceV1?= =?UTF-8?q?=20persistence=20+=20legacy=20migration=20=E2=80=94=20phase=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the WorkspaceRepository (loadCurrent/commit/clearCurrent) that validates the complete candidate through phase-1 whole-workspace validation before an atomic, single-transaction replacement — failed writes leave the previous workspace intact, never bump revision, never mix aggregates (multi-tab last-commit-wins). Persistence is IndexedDB behind an injected WorkspaceStore seam (env.indexedDB, mirroring crypto/storage) so the pure repository unit-tests against an in-memory fake. Add the one-shot legacy migration: build one StoredWorkspaceV1 from the flat asb:* keys (initial Dashboard from panel-role favorites, asb:dashLayout/asb:dashCols -> flow@1), validate the whole candidate, persist atomically, keyed on aggregate record existence, legacy keys untouched. Plus pure workspace-operation semantics (rename/create-new/import-queries/ replace/ID generation). Wire app.workspace in createApp; the favorites-driven render still reads legacy keys this phase (reads move to the aggregate in phases 3-6). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 31 +++ src/env.types.ts | 7 + src/ui/app.ts | 12 ++ src/ui/app.types.ts | 7 + src/workspace/indexeddb-workspace-store.ts | 100 +++++++++ src/workspace/legacy-migration.ts | 131 ++++++++++++ src/workspace/workspace-operations.ts | 81 ++++++++ src/workspace/workspace-repository.ts | 108 ++++++++++ src/workspace/workspace-store.types.ts | 28 +++ tests/helpers/fake-app.ts | 5 + tests/unit/app.test.ts | 14 ++ tests/unit/indexeddb-workspace-store.test.ts | 151 ++++++++++++++ tests/unit/legacy-migration.test.ts | 142 +++++++++++++ tests/unit/workspace-operations.test.ts | 94 +++++++++ tests/unit/workspace-repository.test.ts | 203 +++++++++++++++++++ 15 files changed, 1114 insertions(+) create mode 100644 src/workspace/indexeddb-workspace-store.ts create mode 100644 src/workspace/legacy-migration.ts create mode 100644 src/workspace/workspace-operations.ts create mode 100644 src/workspace/workspace-repository.ts create mode 100644 src/workspace/workspace-store.types.ts create mode 100644 tests/unit/indexeddb-workspace-store.test.ts create mode 100644 tests/unit/legacy-migration.test.ts create mode 100644 tests/unit/workspace-operations.test.ts create mode 100644 tests/unit/workspace-repository.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 340530dd..61c8153e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,37 @@ auto-generated per-PR notes; this file is the curated, human-readable history. 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. + ### 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/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/ui/app.ts b/src/ui/app.ts index 3b9091a5..ba9640fa 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -72,6 +72,8 @@ 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 { createWorkbenchSession } from './workbench/workbench-session.js'; import { createQueryDocumentSession } from '../application/query-document-session.js'; import { createSavedQueryService } from '../application/saved-query-service.js'; @@ -190,6 +192,16 @@ 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. + app.workspace = createWorkspaceRepository({ + store: createIndexedDbWorkspaceStore(env.indexedDB || win.indexedDB), + }); // 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 diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 44a24611..5638c656 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -18,6 +18,7 @@ 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 { 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 +239,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 diff --git a/src/workspace/indexeddb-workspace-store.ts b/src/workspace/indexeddb-workspace-store.ts new file mode 100644 index 00000000..431e1736 --- /dev/null +++ b/src/workspace/indexeddb-workspace-store.ts @@ -0,0 +1,100 @@ +// 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; + dbPromise = 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')); + }); + return dbPromise; + } + + 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/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..b3f41fe1 100644 --- a/tests/helpers/fake-app.ts +++ b/tests/helpers/fake-app.ts @@ -242,6 +242,11 @@ const appDefaults: App = { params: paramsDefaults, graph: graphDefaults, prefs: prefsDefaults, + workspace: { + loadCurrent: async () => null, + commit: async () => ({ ok: true, workspace: {} as never, dashboardRevision: null }), + clearCurrent: async () => {}, + }, sqlEditor: {} as App['sqlEditor'], specEditor: {} as App['specEditor'], CodeViewer: () => ({ setText: () => {}, setLanguage: () => {}, setWrap: () => {}, focus: () => {}, destroy: () => {} }), 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/indexeddb-workspace-store.test.ts b/tests/unit/indexeddb-workspace-store.test.ts new file mode 100644 index 00000000..1c14444a --- /dev/null +++ b/tests/unit/indexeddb-workspace-store.test.ts @@ -0,0 +1,151 @@ +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('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/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/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); + }); +}); From cfbffa3d8908561385fd4bf8e039c1446df79ae1 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 17 Jul 2026 19:16:24 +0000 Subject: [PATCH 03/10] fix(#284): don't cache a failed IndexedDB open; add onblocked guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openDb() cached dbPromise on the first call including on rejection, so one transient open failure (or a no-factory "IndexedDB is unavailable") poisoned the store for the whole page lifetime — every later read/write/clear rejected with the stale error and a same-session retry could never succeed, contradicting the repository's "failed write leaves a draft to retry" contract. Cache only a successful open; drop the cached promise on rejection so the next call reopens. Add an onblocked handler that rejects the open (dormant at version 1, but an unhandled blocked event would hang the open and wedge the cache the same way). Co-Authored-By: Claude Fable 5 --- src/workspace/indexeddb-workspace-store.ts | 14 ++++++-- tests/unit/indexeddb-workspace-store.test.ts | 37 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/workspace/indexeddb-workspace-store.ts b/src/workspace/indexeddb-workspace-store.ts index 431e1736..26ba466b 100644 --- a/src/workspace/indexeddb-workspace-store.ts +++ b/src/workspace/indexeddb-workspace-store.ts @@ -59,7 +59,7 @@ export function createIndexedDbWorkspaceStore( function openDb(): Promise { if (dbPromise) return dbPromise; - dbPromise = new Promise((resolve, reject) => { + const pending = new Promise((resolve, reject) => { if (!factory) { reject(new Error('IndexedDB is unavailable')); return; @@ -71,8 +71,18 @@ export function createIndexedDbWorkspaceStore( }; 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')); }); - return dbPromise; + // 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 { diff --git a/tests/unit/indexeddb-workspace-store.test.ts b/tests/unit/indexeddb-workspace-store.test.ts index 1c14444a..287f2c07 100644 --- a/tests/unit/indexeddb-workspace-store.test.ts +++ b/tests/unit/indexeddb-workspace-store.test.ts @@ -133,6 +133,43 @@ describe('createIndexedDbWorkspaceStore', () => { 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'); From 662dd5a2566b80b7f9fda00850d49e47556d8825 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 17 Jul 2026 19:51:21 +0000 Subject: [PATCH 04/10] =?UTF-8?q?feat(#285):=20Dashboard=20authoring=20dom?= =?UTF-8?q?ain=20=E2=80=94=20atomic=20commands,=20presentation=20resolutio?= =?UTF-8?q?n,=20workspace-wide=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of #280. Adds the fallible, atomic Dashboard authoring domain on top of the phase-1 contracts/codecs and phase-2 persistence: - RFC 7396 JSON Merge Patch (in-house, no dependency) and the ONE canonical presentation resolver (base -> variant -> override -> final validation), shared by the authoring session, saved-query mutation validation, and tests. - A minimal flow@1 layout plugin (placement validation, orphan pruning, size-hint-derived placement, active-plugin resolution with fallback). - DashboardAuthoringSession + typed DashboardCommands with the atomic algorithm (clone -> apply -> normalize -> validate structure/refs/roles/presentations -> replace only when valid); draftVersion stale protection separate from the persisted revision (increments once per successful commit). - Star rewires to toggleMembership (add-query/remove-tile command) with the spec.favorite dual-write; no direct membership mutation anywhere. - planSavedQueryMutation enforces whole-workspace validity for saved-query mutations, rejecting invalidating ones unless an atomic repair is supplied. - New check:arch rule for src/dashboard/application; CHANGELOG entry. Seven new modules, each 100%-covered; full gate (tsc, arch, schemas, vitest) and build green. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 46 +++ build/check-boundaries.mjs | 10 + .../dashboard-authoring-session.ts | 262 ++++++++++++++++++ .../application/dashboard-commands.ts | 172 ++++++++++++ .../application/dashboard-query-resolver.ts | 42 +++ .../application/saved-query-mutation.ts | 177 ++++++++++++ src/dashboard/layouts/flow-layout.ts | 141 ++++++++++ src/dashboard/model/json-merge-patch.ts | 33 +++ src/dashboard/model/presentation-resolver.ts | 161 +++++++++++ .../unit/dashboard-authoring-session.test.ts | 213 ++++++++++++++ tests/unit/dashboard-commands.test.ts | 140 ++++++++++ tests/unit/dashboard-query-resolver.test.ts | 28 ++ tests/unit/flow-layout.test.ts | 122 ++++++++ tests/unit/json-merge-patch.test.ts | 52 ++++ tests/unit/presentation-resolver.test.ts | 154 ++++++++++ tests/unit/saved-query-mutation.test.ts | 215 ++++++++++++++ 16 files changed, 1968 insertions(+) create mode 100644 src/dashboard/application/dashboard-authoring-session.ts create mode 100644 src/dashboard/application/dashboard-commands.ts create mode 100644 src/dashboard/application/dashboard-query-resolver.ts create mode 100644 src/dashboard/application/saved-query-mutation.ts create mode 100644 src/dashboard/layouts/flow-layout.ts create mode 100644 src/dashboard/model/json-merge-patch.ts create mode 100644 src/dashboard/model/presentation-resolver.ts create mode 100644 tests/unit/dashboard-authoring-session.test.ts create mode 100644 tests/unit/dashboard-commands.test.ts create mode 100644 tests/unit/dashboard-query-resolver.test.ts create mode 100644 tests/unit/flow-layout.test.ts create mode 100644 tests/unit/json-merge-patch.test.ts create mode 100644 tests/unit/presentation-resolver.test.ts create mode 100644 tests/unit/saved-query-mutation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c8153e..6928385f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,52 @@ auto-generated per-PR notes; this file is the curated, human-readable history. 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. + ### 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 10e54e48..0a4a9645 100644 --- a/build/check-boundaries.mjs +++ b/build/check-boundaries.mjs @@ -63,6 +63,16 @@ const RULES = [ 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. + dir: 'src/dashboard/application', + forbidden: ['src/dashboard/ui'], + why: 'issue #280 phase 3: application must not import Dashboard UI adapters (model/layouts <- application <- UI)', + }, { dir: 'src/workspace', forbidden: ['src/ui', 'src/editor', 'src/application', 'src/state.ts', 'src/net'], 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..4b528129 --- /dev/null +++ b/src/dashboard/application/dashboard-commands.ts @@ -0,0 +1,172 @@ +// 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); + +/** 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 SHALLOW field patch: a `null` member deletes that tile field; any + // other member replaces it wholesale (cloned). Deliberately NOT a deep + // merge — `presentation.override` is itself stored RFC 7396 patch data, + // so a deep merge would consume its meaningful `null`s. + const patched = { ...(tiles[index] as Record) }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete patched[key]; + else 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/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..a8145d7d --- /dev/null +++ b/src/dashboard/layouts/flow-layout.ts @@ -0,0 +1,141 @@ +// 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 type { DashboardDocumentV1, 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[] }; + +/** 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/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/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/tests/unit/dashboard-authoring-session.test.ts b/tests/unit/dashboard-authoring-session.test.ts new file mode 100644 index 00000000..34cd4175 --- /dev/null +++ b/tests/unit/dashboard-authoring-session.test.ts @@ -0,0 +1,213 @@ +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 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 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); + }); + + 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-commands.test.ts b/tests/unit/dashboard-commands.test.ts new file mode 100644 index 00000000..ea432a18 --- /dev/null +++ b/tests/unit/dashboard-commands.test.ts @@ -0,0 +1,140 @@ +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' }); + }); + + 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-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/flow-layout.test.ts b/tests/unit/flow-layout.test.ts new file mode 100644 index 00000000..0d8ad8c5 --- /dev/null +++ b/tests/unit/flow-layout.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_FLOW_PLACEMENT, deriveFlowPlacement, flowLayoutPlugin, + resolveActiveLayoutPlugin, 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); + }); +}); 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/presentation-resolver.test.ts b/tests/unit/presentation-resolver.test.ts new file mode 100644 index 00000000..7c0491ea --- /dev/null +++ b/tests/unit/presentation-resolver.test.ts @@ -0,0 +1,154 @@ +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('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', () => { + expect(resolveDashboardPresentations({ dashboard: null, 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..fb6c96ca --- /dev/null +++ b/tests/unit/saved-query-mutation.test.ts @@ -0,0 +1,215 @@ +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'); + }); +}); + +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: {} } }, + filters: [{ id: 'flt', parameter: 'x' }], // no source, no targets + tiles: [ + { id: 't1', queryId: 'p1', presentation: { variant: 'alt' } }, + { id: 't2', queryId: 'p2' }, // unaffected by p1 mutations + { id: 't3', queryId: 'p1' }, // affected, but no variant mapping in the repair + ], + }, + } 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: {} } }) }; + const plan = planSavedQueryMutation(multiTile(), dropsAlt, { type: 'switch-variant', tileVariants: { t1: 'other' } }); + expect(plan.ok).toBe(true); + }); + + 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']); + }); +}); From 91df158e2219476c592277019574537e1c422afe Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 17 Jul 2026 20:11:04 +0000 Subject: [PATCH 05/10] =?UTF-8?q?test(#285):=20review=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20harden=20update-tile=20presentation=20merge,=20clos?= =?UTF-8?q?e=20validation=20seams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the #285 review follow-ups (new commit on top of 662dd5a): 1. update-tile presentation footgun (fix): `presentation` now merges ONE level — `variant` and `override` are independent sub-fields, each replaced wholesale (null deletes that sub-field), so a caller can set/clear one without wiping the other. `override` values are still stored verbatim (no deep merge into their meaningful nested nulls). Top-level tile-field behavior unchanged. 2. Session-level tests for the delegated-validation failure class (the validateStoredWorkspaceDocument stage between applyCommand and presentation resolution): add-query incompatible role, tile-limit exceeded, and add-query-instance with an undeclared variant — each asserting structured diagnostics and a byte-for-byte-unchanged draft. 3. saved-query-mutation: tests that APPLY the filter-source repairs and prove the candidate validates — remove-affected-filters on a filter-source role change, and remap of a query that is a filter's own sourceQueryId. 4. Cover the reachable defensive branches (empty/absent presentation on a variant switch; a dashboard without a tiles array). The favorite dual-write's non-object-spec guard is unreachable by contract (a query with a non-object spec never survives command validation to reach the dual-write) and is left as-is. Co-Authored-By: Claude Fable 5 --- .../application/dashboard-commands.ts | 34 ++++++++++++--- .../unit/dashboard-authoring-session.test.ts | 43 +++++++++++++++++++ tests/unit/dashboard-commands.test.ts | 26 +++++++++++ tests/unit/presentation-resolver.test.ts | 19 +++++++- tests/unit/saved-query-mutation.test.ts | 37 ++++++++++++++-- 5 files changed, 148 insertions(+), 11 deletions(-) diff --git a/src/dashboard/application/dashboard-commands.ts b/src/dashboard/application/dashboard-commands.ts index 4b528129..3947a976 100644 --- a/src/dashboard/application/dashboard-commands.ts +++ b/src/dashboard/application/dashboard-commands.ts @@ -67,6 +67,23 @@ const missingTile = (tileId: string): WorkspaceDiagnostic => 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. */ @@ -138,14 +155,19 @@ export function applyCommand( return failWith(diagnostic(['tiles', index], 'dashboard-command-invalid-patch', 'Tile patch may not change tile id or queryId')); } - // A SHALLOW field patch: a `null` member deletes that tile field; any - // other member replaces it wholesale (cloned). Deliberately NOT a deep - // merge — `presentation.override` is itself stored RFC 7396 patch data, - // so a deep merge would consume its meaningful `null`s. + // 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]; - else patched[key] = cloneJson(value); + 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 }; diff --git a/tests/unit/dashboard-authoring-session.test.ts b/tests/unit/dashboard-authoring-session.test.ts index 34cd4175..9371a0f4 100644 --- a/tests/unit/dashboard-authoring-session.test.ts +++ b/tests/unit/dashboard-authoring-session.test.ts @@ -5,12 +5,16 @@ import { 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, @@ -112,6 +116,45 @@ describe('DashboardAuthoringSession — atomic commands', () => { 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 }); diff --git a/tests/unit/dashboard-commands.test.ts b/tests/unit/dashboard-commands.test.ts index ea432a18..74c71a18 100644 --- a/tests/unit/dashboard-commands.test.ts +++ b/tests/unit/dashboard-commands.test.ts @@ -96,6 +96,32 @@ describe('applyCommand — update-tile', () => { 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); diff --git a/tests/unit/presentation-resolver.test.ts b/tests/unit/presentation-resolver.test.ts index 7c0491ea..3ea2cca5 100644 --- a/tests/unit/presentation-resolver.test.ts +++ b/tests/unit/presentation-resolver.test.ts @@ -104,6 +104,22 @@ describe('resolvePresentation', () => { 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); @@ -118,8 +134,9 @@ describe('resolvePresentation', () => { describe('resolveDashboardPresentations', () => { const filterQuery = { id: 'f', sql: "SELECT ['a'] c", specVersion: 1, spec: { dashboard: { role: 'filter' } } }; - it('returns nothing for a non-object dashboard', () => { + 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', () => { diff --git a/tests/unit/saved-query-mutation.test.ts b/tests/unit/saved-query-mutation.test.ts index fb6c96ca..a9ee82f9 100644 --- a/tests/unit/saved-query-mutation.test.ts +++ b/tests/unit/saved-query-mutation.test.ts @@ -65,6 +65,30 @@ describe('planSavedQueryMutation — rejection without repair', () => { 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', () => { @@ -139,12 +163,13 @@ describe('planSavedQueryMutation — repairs skip unaffected and target-less ent 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: {} } }, + 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' } }, + { 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, but no variant mapping in the repair + { id: 't3', queryId: 'p1' }, // affected, no presentation, gets switched (empty-presentation branch) + { id: 't4', queryId: 'p1' }, // affected but unmapped — left untouched ], }, } as StoredWorkspaceV1); @@ -159,8 +184,12 @@ describe('planSavedQueryMutation — repairs skip unaffected and target-less ent 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: {} } }) }; - const plan = planSavedQueryMutation(multiTile(), dropsAlt, { type: 'switch-variant', tileVariants: { t1: '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', () => { From c51954aa440c56b23cb4604fb07f5803b131dc45 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 17 Jul 2026 20:38:14 +0000 Subject: [PATCH 06/10] feat(#286): DashboardViewerSession + normative flow@1 layout (phase 4) Add the read-only Dashboard runtime and the normative flow@1 layout math as pure/application modules, all unit-tested at the per-file gate and free of any Workbench/App/AppState/editor/service/net dependency: - dashboard/application/dashboard-viewer-session.ts: DashboardViewerSession reading membership from dashboard.tiles[] (not spec.favorite), resolving presentations through the shared presentation-resolver, owning filter/tile runtime state behind one state signal, with bounded concurrency, per-tile cancellation, stale-wave guards, and destroy() teardown. The #235 planner runs filter-source-unaffected panels in parallel with the filter wave. Filter usability (#188): per-filter clear (keeps last value), coalesced clear-all (one wave), activeFilterCount, and never-hidden blocking reasons. - dashboard/layouts/flow-layout.ts: presetColumns, effectiveSpan, resolvePlacement, and computeFlowLayout (deterministic row-major packing, KPI-band grouping, mobile one-column normalization without mutating persistence, semantic = DOM = keyboard = visual = print order). - dashboard/layouts/layout-registry.ts: compile-time-registered, lazy-loadable layouts with the flow@1 fallback contract (fail-closed without a valid one). - build/check-boundaries.mjs: explicit phase-4 boundary for dashboard/application, plus a dashboard-boundaries unit test. Accessible authoring uses the phase-3 move-tile/update-placement commands; pointer drag is an equivalent alternative. Live DOM integration (viewer-driven render replacing the favorites path) is the remaining wiring step. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 58 ++ build/check-boundaries.mjs | 16 +- .../application/dashboard-viewer-session.ts | 672 ++++++++++++++++++ src/dashboard/layouts/flow-layout.ts | 161 ++++- src/dashboard/layouts/layout-registry.ts | 118 +++ tests/unit/dashboard-boundaries.test.js | 86 +++ tests/unit/dashboard-viewer-session.test.ts | 497 +++++++++++++ tests/unit/flow-layout.test.ts | 144 +++- tests/unit/layout-registry.test.ts | 115 +++ 9 files changed, 1862 insertions(+), 5 deletions(-) create mode 100644 src/dashboard/application/dashboard-viewer-session.ts create mode 100644 src/dashboard/layouts/layout-registry.ts create mode 100644 tests/unit/dashboard-boundaries.test.js create mode 100644 tests/unit/dashboard-viewer-session.test.ts create mode 100644 tests/unit/layout-registry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6928385f..d68eeff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,64 @@ auto-generated per-PR notes; this file is the curated, human-readable history. 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, an + `activeFilterCount` counting active filter DEFINITIONS, and a per-filter + `blocking` reason (source-query error / required-and-unset / invalid value) + that is never silently hidden. + - `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. This closes the analysis behind #235 and the reorder half + of #153 and dissolves #188 into the viewer's filter contract. Live DOM + integration (the viewer-driven render replacing `src/ui/dashboard.ts`'s + favorites path, the filter-bar affordances, and the a11y/drag controls) is + the remaining wiring step of this phase. + ### 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 0a4a9645..36b060a7 100644 --- a/build/check-boundaries.mjs +++ b/build/check-boundaries.mjs @@ -69,9 +69,21 @@ const RULES = [ // 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'], - why: 'issue #280 phase 3: application must not import Dashboard UI adapters (model/layouts <- application <- UI)', + 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', diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts new file mode 100644 index 00000000..1c203d0a --- /dev/null +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -0,0 +1,672 @@ +// 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, 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; + /** A human reason this filter blocks a target panel (invalid value, + * required-and-unset, or source-query error) — never silently hidden + * (#188). `null` when the filter is not blocking. */ + blocking: string | 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 { + 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; + /** 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; + /** 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; + /** 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 { document, queries } = deps; + const registry = deps.registry; + 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: document, queries, path: ['dashboard'], + }); + + // One runtime record per tile, in semantic (dashboard.tiles) order. + const tiles: TileRuntime[] = (Array.isArray(document.tiles) ? document.tiles : []).map((tile) => { + 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 }; + }); + + // Filter runtime records, in filter order. + const filters: FilterRuntime[] = (Array.isArray(document.filters) ? document.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, blocking: 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])); + + const prepareBatch = (mode: ValidationMode = 'execute') => { + const active = activeMap(); + return prepareParameterizedBatch(analysis, { + values: Object.fromEntries(Object.entries(rawValues()).map(([name, value]) => + [name, curated[name] && !active[name] ? '' : value])), + active: effectiveActive(rawValues(), active), + wallNowMs: deps.wallNow(), validationMode: mode, + }); + }; + + // ── 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: document.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); + } + + // Recompute per-filter blocking (#188 never-silently-hide): a source error, + // a required-and-unset param, or an invalid value. + function recomputeBlocking(): void { + const prepared = prepareBatch('execute'); + for (const filter of filters) { + const field = prepared.fields[filter.def.parameter]; + if (filter.state.status === 'error') { filter.state.blocking = 'Filter source query failed.'; continue; } + if (!field) { filter.state.blocking = null; continue; } + if (field.state === 'missing') filter.state.blocking = 'Required filter value is not set.'; + else if (field.state === 'invalid') filter.state.blocking = field.reason || 'Filter value is not valid.'; + else filter.state.blocking = null; + } + } + + // ── 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; } + recomputeBlocking(); + // 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); + } + recomputeBlocking(); + 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 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 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, + start, refresh, refreshTile, setFilter, clearFilter, clearAllFilters, cancelTile, destroy, + }; +} diff --git a/src/dashboard/layouts/flow-layout.ts b/src/dashboard/layouts/flow-layout.ts index a8145d7d..b4b518c2 100644 --- a/src/dashboard/layouts/flow-layout.ts +++ b/src/dashboard/layouts/flow-layout.ts @@ -15,7 +15,10 @@ 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 type { DashboardDocumentV1, FlowTilePlacementV1 } from '../../generated/json-schema.types.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' }; @@ -120,6 +123,162 @@ 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 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/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-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts new file mode 100644 index 00000000..5fa2d15e --- /dev/null +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -0,0 +1,497 @@ +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 and blocking', 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'); + expect(session.state.value.filters[0].blocking).toContain('source'); + }); + + 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); + }); + + it('never silently hides a blocking filter (source error, required-unset, invalid, ok)', 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: 'src', parameter: 'p', sourceQueryId: 'srcq' }, // source error + { id: 'req', parameter: 'r', defaultActive: true, defaultValue: '' }, // required-unset (declared, blank) + { id: 'inv', parameter: 'num', defaultActive: true, defaultValue: 'notnum' }, // invalid value + { id: 'ok', parameter: 'ok', defaultActive: true, defaultValue: '5' }, // ok + { id: 'undeclared', parameter: 'zzz', sourceQueryId: 'srcq' }, // not declared anywhere + ], + }), + exec, + queries: [ + query('qt', 'SELECT {p:String}, {r:String}, {num:UInt8}, {ok:UInt8}'), + query('srcq', 'SELECT 1 AS p /* badsrc */', { dashboard: { role: 'filter' } }), + ], + })); + await session.start(); + const blocking = Object.fromEntries(session.state.value.filters.map((f) => [f.id, f.blocking])); + expect(blocking.src).toContain('source'); + expect(blocking.req).toContain('Required'); + expect(blocking.inv).toBeTruthy(); + expect(blocking.ok).toBeNull(); + }); +}); + +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('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/flow-layout.test.ts b/tests/unit/flow-layout.test.ts index 0d8ad8c5..7b572c9c 100644 --- a/tests/unit/flow-layout.test.ts +++ b/tests/unit/flow-layout.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; import { - DEFAULT_FLOW_PLACEMENT, deriveFlowPlacement, flowLayoutPlugin, - resolveActiveLayoutPlugin, setFlowPlacement, + 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'; @@ -120,3 +121,142 @@ describe('resolveActiveLayoutPlugin', () => { 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/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); + }); +}); From cfc1a6719385592f86db057b2b6f024c214ec25f Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 17 Jul 2026 21:25:43 +0000 Subject: [PATCH 07/10] =?UTF-8?q?feat(#286):=20live=20read-flip=20?= =?UTF-8?q?=E2=80=94=20viewer-driven=20dashboard=20render=20+=20filter/a11?= =?UTF-8?q?y/KPI=20DOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the DashboardViewerSession, flow@1 model, layout registry, and shared presentation resolver into the running app, flipping Dashboard membership READS from spec.favorite to dashboard.tiles[]. - src/ui/dashboard.ts: rewritten to build a DashboardViewerSession from the persisted StoredWorkspaceV1 (app.loadDashboardWorkspace → repository → one-shot legacy migration) and render/reconcile from its state signal — flow@1 rows/columns, KPI bands (renderKpiCards, #240), per-tile loading/unfilled/error/ready with chart-paint-once, mobile one-column normalization, empty state. Migrated dashboards' implicit {name:Type} params are surfaced as runtime-only filter definitions. - Accessible reorder + sizing (keyboard Move-earlier/later, span/height) drive the phase-3 move-tile/update-placement commands, mirrored into the viewer via a new syncDocument (no re-execution) and best-effort persisted; focus stays on the moved tile with an ARIA live announcement. Pointer drag is an equivalent alternative. - src/ui/filter-bar.ts: shared per-filter clear / clear-all / active-count / blocking-badge affordances, driven by the viewer's filter semantics. - src/ui/app.ts + app.types.ts: app.loadDashboardWorkspace() (migrate-if-needed then loadCurrent), wired at the composition root where the IndexedDB store lives; fake-app gains the seam + a workspace override. - Delete the superseded #276 phase-3b runtime (ui/dashboard/dashboard-session.ts) and the #240 ui/dashboard-kpi-band.ts — absorbed by the viewer + renderKpiCards. Closes #235 and the reorder half of #153; dissolves #188. npm test (3358), build, and check:arch all green. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 31 +- .../application/dashboard-viewer-session.ts | 47 +- src/ui/app.ts | 27 +- src/ui/app.types.ts | 6 + src/ui/dashboard-kpi-band.ts | 234 -- src/ui/dashboard.ts | 930 ++++---- src/ui/dashboard/dashboard-session.ts | 676 ------ src/ui/filter-bar.ts | 53 + tests/helpers/fake-app.ts | 9 +- tests/unit/dashboard-kpi-band.test.ts | 187 -- tests/unit/dashboard-session.test.ts | 831 ------- tests/unit/dashboard-viewer-session.test.ts | 30 + tests/unit/dashboard.test.ts | 2042 ++++------------- tests/unit/filter-bar.test.ts | 19 + 14 files changed, 1009 insertions(+), 4113 deletions(-) delete mode 100644 src/ui/dashboard-kpi-band.ts delete mode 100644 src/ui/dashboard/dashboard-session.ts delete mode 100644 tests/unit/dashboard-kpi-band.test.ts delete mode 100644 tests/unit/dashboard-session.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d68eeff2..3cf65c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -160,11 +160,32 @@ auto-generated per-PR notes; this file is the curated, human-readable history. - 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. This closes the analysis behind #235 and the reorder half - of #153 and dissolves #188 into the viewer's filter contract. Live DOM - integration (the viewer-driven render replacing `src/ui/dashboard.ts`'s - favorites path, the filter-bar affordances, and the a11y/drag controls) is - the remaining wiring step of this phase. + `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. + - **Filter-bar affordances** (`src/ui/filter-bar.ts`, shared): per-filter + clear, coalesced clear-all, "N active" count, and a never-hidden blocking + badge, driven by the viewer's `clearFilter`/`clearAllFilters`/ + `activeFilterCount`/`blocking`. + - **Accessible reorder + sizing.** Keyboard Move-earlier/Move-later, span, and + height controls on every tile drive the phase-3 `move-tile`/ + `update-placement` commands (mirrored into the viewer with `syncDocument`, + no re-execution, and best-effort persisted); focus stays on the moved tile + and an ARIA live region announces the new position. Pointer drag is an + equivalent alternative, never the only mechanism. + 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. ### Changed - **The app.ts → services refactor is complete** (#276, Phase 5). The diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index 1c203d0a..de4cce21 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -128,7 +128,9 @@ export interface ViewerReadRequest { onChunk?: () => void; } export interface ViewerExecutor { - executeRead(result: StreamResult, request: ViewerReadRequest): Promise; + // 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`), @@ -178,6 +180,11 @@ export interface DashboardViewerSession { 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; } @@ -249,8 +256,11 @@ function supersede(record: { gen: number; abortController: AbortController | nul /** Build a `DashboardViewerSession`. */ export function createDashboardViewerSession(deps: DashboardViewerDeps): DashboardViewerSession { - const { document, queries } = deps; + 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(); @@ -261,11 +271,10 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // Structural presentation validation (the SAME shared resolver) — reported // up front so an invalid tile presentation is visible without executing. const presentationDiagnostics = resolveDashboardPresentations({ - dashboard: document, queries, path: ['dashboard'], + dashboard: documentRef, queries, path: ['dashboard'], }); - // One runtime record per tile, in semantic (dashboard.tiles) order. - const tiles: TileRuntime[] = (Array.isArray(document.tiles) ? document.tiles : []).map((tile) => { + 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; @@ -290,10 +299,13 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa 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(document.filters) ? document.filters : []).map((def) => { + 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 = { @@ -356,7 +368,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa return { tiles: tiles.map((runtime) => ({ ...runtime.state })), filters: filters.map((filter) => ({ ...filter.state })), - layout: computeFlowLayout({ tiles: visible, layout: document.layout, mobile }), + layout: computeFlowLayout({ tiles: visible, layout: documentRef.layout, mobile }), activeFilterCount: filters.filter((filter) => filter.state.active).length, running, updatedAt, diagnostics: presentationDiagnostics, }; @@ -653,6 +665,23 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa 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) { @@ -667,6 +696,6 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa return { state: stateSignal as ReadonlySignal, - start, refresh, refreshTile, setFilter, clearFilter, clearAllFilters, cancelTile, destroy, + start, refresh, refreshTile, setFilter, clearFilter, clearAllFilters, cancelTile, syncDocument, destroy, }; } diff --git a/src/ui/app.ts b/src/ui/app.ts index ba9640fa..e5041ebf 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -74,6 +74,7 @@ import { createSchemaGraphSession, SchemaGraphAuthRequiredError } from '../appli 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'; @@ -199,9 +200,8 @@ export function createApp(env: CreateAppEnv = {}): App { // 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. - app.workspace = createWorkspaceRepository({ - store: createIndexedDbWorkspaceStore(env.indexedDB || win.indexedDB), - }); + 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 @@ -1358,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 5638c656..7c6243fd 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -19,6 +19,7 @@ import type { SchemaCatalogService } from '../application/schema-catalog-service 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'; @@ -360,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..4256142f 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -1,79 +1,64 @@ -// 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 (each 100%-covered); 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/**`). +// Structural edits (reorder, span/height, layout preset) go through the +// phase-3 authoring commands (`applyCommand` — `move-tile`/`update-placement`/ +// `change-layout`), are mirrored into the viewer with `syncDocument` (no +// re-execution), and best-effort persisted through the repository. Accessible +// keyboard controls are the first-class reorder/resize mechanism; pointer drag +// is an equivalent alternative. The `spec.favorite` dual-WRITE stays until GA +// (the star action in the Workbench); 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 { analyzeParameterizedSources, fieldControls } from '../core/param-pipeline.js'; +import { queryDashboardRole } from '../dashboard/model/workspace-semantics.js'; +import { renderKpiCards, KPI_STREAM_ARIA } from './kpi-panel.js'; import { - buildKpiBand, buildKpiSourceSlot, setKpiSourceLoading, setKpiSourceUnfilled, applyKpiSourceResult, - refreshBandWarnings, -} from './dashboard-kpi-band.js'; -import { createDashboardSession } from './dashboard/dashboard-session.js'; + filterClearButton, filterClearAllButton, filterActiveCount, filterBlockingBadge, +} 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, FlowHeightV1, + 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 +68,41 @@ 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 FLOW_HEIGHTS: FlowHeightV1[] = ['compact', 'medium', 'large']; +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(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 +113,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 +127,89 @@ 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(); +/** 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; + moveEarlier: HTMLButtonElement; + moveLater: HTMLButtonElement; + spanSel: HTMLSelectElement; + heightSel: HTMLSelectElement; + panelState: { key: string;[k: string]: unknown } | null; + destroy: (() => void) | null; + paintedRows: unknown[][] | null; } -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(); -} - -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)], + }; + + 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), + }); - // 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' } }); + // ── 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,297 @@ 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 (viewer-driven) ──────────────────────────────────────────── const filterHost = h('div', { class: 'dash-filter-host' }); + const filterCountNode = h('span', { class: 'dash-filter-count-host' }); + const clearAllNode = h('span', { class: 'dash-filter-clear-all-host' }); + const filterFields = new Map(); + + function buildFilterBar(initial: ViewerFilterState[]): void { + filterFields.clear(); + const fields = initial.map((f) => { + const badgeHost = h('span', { class: 'dash-filter-badge-host' }); + let input: HTMLInputElement | HTMLSelectElement; + if (f.options && f.options.length) { + const sel = h('select', { class: 'var-input' }, + h('option', { value: '' }, 'All'), + ...f.options.map((o) => h('option', { value: o.value }, o.label))); + sel.value = typeof f.value === 'string' ? f.value : ''; + sel.onchange = () => { session.setFilter(f.id, sel.value); }; + input = sel; + } else { + const inp = h('input', { class: 'var-input', type: 'text', 'aria-label': f.label }); + inp.value = typeof f.value === 'string' ? f.value : ''; + inp.onchange = () => { session.setFilter(f.id, inp.value); }; + inp.onkeydown = (event: KeyboardEvent) => { if (event.key === 'Enter') session.setFilter(f.id, inp.value); }; + input = inp; + } + const clear = filterClearButton({ label: f.label, onClear: () => session.clearFilter(f.id) }); + filterFields.set(f.id, { input, badgeHost }); + return h('label', { class: 'var-field' }, h('span', { class: 'var-name' }, f.label), input, clear, badgeHost); + }); + filterHost.replaceChildren(...(fields.length ? [...fields, clearAllNode, filterCountNode] : [])); + } + + function updateFilterBar(sview: DashboardViewState): void { + // A text field upgrades to a select once its filter-source options land. + const needsRebuild = sview.filters.some((f) => { + const field = filterFields.get(f.id); + return !!field && !!f.options && f.options.length > 0 && field.input.tagName !== 'SELECT'; + }); + if (needsRebuild) buildFilterBar(sview.filters); + for (const f of sview.filters) { + const field = filterFields.get(f.id); + if (field) field.badgeHost.replaceChildren(...(f.blocking ? [filterBlockingBadge(f.blocking)] : [])); + } + clearAllNode.replaceChildren(filterClearAllButton({ active: sview.activeFilterCount > 0, onClearAll: () => session.clearAllFilters() })); + filterCountNode.replaceChildren(filterActiveCount(sview.activeFilterCount)); + } + const filterDiagnosticsHost = h('div', { class: 'dash-filter-diagnostics' }); + const liveRegion = h('div', { + class: 'dash-live', role: 'status', 'aria-live': 'polite', + style: { position: 'absolute', width: '1px', height: '1px', overflow: 'hidden' }, + }); + 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.'); - // The route session this render drives — constructed below, once its hooks - // are wired. `renderFilterBar`/`renderFilterDiagnostics` close over it (only - // ever CALLED once construction below has completed, so the forward - // reference is safe — the same shell↔session wiring pattern - // `WorkbenchSession`'s `attachShell` uses). - let session!: DashboardSession; - - // Rebuild the filter bar with the current curated-field bundle, disposing - // the previous bar's pending debounce timers first (#276 Phase 3b filter-bar - // dispose seam — closes the orphan-timer gap a bare rebuild used to leave). - // `disposeCurrentFilterBar` is also handed to the session as its - // `disposeFilterBar` hook (destroy()'s own teardown) — the SAME function, - // not a second closure, so its one line of teardown logic isn't duplicated. - let filterBarDispose: (() => void) | null = null; - const disposeCurrentFilterBar = (): void => { filterBarDispose?.(); }; - const renderFilterBar = (curatedFields: Record): void => { - disposeCurrentFilterBar(); - const bar = buildFilterBar(app as App, session.controls, (name) => session.runAffected(name), session.getFilterField, { curatedFields }); - filterHost.replaceChildren(bar.el); - filterBarDispose = bar.dispose; - }; + // ── Structural commands (reorder / resize / preset) ─────────────────────── + function runCommand(command: Parameters[1]): void { + const applied = applyCommand(currentDoc, command, { + resolver: createQueryResolver(queries), genTileId: () => 'tile', plugin: flowLayoutPlugin, + }); + if (!applied.ok) return; + 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; }); + } + } - 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); - })); - }; + function currentPlacement(tileId: string): { span?: number; height?: string } { + const items = isObject(currentDoc.layout.items) ? currentDoc.layout.items : {}; + const placement = items[tileId]; + return isObject(placement) ? placement as { span?: number; height?: string } : {}; + } - // 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); + function moveTile(tileId: string, delta: number): void { + const order = currentDoc.tiles.map((t) => t.id); + const index = order.indexOf(tileId); + const toIndex = index + delta; + if (index < 0 || toIndex < 0 || toIndex >= order.length) return; + runCommand({ type: 'move-tile', tileId, toIndex }); + // Focus stays on the moved tile's control; announce the new position. + const tileEl = tileEls.get(tileId); + (delta < 0 ? tileEl?.moveEarlier : tileEl?.moveLater)?.focus(); + liveRegion.textContent = `Moved tile to position ${toIndex + 1} of ${order.length}`; + } + + function setPlacement(tileId: string, patch: { span?: number; height?: string }): void { + runCommand({ type: 'update-placement', tileId, placement: { ...currentPlacement(tileId), ...patch } }); + } + + // ── 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 titleEl = h('span', { class: 'dash-tile-name', title: ts.title }, ts.title); + const moveEarlier = h('button', { type: 'button', class: 'dash-tile-move', title: 'Move earlier', 'aria-label': `Move ${ts.title} earlier`, onclick: () => moveTile(ts.tileId, -1) }, '‹'); + const moveLater = h('button', { type: 'button', class: 'dash-tile-move', title: 'Move later', 'aria-label': `Move ${ts.title} later`, onclick: () => moveTile(ts.tileId, 1) }, '›'); + const spanSel = h('select', { class: 'dash-tile-span', 'aria-label': `${ts.title} width` }, + ...[1, 2, 3].map((n) => h('option', { value: String(n) }, `${n}×`))); + spanSel.onchange = () => setPlacement(ts.tileId, { span: Number(spanSel.value) }); + const heightSel = h('select', { class: 'dash-tile-height', 'aria-label': `${ts.title} height` }, + ...FLOW_HEIGHTS.map((height) => h('option', { value: height }, height))); + heightSel.onchange = () => setPlacement(ts.tileId, { height: heightSel.value }); + const controls = h('div', { class: 'dash-tile-controls' }, moveEarlier, moveLater, spanSel, heightSel); + const head = h('div', { class: 'dash-tile-head' }, titleEl, controls); + 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); + 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, moveEarlier, moveLater, spanSel, heightSel, 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 (no + // defensive `|| []` — the viewer guarantees them on `ready`). + 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); + const placement = currentPlacement(ts.tileId); + tileEl.spanSel.value = String(placement.span ?? 1); + tileEl.heightSel.value = String(placement.height ?? 'medium'); + tileEl.card.dataset.height = String(placement.height ?? 'medium'); + 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, + // resize, 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; + 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; + if (filterFields.size === 0 && sview.filters.length) buildFilterBar(sview.filters); + updateFilterBar(sview); + tileCountLabel.textContent = sview.tiles.length + (sview.tiles.length === 1 ? ' tile' : ' tiles'); + empty.style.display = sview.tiles.length ? 'none' : ''; + 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)); + liveRegion, 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..0c8cd27e 100644 --- a/src/ui/filter-bar.ts +++ b/src/ui/filter-bar.ts @@ -86,6 +86,59 @@ 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). + +/** A per-filter clear affordance (#188): a keyboard-focusable button that + * deactivates one filter without discarding its value (reactivation restores + * it). `label` names the filter for assistive tech. */ +export function filterClearButton(opts: { label: string; onClear: () => void }): HTMLButtonElement { + const btn = h('button', { + type: 'button', class: 'dash-filter-clear', + title: `Clear ${opts.label}`, 'aria-label': `Clear ${opts.label}`, + onclick: () => opts.onClear(), + }, '×'); + return btn; +} + +/** 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; +} + +/** A visible blocking badge (#188): a filter whose state blocks a target panel + * (invalid value / required-and-unset / source-query error) must never be + * silently hidden — this badge states why. */ +export function filterBlockingBadge(reason: string): HTMLElement { + return h('span', { + class: 'dash-filter-blocking', role: 'alert', title: reason, + }, reason); +} + // 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/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts index b3f41fe1..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'; @@ -247,6 +248,7 @@ const appDefaults: App = { 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: () => {} }), @@ -321,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; @@ -518,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/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-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 index 5fa2d15e..1c14cc62 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -473,6 +473,36 @@ describe('per-tile control and lifecycle', () => { 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; diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index 1ea507e7..c81fdf55 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -224,1710 +224,414 @@ 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'); - }); +const q = (id: string, sql: string, extra: Partial = {}): SavedQueryFixture['id'] extends never ? never : ReturnType => + savedQuery({ id, name: id, sql, ...extra }); - 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); - }); - - 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'); + 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('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('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('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'); - }); - - 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('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(); + 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('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 - }); - - 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 preset and placement when the layout omits them', 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'); + // Resize with no items map present exercises the placement fallback. + const spanSel = qsa(app.root, '.dash-tile-span')[0]; + spanSel.value = '2'; + spanSel.dispatchEvent(new Event('change', { bubbles: true })); + 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 — accessible reorder + sizing (#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('Move later / Move earlier reorder tiles, keep focus, and announce the new position', async () => { + const { app, commit } = dashApp({ workspace: twoTiles() }); + await render(app); + expect(order(app)).toEqual(['q1', 'q2']); + const laterBtns = qsa(app.root, '.dash-tile-move[title="Move later"]'); + laterBtns[0].dispatchEvent(new Event('click', { bubbles: true })); + expect(order(app)).toEqual(['q2', 'q1']); + expect(qs(app.root, '.dash-live')?.textContent).toContain('position 2 of 2'); + expect(commit).toHaveBeenCalled(); + // Move earlier back. + qsa(app.root, '.dash-tile-move[title="Move earlier"]')[1].dispatchEvent(new Event('click', { bubbles: true })); + expect(order(app)).toEqual(['q1', 'q2']); + // Move earlier on the FIRST tile is a no-op (out of range → command rejected). + const live = qs(app.root, '.dash-live')?.textContent; + qsa(app.root, '.dash-tile-move[title="Move earlier"]')[0].dispatchEvent(new Event('click', { bubbles: true })); + expect(order(app)).toEqual(['q1', 'q2']); + expect(qs(app.root, '.dash-live')?.textContent).toBe(live); + }); + + it('span and height controls drive update-placement', 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-3', items: {} }, + }), }); - 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 spanSel = qsa(app.root, '.dash-tile-span')[0]; + spanSel.value = '2'; + spanSel.dispatchEvent(new Event('change', { bubbles: true })); + expect((qsa(app.root, '.dash-tile')[0].style as CSSStyleDeclaration).gridColumn).toContain('span 2'); + const heightSel = qsa(app.root, '.dash-tile-height')[0]; + heightSel.value = 'large'; + heightSel.dispatchEvent(new Event('change', { bubbles: true })); + expect(qsa(app.root, '.dash-tile')[0].dataset.height).toBe('large'); + }); + + it('pointer drag reorders as an equivalent alternative', async () => { + const { app } = dashApp({ workspace: twoTiles() }); + await render(app); + 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']); + // 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' }] }), }); - }); - - 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'); + 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 cell click is a harmless no-op (onCell). + qsa(app.root, '.res-table tbody td')[0]?.dispatchEvent(new Event('click', { bubbles: true })); + expect(calls.length).toBe(before); }); }); -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 - }); - }); - 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' }); - }); - - 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 +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('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 + 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); }); - // #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']); - }); - 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(); - }); - 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' }); - }); - 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 + 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(); }); }); -// ── 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'); +describe('renderDashboard — filter bar affordances (#188)', () => { + const filterWs = () => wsWith({ + queries: [q('q1', 'SELECT k, v FROM a WHERE x = {p:String}')], + tiles: [{ id: 't1', queryId: 'q1' }], + }); + + it('surfaces an implicit param filter with a blocking badge, clear, count, and clear-all', async () => { + const { app, calls } = dashApp({ workspace: filterWs() }); + await render(app); + // A required, unset param blocks the panel — the filter stays visible with a badge. + expect(qs(app.root, '.dash-filter-blocking')).not.toBeNull(); + expect(qs(app.root, '.var-field .var-name')?.textContent).toBe('p'); + // Nothing active yet → count/clear-all hidden. + 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'); + // Type a value + commit → one affected-panel wave, filter active. + const input = qs(app.root, '.var-field .var-input'); + const before = calls.length; + input.value = 'foo'; + input.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); await Promise.resolve(); + expect(calls.length).toBeGreaterThan(before); + expect(qs(app.root, '.dash-filter-count')?.textContent).toBe('1 active'); + expect((qs(app.root, '.dash-filter-clear-all') as HTMLElement).style.display).toBe(''); + // Clear one → deactivates (value retained), one wave. + (qs(app.root, '.dash-filter-clear') as HTMLElement).dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + expect(qs(app.root, '.dash-filter-count')?.textContent).toBeFalsy(); + // Enter commits too. + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await Promise.resolve(); + // Clear all resets everything in one wave. + (qs(app.root, '.dash-filter-clear-all') as HTMLElement).dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); }); - 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 a source-less filter with a numeric default active by default', 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 }], + }), + }); + await render(app); + expect(qs(app.root, '.dash-filter-count')?.textContent).toBe('1 active'); + // A numeric default is not a string → the text input starts blank. + expect(qs(app.root, '.var-field .var-input').value).toBe(''); + }); + + it('upgrades a source-backed filter field to a select once its options arrive', async () => { + const { app } = 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' }], + }), + }); + await render(app); + expect(qs(app.root, '.var-field select.var-input')).not.toBeNull(); + const sel = qs(app.root, '.var-field select.var-input'); + sel.value = 'x'; + sel.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); }); }); -// ── 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 +751,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 +802,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(); From 065830b02367e9c490e392f00e197534f3d75971 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 17 Jul 2026 22:10:52 +0000 Subject: [PATCH 08/10] =?UTF-8?q?feat(#286):=20final=20UX=20=E2=80=94=20ri?= =?UTF-8?q?ch=20shared=20filter=20bar=20over=20the=20viewer;=20drag-only?= =?UTF-8?q?=20reorder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product-owner final scope for #286 (we stop here; #287/#288 deferred). - RESTORE rich filter fields (regression fix): the Dashboard filter bar is now the shared buildFilterBar — relative-time presets, recents, enum + curated comboboxes — driven over the viewer. Bridge added to DashboardViewerSession: `controls`, `getFilterField` (draft-aware #170 validation), `applyFilter` (commit with explicit activation), and a parameterized `prepareBatch`. A shim FilterBarApp feeds a draft value/active bag + recents from the real app; the viewer still imports no AppState/app.state (check:arch forbidden list intact). - REMOVE the per-filter "×" clear button (and filterClearButton). - REMOVE the in-tile span (1×/2×/3×) and height controls; span/height are tuned in the Spec editor. The update-placement/setTileSpan/setTileHeight authoring commands stay in the application layer. - Tile reordering is pointer DRAG ONLY: keyboard Move-earlier/later buttons removed; a drop persists dashboard.tiles[] via move-tile. Deliberate owner override of #280's a11y "not the only mechanism" note (recorded in CHANGELOG). - KEEP: "N active" count, coalesced clear-all, never-hide-blocking badge, KPI bands. npm test (3360) + build + check:arch green; per-file coverage at the gate (dashboard.ts / filter-bar.ts / dashboard-viewer-session.ts). No e2e fixture changes (no App-shape change; removed chrome unreferenced there). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 28 ++- .../application/dashboard-viewer-session.ts | 56 ++++- src/ui/dashboard.ts | 230 ++++++++---------- src/ui/filter-bar.ts | 12 - tests/unit/dashboard-viewer-session.test.ts | 28 +++ tests/unit/dashboard.test.ts | 179 +++++++------- 6 files changed, 279 insertions(+), 254 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf65c51..1177dc62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,16 +173,24 @@ auto-generated per-PR notes; this file is the curated, human-readable history. 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. - - **Filter-bar affordances** (`src/ui/filter-bar.ts`, shared): per-filter - clear, coalesced clear-all, "N active" count, and a never-hidden blocking - badge, driven by the viewer's `clearFilter`/`clearAllFilters`/ - `activeFilterCount`/`blocking`. - - **Accessible reorder + sizing.** Keyboard Move-earlier/Move-later, span, and - height controls on every tile drive the phase-3 `move-tile`/ - `update-placement` commands (mirrored into the viewer with `syncDocument`, - no re-execution, and best-effort persisted); focus stays on the moved tile - and an ARIA live region announces the new position. Pointer drag is an - equivalent alternative, never the only mechanism. + - **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), an "N active" count, and a never-hidden blocking badge + (invalid / required-and-unset / source-query error). + - **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. diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index de4cce21..e3da0cdc 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -28,7 +28,7 @@ import { analyzeParameterizedSources, prepareParameterizedBatch, mergedSourceArgs, mergedSourceSql, fieldControls, } from '../../core/param-pipeline.js'; import type { - FieldControl, ParameterAnalysis, PreparedSource, ValidationMode, BoundParamSnapshot, + FieldControl, ParameterAnalysis, PreparedSource, PreparedFieldState, ValidationMode, BoundParamSnapshot, } from '../../core/param-pipeline.js'; import { hasOptionalBlocks } from '../../core/optional-blocks.js'; import { detectSqlFormat } from '../../core/format.js'; @@ -164,6 +164,13 @@ export interface DashboardViewerDeps { 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. */ @@ -172,6 +179,10 @@ export interface DashboardViewerSession { 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; @@ -349,15 +360,25 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa const activeMap = (): Record => Object.fromEntries(filters.map((filter) => [filter.def.parameter, filter.state.active])); - const prepareBatch = (mode: ValidationMode = 'execute') => { - const active = activeMap(); - return prepareParameterizedBatch(analysis, { - values: Object.fromEntries(Object.entries(rawValues()).map(([name, value]) => - [name, curated[name] && !active[name] ? '' : value])), - active: effectiveActive(rawValues(), active), - wallNowMs: deps.wallNow(), validationMode: mode, - }); - }; + // 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)); @@ -632,6 +653,18 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa 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); @@ -696,6 +729,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa return { state: stateSignal as ReadonlySignal, - start, refresh, refreshTile, setFilter, clearFilter, clearAllFilters, cancelTile, syncDocument, destroy, + controls, getFilterField, + start, refresh, refreshTile, setFilter, applyFilter, clearFilter, clearAllFilters, cancelTile, syncDocument, destroy, }; } diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 4256142f..2f036b66 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -4,20 +4,26 @@ // (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 +// 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 (each 100%-covered); this module is the render/interaction -// shell over them. +// dependencies; this module is the render/interaction shell over them. // -// Structural edits (reorder, span/height, layout preset) go through the -// phase-3 authoring commands (`applyCommand` — `move-tile`/`update-placement`/ -// `change-layout`), are mirrored into the viewer with `syncDocument` (no -// re-execution), and best-effort persisted through the repository. Accessible -// keyboard controls are the first-class reorder/resize mechanism; pointer drag -// is an equivalent alternative. The `spec.favorite` dual-WRITE stays until GA -// (the star action in the Workbench); only the READ is flipped here. +// 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. @@ -33,11 +39,11 @@ import { formatBytes as formatBytesUntyped, formatRows as formatRowsUntyped, } from '../core/format.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 { - filterClearButton, filterClearAllButton, filterActiveCount, filterBlockingBadge, -} from './filter-bar.js'; +import { buildFilterBar, filterClearAllButton, filterActiveCount, filterBlockingBadge } from './filter-bar.js'; +import type { FilterBarApp } from './filter-bar.js'; import { createDashboardViewerSession } from '../dashboard/application/dashboard-viewer-session.js'; import type { DashboardViewerSession, DashboardViewState, ViewerTileState, ViewerFilterState, @@ -47,8 +53,7 @@ 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, FlowHeightV1, - SavedQueryV2, StoredWorkspaceV1, + 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'; @@ -83,14 +88,13 @@ export interface DashboardApp { exec: Pick; now(): number; wallNow(): number; - params: Pick; + params: Pick; workspace: Pick; loadDashboardWorkspace(): Promise; } -const FLOW_HEIGHTS: FlowHeightV1[] = ['compact', 'medium', 'large']; -const isObject = (value: unknown): value is Record => - !!value && typeof value === 'object' && !Array.isArray(value); +const valueString = (value: unknown): string => + (typeof value === 'string' ? value : value == null ? '' : String(value)); /** * Build a segmented control (the flow preset switcher). `options` are @@ -133,10 +137,6 @@ interface TileEl { card: HTMLElement; body: HTMLElement; foot: HTMLElement; - moveEarlier: HTMLButtonElement; - moveLater: HTMLButtonElement; - spanSel: HTMLSelectElement; - heightSel: HTMLSelectElement; panelState: { key: string;[k: string]: unknown } | null; destroy: (() => void) | null; paintedRows: unknown[][] | null; @@ -237,106 +237,80 @@ export async function renderDashboard(app: DashboardApp): Promise { 'Dashboard layout'); const layoutWrap = h('div', { class: 'dash-layout-wrap' }, h('span', { class: 'dash-seg-label' }, 'Layout'), presetSeg.el); - // ── Filter bar (viewer-driven) ──────────────────────────────────────────── + // ── Filter bar (shared buildFilterBar, viewer-backed) ───────────────────── const filterHost = h('div', { class: 'dash-filter-host' }); const filterCountNode = h('span', { class: 'dash-filter-count-host' }); const clearAllNode = h('span', { class: 'dash-filter-clear-all-host' }); - const filterFields = new Map(); - - function buildFilterBar(initial: ViewerFilterState[]): void { - filterFields.clear(); - const fields = initial.map((f) => { - const badgeHost = h('span', { class: 'dash-filter-badge-host' }); - let input: HTMLInputElement | HTMLSelectElement; - if (f.options && f.options.length) { - const sel = h('select', { class: 'var-input' }, - h('option', { value: '' }, 'All'), - ...f.options.map((o) => h('option', { value: o.value }, o.label))); - sel.value = typeof f.value === 'string' ? f.value : ''; - sel.onchange = () => { session.setFilter(f.id, sel.value); }; - input = sel; - } else { - const inp = h('input', { class: 'var-input', type: 'text', 'aria-label': f.label }); - inp.value = typeof f.value === 'string' ? f.value : ''; - inp.onchange = () => { session.setFilter(f.id, inp.value); }; - inp.onkeydown = (event: KeyboardEvent) => { if (event.key === 'Enter') session.setFilter(f.id, inp.value); }; - input = inp; - } - const clear = filterClearButton({ label: f.label, onClear: () => session.clearFilter(f.id) }); - filterFields.set(f.id, { input, badgeHost }); - return h('label', { class: 'var-field' }, h('span', { class: 'var-name' }, f.label), input, clear, badgeHost); - }); - filterHost.replaceChildren(...(fields.length ? [...fields, clearAllNode, filterCountNode] : [])); - } + // 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; - function updateFilterBar(sview: DashboardViewState): void { - // A text field upgrades to a select once its filter-source options land. - const needsRebuild = sview.filters.some((f) => { - const field = filterFields.get(f.id); - return !!field && !!f.options && f.options.length > 0 && field.input.tagName !== 'SELECT'; - }); - if (needsRebuild) buildFilterBar(sview.filters); + function rebuildFilterBar(sview: DashboardViewState): void { + filterBarDispose?.(); + const idByParam = new Map(); + const curatedFields: Record = {}; for (const f of sview.filters) { - const field = filterFields.get(f.id); - if (field) field.badgeHost.replaceChildren(...(f.blocking ? [filterBlockingBadge(f.blocking)] : [])); + 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 }; } - clearAllNode.replaceChildren(filterClearAllButton({ active: sview.activeFilterCount > 0, onClearAll: () => session.clearAllFilters() })); - filterCountNode.replaceChildren(filterActiveCount(sview.activeFilterCount)); + 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 filterDiagnosticsHost = h('div', { class: 'dash-filter-diagnostics' }); - const liveRegion = h('div', { - class: 'dash-live', role: 'status', 'aria-live': 'polite', - style: { position: 'absolute', width: '1px', height: '1px', overflow: 'hidden' }, - }); 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 / resize / preset) ─────────────────────── + // ── 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, }); - if (!applied.ok) return; - 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; }); + // 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; }); + } } } - function currentPlacement(tileId: string): { span?: number; height?: string } { - const items = isObject(currentDoc.layout.items) ? currentDoc.layout.items : {}; - const placement = items[tileId]; - return isObject(placement) ? placement as { span?: number; height?: string } : {}; - } - - function moveTile(tileId: string, delta: number): void { - const order = currentDoc.tiles.map((t) => t.id); - const index = order.indexOf(tileId); - const toIndex = index + delta; - if (index < 0 || toIndex < 0 || toIndex >= order.length) return; - runCommand({ type: 'move-tile', tileId, toIndex }); - // Focus stays on the moved tile's control; announce the new position. - const tileEl = tileEls.get(tileId); - (delta < 0 ? tileEl?.moveEarlier : tileEl?.moveLater)?.focus(); - liveRegion.textContent = `Moved tile to position ${toIndex + 1} of ${order.length}`; - } - - function setPlacement(tileId: string, patch: { span?: number; height?: string }): void { - runCommand({ type: 'update-placement', tileId, placement: { ...currentPlacement(tileId), ...patch } }); - } - // ── Tile DOM ────────────────────────────────────────────────────────────── const tileEls = new Map(); let dragTileId: string | null = null; @@ -344,20 +318,12 @@ export async function renderDashboard(app: DashboardApp): Promise { function ensureTileEl(ts: ViewerTileState): TileEl { const existing = tileEls.get(ts.tileId); if (existing) return existing; - const titleEl = h('span', { class: 'dash-tile-name', title: ts.title }, ts.title); - const moveEarlier = h('button', { type: 'button', class: 'dash-tile-move', title: 'Move earlier', 'aria-label': `Move ${ts.title} earlier`, onclick: () => moveTile(ts.tileId, -1) }, '‹'); - const moveLater = h('button', { type: 'button', class: 'dash-tile-move', title: 'Move later', 'aria-label': `Move ${ts.title} later`, onclick: () => moveTile(ts.tileId, 1) }, '›'); - const spanSel = h('select', { class: 'dash-tile-span', 'aria-label': `${ts.title} width` }, - ...[1, 2, 3].map((n) => h('option', { value: String(n) }, `${n}×`))); - spanSel.onchange = () => setPlacement(ts.tileId, { span: Number(spanSel.value) }); - const heightSel = h('select', { class: 'dash-tile-height', 'aria-label': `${ts.title} height` }, - ...FLOW_HEIGHTS.map((height) => h('option', { value: height }, height))); - heightSel.onchange = () => setPlacement(ts.tileId, { height: heightSel.value }); - const controls = h('div', { class: 'dash-tile-controls' }, moveEarlier, moveLater, spanSel, heightSel); - const head = h('div', { class: 'dash-tile-head' }, titleEl, controls); + 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) => { @@ -367,7 +333,7 @@ export async function renderDashboard(app: DashboardApp): Promise { } dragTileId = null; }); - const tileEl: TileEl = { card, body, foot, moveEarlier, moveLater, spanSel, heightSel, panelState: null, destroy: null, paintedRows: null }; + const tileEl: TileEl = { card, body, foot, panelState: null, destroy: null, paintedRows: null }; tileEls.set(ts.tileId, tileEl); return tileEl; } @@ -375,8 +341,7 @@ export async function renderDashboard(app: DashboardApp): Promise { 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 (no - // defensive `|| []` — the viewer guarantees them on `ready`). + // 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); @@ -406,10 +371,6 @@ export async function renderDashboard(app: DashboardApp): Promise { function reconcileTile(ts: ViewerTileState): void { const tileEl = ensureTileEl(ts); - const placement = currentPlacement(ts.tileId); - tileEl.spanSel.value = String(placement.span ?? 1); - tileEl.heightSel.value = String(placement.height ?? 'medium'); - tileEl.card.dataset.height = String(placement.height ?? 'medium'); 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); @@ -454,8 +415,8 @@ export async function renderDashboard(app: DashboardApp): Promise { 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, - // resize, preset, or mobile flip) — moving stable tile cards, so charts are - // never thrashed. + // 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'); @@ -484,6 +445,7 @@ export async function renderDashboard(app: DashboardApp): Promise { // ── 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 @@ -491,11 +453,21 @@ export async function renderDashboard(app: DashboardApp): Promise { // 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; - if (filterFields.size === 0 && sview.filters.length) buildFilterBar(sview.filters); - updateFilterBar(sview); + // 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' : ''; - filterDiagnosticsHost.replaceChildren(...sview.diagnostics.map((d) => h('div', { class: 'dash-config-diagnostic is-error' }, d.message))); + // Never silently hide a blocking filter: a visible badge per blocking filter. + const blocking = sview.filters.filter((f) => f.blocking).map((f) => filterBlockingBadge(`${f.label}: ${f.blocking}`)); + filterDiagnosticsHost.replaceChildren( + ...sview.diagnostics.map((d) => h('div', { class: 'dash-config-diagnostic is-error' }, d.message)), + ...blocking, + ); reconcileGrid(sview); refreshBtn.disabled = sview.running; if (!sview.running && sview.updatedAt != null) { @@ -508,7 +480,7 @@ export async function renderDashboard(app: DashboardApp): Promise { // `!`: the dashboard renders only into a mounted page. app.root!.replaceChildren(h('div', { class: 'dash-page' }, h('div', { class: 'dash-topbar' }, header, toolbar), - liveRegion, filterDiagnosticsHost, empty, grid)); + filterDiagnosticsHost, empty, grid)); await session.start(); } diff --git a/src/ui/filter-bar.ts b/src/ui/filter-bar.ts index 0c8cd27e..06f0fe8e 100644 --- a/src/ui/filter-bar.ts +++ b/src/ui/filter-bar.ts @@ -92,18 +92,6 @@ const buildRecentField = _buildRecentField as (opts: { // 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). -/** A per-filter clear affordance (#188): a keyboard-focusable button that - * deactivates one filter without discarding its value (reactivation restores - * it). `label` names the filter for assistive tech. */ -export function filterClearButton(opts: { label: string; onClear: () => void }): HTMLButtonElement { - const btn = h('button', { - type: 'button', class: 'dash-filter-clear', - title: `Clear ${opts.label}`, 'aria-label': `Clear ${opts.label}`, - onclick: () => opts.onClear(), - }, '×'); - return btn; -} - /** 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. */ diff --git a/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index 1c14cc62..31fcbf2d 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -387,6 +387,34 @@ describe('filters and the #235 execution planner', () => { }); }); +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]] })); diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index c81fdf55..3f25d154 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -403,7 +403,7 @@ describe('renderDashboard — flow layout + preset switcher (#280)', () => { expect(commit).toHaveBeenCalled(); }); - it('defaults preset and placement when the layout omits them', async () => { + 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')], @@ -413,10 +413,7 @@ describe('renderDashboard — flow layout + preset switcher (#280)', () => { }); await render(app); expect(seg(app.root, 'Full width')?.getAttribute('aria-pressed')).toBe('true'); - // Resize with no items map present exercises the placement fallback. - const spanSel = qsa(app.root, '.dash-tile-span')[0]; - spanSel.value = '2'; - spanSel.dispatchEvent(new Event('change', { bubbles: true })); + expect((qsa(app.root, '.dash-row')[0].style as CSSStyleDeclaration).gridTemplateColumns).toContain('repeat(1'); expect(qsa(app.root, '.dash-tile').length).toBe(1); }); @@ -440,7 +437,7 @@ describe('renderDashboard — flow layout + preset switcher (#280)', () => { }); }); -describe('renderDashboard — accessible reorder + sizing (#153/#280)', () => { +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' }], @@ -448,52 +445,24 @@ describe('renderDashboard — accessible reorder + sizing (#153/#280)', () => { }); const order = (app: TestApp): string[] => qsa(app.root, '.dash-tile .dash-tile-name').map((n) => n.textContent || ''); - it('Move later / Move earlier reorder tiles, keep focus, and announce the new position', async () => { - const { app, commit } = dashApp({ workspace: twoTiles() }); - await render(app); - expect(order(app)).toEqual(['q1', 'q2']); - const laterBtns = qsa(app.root, '.dash-tile-move[title="Move later"]'); - laterBtns[0].dispatchEvent(new Event('click', { bubbles: true })); - expect(order(app)).toEqual(['q2', 'q1']); - expect(qs(app.root, '.dash-live')?.textContent).toContain('position 2 of 2'); - expect(commit).toHaveBeenCalled(); - // Move earlier back. - qsa(app.root, '.dash-tile-move[title="Move earlier"]')[1].dispatchEvent(new Event('click', { bubbles: true })); - expect(order(app)).toEqual(['q1', 'q2']); - // Move earlier on the FIRST tile is a no-op (out of range → command rejected). - const live = qs(app.root, '.dash-live')?.textContent; - qsa(app.root, '.dash-tile-move[title="Move earlier"]')[0].dispatchEvent(new Event('click', { bubbles: true })); - expect(order(app)).toEqual(['q1', 'q2']); - expect(qs(app.root, '.dash-live')?.textContent).toBe(live); - }); - - it('span and height controls drive update-placement', 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-3', items: {} }, - }), - }); + it('has no in-tile move / span / height chrome (owner override — drag only)', async () => { + const { app } = dashApp({ workspace: twoTiles() }); await render(app); - const spanSel = qsa(app.root, '.dash-tile-span')[0]; - spanSel.value = '2'; - spanSel.dispatchEvent(new Event('change', { bubbles: true })); - expect((qsa(app.root, '.dash-tile')[0].style as CSSStyleDeclaration).gridColumn).toContain('span 2'); - const heightSel = qsa(app.root, '.dash-tile-height')[0]; - heightSel.value = 'large'; - heightSel.dispatchEvent(new Event('change', { bubbles: true })); - expect(qsa(app.root, '.dash-tile')[0].dataset.height).toBe('large'); + 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 as an equivalent alternative', async () => { - const { app } = dashApp({ workspace: twoTiles() }); + 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']); + 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']); @@ -509,10 +478,33 @@ describe('renderDashboard — accessible reorder + sizing (#153/#280)', () => { 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 cell click is a harmless no-op (onCell). - qsa(app.root, '.res-table tbody td')[0]?.dispatchEvent(new Event('click', { bubbles: true })); + // 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' }], + }), + }); + 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 — KPI bands (#240)', () => { @@ -559,43 +551,50 @@ describe('renderDashboard — KPI bands (#240)', () => { }); }); -describe('renderDashboard — filter bar affordances (#188)', () => { - const filterWs = () => wsWith({ - queries: [q('q1', 'SELECT k, v FROM a WHERE x = {p:String}')], - tiles: [{ id: 't1', queryId: 'q1' }], +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' }], + }), + }); + 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('surfaces an implicit param filter with a blocking badge, clear, count, and clear-all', async () => { - const { app, calls } = dashApp({ workspace: filterWs() }); + 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' }], + }), + }); await render(app); - // A required, unset param blocks the panel — the filter stays visible with a badge. - expect(qs(app.root, '.dash-filter-blocking')).not.toBeNull(); - expect(qs(app.root, '.var-field .var-name')?.textContent).toBe('p'); - // Nothing active yet → count/clear-all hidden. - 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'); - // Type a value + commit → one affected-panel wave, filter active. - const input = qs(app.root, '.var-field .var-input'); + // 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; - input.value = 'foo'; - input.dispatchEvent(new Event('change', { bubbles: true })); + 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(); - expect(calls.length).toBeGreaterThan(before); - expect(qs(app.root, '.dash-filter-count')?.textContent).toBe('1 active'); - expect((qs(app.root, '.dash-filter-clear-all') as HTMLElement).style.display).toBe(''); - // Clear one → deactivates (value retained), one wave. - (qs(app.root, '.dash-filter-clear') as HTMLElement).dispatchEvent(new Event('click', { bubbles: true })); - await Promise.resolve(); - expect(qs(app.root, '.dash-filter-count')?.textContent).toBeFalsy(); - // Enter commits too. - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - await Promise.resolve(); - // Clear all resets everything in one wave. - (qs(app.root, '.dash-filter-clear-all') as HTMLElement).dispatchEvent(new Event('click', { bubbles: true })); - 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('renders a source-less filter with a numeric default active by default', async () => { + 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}')], @@ -605,30 +604,26 @@ describe('renderDashboard — filter bar affordances (#188)', () => { }); await render(app); expect(qs(app.root, '.dash-filter-count')?.textContent).toBe('1 active'); - // A numeric default is not a string → the text input starts blank. - expect(qs(app.root, '.var-field .var-input').value).toBe(''); + 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('upgrades a source-backed filter field to a select once its options arrive', async () => { + it('never silently hides a blocking filter (required-and-unset carries a visible badge)', async () => { const { app } = 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' } }), - ], + queries: [q('q1', 'SELECT k, v FROM a WHERE x = {p:String}')], tiles: [{ id: 't1', queryId: 'q1' }], - filters: [{ id: 'f1', parameter: 'p', sourceQueryId: 'src' }], }), }); await render(app); - expect(qs(app.root, '.var-field select.var-input')).not.toBeNull(); - const sel = qs(app.root, '.var-field select.var-input'); - sel.value = 'x'; - sel.dispatchEvent(new Event('change', { bubbles: true })); - await Promise.resolve(); + expect(qs(app.root, '.dash-filter-blocking')?.textContent).toContain('p'); + 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'); }); }); From 1bc693d7b28efc064c1467ba4f8c26a45033961f Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 17 Jul 2026 23:48:40 +0000 Subject: [PATCH 09/10] fix(#286): dashboard grid stacks flow rows vertically (not side by side) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .dash-grid container kept the pre-#286 grid-template-columns from the model where .dash-tile were direct children. The flow@1 rewrite wraps tiles in .dash-row grids, so the container was laying the ROWS out in columns — full-width rendered rows side by side, 3-column multiplied them across the page. Make the container a vertical flex stack; each .dash-row owns its column count. happy-dom does not compute CSS layout, so the unit suite could not catch this; verified in real Chromium (3 rows stacked, 3 tiles across each). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 +++++++ src/styles.css | 17 +++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1177dc62..d409f907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -194,6 +194,13 @@ auto-generated per-PR notes; this file is the curated, human-readable history. 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 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; } From ea52764556dd70c1bba106a7d8a289710fa530fa Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 17 Jul 2026 23:56:17 +0000 Subject: [PATCH 10/10] fix(#286): drop the per-filter required/invalid blocking badge (owner decision) The "X: Required filter value is not set." badges under the filter bar were noise. Remove the badge rendering, the filterBlockingBadge helper, and the viewer's now-unused per-filter `blocking` computation (a prepareBatch per wave that fed nothing). An unfilled required filter simply leaves its target tiles in their normal unfilled state. Genuine dashboard-config diagnostics still render. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 12 ++--- .../application/dashboard-viewer-session.ts | 21 +-------- src/ui/dashboard.ts | 9 ++-- src/ui/filter-bar.ts | 9 ---- tests/unit/dashboard-viewer-session.test.ts | 46 ++++++++----------- tests/unit/dashboard.test.ts | 4 +- 6 files changed, 32 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d409f907..bee7702d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,10 +135,10 @@ auto-generated per-PR notes; this file is the curated, human-readable history. - **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, an - `activeFilterCount` counting active filter DEFINITIONS, and a per-filter - `blocking` reason (source-query error / required-and-unset / invalid value) - that is never silently hidden. + 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 @@ -181,8 +181,8 @@ auto-generated per-PR notes; this file is the curated, human-readable history. 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), an "N active" count, and a never-hidden blocking badge - (invalid / required-and-unset / source-query error). + 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 diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index e3da0cdc..6f356637 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -96,10 +96,6 @@ export interface ViewerFilterState { status: ViewerFilterStatus; /** Curated options from the filter's source query, when it has one. */ options: FilterHelperOption[] | null; - /** A human reason this filter blocks a target panel (invalid value, - * required-and-unset, or source-query error) — never silently hidden - * (#188). `null` when the filter is not blocking. */ - blocking: string | null; } export interface DashboardViewState { @@ -321,7 +317,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa 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, blocking: null, + active, value: def.defaultValue ?? '', status: 'idle', options: null, }; return { def, source, gen: 0, abortController: null, provider: null, state }; }); @@ -559,19 +555,6 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa applyFilterProviders(providers); } - // Recompute per-filter blocking (#188 never-silently-hide): a source error, - // a required-and-unset param, or an invalid value. - function recomputeBlocking(): void { - const prepared = prepareBatch('execute'); - for (const filter of filters) { - const field = prepared.fields[filter.def.parameter]; - if (filter.state.status === 'error') { filter.state.blocking = 'Filter source query failed.'; continue; } - if (!field) { filter.state.blocking = null; continue; } - if (field.state === 'missing') filter.state.blocking = 'Required filter value is not set.'; - else if (field.state === 'invalid') filter.state.blocking = field.reason || 'Filter value is not valid.'; - else filter.state.blocking = null; - } - } // ── Waves ───────────────────────────────────────────────────────────────── @@ -605,7 +588,6 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa const filterWave = runFilterWave(); await filterWave; if (destroyed) { await unaffectedWave; return; } - recomputeBlocking(); // 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, @@ -634,7 +616,6 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa if (!field) continue; for (const sourceId of field.requiredIn.concat(field.optionalIn)) affectedIds.add(sourceId); } - recomputeBlocking(); 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); diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 2f036b66..23775f0a 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -42,7 +42,7 @@ import { analyzeParameterizedSources, fieldControls } from '../core/param-pipeli 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, filterBlockingBadge } from './filter-bar.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 { @@ -462,11 +462,12 @@ export async function renderDashboard(app: DashboardApp): Promise { filterCountNode.replaceChildren(filterActiveCount(sview.activeFilterCount)); tileCountLabel.textContent = sview.tiles.length + (sview.tiles.length === 1 ? ' tile' : ' tiles'); empty.style.display = sview.tiles.length ? 'none' : ''; - // Never silently hide a blocking filter: a visible badge per blocking filter. - const blocking = sview.filters.filter((f) => f.blocking).map((f) => filterBlockingBadge(`${f.label}: ${f.blocking}`)); + // 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)), - ...blocking, ); reconcileGrid(sview); refreshBtn.disabled = sview.running; diff --git a/src/ui/filter-bar.ts b/src/ui/filter-bar.ts index 06f0fe8e..4b0d3ea0 100644 --- a/src/ui/filter-bar.ts +++ b/src/ui/filter-bar.ts @@ -118,15 +118,6 @@ export function filterActiveCount(count: number): HTMLElement { return node; } -/** A visible blocking badge (#188): a filter whose state blocks a target panel - * (invalid value / required-and-unset / source-query error) must never be - * silently hidden — this badge states why. */ -export function filterBlockingBadge(reason: string): HTMLElement { - return h('span', { - class: 'dash-filter-blocking', role: 'alert', title: reason, - }, reason); -} - // 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/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index 31fcbf2d..ea61192c 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -261,7 +261,7 @@ describe('filters and the #235 execution planner', () => { expect(session.state.value.filters[0].options).toEqual([{ value: 'V', label: 'V' }]); }); - it('marks a filter whose source SQL is invalid as an error and blocking', async () => { + 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({ @@ -273,7 +273,23 @@ describe('filters and the #235 execution planner', () => { })); await session.start(); expect(session.state.value.filters[0].status).toBe('error'); - expect(session.state.value.filters[0].blocking).toContain('source'); + }); + + 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 () => { @@ -359,32 +375,6 @@ describe('filters and the #235 execution planner', () => { expect(calls.length).toBe(base2); }); - it('never silently hides a blocking filter (source error, required-unset, invalid, ok)', 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: 'src', parameter: 'p', sourceQueryId: 'srcq' }, // source error - { id: 'req', parameter: 'r', defaultActive: true, defaultValue: '' }, // required-unset (declared, blank) - { id: 'inv', parameter: 'num', defaultActive: true, defaultValue: 'notnum' }, // invalid value - { id: 'ok', parameter: 'ok', defaultActive: true, defaultValue: '5' }, // ok - { id: 'undeclared', parameter: 'zzz', sourceQueryId: 'srcq' }, // not declared anywhere - ], - }), - exec, - queries: [ - query('qt', 'SELECT {p:String}, {r:String}, {num:UInt8}, {ok:UInt8}'), - query('srcq', 'SELECT 1 AS p /* badsrc */', { dashboard: { role: 'filter' } }), - ], - })); - await session.start(); - const blocking = Object.fromEntries(session.state.value.filters.map((f) => [f.id, f.blocking])); - expect(blocking.src).toContain('source'); - expect(blocking.req).toContain('Required'); - expect(blocking.inv).toBeTruthy(); - expect(blocking.ok).toBeNull(); - }); }); describe('filter-bar bridge (controls / getFilterField / applyFilter)', () => { diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index 3f25d154..3408861a 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -613,7 +613,7 @@ describe('renderDashboard — shared rich filter bar over the viewer (#188)', () expect(qs(app.root, '.dash-filter-count')?.textContent).toBe('1 active'); }); - it('never silently hides a blocking filter (required-and-unset carries a visible badge)', async () => { + 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}')], @@ -621,7 +621,7 @@ describe('renderDashboard — shared rich filter bar over the viewer (#188)', () }), }); await render(app); - expect(qs(app.root, '.dash-filter-blocking')?.textContent).toContain('p'); + 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'); });