diff --git a/CHANGELOG.md b/CHANGELOG.md index 420b4550..264e0940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] +### Removed +- **The unused saved-query repair planner has been removed** (#429 phase 6 / + #500). Direct, ownership-safe Panel and Dashboard trash actions are the + supported cascading delete paths; Library deletion and saved-query edits + continue to fail closed when whole-workspace validation would invalidate a + live Dashboard member. + ## [0.7.0] - 2026-07-27 ### Added diff --git a/src/application/dashboard-tree-model.ts b/src/application/dashboard-tree-model.ts index f18a5869..307fc06b 100644 --- a/src/application/dashboard-tree-model.ts +++ b/src/application/dashboard-tree-model.ts @@ -414,9 +414,9 @@ const variableAnnotation = ( * * Both say what is wrong with the DATA rather than "not allowed": the row is * showing a tile whose dedicated query cannot be proven, and the user's next - * move is to look at the panel, not to try again. Repairing such a workspace - * is #429's repair-planner phase, deliberately not something these controls - * attempt — a guessed owner is how one delete becomes two. + * move is to inspect the workspace data, not to try again. #429 deliberately + * keeps malformed ownership fail-closed rather than guessing a repair — a + * guessed owner is how one delete becomes two. */ const MISSING_PANEL_QUERY_REASON = 'This panel’s query is not in this workspace, so there is nothing to edit or remove.'; diff --git a/src/dashboard/application/saved-query-mutation.ts b/src/dashboard/application/saved-query-mutation.ts deleted file mode 100644 index a4d9171f..00000000 --- a/src/dashboard/application/saved-query-mutation.ts +++ /dev/null @@ -1,195 +0,0 @@ -// Saved-query mutations must preserve workspace validity (#280 "Saved-query -// mutations must preserve workspace validity"). Deleting a query, changing its -// Dashboard role, deleting a selected variant, 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/panel edits are all a replace), so the mutation -// surface is two kinds. Repairs mirror the #280 examples, minus the filter one -// #447 removed: remove the affected tiles, switch tiles to another variant, or -// remap references to another query. - -import { cloneJson } from '../../core/saved-query.js'; -import { canonicalEqual, DASHBOARD_DOCUMENT_SHAPE } from '../model/canonical-json.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 { resolveLayoutPluginSync } from '../layouts/layout-registry.js'; -import { regenerateGridFallback } from '../layouts/grafana-grid-layout.js'; -import { validateStoredWorkspaceDocument } from '../../workspace/stored-workspace.js'; -import type { - DashboardDocumentV2, SavedQueryV2, StoredWorkspaceV5, -} 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' - | 'switch-variant' | 'remap-query'; - -export type SavedQueryRepair = - | { type: 'remove-affected-tiles' } - | { 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: StoredWorkspaceV5 | 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)); -} - -/** Drop every tile that renders the affected query. - * - * There is no companion filter repair any more. A curated filter could be broken - * by a query mutation in two ways — it referenced the query as its option source, - * or it explicitly targeted a tile that was about to disappear — so the repair - * menu offered removing the affected filters as well. A variable is inferred from - * the panel SQL that declares it and targets nothing, so removing the tiles is - * the whole repair: any variable that existed only because of those queries stops - * being inferred, and its stored option SQL (if any) becomes a visible orphan the - * user can keep or delete. */ -function removeAffectedTiles(dashboard: DashboardDocumentV2, affectedId: string): DashboardDocumentV2 { - const tiles = dashboard.tiles.filter((tile) => !(isObject(tile) && tile.queryId === affectedId)); - return { ...dashboard, tiles }; -} - -function switchVariants( - dashboard: DashboardDocumentV2, affectedId: string, tileVariants: Record, -): DashboardDocumentV2 { - 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: DashboardDocumentV2, affectedId: string, to: string): DashboardDocumentV2 { - const tiles = dashboard.tiles.map((tile) => - (isObject(tile) && tile.queryId === affectedId ? { ...tile, queryId: to } : tile)); - return { ...dashboard, tiles }; -} - -function applyRepair(dashboard: DashboardDocumentV2, affectedId: string, repair: SavedQueryRepair): DashboardDocumentV2 { - switch (repair.type) { - case 'remove-affected-tiles': return removeAffectedTiles(dashboard, 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 `tiles`-scoped diagnostic - * offers tile removal, a variant switch, or a remap; there is no other member - * collection left to repair, because a variable is inferred rather than stored - * and so cannot itself hold a reference a mutation could break. */ -export function suggestRepairs(diagnostics: readonly WorkspaceDiagnostic[]): SavedQueryRepairKind[] { - const repairs = new Set(); - for (const diagnostic of diagnostics) { - if (diagnostic.path.includes('tiles')) { - repairs.add('remove-affected-tiles'); - repairs.add('switch-variant'); - repairs.add('remap-query'); - } - } - return [...repairs]; -} - -function validateWorkspace( - candidate: StoredWorkspaceV5, options: SavedQueryMutationOptions, -): WorkspaceDiagnostic[] { - const codecOptions = options.validationService ? { validationService: options.validationService } : {}; - const structural = validateStoredWorkspaceDocument(candidate, codecOptions); - if (structural.length) return structural; - // #424: presentation resolution runs for EVERY Dashboard, each at its own - // indexed path, so a candidate can never be committed with one Dashboard - // repaired and another left holding a dangling or incompatible reference. - return candidate.dashboards.flatMap((dashboard, index) => resolveDashboardPresentations({ - dashboard, queries: candidate.queries, - schemaService: options.schemaService, path: ['dashboards', index], - })); -} - -/** Apply the repair to ONE Dashboard, then normalize it only if the repair - * actually changed it (#424): a Dashboard the mutation does not touch must - * come out canonically identical and keep its revision, so it is never - * re-normalized or fallback-regenerated as a side effect of another - * Dashboard's repair. */ -function repairedDashboard( - dashboard: DashboardDocumentV2, affectedId: string, repair: SavedQueryRepair | undefined, -): DashboardDocumentV2 { - const clone = cloneJson(dashboard); - if (!repair) return clone; - const repaired = applyRepair(clone, affectedId, repair); - if (canonicalEqual(repaired, clone, DASHBOARD_DOCUMENT_SHAPE)) return clone; - // Normalize through the ACTIVE layout engine's own plugin (#291: flow@1 or - // grafana-grid@1, resolved from the document's own `layout.type`) rather - // than a hardcoded flow plugin, then regenerate the flow@1 fallback when - // grafana-grid@1 is active (a repair can add/remove tiles, exactly like the - // authoring commands do) — a no-op under flow@1. - const normalized = resolveLayoutPluginSync(repaired.layout).normalize(repaired); - regenerateGridFallback(normalized.layout, normalized.tiles); - return normalized; -} - -/** 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. - * - * #424: EVERY Dashboard in the workspace is part of the one atomic candidate. - * References are inspected and validated across the whole collection — the - * current UI may only offer repairs for what it can show, but the planner - * still detects a break in a Dashboard the UI never renders, so a mutation - * can never silently corrupt hidden data. - * - * Note for the caller that eventually wires this up (it has no production - * caller yet): ONE `repair` is applied to EVERY Dashboard. That is right for - * `remap-query` (a query id is workspace-global) but blunt for the - * tile-scoped repairs — `switch-variant` keys off `tileVariants[tile.id]`, - * and tile ids are Dashboard-LOCAL, so a coincidental id collision would - * rewrite an unrelated Dashboard's tile. A per-Dashboard repair map is the - * natural extension once a UI can actually address more than one Dashboard. */ -export function planSavedQueryMutation( - workspace: StoredWorkspaceV5, mutation: SavedQueryMutation, - repair?: SavedQueryRepair, options: SavedQueryMutationOptions = {}, -): SavedQueryMutationPlan { - const queries = applyQueryMutation(workspace.queries, mutation); - const dashboards = workspace.dashboards.map( - (dashboard) => repairedDashboard(dashboard, mutation.queryId, repair), - ); - const candidate: StoredWorkspaceV5 = { - storageVersion: 5, id: workspace.id, key: workspace.key, name: workspace.name, - queries: cloneJson(queries), dashboards, - }; - 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/grafana-grid-layout.ts b/src/dashboard/layouts/grafana-grid-layout.ts index d13c5429..44edf9e3 100644 --- a/src/dashboard/layouts/grafana-grid-layout.ts +++ b/src/dashboard/layouts/grafana-grid-layout.ts @@ -484,9 +484,9 @@ function tileRefsOf(tiles: readonly unknown[]): GrafanaGridFallbackTile[] { * `layout.fallback`, mirroring `setGridPlacement`'s own mutate-in-place * contract) — a no-op when `layout` is not a grafana-grid@1 document. The * single shared primitive every #291 application-layer mutation path - * (authoring commands, tile-membership star toggle, saved-query mutation - * planning) calls so "every grid mutation regenerates the flow@1 fallback - * deterministically" is enforced once, not duplicated per call site. The + * (authoring commands and tile membership) calls so "every grid mutation + * regenerates the flow@1 fallback deterministically" is enforced once, not + * duplicated per call site. The * non-grid guard runs BEFORE the tiles→refs mapping/allocation (#291 review * F9) — calling this on the far-more-common flow-engine document costs only * the guard check, never a `tiles[]` walk that would just be thrown away. */ diff --git a/src/dashboard/layouts/layout-registry.ts b/src/dashboard/layouts/layout-registry.ts index 3a497491..56d79951 100644 --- a/src/dashboard/layouts/layout-registry.ts +++ b/src/dashboard/layouts/layout-registry.ts @@ -140,9 +140,9 @@ export function createLayoutRegistry( export const defaultLayoutRegistry: DashboardLayoutRegistry = createLayoutRegistry(BUILTIN_SYNC_PLUGINS.map(syncRegistration)); -/** Synchronous plugin resolution for pure call sites that mutate a Dashboard +/** Synchronous plugin resolution for call sites that mutate a Dashboard * document but cannot await the async registry (`tile-membership.ts`, - * `saved-query-mutation.ts` — #291): looked up in `BUILTIN_SYNC_PLUGINS` by + * `library-assignment.ts` — #291/#428): looked up in `BUILTIN_SYNC_PLUGINS` by * exact `{type, version}` match, else the flow@1 plugin. Both built-in * plugins are stateless, already-constructed values (`load()` never truly * defers for either — see the module doc comment above), so no async is diff --git a/src/state.ts b/src/state.ts index ddec10be..4b56cb13 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1361,8 +1361,8 @@ export async function deleteSaved( id: string, mutate: MutateWorkspace, ): Promise { // Delete by ID from the LATEST workspace (#343): the whole-workspace - // validation/repair policy runs against every `latest` Dashboard (not a stale - // Workbench Dashboard snapshot) via the commit inside `mutateWorkspace`. + // fail-closed validation policy runs against every `latest` Dashboard (not a + // stale Workbench Dashboard snapshot) via the commit inside `mutateWorkspace`. const outcome = await mutate((latest) => { const base = baselineWorkspace(state, latest); return { candidate: candidateFrom(base, base.queries.filter((q) => q.id !== id)) }; diff --git a/src/workspace/import-planner.ts b/src/workspace/import-planner.ts index 48518cd9..bba7ccb6 100644 --- a/src/workspace/import-planner.ts +++ b/src/workspace/import-planner.ts @@ -7,11 +7,11 @@ // A PortableBundle import always resolves to one COMPLETE candidate // StoredWorkspaceV5 built from the repository-level primitives in // workspace-operations.ts, then validated in one pass through -// validateStoredWorkspaceDocument — exactly the same "build the whole -// candidate, validate once, never commit an invalid one" discipline -// saved-query-mutation.ts uses for in-place mutations. Nothing here mutates -// application state; the caller commits the returned candidate atomically -// through the Phase-2 repository, or does not commit at all. +// validateStoredWorkspaceDocument — the same "build the whole candidate, +// validate once, never commit an invalid one" discipline every strict +// workspace write uses. Nothing here mutates application state; the caller +// commits the returned candidate atomically through the Phase-2 repository, +// or does not commit at all. // // Query-identity conflicts are resolved BY ID, never by content-based dedup // (#280): an incoming query conflicts with an existing one only when their @@ -195,8 +195,7 @@ export interface RewriteDashboardReferencesResult { } /** - * Bulk generalization of `saved-query-mutation.ts`'s `remapQuery`: rewrite - * every `tile.queryId` and `filter.sourceQueryId` through `mapping`. A + * Rewrite every `tile.queryId` and `filter.sourceQueryId` through `mapping`. A * reference that maps to `null` (skipped) or has no mapping entry at all * sets `invalidated: true` and collects the source id in * `missingRequiredIds` — the reference is left as-is (never silently diff --git a/src/workspace/stored-workspace.ts b/src/workspace/stored-workspace.ts index 58bd2858..46639d75 100644 --- a/src/workspace/stored-workspace.ts +++ b/src/workspace/stored-workspace.ts @@ -170,8 +170,8 @@ function structuralDiagnostics( /** Complete deterministic validation of one V5 stored-workspace aggregate — * the same pipeline `WorkspaceRepository.commit` runs before any write, and - * the pipeline every candidate builder (import planner, saved-query mutation - * planner) validates its candidate through. */ + * the pipeline every candidate builder (imports, saved-query writes, + * Dashboard commands) validates its candidate through. */ export function validateStoredWorkspaceDocument( document: unknown, { validationService = jsonSchemaValidationService }: WorkspaceCodecOptions = {}, ): WorkspaceDiagnostic[] { diff --git a/tests/unit/dashboard-boundaries.test.js b/tests/unit/dashboard-boundaries.test.js index 6c031767..5aeda7c6 100644 --- a/tests/unit/dashboard-boundaries.test.js +++ b/tests/unit/dashboard-boundaries.test.js @@ -89,4 +89,23 @@ describe('dashboard dependency boundaries', () => { it('src/workspace imports no Workbench UI / App / AppState / editor / service / net modules', () => { expect(violations('src/workspace')).toEqual([]); }); + + it('does not restore the retired saved-query repair planner or its vocabulary', () => { + const retiredPath = ['saved-query', 'mutation.ts'].join('-'); + expect(existsSync(join(repoRoot, 'src/dashboard/application', retiredPath))).toBe(false); + const retiredTerms = [ + ['plan', 'SavedQuery', 'Mutation'].join(''), + ['suggest', 'Repairs'].join(''), + ['SavedQuery', 'Repair'].join(''), + ['remove', '-affected', '-tiles'].join(''), + ]; + const hits = []; + for (const file of [...collectFiles(join(repoRoot, 'src')), ...collectFiles(join(repoRoot, 'tests'))]) { + const source = readFileSync(file, 'utf8'); + for (const term of retiredTerms) { + if (source.includes(term)) hits.push(`${relative(repoRoot, file)} → ${term}`); + } + } + expect(hits).toEqual([]); + }); }); diff --git a/tests/unit/grafana-grid-layout.test.ts b/tests/unit/grafana-grid-layout.test.ts index da5e3c46..d91fbaba 100644 --- a/tests/unit/grafana-grid-layout.test.ts +++ b/tests/unit/grafana-grid-layout.test.ts @@ -563,10 +563,9 @@ describe('regenerateGridFallback', () => { }); // #291 review F9: the id-extraction/filtering used to be built by every - // call site (dashboard-commands.ts, tile-membership.ts, - // saved-query-mutation.ts) before calling this function — now it accepts - // the RAW `dashboard.tiles[]`-shaped array directly and does its own - // filtering, tolerating a malformed entry. + // call site (dashboard-commands.ts and tile-membership.ts) before calling + // this function — now it accepts the RAW `dashboard.tiles[]`-shaped array + // directly and does its own filtering, tolerating a malformed entry. it('accepts a raw dashboard.tiles[]-shaped array directly, dropping a malformed entry', () => { const layout = gridLayout({ a: { span: 4 } }); regenerateGridFallback(layout, [ diff --git a/tests/unit/query-ownership.test.ts b/tests/unit/query-ownership.test.ts index 43dbbf02..af560fd4 100644 --- a/tests/unit/query-ownership.test.ts +++ b/tests/unit/query-ownership.test.ts @@ -109,6 +109,14 @@ describe('libraryQueries', () => { ); expect(libraryQueries(ws)).toEqual([]); }); + + it('excludes a query owned only by a non-compatibility Dashboard', () => { + const ws = workspace( + [query('a'), query('owned-elsewhere'), query('b')], + [dashboard('compatibility', []), dashboard('other', ['owned-elsewhere'])], + ); + expect(libraryQueries(ws).map((entry) => entry.id)).toEqual(['a', 'b']); + }); }); describe('ownersOfQuery', () => { diff --git a/tests/unit/saved-history.test.ts b/tests/unit/saved-history.test.ts index 118e4984..8f039d9e 100644 --- a/tests/unit/saved-history.test.ts +++ b/tests/unit/saved-history.test.ts @@ -198,7 +198,7 @@ describe('renderSavedHistory', () => { // #427: the LIBRARY projection. A query some Dashboard member owns is not a // Library entry — it stays serialized, and #426's tree is how it is reached. - it('saved: hides Dashboard-owned queries and counts only the Library', () => { + it('saved: hides queries owned by a non-current Dashboard, including their trash controls', () => { const app = makeApp(); app.state.sidePanel.value = 'saved'; setSaved(app, [ @@ -208,15 +208,23 @@ describe('renderSavedHistory', () => { app.currentWorkspace = { storageVersion: 5, id: 'w', key: 'w', name: 'W', queries: app.state.savedQueries, - dashboards: [{ - documentVersion: 2, id: 'd', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, - tiles: [{ id: 't1', queryId: 'owned-panel' }], - }], + dashboards: [ + { + documentVersion: 2, id: 'current', title: 'Current', revision: 1, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, + tiles: [], + }, + { + documentVersion: 2, id: 'other', title: 'Other', revision: 1, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, + tiles: [{ id: 't1', queryId: 'owned-panel' }], + }, + ], }; renderSavedHistory(app); - expect(qsa(savedList(app), '.saved-row').map((row) => qs(row, '.name').textContent)) - .toEqual(['Library one']); + const rows = qsa(savedList(app), '.saved-row'); + expect(rows.map((row) => qs(row, '.name').textContent)).toEqual(['Library one']); + expect(qsa(savedList(app), '.sv-act').filter((button) => button.title === 'Delete')).toHaveLength(1); expect(qs(savedTabsRow(app), '.side-count').textContent).toBe('· 1'); // Every stored query is still there — the list is a projection, not a filter // on the workspace. diff --git a/tests/unit/saved-query-mutation.test.ts b/tests/unit/saved-query-mutation.test.ts deleted file mode 100644 index 159d4f95..00000000 --- a/tests/unit/saved-query-mutation.test.ts +++ /dev/null @@ -1,478 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - planSavedQueryMutation, suggestRepairs, -} from '../../src/dashboard/application/saved-query-mutation.js'; -import type { SavedQueryV2, StoredWorkspaceV5 } 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); -// A non-panel-role query — still a valid fixture for role-compatibility tests -// (a tile referencing anything other than role panel is incompatible; #447 -// retired the `filter` role, so `setup` is the only other role left). -const setupQuery = (id: string): SavedQueryV2 => ({ - id, sql: "SELECT ['a','b'] AS country", specVersion: 1, spec: { name: id, dashboard: { role: 'setup' } }, -} as SavedQueryV2); - -// A valid base workspace: a panel tile p1 (declares `country`), and a spare -// panel p2 (also declaring `country`, so a remap onto it stays valid). -const baseWorkspace = (): StoredWorkspaceV5 => ({ - storageVersion: 5, id: 'ws', key: 'ws', name: 'WS', - queries: [ - panelQuery('p1', 'SELECT a,b WHERE c={country:String}'), - panelQuery('p2', 'SELECT a,b WHERE c={country:String}'), - ], - dashboards: [{ - documentVersion: 2, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, - tiles: [{ id: 't1', queryId: 'p1' }], - }], -} as StoredWorkspaceV5); - -const codes = (d: WorkspaceDiagnostic[]): string[] => d.map((x) => x.code); -const find = (d: WorkspaceDiagnostic[], code: string): WorkspaceDiagnostic => - d.find((x) => x.code === 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 to SETUP, which a tile can never execute', () => { - const plan = planSavedQueryMutation(baseWorkspace(), - { type: 'replace-query', queryId: 'p1', query: setupQuery('p1') }); - expect(plan.ok).toBe(false); - expect(codes(plan.diagnostics)).toContain('dashboard-setup-reference'); - }); -}); - -describe('planSavedQueryMutation — atomic repair', () => { - it('removes affected tiles and prunes their layout placements', () => { - const plan = planSavedQueryMutation(baseWorkspace(), - { type: 'delete-query', queryId: 'p1' }, { type: 'remove-affected-tiles' }); - expect(plan.ok).toBe(true); - const dashboard = plan.candidate!.dashboards[0]; - expect(dashboard.tiles).toEqual([]); - expect(dashboard.layout.items).toEqual({}); // orphan placement pruned - }); - - it('switches an affected tile to another valid variant', () => { - const workspace: StoredWorkspaceV5 = { - storageVersion: 5, id: 'ws', key: 'ws', name: 'WS', - queries: [panelQuery('p1', 'SELECT a,b', { variants: { alt: {}, other: {} } })], - dashboards: [{ - documentVersion: 2, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, - tiles: [{ id: 't1', queryId: 'p1', presentation: { variant: 'alt' } }], - }], - } as StoredWorkspaceV5; - 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!.dashboards[0]; - expect(dashboard.tiles[0].queryId).toBe('p2'); - }); -}); - -describe('planSavedQueryMutation — repairs skip unaffected and target-less entries', () => { - // #427: every tile owns its OWN dedicated query, so a valid base has one - // query per tile. `t1`/`t3` both derive from the same authoring source, which - // is why they can be switched to the same variant. - const multiTile = (): StoredWorkspaceV5 => ({ - storageVersion: 5, id: 'ws', key: 'ws', name: 'WS', - queries: [ - panelQuery('p1', 'SELECT a,b', { variants: { alt: {}, other: {} } }), - panelQuery('p2', 'SELECT a,b'), - panelQuery('p3', 'SELECT a,b', { variants: { alt: {}, other: {} } }), - panelQuery('p4', 'SELECT a,b', { variants: { alt: {}, other: {} } }), - ], - dashboards: [{ - documentVersion: 2, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {}, t2: {}, t3: {}, t4: {} } }, - tiles: [ - { id: 't1', queryId: 'p1', presentation: { variant: 'alt' } }, // has a presentation, gets switched - { id: 't2', queryId: 'p2' }, // unaffected by p1 mutations - { id: 't3', queryId: 'p3' }, // no presentation, gets switched (empty-presentation branch) - { id: 't4', queryId: 'p4' }, // unmapped — left untouched - ], - }], - } as StoredWorkspaceV5); - - it('removes only affected tiles, leaving every other tile untouched', () => { - const plan = planSavedQueryMutation(multiTile(), { type: 'delete-query', queryId: 'p1' }, { type: 'remove-affected-tiles' }); - expect(plan.ok).toBe(true); - const dashboard = plan.candidate!.dashboards[0]; - // Only the tile that owned `p1` goes; every other tile is untouched, which - // is now structural rather than incidental — no other tile could reference it. - expect(dashboard.tiles.map((t) => t.id)).toEqual(['t2', 't3', 't4']); - }); - - it('switches only the mapped tiles and skips unmapped ones', () => { - // Replacing p1 and p3 in one candidate: t1 already has a presentation; t3 - // has none — switching it exercises the no-existing-presentation branch. - 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', t3: 'other' } }); - expect(plan.ok).toBe(true); - const tiles = plan.candidate!.dashboards[0].tiles; - expect(tiles.find((t) => t.id === 't1')!.presentation).toEqual({ variant: 'other' }); - // t3 does not reference the replaced query, so the repair skips it — the - // repair is scoped to the AFFECTED query, not to whatever the map names. - expect(tiles.find((t) => t.id === 't3')!.presentation).toBeUndefined(); - }); - - it('leaves an affected tile untouched when the repair map does not name it', () => { - const dropsAlt = { type: 'replace-query' as const, queryId: 'p1', query: panelQuery('p1', 'SELECT a,b', { variants: { other: {} } }) }; - // t1 owns p1 and IS affected, but the map names only t3 — so t1 keeps its - // now-missing variant and the candidate is refused. - const plan = planSavedQueryMutation(multiTile(), dropsAlt, { type: 'switch-variant', tileVariants: { t3: 'other' } }); - expect(plan.ok).toBe(false); - expect(codes(plan.diagnostics)).toContain('dashboard-variant-missing'); - }); - - // #427: "query remaps cannot create multiple owners." A remap is only valid - // when the target ends up owned by exactly one member. - it('remaps a tile onto a zero-owner Library query, and REFUSES to make it shared', () => { - const workspace = (): StoredWorkspaceV5 => ({ - storageVersion: 5, id: 'ws', key: 'ws', name: 'WS', - queries: [panelQuery('p1', 'SELECT a,b'), panelQuery('p2', 'SELECT a,b'), panelQuery('spare', 'SELECT a,b')], - dashboards: [{ - documentVersion: 2, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {}, t2: {} } }, - tiles: [{ id: 't1', queryId: 'p1' }, { id: 't2', queryId: 'p2' }], - }], - } as StoredWorkspaceV5); - - // `spare` has no owner, so the remapped tile becomes its single owner. - const onto = planSavedQueryMutation( - workspace(), { type: 'delete-query', queryId: 'p1' }, { type: 'remap-query', to: 'spare' }, - ); - expect(onto.ok).toBe(true); - expect(onto.candidate!.dashboards[0].tiles.map((t) => t.queryId)).toEqual(['spare', 'p2']); - - // Remapping onto a query another tile already owns would make it shared. - const shared = planSavedQueryMutation( - workspace(), { type: 'delete-query', queryId: 'p1' }, { type: 'remap-query', to: 'p2' }, - ); - expect(shared.ok).toBe(false); - expect(shared.candidate).toBeNull(); - expect(find(shared.diagnostics, 'dashboard-query-multiple-owners').path) - .toEqual(['dashboards', 0, 'tiles', 1, 'queryId']); - }); - - it('tolerates malformed tiles while applying a repair', () => { - const malformed = { - storageVersion: 5, id: 'ws', key: 'ws', name: 'WS', queries: [panelQuery('p1', 'SELECT a,b')], - dashboards: [{ - documentVersion: 2, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'report', items: {} }, - tiles: ['bad', { id: 't1', queryId: 'p1' }], - }], - } as unknown as StoredWorkspaceV5; - const plan = planSavedQueryMutation(malformed, { type: 'delete-query', queryId: 'p1' }, { type: 'remove-affected-tiles' }); - // The repair helper ran over the malformed entry without throwing. - expect(() => planSavedQueryMutation( - malformed, { type: 'delete-query', queryId: 'p1' }, { type: 'remove-affected-tiles' }, - )).not.toThrow(); - expect(plan.candidate === null || Array.isArray(plan.candidate.dashboards[0].tiles)).toBe(true); - }); -}); - -describe('planSavedQueryMutation — grafana-grid@1 engine awareness (#291)', () => { - it('normalizes through the ACTIVE grid plugin and regenerates the flow@1 fallback on a tile-removing repair', () => { - const workspace: StoredWorkspaceV5 = { - storageVersion: 5, id: 'ws', key: 'ws', name: 'WS', - queries: [panelQuery('p1', 'SELECT a,b WHERE c={country:String}')], - dashboards: [{ - documentVersion: 2, id: 'dash', title: 'D', revision: 1, - layout: { type: 'grafana-grid', version: 1, items: { t1: { span: 8 } } }, - tiles: [{ id: 't1', queryId: 'p1' }], - }], - } as StoredWorkspaceV5; - const plan = planSavedQueryMutation(workspace, { type: 'delete-query', queryId: 'p1' }, { type: 'remove-affected-tiles' }); - expect(plan.ok).toBe(true); - const dashboard = plan.candidate!.dashboards[0]; - expect(dashboard.layout.type).toBe('grafana-grid'); - expect(dashboard.tiles).toEqual([]); - expect(dashboard.layout.items).toEqual({}); // orphan grid placement pruned - expect((dashboard.layout as { fallback?: unknown }).fallback).toEqual({ - type: 'flow', version: 1, preset: 'columns-2', items: {}, - }); - }); -}); - -// #424: the planner treats EVERY Dashboard as part of the one atomic candidate. -describe('planSavedQueryMutation — the whole Dashboard collection (#424)', () => { - const tile = (id: string, queryId: string) => ({ id, queryId }); - const dash = (id: string, over: Record = {}) => ({ - documentVersion: 2, id, title: id, revision: 1, - layout: { type: 'flow', version: 1, preset: 'report', items: {} }, - tiles: [], ...over, - }); - /** Two Dashboards SHARING `p1` — the pre-#427 shape. Invalid under the - * ownership invariant, and kept precisely to assert that it is rejected. */ - const shared = (): StoredWorkspaceV5 => ({ - storageVersion: 5, id: 'ws', key: 'ws', name: 'WS', - queries: [ - panelQuery('p1', 'SELECT a,b WHERE c={country:String}'), - panelQuery('p2', 'SELECT a,b WHERE c={country:String}'), - ], - dashboards: [ - dash('exec', { - layout: { type: 'flow', version: 1, preset: 'report', items: { 'exec-p1': {} } }, - tiles: [tile('exec-p1', 'p1')], - }), - dash('sales', { - layout: { type: 'flow', version: 1, preset: 'columns-2', items: { 'sales-p1': {} } }, - tiles: [tile('sales-p1', 'p1'), tile('sales-p2', 'p2')], - }), - ], - } as StoredWorkspaceV5); - - /** The same two Dashboards, each member owning its own dedicated copy — a - * VALID V5 collection, and what every positive path below runs on. */ - const dedicated = (): StoredWorkspaceV5 => ({ - storageVersion: 5, id: 'ws', key: 'ws', name: 'WS', - queries: [ - panelQuery('p1', 'SELECT a,b WHERE c={country:String}'), - panelQuery('p1-sales', 'SELECT a,b WHERE c={country:String}'), - panelQuery('p2', 'SELECT a,b WHERE c={country:String}'), - panelQuery('spare', 'SELECT a,b WHERE c={country:String}'), - ], - dashboards: [ - dash('exec', { - layout: { type: 'flow', version: 1, preset: 'report', items: { 'exec-p1': {} } }, - tiles: [tile('exec-p1', 'p1')], - }), - dash('sales', { - layout: { type: 'flow', version: 1, preset: 'columns-2', items: { 'sales-p1': {} } }, - tiles: [tile('sales-p1', 'p1-sales'), tile('sales-p2', 'p2')], - }), - ], - } as StoredWorkspaceV5); - - // #427: "deleting a Library query requires no Dashboard repair". The workspace - // HAS Dashboards here — the point is that a zero-owner query is not part of any - // of them, so no repair is needed and none of them changes. - it('deletes a zero-owner Library query with no repair and no Dashboard change', () => { - const before = dedicated(); - const plan = planSavedQueryMutation(dedicated(), { type: 'delete-query', queryId: 'spare' }); - expect(plan.ok).toBe(true); - expect(plan.repairs).toEqual([]); - expect(plan.diagnostics).toEqual([]); - expect(plan.candidate!.queries.map((q) => q.id)).not.toContain('spare'); - expect(plan.candidate!.dashboards).toEqual(before.dashboards); - }); - - it('rejects the pre-#427 shared shape outright, at every owner after the first', () => { - const plan = planSavedQueryMutation(shared(), { type: 'delete-query', queryId: 'unused' }); - expect(plan.ok).toBe(false); - expect(find(plan.diagnostics, 'dashboard-query-multiple-owners').path) - .toEqual(['dashboards', 1, 'tiles', 0, 'queryId']); - }); - - it('reports every affected Dashboard when a shared query is deleted', () => { - const plan = planSavedQueryMutation(shared(), { type: 'delete-query', queryId: 'p1' }); - expect(plan.ok).toBe(false); - expect(plan.candidate).toBeNull(); - const missing = plan.diagnostics.filter((d) => d.code === 'dashboard-tile-query-missing'); - // Both Dashboards are diagnosed, each at its own indexed path — a break in - // a Dashboard the current UI never renders can't pass silently. - expect(missing.map((d) => d.path)).toEqual([ - ['dashboards', 0, 'tiles', 0, 'queryId'], - ['dashboards', 1, 'tiles', 0, 'queryId'], - ]); - }); - - it('rejects a repair that fixes one Dashboard while another stays invalid', () => { - // Both Dashboards select the variant `alt`; the replacement drops it. The - // repair names only the FIRST Dashboard's tile, so that one is genuinely - // fixed while the second keeps a selection the query no longer declares — - // a partially repaired candidate, which must not commit. - // Each Dashboard owns its own copy of the query, and BOTH copies are - // replaced by the same mutation id in turn — the partial-repair shape #424 - // cares about survives ownership: it is about two Dashboards being in one - // candidate, not about them sharing a query. - const withVariants = (): StoredWorkspaceV5 => { - const workspace = dedicated(); - const alt = (query: SavedQueryV2): SavedQueryV2 => ({ - ...query, - spec: { ...query.spec, dashboard: { variants: { alt: {}, other: {} } } }, - } as SavedQueryV2); - workspace.queries[0] = alt(workspace.queries[0]); - workspace.queries[1] = alt(workspace.queries[1]); - workspace.dashboards[0].tiles[0].presentation = { variant: 'alt' }; - workspace.dashboards[1].tiles[0].presentation = { variant: 'alt' }; - return workspace; - }; - /** Drop `alt` from BOTH owned copies in one candidate — one through the - * mutation, one because the fixture's second copy declares only `other`. - * Both Dashboards then hold a selection their own query no longer declares. */ - const both = (): StoredWorkspaceV5 => { - const workspace = withVariants(); - workspace.queries[1] = { - ...workspace.queries[1], - spec: { ...workspace.queries[1].spec, dashboard: { variants: { other: {} } } }, - } as SavedQueryV2; - return workspace; - }; - const dropsAlt = { - type: 'replace-query' as const, queryId: 'p1', - query: { - ...withVariants().queries[0], - spec: { - ...withVariants().queries[0].spec, - dashboard: { variants: { other: {} } }, - }, - } as SavedQueryV2, - }; - // Without a repair BOTH Dashboards are diagnosed. - const unrepaired = planSavedQueryMutation(both(), dropsAlt); - expect(unrepaired.ok).toBe(false); - expect(unrepaired.diagnostics.filter((d) => d.code === 'dashboard-variant-missing').map((d) => d.path[1])) - .toEqual([0, 1]); - // Repairing only the first leaves the second broken — still no candidate, - // and the surviving diagnostic points at the Dashboard the UI never shows. - const partial = planSavedQueryMutation( - both(), dropsAlt, { type: 'switch-variant', tileVariants: { 'exec-p1': 'other' } }, - ); - expect(partial.ok).toBe(false); - expect(partial.candidate).toBeNull(); - expect(partial.diagnostics.filter((d) => d.code === 'dashboard-variant-missing').map((d) => d.path[1])) - .toEqual([1]); - // Repairing BOTH commits one atomic candidate. - // A repair applies to EVERY Dashboard, so naming both tiles fixes both — but - // `switch-variant` only touches tiles referencing the AFFECTED query, so the - // second Dashboard's own copy is repaired by replacing it in its own plan. - const full = planSavedQueryMutation( - both(), dropsAlt, - { type: 'switch-variant', tileVariants: { 'exec-p1': 'other', 'sales-p1': 'other' } }, - ); - expect(full.ok).toBe(false); - expect(full.diagnostics.filter((d) => d.code === 'dashboard-variant-missing').map((d) => d.path[1])) - .toEqual([1]); - // Repairing the second Dashboard means mutating the query IT owns. - const second = planSavedQueryMutation( - both(), - { type: 'replace-query', queryId: 'p1-sales', query: { - ...both().queries[1], spec: { ...both().queries[1].spec, dashboard: { variants: { other: {} } } }, - } as SavedQueryV2 }, - { type: 'switch-variant', tileVariants: { 'sales-p1': 'other' } }, - ); - expect(second.ok).toBe(true); - }); - - it('applies a remap across every Dashboard, and rejects one that shares a query', () => { - // A remap is workspace-global, so it reaches a Dashboard the UI never shows. - // With ownership, the target must end up owned by exactly ONE member. - const onto = planSavedQueryMutation( - dedicated(), { type: 'delete-query', queryId: 'p1' }, { type: 'remap-query', to: 'spare' }, - ); - expect(onto.ok).toBe(true); - const [exec, sales] = onto.candidate!.dashboards; - expect(exec.tiles.map((t) => t.queryId)).toEqual(['spare']); - // The Dashboard the remap did not concern is untouched. - expect(sales.tiles.map((t) => t.queryId)).toEqual(['p1-sales', 'p2']); - - // Remapping onto a query another Dashboard's member already owns is refused. - const crossDashboard = planSavedQueryMutation( - dedicated(), { type: 'delete-query', queryId: 'p1' }, { type: 'remap-query', to: 'p2' }, - ); - expect(crossDashboard.ok).toBe(false); - expect(find(crossDashboard.diagnostics, 'dashboard-query-multiple-owners').path) - .toEqual(['dashboards', 1, 'tiles', 1, 'queryId']); - }); - - it('leaves an untouched Dashboard canonically identical, with its revision', () => { - const workspace = dedicated(); - // A grid Dashboard whose flow fallback is deliberately STALE: normalization - // + fallback regeneration would visibly rewrite it, so if this entry came - // out changed, the planner had normalized a Dashboard it never repaired. - workspace.dashboards.push(dash('ops', { - revision: 12, - layout: { - type: 'grafana-grid', version: 1, items: { 'ops-p2': { span: 8 } }, - fallback: { type: 'flow', version: 1, preset: 'report', items: {} }, - }, - tiles: [tile('ops-p2', 'ops-own')], - }) as never); - workspace.queries.push(panelQuery('ops-own', 'SELECT a,b WHERE c={country:String}')); - const untouched = JSON.parse(JSON.stringify(workspace.dashboards[2])); - - // The remap rewrites the one `p1` reference; the third Dashboard never - // referenced it, so the repair is a no-op for it. - const plan = planSavedQueryMutation( - workspace, { type: 'delete-query', queryId: 'p1' }, { type: 'remap-query', to: 'spare' }, - ); - expect(plan.ok).toBe(true); - expect(plan.candidate!.dashboards[2]).toEqual(untouched); - expect(plan.candidate!.dashboards[2].revision).toBe(12); - // Specifically: the stale flow fallback was NOT regenerated for it. - expect((plan.candidate!.dashboards[2].layout as { fallback: { items: unknown } }).fallback.items) - .toEqual({}); - // …while the Dashboard the repair DID touch is rewritten as before. - expect(plan.candidate!.dashboards[0].tiles).toEqual([tile('exec-p1', 'spare')]); - expect(plan.candidate!.dashboards[1].tiles.map((t) => t.queryId)).toEqual(['p1-sales', 'p2']); - // The planner never mutates its input, revisions included. - expect(workspace.dashboards[0].tiles).toEqual([tile('exec-p1', 'p1')]); - }); - - it('carries every Dashboard through byte-identically when no repair is given', () => { - const workspace = dedicated(); - const before = JSON.parse(JSON.stringify(workspace.dashboards)); - // A mutation that breaks nothing needs no repair — and must therefore - // change no Dashboard at all. - const plan = planSavedQueryMutation(workspace, { type: 'delete-query', queryId: 'unused' }); - expect(plan.ok).toBe(true); - expect(plan.candidate!.dashboards).toEqual(before); - expect(plan.candidate!.storageVersion).toBe(5); - }); - - it('rejects a candidate whose duplicate Dashboard ids make the workspace invalid', () => { - const workspace = shared(); - workspace.dashboards.push(dash('exec') as never); - const plan = planSavedQueryMutation(workspace, { type: 'delete-query', queryId: 'unused' }); - expect(plan.ok).toBe(false); - expect(codes(plan.diagnostics)).toContain('workspace-duplicate-dashboard-id'); - }); -}); - -describe('planSavedQueryMutation — no dashboard, and suggestRepairs', () => { - it('always accepts a mutation when the workspace has no dashboard', () => { - const workspace = { ...baseWorkspace(), dashboards: [] } as StoredWorkspaceV5; - const plan = planSavedQueryMutation(workspace, { type: 'delete-query', queryId: 'p1' }); - expect(plan.ok).toBe(true); - expect(plan.candidate!.dashboards).toEqual([]); - }); - - it('maps a tiles-scoped diagnostic to every tile repair kind', () => { - const repairs = suggestRepairs([ - { path: [], severity: 'error', code: 'x', message: '' }, - { path: ['dashboards', 0, 'tiles', 0], severity: 'error', code: 'z', message: '' }, - ]); - expect(repairs).toEqual(['remove-affected-tiles', 'switch-variant', 'remap-query']); - }); -}); diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index 0eef74b3..b26084b5 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -358,6 +358,53 @@ describe('saved queries', () => { // #343: the linked save refreshed the in-sync baseline token to the new commit. expect(tab.lastCommittedQueryToken).toBe(queryToken(s.savedQueries[0])); }); + it('rejects an edit that invalidates a Panel on a non-current Dashboard and changes no state', async () => { + const s = savedTestState(); + const owned = savedQuery({ + id: 'owned', sql: 'SELECT 1', name: 'Panel query', dashboard: { role: 'panel' }, + }); + const current: DashboardDocumentV2 = { + documentVersion: 2, id: 'current', title: 'Current', revision: 1, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, + tiles: [], + }; + const other: DashboardDocumentV2 = { + documentVersion: 2, id: 'other', title: 'Other', revision: 2, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, + tiles: [{ id: 't1', queryId: 'owned' }], + }; + s.savedQueries = [owned]; + s.dashboard = current; + const tab = s.tabs.value[0]; + tab.savedId = 'owned'; + tab.sqlDraft = owned.sql; + setTabSpecDraft(tab, owned.spec, { dirty: true }); + const latest: StoredWorkspaceV5 = { + storageVersion: 5, id: 'w1', key: 'workspace', name: s.libraryName.value, + queries: s.savedQueries, dashboards: [current, other], + }; + const workspaceBefore = JSON.stringify(latest); + const stateBefore = JSON.stringify({ queries: s.savedQueries, dashboard: s.dashboard }); + const tabBefore = JSON.stringify(tab); + const mutate = fakeMutateWorkspace(s, { loadById: async () => latest }); + const setupRole = { ...owned.spec, dashboard: { role: 'setup' as const } }; + + const result = await commitSavedQuery(s, tab, setupRole, mutate); + + expect(result).toMatchObject({ + ok: false, + entry: null, + diagnostics: expect.arrayContaining([ + expect.objectContaining({ code: 'dashboard-setup-reference' }), + ]), + }); + expect(mutate.commit).toHaveBeenCalledTimes(1); + const rejectedCandidate = mutate.commit.mock.calls[0][0] as StoredWorkspaceV5; + expect(rejectedCandidate.dashboards).toEqual([current, other]); + expect(JSON.stringify(latest)).toBe(workspaceBefore); + expect(JSON.stringify({ queries: s.savedQueries, dashboard: s.dashboard })).toBe(stateBefore); + expect(JSON.stringify(tab)).toBe(tabBefore); + }); it('materializes timeRanges on create and only on SQL-dirty linked saves while the property is absent', async () => { const s = savedTestState(); const mutate = fakeMutateWorkspace(s); @@ -611,9 +658,8 @@ describe('saved queries', () => { // #299: the Workbench star also drives Dashboard tile membership, atomically // with the favorite flip — only panel-role queries become tiles (mirrors // legacy-migration.ts's buildLegacyMigrationCandidate), star OFF removes - // every matching tile and scrubs those tile ids from filter targets (mirrors - // saved-query-mutation.ts's removeAffectedTiles), and a null `state.dashboard` - // means favorite-flip-only (no Dashboard to touch). + // every matching tile and scrubs those tile ids from filter targets, and a + // null `state.dashboard` means favorite-flip-only (no Dashboard to touch). // #427 SEVERED the favourite<->membership coupling #299 introduced. A star is a // Library/workbench preference now; Dashboard membership is an explicit // reference to a query the member OWNS. These tests pin the inverse contract: @@ -696,7 +742,7 @@ describe('saved queries', () => { }); - it('preserves every Dashboard through a rename and through a delete', async () => { + it('preserves every Dashboard through a rename', async () => { const s = savedTestState(); s.savedQueries = [savedQuery({ id: 'p1', sql: 'SELECT 1', dashboard: { role: 'panel' } })]; const committed: StoredWorkspaceV5 = { @@ -1017,18 +1063,78 @@ describe('saved queries', () => { await commitSavedQuery(s, tab, tab.specParsed, mutate); expect(queryView(s.savedQueries[0])).toBe('json'); }); - it('deleteSaved removes + clears tab pointers', async () => { + it('deleteSaved removes an ordinary zero-owner Library query and reconciles its linked tab', async () => { const s = savedTestState(); - s.savedQueries = [savedQuery({ id: 's1', sql: 'x', name: 'n' })]; + const queries = [ + savedQuery({ id: 's1', sql: 'x', name: 'Library query' }), + savedQuery({ id: 'owned', sql: 'SELECT 1', name: 'Panel query', dashboard: { role: 'panel' } }), + ]; + const current: DashboardDocumentV2 = { + documentVersion: 2, id: 'current', title: 'Current', revision: 1, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, + tiles: [], + }; + const other: DashboardDocumentV2 = { + documentVersion: 2, id: 'other', title: 'Other', revision: 2, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, + tiles: [{ id: 't1', queryId: 'owned' }], + }; + s.savedQueries = queries; + s.dashboard = current; s.tabs.value[0].savedId = 's1'; - const mutate = fakeMutateWorkspace(s); + const latest: StoredWorkspaceV5 = { + storageVersion: 5, id: 'w1', key: 'workspace', name: s.libraryName.value, + queries, dashboards: [current, other], + }; + const mutate = fakeMutateWorkspace(s, { loadById: async () => latest }); const result = await deleteSaved(s, 's1', mutate); expect(result).toEqual({ ok: true }); - expect(s.savedQueries).toHaveLength(0); + expect(s.savedQueries.map((query) => query.id)).toEqual(['owned']); + const candidate = mutate.commit.mock.calls[0][0] as StoredWorkspaceV5; + expect(candidate.dashboards).toEqual([current, other]); + expect(s.dashboard).toEqual(current); expect(s.tabs.value[0].savedId).toBeNull(); expect(s.tabs.value[0].editorMode).toBe('sql'); }); + it('deleteSaved rejects a query owned only on a non-current Dashboard and changes no state', async () => { + const s = savedTestState(); + const queries = [ + savedQuery({ id: 'lib', sql: 'SELECT 0', name: 'Library query' }), + savedQuery({ id: 'owned', sql: 'SELECT 1', name: 'Panel query', dashboard: { role: 'panel' } }), + ]; + const current: DashboardDocumentV2 = { + documentVersion: 2, id: 'current', title: 'Current', revision: 1, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, + tiles: [], + }; + const other: DashboardDocumentV2 = { + documentVersion: 2, id: 'other', title: 'Other', revision: 2, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, + tiles: [{ id: 't1', queryId: 'owned' }], + }; + s.savedQueries = queries; + s.dashboard = current; + const latest: StoredWorkspaceV5 = { + storageVersion: 5, id: 'w1', key: 'workspace', name: s.libraryName.value, + queries, dashboards: [current, other], + }; + const workspaceBefore = JSON.stringify(latest); + const stateBefore = JSON.stringify({ queries: s.savedQueries, dashboard: s.dashboard }); + const mutate = fakeMutateWorkspace(s, { loadById: async () => latest }); + const result = await deleteSaved(s, 'owned', mutate); + expect(result).toMatchObject({ + ok: false, + diagnostics: expect.arrayContaining([ + expect.objectContaining({ code: 'dashboard-tile-query-missing' }), + ]), + }); + const rejectedCandidate = mutate.commit.mock.calls[0][0] as StoredWorkspaceV5; + expect(rejectedCandidate.dashboards).toEqual([current, other]); + expect(JSON.stringify(latest)).toBe(workspaceBefore); + expect(JSON.stringify({ queries: s.savedQueries, dashboard: s.dashboard })).toBe(stateBefore); + }); + it('deleteSaved maps the defensive aborted mutation arm to empty diagnostics', async () => { const s = savedTestState(); const mutate = vi.fn(async () => ({ ok: false as const, aborted: true as const }));