diff --git a/.claude/skills/ship/SKILL.md b/.claude/skills/ship/SKILL.md index 7f965fec..9de71ec3 100644 --- a/.claude/skills/ship/SKILL.md +++ b/.claude/skills/ship/SKILL.md @@ -50,7 +50,10 @@ Follow `CLAUDE.md` throughout (hard rules 1–5 + the Working-discipline section - Report the **PR URL**. ## 7 β€” πŸ›‘ Merge gate β€” STOP -Do **not** merge. Summarise what shipped and the PR link, start serving the just-built app locally (for manual testing), and wait. If there is another serving process on the same machine, kill the process to retake the port. Merging to `main` is a human call. +Do **not** merge. Summarise what shipped and the PR link, run `npm run local` +to serve the just-built app for manual testing, and wait. If there is another +serving process on the same machine, kill the process to retake the port. +Merging to `main` is a human call. ## After β€” friction β†’ memory If anything needed retries or surprised you (test / env / scope), save a memory so the next `/ship` doesn't repeat it. This session does the saving, not a subagent it spawned. diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b0eb99..070d0bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] +### Added +- **Multi-workspace local persistence foundation** (#406). Stored workspaces + now use the V2 contract with separate immutable opaque `id`, immutable + human-readable URL `key`, and mutable display `name`. IndexedDB stores one + validated aggregate per workspace behind an ID key path and unique key + index; the repository can list summaries, load by ID/key, create, replace, + and delete individual records without affecting neighbors. Last-used key and + injected-clock `lastOpenedAt` metadata select the most recently opened valid + workspace for implicit startup, while explicit `?ws=` lookup never falls + back. Workspace import is additive with reminted local identity, Dashboard + links resolve stable keys, and active edits reload/commit by immutable ID. + ## [0.6.2] - 2026-07-23 ### Fixed diff --git a/README.md b/README.md index ad8b1df7..7a449a9c 100644 --- a/README.md +++ b/README.md @@ -436,22 +436,26 @@ remembered result view and chart config. Saving or editing a query opens a small form with both a name and a description field; the description shows under the row and is included in Markdown/SQL exports. -The queries plus the zero-or-one editable Dashboard form one **current workspace** -(a `StoredWorkspaceV1` aggregate). The workspace is persisted **atomically** in the -browser (IndexedDB) β€” a reload restores queries, Dashboard, layout, and workspace -name together; every saved-query edit commits the whole workspace and only -publishes once persistence succeeds. The header **File β–Ύ** menu carries a name, an -unsaved-changes dot (changes since the last export or import), and these -resource-oriented operations (#287): +The queries plus the zero-or-one editable Dashboard form a workspace +(`StoredWorkspaceV2`). A browser profile can keep multiple workspaces in +IndexedDB; each has an immutable opaque id, a stable human-readable URL key, +and a mutable display name. Each record is persisted **atomically** β€” a reload +restores its queries, Dashboard, layout, and name together; every saved-query +edit commits the whole active workspace and only publishes once persistence +succeeds. Implicit startup reopens the last successfully used workspace. + +The header **File β–Ύ** menu carries the active name, an unsaved-changes dot +(changes since the last export or import), and these resource-oriented +operations: The first four rows are one unlabeled primary-workspace-action group, in this order (#342): -- **New workspace…** β€” clears to an empty, default-named workspace (confirms first - when non-empty). Open editor tabs are unaffected. -- **Open workspace…** β€” atomically replace **both** the query collection and - the Dashboard from a file (confirms first). A multi-Dashboard file asks which - Dashboard to adopt (or none). +- **New workspace…** β€” creates and activates a new empty, default-named + workspace without deleting the previous one. Open editor tabs are unaffected. +- **Import workspace…** β€” creates and activates a new local workspace from a + portable bundle; imported identity is reminted and made unique. A + multi-Dashboard file asks which Dashboard to adopt (or none). - **Export workspace…** (`.json`) β€” write the one canonical **`altinity-sql-browser/portable-bundle`** interchange format: every saved query (catalog order) plus the zero-or-one Dashboard. Uses the deterministic diff --git a/build/schema-manifest.mjs b/build/schema-manifest.mjs index 95900a8a..f23dcb81 100644 --- a/build/schema-manifest.mjs +++ b/build/schema-manifest.mjs @@ -53,10 +53,10 @@ export const SCHEMA_MANIFEST = [ typeExport: 'DashboardDocumentV1', }, { - path: 'schemas/stored-workspace-v1.schema.json', - schemaExport: 'storedWorkspaceV1Schema', - validatorExport: 'validateStoredWorkspaceV1', - typeExport: 'StoredWorkspaceV1', + path: 'schemas/stored-workspace-v2.schema.json', + schemaExport: 'storedWorkspaceV2Schema', + validatorExport: 'validateStoredWorkspaceV2', + typeExport: 'StoredWorkspaceV2', }, { path: 'schemas/portable-bundle-v1.schema.json', diff --git a/docs/ADR-0001-reactivity.md b/docs/ADR-0001-reactivity.md index 7dd7f17c..0f653ff9 100644 --- a/docs/ADR-0001-reactivity.md +++ b/docs/ADR-0001-reactivity.md @@ -348,6 +348,10 @@ as the legacy-migration source). This is a state-flow change worth recording against this ADR because it re-shapes how `state.savedQueries` relates to reactivity: +`StoredWorkspaceV2` and the collection repository introduced by #406 preserve +this projection/commit model per workspace; they replace only the fixed-current +record and V1 identity contract. + - `state.savedQueries` is now a **projection** of the committed workspace, not a directly-mutated array. Boot loads the aggregate and projects it (queries, Dashboard, workspace id/name); every file operation commits and re-projects diff --git a/docs/ADR-0003-dashboard-viewing.md b/docs/ADR-0003-dashboard-viewing.md index 93ae78ae..205e7d17 100644 --- a/docs/ADR-0003-dashboard-viewing.md +++ b/docs/ADR-0003-dashboard-viewing.md @@ -35,7 +35,7 @@ product model, which this ADR records. | Entry | Workbench header `Dashboard β†’` control | Dashboard header **File β†’ "Open for viewing…"** | | Tab | new tab | new tab | | Open source | `current-workspace` (`?ws=&dash=`) | detached snapshot of the current dashboard | -| Storage | **shared** primary workspace store (`asb-workspace`) | its **own** detached store (`asb-dashboard-views`), fresh id | +| Storage | **shared** primary workspace collection (`asb-workspaces-v2`) | its **own** detached store (`asb-dashboard-views`), fresh id | | Editable | yes β€” drag reorder + layout preset persist to the shared aggregate | **read-only** | | Auth | existing postMessage credential handoff (#149) | same | | Survives relogin / reload | yes (shared store) | yes (own persisted record) | @@ -48,7 +48,7 @@ credential handoff. ```ts type DashboardOpenSource = - | { kind: 'current-workspace'; workspaceId: string; dashboardId: string } + | { kind: 'current-workspace'; workspaceKey: string; dashboardId: string } | { kind: 'session-bundle'; token: string; dashboardId: string }; ``` @@ -62,8 +62,8 @@ current-workspace; `?st=&dash=` is session-bundle. > `bootstrap` (`src/main.ts`) reads `?state` as the OAuth CSRF value; a token > there would raise "OAuth state mismatch". -**Mode is discriminated by which store the `ws` id resolves in**, not by a -spoofable URL flag: the id present in the primary workspace store β†’ edit mode; +**Mode is discriminated by which store the `ws` key resolves in**, not by a +spoofable URL flag: the key present in the primary workspace store β†’ edit mode; present in the detached views store β†’ view mode; in neither β†’ a not-found panel that executes nothing (never silently opens a different dashboard). @@ -84,7 +84,8 @@ through a one-time IndexedDB token, then materializes a durable detached copy: transaction (`take` = get+delete, then reject if expired), and strips `st` from the URL via `history.replaceState`. 5. It **materializes** the bundle into the detached store under - `detachedWorkspaceId`, rewrites the URL to `?ws=&dash=`, + `detachedWorkspaceId`, assigns the detached copy a stable local key, rewrites + the URL to `?ws=&dash=`, and renders read-only. Auth is orthogonal and unchanged: the new tab still restores credentials via the @@ -105,7 +106,8 @@ detached `?ws=` URL survives). so there is no untrusted external SQL to gate. The viewer's existing safety limits (row/byte caps, bounded concurrency, per-tile cancellation, stale-wave protection, no Setup execution) still apply. -- **A new IndexedDB store family.** Two dedicated databases join `asb-workspace`: +- **A new IndexedDB store family.** Two dedicated databases join the primary + workspace database (now `asb-workspaces-v2`): `asb-dashboard-handoff` (one-time tokens) and `asb-dashboard-views` (multi-record detached snapshots, keyed by workspace id, with a small retention cap so abandoned views don't grow unbounded). Both reuse the #284 adapter @@ -131,3 +133,28 @@ detached `?ws=` URL survives). suffix. - **A `?view=1` mode flag:** rejected in favor of store-membership discrimination (not spoofable, and naturally yields the not-found case). + +## Addendum β€” 2026-07-23: stable workspace keys and collection persistence (#406) + +`StoredWorkspaceV2` replaces the single fixed `current` aggregate with one +IndexedDB record per workspace. Workspace identity is deliberately split: + +- `id` is the immutable opaque object-store key; +- `key` is the immutable, unique lowercase ASCII URL identity resolved by + `?ws=`; +- `name` is mutable display metadata and never rewrites bookmarks. + +The primary store uses `id` as its key path and a unique `key` index. Create, +replace, delete, and last-opened metadata updates are transaction-scoped; +replace cannot create a missing record or alter its key. Portable workspace +import creates a fresh local ID/key instead of replacing the active workspace. + +Implicit opens persist the successfully opened key in a separate preferences +store and stamp store-owned `lastOpenedAt` metadata using the injected wall +clock. If that preference is absent or invalid, resolution chooses the valid +workspace with the newest timestamp, breaking ties by key; records without a +timestamp fall back deterministically by key. Explicit `?ws=` opens never fall +back to another workspace. + +The detached-view store follows the same key-based `?ws=` resolution contract, +while its generated local key remains separate from its opaque detached ID. diff --git a/schemas/generated/library-v2.bundle.schema.json b/schemas/generated/library-v2.bundle.schema.json index 57699418..69e2e8dd 100644 --- a/schemas/generated/library-v2.bundle.schema.json +++ b/schemas/generated/library-v2.bundle.schema.json @@ -1735,17 +1735,18 @@ } } }, - "stored-workspace-v1": { + "stored-workspace-v2": { "$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.", + "$id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v2.schema.json", + "title": "Altinity SQL Browser stored workspace v2", + "description": "One independently addressable browser-persistence aggregate with immutable application and URL identities, 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, + "x-altinity-version": 2, "type": "object", "required": [ "storageVersion", "id", + "key", "name", "queries", "dashboard" @@ -1753,21 +1754,27 @@ "properties": { "storageVersion": { "title": "Storage version", - "description": "Stored-workspace contract version; always 1 for this contract. Unknown future versions fail closed.", + "description": "Stored-workspace contract version; always 2 for this contract. Unknown future versions fail closed.", "type": "integer", - "const": 1 + "const": 2 }, "id": { "title": "Workspace identifier", - "description": "Stable generated workspace identity. Two files with the same name still produce distinct workspace IDs.", + "description": "Stable generated application identity. Two workspaces with the same display name still have distinct IDs.", "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" }, + "key": { + "title": "Workspace URL key", + "description": "Stable lowercase ASCII identity used by workspace URLs. It is immutable after creation and unique case-insensitively within the local repository.", + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, "name": { "title": "Workspace name", - "description": "User-visible workspace name. Renaming the workspace does not rename its Dashboard.", + "description": "Mutable user-visible workspace name. Renaming it does not change the workspace ID, URL key, or Dashboard title.", "type": "string", "maxLength": 512 }, @@ -1797,6 +1804,7 @@ "x-altinity-order": [ "storageVersion", "id", + "key", "name", "queries", "dashboard" diff --git a/schemas/generated/schema-catalog.json b/schemas/generated/schema-catalog.json index 8976fe48..8756ec42 100644 --- a/schemas/generated/schema-catalog.json +++ b/schemas/generated/schema-catalog.json @@ -41,9 +41,9 @@ }, { "kind": "stored-workspace", - "version": 1, - "id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json", - "path": "../stored-workspace-v1.schema.json" + "version": 2, + "id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v2.schema.json", + "path": "../stored-workspace-v2.schema.json" }, { "kind": "portable-bundle", diff --git a/schemas/stored-workspace-v1.schema.json b/schemas/stored-workspace-v2.schema.json similarity index 50% rename from schemas/stored-workspace-v1.schema.json rename to schemas/stored-workspace-v2.schema.json index 4c2677f0..5e3ad5aa 100644 --- a/schemas/stored-workspace-v1.schema.json +++ b/schemas/stored-workspace-v2.schema.json @@ -1,30 +1,36 @@ { "$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.", + "$id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v2.schema.json", + "title": "Altinity SQL Browser stored workspace v2", + "description": "One independently addressable browser-persistence aggregate with immutable application and URL identities, 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, + "x-altinity-version": 2, "type": "object", - "required": ["storageVersion", "id", "name", "queries", "dashboard"], + "required": ["storageVersion", "id", "key", "name", "queries", "dashboard"], "properties": { "storageVersion": { "title": "Storage version", - "description": "Stored-workspace contract version; always 1 for this contract. Unknown future versions fail closed.", + "description": "Stored-workspace contract version; always 2 for this contract. Unknown future versions fail closed.", "type": "integer", - "const": 1 + "const": 2 }, "id": { "title": "Workspace identifier", - "description": "Stable generated workspace identity. Two files with the same name still produce distinct workspace IDs.", + "description": "Stable generated application identity. Two workspaces with the same display name still have distinct IDs.", "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" }, + "key": { + "title": "Workspace URL key", + "description": "Stable lowercase ASCII identity used by workspace URLs. It is immutable after creation and unique case-insensitively within the local repository.", + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, "name": { "title": "Workspace name", - "description": "User-visible workspace name. Renaming the workspace does not rename its Dashboard.", + "description": "Mutable user-visible workspace name. Renaming it does not change the workspace ID, URL key, or Dashboard title.", "type": "string", "maxLength": 512 }, @@ -45,5 +51,5 @@ } }, "additionalProperties": false, - "x-altinity-order": ["storageVersion", "id", "name", "queries", "dashboard"] + "x-altinity-order": ["storageVersion", "id", "key", "name", "queries", "dashboard"] } diff --git a/src/application/saved-query-service.ts b/src/application/saved-query-service.ts index 74c47659..c3832618 100644 --- a/src/application/saved-query-service.ts +++ b/src/application/saved-query-service.ts @@ -52,7 +52,8 @@ import type { QueryTimeRangeInferenceDiagnostic } from '../core/query-time-range * narrowed to their own exact reads) need between them. */ export interface SavedQueryServiceDeps { state: Pick; + 'savedQueries' | 'resultView' | 'libraryDirty' | 'history' | 'libraryName' + | 'workspaceId' | 'workspaceKey' | 'dashboard'>; saveJSON: SaveJSON; /** `createSavedQuery`'s minting timestamp β€” a genuine wall-clock read * (production wires this to `() => Date.now()`, called at the exact diff --git a/src/core/workspace-key.ts b/src/core/workspace-key.ts new file mode 100644 index 00000000..f55f300c --- /dev/null +++ b/src/core/workspace-key.ts @@ -0,0 +1,59 @@ +// Pure workspace URL-key rules (#406). Persisted keys are strict canonical +// ASCII. Name derivation is deliberately lossy and deterministic; user-entered +// keys use validateWorkspaceKey and are never silently rewritten. + +export const WORKSPACE_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/; + +export type WorkspaceKeyValidation = + | { ok: true; key: string } + | { ok: false; reason: 'required' | 'invalid' }; + +/** Normalize a lookup without turning an invalid key into a valid one. */ +export function normalizeWorkspaceKeyLookup(value: string): string { + return value.toLowerCase(); +} + +/** Strictly validate a user-entered or persisted canonical workspace key. */ +export function validateWorkspaceKey(value: unknown): WorkspaceKeyValidation { + if (typeof value !== 'string' || value.length === 0) { + return { ok: false, reason: 'required' }; + } + if (!WORKSPACE_KEY_PATTERN.test(value)) { + return { ok: false, reason: 'invalid' }; + } + return { ok: true, key: value }; +} + +export function isValidWorkspaceKey(value: unknown): value is string { + return validateWorkspaceKey(value).ok; +} + +function asciiLower(value: string): string { + return value.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); +} + +function derivedBase(name: unknown): string { + if (typeof name !== 'string') return 'workspace'; + const key = asciiLower(name) + .replace(/[^a-z0-9_-]+/g, '_') + .replace(/^[_-]+|[_-]+$/g, ''); + return key || 'workspace'; +} + +/** + * Derive an available canonical key from a display name. + * + * Existing keys are compared case-insensitively. A collision keeps the base + * for the first workspace and appends `_2`, `_3`, ... for later workspaces. + */ +export function deriveWorkspaceKey( + name: unknown, + existingKeys: Iterable = [], +): string { + const base = derivedBase(name); + const occupied = new Set(Array.from(existingKeys, normalizeWorkspaceKeyLookup)); + if (!occupied.has(base)) return base; + let suffix = 2; + while (occupied.has(`${base}_${suffix}`)) suffix += 1; + return `${base}_${suffix}`; +} diff --git a/src/dashboard/application/dashboard-open-source.ts b/src/dashboard/application/dashboard-open-source.ts index 459bf12a..a6ce585e 100644 --- a/src/dashboard/application/dashboard-open-source.ts +++ b/src/dashboard/application/dashboard-open-source.ts @@ -4,7 +4,7 @@ // telling it which store to read the dashboard from. // // Two ways a dashboard tab can be opened (see the coordinator's two-mode -// model): EDIT mode resolves `?ws=&dash=` against the +// model): EDIT mode resolves `?ws=&dash=` against the // shared primary workspace store; VIEW mode's one-time handoff resolves // `?st=&dash=` against the dedicated handoff store, then // rewrites the URL to a persistent `?ws=` once materialized. `st` @@ -13,7 +13,7 @@ /** Which store a standalone Dashboard tab should resolve its dashboard from. */ export type DashboardOpenSource = - | { kind: 'current-workspace'; workspaceId: string; dashboardId: string } + | { kind: 'current-workspace'; workspaceKey: string; dashboardId: string } | { kind: 'session-bundle'; token: string; dashboardId: string }; /** @@ -34,8 +34,8 @@ export function parseDashboardOpenSource(search: string): DashboardOpenSource | const dashboardId = params.get('dash') ?? ''; const token = params.get('st'); if (token) return { kind: 'session-bundle', token, dashboardId }; - const workspaceId = params.get('ws'); - if (workspaceId) return { kind: 'current-workspace', workspaceId, dashboardId }; + const workspaceKey = params.get('ws'); + if (workspaceKey) return { kind: 'current-workspace', workspaceKey, dashboardId }; return null; } @@ -50,7 +50,7 @@ export function buildDashboardSearch(source: DashboardOpenSource): string { if (source.kind === 'session-bundle') { params.set('st', source.token); } else { - params.set('ws', source.workspaceId); + params.set('ws', source.workspaceKey); } params.set('dash', source.dashboardId); return '?' + params.toString(); diff --git a/src/dashboard/application/empty-dashboard.ts b/src/dashboard/application/empty-dashboard.ts index 3022b8a9..d858fd8f 100644 --- a/src/dashboard/application/empty-dashboard.ts +++ b/src/dashboard/application/empty-dashboard.ts @@ -1,6 +1,6 @@ // Canonical empty Dashboard document. Shared by the Dashboard authoring // session and the Workbench favorite -> tile bridge: both paths must mint the -// same valid document when a StoredWorkspaceV1 legitimately has dashboard:null. +// same valid document when a StoredWorkspaceV2 legitimately has dashboard:null. import { deriveFlowFallback } from '../layouts/grafana-grid-layout.js'; import type { DashboardDocumentV1 } from '../../generated/json-schema.types.js'; diff --git a/src/dashboard/application/saved-query-mutation.ts b/src/dashboard/application/saved-query-mutation.ts index 9b3c8bb7..933a5109 100644 --- a/src/dashboard/application/saved-query-mutation.ts +++ b/src/dashboard/application/saved-query-mutation.ts @@ -24,7 +24,7 @@ import { resolveLayoutPluginSync } from '../layouts/layout-registry.js'; import { regenerateGridFallback } from '../layouts/grafana-grid-layout.js'; import { validateStoredWorkspaceDocument } from '../../workspace/stored-workspace.js'; import type { - DashboardDocumentV1, SavedQueryV2, StoredWorkspaceV1, + DashboardDocumentV1, SavedQueryV2, StoredWorkspaceV2, } from '../../generated/json-schema.types.js'; const isObject = (value: unknown): value is Record => @@ -51,7 +51,7 @@ export type SavedQueryRepair = * can offer. */ export interface SavedQueryMutationPlan { ok: boolean; - candidate: StoredWorkspaceV1 | null; + candidate: StoredWorkspaceV2 | null; diagnostics: WorkspaceDiagnostic[]; repairs: SavedQueryRepairKind[]; } @@ -145,7 +145,7 @@ export function suggestRepairs(diagnostics: readonly WorkspaceDiagnostic[]): Sav } function validateWorkspace( - candidate: StoredWorkspaceV1, options: SavedQueryMutationOptions, + candidate: StoredWorkspaceV2, options: SavedQueryMutationOptions, ): WorkspaceDiagnostic[] { const codecOptions = options.validationService ? { validationService: options.validationService } : {}; const structural = validateStoredWorkspaceDocument(candidate, codecOptions); @@ -161,7 +161,7 @@ function validateWorkspace( * 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, + workspace: StoredWorkspaceV2, mutation: SavedQueryMutation, repair?: SavedQueryRepair, options: SavedQueryMutationOptions = {}, ): SavedQueryMutationPlan { const queries = applyQueryMutation(workspace.queries, mutation); @@ -176,8 +176,8 @@ export function planSavedQueryMutation( dashboard = resolveLayoutPluginSync(dashboard.layout).normalize(dashboard); regenerateGridFallback(dashboard.layout, dashboard.tiles); } - const candidate: StoredWorkspaceV1 = { - storageVersion: 1, id: workspace.id, name: workspace.name, + const candidate: StoredWorkspaceV2 = { + storageVersion: 2, id: workspace.id, key: workspace.key, name: workspace.name, queries: cloneJson(queries), dashboard, }; const diagnostics = validateWorkspace(candidate, options); diff --git a/src/dashboard/application/session-bundle.ts b/src/dashboard/application/session-bundle.ts index d691d957..3c8a0f8b 100644 --- a/src/dashboard/application/session-bundle.ts +++ b/src/dashboard/application/session-bundle.ts @@ -18,8 +18,9 @@ import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; import type { HandoffRecord } from '../../workspace/handoff-store.types.js'; import type { DashboardOpenSource } from './dashboard-open-source.js'; import type { - DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV1, + DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV2, } from '../../generated/json-schema.types.js'; +import { deriveWorkspaceKey, normalizeWorkspaceKeyLookup } from '../../core/workspace-key.js'; export type BuildViewHandoffRecordResult = | { ok: true; record: HandoffRecord } @@ -55,12 +56,12 @@ export function buildViewHandoffRecord( } export type MaterializeDetachedWorkspaceResult = - | { ok: true; workspace: StoredWorkspaceV1 } + | { ok: true; workspace: StoredWorkspaceV2 } | { ok: false; diagnostics: WorkspaceDiagnostic[] }; /** * Materialize a consumed handoff record's bundle text into a detached, - * read-only `StoredWorkspaceV1`. Decodes and validates `text`, selects the + * read-only `StoredWorkspaceV2`. Decodes and validates `text`, selects the * Dashboard matching `dashboardId` out of the bundle (failing with a single * diagnostic when absent), and assembles the detached workspace under * `detachedWorkspaceId`. `name` is the selected Dashboard's title, falling @@ -82,8 +83,9 @@ export function materializeDetachedWorkspace( return { ok: true, workspace: { - storageVersion: 1, + storageVersion: 2, id: detachedWorkspaceId, + key: deriveWorkspaceKey(detachedWorkspaceId), name: selected.title || 'Dashboard', queries: bundle.queries, dashboard: selected, @@ -93,27 +95,29 @@ export function materializeDetachedWorkspace( /** Pure mode resolution result for a parsed `current-workspace` open source. */ export type DashboardModeResolution = - | { mode: 'edit'; workspace: StoredWorkspaceV1 } - | { mode: 'view'; workspace: StoredWorkspaceV1 } + | { mode: 'edit'; workspace: StoredWorkspaceV2 } + | { mode: 'view'; workspace: StoredWorkspaceV2 } | { mode: 'not-found' }; const matches = ( - workspace: StoredWorkspaceV1 | null, source: Extract, -): workspace is StoredWorkspaceV1 => - !!workspace && workspace.id === source.workspaceId && workspace.dashboard?.id === source.dashboardId; + workspace: StoredWorkspaceV2 | null, source: Extract, +): workspace is StoredWorkspaceV2 => + !!workspace + && workspace.key === normalizeWorkspaceKeyLookup(source.workspaceKey) + && workspace.dashboard?.id === source.dashboardId; /** * Resolve a parsed `current-workspace` `DashboardOpenSource` against the two * candidate workspaces already loaded by the coordinator: `primary` (from the - * shared `asb-workspace` store) and `detached` (from the `asb-dashboard-views` - * detached-views store). Both the workspace id AND the Dashboard id must - * match β€” a workspace id match with a differing (or missing) Dashboard id is + * shared `asb-workspaces-v2` store) and `detached` (from the `asb-dashboard-views` + * detached-views store). Both the workspace key AND the Dashboard id must + * match β€” a workspace-key match with a differing (or missing) Dashboard id is * `not-found`, never a silent fall-through to edit mode. */ export function resolveDashboardMode( source: Extract, - primary: StoredWorkspaceV1 | null, - detached: StoredWorkspaceV1 | null, + primary: StoredWorkspaceV2 | null, + detached: StoredWorkspaceV2 | null, ): DashboardModeResolution { if (matches(primary, source)) return { mode: 'edit', workspace: primary }; if (matches(detached, source)) return { mode: 'view', workspace: detached }; diff --git a/src/dashboard/application/tile-membership.ts b/src/dashboard/application/tile-membership.ts index 3c14f919..40e7f2be 100644 --- a/src/dashboard/application/tile-membership.ts +++ b/src/dashboard/application/tile-membership.ts @@ -1,11 +1,11 @@ // Wire the Workbench favorite star to Dashboard tile membership (#299). -// `StoredWorkspaceV1.dashboard.tiles[]` is canonical Dashboard membership +// `StoredWorkspaceV2.dashboard.tiles[]` is canonical Dashboard membership // (#287); toggling `spec.favorite` on a saved query must add/remove the // matching tile IN THE SAME atomic commit as the favorite flip, or the star // and the Dashboard silently disagree about what's on it. // // The role gate mirrors the migration precedent in -// `src/workspace/legacy-migration.ts`'s `buildLegacyMigrationCandidate`: only +// the original single-workspace migration contract: only // PANEL-role queries ever become tiles β€” a favorited filter/setup-role query // stays favorited (the star's own visual state) but never gets a tile. Tile // removal mirrors `src/dashboard/application/saved-query-mutation.ts`'s @@ -89,7 +89,7 @@ export function removeTileMembership( * tile `{ id: genId(), queryId: query.id }`. * - Star ON + a tile already references it β†’ unchanged (idempotent). * - Star ON + filter/setup-role query β†’ unchanged (favorite flip only; never - * becomes a tile, matching `buildLegacyMigrationCandidate`). + * becomes a tile, matching the legacy membership rule). * - Star OFF β†’ remove EVERY tile referencing the query and scrub those tile * ids from every filter's `targets`. * - Star ON + panel-role query + `dashboard:null` β†’ mint the canonical empty diff --git a/src/dashboard/model/canonical-json.ts b/src/dashboard/model/canonical-json.ts index 30da25e4..b0230c82 100644 --- a/src/dashboard/model/canonical-json.ts +++ b/src/dashboard/model/canonical-json.ts @@ -142,7 +142,7 @@ export const DASHBOARD_DOCUMENT_SHAPE: CanonicalShape = { }; export const STORED_WORKSPACE_SHAPE: CanonicalShape = { - order: ['storageVersion', 'id', 'name', 'queries', 'dashboard'], + order: ['storageVersion', 'id', 'key', 'name', 'queries', 'dashboard'], fields: { queries: { items: SAVED_QUERY_SHAPE }, dashboard: DASHBOARD_DOCUMENT_SHAPE, diff --git a/src/dashboard/model/dashboard-export.ts b/src/dashboard/model/dashboard-export.ts index 337e7446..84e02b92 100644 --- a/src/dashboard/model/dashboard-export.ts +++ b/src/dashboard/model/dashboard-export.ts @@ -22,7 +22,7 @@ import { CURRENT_PORTABLE_BUNDLE_VERSION, PORTABLE_BUNDLE_FORMAT, PORTABLE_BUNDLE_V1_SCHEMA_ID, } from './portable-bundle-codec.js'; import type { - DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV1, + DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV2, } from '../../generated/json-schema.types.js'; function bundleEnvelope( @@ -65,7 +65,7 @@ export function buildDashboardExportBundle( * emitted resource β€” the input `workspace` (including its Dashboard * `revision`) is left byte-for-byte unchanged. */ export function buildWorkspaceExportBundle( - workspace: StoredWorkspaceV1, nowISO: string, + workspace: StoredWorkspaceV2, nowISO: string, ): PortableBundleV1 { const queries = cloneJson(workspace.queries); const dashboards = workspace.dashboard ? [cloneJson(workspace.dashboard)] : []; diff --git a/src/generated/json-schema-validators.js b/src/generated/json-schema-validators.js index dcdf933f..31b765d4 100644 --- a/src/generated/json-schema-validators.js +++ b/src/generated/json-schema-validators.js @@ -5849,7 +5849,8 @@ function validate52(data, { instancePath = "", parentData, parentDataProperty, r return errors === 0; } validate52.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -var validateStoredWorkspaceV1 = validate63; +var validateStoredWorkspaceV2 = validate63; +var pattern13 = new RegExp("^[a-z0-9][a-z0-9_-]*$", "u"); function validate63(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; @@ -5880,8 +5881,8 @@ function validate63(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - if (data.name === void 0) { - const err2 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "name" }, message: "must have required property 'name'" }; + if (data.key === void 0) { + const err2 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "key" }, message: "must have required property 'key'" }; if (vErrors === null) { vErrors = [err2]; } else { @@ -5889,8 +5890,8 @@ function validate63(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - if (data.queries === void 0) { - const err3 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "queries" }, message: "must have required property 'queries'" }; + if (data.name === void 0) { + const err3 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "name" }, message: "must have required property 'name'" }; if (vErrors === null) { vErrors = [err3]; } else { @@ -5898,8 +5899,8 @@ function validate63(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - if (data.dashboard === void 0) { - const err4 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "dashboard" }, message: "must have required property 'dashboard'" }; + if (data.queries === void 0) { + const err4 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "queries" }, message: "must have required property 'queries'" }; if (vErrors === null) { vErrors = [err4]; } else { @@ -5907,13 +5908,22 @@ function validate63(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } + if (data.dashboard === void 0) { + const err5 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "dashboard" }, message: "must have required property 'dashboard'" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + 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 (!(key0 === "storageVersion" || key0 === "id" || key0 === "key" || key0 === "name" || key0 === "queries" || key0 === "dashboard")) { + const err6 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; if (vErrors === null) { - vErrors = [err5]; + vErrors = [err6]; } else { - vErrors.push(err5); + vErrors.push(err6); } errors++; } @@ -5921,20 +5931,20 @@ function validate63(data, { instancePath = "", parentData, parentDataProperty, r 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" }; + const err7 = { instancePath: instancePath + "/storageVersion", schemaPath: "#/properties/storageVersion/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; if (vErrors === null) { - vErrors = [err6]; + vErrors = [err7]; } else { - vErrors.push(err6); + vErrors.push(err7); } 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 (2 !== data0) { + const err8 = { instancePath: instancePath + "/storageVersion", schemaPath: "#/properties/storageVersion/const", keyword: "const", params: { allowedValue: 2 }, message: "must be equal to constant" }; if (vErrors === null) { - vErrors = [err7]; + vErrors = [err8]; } else { - vErrors.push(err7); + vErrors.push(err8); } errors++; } @@ -5943,119 +5953,141 @@ function validate63(data, { instancePath = "", parentData, parentDataProperty, r 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" }; + const err9 = { 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]; + vErrors = [err9]; } else { - vErrors.push(err8); + vErrors.push(err9); } 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" }; + const err10 = { 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]; + vErrors = [err10]; } else { - vErrors.push(err9); + vErrors.push(err10); } errors++; } if (!pattern4.test(data1)) { - const err10 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/pattern", keyword: "pattern", params: { pattern: "\\S" }, message: 'must match pattern "\\S"' }; + const err11 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/pattern", keyword: "pattern", params: { pattern: "\\S" }, message: 'must match pattern "\\S"' }; if (vErrors === null) { - vErrors = [err10]; + vErrors = [err11]; } else { - vErrors.push(err10); + vErrors.push(err11); } errors++; } } else { - const err11 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + const err12 = { instancePath: instancePath + "/id", schemaPath: "#/properties/id/type", keyword: "type", params: { type: "string" }, message: "must be string" }; if (vErrors === null) { - vErrors = [err11]; + vErrors = [err12]; } else { - vErrors.push(err11); + vErrors.push(err12); } errors++; } } - if (data.name !== void 0) { - let data2 = data.name; + if (data.key !== void 0) { + let data2 = data.key; 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 (!pattern13.test(data2)) { + const err13 = { instancePath: instancePath + "/key", schemaPath: "#/properties/key/pattern", keyword: "pattern", params: { pattern: "^[a-z0-9][a-z0-9_-]*$" }, message: 'must match pattern "^[a-z0-9][a-z0-9_-]*$"' }; if (vErrors === null) { - vErrors = [err12]; + vErrors = [err13]; } else { - vErrors.push(err12); + vErrors.push(err13); } errors++; } } else { - const err13 = { instancePath: instancePath + "/name", schemaPath: "#/properties/name/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + const err14 = { instancePath: instancePath + "/key", schemaPath: "#/properties/key/type", keyword: "type", params: { type: "string" }, message: "must be string" }; if (vErrors === null) { - vErrors = [err13]; + vErrors = [err14]; } else { - vErrors.push(err13); + vErrors.push(err14); + } + errors++; + } + } + if (data.name !== void 0) { + let data3 = data.name; + if (typeof data3 === "string") { + if (func1(data3) > 512) { + const err15 = { instancePath: instancePath + "/name", schemaPath: "#/properties/name/maxLength", keyword: "maxLength", params: { limit: 512 }, message: "must NOT have more than 512 characters" }; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); + } + errors++; + } + } else { + const err16 = { instancePath: instancePath + "/name", schemaPath: "#/properties/name/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err16]; + } else { + vErrors.push(err16); } 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" }; + let data4 = data.queries; + if (Array.isArray(data4)) { + if (data4.length > 1e3) { + const err17 = { 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]; + vErrors = [err17]; } else { - vErrors.push(err14); + vErrors.push(err17); } errors++; } - const len0 = data3.length; + const len0 = data4.length; for (let i0 = 0; i0 < len0; i0++) { - if (!validate42(data3[i0], { instancePath: instancePath + "/queries/" + i0, parentData: data3, parentDataProperty: i0, rootData, dynamicAnchors })) { + 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; } } } else { - const err15 = { instancePath: instancePath + "/queries", schemaPath: "#/properties/queries/type", keyword: "type", params: { type: "array" }, message: "must be array" }; + const err18 = { instancePath: instancePath + "/queries", schemaPath: "#/properties/queries/type", keyword: "type", params: { type: "array" }, message: "must be array" }; if (vErrors === null) { - vErrors = [err15]; + vErrors = [err18]; } else { - vErrors.push(err15); + vErrors.push(err18); } errors++; } } if (data.dashboard !== void 0) { - let data5 = data.dashboard; - const _errs12 = errors; + let data6 = data.dashboard; + const _errs14 = errors; let valid3 = false; let passing0 = null; - const _errs13 = errors; - if (!validate52(data5, { instancePath: instancePath + "/dashboard", parentData: data, parentDataProperty: "dashboard", rootData, dynamicAnchors })) { + const _errs15 = errors; + if (!validate52(data6, { instancePath: instancePath + "/dashboard", parentData: data, parentDataProperty: "dashboard", rootData, dynamicAnchors })) { vErrors = vErrors === null ? validate52.errors : vErrors.concat(validate52.errors); errors = vErrors.length; } - var _valid0 = _errs13 === errors; + var _valid0 = _errs15 === 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" }; + const _errs16 = errors; + if (data6 !== null) { + const err19 = { instancePath: instancePath + "/dashboard", schemaPath: "#/properties/dashboard/oneOf/1/type", keyword: "type", params: { type: "null" }, message: "must be null" }; if (vErrors === null) { - vErrors = [err16]; + vErrors = [err19]; } else { - vErrors.push(err16); + vErrors.push(err19); } errors++; } - var _valid0 = _errs14 === errors; + var _valid0 = _errs16 === errors; if (_valid0 && valid3) { valid3 = false; passing0 = [passing0, 1]; @@ -6066,18 +6098,18 @@ function validate63(data, { instancePath = "", parentData, parentDataProperty, r } } if (!valid3) { - const err17 = { instancePath: instancePath + "/dashboard", schemaPath: "#/properties/dashboard/oneOf", keyword: "oneOf", params: { passingSchemas: passing0 }, message: "must match exactly one schema in oneOf" }; + const err20 = { 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]; + vErrors = [err20]; } else { - vErrors.push(err17); + vErrors.push(err20); } errors++; } else { - errors = _errs12; + errors = _errs14; if (vErrors !== null) { - if (_errs12) { - vErrors.length = _errs12; + if (_errs14) { + vErrors.length = _errs14; } else { vErrors = null; } @@ -6085,11 +6117,11 @@ function validate63(data, { instancePath = "", parentData, parentDataProperty, r } } } else { - const err18 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + const err21 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; if (vErrors === null) { - vErrors = [err18]; + vErrors = [err21]; } else { - vErrors.push(err18); + vErrors.push(err21); } errors++; } @@ -6378,7 +6410,7 @@ export { validatePortableBundleV1, validateQuerySpecV1, validateSavedQueryV2, - validateStoredWorkspaceV1 + validateStoredWorkspaceV2 }; export const validatorsById = { @@ -6388,6 +6420,6 @@ export const validatorsById = { "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json": validateFlowLayoutV1, "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json": validateGrafanaGridLayoutV1, "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/stored-workspace-v2.schema.json": validateStoredWorkspaceV2, "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 37ef0b99..3d65c193 100644 --- a/src/generated/json-schema.types.ts +++ b/src/generated/json-schema.types.ts @@ -952,30 +952,36 @@ export interface DashboardDocumentV1 { tiles: DashboardTileV1[]; } -// stored-workspace v1 β€” https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json +// stored-workspace v2 β€” https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v2.schema.json /** - * Altinity SQL Browser stored workspace v1 + * Altinity SQL Browser stored workspace v2 * - * 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. + * One independently addressable browser-persistence aggregate with immutable application and URL identities, an ordered saved-query collection, and zero or one Dashboard. Internal persistence contract; portable interchange uses portable-bundle-v1 instead. */ -export interface StoredWorkspaceV1 { +export interface StoredWorkspaceV2 { /** * Storage version * - * Stored-workspace contract version; always 1 for this contract. Unknown future versions fail closed. + * Stored-workspace contract version; always 2 for this contract. Unknown future versions fail closed. */ - storageVersion: 1; + storageVersion: 2; /** * Workspace identifier * - * Stable generated workspace identity. Two files with the same name still produce distinct workspace IDs. + * Stable generated application identity. Two workspaces with the same display name still have distinct IDs. */ id: string; + /** + * Workspace URL key + * + * Stable lowercase ASCII identity used by workspace URLs. It is immutable after creation and unique case-insensitively within the local repository. + */ + key: string; /** * Workspace name * - * User-visible workspace name. Renaming the workspace does not rename its Dashboard. + * Mutable user-visible workspace name. Renaming it does not change the workspace ID, URL key, or Dashboard title. */ name: string; /** diff --git a/src/generated/json-schemas.js b/src/generated/json-schemas.js index 45911b17..811915f8 100644 --- a/src/generated/json-schemas.js +++ b/src/generated/json-schemas.js @@ -1737,17 +1737,18 @@ export const dashboardV1Schema = { } }; -export const storedWorkspaceV1Schema = { +export const storedWorkspaceV2Schema = { "$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.", + "$id": "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v2.schema.json", + "title": "Altinity SQL Browser stored workspace v2", + "description": "One independently addressable browser-persistence aggregate with immutable application and URL identities, 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, + "x-altinity-version": 2, "type": "object", "required": [ "storageVersion", "id", + "key", "name", "queries", "dashboard" @@ -1755,21 +1756,27 @@ export const storedWorkspaceV1Schema = { "properties": { "storageVersion": { "title": "Storage version", - "description": "Stored-workspace contract version; always 1 for this contract. Unknown future versions fail closed.", + "description": "Stored-workspace contract version; always 2 for this contract. Unknown future versions fail closed.", "type": "integer", - "const": 1 + "const": 2 }, "id": { "title": "Workspace identifier", - "description": "Stable generated workspace identity. Two files with the same name still produce distinct workspace IDs.", + "description": "Stable generated application identity. Two workspaces with the same display name still have distinct IDs.", "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" }, + "key": { + "title": "Workspace URL key", + "description": "Stable lowercase ASCII identity used by workspace URLs. It is immutable after creation and unique case-insensitively within the local repository.", + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, "name": { "title": "Workspace name", - "description": "User-visible workspace name. Renaming the workspace does not rename its Dashboard.", + "description": "Mutable user-visible workspace name. Renaming it does not change the workspace ID, URL key, or Dashboard title.", "type": "string", "maxLength": 512 }, @@ -1799,6 +1806,7 @@ export const storedWorkspaceV1Schema = { "x-altinity-order": [ "storageVersion", "id", + "key", "name", "queries", "dashboard" @@ -1902,6 +1910,6 @@ export const schemasById = { "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json": flowLayoutV1Schema, "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json": grafanaGridLayoutV1Schema, "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/stored-workspace-v2.schema.json": storedWorkspaceV2Schema, "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json": portableBundleV1Schema, }; diff --git a/src/main.ts b/src/main.ts index f6229a11..c4b5072a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -47,11 +47,11 @@ export interface BootstrapApp { * main.ts's own `string | null` sentinel (`null` means "no callback * error"), so this contract states what's actually passed here. */ showLogin(msg?: string | null): void; - /** #287 W4: resolve the current StoredWorkspaceV1 aggregate (migrating the - * legacy flat state once, if needed) and project it onto `app.state` - * before the first `renderApp()` β€” see `App.loadWorkspaceOnBoot`'s own doc - * comment (app.types.ts). The real return value is never read here - * (`Promise` is enough for `bootstrap`'s own purposes). */ + /** Resolve the explicit or last-used StoredWorkspaceV2 and project it onto + * `app.state` before the first `renderApp()` β€” see + * `App.loadWorkspaceOnBoot`'s own doc comment (app.types.ts). The real + * return value is never read here (`Promise` is enough for + * `bootstrap`'s own purposes). */ loadWorkspaceOnBoot(): Promise; } diff --git a/src/state.ts b/src/state.ts index ca5c4449..4db1dd5a 100644 --- a/src/state.ts +++ b/src/state.ts @@ -28,7 +28,7 @@ import { } from './core/spec-draft.js'; import { signal } from '@preact/signals-core'; import type { Signal } from '@preact/signals-core'; -import type { QuerySpecV1, SavedQueryV2, DashboardDocumentV1, StoredWorkspaceV1 } from './generated/json-schema.types.js'; +import type { QuerySpecV1, SavedQueryV2, DashboardDocumentV1, StoredWorkspaceV2 } from './generated/json-schema.types.js'; import type { SpecDiagnostic } from './editor/spec-editor.types.js'; import type { WorkspaceMutationInput, WorkspaceMutationOutcome } from './ui/app.types.js'; import type { WorkspaceDiagnostic } from './dashboard/model/workspace-diagnostics.js'; @@ -36,6 +36,7 @@ import { queryToken, reconcileLinkedTabs } from './workspace/workspace-sync.js'; import type { LinkedTabSnapshot } from './workspace/workspace-sync.js'; import { materializeQueryTimeRange } from './core/query-time-range.js'; import type { QueryTimeRangeInferenceDiagnostic } from './core/query-time-range.js'; +import { deriveWorkspaceKey } from './core/workspace-key.js'; // ── Persisted-data types (schema-generated) ───────────────────────────────── @@ -61,7 +62,7 @@ export type SaveJSON = (key: string, value: unknown) => void; export type SaveStr = (key: string, value: string) => void; // ── Read-before-write mutation seam (#287 W4 / #343) ─────────────────────── -// The saved-query CRUD ops below persist through the StoredWorkspaceV1 +// The saved-query CRUD ops below persist through the StoredWorkspaceV2 // aggregate (IndexedDB), never the flat `asb:saved` localStorage key. #343: // they no longer take a raw `commit(candidate)` callback that would let them // build a whole candidate from stale in-memory `AppState` and clobber a change @@ -83,7 +84,7 @@ export type SaveStr = (key: string, value: string) => void; * committed workspace (or `null` when nothing is persisted yet) and returns the * complete candidate to commit β€” or `null` to abort committing nothing. */ export type MutateWorkspace = ( - transform: (latest: StoredWorkspaceV1 | null) => + transform: (latest: StoredWorkspaceV2 | null) => WorkspaceMutationInput | null | Promise | null>, ) => Promise>; @@ -124,11 +125,11 @@ const asSpecDiagnostics = (diagnostics: readonly WorkspaceDiagnostic[]): SpecDia * when it exists, so once the aggregate is persisted a mutation NEVER rebuilds * a candidate from a stale in-memory projection (#343). Pure. */ function baselineWorkspace( - state: Pick, - latest: StoredWorkspaceV1 | null, -): StoredWorkspaceV1 { + state: Pick, + latest: StoredWorkspaceV2 | null, +): StoredWorkspaceV2 { return latest ?? { - storageVersion: 1, id: state.workspaceId, name: state.libraryName.value, + storageVersion: 2, id: state.workspaceId, key: state.workspaceKey, name: state.libraryName.value, queries: state.savedQueries, dashboard: state.dashboard, }; } @@ -137,9 +138,11 @@ function baselineWorkspace( * carried through unchanged, the op's own next `queries`, and β€” unless the op * explicitly changes it β€” the base Dashboard byte-for-byte. Pure. */ const candidateFrom = ( - base: StoredWorkspaceV1, queries: SavedQueryV2[], + base: StoredWorkspaceV2, queries: SavedQueryV2[], dashboard: DashboardDocumentV1 | null = base.dashboard, -): StoredWorkspaceV1 => ({ storageVersion: 1, id: base.id, name: base.name, queries, dashboard }); +): StoredWorkspaceV2 => ({ + storageVersion: 2, id: base.id, key: base.key, name: base.name, queries, dashboard, +}); /** The committed entry, re-read from the just-committed canonical `queries` * array (not the locally-computed candidate entry) β€” the aggregate's commit @@ -331,14 +334,14 @@ export interface AppState { history: HistoryEntry[]; libraryName: Signal; libraryDirty: Signal; - /** #287 W4: the current committed StoredWorkspaceV1's Dashboard document β€” + /** #287 W4: the current committed StoredWorkspaceV2's Dashboard document β€” * the Workbench NEVER mutates this (that's the /dashboard route's job); it * is only carried through, unchanged, in every saved-query CRUD commit * candidate. `null` until the boot projection (app.ts's * `loadWorkspaceOnBoot`) resolves the aggregate, or when the workspace * genuinely has no Dashboard yet. */ dashboard: DashboardDocumentV1 | null; - /** #287 W4: the current committed StoredWorkspaceV1's id, carried forward + /** #287 W4: the current committed StoredWorkspaceV2's id, carried forward * unchanged by every saved-query CRUD commit candidate. `createState` * mints a session-local placeholder synchronously (never blank β€” the * stored-workspace schema requires a non-empty id), so a CRUD op run @@ -347,6 +350,8 @@ export interface AppState { * with the real committed id (existing or freshly migrated) once * resolved. See `createState`'s own comment on `mintWorkspaceId`. */ workspaceId: string; + /** Immutable canonical URL key for the active workspace. */ + workspaceKey: string; libraryFilter: string; shortcutsOpen: Signal; isMobile: Signal; @@ -472,6 +477,7 @@ export function createState(read: StateReader = { loadJSON, loadStr }): AppState const num = (key: string, dflt: number, lo: number, hi: number) => clamp(parseFloat(read.loadStr(key, String(dflt))), lo, hi); const storedQueries = decodeStoredSavedQueries(read.loadJSON(KEYS.saved, [])); + const initialWorkspaceName = read.loadStr(KEYS.libraryName, DEFAULT_LIBRARY_NAME); return { nextTabId: 2, theme: read.loadStr(KEYS.theme, 'light'), @@ -599,23 +605,20 @@ export function createState(read: StateReader = { loadJSON, loadStr }): AppState // effect that reads these. `libraryName` is persisted; `libraryDirty` // (unsaved changes since the last file Save/Replace/New) is session-only and // resets on reload. Read/write via `.value`. - libraryName: signal(read.loadStr(KEYS.libraryName, DEFAULT_LIBRARY_NAME)), + libraryName: signal(initialWorkspaceName), libraryDirty: signal(false), - // #287 W4: the aggregate projection. `dashboard` has no aggregate to read - // yet at this synchronous constructor β€” it starts `null` and is populated - // once app.ts's async boot step (`loadWorkspaceOnBoot`) resolves the real - // StoredWorkspaceV1 (after the one-shot legacy migration). `workspaceId` - // is minted here rather than left blank: the stored-workspace schema - // requires a non-empty id, so a save attempted in the window before boot - // projection completes (or by a fixture that never runs it at all β€” e.g. - // a unit test driving `createApp` directly) still succeeds, committing - // the FIRST-ever aggregate under this freshly-minted id; `loadCurrent`'s - // migration marker is keyed on store record existence, so that commit is - // simply treated as "already migrated" rather than raced/overwritten. + // The synchronous constructor has no persisted workspace to project yet: + // `dashboard` starts null and app.ts's async `loadWorkspaceOnBoot` fills it + // after resolving the explicit or last-used StoredWorkspaceV2. + // `workspaceId` is minted here rather than left blank because the persisted + // schema requires a non-empty id. A save attempted before boot projection + // completes (or by a fixture that never runs it) can therefore still commit + // a valid workspace under this placeholder identity. // `loadWorkspaceOnBoot` overwrites this with the real committed id once // it resolves (a pre-existing aggregate, or the one migration just built). dashboard: null, workspaceId: mintWorkspaceId(), + workspaceKey: deriveWorkspaceKey(initialWorkspaceName), // Transient search text for the Library/History side panel (session-only, // cleared on a tab switch); never persisted. libraryFilter: '', @@ -724,6 +727,20 @@ export function savedForTab( return (tab && tab.savedId && state.savedQueries.find((q) => q.id === tab.savedId)) || null; } +/** Detach every tab linked to the workspace being left. Saved-query ids are + * scoped to their enclosing workspace, so carrying a link across a workspace + * identity change could bind the tab's draft to an unrelated query that happens + * to reuse the same id. Drafts and dirty flags deliberately remain untouched. */ +export function detachWorkspaceBoundTabs(state: Pick): void { + for (const tab of state.tabs.value) { + if (!tab.savedId) continue; + tab.savedId = null; + tab.editorMode = 'sql'; + tab.lastCommittedQueryToken = undefined; + tab.externalState = null; + } +} + /** Clear links from open tabs to saved queries that are absent from a newly * committed workspace. The SQL draft stays open; only the invalid association * and its Spec-only editor mode are reset, matching deleteSaved(). */ @@ -784,7 +801,7 @@ export interface LinkedTabReconcileSummary { * erase the orphan/detach distinction. Pure over the tab objects. */ export function reconcileLinkedTabsToLatest( state: Pick, - latest: StoredWorkspaceV1 | null, + latest: StoredWorkspaceV2 | null, validationService: SpecValidationService = defaultSpecValidationService, ): LinkedTabReconcileSummary { const tabs = state.tabs.value; @@ -839,7 +856,7 @@ export function reconcileLinkedTabsToLatest( * which satisfies this directly. */ export async function createSavedQuery( - state: Pick, + state: Pick, tab: QueryTab | null | undefined, name: unknown, description: unknown, mutate: MutateWorkspace, now: number = Date.now(), validationService: SpecValidationService = defaultSpecValidationService, @@ -908,7 +925,7 @@ export async function createSavedQuery( * `baselineWorkspace`/the candidate need, same convention as `createSavedQuery` * above. */ export async function commitSavedQuery( - state: Pick, + state: Pick, tab: QueryTab, spec: QuerySpecDraft | null | undefined, mutate: MutateWorkspace, validationService: SpecValidationService = defaultSpecValidationService, @@ -987,7 +1004,7 @@ const identityDashboardTransform: DashboardTransform = (dashboard) => dashboard; * full `AppState` through unchanged (it satisfies this directly). */ export async function patchSavedSpec( - state: Pick, + state: Pick, id: string, patch: SpecPatch, mutate: MutateWorkspace, validationService: SpecValidationService = defaultSpecValidationService, @@ -1174,7 +1191,7 @@ export type CommitOnlyResult = { ok: true } | { ok: false; diagnostics: Workspac * aggregate commit (#287 W4): on `ok: false` NOTHING is mutated (the query * and every tab pointer to it are left exactly as they were). */ export async function deleteSaved( - state: Pick, + state: Pick, id: string, mutate: MutateWorkspace, ): Promise { // Delete by ID from the LATEST workspace (#343): the whole-workspace diff --git a/src/ui/app.ts b/src/ui/app.ts index 611ea8ae..24b0e816 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -9,11 +9,11 @@ import { Icon } from './icons.js'; import { createState, activeTab, savedForTab, tabPanel, - normalizeRowLimit, reconcileTabsWithSavedQueries, + normalizeRowLimit, detachWorkspaceBoundTabs, reconcileTabsWithSavedQueries, adoptSavedIntoTab, reconcileLinkedTabsToLatest, } from '../state.js'; import type { QueryTab, AppState, SpecValidationService } from '../state.js'; -import type { SavedQueryV2, StoredWorkspaceV1 } from '../generated/json-schema.types.js'; +import type { SavedQueryV2, StoredWorkspaceV2 } from '../generated/json-schema.types.js'; import { splitStatements } from '../core/sql-split.js'; import { analysisView, fieldControls, fieldControlKind } from '../core/param-pipeline.js'; import { hasOptionalBlocks } from '../core/optional-blocks.js'; @@ -75,10 +75,12 @@ import type { ExportSink, FileHandleLike, DirectoryHandleLike } from '../applica import { createSchemaGraphSession, SchemaGraphAuthRequiredError } from '../application/schema-graph-session.js'; import { createAppPreferences } from '../application/app-preferences.js'; import { createWorkspaceRepository } from '../workspace/workspace-repository.js'; +import type { WorkspaceLoadResult } from '../workspace/workspace-repository.js'; import { createIndexedDbWorkspaceStore } from '../workspace/indexeddb-workspace-store.js'; import { createIndexedDbHandoffStore } from '../workspace/indexeddb-handoff-store.js'; import { createIndexedDbDetachedViewsStore } from '../workspace/indexeddb-detached-views-store.js'; -import { migrateLegacyWorkspaceIfNeeded } from '../workspace/legacy-migration.js'; +import { createNewWorkspace, DEFAULT_WORKSPACE_NAME } from '../workspace/workspace-operations.js'; +import { deriveWorkspaceKey } from '../core/workspace-key.js'; import { workspaceToken, queryToken, queriesChanged } from '../workspace/workspace-sync.js'; import { buildConflictChooser } from './conflict-resolution.js'; import { isDashboardRoute } from '../core/dashboard.js'; @@ -147,6 +149,8 @@ export function createApp(env: CreateAppEnv = {}): App { || ((name: string): BroadcastChannelPort | null => (typeof win.BroadcastChannel === 'function' ? new win.BroadcastChannel(name) : null)); const documentVisible = env.documentVisible || (() => doc.visibilityState !== 'hidden'); + // Epoch clock shared by persistence metadata and parameter execution. + const wallNow = (): number => (env.wallNow || (() => Date.now()))(); // Built up as a `Partial` first (every field below has a real, // App-typed value already β€” `Partial` just lets this literal typecheck @@ -220,15 +224,15 @@ export function createApp(env: CreateAppEnv = {}): App { app.prefs = prefs; app.saveJSON = saveJSON; app.saveStr = saveStr; - // Atomic StoredWorkspaceV1 persistence (#280 Phase 2 / #284): the injected - // IndexedDB factory seam (mirrors crypto/sessionStorage) backs a single-record - // WorkspaceStore, behind which the pure WorkspaceRepository does validate-then- - // atomically-replace commits. Constructed lazily β€” no database is opened until + // Atomic StoredWorkspaceV2 persistence: the injected IndexedDB factory seam + // (mirrors crypto/sessionStorage) backs the workspace collection, behind + // which the pure WorkspaceRepository validates create/replace commits. + // Constructed lazily β€” no database is opened until // a workspace operation runs β€” so this never touches IndexedDB during // bootstrap. The favorites-driven Dashboard render still reads legacy keys in // this phase; wiring reads onto the aggregate is Phases 3-6 of #280. const workspaceStore = createIndexedDbWorkspaceStore(env.indexedDB || win.indexedDB); - app.workspace = createWorkspaceRepository({ store: workspaceStore }); + app.workspace = createWorkspaceRepository({ store: workspaceStore, now: wallNow }); // #288 Phase 6 β€” Dashboard viewing. Two dedicated IndexedDB stores (own // databases, same injected factory + lazy-open pattern as the workspace // store): `handoff` is the one-time cross-tab token transport that carries a @@ -519,7 +523,6 @@ export function createApp(env: CreateAppEnv = {}): App { // wrong for epoch-relative values (#169's `now-1h`). Callers resolve one // wallNow() per execution wave and thread it through every prepare of that // wave; debounce/coalescing also live in the callers, never in the pipeline. - const wallNow = (): number => (env.wallNow || (() => Date.now()))(); app.wallNow = wallNow; // A unique id for a query_id / session_id. Prefer crypto.randomUUID; its // fallback (non-secure context, where randomUUID is undefined) must still be @@ -1525,57 +1528,13 @@ 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: run the one-shot legacy migration (favorites β†’ tiles, - // dashLayout/dashCols β†’ flow@1) β€” it only actually does anything when no - // aggregate record exists yet (`migrateLegacyWorkspaceIfNeeded` keys on raw - // record existence via the store, never on `loadCurrent`/`loadCurrentResult` - // validity, so a present-but-corrupt record is never clobbered by a re-run - // β€” #300). Shared by `loadDashboardWorkspace` below and the boot path - // (`loadWorkspaceOnBoot`) so both read the SAME migration deps built off the - // current `app.state` snapshot. - const runLegacyMigrationIfNeeded = () => 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-'), - }); - - // #286 Phase 4: resolve the current StoredWorkspaceV1 the Dashboard viewer - // reads from. Reads via `loadCurrent` (not `loadCurrentResult`), so a - // corrupt-but-present record still collapses to `null` here β€” the /dashboard - // route's own render just falls back to its empty state; the user-visible - // corrupt-record surface (#300) lives in the boot path below, which both - // `main.ts`'s OAuth bootstrap and the basic-auth `connect` action go through. - app.loadDashboardWorkspace = async () => { - await runLegacyMigrationIfNeeded(); - return app.workspace.loadCurrent(); - }; - - // #287 W4: the async boot-init step β€” migrate-if-needed + loadCurrent (via - // `loadDashboardWorkspace` above), then PROJECT the resolved aggregate onto - // `state` so the Workbench (not only the /dashboard route) treats it as the - // saved-query collection's single source of truth. `main.ts`'s `bootstrap` - // awaits this before the first `renderApp()`. A null/failed load leaves - // `state` exactly as `createState()`'s synchronous legacy read already - // populated it (a brand-new install with nothing to migrate yet, or a - // degraded IndexedDB) β€” including its own synchronously-minted - // `workspaceId` placeholder (see `createState`'s `mintWorkspaceId`), so a - // saved-query CRUD op run in this window (or by a caller that never awaits - // this step at all) still succeeds rather than failing closed. - // #287 W5: the projection every commit of the aggregate onto `state` - // shares β€” extracted from this same assignment's pre-W5 inline body so - // `loadWorkspaceOnBoot` and every file-menu.js write (New/Import/Replace/ - // rename) apply it identically. `libraryDirty` clears here too: a workspace - // that was JUST committed (boot's own load, or a file-menu op's commit) is - // by construction in sync with what's persisted, matching the pre-#287 - // New/Replace-clears-dirty behavior file-menu.js's own ops used to apply - // directly. - const applyCommittedWorkspace = (workspace: StoredWorkspaceV1): void => { + // Project the active StoredWorkspaceV2 onto the current application surface. + // Persistence is now a collection; this projection identifies which record + // this tab is editing, while the repository remains independently addressable + // by immutable id and stable URL key. + const applyCommittedWorkspace = (workspace: StoredWorkspaceV2): void => { + const workspaceChanged = app.state.workspaceId !== workspace.id; + if (workspaceChanged) detachWorkspaceBoundTabs(app.state); app.state.savedQueries = workspace.queries; reconcileTabsWithSavedQueries(app.state); // #343: seed the in-sync baseline token for any still-linked tab that lacks @@ -1591,6 +1550,7 @@ export function createApp(env: CreateAppEnv = {}): App { } app.state.dashboard = workspace.dashboard; app.state.workspaceId = workspace.id; + app.state.workspaceKey = workspace.key; app.state.libraryName.value = workspace.name; app.state.libraryDirty.value = false; // #343 Β§2: this projection IS now the tab's committed baseline β€” record its @@ -1601,9 +1561,7 @@ export function createApp(env: CreateAppEnv = {}): App { }; app.applyCommittedWorkspace = applyCommittedWorkspace; // #287 W5: the shared WorkspaceIdGen seam file-menu.js's New workspace / - // Import / Replace operations mint fresh ids through β€” the same generator - // `loadDashboardWorkspace`'s one-shot legacy migration already uses inline - // above (`uid('ws-')`). + // Import / Replace operations use to mint fresh ids (`uid('ws-')`). app.genId = () => uid('ws-'); // #287 review fix: serialize saved-query writes so overlapping async CRUD @@ -1636,11 +1594,6 @@ export function createApp(env: CreateAppEnv = {}): App { // used to detect whether a later reload actually changed anything (not CAS). let lastCommittedToken = ''; app.getLastCommittedToken = () => lastCommittedToken; - // #343 Β§5/Β§6: the channel-receive + focus/visibility listeners only ever call - // THIS hook; it is wired to the coalesced refresh scheduler just below (once - // `refreshWorkspaceFromStore` and its dependencies are defined). A no-op until - // then so an inbound message arriving mid-construction can't fault. - app.onExternalWorkspaceChange = () => {}; // #343 step 4: the route/surface refresh hook a mounted route registers to // react AFTER a refresh actually projected an external change β€” the standalone // Dashboard route (a later step) overrides this to rebuild its viewer session @@ -1653,28 +1606,26 @@ export function createApp(env: CreateAppEnv = {}): App { if (workspaceChannel) { workspaceChannel.onmessage = (event) => { const msg = event.data as WorkspaceChangedMessage | null; - if (!msg || msg.type !== 'workspace-changed' || msg.sourceTabId === sourceTabId) return; + if (!msg || msg.type !== 'workspace-changed' || msg.sourceTabId === sourceTabId + || msg.workspaceId !== app.state.workspaceId) return; app.onExternalWorkspaceChange(msg); }; } - // #341/#344 review fix: the ONLY correct way to build a workspace-mutation - // candidate. `serializeWrite` alone only serializes EXECUTION β€” a producer - // that builds its whole candidate from `state`/`app.workspace` BEFORE - // entering the queue (e.g. across an awaited user dialog) can still have it - // clobber a mutation that committed while it waited, because its candidate - // was already stale the moment it was built. Reading `app.workspace - // .loadCurrent()` INSIDE the queued op (never cached in an outer variable) - // is what makes it authoritative: every write is serialized through this - // same queue, so by the time this op runs, `loadCurrent()` reflects every - // write queued before it. + // Build every mutation from this tab's active workspace, reloaded by + // immutable id INSIDE the queue. Repository commits can never create, so an + // externally deleted active workspace aborts rather than resurrecting it. // #343 Β§2: on a SUCCESSFUL commit the primitive itself owns the projection // (`applyCommittedWorkspace`, exactly once), records the snapshot token, and // broadcasts ONE invalidation β€” callers no longer project. An aborted // transform (null / null candidate) commits nothing and notifies no one; a // failed commit surfaces its diagnostics without projecting or notifying. app.mutateWorkspace = (transform) => app.serializeWrite(async () => { - const latest = await app.workspace.loadCurrent(); + const loaded = await app.workspace.loadById(app.state.workspaceId); + if (loaded.status === 'corrupt') { + return { ok: false as const, diagnostics: loaded.diagnostics }; + } + const latest = loaded.status === 'ok' ? loaded.workspace : null; const input = await transform(latest); if (!input || !input.candidate) { return { ok: false as const, aborted: true as const, data: input ? input.data : undefined }; @@ -1716,9 +1667,9 @@ export function createApp(env: CreateAppEnv = {}): App { // aggregate). The Dashboard route sets `dashboardReadOnly` per render; a // Workbench tab and an editable Dashboard leave it false. if (app.dashboardReadOnly) return; - let loaded: StoredWorkspaceV1 | null; + let loaded: StoredWorkspaceV2 | null; try { - const result = await app.workspace.loadCurrentResult(); + const result = await app.workspace.loadById(app.state.workspaceId); if (result.status === 'corrupt') { warnRefreshFailed(); return; } loaded = result.status === 'ok' ? result.workspace : null; } catch { @@ -1785,44 +1736,72 @@ export function createApp(env: CreateAppEnv = {}): App { doc.addEventListener('visibilitychange', () => { if (documentVisible()) scheduleWorkspaceRefresh(); }); } - // #300: the Reset action offered on the corrupt-workspace toast below. - // `loadCurrent()`/`clearCurrent()`'s callers rely on a corrupt record - // collapsing to `null`/being silently removable β€” here that removal is - // deliberate and user-initiated. Clearing makes the store look exactly like - // a fresh install to `migrateLegacyWorkspaceIfNeeded`'s existence check, so - // it rebuilds a brand-new aggregate from the CURRENT legacy/local state - // (favorites, layout prefs) rather than leaving the next random CRUD op to - // silently mint one over nothing. Only projects + re-renders on success β€” - // an immediately-re-corrupt or still-empty outcome (unexpected; the store - // was just cleared) leaves the legacy `createState()` projection standing, - // same as any other failed load. - const resetCorruptWorkspace = async (): Promise => { - await app.workspace.clearCurrent(); - await runLegacyMigrationIfNeeded(); - const result = await app.workspace.loadCurrentResult(); + const provisionInitialWorkspace = async (): Promise => { + const listed = await app.workspace.list(); + const key = deriveWorkspaceKey(DEFAULT_WORKSPACE_NAME, listed.summaries.map((item) => item.key)); + const created = await app.workspace.create(createNewWorkspace(app.genId, key, DEFAULT_WORKSPACE_NAME)); + if (created.ok) return { status: 'ok', workspace: created.workspace }; + // A different tab may have provisioned the collection after our empty + // resolution. Re-resolve instead of creating a second fallback workspace. + return app.workspace.resolveImplicit(); + }; + + const resolveImplicitOrProvision = async (): Promise => { + const resolved = await app.workspace.resolveImplicit(); + return resolved.status === 'empty' ? provisionInitialWorkspace() : resolved; + }; + + const recordOpened = async (workspace: StoredWorkspaceV2): Promise => { + const result = await app.workspace.markOpened(workspace.key); + if (!result.ok) { + flashToast('Workspace opened, but its last-used timestamp could not be saved.', { document: doc }); + } + }; + + app.loadDashboardWorkspace = async (key?: string, dashboardId?: string) => { + const result = key + ? await app.workspace.loadByKey(key) + : await resolveImplicitOrProvision(); + if (result.status !== 'ok') return null; + // A keyed Dashboard route is valid only when both parts of its identity + // resolve. Do not make a workspace "last used" for a not-found Dashboard. + if (dashboardId !== undefined && result.workspace.dashboard?.id !== dashboardId) return null; + await recordOpened(result.workspace); + return result.workspace; + }; + + const resetCorruptWorkspace = async (id: string): Promise => { + const deleted = await app.workspace.delete(id); + if (!deleted.ok) return; + const result = await resolveImplicitOrProvision(); if (result.status === 'ok') { applyCommittedWorkspace(result.workspace); + await recordOpened(result.workspace); app.renderApp(); } }; app.loadWorkspaceOnBoot = async () => { - await runLegacyMigrationIfNeeded(); - const result = await app.workspace.loadCurrentResult(); + const workspaceParams = new URLSearchParams(loc.search); + const explicitKey = workspaceParams.has('ws') ? (workspaceParams.get('ws') ?? '') : null; + const result = explicitKey !== null + ? await app.workspace.loadByKey(explicitKey) + : await resolveImplicitOrProvision(); if (result.status === 'corrupt') { - // #300: a corrupt-but-present aggregate is surfaced instead of silently - // continuing on the legacy projection (which would otherwise let the - // next saved-query CRUD commit orphan the corrupt record with no - // user-visible error). State is left untouched β€” same as any other - // null/failed load below. flashToast( - 'Saved workspace could not be read β€” your queries and dashboard layout are unaffected until you reset it.', - { document: app.document, action: { label: 'Reset workspace', onClick: () => { void resetCorruptWorkspace(); } } }, + 'Saved workspace could not be read. Other local workspaces remain unaffected.', + { + document: app.document, + action: { label: 'Reset workspace', onClick: () => { void resetCorruptWorkspace(result.id); } }, + }, ); return null; } const workspace = result.status === 'ok' ? result.workspace : null; - if (workspace) applyCommittedWorkspace(workspace); + if (workspace) { + applyCommittedWorkspace(workspace); + await recordOpened(workspace); + } return workspace; }; @@ -1835,9 +1814,9 @@ export function createApp(env: CreateAppEnv = {}): App { // concern. A workspace with no dashboard opens the bare route (legacy). function openDashboard(): void { const dashId = app.state.dashboard?.id; - const wsId = app.state.workspaceId; - const search = (wsId && dashId) - ? buildDashboardSearch({ kind: 'current-workspace', workspaceId: wsId, dashboardId: dashId }) + const wsKey = app.state.workspaceKey; + const search = (wsKey && dashId) + ? buildDashboardSearch({ kind: 'current-workspace', workspaceKey: wsKey, dashboardId: dashId }) : ''; const child = app.openWindow(loc.origin + conn.basePath + '/dashboard' + search); if (child) conn.grantHandoffTo(child); @@ -1896,7 +1875,7 @@ export function createApp(env: CreateAppEnv = {}): App { if (!materialized.ok) return null; await app.detachedViews.put({ workspace: materialized.workspace, savedAt: wallNow() }); const search = buildDashboardSearch({ - kind: 'current-workspace', workspaceId: materialized.workspace.id, dashboardId: record.dashboardId, + kind: 'current-workspace', workspaceKey: materialized.workspace.key, dashboardId: record.dashboardId, }); win.history.replaceState(null, '', loc.origin + conn.basePath + '/dashboard' + search); app.dashboardOpenSource = parseDashboardOpenSource(search); @@ -1909,9 +1888,9 @@ export function createApp(env: CreateAppEnv = {}): App { // URL's `dash=` would otherwise fail the viewer's strict id verification. app.reloadDashboardRoute = () => { const dash = app.state.dashboard; - const wsId = app.state.workspaceId; - if (dash && wsId) { - const search = buildDashboardSearch({ kind: 'current-workspace', workspaceId: wsId, dashboardId: dash.id }); + const wsKey = app.state.workspaceKey; + if (dash && wsKey) { + const search = buildDashboardSearch({ kind: 'current-workspace', workspaceKey: wsKey, dashboardId: dash.id }); win.history.replaceState(null, '', loc.origin + conn.basePath + '/dashboard' + search); app.dashboardOpenSource = parseDashboardOpenSource(search); } @@ -1928,12 +1907,10 @@ export function createApp(env: CreateAppEnv = {}): App { loadIntoNewTab: (queryOrName, sql) => { loadIntoNewTab(app, queryOrName, sql); toEditorOnMobile(); }, login: (idpId, targetOrigin) => conn.beginOAuth(idpId, targetOrigin), // Basic-auth login renders in-page (no page reload), so β€” unlike the OAuth - // path, where `main.ts`'s `bootstrap` awaits it β€” this is the ONLY place the - // aggregate load + legacy migration runs for a username/password session. - // Without it a first basic-auth session would render on the placeholder - // workspaceId and skip `migrateLegacyWorkspaceIfNeeded`, so the first CRUD - // commit would mint an orphan aggregate the migration marker then treats as - // "already migrated" β€” permanently stranding legacy favorites/layout (#287). + // path, where `main.ts`'s `bootstrap` awaits it β€” this is the only place + // workspace resolution runs for a username/password session. Without it, + // basic auth would keep rendering the placeholder workspace instead of the + // requested or last-used persisted workspace. connect: async (input) => { await conn.connectBasic(input); await app.loadWorkspaceOnBoot(); app.renderApp(); }, share, copyResult, diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 4b319cd1..8a104635 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -24,7 +24,7 @@ import type { WorkspaceDiagnostic } from '../dashboard/model/workspace-diagnosti import type { HandoffStore } from '../workspace/handoff-store.types.js'; import type { DetachedViewsStore } from '../workspace/detached-views-store.types.js'; import type { DashboardOpenSource } from '../dashboard/application/dashboard-open-source.js'; -import type { StoredWorkspaceV1 } from '../generated/json-schema.types.js'; +import type { StoredWorkspaceV2 } 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'; @@ -43,7 +43,7 @@ type Json = Record; * commit resolves (e.g. the created query id a tab must link to). Returning the * whole object as `null` also aborts. */ export interface WorkspaceMutationInput { - candidate: StoredWorkspaceV1 | null; + candidate: StoredWorkspaceV2 | null; data?: T; } @@ -55,7 +55,7 @@ export interface WorkspaceMutationInput { * three outcomes (undefined when the queued op rejected before the transform * ran). */ export type WorkspaceMutationOutcome = - | { ok: true; workspace: StoredWorkspaceV1; dashboardRevision: number | null; data?: T } + | { ok: true; workspace: StoredWorkspaceV2; dashboardRevision: number | null; data?: T } | { ok: false; aborted: true; data?: T } | { ok: false; aborted?: false; diagnostics: WorkspaceDiagnostic[]; data?: T }; @@ -74,7 +74,7 @@ export interface WorkspaceChangedMessage { * relative to the previous projection β€” the Dashboard route rebuilds its viewer * session on a query-only change even when the Dashboard document is identical. */ export interface WorkspaceExternallyChangedInfo { - workspace: StoredWorkspaceV1 | null; + workspace: StoredWorkspaceV2 | null; queriesChanged: boolean; } @@ -307,7 +307,7 @@ 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), + /** Atomic StoredWorkspaceV2 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 @@ -465,14 +465,13 @@ export interface App { * detached store, rewrite the URL to the durable `?ws=` form, and return the * detached workspace (or null when the token is missing/expired/undecodable). * ADR-0003. */ - consumeDashboardHandoff(): Promise; - /** #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; + consumeDashboardHandoff(): Promise; + /** Resolve the keyed or implicit StoredWorkspaceV2 for the Dashboard viewer. + * Returns null when no matching workspace (or requested Dashboard id) is + * available. */ + loadDashboardWorkspace(key?: string, dashboardId?: string): Promise; /** #287 W4: the async boot-init step β€” runs `loadDashboardWorkspace` - * (migrate-if-needed, then `workspace.loadCurrent()`) and, when it + * (resolve the explicit key or implicit last-opened workspace) and, when it * resolves a real aggregate, PROJECTS it onto `state` (`savedQueries`, * `dashboard`, `workspaceId`, `libraryName`) so the whole app (not only * the /dashboard route) treats the aggregate as the saved-query @@ -480,8 +479,8 @@ export interface App { * this before the first `renderApp()`. On a null/failed load, `state` * keeps whatever the legacy-projected `createState()` synchronous read * already populated (a brand-new install, or a degraded IndexedDB). */ - loadWorkspaceOnBoot(): Promise; - /** #287 W5: project a committed `StoredWorkspaceV1` onto `state` + loadWorkspaceOnBoot(): Promise; + /** #287 W5: project a committed `StoredWorkspaceV2` onto `state` * (`savedQueries`/`dashboard`/`workspaceId`/`libraryName`, and clear * `libraryDirty` β€” a fresh committed workspace is, by construction, in * sync with what's persisted) β€” the exact projection `loadWorkspaceOnBoot` @@ -490,13 +489,12 @@ export interface App { * (`updateSaveBtn`/`updateEditorModeUi`/`renderSavedHistory`) is the * caller's job β€” this never touches `app.dom` (it also runs during boot, * before the first `renderApp()`/mount). */ - applyCommittedWorkspace(workspace: StoredWorkspaceV1): void; - /** #287 W5: a fresh, unguessable id β€” the same generator - * `loadDashboardWorkspace`'s legacy migration already uses internally - * (`uid('ws-')`), exposed here as the injected `WorkspaceIdGen` the - * file-menu's New workspace / Import / Replace operations pass to - * `createNewWorkspace`/the import planner. One shared generator: a minted - * id only needs to be unique, never to encode which op minted it. */ + applyCommittedWorkspace(workspace: StoredWorkspaceV2): void; + /** #287 W5: a fresh, unguessable id (`uid('ws-')`), exposed here as the + * injected `WorkspaceIdGen` the file-menu's New workspace / Import / + * Replace operations pass to `createNewWorkspace`/the import planner. One + * shared generator: a minted id only needs to be unique, never to encode + * which op minted it. */ genId(): string; /** #287 review fix: serialize saved-query write operations per-app so two * overlapping async CRUD commits can't interleave. Each queued op runs only @@ -519,7 +517,7 @@ export interface App { * `serializeWrite`, so a mutation that committed while they awaited a user * dialog (or just lost the race) got silently clobbered by the later, * stale write. `mutateWorkspace` closes that window: the queued op reads - * the latest COMMITTED aggregate via `app.workspace.loadCurrent()` at + * the latest committed aggregate via `app.workspace.loadById()` at * DEQUEUE time (never cached in a variable outside the op β€” every other * producer commits through the same repository, so only a read taken * inside the queue slot is guaranteed fresh), hands it to `transform`, and @@ -528,7 +526,7 @@ export interface App { * `null`. Rejections propagate to the caller like `serializeWrite`'s own; * the queue itself never wedges. */ mutateWorkspace( - transform: (latest: StoredWorkspaceV1 | null) => + transform: (latest: StoredWorkspaceV2 | null) => WorkspaceMutationInput | null | Promise | null>, ): Promise>; /** #343 Β§5: this tab's random per-session id, minted through the crypto seam. diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 32d19496..1d330c9e 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -1,6 +1,6 @@ // 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` +// `dashboard.tiles[]`: this module resolves the current `StoredWorkspaceV2` // (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 @@ -86,7 +86,7 @@ import { loadJSON } from '../core/storage.js'; import { KEYS } from '../state.js'; import type { DashboardDocumentV1, DashboardFilterDefinitionV1, DashboardLayoutDocumentV1, FlowPresetV1, - SavedQueryV2, StoredWorkspaceV1, + SavedQueryV2, StoredWorkspaceV2, } from '../generated/json-schema.types.js'; import type { App, AppDom, ActionsRegistry } from './app.types.js'; import type { DashboardOpenSource } from '../dashboard/application/dashboard-open-source.js'; @@ -132,15 +132,15 @@ export interface DashboardApp { wallNow(): number; params: Pick; workspace: Pick; - loadDashboardWorkspace(): Promise; + loadDashboardWorkspace(key?: string, dashboardId?: string): Promise; // #288 Phase 6 β€” viewer routing (ADR-0003): the parsed open-source of this // tab, the detached-views lookup, the one-time-handoff consumer, and the // projection of the resolved workspace onto app.state (so the File menu's // export/import act on THIS dashboard). dashboardOpenSource: DashboardOpenSource | null; - detachedViews: Pick; - consumeDashboardHandoff(): Promise; - applyCommittedWorkspace(workspace: StoredWorkspaceV1): void; + detachedViews: Pick; + consumeDashboardHandoff(): Promise; + applyCommittedWorkspace(workspace: StoredWorkspaceV2): void; // #341/#344: every editable Dashboard command commits through // `mutateWorkspace` β€” the same serialized-queue-plus-read-at-dequeue seam // saved-query mutations use, so a rapid sequence of drag/resize/preset/ @@ -325,7 +325,7 @@ function renderDashboardNotFound(app: DashboardApp): void { * #347: only ever built for an EDITABLE (non-read-only) Dashboard β€” the * caller omits the control entirely for a read-only route. (Previously a * read-only view got an Export-only menu, but `exportDashboardAction` - * resolves the latest COMMITTED PRIMARY workspace via `workspace.loadCurrent()`, + * resolves the latest committed primary workspace via its immutable ID, * which may be unrelated to the read-only Dashboard on screen; hiding rows * wasn't enough, since the surviving Export button still exported the wrong * workspace.) Every item delegates to an `app.actions.*` seam (dashboard.ts @@ -406,7 +406,7 @@ export async function renderDashboard(app: DashboardApp): Promise { // current-workspace path. A resolution failure shows not-found β€” never a // different dashboard. const source = app.dashboardOpenSource; - let workspace: StoredWorkspaceV1 | null; + let workspace: StoredWorkspaceV2 | null; let readOnly = false; // #343 step 6: default to suppressing the cross-tab refresh until the mode is // resolved β€” an early not-found return then leaves this route inert w.r.t. @@ -423,8 +423,8 @@ export async function renderDashboard(app: DashboardApp): Promise { if (!workspace) { renderDashboardNotFound(app); return; } readOnly = true; } else if (source && source.kind === 'current-workspace') { - const primary = await app.loadDashboardWorkspace(); - const detached = await app.detachedViews.get(source.workspaceId); + const primary = await app.loadDashboardWorkspace(source.workspaceKey, source.dashboardId); + const detached = await app.detachedViews.getByKey(source.workspaceKey); const resolved = resolveDashboardMode(source, primary, detached); if (resolved.mode === 'not-found') { renderDashboardNotFound(app); return; } workspace = resolved.workspace; @@ -467,7 +467,7 @@ export async function renderDashboard(app: DashboardApp): Promise { // rebuild its candidate from this stale snapshot and silently reverse that // other producer's mutation. `null` when no persisted aggregate exists yet // (legacy/empty) β€” commands then stay optimistic-only, same as before #341. - let committedWorkspace: StoredWorkspaceV1 | null = workspace; + let committedWorkspace: StoredWorkspaceV2 | null = workspace; // #344 review fix: queued command DESCRIPTORS (dispatch order), not // pre-built document snapshots. A snapshot-based queue (the pre-#344 // `latestOptimistic` scheme) still lost updates: command B's optimistic doc @@ -987,7 +987,7 @@ export async function renderDashboard(app: DashboardApp): Promise { // moved past the route cache, so rebasing from the stale cache would // re-publish a document containing what the concurrent commit removed. // Stays `undefined` when the queued op rejected before the transform ran. - let observed: StoredWorkspaceV1 | null | undefined; + let observed: StoredWorkspaceV2 | null | undefined; void app.mutateWorkspace((latest) => { observed = latest; if (!latest || !latest.dashboard) return null; @@ -997,7 +997,7 @@ export async function renderDashboard(app: DashboardApp): Promise { const committedDoc = resolveLayoutPluginSync(reapplied.dashboard.layout).normalize(reapplied.dashboard); return { candidate: { - storageVersion: 1, id: latest.id, name: latest.name, queries: reapplied.queries, + storageVersion: 2, id: latest.id, key: latest.key, name: latest.name, queries: reapplied.queries, dashboard: { ...committedDoc, revision: base.revision + 1 }, }, }; @@ -1012,7 +1012,7 @@ export async function renderDashboard(app: DashboardApp): Promise { settleCommand(result, observed); }, () => { // The queued op itself REJECTED (blocked/quota/private-mode storage β€” - // `loadCurrent`/the store threw, distinct from an `ok:false` commit). + // the active-ID load/store threw, distinct from an `ok:false` commit). // Without this handler the rejection is unhandled and, worse, this // command would stay in `pendingCommands` forever, corrupting every // future rebase. @@ -1062,7 +1062,7 @@ export async function renderDashboard(app: DashboardApp): Promise { // One command's resolution β€” success, `ok:false`, transform null-abort, or // storage rejection (mapped to `ok:false` by the caller) β€” always: drop the // head descriptor, refresh committed truth, toast failure, rebase. - function settleCommand(result: WorkspaceCommitResult | null, observed: StoredWorkspaceV1 | null | undefined): void { + function settleCommand(result: WorkspaceCommitResult | null, observed: StoredWorkspaceV2 | null | undefined): void { // FIFO queue β€” every resolution arrives in dispatch order, so this // command is always the head. pendingCommands.shift(); @@ -1076,7 +1076,7 @@ export async function renderDashboard(app: DashboardApp): Promise { // the transform observed β€” the truth that rejected/invalidated this // command β€” so the rebase below never re-publishes a stale document // (a tile a concurrent producer removed staying visible). `undefined` - // means the op rejected before `loadCurrent()` resolved: nothing + // means the op rejected before the active-ID load resolved: nothing // fresher was observed, keep the current cache. if (observed !== undefined) { committedWorkspace = observed; diff --git a/src/ui/file-menu.ts b/src/ui/file-menu.ts index bafa6b24..234e0477 100644 --- a/src/ui/file-menu.ts +++ b/src/ui/file-menu.ts @@ -1,13 +1,10 @@ // The header "File β–Ύ" menu (#287 W5): resource-oriented portable-bundle // workspace operations β€” New workspace / Import queries / Import Dashboard / -// Open workspace / Export Dashboard / Export workspace β€” plus the kept +// Import workspace / Export Dashboard / Export workspace β€” plus the kept // "Open as dashboard" (#149), the Variable history section, and the one-way // Markdown/SQL "share" downloads (buildMarkdownDoc/buildSqlDoc, unchanged). -// #342 reordered the Workbench menu around an unlabeled primary group (New / -// Open / Export workspace / Import queries), renamed "Replace workspace…" to -// "Open workspace…" (same destructive whole-workspace replace semantics β€” -// see `startOpenWorkspace`/`confirmOpenWorkspace` below), and moved Share/ -// Publish above Variable history. +// #406 makes workspace import additive: local identity is reminted, the +// generated key is made unique, and the previously active record is untouched. // The legacy Library New/Save-JSON/Open-replace/Append ops are gone; every // write here builds a `PortableBundleImportPlan` (workspace/import-planner.js) // or a repository-level primitive (workspace/workspace-operations.js) INSIDE @@ -42,8 +39,9 @@ import type { QueryDecision, QueryConflict, QueryConflictAction, DashboardSummary, PortableBundleImportPlan, } from '../workspace/import-planner.js'; import { createNewWorkspace, renameWorkspace } from '../workspace/workspace-operations.js'; +import { deriveWorkspaceKey } from '../core/workspace-key.js'; import type { App } from './app.types.js'; -import type { PortableBundleV1, SavedQueryV2, StoredWorkspaceV1 } from '../generated/json-schema.types.js'; +import type { PortableBundleV1, SavedQueryV2, StoredWorkspaceV2 } from '../generated/json-schema.types.js'; import type { WorkspaceDiagnostic } from '../dashboard/model/workspace-diagnostics.js'; /** Workspace/library name β†’ safe file base (strips path/illegal chars, @@ -170,7 +168,7 @@ export function openFileMenu(app: App): void { // then Variable history β€” the query-count footer stays last. const rows: MenuRow[] = [ { kind: 'item', icon: Icon.plus(), label: 'New workspace…', onClick: () => newWorkspaceAction(app) }, - { kind: 'item', icon: Icon.folderOpen(), label: 'Open workspace…', onClick: () => openWorkspaceInput.click() }, + { kind: 'item', icon: Icon.folderOpen(), label: 'Import workspace…', onClick: () => openWorkspaceInput.click() }, { kind: 'item', icon: Icon.download(), label: 'Export workspace…', meta: '.json', onClick: () => exportWorkspaceAction(app) }, { kind: 'item', icon: Icon.upload(), label: 'Import queries…', onClick: () => importQueriesInput.click() }, { kind: 'sep' }, @@ -193,7 +191,7 @@ export function openFileMenu(app: App): void { const handle = openMenu({ document: doc, trigger: app.dom.fileBtn!, rows }); // The hidden file pickers aren't menu ROWS (no label/click chrome of their // own) β€” they're display:none inputs `.click()`-triggered by the Import - // queries / Open workspace items above. Parent them to the mounted menu + // queries / Import workspace items above. Parent them to the mounted menu // so they're torn down with it on close (no leak) and `picker(i)`-style // lookups (`.file-menu input[type=file]`) keep finding them. The item click // closes the menu (detaching these) BEFORE running its onClick, so the @@ -267,10 +265,11 @@ function readBundleFile(app: App, file: File, onBundle: (bundle: PortableBundleV /** The current committed aggregate, reconstructed from `state` β€” W4 keeps * `state.savedQueries`/`dashboard`/`workspaceId`/`libraryName` as a live * projection of it, so this never needs its own read of `app.workspace`. */ -function currentWorkspace(app: App): StoredWorkspaceV1 { +function currentWorkspace(app: App): StoredWorkspaceV2 { return { - storageVersion: 1, + storageVersion: 2, id: app.state.workspaceId, + key: app.state.workspaceKey, name: app.state.libraryName.value, queries: app.state.savedQueries, dashboard: app.state.dashboard, @@ -292,7 +291,7 @@ function afterLibraryChange(app: App): void { app.updateEditorModeUi!(); renderSavedHistory(app); // Keep the header "Dashboard β†’" control in sync when a commit adds/removes - // the workspace's Dashboard (e.g. Open workspace / Import queries). + // the workspace's Dashboard (e.g. Import workspace / Import queries). renderDashboardNav(app); } @@ -307,7 +306,7 @@ function afterLibraryChange(app: App): void { * (schema/persistence failure) toasts the first diagnostic. Never a partial * write either way. */ async function commitWorkspace( - app: App, build: (latest: StoredWorkspaceV1 | null) => StoredWorkspaceV1 | null, + app: App, build: (latest: StoredWorkspaceV2 | null) => StoredWorkspaceV2 | null, successMsg?: string | (() => string), ): Promise { const result = await app.mutateWorkspace((latest) => { @@ -339,7 +338,7 @@ async function commitWorkspace( * new content-DIFFERING conflict aborts (`null`) β€” the user must re-run the * import and decide against the workspace as it now is. */ function revalidateDecisions( - base: StoredWorkspaceV1, incoming: readonly SavedQueryV2[], decisions: readonly QueryDecision[], + base: StoredWorkspaceV2, incoming: readonly SavedQueryV2[], decisions: readonly QueryDecision[], ): QueryDecision[] | null { const conflicts = detectQueryConflicts(base.queries, incoming); const decided = new Set(decisions.map((decision) => decision.sourceId)); @@ -358,8 +357,8 @@ function revalidateDecisions( * Dashboard dependency) β€” never a partial/invalid/silently-lossy commit. */ function planBuild( app: App, incoming: readonly SavedQueryV2[], decisions: readonly QueryDecision[], - run: (base: StoredWorkspaceV1, decisions: readonly QueryDecision[]) => PortableBundleImportPlan, -): (latest: StoredWorkspaceV1 | null) => StoredWorkspaceV1 | null { + run: (base: StoredWorkspaceV2, decisions: readonly QueryDecision[]) => PortableBundleImportPlan, +): (latest: StoredWorkspaceV2 | null) => StoredWorkspaceV2 | null { return (latest) => { const base = latest ?? currentWorkspace(app); const revalidated = revalidateDecisions(base, incoming, decisions); @@ -465,7 +464,7 @@ function openConflictDialog( // ── multi-dashboard picker ─────────────────────────────────────────────────── /** Show a picker over `dashboards` (bundle array order β€” presentation order, - * not re-sorted); `allowNone` adds a "No dashboard" row (Open workspace's + * not re-sorted); `allowNone` adds a "No dashboard" row (workspace import's * own owner decision β€” Import Dashboard never offers it, since it must * import exactly one). Cancelling calls neither branch of `onPick`. */ function openDashboardPicker( @@ -490,26 +489,31 @@ function openDashboardPicker( // ── actions: New workspace ─────────────────────────────────────────────────── function newWorkspaceAction(app: App): void { - const cur = currentWorkspace(app); - if (cur.queries.length || cur.dashboard) confirmNewWorkspace(app, cur); - else void doNewWorkspace(app); + void doNewWorkspace(app); } async function doNewWorkspace(app: App): Promise { - // Independent of `latest` (a brand-new workspace never derives from the - // current one) β€” still routed through `commitWorkspace`/`mutateWorkspace` - // for the same one write-path discipline every other mutation here uses. - await commitWorkspace(app, () => createNewWorkspace(app.genId), 'Started a new workspace'); -} - -function confirmNewWorkspace(app: App, cur: StoredWorkspaceV1): void { - openConfirm(app, { - title: 'Start a new workspace?', - body: ['This clears your current ', h('b', null, String(cur.queries.length)), ' saved ', - cur.queries.length === 1 ? 'query' : 'queries', cur.dashboard ? ' and your Dashboard' : '', - '. Open editor tabs are unaffected. Export first if you want to keep them.'], - confirmLabel: 'New workspace', - onConfirm: () => void doNewWorkspace(app), + await app.serializeWrite(async () => { + const listed = await app.workspace.list(); + const name = 'SQL Library'; + const key = deriveWorkspaceKey(name, [ + ...listed.summaries.map((item) => item.key), + ...listed.corrupt.map((item) => item.key), + ]); + const result = await app.workspace.create(createNewWorkspace(app.genId, key, name)); + if (!result.ok) { + flashToast('βœ• ' + first(result.diagnostics, 'Could not create workspace'), { document: app.document }); + return; + } + app.applyCommittedWorkspace(result.workspace); + const opened = await app.workspace.markOpened(result.workspace.key); + afterLibraryChange(app); + flashToast( + opened.ok + ? 'Started a new workspace' + : 'Started a new workspace, but its last-used timestamp could not be saved.', + { document: app.document }, + ); }); } @@ -584,8 +588,8 @@ function startImportDashboard(app: App, bundle: PortableBundleV1): void { function runImportDashboard(app: App, bundle: PortableBundleV1, dashboardId: string): void { // v1 holds at most one Dashboard, so importing one REPLACES the current // Dashboard (its tiles/layout/filters). Confirm first when that would discard - // an existing Dashboard β€” matching New workspace/Open workspace, which also - // gate destructive commits (#287; flagged in review β€” silent, unrecoverable loss). + // an existing Dashboard β€” unlike additive New/Import workspace, this gates + // a destructive commit (#287; flagged in review β€” silent, unrecoverable loss). if (app.state.dashboard) { openConfirm(app, { title: 'Import and replace current Dashboard?', @@ -620,11 +624,7 @@ function doImportDashboard(app: App, bundle: PortableBundleV1, dashboardId: stri }); } -// ── actions: Open workspace ────────────────────────────────────────────────── -// #342: user-facing rename of "Replace workspace…" β€” same destructive -// whole-workspace replace semantics (picker β†’ plan β†’ confirm β†’ commit), just -// relabeled. `planReplaceWorkspace` (import-planner.js) keeps its own name; -// it's an internal primitive, not user-facing copy. +// ── actions: Import workspace ──────────────────────────────────────────────── function onOpenWorkspaceFile(app: App, file: File): void { readBundleFile(app, file, (bundle) => startOpenWorkspace(app, bundle)); @@ -633,36 +633,44 @@ function onOpenWorkspaceFile(app: App, file: File): void { function startOpenWorkspace(app: App, bundle: PortableBundleV1): void { const dashboards = listBundleDashboards(bundle); if (dashboards.length > 1) { - openDashboardPicker(app, 'Open workspace β€” which dashboard?', dashboards, true, (id) => { - confirmOpenWorkspace(app, bundle, id === null ? undefined : id); + openDashboardPicker(app, 'Import workspace β€” which dashboard?', dashboards, true, (id) => { + void importWorkspace(app, bundle, id === null ? undefined : id); }); return; } - confirmOpenWorkspace(app, bundle, dashboards[0]?.id); -} - -function confirmOpenWorkspace(app: App, bundle: PortableBundleV1, sourceDashboardId: string | undefined): void { - const cur = currentWorkspace(app); - openConfirm(app, { - title: 'Open workspace?', - body: ['Opening this file replaces your current ', h('b', null, String(cur.queries.length)), ' saved ', - cur.queries.length === 1 ? 'query' : 'queries', cur.dashboard ? ' and your Dashboard' : '', - ' with ', h('b', null, String(bundle.queries.length)), ' ', queries(bundle.queries.length), - sourceDashboardId ? ' and the selected Dashboard' : '', ' from the file. Open editor tabs are unaffected.'], - confirmLabel: 'Open workspace', - onConfirm: () => { - // Same snapshot-for-the-dialog / re-plan-against-latest-for-the-commit - // split as `startImportQueries`/`doImportDashboard` above (#341/#344) β€” - // `cur` above (the confirm body's own snapshot) has the same property. - const workspace = currentWorkspace(app); - withQueryDecisions(app, workspace.queries, bundle.queries, (decisions) => { - void commitWorkspace( - app, planBuild(app, bundle.queries, decisions, - (base, revalidated) => planReplaceWorkspace(base, bundle, sourceDashboardId, revalidated, app.genId)), - 'Opened workspace', - ); - }); - }, + void importWorkspace(app, bundle, dashboards[0]?.id); +} + +async function importWorkspace( + app: App, bundle: PortableBundleV1, sourceDashboardId: string | undefined, +): Promise { + await app.serializeWrite(async () => { + const listed = await app.workspace.list(); + const name = bundle.metadata?.name?.trim() || 'Imported workspace'; + const key = deriveWorkspaceKey(name, [ + ...listed.summaries.map((item) => item.key), + ...listed.corrupt.map((item) => item.key), + ]); + const base = createNewWorkspace(app.genId, key, name); + const plan = planReplaceWorkspace(base, bundle, sourceDashboardId, [], app.genId); + if (!plan.candidateWorkspace) { + flashToast('βœ• ' + first(plan.diagnostics, 'Import failed'), { document: app.document }); + return; + } + const result = await app.workspace.create(plan.candidateWorkspace); + if (!result.ok) { + flashToast('βœ• ' + first(result.diagnostics, 'Could not import workspace'), { document: app.document }); + return; + } + app.applyCommittedWorkspace(result.workspace); + const opened = await app.workspace.markOpened(result.workspace.key); + afterLibraryChange(app); + flashToast( + opened.ok + ? 'Imported workspace' + : 'Imported workspace, but its last-used timestamp could not be saved.', + { document: app.document }, + ); }); } @@ -682,10 +690,11 @@ function downloadEncodedBundle(app: App, bundle: PortableBundleV1, baseName: str * when the flush/read REJECTS (blocked/quota/private-mode IndexedDB); the * callers then fall back to the pre-#341 `app.state`-derived reads, so an * export never becomes a silent no-op on an unhandled rejection. */ -async function flushAndLoadCommitted(app: App): Promise { +async function flushAndLoadCommitted(app: App): Promise { try { await app.flushWorkspaceWrites(); - return await app.workspace.loadCurrent(); + const result = await app.workspace.loadById(app.state.workspaceId); + return result.status === 'ok' ? result.workspace : null; } catch { return null; } diff --git a/src/ui/icons.ts b/src/ui/icons.ts index b4a77782..1f2e5a1f 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -106,7 +106,7 @@ export const Icon = { undo: () => svg('M4.5 3.5 2 6l2.5 2.5M2 6h5a2.5 2.5 0 0 1 2.5 2.5', 12, 12), redo: () => svg('M7.5 3.5 10 6l-2.5 2.5M10 6H5a2.5 2.5 0 0 0-2.5 2.5', 12, 12), bookmark: () => iconEl('', 12, 12, 1.3), - // File menu "Open workspace…" (#342): an open folder, distinct from the + // File menu "Import workspace…": an open folder, distinct from the // closed-loop `refresh` glyph it replaces there. folderOpen: () => iconEl('', 12, 12, 1.3), // #314 β€” the schema-surface "Open engine/type reference" actions' icon (an diff --git a/src/workspace/detached-views-store.types.ts b/src/workspace/detached-views-store.types.ts index f403898c..4cfb0957 100644 --- a/src/workspace/detached-views-store.types.ts +++ b/src/workspace/detached-views-store.types.ts @@ -1,17 +1,17 @@ // The injected persistence seam for detached VIEW-mode Dashboard snapshots // (#288 Phase 6 handoff). A detached view is a read-only copy of a Dashboard // materialized into its own store under a fresh workspace id β€” distinct from -// the single shared primary `asb-workspace` aggregate (`WorkspaceStore`). +// the shared primary `asb-workspaces-v2` collection (`WorkspaceStore`). // Many records may accumulate over time (one per "Open for viewing…"), so // unlike the primary store this seam is a small keyed collection with a // retention cap, not a single aggregate record. // // Type-only (ADR-0002 seam contract) β€” no executable statements, excluded // from the coverage gate like every other `*.types.ts`. -import type { StoredWorkspaceV1 } from '../generated/json-schema.types.js'; +import type { StoredWorkspaceV2 } from '../generated/json-schema.types.js'; export interface DetachedViewRecord { - workspace: StoredWorkspaceV1; + workspace: StoredWorkspaceV2; savedAt: number; } @@ -21,5 +21,7 @@ export interface DetachedViewsStore { * `maxRecords` (by `savedAt`) so it cannot grow unbounded. */ put(record: DetachedViewRecord): Promise; /** Load one detached workspace by id, or `null` when absent. */ - get(id: string): Promise; + get(id: string): Promise; + /** Load one detached workspace by its canonical URL key. */ + getByKey(key: string): Promise; } diff --git a/src/workspace/import-planner.ts b/src/workspace/import-planner.ts index 09dfee4e..3e7ba529 100644 --- a/src/workspace/import-planner.ts +++ b/src/workspace/import-planner.ts @@ -5,7 +5,7 @@ // in tests and unguessable in production. // // A PortableBundle import always resolves to one COMPLETE candidate -// StoredWorkspaceV1 built from the repository-level primitives in +// StoredWorkspaceV2 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 @@ -30,7 +30,7 @@ import type { WorkspaceIdGen } from './workspace-operations.js'; import { validateStoredWorkspaceDocument } from './stored-workspace.js'; import type { WorkspaceCodecOptions } from './stored-workspace.js'; import type { - DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV1, + DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV2, } from '../generated/json-schema.types.js'; // --- Dashboard listing ------------------------------------------------------- @@ -272,7 +272,7 @@ function mergeIncomingQueries( return [...merged, ...additions]; } -/** REPLACE the query catalog wholesale (File menu's "Open workspace…", #342): +/** Replace the query catalog wholesale when constructing an imported workspace: * only queries * reachable from the incoming bundle survive, in bundle order; 'use-existing' * keeps the existing query's own content (still under the shared id) rather @@ -301,7 +301,7 @@ function replaceIncomingQueries( export interface PortableBundleImportPlan { sourceDashboardId?: string; queryMappings: IdMapping; - candidateWorkspace: StoredWorkspaceV1 | null; + candidateWorkspace: StoredWorkspaceV2 | null; diagnostics: WorkspaceDiagnostic[]; } @@ -315,7 +315,7 @@ function invalidPlan( } function validatedPlan( - candidate: StoredWorkspaceV1, queryMappings: IdMapping, options: WorkspaceCodecOptions, sourceDashboardId?: string, + candidate: StoredWorkspaceV2, queryMappings: IdMapping, options: WorkspaceCodecOptions, sourceDashboardId?: string, ): PortableBundleImportPlan { const diagnostics = validateStoredWorkspaceDocument(candidate, options); if (diagnostics.length) return invalidPlan(diagnostics, queryMappings, sourceDashboardId); @@ -349,7 +349,7 @@ function invalidatedDashboardPlan( /** Queries-only import (Dashboard untouched): merge the bundle's queries into * the workspace's query catalog per `decisions`, and validate the result. */ export function planImportQueries( - workspace: StoredWorkspaceV1, bundle: PortableBundleV1, + workspace: StoredWorkspaceV2, bundle: PortableBundleV1, decisions: readonly QueryDecision[], genId: WorkspaceIdGen, options: WorkspaceCodecOptions = {}, ): PortableBundleImportPlan { @@ -365,7 +365,7 @@ export function planImportQueries( * or unmapped required dependency invalidates the plan (`candidateWorkspace: * null`) rather than silently dropping the reference. */ export function planImportDashboard( - workspace: StoredWorkspaceV1, bundle: PortableBundleV1, sourceDashboardId: string, + workspace: StoredWorkspaceV2, bundle: PortableBundleV1, sourceDashboardId: string, decisions: readonly QueryDecision[], mode: 'copy' | 'replace', genId: WorkspaceIdGen, options: WorkspaceCodecOptions = {}, ): PortableBundleImportPlan { @@ -395,7 +395,7 @@ export function planImportDashboard( * Dashboard, keeping its own id/revision. Omit `sourceDashboardId` to * replace with a query-only workspace (Dashboard cleared to `null`). */ export function planReplaceWorkspace( - workspace: StoredWorkspaceV1, bundle: PortableBundleV1, sourceDashboardId: string | undefined, + workspace: StoredWorkspaceV2, bundle: PortableBundleV1, sourceDashboardId: string | undefined, decisions: readonly QueryDecision[], genId: WorkspaceIdGen, options: WorkspaceCodecOptions = {}, ): PortableBundleImportPlan { diff --git a/src/workspace/indexeddb-detached-views-store.ts b/src/workspace/indexeddb-detached-views-store.ts index 35793a81..f139ccc2 100644 --- a/src/workspace/indexeddb-detached-views-store.ts +++ b/src/workspace/indexeddb-detached-views-store.ts @@ -2,7 +2,7 @@ // (#288 Phase 6 β€” VIEW-mode Dashboard handoff). Each detached view is a // read-only snapshot of one Dashboard, keyed by its own fresh workspace id, // stored in a DEDICATED database separate from the single shared primary -// `asb-workspace` aggregate β€” a detached view is not the editable primary +// `asb-workspaces-v2` collection β€” a detached view is not the editable primary // workspace and must never be reachable through that store's key. Because // "Open for viewing…" can be used repeatedly, this store is a small keyed // collection with a retention cap (newest `maxRecords` by `savedAt`), unlike @@ -13,7 +13,8 @@ // that file for the seam-pattern rationale. import type { DetachedViewRecord, DetachedViewsStore } from './detached-views-store.types.js'; -import type { StoredWorkspaceV1 } from '../generated/json-schema.types.js'; +import type { StoredWorkspaceV2 } from '../generated/json-schema.types.js'; +import { normalizeWorkspaceKeyLookup } from '../core/workspace-key.js'; export interface IndexedDbDetachedViewsStoreOptions { /** IndexedDB database name. */ @@ -124,12 +125,20 @@ export function createIndexedDbDetachedViewsStore( await transactionDone(tx); } - async function get(id: string): Promise { + async function get(id: string): Promise { const db = await openDb(); const tx = db.transaction([storeName], 'readonly'); const record = await requestResult(tx.objectStore(storeName).get(id)); return record ? record.workspace : null; } - return { put, get }; + async function getByKey(key: string): Promise { + const db = await openDb(); + const tx = db.transaction([storeName], 'readonly'); + const records = await requestResult(tx.objectStore(storeName).getAll()); + const normalized = normalizeWorkspaceKeyLookup(key); + return records.find((record) => record.workspace.key === normalized)?.workspace ?? null; + } + + return { put, get, getByKey }; } diff --git a/src/workspace/indexeddb-handoff-store.ts b/src/workspace/indexeddb-handoff-store.ts index 5c7c0047..aba50cba 100644 --- a/src/workspace/indexeddb-handoff-store.ts +++ b/src/workspace/indexeddb-handoff-store.ts @@ -2,9 +2,9 @@ // Dashboard view-mode handoff). Structured exactly like // `indexeddb-workspace-store.ts` (lazy cached `openDb`, `requestResult`, // `transactionDone`, cache dropped on a failed open) but backed by its OWN -// dedicated database β€” never the `asb-workspace` DB β€” since a handoff token +// dedicated database β€” never the `asb-workspaces-v2` DB β€” since a handoff token // is a short-lived, one-time-consumed record with its own lifecycle, unlike -// the workspace aggregate's single-record replace-whole-thing semantics. +// an editable workspace record's replace-whole-aggregate semantics. // // The `IDBFactory` is injected the same way as the workspace store, so tests // drive this with a minimal in-memory fake factory instead of a real browser diff --git a/src/workspace/indexeddb-workspace-store.ts b/src/workspace/indexeddb-workspace-store.ts index 26ba466b..1a5d553b 100644 --- a/src/workspace/indexeddb-workspace-store.ts +++ b/src/workspace/indexeddb-workspace-store.ts @@ -1,34 +1,26 @@ -// 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'; +import type { + WorkspaceStore, + WorkspaceStoreCreateResult, + WorkspaceStoreMarkOpenedResult, + WorkspaceStoreRecord, + WorkspaceStoreReplaceResult, +} 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; + workspaceStoreName?: string; + preferenceStoreName?: string; + keyIndexName?: string; } const DEFAULTS = { - dbName: 'asb-workspace', - storeName: 'workspace', - recordKey: 'current', + dbName: 'asb-workspaces-v2', + workspaceStoreName: 'workspaces', + preferenceStoreName: 'preferences', + keyIndexName: 'by-key', } as const; +const LAST_USED_KEY = 'last-used-key'; -// Promisify one IDBRequest. function requestResult(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); @@ -36,7 +28,6 @@ function requestResult(request: IDBRequest): Promise { }); } -// Resolve when a readwrite transaction durably completes (the atomicity point). function transactionDone(tx: IDBTransaction): Promise { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); @@ -45,16 +36,18 @@ function transactionDone(tx: IDBTransaction): Promise { }); } -/** 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. */ +function isConstraintError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'ConstraintError'; +} + export function createIndexedDbWorkspaceStore( - factory: IDBFactory | undefined, options: IndexedDbWorkspaceStoreOptions = {}, + factory: IDBFactory | undefined, + options: IndexedDbWorkspaceStoreOptions = {}, ): WorkspaceStore { const dbName = options.dbName ?? DEFAULTS.dbName; - const storeName = options.storeName ?? DEFAULTS.storeName; - const recordKey = options.recordKey ?? DEFAULTS.recordKey; + const workspaceStoreName = options.workspaceStoreName ?? DEFAULTS.workspaceStoreName; + const preferenceStoreName = options.preferenceStoreName ?? DEFAULTS.preferenceStoreName; + const keyIndexName = options.keyIndexName ?? DEFAULTS.keyIndexName; let dbPromise: Promise | null = null; function openDb(): Promise { @@ -67,44 +60,151 @@ export function createIndexedDbWorkspaceStore( const request = factory.open(dbName, 1); request.onupgradeneeded = () => { const db = request.result; - if (!db.objectStoreNames.contains(storeName)) db.createObjectStore(storeName); + db.createObjectStore(workspaceStoreName, { keyPath: 'id' }) + .createIndex(keyIndexName, 'key', { unique: true }); + db.createObjectStore(preferenceStoreName); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed')); - // Dormant at version 1 (no upgrade can block a fresh open), but an - // unhandled `blocked` event would hang the open forever β€” reject so the - // no-cache-on-failure path below reopens on the next call. request.onblocked = () => reject(new Error('IndexedDB open blocked')); }); - // Cache only a SUCCESSFUL open. A rejected open must not poison the store - // for the page's lifetime (one createApp() builds one long-lived store): - // drop the cached promise on failure so a same-session retry can reopen, - // matching the repository's "failed write leaves a draft to retry" contract. dbPromise = pending; - pending.catch(() => { if (dbPromise === pending) dbPromise = null; }); + pending.catch(() => { dbPromise = null; }); return pending; } - async function read(): Promise { + async function list(): Promise { + const db = await openDb(); + const tx = db.transaction([workspaceStoreName], 'readonly'); + return requestResult( + tx.objectStore(workspaceStoreName).getAll() as IDBRequest, + ); + } + + async function readById(id: string): Promise { + const db = await openDb(); + const tx = db.transaction([workspaceStoreName], 'readonly'); + const value = await requestResult( + tx.objectStore(workspaceStoreName).get(id) as IDBRequest, + ); + return value ?? null; + } + + async function readByKey(key: string): Promise { + const db = await openDb(); + const tx = db.transaction([workspaceStoreName], 'readonly'); + const value = await requestResult( + tx.objectStore(workspaceStoreName).index(keyIndexName).get(key) as + IDBRequest, + ); + return value ?? null; + } + + async function create(record: WorkspaceStoreRecord): Promise { const db = await openDb(); - const tx = db.transaction([storeName], 'readonly'); - const value = await requestResult(tx.objectStore(storeName).get(recordKey)); + const tx = db.transaction([workspaceStoreName], 'readwrite'); + const done = transactionDone(tx); + try { + await requestResult(tx.objectStore(workspaceStoreName).add(record)); + await done; + return { status: 'created' }; + } catch (error) { + await done.catch(() => undefined); + if (!isConstraintError(error)) throw error; + // The unique index and keyPath performed the atomic enforcement. These + // post-failure reads only turn its ConstraintError into a useful outcome. + if (await readById(record.id)) return { status: 'duplicate-id' }; + if (await readByKey(record.key)) return { status: 'duplicate-key' }; + throw error; + } + } + + async function replace(record: WorkspaceStoreRecord): Promise { + const db = await openDb(); + const tx = db.transaction([workspaceStoreName], 'readwrite'); + const done = transactionDone(tx); + const objectStore = tx.objectStore(workspaceStoreName); + const existing = await requestResult( + objectStore.get(record.id) as IDBRequest, + ); + if (!existing) { + await done; + return { status: 'not-found' }; + } + if (existing.key !== record.key) { + await done; + return { status: 'immutable-key' }; + } + objectStore.put({ ...record, lastOpenedAt: existing.lastOpenedAt }); + await done; + return { status: 'replaced' }; + } + + async function deleteWorkspace(id: string): Promise { + const db = await openDb(); + const tx = db.transaction([workspaceStoreName, preferenceStoreName], 'readwrite'); + const done = transactionDone(tx); + const workspaceStore = tx.objectStore(workspaceStoreName); + const preferenceStore = tx.objectStore(preferenceStoreName); + const existing = await requestResult( + workspaceStore.get(id) as IDBRequest, + ); + if (!existing) { + await done; + return false; + } + workspaceStore.delete(id); + const lastUsed = await requestResult(preferenceStore.get(LAST_USED_KEY)); + if (lastUsed === existing.key) preferenceStore.delete(LAST_USED_KEY); + await done; + return true; + } + + async function getLastUsedKey(): Promise { + const db = await openDb(); + const tx = db.transaction([preferenceStoreName], 'readonly'); + const value = await requestResult(tx.objectStore(preferenceStoreName).get(LAST_USED_KEY)); return typeof value === 'string' ? value : null; } - async function write(text: string): Promise { + async function markOpened( + key: string, + timestamp: number, + ): Promise { const db = await openDb(); - const tx = db.transaction([storeName], 'readwrite'); - tx.objectStore(storeName).put(text, recordKey); - await transactionDone(tx); + const tx = db.transaction([workspaceStoreName, preferenceStoreName], 'readwrite'); + const done = transactionDone(tx); + const workspaceStore = tx.objectStore(workspaceStoreName); + const existing = await requestResult( + workspaceStore.index(keyIndexName).get(key) as + IDBRequest, + ); + if (!existing) { + await done; + return { status: 'not-found' }; + } + workspaceStore.put({ ...existing, lastOpenedAt: timestamp }); + tx.objectStore(preferenceStoreName).put(key, LAST_USED_KEY); + await done; + return { status: 'opened' }; } - async function clear(): Promise { + async function clearLastUsedKey(): Promise { const db = await openDb(); - const tx = db.transaction([storeName], 'readwrite'); - tx.objectStore(storeName).delete(recordKey); + const tx = db.transaction([preferenceStoreName], 'readwrite'); + tx.objectStore(preferenceStoreName).delete(LAST_USED_KEY); await transactionDone(tx); } - return { read, write, clear }; + return { + list, + readById, + readByKey, + create, + replace, + delete: deleteWorkspace, + getLastUsedKey, + markOpened, + clearLastUsedKey, + }; } diff --git a/src/workspace/legacy-migration.ts b/src/workspace/legacy-migration.ts deleted file mode 100644 index 3caa8637..00000000 --- a/src/workspace/legacy-migration.ts +++ /dev/null @@ -1,132 +0,0 @@ -// 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 nearest valid single-column flow preset, - * `report` (#321: `full-width` was removed from flow@1 entirely); `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' ? 'report' : view; -} - -/** Build the one candidate StoredWorkspaceV1 from the legacy state (steps 2-4). - * The initial Dashboard's tiles are the PANEL-role favorites in catalog order: - * a favorited filter/setup-role query cannot be a tile by the #280 contract, - * so it is not turned into one (it stays in the query collection). The - * Dashboard is always created so the legacy layout preference is preserved - * even for a user with no favorites yet; it starts at revision 1. `genId` is - * called once for the workspace ID, once for the Dashboard ID, and once per - * tile. The candidate is NOT validated here β€” the caller validates the whole - * thing through `repository.commit`. */ -export function buildLegacyMigrationCandidate( - legacy: LegacyWorkspaceInput, genId: WorkspaceIdGen, -): StoredWorkspaceV1 { - const name = legacy.name.trim() ? legacy.name : DEFAULT_WORKSPACE_NAME; - const queries = [...legacy.queries]; - const workspaceId = genId(); - const dashboardId = genId(); - const tiles = queries - .filter((query) => queryFavorite(query) && queryDashboardRole(query) === 'panel') - .map((query) => ({ id: genId(), queryId: query.id })); - return { - storageVersion: CURRENT_STORAGE_VERSION, - id: workspaceId, - name, - queries, - dashboard: { - documentVersion: 1, - id: dashboardId, - title: name, - revision: 1, - layout: { - type: 'flow', - version: 1, - preset: legacyLayoutToFlowPreset(legacy.dashLayout, legacy.dashCols), - items: {}, - }, - filters: [], - tiles, - }, - }; -} - -/** The outcome of `migrateLegacyWorkspaceIfNeeded`. `migrated: false` with - * `reason: 'aggregate-exists'` means the marker found a record and skipped; - * `reason: 'commit-failed'` carries the whole-candidate validation or - * persistence diagnostics (legacy keys were left intact). */ -export type MigrationResult = - | { migrated: true; result: Extract } - | { migrated: false; reason: 'aggregate-exists' } - | { migrated: false; reason: 'commit-failed'; diagnostics: WorkspaceDiagnostic[] }; - -export interface MigrationDeps { - /** Checked for record existence β€” the migration marker. */ - store: WorkspaceStore; - /** The repository whose atomic `commit` validates + persists the candidate. */ - repository: WorkspaceRepository; - legacy: LegacyWorkspaceInput; - genId: WorkspaceIdGen; -} - -/** Run the one-shot migration when β€” and only when β€” no aggregate record - * exists yet. Idempotent: once the aggregate persists, a later call finds the - * record and returns `aggregate-exists` without rebuilding or rewriting. A - * failed commit leaves the store (and every legacy key) untouched, so a retry - * on the next load is safe. */ -export async function migrateLegacyWorkspaceIfNeeded(deps: MigrationDeps): Promise { - const { store, repository, legacy, genId } = deps; - const existing = await store.read(); - if (existing !== null) return { migrated: false, reason: 'aggregate-exists' }; - const candidate = buildLegacyMigrationCandidate(legacy, genId); - const result = await repository.commit(candidate); - if (!result.ok) return { migrated: false, reason: 'commit-failed', diagnostics: result.diagnostics }; - return { migrated: true, result }; -} diff --git a/src/workspace/stored-workspace.ts b/src/workspace/stored-workspace.ts index 12ff67eb..620e1b0f 100644 --- a/src/workspace/stored-workspace.ts +++ b/src/workspace/stored-workspace.ts @@ -1,5 +1,5 @@ -// StoredWorkspaceV1 contract codec and whole-workspace validation (#280 -// "Internal persistence: StoredWorkspaceV1", phase 1 of #283). The atomic +// StoredWorkspaceV2 contract codec and whole-workspace validation (#280 +// "Internal persistence: StoredWorkspaceV2", 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- @@ -20,11 +20,11 @@ import { } 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'; +import type { StoredWorkspaceV2 } 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 const CURRENT_STORED_WORKSPACE_VERSION = 2; +export const STORED_WORKSPACE_V2_SCHEMA_ID = + 'https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v2.schema.json'; export type WorkspaceFailResult = { ok: false; diagnostics: WorkspaceDiagnostic[] }; @@ -69,7 +69,7 @@ export function validateStoredWorkspaceDocument( 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) + const structural = validationService.validate(STORED_WORKSPACE_V2_SCHEMA_ID, document) .filter((item) => !(item.path[0] === 'queries' && skipQueryIndexes.has(item.path[1])) && !(skipDashboard && item.path[0] === 'dashboard')); if (versionDiagnostics.length || structural.length) { @@ -83,7 +83,7 @@ export function validateStoredWorkspaceDocument( ]); } -export type DecodeStoredWorkspaceResult = { ok: true; value: StoredWorkspaceV1 } | WorkspaceFailResult; +export type DecodeStoredWorkspaceResult = { ok: true; value: StoredWorkspaceV2 } | WorkspaceFailResult; /** Parse and fully validate stored-workspace JSON text. */ export function decodeStoredWorkspaceJson( @@ -93,7 +93,7 @@ export function decodeStoredWorkspaceJson( 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 }; + return { ok: true, value: parsed.value as StoredWorkspaceV2 }; } export type EncodeStoredWorkspaceResult = { ok: true; value: string } | WorkspaceFailResult; diff --git a/src/workspace/workspace-operations.ts b/src/workspace/workspace-operations.ts index f78951b7..da8a9938 100644 --- a/src/workspace/workspace-operations.ts +++ b/src/workspace/workspace-operations.ts @@ -1,79 +1,55 @@ -// 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. +// Pure StoredWorkspaceV2 operations (#406). Persistence policy, uniqueness, +// and key derivation live outside this module. -import type { SavedQueryV2, StoredWorkspaceV1 } from '../generated/json-schema.types.js'; +import type { SavedQueryV2, StoredWorkspaceV2 } 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 CURRENT_STORAGE_VERSION = 2 as const; 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. */ +/** Injected in production as crypto.randomUUID and deterministic in tests. */ 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 { +/** Rename display metadata only. Stable ID/key and contents are untouched. */ +export function renameWorkspace( + workspace: StoredWorkspaceV2, name: unknown, +): StoredWorkspaceV2 { 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 { +/** + * Construct a new empty V2 workspace from an injected identity and key. + * The repository validates the key and atomically enforces uniqueness. + */ +export function createNewWorkspace( + genId: WorkspaceIdGen, key: string, name?: unknown, +): StoredWorkspaceV2 { return { storageVersion: CURRENT_STORAGE_VERSION, id: genId(), + key, 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. */ +/** Replace only the active workspace's query collection. */ export function importQueries( - workspace: StoredWorkspaceV1, queries: readonly SavedQueryV2[], -): StoredWorkspaceV1 { + workspace: StoredWorkspaceV2, queries: readonly SavedQueryV2[], +): StoredWorkspaceV2 { 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 the File menu's "Open workspace…" (#280; user-facing - * label renamed by #342). 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. */ +/** Replace portable contents while preserving local identity metadata. */ export function replaceWorkspaceContents( - workspace: StoredWorkspaceV1, - contents: { queries: readonly SavedQueryV2[]; dashboard: StoredWorkspaceV1['dashboard'] }, -): StoredWorkspaceV1 { + workspace: StoredWorkspaceV2, + contents: { queries: readonly SavedQueryV2[]; dashboard: StoredWorkspaceV2['dashboard'] }, +): StoredWorkspaceV2 { return { ...workspace, queries: [...contents.queries], diff --git a/src/workspace/workspace-repository.ts b/src/workspace/workspace-repository.ts index b3863daf..af6394fd 100644 --- a/src/workspace/workspace-repository.ts +++ b/src/workspace/workspace-repository.ts @@ -1,133 +1,289 @@ -// 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 caller that built the candidate owns that). +// Multi-workspace repository (#406). The repository owns validation, canonical +// encoding, collection policy, and translation of store outcomes into +// application-facing diagnostics. IndexedDB remains behind WorkspaceStore. import { decodeStoredWorkspaceJson, encodeStoredWorkspaceJson, } from './stored-workspace.js'; -import type { WorkspaceStore } from './workspace-store.types.js'; +import type { + WorkspaceStore, WorkspaceStoreCreateResult, WorkspaceStoreRecord, + WorkspaceStoreReplaceResult, +} 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'; +import { normalizeWorkspaceKeyLookup } from '../core/workspace-key.js'; +import type { StoredWorkspaceV2 } 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 #300 tri-state load result `loadCurrentResult` returns β€” unlike - * `loadCurrent` (below), it distinguishes "no record persisted yet" (`empty`) - * from "a record is persisted but doesn't decode/validate" (`corrupt`, with - * the codec's diagnostics) from a normal successful load (`ok`). Boot-time - * callers that need to surface a corrupt-but-present record to the user - * (rather than silently continuing on the legacy projection) use this - * instead of `loadCurrent`. */ +export interface WorkspaceSummary { + readonly id: string; + readonly key: string; + readonly name: string; + readonly queryCount: number; + readonly hasDashboard: boolean; + readonly lastOpenedAt: number | null; +} + +export interface CorruptWorkspaceRecord { + readonly id: string; + readonly key: string; + readonly diagnostics: WorkspaceDiagnostic[]; +} + +export interface WorkspaceListResult { + readonly summaries: WorkspaceSummary[]; + readonly corrupt: CorruptWorkspaceRecord[]; +} + +/** Explicit keyed loads never silently select a different workspace. */ export type WorkspaceLoadResult = - | { status: 'empty' } - | { status: 'ok'; workspace: StoredWorkspaceV1 } - | { status: 'corrupt'; diagnostics: WorkspaceDiagnostic[] }; + | { readonly status: 'empty' } + | { readonly status: 'ok'; readonly workspace: StoredWorkspaceV2 } + | { + readonly status: 'corrupt'; + /** Record identity stays available for targeted reset/recovery. */ + readonly id: string; + readonly key: string; + readonly diagnostics: WorkspaceDiagnostic[]; + }; + +export type WorkspaceCommitResult = + | { + readonly ok: true; + readonly workspace: StoredWorkspaceV2; + readonly dashboardRevision: number | null; + } + | { readonly ok: false; readonly diagnostics: WorkspaceDiagnostic[] }; + +export type WorkspaceDeleteResult = + | { readonly ok: true; readonly deleted: boolean } + | { readonly ok: false; readonly diagnostics: WorkspaceDiagnostic[] }; + +export type WorkspaceMarkOpenedResult = + | { readonly ok: true } + | { readonly ok: false; readonly 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; - /** Like `loadCurrent`, but distinguishes "no record" from "record present - * but undecodable" (#300) instead of collapsing both to `null` β€” see - * `WorkspaceLoadResult`. */ - loadCurrentResult(): 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; + list(): Promise; + loadById(id: string): Promise; + loadByKey(key: string): Promise; + create(workspace: StoredWorkspaceV2): Promise; + commit(workspace: StoredWorkspaceV2): Promise; + /** Idempotent: an unknown ID succeeds with `deleted: false`. */ + delete(id: string): Promise; + /** Resolve startup's implicit workspace; explicit URL-key loads use loadByKey. */ + resolveImplicit(): Promise; + /** Stamp a workspace only after the application has opened it successfully. */ + markOpened(key: string): 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; + readonly store: WorkspaceStore; + readonly validationService?: JsonSchemaValidationService; + /** Injected because opening metadata must be deterministic in tests. */ + readonly now?: () => number; } -const message = (error: unknown): string => +const errorMessage = (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. */ +const repositoryDiagnostic = ( + code: string, message: string, path: readonly (string | number)[] = [], +): WorkspaceDiagnostic => diagnostic([...path], code, message); + +const persistenceFailure = (verb: string, error: unknown): WorkspaceCommitResult => ({ + ok: false, + diagnostics: [repositoryDiagnostic( + 'workspace-persist-failed', `${verb} failed: ${errorMessage(error)}`, + )], +}); + +const published = (encoded: string): Extract => { + const workspace = JSON.parse(encoded) as StoredWorkspaceV2; + return { + ok: true, + workspace, + dashboardRevision: workspace.dashboard === null ? null : workspace.dashboard.revision, + }; +}; + +const summary = ( + workspace: StoredWorkspaceV2, lastOpenedAt: number | null, +): WorkspaceSummary => ({ + id: workspace.id, + key: workspace.key, + name: workspace.name, + queryCount: workspace.queries.length, + hasDashboard: workspace.dashboard !== null, + lastOpenedAt, +}); + +function storeCreateFailure(result: Exclude) { + return result.status === 'duplicate-id' + ? repositoryDiagnostic('workspace-duplicate-id', 'A workspace with this ID already exists', ['id']) + : repositoryDiagnostic('workspace-duplicate-key', 'A workspace with this key already exists', ['key']); +} + +function storeReplaceFailure(result: Exclude) { + return result.status === 'not-found' + ? repositoryDiagnostic('workspace-not-found', 'The workspace no longer exists', ['id']) + : repositoryDiagnostic('workspace-key-immutable', 'A workspace key cannot be changed', ['key']); +} + +/** Build a collection repository. Construction itself performs no I/O. */ export function createWorkspaceRepository(deps: WorkspaceRepositoryDeps): WorkspaceRepository { - const { store, validationService } = deps; + const { store, validationService, now = Date.now } = 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; + const decodeRecord = (record: WorkspaceStoreRecord): WorkspaceLoadResult => { + const decoded = decodeStoredWorkspaceJson(record.text, codecOptions); + if (!decoded.ok) { + return { + status: 'corrupt', id: record.id, key: record.key, diagnostics: decoded.diagnostics, + }; + } + if (decoded.value.id !== record.id || decoded.value.key !== record.key) { + return { + status: 'corrupt', + id: record.id, + key: record.key, + diagnostics: [repositoryDiagnostic( + 'workspace-record-identity-mismatch', + 'Stored workspace identity does not match its record key', + )], + }; + } + return { status: 'ok', workspace: decoded.value }; + }; + + async function list(): Promise { + const records = await store.list(); + const summaries: WorkspaceSummary[] = []; + const corrupt: CorruptWorkspaceRecord[] = []; + for (const record of records) { + const decoded = decodeRecord(record); + if (decoded.status === 'ok') { + summaries.push(summary(decoded.workspace, record.lastOpenedAt)); + } else if (decoded.status === 'corrupt') { + corrupt.push({ id: record.id, key: record.key, diagnostics: decoded.diagnostics }); + } + } + summaries.sort((a, b) => a.key.localeCompare(b.key)); + corrupt.sort((a, b) => a.key.localeCompare(b.key) || a.id.localeCompare(b.id)); + return { summaries, corrupt }; + } + + async function loadById(id: string): Promise { + const record = await store.readById(id); + return record === null ? { status: 'empty' } : decodeRecord(record); } - async function loadCurrentResult(): Promise { - const text = await store.read(); - if (text === null) return { status: 'empty' }; - const decoded = decodeStoredWorkspaceJson(text, codecOptions); - return decoded.ok - ? { status: 'ok', workspace: decoded.value } - : { status: 'corrupt', diagnostics: decoded.diagnostics }; + async function loadByKey(key: string): Promise { + const record = await store.readByKey(normalizeWorkspaceKeyLookup(key)); + return record === null ? { status: 'empty' } : decodeRecord(record); } - 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); + async function create(workspace: StoredWorkspaceV2): Promise { + const encoded = encodeStoredWorkspaceJson(workspace, codecOptions); if (!encoded.ok) return { ok: false, diagnostics: encoded.diagnostics }; try { - await store.write(encoded.value); + const result = await store.create({ + id: workspace.id, key: workspace.key, text: encoded.value, lastOpenedAt: null, + }); + return result.status === 'created' + ? published(encoded.value) + : { ok: false, diagnostics: [storeCreateFailure(result)] }; + } catch (error) { + return persistenceFailure('Creating the workspace', error); + } + } + + async function commit(workspace: StoredWorkspaceV2): Promise { + const encoded = encodeStoredWorkspaceJson(workspace, codecOptions); + if (!encoded.ok) return { ok: false, diagnostics: encoded.diagnostics }; + try { + // Store.replace enforces existence/key immutability and preserves its + // store-owned lastOpenedAt metadata in the same transaction. + const result = await store.replace({ + id: workspace.id, + key: workspace.key, + text: encoded.value, + lastOpenedAt: null, + }); + return result.status === 'replaced' + ? published(encoded.value) + : { ok: false, diagnostics: [storeReplaceFailure(result)] }; + } catch (error) { + return persistenceFailure('Persisting the workspace', error); + } + } + + async function deleteWorkspace(id: string): Promise { + try { + return { ok: true, deleted: await store.delete(id) }; } 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)}`)], + diagnostics: [repositoryDiagnostic( + 'workspace-delete-failed', `Deleting the workspace failed: ${errorMessage(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(); + async function resolveImplicit(): Promise { + const preferredKey = await store.getLastUsedKey(); + if (preferredKey !== null) { + const preferred = await loadByKey(preferredKey); + if (preferred.status === 'ok') return preferred; + await store.clearLastUsedKey(); + } + + const records = await store.list(); + const ranked = records.map((record) => { + const decoded = decodeRecord(record); + return { record, decoded }; + }); + ranked.sort((a, b) => { + const aOpened = a.record.lastOpenedAt; + const bOpened = b.record.lastOpenedAt; + if (aOpened !== null || bOpened !== null) { + if (aOpened === null) return 1; + if (bOpened === null) return -1; + if (aOpened !== bOpened) return bOpened - aOpened; + } + return a.record.key.localeCompare(b.record.key) || a.record.id.localeCompare(b.record.id); + }); + const valid = ranked.find(({ decoded }) => decoded.status === 'ok'); + if (valid?.decoded.status === 'ok') return valid.decoded; + const corrupt = ranked.find(({ decoded }) => decoded.status === 'corrupt'); + return corrupt?.decoded ?? { status: 'empty' }; + } + + async function markOpened(key: string): Promise { + try { + const result = await store.markOpened(normalizeWorkspaceKeyLookup(key), now()); + return result.status === 'opened' + ? { ok: true } + : { + ok: false, + diagnostics: [repositoryDiagnostic( + 'workspace-not-found', 'The workspace no longer exists', ['key'], + )], + }; + } catch (error) { + return { + ok: false, + diagnostics: [repositoryDiagnostic( + 'workspace-mark-opened-failed', + `Recording the opened workspace failed: ${errorMessage(error)}`, + )], + }; + } } - return { loadCurrent, loadCurrentResult, commit, clearCurrent }; + return { + list, loadById, loadByKey, create, commit, + delete: deleteWorkspace, resolveImplicit, markOpened, + }; } diff --git a/src/workspace/workspace-store.types.ts b/src/workspace/workspace-store.types.ts index bee4a48a..9da1d91b 100644 --- a/src/workspace/workspace-store.types.ts +++ b/src/workspace/workspace-store.types.ts @@ -1,28 +1,49 @@ -// 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`. +// The injected persistence seam for the multi-workspace repository (#406). +// Persisted workspace content is an opaque canonical JSON string. Store-owned +// metadata stays outside that JSON so opening a workspace does not dirty its +// portable aggregate. + +export interface WorkspaceStoreRecord { + readonly id: string; + /** Canonical lowercase workspace key; callers validate before persistence. */ + readonly key: string; + readonly text: string; + readonly lastOpenedAt: number | null; +} + +export type WorkspaceStoreCreateResult = + | { readonly status: 'created' } + | { readonly status: 'duplicate-id' } + | { readonly status: 'duplicate-key' }; + +export type WorkspaceStoreReplaceResult = + | { readonly status: 'replaced' } + | { readonly status: 'not-found' } + | { readonly status: 'immutable-key' }; + +export type WorkspaceStoreMarkOpenedResult = + | { readonly status: 'opened' } + | { readonly status: 'not-found' }; + 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; + list(): Promise; + readById(id: string): Promise; + readByKey(key: string): Promise; + + /** Atomically add a workspace. Both `id` and canonical `key` are unique. */ + create(record: WorkspaceStoreRecord): Promise; + + /** Replace content only when `id` exists and its key is unchanged. The + * store preserves its current `lastOpenedAt` metadata atomically. */ + replace(record: WorkspaceStoreRecord): Promise; + + /** Delete exactly one workspace. Resolves false when `id` did not exist. */ + delete(id: string): Promise; + + getLastUsedKey(): Promise; + + /** Atomically stamp the workspace and make it the last-used preference. */ + markOpened(key: string, timestamp: number): Promise; + + clearLastUsedKey(): Promise; } diff --git a/src/workspace/workspace-sync.ts b/src/workspace/workspace-sync.ts index d6bd48dc..768f4685 100644 --- a/src/workspace/workspace-sync.ts +++ b/src/workspace/workspace-sync.ts @@ -14,7 +14,7 @@ import { encodeStoredWorkspaceJson } from './stored-workspace.js'; import { canonicalJson, SAVED_QUERY_SHAPE } from '../dashboard/model/canonical-json.js'; -import type { SavedQueryV2, StoredWorkspaceV1 } from '../generated/json-schema.types.js'; +import type { SavedQueryV2, StoredWorkspaceV2 } from '../generated/json-schema.types.js'; /** Snapshot-identity token for a whole workspace (or its absence). Two * workspaces produce the same token iff they are canonically equal, so a tab @@ -22,7 +22,7 @@ import type { SavedQueryV2, StoredWorkspaceV1 } from '../generated/json-schema.t * (no persisted workspace) tokens to `''`; a committed workspace always * encodes cleanly, but a would-be-invalid one also collapses to `''` (never * throws β€” this is an equality probe, not a validator). Not repository CAS. */ -export function workspaceToken(ws: StoredWorkspaceV1 | null): string { +export function workspaceToken(ws: StoredWorkspaceV2 | null): string { if (ws === null) return ''; const encoded = encodeStoredWorkspaceJson(ws); return encoded.ok ? encoded.value : ''; @@ -81,7 +81,7 @@ export interface TabReconcilePlan { * persisted, so every linked query counts as deleted). Pure: returns one plan * per input tab, in order, and applies no mutation. */ export function reconcileLinkedTabs( - latest: StoredWorkspaceV1 | null, tabs: readonly LinkedTabSnapshot[], + latest: StoredWorkspaceV2 | null, tabs: readonly LinkedTabSnapshot[], ): TabReconcilePlan[] { const queries = latest ? latest.queries : []; return tabs.map((tab) => { diff --git a/tests/e2e/dashboard-kpi-move.html b/tests/e2e/dashboard-kpi-move.html index 9c79c64a..d5910f5a 100644 --- a/tests/e2e/dashboard-kpi-move.html +++ b/tests/e2e/dashboard-kpi-move.html @@ -37,7 +37,7 @@ import { renderDashboard } from '/src/ui/dashboard.js'; const seed = { - storageVersion: 1, id: 'workspace', name: 'Workspace', + storageVersion: 2, id: 'workspace', key: 'workspace', name: 'Workspace', queries: [ { id: 'q1', sql: 'SELECT 111111', specVersion: 1, spec: { name: 'First metric', favorite: true, panel: { cfg: { type: 'kpi' } } } }, { id: 'q2', sql: 'SELECT 222222', specVersion: 1, spec: { name: 'Second metric', favorite: true, panel: { cfg: { type: 'kpi' } } } }, @@ -62,14 +62,17 @@ result.error = null; return result; }; - await app.workspace.clearCurrent(); - const seeded = await app.workspace.commit(seed); + await app.workspace.delete(seed.id); + const seeded = await app.workspace.create(seed); if (!seeded.ok) throw new Error(seeded.diagnostics[0]?.message || 'seed failed'); app.applyCommittedWorkspace(seeded.workspace); const realCommit = app.workspace.commit.bind(app.workspace); window.__commitCount = 0; app.workspace.commit = async (...args) => { window.__commitCount += 1; return realCommit(...args); }; - window.__workspace = () => app.workspace.loadCurrent(); + window.__workspace = async () => { + const loaded = await app.workspace.loadById(seed.id); + return loaded.status === 'ok' ? loaded.workspace : null; + }; await renderDashboard(app); window.__ready = true; diff --git a/tests/e2e/dashboard-membership.html b/tests/e2e/dashboard-membership.html index 2529bf0e..295da5aa 100644 --- a/tests/e2e/dashboard-membership.html +++ b/tests/e2e/dashboard-membership.html @@ -37,7 +37,7 @@ import { renderDashboard } from '/src/ui/dashboard.js'; const seed = { - storageVersion: 1, id: 'workspace', name: 'Workspace', dashboard: null, + storageVersion: 2, id: 'workspace', key: 'workspace', name: 'Workspace', dashboard: null, queries: [{ id: 'q1', sql: 'SELECT 1', specVersion: 1, spec: { name: 'Revenue', favorite: false }, @@ -69,17 +69,22 @@ renderSavedHistory(app); } - let workspace = await app.workspace.loadCurrent(); + const loaded = await app.workspace.loadById(seed.id); + let workspace = loaded.status === 'ok' ? loaded.workspace : null; if (!sessionStorage.getItem('membership-seeded')) { - await app.workspace.clearCurrent(); - const committed = await app.workspace.commit(seed); + await app.workspace.delete(seed.id); + const committed = await app.workspace.create(seed); if (!committed.ok) throw new Error(committed.diagnostics[0]?.message || 'seed failed'); workspace = committed.workspace; sessionStorage.setItem('membership-seeded', '1'); } + if (!workspace) throw new Error('seeded workspace missing'); app.applyCommittedWorkspace(workspace); renderWorkbench(); - window.__workspace = () => app.workspace.loadCurrent(); + window.__workspace = async () => { + const current = await app.workspace.loadById(seed.id); + return current.status === 'ok' ? current.workspace : null; + }; window.__ready = true; diff --git a/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts index c2c3e339..7a89e90a 100644 --- a/tests/helpers/fake-app.ts +++ b/tests/helpers/fake-app.ts @@ -27,7 +27,9 @@ import type { MutateWorkspace } from '../../src/state.js'; import { createNoopPort } from '../../src/editor/editor-port.js'; import { createNoopSpecEditor } from '../../src/editor/spec-editor.js'; import { createWorkspaceRepository } from '../../src/workspace/workspace-repository.js'; -import type { WorkspaceStore } from '../../src/workspace/workspace-store.types.js'; +import type { + WorkspaceStore, WorkspaceStoreRecord, +} from '../../src/workspace/workspace-store.types.js'; import type { App, ActionsRegistry, AppDom, ChCtx } from '../../src/ui/app.types.js'; import type { AppState } from '../../src/state.js'; import type { ConfigDoc, ResolvedIdpConfig } from '../../src/net/oauth-config.js'; @@ -44,7 +46,7 @@ import type { WorkbenchParameterSession } from '../../src/application/workbench- 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 { StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; +import type { StoredWorkspaceV2 } from '../../src/generated/json-schema.types.js'; import type { SavedQueryService, CreateSavedResult, CommitLinkedResult, ShareResult, } from '../../src/application/saved-query-service.js'; @@ -57,12 +59,46 @@ import type { AssembledReference } from '../../src/core/completions.js'; // exercising `createSavedQuery`/`toggleFavorite`/`deleteSaved`/etc. through // `app.workspace.commit` gets genuine whole-candidate schema validation // rather than an always-succeeds/always-fails placeholder. -export function memWorkspaceStore(initial: string | null = null): WorkspaceStore { - let value = initial; +export function memWorkspaceStore(initial: readonly WorkspaceStoreRecord[] = []): WorkspaceStore { + const records = new Map(initial.map((record) => [record.id, record])); + let lastUsedKey: string | null = null; return { - read: async () => value, - write: async (text: string) => { value = text; }, - clear: async () => { value = null; }, + list: async () => [...records.values()], + readById: async (id) => records.get(id) ?? null, + readByKey: async (key) => ( + [...records.values()].find((record) => record.key === key) ?? null + ), + create: async (record) => { + if (records.has(record.id)) return { status: 'duplicate-id' }; + if ([...records.values()].some((current) => current.key === record.key)) { + return { status: 'duplicate-key' }; + } + records.set(record.id, record); + return { status: 'created' }; + }, + replace: async (record) => { + const current = records.get(record.id); + if (!current) return { status: 'not-found' }; + if (current.key !== record.key) return { status: 'immutable-key' }; + records.set(record.id, { ...record, lastOpenedAt: current.lastOpenedAt }); + return { status: 'replaced' }; + }, + delete: async (id) => { + const current = records.get(id); + if (!current) return false; + records.delete(id); + if (lastUsedKey === current.key) lastUsedKey = null; + return true; + }, + getLastUsedKey: async () => lastUsedKey, + markOpened: async (key, timestamp) => { + const current = [...records.values()].find((record) => record.key === key); + if (!current) return { status: 'not-found' }; + records.set(current.id, { ...current, lastOpenedAt: timestamp }); + lastUsedKey = key; + return { status: 'opened' }; + }, + clearLastUsedKey: async () => { lastUsedKey = null; }, }; } @@ -71,7 +107,9 @@ export function memWorkspaceStore(initial: string | null = null): WorkspaceStore * counts the same way it used to assert on the retired `save: SaveJSON` * spy β€” `vi.fn(impl)` still delegates to the real implementation. */ export function fakeWorkspaceCommit() { - return vi.fn(createWorkspaceRepository({ store: memWorkspaceStore() }).commit); + return vi.fn(async (candidate: StoredWorkspaceV2) => ( + createWorkspaceRepository({ store: memWorkspaceStore() }).create(candidate) + )); } /** A `MutateWorkspace` (`App['mutateWorkspace']`, structurally) over a private @@ -81,29 +119,36 @@ export function fakeWorkspaceCommit() { * so a planner falls back to the passed `state`), run the transform, commit, * then PROJECT committed truth onto `state` (savedQueries/dashboard/id/name + * reconcile tab links when `state` carries `tabs`, clearing libraryDirty) - * exactly once. `commit`/`loadCurrent` are `vi.fn` spies (assert call counts as - * with the retired `fakeWorkspaceCommit`); pass `commit`/`loadCurrent` + * exactly once. `commit`/`loadById` are `vi.fn` spies (assert call counts as + * with the retired `fakeWorkspaceCommit`); pass `commit`/`loadById` * overrides to exercise a rejecting commit or a distinct committed `latest`. */ export function fakeMutateWorkspace( - state: Pick, - opts: { commit?: WorkspaceRepository['commit']; loadCurrent?: WorkspaceRepository['loadCurrent'] } = {}, -): MutateWorkspace & { commit: ReturnType; loadCurrent: ReturnType } { - const repo = createWorkspaceRepository({ store: memWorkspaceStore() }); - const commit = vi.fn(opts.commit ?? repo.commit); - const loadCurrent = vi.fn(opts.loadCurrent ?? repo.loadCurrent); + state: Pick, + opts: { + commit?: WorkspaceRepository['commit']; + loadById?: (id: string) => Promise; + } = {}, +): MutateWorkspace & { commit: ReturnType; loadById: ReturnType } { + let current: StoredWorkspaceV2 | null = null; + const commit = vi.fn(opts.commit ?? (async (candidate: StoredWorkspaceV2) => ( + createWorkspaceRepository({ store: memWorkspaceStore() }).create(candidate) + ))); + const loadById = vi.fn(opts.loadById ?? (async () => current)); const mutate = (async (transform) => { - const latest = await loadCurrent(); + const latest = await loadById(state.workspaceId); const input = await transform(latest); if (!input || !input.candidate) { return { ok: false as const, aborted: true as const, data: input ? input.data : undefined }; } const result = await commit(input.candidate); if (!result.ok) return { ok: false as const, diagnostics: result.diagnostics, data: input.data }; + current = result.workspace; state.savedQueries = result.workspace.queries; const withTabs = state as Partial> & typeof state; if (withTabs.tabs) reconcileTabsWithSavedQueries(state as Pick); state.dashboard = result.workspace.dashboard; state.workspaceId = result.workspace.id; + state.workspaceKey = result.workspace.key; state.libraryName.value = result.workspace.name; state.libraryDirty.value = false; return { @@ -111,28 +156,52 @@ export function fakeMutateWorkspace( dashboardRevision: result.dashboardRevision, data: input.data, }; }) as MutateWorkspace; - return Object.assign(mutate, { commit, loadCurrent }); + return Object.assign(mutate, { commit, loadById }); } /** A STATEFUL in-memory `WorkspaceRepository` fake (#341/#344 review fix): - * `commit` stores the candidate and `loadCurrent`/`loadCurrentResult` return + * `commit` stores the candidate and keyed loads return * the LAST committed value β€” unlike the module-default `appDefaults.workspace` - * (a stateless echo `commit` paired with a `loadCurrent` that always answers + * (a stateless echo `commit` paired with keyed loads that always answer * `null`), so a mixed-producer test (a pending write, then a second op queued * through `app.mutateWorkspace` behind it) can assert the second op's * transform actually observed the first commit's effect. No schema * validation (unlike `fakeWorkspaceCommit`'s real-repository-backed spy) β€” * just enough state to make read-after-write hold. */ -export function statefulWorkspaceRepo(initial: StoredWorkspaceV1 | null = null): WorkspaceRepository { +export function statefulWorkspaceRepo(initial: StoredWorkspaceV2 | null = null): WorkspaceRepository { let current = initial; return { - loadCurrent: async () => current, - loadCurrentResult: async () => (current ? { status: 'ok', workspace: current } : { status: 'empty' }), + list: async () => ({ + summaries: current ? [{ + id: current.id, key: current.key, name: current.name, + queryCount: current.queries.length, hasDashboard: current.dashboard !== null, + lastOpenedAt: null, + }] : [], + corrupt: [], + }), + loadById: async (id) => ( + current?.id === id ? { status: 'ok', workspace: current } : { status: 'empty' } + ), + loadByKey: async (key) => ( + current?.key === key ? { status: 'ok', workspace: current } : { status: 'empty' } + ), + create: async (candidate) => { + current = candidate; + return { ok: true, workspace: candidate, dashboardRevision: candidate.dashboard?.revision ?? null }; + }, commit: async (candidate) => { current = candidate; return { ok: true, workspace: candidate, dashboardRevision: candidate.dashboard === null ? null : candidate.dashboard.revision }; }, - clearCurrent: async () => { current = null; }, + delete: async (id) => { + const deleted = current?.id === id; + if (deleted) current = null; + return { ok: true, deleted }; + }, + resolveImplicit: async () => ( + current ? { status: 'ok', workspace: current } : { status: 'empty' } + ), + markOpened: async () => ({ ok: true }), }; } @@ -348,14 +417,19 @@ const appDefaults: App = { // rule. A test asserting real validate-then-persist behavior overrides this // with `fakeWorkspaceCommit()` (or its own stub) instead. workspace: { - loadCurrent: async () => null, - // #300: default mirrors `loadCurrent`'s own default (no record) β€” a - // fixture testing the corrupt-record surface overrides this directly. - loadCurrentResult: async () => ({ status: 'empty' }), + list: async () => ({ summaries: [], corrupt: [] }), + loadById: async () => ({ status: 'empty' }), + loadByKey: async () => ({ status: 'empty' }), + create: async (candidate) => ({ + ok: true, workspace: candidate, + dashboardRevision: candidate.dashboard?.revision ?? null, + }), commit: async (candidate) => ({ ok: true, workspace: candidate, dashboardRevision: candidate.dashboard === null ? null : candidate.dashboard.revision, }), - clearCurrent: async () => {}, + delete: async () => ({ ok: true, deleted: false }), + resolveImplicit: async () => ({ status: 'empty' }), + markOpened: async () => ({ ok: true }), }, // #288 Phase 6 β€” Dashboard viewing seams. In-memory no-op stores by default; // a test exercising the handoff/detached-view flow overrides these. @@ -366,6 +440,7 @@ const appDefaults: App = { detachedViews: { put: async () => {}, get: async () => null, + getByKey: async () => null, }, dashboardOpenSource: null, dashboardRoute: false, @@ -501,7 +576,7 @@ const appDefaults: App = { export type MakeAppOverrides = AppOverrides; type AppOverrides = Partial> & { /** Partial like the rest (#286 Phase 4) β€” the Dashboard viewer reads a - * StoredWorkspaceV1 through `loadDashboardWorkspace`/`workspace.loadCurrent`; + * StoredWorkspaceV2 through `loadDashboardWorkspace`/`workspace.loadById`; * a dashboard test overrides just the method(s) it drives. */ workspace?: Partial; dom?: Partial; @@ -555,7 +630,7 @@ export function makeApp>(override const state = createState({ loadStr: (k, d) => d, loadJSON: (k, d) => d }); const root = document.createElement('div'); // Resolved BEFORE `base` so the `mutateWorkspace` queue below (which needs to - // call `.loadCurrent()`/`.commit()` on the SAME repository the rest of the + // call `.loadById()`/`.commit()` on the SAME repository the rest of the // fake app sees) can close over it directly β€” the final `workspace` field is // this exact object, not a fresh merge (`overrides.workspace` layers under // `appDefaults.workspace`, same three-way convention as `dom`/`conn`/etc.). @@ -563,13 +638,14 @@ export function makeApp>(override // #287 W5 / #343 Β§2: the one state-backed projection both `applyCommittedWorkspace` // and the `mutateWorkspace` success path share (mirrors app.ts, where the // primitive projects exactly once so callers no longer do). - const applyCommittedWorkspace = (workspace: StoredWorkspaceV1): void => { + const applyCommittedWorkspace = (workspace: StoredWorkspaceV2): void => { state.savedQueries = workspace.queries; // #343: mirror app.ts's own projection β€” detach any open tab whose linked // saved query is absent from the committed collection (deleted elsewhere). reconcileTabsWithSavedQueries(state); state.dashboard = workspace.dashboard; state.workspaceId = workspace.id; + state.workspaceKey = workspace.key; state.libraryName.value = workspace.name; state.libraryDirty.value = false; }; @@ -634,14 +710,15 @@ export function makeApp>(override // this call resolve β€” mirrors app.ts's own `writeChain`-backed pair. const flushWorkspaceWrites = (): Promise => chain.then(() => undefined, () => undefined); // #341/#344: mirrors app.ts's own `mutateWorkspace` β€” reads `workspaceRepo - // .loadCurrent()` (the SAME repo `workspace.commit` below publishes + // .loadById()` (the SAME repo `workspace.commit` below publishes // through) at DEQUEUE time, never a value captured at enqueue time, so a // mixed-producer test (a pending saved-query-style commit, then a // rename/import queued behind it) observes the first commit's effect. // #343 Β§2: on success the primitive projects committed truth exactly once // and threads `data` back β€” callers no longer project (mirrors app.ts). const mutateWorkspace: App['mutateWorkspace'] = (transform) => serializeWrite(async () => { - const latest = await workspaceRepo.loadCurrent(); + const loaded = await workspaceRepo.loadById(state.workspaceId); + const latest = loaded.status === 'ok' ? loaded.workspace : null; const input = await transform(latest); if (!input || !input.candidate) { return { ok: false as const, aborted: true as const, data: input ? input.data : undefined }; diff --git a/tests/helpers/fake-idb.ts b/tests/helpers/fake-idb.ts index 620c6b28..5124ad09 100644 --- a/tests/helpers/fake-idb.ts +++ b/tests/helpers/fake-idb.ts @@ -1,18 +1,7 @@ -// A minimal, always-succeeding in-memory fake `IDBFactory` (#287 W4). Not -// under src/, so it does not count toward coverage. -// -// Real `createApp(env)`-based fixtures (app.test.ts's `env()`) inject this by -// default so `app.workspace.commit` β€” the seam every saved-query CRUD op now -// awaits β€” actually persists under happy-dom, which has no real `indexedDB`. -// Without it every commit would reject with a `workspace-persist-failed` -// diagnostic (see `indexeddb-workspace-store.ts`'s `openDb` β€” a missing -// factory rejects the open), so every save/rename/favorite/delete flow driven -// through the real app would silently do nothing. -// -// Mirrors `indexeddb-workspace-store.test.ts`'s own richer local `fakeFactory` -// (which adds per-call error-injection knobs this generic, always-succeeds -// fixture doesn't need) and `dashboard.test.ts`'s own local `fakeIndexedDb` -// (same shape, kept separate there since it predates this shared helper). +// Always-succeeding in-memory IndexedDB for createApp-based tests. It models +// the collection features used by the workspace, detached-view, and handoff +// adapters: database-scoped stores, key paths, unique indexes, getAll/add, and +// transactions that stay active through request-handler promise microtasks. type Handler = (() => void) | null; class FakeRequest { @@ -23,51 +12,237 @@ class FakeRequest { onupgradeneeded: Handler = null; } -class FakeObjectStore { - data: Map; - constructor(data: Map) { this.data = data; } - get(key: string): FakeRequest { - const req = new FakeRequest(); - queueMicrotask(() => { 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(); } +interface StoreData { + keyPath: string | null; + values: Map; + indexes: Map; } -class FakeTx { +class FakeTransaction { + error: Error | null = null; oncomplete: Handler = null; onerror: Handler = null; onabort: Handler = null; - db: FakeDB; - constructor(db: FakeDB) { + private pending = 0; + private completionQueued = false; + private settled = false; + private readonly db: FakeDB; + private readonly names: string[]; + + constructor(db: FakeDB, names: string[]) { this.db = db; - queueMicrotask(() => this.oncomplete?.()); + this.names = names; + this.queueCompletion(); + } + + objectStore(name: string): FakeObjectStore { + if (!this.names.includes(name)) throw new Error(`Store ${name} is outside transaction`); + const data = this.db.stores.get(name); + if (!data) throw new Error(`Unknown store ${name}`); + return new FakeObjectStore(this, data); + } + + request(action: () => unknown): FakeRequest { + const request = new FakeRequest(); + this.pending += 1; + queueMicrotask(() => { + try { + request.result = action(); + request.onsuccess?.(); + } catch (error) { + request.error = error; + request.onerror?.(); + this.settled = true; + this.error = error instanceof Error ? error : new Error(String(error)); + queueMicrotask(() => this.onabort?.()); + } finally { + this.pending -= 1; + this.queueCompletion(); + } + }); + return request; + } + + cursor(data: StoreData): FakeRequest { + const request = new FakeRequest(); + const entries = [...data.values.entries()]; + let position = 0; + this.pending += 1; + const advance = (): void => { + queueMicrotask(() => { + const entry = entries[position++]; + if (entry) { + request.result = { + key: entry[0], + value: entry[1], + continue: advance, + }; + } else { + request.result = null; + this.pending -= 1; + this.queueCompletion(); + } + request.onsuccess?.(); + }); + }; + advance(); + return request; + } + + private queueCompletion(): void { + if (this.completionQueued || this.settled) return; + this.completionQueued = true; + setTimeout(() => { + this.completionQueued = false; + if (this.pending !== 0 || this.settled) return; + this.settled = true; + this.oncomplete?.(); + }, 0); + } +} + +class FakeIndex { + private readonly tx: FakeTransaction; + private readonly data: StoreData; + private readonly keyPath: string; + + constructor(tx: FakeTransaction, data: StoreData, keyPath: string) { + this.tx = tx; + this.data = data; + this.keyPath = keyPath; + } + + get(key: IDBValidKey): FakeRequest { + return this.tx.request(() => ( + [...this.data.values.values()].find((value) => ( + (value as Record)[this.keyPath] === key + )) + )); + } +} + +class FakeObjectStore { + private readonly tx: FakeTransaction; + private readonly data: StoreData; + + constructor(tx: FakeTransaction, data: StoreData) { + this.tx = tx; + this.data = data; + } + + get(key: IDBValidKey): FakeRequest { + return this.tx.request(() => this.data.values.get(key)); + } + + getAll(): FakeRequest { + return this.tx.request(() => [...this.data.values.values()]); + } + + add(value: unknown, explicitKey?: IDBValidKey): FakeRequest { + return this.tx.request(() => { + const key = this.resolveKey(value, explicitKey); + if (this.data.values.has(key)) throw new DOMException('Duplicate key', 'ConstraintError'); + this.assertUnique(value); + this.data.values.set(key, value); + return key; + }); + } + + put(value: unknown, explicitKey?: IDBValidKey): FakeRequest { + return this.tx.request(() => { + const key = this.resolveKey(value, explicitKey); + this.assertUnique(value, key); + this.data.values.set(key, value); + return key; + }); + } + + delete(key: IDBValidKey): FakeRequest { + return this.tx.request(() => { + this.data.values.delete(key); + return undefined; + }); + } + + index(name: string): FakeIndex { + const index = this.data.indexes.get(name); + if (!index) throw new Error(`Unknown index ${name}`); + return new FakeIndex(this.tx, this.data, index.keyPath); + } + + openCursor(): FakeRequest { + return this.tx.cursor(this.data); + } + + private resolveKey(value: unknown, explicitKey?: IDBValidKey): IDBValidKey { + if (explicitKey !== undefined) return explicitKey; + if (!this.data.keyPath) throw new Error('Explicit key required'); + return (value as Record)[this.data.keyPath]; + } + + private assertUnique(value: unknown, replacingKey?: IDBValidKey): void { + for (const index of this.data.indexes.values()) { + if (!index.unique) continue; + const indexedValue = (value as Record)[index.keyPath]; + for (const [key, current] of this.data.values) { + if (key !== replacingKey + && (current as Record)[index.keyPath] === indexedValue) { + throw new DOMException('Duplicate index', 'ConstraintError'); + } + } + } + } +} + +class FakeUpgradeObjectStore { + private readonly data: StoreData; + constructor(data: StoreData) { this.data = data; } + + createIndex(name: string, keyPath: string, options?: IDBIndexParameters): FakeUpgradeObjectStore { + this.data.indexes.set(name, { keyPath, unique: options?.unique ?? false }); + return this; } - objectStore(name: string): FakeObjectStore { return new FakeObjectStore(this.db.stores.get(name)!); } } class FakeDB { - stores = new Map>(); - objectStoreNames = { contains: (n: string): boolean => this.stores.has(n) }; - createObjectStore(name: string): void { this.stores.set(name, new Map()); } - transaction(_names: string[], _mode: string): FakeTx { return new FakeTx(this); } + stores = new Map(); + objectStoreNames = { contains: (name: string): boolean => this.stores.has(name) }; + + createObjectStore(name: string, options?: IDBObjectStoreParameters): FakeUpgradeObjectStore { + if (this.stores.has(name)) throw new DOMException('Store exists', 'ConstraintError'); + const data: StoreData = { + keyPath: typeof options?.keyPath === 'string' ? options.keyPath : null, + values: new Map(), + indexes: new Map(), + }; + this.stores.set(name, data); + return new FakeUpgradeObjectStore(data); + } + + transaction(names: string[], _mode: IDBTransactionMode): FakeTransaction { + return new FakeTransaction(this, names); + } } -/** A fresh always-succeeding fake `IDBFactory` β€” one independent in-memory - * database per call (construct one per test/app unless deliberately sharing - * across several `createApp()` calls in the same test). */ +/** A fresh fake factory. Database names remain isolated, while multiple apps + * constructed with the same factory reopen and share each named database. */ export function fakeIndexedDbFactory(): IDBFactory { - const db = new FakeDB(); + const databases = new Map(); return { - open(_name: string, _version?: number) { - const req = new FakeRequest(); + open(name: string, _version?: number) { + const request = new FakeRequest(); queueMicrotask(() => { - req.result = db; - req.onupgradeneeded?.(); - req.onsuccess?.(); + let db = databases.get(name); + const needsUpgrade = !db; + if (!db) { + db = new FakeDB(); + databases.set(name, db); + } + request.result = db; + if (needsUpgrade) request.onupgradeneeded?.(); + request.onsuccess?.(); }); - return req as unknown as IDBOpenDBRequest; + return request as unknown as IDBOpenDBRequest; }, } as unknown as IDBFactory; } diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index c43f1946..1c8a0bd4 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -20,9 +20,9 @@ import { decodeShare } from '../../src/core/share.js'; import type { CreateAppEnv, BroadcastChannelPort } from '../../src/env.types.js'; import type { App, WorkspaceChangedMessage } from '../../src/ui/app.types.js'; import type { AppState, QueryTab } from '../../src/state.js'; -import { renameSaved } from '../../src/state.js'; +import { renameSaved, savedForTab } from '../../src/state.js'; import type { SchemaDb } from '../../src/core/from-scope.js'; -import type { SavedQueryV2, StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; +import type { SavedQueryV2, StoredWorkspaceV2 } from '../../src/generated/json-schema.types.js'; import type { CompletionItem, AssembledReference } from '../../src/core/completions.js'; import type { QueryResult, ScriptResult, ScriptExportResult, ScriptEntry, ScriptExportEntry, ResultSchemaGraph, @@ -373,6 +373,17 @@ function env(over: Partial = {}): CreateAppEnv { }; } +async function seedActiveWorkspace(app: App, workspace: StoredWorkspaceV2): Promise { + const created = await app.workspace.create(workspace); + if (!created.ok) throw new Error(`fixture failed: ${created.diagnostics[0]?.message}`); + app.applyCommittedWorkspace(created.workspace); +} + +async function loadActiveWorkspace(app: App): Promise { + const loaded = await app.workspace.loadById(app.state.workspaceId); + return loaded.status === 'ok' ? loaded.workspace : null; +} + beforeEach(() => { document.body.innerHTML = ''; }); afterEach(() => { for (const editor of liveEditors) editor.destroy(); @@ -386,15 +397,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', () => { + it('constructs the atomic StoredWorkspaceV2 repository (#284) behind the injected IndexedDB seam', () => { // `indexedDB: undefined` overrides `env()`'s own #287 W4 default fake // factory β†’ 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({ indexedDB: undefined })); - expect(typeof app.workspace.loadCurrent).toBe('function'); + expect(typeof app.workspace.list).toBe('function'); + expect(typeof app.workspace.loadById).toBe('function'); + expect(typeof app.workspace.loadByKey).toBe('function'); + expect(typeof app.workspace.create).toBe('function'); expect(typeof app.workspace.commit).toBe('function'); - expect(typeof app.workspace.clearCurrent).toBe('function'); + expect(typeof app.workspace.delete).toBe('function'); + expect(typeof app.workspace.resolveImplicit).toBe('function'); + expect(typeof app.workspace.markOpened).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). @@ -536,13 +552,13 @@ describe('createApp basics', () => { // "read latest at dequeue time" discipline shows up here, not only in // file-menu.ts's own higher-level tests. describe('app.mutateWorkspace (#341/#344)', () => { - const seedWorkspace = (over: Partial = {}) => ( - { storageVersion: 1 as const, id: 'w1', name: 'Seed', queries: [], dashboard: null, ...over } + const seedWorkspace = (over: Partial = {}) => ( + { storageVersion: 2 as const, id: 'w1', key: 'workspace_one', name: 'Seed', queries: [], dashboard: null, ...over } ); it('the transform receives the latest COMMITTED aggregate at DEQUEUE time, not whatever was current when mutateWorkspace was called', async () => { const app = createApp(env()); - await app.mutateWorkspace(() => ({ candidate: seedWorkspace() })); + await seedActiveWorkspace(app, seedWorkspace()); // A first write is pending in the queue (gated open manually, like // saved-history.test.ts's own concurrent-writes regression) β€” a SECOND // `mutateWorkspace` call fires while it's still pending. @@ -555,7 +571,7 @@ describe('app.mutateWorkspace (#341/#344)', () => { const second = app.mutateWorkspace((latest) => (latest ? { candidate: { ...latest, name: 'Renamed' } } : null)); release(); await Promise.all([first, second]); - const finalWs = await app.workspace.loadCurrent(); + const finalWs = await loadActiveWorkspace(app); // Both mutations landed: the queued commit's query AND the rename that ran // after it β€” a `mutateWorkspace` that captured `latest` at enqueue time // (before the first commit) would have reverted the query back to []. @@ -565,36 +581,37 @@ describe('app.mutateWorkspace (#341/#344)', () => { it('a null/undefined-returning transform aborts β€” nothing persisted, resolves an aborted outcome', async () => { const app = createApp(env()); - await app.mutateWorkspace(() => ({ candidate: seedWorkspace({ name: 'Before' }) })); + await seedActiveWorkspace(app, seedWorkspace({ name: 'Before' })); const result = await app.mutateWorkspace(() => null); expect(result.ok).toBe(false); expect(result.ok === false && result.aborted).toBe(true); - const ws = await app.workspace.loadCurrent(); + const ws = await loadActiveWorkspace(app); expect(ws?.name).toBe('Before'); // untouched }); it('a null CANDIDATE aborts too (and threads data back), committing nothing', async () => { const app = createApp(env()); - await app.mutateWorkspace(() => ({ candidate: seedWorkspace({ name: 'Before' }) })); + await seedActiveWorkspace(app, seedWorkspace({ name: 'Before' })); const result = await app.mutateWorkspace(() => ({ candidate: null, data: 'why' })); expect(result.ok).toBe(false); expect(result.data).toBe('why'); - expect((await app.workspace.loadCurrent())?.name).toBe('Before'); + expect((await loadActiveWorkspace(app))?.name).toBe('Before'); }); it('a transform rejection propagates to the caller without wedging the queue for the next op', async () => { const app = createApp(env()); - await app.mutateWorkspace(() => ({ candidate: seedWorkspace({ name: 'Before' }) })); + await seedActiveWorkspace(app, seedWorkspace({ name: 'Before' })); await expect(app.mutateWorkspace(() => { throw new Error('boom'); })).rejects.toThrow('boom'); // The queue still advances β€” a later op is not stuck behind the rejection. const result = await app.mutateWorkspace((latest) => (latest ? { candidate: { ...latest, name: 'After' } } : null)); expect(result.ok).toBe(true); - const ws = await app.workspace.loadCurrent(); + const ws = await loadActiveWorkspace(app); expect(ws?.name).toBe('After'); }); it('projects the committed workspace exactly once on success, and threads operation data back', async () => { const app = createApp(env()); + await seedActiveWorkspace(app, seedWorkspace()); const projected: string[] = []; const orig = app.applyCommittedWorkspace; app.applyCommittedWorkspace = (ws) => { projected.push(ws.name); orig(ws); }; @@ -607,18 +624,40 @@ describe('app.mutateWorkspace (#341/#344)', () => { it('does not project on an aborted or failed commit', async () => { const app = createApp(env()); + await seedActiveWorkspace(app, seedWorkspace()); + const tokenBefore = app.getLastCommittedToken(); let projections = 0; const orig = app.applyCommittedWorkspace; app.applyCommittedWorkspace = (ws) => { projections++; orig(ws); }; await app.mutateWorkspace(() => null); // abort // Invalid candidate β†’ commit fails (a query missing required fields). const failed = await app.mutateWorkspace(() => ({ - candidate: { storageVersion: 1, id: 'x', name: 'n', queries: [{ bad: true } as never], dashboard: null }, + candidate: { storageVersion: 2, id: 'x', key: 'x', name: 'n', queries: [{ bad: true } as never], dashboard: null }, })); expect(failed.ok).toBe(false); expect(failed.ok === false && failed.aborted).toBeFalsy(); // a real failure, not an abort expect(projections).toBe(0); - expect(app.getLastCommittedToken()).toBe(''); // never advanced + expect(app.getLastCommittedToken()).toBe(tokenBefore); // never advanced + }); + + it('fails closed when the active record is corrupt and never invokes the transform or commit', async () => { + const app = createApp(env()); + const diagnostics = [{ + path: ['storageVersion'], severity: 'error' as const, + code: 'workspace-version-unsupported', message: 'Unsupported version', + }]; + app.workspace.loadById = vi.fn(async () => ({ + status: 'corrupt' as const, + id: app.state.workspaceId, + key: app.state.workspaceKey, + diagnostics, + })); + const transform = vi.fn(() => ({ candidate: seedWorkspace() })); + const commit = vi.spyOn(app.workspace, 'commit'); + const result = await app.mutateWorkspace(transform); + expect(result).toEqual({ ok: false, diagnostics }); + expect(transform).not.toHaveBeenCalled(); + expect(commit).not.toHaveBeenCalled(); }); }); @@ -627,8 +666,8 @@ describe('app.mutateWorkspace (#341/#344)', () => { // own poke on receipt; `documentVisible` is the injected visibility read the // focus/visibility fallback (step 4) consults. describe('app cross-tab invalidation (#343)', () => { - const seed = (over: Partial = {}): StoredWorkspaceV1 => ( - { storageVersion: 1, id: 'w1', name: 'Seed', queries: [], dashboard: null, ...over } + const seed = (over: Partial = {}): StoredWorkspaceV2 => ( + { storageVersion: 2, id: 'w1', key: 'workspace_one', name: 'Seed', queries: [], dashboard: null, ...over } ); it('posts exactly one invalidation per successful commit, and none on abort or failure', async () => { @@ -639,11 +678,14 @@ describe('app cross-tab invalidation (#343)', () => { close: () => {}, }); const app = createApp(env({ broadcastChannel: factory })); - await app.mutateWorkspace(() => ({ candidate: seed({ name: 'One' }) })); + await seedActiveWorkspace(app, seed()); + await app.mutateWorkspace((latest) => ({ + candidate: { ...latest!, name: 'One' }, + })); expect(posted).toHaveLength(1); expect(posted[0]).toEqual({ type: 'workspace-changed', sourceTabId: app.sourceTabId, workspaceId: 'w1' }); await app.mutateWorkspace(() => null); // abort - await app.mutateWorkspace(() => ({ candidate: { storageVersion: 1, id: 'x', name: 'n', queries: [{ bad: true } as never], dashboard: null } })); // fail + await app.mutateWorkspace(() => ({ candidate: { storageVersion: 2, id: 'x', key: 'x', name: 'n', queries: [{ bad: true } as never], dashboard: null } })); // fail expect(posted).toHaveLength(1); // still just the one success }); @@ -658,7 +700,9 @@ describe('app cross-tab invalidation (#343)', () => { // Wrap B's DEFAULT no-op so it still runs (coverage) while we observe. const bDefault = b.onExternalWorkspaceChange; b.onExternalWorkspaceChange = (m) => { bSeen.push(m); bDefault(m); }; - await a.mutateWorkspace(() => ({ candidate: seed() })); + await seedActiveWorkspace(a, seed()); + await b.loadWorkspaceOnBoot(); + await a.mutateWorkspace((latest) => ({ candidate: { ...latest!, name: 'Changed' } })); expect(aSeen).toHaveLength(0); // guard drops A's own poke expect(bSeen).toEqual([{ type: 'workspace-changed', sourceTabId: a.sourceTabId, workspaceId: 'w1' }]); }); @@ -710,7 +754,8 @@ describe('app cross-tab invalidation (#343)', () => { (window as unknown as { BroadcastChannel?: unknown }).BroadcastChannel = FakeBC; try { const app = createApp(env({ broadcastChannel: undefined })); - await app.mutateWorkspace(() => ({ candidate: seed() })); + await seedActiveWorkspace(app, seed()); + await app.mutateWorkspace((latest) => ({ candidate: { ...latest!, name: 'Changed' } })); const ch = FakeBC.made.find((c) => c.name === 'asb:workspace'); expect(ch?.posted).toHaveLength(1); } finally { @@ -720,7 +765,8 @@ describe('app cross-tab invalidation (#343)', () => { it('commits fine when no channel is available (null factory)', async () => { const app = createApp(env({ broadcastChannel: () => null })); - const result = await app.mutateWorkspace(() => ({ candidate: seed() })); + await seedActiveWorkspace(app, seed()); + const result = await app.mutateWorkspace((latest) => ({ candidate: { ...latest!, name: 'Changed' } })); expect(result.ok).toBe(true); }); }); @@ -728,8 +774,8 @@ describe('app cross-tab invalidation (#343)', () => { // #343 steps 4/6/8 β€” workspace refresh through the write queue (focus/visibility // fallback), the linked-tab conflict resolution UI, and the Save-button state. describe('app workspace refresh + conflict UI (#343)', () => { - const q1ws = (sql = 'SELECT 1', name = 'q1'): StoredWorkspaceV1 => ({ - storageVersion: 1, id: 'w1', name: 'Team', + const q1ws = (sql = 'SELECT 1', name = 'q1'): StoredWorkspaceV2 => ({ + storageVersion: 2, id: 'w1', key: 'workspace_one', name: 'Team', queries: [{ id: 'q1', sql, specVersion: 1, spec: { name, favorite: false } } as SavedQueryV2], dashboard: null, }); @@ -740,7 +786,7 @@ describe('app workspace refresh + conflict UI (#343)', () => { it('Save on a linked tab whose query was deleted in another tab refreshes the association (#343 review)', async () => { const app = createApp(env()); - await app.mutateWorkspace(() => ({ candidate: q1ws() })); + await seedActiveWorkspace(app, q1ws()); await app.loadWorkspaceOnBoot(); app.renderApp(); const t = openQ1(app); @@ -757,14 +803,14 @@ describe('app workspace refresh + conflict UI (#343)', () => { expect(t.savedId).toBeNull(); expect(t.externalState).toBe('deleted'); expect(t.sqlDraft).toBe('SELECT my draft'); - const persisted = await app.workspace.loadCurrent(); + const persisted = await loadActiveWorkspace(app); expect(persisted!.queries).toHaveLength(0); // still deleted }); it('window focus schedules a refresh; a visible visibilitychange schedules another', async () => { const app = createApp(env()); - await app.mutateWorkspace(() => ({ candidate: q1ws() })); - const spy = vi.spyOn(app.workspace, 'loadCurrentResult'); + await seedActiveWorkspace(app, q1ws()); + const spy = vi.spyOn(app.workspace, 'loadById'); window.dispatchEvent(new Event('focus')); await app.flushWorkspaceWrites(); expect(spy).toHaveBeenCalled(); @@ -776,8 +822,8 @@ describe('app workspace refresh + conflict UI (#343)', () => { it('visibilitychange does not refresh while the tab is hidden', async () => { const app = createApp(env({ documentVisible: () => false })); - await app.mutateWorkspace(() => ({ candidate: q1ws() })); - const spy = vi.spyOn(app.workspace, 'loadCurrentResult'); + await seedActiveWorkspace(app, q1ws()); + const spy = vi.spyOn(app.workspace, 'loadById'); document.dispatchEvent(new Event('visibilitychange')); await app.flushWorkspaceWrites(); expect(spy).not.toHaveBeenCalled(); @@ -785,8 +831,10 @@ describe('app workspace refresh + conflict UI (#343)', () => { it('a corrupt reload warns and keeps the projection without wedging the queue', async () => { const app = createApp(env()); - await app.mutateWorkspace(() => ({ candidate: q1ws() })); - vi.spyOn(app.workspace, 'loadCurrentResult').mockResolvedValueOnce({ status: 'corrupt', diagnostics: [] }); + await seedActiveWorkspace(app, q1ws()); + vi.spyOn(app.workspace, 'loadById').mockResolvedValueOnce({ + status: 'corrupt', id: 'w1', key: 'workspace_one', diagnostics: [], + }); await app.refreshWorkspaceFromStore(); // warns internally, returns expect(app.state.savedQueries[0].id).toBe('q1'); // projection intact const after = await app.mutateWorkspace((latest) => ({ candidate: { ...latest!, name: 'Still works' } })); @@ -795,7 +843,7 @@ describe('app workspace refresh + conflict UI (#343)', () => { it('back-fills the per-tab baseline token for a tab linked without one at projection', async () => { const app = createApp(env()); - await app.mutateWorkspace(() => ({ candidate: q1ws() })); + await seedActiveWorkspace(app, q1ws()); const t = app.state.tabs.value[0]; t.savedId = 'q1'; t.lastCommittedQueryToken = undefined; // linked, but no baseline token yet @@ -805,7 +853,7 @@ describe('app workspace refresh + conflict UI (#343)', () => { it('shows "Resolve conflict" on the Save button for a conflicted tab', async () => { const app = createApp(env()); - await app.mutateWorkspace(() => ({ candidate: q1ws() })); + await seedActiveWorkspace(app, q1ws()); await app.loadWorkspaceOnBoot(); app.renderApp(); const t = openQ1(app); @@ -821,7 +869,7 @@ describe('app workspace refresh + conflict UI (#343)', () => { it('Save on a conflicted tab opens the two-action chooser (once), not a silent overwrite', async () => { const app = createApp(env()); - await app.mutateWorkspace(() => ({ candidate: q1ws() })); + await seedActiveWorkspace(app, q1ws()); await app.loadWorkspaceOnBoot(); app.renderApp(); const t = openQ1(app); @@ -837,7 +885,7 @@ describe('app workspace refresh + conflict UI (#343)', () => { const store = fakeIndexedDbFactory(); const app = createApp(env({ indexedDB: store })); const other = createApp(env({ indexedDB: store })); - await app.mutateWorkspace(() => ({ candidate: q1ws('SELECT 1') })); + await seedActiveWorkspace(app, q1ws('SELECT 1')); await app.loadWorkspaceOnBoot(); app.renderApp(); const t = openQ1(app); @@ -858,7 +906,7 @@ describe('app workspace refresh + conflict UI (#343)', () => { const store = fakeIndexedDbFactory(); const app = createApp(env({ indexedDB: store })); const other = createApp(env({ indexedDB: store })); - await app.mutateWorkspace(() => ({ candidate: q1ws('SELECT 1') })); + await seedActiveWorkspace(app, q1ws('SELECT 1')); await app.loadWorkspaceOnBoot(); app.renderApp(); const t = openQ1(app); @@ -872,13 +920,13 @@ describe('app workspace refresh + conflict UI (#343)', () => { (document.querySelector('.conflict-chooser .cf-overwrite') as HTMLElement).dispatchEvent(new Event('click', { bubbles: true })); await app.flushWorkspaceWrites(); expect(t.externalState ?? null).toBeNull(); // conflict resolved - const persisted = await app.workspace.loadCurrent(); + const persisted = await loadActiveWorkspace(app); expect(persisted!.queries.find((q) => q.id === 'q1')!.sql).toBe('SELECT my kept draft'); }); it('the reload resolution on a vanished query refreshes the tab association instead of leaving the stale conflict (#343 review)', async () => { const app = createApp(env()); - await app.mutateWorkspace(() => ({ candidate: q1ws() })); + await seedActiveWorkspace(app, q1ws()); await app.loadWorkspaceOnBoot(); app.renderApp(); const t = openQ1(app); @@ -3294,6 +3342,7 @@ describe('share + star + columns', () => { }); it('save opens a name popover; Save commits, links the tab, and the button reads "Saved"', async () => { const app = createApp(env()); + await app.loadWorkspaceOnBoot(); app.renderApp(); app.activeTab().sqlDraft = 'SELECT 42'; app.actions.save(); // opens the popover synchronously (before any await) @@ -3302,7 +3351,8 @@ describe('share + star + columns', () => { expect(qs(pop, '.sp-input').value).toBe('SELECT 42'); // inferred name qs(pop, '.sp-input').value = 'My fave'; qs(pop, '.sp-save').dispatchEvent(new Event('click')); - await flush(); // the popover's Save button awaits the aggregate commit (#287 W4) + await app.flushWorkspaceWrites(); + await flush(); // let the popover handler finish its post-commit repaint expect(app.state.savedQueries).toHaveLength(1); expect(app.state.savedQueries[0]).toMatchObject({ sql: 'SELECT 42', spec: { name: 'My fave', favorite: false } }); expect(app.activeTab().savedId).toBe(app.state.savedQueries[0].id); @@ -3312,11 +3362,13 @@ describe('share + star + columns', () => { }); it('successful create keeps Saved confirmation alongside an inference warning', async () => { const app = createApp(env()); + await app.loadWorkspaceOnBoot(); app.renderApp(); app.activeTab().sqlDraft = 'SELECT {from:DateTime}, {to:DateTime}, {start:DateTime}, {end:DateTime}'; app.actions.save(); qs(document, '.save-popover .sp-input').value = 'Ambiguous range'; qs(document, '.save-popover .sp-save').dispatchEvent(new Event('click')); + await app.flushWorkspaceWrites(); await flush(); expect(app.state.savedQueries).toHaveLength(1); expect(app.state.savedQueries[0].spec.timeRanges).toBeUndefined(); @@ -3325,6 +3377,7 @@ describe('share + star + columns', () => { }); it('save popover keyboard handlers reject a blank name and commit from name or description', async () => { const app = createApp(env()); + await app.loadWorkspaceOnBoot(); app.renderApp(); app.activeTab().sqlDraft = 'SELECT 42'; app.actions.save(); @@ -3338,11 +3391,13 @@ describe('share + star + columns', () => { expect(app.state.savedQueries).toEqual([]); input.value = 'Keyboard save'; description.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true, cancelable: true })); + await app.flushWorkspaceWrites(); await flush(); expect(app.state.savedQueries[0].spec.name).toBe('Keyboard save'); }); it('save popover: re-opening is idempotent, Esc closes, dirty edit flips "Saved"β†’"Save"', async () => { const app = createApp(env()); + await app.loadWorkspaceOnBoot(); app.renderApp(); app.activeTab().sqlDraft = 'SELECT 1'; app.actions.save(); @@ -3350,6 +3405,7 @@ describe('share + star + columns', () => { expect(qsa(document, '.save-popover')).toHaveLength(1); qs(document, '.save-popover .sp-input').value = 'Q'; qs(document, '.save-popover .sp-save').dispatchEvent(new Event('click')); + await app.flushWorkspaceWrites(); await flush(); expect(app.dom.saveBtn!.textContent).toContain('Saved'); // edit β†’ button reverts to "Save" @@ -3384,8 +3440,12 @@ describe('share + star + columns', () => { }); it('successful linked Save keeps Saved confirmation alongside an inference warning', async () => { const app = createApp(env()); + await app.loadWorkspaceOnBoot(); app.renderApp(); - app.state.savedQueries = [savedQueryFixture({ id: 's9', name: 'Fav', sql: 'SELECT 9' })]; + const linked = savedQueryFixture({ id: 's9', name: 'Fav', sql: 'SELECT 9' }); + await app.mutateWorkspace((latest) => ({ + candidate: { ...latest!, queries: [linked] }, + })); app.actions.loadIntoNewTab(asQueryOrName(app.state.savedQueries[0])); app.sqlEditor.replaceDocument('SELECT {from:DateTime}, {to:DateTime}, {start:DateTime}, {end:DateTime}'); await app.actions.save(); @@ -3427,23 +3487,33 @@ describe('share + star + columns', () => { const app = createApp(env()); app.state.libraryName.value = 'Before boot'; const before = app.state.workspaceId; + const markOpened = vi.spyOn(app.workspace, 'markOpened'); const workspace = await app.loadWorkspaceOnBoot(); expect(workspace).not.toBeNull(); expect(app.state.savedQueries).toEqual(workspace!.queries); expect(app.state.dashboard).toEqual(workspace!.dashboard); expect(app.state.workspaceId).toBe(workspace!.id); + expect(app.state.workspaceKey).toBe(workspace!.key); expect(app.state.workspaceId).not.toBe(before); // overwritten by the real committed id expect(app.state.libraryName.value).toBe(workspace!.name); + expect(markOpened).toHaveBeenCalledWith(workspace!.key); - // The one-shot migration's own commit rejected (a real repository + // Initial collection provisioning rejected (a real repository // rejection, not an unavailable store β€” see `workspace-persist-failed`): - // nothing was ever written, so the still-real, working `loadCurrent()` - // finds no record and resolves null β†’ state is left exactly as it was + // nothing was ever written, so collection resolution stays empty and + // resolves null β†’ state is left exactly as it was // (the synchronous `createState()` legacy projection, including its // minted placeholder id). const app2 = createApp(env()); const beforeId2 = app2.state.workspaceId; - app2.workspace.commit = vi.fn(async () => ({ + app2.workspace.list = vi.fn(async () => ({ + summaries: [{ + id: 'existing', key: 'sql_library', name: 'Existing', + queryCount: 0, hasDashboard: false, lastOpenedAt: null, + }], + corrupt: [], + })); + app2.workspace.create = vi.fn(async () => ({ ok: false as const, diagnostics: [{ path: [], severity: 'error' as const, code: 'test-fail', message: 'boom' }], })); const workspace2 = await app2.loadWorkspaceOnBoot(); @@ -3451,20 +3521,176 @@ describe('share + star + columns', () => { expect(app2.state.workspaceId).toBe(beforeId2); expect(app2.state.dashboard).toBeNull(); }); + it('keeps the workspace open and warns when last-used metadata cannot be recorded', async () => { + const app = createApp(env()); + app.workspace.markOpened = vi.fn(async () => ({ + ok: false as const, + diagnostics: [{ + path: [], severity: 'error' as const, + code: 'workspace-mark-opened-failed', message: 'metadata blocked', + }], + })); + const workspace = await app.loadWorkspaceOnBoot(); + expect(workspace).not.toBeNull(); + expect(app.state.workspaceId).toBe(workspace!.id); + expect(qs(document, '.share-toast').textContent) + .toBe('Workspace opened, but its last-used timestamp could not be saved.'); + }); + it('returns null for an explicit Dashboard workspace key that does not exist', async () => { + const app = createApp(env()); + await expect(app.loadDashboardWorkspace('missing_workspace')).resolves.toBeNull(); + }); + it('marks a keyed Dashboard workspace opened only after its Dashboard id resolves', async () => { + const app = createApp(env()); + const workspace: StoredWorkspaceV2 = { + storageVersion: 2, id: 'dashboard-workspace-id', key: 'dashboard_workspace', + name: 'Dashboard workspace', queries: [], + dashboard: { + documentVersion: 1, id: 'dashboard-id', title: 'Dashboard', revision: 1, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, + filters: [], tiles: [], + }, + }; + expect((await app.workspace.create(workspace)).ok).toBe(true); + const markOpened = vi.spyOn(app.workspace, 'markOpened'); + + await expect(app.loadDashboardWorkspace(workspace.key, 'missing-dashboard')).resolves.toBeNull(); + expect(markOpened).not.toHaveBeenCalled(); + + await expect(app.loadDashboardWorkspace(workspace.key, 'dashboard-id')).resolves.toMatchObject({ + id: workspace.id, + }); + expect(markOpened).toHaveBeenCalledOnce(); + expect(markOpened).toHaveBeenCalledWith(workspace.key); + }); + it('boot resolves an explicit Workbench ws key without falling back or provisioning', async () => { + const location = { + host: 'ch.example', origin: 'https://ch.example', pathname: '/sql', + search: '?ws=alpha', hash: '', href: 'https://ch.example/sql?ws=alpha', + } as Location; + const app = createApp(env({ location })); + const alpha: StoredWorkspaceV2 = { + storageVersion: 2, id: 'alpha-id', key: 'alpha', name: 'Alpha', + queries: [savedQueryFixture({ id: 'q1', name: 'Q1' })], dashboard: null, + }; + const created = await app.workspace.create(alpha); + expect(created.ok).toBe(true); + const create = vi.spyOn(app.workspace, 'create'); + const resolved = await app.loadWorkspaceOnBoot(); + expect(resolved?.id).toBe('alpha-id'); + expect(app.state.workspaceKey).toBe('alpha'); + expect(create).not.toHaveBeenCalled(); + + const missing = createApp(env({ + location: { ...location, search: '?ws=missing' } as Location, + })); + const beforeId = missing.state.workspaceId; + const missingCreate = vi.spyOn(missing.workspace, 'create'); + await expect(missing.loadWorkspaceOnBoot()).resolves.toBeNull(); + expect(missing.state.workspaceId).toBe(beforeId); + expect(missingCreate).not.toHaveBeenCalled(); + }); + it('boot surfaces an explicitly keyed corrupt workspace without implicit fallback', async () => { + const app = createApp(env({ + location: { + host: 'ch.example', origin: 'https://ch.example', pathname: '/sql', + search: '?ws=broken', hash: '', href: 'https://ch.example/sql?ws=broken', + } as Location, + })); + app.workspace.loadByKey = vi.fn(async () => ({ + status: 'corrupt' as const, + id: 'broken-id', + key: 'broken', + diagnostics: [{ + path: [], severity: 'error' as const, + code: 'workspace-version-unsupported', message: 'Unsupported version', + }], + })); + const implicit = vi.spyOn(app.workspace, 'resolveImplicit'); + await expect(app.loadWorkspaceOnBoot()).resolves.toBeNull(); + expect(implicit).not.toHaveBeenCalled(); + expect(qs(document, '.share-toast').textContent).toContain('Saved workspace could not be read'); + }); it('#365: applying a committed workspace scrubs dangling tab links but keeps the SQL draft open', () => { const app = createApp(env()); const tab = app.activeTab(); tab.sqlDraft = 'SELECT still_here'; tab.savedId = 'removed'; tab.editorMode = 'spec'; - const workspace: StoredWorkspaceV1 = { - storageVersion: 1, id: 'new-workspace', name: 'New workspace', queries: [], dashboard: null, + const workspace: StoredWorkspaceV2 = { + storageVersion: 2, id: 'new-workspace', key: 'new_workspace', + name: 'New workspace', queries: [], dashboard: null, }; app.applyCommittedWorkspace(workspace); expect(tab).toMatchObject({ sqlDraft: 'SELECT still_here', savedId: null, editorMode: 'sql' }); expect(app.state.savedQueries).toEqual([]); expect(app.state.workspaceId).toBe('new-workspace'); }); + it('detaches a linked tab when a different workspace reuses the same query id', () => { + const app = createApp(env()); + const tab = app.activeTab(); + const workspaceA: StoredWorkspaceV2 = { + storageVersion: 2, id: 'workspace-a', key: 'workspace_a', + name: 'Workspace A', + queries: [savedQueryFixture({ id: 'q1', name: 'A', sql: "SELECT 'A'" })], + dashboard: null, + }; + const workspaceB: StoredWorkspaceV2 = { + storageVersion: 2, id: 'workspace-b', key: 'workspace_b', + name: 'Workspace B', + queries: [savedQueryFixture({ id: 'q1', name: 'B', sql: "SELECT 'B'" })], + dashboard: null, + }; + app.applyCommittedWorkspace(workspaceA); + tab.savedId = 'q1'; + tab.editorMode = 'spec'; + tab.sqlDraft = "SELECT 'A draft'"; + tab.specText = '{"name":"A draft"}'; + tab.dirtySql = true; + tab.dirtySpec = true; + tab.lastCommittedQueryToken = 'workspace-a-token'; + tab.externalState = 'conflict'; + + app.applyCommittedWorkspace(workspaceB); + + expect(tab).toMatchObject({ + savedId: null, + editorMode: 'sql', + sqlDraft: "SELECT 'A draft'", + specText: '{"name":"A draft"}', + dirtySql: true, + dirtySpec: true, + externalState: null, + }); + expect(tab.lastCommittedQueryToken).toBeUndefined(); + expect(savedForTab(app.state, tab)).toBeNull(); + }); + it('keeps a valid query link when re-projecting the same workspace identity', () => { + const app = createApp(env()); + const tab = app.activeTab(); + const workspace: StoredWorkspaceV2 = { + storageVersion: 2, id: 'workspace-a', key: 'workspace_a', + name: 'Workspace A', + queries: [savedQueryFixture({ id: 'q1', name: 'A', sql: "SELECT 'A'" })], + dashboard: null, + }; + app.applyCommittedWorkspace(workspace); + tab.savedId = 'q1'; + tab.editorMode = 'spec'; + tab.lastCommittedQueryToken = 'existing-token'; + + app.applyCommittedWorkspace({ + ...workspace, + queries: [savedQueryFixture({ id: 'q1', name: 'A2', sql: "SELECT 'A2'" })], + }); + + expect(tab).toMatchObject({ + savedId: 'q1', + editorMode: 'spec', + lastCommittedQueryToken: 'existing-token', + }); + expect(savedForTab(app.state, tab)?.sql).toBe("SELECT 'A2'"); + }); it('#300: a corrupt-but-present aggregate surfaces a toast instead of silently continuing, and its Reset action rebuilds a fresh one', async () => { const app = createApp(env()); const beforeId = app.state.workspaceId; @@ -3474,16 +3700,24 @@ describe('share + star + columns', () => { code: 'workspace-version-unsupported', message: 'Unsupported stored-workspace version', }]; // Simulate a corrupt-but-present record without hand-rolling raw - // IndexedDB bytes: stub `loadCurrentResult` to report `corrupt` while + // IndexedDB bytes: stub `resolveImplicit` to report `corrupt` while // `corrupted` is true, delegating to the REAL (fake-IndexedDB-backed) // implementation once it flips false β€” so Reset's rebuild below exercises // the genuine migrate-then-load path, not another stub. let corrupted = true; - const realLoadCurrentResult = app.workspace.loadCurrentResult.bind(app.workspace); - app.workspace.loadCurrentResult = vi.fn( - async () => (corrupted ? { status: 'corrupt' as const, diagnostics } : realLoadCurrentResult()), + const realResolveImplicit = app.workspace.resolveImplicit.bind(app.workspace); + app.workspace.resolveImplicit = vi.fn( + async () => (corrupted + ? { + status: 'corrupt' as const, + id: 'corrupt-workspace', + key: 'corrupt_workspace', + diagnostics, + } + : realResolveImplicit()), ); - const clearSpy = vi.spyOn(app.workspace, 'clearCurrent'); + const deleteSpy = vi.spyOn(app.workspace, 'delete') + .mockResolvedValueOnce({ ok: true, deleted: true }); const workspace = await app.loadWorkspaceOnBoot(); expect(workspace).toBeNull(); @@ -3496,26 +3730,53 @@ describe('share + star + columns', () => { expect(toastEl.textContent).toContain('Saved workspace could not be read'); const resetBtn = qs(toastEl, 'button.share-toast-action'); expect(resetBtn.textContent).toBe('Reset workspace'); - expect(clearSpy).not.toHaveBeenCalled(); + expect(deleteSpy).not.toHaveBeenCalled(); - // Invoke the Reset action: clears the (real) store, then re-runs the - // migrate + load path, which now genuinely resolves `ok` and projects. + // Invoke Reset: deletes only the corrupt record, then re-runs collection + // resolution/provisioning and projects the replacement. corrupted = false; resetBtn.click(); - await flush(); + await vi.waitFor(() => { + expect(app.state.workspaceId).not.toBe(beforeId); + }); - expect(clearSpy).toHaveBeenCalledTimes(1); - const rebuilt = await app.workspace.loadCurrent(); + expect(deleteSpy).toHaveBeenCalledWith('corrupt-workspace'); + const rebuilt = await loadActiveWorkspace(app); expect(rebuilt).not.toBeNull(); expect(app.state.workspaceId).toBe(rebuilt!.id); expect(app.state.workspaceId).not.toBe(beforeId); }); + it('leaves a corrupt workspace untouched when its targeted reset delete fails', async () => { + const app = createApp(env()); + const beforeId = app.state.workspaceId; + app.workspace.resolveImplicit = vi.fn(async () => ({ + status: 'corrupt' as const, + id: 'corrupt-workspace', + key: 'corrupt_workspace', + diagnostics: [{ + path: [], severity: 'error' as const, + code: 'workspace-version-unsupported', message: 'Unsupported version', + }], + })); + app.workspace.delete = vi.fn(async () => ({ + ok: false as const, + diagnostics: [{ + path: [], severity: 'error' as const, + code: 'workspace-delete-failed', message: 'Delete failed', + }], + })); + await app.loadWorkspaceOnBoot(); + qs(document, '.share-toast-action').click(); + await flush(); + expect(app.workspace.delete).toHaveBeenCalledWith('corrupt-workspace'); + expect(app.state.workspaceId).toBe(beforeId); + }); it('#300: the empty and ok load-result cases behave exactly as before (no toast, migrate-then-project runs as usual)', async () => { const app = createApp(env()); // `env()`'s own #287 default fake IndexedDB: a working store with nothing // persisted yet resolves `empty`, so `loadWorkspaceOnBoot` migrates and // projects the freshly-committed aggregate exactly as the pre-#300 test - // above (line ~2963) already covers for `loadCurrent`. + // above already covers for collection resolution. const workspace = await app.loadWorkspaceOnBoot(); expect(workspace).not.toBeNull(); expect(app.state.savedQueries).toEqual(workspace!.queries); @@ -3740,8 +4001,14 @@ describe('share + star + columns', () => { }); it('linked Save commits SQL and authoritative Spec directly without a popover', async () => { const app = createApp(env()); + await app.loadWorkspaceOnBoot(); app.renderApp(); - app.state.savedQueries = [savedQueryFixture({ id: 's9', name: 'Fav', sql: 'SELECT 9', description: 'why' })]; + const linked = savedQueryFixture({ + id: 's9', name: 'Fav', sql: 'SELECT 9', description: 'why', + }); + await app.mutateWorkspace((latest) => ({ + candidate: { ...latest!, queries: [linked] }, + })); app.actions.loadIntoNewTab(asQueryOrName(app.state.savedQueries[0])); app.sqlEditor.replaceDocument('SELECT 10'); app.specEditor.replaceDocument('{"name":" Renamed ","description":" updated reason ","favorite":false,"future":{"kept":true}}'); @@ -3799,6 +4066,7 @@ describe('exhaustive controller coverage', () => { it('clicks every header + toolbar control', async () => { const e = env({ window: fakeWin(), navigator: { clipboard: asClipboard({ writeText: vi.fn(async () => {}) }) } }); const app = createApp(e); + await app.loadWorkspaceOnBoot(); app.renderApp(); qs(app.root, '.new-tab').dispatchEvent(new Event('click')); qs(app.root, '.hd-btn[title^="Keyboard"]').dispatchEvent(new Event('click')); // shortcuts @@ -3806,7 +4074,8 @@ describe('exhaustive controller coverage', () => { app.dom.saveBtn!.dispatchEvent(new Event('click')); // open save popover qs(document, '.save-popover .sp-input').value = 'Q'; qs(document, '.save-popover .sp-save').dispatchEvent(new Event('click')); // commit - await flush(); // the popover's Save button awaits the aggregate commit (#287 W4) + await app.flushWorkspaceWrites(); + await flush(); // let the popover handler finish its post-commit repaint app.dom.shareBtn!.dispatchEvent(new Event('click')); // share expect(app.state.tabs.value.length).toBeGreaterThan(1); expect(app.state.savedQueries.length).toBe(1); @@ -4879,7 +5148,9 @@ describe('Dashboard viewing (open-source, handoff, actions) β€” #288/#302', () = it('createApp parses the tab open-source + route flag from the location', () => { const editTab = createApp(env({ location: { origin: 'https://ch.example', pathname: '/sql/dashboard', search: '?ws=w1&dash=d1', host: 'ch.example' } as Location })); - expect(editTab.dashboardOpenSource).toEqual({ kind: 'current-workspace', workspaceId: 'w1', dashboardId: 'd1' }); + expect(editTab.dashboardOpenSource).toEqual({ + kind: 'current-workspace', workspaceKey: 'w1', dashboardId: 'd1', + }); expect(editTab.dashboardRoute).toBe(true); const workbenchTab = createApp(env()); expect(workbenchTab.dashboardOpenSource).toBeNull(); @@ -4890,10 +5161,10 @@ describe('Dashboard viewing (open-source, handoff, actions) β€” #288/#302', () = const opened: (string | undefined)[] = []; const c = child(); const app = createApp(env({ openWindow: asOpenWindow((url?: string) => { opened.push(url); return c; }) })); - app.state.workspaceId = 'w9'; + app.state.workspaceKey = 'workspace_nine'; app.state.dashboard = vdash() as never; app.openDashboard(); - expect(opened[0]).toBe('https://ch.example/sql/dashboard?ws=w9&dash=d'); + expect(opened[0]).toBe('https://ch.example/sql/dashboard?ws=workspace_nine&dash=d'); expect(c.postMessage).not.toBe(undefined); // grantHandoffTo ran against the child // No dashboard β†’ bare route. app.state.dashboard = null; @@ -4948,7 +5219,11 @@ describe('Dashboard viewing (open-source, handoff, actions) β€” #288/#302', () = const app = createApp(env({ location: { origin: 'https://ch.example', pathname: '/sql/dashboard', search: '?st=tok&dash=d', host: 'ch.example' } as Location })); const put = vi.fn(async () => {}); app.handoff = { take: vi.fn(async () => ({ text: bundleText(), dashboardId: 'd', detachedWorkspaceId: 'wsview-1', expiresAt: 9e12 })), put: vi.fn(async () => {}) }; - app.detachedViews = { get: vi.fn(async () => null), put }; + app.detachedViews = { + get: vi.fn(async () => null), + getByKey: vi.fn(async () => null), + put, + }; // happy-dom's real replaceState rejects a cross-origin URL from the test's // blob: document β€” stub the impl so we observe the call without it throwing. const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); @@ -4956,7 +5231,9 @@ describe('Dashboard viewing (open-source, handoff, actions) β€” #288/#302', () = expect(ws?.id).toBe('wsview-1'); expect(put).toHaveBeenCalledOnce(); expect(replaceState).toHaveBeenCalled(); - expect(app.dashboardOpenSource).toEqual({ kind: 'current-workspace', workspaceId: 'wsview-1', dashboardId: 'd' }); + expect(app.dashboardOpenSource).toEqual({ + kind: 'current-workspace', workspaceKey: 'wsview-1', dashboardId: 'd', + }); replaceState.mockRestore(); }); @@ -4974,11 +5251,15 @@ describe('Dashboard viewing (open-source, handoff, actions) β€” #288/#302', () = const app = createApp(env({ location: { origin: 'https://ch.example', pathname: '/sql/dashboard', search: '?ws=w&dash=old', host: 'ch.example' } as Location })); app.renderDashboard = vi.fn(); const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); - app.state.workspaceId = 'w'; + app.state.workspaceKey = 'workspace'; app.state.dashboard = { ...vdash(), id: 'dNew' } as never; app.reloadDashboardRoute(); - expect(replaceState).toHaveBeenCalledWith(null, '', 'https://ch.example/sql/dashboard?ws=w&dash=dNew'); - expect(app.dashboardOpenSource).toEqual({ kind: 'current-workspace', workspaceId: 'w', dashboardId: 'dNew' }); + expect(replaceState).toHaveBeenCalledWith( + null, '', 'https://ch.example/sql/dashboard?ws=workspace&dash=dNew', + ); + expect(app.dashboardOpenSource).toEqual({ + kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'dNew', + }); expect(app.renderDashboard).toHaveBeenCalledOnce(); // No dashboard β†’ re-render only, no URL rewrite. replaceState.mockClear(); diff --git a/tests/unit/canonical-json.test.ts b/tests/unit/canonical-json.test.ts index e0d849d0..cf32a0c6 100644 --- a/tests/unit/canonical-json.test.ts +++ b/tests/unit/canonical-json.test.ts @@ -88,11 +88,13 @@ describe('documented shapes', () => { 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 }; + const workspaceA = { dashboard, queries: [query], name: 'W', key: 'operations', id: 'w1', storageVersion: 2 }; + const workspaceB = { storageVersion: 2, id: 'w1', key: 'operations', 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('"id"')).toBeLessThan(ws.indexOf('"key"')); + expect(ws.indexOf('"key"')).toBeLessThan(ws.indexOf('"name"')); expect(ws.indexOf('"storageVersion"')).toBeLessThan(ws.indexOf('"queries"')); expect(ws.indexOf('"documentVersion"')).toBeLessThan(ws.indexOf('"revision"')); diff --git a/tests/unit/cross-tab-consistency.test.ts b/tests/unit/cross-tab-consistency.test.ts index eee5c5ca..fa48ce83 100644 --- a/tests/unit/cross-tab-consistency.test.ts +++ b/tests/unit/cross-tab-consistency.test.ts @@ -6,7 +6,7 @@ // tab A must survive an unrelated change committed in tab B, and an operation // that no longer applies to `latest` must abort without recreating deleted data. // -// These assert the PERSISTED outcome via the shared store (`loadCurrent`), not +// These assert the persisted outcome via the active record's immutable ID, not // UI refresh β€” the cross-tab invalidation/refresh wiring is #343 step 4+. import { describe, it, expect, vi } from 'vitest'; @@ -14,7 +14,7 @@ import { createApp } from '../../src/ui/app.js'; import { createSavedQuery, commitSavedQuery, deleteSaved, renameSaved } from '../../src/state.js'; import { fakeIndexedDbFactory } from '../helpers/fake-idb.js'; import type { App } from '../../src/ui/app.types.js'; -import type { SavedQueryV2, StoredWorkspaceV1, DashboardDocumentV1 } from '../../src/generated/json-schema.types.js'; +import type { SavedQueryV2, StoredWorkspaceV2, DashboardDocumentV1 } from '../../src/generated/json-schema.types.js'; // A minimal real `createApp` over an injected shared store β€” the editor/spec // seams fall back to their noop ports (createApp's own defaults), so no @@ -39,28 +39,29 @@ const twoTileDashboard = (): DashboardDocumentV1 => ({ filters: [], tiles: [{ id: 't1', queryId: 'q1' }, { id: 't2', queryId: 'q2' }], } as DashboardDocumentV1); -const dashboardSeed = (): StoredWorkspaceV1 => ({ - storageVersion: 1, id: 'w1', name: 'Team', queries: [tiledQuery('q1'), tiledQuery('q2')], +const dashboardSeed = (): StoredWorkspaceV2 => ({ + storageVersion: 2, id: 'w1', key: 'team', name: 'Team', queries: [tiledQuery('q1'), tiledQuery('q2')], dashboard: twoTileDashboard(), }); /** Load the committed workspace from the shared store (fails loudly if empty). */ -async function committed(app: App): Promise { - const workspace = await app.workspace.loadCurrent(); - if (!workspace) throw new Error('expected a committed workspace'); - return workspace; +async function committed(app: App): Promise { + const loaded = await app.workspace.loadById(app.state.workspaceId); + if (loaded.status !== 'ok') throw new Error('expected a committed workspace'); + return loaded.workspace; } -const layoutItems = (workspace: StoredWorkspaceV1): Record => +const layoutItems = (workspace: StoredWorkspaceV2): Record => workspace.dashboard!.layout.items as Record; -const queryById = (workspace: StoredWorkspaceV1, id: string): SavedQueryV2 | undefined => +const queryById = (workspace: StoredWorkspaceV2, id: string): SavedQueryV2 | undefined => workspace.queries.find((q) => q.id === id); /** Seed the shared store (via A) and project it into both tabs, mirroring two * tabs that each loaded the same committed aggregate on boot. */ -async function seed(a: App, b: App, workspace: StoredWorkspaceV1): Promise { - const outcome = await a.mutateWorkspace(() => ({ candidate: workspace })); +async function seed(a: App, b: App, workspace: StoredWorkspaceV2): Promise { + const outcome = await a.workspace.create(workspace); expect(outcome.ok).toBe(true); + if (outcome.ok) a.applyCommittedWorkspace(outcome.workspace); await b.loadWorkspaceOnBoot(); } @@ -159,8 +160,8 @@ describe('cross-tab read-before-write (#343)', () => { it('save-linked to an externally deleted query aborts and does not recreate it', async () => { const store = fakeIndexedDbFactory(); const a = tab(store); const b = tab(store); - const seedNoDash: StoredWorkspaceV1 = { - storageVersion: 1, id: 'w1', name: 'Team', + const seedNoDash: StoredWorkspaceV2 = { + storageVersion: 2, id: 'w1', key: 'team', name: 'Team', queries: [{ id: 'q1', sql: 'SELECT 1', specVersion: 1, spec: { name: 'q1', favorite: false } } as SavedQueryV2], dashboard: null, }; @@ -191,8 +192,8 @@ describe('cross-tab read-before-write (#343)', () => { // linked tabs adopt/conflict/detach/orphan, refresh coalesces + orders through // the write queue, and a failed reload never wedges it. describe('cross-tab refresh + linked-tab reconcile (#343)', () => { - const oneQuery = (sql = 'SELECT 1', name = 'q1'): StoredWorkspaceV1 => ({ - storageVersion: 1, id: 'w1', name: 'Team', + const oneQuery = (sql = 'SELECT 1', name = 'q1'): StoredWorkspaceV2 => ({ + storageVersion: 2, id: 'w1', key: 'team', name: 'Team', queries: [{ id: 'q1', sql, specVersion: 1, spec: { name, favorite: false } } as SavedQueryV2], dashboard: null, }); @@ -235,8 +236,8 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { it('a flagged conflict survives a Library star/rename plus an unrelated external change (#343 review blocker)', async () => { const store = fakeIndexedDbFactory(); const a = tab(store); const b = tab(store); - const twoQueries: StoredWorkspaceV1 = { - storageVersion: 1, id: 'w1', name: 'Team', + const twoQueries: StoredWorkspaceV2 = { + storageVersion: 2, id: 'w1', key: 'team', name: 'Team', queries: [ { id: 'q1', sql: 'SELECT 1', specVersion: 1, spec: { name: 'q1', favorite: false } } as SavedQueryV2, { id: 'q2', sql: 'SELECT 2', specVersion: 1, spec: { name: 'q2', favorite: false } } as SavedQueryV2, @@ -365,7 +366,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { const before = b.state.savedQueries; await b.refreshWorkspaceFromStore(); // token unchanged β†’ no reproject expect(b.state.savedQueries).toBe(before); // same reference, not reprojected - await b.workspace.clearCurrent(); // externally emptied + await b.workspace.delete(b.state.workspaceId); // externally emptied await b.refreshWorkspaceFromStore(); // loaded === null β†’ keeps projection expect(b.state.savedQueries).toBe(before); }); @@ -374,7 +375,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { const store = fakeIndexedDbFactory(); const a = tab(store); const b = tab(store); await seed(a, b, oneQuery()); - const spy = vi.spyOn(b.workspace, 'loadCurrentResult'); + const spy = vi.spyOn(b.workspace, 'loadById'); const poke = { type: 'workspace-changed' as const, sourceTabId: 'other', workspaceId: 'w1' }; b.onExternalWorkspaceChange(poke); b.onExternalWorkspaceChange(poke); @@ -428,10 +429,10 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { expect(final.queries.some((q) => q.spec.name === 'B-new')).toBe(true); // B's write landed on refreshed latest // A rejected reload warns internally but never wedges: a later write succeeds. - const orig = b.workspace.loadCurrentResult.bind(b.workspace); - b.workspace.loadCurrentResult = vi.fn(async () => { throw new Error('idb down'); }); + const orig = b.workspace.loadById.bind(b.workspace); + b.workspace.loadById = vi.fn(async () => { throw new Error('idb down'); }); await b.refreshWorkspaceFromStore(); // swallowed - b.workspace.loadCurrentResult = orig; + b.workspace.loadById = orig; const after = await b.mutateWorkspace((latest) => ({ candidate: { ...latest!, name: 'Recovered' } })); expect(after.ok).toBe(true); }); diff --git a/tests/unit/dashboard-export.test.ts b/tests/unit/dashboard-export.test.ts index 57b768e9..fa24a500 100644 --- a/tests/unit/dashboard-export.test.ts +++ b/tests/unit/dashboard-export.test.ts @@ -4,7 +4,7 @@ import { } from '../../src/dashboard/model/dashboard-export.js'; import { canonicalEqual } from '../../src/dashboard/model/canonical-json.js'; import type { - DashboardDocumentV1, SavedQueryV2, StoredWorkspaceV1, + DashboardDocumentV1, SavedQueryV2, StoredWorkspaceV2, } from '../../src/generated/json-schema.types.js'; const query = (id: string): SavedQueryV2 => ({ @@ -85,12 +85,12 @@ describe('buildDashboardExportBundle', () => { }); describe('buildWorkspaceExportBundle', () => { - const workspaceFixture = (over: Partial = {}): StoredWorkspaceV1 => ({ - storageVersion: 1, id: 'ws', name: 'WS', + const workspaceFixture = (over: Partial = {}): StoredWorkspaceV2 => ({ + storageVersion: 2, id: 'ws', key: 'ws', name: 'WS', queries: [query('q2'), query('q1'), query('q3')], dashboard: dashboard('d1', ['q1']), ...over, - } as StoredWorkspaceV1); + } as StoredWorkspaceV2); it('emits every query in catalog order, not reordered by Dashboard tile usage', () => { const ws = workspaceFixture(); @@ -149,8 +149,8 @@ describe('canonical consistency between the two export bundles (#341)', () => { const nowISO = '2020-01-01T00:00:00Z'; // A workspace whose Dashboard depends on q1 (tile) + q2 (filter source), plus // an unrelated q3 that only the Workbench export carries. - const ws: StoredWorkspaceV1 = { - storageVersion: 1, id: 'ws', name: 'WS', + const ws: StoredWorkspaceV2 = { + storageVersion: 2, id: 'ws', key: 'ws', name: 'WS', queries: [query('q3'), query('q1'), query('q2')], // deliberately not tile order dashboard: dashboard('d1', ['q1'], ['q2']), }; diff --git a/tests/unit/dashboard-open-source.test.ts b/tests/unit/dashboard-open-source.test.ts index 41ec592b..2b2160e8 100644 --- a/tests/unit/dashboard-open-source.test.ts +++ b/tests/unit/dashboard-open-source.test.ts @@ -7,7 +7,7 @@ import type { DashboardOpenSource } from '../../src/dashboard/application/dashbo describe('parseDashboardOpenSource', () => { it('resolves a current-workspace source from ?ws=&dash=', () => { expect(parseDashboardOpenSource('?ws=abc&dash=def')).toEqual({ - kind: 'current-workspace', workspaceId: 'abc', dashboardId: 'def', + kind: 'current-workspace', workspaceKey: 'abc', dashboardId: 'def', }); }); @@ -25,7 +25,7 @@ describe('parseDashboardOpenSource', () => { it('defaults a missing dash to the empty string', () => { expect(parseDashboardOpenSource('?ws=abc')).toEqual({ - kind: 'current-workspace', workspaceId: 'abc', dashboardId: '', + kind: 'current-workspace', workspaceKey: 'abc', dashboardId: '', }); expect(parseDashboardOpenSource('?st=tok')).toEqual({ kind: 'session-bundle', token: 'tok', dashboardId: '', @@ -45,13 +45,13 @@ describe('parseDashboardOpenSource', () => { it('treats an empty ws or st value as absent (falls through)', () => { expect(parseDashboardOpenSource('?ws=&dash=def')).toBeNull(); expect(parseDashboardOpenSource('?st=&ws=abc&dash=def')).toEqual({ - kind: 'current-workspace', workspaceId: 'abc', dashboardId: 'def', + kind: 'current-workspace', workspaceKey: 'abc', dashboardId: 'def', }); }); it('URL-decodes ws, st, and dash', () => { expect(parseDashboardOpenSource('?ws=a%20b&dash=c%2Fd')).toEqual({ - kind: 'current-workspace', workspaceId: 'a b', dashboardId: 'c/d', + kind: 'current-workspace', workspaceKey: 'a b', dashboardId: 'c/d', }); expect(parseDashboardOpenSource('?st=tok%2B1&dash=x')).toEqual({ kind: 'session-bundle', token: 'tok+1', dashboardId: 'x', @@ -62,20 +62,20 @@ describe('parseDashboardOpenSource', () => { expect(parseDashboardOpenSource('?state=csrf-value&dash=def')).toBeNull(); // `state` present alongside a real source must not interfere. expect(parseDashboardOpenSource('?state=csrf-value&ws=abc&dash=def')).toEqual({ - kind: 'current-workspace', workspaceId: 'abc', dashboardId: 'def', + kind: 'current-workspace', workspaceKey: 'abc', dashboardId: 'def', }); }); it('accepts a search string without a leading ?', () => { expect(parseDashboardOpenSource('ws=abc&dash=def')).toEqual({ - kind: 'current-workspace', workspaceId: 'abc', dashboardId: 'def', + kind: 'current-workspace', workspaceKey: 'abc', dashboardId: 'def', }); }); }); describe('buildDashboardSearch', () => { it('builds ?ws=&dash= for a current-workspace source', () => { - expect(buildDashboardSearch({ kind: 'current-workspace', workspaceId: 'abc', dashboardId: 'def' })) + expect(buildDashboardSearch({ kind: 'current-workspace', workspaceKey: 'abc', dashboardId: 'def' })) .toBe('?ws=abc&dash=def'); }); @@ -85,13 +85,13 @@ describe('buildDashboardSearch', () => { }); it('URL-encodes values that need it', () => { - expect(buildDashboardSearch({ kind: 'current-workspace', workspaceId: 'a b', dashboardId: 'c/d' })) + expect(buildDashboardSearch({ kind: 'current-workspace', workspaceKey: 'a b', dashboardId: 'c/d' })) .toBe('?ws=a+b&dash=c%2Fd'); }); it('round-trips both kinds through parse', () => { const sources: DashboardOpenSource[] = [ - { kind: 'current-workspace', workspaceId: 'ws-1', dashboardId: 'dash-1' }, + { kind: 'current-workspace', workspaceKey: 'ws-1', dashboardId: 'dash-1' }, { kind: 'session-bundle', token: 'tok-1', dashboardId: 'dash-1' }, ]; for (const source of sources) { diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index fb6497ef..6572bfbb 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -18,6 +18,7 @@ import { resolveLayoutPluginSync } from '../../src/dashboard/layouts/layout-regi import { applyStreamLine } from '../../src/core/stream.js'; import { emptyRecentMap, recordRecent } from '../../src/core/recent-values.js'; import { makeApp, FakeChart } from '../helpers/fake-app.js'; +import { fakeIndexedDbFactory } from '../helpers/fake-idb.js'; import { createApp } from '../../src/ui/app.js'; import { createCodeMirrorEditor } from '../../src/editor/codemirror-adapter.js'; import { savedQuery } from '../helpers/saved-query.js'; @@ -28,7 +29,7 @@ import type { AppState } from '../../src/state.js'; import type { Column } from '../../src/core/panel-cfg.js'; import type { CreateAppEnv } from '../../src/env.types.js'; import type { ResolvedIdpConfig, ConfigDoc } from '../../src/net/oauth-config.js'; -import type { StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; +import type { StoredWorkspaceV2 } from '../../src/generated/json-schema.types.js'; type FakeApp = ReturnType; @@ -233,7 +234,7 @@ describe('auth-handoff message predicates', () => { // ── 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 +// to the persisted StoredWorkspaceV2; 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. @@ -272,14 +273,15 @@ const q = (id: string, sql: string, extra: Partial = {}): Sav interface WsOver { id?: string; - tiles?: NonNullable['tiles']; + key?: string; + tiles?: NonNullable['tiles']; filters?: Record[]; layout?: Record; queries?: ReturnType[]; title?: string; } const wsWith = (over: WsOver = {}) => ({ - storageVersion: 1 as const, id: 'w', name: 'W', + storageVersion: 2 as const, id: 'w', key: over.key ?? 'workspace', name: 'W', queries: over.queries ?? [], dashboard: { documentVersion: 1 as const, id: over.id ?? 'd', title: over.title ?? 'My Dash', revision: 1, @@ -296,16 +298,16 @@ function dashApp(opts: { } = {}) { const { executeRead, calls } = makeExec(opts.responder); // #344 review fix: `runCommand` now builds its commit candidate through - // `app.mutateWorkspace`, which reads `app.workspace.loadCurrent()` at - // DEQUEUE time β€” the module-default `appDefaults.workspace.loadCurrent` - // always answers `null`, so a bare `{ commit }` override (pre-#344's only + // `app.mutateWorkspace`, which reads `app.workspace.loadById()` at + // DEQUEUE time β€” the module-default `appDefaults.workspace.loadById` + // always answers `empty`, so a bare `{ commit }` override (pre-#344's only // requirement) would make every `runCommand` dispatch null-abort. `current` // is this fixture's own tiny stateful mirror (statefulWorkspaceRepo's same // shape, inlined so a caller's custom `opts.commit` β€” simulating a failure // then a success, or a slow-to-resolve first call β€” still keeps - // `loadCurrent` in sync: only a genuinely OK result advances `current`, + // `loadById` in sync: only a genuinely OK result advances `current`, // exactly like the real `WorkspaceRepository`). - let current: StoredWorkspaceV1 | null = (opts.workspace === undefined ? null : opts.workspace) as StoredWorkspaceV1 | null; + let current: StoredWorkspaceV2 | null = (opts.workspace === undefined ? null : opts.workspace) as StoredWorkspaceV2 | null; // #341: default commit ECHOES the candidate it was given (mirrors // `appDefaults.workspace.commit` in fake-app.ts) β€” `runCommand`'s post-commit // projection (`applyCommittedWorkspace(result.workspace)`, `currentDoc = @@ -321,15 +323,27 @@ function dashApp(opts: { }); const app = makeApp({ exec: { executeRead }, - workspace: { commit, loadCurrent: async () => current }, - // Mirrors production (`app.loadDashboardWorkspace` = migrate + `loadCurrent()`): + workspace: { + commit, + loadById: async (id) => ( + current?.id === id + ? { status: 'ok' as const, workspace: current } + : { status: 'empty' as const } + ), + } as Partial, + // Mirrors production (`app.loadDashboardWorkspace` resolves the route key): // reads the fixture's stateful `current`, so a route REBUILD (#350 β€” // membership-restoring rollback) re-renders from committed truth, not the // initially-loaded snapshot. loadDashboardWorkspace: async () => current as never, }) as TestApp; if (opts.savedQueries) app.state.savedQueries = opts.savedQueries as AppState['savedQueries']; - return { app, calls, commit }; + const loadActive = async (): Promise => { + const loaded = await app.workspace.loadById(app.state.workspaceId); + if (loaded.status !== 'ok') throw new Error(`Expected active workspace, got ${loaded.status}`); + return loaded.workspace; + }; + return { app, calls, commit, loadActive }; } const render = (app: TestApp): Promise => renderDashboard(app as unknown as Parameters[0]); @@ -907,7 +921,7 @@ describe('renderDashboard β€” reorder (Command/Ctrl pointer-drag) + sort (#153/# it('read-only dashboard: ⌘-drag does not move and no drag listeners are wired', async () => { const detached = twoTiles(); const { app, commit } = modeApp({ - workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' }, + workspace: null, detached, openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' }, }); await render(app); const cards = qsa(app.root, '.dash-tile'); @@ -1017,7 +1031,7 @@ describe('renderDashboard β€” modkey cursor cue (#332)', () => { it('a read-only dashboard never installs the modkey listeners', async () => { const detached = oneTile(); const { app } = modeApp({ - workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' }, + workspace: null, detached, openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' }, }); await render(app); const grid = qs(app.root, '.dash-grid'); @@ -1055,7 +1069,7 @@ describe('renderDashboard β€” shared cell-detail drawer (#332)', () => { }); const { app } = modeApp({ workspace: null, detached, responder: () => ({ columns: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], rows: [['hello', 42]] }), - openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' }, + openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' }, }); await render(app); qs(app.root, '.res-table tbody td.cell')?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); @@ -1592,7 +1606,7 @@ describe('renderDashboard β€” grafana-grid engine (#291)', () => { const detached = kpiGridWs(); const { app } = modeApp({ workspace: null, detached, responder: kpiResponder, - openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' }, + openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' }, }); await render(app); const card = qs(app.root, '.dash-gg-tile'); @@ -1703,7 +1717,7 @@ describe('renderDashboard β€” grafana-grid engine (#291)', () => { const detached = twoTilesGrid(); const { app: readonlyApp } = modeApp({ - workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' }, + workspace: null, detached, openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' }, }); await render(readonlyApp); expect(qsa(readonlyApp.root, '.dash-gg-grip').length).toBe(0); @@ -2457,7 +2471,7 @@ describe('renderDashboard β€” drag auto-scroll (#338)', () => { it('read-only: no drag listeners wired, so no auto-scroll ever starts', async () => { const detached = gridWs(); const { app, commit } = modeApp({ - workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' }, + workspace: null, detached, openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' }, }); await render(app); const cards = qsa(app.root, '.dash-gg-tile'); @@ -2704,7 +2718,7 @@ describe('renderDashboard β€” Full view (#321)', () => { it('read-only view: the reduced selector only calls session.setGridRenderMode β€” never a command', async () => { const detached = twoTilesGrid(); const { app, commit } = modeApp({ - workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' }, + workspace: null, detached, openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' }, }); await render(app); const select = layoutSelect(app.root); @@ -3288,7 +3302,7 @@ describe('renderDashboard β€” compound time-range control (#335)', () => { id: 'd', queries: [paired()], tiles: [{ id: 't1', queryId: 'q1' }], }); const { app, calls } = modeApp({ - workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' }, + workspace: null, detached, openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' }, }); await render(app); document.body.appendChild(rootEl(app)); @@ -3849,38 +3863,13 @@ 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() })); + it('provisions an empty aggregate workspace, then reads it back', async () => { + const app = realApp(appEnv({ indexedDB: fakeIndexedDbFactory() })); 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'); + expect(ws?.queries).toEqual([]); + expect(ws?.dashboard).toBeNull(); // Idempotent: a second call finds the aggregate and returns it unchanged. const again = await app.loadDashboardWorkspace(); expect(again?.id).toBe(ws?.id); @@ -3900,11 +3889,11 @@ describe('app.renderDashboard', () => { ]), })]]); const app = realApp(appEnv({ fetch: asFetch(fetch) })); - // Drive the read-flip deterministically: a StoredWorkspaceV1 whose one tile + // Drive the read-flip deterministically: a StoredWorkspaceV2 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], + storageVersion: 2, id: 'w', key: 'workspace', name: 'W', queries: [query], dashboard: { documentVersion: 1, id: 'd', title: 'D', revision: 1, layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [{ id: 't1', queryId: '1' }] }, }); await app.renderDashboard(); @@ -3988,12 +3977,14 @@ describe('app auth handoff', () => { // ── #288 Phase 6: open-source modes + the Dashboard header File menu ────────── // renderDashboard now branches on `app.dashboardOpenSource`: a current-workspace -// route verifies BOTH ids against the primary (edit) or detached (view) store; a +// route verifies BOTH the workspace key and dashboard id against the primary +// (edit) or detached (view) store; a // session-bundle route consumes the one-time handoff into a read-only view; a // resolution failure shows not-found. See ADR-0003. /** Build a dashApp wired for a specific open-source mode. `detached` seeds - * `app.detachedViews.get`; `consume` overrides `app.consumeDashboardHandoff`. */ + * `app.detachedViews.getByKey`; `consume` overrides + * `app.consumeDashboardHandoff`. */ function modeApp(opts: { workspace?: ReturnType | null; openSource?: TestApp['dashboardOpenSource']; @@ -4004,7 +3995,13 @@ function modeApp(opts: { const built = dashApp({ workspace: opts.workspace, responder: opts.responder }); const app = built.app; app.dashboardOpenSource = opts.openSource ?? null; - app.detachedViews = { get: vi.fn(async () => (opts.detached ?? null) as never), put: vi.fn(async () => {}) }; + app.detachedViews = { + get: vi.fn(async () => (opts.detached ?? null) as never), + getByKey: vi.fn(async (key: string) => ( + opts.detached?.key === key ? opts.detached : null + ) as never), + put: vi.fn(async () => {}), + }; if (opts.consume) app.consumeDashboardHandoff = opts.consume; return { ...built, app }; } @@ -4022,9 +4019,9 @@ const menuSections = (): string[] => describe('renderDashboard β€” open-source modes (#288)', () => { afterEach(() => { qsa(document, '.dash-file-menu, .fm-overlay').forEach((n) => n.remove()); }); - it('current-workspace: both ids match the primary store β†’ editable (reorder grip present, layout switcher)', async () => { + it('current-workspace: workspace key and dashboard id match the primary store β†’ editable (reorder grip present, layout switcher)', async () => { const ws = wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }); - const { app } = modeApp({ workspace: ws, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' } }); + const { app } = modeApp({ workspace: ws, openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' } }); await render(app); expect(qs(app.root, '.dash-notfound')).toBeNull(); expect(qsa(app.root, '.dash-tile').length).toBe(1); @@ -4037,7 +4034,7 @@ describe('renderDashboard β€” open-source modes (#288)', () => { it('current-workspace: workspace matches but dashboard id differs β†’ not-found, runs nothing', async () => { const ws = wsWith({ id: 'd' }); - const { app, calls } = modeApp({ workspace: ws, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'other' } }); + const { app, calls } = modeApp({ workspace: ws, openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'other' } }); await render(app); expect(qs(app.root, '.dash-notfound')).toBeTruthy(); expect(qs(app.root, '.dash-notfound-title')?.textContent).toContain('unavailable'); @@ -4045,14 +4042,14 @@ describe('renderDashboard β€” open-source modes (#288)', () => { expect(calls.length).toBe(0); }); - it('current-workspace: id resolves only in the detached store β†’ read-only view (no drag, no layout selector for a flow doc, #321)', async () => { + it('current-workspace: key resolves only in the detached store β†’ read-only view (no drag, no layout selector for a flow doc, #321)', async () => { // `wsWith`'s default layout is flow@1 β€” this is the pre-#321 shape any // existing shared doc has. The reduced read-only selector is a // grafana-grid-only render-mode toggle; for a read-only FLOW doc there is // no engine switch possible read-only, so the selector must be HIDDEN // entirely (not shown with a dead 'Full view' option over a flow layout). const detached = wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }); - const { app } = modeApp({ workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' } }); + const { app } = modeApp({ workspace: null, detached, openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' } }); await render(app); expect(qs(app.root, '.dash-notfound')).toBeNull(); expect(qsa(app.root, '.dash-tile').length).toBe(1); @@ -4066,7 +4063,7 @@ describe('renderDashboard β€” open-source modes (#288)', () => { id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }], layout: { type: 'grafana-grid', version: 1, items: {} }, }); - const { app, commit } = modeApp({ workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' } }); + const { app, commit } = modeApp({ workspace: null, detached, openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' } }); await render(app); expect(qs(app.root, '.dash-notfound')).toBeNull(); // #321: read-only still shows the REDUCED Grid Tiles / Full view selector @@ -4111,7 +4108,7 @@ describe('renderDashboard β€” Dashboard header File menu (#302)', () => { const editApp = () => modeApp({ workspace: wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }), - openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' }, + openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' }, }); it('the trigger uses the shared downward-chevron treatment, never a right-pointing arrow', async () => { @@ -4177,7 +4174,7 @@ describe('renderDashboard β€” Dashboard header File menu (#302)', () => { it('detached view mode omits the File button entirely, not just its Import/Open rows (#347)', async () => { const detached = wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }); - const { app } = modeApp({ workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' } }); + const { app } = modeApp({ workspace: null, detached, openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' } }); await render(app); expect(qs(app.root, '.dash-file-btn')).toBeNull(); expect(document.querySelector('.dash-file-menu')).toBeNull(); @@ -4198,7 +4195,7 @@ describe('renderDashboard β€” Dashboard header File menu (#302)', () => { // tab is showing someone else's shared/detached Dashboard. const primary = wsWith({ id: 'other', queries: [q('secret', 'SELECT 2')], tiles: [{ id: 'ts', queryId: 'secret' }] }); const detached = wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }); - const { app } = modeApp({ workspace: primary, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' } }); + const { app } = modeApp({ workspace: primary, detached, openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' } }); await render(app); expect(qs(app.root, '.dash-notfound')).toBeNull(); // No File control at all β€” no way to reach exportDashboard() (which reads @@ -4249,8 +4246,11 @@ describe('renderDashboard β€” the serialized write pipeline (#341)', () => { it('drops an optimistic command when the dashboard disappeared before dequeue', async () => { const { app, commit } = dashApp({ workspace: twoTiles() }); await render(app); - app.workspace.loadCurrent = vi.fn(async () => ({ - storageVersion: 1 as const, id: 'w', name: 'W', queries: [], dashboard: null, + app.workspace.loadById = vi.fn(async () => ({ + status: 'ok' as const, + workspace: { + storageVersion: 2 as const, id: 'w', key: 'workspace', name: 'W', queries: [], dashboard: null, + }, })); const cards = qsa(app.root, '.dash-tile'); stubTileRects(cards); @@ -4349,7 +4349,7 @@ describe('renderDashboard β€” the serialized write pipeline (#341)', () => { it('rapid commands commit in STRICT invocation order β€” a slow-to-resolve first commit is never skipped or reordered by a second', async () => { const seen: string[] = []; let resolveFirst!: (v: unknown) => void; - const commit = vi.fn((candidate: StoredWorkspaceV1) => { + const commit = vi.fn((candidate: StoredWorkspaceV2) => { const layout = candidate.dashboard!.layout; seen.push(layout.type === 'flow' ? String(layout.preset) : layout.type); const result = { ok: true as const, workspace: candidate, dashboardRevision: candidate.dashboard!.revision }; @@ -4387,7 +4387,7 @@ describe('renderDashboard β€” the serialized write pipeline (#341)', () => { ok: false, diagnostics: [{ path: [], severity: 'error', code: 'workspace-persist-failed', message: 'boom' }], }) - .mockImplementation(async (candidate: StoredWorkspaceV1) => ( + .mockImplementation(async (candidate: StoredWorkspaceV2) => ( { ok: true, workspace: candidate, dashboardRevision: candidate.dashboard ? candidate.dashboard.revision : null } )); const { app } = dashApp({ workspace: twoTiles(), commit }); @@ -4421,7 +4421,7 @@ describe('renderDashboard β€” the serialized write pipeline (#341)', () => { let resolveA!: (v: unknown) => void; const commit = vi.fn() .mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve; })) - .mockImplementation(async (candidate: StoredWorkspaceV1) => ( + .mockImplementation(async (candidate: StoredWorkspaceV2) => ( { ok: true as const, workspace: candidate, dashboardRevision: candidate.dashboard ? candidate.dashboard.revision : null } )); const { app } = dashApp({ workspace: twoTilesGrid(), commit }); @@ -4464,7 +4464,7 @@ describe('renderDashboard β€” the serialized write pipeline (#341)', () => { // Dashboard command β€” its candidate is built from `app.workspace.loadCurrent()` // at dequeue time, never a route-local snapshot taken when the route opened. it('a producer other than Dashboard commands (a saved-query mutation) commits through the same queue without being clobbered', async () => { - const { app } = dashApp({ workspace: twoTilesGrid() }); + const { app, loadActive } = dashApp({ workspace: twoTilesGrid() }); await render(app); // Simulate another producer (e.g. the saved-query drawer) mutating // `queries` through the shared `app.mutateWorkspace` queue. @@ -4488,7 +4488,7 @@ describe('renderDashboard β€” the serialized write pipeline (#341)', () => { // must null-abort β€” roll back its own optimistic edit, toast, and leave the // queue usable for the next command. it('a command invalidated against committed truth by the time it is dequeued rolls back and does not wedge the queue', async () => { - const { app } = dashApp({ workspace: twoTilesGrid() }); + const { app, loadActive } = dashApp({ workspace: twoTilesGrid() }); await render(app); const gridEl = qs(app.root, '.dash-gg-grid'); Object.defineProperty(gridEl, 'clientWidth', { value: 1200, configurable: true }); @@ -4499,7 +4499,7 @@ describe('renderDashboard β€” the serialized write pipeline (#341)', () => { // `currentDoc`/the rendered session directly (only a later `runCommand` // resolution does) β€” the sanity check confirms it landed in the store. await app.mutateWorkspace((latest) => (latest ? { candidate: { ...latest, dashboard: { ...latest.dashboard!, tiles: [latest.dashboard!.tiles[1]], revision: latest.dashboard!.revision + 1 } } } : null)); - expect((await app.workspace.loadCurrent())?.dashboard?.tiles.map((t) => t.id)).toEqual(['t2']); + expect((await loadActive()).dashboard?.tiles.map((t) => t.id)).toEqual(['t2']); // Resize t1's placement through the UI β€” t1 is still present in this // route's OWN optimistic `currentDoc` (it hasn't seen the concurrent // removal yet), so it applies optimistically (a plain placement change, @@ -4524,7 +4524,7 @@ describe('renderDashboard β€” the serialized write pipeline (#341)', () => { // so removing it empties the persisted tiles list. qsa(app.root, '.dash-gg-del')[0].click(); await flush(); - expect((await app.workspace.loadCurrent())?.dashboard?.tiles).toEqual([]); + expect((await loadActive()).dashboard?.tiles).toEqual([]); }); // #350 (pulled into scope by review 2): a rebase that RESTORES membership β€” @@ -4538,7 +4538,7 @@ describe('renderDashboard β€” the serialized write pipeline (#341)', () => { ok: false, diagnostics: [{ path: [], severity: 'error', code: 'workspace-persist-failed', message: 'boom' }], }) - .mockImplementation(async (candidate: StoredWorkspaceV1) => ( + .mockImplementation(async (candidate: StoredWorkspaceV2) => ( { ok: true, workspace: candidate, dashboardRevision: candidate.dashboard ? candidate.dashboard.revision : null } )); const workspace = wsWith({ @@ -4547,7 +4547,7 @@ describe('renderDashboard β€” the serialized write pipeline (#341)', () => { filters: [{ id: 'f1', parameter: 'x', targets: ['t1'] }], layout: { type: 'grafana-grid', version: 1, items: {} }, }); - const { app } = dashApp({ workspace, commit }); + const { app, loadActive } = dashApp({ workspace, commit }); await render(app); expect(qsa(app.root, '.dash-gg-tile')).toHaveLength(2); qs(app.root, '.dash-gg-del').click(); // remove t1 β€” its commit fails @@ -4562,11 +4562,11 @@ describe('renderDashboard β€” the serialized write pipeline (#341)', () => { expect(app.state.dashboard?.tiles.map((t) => t.id)).toEqual(['t1', 't2']); expect(app.state.dashboard?.filters[0].targets).toEqual(['t1']); expect(queryFavorite(app.state.savedQueries.find((query) => query.id === 'q1'))).toBe(true); - expect((await app.workspace.loadCurrent())?.dashboard?.tiles.map((t) => t.id)).toEqual(['t1', 't2']); + expect((await loadActive()).dashboard?.tiles.map((t) => t.id)).toEqual(['t1', 't2']); // The rebuilt route is fully functional β€” a later command still commits. qsa(app.root, '.dash-gg-del')[1].click(); await flush(); - expect((await app.workspace.loadCurrent())?.dashboard?.tiles.map((t) => t.id)).toEqual(['t1']); + expect((await loadActive()).dashboard?.tiles.map((t) => t.id)).toEqual(['t1']); }); // #344 review fix (coordinator hardening): a commit that REJECTS (the store @@ -4577,7 +4577,7 @@ describe('renderDashboard β€” the serialized write pipeline (#341)', () => { it('a REJECTED commit (storage threw) rolls back, toasts, and does not wedge the queue or the pending-command bookkeeping', async () => { const commit = vi.fn() .mockRejectedValueOnce(new Error('storage blocked')) - .mockImplementation(async (candidate: StoredWorkspaceV1) => ( + .mockImplementation(async (candidate: StoredWorkspaceV2) => ( { ok: true, workspace: candidate, dashboardRevision: candidate.dashboard ? candidate.dashboard.revision : null } )); const { app } = dashApp({ workspace: twoTiles(), commit }); @@ -4620,7 +4620,7 @@ describe('renderDashboard β€” external-workspace rebuild (#343 step 6)', () => { const loadCalls = (fn: unknown): number => (fn as ReturnType).mock.calls.length; it('an editable route rebuilds its viewer session when the Dashboard document changed externally', async () => { - const { app } = dashApp({ workspace: twoTiles() }); + const { app, loadActive } = dashApp({ workspace: twoTiles() }); await render(app); expect(tileNames(app)).toEqual(['q1', 'q2']); // Another tab commits a tile removal β€” this advances the shared store and @@ -4633,13 +4633,13 @@ describe('renderDashboard β€” external-workspace rebuild (#343 step 6)', () => { }); expect(tileNames(app)).toEqual(['q1', 'q2']); // session still shows both tiles // The app-level refresh fires the hook after projecting the external change. - app.onWorkspaceExternallyChanged({ workspace: await app.workspace.loadCurrent(), queriesChanged: false }); + app.onWorkspaceExternallyChanged({ workspace: await loadActive(), queriesChanged: false }); await flush(); await flush(); // the rebuild is itself an async render pass expect(tileNames(app)).toEqual(['q1']); // rebuilt from committed truth }); it('rebuilds on an external QUERY-ONLY change even when the Dashboard document is byte-identical', async () => { - const { app, calls } = dashApp({ workspace: twoTiles() }); + const { app, calls, loadActive } = dashApp({ workspace: twoTiles() }); await render(app); await flush(); // A query-only external commit: q1's SQL changes; the dashboard document is @@ -4650,37 +4650,37 @@ describe('renderDashboard β€” external-workspace rebuild (#343 step 6)', () => { queries: latest!.queries.map((sq) => (sq.id === 'q1' ? { ...sq, sql: 'SELECT 999' } : sq)), } })); const before = calls.length; - app.onWorkspaceExternallyChanged({ workspace: await app.workspace.loadCurrent(), queriesChanged: true }); + app.onWorkspaceExternallyChanged({ workspace: await loadActive(), queriesChanged: true }); await flush(); await flush(); expect(calls.slice(before).some((c) => c.sql.includes('999'))).toBe(true); // re-executed with new SQL }); it('a detached read-only view ignores primary-workspace invalidation (no rebuild)', async () => { const detached = wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }); - const { app } = modeApp({ workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' } }); + const { app } = modeApp({ workspace: null, detached, openSource: { kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'd' } }); await render(app); expect(app.dashboardReadOnly).toBe(true); // route resolved as read-only - const getBefore = loadCalls(app.detachedViews.get); // one read during the initial render + const getBefore = loadCalls(app.detachedViews.getByKey); // one read during the initial render app.onWorkspaceExternallyChanged({ workspace: detached as never, queriesChanged: true }); await flush(); await flush(); // A rebuild would re-render β†’ re-read the detached store; it stayed inert. - expect(loadCalls(app.detachedViews.get)).toBe(getBefore); + expect(loadCalls(app.detachedViews.getByKey)).toBe(getBefore); expect(qsa(app.root, '.dash-tile')).toHaveLength(1); // unchanged }); it('a stale rebuild waits until pending Dashboard command descriptors settle', async () => { let resolveCommit!: () => void; - const commit = vi.fn((candidate: StoredWorkspaceV1) => new Promise((resolve) => { + const commit = vi.fn((candidate: StoredWorkspaceV2) => new Promise((resolve) => { resolveCommit = () => resolve({ ok: true, workspace: candidate, dashboardRevision: candidate.dashboard!.revision }); })); - const { app } = dashApp({ workspace: twoTiles(), commit: commit as unknown as Mock }); + const { app, loadActive } = dashApp({ workspace: twoTiles(), commit: commit as unknown as Mock }); await render(app); const loadSpy = vi.spyOn(app, 'loadDashboardWorkspace'); pickLayout(app.root, 'columns-2'); // dispatch a command whose commit stays pending for (let i = 0; i < 4; i++) await Promise.resolve(); // reach commit() β€” still unresolved // An external change arrives WHILE the command is pending: the rebuild must // defer (no resolution handler from this render may survive into the rebuilt one). - app.onWorkspaceExternallyChanged({ workspace: await app.workspace.loadCurrent(), queriesChanged: false }); + app.onWorkspaceExternallyChanged({ workspace: await loadActive(), queriesChanged: false }); await flush(); expect(loadSpy).not.toHaveBeenCalled(); // deferred behind the pending command resolveCommit(); @@ -4689,10 +4689,10 @@ describe('renderDashboard β€” external-workspace rebuild (#343 step 6)', () => { }); it('coalesces duplicate external notifications into a single rebuild', async () => { - const { app } = dashApp({ workspace: twoTiles() }); + const { app, loadActive } = dashApp({ workspace: twoTiles() }); await render(app); const loadSpy = vi.spyOn(app, 'loadDashboardWorkspace'); - const info = { workspace: await app.workspace.loadCurrent(), queriesChanged: false }; + const info = { workspace: await loadActive(), queriesChanged: false }; app.onWorkspaceExternallyChanged(info); app.onWorkspaceExternallyChanged(info); app.onWorkspaceExternallyChanged(info); @@ -4714,14 +4714,14 @@ describe('renderDashboard β€” external-workspace rebuild (#343 step 6)', () => { key === KEYS.dashFilters ? { dfx: { n: { value: '77', active: true } } } : realLoadJSON(key, fallback, store) )); try { - const { app } = dashApp({ workspace: ws }); + const { app, loadActive } = dashApp({ workspace: ws }); await render(app); const filterInput = (): HTMLInputElement => { const field = qsa(app.root, '.dash-filter-host .var-field').find((f) => qs(f, '.var-name')?.textContent === 'n')!; return qs(field, 'input'); }; expect(filterInput().value).toBe('77'); // seeded from the store, not the default 5 - app.onWorkspaceExternallyChanged({ workspace: await app.workspace.loadCurrent(), queriesChanged: false }); + app.onWorkspaceExternallyChanged({ workspace: await loadActive(), queriesChanged: false }); await flush(); await flush(); expect(filterInput().value).toBe('77'); // still seeded after the rebuild re-reads the store } finally { diff --git a/tests/unit/file-menu.test.ts b/tests/unit/file-menu.test.ts index cf918706..69f14c34 100644 --- a/tests/unit/file-menu.test.ts +++ b/tests/unit/file-menu.test.ts @@ -10,7 +10,7 @@ import type { MakeAppOverrides } from '../helpers/fake-app.js'; import { savedQuery } from '../helpers/saved-query.js'; import type { SavedQueryFixture } from '../helpers/saved-query.js'; import type { App } from '../../src/ui/app.types.js'; -import type { DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; +import type { DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV2 } from '../../src/generated/json-schema.types.js'; const click = (el: Element): boolean => el.dispatchEvent(new Event('click', { bubbles: true })); const key = (target: EventTarget, k: string, mods: KeyboardEventInit = {}): boolean => @@ -25,6 +25,11 @@ const setSaved = (app: App, queries: SavedQueryFixture[]): void => { // .commit` always returns a Promise), so an assertion made right after firing // a UI event needs one tick before the projection lands. const flush = (): Promise => new Promise((r) => setTimeout(r)); +const loadActiveWorkspace = async (app: App): Promise => { + const loaded = await app.workspace.loadById(app.state.workspaceId); + if (loaded.status !== 'ok') throw new Error(`Expected active workspace, got ${loaded.status}`); + return loaded.workspace; +}; // A FileReader stub: readAsText resolves synchronously with `content` (or errors). // Implements the full (mostly-unused) `FileReader` interface honestly β€” rather @@ -223,7 +228,7 @@ describe('file menu structure', () => { app.state.savedQueries = [panelQuery('s1', 'A'), panelQuery('s2', 'B')]; openFileMenu(app); expect([...document.querySelectorAll('.fm-label')].map((l) => l.textContent)).toEqual([ - 'New workspace…', 'Open workspace…', 'Export workspace…', 'Import queries…', + 'New workspace…', 'Import workspace…', 'Export workspace…', 'Import queries…', 'Download Markdown', 'Download SQL', 'Remember recent variable values', 'Clear all recent values', ]); @@ -252,13 +257,13 @@ describe('file menu structure', () => { const primary = rows.slice(0, 4); expect(primary.every((r) => r.classList.contains('fm-item'))).toBe(true); expect(primary.map((r) => r.querySelector('.fm-label')!.textContent)).toEqual([ - 'New workspace…', 'Open workspace…', 'Export workspace…', 'Import queries…', + 'New workspace…', 'Import workspace…', 'Export workspace…', 'Import queries…', ]); expect(rows[4].classList.contains('fm-sep')).toBe(true); // Keyboard focus order matches the visual row order exactly. await flush(); key(document, 'ArrowDown'); - expect(document.activeElement).toBe(item(/Open workspace/)); + expect(document.activeElement).toBe(item(/Import workspace/)); }); it('footer shows the empty state when there are no queries', () => { @@ -327,11 +332,11 @@ describe('Export', () => { // (`loadCurrent`), not stale `app.state` β€” the whole reason the actions // became async (flush pending writes β†’ read back the aggregate). it('exportWorkspaceAction builds the bundle from the committed workspace (loadCurrent), not stale app.state (#341)', async () => { - const committed: StoredWorkspaceV1 = { - storageVersion: 1, id: 'w1', name: 'Committed Lib', + const committed: StoredWorkspaceV2 = { + storageVersion: 2, id: 'w1', key: 'committed_lib', name: 'Committed Lib', queries: [panelQuery('c1', 'Committed')], dashboard: null, }; - const app = mount({ workspace: { loadCurrent: async () => committed } }); + const app = mount({ workspace: { loadById: async () => ({ status: 'ok' as const, workspace: committed }) } }); // app.state is deliberately DIFFERENT β€” a regression reading state instead // of the committed aggregate would export THIS, failing the id assertion. app.state.savedQueries = [panelQuery('stale', 'Stale')]; @@ -345,12 +350,12 @@ describe('Export', () => { }); it('exportDashboardAction builds from the committed dashboard (loadCurrent), not stale app.state (#341)', async () => { - const committed: StoredWorkspaceV1 = { - storageVersion: 1, id: 'w1', name: 'Lib', + const committed: StoredWorkspaceV2 = { + storageVersion: 2, id: 'w1', key: 'lib', name: 'Lib', queries: [panelQuery('c1', 'Committed')], dashboard: dashboardDoc({ title: 'Committed', tiles: [{ id: 't1', queryId: 'c1' }] }), }; - const app = mount({ workspace: { loadCurrent: async () => committed } }); + const app = mount({ workspace: { loadById: async () => ({ status: 'ok' as const, workspace: committed }) } }); app.state.dashboard = dashboardDoc({ title: 'Stale', tiles: [{ id: 't9', queryId: 'stale' }] }); app.state.savedQueries = [panelQuery('stale', 'Stale')]; await exportDashboardAction(app); @@ -365,7 +370,7 @@ describe('Export', () => { // the export must fall back to `app.state`, never become a silent no-op on an // unhandled rejection (a regression from the pre-#341 synchronous export). it('exportWorkspaceAction falls back to app.state when loadCurrent rejects β€” never a silent no-op (#341)', async () => { - const app = mount({ workspace: { loadCurrent: async () => { throw new Error('idb blocked'); } } }); + const app = mount({ workspace: { loadById: async () => { throw new Error('idb blocked'); } } }); app.state.libraryName.value = 'My Lib'; app.state.savedQueries = [panelQuery('p1'), panelQuery('p2')]; openFileMenu(app); @@ -672,41 +677,50 @@ describe('afterLibraryChange β€” dashboard route (#302)', () => { }); }); -describe('Open workspace (#342 rename of "Replace workspace…")', () => { - it('the menu item closes the menu, opens the picker, and β€” after confirming β€” replaces the catalog + Dashboard', async () => { +describe('Import workspace (#406 additive collection)', () => { + it('the menu item closes the menu, opens the picker, and creates a fresh active workspace', async () => { const dep = panelQuery('p1', 'Panel'); const dash = dashboardDoc({ id: 'd1', title: 'Ops', tiles: [{ id: 't1', queryId: 'p1' }] }); - const app = mount({ FileReader: fakeReader(bundleText({ queries: [dep], dashboards: [dash] })) }); + const create = vi.fn(async (workspace: StoredWorkspaceV2) => ({ + ok: true as const, workspace, dashboardRevision: workspace.dashboard?.revision ?? null, + })); + const app = mount({ + FileReader: fakeReader(bundleText({ + metadata: { name: 'Imported Ops' }, queries: [dep], dashboards: [dash], + })), + workspace: { + create, + list: async () => ({ + summaries: [{ + id: 'existing', key: 'imported_ops', name: 'Existing', + queryCount: 0, hasDashboard: false, lastOpenedAt: null, + }], + corrupt: [{ id: 'broken', key: 'imported_ops_2', diagnostics: [] }], + }), + }, + }); app.state.savedQueries = [panelQuery('old', 'Old')]; + const oldId = app.state.workspaceId; openFileMenu(app); const input = picker(1); input.click = vi.fn(); - click(item(/Open workspace/)!); + click(item(/Import workspace/)!); expect(document.querySelector('.file-menu')).toBeNull(); expect(input.click).toHaveBeenCalled(); pickFile(input); - const dialog = document.querySelector('.fm-dialog-card')!; - expect(dialog.textContent).toContain('Open workspace?'); - expect(dialog.textContent).toContain('current 1 saved query'); - expect(dialog.textContent).toContain('and the selected Dashboard'); - click(document.querySelector('.fm-dialog-confirm')!); await flush(); - expect(app.state.savedQueries.map((q) => q.id)).toEqual(['p1']); - expect(app.state.dashboard!.id).toBe('d1'); // Open workspace keeps the bundle Dashboard's own id/revision - expect(toast()).toBe('Opened workspace'); - }); - - it('cancelling the confirm dialog leaves the workspace untouched', () => { - const app = mount({ FileReader: fakeReader(bundleText({ queries: [panelQuery('p1')] })) }); - app.state.savedQueries = [panelQuery('old', 'Old')]; - openFileMenu(app); - pickFile(picker(1)); - click(document.querySelector('.fm-dialog-cancel')!); expect(document.querySelector('.fm-dialog-card')).toBeNull(); - expect(app.state.savedQueries.map((q) => q.id)).toEqual(['old']); + expect(create).toHaveBeenCalledTimes(1); + expect(create.mock.calls[0][0]).toMatchObject({ + storageVersion: 2, key: 'imported_ops_3', name: 'Imported Ops', + }); + expect(create.mock.calls[0][0].id).not.toBe(oldId); + expect(app.state.savedQueries.map((q) => q.id)).toEqual(['p1']); + expect(app.state.dashboard!.id).toBe('d1'); + expect(toast()).toBe('Imported workspace'); }); - it('shows a picker with a "No dashboard" option for a multi-dashboard bundle; picking it clears the Dashboard', async () => { + it('shows a picker with a "No dashboard" option for a multi-dashboard bundle', async () => { const dashA = dashboardDoc({ id: 'a', title: 'Alpha' }); const dashB = dashboardDoc({ id: 'b', title: 'Beta' }); const app = mount({ FileReader: fakeReader(bundleText({ dashboards: [dashA, dashB] })) }); @@ -714,31 +728,67 @@ describe('Open workspace (#342 rename of "Replace workspace…")', () => { openFileMenu(app); pickFile(picker(1)); const dialog = document.querySelector('.fm-dialog-card')!; - expect(dialog.textContent).toContain('Open workspace β€” which dashboard?'); + expect(dialog.textContent).toContain('Import workspace β€” which dashboard?'); const noneRow = [...dialog.querySelectorAll('.fm-item')].find((b) => (b.textContent || '').includes('No dashboard'))!; click(noneRow); - expect(document.querySelector('.fm-dialog-card')!.textContent).toContain('Open workspace?'); - click(document.querySelector('.fm-dialog-confirm')!); await flush(); + expect(document.querySelector('.fm-dialog-card')).toBeNull(); expect(app.state.dashboard).toBeNull(); }); - it('auto-picks the sole Dashboard in a single-dashboard bundle (no picker)', () => { + it('dismisses the multi-dashboard picker on Escape', () => { + const app = mount({ + FileReader: fakeReader(bundleText({ + dashboards: [ + dashboardDoc({ id: 'a', title: 'Alpha' }), + dashboardDoc({ id: 'b', title: 'Beta' }), + ], + })), + }); + openFileMenu(app); + pickFile(picker(1)); + const event = new KeyboardEvent('keydown', { + key: 'Escape', bubbles: true, cancelable: true, + }); + document.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(document.querySelector('.fm-dialog-card')).toBeNull(); + }); + + it('auto-picks the sole Dashboard in a single-dashboard bundle (no picker)', async () => { const dash = dashboardDoc({ id: 'only', title: 'Only' }); const app = mount({ FileReader: fakeReader(bundleText({ dashboards: [dash] })) }); openFileMenu(app); pickFile(picker(1)); - expect(document.querySelector('.fm-dialog-card')!.textContent).toContain('Open workspace?'); + await flush(); + expect(document.querySelector('.fm-dialog-card')).toBeNull(); + expect(app.state.dashboard?.id).toBe('only'); + }); + + it('warns when an imported workspace is active but last-used metadata cannot be saved', async () => { + const app = mount({ + FileReader: fakeReader(bundleText({ queries: [panelQuery('q1')] })), + workspace: { + markOpened: async () => ({ + ok: false as const, + diagnostics: [{ path: [], severity: 'error' as const, code: 'x', message: 'blocked' }], + }), + }, + }); + openFileMenu(app); + pickFile(picker(1)); + await app.flushWorkspaceWrites(); + await flush(); + expect(app.state.savedQueries.map((query) => query.id)).toEqual(['q1']); + expect(toast()).toBe('Imported workspace, but its last-used timestamp could not be saved.'); }); - it('a queries-only bundle (no Dashboard) clears an existing Dashboard', async () => { + it('a queries-only bundle creates a workspace without a Dashboard', async () => { const app = mount({ FileReader: fakeReader(bundleText({ queries: [panelQuery('p1')] })) }); app.state.dashboard = dashboardDoc({ id: 'existing', title: 'Existing' }); app.state.savedQueries = []; openFileMenu(app); pickFile(picker(1)); - expect(document.querySelector('.fm-dialog-card')!.textContent).toContain('current 0 saved queries'); - click(document.querySelector('.fm-dialog-confirm')!); await flush(); expect(app.state.dashboard).toBeNull(); expect(app.state.savedQueries.map((q) => q.id)).toEqual(['p1']); @@ -747,7 +797,17 @@ describe('Open workspace (#342 rename of "Replace workspace…")', () => { describe('New workspace', () => { it('commits directly (no confirm) when the workspace is already empty', async () => { - const app = mount(); + const app = mount({ + workspace: { + list: async () => ({ + summaries: [{ + id: 'existing', key: 'sql_library', name: 'Existing', + queryCount: 0, hasDashboard: false, lastOpenedAt: null, + }], + corrupt: [{ id: 'broken', key: 'sql_library_2', diagnostics: [] }], + }), + }, + }); const oldId = app.state.workspaceId; openFileMenu(app); click(item(/New workspace/)!); @@ -755,63 +815,46 @@ describe('New workspace', () => { expect(document.querySelector('.fm-dialog-backdrop')).toBeNull(); expect(app.state.savedQueries).toEqual([]); expect(app.state.libraryName.value).toBe('SQL Library'); + expect(app.state.workspaceKey).toBe('sql_library_3'); expect(app.state.workspaceId).not.toBe(oldId); expect(toast()).toBe('Started a new workspace'); }); - it('confirms first when there are saved queries', async () => { + it('creates additively without confirmation when there are saved queries', async () => { const app = mount(); app.state.savedQueries = [panelQuery('q1')]; openFileMenu(app); click(item(/New workspace/)!); - expect(document.querySelector('.fm-dialog-card')!.textContent).toContain('Start a new workspace?'); - click(document.querySelector('.fm-dialog-confirm')!); await flush(); + expect(document.querySelector('.fm-dialog-card')).toBeNull(); expect(app.state.savedQueries).toEqual([]); }); - it('confirms first when a Dashboard exists even with no saved queries', () => { + it('creates additively without confirmation when a Dashboard exists', async () => { const app = mount(); app.state.dashboard = dashboardDoc(); openFileMenu(app); click(item(/New workspace/)!); - expect(document.querySelector('.fm-dialog-card')!.textContent).toContain('Start a new workspace?'); - }); - - it('confirm dialog: Cancel, backdrop click, and Escape all dismiss; a card click does not', () => { - const app = mount(); - app.state.savedQueries = [panelQuery('q1'), panelQuery('q2')]; // two β†’ exercises the plural dialog copy - const openNew = (): void => { openFileMenu(app); click(item(/New workspace/)!); }; - // Cancel - openNew(); - click(document.querySelector('.fm-dialog-cancel')!); - expect(document.querySelector('.fm-dialog-backdrop')).toBeNull(); - expect(app.state.savedQueries).toHaveLength(2); - // backdrop click - openNew(); - const backdrop = document.querySelector('.fm-dialog-backdrop')!; - backdrop.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); - click(backdrop); - expect(document.querySelector('.fm-dialog-backdrop')).toBeNull(); - // card click keeps it open; Escape closes it - openNew(); - click(document.querySelector('.fm-dialog-card')!); - expect(document.querySelector('.fm-dialog-backdrop')).not.toBeNull(); - key(document, 'Escape'); - expect(document.querySelector('.fm-dialog-backdrop')).toBeNull(); - expect(app.state.savedQueries).toHaveLength(2); + await flush(); + expect(document.querySelector('.fm-dialog-card')).toBeNull(); + expect(app.state.dashboard).toBeNull(); }); - it('a gesture starting on the card and ending on the backdrop does not dismiss it (#110)', () => { - const app = mount(); - app.state.savedQueries = [panelQuery('q1')]; + it('warns when a new workspace is active but last-used metadata cannot be saved', async () => { + const app = mount({ + workspace: { + markOpened: async () => ({ + ok: false as const, + diagnostics: [{ path: [], severity: 'error' as const, code: 'x', message: 'blocked' }], + }), + }, + }); openFileMenu(app); click(item(/New workspace/)!); - const backdrop = document.querySelector('.fm-dialog-backdrop')!; - const card = document.querySelector('.fm-dialog-card')!; - card.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); - backdrop.dispatchEvent(new Event('click', { bubbles: true })); // click's target is the backdrop - expect(document.querySelector('.fm-dialog-backdrop')).not.toBeNull(); + await app.flushWorkspaceWrites(); + await flush(); + expect(app.state.libraryName.value).toBe('SQL Library'); + expect(toast()).toBe('Started a new workspace, but its last-used timestamp could not be saved.'); }); }); @@ -867,8 +910,8 @@ describe('decode failures', () => { // queue while a SECOND op (here, a rename / an import) is fired behind it. describe('mixed-producer serialization (#341/#344 review fix)', () => { it('a pending saved-query-style mutation commits before a queued rename builds its candidate β€” the rename lands on top, nothing reverts', async () => { - const seed: StoredWorkspaceV1 = { - storageVersion: 1, id: 'w1', name: 'Orig', queries: [panelQuery('q1', 'Q1')], dashboard: null, + const seed: StoredWorkspaceV2 = { + storageVersion: 2, id: 'w1', key: 'orig', name: 'Orig', queries: [panelQuery('q1', 'Q1')], dashboard: null, }; const app = mount({ workspace: statefulWorkspaceRepo(seed) }); app.state.savedQueries = seed.queries; @@ -895,17 +938,17 @@ describe('mixed-producer serialization (#341/#344 review fix)', () => { await pendingMutation; await app.flushWorkspaceWrites(); - const finalWs = await app.workspace.loadCurrent(); + const finalWs = await loadActiveWorkspace(app); // A `renameWorkspaceAction` that built its candidate from a pre-queue // snapshot would have re-committed the ORIGINAL [q1] catalog, silently // reverting the q2 mutation that landed while it waited. - expect(finalWs!.queries.map((q) => q.id)).toEqual(['q1', 'q2']); - expect(finalWs!.name).toBe('Renamed'); + expect(finalWs.queries.map((q) => q.id)).toEqual(['q1', 'q2']); + expect(finalWs.name).toBe('Renamed'); }); it('a pending saved-query-style mutation commits before a queued Import queries builds its candidate β€” the import lands on top of the post-mutation catalog', async () => { - const seed: StoredWorkspaceV1 = { - storageVersion: 1, id: 'w1', name: 'Lib', queries: [panelQuery('q1', 'Q1')], dashboard: null, + const seed: StoredWorkspaceV2 = { + storageVersion: 2, id: 'w1', key: 'lib', name: 'Lib', queries: [panelQuery('q1', 'Q1')], dashboard: null, }; const app = mount({ workspace: statefulWorkspaceRepo(seed), @@ -932,10 +975,10 @@ describe('mixed-producer serialization (#341/#344 review fix)', () => { await pendingMutation; await app.flushWorkspaceWrites(); - const finalWs = await app.workspace.loadCurrent(); + const finalWs = await loadActiveWorkspace(app); // A stale-snapshot import would have planned against [q1] only, dropping // q2 from the committed candidate. - expect(finalWs!.queries.map((q) => q.id).sort()).toEqual(['new1', 'q1', 'q2']); + expect(finalWs.queries.map((q) => q.id).sort()).toEqual(['new1', 'q1', 'q2']); }); // #344 review 3: the conflict DECISIONS are collected against the pre-queue @@ -944,8 +987,8 @@ describe('mixed-producer serialization (#341/#344 review fix)', () => { // without dequeue-time revalidation the import would silently drop the // incoming query and still toast success. it('a conflict minted while the import waits in the queue ABORTS the import (content differs) instead of silently skipping', async () => { - const seed: StoredWorkspaceV1 = { - storageVersion: 1, id: 'w1', name: 'Lib', queries: [panelQuery('q1', 'Q1')], dashboard: null, + const seed: StoredWorkspaceV2 = { + storageVersion: 2, id: 'w1', key: 'lib', name: 'Lib', queries: [panelQuery('q1', 'Q1')], dashboard: null, }; const app = mount({ workspace: statefulWorkspaceRepo(seed), @@ -972,17 +1015,17 @@ describe('mixed-producer serialization (#341/#344 review fix)', () => { await pendingMutation; await app.flushWorkspaceWrites(); - const finalWs = await app.workspace.loadCurrent(); + const finalWs = await loadActiveWorkspace(app); // The import aborted whole: the queued mutation's new1 ('Mine') stands, // the bundle's new1 ('Theirs') was neither imported nor silently skipped // under a success toast. - expect(finalWs!.queries.map((q) => queryName(q)).sort()).toEqual(['Mine', 'Q1']); + expect(finalWs.queries.map((q) => queryName(q)).sort()).toEqual(['Mine', 'Q1']); expect(toast()).toBe('βœ• Workspace changed while importing β€” nothing imported, try again'); }); it('a conflict minted while the import waits in the queue auto-resolves when canonically IDENTICAL β€” no duplicate, honest count', async () => { - const seed: StoredWorkspaceV1 = { - storageVersion: 1, id: 'w1', name: 'Lib', queries: [panelQuery('q1', 'Q1')], dashboard: null, + const seed: StoredWorkspaceV2 = { + storageVersion: 2, id: 'w1', key: 'lib', name: 'Lib', queries: [panelQuery('q1', 'Q1')], dashboard: null, }; const app = mount({ workspace: statefulWorkspaceRepo(seed), @@ -1007,26 +1050,74 @@ describe('mixed-producer serialization (#341/#344 review fix)', () => { await pendingMutation; await app.flushWorkspaceWrites(); - const finalWs = await app.workspace.loadCurrent(); + const finalWs = await loadActiveWorkspace(app); // Auto-resolved to 'use-existing': exactly ONE new1, and the toast counts // it as imported (the query IS available after the import). - expect(finalWs!.queries.map((q) => q.id).sort()).toEqual(['new1', 'q1']); + expect(finalWs.queries.map((q) => q.id).sort()).toEqual(['new1', 'q1']); expect(toast()).toBe('Imported 1 query'); }); }); describe('commit failure', () => { - it('a rejected commit toasts the first diagnostic and leaves state untouched', async () => { + it('a rejected rename commit toasts the diagnostic and keeps the active name', async () => { const commit = vi.fn(async () => ({ - ok: false as const, diagnostics: [{ path: [], severity: 'error' as const, code: 'x', message: 'nope' }], + ok: false as const, + diagnostics: [{ path: [], severity: 'error' as const, code: 'x', message: 'rename failed' }], })); const app = mount({ workspace: { commit } }); + app.state.libraryName.value = 'Original'; + renderLibraryTitle(app); + click(app.dom.libraryTitle!.querySelector('.lib-name')!); + const input = app.dom.libraryTitle!.querySelector('.lib-name-input')!; + input.value = 'Renamed'; + key(input, 'Enter'); + await app.flushWorkspaceWrites(); + await flush(); + expect(toast()).toBe('βœ• rename failed'); + expect(app.state.libraryName.value).toBe('Original'); + }); + + it('a rejected create toasts the first diagnostic and leaves state untouched', async () => { + const create = vi.fn(async () => ({ + ok: false as const, diagnostics: [{ path: [], severity: 'error' as const, code: 'x', message: 'nope' }], + })); + const app = mount({ workspace: { create } }); app.state.savedQueries = [panelQuery('q1')]; openFileMenu(app); click(item(/New workspace/)!); - click(document.querySelector('.fm-dialog-confirm')!); await flush(); expect(toast()).toBe('βœ• nope'); expect(app.state.savedQueries.map((q) => q.id)).toEqual(['q1']); }); + + it('a rejected imported-workspace create reports its diagnostic', async () => { + const create = vi.fn(async () => ({ + ok: false as const, + diagnostics: [{ path: [], severity: 'error' as const, code: 'x', message: 'import blocked' }], + })); + const app = mount({ + workspace: { create }, + FileReader: fakeReader(bundleText({ queries: [panelQuery('q1')] })), + }); + openFileMenu(app); + pickFile(picker(1)); + await app.flushWorkspaceWrites(); + await flush(); + expect(toast()).toBe('βœ• import blocked'); + }); + + it('an invalid imported workspace reports the planner diagnostic without creating', async () => { + const create = vi.fn(); + const app = mount({ + workspace: { create }, + genId: () => '', + FileReader: fakeReader(bundleText({ queries: [panelQuery('q1')] })), + }); + openFileMenu(app); + pickFile(picker(1)); + await app.flushWorkspaceWrites(); + await flush(); + expect(create).not.toHaveBeenCalled(); + expect(toast()).toMatch(/^βœ• /); + }); }); diff --git a/tests/unit/import-planner.test.ts b/tests/unit/import-planner.test.ts index 212cfbda..63acba3d 100644 --- a/tests/unit/import-planner.test.ts +++ b/tests/unit/import-planner.test.ts @@ -7,7 +7,7 @@ import type { IdMapping, QueryConflict, QueryDecision, } from '../../src/workspace/import-planner.js'; import type { - DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV1, + DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV2, } from '../../src/generated/json-schema.types.js'; // --- fixtures ---------------------------------------------------------------- @@ -28,8 +28,8 @@ const dashboardDoc = (over: Partial = {}): DashboardDocumen filters: [], tiles: [], ...over, }); -const workspace = (over: Partial = {}): StoredWorkspaceV1 => ({ - storageVersion: 1, id: 'w1', name: 'Workspace', queries: [], dashboard: null, ...over, +const workspace = (over: Partial = {}): StoredWorkspaceV2 => ({ + storageVersion: 2, id: 'w1', key: 'workspace', name: 'Workspace', queries: [], dashboard: null, ...over, }); const bundle = (over: Partial = {}): PortableBundleV1 => ({ diff --git a/tests/unit/indexeddb-detached-views-store.test.ts b/tests/unit/indexeddb-detached-views-store.test.ts index 5e8ea8bf..00e0600c 100644 --- a/tests/unit/indexeddb-detached-views-store.test.ts +++ b/tests/unit/indexeddb-detached-views-store.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { createIndexedDbDetachedViewsStore } from '../../src/workspace/indexeddb-detached-views-store.js'; -import type { StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; +import type { StoredWorkspaceV2 } from '../../src/generated/json-schema.types.js'; // ── A minimal in-memory fake IDBFactory ────────────────────────────────────── // Extends the shape established in `indexeddb-workspace-store.test.ts` with @@ -43,6 +43,17 @@ class FakeObjectStore { return req; } + getAll(): FakeRequest { + const req = new FakeRequest(); + queueMicrotask(() => { + if (this.cfg.failGet) { req.error = this.cfg.getError ?? null; req.onerror?.(); } else { + req.result = Array.from(this.data.values()); + 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(); } @@ -116,8 +127,11 @@ function fakeFactory(cfg: Cfg = {}): IDBFactory { } as unknown as IDBFactory; } -function workspace(id: string): StoredWorkspaceV1 { - return { storageVersion: 1, id, name: `Workspace ${id}`, queries: [], dashboard: null }; +function workspace(id: string): StoredWorkspaceV2 { + return { + storageVersion: 2, id, key: `workspace_${id}`, name: `Workspace ${id}`, + queries: [], dashboard: null, + }; } describe('createIndexedDbDetachedViewsStore', () => { @@ -132,6 +146,15 @@ describe('createIndexedDbDetachedViewsStore', () => { expect(await store.get('missing')).toBeNull(); }); + it('loads a detached workspace by its canonical URL key', async () => { + const store = createIndexedDbDetachedViewsStore(fakeFactory()); + await store.put({ workspace: workspace('w1'), savedAt: 1000 }); + await store.put({ workspace: workspace('w2'), savedAt: 2000 }); + expect(await store.getByKey('workspace_w2')).toEqual(workspace('w2')); + expect(await store.getByKey('WORKSPACE_W2')).toEqual(workspace('w2')); + expect(await store.getByKey('missing')).toBeNull(); + }); + it('prunes to the newest maxRecords by savedAt within the same transaction', async () => { const store = createIndexedDbDetachedViewsStore(fakeFactory(), { maxRecords: 2 }); await store.put({ workspace: workspace('a'), savedAt: 1 }); @@ -163,6 +186,7 @@ describe('createIndexedDbDetachedViewsStore', () => { it('rejects every operation when no IndexedDB factory is available', async () => { const store = createIndexedDbDetachedViewsStore(undefined); await expect(store.get('x')).rejects.toThrow('IndexedDB is unavailable'); + await expect(store.getByKey('x')).rejects.toThrow('IndexedDB is unavailable'); await expect(store.put({ workspace: workspace('x'), savedAt: 1 })).rejects.toThrow('IndexedDB is unavailable'); }); @@ -212,6 +236,7 @@ describe('createIndexedDbDetachedViewsStore', () => { await expect(boom.get('x')).rejects.toThrow('get boom'); const silent = createIndexedDbDetachedViewsStore(fakeFactory({ failGet: true })); await expect(silent.get('x')).rejects.toThrow('IndexedDB request failed'); + await expect(silent.getByKey('x')).rejects.toThrow('IndexedDB request failed'); }); it('rejects a put when the pruning cursor read fails (with and without a request error)', async () => { diff --git a/tests/unit/indexeddb-workspace-store.test.ts b/tests/unit/indexeddb-workspace-store.test.ts index 287f2c07..354a8333 100644 --- a/tests/unit/indexeddb-workspace-store.test.ts +++ b/tests/unit/indexeddb-workspace-store.test.ts @@ -1,21 +1,19 @@ import { describe, expect, it } from 'vitest'; import { createIndexedDbWorkspaceStore } from '../../src/workspace/indexeddb-workspace-store.js'; +import type { WorkspaceStoreRecord } from '../../src/workspace/workspace-store.types.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; +interface FakeConfig { + failOpen?: boolean; + blockOpen?: boolean; + openError?: unknown; + failRequest?: string; + requestError?: unknown; + silentRequestError?: boolean; + failReadwriteTx?: 'error' | 'abort'; + txError?: unknown; + silentTxError?: boolean; } class FakeRequest { @@ -24,165 +22,446 @@ class FakeRequest { onsuccess: Handler = null; onerror: Handler = null; onupgradeneeded: Handler = null; + onblocked: 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(); } +interface FakeStoreData { + keyPath: string | null; + values: Map; + indexes: Map; } -class FakeTx { - error: unknown = null; +class FakeTransaction { + error: DOMException | Error | null = null; oncomplete: Handler = null; onerror: Handler = null; onabort: Handler = null; - db: FakeDB; - cfg: Cfg; - constructor(db: FakeDB, cfg: Cfg) { + private pending = 0; + private settled = false; + private completionQueued = false; + private readonly snapshots = new Map>(); + private readonly db: FakeDatabase; + private readonly names: string[]; + private readonly mode: IDBTransactionMode; + private readonly config: FakeConfig; + + constructor( + db: FakeDatabase, + names: string[], + mode: IDBTransactionMode, + config: FakeConfig, + ) { this.db = db; - this.cfg = cfg; + this.names = names; + this.mode = mode; + this.config = config; + for (const name of names) { + this.snapshots.set(name, new Map(db.stores.get(name)?.values)); + } + this.queueCompletion(); + } + + objectStore(name: string): FakeObjectStore { + if (!this.names.includes(name)) throw new Error(`Store ${name} is outside transaction`); + return new FakeObjectStore(this, this.db.stores.get(name)!); + } + + request(operation: string, action: () => unknown): FakeRequest { + const request = new FakeRequest(); + this.pending += 1; 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?.(); + if (this.settled) return; + try { + if (this.config.failRequest === operation) { + const failure = this.config.requestError ?? new Error(`${operation} failed`); + if (this.config.silentRequestError) { + request.error = null; + request.onerror?.(); + this.abort(failure); + return; + } + throw failure; + } + request.result = action(); + request.onsuccess?.(); + } catch (error) { + request.error = error; + request.onerror?.(); + this.abort(error); + } finally { + this.pending -= 1; + this.queueCompletion(); + } + }); + return request; + } + + private queueCompletion(): void { + if (this.completionQueued || this.settled) return; + this.completionQueued = true; + // IDB transactions remain active through promise microtasks scheduled by + // request event handlers, and complete at the next event-loop boundary. + setTimeout(() => { + this.completionQueued = false; + if (this.settled || this.pending !== 0) return; + if (this.mode === 'readwrite' && this.config.failReadwriteTx) { + const fallback = this.config.failReadwriteTx === 'abort' + ? new Error('forced transaction abort') + : new Error('forced transaction error'); + this.abort(this.config.txError ?? fallback, this.config.failReadwriteTx); + return; + } + this.settled = true; + this.oncomplete?.(); + }, 0); + } + + private abort(error: unknown, event: 'abort' | 'error' = 'abort'): void { + if (this.settled) return; + this.settled = true; + this.error = this.config.silentTxError + ? null + : error instanceof Error || error instanceof DOMException + ? error + : new Error('fake transaction failed'); + for (const [name, snapshot] of this.snapshots) { + this.db.stores.get(name)!.values = new Map(snapshot); + } + queueMicrotask(() => { + if (event === 'error') this.onerror?.(); + else this.onabort?.(); }); } - 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); } +class FakeIndex { + private readonly tx: FakeTransaction; + private readonly data: FakeStoreData; + private readonly keyPath: string; + + constructor( + tx: FakeTransaction, + data: FakeStoreData, + keyPath: string, + ) { + this.tx = tx; + this.data = data; + this.keyPath = keyPath; + } + + get(key: IDBValidKey): FakeRequest { + return this.tx.request('index.get', () => ( + [...this.data.values.values()].find((value) => ( + (value as Record)[this.keyPath] === key + )) + )); + } } -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; +class FakeObjectStore { + private readonly tx: FakeTransaction; + private readonly data: FakeStoreData; + + constructor( + tx: FakeTransaction, + data: FakeStoreData, + ) { + this.tx = tx; + this.data = data; + } + + get(key: IDBValidKey): FakeRequest { + return this.tx.request('get', () => this.data.values.get(key)); + } + + getAll(): FakeRequest { + return this.tx.request('getAll', () => [...this.data.values.values()]); + } + + add(value: unknown, explicitKey?: IDBValidKey): FakeRequest { + return this.tx.request('add', () => { + const key = this.resolveKey(value, explicitKey); + if (this.data.values.has(key)) throw new DOMException('Duplicate id', 'ConstraintError'); + this.assertUniqueIndexes(value); + this.data.values.set(key, value); + return key; + }); + } + + put(value: unknown, explicitKey?: IDBValidKey): FakeRequest { + return this.tx.request('put', () => { + const key = this.resolveKey(value, explicitKey); + this.assertUniqueIndexes(value, key); + this.data.values.set(key, value); + return key; + }); + } + + delete(key: IDBValidKey): FakeRequest { + return this.tx.request('delete', () => { + this.data.values.delete(key); + return undefined; + }); + } + + index(name: string): FakeIndex { + const index = this.data.indexes.get(name); + if (!index) throw new Error(`Missing index ${name}`); + return new FakeIndex(this.tx, this.data, index.keyPath); + } + + private resolveKey(value: unknown, explicitKey?: IDBValidKey): IDBValidKey { + if (explicitKey !== undefined) return explicitKey; + if (!this.data.keyPath) throw new Error('An explicit key is required'); + return (value as Record)[this.data.keyPath]; + } + + private assertUniqueIndexes(value: unknown, replacingKey?: IDBValidKey): void { + for (const index of this.data.indexes.values()) { + if (!index.unique) continue; + const indexedValue = (value as Record)[index.keyPath]; + for (const [key, current] of this.data.values) { + if (key !== replacingKey + && (current as Record)[index.keyPath] === indexedValue) { + throw new DOMException('Duplicate index', 'ConstraintError'); + } + } + } + } +} + +class FakeUpgradeObjectStore { + private readonly data: FakeStoreData; + constructor(data: FakeStoreData) { this.data = data; } + createIndex(name: string, keyPath: string, options?: IDBIndexParameters): FakeUpgradeObjectStore { + this.data.indexes.set(name, { keyPath, unique: options?.unique ?? false }); + return this; + } +} + +class FakeDatabase { + stores = new Map(); + objectStoreNames = { contains: (name: string) => this.stores.has(name) }; + private readonly config: FakeConfig; + + constructor(config: FakeConfig) { this.config = config; } + + createObjectStore(name: string, options?: IDBObjectStoreParameters): FakeUpgradeObjectStore { + const data: FakeStoreData = { + keyPath: typeof options?.keyPath === 'string' ? options.keyPath : null, + values: new Map(), + indexes: new Map(), + }; + this.stores.set(name, data); + return new FakeUpgradeObjectStore(data); + } + + transaction(names: string[], mode: IDBTransactionMode): FakeTransaction { + return new FakeTransaction(this, names, mode, this.config); + } } +class FakeFactory { + readonly config: FakeConfig; + readonly databases = new Map(); + openCount = 0; + lastVersion: number | undefined; + + constructor(config: FakeConfig = {}) { + this.config = config; + } + + open(name: string, version?: number): IDBOpenDBRequest { + this.openCount += 1; + this.lastVersion = version; + const request = new FakeRequest(); + queueMicrotask(() => { + if (this.config.blockOpen) { + request.onblocked?.(); + return; + } + if (this.config.failOpen) { + request.error = this.config.openError ?? null; + request.onerror?.(); + return; + } + let db = this.databases.get(name); + const needsUpgrade = !db; + if (!db) { + db = new FakeDatabase(this.config); + this.databases.set(name, db); + } + request.result = db; + if (needsUpgrade) request.onupgradeneeded?.(); + request.onsuccess?.(); + }); + return request as unknown as IDBOpenDBRequest; + } +} + +const record = ( + id: string, + key: string, + text = `{"id":"${id}"}`, + lastOpenedAt: number | null = null, +): WorkspaceStoreRecord => ({ id, key, text, lastOpenedAt }); + 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('creates independent records, lists them, and reopens the same database', async () => { + const factory = new FakeFactory(); + const first = createIndexedDbWorkspaceStore(factory as unknown as IDBFactory); + expect(await first.list()).toEqual([]); + expect(await first.create(record('id-1', 'alpha'))).toEqual({ status: 'created' }); + expect(await first.create(record('id-2', 'beta'))).toEqual({ status: 'created' }); + expect(await first.list()).toEqual([record('id-1', 'alpha'), record('id-2', 'beta')]); + expect(factory.openCount).toBe(1); + + const reopened = createIndexedDbWorkspaceStore(factory as unknown as IDBFactory); + expect(await reopened.list()).toHaveLength(2); + expect(factory.openCount).toBe(2); + expect(factory.lastVersion).toBe(1); }); - 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('looks up records independently by id and canonical key', async () => { + const store = createIndexedDbWorkspaceStore(new FakeFactory() as unknown as IDBFactory); + await store.create(record('id-1', 'alpha')); + expect(await store.readById('id-1')).toEqual(record('id-1', 'alpha')); + expect(await store.readByKey('alpha')).toEqual(record('id-1', 'alpha')); + expect(await store.readById('missing')).toBeNull(); + expect(await store.readByKey('missing')).toBeNull(); }); - 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('reports duplicate ids and duplicate keys from atomic add constraints', async () => { + const store = createIndexedDbWorkspaceStore(new FakeFactory() as unknown as IDBFactory); + await store.create(record('id-1', 'alpha')); + expect(await store.create(record('id-1', 'beta'))).toEqual({ status: 'duplicate-id' }); + expect(await store.create(record('id-2', 'alpha'))).toEqual({ status: 'duplicate-key' }); + expect(await store.list()).toEqual([record('id-1', 'alpha')]); }); - 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 an unexplained constraint and a non-constraint DOM failure', async () => { + const constraintFactory = new FakeFactory({ + failRequest: 'add', + requestError: new DOMException('unexpected constraint', 'ConstraintError'), + }); + const constrained = createIndexedDbWorkspaceStore(constraintFactory as unknown as IDBFactory); + await expect(constrained.create(record('id-1', 'alpha'))) + .rejects.toThrow('unexpected constraint'); + + const abortFactory = new FakeFactory({ + failRequest: 'add', + requestError: new DOMException('quota-ish', 'AbortError'), + }); + const aborted = createIndexedDbWorkspaceStore(abortFactory as unknown as IDBFactory); + await expect(aborted.create(record('id-1', 'alpha'))).rejects.toThrow('quota-ish'); }); - 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('replaces only an existing record with its immutable key', async () => { + const store = createIndexedDbWorkspaceStore(new FakeFactory() as unknown as IDBFactory); + await store.create(record('id-1', 'alpha', undefined, 7)); + expect(await store.replace(record('missing', 'alpha', 'new'))).toEqual({ status: 'not-found' }); + expect(await store.replace(record('id-1', 'renamed', 'new'))).toEqual({ status: 'immutable-key' }); + expect(await store.replace(record('id-1', 'alpha', 'new', 9))).toEqual({ status: 'replaced' }); + expect(await store.readById('id-1')).toEqual(record('id-1', 'alpha', 'new', 7)); }); - 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('deletes exactly one id, is idempotent, and clears only a matching preference', async () => { + const store = createIndexedDbWorkspaceStore(new FakeFactory() as unknown as IDBFactory); + await store.create(record('id-1', 'alpha')); + await store.create(record('id-2', 'beta')); + await store.markOpened('alpha', 1); + expect(await store.delete('id-2')).toBe(true); + expect(await store.getLastUsedKey()).toBe('alpha'); + expect(await store.delete('missing')).toBe(false); + expect(await store.delete('id-1')).toBe(true); + expect(await store.getLastUsedKey()).toBeNull(); + expect(await store.list()).toEqual([]); }); - 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('marks an existing workspace opened and manages the last-used preference', async () => { + const store = createIndexedDbWorkspaceStore(new FakeFactory() as unknown as IDBFactory); + await store.create(record('id-1', 'alpha')); + expect(await store.markOpened('missing', 4)).toEqual({ status: 'not-found' }); + expect(await store.getLastUsedKey()).toBeNull(); + expect(await store.markOpened('alpha', 123)).toEqual({ status: 'opened' }); + expect(await store.readByKey('alpha')).toEqual(record('id-1', 'alpha', undefined, 123)); + expect(await store.getLastUsedKey()).toBe('alpha'); + await store.clearLastUsedKey(); + expect(await store.getLastUsedKey()).toBeNull(); }); - 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('rolls back record and preference changes when a transaction fails', async () => { + const factory = new FakeFactory(); + const store = createIndexedDbWorkspaceStore(factory as unknown as IDBFactory); + await store.create(record('id-1', 'alpha')); + await store.markOpened('alpha', 10); + + factory.config.failReadwriteTx = 'abort'; + factory.config.txError = new Error('disk full'); + await expect(store.delete('id-1')).rejects.toThrow('disk full'); + factory.config.failReadwriteTx = undefined; + expect(await store.readById('id-1')).toEqual(record('id-1', 'alpha', undefined, 10)); + expect(await store.getLastUsedKey()).toBe('alpha'); }); - 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 environmental request and transaction failures with fallback errors', async () => { + const requestFactory = new FakeFactory({ failRequest: 'get' }); + const requestStore = createIndexedDbWorkspaceStore(requestFactory as unknown as IDBFactory); + await expect(requestStore.readById('x')).rejects.toThrow('get failed'); + + const silentRequestFactory = new FakeFactory({ + failRequest: 'get', + silentRequestError: true, + }); + const silentRequestStore = createIndexedDbWorkspaceStore( + silentRequestFactory as unknown as IDBFactory, + ); + await expect(silentRequestStore.readById('x')).rejects.toThrow('IndexedDB request failed'); + + const txFactory = new FakeFactory({ + failReadwriteTx: 'error', + silentTxError: true, + }); + const txStore = createIndexedDbWorkspaceStore(txFactory as unknown as IDBFactory); + await expect(txStore.clearLastUsedKey()).rejects.toThrow('IndexedDB transaction failed'); + + txFactory.config.failReadwriteTx = 'abort'; + await expect(txStore.clearLastUsedKey()).rejects.toThrow('IndexedDB transaction aborted'); + }); + + it('rejects unavailable/open failures and retries after failed or blocked opens', async () => { + const unavailable = createIndexedDbWorkspaceStore(undefined); + await expect(unavailable.list()).rejects.toThrow('IndexedDB is unavailable'); + + const factory = new FakeFactory({ failOpen: true, openError: new Error('open boom') }); + const store = createIndexedDbWorkspaceStore(factory as unknown as IDBFactory); + await expect(store.list()).rejects.toThrow('open boom'); + factory.config.failOpen = false; + expect(await store.list()).toEqual([]); + + const blockedFactory = new FakeFactory({ blockOpen: true }); + const blocked = createIndexedDbWorkspaceStore(blockedFactory as unknown as IDBFactory); + await expect(blocked.list()).rejects.toThrow('IndexedDB open blocked'); + blockedFactory.config.blockOpen = false; + expect(await blocked.list()).toEqual([]); + expect(blockedFactory.openCount).toBe(2); }); - 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'); + it('uses fallback errors and honors custom database/store/index names', async () => { + const silentOpen = createIndexedDbWorkspaceStore( + new FakeFactory({ failOpen: true }) as unknown as IDBFactory, + ); + await expect(silentOpen.list()).rejects.toThrow('IndexedDB open failed'); + + const factory = new FakeFactory(); + const store = createIndexedDbWorkspaceStore(factory as unknown as IDBFactory, { + dbName: 'custom-db', + workspaceStoreName: 'custom-workspaces', + preferenceStoreName: 'custom-preferences', + keyIndexName: 'custom-key-index', + }); + await store.create(record('id-1', 'alpha')); + expect(await store.readByKey('alpha')).toEqual(record('id-1', 'alpha')); + expect(factory.databases.has('custom-db')).toBe(true); }); }); diff --git a/tests/unit/legacy-migration.test.ts b/tests/unit/legacy-migration.test.ts deleted file mode 100644 index ad9a65ed..00000000 --- a/tests/unit/legacy-migration.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -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('report'); - 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: 'report', 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/query-document-session.test.ts b/tests/unit/query-document-session.test.ts index bc0cf905..2f7a923d 100644 --- a/tests/unit/query-document-session.test.ts +++ b/tests/unit/query-document-session.test.ts @@ -282,7 +282,8 @@ describe('resolveEditorMode', () => { const state = { tabs: signal([tab]), savedQueries: [] as AppState['savedQueries'], resultView: signal<'table' | 'json' | 'panel' | 'filter'>('table'), libraryDirty: signal(false), - libraryName: signal('Lib'), workspaceId: 'w1', dashboard: null as AppState['dashboard'], + libraryName: signal('Lib'), workspaceId: 'w1', workspaceKey: 'lib', + dashboard: null as AppState['dashboard'], }; // Link the tab via the real state.ts create path β€” the exact invariant // `savedForTab` (this session's own dependency) reads. #287 W4: the diff --git a/tests/unit/saved-history.test.ts b/tests/unit/saved-history.test.ts index 02fbb1bb..7fa4bb03 100644 --- a/tests/unit/saved-history.test.ts +++ b/tests/unit/saved-history.test.ts @@ -292,7 +292,7 @@ describe('renderSavedHistory', () => { const app = makeApp(); // The latest committed workspace no longer contains s1 β€” the patch aborts. app.mutateWorkspace = (async (transform: Parameters[0]) => { - const input = await transform({ storageVersion: 1, id: 'w1', name: 'L', queries: [], dashboard: null }); + const input = await transform({ storageVersion: 2, id: 'w1', key: 'l', name: 'L', queries: [], dashboard: null }); expect(input).toBeNull(); // the planner found no target and aborted return { ok: false as const, aborted: true as const, data: undefined }; }) as App['mutateWorkspace']; @@ -311,7 +311,7 @@ describe('renderSavedHistory', () => { it('#343: rename on a query deleted in another tab toasts and refreshes the workspace', async () => { const app = makeApp(); app.mutateWorkspace = (async (transform: Parameters[0]) => { - const input = await transform({ storageVersion: 1, id: 'w1', name: 'L', queries: [], dashboard: null }); + const input = await transform({ storageVersion: 2, id: 'w1', key: 'l', name: 'L', queries: [], dashboard: null }); expect(input).toBeNull(); return { ok: false as const, aborted: true as const, data: undefined }; }) as App['mutateWorkspace']; diff --git a/tests/unit/saved-query-mutation.test.ts b/tests/unit/saved-query-mutation.test.ts index 69962c9f..2a59120e 100644 --- a/tests/unit/saved-query-mutation.test.ts +++ b/tests/unit/saved-query-mutation.test.ts @@ -4,7 +4,7 @@ import { } 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 { SavedQueryV2, StoredWorkspaceV2 } 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 => ({ @@ -18,8 +18,8 @@ const filterQuery = (id: string): 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', +const baseWorkspace = (): StoredWorkspaceV2 => ({ + storageVersion: 2, 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}'), @@ -31,7 +31,7 @@ const baseWorkspace = (): StoredWorkspaceV1 => ({ filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1', targets: ['t1'] }], tiles: [{ id: 't1', queryId: 'p1' }], }, -} as StoredWorkspaceV1); +} as StoredWorkspaceV2); const codes = (d: WorkspaceDiagnostic[]): string[] => d.map((x) => x.code); @@ -75,8 +75,8 @@ describe('planSavedQueryMutation β€” rejection without repair', () => { }); it('remaps a filter source reference onto another filter query', () => { - const workspace: StoredWorkspaceV1 = { - storageVersion: 1, id: 'ws', name: 'WS', + const workspace: StoredWorkspaceV2 = { + storageVersion: 2, id: 'ws', key: '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, @@ -84,7 +84,7 @@ describe('planSavedQueryMutation β€” rejection without repair', () => { filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1', targets: ['t1'] }], tiles: [{ id: 't1', queryId: 'p1' }], }, - } as StoredWorkspaceV1; + } as StoredWorkspaceV2; 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'); @@ -131,15 +131,15 @@ describe('planSavedQueryMutation β€” atomic repair', () => { }); it('switches an affected tile to another valid variant', () => { - const workspace: StoredWorkspaceV1 = { - storageVersion: 1, id: 'ws', name: 'WS', + const workspace: StoredWorkspaceV2 = { + storageVersion: 2, id: 'ws', key: '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: 'report', items: { t1: {} } }, filters: [], tiles: [{ id: 't1', queryId: 'p1', presentation: { variant: 'alt' } }], }, - } as StoredWorkspaceV1; + } as StoredWorkspaceV2; const deletesAlt = { type: 'replace-query' as const, queryId: 'p1', query: panelQuery('p1', 'SELECT a,b', { variants: { other: {} } }) }; const rejected = planSavedQueryMutation(workspace, deletesAlt); @@ -181,8 +181,8 @@ describe('planSavedQueryMutation β€” atomic repair', () => { }); describe('planSavedQueryMutation β€” repairs skip unaffected and target-less entries', () => { - const multiTile = (): StoredWorkspaceV1 => ({ - storageVersion: 1, id: 'ws', name: 'WS', + const multiTile = (): StoredWorkspaceV2 => ({ + storageVersion: 2, id: 'ws', key: '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, @@ -195,7 +195,7 @@ describe('planSavedQueryMutation β€” repairs skip unaffected and target-less ent { id: 't4', queryId: 'p1' }, // affected but unmapped β€” left untouched ], }, - } as StoredWorkspaceV1); + } as StoredWorkspaceV2); 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' }); @@ -216,8 +216,8 @@ describe('planSavedQueryMutation β€” repairs skip unaffected and target-less ent }); it('remaps only affected tiles and leaves a source-less filter intact', () => { - const workspace: StoredWorkspaceV1 = { - storageVersion: 1, id: 'ws', name: 'WS', + const workspace: StoredWorkspaceV2 = { + storageVersion: 2, id: 'ws', key: 'ws', name: 'WS', queries: [panelQuery('p1', 'SELECT a,b'), panelQuery('p2', 'SELECT a,b')], dashboard: { documentVersion: 1, id: 'dash', title: 'D', revision: 1, @@ -225,7 +225,7 @@ describe('planSavedQueryMutation β€” repairs skip unaffected and target-less ent filters: [{ id: 'flt', parameter: 'x' }], // no source tiles: [{ id: 't1', queryId: 'p1' }, { id: 't2', queryId: 'p2' }], }, - } as StoredWorkspaceV1; + } as StoredWorkspaceV2; const plan = planSavedQueryMutation(workspace, { type: 'delete-query', queryId: 'p1' }, { type: 'remap-query', to: 'p2' }); expect(plan.ok).toBe(true); const dashboard = plan.candidate!.dashboard!; @@ -234,13 +234,13 @@ describe('planSavedQueryMutation β€” repairs skip unaffected and target-less ent it('tolerates malformed tiles and filters while applying a repair', () => { const malformed = { - storageVersion: 1, id: 'ws', name: 'WS', queries: [panelQuery('p1', 'SELECT a,b')], + storageVersion: 2, id: 'ws', key: 'ws', name: 'WS', queries: [panelQuery('p1', 'SELECT a,b')], dashboard: { documentVersion: 1, id: 'dash', title: 'D', revision: 1, layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: ['bad', { id: 'flt', parameter: 'x' }], tiles: ['bad', { id: 't1', queryId: 'p1' }], }, - } as unknown as StoredWorkspaceV1; + } as unknown as StoredWorkspaceV2; 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. @@ -254,8 +254,8 @@ describe('planSavedQueryMutation β€” grafana-grid@1 engine awareness (#291)', () // normalization/fallback regeneration, not filter-selection contract // validity; see the "removes affected tiles" test above for why a // source-backed filter left with zero tiles would (correctly) now fail. - const workspace: StoredWorkspaceV1 = { - storageVersion: 1, id: 'ws', name: 'WS', + const workspace: StoredWorkspaceV2 = { + storageVersion: 2, id: 'ws', key: 'ws', name: 'WS', queries: [panelQuery('p1', 'SELECT a,b WHERE c={country:String}'), filterQuery('f1')], dashboard: { documentVersion: 1, id: 'dash', title: 'D', revision: 1, @@ -263,7 +263,7 @@ describe('planSavedQueryMutation β€” grafana-grid@1 engine awareness (#291)', () filters: [{ id: 'flt', parameter: 'country', targets: ['t1'] }], tiles: [{ id: 't1', queryId: 'p1' }], }, - } as StoredWorkspaceV1; + } as StoredWorkspaceV2; const plan = planSavedQueryMutation(workspace, { type: 'delete-query', queryId: 'p1' }, { type: 'remove-affected-tiles' }); expect(plan.ok).toBe(true); const dashboard = plan.candidate!.dashboard!; @@ -278,7 +278,7 @@ describe('planSavedQueryMutation β€” grafana-grid@1 engine awareness (#291)', () 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 workspace = { ...baseWorkspace(), dashboard: null } as StoredWorkspaceV2; const plan = planSavedQueryMutation(workspace, { type: 'delete-query', queryId: 'p1' }); expect(plan.ok).toBe(true); expect(plan.candidate!.dashboard).toBeNull(); diff --git a/tests/unit/saved-query-service.test.ts b/tests/unit/saved-query-service.test.ts index 28c18578..4fa4e9a3 100644 --- a/tests/unit/saved-query-service.test.ts +++ b/tests/unit/saved-query-service.test.ts @@ -23,7 +23,8 @@ import { fakeMutateWorkspace } from '../helpers/fake-app.js'; // ── Fakes ──────────────────────────────────────────────────────────────────── type StateSlice = Pick; + 'savedQueries' | 'resultView' | 'libraryDirty' | 'history' | 'libraryName' + | 'workspaceId' | 'workspaceKey' | 'dashboard'>; function makeState(over: Partial = {}): StateSlice { return { @@ -33,6 +34,7 @@ function makeState(over: Partial = {}): StateSlice { history: over.history ?? [], libraryName: over.libraryName ?? signal('Lib'), workspaceId: over.workspaceId ?? 'w1', + workspaceKey: over.workspaceKey ?? 'workspace', dashboard: over.dashboard ?? null, }; } diff --git a/tests/unit/schema-build.test.js b/tests/unit/schema-build.test.js index 65584b2b..59cdaeb6 100644 --- a/tests/unit/schema-build.test.js +++ b/tests/unit/schema-build.test.js @@ -10,6 +10,7 @@ import { } from '../../build/compile-json-schemas.mjs'; import { ANNOTATION_KEYWORDS, SCHEMA_MANIFEST } from '../../build/schema-manifest.mjs'; import { buildSchemaTypes } from '../../build/emit-schema-types.mjs'; +import { validateStoredWorkspaceV2 } from '../../src/generated/json-schema-validators.js'; const root = resolve(process.cwd()); @@ -22,13 +23,13 @@ describe('multi-schema build', () => { 'schemas/dashboard-layout-flow-v1.schema.json', 'schemas/dashboard-layout-grafana-grid-v1.schema.json', 'schemas/dashboard-v1.schema.json', - 'schemas/stored-workspace-v1.schema.json', + 'schemas/stored-workspace-v2.schema.json', 'schemas/portable-bundle-v1.schema.json', ]); const KINDS = [ ['query-spec', 1], ['saved-query', 2], ['library', 2], ['dashboard-layout-flow', 1], ['dashboard-layout-grafana-grid', 1], - ['dashboard', 1], ['stored-workspace', 1], ['portable-bundle', 1], + ['dashboard', 1], ['stored-workspace', 2], ['portable-bundle', 1], ]; const records = await loadRecords(); expect(records.map(({ schema }) => [schema['x-altinity-kind'], schema['x-altinity-version']])) @@ -48,7 +49,7 @@ describe('multi-schema build', () => { 'https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json', 'https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-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/stored-workspace-v2.schema.json', 'https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json', ]); const ajv = new Ajv2020({ strict: true, allErrors: true }); @@ -62,6 +63,23 @@ describe('multi-schema build', () => { })).toBe(true); }); + it('validates the stored-workspace v2 identity and key contract', () => { + const workspace = { + storageVersion: 2, + id: 'opaque-id', + key: 'clickhouse_operations', + name: 'ClickHouse Operations', + queries: [], + dashboard: null, + }; + expect(validateStoredWorkspaceV2(workspace)).toBe(true); + for (const key of ['', '_private', '-private', 'Upper', 'with space', 'cafΓ©']) { + expect(validateStoredWorkspaceV2({ ...workspace, key })).toBe(false); + } + expect(validateStoredWorkspaceV2({ ...workspace, storageVersion: 1 })).toBe(false); + expect(validateStoredWorkspaceV2({ ...workspace, extra: true })).toBe(false); + }); + it('generates deterministic artifacts and standalone code without Ajv runtime imports', async () => { const first = await generatedSources(); const second = await generatedSources(); @@ -100,7 +118,7 @@ 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', - 'FlowLayoutV1', 'GrafanaGridLayoutV1', 'DashboardDocumentV1', 'StoredWorkspaceV1', 'PortableBundleV1', + 'FlowLayoutV1', 'GrafanaGridLayoutV1', 'DashboardDocumentV1', 'StoredWorkspaceV2', 'PortableBundleV1', ]); const sources = await generatedSources(); const types = Object.entries(sources).find(([path]) => path.endsWith('json-schema.types.ts'))[1]; @@ -128,7 +146,7 @@ describe('multi-schema build', () => { // 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 StoredWorkspaceV2'); expect(types).toContain('export interface PortableBundleV1'); expect(types).toContain('export type DashboardLayoutFallbackV1 = FlowLayoutV1;'); expect(types).toContain('export type QueryPresentationPatchV1 = Record;'); @@ -137,7 +155,9 @@ describe('multi-schema build', () => { expect(block('GrafanaGridTilePlacementV1')).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('StoredWorkspaceV2')).toContain('storageVersion: 2;'); + expect(block('StoredWorkspaceV2')).toContain('key: string;'); + expect(block('StoredWorkspaceV2')).toContain('dashboard: DashboardDocumentV1 | null;'); expect(block('QueryDashboardPresentationV1')).toContain('variants?: Record;'); }); diff --git a/tests/unit/session-bundle.test.ts b/tests/unit/session-bundle.test.ts index 4884280c..174cce54 100644 --- a/tests/unit/session-bundle.test.ts +++ b/tests/unit/session-bundle.test.ts @@ -4,7 +4,7 @@ import { } from '../../src/dashboard/application/session-bundle.js'; import { encodePortableBundleJson } from '../../src/dashboard/model/portable-bundle-codec.js'; import type { - DashboardDocumentV1, SavedQueryV2, StoredWorkspaceV1, + DashboardDocumentV1, SavedQueryV2, StoredWorkspaceV2, } from '../../src/generated/json-schema.types.js'; const panelQuery = (id: string): SavedQueryV2 => ({ @@ -86,16 +86,17 @@ describe('materializeDetachedWorkspace', () => { ]); }); - it('materializes the selected dashboard into a StoredWorkspaceV1 named after its title', () => { + it('materializes the selected dashboard into a StoredWorkspaceV2 named after its title', () => { const dashboard = dashboardDoc('d1', 'My Dashboard', ['q1']); const queries = [panelQuery('q1')]; const text = encodedBundle(dashboard, queries); const result = materializeDetachedWorkspace(text, 'd1', 'detached-1'); expect(result.ok).toBe(true); if (!result.ok) return; - const workspace: StoredWorkspaceV1 = result.workspace; - expect(workspace.storageVersion).toBe(1); + const workspace: StoredWorkspaceV2 = result.workspace; + expect(workspace.storageVersion).toBe(2); expect(workspace.id).toBe('detached-1'); + expect(workspace.key).toBe('detached-1'); expect(workspace.name).toBe('My Dashboard'); expect(workspace.dashboard?.id).toBe('d1'); expect(workspace.queries.map((q) => q.id)).toEqual(['q1']); @@ -113,51 +114,61 @@ describe('materializeDetachedWorkspace', () => { }); describe('resolveDashboardMode', () => { - const workspaceFixture = (id: string, dashboardId: string | null): StoredWorkspaceV1 => ({ - storageVersion: 1, id, name: 'WS', queries: [], + const workspaceFixture = (id: string, dashboardId: string | null): StoredWorkspaceV2 => ({ + storageVersion: 2, id, key: `${id}_key`, name: 'WS', queries: [], dashboard: dashboardId ? dashboardDoc(dashboardId, 'D', []) : null, }); it('resolves edit mode when the primary workspace and dashboard id both match', () => { const primary = workspaceFixture('ws1', 'd1'); - const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd1' }, primary, null); + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceKey: 'ws1_key', dashboardId: 'd1' }, primary, null); expect(result).toEqual({ mode: 'edit', workspace: primary }); }); it('resolves view mode when only the detached workspace and dashboard id match', () => { const detached = workspaceFixture('ws1', 'd1'); - const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd1' }, null, detached); + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceKey: 'ws1_key', dashboardId: 'd1' }, null, detached); expect(result).toEqual({ mode: 'view', workspace: detached }); }); it('prefers the primary workspace over the detached workspace when both match', () => { const primary = workspaceFixture('ws1', 'd1'); const detached = workspaceFixture('ws1', 'd1'); - const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd1' }, primary, detached); + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceKey: 'ws1_key', dashboardId: 'd1' }, primary, detached); + expect(result).toEqual({ mode: 'edit', workspace: primary }); + }); + + it('matches the canonical workspace key case-insensitively', () => { + const primary = workspaceFixture('ws1', 'd1'); + const result = resolveDashboardMode( + { kind: 'current-workspace', workspaceKey: 'WS1_KEY', dashboardId: 'd1' }, + primary, + null, + ); expect(result).toEqual({ mode: 'edit', workspace: primary }); }); it('returns not-found when neither workspace is loaded', () => { - const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd1' }, null, null); + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceKey: 'ws1_key', dashboardId: 'd1' }, null, null); expect(result).toEqual({ mode: 'not-found' }); }); it('returns not-found when the workspace id matches but the dashboard id differs', () => { const primary = workspaceFixture('ws1', 'd1'); - const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd2' }, primary, null); + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceKey: 'ws1_key', dashboardId: 'd2' }, primary, null); expect(result).toEqual({ mode: 'not-found' }); }); it('returns not-found when the workspace has no dashboard at all', () => { const primary = workspaceFixture('ws1', null); - const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd1' }, primary, null); + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceKey: 'ws1_key', dashboardId: 'd1' }, primary, null); expect(result).toEqual({ mode: 'not-found' }); }); - it('returns not-found when the workspace id itself differs from both candidates', () => { + it('returns not-found when the workspace key differs from both candidates', () => { const primary = workspaceFixture('ws-other', 'd1'); const detached = workspaceFixture('ws-other-2', 'd1'); - const result = resolveDashboardMode({ kind: 'current-workspace', workspaceId: 'ws1', dashboardId: 'd1' }, primary, detached); + const result = resolveDashboardMode({ kind: 'current-workspace', workspaceKey: 'ws1_key', dashboardId: 'd1' }, primary, detached); expect(result).toEqual({ mode: 'not-found' }); }); }); diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index 391950f1..b0d41909 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -4,7 +4,7 @@ import { createSavedQuery, commitSavedQuery, savedForTab, renameSaved, toggleFavorite, sortedSaved, filterSaved, filterHistory, deleteSaved, recordHistory, recordScriptHistory, clearHistory, deleteHistory, tabPanel, setTabSpecDraft, patchSpecDraft, tabDirty, - reconcileTabsWithSavedQueries, adoptSavedIntoTab, reconcileLinkedTabsToLatest, + detachWorkspaceBoundTabs, reconcileTabsWithSavedQueries, adoptSavedIntoTab, reconcileLinkedTabsToLatest, } from '../../src/state.js'; import type { StateReader, HistoryResultSnapshot, HistoryEntry, QueryTab, SpecValidationService, AppState, SavedEntryResult, @@ -12,7 +12,7 @@ import type { import { queryToken } from '../../src/workspace/workspace-sync.js'; import { queryDescription, queryFavorite, queryName, queryPanel, queryView } from '../../src/core/saved-query.js'; import { savedQuery as savedQueryUntyped } from '../helpers/saved-query.js'; -import type { DashboardDocumentV1, SavedQueryV2, StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; +import type { DashboardDocumentV1, SavedQueryV2, StoredWorkspaceV2 } from '../../src/generated/json-schema.types.js'; import { fakeMutateWorkspace } from '../helpers/fake-app.js'; import type { WorkspaceDiagnostic } from '../../src/dashboard/model/workspace-diagnostics.js'; @@ -379,6 +379,34 @@ describe('saved queries', () => { expect(dangling).toMatchObject({ savedId: null, editorMode: 'sql' }); expect(unsaved).toMatchObject({ savedId: null, editorMode: 'sql' }); }); + it('detaches every workspace-bound tab without changing its drafts or dirty state', () => { + const s = savedTestState(); + const linked = s.tabs.value[0]; + linked.savedId = 'same-id'; + linked.editorMode = 'spec'; + linked.sqlDraft = "SELECT 'A'"; + linked.specText = '{"name":"A"}'; + linked.dirtySql = true; + linked.dirtySpec = true; + linked.lastCommittedQueryToken = 'workspace-a-token'; + linked.externalState = 'conflict'; + const unsaved = newTabObj('t2'); + s.tabs.value = [linked, unsaved]; + + detachWorkspaceBoundTabs(s); + + expect(linked).toMatchObject({ + savedId: null, + editorMode: 'sql', + sqlDraft: "SELECT 'A'", + specText: '{"name":"A"}', + dirtySql: true, + dirtySpec: true, + externalState: null, + }); + expect(linked.lastCommittedQueryToken).toBeUndefined(); + expect(unsaved).toMatchObject({ savedId: null, editorMode: 'sql' }); + }); it('renameSaved updates the entry + any linked tab name', async () => { const s = savedTestState(); s.savedQueries = [savedQuery({ id: 's1', sql: 'x', name: 'old' })]; @@ -608,8 +636,8 @@ describe('saved queries', () => { tab.savedId = 's1'; // Another tab already committed a workspace where s1 is gone β€” the mutation // resolves the target against THAT latest, not the stale local projection. - const latest: StoredWorkspaceV1 = { storageVersion: 1, id: 'w1', name: 'SQL Library', queries: [], dashboard: null }; - const mutate = fakeMutateWorkspace(s, { loadCurrent: async () => latest }); + const latest: StoredWorkspaceV2 = { storageVersion: 2, id: 'w1', key: 'sql_library', name: 'SQL Library', queries: [], dashboard: null }; + const mutate = fakeMutateWorkspace(s, { loadById: async () => latest }); expect(await renameSaved(s, 's1', 'New', undefined, mutate)).toEqual({ ok: false, invalidTab: null, entry: null, deletedExternally: true }); expect(await toggleFavorite(s, 's1', mutate, genTileId())).toEqual({ ok: false, invalidTab: null, entry: null, deletedExternally: true }); expect(mutate.commit).not.toHaveBeenCalled(); // never recreated @@ -622,8 +650,8 @@ describe('saved queries', () => { tab.savedId = 's1'; tab.sqlDraft = 'SELECT my draft'; setTabSpecDraft(tab, { name: 'Local', favorite: false }); - const latest: StoredWorkspaceV1 = { storageVersion: 1, id: 'w1', name: 'SQL Library', queries: [], dashboard: null }; - const mutate = fakeMutateWorkspace(s, { loadCurrent: async () => latest }); + const latest: StoredWorkspaceV2 = { storageVersion: 2, id: 'w1', key: 'sql_library', name: 'SQL Library', queries: [], dashboard: null }; + const mutate = fakeMutateWorkspace(s, { loadById: async () => latest }); const result = await commitSavedQuery(s, tab, tab.specParsed, mutate); expect(result).toEqual({ ok: false, entry: null, deletedExternally: true }); expect(mutate.commit).not.toHaveBeenCalled(); // aborted β€” never recreated @@ -642,8 +670,8 @@ describe('saved queries', () => { // Another tab already committed a changed s1; this tab has NOT refreshed yet // (missed poke) β€” no conflict flagged so far. const externalQ = savedQuery({ id: 's1', name: 'Local', sql: 'SELECT 999 /* external */' }); - const latest: StoredWorkspaceV1 = { storageVersion: 1, id: 'w1', name: 'SQL Library', queries: [externalQ], dashboard: null }; - const mutate = fakeMutateWorkspace(s, { loadCurrent: async () => latest }); + const latest: StoredWorkspaceV2 = { storageVersion: 2, id: 'w1', key: 'sql_library', name: 'SQL Library', queries: [externalQ], dashboard: null }; + const mutate = fakeMutateWorkspace(s, { loadById: async () => latest }); // The user renames from the Library β€” the patch folds into LATEST (keeps the // external SQL) but must not stamp the newest token onto this stale tab. const result = await renameSaved(s, 's1', 'Renamed here', undefined, mutate); @@ -651,7 +679,7 @@ describe('saved queries', () => { expect(s.savedQueries[0].sql).toBe('SELECT 999 /* external */'); // external change preserved expect(tab.lastCommittedQueryToken).toBe(queryToken(oldQ)); // baseline unchanged // …so the next refresh still classifies this dirty tab as CONFLICT. - const summary = reconcileLinkedTabsToLatest(s, { storageVersion: 1, id: 'w1', name: 'SQL Library', queries: s.savedQueries, dashboard: null }); + const summary = reconcileLinkedTabsToLatest(s, { storageVersion: 2, id: 'w1', key: 'sql_library', name: 'SQL Library', queries: s.savedQueries, dashboard: null }); expect(summary.conflicts).toBe(1); expect(tab.externalState).toBe('conflict'); }); @@ -667,8 +695,8 @@ describe('saved queries', () => { tab.lastCommittedQueryToken = queryToken(oldQ); // Another tab changed s1's SQL; this tab missed the poke and did NOT refresh. const externalQ = savedQuery({ id: 's1', name: 'Local', sql: 'SELECT 999 /* external */' }); - const latest: StoredWorkspaceV1 = { storageVersion: 1, id: 'w1', name: 'SQL Library', queries: [externalQ], dashboard: null }; - const mutate = fakeMutateWorkspace(s, { loadCurrent: async () => latest }); + const latest: StoredWorkspaceV2 = { storageVersion: 2, id: 'w1', key: 'sql_library', name: 'SQL Library', queries: [externalQ], dashboard: null }; + const mutate = fakeMutateWorkspace(s, { loadById: async () => latest }); const result = await renameSaved(s, 's1', 'Renamed here', undefined, mutate); expect(result?.ok).toBe(true); // The tab adopted the COMPLETE committed entry NOW (external SQL + this @@ -909,8 +937,8 @@ describe('linked-tab reconcile (#343)', () => { const q = (id: string, sql: string, name = id): SavedQueryV2 => ({ id, sql, specVersion: 1, spec: { name, favorite: false }, } as SavedQueryV2); - const ws = (queries: SavedQueryV2[]): StoredWorkspaceV1 => ({ - storageVersion: 1, id: 'w1', name: 'Team', queries, dashboard: null, + const ws = (queries: SavedQueryV2[]): StoredWorkspaceV2 => ({ + storageVersion: 2, id: 'w1', key: 'team', name: 'Team', queries, dashboard: null, }); /** A tab linked to `query` and currently in sync with it. */ const linkedTab = (id: string, query: SavedQueryV2): QueryTab => { diff --git a/tests/unit/stored-workspace.test.ts b/tests/unit/stored-workspace.test.ts index f188adaf..e9c5f1de 100644 --- a/tests/unit/stored-workspace.test.ts +++ b/tests/unit/stored-workspace.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { - CURRENT_STORED_WORKSPACE_VERSION, STORED_WORKSPACE_V1_SCHEMA_ID, + CURRENT_STORED_WORKSPACE_VERSION, STORED_WORKSPACE_V2_SCHEMA_ID, decodeStoredWorkspaceJson, encodeStoredWorkspaceJson, validateStoredWorkspaceDocument, } from '../../src/workspace/stored-workspace.js'; import type { WorkspaceDiagnostic } from '../../src/dashboard/model/workspace-diagnostics.js'; @@ -14,7 +14,7 @@ const dashboardDoc = (over: Record = {}) => ({ layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [], ...over, }); const workspace = (over: Record = {}) => ({ - storageVersion: 1, id: 'w1', name: 'W', queries: [], dashboard: null, ...over, + storageVersion: 2, id: 'w1', key: 'workspace', name: 'W', queries: [], dashboard: null, ...over, }); describe('validateStoredWorkspaceDocument', () => { @@ -32,11 +32,13 @@ describe('validateStoredWorkspaceDocument', () => { 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']); + expect(codes(validateStoredWorkspaceDocument({ storageVersion: 3 }))).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: [] }); + const d = validateStoredWorkspaceDocument({ + storageVersion: 2, id: 'w', key: 'workspace', name: 'W', queries: [], + }); expect(has(d, 'schema-required')).toBe(true); // dashboard required (may be null) }); @@ -70,7 +72,7 @@ describe('decodeStoredWorkspaceJson', () => { it('propagates codec-guard and validation failures', () => { expect(decodeStoredWorkspaceJson('{bad').ok).toBe(false); - const invalid = decodeStoredWorkspaceJson(JSON.stringify({ storageVersion: 2 })); + const invalid = decodeStoredWorkspaceJson(JSON.stringify({ storageVersion: 3 })); expect(!invalid.ok && invalid.diagnostics[0].code).toBe('workspace-version-unsupported'); }); }); @@ -83,11 +85,11 @@ describe('encodeStoredWorkspaceJson', () => { 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'); + expect(STORED_WORKSPACE_V2_SCHEMA_ID).toContain('stored-workspace-v2'); }); it('rejects an invalid workspace before encoding', () => { - const result = encodeStoredWorkspaceJson({ storageVersion: 2 }); + const result = encodeStoredWorkspaceJson({ storageVersion: 3 }); expect(!result.ok && result.diagnostics[0].code).toBe('workspace-version-unsupported'); }); diff --git a/tests/unit/workspace-key.test.ts b/tests/unit/workspace-key.test.ts new file mode 100644 index 00000000..746e8578 --- /dev/null +++ b/tests/unit/workspace-key.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { + deriveWorkspaceKey, + isValidWorkspaceKey, + normalizeWorkspaceKeyLookup, + validateWorkspaceKey, + WORKSPACE_KEY_PATTERN, +} from '../../src/core/workspace-key.js'; + +describe('workspace key validation', () => { + it('accepts only non-empty canonical lowercase ASCII keys beginning alphanumeric', () => { + for (const key of ['a', '0', 'clickhouse_operations', 'production-eu', 'a_2']) { + expect(WORKSPACE_KEY_PATTERN.test(key)).toBe(true); + expect(isValidWorkspaceKey(key)).toBe(true); + expect(validateWorkspaceKey(key)).toEqual({ ok: true, key }); + } + for (const key of ['', '_private', '-private', 'Upper', 'with space', 'cafΓ©', 'a.b']) { + expect(isValidWorkspaceKey(key)).toBe(false); + } + expect(validateWorkspaceKey('')).toEqual({ ok: false, reason: 'required' }); + expect(validateWorkspaceKey(null)).toEqual({ ok: false, reason: 'required' }); + expect(validateWorkspaceKey('UPPER')).toEqual({ ok: false, reason: 'invalid' }); + }); + + it('lowercases lookup input without trimming or otherwise repairing it', () => { + expect(normalizeWorkspaceKeyLookup('Production_EU')).toBe('production_eu'); + expect(normalizeWorkspaceKeyLookup(' Invalid ')).toBe(' invalid '); + }); +}); + +describe('workspace key derivation', () => { + it('lowercases ASCII, replaces invalid runs, trims separators, and falls back', () => { + expect(deriveWorkspaceKey('ClickHouse Operations')).toBe('clickhouse_operations'); + expect(deriveWorkspaceKey('Production EU')).toBe('production_eu'); + expect(deriveWorkspaceKey('__One---Two__')).toBe('one---two'); + expect(deriveWorkspaceKey(' cafΓ© / 東京 ')).toBe('caf'); + expect(deriveWorkspaceKey('é東京')).toBe('workspace'); + expect(deriveWorkspaceKey(' _ - ')).toBe('workspace'); + expect(deriveWorkspaceKey(undefined)).toBe('workspace'); + }); + + it('uses deterministic case-insensitive numeric collision suffixes', () => { + expect(deriveWorkspaceKey('Operations', ['other'])).toBe('operations'); + expect(deriveWorkspaceKey('Operations', ['OPERATIONS'])).toBe('operations_2'); + expect(deriveWorkspaceKey('Operations', ['operations', 'OPERATIONS_2', 'operations_4'])) + .toBe('operations_3'); + expect(deriveWorkspaceKey('', ['WORKSPACE', 'workspace_2'])).toBe('workspace_3'); + }); +}); diff --git a/tests/unit/workspace-operations.test.ts b/tests/unit/workspace-operations.test.ts index 51a69440..765e422d 100644 --- a/tests/unit/workspace-operations.test.ts +++ b/tests/unit/workspace-operations.test.ts @@ -4,91 +4,73 @@ import { createNewWorkspace, generateWorkspaceId, importQueries, renameWorkspace, replaceWorkspaceContents, } from '../../src/workspace/workspace-operations.js'; -import type { SavedQueryV2, StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; +import type { SavedQueryV2, StoredWorkspaceV2 } 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 query = (id: string): SavedQueryV2 => ({ + id, sql: 'SELECT 1', specVersion: 1, spec: { name: id, favorite: false }, }); -const dashboard = (): NonNullable => ({ - documentVersion: 1, id: 'd1', title: 'Dash', revision: 3, - layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [], +const base = (): StoredWorkspaceV2 => ({ + storageVersion: 2, + id: 'id-1', + key: 'stable_key', + name: 'Display name', + queries: [query('q1')], + dashboard: null, }); -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, +describe('workspace operations', () => { + it('creates an empty V2 workspace from the injected ID, key, and name', () => { + const genId = () => 'opaque-id'; + expect(createNewWorkspace(genId, 'clickhouse_ops', 'ClickHouse Ops')).toEqual({ + storageVersion: CURRENT_STORAGE_VERSION, + id: 'opaque-id', + key: 'clickhouse_ops', + name: 'ClickHouse Ops', + queries: [], + dashboard: null, }); + expect(generateWorkspaceId(genId)).toBe('opaque-id'); }); - it('defaults the name when omitted', () => { - expect(createNewWorkspace(counter()).name).toBe(DEFAULT_WORKSPACE_NAME); + it('uses the default display name for missing, blank, or non-string names', () => { + expect(createNewWorkspace(() => 'a', 'a').name).toBe(DEFAULT_WORKSPACE_NAME); + expect(createNewWorkspace(() => 'b', 'b', ' ').name).toBe(DEFAULT_WORKSPACE_NAME); + expect(createNewWorkspace(() => 'c', 'c', 12).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); + it('renames only the mutable display name', () => { + const workspace = base(); + const renamed = renameWorkspace(workspace, 'Renamed'); + expect(renamed).toEqual({ ...workspace, name: 'Renamed' }); + expect(renamed.id).toBe(workspace.id); + expect(renamed.key).toBe(workspace.key); + expect(renamed.queries).toBe(workspace.queries); + expect(renameWorkspace(workspace, '').name).toBe(DEFAULT_WORKSPACE_NAME); }); -}); -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('imports queries without changing identity or dashboard', () => { + const workspace = base(); + const incoming = [query('q2')]; + const result = importQueries(workspace, incoming); + expect(result.queries).toEqual(incoming); + expect(result.queries).not.toBe(incoming); + expect(result.id).toBe(workspace.id); + expect(result.key).toBe(workspace.key); + expect(result.dashboard).toBe(workspace.dashboard); }); - it('can clear the Dashboard as part of an atomic replacement', () => { - expect(replaceWorkspaceContents(base(), { queries: [], dashboard: null }).dashboard).toBeNull(); + it('replaces portable contents while preserving local identity', () => { + const workspace = base(); + const incoming = [query('q2')]; + const result = replaceWorkspaceContents(workspace, { + queries: incoming, + dashboard: null, + }); + expect(result.queries).toEqual(incoming); + expect(result.queries).not.toBe(incoming); + expect(result.dashboard).toBeNull(); + expect(result.id).toBe(workspace.id); + expect(result.key).toBe(workspace.key); + expect(result.name).toBe(workspace.name); }); }); diff --git a/tests/unit/workspace-repository.test.ts b/tests/unit/workspace-repository.test.ts index f8ededf6..141d8167 100644 --- a/tests/unit/workspace-repository.test.ts +++ b/tests/unit/workspace-repository.test.ts @@ -1,235 +1,323 @@ -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 { describe, expect, it } from 'vitest'; +import { createWorkspaceRepository } from '../../src/workspace/workspace-repository.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; +import type { + WorkspaceStore, WorkspaceStoreCreateResult, WorkspaceStoreRecord, + WorkspaceStoreReplaceResult, +} from '../../src/workspace/workspace-store.types.js'; +import type { StoredWorkspaceV2 } from '../../src/generated/json-schema.types.js'; + +const workspace = (over: Partial = {}): StoredWorkspaceV2 => ({ + storageVersion: 2, + id: 'w1', + key: 'workspace_one', + name: 'Workspace One', + queries: [], + dashboard: null, + ...over, +}); + +const encode = (value: StoredWorkspaceV2): string => { + const result = encodeStoredWorkspaceJson(value); + if (!result.ok) throw new Error('invalid test fixture'); + return result.value; +}; + +function memoryStore(initial: WorkspaceStoreRecord[] = []) { + const records = new Map(initial.map((record) => [record.id, { ...record }])); + let lastUsedKey: string | null = null; + let fail: string | null = null; + let forcedCreate: WorkspaceStoreCreateResult | null = null; + let forcedReplace: WorkspaceStoreReplaceResult | null = null; + const store: WorkspaceStore & { - readonly value: string | null; readonly writes: string[]; setFailWrite(f: boolean): void; + records: Map; + setLastUsed(key: string | null): void; + setFail(operation: string | null): void; + forceCreate(result: WorkspaceStoreCreateResult | null): void; + forceReplace(result: WorkspaceStoreReplaceResult | null): void; } = { - read: async () => value, - write: async (text: string) => { - if (failWrite) throw new Error('quota exceeded'); - writes.push(text); - value = text; + list: async () => { + if (fail === 'list') throw 'list unavailable'; + return [...records.values()]; + }, + readById: async (id) => { + if (fail === 'read') throw new Error('read unavailable'); + return records.get(id) ?? null; + }, + readByKey: async (key) => + [...records.values()].find((record) => record.key.toLowerCase() === key.toLowerCase()) ?? null, + create: async (record) => { + if (fail === 'create') throw new Error('quota exceeded'); + if (forcedCreate) return forcedCreate; + if (records.has(record.id)) return { status: 'duplicate-id' }; + if ([...records.values()].some((item) => item.key.toLowerCase() === record.key.toLowerCase())) { + return { status: 'duplicate-key' }; + } + records.set(record.id, { ...record }); + return { status: 'created' }; + }, + replace: async (record) => { + if (fail === 'replace') throw 'disk full'; + if (forcedReplace) return forcedReplace; + const existing = records.get(record.id); + if (!existing) return { status: 'not-found' }; + if (existing.key !== record.key) return { status: 'immutable-key' }; + records.set(record.id, { ...record, lastOpenedAt: existing.lastOpenedAt }); + return { status: 'replaced' }; + }, + delete: async (id) => { + if (fail === 'delete') throw new Error('delete unavailable'); + const existing = records.get(id); + if (!existing) return false; + records.delete(id); + if (lastUsedKey === existing.key) lastUsedKey = null; + return true; + }, + getLastUsedKey: async () => lastUsedKey, + markOpened: async (key, timestamp) => { + if (fail === 'markOpened') throw new Error('preference unavailable'); + const entry = [...records.entries()].find(([, record]) => record.key === key); + if (!entry) return { status: 'not-found' }; + records.set(entry[0], { ...entry[1], lastOpenedAt: timestamp }); + lastUsedKey = key; + return { status: 'opened' }; }, - clear: async () => { value = null; }, - get value() { return value; }, - get writes() { return writes; }, - setFailWrite(f: boolean) { failWrite = f; }, + clearLastUsedKey: async () => { lastUsedKey = null; }, + records, + setLastUsed: (key) => { lastUsedKey = key; }, + setFail: (operation) => { fail = operation; }, + forceCreate: (result) => { forcedCreate = result; }, + forceReplace: (result) => { forcedReplace = result; }, }; 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: 'report', items: { t1: {} } }, - filters: [], tiles: [{ id: 't1', queryId: 'p1' }], - }, - ...over, +const record = ( + value: StoredWorkspaceV2, lastOpenedAt: number | null = null, +): WorkspaceStoreRecord => ({ + id: value.id, key: value.key, text: encode(value), lastOpenedAt, }); -describe('createWorkspaceRepository.loadCurrent', () => { - it('returns null when no aggregate record exists', async () => { - const repo = createWorkspaceRepository({ store: memStore(null) }); - expect(await repo.loadCurrent()).toBeNull(); +describe('workspace repository collection', () => { + it('creates and independently loads multiple workspaces by ID and canonical key', async () => { + const store = memoryStore(); + const repository = createWorkspaceRepository({ store }); + const one = workspace(); + const two = workspace({ id: 'w2', key: 'workspace_two', name: 'Two' }); + expect((await repository.create(one)).ok).toBe(true); + expect((await repository.create(two)).ok).toBe(true); + expect(await repository.loadById('w1')).toEqual({ status: 'ok', workspace: one }); + expect(await repository.loadByKey('WORKSPACE_TWO')).toEqual({ status: 'ok', workspace: two }); + expect(await repository.loadById('missing')).toEqual({ status: 'empty' }); + expect(await repository.loadByKey('missing')).toEqual({ status: 'empty' }); }); - 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('lists deterministic summaries and separately reports corrupt records', async () => { + const one = workspace({ queries: [{ + id: 'q', sql: 'SELECT 1', specVersion: 1, spec: { name: 'q', favorite: false }, + }] }); + const two = workspace({ id: 'w2', key: 'a_key', name: 'A' }); + const store = memoryStore([ + record(one, 8), + record(two), + { id: 'broken', key: 'broken', text: 'not json', lastOpenedAt: null }, + { id: 'wrong', key: 'wrong', text: encode(workspace({ id: 'other', key: 'other' })), lastOpenedAt: null }, + ]); + const result = await createWorkspaceRepository({ store }).list(); + expect(result.summaries).toEqual([ + { id: 'w2', key: 'a_key', name: 'A', queryCount: 0, hasDashboard: false, lastOpenedAt: null }, + { + id: 'w1', key: 'workspace_one', name: 'Workspace One', + queryCount: 1, hasDashboard: false, lastOpenedAt: 8, + }, + ]); + expect(result.corrupt.map(({ id }) => id)).toEqual(['broken', 'wrong']); + expect(result.corrupt[1].diagnostics[0].code).toBe('workspace-record-identity-mismatch'); }); - 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(); + it('preserves corrupt as a distinct keyed load state', async () => { + const store = memoryStore([{ id: 'bad', key: 'bad', text: '{}', lastOpenedAt: null }]); + const loaded = await createWorkspaceRepository({ store }).loadByKey('bad'); + expect(loaded.status).toBe('corrupt'); }); -}); -describe('createWorkspaceRepository.loadCurrentResult', () => { - it('reports empty when no aggregate record exists', async () => { - const repo = createWorkspaceRepository({ store: memStore(null) }); - expect(await repo.loadCurrentResult()).toEqual({ status: 'empty' }); - }); + it('validates before create and distinguishes duplicate ID, duplicate key, and persistence failure', async () => { + const store = memoryStore(); + const repository = createWorkspaceRepository({ store }); + const invalid = workspace({ key: 'INVALID KEY' }); + expect((await repository.create(invalid)).ok).toBe(false); + expect(store.records.size).toBe(0); - it('reports ok with the decoded workspace when a valid record is stored', 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.loadCurrentResult()).toEqual({ status: 'ok', workspace: ws }); - }); + await repository.create(workspace()); + let result = await repository.create(workspace()); + expect(!result.ok && result.diagnostics[0].code).toBe('workspace-duplicate-id'); + result = await repository.create(workspace({ id: 'w2' })); + expect(!result.ok && result.diagnostics[0].code).toBe('workspace-duplicate-key'); - it('reports corrupt with diagnostics when a present record fails to decode/validate (never collapses to empty)', async () => { - const repo = createWorkspaceRepository({ store: memStore('{"storageVersion":2}') }); - const result = await repo.loadCurrentResult(); - expect(result.status).toBe('corrupt'); - if (result.status !== 'corrupt') throw new Error('unreachable'); - expect(result.diagnostics.length).toBeGreaterThan(0); - expect(result.diagnostics.some((d) => d.code === 'workspace-version-unsupported')).toBe(true); + store.setFail('create'); + result = await repository.create(workspace({ id: 'w3', key: 'w3' })); + expect(!result.ok && result.diagnostics[0].code).toBe('workspace-persist-failed'); }); - it('reports corrupt for text that is not even valid JSON', async () => { - const repo = createWorkspaceRepository({ store: memStore('not json{') }); - const result = await repo.loadCurrentResult(); - expect(result.status).toBe('corrupt'); - if (result.status !== 'corrupt') throw new Error('unreachable'); - expect(result.diagnostics.length).toBeGreaterThan(0); + it('commits one existing workspace without changing another or its open timestamp', async () => { + const one = workspace(); + const two = workspace({ id: 'w2', key: 'two', name: 'Two' }); + const store = memoryStore([record(one, 11), record(two, 22)]); + const repository = createWorkspaceRepository({ store }); + const changed = { ...one, name: 'Renamed' }; + const result = await repository.commit(changed); + expect(result.ok && result.workspace).toEqual(changed); + expect(result.ok && result.dashboardRevision).toBeNull(); + expect(store.records.get('w1')?.lastOpenedAt).toBe(11); + expect(store.records.get('w2')).toEqual(record(two, 22)); }); -}); -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({ + it('publishes a committed Dashboard revision', async () => { + const value = workspace({ + queries: [{ + id: 'q1', sql: 'SELECT 1', specVersion: 1, + spec: { name: 'q1', favorite: true }, + }], dashboard: { - documentVersion: 1, id: 'd', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, - filters: [], tiles: [{ id: 't1', queryId: 'gone' }], + documentVersion: 1, + id: 'd1', + title: 'Dashboard', + revision: 4, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, + filters: [], + tiles: [], }, }); - 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(); + const repository = createWorkspaceRepository({ store: memoryStore([record(value)]) }); + const result = await repository.commit(value); + expect(result.ok && result.dashboardRevision).toBe(4); }); - 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()); + it('validates commits and distinguishes not found, immutable key, and persistence failures', async () => { + const original = workspace(); + const store = memoryStore([record(original)]); + const repository = createWorkspaceRepository({ store }); + + let result = await repository.commit({ ...original, key: 'INVALID KEY' }); expect(result.ok).toBe(false); - if (result.ok) throw new Error('unreachable'); - expect(result.diagnostics[0].message).toContain('disk full'); + expect(store.records.get('w1')?.text).toBe(encode(original)); + + result = await repository.commit(workspace({ id: 'missing', key: 'missing' })); + expect(!result.ok && result.diagnostics[0].code).toBe('workspace-not-found'); + result = await repository.commit({ ...original, key: 'changed' }); + expect(!result.ok && result.diagnostics[0].code).toBe('workspace-key-immutable'); + + store.forceReplace({ status: 'not-found' }); + result = await repository.commit(original); + expect(!result.ok && result.diagnostics[0].code).toBe('workspace-not-found'); + store.forceReplace({ status: 'immutable-key' }); + result = await repository.commit(original); + expect(!result.ok && result.diagnostics[0].code).toBe('workspace-key-immutable'); + store.forceReplace(null); + store.setFail('replace'); + result = await repository.commit(original); + expect(!result.ok && 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); + it('deletes exactly one workspace idempotently and reports failed deletes', async () => { + const one = workspace(); + const two = workspace({ id: 'w2', key: 'two' }); + const store = memoryStore([record(one), record(two)]); + const repository = createWorkspaceRepository({ store }); + expect(await repository.delete('w1')).toEqual({ ok: true, deleted: true }); + expect(await repository.delete('w1')).toEqual({ ok: true, deleted: false }); + expect(store.records.get('w2')).toEqual(record(two)); + store.setFail('delete'); + const failed = await repository.delete('w2'); + expect(!failed.ok && failed.diagnostics[0].code).toBe('workspace-delete-failed'); }); }); -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('implicit workspace resolution and opened metadata', () => { + it('first uses a valid last-used preference', async () => { + const store = memoryStore([ + record(workspace(), 100), + record(workspace({ id: 'w2', key: 'two' }), 1), + ]); + store.setLastUsed('two'); + const result = await createWorkspaceRepository({ store }).resolveImplicit(); + expect(result.status === 'ok' && result.workspace.key).toBe('two'); + }); + + it('falls back from a missing or corrupt preference to newest opened, ties by key', async () => { + const a = workspace({ id: 'a', key: 'a' }); + const b = workspace({ id: 'b', key: 'b' }); + const store = memoryStore([ + record(b, 10), + { id: 'bad', key: 'bad', text: '{}', lastOpenedAt: 99 }, + record(a, 10), + ]); + store.setLastUsed('missing'); + let result = await createWorkspaceRepository({ store }).resolveImplicit(); + expect(result.status === 'ok' && result.workspace.key).toBe('a'); + + store.setLastUsed('bad'); + result = await createWorkspaceRepository({ store }).resolveImplicit(); + expect(result.status === 'ok' && result.workspace.key).toBe('a'); + }); + + it('uses key order when no workspace has usage metadata and stays empty when none are valid', async () => { + const store = memoryStore([ + record(workspace({ id: 'b', key: 'b' })), + record(workspace({ id: 'a', key: 'a' })), + ]); + let result = await createWorkspaceRepository({ store }).resolveImplicit(); + expect(result.status === 'ok' && result.workspace.key).toBe('a'); + store.records.clear(); + result = await createWorkspaceRepository({ store }).resolveImplicit(); + expect(result).toEqual({ status: 'empty' }); + }); + + it('ranks timestamped workspaces ahead of unstamped ones in either input order', async () => { + const a = workspace({ id: 'a', key: 'a' }); + const b = workspace({ id: 'b', key: 'b' }); + for (const records of [[record(a), record(b, 1)], [record(b, 1), record(a)]]) { + const result = await createWorkspaceRepository({ store: memoryStore(records) }).resolveImplicit(); + expect(result.status === 'ok' && result.workspace.key).toBe('b'); + } + }); + + it('returns deterministic corrupt identity when no valid workspace exists', async () => { + const store = memoryStore([ + { id: 'z', key: 'same', text: '{}', lastOpenedAt: 1 }, + { id: 'a', key: 'same', text: '{}', lastOpenedAt: 1 }, + ]); + const result = await createWorkspaceRepository({ store }).resolveImplicit(); + expect(result).toMatchObject({ status: 'corrupt', id: 'a', key: 'same' }); + }); + + it('marks opened using the injected time and reports not-found and persistence failure', async () => { + const store = memoryStore([record(workspace())]); + const repository = createWorkspaceRepository({ store, now: () => 1234 }); + expect(await repository.markOpened('WORKSPACE_ONE')).toEqual({ ok: true }); + expect(store.records.get('w1')?.lastOpenedAt).toBe(1234); + expect(await repository.resolveImplicit()).toEqual({ + status: 'ok', workspace: workspace(), + }); + expect(await repository.markOpened('missing')).toMatchObject({ + ok: false, diagnostics: [{ code: 'workspace-not-found' }], + }); + store.setFail('markOpened'); + expect(await repository.markOpened('workspace_one')).toMatchObject({ + ok: false, diagnostics: [{ code: 'workspace-mark-opened-failed' }], + }); + }); + + it('propagates read failures distinctly from empty records', async () => { + const store = memoryStore(); + store.setFail('read'); + await expect(createWorkspaceRepository({ store }).loadById('w1')).rejects.toThrow('read unavailable'); }); -}); -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); + it('propagates list failures distinctly from an empty collection', async () => { + const store = memoryStore(); + store.setFail('list'); + await expect(createWorkspaceRepository({ store }).list()).rejects.toBe('list unavailable'); }); }); diff --git a/tests/unit/workspace-sync.test.ts b/tests/unit/workspace-sync.test.ts index 6fda5bda..23089b52 100644 --- a/tests/unit/workspace-sync.test.ts +++ b/tests/unit/workspace-sync.test.ts @@ -4,10 +4,10 @@ import { } from '../../src/workspace/workspace-sync.js'; import type { LinkedTabSnapshot } from '../../src/workspace/workspace-sync.js'; import { savedQuery } from '../helpers/saved-query.js'; -import type { SavedQueryV2, StoredWorkspaceV1 } from '../../src/generated/json-schema.types.js'; +import type { SavedQueryV2, StoredWorkspaceV2 } from '../../src/generated/json-schema.types.js'; -const ws = (over: Partial = {}): StoredWorkspaceV1 => ({ - storageVersion: 1, id: 'w1', name: 'WS', queries: [], dashboard: null, ...over, +const ws = (over: Partial = {}): StoredWorkspaceV2 => ({ + storageVersion: 2, id: 'w1', key: 'ws', name: 'WS', queries: [], dashboard: null, ...over, }); const tab = (over: Partial = {}): LinkedTabSnapshot => ({ @@ -30,7 +30,7 @@ describe('workspaceToken', () => { it('collapses an invalid workspace to empty rather than throwing', () => { // Unsupported storageVersion fails the codec β€” token is '' (equality probe, // not a validator). - expect(workspaceToken(ws({ storageVersion: 2 as unknown as 1 }))).toBe(''); + expect(workspaceToken(ws({ storageVersion: 1 as unknown as 2 }))).toBe(''); }); });