-
Notifications
You must be signed in to change notification settings - Fork 3.6k
feat(undo-redo): migrate persistence from localStorage to IndexedDB #4738
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fe854cb
d9201d8
54a70cc
3f0e896
2a8bf5f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| 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') | ||
| }) | ||
| }) | ||
| }) | ||
| 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 | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Orphan localStorage not clearedMedium Severity The 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 | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
cursor[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sign-out races undo hydrationMedium Severity
Additional Locations (1)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 | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reads ignore legacy localStorageMedium Severity After a failed Additional Locations (1)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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||
| logger.warn('IndexedDB write failed', { name, error }) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
|
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 }) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removeItemgetItemandsetItemeach have a dedicated test that verifies IndexedDB errors are caught and don't propagate.removeItemhas only a success-path test. A test that mocksidbDelto reject would mirror the existing pattern and guard against a future regression where thecatchblock is accidentally removed.