diff --git a/CHANGELOG.md b/CHANGELOG.md index 21afee7..939ddb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -815,7 +815,7 @@ auto-generated per-PR notes; this file is the curated, human-readable history. 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 + override → final validation), shared by Dashboard viewing, 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 @@ -826,23 +826,15 @@ auto-generated per-PR notes; this file is the curated, human-readable history. 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 + - `dashboard/application/dashboard-commands.ts` + + `dashboard-query-resolver.ts` — fallible, atomic typed commands for every + membership/placement/layout change (`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. + to an isolated candidate → return diagnostics or the candidate without + mutating the input. The unused session wrapper was removed before gaining + a production caller; future non-DOM authoring must be built against the + shared `mutateWorkspace` pipeline. - `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 @@ -850,7 +842,8 @@ auto-generated per-PR notes; this file is the curated, human-readable history. 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. + Live Workbench wiring consumes these commands through the shared workspace + mutation path. - **Dashboard read-only viewer runtime + normative `flow@1` layout** (#286, phase 4 of #280). New pure/application modules, gated at the per-file diff --git a/src/dashboard/application/dashboard-authoring-session.ts b/src/dashboard/application/dashboard-authoring-session.ts deleted file mode 100644 index e1c9278..0000000 --- a/src/dashboard/application/dashboard-authoring-session.ts +++ /dev/null @@ -1,247 +0,0 @@ -// 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 { buildDashboardExportBundle } from '../model/dashboard-export.js'; -import { defaultLayoutRegistry } from '../layouts/layout-registry.js'; -import { applyCommand } from './dashboard-commands.js'; -import type { DashboardCommand, DashboardCommandResult } from './dashboard-commands.js'; -import { removeTileMembership } from './tile-membership.js'; -import { createEmptyDashboard } from './empty-dashboard.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; -} - -/** 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, candidateQueries: SavedQueryV2[] = queries, - ): WorkspaceDiagnostic[] { - const candidate: StoredWorkspaceV1 = { - storageVersion: 1, id: workspaceId, name: workspaceName, queries: candidateQueries, 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 through the full registry (#291: the - // grafana-grid@1 engine, not just flow@1) — for change-layout, the NEW - // layout; for every other command, the document's CURRENTLY active - // layout, so `update-placement`/`add-query` seeding and validation route - // through whichever engine (grid or flow) is actually active. - const layoutForPlugin = command.type === 'change-layout' ? command.layout : current.document.layout; - const resolved = await defaultLayoutRegistry.resolve(layoutForPlugin); - if (!resolved.ok) return returnFail(resolved.diagnostics, baseVersion); - - const resolver = createQueryResolver(queries); - const applied = applyCommand(current.document, command, { resolver, genTileId: genId, plugin: resolved.plugin }); - if (!applied.ok) return returnFail(applied.diagnostics, baseVersion); - - const membership = command.type === 'remove-tile' - ? removeTileMembership(current.document, queries, command.tileId) - : null; - const candidateQueries = membership?.queries ?? queries; - const normalized = resolved.plugin.normalize(membership?.dashboard ?? applied.dashboard); - const diagnostics = validateCandidate(normalized, candidateQueries); - if (diagnostics.length) return returnFail(diagnostics, baseVersion); - - const draftVersion = baseVersion + 1; - queries = candidateQueries; - 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 { - // Export never mutates workspace identity or revision — nothing here - // touches `committedRevision` or the draft; the builder deep-clones. - return buildDashboardExportBundle(stateSignal.value.document, queries, nowISO()); - } - - 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 index 829a6fe..d0c4299 100644 --- a/src/dashboard/application/dashboard-commands.ts +++ b/src/dashboard/application/dashboard-commands.ts @@ -3,16 +3,16 @@ // 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. +// the input draft. Callers remain responsible for normalizing through the +// layout plugin, running whole-workspace + presentation validation, and only +// adopting a candidate when it 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. +// are left to the caller's validation stage. import { cloneJson } from '../../core/saved-query.js'; import { diagnostic } from '../model/workspace-diagnostics.js'; @@ -42,11 +42,6 @@ export type DashboardCommand = | { 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. */ @@ -169,8 +164,8 @@ function applyCommandToClone( 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. + // The removed tile's placement becomes an orphan; the caller's layout + // normalization step prunes it. return { ok: true, dashboard, value: undefined }; } @@ -222,7 +217,7 @@ function applyCommandToClone( const index = tileIndex(tiles, command.tileId); if (index < 0) return failWith(missingTile(command.tileId)); // Validated through the ACTIVE engine's own plugin (`ctx.plugin`, - // resolved by the session from the current document — grid: span + // resolved by the caller from the current document — grid: span // 1..12; flow: span 1..3) rather than a hardcoded flow plugin (#291). const placementDiags = ctx.plugin.validatePlacement(command.placement, ['layout', 'items', command.tileId]); @@ -231,9 +226,9 @@ function applyCommandToClone( return { ok: true, dashboard, value: undefined }; } - // change-layout: the load/version/fallback failures are the session's + // change-layout: load/version/fallback failures belong to the caller's // registry-resolution step; a normalization that produces an invalid - // document is the session's validation step. Here we install the new + // document belongs to the caller's validation step. Here we install the new // layout AND, when it is an engine switch (#291 owner decision 3), // convert placements between engines: // - flow -> grafana-grid: seed every tile's grid placement from its diff --git a/src/dashboard/layouts/flow-layout.ts b/src/dashboard/layouts/flow-layout.ts index b59544f..96be966 100644 --- a/src/dashboard/layouts/flow-layout.ts +++ b/src/dashboard/layouts/flow-layout.ts @@ -32,7 +32,7 @@ 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. */ +/** The narrow layout-plugin contract the authoring commands and callers use. */ export interface DashboardLayoutPlugin { readonly type: string; readonly version: number; diff --git a/src/workspace/workspace-repository.ts b/src/workspace/workspace-repository.ts index 4121941..b3863da 100644 --- a/src/workspace/workspace-repository.ts +++ b/src/workspace/workspace-repository.ts @@ -13,7 +13,7 @@ // 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). +// the caller that built the candidate owns that). import { decodeStoredWorkspaceJson, encodeStoredWorkspaceJson, diff --git a/tests/unit/dashboard-authoring-session.test.ts b/tests/unit/dashboard-authoring-session.test.ts deleted file mode 100644 index a138a5a..0000000 --- a/tests/unit/dashboard-authoring-session.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - createDashboardAuthoringSession, -} from '../../src/dashboard/application/dashboard-authoring-session.js'; -import { createWorkspaceRepository } from '../../src/workspace/workspace-repository.js'; -import { jsonSchemaValidationService } from '../../src/core/library-codec.js'; -import { querySpecSchemaService } from '../../src/core/spec-schema.js'; -import { PORTABLE_LIMITS } from '../../src/dashboard/model/portable-limits.js'; -import type { StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; - -const panelQuery = (id: string) => ({ - id, sql: 'SELECT a,b', specVersion: 1 as const, - spec: { name: id, panel: { cfg: { type: 'bar', x: 0, y: [1] } } }, -}); -const filterQuery = (id: string) => ({ - id, sql: "SELECT ['a','b'] AS c", specVersion: 1 as const, spec: { name: id, dashboard: { role: 'filter' } }, -}); - -const emptyDash = () => ({ - documentVersion: 1 as const, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [], -}); - -const workspaceFixture = (over: Partial = {}): StoredWorkspaceV1 => ({ - storageVersion: 1, id: 'ws', name: 'WS', - queries: [panelQuery('q1'), panelQuery('q2')], dashboard: emptyDash(), ...over, -} as StoredWorkspaceV1); - -const makeStore = () => { - let text: string | null = null; - let failWrite = false; - return { - store: { - read: async () => text, - write: async (t: string) => { if (failWrite) throw new Error('disk full'); text = t; }, - clear: async () => { text = null; }, - }, - setFailWrite: (v: boolean) => { failWrite = v; }, - }; -}; - -interface SessionOpts { - workspace?: StoredWorkspaceV1; - nowISO?: () => string; - withServices?: boolean; -} -const makeSession = (opts: SessionOpts = {}) => { - const backing = makeStore(); - const repository = createWorkspaceRepository({ store: backing.store }); - let n = 0; - const session = createDashboardAuthoringSession({ - workspace: opts.workspace ?? workspaceFixture(), - repository, genId: () => `g${++n}`, nowISO: opts.nowISO, - ...(opts.withServices ? { validationService: jsonSchemaValidationService, schemaService: querySpecSchemaService } : {}), - }); - return { session, ...backing }; -}; - -describe('DashboardAuthoringSession — atomic commands', () => { - it('replaces the draft and increments draftVersion once on success', async () => { - const { session } = makeSession(); - const result = await session.execute({ type: 'add-query', queryId: 'q1' }); - expect(result.ok).toBe(true); - if (result.ok) expect((result.value as { tileId: string }).tileId).toBe('g1'); - expect(result.draftVersion).toBe(1); - expect(session.state.value.draftVersion).toBe(1); - expect(session.state.value.dirty).toBe(true); - expect(session.state.value.document.tiles).toHaveLength(1); - expect(session.state.value.diagnostics).toEqual([]); - }); - - it('leaves the draft byte-for-byte unchanged on a failed command', async () => { - const { session } = makeSession(); - const before = JSON.stringify(session.state.value.document); - const result = await session.execute({ type: 'add-query', queryId: 'gone' }); - expect(result.ok).toBe(false); - expect(JSON.stringify(session.state.value.document)).toBe(before); - expect(session.state.value.draftVersion).toBe(0); - expect(session.state.value.diagnostics.length).toBeGreaterThan(0); - if (!result.ok) expect(result.draftVersion).toBe(0); - }); - - it('rejects a stale expectedDraftVersion without mutating the draft', async () => { - const { session } = makeSession(); - await session.execute({ type: 'add-query', queryId: 'q1' }); - const stale = await session.execute({ type: 'add-query', queryId: 'q2' }, { expectedDraftVersion: 0 }); - expect(stale.ok).toBe(false); - if (!stale.ok) expect(stale.diagnostics[0].code).toBe('dashboard-command-stale'); - expect(session.state.value.draftVersion).toBe(1); - expect(session.state.value.document.tiles).toHaveLength(1); - }); - - it('does not clamp an out-of-range move index and fails a duplicate default instance', async () => { - const { session } = makeSession(); - await session.execute({ type: 'add-query', queryId: 'q1' }); - await session.execute({ type: 'add-query', queryId: 'q2' }); - const badMove = await session.execute({ type: 'move-tile', tileId: 'g1', toIndex: 9 }); - expect(badMove.ok).toBe(false); - if (!badMove.ok) expect(badMove.diagnostics[0].code).toBe('dashboard-command-index-out-of-range'); - - const dup = await session.execute({ type: 'add-query', queryId: 'q1' }); - expect(dup.ok).toBe(false); - if (!dup.ok) expect(dup.diagnostics[0].code).toBe('dashboard-command-duplicate-instance'); - }); - - it('runs presentation resolution as part of validation (override deletes a required field)', async () => { - const { session } = makeSession({ withServices: true }); - const added = await session.execute({ type: 'add-query', queryId: 'q1' }); - expect(added.ok).toBe(true); - const before = JSON.stringify(session.state.value.document); - const bad = await session.execute({ - type: 'update-tile', tileId: 'g1', patch: { presentation: { override: { cfg: { x: null } } } }, - }); - expect(bad.ok).toBe(false); - if (!bad.ok) expect(bad.diagnostics.some((d) => d.code === 'schema-required')).toBe(true); - expect(JSON.stringify(session.state.value.document)).toBe(before); - }); - - // These prove the atomic guarantee for the DELEGATED-validation failure - // class — failures raised by validateStoredWorkspaceDocument (structural / - // role / reference / limit), i.e. the stage between applyCommand and - // presentation resolution. - it('rejects add-query on an incompatible-role query without mutating the draft', async () => { - const { session } = makeSession({ workspace: workspaceFixture({ queries: [panelQuery('q1'), filterQuery('f1')] as never }) }); - const before = JSON.stringify(session.state.value.document); - const result = await session.execute({ type: 'add-query', queryId: 'f1' }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.diagnostics.some((d) => d.code === 'dashboard-tile-role-incompatible')).toBe(true); - expect(JSON.stringify(session.state.value.document)).toBe(before); - expect(session.state.value.draftVersion).toBe(0); - }); - - it('rejects exceeding the tile limit without mutating the draft', async () => { - const full = Array.from({ length: PORTABLE_LIMITS.maxTilesPerDashboard }, (_, i) => ({ id: `t${i}`, queryId: 'q1' })); - const { session } = makeSession({ workspace: workspaceFixture({ dashboard: { ...emptyDash(), tiles: full } as never }) }); - const before = JSON.stringify(session.state.value.document); - const result = await session.execute({ type: 'add-query-instance', queryId: 'q1' }); - expect(result.ok).toBe(false); - // The tile-count bound is enforced first by the schema's maxItems - // (schema-array-size); the semantic limit-tile-count re-check backs it up. - if (!result.ok) { - expect(result.diagnostics.some((d) => d.code === 'schema-array-size' || d.code === 'limit-tile-count')).toBe(true); - } - expect(JSON.stringify(session.state.value.document)).toBe(before); - expect(session.state.value.draftVersion).toBe(0); - }); - - it('rejects add-query-instance with an undeclared variant name without mutating the draft', async () => { - const { session } = makeSession(); - const before = JSON.stringify(session.state.value.document); - const result = await session.execute({ type: 'add-query-instance', queryId: 'q1', variant: 'nope' }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.diagnostics.some((d) => d.code === 'dashboard-variant-missing')).toBe(true); - expect(JSON.stringify(session.state.value.document)).toBe(before); - expect(session.state.value.draftVersion).toBe(0); - }); - - it('fails a change-layout the plugin cannot load, leaving the previous layout intact', async () => { - const { session } = makeSession(); - const result = await session.execute({ type: 'change-layout', layout: { type: 'grid', version: 9 } as never }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.diagnostics[0].code).toBe('dashboard-layout-load-failed'); - expect(session.state.value.document.layout.type).toBe('flow'); - }); -}); - -describe('DashboardAuthoringSession — revision semantics and commit', () => { - it('increments the persisted revision once per successful commit, batching several draft changes', async () => { - const { session } = makeSession(); - // The loaded dashboard is at revision 1, so the first new commit is 2. - await session.execute({ type: 'add-query', queryId: 'q1' }); - const c1 = await session.commit(); - expect(c1.ok && c1.dashboardRevision).toBe(2); - expect(session.state.value.dirty).toBe(false); - - await session.execute({ type: 'add-query', queryId: 'q2' }); - await session.execute({ type: 'move-tile', tileId: 'g1', toIndex: 1 }); - const c2 = await session.commit(); - expect(c2.ok && c2.dashboardRevision).toBe(3); - }); - - it('leaves the draft dirty and the revision unchanged when persistence fails, and supports retry', async () => { - const backing = makeStore(); - const repository = createWorkspaceRepository({ store: backing.store }); - let n = 0; - const session = createDashboardAuthoringSession({ workspace: workspaceFixture(), repository, genId: () => `g${++n}` }); - await session.execute({ type: 'add-query', queryId: 'q1' }); - backing.setFailWrite(true); - const failed = await session.commit(); - expect(failed.ok).toBe(false); - expect(session.state.value.dirty).toBe(true); - backing.setFailWrite(false); - const retry = await session.commit(); - expect(retry.ok && retry.dashboardRevision).toBe(2); // the failed attempt did not consume a revision - }); - - it('starts an empty grafana-grid Dashboard (revision 1) with a columns-2 flow fallback 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); - expect(session.state.value.document.layout).toEqual({ - type: 'grafana-grid', version: 1, items: {}, - fallback: { type: 'flow', version: 1, preset: 'columns-2', items: {} }, - }); - 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('raw remove-tile cleans targets and mirrors membership for final and remaining instances', async () => { - const query = { - ...panelQuery('q1'), sql: 'SELECT {x:String} AS a, 1 AS b', - spec: { ...panelQuery('q1').spec, favorite: true }, - }; - const workspace = workspaceFixture({ - queries: [query], - dashboard: { - ...emptyDash(), - tiles: [{ id: 't1', queryId: 'q1' }, { id: 't2', queryId: 'q1' }], - filters: [{ id: 'f1', parameter: 'x', targets: ['t1', 't2'] }], - }, - } as StoredWorkspaceV1); - const { session } = makeSession({ workspace }); - expect((await session.execute({ type: 'remove-tile', tileId: 't1' })).ok).toBe(true); - let committed = await session.commit(); - expect(committed.ok && committed.workspace.queries[0].spec.favorite).toBe(true); - expect(committed.ok && committed.workspace.dashboard!.filters[0].targets).toEqual(['t2']); - expect(committed.ok && committed.dashboardRevision).toBe(2); - - expect((await session.execute({ type: 'remove-tile', tileId: 't2' })).ok).toBe(true); - committed = await session.commit(); - expect(committed.ok && committed.workspace.queries[0].spec.favorite).toBe(false); - expect(committed.ok && committed.workspace.dashboard!.filters[0].targets).toEqual([]); - expect(committed.ok && committed.dashboardRevision).toBe(3); - }); - - it('a membership validation failure leaves both the Dashboard and favorite mirror unchanged', async () => { - const query = { - ...panelQuery('q1'), sql: 'SELECT {country:String} AS a, 1 AS b', - spec: { ...panelQuery('q1').spec, favorite: true }, - }; - const workspace = workspaceFixture({ - queries: [query, filterQuery('source')], - dashboard: { - ...emptyDash(), tiles: [{ id: 't1', queryId: 'q1' }], - filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'source', targets: ['t1'] }], - }, - } as StoredWorkspaceV1); - const { session } = makeSession({ workspace }); - const before = JSON.stringify(session.state.value.document); - const result = await session.execute({ type: 'remove-tile', tileId: 't1' }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.diagnostics.some((d) => d.code === 'filter-selection-no-consumers')).toBe(true); - expect(JSON.stringify(session.state.value.document)).toBe(before); - expect(session.state.value.draftVersion).toBe(0); - expect(session.createPortableBundle().queries.find((q) => q.id === 'q1')?.spec.favorite).toBe(true); - }); - - 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/workspace-semantics.test.ts b/tests/unit/workspace-semantics.test.ts index 7a62a33..50ce343 100644 --- a/tests/unit/workspace-semantics.test.ts +++ b/tests/unit/workspace-semantics.test.ts @@ -193,6 +193,8 @@ describe('validateDashboardSemantics', () => { 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 noVariants = dashboardDoc({ tiles: [tile('t1', 'p1', { presentation: { variant: 'nope' } })], layout: flowLayout({ t1: {} }) }); + expect(has(validateDashboardSemantics(noVariants, { queries: [panelQuery('p1')] }), '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.