Skip to content

feat(undo-redo): migrate persistence from localStorage to IndexedDB#4738

Open
Sprexatura wants to merge 5 commits into
simstudioai:mainfrom
Sprexatura:feat/undo-redo-indexeddb
Open

feat(undo-redo): migrate persistence from localStorage to IndexedDB#4738
Sprexatura wants to merge 5 commits into
simstudioai:mainfrom
Sprexatura:feat/undo-redo-indexeddb

Conversation

@Sprexatura
Copy link
Copy Markdown
Contributor

Summary

Workflow undo/redo persistence currently lives in localStorage and stores operation snapshots that include full block state. Active editing of multiple workflows quickly fills the per-stack capacity with snapshots that can be several KB each, occupying the bulk of the origin's ~5 MB localStorage budget. Once the budget is exhausted, any other persisted Zustand store whose setItem is called next throws QuotaExceededError.

This PR moves workflow-undo-redo to IndexedDB using the same idb-keyval adapter pattern as terminal-console-store (#2812), aligning with the policy stated in #497 to minimize localStorage usage.

The earlier safeStorageAdapter (#2079) silently swallowed quota throws inside this store but did not free the origin budget that other small UI stores share, leaving them to throw uncaught QuotaExceededError. Migrating to IndexedDB removes the underlying contention.

Changes

  • New apps/sim/stores/undo-redo/storage.ts wrapping idb-keyval, mirroring apps/sim/stores/terminal/console/storage.ts. Includes a one-time localStorage → IndexedDB migration that copies any existing data and removes the legacy localStorage key on first module load.
  • New apps/sim/stores/undo-redo/storage.test.ts covering migration idempotency, graceful failure on IndexedDB errors, and basic get/set/remove behavior (10 cases).
  • apps/sim/stores/undo-redo/store.ts: drop the inline safeStorageAdapter and use the new indexedDBStorage.

Structured as two commits — first commit adds the adapter with no behavioral change, second commit swaps the store's persist middleware.

Verification

Detailed reproduction steps are in #4737. Local verification:

  1. Reproduce the cap-reached state on a workflow editor session (see bug(undo-redo): QuotaExceededError when localStorage cap is exhausted by undo/redo snapshots #4737 step list).
  2. Trigger any setItem on another small store (e.g., dismiss a notification) — QuotaExceededError thrown.
  3. Apply this change and reload the editor — migration runs once, copying the existing workflow-undo-redo payload to IndexedDB and removing the localStorage key.
  4. Retry the trigger from step 2 — no throw.

Fixes #4737

Add apps/sim/stores/undo-redo/storage.ts wrapping idb-keyval, mirroring
the pattern of apps/sim/stores/terminal/console/storage.ts. Includes a
one-time localStorage -> IndexedDB migration that runs on first module
load and removes the legacy localStorage key after copying.

storage.test.ts covers migration idempotency, graceful failure on
IndexedDB errors, and basic get/set/remove behavior (10 cases).

No behavioral change in this commit -- the adapter is unused until the
follow-up swaps the store's persist middleware.

Refs simstudioai#4737

Signed-off-by: JaeHyung Jang <jaehyung.jang@navercorp.com>
Replace inline safeStorageAdapter with indexedDBStorage. Workflow undo/
redo persistence moves from localStorage (~5MB origin cap) to IndexedDB
(~GB), eliminating QuotaExceededError that can surface from any small
persisted Zustand store (notification-storage, panel-editor-state,
etc.) once workflow-undo-redo occupies the bulk of the origin's
localStorage budget.

Same pattern as PR simstudioai#2812 (terminal-console -> IndexedDB) and aligns
with PR simstudioai#497's policy of minimizing localStorage usage. The earlier
safeStorageAdapter (PR simstudioai#2079) silently swallowed quota throws in this
store but did not free the origin budget that the other small UI
stores share, leaving them to throw uncaught QuotaExceededError.

Fixes simstudioai#4737

Signed-off-by: JaeHyung Jang <jaehyung.jang@navercorp.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped May 26, 2026 9:28am

Request Review

@cursor
Copy link
Copy Markdown

cursor Bot commented May 26, 2026

PR Summary

Medium Risk
Changes where undo history is stored and how it is migrated/cleared on load and sign-out; mistakes could lose history or briefly race during rehydration, though tests cover migration and write ordering.

Overview
Moves workflow undo/redo persistence off localStorage onto IndexedDB via a new indexedDBStorage adapter (same idb-keyval pattern as the terminal console store), so large undo snapshots no longer compete for the origin’s ~5 MB localStorage quota.

On first load, a one-time migration copies any existing workflow-undo-redo payload into IndexedDB, sets a migration flag, and removes the legacy localStorage key. The adapter awaits migration on reads/writes, gates writes until the first rehydration getItem finishes (avoids overwriting persisted state with empty in-memory state), and swallows most IDB errors on get/set/remove so the app keeps running.

The undo/redo Zustand store drops the inline safeStorageAdapter and uses createJSONStorage(() => indexedDBStorage). Sign-out (clearUserData) now also calls clearPersistedUndoRedo() so undo history does not linger across sessions. New unit tests cover migration, adapter behavior, clear-on-logout, and the hydration race.

Reviewed by Cursor Bugbot for commit 2a8bf5f. Bugbot is set up for automated code reviews on this repo. Configure here.


try {
const migrated = await get<boolean>(MIGRATION_KEY)
if (migrated) return
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orphan localStorage not cleared

Medium Severity

The migrateFromLocalStorage function short-circuits if MIGRATION_KEY is already set in IndexedDB. This prevents localStorage.removeItem(STORE_KEY) from executing, allowing the workflow-undo-redo key to persist in localStorage and consume origin storage quota.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d9201d8. Configure here.

Comment thread apps/sim/stores/undo-redo/storage.ts
} catch (error) {
logger.warn('IndexedDB read failed', { name, error })
return null
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reads ignore legacy localStorage

Medium Severity

After a failed localStorage→IndexedDB migration, indexedDBStorage.getItem still reads only IndexedDB and returns null even when the legacy workflow-undo-redo payload remains in localStorage. The previous safeStorageAdapter read from localStorage directly, so persisted undo stacks could still hydrate on that load.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d9201d8. Configure here.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 26, 2026

Greptile Summary

This PR migrates the workflow-undo-redo Zustand persist store from localStorage to IndexedDB via idb-keyval, matching the pattern already established by terminal-console-store. The inline safeStorageAdapter (which silently swallowed quota errors without freeing origin budget) is replaced with an async indexedDBStorage adapter that includes a one-time migration on first load.

  • storage.ts: New adapter wrapping idb-keyval with a module-level migration routine that copies any existing localStorage payload to IndexedDB, removes the legacy key, and marks completion with a sentinel — preventing re-runs and leaving localStorage intact on IDB failure so the migration can retry.
  • store.ts: Single-line change swapping safeStorageAdapter for indexedDBStorage in the persist config.
  • storage.test.ts: 10 test cases covering migration idempotency, IDB failure resilience, and all three adapter operations (get/set/remove).

Confidence Score: 4/5

Safe to merge; the migration is idempotent and adapter errors are all caught, so there is no data-loss path.

The migration logic is sound — IDB writes are awaited before the localStorage key is removed, the sentinel prevents re-runs, and failures leave localStorage intact for a retry. The only open questions are a minor SSR-guard naming inconsistency with the sibling storage file and the absence of a migration-gate in setItem, which is safe in the current Zustand flow but could surprise future callers. Test coverage is solid across 10 cases with one small gap (no removeItem error-swallowing test).

apps/sim/stores/undo-redo/storage.ts — the setItem/removeItem migration gate and SSR guard style are worth a second look before merging.

Important Files Changed

Filename Overview
apps/sim/stores/undo-redo/storage.ts New IndexedDB adapter with one-time localStorage migration; SSR guard uses typeof localStorage instead of typeof window (unlike sibling storage.ts), and setItem/removeItem don't await migration completion.
apps/sim/stores/undo-redo/storage.test.ts 10 test cases covering migration idempotency and adapter behavior; missing an error-swallowing test for removeItem, which is covered for getItem and setItem.
apps/sim/stores/undo-redo/store.ts Drops inline safeStorageAdapter and wires indexedDBStorage; mechanical one-line change in the persist config with no logic changes.

Sequence Diagram

sequenceDiagram
    participant Module as storage.ts (module load)
    participant IDB as IndexedDB (idb-keyval)
    participant LS as localStorage
    participant Store as useUndoRedoStore (Zustand persist)

    Module->>IDB: get(MIGRATION_KEY)
    alt already migrated
        IDB-->>Module: true → return early
    else first load
        IDB-->>Module: undefined
        Module->>LS: getItem("workflow-undo-redo")
        alt data exists
            LS-->>Module: legacyPayload
            Module->>IDB: set("workflow-undo-redo", legacyPayload)
            Module->>LS: removeItem("workflow-undo-redo")
        end
        Module->>IDB: set(MIGRATION_KEY, true)
    end

    Note over Module: migrationPromiseInternal resolves → set to null

    Store->>Module: indexedDBStorage.getItem("workflow-undo-redo")
    Module->>Module: await migrationPromiseInternal (if still pending)
    Module->>IDB: get("workflow-undo-redo")
    IDB-->>Store: persisted JSON string (or null)

    Store->>Module: indexedDBStorage.setItem("workflow-undo-redo", newState)
    Module->>IDB: set("workflow-undo-redo", newState)
Loading

Reviews (1): Last reviewed commit: "feat(undo-redo): migrate persistence fro..." | Re-trigger Greptile

Comment thread apps/sim/stores/undo-redo/storage.ts Outdated
Comment on lines +19 to +20
async function migrateFromLocalStorage(): Promise<void> {
if (typeof localStorage === 'undefined') return
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The SSR guard throughout this file checks typeof localStorage === 'undefined', but the sibling terminal/console/storage.ts (and general Next.js convention) uses typeof window === 'undefined'. Both are equivalent in practice today, but diverging from the established pattern makes it harder to do a grep-based audit of all SSR guards in the codebase.

Suggested change
async function migrateFromLocalStorage(): Promise<void> {
if (typeof localStorage === 'undefined') return
async function migrateFromLocalStorage(): Promise<void> {
if (typeof window === 'undefined') return

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +69 to +72
setItem: async (name: string, value: string): Promise<void> => {
if (typeof localStorage === 'undefined') return
try {
await set(name, value)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 setItem writes to IndexedDB without waiting for migration to finish. In the normal Zustand persist flow this is safe (Zustand calls getItem before any setItem), but if the adapter is ever called directly while migration is still in progress, a newly-written state snapshot could be silently overwritten when migration copies the old localStorage value. Adding the same migration gate that getItem already has makes the contract explicit and future-proof.

Suggested change
setItem: async (name: string, value: string): Promise<void> => {
if (typeof localStorage === 'undefined') return
try {
await set(name, value)
setItem: async (name: string, value: string): Promise<void> => {
if (typeof localStorage === 'undefined') return
if (migrationPromiseInternal) {
await migrationPromiseInternal
}
try {
await set(name, value)

Comment on lines +126 to +133

idbSet.mockRejectedValueOnce(new Error('idb quota'))
await expect(indexedDBStorage.setItem(STORE_KEY, 'x')).resolves.toBeUndefined()
})

it('removeItem deletes from IndexedDB', async () => {
const { indexedDBStorage, migrationReady } = await loadFreshModule()
await migrationReady
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing error-swallowing test for removeItem

getItem and setItem each have a dedicated test that verifies IndexedDB errors are caught and don't propagate. removeItem has only a success-path test. A test that mocks idbDel to reject would mirror the existing pattern and guard against a future regression where the catch block is accidentally removed.

- Gate setItem/removeItem on migration completion via shared
  awaitMigration() helper. Previously only getItem awaited the one-time
  localStorage -> IndexedDB migration; a setItem racing the migration
  could be overwritten when migration completed (Cursor Bugbot, high).
- Use `typeof window` for the SSR guard to match
  apps/sim/stores/terminal/console/storage.ts (Greptile).
- Add error-swallowing test for removeItem to mirror the existing
  coverage on getItem and setItem (Greptile).
- Switch storage.test.ts to `@vitest-environment jsdom` since the
  module-load IIFE depends on `typeof window`.

Signed-off-by: JaeHyung Jang <jaehyung.jang@navercorp.com>
Comment thread apps/sim/stores/undo-redo/store.ts
`clearUserData` previously cleared `localStorage` only. After moving
workflow-undo-redo persistence to IndexedDB, undo/redo history could
survive across user sessions on the same device.

Add `clearPersistedUndoRedo()` to the adapter and invoke it from
`clearUserData` so sign-out removes the IndexedDB entry as well.
The migration flag is left intact so the one-time migration does not
re-run on the next sign-in.

Signed-off-by: JaeHyung Jang <jaehyung.jang@navercorp.com>
Comment thread apps/sim/stores/undo-redo/store.ts
Comment thread apps/sim/stores/undo-redo/storage.ts
- Block setItem/removeItem on the first getItem (hydration read) so a
  state change cannot persist an empty in-memory snapshot over the
  IndexedDB payload before zustand's persist middleware finishes
  rehydration. Without this guard, switching from synchronous
  localStorage to async IndexedDB introduced a race that could wipe
  undo history after reload (Cursor Bugbot, medium).
- Propagate IndexedDB errors from clearPersistedUndoRedo so a delete
  failure surfaces to clearUserData's outer try/catch instead of
  silently leaving a previous user's payload behind (Cursor Bugbot,
  medium).
- Add tests for the hydration-read ordering and the error propagation.

Signed-off-by: JaeHyung Jang <jaehyung.jang@navercorp.com>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2a8bf5f. Configure here.

logger.warn('Failed to clear persisted undo-redo', { error })
throw error
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sign-out races undo hydration

Medium Severity

clearUserData deletes the persisted undo payload from IndexedDB while Zustand may still be rehydrating over async storage. If the initial getItem read started before deletion but completes afterward with the previous snapshot, persist can merge that undo history back into the in-memory store after storage was cleared. Sign-out navigates to login without a full reload, so the next session in the same tab can retain the prior user’s stacks in memory even though IndexedDB was wiped.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2a8bf5f. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(undo-redo): QuotaExceededError when localStorage cap is exhausted by undo/redo snapshots

1 participant