Skip to content

Commit fe854cb

Browse files
committed
feat(undo-redo): add IndexedDB storage adapter
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 #4737 Signed-off-by: JaeHyung Jang <jaehyung.jang@navercorp.com>
1 parent f6c9998 commit fe854cb

2 files changed

Lines changed: 235 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { idbStore, idbGet, idbSet, idbDel } = vi.hoisted(() => {
7+
const store = new Map<string, unknown>()
8+
return {
9+
idbStore: store,
10+
idbGet: vi.fn(async (key: string) => store.get(key) ?? undefined),
11+
idbSet: vi.fn(async (key: string, value: unknown) => {
12+
store.set(key, value)
13+
}),
14+
idbDel: vi.fn(async (key: string) => {
15+
store.delete(key)
16+
}),
17+
}
18+
})
19+
20+
vi.mock('idb-keyval', () => ({
21+
get: idbGet,
22+
set: idbSet,
23+
del: idbDel,
24+
}))
25+
26+
const STORE_KEY = 'workflow-undo-redo'
27+
const MIGRATION_KEY = 'workflow-undo-redo-migrated'
28+
29+
async function loadFreshModule() {
30+
vi.resetModules()
31+
return await import('@/stores/undo-redo/storage')
32+
}
33+
34+
describe('undo-redo IndexedDB storage adapter', () => {
35+
beforeEach(() => {
36+
idbStore.clear()
37+
idbGet.mockClear()
38+
idbSet.mockClear()
39+
idbDel.mockClear()
40+
localStorage.clear()
41+
vi.mocked(localStorage.getItem).mockClear()
42+
vi.mocked(localStorage.setItem).mockClear()
43+
vi.mocked(localStorage.removeItem).mockClear()
44+
})
45+
46+
describe('migration', () => {
47+
it('copies localStorage data into IndexedDB and removes the localStorage key on first load', async () => {
48+
const legacyPayload = JSON.stringify({ state: { stacks: {} }, version: 0 })
49+
localStorage.setItem(STORE_KEY, legacyPayload)
50+
idbSet.mockClear()
51+
52+
const { migrationReady } = await loadFreshModule()
53+
await migrationReady
54+
55+
expect(idbSet).toHaveBeenCalledWith(STORE_KEY, legacyPayload)
56+
expect(idbStore.get(STORE_KEY)).toBe(legacyPayload)
57+
expect(localStorage.getItem(STORE_KEY)).toBeNull()
58+
expect(idbStore.get(MIGRATION_KEY)).toBe(true)
59+
})
60+
61+
it('skips data copy when localStorage is empty but still marks migration complete', async () => {
62+
const { migrationReady } = await loadFreshModule()
63+
await migrationReady
64+
65+
expect(idbSet).toHaveBeenCalledWith(MIGRATION_KEY, true)
66+
expect(idbSet).not.toHaveBeenCalledWith(STORE_KEY, expect.anything())
67+
expect(idbStore.get(MIGRATION_KEY)).toBe(true)
68+
})
69+
70+
it('does not re-run when MIGRATION_KEY is already set', async () => {
71+
idbStore.set(MIGRATION_KEY, true)
72+
const legacyPayload = JSON.stringify({ state: { stacks: { foo: {} } }, version: 0 })
73+
localStorage.setItem(STORE_KEY, legacyPayload)
74+
75+
const { migrationReady } = await loadFreshModule()
76+
await migrationReady
77+
78+
expect(idbSet).not.toHaveBeenCalledWith(STORE_KEY, expect.anything())
79+
expect(localStorage.getItem(STORE_KEY)).toBe(legacyPayload)
80+
})
81+
82+
it('does not throw when IndexedDB set fails — leaves localStorage intact for retry', async () => {
83+
idbSet.mockRejectedValueOnce(new Error('idb write failed'))
84+
const legacyPayload = JSON.stringify({ state: { stacks: {} }, version: 0 })
85+
localStorage.setItem(STORE_KEY, legacyPayload)
86+
87+
const { migrationReady } = await loadFreshModule()
88+
await expect(migrationReady).resolves.toBeUndefined()
89+
90+
expect(localStorage.getItem(STORE_KEY)).toBe(legacyPayload)
91+
})
92+
})
93+
94+
describe('storage adapter', () => {
95+
it('getItem awaits migration completion before reading', async () => {
96+
const legacyPayload = JSON.stringify({ state: { stacks: { a: {} } }, version: 0 })
97+
localStorage.setItem(STORE_KEY, legacyPayload)
98+
99+
const { indexedDBStorage, migrationReady } = await loadFreshModule()
100+
const readPromise = indexedDBStorage.getItem(STORE_KEY)
101+
await migrationReady
102+
const value = await readPromise
103+
104+
expect(value).toBe(legacyPayload)
105+
})
106+
107+
it('getItem returns null when key is absent', async () => {
108+
const { indexedDBStorage, migrationReady } = await loadFreshModule()
109+
await migrationReady
110+
111+
const value = await indexedDBStorage.getItem('does-not-exist')
112+
expect(value).toBeNull()
113+
})
114+
115+
it('setItem writes through to IndexedDB', async () => {
116+
const { indexedDBStorage, migrationReady } = await loadFreshModule()
117+
await migrationReady
118+
119+
await indexedDBStorage.setItem(STORE_KEY, 'new-value')
120+
expect(idbStore.get(STORE_KEY)).toBe('new-value')
121+
})
122+
123+
it('setItem swallows IndexedDB errors so the store never crashes the app', async () => {
124+
const { indexedDBStorage, migrationReady } = await loadFreshModule()
125+
await migrationReady
126+
127+
idbSet.mockRejectedValueOnce(new Error('idb quota'))
128+
await expect(indexedDBStorage.setItem(STORE_KEY, 'x')).resolves.toBeUndefined()
129+
})
130+
131+
it('removeItem deletes from IndexedDB', async () => {
132+
const { indexedDBStorage, migrationReady } = await loadFreshModule()
133+
await migrationReady
134+
idbStore.set(STORE_KEY, 'present')
135+
136+
await indexedDBStorage.removeItem(STORE_KEY)
137+
expect(idbStore.has(STORE_KEY)).toBe(false)
138+
})
139+
140+
it('getItem swallows IndexedDB read errors and returns null', async () => {
141+
const { indexedDBStorage, migrationReady } = await loadFreshModule()
142+
await migrationReady
143+
144+
idbGet.mockRejectedValueOnce(new Error('idb read failed'))
145+
const value = await indexedDBStorage.getItem(STORE_KEY)
146+
expect(value).toBeNull()
147+
})
148+
})
149+
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { createLogger } from '@sim/logger'
2+
import { del, get, set } from 'idb-keyval'
3+
import type { StateStorage } from 'zustand/middleware'
4+
5+
const logger = createLogger('UndoRedoStorage')
6+
7+
const STORE_KEY = 'workflow-undo-redo'
8+
const MIGRATION_KEY = 'workflow-undo-redo-migrated'
9+
10+
let migrationPromiseInternal: Promise<void> | null = null
11+
12+
/**
13+
* Migrates existing undo/redo data from localStorage to IndexedDB.
14+
* Runs once on first load; subsequent loads short-circuit on MIGRATION_KEY.
15+
*
16+
* On success the localStorage key is removed, freeing origin storage quota
17+
* for the other persisted Zustand stores that share it.
18+
*/
19+
async function migrateFromLocalStorage(): Promise<void> {
20+
if (typeof localStorage === 'undefined') return
21+
22+
try {
23+
const migrated = await get<boolean>(MIGRATION_KEY)
24+
if (migrated) return
25+
26+
const localData = localStorage.getItem(STORE_KEY)
27+
if (localData) {
28+
await set(STORE_KEY, localData)
29+
localStorage.removeItem(STORE_KEY)
30+
logger.info('Migrated undo-redo store from localStorage to IndexedDB')
31+
}
32+
33+
await set(MIGRATION_KEY, true)
34+
} catch (error) {
35+
logger.warn('Migration from localStorage failed', { error })
36+
}
37+
}
38+
39+
if (typeof localStorage !== 'undefined') {
40+
migrationPromiseInternal = migrateFromLocalStorage().finally(() => {
41+
migrationPromiseInternal = null
42+
})
43+
}
44+
45+
/**
46+
* Resolves when the one-time localStorage → IndexedDB migration finishes.
47+
* Exposed for tests; production code reads through `indexedDBStorage.getItem`
48+
* which already awaits this promise.
49+
*/
50+
export const migrationReady: Promise<void> = migrationPromiseInternal ?? Promise.resolve()
51+
52+
export const indexedDBStorage: StateStorage = {
53+
getItem: async (name: string): Promise<string | null> => {
54+
if (typeof localStorage === 'undefined') return null
55+
56+
if (migrationPromiseInternal) {
57+
await migrationPromiseInternal
58+
}
59+
60+
try {
61+
const value = await get<string>(name)
62+
return value ?? null
63+
} catch (error) {
64+
logger.warn('IndexedDB read failed', { name, error })
65+
return null
66+
}
67+
},
68+
69+
setItem: async (name: string, value: string): Promise<void> => {
70+
if (typeof localStorage === 'undefined') return
71+
try {
72+
await set(name, value)
73+
} catch (error) {
74+
logger.warn('IndexedDB write failed', { name, error })
75+
}
76+
},
77+
78+
removeItem: async (name: string): Promise<void> => {
79+
if (typeof localStorage === 'undefined') return
80+
try {
81+
await del(name)
82+
} catch (error) {
83+
logger.warn('IndexedDB delete failed', { name, error })
84+
}
85+
},
86+
}

0 commit comments

Comments
 (0)