Files
zclaw_openfang/desktop/src/lib/idb-storage.ts
iven 0a04b260a4
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
refactor(desktop): ChatStore structured split + IDB persistence + stream cancel
Split monolithic chatStore.ts (908 lines) into 4 focused stores:
- chatStore.ts: facade layer, owns messages[], backward-compatible selectors
- conversationStore.ts: conversation CRUD, agent switching, IndexedDB persistence
- streamStore.ts: streaming orchestration, chat mode, suggestions
- messageStore.ts: token tracking

Key fixes from 3-round deep audit:
- C1: Fix Rust serde camelCase vs TS snake_case mismatch (toolStart/toolEnd/iterationStart)
- C2: Fix IDB async rehydration race with persist.hasHydrated() subscribe
- C3: Add sessionKey to partialize to survive page refresh
- H3: Fix IDB migration retry on failure (don't set migrated=true in catch)
- M3: Fix ToolCallStep deduplication (toolStart creates, toolEnd updates)
- M-NEW-2: Clear sessionKey on cancelStream

Also adds:
- Rust backend stream cancellation via AtomicBool + cancel_stream command
- IndexedDB storage adapter with one-time localStorage migration
- HMR cleanup for cross-store subscriptions
2026-04-03 00:24:16 +08:00

135 lines
4.1 KiB
TypeScript

/**
* 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<IDBPDatabase> | null = null;
function getDB(): Promise<IDBPDatabase> {
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<void> {
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<string | null> => {
// 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<void> => {
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<void> => {
try {
const db = await getDB();
await db.delete(STORE_NAME, name);
} catch (err) {
log.error('IndexedDB removeItem failed:', err);
}
},
};
}