Skip to content

Commit e3eedf2

Browse files
merge #284: Dashboard v1 phase 2 — atomic StoredWorkspaceV1 persistence + legacy migration
2 parents 53c99a7 + cfbffa3 commit e3eedf2

15 files changed

Lines changed: 1161 additions & 0 deletions

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,37 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
3131
whole-workspace cross-resource semantic validation. No persistence, no UI,
3232
no import planner yet (later phases of #280).
3333

34+
- **Atomic `StoredWorkspaceV1` persistence and one-shot legacy migration**
35+
(#284, phase 2 of #280). New pure `src/workspace/` modules: a
36+
`WorkspaceRepository` (`loadCurrent`/`commit`/`clearCurrent`) that
37+
validates the complete candidate through phase-1 whole-workspace validation
38+
**before** writing and then atomically replaces the aggregate in one
39+
transaction — a failed write leaves the previously stored workspace intact,
40+
never increments Dashboard revision, and never produces a partially-mixed
41+
workspace (multi-tab policy: last successful commit wins, atomic replacement,
42+
not compare-and-swap). Persistence is **IndexedDB** (chosen over a single
43+
localStorage key: the 10 MiB `maxDecodedJsonBytes` aggregate exceeds the
44+
~5 MB localStorage quota, and phase 6 needs IndexedDB anyway) behind an
45+
injected `WorkspaceStore` seam (`env.indexedDB`, mirroring the crypto/storage
46+
seams) so the repository unit-tests against a plain in-memory fake. The
47+
one-shot legacy migration builds one candidate `StoredWorkspaceV1` from the
48+
flat `asb:*` keys — the initial Dashboard from `spec.favorite` (panel-role
49+
favorites become tiles in catalog order), `asb:dashLayout`/`asb:dashCols`
50+
converted to a valid `flow@1` layout — validates the whole candidate, and
51+
persists it atomically; it runs only when no aggregate record exists and
52+
never deletes or modifies legacy keys. Repository + operation semantics only;
53+
no authoring commands, viewer, or import UI yet (later phases of #280).
54+
- **`spec.favorite` dual-write removal path.** During phases 2-4 `spec.favorite`
55+
stays the membership source the existing favorites-driven Dashboard render
56+
reads, while `dashboard.tiles` becomes the canonical membership going
57+
forward; phase 3's star command dual-writes both. Membership **reads** flip
58+
to `dashboard.tiles` when phase 4's `DashboardViewerSession` + `flow@1`
59+
viewer replace `src/ui/dashboard.ts`'s `savedQueries.filter(queryFavorite)`
60+
render; the `spec.favorite` **dual-write** is then deleted in the v1
61+
Dashboard GA release (the release closing #280), after phase 5 lands — the
62+
schema keeps `favorite` only as an ignored legacy-compat field readable by
63+
old migrations.
64+
3465
### Changed
3566
- **The app.ts → services refactor is complete** (#276, Phase 5). The
3667
temporary `App` delegates from Phases 2–4 are deleted — consumers read

src/env.types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ export interface CreateAppEnv {
1919
fetch?: typeof fetch;
2020
crypto?: Crypto;
2121
sessionStorage?: Storage;
22+
/** IndexedDB factory seam (#280 Phase 2 / #284) — injected like crypto/
23+
* sessionStorage so the atomic `WorkspaceRepository` (app.workspace) has a
24+
* real backing store in the browser and an in-memory fake in tests.
25+
* Resolves to `win.indexedDB` when omitted; may be absent entirely on a
26+
* platform without IndexedDB, in which case workspace operations reject
27+
* (caught by the repository) rather than throwing at construction. */
28+
indexedDB?: IDBFactory;
2229
root?: Element | null;
2330
Chart?: unknown; // Chart.js constructor — new app.Chart(canvas, cfg)
2431
cssVar?: (name: string) => string;

src/ui/app.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ import { createExportService } from '../application/export-service.js';
7272
import type { ExportSink, FileHandleLike, DirectoryHandleLike } from '../application/export-service.js';
7373
import { createSchemaGraphSession, SchemaGraphAuthRequiredError } from '../application/schema-graph-session.js';
7474
import { createAppPreferences } from '../application/app-preferences.js';
75+
import { createWorkspaceRepository } from '../workspace/workspace-repository.js';
76+
import { createIndexedDbWorkspaceStore } from '../workspace/indexeddb-workspace-store.js';
7577
import { createWorkbenchSession } from './workbench/workbench-session.js';
7678
import { createQueryDocumentSession } from '../application/query-document-session.js';
7779
import { createSavedQueryService } from '../application/saved-query-service.js';
@@ -190,6 +192,16 @@ export function createApp(env: CreateAppEnv = {}): App {
190192
app.prefs = prefs;
191193
app.saveJSON = saveJSON;
192194
app.saveStr = saveStr;
195+
// Atomic StoredWorkspaceV1 persistence (#280 Phase 2 / #284): the injected
196+
// IndexedDB factory seam (mirrors crypto/sessionStorage) backs a single-record
197+
// WorkspaceStore, behind which the pure WorkspaceRepository does validate-then-
198+
// atomically-replace commits. Constructed lazily — no database is opened until
199+
// a workspace operation runs — so this never touches IndexedDB during
200+
// bootstrap. The favorites-driven Dashboard render still reads legacy keys in
201+
// this phase; wiring reads onto the aggregate is Phases 3-6 of #280.
202+
app.workspace = createWorkspaceRepository({
203+
store: createIndexedDbWorkspaceStore(env.indexedDB || win.indexedDB),
204+
});
193205
// The `{name:Type}` var-value/filter-active/recent-value persistence
194206
// wrappers (saveVarValues/saveFilterActive/saveVarRecent/
195207
// saveVarRecentDisabled) + the recent-value policy that sits on top of them

src/ui/app.types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { ConnectionSession, SessionChCtx } from '../application/connection-
1818
import type { SchemaCatalogService } from '../application/schema-catalog-service.js';
1919
import type { SchemaGraphSession } from '../application/schema-graph-session.js';
2020
import type { AppPreferences } from '../application/app-preferences.js';
21+
import type { WorkspaceRepository } from '../workspace/workspace-repository.js';
2122
import type { SavedQueryV2 } from '../generated/json-schema.types.js';
2223
import type { DynamicSources } from '../core/spec-completion.js';
2324
import type { WorkbenchSession } from './workbench/workbench-session.js';
@@ -238,6 +239,12 @@ export interface App {
238239
* `App.savePref` delegate); `toggleTheme`'s preference-write half also
239240
* delegates here, the DOM half stays in app.ts. */
240241
prefs: AppPreferences;
242+
/** Atomic StoredWorkspaceV1 aggregate persistence (#280 Phase 2 / #284),
243+
* behind the injected IndexedDB seam (`env.indexedDB`). Pure/testable — no
244+
* App/AppState/DOM dependency. In this phase it is constructed but the
245+
* favorites-driven Dashboard render still reads legacy keys; Phases 3-6 of
246+
* #280 route reads/commits through it and retire the legacy keys. */
247+
workspace: WorkspaceRepository;
241248
saveJSON(key: string, value: unknown): void;
242249
saveStr(key: string, value: string): void;
243250
/** The one deliberate delegate survivor of #276 Phase 5's params-group
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Concrete IndexedDB adapter behind the injected `WorkspaceStore` seam (#280
2+
// Phase 2 / issue #284). This is the one place a real IndexedDB is touched;
3+
// the repository/migration logic never sees it. The pinned Phase-2 decision:
4+
// persist the StoredWorkspaceV1 aggregate as a SINGLE record and replace it
5+
// atomically via ONE readwrite transaction (IndexedDB was chosen over a single
6+
// localStorage key because a 10 MiB `maxDecodedJsonBytes` aggregate exceeds the
7+
// ~5 MB localStorage origin quota, and because Phase 6's cross-tab token
8+
// handoff needs IndexedDB anyway).
9+
//
10+
// The `IDBFactory` is injected (createApp resolves `env.indexedDB` /
11+
// `win.indexedDB`), so tests drive this with a minimal in-memory fake factory
12+
// instead of a real browser database — the same seam pattern as fetch/crypto.
13+
14+
import type { WorkspaceStore } from './workspace-store.types.js';
15+
16+
export interface IndexedDbWorkspaceStoreOptions {
17+
/** IndexedDB database name. */
18+
dbName?: string;
19+
/** Object-store name inside the database. */
20+
storeName?: string;
21+
/** Fixed key of the single aggregate record. */
22+
recordKey?: string;
23+
}
24+
25+
const DEFAULTS = {
26+
dbName: 'asb-workspace',
27+
storeName: 'workspace',
28+
recordKey: 'current',
29+
} as const;
30+
31+
// Promisify one IDBRequest.
32+
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
33+
return new Promise<T>((resolve, reject) => {
34+
request.onsuccess = () => resolve(request.result);
35+
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
36+
});
37+
}
38+
39+
// Resolve when a readwrite transaction durably completes (the atomicity point).
40+
function transactionDone(tx: IDBTransaction): Promise<void> {
41+
return new Promise<void>((resolve, reject) => {
42+
tx.oncomplete = () => resolve();
43+
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'));
44+
tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted'));
45+
});
46+
}
47+
48+
/** Build a `WorkspaceStore` backed by IndexedDB. The database is opened lazily
49+
* on first use (and the open promise cached), so constructing the store with a
50+
* not-yet-available factory never throws — the failure surfaces only when an
51+
* operation actually runs, where the repository catches it. */
52+
export function createIndexedDbWorkspaceStore(
53+
factory: IDBFactory | undefined, options: IndexedDbWorkspaceStoreOptions = {},
54+
): WorkspaceStore {
55+
const dbName = options.dbName ?? DEFAULTS.dbName;
56+
const storeName = options.storeName ?? DEFAULTS.storeName;
57+
const recordKey = options.recordKey ?? DEFAULTS.recordKey;
58+
let dbPromise: Promise<IDBDatabase> | null = null;
59+
60+
function openDb(): Promise<IDBDatabase> {
61+
if (dbPromise) return dbPromise;
62+
const pending = new Promise<IDBDatabase>((resolve, reject) => {
63+
if (!factory) {
64+
reject(new Error('IndexedDB is unavailable'));
65+
return;
66+
}
67+
const request = factory.open(dbName, 1);
68+
request.onupgradeneeded = () => {
69+
const db = request.result;
70+
if (!db.objectStoreNames.contains(storeName)) db.createObjectStore(storeName);
71+
};
72+
request.onsuccess = () => resolve(request.result);
73+
request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed'));
74+
// Dormant at version 1 (no upgrade can block a fresh open), but an
75+
// unhandled `blocked` event would hang the open forever — reject so the
76+
// no-cache-on-failure path below reopens on the next call.
77+
request.onblocked = () => reject(new Error('IndexedDB open blocked'));
78+
});
79+
// Cache only a SUCCESSFUL open. A rejected open must not poison the store
80+
// for the page's lifetime (one createApp() builds one long-lived store):
81+
// drop the cached promise on failure so a same-session retry can reopen,
82+
// matching the repository's "failed write leaves a draft to retry" contract.
83+
dbPromise = pending;
84+
pending.catch(() => { if (dbPromise === pending) dbPromise = null; });
85+
return pending;
86+
}
87+
88+
async function read(): Promise<string | null> {
89+
const db = await openDb();
90+
const tx = db.transaction([storeName], 'readonly');
91+
const value = await requestResult(tx.objectStore(storeName).get(recordKey));
92+
return typeof value === 'string' ? value : null;
93+
}
94+
95+
async function write(text: string): Promise<void> {
96+
const db = await openDb();
97+
const tx = db.transaction([storeName], 'readwrite');
98+
tx.objectStore(storeName).put(text, recordKey);
99+
await transactionDone(tx);
100+
}
101+
102+
async function clear(): Promise<void> {
103+
const db = await openDb();
104+
const tx = db.transaction([storeName], 'readwrite');
105+
tx.objectStore(storeName).delete(recordKey);
106+
await transactionDone(tx);
107+
}
108+
109+
return { read, write, clear };
110+
}

src/workspace/legacy-migration.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Legacy migration marker (#280 "Legacy migration marker", Phase 2 of #280 /
2+
// issue #284). One-shot conversion of the pre-aggregate flat localStorage
3+
// state into one atomic StoredWorkspaceV1:
4+
//
5+
// 1. read legacy values (the caller supplies them — this module is pure);
6+
// 2. build one candidate StoredWorkspaceV1;
7+
// 3. create the initial Dashboard from legacy favorites (`spec.favorite`);
8+
// 4. convert the legacy layout preferences (asb:dashLayout/asb:dashCols) to a
9+
// valid flow@1 layout;
10+
// 5. validate the WHOLE candidate (via the repository's commit);
11+
// 6. persist it atomically;
12+
// 7. treat a successful aggregate write as migration completion.
13+
//
14+
// The marker is aggregate RECORD EXISTENCE — migration runs only when the
15+
// store holds no record (checked via `store.read()`), never keyed on
16+
// loadCurrent validity, so a present-but-corrupt aggregate is never clobbered
17+
// by a re-run. Legacy keys are NEVER deleted or modified here: Phase 2 still
18+
// serves the favorites-driven UI off them, and #280 forbids touching them
19+
// before the aggregate write succeeds. Removing the legacy reads (and the
20+
// `spec.favorite` dual-write) is the documented Phase 3-5 removal path, not
21+
// this phase.
22+
//
23+
// Pure over injected seams: the query-collection is decoded by the caller, the
24+
// ID generator is injected, and persistence goes through the injected
25+
// WorkspaceStore/WorkspaceRepository — no DOM, no storage, no crypto import.
26+
27+
import { queryFavorite } from '../core/saved-query.js';
28+
import { activeDashboardView } from '../core/dashboard.js';
29+
import { queryDashboardRole } from '../dashboard/model/workspace-semantics.js';
30+
import { CURRENT_STORAGE_VERSION, DEFAULT_WORKSPACE_NAME } from './workspace-operations.js';
31+
import type { WorkspaceIdGen } from './workspace-operations.js';
32+
import type { WorkspaceStore } from './workspace-store.types.js';
33+
import type { WorkspaceRepository, WorkspaceCommitResult } from './workspace-repository.js';
34+
import type { WorkspaceDiagnostic } from '../dashboard/model/workspace-diagnostics.js';
35+
import type {
36+
FlowPresetV1, SavedQueryV2, StoredWorkspaceV1,
37+
} from '../generated/json-schema.types.js';
38+
39+
/** The flat legacy persistence the caller reads out of localStorage before
40+
* migrating: the decoded saved-query collection (asb:saved), the Library name
41+
* (asb:libraryName), and the two Dashboard layout preferences
42+
* (asb:dashLayout/asb:dashCols). */
43+
export interface LegacyWorkspaceInput {
44+
name: string;
45+
queries: readonly SavedQueryV2[];
46+
dashLayout: string;
47+
dashCols: number;
48+
}
49+
50+
/** Map the legacy Dashboard layout preferences to a normative flow@1 preset.
51+
* Reuses the existing `activeDashboardView` derivation (core/dashboard.ts) and
52+
* remaps its `wide` value to the flow preset name `full-width`; `report`,
53+
* `columns-2`, and `columns-3` already match the flow preset names. */
54+
export function legacyLayoutToFlowPreset(dashLayout: string, dashCols: number): FlowPresetV1 {
55+
const view = activeDashboardView({ dashLayout, dashCols });
56+
return view === 'wide' ? 'full-width' : view;
57+
}
58+
59+
/** Build the one candidate StoredWorkspaceV1 from the legacy state (steps 2-4).
60+
* The initial Dashboard's tiles are the PANEL-role favorites in catalog order:
61+
* a favorited filter/setup-role query cannot be a tile by the #280 contract,
62+
* so it is not turned into one (it stays in the query collection). The
63+
* Dashboard is always created so the legacy layout preference is preserved
64+
* even for a user with no favorites yet; it starts at revision 1. `genId` is
65+
* called once for the workspace ID, once for the Dashboard ID, and once per
66+
* tile. The candidate is NOT validated here — the caller validates the whole
67+
* thing through `repository.commit`. */
68+
export function buildLegacyMigrationCandidate(
69+
legacy: LegacyWorkspaceInput, genId: WorkspaceIdGen,
70+
): StoredWorkspaceV1 {
71+
const name = legacy.name.trim() ? legacy.name : DEFAULT_WORKSPACE_NAME;
72+
const queries = [...legacy.queries];
73+
const workspaceId = genId();
74+
const dashboardId = genId();
75+
const tiles = queries
76+
.filter((query) => queryFavorite(query) && queryDashboardRole(query) === 'panel')
77+
.map((query) => ({ id: genId(), queryId: query.id }));
78+
return {
79+
storageVersion: CURRENT_STORAGE_VERSION,
80+
id: workspaceId,
81+
name,
82+
queries,
83+
dashboard: {
84+
documentVersion: 1,
85+
id: dashboardId,
86+
title: name,
87+
revision: 1,
88+
layout: {
89+
type: 'flow',
90+
version: 1,
91+
preset: legacyLayoutToFlowPreset(legacy.dashLayout, legacy.dashCols),
92+
items: {},
93+
},
94+
filters: [],
95+
tiles,
96+
},
97+
};
98+
}
99+
100+
/** The outcome of `migrateLegacyWorkspaceIfNeeded`. `migrated: false` with
101+
* `reason: 'aggregate-exists'` means the marker found a record and skipped;
102+
* `reason: 'commit-failed'` carries the whole-candidate validation or
103+
* persistence diagnostics (legacy keys were left intact). */
104+
export type MigrationResult =
105+
| { migrated: true; result: Extract<WorkspaceCommitResult, { ok: true }> }
106+
| { migrated: false; reason: 'aggregate-exists' }
107+
| { migrated: false; reason: 'commit-failed'; diagnostics: WorkspaceDiagnostic[] };
108+
109+
export interface MigrationDeps {
110+
/** Checked for record existence — the migration marker. */
111+
store: WorkspaceStore;
112+
/** The repository whose atomic `commit` validates + persists the candidate. */
113+
repository: WorkspaceRepository;
114+
legacy: LegacyWorkspaceInput;
115+
genId: WorkspaceIdGen;
116+
}
117+
118+
/** Run the one-shot migration when — and only when — no aggregate record
119+
* exists yet. Idempotent: once the aggregate persists, a later call finds the
120+
* record and returns `aggregate-exists` without rebuilding or rewriting. A
121+
* failed commit leaves the store (and every legacy key) untouched, so a retry
122+
* on the next load is safe. */
123+
export async function migrateLegacyWorkspaceIfNeeded(deps: MigrationDeps): Promise<MigrationResult> {
124+
const { store, repository, legacy, genId } = deps;
125+
const existing = await store.read();
126+
if (existing !== null) return { migrated: false, reason: 'aggregate-exists' };
127+
const candidate = buildLegacyMigrationCandidate(legacy, genId);
128+
const result = await repository.commit(candidate);
129+
if (!result.ok) return { migrated: false, reason: 'commit-failed', diagnostics: result.diagnostics };
130+
return { migrated: true, result };
131+
}

0 commit comments

Comments
 (0)