/** * idb-storage.ts — Zustand-compatible async storage adapter using IndexedDB. * * Provides a drop-in replacement for localStorage that uses IndexedDB, * bypassing the 5MB storage limit for conversation data. * * Includes one-time migration from localStorage for the conversation store. */ import { openDB, type IDBPDatabase } from 'idb'; import { createLogger } from './logger'; const log = createLogger('IDBStorage'); // --------------------------------------------------------------------------- // IndexedDB schema // --------------------------------------------------------------------------- const DB_NAME = 'zclaw-store'; const DB_VERSION = 1; const STORE_NAME = 'keyvalue'; // localStorage key that holds existing conversation data const CONVERSATION_LS_KEY = 'zclaw-conversation-storage'; // --------------------------------------------------------------------------- // Database singleton // --------------------------------------------------------------------------- let dbPromise: Promise | null = null; function getDB(): Promise { if (!dbPromise) { dbPromise = openDB(DB_NAME, DB_VERSION, { upgrade(db) { if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME); } }, }); } return dbPromise; } // --------------------------------------------------------------------------- // One-time migration from localStorage -> IndexedDB // --------------------------------------------------------------------------- let migrated = false; async function migrateFromLocalStorage(): Promise { if (migrated) return; migrated = true; try { const db = await getDB(); // Check if IndexedDB already has data const existing = await db.get(STORE_NAME, CONVERSATION_LS_KEY); if (existing !== undefined) { return; // Already migrated } // Read from localStorage const lsData = localStorage.getItem(CONVERSATION_LS_KEY); if (!lsData) return; log.info('Migrating conversation data from localStorage to IndexedDB...'); const parsed = JSON.parse(lsData); // Write to IndexedDB await db.put(STORE_NAME, parsed, CONVERSATION_LS_KEY); // Delete from localStorage to free space localStorage.removeItem(CONVERSATION_LS_KEY); log.info('Migration complete. localStorage entry removed.'); } catch (err) { log.error('Migration from localStorage failed:', err); // Allow retry on next load — don't leave `migrated = true` on failure migrated = false; } } // --------------------------------------------------------------------------- // Zustand-compatible storage adapter // --------------------------------------------------------------------------- /** * Create a Zustand persist storage adapter backed by IndexedDB. * * Usage: * persist(store, { storage: createJSONStorage(() => createIdbStorageAdapter()) }) */ export function createIdbStorageAdapter() { return { getItem: async (name: string): Promise => { // Perform migration on first access for conversation store key if (name === CONVERSATION_LS_KEY) { await migrateFromLocalStorage(); } try { const db = await getDB(); const value = await db.get(STORE_NAME, name); if (value === undefined) { return null; } // Zustand persist expects a JSON string return typeof value === 'string' ? value : JSON.stringify(value); } catch (err) { log.error('IndexedDB getItem failed:', err); return null; } }, setItem: async (name: string, value: string): Promise => { try { const db = await getDB(); const parsed = JSON.parse(value); await db.put(STORE_NAME, parsed, name); } catch (err) { log.error('IndexedDB setItem failed:', err); } }, removeItem: async (name: string): Promise => { try { const db = await getDB(); await db.delete(STORE_NAME, name); } catch (err) { log.error('IndexedDB removeItem failed:', err); } }, }; }