diff --git a/CONTEXT.md b/CONTEXT.md index 2db04e7cf..c240f5016 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 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) + +**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`. 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 - 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..6c796e30f --- /dev/null +++ b/docs/adr/20260619-storage-layer-owns-persistence-failure.md @@ -0,0 +1,42 @@ +# 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 (a lint-enforced code convention, not an ADR). 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. 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`. 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. + +### 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 `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 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 893e18e75..e732f9f9d 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 "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. 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. + ## 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/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 new file mode 100644 index 000000000..c03ee898c --- /dev/null +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom +import { act, render, screen } from "@testing-library/react"; +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, so reset it between tests. +afterEach(() => { + act(() => persistenceStatusStore.reset()); +}); + +function fail(key: string, reason: "terminal-quota" | "terminal-access") { + act(() => + persistenceStatusStore.markFailed(key, reason, 1, { + name: "QuotaExceededError", + message: "storage full", + }), + ); +} + +describe("PersistenceStatusIndicator", () => { + test("shows nothing while idle", () => { + renderIndicator(); + + expect( + screen.queryByRole("button", { name: /changes not saved/i }), + ).not.toBeInTheDocument(); + }); + + test("shows nothing while a write is merely in flight", () => { + renderIndicator(); + + act(() => persistenceStatusStore.markSaving("configuration")); + + expect( + screen.queryByRole("button", { name: /changes not saved/i }), + ).not.toBeInTheDocument(); + }); + + test("shows a standing indicator when a write fails terminally", () => { + renderIndicator(); + + fail("configuration", "terminal-access"); + + expect( + screen.getByRole("button", { name: /changes not saved/i }), + ).toBeInTheDocument(); + }); + + 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: /changes not saved/i }), + ); + + expect(screen.getByRole("dialog")).toHaveTextContent(/failed writes/i); + }); + + test("offers to save the configuration when storage is full", 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: /save configuration/i }), + ).toBeInTheDocument(); + }); + + 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 new file mode 100644 index 000000000..36e2753be --- /dev/null +++ b/packages/graph-explorer/src/components/PersistenceStatusIndicator/PersistenceStatusIndicator.tsx @@ -0,0 +1,98 @@ +import localforage from "localforage"; +import { InfoIcon } from "lucide-react"; + +import { saveLocalForageToFile } from "@/core/StateProvider/localDb"; +import { usePersistenceStatus } from "@/core/StateProvider/persistence/usePersistenceStatus"; + +import { Button } from "../Button"; +import { CodeEditor } from "../CodeEditor"; +import { + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../Dialog"; +import { FormItem } from "../Form"; +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 + * "Changes not saved" button that opens a detail dialog. + * + * The dialog is the single recovery surface: it shows the raw failure records + * 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(); + + if (status !== "failed") { + return null; + } + + const canBackUp = failures.some( + failure => failure.reason === "terminal-quota", + ); + + return ( + + + + + + + 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 ? ( + + ) : null} + + + + +
+
+ ); +} diff --git a/packages/graph-explorer/src/components/index.ts b/packages/graph-explorer/src/components/index.ts index 49e83a20f..3dc6d0b41 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 "./PersistenceStatusIndicator/PersistenceStatusIndicator"; + export * from "./Toaster"; export * from "./RouteButton"; 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/classifyStorageError.test.ts b/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.test.ts new file mode 100644 index 000000000..219e14bd6 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.test.ts @@ -0,0 +1,39 @@ +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("classifies SecurityError as terminal-access", () => { + expect(classifyStorageError(domException("SecurityError"))).toBe( + "terminal-access", + ); + }); + + 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"); + }); + + 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..103765084 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/classifyStorageError.ts @@ -0,0 +1,45 @@ +/** + * 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": + 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 new file mode 100644 index 000000000..bd190ba88 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/index.ts @@ -0,0 +1,48 @@ +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 + * Persistence 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); +} + +/** + * 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. 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 errorName = kind === "quota" ? "QuotaExceededError" : "SecurityError"; + 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 new file mode 100644 index 000000000..7d24ccc71 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.test.ts @@ -0,0 +1,157 @@ +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(); + + expect(store.getSnapshot()).toStrictEqual({ + status: "idle", + failures: [], + isSettling: false, + }); + }); + + 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 failedAt = new Date("2026-06-22T00:00:00Z"); + const store = createPersistenceStatusStore({ now: () => failedAt }); + + store.markSaving("schema"); + store.markFailed("configuration", "terminal-quota", 3, DETAILS); + + const snapshot = store.getSnapshot(); + expect(snapshot.status).toBe("failed"); + expect(snapshot.failures).toStrictEqual([ + { + key: "configuration", + reason: "terminal-quota", + attemptCount: 3, + lastAttemptAt: failedAt, + details: DETAILS, + }, + ]); + }); + + test("a failed key clears on its next successful write", () => { + const store = createPersistenceStatusStore(); + + store.markFailed("user-styling", "terminal-quota", 1, DETAILS); + store.markSaved("user-styling"); + + expect(store.getSnapshot()).toStrictEqual({ + status: "idle", + failures: [], + isSettling: false, + }); + }); + + test("stays failed while another key still has an outstanding failure", () => { + const store = createPersistenceStatusStore(); + + store.markFailed("schema", "terminal-access", 1, DETAILS); + store.markFailed("user-styling", "terminal-quota", 2, DETAILS); + store.markSaved("schema"); + + const snapshot = store.getSnapshot(); + expect(snapshot.status).toBe("failed"); + expect(snapshot.failures).toMatchObject([ + { key: "user-styling", reason: "terminal-quota", attemptCount: 2 }, + ]); + }); + + test("reset clears all in-flight and failed state", () => { + const store = createPersistenceStatusStore(); + store.markSaving("schema"); + store.markFailed("configuration", "terminal-quota", 1, DETAILS); + + 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(); + + 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", 1, DETAILS); + + 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", 1, DETAILS); + 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 new file mode 100644 index 000000000..c52b25f47 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/persistenceStatusStore.ts @@ -0,0 +1,179 @@ +import type { ErrorDetails } from "@/utils/createErrorDetails"; + +import { logger } from "@/utils"; + +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; + /** How many attempts were made before giving up (including the first try). */ + 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 { + 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 { + 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, + attemptCount: number, + details: ErrorDetails, + ): void; + /** + * 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 { + /** 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. + * + * Aggregation precedence: any terminal failure → `failed`; else any in-flight + * key → `saving`; else `idle`. + */ +export function createPersistenceStatusStore({ + now = () => new Date(), +}: PersistenceStatusStoreConfig = {}): PersistenceStatusStore { + const inFlightKeys = new Set(); + const failuresByKey = new Map(); + const listeners = new Set<() => void>(); + + let snapshot: PersistenceStatusSnapshot = { + status: "idle", + failures: [], + isSettling: false, + }; + + function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + } + + function snapshotsEqual(next: PersistenceStatusSnapshot) { + return ( + next.status === snapshot.status && + next.isSettling === snapshot.isSettling && + next.failures.length === snapshot.failures.length && + next.failures.every( + (failure, index) => failure === snapshot.failures[index], + ) + ); + } + + function recompute() { + const status: PersistenceStatus = failuresByKey.size + ? "failed" + : inFlightKeys.size + ? "saving" + : "idle"; + const next: PersistenceStatusSnapshot = { + status, + failures: [...failuresByKey.values()], + isSettling: inFlightKeys.size > 0, + }; + + if (snapshotsEqual(next)) { + return; + } + + if (status !== snapshot.status) { + logger.debug(`[persistence] status ${snapshot.status} → ${status}`); + } + + snapshot = next; + listeners.forEach(listener => listener()); + } + + return { + subscribe, + getSnapshot() { + 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, attemptCount, details) { + logger.debug( + `[persistence] failed "${key}" (${reason}, ${attemptCount} attempts)`, + ); + inFlightKeys.delete(key); + failuresByKey.set(key, { + key, + reason, + attemptCount, + lastAttemptAt: now(), + details, + }); + recompute(); + }, + waitForIdle() { + if (!snapshot.isSettling) { + return Promise.resolve(); + } + return new Promise(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/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/persistence/writeQueue.test.ts b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts new file mode 100644 index 000000000..c4c84638f --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.test.ts @@ -0,0 +1,163 @@ +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().status).toBe("failed"); + expect(store.getSnapshot().failures).toMatchObject([ + { + 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" }, + }, + ]); + }); + + 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().status).toBe("failed"); + expect(store.getSnapshot().failures).toMatchObject([ + { key: "configuration", reason: "retryable", attemptCount: 3 }, + ]); + }); + + 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).toMatchObject([ + { 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", + }); + }); +}); 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..dae50c537 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/persistence/writeQueue.ts @@ -0,0 +1,124 @@ +import { + createErrorDetails, + type ErrorDetails, +} from "@/utils/createErrorDetails"; + +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 { + // 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. */ + interface TerminalFailure { + reason: StorageErrorClassification; + attemptCount: number; + details: ErrorDetails; + } + + /** 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 { + reason: classification, + attemptCount: attempt + 1, + details: createErrorDetails(error), + }; + } + await delay(attempt); + } + } + return null; + } + + async function drain(key: string) { + store.markSaving(key); + let failure: TerminalFailure | 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.reason, + failure.attemptCount, + failure.details, + ); + } 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.add(key); + void drain(key); + } + }, + }; +} 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/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/modules/ConnectionDetail/ConnectionDetail.tsx b/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx index 481cfc215..753665b9b 100644 --- a/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx +++ b/packages/graph-explorer/src/modules/ConnectionDetail/ConnectionDetail.tsx @@ -50,6 +50,10 @@ import { useHasActiveSchema, useMaybeActiveSchema, } from "@/core"; +import { + debugForcePersistenceFailure, + debugResetPersistenceStatus, +} from "@/core/StateProvider/persistence"; import { useDeleteActiveConfiguration } from "@/hooks/useDeleteConfig"; import useEntitiesCounts from "@/hooks/useEntitiesCounts"; import { useCancelSchemaSync, useSchemaSync } from "@/hooks/useSchemaSync"; @@ -462,6 +466,15 @@ function DebugActions() { Reset Vertex Totals + + + diff --git a/packages/graph-explorer/src/routes/Connections/Connections.tsx b/packages/graph-explorer/src/routes/Connections/Connections.tsx index 16e3ec26a..e7cadac19 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, + PersistenceStatusIndicator, 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..209af0393 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, + PersistenceStatusIndicator, 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/GraphExplorer/GraphExplorer.tsx b/packages/graph-explorer/src/routes/GraphExplorer/GraphExplorer.tsx index 266a84c40..0108ac2fb 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, + PersistenceStatusIndicator, 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..368fde834 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, + PersistenceStatusIndicator, 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..991c61272 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, + PersistenceStatusIndicator, Workspace, WorkspaceContent, } from "@/components"; @@ -23,6 +24,7 @@ export default function SettingsRoot() { + 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(); } }