From ea285d9098e15c5eb6507daab21f0f7f11c764e7 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 19 Jun 2026 16:31:51 -0500 Subject: [PATCH 01/25] Add persistence-status layer design docs (CONTEXT.md, ADR) --- CONTEXT.md | 8 ++++ ...-storage-layer-owns-persistence-failure.md | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 docs/adr/20260619-storage-layer-owns-persistence-failure.md diff --git a/CONTEXT.md b/CONTEXT.md index 2db04e7cf..dc25fa100 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -78,6 +78,14 @@ _Avoid_: Model, structure The on-disk JSON format a user gets when they export a Connection (`saveConfigurationToFile`), and which import consumes. It bundles the connection config with a snapshot of the Schema (`lastUpdate` is an ISO string on disk). A single Zod schema in `parseConnectionFile.ts` is the source of truth: both the writer and the importer target the same inferred type (`ExportedConnectionFile`). The writer assigns a `Date` for `lastUpdate` and `JSON.stringify` serializes it to the ISO string; the parser coerces it back via `z.coerce.date()`. The schema is lenient (every level is a `looseObject`), so unknown and legacy fields — styling, `__inferred`/`__matches` on prefixes, attribute `dataType` — pass through untouched. It is intentionally decoupled from the in-memory configuration and from the IndexedDB storage shape, so the wire format can evolve independently. On import it is split — the connection lands in `configurationAtom`, the schema in `schemaAtom`. _Avoid_: Configuration file (the wire format is not the in-memory or persisted shape) +**Persistence Status**: +The single, global state of whether the app's client-side data is safely written to IndexedDB — `idle | saving | failed`. One source of truth that the save-status indicator subscribes to. `idle` = nothing queued and everything durable (no separate "saved" state — visually identical to a fresh session). `saving` = at least one write queued or in flight, including retryable retries. `failed` = at least one terminal failure outstanding, carrying failure records for a drill-in detail view. Aggregated across all per-key write queues by precedence: any terminal → `failed`; else any in-flight → `saving`; else `idle`. A `failed` key clears on its next successful write; status returns to `idle` when no failure records remain. Deliberately **not** per-key: a failed write flips the whole status rather than naming which collection failed, because the user cannot act on an individual key (the storage layer retries on their behalf). Lives in a plain external store outside React/Jotai; the React edge bridges it via `useSyncExternalStore`. +_Avoid_: Save state (ambiguous with Session) + +**Save-Status Indicator**: +The persistent, Google-Docs-style UI element (corner of the app) that renders Persistence Status — quiet/absent at `idle`, "Saving…" at `saving`, an attention treatment at `failed`. Replaces scattered per-write toasts. Toasts are reserved for terminal, actionable failures only — notably out-of-storage (quota), which prompts a full client-side backup via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`). Recovery scope is retry (transient failures) plus the backup prompt (terminal failures) — it does not guarantee the write eventually lands. +_Avoid_: Save toast, save banner + ## Relationships - Each browser tab has at most one **Active Connection**; different tabs may have different ones diff --git a/docs/adr/20260619-storage-layer-owns-persistence-failure.md b/docs/adr/20260619-storage-layer-owns-persistence-failure.md new file mode 100644 index 000000000..139cf7cf8 --- /dev/null +++ b/docs/adr/20260619-storage-layer-owns-persistence-failure.md @@ -0,0 +1,40 @@ +# ADR — The storage layer owns persistence failure; status lives outside React + +- **Status:** Accepted +- **Date:** 2026-06-19 +- **Related:** ADR `per-key-diff-merge-cross-tab-reconciliation` (the re-read-merge that occupies the same write-path seam this builds on); ADR `indexeddb-not-localstorage-for-persistence` (the substrate, and source of the error taxonomy). Supersedes the interim per-call-site floating-promise convention (ADR `explicit-floating-promise-convention`). Issue #1854. + +## Context + +Graph Explorer stores all user data client-side in IndexedDB via `atomWithLocalForage` (`core/StateProvider/atomWithLocalForage.ts`). The write path updates an in-memory Jotai atom synchronously and flushes to IndexedDB in the background. When the durable write **fails**, nothing recovers: in-memory state silently diverges from disk and is lost on reload. + +The setter currently returns `Promise`, exposing the background write as interface surface. Each of ~36 call sites must then decide how to handle that promise — an implementation detail (the write is async) leaking into every caller. The interim convention (`logAndNotify`/`logAndIgnore`/`void`) made the leak explicit per site, but the per-site model means each caller re-derives failure handling and message vocabulary the storage layer is better positioned to own. + +## Decision + +The **storage layer owns the write, so it owns the failure.** Concretely: + +1. **The persisted setter returns `void` again.** Failure is handled internally; there is no promise for callers to mishandle. This is a deliberately *deep* module — a trivial interface (`set(atom, next)`) hiding substantial machinery (queue, retry, taxonomy, merge, status). +2. **Persistence Status is a single, global, three-state model** — `idle | saving | failed` — aggregated across all per-key write queues (any terminal → `failed`; else any in-flight → `saving`; else `idle`). It is **not** per-key: users cannot act on an individual key, so the status names no key. Failure records behind the `failed` state feed a drill-in detail view. +3. **The status store lives outside React/Jotai** — a plain external store (`subscribe`/`getSnapshot`/`emit`). The React edge bridges it with `useSyncExternalStore`. Humanization of raw `{ key, reason, ... }` records (including any engine-specific vocabulary via `useTranslations`) happens only at that React edge. +4. **Failures are classified by a pure `classifyStorageError` function** — `QuotaExceededError` and security/access errors are terminal; everything else (including unknown `error.name`) is retryable up to a cap, then terminal. Retryable-by-default is the safer error: a transient wrongly called terminal loses recoverable data, while a terminal wrongly retried only wastes a few capped backoff cycles. +5. **A per-key write queue** coalesces rapid successive writes, retries transient failures with backoff, and reports outcomes into the status store. Its `flush` body is the only place that touches IndexedDB. + +### Internal seams (deep ≠ monolithic) + +The depth is hidden behind composition, not crammed into one file: `classifyStorageError` (pure taxonomy) · per-key write queue (coalesce + retry, knows only a `flush` thunk) · the merge-flush (the re-read-diff-write from the cross-tab ADR) · the global status store (write-only from the queue's side). `atomWithLocalForage` composes them. + +## Considered Options + +- **Storage-layer ownership + global status store (chosen).** Smallest caller-facing interface, single source of truth, React-free core. Collapses the per-site model and the engine-vocabulary-outside-hooks problem (the layer stays raw; React humanizes). +- **Keep the per-call-site `.catch`/`logAndNotify` model.** Rejected: re-derives handling and vocabulary at every site; status is scattered, not a single source of truth. +- **Status as a Jotai atom.** Rejected: every persisted atom would spawn status wiring that must aggregate to one global value, and it would join the fragile top-level-`await` preload dance in `storageAtoms.ts`. A plain store has no mount ordering and is the correct primitive for an external mutable source. +- **Per-key status (name which collection failed).** Rejected: not actionable for the user (the layer retries on their behalf); adds the engine-specific vocabulary burden to the core; the detail view covers the diagnostic need. + +## Consequences + +- **The ~36 call sites lose their floating-promise handling** — the interim convention is superseded, and that migration is expected to be largely undone. Net caller code shrinks. +- **Tests can no longer await a per-write promise.** They synchronize on a layer-level `waitForPersistenceIdle()` derived from the status store — a better signal that survives coalescing/retry and exercises the production status path. +- **Status is strictly per-tab.** Consistent with the substrate ADR's "no cross-tab sync primitives," a failed write in one tab shows `failed` there regardless of other tabs, and clears only when that tab itself successfully flushes the key. Honest about whose edit is at risk. +- **Recovery is retry + backup prompt, not a write guarantee.** Terminal quota toasts point at the full backup (`saveLocalForageToFile`), which is read-mostly and so remains viable under quota pressure. We do **not** block reload (`beforeunload`). +- **This effort and the cross-tab merge are orthogonal**, meeting only at the `flush` seam — either can ship first without blocking the other. From 256c2176858ac0248f70a2429b3bab3ac0153994 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 19 Jun 2026 16:54:03 -0500 Subject: [PATCH 02/25] Add persistence-status core: error taxonomy, status store, write queue --- .../persistence/classifyStorageError.test.ts | 38 ++++++ .../persistence/classifyStorageError.ts | 40 ++++++ .../persistenceStatusStore.test.ts | 106 ++++++++++++++++ .../persistence/persistenceStatusStore.ts | 108 ++++++++++++++++ .../persistence/writeQueue.test.ts | 116 ++++++++++++++++++ .../StateProvider/persistence/writeQueue.ts | 104 ++++++++++++++++ 6 files changed, 512 insertions(+) create mode 100644 packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.test.ts create mode 100644 packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.ts create mode 100644 packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts create mode 100644 packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts create mode 100644 packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts create mode 100644 packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.test.ts b/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.test.ts new file mode 100644 index 000000000..930775de9 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "vitest"; + +import { classifyStorageError } from "./classifyStorageError"; + +function domException(name: string) { + return new DOMException("boom", name); +} + +describe("classifyStorageError", () => { + test("classifies a quota-exceeded error as terminal-quota", () => { + expect(classifyStorageError(domException("QuotaExceededError"))).toBe( + "terminal-quota", + ); + }); + + test.each(["SecurityError", "InvalidStateError"])( + "classifies %s as terminal-access", + name => { + expect(classifyStorageError(domException(name))).toBe("terminal-access"); + }, + ); + + test.each(["AbortError", "UnknownError", "TimeoutError"])( + "classifies the transient error %s as retryable", + name => { + expect(classifyStorageError(domException(name))).toBe("retryable"); + }, + ); + + test("defaults an unrecognized error to retryable", () => { + expect(classifyStorageError(new Error("something odd"))).toBe("retryable"); + }); + + test("defaults a non-error value to retryable", () => { + expect(classifyStorageError("not an error")).toBe("retryable"); + expect(classifyStorageError(undefined)).toBe("retryable"); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.ts b/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.ts new file mode 100644 index 000000000..5b9050253 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.ts @@ -0,0 +1,40 @@ +/** + * How a failed IndexedDB write should be handled. + * + * - `terminal-quota` — the store is full. Retrying writes the same bytes, so it + * cannot succeed; the user must free space or back up their data. + * - `terminal-access` — IndexedDB is unavailable (private-browsing restriction, + * blocked by policy). The database never opens, so retrying is futile. + * - `retryable` — a transient failure (transaction conflict, I/O hiccup, or an + * error we cannot positively identify). Worth retrying with backoff. + */ +export type StorageErrorClassification = + | "terminal-quota" + | "terminal-access" + | "retryable"; + +function errorName(error: unknown): string { + return error instanceof Error ? error.name : ""; +} + +/** + * Classifies an IndexedDB write failure so the write queue can decide whether to + * retry or surface it as terminal. + * + * Unknown errors default to `retryable`: a transient failure wrongly treated as + * terminal loses recoverable data, whereas a terminal failure wrongly retried + * only wastes a few capped backoff cycles before escalating anyway. + */ +export function classifyStorageError( + error: unknown, +): StorageErrorClassification { + switch (errorName(error)) { + case "QuotaExceededError": + return "terminal-quota"; + case "SecurityError": + case "InvalidStateError": + return "terminal-access"; + default: + return "retryable"; + } +} diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts new file mode 100644 index 000000000..1f88cc8c8 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createPersistenceStatusStore } from "./persistenceStatusStore"; + +describe("persistenceStatusStore", () => { + test("starts idle with no failures", () => { + const store = createPersistenceStatusStore(); + + expect(store.getSnapshot()).toStrictEqual({ status: "idle", failures: [] }); + }); + + test("reports saving while a key is in flight", () => { + const store = createPersistenceStatusStore(); + + store.markSaving("configuration"); + + expect(store.getSnapshot().status).toBe("saving"); + }); + + test("returns to idle once an in-flight key is saved", () => { + const store = createPersistenceStatusStore(); + + store.markSaving("configuration"); + store.markSaved("configuration"); + + expect(store.getSnapshot().status).toBe("idle"); + }); + + test("a terminal failure takes precedence over other in-flight writes", () => { + const store = createPersistenceStatusStore(); + + store.markSaving("schema"); + store.markFailed("configuration", "terminal-quota"); + + const snapshot = store.getSnapshot(); + expect(snapshot.status).toBe("failed"); + expect(snapshot.failures).toStrictEqual([ + { key: "configuration", reason: "terminal-quota" }, + ]); + }); + + test("a failed key clears on its next successful write", () => { + const store = createPersistenceStatusStore(); + + store.markFailed("user-styling", "terminal-quota"); + store.markSaved("user-styling"); + + expect(store.getSnapshot()).toStrictEqual({ + status: "idle", + failures: [], + }); + }); + + test("stays failed while another key still has an outstanding failure", () => { + const store = createPersistenceStatusStore(); + + store.markFailed("schema", "terminal-access"); + store.markFailed("user-styling", "terminal-quota"); + store.markSaved("schema"); + + const snapshot = store.getSnapshot(); + expect(snapshot.status).toBe("failed"); + expect(snapshot.failures).toStrictEqual([ + { key: "user-styling", reason: "terminal-quota" }, + ]); + }); + + test("notifies subscribers on change and stops after unsubscribe", () => { + const store = createPersistenceStatusStore(); + const listener = vi.fn(); + + const unsubscribe = store.subscribe(listener); + store.markSaving("configuration"); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + store.markSaved("configuration"); + expect(listener).toHaveBeenCalledTimes(1); + }); + + test("waitForIdle resolves once outstanding writes drain", async () => { + const store = createPersistenceStatusStore(); + store.markSaving("configuration"); + + const idle = store.waitForIdle(); + store.markSaved("configuration"); + + await expect(idle).resolves.toBeUndefined(); + }); + + test("waitForIdle resolves immediately when already idle", async () => { + const store = createPersistenceStatusStore(); + + await expect(store.waitForIdle()).resolves.toBeUndefined(); + }); + + test("waitForIdle resolves once a failure is the only thing outstanding", async () => { + const store = createPersistenceStatusStore(); + store.markSaving("schema"); + + const idle = store.waitForIdle(); + store.markFailed("schema", "terminal-quota"); + + await expect(idle).resolves.toBeUndefined(); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts new file mode 100644 index 000000000..9a04ae474 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts @@ -0,0 +1,108 @@ +import type { StorageErrorClassification } from "./classifyStorageError"; + +/** + * The single, global state of whether the app's client-side data is safely + * written to IndexedDB. + * + * - `idle` — nothing queued and everything durable (also the fresh-session + * state; there is deliberately no separate "saved"). + * - `saving` — at least one write is queued or in flight, including retries. + * - `failed` — at least one terminal failure is outstanding. + */ +export type PersistenceStatus = "idle" | "saving" | "failed"; + +/** A terminal write failure, surfaced for the drill-in detail view. */ +export interface PersistenceFailure { + key: string; + reason: StorageErrorClassification; +} + +export interface PersistenceStatusSnapshot { + status: PersistenceStatus; + failures: PersistenceFailure[]; +} + +export interface PersistenceStatusStore { + subscribe(listener: () => void): () => void; + getSnapshot(): PersistenceStatusSnapshot; + /** A write for `key` has started or is being retried. */ + markSaving(key: string): void; + /** A write for `key` landed durably; clears any prior failure for that key. */ + markSaved(key: string): void; + /** A write for `key` failed terminally and will not be retried. */ + markFailed(key: string, reason: StorageErrorClassification): void; + /** + * Resolves once no write is in flight — i.e. status has settled to `idle` or + * `failed`. The test seam that replaces awaiting a per-write promise. + */ + waitForIdle(): Promise; +} + +/** + * Creates a persistence-status store: a plain external store (outside + * React/Jotai) that aggregates per-key write outcomes into one global status. + * + * Aggregation precedence: any terminal failure → `failed`; else any in-flight + * key → `saving`; else `idle`. + */ +export function createPersistenceStatusStore(): PersistenceStatusStore { + const inFlightKeys = new Set(); + const failuresByKey = new Map(); + const listeners = new Set<() => void>(); + + let snapshot: PersistenceStatusSnapshot = { status: "idle", failures: [] }; + + function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + } + + function recompute() { + const status: PersistenceStatus = failuresByKey.size + ? "failed" + : inFlightKeys.size + ? "saving" + : "idle"; + const failures = [...failuresByKey].map(([key, reason]) => ({ + key, + reason, + })); + + snapshot = { status, failures }; + listeners.forEach(listener => listener()); + } + + return { + subscribe, + getSnapshot() { + return snapshot; + }, + markSaving(key) { + inFlightKeys.add(key); + recompute(); + }, + markSaved(key) { + inFlightKeys.delete(key); + failuresByKey.delete(key); + recompute(); + }, + markFailed(key, reason) { + inFlightKeys.delete(key); + failuresByKey.set(key, reason); + recompute(); + }, + waitForIdle() { + if (snapshot.status !== "saving") { + return Promise.resolve(); + } + return new Promise(resolve => { + const unsubscribe = subscribe(() => { + if (snapshot.status !== "saving") { + unsubscribe(); + resolve(); + } + }); + }); + }, + }; +} diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts new file mode 100644 index 000000000..b69da2109 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createPersistenceStatusStore } from "./persistenceStatusStore"; +import { createWriteQueue } from "./writeQueue"; + +function quotaError() { + return new DOMException("full", "QuotaExceededError"); +} + +function noDelay() { + return Promise.resolve(); +} + +describe("createWriteQueue", () => { + test("flushes a single enqueued write and settles to idle", async () => { + const store = createPersistenceStatusStore(); + const queue = createWriteQueue({ store, delay: noDelay }); + const flush = vi.fn().mockResolvedValue(undefined); + + queue.enqueue("configuration", flush); + await store.waitForIdle(); + + expect(flush).toHaveBeenCalledTimes(1); + expect(store.getSnapshot().status).toBe("idle"); + }); + + test("coalesces writes enqueued while one is in flight to only the latest", async () => { + const store = createPersistenceStatusStore(); + const queue = createWriteQueue({ store, delay: noDelay }); + + let releaseFirst!: () => void; + const first = vi.fn( + () => new Promise(resolve => (releaseFirst = resolve)), + ); + const superseded = vi.fn().mockResolvedValue(undefined); + const latest = vi.fn().mockResolvedValue(undefined); + + queue.enqueue("user-styling", first); + // Both arrive while `first` is still in flight; only `latest` should run. + queue.enqueue("user-styling", superseded); + queue.enqueue("user-styling", latest); + releaseFirst(); + await store.waitForIdle(); + + expect(first).toHaveBeenCalledTimes(1); + expect(superseded).not.toHaveBeenCalled(); + expect(latest).toHaveBeenCalledTimes(1); + }); + + test("retries a transient failure and settles to idle once it succeeds", async () => { + const store = createPersistenceStatusStore(); + const delay = vi.fn(noDelay); + const queue = createWriteQueue({ store, delay }); + const flush = vi + .fn() + .mockRejectedValueOnce(new DOMException("conflict", "AbortError")) + .mockResolvedValueOnce(undefined); + + queue.enqueue("schema", flush); + await store.waitForIdle(); + + expect(flush).toHaveBeenCalledTimes(2); + expect(delay).toHaveBeenCalledTimes(1); + expect(store.getSnapshot().status).toBe("idle"); + }); + + test("does not retry a terminal quota failure", async () => { + const store = createPersistenceStatusStore(); + const delay = vi.fn(noDelay); + const queue = createWriteQueue({ store, delay }); + const flush = vi.fn().mockRejectedValue(quotaError()); + + queue.enqueue("graph-sessions", flush); + await store.waitForIdle(); + + expect(flush).toHaveBeenCalledTimes(1); + expect(delay).not.toHaveBeenCalled(); + expect(store.getSnapshot()).toStrictEqual({ + status: "failed", + failures: [{ key: "graph-sessions", reason: "terminal-quota" }], + }); + }); + + test("escalates a persistently transient failure to terminal after the cap", async () => { + const store = createPersistenceStatusStore(); + const queue = createWriteQueue({ store, delay: noDelay }); + const flush = vi + .fn() + .mockRejectedValue(new DOMException("hiccup", "UnknownError")); + + queue.enqueue("configuration", flush); + await store.waitForIdle(); + + expect(flush).toHaveBeenCalledTimes(3); + expect(store.getSnapshot()).toStrictEqual({ + status: "failed", + failures: [{ key: "configuration", reason: "retryable" }], + }); + }); + + test("runs writes for different keys independently", async () => { + const store = createPersistenceStatusStore(); + const queue = createWriteQueue({ store, delay: noDelay }); + const configFlush = vi.fn().mockResolvedValue(undefined); + const schemaFlush = vi.fn().mockRejectedValue(quotaError()); + + queue.enqueue("configuration", configFlush); + queue.enqueue("schema", schemaFlush); + await store.waitForIdle(); + + expect(configFlush).toHaveBeenCalledTimes(1); + expect(store.getSnapshot().failures).toStrictEqual([ + { key: "schema", reason: "terminal-quota" }, + ]); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts new file mode 100644 index 000000000..ce6606092 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts @@ -0,0 +1,104 @@ +import type { PersistenceStatusStore } from "./persistenceStatusStore"; + +import { + classifyStorageError, + type StorageErrorClassification, +} from "./classifyStorageError"; + +/** Persists the latest value for a key to the backing store. */ +export type Flush = () => Promise; + +const DEFAULT_MAX_ATTEMPTS = 3; +const DEFAULT_BASE_DELAY_MS = 100; + +/** Exponential backoff: 100ms, 200ms, 400ms, … */ +function exponentialBackoff(attempt: number) { + return new Promise(resolve => + setTimeout(resolve, DEFAULT_BASE_DELAY_MS * 2 ** attempt), + ); +} + +export interface WriteQueueConfig { + store: PersistenceStatusStore; + /** Backoff before retry `attempt` (0-based). Injectable for tests. */ + delay?: (attempt: number) => Promise; + /** Total attempts before a retryable failure escalates to terminal. */ + maxAttempts?: number; +} + +export interface WriteQueue { + /** + * Schedules a write for `key`. Returns immediately. Writes to the same key + * are serialized; a write enqueued while one is in flight replaces any other + * pending write for that key (coalesce-to-latest), so only the newest value + * is persisted. + */ + enqueue(key: string, flush: Flush): void; +} + +/** + * Serializes, coalesces, and retries IndexedDB writes per key, reporting + * outcomes to the persistence-status store. + * + * It knows nothing about IndexedDB, Jotai, or the value being written — only a + * `flush` thunk. That keeps the cross-tab merge (a separate concern) orthogonal: + * it lives inside whatever `flush` the caller supplies. + */ +export function createWriteQueue({ + store, + delay = exponentialBackoff, + maxAttempts = DEFAULT_MAX_ATTEMPTS, +}: WriteQueueConfig): WriteQueue { + // The write currently running for a key, plus the next flush waiting to run. + const running = new Map>(); + const pending = new Map(); + + /** Runs one flush with retry, returning the terminal failure if it gives up. */ + async function runWithRetry( + flush: Flush, + ): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + await flush(); + return null; + } catch (error) { + const classification = classifyStorageError(error); + const isLastAttempt = attempt === maxAttempts - 1; + if (classification !== "retryable" || isLastAttempt) { + return classification; + } + await delay(attempt); + } + } + return null; + } + + async function drain(key: string) { + store.markSaving(key); + let failure: StorageErrorClassification | null = null; + + let next = pending.get(key); + while (next) { + pending.delete(key); + failure = await runWithRetry(next); + next = pending.get(key); + } + + running.delete(key); + if (failure) { + store.markFailed(key, failure); + } else { + store.markSaved(key); + } + } + + return { + enqueue(key, flush) { + // Replace any not-yet-started write so only the latest value lands. + pending.set(key, flush); + if (!running.has(key)) { + running.set(key, drain(key)); + } + }, + }; +} From 4151acb04179d2d53709e87c601ceb062ec8118a Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 19 Jun 2026 17:00:59 -0500 Subject: [PATCH 03/25] Route IndexedDB writes through the persistence queue; setter returns void --- .../activeConnectionStorage.test.ts | 19 ++++++---- .../StateProvider/activeConnectionStorage.ts | 12 ++++-- .../StateProvider/atomWithLocalForage.test.ts | 37 +++++-------------- .../core/StateProvider/atomWithLocalForage.ts | 3 +- .../core/StateProvider/persistence/index.ts | 26 +++++++++++++ .../core/StateProvider/writeThroughAtom.ts | 18 ++++----- .../src/utils/testing/persistence.test.ts | 24 +++++++----- .../src/utils/testing/persistence.ts | 19 +++------- 8 files changed, 85 insertions(+), 73 deletions(-) create mode 100644 packages/graph-explorer/src/core/StateProvider/persistence/index.ts diff --git a/packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.test.ts b/packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.test.ts index 47fd36fc8..f5c332f65 100644 --- a/packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.test.ts @@ -9,6 +9,7 @@ import { ACTIVE_CONNECTION_STORAGE_KEY, createActiveConfigurationAtom, } from "./activeConnectionStorage"; +import { persistenceStatusStore } from "./persistence"; import { createInMemorySessionStorage } from "./safeSessionStorage"; /** @@ -26,8 +27,10 @@ async function openTab() { return { read: () => store.get(atom), /** Activates a connection; resolves once the breadcrumb has landed. */ - activate: (id: ReturnType | null) => - store.set(atom, id), + activate: (id: ReturnType | null) => { + store.set(atom, id); + return persistenceStatusStore.waitForIdle(); + }, /** Reloads this tab: a fresh store and atom over the same sessionStorage. */ reload: async () => { store = createStore(); @@ -83,15 +86,15 @@ describe("activeConnectionStorage", () => { const store = createStore(); const activated = createNewConfigurationId(); - // The in-memory value and sessionStorage update synchronously; the write - // returns the breadcrumb persistence promise to await without timeouts. - const persisted = store.set(atom, activated); + // The in-memory value and sessionStorage update synchronously; the + // breadcrumb lands in the background through the shared write queue. + store.set(atom, activated); expect(store.get(atom)).toBe(activated); expect(sessionStorage.getItem(ACTIVE_CONNECTION_STORAGE_KEY)).toBe( activated, ); - await persisted; + await persistenceStatusStore.waitForIdle(); expect(await localForage.getItem(ACTIVE_CONNECTION_STORAGE_KEY)).toBe( activated, ); @@ -105,11 +108,11 @@ describe("activeConnectionStorage", () => { const atom = await createActiveConfigurationAtom({ sessionStorage }); const store = createStore(); - const persisted = store.set(atom, null); + store.set(atom, null); expect(store.get(atom)).toBeNull(); expect(sessionStorage.getItem(ACTIVE_CONNECTION_STORAGE_KEY)).toBeNull(); - await persisted; + await persistenceStatusStore.waitForIdle(); expect(await localForage.getItem(ACTIVE_CONNECTION_STORAGE_KEY)).toBeNull(); }); diff --git a/packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.ts b/packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.ts index 3305bff00..520c3c537 100644 --- a/packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.ts +++ b/packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.ts @@ -2,6 +2,7 @@ import localForage from "localforage"; import type { ConfigurationId } from "../ConfigurationProvider"; +import { persistThroughQueue } from "./persistence"; import { resolveSessionStorage } from "./safeSessionStorage"; import { createWriteThroughAtom } from "./writeThroughAtom"; @@ -54,15 +55,18 @@ export async function createActiveConfigurationAtom({ return createWriteThroughAtom( seedValue, - // sessionStorage updates synchronously; the returned promise tracks the - // breadcrumb landing in localForage. - async nextValue => { + // The per-tab sessionStorage value updates synchronously; the shared + // localForage breadcrumb is persisted through the queue so its outcome + // joins the global persistence status like any other IndexedDB write. + nextValue => { if (nextValue === null) { sessionStorage.removeItem(ACTIVE_CONNECTION_STORAGE_KEY); } else { sessionStorage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, nextValue); } - await localForage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, nextValue); + persistThroughQueue(ACTIVE_CONNECTION_STORAGE_KEY, async () => { + await localForage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, nextValue); + }); }, "activeConfigurationAtom", ); diff --git a/packages/graph-explorer/src/core/StateProvider/atomWithLocalForage.test.ts b/packages/graph-explorer/src/core/StateProvider/atomWithLocalForage.test.ts index 9765cdb5c..8fd717d43 100644 --- a/packages/graph-explorer/src/core/StateProvider/atomWithLocalForage.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/atomWithLocalForage.test.ts @@ -3,6 +3,7 @@ import localforage from "localforage"; import { beforeEach, describe, expect, test } from "vitest"; import { atomWithLocalForage } from "./atomWithLocalForage"; +import { persistenceStatusStore } from "./persistence"; describe("atomWithLocalForage", () => { let store: ReturnType; @@ -24,36 +25,20 @@ describe("atomWithLocalForage", () => { const atom = await atomWithLocalForage(key, "initial"); store.set(atom, "new-value"); - - // Wait a bit for async persistence - await new Promise(resolve => setTimeout(resolve, 10)); + await persistenceStatusStore.waitForIdle(); const stored = await localforage.getItem(key); expect(stored).toBe("new-value"); }); - test("should return the persistence promise from a write", async () => { - const key = "test-awaitable-write"; - const atom = await atomWithLocalForage(key, "initial"); - - // The in-memory value updates synchronously; the write returns the - // background persistence promise so callers can wait for the value to land - // in storage without relying on arbitrary timeouts. - const persisted = store.set(atom, "new-value"); - expect(persisted).toBeInstanceOf(Promise); - await persisted; - - const stored = await localforage.getItem(key); - expect(stored).toBe("new-value"); - }); + test("should report persistence status while a write lands", async () => { + const atom = await atomWithLocalForage("test-status", "initial"); - test("should return a resolved promise when a write is a no-op", async () => { - const atom = await atomWithLocalForage("test-no-op", "initial"); + store.set(atom, "new-value"); + expect(persistenceStatusStore.getSnapshot().status).toBe("saving"); - // Writing the same value short-circuits, but callers can still await the - // result uniformly. - const persisted = store.set(atom, "initial"); - await expect(persisted).resolves.toBeUndefined(); + await persistenceStatusStore.waitForIdle(); + expect(persistenceStatusStore.getSnapshot().status).toBe("idle"); }); test("should handle function updates", async () => { @@ -111,8 +96,7 @@ describe("atomWithLocalForage", () => { expect(store.get(atom)).toBe(complexObject); - // Wait for async persistence - await new Promise(resolve => setTimeout(resolve, 10)); + await persistenceStatusStore.waitForIdle(); const stored = await localforage.getItem(key); expect(stored).toEqual(complexObject); @@ -145,8 +129,7 @@ describe("atomWithLocalForage", () => { expect(store.get(atom)).toBe(3); - // Wait for async persistence - await new Promise(resolve => setTimeout(resolve, 10)); + await persistenceStatusStore.waitForIdle(); const stored = await localforage.getItem(key); expect(stored).toBe(3); diff --git a/packages/graph-explorer/src/core/StateProvider/atomWithLocalForage.ts b/packages/graph-explorer/src/core/StateProvider/atomWithLocalForage.ts index 24733f327..02bf04f28 100644 --- a/packages/graph-explorer/src/core/StateProvider/atomWithLocalForage.ts +++ b/packages/graph-explorer/src/core/StateProvider/atomWithLocalForage.ts @@ -1,5 +1,6 @@ import localForage from "localforage"; +import { persistThroughQueue } from "./persistence"; import { createWriteThroughAtom } from "./writeThroughAtom"; localForage.config({ @@ -44,7 +45,7 @@ export async function atomWithLocalForage(key: string, initialValue: T) { return createWriteThroughAtom( preloadValue, - nextValue => storage.setItem(nextValue), + nextValue => persistThroughQueue(key, () => storage.setItem(nextValue)), `atomWithLocalForage(${key})`, ); } diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/index.ts b/packages/graph-explorer/src/core/StateProvider/persistence/index.ts new file mode 100644 index 000000000..4aa4031f3 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/index.ts @@ -0,0 +1,26 @@ +import { createPersistenceStatusStore } from "./persistenceStatusStore"; +import { createWriteQueue, type Flush } from "./writeQueue"; + +export type { + PersistenceStatus, + PersistenceStatusSnapshot, + PersistenceFailure, +} from "./persistenceStatusStore"; + +/** + * The app-wide persistence-status store. A single global instance because the + * Save-Status Indicator shows one aggregated status for the whole app. Tests + * construct isolated stores via `createPersistenceStatusStore` instead. + */ +export const persistenceStatusStore = createPersistenceStatusStore(); + +const writeQueue = createWriteQueue({ store: persistenceStatusStore }); + +/** + * Schedules an IndexedDB write for `key` through the shared retry/coalesce queue + * so its outcome flows into the global persistence status. Returns immediately; + * the queue owns retries and failure reporting. + */ +export function persistThroughQueue(key: string, flush: Flush): void { + writeQueue.enqueue(key, flush); +} diff --git a/packages/graph-explorer/src/core/StateProvider/writeThroughAtom.ts b/packages/graph-explorer/src/core/StateProvider/writeThroughAtom.ts index 48ea5795c..b6c397f40 100644 --- a/packages/graph-explorer/src/core/StateProvider/writeThroughAtom.ts +++ b/packages/graph-explorer/src/core/StateProvider/writeThroughAtom.ts @@ -11,26 +11,24 @@ export type SetStateAction = Value | ((prev: Value) => Value); * update the cache, then persist. Callers supply how seeding and persistence * map to their backing store (localForage, sessionStorage, etc.). * - * The in-memory value updates synchronously; the write returns the background - * persistence promise so callers can await the value landing in storage when - * they need to (the app does not, but tests do). It is never awaited in - * production. + * The in-memory value updates synchronously and the setter returns `void`: + * persistence happens in the background and the caller owns its outcome (the + * `persist` callback must not let its work float — it handles its own failures). * * @param seed The initial value, already loaded from the backing store. - * @param persist Writes a changed value to the backing store, returning a - * promise that resolves once it has landed. + * @param persist Writes a changed value to the backing store in the background. * @param debugLabel Label surfaced in Jotai devtools. */ export function createWriteThroughAtom( seed: Value, - persist: (value: Value) => Promise, + persist: (value: Value) => void, debugLabel: string, ) { const baseAtom = atom(seed); const derivedAtom = atom( get => get(baseAtom), - (get, set, update: SetStateAction): Promise => { + (get, set, update: SetStateAction): void => { const prevValue = get(baseAtom); const nextValue = typeof update === "function" @@ -38,11 +36,11 @@ export function createWriteThroughAtom( : update; if (prevValue === nextValue) { - return Promise.resolve(); + return; } set(baseAtom, nextValue); - return persist(nextValue); + persist(nextValue); }, ); diff --git a/packages/graph-explorer/src/utils/testing/persistence.test.ts b/packages/graph-explorer/src/utils/testing/persistence.test.ts index 6c21d7073..38fc46aad 100644 --- a/packages/graph-explorer/src/utils/testing/persistence.test.ts +++ b/packages/graph-explorer/src/utils/testing/persistence.test.ts @@ -14,7 +14,7 @@ describe("persistence test helpers", () => { const key = createRandomName("key"); const tab = await openPersistenceTab(key, "initial"); - await tab.write("updated"); + tab.write("updated"); expect(tab.read()).toBe("updated"); }); @@ -29,13 +29,12 @@ describe("persistence test helpers", () => { expect(await readPersistedValue(key)).toBe("updated"); }); - test("flush waits for every write, not just the most recent", async () => { + test("flush waits for rapid successive writes to settle", async () => { const key = createRandomName("key"); const tab = await openPersistenceTab(key, "initial"); - // Fire several writes without awaiting each, then flush once. flush() must - // wait for all of them; tracking only the latest would let earlier writes - // be unobserved. + // Fire several writes without awaiting each, then flush once. The queue + // coalesces them, so only the latest value lands. tab.write("first"); tab.write("second"); tab.write("third"); @@ -47,7 +46,8 @@ describe("persistence test helpers", () => { test("a tab opened later preloads what an earlier tab persisted", async () => { const key = createRandomName("key"); const firstTab = await openPersistenceTab(key, "initial"); - await firstTab.write("from-first-tab"); + firstTab.write("from-first-tab"); + await firstTab.flush(); const secondTab = await openPersistenceTab(key, "initial"); @@ -59,7 +59,8 @@ describe("persistence test helpers", () => { const tabA = await openPersistenceTab(key, "initial"); const tabB = await openPersistenceTab(key, "initial"); - await tabA.write("written-by-a"); + tabA.write("written-by-a"); + await tabA.flush(); // Tab B holds its own stale in-memory copy until it reloads, but the shared // storage already reflects Tab A's write. @@ -86,7 +87,8 @@ describe("per-test storage isolation", () => { test("first test persists a value under the shared key", async () => { const tab = await openPersistenceTab(sharedKey, "initial"); - await tab.write("written-by-first-test"); + tab.write("written-by-first-test"); + await tab.flush(); expect(await readPersistedValue(sharedKey)).toBe("written-by-first-test"); }); @@ -145,13 +147,15 @@ describe("cross-tab user styling reconciliation", () => { const tabB = await openPersistenceTab(key, {}); // Tab A styles type X and persists. - await tabA.write({ vertices: [styleForType(typeX)] }); + tabA.write({ vertices: [styleForType(typeX)] }); + await tabA.flush(); // Tab B, still holding its stale empty copy, styles type Y and persists. - await tabB.write(prev => ({ + tabB.write(prev => ({ vertices: [...(prev.vertices ?? []), styleForType(typeY)], })); + await tabB.flush(); const persisted = await readPersistedValue(key); persistedTypes = new Set(persisted?.vertices?.map(vertex => vertex.type)); }); diff --git a/packages/graph-explorer/src/utils/testing/persistence.ts b/packages/graph-explorer/src/utils/testing/persistence.ts index e28d4a889..2353845fb 100644 --- a/packages/graph-explorer/src/utils/testing/persistence.ts +++ b/packages/graph-explorer/src/utils/testing/persistence.ts @@ -4,6 +4,7 @@ import localforage from "localforage"; import type { AppStore } from "@/core"; import { atomWithLocalForage } from "@/core/StateProvider/atomWithLocalForage"; +import { persistenceStatusStore } from "@/core/StateProvider/persistence"; type SetStateAction = Value | ((prev: Value) => Value); @@ -22,7 +23,6 @@ type SetStateAction = Value | ((prev: Value) => Value); export class PersistenceTab { #store: AppStore; #atom: Awaited>>; - #pendingWrite: Promise = Promise.resolve(); constructor( store: AppStore, @@ -39,25 +39,18 @@ export class PersistenceTab { /** * Writes a new value to this tab. The in-memory value updates synchronously; - * the returned promise resolves once this write has been persisted to storage. + * persistence happens in the background through the shared write queue. */ - write(update: SetStateAction): Promise { - // store.set updates the in-memory value synchronously and returns the - // background persistence promise for THIS write. - const persisted = this.#store.set(this.#atom, update); - // Accumulate every write so flush() waits for all of them, not just the - // latest. Replacing #pendingWrite here would drop earlier writes that have - // not yet landed, leaving flush() to await an incomplete set. - this.#pendingWrite = this.#pendingWrite.then(() => persisted); - return persisted; + write(update: SetStateAction): void { + this.#store.set(this.#atom, update); } /** - * Waits for all of this tab's background writes to land in storage, letting + * Waits for all outstanding background writes to land in storage, letting * tests assert against persisted state without arbitrary timeouts. */ flush(): Promise { - return this.#pendingWrite; + return persistenceStatusStore.waitForIdle(); } } From 7b48ebecec64a9288b5dd8eda801f4d8d414d660 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 19 Jun 2026 17:08:47 -0500 Subject: [PATCH 04/25] Add save-status indicator wired to the persistence status store --- ...-storage-layer-owns-persistence-failure.md | 2 +- .../SaveStatusIndicator.test.tsx | 69 +++++++++++++++++ .../SaveStatusIndicator.tsx | 60 +++++++++++++++ .../persistence/usePersistenceStatus.ts | 14 ++++ .../src/core/StateProvider/userLayout.test.ts | 36 ++++----- .../StateProvider/userPreferences.test.ts | 76 +++++++++---------- .../src/routes/DefaultLayout.tsx | 4 + 7 files changed, 201 insertions(+), 60 deletions(-) create mode 100644 packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx create mode 100644 packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx create mode 100644 packages/graph-explorer/src/core/StateProvider/persistence/usePersistenceStatus.ts diff --git a/docs/adr/20260619-storage-layer-owns-persistence-failure.md b/docs/adr/20260619-storage-layer-owns-persistence-failure.md index 139cf7cf8..753bc1d6d 100644 --- a/docs/adr/20260619-storage-layer-owns-persistence-failure.md +++ b/docs/adr/20260619-storage-layer-owns-persistence-failure.md @@ -14,7 +14,7 @@ The setter currently returns `Promise`, exposing the background write as i The **storage layer owns the write, so it owns the failure.** Concretely: -1. **The persisted setter returns `void` again.** Failure is handled internally; there is no promise for callers to mishandle. This is a deliberately *deep* module — a trivial interface (`set(atom, next)`) hiding substantial machinery (queue, retry, taxonomy, merge, status). +1. **The persisted setter returns `void` again.** Failure is handled internally; there is no promise for callers to mishandle. This is a deliberately _deep_ module — a trivial interface (`set(atom, next)`) hiding substantial machinery (queue, retry, taxonomy, merge, status). 2. **Persistence Status is a single, global, three-state model** — `idle | saving | failed` — aggregated across all per-key write queues (any terminal → `failed`; else any in-flight → `saving`; else `idle`). It is **not** per-key: users cannot act on an individual key, so the status names no key. Failure records behind the `failed` state feed a drill-in detail view. 3. **The status store lives outside React/Jotai** — a plain external store (`subscribe`/`getSnapshot`/`emit`). The React edge bridges it with `useSyncExternalStore`. Humanization of raw `{ key, reason, ... }` records (including any engine-specific vocabulary via `useTranslations`) happens only at that React edge. 4. **Failures are classified by a pure `classifyStorageError` function** — `QuotaExceededError` and security/access errors are terminal; everything else (including unknown `error.name`) is retryable up to a cap, then terminal. Retryable-by-default is the safer error: a transient wrongly called terminal loses recoverable data, while a terminal wrongly retried only wastes a few capped backoff cycles. diff --git a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx new file mode 100644 index 000000000..713bf6714 --- /dev/null +++ b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom +import { act, render, screen } from "@testing-library/react"; +import { toast } from "sonner"; +import { afterEach, describe, expect, test } from "vitest"; + +import { persistenceStatusStore } from "@/core/StateProvider/persistence"; + +import { SaveStatusIndicator } from "./SaveStatusIndicator"; + +// The indicator reads the app-wide singleton store. Return it to idle between +// tests by marking every key this suite touches as saved, which clears both +// in-flight and failed state for that key. +const KEYS_UNDER_TEST = ["configuration", "schema", "graph-sessions"]; +afterEach(() => { + act(() => { + KEYS_UNDER_TEST.forEach(key => persistenceStatusStore.markSaved(key)); + }); +}); + +describe("SaveStatusIndicator", () => { + test("shows nothing while idle", () => { + render(); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + test("shows a saving message while a write is in flight", () => { + render(); + + act(() => persistenceStatusStore.markSaving("configuration")); + + expect(screen.getByRole("status")).toHaveTextContent(/saving/i); + }); + + test("shows a failure message when a write fails terminally", () => { + render(); + + act(() => + persistenceStatusStore.markFailed("configuration", "terminal-access"), + ); + + expect(screen.getByRole("status")).toHaveTextContent(/couldn.t save/i); + }); + + test("prompts a backup when a write fails for lack of storage", () => { + render(); + + act(() => + persistenceStatusStore.markFailed("graph-sessions", "terminal-quota"), + ); + + expect(toast.error).toHaveBeenCalledWith( + "Out of storage", + expect.objectContaining({ + action: expect.objectContaining({ label: "Back up" }), + }), + ); + }); + + test("does not prompt a backup for a non-quota failure", () => { + render(); + + act(() => + persistenceStatusStore.markFailed("configuration", "terminal-access"), + ); + + expect(toast.error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx new file mode 100644 index 000000000..6e7dd5021 --- /dev/null +++ b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx @@ -0,0 +1,60 @@ +import localforage from "localforage"; +import { CloudAlertIcon, Loader2Icon } from "lucide-react"; +import { useEffect } from "react"; +import { toast } from "sonner"; + +import { saveLocalForageToFile } from "@/core/StateProvider/localDb"; +import { usePersistenceStatus } from "@/core/StateProvider/persistence/usePersistenceStatus"; + +/** + * A persistent, Google-Docs-style indicator of whether the app's data is safely + * written to IndexedDB. Quiet while idle; shows progress while saving; raises a + * standing warning when a write has failed. + * + * Toasts are reserved for the one terminal failure a user can act on: running + * out of storage, where the recovery is to back up their data to a file. + */ +export function SaveStatusIndicator() { + const { status, failures } = usePersistenceStatus(); + + const isOutOfStorage = failures.some( + failure => failure.reason === "terminal-quota", + ); + + useEffect(() => { + if (!isOutOfStorage) { + return; + } + toast.error("Out of storage", { + description: + "Some changes couldn't be saved. Back up your data to a file so you don't lose it.", + action: { + label: "Back up", + onClick: () => void saveLocalForageToFile(localforage), + }, + }); + }, [isOutOfStorage]); + + if (status === "idle") { + return null; + } + + if (status === "saving") { + return ( +
+ + Saving… +
+ ); + } + + return ( +
+ + Couldn't save your changes +
+ ); +} diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/usePersistenceStatus.ts b/packages/graph-explorer/src/core/StateProvider/persistence/usePersistenceStatus.ts new file mode 100644 index 000000000..e03715c9e --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/usePersistenceStatus.ts @@ -0,0 +1,14 @@ +import { useSyncExternalStore } from "react"; + +import { persistenceStatusStore } from "./index"; + +/** + * Subscribes a React component to the global persistence status. The status + * store lives outside React/Jotai; this is the bridge. + */ +export function usePersistenceStatus() { + return useSyncExternalStore( + persistenceStatusStore.subscribe, + persistenceStatusStore.getSnapshot, + ); +} diff --git a/packages/graph-explorer/src/core/StateProvider/userLayout.test.ts b/packages/graph-explorer/src/core/StateProvider/userLayout.test.ts index c0337066b..021609fdb 100644 --- a/packages/graph-explorer/src/core/StateProvider/userLayout.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/userLayout.test.ts @@ -25,29 +25,29 @@ describe("useViewToggles", () => { expect(result.current.isTableVisible).toBe(true); }); - it("should toggle graph view", async () => { + it("should toggle graph view", () => { const { result } = renderHookWithState(() => useViewToggles()); - await act(async () => result.current.toggleGraphVisibility()); + act(() => result.current.toggleGraphVisibility()); expect(result.current.isGraphVisible).toBe(false); expect(result.current.isTableVisible).toBe(true); - await act(async () => result.current.toggleGraphVisibility()); + act(() => result.current.toggleGraphVisibility()); expect(result.current.isGraphVisible).toBe(true); expect(result.current.isTableVisible).toBe(true); }); - it("should toggle table view", async () => { + it("should toggle table view", () => { const { result } = renderHookWithState(() => useViewToggles()); - await act(async () => result.current.toggleTableVisibility()); + act(() => result.current.toggleTableVisibility()); expect(result.current.isGraphVisible).toBe(true); expect(result.current.isTableVisible).toBe(false); - await act(async () => result.current.toggleTableVisibility()); + act(() => result.current.toggleTableVisibility()); expect(result.current.isGraphVisible).toBe(true); expect(result.current.isTableVisible).toBe(true); @@ -61,19 +61,19 @@ describe("useSidebar", () => { expect(result.current.isSidebarOpen).toBe(true); }); - it("should change to the given sidebar item", async () => { + it("should change to the given sidebar item", () => { const { result } = renderHookWithJotai(() => useSidebar()); - await act(async () => result.current.toggleSidebar("details")); + act(() => result.current.toggleSidebar("details")); expect(result.current.isSidebarOpen).toBe(true); expect(result.current.activeSidebarItem).toBe("details"); - await act(async () => result.current.toggleSidebar("search")); + act(() => result.current.toggleSidebar("search")); expect(result.current.isSidebarOpen).toBe(true); expect(result.current.activeSidebarItem).toBe("search"); }); - it("should close the sidebar if toggling to the same item", async () => { + it("should close the sidebar if toggling to the same item", () => { const { result } = renderHookWithJotai( () => useSidebar(), store => @@ -83,16 +83,16 @@ describe("useSidebar", () => { } satisfies UserLayout), ); - await act(async () => result.current.toggleSidebar("details")); + act(() => result.current.toggleSidebar("details")); expect(result.current.isSidebarOpen).toBe(false); expect(result.current.activeSidebarItem).toBeNull(); }); - it("should close the sidebar", async () => { + it("should close the sidebar", () => { const { result } = renderHookWithJotai(() => useSidebar()); - await act(async () => result.current.closeSidebar()); + act(() => result.current.closeSidebar()); expect(result.current.isSidebarOpen).toBe(false); }); @@ -145,25 +145,25 @@ describe("useTableViewSize", () => { expect(result.current[0]).toBe(300); }); - it("should return 100% when graph viewer is hidden", async () => { + it("should return 100% when graph viewer is hidden", () => { const { result } = renderHookWithState(() => ({ tableView: useTableViewSize(), toggles: useViewToggles(), })); - await act(async () => result.current.toggles.toggleGraphVisibility()); + act(() => result.current.toggles.toggleGraphVisibility()); expect(result.current.tableView[0]).toBe("100%"); }); - it("should adjust height by delta", async () => { + it("should adjust height by delta", () => { const { result } = renderHookWithState(() => useTableViewSize()); - await act(async () => result.current[1](50)); + act(() => result.current[1](50)); expect(result.current[0]).toBe(350); - await act(async () => result.current[1](-100)); + act(() => result.current[1](-100)); expect(result.current[0]).toBe(250); }); diff --git a/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts index 0a1ad999a..3197dff95 100644 --- a/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts @@ -57,33 +57,31 @@ describe("useVertexStyling", () => { expect(result.current.vertexStyle).toStrictEqual(expected); }); - it("should insert the vertex style when none exist", async () => { + it("should insert the vertex style when none exist", () => { const dbState = new DbState(); const { result } = renderHookWithState( () => useVertexStyling(createVertexType("test")), dbState, ); - await act(async () => result.current.setVertexStyle({ color: "red" })); + act(() => result.current.setVertexStyle({ color: "red" })); expect(result.current.vertexStyle).toStrictEqual( createExpectedVertex({ type: createVertexType("test"), color: "red" }), ); }); - it("should update the existing style, merging new styles", async () => { + it("should update the existing style, merging new styles", () => { const dbState = new DbState(); const { result } = renderHookWithState( () => useVertexStyling(createVertexType("test")), dbState, ); - await act(async () => + act(() => result.current.setVertexStyle({ color: "red", borderColor: "green" }), ); - await act(async () => - result.current.setVertexStyle({ borderColor: "blue" }), - ); + act(() => result.current.setVertexStyle({ borderColor: "blue" })); expect(result.current.vertexStyle).toStrictEqual( createExpectedVertex({ @@ -94,7 +92,7 @@ describe("useVertexStyling", () => { ); }); - it("should reset the vertex style", async () => { + it("should reset the vertex style", () => { const dbState = new DbState(); dbState.addVertexStyle(createVertexType("test"), { borderColor: "blue" }); @@ -103,14 +101,14 @@ describe("useVertexStyling", () => { dbState, ); - await act(async () => result.current.resetVertexStyle()); + act(() => result.current.resetVertexStyle()); expect(result.current.vertexStyle).toStrictEqual( createExpectedVertex({ type: createVertexType("test") }), ); }); - it("should not affect other vertex styles when updating", async () => { + it("should not affect other vertex styles when updating", () => { const dbState = new DbState(); dbState.addVertexStyle(createVertexType("type1"), { color: "red" }); dbState.addVertexStyle(createVertexType("type2"), { color: "blue" }); @@ -120,9 +118,7 @@ describe("useVertexStyling", () => { dbState, ); - await act(async () => - result.current.setVertexStyle({ borderColor: "green" }), - ); + act(() => result.current.setVertexStyle({ borderColor: "green" })); // Check that type1 was updated expect(result.current.vertexStyle).toStrictEqual( @@ -146,7 +142,7 @@ describe("useVertexStyling", () => { ); }); - it("should not affect other vertex styles when resetting", async () => { + it("should not affect other vertex styles when resetting", () => { const dbState = new DbState(); dbState.addVertexStyle(createVertexType("type1"), { color: "red" }); dbState.addVertexStyle(createVertexType("type2"), { color: "blue" }); @@ -156,7 +152,7 @@ describe("useVertexStyling", () => { dbState, ); - await act(async () => result.current.resetVertexStyle()); + act(() => result.current.resetVertexStyle()); expect(result.current.vertexStyle).toStrictEqual( createExpectedVertex({ type: createVertexType("type1") }), @@ -175,14 +171,14 @@ describe("useVertexStyling", () => { ); }); - it("should handle empty style updates", async () => { + it("should handle empty style updates", () => { const dbState = new DbState(); const { result } = renderHookWithState( () => useVertexStyling(createVertexType("test")), dbState, ); - await act(async () => result.current.setVertexStyle({})); + act(() => result.current.setVertexStyle({})); expect(result.current.vertexStyle).toStrictEqual( createExpectedVertex({ type: createVertexType("test") }), @@ -216,14 +212,14 @@ describe("useEdgeStyling", () => { expect(result.current.edgeStyle).toStrictEqual(createExpectedEdge(style)); }); - it("should insert the edge style when none exist", async () => { + it("should insert the edge style when none exist", () => { const dbState = new DbState(); const { result } = renderHookWithState( () => useEdgeStyling(createEdgeType("test")), dbState, ); - await act(async () => result.current.setEdgeStyle({ lineColor: "red" })); + act(() => result.current.setEdgeStyle({ lineColor: "red" })); expect(result.current.edgeStyle).toStrictEqual( createExpectedEdge({ @@ -233,17 +229,17 @@ describe("useEdgeStyling", () => { ); }); - it("should update the existing style, merging new styles", async () => { + it("should update the existing style, merging new styles", () => { const dbState = new DbState(); const { result } = renderHookWithState( () => useEdgeStyling(createEdgeType("test")), dbState, ); - await act(async () => + act(() => result.current.setEdgeStyle({ lineColor: "red", labelColor: "green" }), ); - await act(async () => result.current.setEdgeStyle({ labelColor: "blue" })); + act(() => result.current.setEdgeStyle({ labelColor: "blue" })); expect(result.current.edgeStyle).toStrictEqual( createExpectedEdge({ @@ -254,22 +250,22 @@ describe("useEdgeStyling", () => { ); }); - it("should reset the edge style", async () => { + it("should reset the edge style", () => { const dbState = new DbState(); const { result } = renderHookWithState( () => useEdgeStyling(createEdgeType("test")), dbState, ); - await act(async () => result.current.setEdgeStyle({ labelColor: "blue" })); - await act(async () => result.current.resetEdgeStyle()); + act(() => result.current.setEdgeStyle({ labelColor: "blue" })); + act(() => result.current.resetEdgeStyle()); expect(result.current.edgeStyle).toStrictEqual( createExpectedEdge({ type: createEdgeType("test") }), ); }); - it("should not affect other edge styles when updating", async () => { + it("should not affect other edge styles when updating", () => { const dbState = new DbState(); dbState.addEdgeStyle(createEdgeType("type1"), { lineColor: "red" }); dbState.addEdgeStyle(createEdgeType("type2"), { lineColor: "blue" }); @@ -279,7 +275,7 @@ describe("useEdgeStyling", () => { dbState, ); - await act(async () => result.current.setEdgeStyle({ labelColor: "green" })); + act(() => result.current.setEdgeStyle({ labelColor: "green" })); // Check that type1 was updated expect(result.current.edgeStyle).toStrictEqual( @@ -303,7 +299,7 @@ describe("useEdgeStyling", () => { ); }); - it("should not affect other edge styles when resetting", async () => { + it("should not affect other edge styles when resetting", () => { const dbState = new DbState(); dbState.addEdgeStyle(createEdgeType("type1"), { lineColor: "red" }); dbState.addEdgeStyle(createEdgeType("type2"), { lineColor: "blue" }); @@ -313,7 +309,7 @@ describe("useEdgeStyling", () => { dbState, ); - await act(async () => result.current.resetEdgeStyle()); + act(() => result.current.resetEdgeStyle()); expect(result.current.edgeStyle).toStrictEqual( createExpectedEdge({ type: createEdgeType("type1") }), @@ -332,14 +328,14 @@ describe("useEdgeStyling", () => { ); }); - it("should handle empty style updates", async () => { + it("should handle empty style updates", () => { const dbState = new DbState(); const { result } = renderHookWithState( () => useEdgeStyling(createEdgeType("test")), dbState, ); - await act(async () => result.current.setEdgeStyle({})); + act(() => result.current.setEdgeStyle({})); expect(result.current.edgeStyle).toStrictEqual( createExpectedEdge({ type: createEdgeType("test") }), @@ -348,7 +344,7 @@ describe("useEdgeStyling", () => { }); describe("useDeferredAtom integration", () => { - it("should handle multiple rapid updates correctly", async () => { + it("should handle multiple rapid updates correctly", () => { const dbState = new DbState(); const { result } = renderHookWithState( () => useVertexStyling(createVertexType("test")), @@ -356,10 +352,10 @@ describe("useDeferredAtom integration", () => { ); // Simulate rapid updates that might happen in real usage - await act(async () => { - await result.current.setVertexStyle({ color: "red" }); - await result.current.setVertexStyle({ borderColor: "blue" }); - await result.current.setVertexStyle({ shape: "ellipse" }); + act(() => { + result.current.setVertexStyle({ color: "red" }); + result.current.setVertexStyle({ borderColor: "blue" }); + result.current.setVertexStyle({ shape: "ellipse" }); }); expect(result.current.vertexStyle).toStrictEqual( @@ -372,7 +368,7 @@ describe("useDeferredAtom integration", () => { ); }); - it("should handle deferred atom updates correctly", async () => { + it("should handle deferred atom updates correctly", () => { const dbState = new DbState(); const { result } = renderHookWithState( () => useVertexStyling(createVertexType("test")), @@ -380,7 +376,7 @@ describe("useDeferredAtom integration", () => { ); // Test that the deferred atom pattern works with the hook - await act(async () => result.current.setVertexStyle({ color: "red" })); + act(() => result.current.setVertexStyle({ color: "red" })); // The hook should immediately reflect the change in its local state expect(result.current.vertexStyle).toStrictEqual( @@ -391,9 +387,7 @@ describe("useDeferredAtom integration", () => { ); // Test that subsequent updates work correctly - await act(async () => - result.current.setVertexStyle({ borderColor: "blue" }), - ); + act(() => result.current.setVertexStyle({ borderColor: "blue" })); expect(result.current.vertexStyle).toStrictEqual( createExpectedVertex({ diff --git a/packages/graph-explorer/src/routes/DefaultLayout.tsx b/packages/graph-explorer/src/routes/DefaultLayout.tsx index 0b2d07086..6ef5015cf 100644 --- a/packages/graph-explorer/src/routes/DefaultLayout.tsx +++ b/packages/graph-explorer/src/routes/DefaultLayout.tsx @@ -5,6 +5,7 @@ import { ErrorBoundary } from "react-error-boundary"; import { Outlet } from "react-router"; import { TooltipProvider } from "@/components"; +import { SaveStatusIndicator } from "@/components/SaveStatusIndicator/SaveStatusIndicator"; import { Toaster } from "@/components/Toaster"; import { diagnosticLoggingAtom } from "@/core"; import AppErrorPage from "@/core/AppErrorPage"; @@ -37,6 +38,9 @@ export default function DefaultLayout() { +
+ +
From c1a55cbcbca23a4d54744f488aec57adcdbb5265 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 19 Jun 2026 17:10:03 -0500 Subject: [PATCH 05/25] Record rebase-driven design refinements in persistence ADR --- docs/adr/20260619-storage-layer-owns-persistence-failure.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/adr/20260619-storage-layer-owns-persistence-failure.md b/docs/adr/20260619-storage-layer-owns-persistence-failure.md index 753bc1d6d..609e30c64 100644 --- a/docs/adr/20260619-storage-layer-owns-persistence-failure.md +++ b/docs/adr/20260619-storage-layer-owns-persistence-failure.md @@ -38,3 +38,5 @@ The depth is hidden behind composition, not crammed into one file: `classifyStor - **Status is strictly per-tab.** Consistent with the substrate ADR's "no cross-tab sync primitives," a failed write in one tab shows `failed` there regardless of other tabs, and clears only when that tab itself successfully flushes the key. Honest about whose edit is at risk. - **Recovery is retry + backup prompt, not a write guarantee.** Terminal quota toasts point at the full backup (`saveLocalForageToFile`), which is read-mostly and so remains viable under quota pressure. We do **not** block reload (`beforeunload`). - **This effort and the cross-tab merge are orthogonal**, meeting only at the `flush` seam — either can ship first without blocking the other. +- **Every IndexedDB write routes through one shared queue, including the Active Connection breadcrumb.** The queue/status are not embedded in `atomWithLocalForage`; they live in a shared `persistThroughQueue` helper that wraps the localForage write itself. Both `atomWithLocalForage` and the per-tab active-connection path (whose synchronous sessionStorage write stays outside the queue) call it, so all IndexedDB writes feed one global status. This dropped the special-case that would have excluded the breadcrumb. +- **The `void`-setter change lives in the shared `createWriteThroughAtom`**, which the active-connection path also uses. No production call site awaited the old promise (only tests did), so the migration was mechanical. The test seam moved to a layer-level `waitForIdle()` on the status store, replacing per-write promise awaits across the persistence test helpers and the active-connection tests. From 08305c09c2bd3d875f01f64c2e9108901ec9258d Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 19 Jun 2026 17:13:07 -0500 Subject: [PATCH 06/25] Add diagnostic logging to the persistence status store --- .../StateProvider/persistence/persistenceStatusStore.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts index 9a04ae474..86ad6dac7 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts @@ -1,3 +1,5 @@ +import { logger } from "@/utils"; + import type { StorageErrorClassification } from "./classifyStorageError"; /** @@ -68,6 +70,10 @@ export function createPersistenceStatusStore(): PersistenceStatusStore { reason, })); + if (status !== snapshot.status) { + logger.debug(`[persistence] status ${snapshot.status} → ${status}`); + } + snapshot = { status, failures }; listeners.forEach(listener => listener()); } @@ -78,15 +84,18 @@ export function createPersistenceStatusStore(): PersistenceStatusStore { return snapshot; }, markSaving(key) { + logger.debug(`[persistence] saving "${key}"`); inFlightKeys.add(key); recompute(); }, markSaved(key) { + logger.debug(`[persistence] saved "${key}"`); inFlightKeys.delete(key); failuresByKey.delete(key); recompute(); }, markFailed(key, reason) { + logger.debug(`[persistence] failed "${key}" (${reason})`); inFlightKeys.delete(key); failuresByKey.set(key, reason); recompute(); From ddc23c870fbb45aa7e514713639e1ba8c5ea8593 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 19 Jun 2026 17:22:21 -0500 Subject: [PATCH 07/25] Move save-status indicator into the nav bar after the page title --- packages/graph-explorer/src/components/index.ts | 2 ++ .../graph-explorer/src/routes/Connections/Connections.tsx | 2 ++ .../graph-explorer/src/routes/DataExplorer/DataExplorer.tsx | 2 ++ packages/graph-explorer/src/routes/DefaultLayout.tsx | 4 ---- .../graph-explorer/src/routes/GraphExplorer/GraphExplorer.tsx | 2 ++ .../src/routes/SchemaExplorer/SchemaExplorer.tsx | 2 ++ packages/graph-explorer/src/routes/Settings/SettingsRoot.tsx | 2 ++ 7 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/graph-explorer/src/components/index.ts b/packages/graph-explorer/src/components/index.ts index 49e83a20f..caca82600 100644 --- a/packages/graph-explorer/src/components/index.ts +++ b/packages/graph-explorer/src/components/index.ts @@ -52,6 +52,8 @@ export * from "./NavBar"; export * from "./NoEdgeTypesEmptyState"; export * from "./NoNodeTypesEmptyState"; +export * from "./SaveStatusIndicator/SaveStatusIndicator"; + export * from "./Toaster"; export * from "./RouteButton"; diff --git a/packages/graph-explorer/src/routes/Connections/Connections.tsx b/packages/graph-explorer/src/routes/Connections/Connections.tsx index 16e3ec26a..7070b1fb1 100644 --- a/packages/graph-explorer/src/routes/Connections/Connections.tsx +++ b/packages/graph-explorer/src/routes/Connections/Connections.tsx @@ -7,6 +7,7 @@ import { PanelEmptyState, PanelGroup, RouteButtonGroup, + SaveStatusIndicator, Workspace, WorkspaceContent, } from "@/components"; @@ -28,6 +29,7 @@ export default function Connections() { title="Connections Details" subtitle={`Connection: ${config?.displayLabel || config?.id || "none"}`} /> + diff --git a/packages/graph-explorer/src/routes/DataExplorer/DataExplorer.tsx b/packages/graph-explorer/src/routes/DataExplorer/DataExplorer.tsx index 43ed056f9..a7c75c33c 100644 --- a/packages/graph-explorer/src/routes/DataExplorer/DataExplorer.tsx +++ b/packages/graph-explorer/src/routes/DataExplorer/DataExplorer.tsx @@ -20,6 +20,7 @@ import { PanelGroup, PanelHeader, RouteButtonGroup, + SaveStatusIndicator, SchemaDiscoveryBoundary, SelectField, SendIcon, @@ -216,6 +217,7 @@ function Layout({ children }: { children: React.ReactNode }) { title="Data Explorer" subtitle={`Connection: ${config?.displayLabel || config?.id}`} /> + diff --git a/packages/graph-explorer/src/routes/DefaultLayout.tsx b/packages/graph-explorer/src/routes/DefaultLayout.tsx index 6ef5015cf..0b2d07086 100644 --- a/packages/graph-explorer/src/routes/DefaultLayout.tsx +++ b/packages/graph-explorer/src/routes/DefaultLayout.tsx @@ -5,7 +5,6 @@ import { ErrorBoundary } from "react-error-boundary"; import { Outlet } from "react-router"; import { TooltipProvider } from "@/components"; -import { SaveStatusIndicator } from "@/components/SaveStatusIndicator/SaveStatusIndicator"; import { Toaster } from "@/components/Toaster"; import { diagnosticLoggingAtom } from "@/core"; import AppErrorPage from "@/core/AppErrorPage"; @@ -38,9 +37,6 @@ export default function DefaultLayout() { -
- -
diff --git a/packages/graph-explorer/src/routes/GraphExplorer/GraphExplorer.tsx b/packages/graph-explorer/src/routes/GraphExplorer/GraphExplorer.tsx index 266a84c40..029a2105f 100644 --- a/packages/graph-explorer/src/routes/GraphExplorer/GraphExplorer.tsx +++ b/packages/graph-explorer/src/routes/GraphExplorer/GraphExplorer.tsx @@ -15,6 +15,7 @@ import { NavBarTitle, PanelGroup, RouteButtonGroup, + SaveStatusIndicator, SchemaDiscoveryBoundary, Workspace, WorkspaceContent, @@ -69,6 +70,7 @@ const GraphExplorer = () => { title={LABELS.APP_NAME} subtitle={`Connection: ${config?.displayLabel || config?.id}`} /> + {hasSchema && ( diff --git a/packages/graph-explorer/src/routes/SchemaExplorer/SchemaExplorer.tsx b/packages/graph-explorer/src/routes/SchemaExplorer/SchemaExplorer.tsx index dc900d773..a67edb05d 100644 --- a/packages/graph-explorer/src/routes/SchemaExplorer/SchemaExplorer.tsx +++ b/packages/graph-explorer/src/routes/SchemaExplorer/SchemaExplorer.tsx @@ -3,6 +3,7 @@ import { NavBarContent, NavBarTitle, RouteButtonGroup, + SaveStatusIndicator, SchemaDiscoveryBoundary, Workspace, WorkspaceContent, @@ -24,6 +25,7 @@ export default function SchemaExplorer() { title="Schema Explorer" subtitle={`Connection: ${config?.displayLabel || config?.id || "none"}`} /> + diff --git a/packages/graph-explorer/src/routes/Settings/SettingsRoot.tsx b/packages/graph-explorer/src/routes/Settings/SettingsRoot.tsx index 8e2c3f046..6ec97679a 100644 --- a/packages/graph-explorer/src/routes/Settings/SettingsRoot.tsx +++ b/packages/graph-explorer/src/routes/Settings/SettingsRoot.tsx @@ -10,6 +10,7 @@ import { PanelContent, PanelGroup, RouteButtonGroup, + SaveStatusIndicator, Workspace, WorkspaceContent, } from "@/components"; @@ -23,6 +24,7 @@ export default function SettingsRoot() { + From dfb49050ed60823f0778e4b3db841b3ef2a4efb4 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 19 Jun 2026 17:32:52 -0500 Subject: [PATCH 08/25] Render save-status indicator as a danger Alert, only on failure --- .../SaveStatusIndicator.test.tsx | 8 ++--- .../SaveStatusIndicator.tsx | 30 ++++++------------- .../core/StateProvider/persistence/index.ts | 10 +++++++ 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx index 713bf6714..97039cfaa 100644 --- a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx +++ b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx @@ -21,15 +21,15 @@ describe("SaveStatusIndicator", () => { test("shows nothing while idle", () => { render(); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); - test("shows a saving message while a write is in flight", () => { + test("shows nothing while a write is merely in flight", () => { render(); act(() => persistenceStatusStore.markSaving("configuration")); - expect(screen.getByRole("status")).toHaveTextContent(/saving/i); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); test("shows a failure message when a write fails terminally", () => { @@ -39,7 +39,7 @@ describe("SaveStatusIndicator", () => { persistenceStatusStore.markFailed("configuration", "terminal-access"), ); - expect(screen.getByRole("status")).toHaveTextContent(/couldn.t save/i); + expect(screen.getByRole("alert")).toHaveTextContent(/couldn.t save/i); }); test("prompts a backup when a write fails for lack of storage", () => { diff --git a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx index 6e7dd5021..edd1076f6 100644 --- a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx @@ -1,15 +1,15 @@ import localforage from "localforage"; -import { CloudAlertIcon, Loader2Icon } from "lucide-react"; +import { CircleAlertIcon } from "lucide-react"; import { useEffect } from "react"; import { toast } from "sonner"; +import { Alert, AlertTitle } from "@/components/Alert"; import { saveLocalForageToFile } from "@/core/StateProvider/localDb"; import { usePersistenceStatus } from "@/core/StateProvider/persistence/usePersistenceStatus"; /** - * A persistent, Google-Docs-style indicator of whether the app's data is safely - * written to IndexedDB. Quiet while idle; shows progress while saving; raises a - * standing warning when a write has failed. + * A standing warning that the app failed to write data to IndexedDB. Renders + * nothing while idle or saving — only a terminal failure surfaces. * * Toasts are reserved for the one terminal failure a user can act on: running * out of storage, where the recovery is to back up their data to a file. @@ -35,26 +35,14 @@ export function SaveStatusIndicator() { }); }, [isOutOfStorage]); - if (status === "idle") { + if (status !== "failed") { return null; } - if (status === "saving") { - return ( -
- - Saving… -
- ); - } - return ( -
- - Couldn't save your changes -
+ + + Couldn't save your changes + ); } diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/index.ts b/packages/graph-explorer/src/core/StateProvider/persistence/index.ts index 4aa4031f3..7f58a3c56 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/index.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/index.ts @@ -24,3 +24,13 @@ const writeQueue = createWriteQueue({ store: persistenceStatusStore }); export function persistThroughQueue(key: string, flush: Flush): void { writeQueue.enqueue(key, flush); } + +// DEV-only: expose the status store on window so the save-status indicator can +// be driven from the browser console, e.g. +// __persistence.markFailed("configuration", "terminal-access") +// __persistence.markFailed("graph-sessions", "terminal-quota") +// __persistence.markSaving("schema"); __persistence.markSaved("schema") +if (import.meta.env.DEV) { + (window as unknown as Record).__persistence = + persistenceStatusStore; +} From 31f467c114c835477bce9ad9231214042516cfc6 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 19 Jun 2026 17:37:55 -0500 Subject: [PATCH 09/25] Keep the out-of-storage backup toast until the user dismisses it --- .../src/components/SaveStatusIndicator/SaveStatusIndicator.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx index edd1076f6..6a89bef10 100644 --- a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx @@ -28,6 +28,9 @@ export function SaveStatusIndicator() { toast.error("Out of storage", { description: "Some changes couldn't be saved. Back up your data to a file so you don't lose it.", + // Data loss is at stake, so this stays until the user acts on or dismisses + // it rather than disappearing on the Toaster's default timeout. + duration: Infinity, action: { label: "Back up", onClick: () => void saveLocalForageToFile(localforage), From 78360a7757d1af0fb205e49bb184c5d0650243dd Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 19 Jun 2026 17:42:43 -0500 Subject: [PATCH 10/25] Reword storage-full toast to reference browser storage, not disk --- .../SaveStatusIndicator/SaveStatusIndicator.test.tsx | 4 ++-- .../components/SaveStatusIndicator/SaveStatusIndicator.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx index 97039cfaa..991d6b99f 100644 --- a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx +++ b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx @@ -50,9 +50,9 @@ describe("SaveStatusIndicator", () => { ); expect(toast.error).toHaveBeenCalledWith( - "Out of storage", + "Browser storage is full", expect.objectContaining({ - action: expect.objectContaining({ label: "Back up" }), + action: expect.objectContaining({ label: "Download backup" }), }), ); }); diff --git a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx index 6a89bef10..09dede839 100644 --- a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx @@ -25,14 +25,14 @@ export function SaveStatusIndicator() { if (!isOutOfStorage) { return; } - toast.error("Out of storage", { + toast.error("Browser storage is full", { description: - "Some changes couldn't be saved. Back up your data to a file so you don't lose it.", + "Graph Explorer has run out of space to save changes in this browser. Download a backup so you don't lose your work.", // Data loss is at stake, so this stays until the user acts on or dismisses // it rather than disappearing on the Toaster's default timeout. duration: Infinity, action: { - label: "Back up", + label: "Download backup", onClick: () => void saveLocalForageToFile(localforage), }, }); From a9b07069d4cdf7f9f302e76848ec8238a8d159ce Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 11:18:42 -0500 Subject: [PATCH 11/25] Split terminal-failure toast: backup for full storage, plain warning for inaccessible --- .../SaveStatusIndicator.test.tsx | 9 ++++++-- .../SaveStatusIndicator.tsx | 21 +++++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx index 991d6b99f..a1f4302f5 100644 --- a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx +++ b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx @@ -57,13 +57,18 @@ describe("SaveStatusIndicator", () => { ); }); - test("does not prompt a backup for a non-quota failure", () => { + test("warns without a backup when storage is inaccessible", () => { render(); act(() => persistenceStatusStore.markFailed("configuration", "terminal-access"), ); - expect(toast.error).not.toHaveBeenCalled(); + // A backup is useless here — IndexedDB never opened, so there is nothing to + // read. The toast warns but offers no action. + expect(toast.error).toHaveBeenCalledWith( + "Can't save to browser storage", + expect.not.objectContaining({ action: expect.anything() }), + ); }); }); diff --git a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx index 09dede839..059e200fe 100644 --- a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx @@ -11,8 +11,11 @@ import { usePersistenceStatus } from "@/core/StateProvider/persistence/usePersis * A standing warning that the app failed to write data to IndexedDB. Renders * nothing while idle or saving — only a terminal failure surfaces. * - * Toasts are reserved for the one terminal failure a user can act on: running - * out of storage, where the recovery is to back up their data to a file. + * Toasts are reserved for terminal failures and their wording depends on the + * reason. When storage is full a backup is offered, because IndexedDB can still + * be read to capture everything that did persist. When storage is inaccessible + * (private mode, blocked by policy) no backup is offered: the database never + * opened, so there is nothing to read. */ export function SaveStatusIndicator() { const { status, failures } = usePersistenceStatus(); @@ -20,6 +23,9 @@ export function SaveStatusIndicator() { const isOutOfStorage = failures.some( failure => failure.reason === "terminal-quota", ); + const isStorageInaccessible = failures.some( + failure => failure.reason === "terminal-access", + ); useEffect(() => { if (!isOutOfStorage) { @@ -38,6 +44,17 @@ export function SaveStatusIndicator() { }); }, [isOutOfStorage]); + useEffect(() => { + if (!isStorageInaccessible) { + return; + } + toast.error("Can't save to browser storage", { + description: + "Graph Explorer can't access browser storage, so your changes won't be saved this session. This often happens in private browsing or when storage is blocked.", + duration: Infinity, + }); + }, [isStorageInaccessible]); + if (status !== "failed") { return null; } From 4bdaa5054e18cc3a5191192a1ed1f92473a9642e Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 11:24:10 -0500 Subject: [PATCH 12/25] Rename SaveStatusIndicator to PersistenceStatusIndicator for glossary consistency --- CONTEXT.md | 8 ++++---- .../PersistenceStatusIndicator.test.tsx} | 14 +++++++------- .../PersistenceStatusIndicator.tsx} | 2 +- packages/graph-explorer/src/components/index.ts | 2 +- .../src/core/StateProvider/persistence/index.ts | 8 ++++---- .../src/routes/Connections/Connections.tsx | 4 ++-- .../src/routes/DataExplorer/DataExplorer.tsx | 4 ++-- .../src/routes/GraphExplorer/GraphExplorer.tsx | 4 ++-- .../src/routes/SchemaExplorer/SchemaExplorer.tsx | 4 ++-- .../src/routes/Settings/SettingsRoot.tsx | 4 ++-- 10 files changed, 27 insertions(+), 27 deletions(-) rename packages/graph-explorer/src/components/{SaveStatusIndicator/SaveStatusIndicator.test.tsx => PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx} (85%) rename packages/graph-explorer/src/components/{SaveStatusIndicator/SaveStatusIndicator.tsx => PersistenceStatusIndicator/PersistenceStatusIndicator.tsx} (98%) diff --git a/CONTEXT.md b/CONTEXT.md index dc25fa100..23c72fd30 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -79,12 +79,12 @@ The on-disk JSON format a user gets when they export a Connection (`saveConfigur _Avoid_: Configuration file (the wire format is not the in-memory or persisted shape) **Persistence Status**: -The single, global state of whether the app's client-side data is safely written to IndexedDB — `idle | saving | failed`. One source of truth that the save-status indicator subscribes to. `idle` = nothing queued and everything durable (no separate "saved" state — visually identical to a fresh session). `saving` = at least one write queued or in flight, including retryable retries. `failed` = at least one terminal failure outstanding, carrying failure records for a drill-in detail view. Aggregated across all per-key write queues by precedence: any terminal → `failed`; else any in-flight → `saving`; else `idle`. A `failed` key clears on its next successful write; status returns to `idle` when no failure records remain. Deliberately **not** per-key: a failed write flips the whole status rather than naming which collection failed, because the user cannot act on an individual key (the storage layer retries on their behalf). Lives in a plain external store outside React/Jotai; the React edge bridges it via `useSyncExternalStore`. +The single, global state of whether the app's client-side data is safely written to IndexedDB — `idle | saving | failed`. One source of truth that the Persistence Status Indicator subscribes to. `idle` = nothing queued and everything durable (no separate "saved" state — visually identical to a fresh session). `saving` = at least one write queued or in flight, including retryable retries. `failed` = at least one terminal failure outstanding, carrying failure records for a drill-in detail view. Aggregated across all per-key write queues by precedence: any terminal → `failed`; else any in-flight → `saving`; else `idle`. A `failed` key clears on its next successful write; status returns to `idle` when no failure records remain. Deliberately **not** per-key: a failed write flips the whole status rather than naming which collection failed, because the user cannot act on an individual key (the storage layer retries on their behalf). Lives in a plain external store outside React/Jotai; the React edge bridges it via `useSyncExternalStore`. _Avoid_: Save state (ambiguous with Session) -**Save-Status Indicator**: -The persistent, Google-Docs-style UI element (corner of the app) that renders Persistence Status — quiet/absent at `idle`, "Saving…" at `saving`, an attention treatment at `failed`. Replaces scattered per-write toasts. Toasts are reserved for terminal, actionable failures only — notably out-of-storage (quota), which prompts a full client-side backup via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`). Recovery scope is retry (transient failures) plus the backup prompt (terminal failures) — it does not guarantee the write eventually lands. -_Avoid_: Save toast, save banner +**Persistence Status Indicator**: +The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger Alert — and stays absent at `idle` and `saving`. Replaces scattered per-write toasts. Toasts are reserved for terminal failures: out-of-storage (quota) prompts a full client-side backup via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`), while inaccessible storage (private mode, blocked) warns without a backup since the database never opened. Recovery scope is retry (transient failures) plus the backup prompt (terminal failures) — it does not guarantee the write eventually lands. +_Avoid_: Save-status indicator, save toast, save banner ## Relationships diff --git a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx similarity index 85% rename from packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx rename to packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx index a1f4302f5..632c94faf 100644 --- a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.test.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, test } from "vitest"; import { persistenceStatusStore } from "@/core/StateProvider/persistence"; -import { SaveStatusIndicator } from "./SaveStatusIndicator"; +import { PersistenceStatusIndicator } from "./PersistenceStatusIndicator"; // The indicator reads the app-wide singleton store. Return it to idle between // tests by marking every key this suite touches as saved, which clears both @@ -17,15 +17,15 @@ afterEach(() => { }); }); -describe("SaveStatusIndicator", () => { +describe("PersistenceStatusIndicator", () => { test("shows nothing while idle", () => { - render(); + render(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); test("shows nothing while a write is merely in flight", () => { - render(); + render(); act(() => persistenceStatusStore.markSaving("configuration")); @@ -33,7 +33,7 @@ describe("SaveStatusIndicator", () => { }); test("shows a failure message when a write fails terminally", () => { - render(); + render(); act(() => persistenceStatusStore.markFailed("configuration", "terminal-access"), @@ -43,7 +43,7 @@ describe("SaveStatusIndicator", () => { }); test("prompts a backup when a write fails for lack of storage", () => { - render(); + render(); act(() => persistenceStatusStore.markFailed("graph-sessions", "terminal-quota"), @@ -58,7 +58,7 @@ describe("SaveStatusIndicator", () => { }); test("warns without a backup when storage is inaccessible", () => { - render(); + render(); act(() => persistenceStatusStore.markFailed("configuration", "terminal-access"), diff --git a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx similarity index 98% rename from packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx rename to packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx index 059e200fe..25ed54393 100644 --- a/packages/graph-explorer/src/components/SaveStatusIndicator/SaveStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx @@ -17,7 +17,7 @@ import { usePersistenceStatus } from "@/core/StateProvider/persistence/usePersis * (private mode, blocked by policy) no backup is offered: the database never * opened, so there is nothing to read. */ -export function SaveStatusIndicator() { +export function PersistenceStatusIndicator() { const { status, failures } = usePersistenceStatus(); const isOutOfStorage = failures.some( diff --git a/packages/graph-explorer/src/components/index.ts b/packages/graph-explorer/src/components/index.ts index caca82600..3dc6d0b41 100644 --- a/packages/graph-explorer/src/components/index.ts +++ b/packages/graph-explorer/src/components/index.ts @@ -52,7 +52,7 @@ export * from "./NavBar"; export * from "./NoEdgeTypesEmptyState"; export * from "./NoNodeTypesEmptyState"; -export * from "./SaveStatusIndicator/SaveStatusIndicator"; +export * from "./PersistenceStatusIndicator/PersistenceStatusIndicator"; export * from "./Toaster"; diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/index.ts b/packages/graph-explorer/src/core/StateProvider/persistence/index.ts index 7f58a3c56..c8844e64c 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/index.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/index.ts @@ -9,8 +9,8 @@ export type { /** * The app-wide persistence-status store. A single global instance because the - * Save-Status Indicator shows one aggregated status for the whole app. Tests - * construct isolated stores via `createPersistenceStatusStore` instead. + * Persistence Status Indicator shows one aggregated status for the whole app. + * Tests construct isolated stores via `createPersistenceStatusStore` instead. */ export const persistenceStatusStore = createPersistenceStatusStore(); @@ -25,8 +25,8 @@ export function persistThroughQueue(key: string, flush: Flush): void { writeQueue.enqueue(key, flush); } -// DEV-only: expose the status store on window so the save-status indicator can -// be driven from the browser console, e.g. +// DEV-only: expose the status store on window so the Persistence Status +// Indicator can be driven from the browser console, e.g. // __persistence.markFailed("configuration", "terminal-access") // __persistence.markFailed("graph-sessions", "terminal-quota") // __persistence.markSaving("schema"); __persistence.markSaved("schema") diff --git a/packages/graph-explorer/src/routes/Connections/Connections.tsx b/packages/graph-explorer/src/routes/Connections/Connections.tsx index 7070b1fb1..e7cadac19 100644 --- a/packages/graph-explorer/src/routes/Connections/Connections.tsx +++ b/packages/graph-explorer/src/routes/Connections/Connections.tsx @@ -7,7 +7,7 @@ import { PanelEmptyState, PanelGroup, RouteButtonGroup, - SaveStatusIndicator, + PersistenceStatusIndicator, Workspace, WorkspaceContent, } from "@/components"; @@ -29,7 +29,7 @@ export default function Connections() { title="Connections Details" subtitle={`Connection: ${config?.displayLabel || config?.id || "none"}`} /> - +
diff --git a/packages/graph-explorer/src/routes/DataExplorer/DataExplorer.tsx b/packages/graph-explorer/src/routes/DataExplorer/DataExplorer.tsx index a7c75c33c..209af0393 100644 --- a/packages/graph-explorer/src/routes/DataExplorer/DataExplorer.tsx +++ b/packages/graph-explorer/src/routes/DataExplorer/DataExplorer.tsx @@ -20,7 +20,7 @@ import { PanelGroup, PanelHeader, RouteButtonGroup, - SaveStatusIndicator, + PersistenceStatusIndicator, SchemaDiscoveryBoundary, SelectField, SendIcon, @@ -217,7 +217,7 @@ function Layout({ children }: { children: React.ReactNode }) { title="Data Explorer" subtitle={`Connection: ${config?.displayLabel || config?.id}`} /> - + diff --git a/packages/graph-explorer/src/routes/GraphExplorer/GraphExplorer.tsx b/packages/graph-explorer/src/routes/GraphExplorer/GraphExplorer.tsx index 029a2105f..0108ac2fb 100644 --- a/packages/graph-explorer/src/routes/GraphExplorer/GraphExplorer.tsx +++ b/packages/graph-explorer/src/routes/GraphExplorer/GraphExplorer.tsx @@ -15,7 +15,7 @@ import { NavBarTitle, PanelGroup, RouteButtonGroup, - SaveStatusIndicator, + PersistenceStatusIndicator, SchemaDiscoveryBoundary, Workspace, WorkspaceContent, @@ -70,7 +70,7 @@ const GraphExplorer = () => { title={LABELS.APP_NAME} subtitle={`Connection: ${config?.displayLabel || config?.id}`} /> - + {hasSchema && ( diff --git a/packages/graph-explorer/src/routes/SchemaExplorer/SchemaExplorer.tsx b/packages/graph-explorer/src/routes/SchemaExplorer/SchemaExplorer.tsx index a67edb05d..368fde834 100644 --- a/packages/graph-explorer/src/routes/SchemaExplorer/SchemaExplorer.tsx +++ b/packages/graph-explorer/src/routes/SchemaExplorer/SchemaExplorer.tsx @@ -3,7 +3,7 @@ import { NavBarContent, NavBarTitle, RouteButtonGroup, - SaveStatusIndicator, + PersistenceStatusIndicator, SchemaDiscoveryBoundary, Workspace, WorkspaceContent, @@ -25,7 +25,7 @@ export default function SchemaExplorer() { title="Schema Explorer" subtitle={`Connection: ${config?.displayLabel || config?.id || "none"}`} /> - + diff --git a/packages/graph-explorer/src/routes/Settings/SettingsRoot.tsx b/packages/graph-explorer/src/routes/Settings/SettingsRoot.tsx index 6ec97679a..991c61272 100644 --- a/packages/graph-explorer/src/routes/Settings/SettingsRoot.tsx +++ b/packages/graph-explorer/src/routes/Settings/SettingsRoot.tsx @@ -10,7 +10,7 @@ import { PanelContent, PanelGroup, RouteButtonGroup, - SaveStatusIndicator, + PersistenceStatusIndicator, Workspace, WorkspaceContent, } from "@/components"; @@ -24,7 +24,7 @@ export default function SettingsRoot() { - + From 97d6f23fd49ed017b4042d53bef4ceb69d579035 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 12:35:02 -0500 Subject: [PATCH 13/25] Fix persistence status edge cases and remove debug hook - waitForIdle now gates on in-flight count, not status, so a terminal failure on one key no longer signals settled while another key drains - skip no-op status recomputes to avoid redundant indicator re-renders - reclassify InvalidStateError as retryable (often a transient IDB state) - give terminal-failure toasts stable ids so they replace, not stack - remove the DEV-only window.__persistence hook (broke node-env tests) --- .../PersistenceStatusIndicator.tsx | 6 +++ .../persistence/classifyStorageError.test.ts | 25 ++++++----- .../persistence/classifyStorageError.ts | 7 ++- .../core/StateProvider/persistence/index.ts | 10 ----- .../persistenceStatusStore.test.ts | 21 +++++++++ .../persistence/persistenceStatusStore.ts | 45 ++++++++++++++++--- 6 files changed, 84 insertions(+), 30 deletions(-) diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx index 25ed54393..97cd79125 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx @@ -32,6 +32,9 @@ export function PersistenceStatusIndicator() { return; } toast.error("Browser storage is full", { + // Stable id so a recurring failure replaces the existing toast rather than + // stacking a duplicate (the indicator remounts on every route change). + id: "persistence-quota", description: "Graph Explorer has run out of space to save changes in this browser. Download a backup so you don't lose your work.", // Data loss is at stake, so this stays until the user acts on or dismisses @@ -49,6 +52,9 @@ export function PersistenceStatusIndicator() { return; } toast.error("Can't save to browser storage", { + // Stable id so a recurring failure replaces the existing toast rather than + // stacking a duplicate (the indicator remounts on every route change). + id: "persistence-access", description: "Graph Explorer can't access browser storage, so your changes won't be saved this session. This often happens in private browsing or when storage is blocked.", duration: Infinity, diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.test.ts b/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.test.ts index 930775de9..219e14bd6 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.test.ts @@ -13,19 +13,20 @@ describe("classifyStorageError", () => { ); }); - test.each(["SecurityError", "InvalidStateError"])( - "classifies %s as terminal-access", - name => { - expect(classifyStorageError(domException(name))).toBe("terminal-access"); - }, - ); + test("classifies SecurityError as terminal-access", () => { + expect(classifyStorageError(domException("SecurityError"))).toBe( + "terminal-access", + ); + }); - test.each(["AbortError", "UnknownError", "TimeoutError"])( - "classifies the transient error %s as retryable", - name => { - expect(classifyStorageError(domException(name))).toBe("retryable"); - }, - ); + test.each([ + "AbortError", + "UnknownError", + "TimeoutError", + "InvalidStateError", + ])("classifies the transient error %s as retryable", name => { + expect(classifyStorageError(domException(name))).toBe("retryable"); + }); test("defaults an unrecognized error to retryable", () => { expect(classifyStorageError(new Error("something odd"))).toBe("retryable"); diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.ts b/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.ts index 5b9050253..103765084 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.ts @@ -32,9 +32,14 @@ export function classifyStorageError( case "QuotaExceededError": return "terminal-quota"; case "SecurityError": - case "InvalidStateError": return "terminal-access"; default: + // Everything else — including InvalidStateError, which IndexedDB also + // raises for transient transaction/cursor states, not only a database + // that never opened — is retryable. Retryable-by-default is the safer + // error: a transient failure wrongly called terminal loses recoverable + // data, while a terminal one wrongly retried just burns a few capped + // backoff cycles. return "retryable"; } } diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/index.ts b/packages/graph-explorer/src/core/StateProvider/persistence/index.ts index c8844e64c..c6392ecb9 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/index.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/index.ts @@ -24,13 +24,3 @@ const writeQueue = createWriteQueue({ store: persistenceStatusStore }); export function persistThroughQueue(key: string, flush: Flush): void { writeQueue.enqueue(key, flush); } - -// DEV-only: expose the status store on window so the Persistence Status -// Indicator can be driven from the browser console, e.g. -// __persistence.markFailed("configuration", "terminal-access") -// __persistence.markFailed("graph-sessions", "terminal-quota") -// __persistence.markSaving("schema"); __persistence.markSaved("schema") -if (import.meta.env.DEV) { - (window as unknown as Record).__persistence = - persistenceStatusStore; -} diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts index 1f88cc8c8..fa480c417 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts @@ -103,4 +103,25 @@ describe("persistenceStatusStore", () => { await expect(idle).resolves.toBeUndefined(); }); + + test("waitForIdle does not resolve while another key is still in flight after a failure", async () => { + const store = createPersistenceStatusStore(); + store.markSaving("configuration"); + store.markSaving("schema"); + + let resolved = false; + const idle = store.waitForIdle().then(() => { + resolved = true; + }); + + // One key fails terminally, but the other is still draining. Status flips to + // "failed", yet a write is genuinely still in flight, so idle must wait. + store.markFailed("configuration", "terminal-quota"); + await Promise.resolve(); + expect(resolved).toBe(false); + + store.markSaved("schema"); + await idle; + expect(resolved).toBe(true); + }); }); diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts index 86ad6dac7..3f6d5df49 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts @@ -51,6 +51,11 @@ export function createPersistenceStatusStore(): PersistenceStatusStore { const inFlightKeys = new Set(); const failuresByKey = new Map(); const listeners = new Set<() => void>(); + // Resolvers waiting for all in-flight writes to drain. Tracked separately + // from `listeners` because idleness keys off the in-flight count, not the + // snapshot — a snapshot-unchanged transition (e.g. the last in-flight key + // settling while a failure remains) must still wake these. + let idleWaiters: Array<() => void> = []; let snapshot: PersistenceStatusSnapshot = { status: "idle", failures: [] }; @@ -59,6 +64,27 @@ export function createPersistenceStatusStore(): PersistenceStatusStore { return () => listeners.delete(listener); } + function flushIdleWaitersIfDrained() { + if (inFlightKeys.size > 0 || idleWaiters.length === 0) { + return; + } + const waiters = idleWaiters; + idleWaiters = []; + waiters.forEach(resolve => resolve()); + } + + function failuresEqual(next: PersistenceFailure[]) { + const current = snapshot.failures; + return ( + next.length === current.length && + next.every( + (failure, index) => + failure.key === current[index].key && + failure.reason === current[index].reason, + ) + ); + } + function recompute() { const status: PersistenceStatus = failuresByKey.size ? "failed" @@ -70,6 +96,10 @@ export function createPersistenceStatusStore(): PersistenceStatusStore { reason, })); + if (status === snapshot.status && failuresEqual(failures)) { + return; + } + if (status !== snapshot.status) { logger.debug(`[persistence] status ${snapshot.status} → ${status}`); } @@ -93,24 +123,25 @@ export function createPersistenceStatusStore(): PersistenceStatusStore { inFlightKeys.delete(key); failuresByKey.delete(key); recompute(); + flushIdleWaitersIfDrained(); }, markFailed(key, reason) { logger.debug(`[persistence] failed "${key}" (${reason})`); inFlightKeys.delete(key); failuresByKey.set(key, reason); recompute(); + flushIdleWaitersIfDrained(); }, waitForIdle() { - if (snapshot.status !== "saving") { + // Resolve when no write is in flight, regardless of whether failures + // remain. Keying off the in-flight count (not status) means a terminal + // failure on one key does not prematurely signal "settled" while another + // key is still draining. + if (inFlightKeys.size === 0) { return Promise.resolve(); } return new Promise(resolve => { - const unsubscribe = subscribe(() => { - if (snapshot.status !== "saving") { - unsubscribe(); - resolve(); - } - }); + idleWaiters.push(resolve); }); }, }; From f1b86d8d93f4ea6757bd7ef0774c2cc5623dc06f Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 12:55:35 -0500 Subject: [PATCH 14/25] Add failure detail dialog and drop terminal-failure toasts The failure Alert now opens a detail dialog listing each failed collection (humanized from its storage key) and why it failed, with a Download backup button only when storage is full. Toasts are removed: the standing Alert plus dialog is the single, non-stacking recovery surface. Failure records now carry attemptCount and lastAttemptAt. Syncs CONTEXT.md and the ADR to the shipped detail view, fixes the ADR's waitForIdle seam name and the non-existent superseded-ADR reference, and adds a troubleshooting entry for save failures. --- CONTEXT.md | 2 +- ...-storage-layer-owns-persistence-failure.md | 8 +- docs/guides/troubleshooting.md | 12 ++ .../PersistenceStatusIndicator.test.tsx | 74 ++++---- .../PersistenceStatusIndicator.tsx | 162 ++++++++++++------ .../persistenceStatusStore.test.ts | 26 +-- .../persistence/persistenceStatusStore.ts | 45 +++-- .../persistence/writeQueue.test.ts | 18 +- .../StateProvider/persistence/writeQueue.ts | 16 +- 9 files changed, 234 insertions(+), 129 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 23c72fd30..bfbb11b1b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -83,7 +83,7 @@ The single, global state of whether the app's client-side data is safely written _Avoid_: Save state (ambiguous with Session) **Persistence Status Indicator**: -The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger Alert — and stays absent at `idle` and `saving`. Replaces scattered per-write toasts. Toasts are reserved for terminal failures: out-of-storage (quota) prompts a full client-side backup via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`), while inaccessible storage (private mode, blocked) warns without a backup since the database never opened. Recovery scope is retry (transient failures) plus the backup prompt (terminal failures) — it does not guarantee the write eventually lands. +The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger Alert — and stays absent at `idle` and `saving`. Replaces scattered per-write toasts. Clicking the Alert opens a detail dialog listing each failed collection (humanized from its storage key) and why it failed. The dialog offers a full client-side backup via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`) when storage is full (quota), but not when storage is inaccessible (private mode, blocked) since the database never opened and there is nothing to read. Recovery scope is retry (transient failures) plus the backup (terminal-quota failures) — it does not guarantee the write eventually lands. _Avoid_: Save-status indicator, save toast, save banner ## Relationships diff --git a/docs/adr/20260619-storage-layer-owns-persistence-failure.md b/docs/adr/20260619-storage-layer-owns-persistence-failure.md index 609e30c64..7bc684d62 100644 --- a/docs/adr/20260619-storage-layer-owns-persistence-failure.md +++ b/docs/adr/20260619-storage-layer-owns-persistence-failure.md @@ -2,7 +2,7 @@ - **Status:** Accepted - **Date:** 2026-06-19 -- **Related:** ADR `per-key-diff-merge-cross-tab-reconciliation` (the re-read-merge that occupies the same write-path seam this builds on); ADR `indexeddb-not-localstorage-for-persistence` (the substrate, and source of the error taxonomy). Supersedes the interim per-call-site floating-promise convention (ADR `explicit-floating-promise-convention`). Issue #1854. +- **Related:** ADR `per-key-diff-merge-cross-tab-reconciliation` (the re-read-merge that occupies the same write-path seam this builds on); ADR `indexeddb-not-localstorage-for-persistence` (the substrate, and source of the error taxonomy). Supersedes the interim per-call-site floating-promise convention (a lint-enforced code convention, not an ADR). Issue #1854. ## Context @@ -15,7 +15,7 @@ The setter currently returns `Promise`, exposing the background write as i The **storage layer owns the write, so it owns the failure.** Concretely: 1. **The persisted setter returns `void` again.** Failure is handled internally; there is no promise for callers to mishandle. This is a deliberately _deep_ module — a trivial interface (`set(atom, next)`) hiding substantial machinery (queue, retry, taxonomy, merge, status). -2. **Persistence Status is a single, global, three-state model** — `idle | saving | failed` — aggregated across all per-key write queues (any terminal → `failed`; else any in-flight → `saving`; else `idle`). It is **not** per-key: users cannot act on an individual key, so the status names no key. Failure records behind the `failed` state feed a drill-in detail view. +2. **Persistence Status is a single, global, three-state model** — `idle | saving | failed` — aggregated across all per-key write queues (any terminal → `failed`; else any in-flight → `saving`; else `idle`). It is **not** per-key: users cannot act on an individual key, so the status names no key. Each failure carries a record (`key`, `reason`, `attemptCount`, `lastAttemptAt`); these feed a drill-in detail dialog opened from the standing failure Alert. 3. **The status store lives outside React/Jotai** — a plain external store (`subscribe`/`getSnapshot`/`emit`). The React edge bridges it with `useSyncExternalStore`. Humanization of raw `{ key, reason, ... }` records (including any engine-specific vocabulary via `useTranslations`) happens only at that React edge. 4. **Failures are classified by a pure `classifyStorageError` function** — `QuotaExceededError` and security/access errors are terminal; everything else (including unknown `error.name`) is retryable up to a cap, then terminal. Retryable-by-default is the safer error: a transient wrongly called terminal loses recoverable data, while a terminal wrongly retried only wastes a few capped backoff cycles. 5. **A per-key write queue** coalesces rapid successive writes, retries transient failures with backoff, and reports outcomes into the status store. Its `flush` body is the only place that touches IndexedDB. @@ -34,9 +34,9 @@ The depth is hidden behind composition, not crammed into one file: `classifyStor ## Consequences - **The ~36 call sites lose their floating-promise handling** — the interim convention is superseded, and that migration is expected to be largely undone. Net caller code shrinks. -- **Tests can no longer await a per-write promise.** They synchronize on a layer-level `waitForPersistenceIdle()` derived from the status store — a better signal that survives coalescing/retry and exercises the production status path. +- **Tests can no longer await a per-write promise.** They synchronize on a layer-level `persistenceStatusStore.waitForIdle()` derived from the status store — a better signal that survives coalescing/retry and exercises the production status path. - **Status is strictly per-tab.** Consistent with the substrate ADR's "no cross-tab sync primitives," a failed write in one tab shows `failed` there regardless of other tabs, and clears only when that tab itself successfully flushes the key. Honest about whose edit is at risk. -- **Recovery is retry + backup prompt, not a write guarantee.** Terminal quota toasts point at the full backup (`saveLocalForageToFile`), which is read-mostly and so remains viable under quota pressure. We do **not** block reload (`beforeunload`). +- **Recovery is retry + backup, not a write guarantee.** The failure Alert opens a detail dialog; for terminal-quota failures the dialog offers a full backup (`saveLocalForageToFile`), which is read-mostly and so remains viable under quota pressure. Terminal-access failures (private mode, blocked) offer no backup — IndexedDB never opened, so there is nothing to read. We do **not** block reload (`beforeunload`). - **This effort and the cross-tab merge are orthogonal**, meeting only at the `flush` seam — either can ship first without blocking the other. - **Every IndexedDB write routes through one shared queue, including the Active Connection breadcrumb.** The queue/status are not embedded in `atomWithLocalForage`; they live in a shared `persistThroughQueue` helper that wraps the localForage write itself. Both `atomWithLocalForage` and the per-tab active-connection path (whose synchronous sessionStorage write stays outside the queue) call it, so all IndexedDB writes feed one global status. This dropped the special-case that would have excluded the breadcrumb. - **The `void`-setter change lives in the shared `createWriteThroughAtom`**, which the active-connection path also uses. No production call site awaited the old promise (only tests did), so the migration was mechanical. The test seam moved to a layer-level `waitForIdle()` on the status store, replacing per-write promise awaits across the persistence test helpers and the active-connection tests. diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 893e18e75..30be6588a 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -7,6 +7,7 @@ This page contains workarounds for common issues and information on how to diagn - [Docker Container Issues](#docker-container-issues) - [Schema Sync Fails](#schema-sync-fails) - [Save & Load Configuration](#save--load-configuration) +- [Graph Explorer Can't Save Your Changes](#graph-explorer-cant-save-your-changes) - [Gathering SageMaker Logs](#gathering-sagemaker-logs) ## Docker Container Issues @@ -131,6 +132,17 @@ To save the configuration data: This configuration can be restored using the "Load Configuration" button in the same settings page. +## Graph Explorer Can't Save Your Changes + +Graph Explorer stores all of your data — connections, schema, styling, and exploration sessions — in your browser, not on the server. When it can't write that data to browser storage, a "Couldn't save your changes" indicator appears in the navigation bar. Click it to see which data failed to save and why. + +There are two common causes: + +- **Browser storage is full.** Graph Explorer has run out of space to save changes. The detail dialog offers a "Download backup" button — use it to export your data to a file (the same format as [Save & Load Configuration](#save--load-configuration)), then free up browser storage and reload the page. You can restore the file afterward with "Load Configuration". Exploration sessions are the largest data and the most likely cause; clearing old sessions or other sites' storage frees space. +- **Storage is blocked or inaccessible.** Graph Explorer can't access browser storage at all, so changes won't be saved for the session. This commonly happens in private/incognito windows or when browser settings or policy block storage for the site. Open Graph Explorer in a normal window, or allow storage for the site, to persist your work. + +Transient storage hiccups are retried automatically, so the indicator only appears for failures that could not be recovered. + ## Gathering SageMaker Logs The Graph Explorer proxy server outputs log statements to standard out. By default, these logs will be forwarded to CloudWatch if the Notebook has the proper permissions. diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx index 632c94faf..ad2bc76e3 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx @@ -1,12 +1,17 @@ // @vitest-environment happy-dom import { act, render, screen } from "@testing-library/react"; -import { toast } from "sonner"; +import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, test } from "vitest"; +import { TooltipProvider } from "@/components"; import { persistenceStatusStore } from "@/core/StateProvider/persistence"; import { PersistenceStatusIndicator } from "./PersistenceStatusIndicator"; +function renderIndicator() { + return render(, { wrapper: TooltipProvider }); +} + // The indicator reads the app-wide singleton store. Return it to idle between // tests by marking every key this suite touches as saved, which clears both // in-flight and failed state for that key. @@ -17,58 +22,67 @@ afterEach(() => { }); }); +function fail(key: string, reason: "terminal-quota" | "terminal-access") { + act(() => persistenceStatusStore.markFailed(key, reason, 1)); +} + describe("PersistenceStatusIndicator", () => { test("shows nothing while idle", () => { - render(); + renderIndicator(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); test("shows nothing while a write is merely in flight", () => { - render(); + renderIndicator(); act(() => persistenceStatusStore.markSaving("configuration")); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); - test("shows a failure message when a write fails terminally", () => { - render(); + test("shows a standing alert when a write fails terminally", () => { + renderIndicator(); - act(() => - persistenceStatusStore.markFailed("configuration", "terminal-access"), - ); + fail("configuration", "terminal-access"); expect(screen.getByRole("alert")).toHaveTextContent(/couldn.t save/i); }); - test("prompts a backup when a write fails for lack of storage", () => { - render(); + test("opens a detail dialog listing the failed data and reason", async () => { + const user = userEvent.setup(); + renderIndicator(); + + fail("configuration", "terminal-access"); + await user.click(screen.getByRole("alert")); + + const dialog = screen.getByRole("dialog"); + // The storage key is humanized to a friendly label. + expect(dialog).toHaveTextContent(/connections/i); + expect(dialog).toHaveTextContent(/can.t access browser storage/i); + }); + + test("offers a backup in the dialog when storage is full", async () => { + const user = userEvent.setup(); + renderIndicator(); - act(() => - persistenceStatusStore.markFailed("graph-sessions", "terminal-quota"), - ); + fail("graph-sessions", "terminal-quota"); + await user.click(screen.getByRole("alert")); - expect(toast.error).toHaveBeenCalledWith( - "Browser storage is full", - expect.objectContaining({ - action: expect.objectContaining({ label: "Download backup" }), - }), - ); + expect( + screen.getByRole("button", { name: /download backup/i }), + ).toBeInTheDocument(); }); - test("warns without a backup when storage is inaccessible", () => { - render(); + test("offers no backup when storage is merely inaccessible", async () => { + const user = userEvent.setup(); + renderIndicator(); - act(() => - persistenceStatusStore.markFailed("configuration", "terminal-access"), - ); + fail("configuration", "terminal-access"); + await user.click(screen.getByRole("alert")); - // A backup is useless here — IndexedDB never opened, so there is nothing to - // read. The toast warns but offers no action. - expect(toast.error).toHaveBeenCalledWith( - "Can't save to browser storage", - expect.not.objectContaining({ action: expect.anything() }), - ); + expect( + screen.queryByRole("button", { name: /download backup/i }), + ).not.toBeInTheDocument(); }); }); diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx index 97cd79125..613cde229 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx @@ -1,74 +1,128 @@ import localforage from "localforage"; import { CircleAlertIcon } from "lucide-react"; -import { useEffect } from "react"; -import { toast } from "sonner"; -import { Alert, AlertTitle } from "@/components/Alert"; +import type { PersistenceFailure } from "@/core/StateProvider/persistence"; + import { saveLocalForageToFile } from "@/core/StateProvider/localDb"; import { usePersistenceStatus } from "@/core/StateProvider/persistence/usePersistenceStatus"; +import { Alert, AlertTitle } from "../Alert"; +import { Button } from "../Button"; +import { + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../Dialog"; +import { FormItem } from "../Form"; +import { Label } from "../Label"; + +/** + * Friendly, engine-neutral names for the storage keys. The store reports raw + * keys; humanizing them lives here at the React edge so the storage layer stays + * free of UI vocabulary. + */ +const STORAGE_KEY_LABELS: Record = { + configuration: "Connections", + "active-configuration": "Connections", + schema: "Schema", + "user-styling": "Style preferences", + "user-layout": "Layout preferences", + "graph-sessions": "Exploration sessions", +}; + +function labelForKey(key: string) { + return STORAGE_KEY_LABELS[key] ?? key; +} + +function describeReason(failure: PersistenceFailure) { + switch (failure.reason) { + case "terminal-quota": + return "Browser storage is full"; + case "terminal-access": + return "Can't access browser storage"; + case "retryable": + return "Couldn't be saved"; + } +} + /** * A standing warning that the app failed to write data to IndexedDB. Renders - * nothing while idle or saving — only a terminal failure surfaces. + * nothing while idle or saving — only a terminal failure surfaces, as a danger + * Alert that opens a detail dialog. * - * Toasts are reserved for terminal failures and their wording depends on the - * reason. When storage is full a backup is offered, because IndexedDB can still - * be read to capture everything that did persist. When storage is inaccessible - * (private mode, blocked by policy) no backup is offered: the database never - * opened, so there is nothing to read. + * The dialog is the single recovery surface: it lists what failed and why, and + * offers a backup download when (and only when) storage is full — IndexedDB is + * still readable then, so a backup can capture everything that did persist. When + * storage is inaccessible (private mode, blocked) no backup is offered: the + * database never opened, so there is nothing to read. */ export function PersistenceStatusIndicator() { const { status, failures } = usePersistenceStatus(); - const isOutOfStorage = failures.some( - failure => failure.reason === "terminal-quota", - ); - const isStorageInaccessible = failures.some( - failure => failure.reason === "terminal-access", - ); - - useEffect(() => { - if (!isOutOfStorage) { - return; - } - toast.error("Browser storage is full", { - // Stable id so a recurring failure replaces the existing toast rather than - // stacking a duplicate (the indicator remounts on every route change). - id: "persistence-quota", - description: - "Graph Explorer has run out of space to save changes in this browser. Download a backup so you don't lose your work.", - // Data loss is at stake, so this stays until the user acts on or dismisses - // it rather than disappearing on the Toaster's default timeout. - duration: Infinity, - action: { - label: "Download backup", - onClick: () => void saveLocalForageToFile(localforage), - }, - }); - }, [isOutOfStorage]); - - useEffect(() => { - if (!isStorageInaccessible) { - return; - } - toast.error("Can't save to browser storage", { - // Stable id so a recurring failure replaces the existing toast rather than - // stacking a duplicate (the indicator remounts on every route change). - id: "persistence-access", - description: - "Graph Explorer can't access browser storage, so your changes won't be saved this session. This often happens in private browsing or when storage is blocked.", - duration: Infinity, - }); - }, [isStorageInaccessible]); - if (status !== "failed") { return null; } + const canBackUp = failures.some( + failure => failure.reason === "terminal-quota", + ); + return ( - - - Couldn't save your changes - + + + + + Couldn't save your changes + + + + + Couldn't save your changes + + + {canBackUp ? ( +

+ Your browser is out of storage. Download a backup so you + don't lose your work, then free up space and reload. +

+ ) : ( +

+ Graph Explorer can't access browser storage, so these changes + won't be saved this session. This often happens in private + browsing or when storage is blocked. +

+ )} + {failures.map(failure => ( + + +
+ {describeReason(failure)} +
+
+ ))} +
+ + {canBackUp ? ( + + ) : null} + + + + +
+
); } diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts index fa480c417..c638d5755 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts @@ -27,22 +27,28 @@ describe("persistenceStatusStore", () => { }); test("a terminal failure takes precedence over other in-flight writes", () => { - const store = createPersistenceStatusStore(); + const failedAt = new Date("2026-06-22T00:00:00Z"); + const store = createPersistenceStatusStore({ now: () => failedAt }); store.markSaving("schema"); - store.markFailed("configuration", "terminal-quota"); + store.markFailed("configuration", "terminal-quota", 3); const snapshot = store.getSnapshot(); expect(snapshot.status).toBe("failed"); expect(snapshot.failures).toStrictEqual([ - { key: "configuration", reason: "terminal-quota" }, + { + key: "configuration", + reason: "terminal-quota", + attemptCount: 3, + lastAttemptAt: failedAt, + }, ]); }); test("a failed key clears on its next successful write", () => { const store = createPersistenceStatusStore(); - store.markFailed("user-styling", "terminal-quota"); + store.markFailed("user-styling", "terminal-quota", 1); store.markSaved("user-styling"); expect(store.getSnapshot()).toStrictEqual({ @@ -54,14 +60,14 @@ describe("persistenceStatusStore", () => { test("stays failed while another key still has an outstanding failure", () => { const store = createPersistenceStatusStore(); - store.markFailed("schema", "terminal-access"); - store.markFailed("user-styling", "terminal-quota"); + store.markFailed("schema", "terminal-access", 1); + store.markFailed("user-styling", "terminal-quota", 2); store.markSaved("schema"); const snapshot = store.getSnapshot(); expect(snapshot.status).toBe("failed"); - expect(snapshot.failures).toStrictEqual([ - { key: "user-styling", reason: "terminal-quota" }, + expect(snapshot.failures).toMatchObject([ + { key: "user-styling", reason: "terminal-quota", attemptCount: 2 }, ]); }); @@ -99,7 +105,7 @@ describe("persistenceStatusStore", () => { store.markSaving("schema"); const idle = store.waitForIdle(); - store.markFailed("schema", "terminal-quota"); + store.markFailed("schema", "terminal-quota", 1); await expect(idle).resolves.toBeUndefined(); }); @@ -116,7 +122,7 @@ describe("persistenceStatusStore", () => { // One key fails terminally, but the other is still draining. Status flips to // "failed", yet a write is genuinely still in flight, so idle must wait. - store.markFailed("configuration", "terminal-quota"); + store.markFailed("configuration", "terminal-quota", 1); await Promise.resolve(); expect(resolved).toBe(false); diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts index 3f6d5df49..040422d90 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts @@ -17,6 +17,10 @@ export type PersistenceStatus = "idle" | "saving" | "failed"; export interface PersistenceFailure { key: string; reason: StorageErrorClassification; + /** How many attempts were made before giving up (including the first try). */ + attemptCount: number; + /** When the final, failed attempt occurred. */ + lastAttemptAt: Date; } export interface PersistenceStatusSnapshot { @@ -32,7 +36,11 @@ export interface PersistenceStatusStore { /** A write for `key` landed durably; clears any prior failure for that key. */ markSaved(key: string): void; /** A write for `key` failed terminally and will not be retried. */ - markFailed(key: string, reason: StorageErrorClassification): void; + markFailed( + key: string, + reason: StorageErrorClassification, + attemptCount: number, + ): void; /** * Resolves once no write is in flight — i.e. status has settled to `idle` or * `failed`. The test seam that replaces awaiting a per-write promise. @@ -40,6 +48,11 @@ export interface PersistenceStatusStore { waitForIdle(): Promise; } +interface PersistenceStatusStoreConfig { + /** Supplies the timestamp stamped on a failure. Injectable for tests. */ + now?: () => Date; +} + /** * Creates a persistence-status store: a plain external store (outside * React/Jotai) that aggregates per-key write outcomes into one global status. @@ -47,9 +60,11 @@ export interface PersistenceStatusStore { * Aggregation precedence: any terminal failure → `failed`; else any in-flight * key → `saving`; else `idle`. */ -export function createPersistenceStatusStore(): PersistenceStatusStore { +export function createPersistenceStatusStore({ + now = () => new Date(), +}: PersistenceStatusStoreConfig = {}): PersistenceStatusStore { const inFlightKeys = new Set(); - const failuresByKey = new Map(); + const failuresByKey = new Map(); const listeners = new Set<() => void>(); // Resolvers waiting for all in-flight writes to drain. Tracked separately // from `listeners` because idleness keys off the in-flight count, not the @@ -77,11 +92,7 @@ export function createPersistenceStatusStore(): PersistenceStatusStore { const current = snapshot.failures; return ( next.length === current.length && - next.every( - (failure, index) => - failure.key === current[index].key && - failure.reason === current[index].reason, - ) + next.every((failure, index) => failure === current[index]) ); } @@ -91,10 +102,7 @@ export function createPersistenceStatusStore(): PersistenceStatusStore { : inFlightKeys.size ? "saving" : "idle"; - const failures = [...failuresByKey].map(([key, reason]) => ({ - key, - reason, - })); + const failures = [...failuresByKey.values()]; if (status === snapshot.status && failuresEqual(failures)) { return; @@ -125,10 +133,17 @@ export function createPersistenceStatusStore(): PersistenceStatusStore { recompute(); flushIdleWaitersIfDrained(); }, - markFailed(key, reason) { - logger.debug(`[persistence] failed "${key}" (${reason})`); + markFailed(key, reason, attemptCount) { + logger.debug( + `[persistence] failed "${key}" (${reason}, ${attemptCount} attempts)`, + ); inFlightKeys.delete(key); - failuresByKey.set(key, reason); + failuresByKey.set(key, { + key, + reason, + attemptCount, + lastAttemptAt: now(), + }); recompute(); flushIdleWaitersIfDrained(); }, diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts index b69da2109..76eb576f8 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts @@ -75,10 +75,10 @@ describe("createWriteQueue", () => { expect(flush).toHaveBeenCalledTimes(1); expect(delay).not.toHaveBeenCalled(); - expect(store.getSnapshot()).toStrictEqual({ - status: "failed", - failures: [{ key: "graph-sessions", reason: "terminal-quota" }], - }); + expect(store.getSnapshot().status).toBe("failed"); + expect(store.getSnapshot().failures).toMatchObject([ + { key: "graph-sessions", reason: "terminal-quota", attemptCount: 1 }, + ]); }); test("escalates a persistently transient failure to terminal after the cap", async () => { @@ -92,10 +92,10 @@ describe("createWriteQueue", () => { await store.waitForIdle(); expect(flush).toHaveBeenCalledTimes(3); - expect(store.getSnapshot()).toStrictEqual({ - status: "failed", - failures: [{ key: "configuration", reason: "retryable" }], - }); + expect(store.getSnapshot().status).toBe("failed"); + expect(store.getSnapshot().failures).toMatchObject([ + { key: "configuration", reason: "retryable", attemptCount: 3 }, + ]); }); test("runs writes for different keys independently", async () => { @@ -109,7 +109,7 @@ describe("createWriteQueue", () => { await store.waitForIdle(); expect(configFlush).toHaveBeenCalledTimes(1); - expect(store.getSnapshot().failures).toStrictEqual([ + expect(store.getSnapshot().failures).toMatchObject([ { key: "schema", reason: "terminal-quota" }, ]); }); diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts index ce6606092..623758b81 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts @@ -53,10 +53,14 @@ export function createWriteQueue({ const running = new Map>(); const pending = new Map(); + /** A terminal failure plus how many attempts were made before giving up. */ + interface TerminalFailure { + reason: StorageErrorClassification; + attemptCount: number; + } + /** Runs one flush with retry, returning the terminal failure if it gives up. */ - async function runWithRetry( - flush: Flush, - ): Promise { + async function runWithRetry(flush: Flush): Promise { for (let attempt = 0; attempt < maxAttempts; attempt++) { try { await flush(); @@ -65,7 +69,7 @@ export function createWriteQueue({ const classification = classifyStorageError(error); const isLastAttempt = attempt === maxAttempts - 1; if (classification !== "retryable" || isLastAttempt) { - return classification; + return { reason: classification, attemptCount: attempt + 1 }; } await delay(attempt); } @@ -75,7 +79,7 @@ export function createWriteQueue({ async function drain(key: string) { store.markSaving(key); - let failure: StorageErrorClassification | null = null; + let failure: TerminalFailure | null = null; let next = pending.get(key); while (next) { @@ -86,7 +90,7 @@ export function createWriteQueue({ running.delete(key); if (failure) { - store.markFailed(key, failure); + store.markFailed(key, failure.reason, failure.attemptCount); } else { store.markSaved(key); } From 95b192905b1c247cf807f4a3d72e34e610ddcab8 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 13:42:57 -0500 Subject: [PATCH 15/25] Add debug actions to force persistence failures with and without backup --- .../src/core/StateProvider/persistence/index.ts | 17 +++++++++++++++++ .../ConnectionDetail/ConnectionDetail.tsx | 7 +++++++ 2 files changed, 24 insertions(+) diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/index.ts b/packages/graph-explorer/src/core/StateProvider/persistence/index.ts index c6392ecb9..4a8458c5b 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/index.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/index.ts @@ -24,3 +24,20 @@ const writeQueue = createWriteQueue({ store: persistenceStatusStore }); export function persistThroughQueue(key: string, flush: Flush): void { writeQueue.enqueue(key, flush); } + +/** + * Forces a terminal write failure through the real queue, for exercising the + * Persistence Status Indicator from the debug actions. Drives the genuine path + * (classify → markFailed → status), so the indicator and its detail dialog + * behave exactly as they would for a real failure. + * + * @param kind `"quota"` raises a `QuotaExceededError` (offers a backup); + * `"access"` raises a `SecurityError` (no backup). + */ +export function debugForcePersistenceFailure(kind: "quota" | "access"): void { + const key = kind === "quota" ? "graph-sessions" : "configuration"; + const errorName = kind === "quota" ? "QuotaExceededError" : "SecurityError"; + persistThroughQueue(key, () => + Promise.reject(new DOMException("Forced debug failure", errorName)), + ); +} diff --git a/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx b/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx index 481cfc215..293f9cbb3 100644 --- a/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx +++ b/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx @@ -50,6 +50,7 @@ import { useHasActiveSchema, useMaybeActiveSchema, } from "@/core"; +import { debugForcePersistenceFailure } from "@/core/StateProvider/persistence"; import { useDeleteActiveConfiguration } from "@/hooks/useDeleteConfig"; import useEntitiesCounts from "@/hooks/useEntitiesCounts"; import { useCancelSchemaSync, useSchemaSync } from "@/hooks/useSchemaSync"; @@ -462,6 +463,12 @@ function DebugActions() { Reset Vertex Totals + + From 740d60db140ecffd29f9c99a38a83161e03b4bdd Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 14:01:20 -0500 Subject: [PATCH 16/25] Give failure alert a Details button and show raw failure records as JSON --- CONTEXT.md | 2 +- .../PersistenceStatusIndicator.test.tsx | 15 +- .../PersistenceStatusIndicator.tsx | 142 +++++++----------- 3 files changed, 66 insertions(+), 93 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index bfbb11b1b..8fa7db2e8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -83,7 +83,7 @@ The single, global state of whether the app's client-side data is safely written _Avoid_: Save state (ambiguous with Session) **Persistence Status Indicator**: -The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger Alert — and stays absent at `idle` and `saving`. Replaces scattered per-write toasts. Clicking the Alert opens a detail dialog listing each failed collection (humanized from its storage key) and why it failed. The dialog offers a full client-side backup via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`) when storage is full (quota), but not when storage is inaccessible (private mode, blocked) since the database never opened and there is nothing to read. Recovery scope is retry (transient failures) plus the backup (terminal-quota failures) — it does not guarantee the write eventually lands. +The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger Alert with a "Details" button — and stays absent at `idle` and `saving`. Replaces scattered per-write toasts. The Details button opens a dialog showing the raw failure records (key, reason, attempt count, last attempt) in a read-only JSON editor. The dialog offers a full client-side backup via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`) when storage is full (quota), but not when storage is inaccessible (private mode, blocked) since the database never opened and there is nothing to read. Recovery scope is retry (transient failures) plus the backup (terminal-quota failures) — it does not guarantee the write eventually lands. _Avoid_: Save-status indicator, save toast, save banner ## Relationships diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx index ad2bc76e3..00e170fc2 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx @@ -49,17 +49,16 @@ describe("PersistenceStatusIndicator", () => { expect(screen.getByRole("alert")).toHaveTextContent(/couldn.t save/i); }); - test("opens a detail dialog listing the failed data and reason", async () => { + test("opens a detail dialog from the Details button", async () => { const user = userEvent.setup(); renderIndicator(); fail("configuration", "terminal-access"); - await user.click(screen.getByRole("alert")); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - const dialog = screen.getByRole("dialog"); - // The storage key is humanized to a friendly label. - expect(dialog).toHaveTextContent(/connections/i); - expect(dialog).toHaveTextContent(/can.t access browser storage/i); + await user.click(screen.getByRole("button", { name: /details/i })); + + expect(screen.getByRole("dialog")).toHaveTextContent(/couldn.t save/i); }); test("offers a backup in the dialog when storage is full", async () => { @@ -67,7 +66,7 @@ describe("PersistenceStatusIndicator", () => { renderIndicator(); fail("graph-sessions", "terminal-quota"); - await user.click(screen.getByRole("alert")); + await user.click(screen.getByRole("button", { name: /details/i })); expect( screen.getByRole("button", { name: /download backup/i }), @@ -79,7 +78,7 @@ describe("PersistenceStatusIndicator", () => { renderIndicator(); fail("configuration", "terminal-access"); - await user.click(screen.getByRole("alert")); + await user.click(screen.getByRole("button", { name: /details/i })); expect( screen.queryByRole("button", { name: /download backup/i }), diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx index 613cde229..d8ef9a6fb 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx @@ -1,13 +1,12 @@ import localforage from "localforage"; -import { CircleAlertIcon } from "lucide-react"; - -import type { PersistenceFailure } from "@/core/StateProvider/persistence"; +import { CircleAlertIcon, InfoIcon } from "lucide-react"; import { saveLocalForageToFile } from "@/core/StateProvider/localDb"; import { usePersistenceStatus } from "@/core/StateProvider/persistence/usePersistenceStatus"; import { Alert, AlertTitle } from "../Alert"; import { Button } from "../Button"; +import { CodeEditor } from "../CodeEditor"; import { Dialog, DialogBody, @@ -21,44 +20,15 @@ import { import { FormItem } from "../Form"; import { Label } from "../Label"; -/** - * Friendly, engine-neutral names for the storage keys. The store reports raw - * keys; humanizing them lives here at the React edge so the storage layer stays - * free of UI vocabulary. - */ -const STORAGE_KEY_LABELS: Record = { - configuration: "Connections", - "active-configuration": "Connections", - schema: "Schema", - "user-styling": "Style preferences", - "user-layout": "Layout preferences", - "graph-sessions": "Exploration sessions", -}; - -function labelForKey(key: string) { - return STORAGE_KEY_LABELS[key] ?? key; -} - -function describeReason(failure: PersistenceFailure) { - switch (failure.reason) { - case "terminal-quota": - return "Browser storage is full"; - case "terminal-access": - return "Can't access browser storage"; - case "retryable": - return "Couldn't be saved"; - } -} - /** * A standing warning that the app failed to write data to IndexedDB. Renders * nothing while idle or saving — only a terminal failure surfaces, as a danger - * Alert that opens a detail dialog. + * Alert with a "Details" button that opens a detail dialog. * - * The dialog is the single recovery surface: it lists what failed and why, and - * offers a backup download when (and only when) storage is full — IndexedDB is - * still readable then, so a backup can capture everything that did persist. When - * storage is inaccessible (private mode, blocked) no backup is offered: the + * The dialog is the single recovery surface: it shows the raw failure records + * and offers a backup download when (and only when) storage is full — IndexedDB + * is still readable then, so a backup can capture everything that did persist. + * When storage is inaccessible (private mode, blocked) no backup is offered: the * database never opened, so there is nothing to read. */ export function PersistenceStatusIndicator() { @@ -73,56 +43,60 @@ export function PersistenceStatusIndicator() { ); return ( - - - - - Couldn't save your changes - - - - - Couldn't save your changes - - - {canBackUp ? ( -

- Your browser is out of storage. Download a backup so you - don't lose your work, then free up space and reload. -

- ) : ( + + + Couldn't save your changes + + + + + + + Couldn't save your changes + +

- Graph Explorer can't access browser storage, so these changes - won't be saved this session. This often happens in private - browsing or when storage is blocked. + {canBackUp + ? "Your browser is out of storage. Download a backup so you don't lose your work, then free up space and reload." + : "Graph Explorer can't access browser storage, so these changes won't be saved this session. This often happens in private browsing or when storage is blocked."}

- )} - {failures.map(failure => ( - - -
- {describeReason(failure)} + + +
+
- ))} - - - {canBackUp ? ( - - ) : null} - - - - - -
+
+ + {canBackUp ? ( + + ) : null} + + + + +
+
+ ); } From 1e458ab3bd3253820a600b5bd2e44db13cc4e9f5 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 14:16:30 -0500 Subject: [PATCH 17/25] Add AlertAction slot from shadcn and use it for the failure Details button --- .../graph-explorer/src/components/Alert.tsx | 23 +++++++++++++++---- .../PersistenceStatusIndicator.tsx | 16 +++++++------ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/graph-explorer/src/components/Alert.tsx b/packages/graph-explorer/src/components/Alert.tsx index 3f3dd393e..62e330e73 100644 --- a/packages/graph-explorer/src/components/Alert.tsx +++ b/packages/graph-explorer/src/components/Alert.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { cn } from "@/utils"; const alertVariants = cva({ - base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + base: "group/alert relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-data-[slot=alert-action]:pr-16 has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:row-span-2 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", variants: { variant: { default: "bg-card text-card-foreground", @@ -39,7 +39,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription, AlertAction }; diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx index d8ef9a6fb..1502bb5bc 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx @@ -4,7 +4,7 @@ import { CircleAlertIcon, InfoIcon } from "lucide-react"; import { saveLocalForageToFile } from "@/core/StateProvider/localDb"; import { usePersistenceStatus } from "@/core/StateProvider/persistence/usePersistenceStatus"; -import { Alert, AlertTitle } from "../Alert"; +import { Alert, AlertAction, AlertTitle } from "../Alert"; import { Button } from "../Button"; import { CodeEditor } from "../CodeEditor"; import { @@ -47,12 +47,14 @@ export function PersistenceStatusIndicator() { Couldn't save your changes - - - + + + + + Couldn't save your changes From b1a8211c2e06b7b79be48538efeb4a6591de70e8 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 15:07:55 -0500 Subject: [PATCH 18/25] Reserve room for the Details button so it doesn't overlap the alert title --- .../PersistenceStatusIndicator/PersistenceStatusIndicator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx index 1502bb5bc..ea5e47e95 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx @@ -43,7 +43,7 @@ export function PersistenceStatusIndicator() { ); return ( - + Couldn't save your changes From a35b74a09f082e8fa59850e1b4365d4e220a38a6 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 16:05:33 -0500 Subject: [PATCH 19/25] Make failure indicator a danger button; drop unused Alert changes The indicator is now a rounded danger "Changes not saved" button that opens the detail dialog, rather than an Alert with an action slot. Revert the AlertAction addition to the shared Alert component since nothing uses it anymore, and sync the docs. --- CONTEXT.md | 2 +- ...-storage-layer-owns-persistence-failure.md | 4 +- .../graph-explorer/src/components/Alert.tsx | 23 +--- .../src/components/Button/Button.tsx | 2 +- .../PersistenceStatusIndicator.test.tsx | 28 +++-- .../PersistenceStatusIndicator.tsx | 109 ++++++++---------- 6 files changed, 78 insertions(+), 90 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 8fa7db2e8..efc8d5fb8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -83,7 +83,7 @@ The single, global state of whether the app's client-side data is safely written _Avoid_: Save state (ambiguous with Session) **Persistence Status Indicator**: -The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger Alert with a "Details" button — and stays absent at `idle` and `saving`. Replaces scattered per-write toasts. The Details button opens a dialog showing the raw failure records (key, reason, attempt count, last attempt) in a read-only JSON editor. The dialog offers a full client-side backup via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`) when storage is full (quota), but not when storage is inaccessible (private mode, blocked) since the database never opened and there is nothing to read. Recovery scope is retry (transient failures) plus the backup (terminal-quota failures) — it does not guarantee the write eventually lands. +The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger "Changes not saved" button — and stays absent at `idle` and `saving`. Replaces scattered per-write toasts. Clicking it opens a dialog showing the raw failure records (key, reason, attempt count, last attempt) in a read-only JSON editor. The dialog offers a full client-side backup via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`) when storage is full (quota), but not when storage is inaccessible (private mode, blocked) since the database never opened and there is nothing to read. Recovery scope is retry (transient failures) plus the backup (terminal-quota failures) — it does not guarantee the write eventually lands. _Avoid_: Save-status indicator, save toast, save banner ## Relationships diff --git a/docs/adr/20260619-storage-layer-owns-persistence-failure.md b/docs/adr/20260619-storage-layer-owns-persistence-failure.md index 7bc684d62..c948d8408 100644 --- a/docs/adr/20260619-storage-layer-owns-persistence-failure.md +++ b/docs/adr/20260619-storage-layer-owns-persistence-failure.md @@ -15,7 +15,7 @@ The setter currently returns `Promise`, exposing the background write as i The **storage layer owns the write, so it owns the failure.** Concretely: 1. **The persisted setter returns `void` again.** Failure is handled internally; there is no promise for callers to mishandle. This is a deliberately _deep_ module — a trivial interface (`set(atom, next)`) hiding substantial machinery (queue, retry, taxonomy, merge, status). -2. **Persistence Status is a single, global, three-state model** — `idle | saving | failed` — aggregated across all per-key write queues (any terminal → `failed`; else any in-flight → `saving`; else `idle`). It is **not** per-key: users cannot act on an individual key, so the status names no key. Each failure carries a record (`key`, `reason`, `attemptCount`, `lastAttemptAt`); these feed a drill-in detail dialog opened from the standing failure Alert. +2. **Persistence Status is a single, global, three-state model** — `idle | saving | failed` — aggregated across all per-key write queues (any terminal → `failed`; else any in-flight → `saving`; else `idle`). It is **not** per-key: users cannot act on an individual key, so the status names no key. Each failure carries a record (`key`, `reason`, `attemptCount`, `lastAttemptAt`); these feed a drill-in detail dialog opened from the standing failure indicator. 3. **The status store lives outside React/Jotai** — a plain external store (`subscribe`/`getSnapshot`/`emit`). The React edge bridges it with `useSyncExternalStore`. Humanization of raw `{ key, reason, ... }` records (including any engine-specific vocabulary via `useTranslations`) happens only at that React edge. 4. **Failures are classified by a pure `classifyStorageError` function** — `QuotaExceededError` and security/access errors are terminal; everything else (including unknown `error.name`) is retryable up to a cap, then terminal. Retryable-by-default is the safer error: a transient wrongly called terminal loses recoverable data, while a terminal wrongly retried only wastes a few capped backoff cycles. 5. **A per-key write queue** coalesces rapid successive writes, retries transient failures with backoff, and reports outcomes into the status store. Its `flush` body is the only place that touches IndexedDB. @@ -36,7 +36,7 @@ The depth is hidden behind composition, not crammed into one file: `classifyStor - **The ~36 call sites lose their floating-promise handling** — the interim convention is superseded, and that migration is expected to be largely undone. Net caller code shrinks. - **Tests can no longer await a per-write promise.** They synchronize on a layer-level `persistenceStatusStore.waitForIdle()` derived from the status store — a better signal that survives coalescing/retry and exercises the production status path. - **Status is strictly per-tab.** Consistent with the substrate ADR's "no cross-tab sync primitives," a failed write in one tab shows `failed` there regardless of other tabs, and clears only when that tab itself successfully flushes the key. Honest about whose edit is at risk. -- **Recovery is retry + backup, not a write guarantee.** The failure Alert opens a detail dialog; for terminal-quota failures the dialog offers a full backup (`saveLocalForageToFile`), which is read-mostly and so remains viable under quota pressure. Terminal-access failures (private mode, blocked) offer no backup — IndexedDB never opened, so there is nothing to read. We do **not** block reload (`beforeunload`). +- **Recovery is retry + backup, not a write guarantee.** The failure indicator opens a detail dialog; for terminal-quota failures the dialog offers a full backup (`saveLocalForageToFile`), which is read-mostly and so remains viable under quota pressure. Terminal-access failures (private mode, blocked) offer no backup — IndexedDB never opened, so there is nothing to read. We do **not** block reload (`beforeunload`). - **This effort and the cross-tab merge are orthogonal**, meeting only at the `flush` seam — either can ship first without blocking the other. - **Every IndexedDB write routes through one shared queue, including the Active Connection breadcrumb.** The queue/status are not embedded in `atomWithLocalForage`; they live in a shared `persistThroughQueue` helper that wraps the localForage write itself. Both `atomWithLocalForage` and the per-tab active-connection path (whose synchronous sessionStorage write stays outside the queue) call it, so all IndexedDB writes feed one global status. This dropped the special-case that would have excluded the breadcrumb. - **The `void`-setter change lives in the shared `createWriteThroughAtom`**, which the active-connection path also uses. No production call site awaited the old promise (only tests did), so the migration was mechanical. The test seam moved to a layer-level `waitForIdle()` on the status store, replacing per-write promise awaits across the persistence test helpers and the active-connection tests. diff --git a/packages/graph-explorer/src/components/Alert.tsx b/packages/graph-explorer/src/components/Alert.tsx index 62e330e73..3f3dd393e 100644 --- a/packages/graph-explorer/src/components/Alert.tsx +++ b/packages/graph-explorer/src/components/Alert.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { cn } from "@/utils"; const alertVariants = cva({ - base: "group/alert relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-data-[slot=alert-action]:pr-16 has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:row-span-2 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", variants: { variant: { default: "bg-card text-card-foreground", @@ -39,7 +39,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
) { - return ( -
- ); -} - -export { Alert, AlertTitle, AlertDescription, AlertAction }; +export { Alert, AlertTitle, AlertDescription }; diff --git a/packages/graph-explorer/src/components/Button/Button.tsx b/packages/graph-explorer/src/components/Button/Button.tsx index 17cddeaf4..b0557cab8 100644 --- a/packages/graph-explorer/src/components/Button/Button.tsx +++ b/packages/graph-explorer/src/components/Button/Button.tsx @@ -6,7 +6,7 @@ import { cn } from "@/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "../Tooltip"; const buttonStyles = cva({ - base: "inline-flex items-center justify-center gap-2 font-medium focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 disabled:saturate-0 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:saturate-0 [&_svg]:pointer-events-none [&_svg]:shrink-0", + base: "inline-flex items-center justify-center gap-2 leading-none font-medium focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 disabled:saturate-0 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:saturate-0 [&_svg]:pointer-events-none [&_svg]:shrink-0", variants: { variant: { primary: diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx index 00e170fc2..3c80bb9b1 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx @@ -30,7 +30,9 @@ describe("PersistenceStatusIndicator", () => { test("shows nothing while idle", () => { renderIndicator(); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /changes not saved/i }), + ).not.toBeInTheDocument(); }); test("shows nothing while a write is merely in flight", () => { @@ -38,25 +40,31 @@ describe("PersistenceStatusIndicator", () => { act(() => persistenceStatusStore.markSaving("configuration")); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /changes not saved/i }), + ).not.toBeInTheDocument(); }); - test("shows a standing alert when a write fails terminally", () => { + test("shows a standing indicator when a write fails terminally", () => { renderIndicator(); fail("configuration", "terminal-access"); - expect(screen.getByRole("alert")).toHaveTextContent(/couldn.t save/i); + expect( + screen.getByRole("button", { name: /changes not saved/i }), + ).toBeInTheDocument(); }); - test("opens a detail dialog from the Details button", async () => { + test("opens a detail dialog from the indicator", async () => { const user = userEvent.setup(); renderIndicator(); fail("configuration", "terminal-access"); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /details/i })); + await user.click( + screen.getByRole("button", { name: /changes not saved/i }), + ); expect(screen.getByRole("dialog")).toHaveTextContent(/couldn.t save/i); }); @@ -66,7 +74,9 @@ describe("PersistenceStatusIndicator", () => { renderIndicator(); fail("graph-sessions", "terminal-quota"); - await user.click(screen.getByRole("button", { name: /details/i })); + await user.click( + screen.getByRole("button", { name: /changes not saved/i }), + ); expect( screen.getByRole("button", { name: /download backup/i }), @@ -78,7 +88,9 @@ describe("PersistenceStatusIndicator", () => { renderIndicator(); fail("configuration", "terminal-access"); - await user.click(screen.getByRole("button", { name: /details/i })); + await user.click( + screen.getByRole("button", { name: /changes not saved/i }), + ); expect( screen.queryByRole("button", { name: /download backup/i }), diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx index ea5e47e95..6759cbbd7 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx @@ -1,10 +1,9 @@ import localforage from "localforage"; -import { CircleAlertIcon, InfoIcon } from "lucide-react"; +import { InfoIcon } from "lucide-react"; import { saveLocalForageToFile } from "@/core/StateProvider/localDb"; import { usePersistenceStatus } from "@/core/StateProvider/persistence/usePersistenceStatus"; -import { Alert, AlertAction, AlertTitle } from "../Alert"; import { Button } from "../Button"; import { CodeEditor } from "../CodeEditor"; import { @@ -23,7 +22,7 @@ import { Label } from "../Label"; /** * A standing warning that the app failed to write data to IndexedDB. Renders * nothing while idle or saving — only a terminal failure surfaces, as a danger - * Alert with a "Details" button that opens a detail dialog. + * "Changes not saved" button that opens a detail dialog. * * The dialog is the single recovery surface: it shows the raw failure records * and offers a backup download when (and only when) storage is full — IndexedDB @@ -43,62 +42,54 @@ export function PersistenceStatusIndicator() { ); return ( - - - Couldn't save your changes - - - - + + + + Couldn't save your changes + + +

+ {canBackUp + ? "Your browser is out of storage. Download a backup so you don't lose your work, then free up space and reload." + : "Graph Explorer can't access browser storage, so these changes won't be saved this session. This often happens in private browsing or when storage is blocked."} +

+ + +
+ +
+
+
+ + {canBackUp ? ( + - -
- - - Couldn't save your changes - - -

- {canBackUp - ? "Your browser is out of storage. Download a backup so you don't lose your work, then free up space and reload." - : "Graph Explorer can't access browser storage, so these changes won't be saved this session. This often happens in private browsing or when storage is blocked."} -

- - -
- -
-
-
- - {canBackUp ? ( - - ) : null} - - - - -
-
-
+ ) : null} + + + + + +
); } From 0b04b2c0c923a4c3296478d3b8c3a2688398524d Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 17:12:24 -0500 Subject: [PATCH 20/25] Address code review: simpler store, generic recovery dialog, cleanups - waitForIdle drives off a new snapshot isSettling flag via the single subscribe path; drop the parallel idleWaiters mechanism - add store.reset(); use it for test cleanup and a Reset Save Status debug action instead of enumerating keys - detail dialog is now generic: always offers Save Configuration backup regardless of failure reason, matching the settings screen wording - write queue running set is a Set, not a Map with an unread Promise value - debug failure helper uses synthetic keys, decoupled from real atoms - revert the global leading-none button base change - sync ADR, CONTEXT.md, and troubleshooting docs --- CONTEXT.md | 2 +- ...-storage-layer-owns-persistence-failure.md | 4 +- docs/guides/troubleshooting.md | 4 +- .../src/components/Button/Button.tsx | 2 +- .../PersistenceStatusIndicator.test.tsx | 44 ++++------- .../PersistenceStatusIndicator.tsx | 35 ++++----- .../core/StateProvider/persistence/index.ts | 11 ++- .../persistenceStatusStore.test.ts | 21 +++++- .../persistence/persistenceStatusStore.ts | 74 +++++++++++-------- .../StateProvider/persistence/writeQueue.ts | 7 +- .../ConnectionDetail/ConnectionDetail.tsx | 8 +- 11 files changed, 116 insertions(+), 96 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index efc8d5fb8..c50222043 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -83,7 +83,7 @@ The single, global state of whether the app's client-side data is safely written _Avoid_: Save state (ambiguous with Session) **Persistence Status Indicator**: -The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger "Changes not saved" button — and stays absent at `idle` and `saving`. Replaces scattered per-write toasts. Clicking it opens a dialog showing the raw failure records (key, reason, attempt count, last attempt) in a read-only JSON editor. The dialog offers a full client-side backup via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`) when storage is full (quota), but not when storage is inaccessible (private mode, blocked) since the database never opened and there is nothing to read. Recovery scope is retry (transient failures) plus the backup (terminal-quota failures) — it does not guarantee the write eventually lands. +The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger "Changes not saved" button — and stays absent at `idle` and `saving`. Replaces scattered per-write toasts. Clicking it opens a dialog showing the raw failure records (key, reason, attempt count, last attempt) in a read-only JSON editor, and always offers to save the configuration to a file via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`) so the user can preserve whatever did persist regardless of why the write failed. Recovery scope is retry (transient failures) plus that backup (terminal failures) — it does not guarantee the write eventually lands. _Avoid_: Save-status indicator, save toast, save banner ## Relationships diff --git a/docs/adr/20260619-storage-layer-owns-persistence-failure.md b/docs/adr/20260619-storage-layer-owns-persistence-failure.md index c948d8408..74314fb77 100644 --- a/docs/adr/20260619-storage-layer-owns-persistence-failure.md +++ b/docs/adr/20260619-storage-layer-owns-persistence-failure.md @@ -16,7 +16,7 @@ The **storage layer owns the write, so it owns the failure.** Concretely: 1. **The persisted setter returns `void` again.** Failure is handled internally; there is no promise for callers to mishandle. This is a deliberately _deep_ module — a trivial interface (`set(atom, next)`) hiding substantial machinery (queue, retry, taxonomy, merge, status). 2. **Persistence Status is a single, global, three-state model** — `idle | saving | failed` — aggregated across all per-key write queues (any terminal → `failed`; else any in-flight → `saving`; else `idle`). It is **not** per-key: users cannot act on an individual key, so the status names no key. Each failure carries a record (`key`, `reason`, `attemptCount`, `lastAttemptAt`); these feed a drill-in detail dialog opened from the standing failure indicator. -3. **The status store lives outside React/Jotai** — a plain external store (`subscribe`/`getSnapshot`/`emit`). The React edge bridges it with `useSyncExternalStore`. Humanization of raw `{ key, reason, ... }` records (including any engine-specific vocabulary via `useTranslations`) happens only at that React edge. +3. **The status store lives outside React/Jotai** — a plain external store (`subscribe`/`getSnapshot`/`emit`). The React edge bridges it with `useSyncExternalStore`. The store emits raw `{ key, reason, attemptCount, lastAttemptAt }` records; the detail dialog currently renders them verbatim as JSON (a diagnostic surface). Any humanization (friendly labels, engine-specific vocabulary via `useTranslations`) is a React-edge concern if added later — the store stays raw and React-free. 4. **Failures are classified by a pure `classifyStorageError` function** — `QuotaExceededError` and security/access errors are terminal; everything else (including unknown `error.name`) is retryable up to a cap, then terminal. Retryable-by-default is the safer error: a transient wrongly called terminal loses recoverable data, while a terminal wrongly retried only wastes a few capped backoff cycles. 5. **A per-key write queue** coalesces rapid successive writes, retries transient failures with backoff, and reports outcomes into the status store. Its `flush` body is the only place that touches IndexedDB. @@ -36,7 +36,7 @@ The depth is hidden behind composition, not crammed into one file: `classifyStor - **The ~36 call sites lose their floating-promise handling** — the interim convention is superseded, and that migration is expected to be largely undone. Net caller code shrinks. - **Tests can no longer await a per-write promise.** They synchronize on a layer-level `persistenceStatusStore.waitForIdle()` derived from the status store — a better signal that survives coalescing/retry and exercises the production status path. - **Status is strictly per-tab.** Consistent with the substrate ADR's "no cross-tab sync primitives," a failed write in one tab shows `failed` there regardless of other tabs, and clears only when that tab itself successfully flushes the key. Honest about whose edit is at risk. -- **Recovery is retry + backup, not a write guarantee.** The failure indicator opens a detail dialog; for terminal-quota failures the dialog offers a full backup (`saveLocalForageToFile`), which is read-mostly and so remains viable under quota pressure. Terminal-access failures (private mode, blocked) offer no backup — IndexedDB never opened, so there is nothing to read. We do **not** block reload (`beforeunload`). +- **Recovery is retry + backup, not a write guarantee.** The failure indicator opens a detail dialog that always offers a full configuration backup (`saveLocalForageToFile`) — it is read-mostly, so it remains viable even under quota pressure, and it lets the user preserve whatever did persist regardless of the failure reason. We do **not** block reload (`beforeunload`). - **This effort and the cross-tab merge are orthogonal**, meeting only at the `flush` seam — either can ship first without blocking the other. - **Every IndexedDB write routes through one shared queue, including the Active Connection breadcrumb.** The queue/status are not embedded in `atomWithLocalForage`; they live in a shared `persistThroughQueue` helper that wraps the localForage write itself. Both `atomWithLocalForage` and the per-tab active-connection path (whose synchronous sessionStorage write stays outside the queue) call it, so all IndexedDB writes feed one global status. This dropped the special-case that would have excluded the breadcrumb. - **The `void`-setter change lives in the shared `createWriteThroughAtom`**, which the active-connection path also uses. No production call site awaited the old promise (only tests did), so the migration was mechanical. The test seam moved to a layer-level `waitForIdle()` on the status store, replacing per-write promise awaits across the persistence test helpers and the active-connection tests. diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 30be6588a..29666bfde 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -134,11 +134,11 @@ This configuration can be restored using the "Load Configuration" button in the ## Graph Explorer Can't Save Your Changes -Graph Explorer stores all of your data — connections, schema, styling, and exploration sessions — in your browser, not on the server. When it can't write that data to browser storage, a "Couldn't save your changes" indicator appears in the navigation bar. Click it to see which data failed to save and why. +Graph Explorer stores all of your data — connections, schema, styling, and exploration sessions — in your browser, not on the server. When it can't write that data to browser storage, a "Changes not saved" indicator appears in the navigation bar. Click it to see which data failed to save and why, and to save a backup. The dialog always offers a "Save Configuration" button — use it to export your data to a file (the same format as [Save & Load Configuration](#save--load-configuration)) so you don't lose your work, then reload the page. You can restore the file afterward with "Load Configuration". There are two common causes: -- **Browser storage is full.** Graph Explorer has run out of space to save changes. The detail dialog offers a "Download backup" button — use it to export your data to a file (the same format as [Save & Load Configuration](#save--load-configuration)), then free up browser storage and reload the page. You can restore the file afterward with "Load Configuration". Exploration sessions are the largest data and the most likely cause; clearing old sessions or other sites' storage frees space. +- **Browser storage is full.** Graph Explorer has run out of space to save changes. Free up browser storage and reload the page. Exploration sessions are the largest data and the most likely cause; clearing old sessions or other sites' storage frees space. - **Storage is blocked or inaccessible.** Graph Explorer can't access browser storage at all, so changes won't be saved for the session. This commonly happens in private/incognito windows or when browser settings or policy block storage for the site. Open Graph Explorer in a normal window, or allow storage for the site, to persist your work. Transient storage hiccups are retried automatically, so the indicator only appears for failures that could not be recovered. diff --git a/packages/graph-explorer/src/components/Button/Button.tsx b/packages/graph-explorer/src/components/Button/Button.tsx index b0557cab8..17cddeaf4 100644 --- a/packages/graph-explorer/src/components/Button/Button.tsx +++ b/packages/graph-explorer/src/components/Button/Button.tsx @@ -6,7 +6,7 @@ import { cn } from "@/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "../Tooltip"; const buttonStyles = cva({ - base: "inline-flex items-center justify-center gap-2 leading-none font-medium focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 disabled:saturate-0 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:saturate-0 [&_svg]:pointer-events-none [&_svg]:shrink-0", + base: "inline-flex items-center justify-center gap-2 font-medium focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 disabled:saturate-0 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:saturate-0 [&_svg]:pointer-events-none [&_svg]:shrink-0", variants: { variant: { primary: diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx index 3c80bb9b1..95b44e1df 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx @@ -12,14 +12,9 @@ function renderIndicator() { return render(, { wrapper: TooltipProvider }); } -// The indicator reads the app-wide singleton store. Return it to idle between -// tests by marking every key this suite touches as saved, which clears both -// in-flight and failed state for that key. -const KEYS_UNDER_TEST = ["configuration", "schema", "graph-sessions"]; +// The indicator reads the app-wide singleton store, so reset it between tests. afterEach(() => { - act(() => { - KEYS_UNDER_TEST.forEach(key => persistenceStatusStore.markSaved(key)); - }); + act(() => persistenceStatusStore.reset()); }); function fail(key: string, reason: "terminal-quota" | "terminal-access") { @@ -69,31 +64,22 @@ describe("PersistenceStatusIndicator", () => { expect(screen.getByRole("dialog")).toHaveTextContent(/couldn.t save/i); }); - test("offers a backup in the dialog when storage is full", async () => { + test("offers to save the configuration to a file regardless of failure reason", async () => { const user = userEvent.setup(); - renderIndicator(); - - fail("graph-sessions", "terminal-quota"); - await user.click( - screen.getByRole("button", { name: /changes not saved/i }), - ); - - expect( - screen.getByRole("button", { name: /download backup/i }), - ).toBeInTheDocument(); - }); - test("offers no backup when storage is merely inaccessible", async () => { - const user = userEvent.setup(); - renderIndicator(); + for (const reason of ["terminal-quota", "terminal-access"] as const) { + fail("configuration", reason); + const { unmount } = renderIndicator(); + await user.click( + screen.getByRole("button", { name: /changes not saved/i }), + ); - fail("configuration", "terminal-access"); - await user.click( - screen.getByRole("button", { name: /changes not saved/i }), - ); + expect( + screen.getByRole("button", { name: /save configuration/i }), + ).toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: /download backup/i }), - ).not.toBeInTheDocument(); + unmount(); + act(() => persistenceStatusStore.reset()); + } }); }); diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx index 6759cbbd7..2ca8a5140 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx @@ -1,5 +1,5 @@ import localforage from "localforage"; -import { InfoIcon } from "lucide-react"; +import { InfoIcon, SaveAllIcon } from "lucide-react"; import { saveLocalForageToFile } from "@/core/StateProvider/localDb"; import { usePersistenceStatus } from "@/core/StateProvider/persistence/usePersistenceStatus"; @@ -25,10 +25,8 @@ import { Label } from "../Label"; * "Changes not saved" button that opens a detail dialog. * * The dialog is the single recovery surface: it shows the raw failure records - * and offers a backup download when (and only when) storage is full — IndexedDB - * is still readable then, so a backup can capture everything that did persist. - * When storage is inaccessible (private mode, blocked) no backup is offered: the - * database never opened, so there is nothing to read. + * and always offers to save the configuration to a file, so the user can + * preserve whatever did persist regardless of why the write failed. */ export function PersistenceStatusIndicator() { const { status, failures } = usePersistenceStatus(); @@ -37,10 +35,6 @@ export function PersistenceStatusIndicator() { return null; } - const canBackUp = failures.some( - failure => failure.reason === "terminal-quota", - ); - return ( @@ -55,9 +49,9 @@ export function PersistenceStatusIndicator() {

- {canBackUp - ? "Your browser is out of storage. Download a backup so you don't lose your work, then free up space and reload." - : "Graph Explorer can't access browser storage, so these changes won't be saved this session. This often happens in private browsing or when storage is blocked."} + Graph Explorer couldn't save some changes to browser storage. + Save your configuration to a file so you don't lose your work, + then reload the page.

@@ -77,16 +71,15 @@ export function PersistenceStatusIndicator() {
- {canBackUp ? ( - - ) : null} + - +
diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/index.ts b/packages/graph-explorer/src/core/StateProvider/persistence/index.ts index 4a8458c5b..bd190ba88 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/index.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/index.ts @@ -29,15 +29,20 @@ export function persistThroughQueue(key: string, flush: Flush): void { * Forces a terminal write failure through the real queue, for exercising the * Persistence Status Indicator from the debug actions. Drives the genuine path * (classify → markFailed → status), so the indicator and its detail dialog - * behave exactly as they would for a real failure. + * behave exactly as they would for a real failure. Clear it with + * {@link debugResetPersistenceStatus}. * * @param kind `"quota"` raises a `QuotaExceededError` (offers a backup); * `"access"` raises a `SecurityError` (no backup). */ export function debugForcePersistenceFailure(kind: "quota" | "access"): void { - const key = kind === "quota" ? "graph-sessions" : "configuration"; const errorName = kind === "quota" ? "QuotaExceededError" : "SecurityError"; - persistThroughQueue(key, () => + persistThroughQueue(`debug-forced-${kind}`, () => Promise.reject(new DOMException("Forced debug failure", errorName)), ); } + +/** Clears any forced (or real) persistence failure, returning status to idle. */ +export function debugResetPersistenceStatus(): void { + persistenceStatusStore.reset(); +} diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts index c638d5755..5f35d9a96 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts @@ -6,7 +6,11 @@ describe("persistenceStatusStore", () => { test("starts idle with no failures", () => { const store = createPersistenceStatusStore(); - expect(store.getSnapshot()).toStrictEqual({ status: "idle", failures: [] }); + expect(store.getSnapshot()).toStrictEqual({ + status: "idle", + failures: [], + isSettling: false, + }); }); test("reports saving while a key is in flight", () => { @@ -54,6 +58,7 @@ describe("persistenceStatusStore", () => { expect(store.getSnapshot()).toStrictEqual({ status: "idle", failures: [], + isSettling: false, }); }); @@ -71,6 +76,20 @@ describe("persistenceStatusStore", () => { ]); }); + test("reset clears all in-flight and failed state", () => { + const store = createPersistenceStatusStore(); + store.markSaving("schema"); + store.markFailed("configuration", "terminal-quota", 1); + + store.reset(); + + expect(store.getSnapshot()).toStrictEqual({ + status: "idle", + failures: [], + isSettling: false, + }); + }); + test("notifies subscribers on change and stops after unsubscribe", () => { const store = createPersistenceStatusStore(); const listener = vi.fn(); diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts index 040422d90..c3ac69702 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts @@ -26,6 +26,12 @@ export interface PersistenceFailure { export interface PersistenceStatusSnapshot { status: PersistenceStatus; failures: PersistenceFailure[]; + /** + * Whether any write is currently in flight. Distinct from `status === "saving"` + * because a terminal failure on one key makes `status` `failed` while another + * key may still be draining. Drives `waitForIdle`. + */ + isSettling: boolean; } export interface PersistenceStatusStore { @@ -42,10 +48,13 @@ export interface PersistenceStatusStore { attemptCount: number, ): void; /** - * Resolves once no write is in flight — i.e. status has settled to `idle` or - * `failed`. The test seam that replaces awaiting a per-write promise. + * Resolves once no write is in flight. The test seam that replaces awaiting a + * per-write promise. Resolves immediately when nothing is in flight, so a + * write enqueued *after* this call is not awaited — issue all writes first. */ waitForIdle(): Promise; + /** Clears all in-flight and failed state. Returns the store to `idle`. */ + reset(): void; } interface PersistenceStatusStoreConfig { @@ -66,33 +75,26 @@ export function createPersistenceStatusStore({ const inFlightKeys = new Set(); const failuresByKey = new Map(); const listeners = new Set<() => void>(); - // Resolvers waiting for all in-flight writes to drain. Tracked separately - // from `listeners` because idleness keys off the in-flight count, not the - // snapshot — a snapshot-unchanged transition (e.g. the last in-flight key - // settling while a failure remains) must still wake these. - let idleWaiters: Array<() => void> = []; - let snapshot: PersistenceStatusSnapshot = { status: "idle", failures: [] }; + let snapshot: PersistenceStatusSnapshot = { + status: "idle", + failures: [], + isSettling: false, + }; function subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); } - function flushIdleWaitersIfDrained() { - if (inFlightKeys.size > 0 || idleWaiters.length === 0) { - return; - } - const waiters = idleWaiters; - idleWaiters = []; - waiters.forEach(resolve => resolve()); - } - - function failuresEqual(next: PersistenceFailure[]) { - const current = snapshot.failures; + function snapshotsEqual(next: PersistenceStatusSnapshot) { return ( - next.length === current.length && - next.every((failure, index) => failure === current[index]) + next.status === snapshot.status && + next.isSettling === snapshot.isSettling && + next.failures.length === snapshot.failures.length && + next.failures.every( + (failure, index) => failure === snapshot.failures[index], + ) ); } @@ -102,9 +104,13 @@ export function createPersistenceStatusStore({ : inFlightKeys.size ? "saving" : "idle"; - const failures = [...failuresByKey.values()]; + const next: PersistenceStatusSnapshot = { + status, + failures: [...failuresByKey.values()], + isSettling: inFlightKeys.size > 0, + }; - if (status === snapshot.status && failuresEqual(failures)) { + if (snapshotsEqual(next)) { return; } @@ -112,7 +118,7 @@ export function createPersistenceStatusStore({ logger.debug(`[persistence] status ${snapshot.status} → ${status}`); } - snapshot = { status, failures }; + snapshot = next; listeners.forEach(listener => listener()); } @@ -131,7 +137,6 @@ export function createPersistenceStatusStore({ inFlightKeys.delete(key); failuresByKey.delete(key); recompute(); - flushIdleWaitersIfDrained(); }, markFailed(key, reason, attemptCount) { logger.debug( @@ -145,19 +150,24 @@ export function createPersistenceStatusStore({ lastAttemptAt: now(), }); recompute(); - flushIdleWaitersIfDrained(); }, waitForIdle() { - // Resolve when no write is in flight, regardless of whether failures - // remain. Keying off the in-flight count (not status) means a terminal - // failure on one key does not prematurely signal "settled" while another - // key is still draining. - if (inFlightKeys.size === 0) { + if (!snapshot.isSettling) { return Promise.resolve(); } return new Promise(resolve => { - idleWaiters.push(resolve); + const unsubscribe = subscribe(() => { + if (!snapshot.isSettling) { + unsubscribe(); + resolve(); + } + }); }); }, + reset() { + inFlightKeys.clear(); + failuresByKey.clear(); + recompute(); + }, }; } diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts index 623758b81..c6ea21514 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts @@ -49,8 +49,8 @@ export function createWriteQueue({ delay = exponentialBackoff, maxAttempts = DEFAULT_MAX_ATTEMPTS, }: WriteQueueConfig): WriteQueue { - // The write currently running for a key, plus the next flush waiting to run. - const running = new Map>(); + // Keys with a drain currently running, plus the next flush waiting to run. + const running = new Set(); const pending = new Map(); /** A terminal failure plus how many attempts were made before giving up. */ @@ -101,7 +101,8 @@ export function createWriteQueue({ // Replace any not-yet-started write so only the latest value lands. pending.set(key, flush); if (!running.has(key)) { - running.set(key, drain(key)); + running.add(key); + void drain(key); } }, }; diff --git a/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx b/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx index 293f9cbb3..753665b9b 100644 --- a/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx +++ b/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx @@ -50,7 +50,10 @@ import { useHasActiveSchema, useMaybeActiveSchema, } from "@/core"; -import { debugForcePersistenceFailure } from "@/core/StateProvider/persistence"; +import { + debugForcePersistenceFailure, + debugResetPersistenceStatus, +} from "@/core/StateProvider/persistence"; import { useDeleteActiveConfiguration } from "@/hooks/useDeleteConfig"; import useEntitiesCounts from "@/hooks/useEntitiesCounts"; import { useCancelSchemaSync, useSchemaSync } from "@/hooks/useSchemaSync"; @@ -469,6 +472,9 @@ function DebugActions() { + From 5d8de5f8cf3caeeca41b7405a50a71b37ef18591 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 17:18:24 -0500 Subject: [PATCH 21/25] Only offer the backup when storage is readable; restore reason-specific messaging --- CONTEXT.md | 2 +- ...-storage-layer-owns-persistence-failure.md | 2 +- docs/guides/troubleshooting.md | 6 ++-- .../PersistenceStatusIndicator.test.tsx | 35 ++++++++++++------- .../PersistenceStatusIndicator.tsx | 34 +++++++++++------- 5 files changed, 48 insertions(+), 31 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index c50222043..1cf911a41 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -83,7 +83,7 @@ The single, global state of whether the app's client-side data is safely written _Avoid_: Save state (ambiguous with Session) **Persistence Status Indicator**: -The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger "Changes not saved" button — and stays absent at `idle` and `saving`. Replaces scattered per-write toasts. Clicking it opens a dialog showing the raw failure records (key, reason, attempt count, last attempt) in a read-only JSON editor, and always offers to save the configuration to a file via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`) so the user can preserve whatever did persist regardless of why the write failed. Recovery scope is retry (transient failures) plus that backup (terminal failures) — it does not guarantee the write eventually lands. +The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger "Changes not saved" button — and stays absent at `idle` and `saving`. Replaces scattered per-write toasts. Clicking it opens a dialog showing the raw failure records (key, reason, attempt count, last attempt) in a read-only JSON editor. The dialog offers to save the configuration to a file via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`) when storage is full (quota) — IndexedDB is still readable then — but not when storage is inaccessible (private mode, blocked), since the database never opened and there is nothing to read. Recovery scope is retry (transient failures) plus that backup (terminal-quota failures) — it does not guarantee the write eventually lands. _Avoid_: Save-status indicator, save toast, save banner ## Relationships diff --git a/docs/adr/20260619-storage-layer-owns-persistence-failure.md b/docs/adr/20260619-storage-layer-owns-persistence-failure.md index 74314fb77..6c796e30f 100644 --- a/docs/adr/20260619-storage-layer-owns-persistence-failure.md +++ b/docs/adr/20260619-storage-layer-owns-persistence-failure.md @@ -36,7 +36,7 @@ The depth is hidden behind composition, not crammed into one file: `classifyStor - **The ~36 call sites lose their floating-promise handling** — the interim convention is superseded, and that migration is expected to be largely undone. Net caller code shrinks. - **Tests can no longer await a per-write promise.** They synchronize on a layer-level `persistenceStatusStore.waitForIdle()` derived from the status store — a better signal that survives coalescing/retry and exercises the production status path. - **Status is strictly per-tab.** Consistent with the substrate ADR's "no cross-tab sync primitives," a failed write in one tab shows `failed` there regardless of other tabs, and clears only when that tab itself successfully flushes the key. Honest about whose edit is at risk. -- **Recovery is retry + backup, not a write guarantee.** The failure indicator opens a detail dialog that always offers a full configuration backup (`saveLocalForageToFile`) — it is read-mostly, so it remains viable even under quota pressure, and it lets the user preserve whatever did persist regardless of the failure reason. We do **not** block reload (`beforeunload`). +- **Recovery is retry + backup, not a write guarantee.** The failure indicator opens a detail dialog; for terminal-quota failures the dialog offers a full configuration backup (`saveLocalForageToFile`), which is read-mostly and so remains viable under quota pressure. Terminal-access failures (private mode, blocked) offer no backup — IndexedDB never opened, so there is nothing to read. We do **not** block reload (`beforeunload`). - **This effort and the cross-tab merge are orthogonal**, meeting only at the `flush` seam — either can ship first without blocking the other. - **Every IndexedDB write routes through one shared queue, including the Active Connection breadcrumb.** The queue/status are not embedded in `atomWithLocalForage`; they live in a shared `persistThroughQueue` helper that wraps the localForage write itself. Both `atomWithLocalForage` and the per-tab active-connection path (whose synchronous sessionStorage write stays outside the queue) call it, so all IndexedDB writes feed one global status. This dropped the special-case that would have excluded the breadcrumb. - **The `void`-setter change lives in the shared `createWriteThroughAtom`**, which the active-connection path also uses. No production call site awaited the old promise (only tests did), so the migration was mechanical. The test seam moved to a layer-level `waitForIdle()` on the status store, replacing per-write promise awaits across the persistence test helpers and the active-connection tests. diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 29666bfde..e732f9f9d 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -134,12 +134,12 @@ This configuration can be restored using the "Load Configuration" button in the ## Graph Explorer Can't Save Your Changes -Graph Explorer stores all of your data — connections, schema, styling, and exploration sessions — in your browser, not on the server. When it can't write that data to browser storage, a "Changes not saved" indicator appears in the navigation bar. Click it to see which data failed to save and why, and to save a backup. The dialog always offers a "Save Configuration" button — use it to export your data to a file (the same format as [Save & Load Configuration](#save--load-configuration)) so you don't lose your work, then reload the page. You can restore the file afterward with "Load Configuration". +Graph Explorer stores all of your data — connections, schema, styling, and exploration sessions — in your browser, not on the server. When it can't write that data to browser storage, a "Changes not saved" indicator appears in the navigation bar. Click it to see which data failed to save and why. There are two common causes: -- **Browser storage is full.** Graph Explorer has run out of space to save changes. Free up browser storage and reload the page. Exploration sessions are the largest data and the most likely cause; clearing old sessions or other sites' storage frees space. -- **Storage is blocked or inaccessible.** Graph Explorer can't access browser storage at all, so changes won't be saved for the session. This commonly happens in private/incognito windows or when browser settings or policy block storage for the site. Open Graph Explorer in a normal window, or allow storage for the site, to persist your work. +- **Browser storage is full.** Graph Explorer has run out of space to save changes. The dialog offers a "Save Configuration" button — use it to export your data to a file (the same format as [Save & Load Configuration](#save--load-configuration)) so you don't lose your work, then free up browser storage and reload the page. You can restore the file afterward with "Load Configuration". Exploration sessions are the largest data and the most likely cause; clearing old sessions or other sites' storage frees space. +- **Storage is blocked or inaccessible.** Graph Explorer can't access browser storage at all, so changes won't be saved for the session. No backup is offered here, since the data can't be read out. This commonly happens in private/incognito windows or when browser settings or policy block storage for the site. Open Graph Explorer in a normal window, or allow storage for the site, to persist your work. Transient storage hiccups are retried automatically, so the indicator only appears for failures that could not be recovered. diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx index 95b44e1df..11182465a 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx @@ -64,22 +64,31 @@ describe("PersistenceStatusIndicator", () => { expect(screen.getByRole("dialog")).toHaveTextContent(/couldn.t save/i); }); - test("offers to save the configuration to a file regardless of failure reason", async () => { + test("offers to save the configuration when storage is full", async () => { const user = userEvent.setup(); + renderIndicator(); - for (const reason of ["terminal-quota", "terminal-access"] as const) { - fail("configuration", reason); - const { unmount } = renderIndicator(); - await user.click( - screen.getByRole("button", { name: /changes not saved/i }), - ); + fail("graph-sessions", "terminal-quota"); + await user.click( + screen.getByRole("button", { name: /changes not saved/i }), + ); - expect( - screen.getByRole("button", { name: /save configuration/i }), - ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /save configuration/i }), + ).toBeInTheDocument(); + }); - unmount(); - act(() => persistenceStatusStore.reset()); - } + test("offers no backup when storage is inaccessible", async () => { + const user = userEvent.setup(); + renderIndicator(); + + fail("configuration", "terminal-access"); + await user.click( + screen.getByRole("button", { name: /changes not saved/i }), + ); + + expect( + screen.queryByRole("button", { name: /save configuration/i }), + ).not.toBeInTheDocument(); }); }); diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx index 2ca8a5140..0e55f23c3 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx @@ -25,8 +25,10 @@ import { Label } from "../Label"; * "Changes not saved" button that opens a detail dialog. * * The dialog is the single recovery surface: it shows the raw failure records - * and always offers to save the configuration to a file, so the user can - * preserve whatever did persist regardless of why the write failed. + * and offers to save the configuration to a file when (and only when) storage + * is full — IndexedDB is still readable then, so a backup can capture whatever + * did persist. When storage is inaccessible (private mode, blocked) no backup is + * offered: the database never opened, so there is nothing to read. */ export function PersistenceStatusIndicator() { const { status, failures } = usePersistenceStatus(); @@ -35,6 +37,10 @@ export function PersistenceStatusIndicator() { return null; } + const canBackUp = failures.some( + failure => failure.reason === "terminal-quota", + ); + return ( @@ -49,9 +55,9 @@ export function PersistenceStatusIndicator() {

- Graph Explorer couldn't save some changes to browser storage. - Save your configuration to a file so you don't lose your work, - then reload the page. + {canBackUp + ? "Your browser is out of storage. Save your configuration to a file so you don't lose your work, then free up space and reload." + : "Graph Explorer can't access browser storage, so these changes won't be saved this session. This often happens in private browsing or when storage is blocked."}

@@ -71,15 +77,17 @@ export function PersistenceStatusIndicator() {
- + {canBackUp ? ( + + ) : null} - + From d1ba3b1fac3343f2f49e2fb7aca4d84c8ded3429 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 17:48:22 -0500 Subject: [PATCH 22/25] Update dialog test assertion after title cleanup --- .../graph-explorer/src/components/NavBar.tsx | 2 +- .../PersistenceStatusIndicator.test.tsx | 2 +- .../PersistenceStatusIndicator.tsx | 18 ++++++++++-------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/graph-explorer/src/components/NavBar.tsx b/packages/graph-explorer/src/components/NavBar.tsx index 0b0ef57c4..f552aff06 100644 --- a/packages/graph-explorer/src/components/NavBar.tsx +++ b/packages/graph-explorer/src/components/NavBar.tsx @@ -34,7 +34,7 @@ export function NavBarContent({ return (
); diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx index 11182465a..793c9706a 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx @@ -61,7 +61,7 @@ describe("PersistenceStatusIndicator", () => { screen.getByRole("button", { name: /changes not saved/i }), ); - expect(screen.getByRole("dialog")).toHaveTextContent(/couldn.t save/i); + expect(screen.getByRole("dialog")).toHaveTextContent(/failed writes/i); }); test("offers to save the configuration when storage is full", async () => { diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx index 0e55f23c3..36e2753be 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx @@ -1,5 +1,5 @@ import localforage from "localforage"; -import { InfoIcon, SaveAllIcon } from "lucide-react"; +import { InfoIcon } from "lucide-react"; import { saveLocalForageToFile } from "@/core/StateProvider/localDb"; import { usePersistenceStatus } from "@/core/StateProvider/persistence/usePersistenceStatus"; @@ -51,14 +51,17 @@ export function PersistenceStatusIndicator() { - Couldn't save your changes + Changes not saved -

- {canBackUp - ? "Your browser is out of storage. Save your configuration to a file so you don't lose your work, then free up space and reload." - : "Graph Explorer can't access browser storage, so these changes won't be saved this session. This often happens in private browsing or when storage is blocked."} -

+ + +

+ {canBackUp + ? "Your browser is out of storage. Save your configuration to a file so you don't lose your work, then free up space and reload." + : "Graph Explorer can't access browser storage, so these changes won't be saved this session. This often happens in private browsing or when storage is blocked."} +

+
@@ -82,7 +85,6 @@ export function PersistenceStatusIndicator() { variant="primary" onClick={() => void saveLocalForageToFile(localforage)} > - Save Configuration ) : null} From 14d6cbc1458551bbd6bab640d4aa11c7e881334f Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 19:02:49 -0500 Subject: [PATCH 23/25] Capture underlying error details on persistence failures --- .../PersistenceStatusIndicator.test.tsx | 7 ++++++- .../persistenceStatusStore.test.ts | 19 ++++++++++++------- .../persistence/persistenceStatusStore.ts | 8 +++++++- .../StateProvider/persistence/writeQueue.ts | 19 +++++++++++++++++-- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx index 793c9706a..c03ee898c 100644 --- a/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx @@ -18,7 +18,12 @@ afterEach(() => { }); function fail(key: string, reason: "terminal-quota" | "terminal-access") { - act(() => persistenceStatusStore.markFailed(key, reason, 1)); + act(() => + persistenceStatusStore.markFailed(key, reason, 1, { + name: "QuotaExceededError", + message: "storage full", + }), + ); } describe("PersistenceStatusIndicator", () => { diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts index 5f35d9a96..7d24ccc71 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test, vi } from "vitest"; +import type { ErrorDetails } from "@/utils/createErrorDetails"; + import { createPersistenceStatusStore } from "./persistenceStatusStore"; +const DETAILS: ErrorDetails = { name: "QuotaExceededError", message: "full" }; + describe("persistenceStatusStore", () => { test("starts idle with no failures", () => { const store = createPersistenceStatusStore(); @@ -35,7 +39,7 @@ describe("persistenceStatusStore", () => { const store = createPersistenceStatusStore({ now: () => failedAt }); store.markSaving("schema"); - store.markFailed("configuration", "terminal-quota", 3); + store.markFailed("configuration", "terminal-quota", 3, DETAILS); const snapshot = store.getSnapshot(); expect(snapshot.status).toBe("failed"); @@ -45,6 +49,7 @@ describe("persistenceStatusStore", () => { reason: "terminal-quota", attemptCount: 3, lastAttemptAt: failedAt, + details: DETAILS, }, ]); }); @@ -52,7 +57,7 @@ describe("persistenceStatusStore", () => { test("a failed key clears on its next successful write", () => { const store = createPersistenceStatusStore(); - store.markFailed("user-styling", "terminal-quota", 1); + store.markFailed("user-styling", "terminal-quota", 1, DETAILS); store.markSaved("user-styling"); expect(store.getSnapshot()).toStrictEqual({ @@ -65,8 +70,8 @@ describe("persistenceStatusStore", () => { test("stays failed while another key still has an outstanding failure", () => { const store = createPersistenceStatusStore(); - store.markFailed("schema", "terminal-access", 1); - store.markFailed("user-styling", "terminal-quota", 2); + store.markFailed("schema", "terminal-access", 1, DETAILS); + store.markFailed("user-styling", "terminal-quota", 2, DETAILS); store.markSaved("schema"); const snapshot = store.getSnapshot(); @@ -79,7 +84,7 @@ describe("persistenceStatusStore", () => { test("reset clears all in-flight and failed state", () => { const store = createPersistenceStatusStore(); store.markSaving("schema"); - store.markFailed("configuration", "terminal-quota", 1); + store.markFailed("configuration", "terminal-quota", 1, DETAILS); store.reset(); @@ -124,7 +129,7 @@ describe("persistenceStatusStore", () => { store.markSaving("schema"); const idle = store.waitForIdle(); - store.markFailed("schema", "terminal-quota", 1); + store.markFailed("schema", "terminal-quota", 1, DETAILS); await expect(idle).resolves.toBeUndefined(); }); @@ -141,7 +146,7 @@ describe("persistenceStatusStore", () => { // One key fails terminally, but the other is still draining. Status flips to // "failed", yet a write is genuinely still in flight, so idle must wait. - store.markFailed("configuration", "terminal-quota", 1); + store.markFailed("configuration", "terminal-quota", 1, DETAILS); await Promise.resolve(); expect(resolved).toBe(false); diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts index c3ac69702..c52b25f47 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts @@ -1,3 +1,5 @@ +import type { ErrorDetails } from "@/utils/createErrorDetails"; + import { logger } from "@/utils"; import type { StorageErrorClassification } from "./classifyStorageError"; @@ -21,6 +23,8 @@ export interface PersistenceFailure { attemptCount: number; /** When the final, failed attempt occurred. */ lastAttemptAt: Date; + /** Name, message, and any cause data from the underlying error. */ + details: ErrorDetails; } export interface PersistenceStatusSnapshot { @@ -46,6 +50,7 @@ export interface PersistenceStatusStore { key: string, reason: StorageErrorClassification, attemptCount: number, + details: ErrorDetails, ): void; /** * Resolves once no write is in flight. The test seam that replaces awaiting a @@ -138,7 +143,7 @@ export function createPersistenceStatusStore({ failuresByKey.delete(key); recompute(); }, - markFailed(key, reason, attemptCount) { + markFailed(key, reason, attemptCount, details) { logger.debug( `[persistence] failed "${key}" (${reason}, ${attemptCount} attempts)`, ); @@ -148,6 +153,7 @@ export function createPersistenceStatusStore({ reason, attemptCount, lastAttemptAt: now(), + details, }); recompute(); }, diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts index c6ea21514..dae50c537 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts @@ -1,3 +1,8 @@ +import { + createErrorDetails, + type ErrorDetails, +} from "@/utils/createErrorDetails"; + import type { PersistenceStatusStore } from "./persistenceStatusStore"; import { @@ -57,6 +62,7 @@ export function createWriteQueue({ interface TerminalFailure { reason: StorageErrorClassification; attemptCount: number; + details: ErrorDetails; } /** Runs one flush with retry, returning the terminal failure if it gives up. */ @@ -69,7 +75,11 @@ export function createWriteQueue({ const classification = classifyStorageError(error); const isLastAttempt = attempt === maxAttempts - 1; if (classification !== "retryable" || isLastAttempt) { - return { reason: classification, attemptCount: attempt + 1 }; + return { + reason: classification, + attemptCount: attempt + 1, + details: createErrorDetails(error), + }; } await delay(attempt); } @@ -90,7 +100,12 @@ export function createWriteQueue({ running.delete(key); if (failure) { - store.markFailed(key, failure.reason, failure.attemptCount); + store.markFailed( + key, + failure.reason, + failure.attemptCount, + failure.details, + ); } else { store.markSaved(key); } From e7dfe13084bd07a6c356cd74992e2803a95fa27b Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 19:23:43 -0500 Subject: [PATCH 24/25] Describe the indicator's current state only, not the interim toast model --- CONTEXT.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 1cf911a41..c240f5016 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -83,8 +83,8 @@ The single, global state of whether the app's client-side data is safely written _Avoid_: Save state (ambiguous with Session) **Persistence Status Indicator**: -The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger "Changes not saved" button — and stays absent at `idle` and `saving`. Replaces scattered per-write toasts. Clicking it opens a dialog showing the raw failure records (key, reason, attempt count, last attempt) in a read-only JSON editor. The dialog offers to save the configuration to a file via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`) when storage is full (quota) — IndexedDB is still readable then — but not when storage is inaccessible (private mode, blocked), since the database never opened and there is nothing to read. Recovery scope is retry (transient failures) plus that backup (terminal-quota failures) — it does not guarantee the write eventually lands. -_Avoid_: Save-status indicator, save toast, save banner +The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger "Changes not saved" button — and stays absent at `idle` and `saving`. Clicking it opens a dialog showing the raw failure records (key, reason, attempt count, last attempt, and the underlying error's name/message/cause) in a read-only JSON editor. The dialog offers to save the configuration to a file via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`) when storage is full (quota) — IndexedDB is still readable then — but not when storage is inaccessible (private mode, blocked), since the database never opened and there is nothing to read. Recovery scope is retry (transient failures) plus that backup (terminal-quota failures) — it does not guarantee the write eventually lands. +_Avoid_: Save-status indicator ## Relationships From 16e156b19f38e8599d8a13947ba4f7e2e61b1162 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 22 Jun 2026 19:28:11 -0500 Subject: [PATCH 25/25] Test that the write queue captures error details onto failure records --- .../persistence/writeQueue.test.ts | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts index 76eb576f8..c4c84638f 100644 --- a/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts @@ -77,7 +77,13 @@ describe("createWriteQueue", () => { expect(delay).not.toHaveBeenCalled(); expect(store.getSnapshot().status).toBe("failed"); expect(store.getSnapshot().failures).toMatchObject([ - { key: "graph-sessions", reason: "terminal-quota", attemptCount: 1 }, + { + key: "graph-sessions", + reason: "terminal-quota", + attemptCount: 1, + // The thrown error's name and message are captured on the record. + details: { name: "QuotaExceededError", message: "full" }, + }, ]); }); @@ -113,4 +119,45 @@ describe("createWriteQueue", () => { { key: "schema", reason: "terminal-quota" }, ]); }); + + test("captures the underlying error's cause chain on the failure record", async () => { + const store = createPersistenceStatusStore(); + const queue = createWriteQueue({ store, delay: noDelay }); + const error = new Error("could not open database", { + cause: new Error("disk write failed"), + }); + const flush = vi.fn().mockRejectedValue(error); + + queue.enqueue("configuration", flush); + await store.waitForIdle(); + + const [failure] = store.getSnapshot().failures; + expect(failure.details).toMatchObject({ + name: "Error", + message: "could not open database", + }); + // createErrorDetails serializes the cause into the `data` field. + expect(failure.details.data).toContain("disk write failed"); + }); + + test("records each failed key's own error details", async () => { + const store = createPersistenceStatusStore(); + const queue = createWriteQueue({ store, delay: noDelay }); + + queue.enqueue("configuration", () => + Promise.reject(new DOMException("full", "QuotaExceededError")), + ); + queue.enqueue("schema", () => + Promise.reject(new DOMException("blocked", "SecurityError")), + ); + await store.waitForIdle(); + + const detailsByKey = Object.fromEntries( + store.getSnapshot().failures.map(f => [f.key, f.details.name]), + ); + expect(detailsByKey).toStrictEqual({ + configuration: "QuotaExceededError", + schema: "SecurityError", + }); + }); });