Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/sim/stores/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { environmentKeys } from '@/hooks/queries/environment'
import { useExecutionStore } from '@/stores/execution'
import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
import { clearPersistedUndoRedo } from '@/stores/undo-redo/storage'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
Expand Down Expand Up @@ -55,6 +56,9 @@ export async function clearUserData(): Promise<void> {
const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key))
keysToRemove.forEach((key) => localStorage.removeItem(key))

// Persisted undo/redo lives in IndexedDB; remove it as well.
await clearPersistedUndoRedo()

logger.info('User data cleared successfully')
} catch (error) {
logger.error('Error clearing user data:', { error })
Expand Down
216 changes: 216 additions & 0 deletions apps/sim/stores/undo-redo/storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/**
* @vitest-environment jsdom
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { idbStore, idbGet, idbSet, idbDel } = vi.hoisted(() => {
const store = new Map<string, unknown>()
return {
idbStore: store,
idbGet: vi.fn(async (key: string) => store.get(key) ?? undefined),
idbSet: vi.fn(async (key: string, value: unknown) => {
store.set(key, value)
}),
idbDel: vi.fn(async (key: string) => {
store.delete(key)
}),
}
})

vi.mock('idb-keyval', () => ({
get: idbGet,
set: idbSet,
del: idbDel,
}))

const STORE_KEY = 'workflow-undo-redo'
const MIGRATION_KEY = 'workflow-undo-redo-migrated'

async function loadFreshModule() {
vi.resetModules()
return await import('@/stores/undo-redo/storage')
}

describe('undo-redo IndexedDB storage adapter', () => {
beforeEach(() => {
idbStore.clear()
idbGet.mockClear()
idbSet.mockClear()
idbDel.mockClear()
localStorage.clear()
vi.mocked(localStorage.getItem).mockClear()
vi.mocked(localStorage.setItem).mockClear()
vi.mocked(localStorage.removeItem).mockClear()
})

describe('migration', () => {
it('copies localStorage data into IndexedDB and removes the localStorage key on first load', async () => {
const legacyPayload = JSON.stringify({ state: { stacks: {} }, version: 0 })
localStorage.setItem(STORE_KEY, legacyPayload)
idbSet.mockClear()

const { migrationReady } = await loadFreshModule()
await migrationReady

expect(idbSet).toHaveBeenCalledWith(STORE_KEY, legacyPayload)
expect(idbStore.get(STORE_KEY)).toBe(legacyPayload)
expect(localStorage.getItem(STORE_KEY)).toBeNull()
expect(idbStore.get(MIGRATION_KEY)).toBe(true)
})

it('skips data copy when localStorage is empty but still marks migration complete', async () => {
const { migrationReady } = await loadFreshModule()
await migrationReady

expect(idbSet).toHaveBeenCalledWith(MIGRATION_KEY, true)
expect(idbSet).not.toHaveBeenCalledWith(STORE_KEY, expect.anything())
expect(idbStore.get(MIGRATION_KEY)).toBe(true)
})

it('does not re-run when MIGRATION_KEY is already set', async () => {
idbStore.set(MIGRATION_KEY, true)
const legacyPayload = JSON.stringify({ state: { stacks: { foo: {} } }, version: 0 })
localStorage.setItem(STORE_KEY, legacyPayload)

const { migrationReady } = await loadFreshModule()
await migrationReady

expect(idbSet).not.toHaveBeenCalledWith(STORE_KEY, expect.anything())
expect(localStorage.getItem(STORE_KEY)).toBe(legacyPayload)
})

it('does not throw when IndexedDB set fails — leaves localStorage intact for retry', async () => {
idbSet.mockRejectedValueOnce(new Error('idb write failed'))
const legacyPayload = JSON.stringify({ state: { stacks: {} }, version: 0 })
localStorage.setItem(STORE_KEY, legacyPayload)

const { migrationReady } = await loadFreshModule()
await expect(migrationReady).resolves.toBeUndefined()

expect(localStorage.getItem(STORE_KEY)).toBe(legacyPayload)
})
})

describe('storage adapter', () => {
it('getItem awaits migration completion before reading', async () => {
const legacyPayload = JSON.stringify({ state: { stacks: { a: {} } }, version: 0 })
localStorage.setItem(STORE_KEY, legacyPayload)

const { indexedDBStorage, migrationReady } = await loadFreshModule()
const readPromise = indexedDBStorage.getItem(STORE_KEY)
await migrationReady
const value = await readPromise

expect(value).toBe(legacyPayload)
})

it('getItem returns null when key is absent', async () => {
const { indexedDBStorage, migrationReady } = await loadFreshModule()
await migrationReady

const value = await indexedDBStorage.getItem('does-not-exist')
expect(value).toBeNull()
})

it('setItem writes through to IndexedDB', async () => {
const { indexedDBStorage, migrationReady } = await loadFreshModule()
await migrationReady

await indexedDBStorage.setItem(STORE_KEY, 'new-value')
expect(idbStore.get(STORE_KEY)).toBe('new-value')
})

it('setItem swallows IndexedDB errors so the store never crashes the app', async () => {
const { indexedDBStorage, migrationReady } = await loadFreshModule()
await migrationReady

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
Comment on lines +126 to +133
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.

idbStore.set(STORE_KEY, 'present')

await indexedDBStorage.removeItem(STORE_KEY)
expect(idbStore.has(STORE_KEY)).toBe(false)
})

it('removeItem swallows IndexedDB errors', async () => {
const { indexedDBStorage, migrationReady } = await loadFreshModule()
await migrationReady

idbDel.mockRejectedValueOnce(new Error('idb delete failed'))
await expect(indexedDBStorage.removeItem(STORE_KEY)).resolves.toBeUndefined()
})

it('getItem swallows IndexedDB read errors and returns null', async () => {
const { indexedDBStorage, migrationReady } = await loadFreshModule()
await migrationReady

idbGet.mockRejectedValueOnce(new Error('idb read failed'))
const value = await indexedDBStorage.getItem(STORE_KEY)
expect(value).toBeNull()
})
})

describe('clearPersistedUndoRedo', () => {
it('deletes the undo-redo key from IndexedDB', async () => {
const { clearPersistedUndoRedo, migrationReady } = await loadFreshModule()
await migrationReady
idbStore.set(STORE_KEY, 'present')

await clearPersistedUndoRedo()

expect(idbStore.has(STORE_KEY)).toBe(false)
})

it('leaves the migration flag intact so migration does not re-run', async () => {
const { clearPersistedUndoRedo, migrationReady } = await loadFreshModule()
await migrationReady
idbStore.set(STORE_KEY, 'present')

await clearPersistedUndoRedo()

expect(idbStore.get(MIGRATION_KEY)).toBe(true)
})

it('propagates IndexedDB errors so callers can surface the failure', async () => {
const { clearPersistedUndoRedo, migrationReady } = await loadFreshModule()
await migrationReady

idbDel.mockRejectedValueOnce(new Error('idb delete failed'))
await expect(clearPersistedUndoRedo()).rejects.toThrow('idb delete failed')
})
})

describe('hydration race', () => {
it('blocks setItem until the first getItem resolves', async () => {
const { indexedDBStorage, migrationReady } = await loadFreshModule()
await migrationReady
idbStore.set(STORE_KEY, 'persisted-snapshot')

let releaseRead: ((value: 'persisted-snapshot') => void) | null = null
idbGet.mockImplementationOnce(
() =>
new Promise<'persisted-snapshot'>((resolve) => {
releaseRead = resolve
})
)

const readPromise = indexedDBStorage.getItem(STORE_KEY)
const writePromise = indexedDBStorage.setItem(STORE_KEY, 'empty-state')

// Give the microtask queue a chance to process; the write must still be pending.
await Promise.resolve()
expect(idbStore.get(STORE_KEY)).toBe('persisted-snapshot')

releaseRead?.('persisted-snapshot')
await readPromise
await writePromise

expect(idbStore.get(STORE_KEY)).toBe('empty-state')
})
})
})
142 changes: 142 additions & 0 deletions apps/sim/stores/undo-redo/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { createLogger } from '@sim/logger'
import { del, get, set } from 'idb-keyval'
import type { StateStorage } from 'zustand/middleware'

const logger = createLogger('UndoRedoStorage')

const STORE_KEY = 'workflow-undo-redo'
const MIGRATION_KEY = 'workflow-undo-redo-migrated'

let migrationPromiseInternal: Promise<void> | null = null

/**
* Resolves with the first `getItem` result that goes through the adapter.
* Used to gate writes until the initial rehydration read completes — without
* this, a `setItem` triggered before the async `getItem` returns would
* overwrite the IndexedDB snapshot with an empty in-memory state.
*/
let hydrationReadPromise: Promise<string | null> | null = null

/**
* Migrates existing undo/redo data from localStorage to IndexedDB.
* Runs once on first load; subsequent loads short-circuit on MIGRATION_KEY.
*
* On success the localStorage key is removed, freeing origin storage quota
* for the other persisted Zustand stores that share it.
*/
async function migrateFromLocalStorage(): Promise<void> {
if (typeof window === 'undefined') return

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.


const localData = localStorage.getItem(STORE_KEY)
if (localData) {
await set(STORE_KEY, localData)
localStorage.removeItem(STORE_KEY)
logger.info('Migrated undo-redo store from localStorage to IndexedDB')
}

await set(MIGRATION_KEY, true)
} catch (error) {
logger.warn('Migration from localStorage failed', { error })
}
}

if (typeof window !== 'undefined') {
migrationPromiseInternal = migrateFromLocalStorage().finally(() => {
migrationPromiseInternal = null
})
}

/**
* Resolves when the one-time localStorage → IndexedDB migration finishes.
* Exposed for tests; production code reads through `indexedDBStorage`
* methods which already await this promise.
*/
export const migrationReady: Promise<void> = migrationPromiseInternal ?? Promise.resolve()

async function awaitMigration(): Promise<void> {
if (migrationPromiseInternal) {
await migrationPromiseInternal
}
}

async function awaitHydrationRead(): Promise<void> {
if (hydrationReadPromise) {
try {
await hydrationReadPromise
} catch {
// The read promise already swallowed its own errors; ignore here.
}
}
}

/**
* Removes the persisted undo/redo payload from IndexedDB.
*
* Called from `clearUserData` on sign-out so undo history does not
* survive across user sessions on the same device. Errors are
* propagated so callers can decide how to react (the default
* `clearUserData` already wraps this call in a try/catch).
*/
export async function clearPersistedUndoRedo(): Promise<void> {
if (typeof window === 'undefined') return
await awaitMigration()

try {
await del(STORE_KEY)
} catch (error) {
logger.warn('Failed to clear persisted undo-redo', { error })
throw error
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
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.


export const indexedDBStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
if (typeof window === 'undefined') return null
await awaitMigration()

const readPromise = (async () => {
try {
const value = await get<string>(name)
return value ?? null
} catch (error) {
logger.warn('IndexedDB read failed', { name, error })
return null
}
})()

// Record the first read so concurrent writes can wait for it to complete.
if (!hydrationReadPromise) {
hydrationReadPromise = readPromise
}
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.


return await readPromise
},

setItem: async (name: string, value: string): Promise<void> => {
if (typeof window === 'undefined') return
await awaitMigration()
await awaitHydrationRead()

try {
await set(name, value)
Comment on lines +119 to +125
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)

} catch (error) {
logger.warn('IndexedDB write failed', { name, error })
}
},
Comment thread
cursor[bot] marked this conversation as resolved.

removeItem: async (name: string): Promise<void> => {
if (typeof window === 'undefined') return
await awaitMigration()
await awaitHydrationRead()

try {
await del(name)
} catch (error) {
logger.warn('IndexedDB delete failed', { name, error })
}
},
}
Loading