/** * IndexedDBContext - Clean persistence layer for file storage * Integrates with FileContext to provide transparent file persistence */ import React, { createContext, useContext, useCallback, useRef } from 'react'; import { fileStorage } from '../services/fileStorage'; import { FileId } from '../types/file'; import { StirlingFileStub, createStirlingFile } from '../types/fileContext'; import { generateThumbnailForFile } from '../utils/thumbnailUtils'; const DEBUG = process.env.NODE_ENV === 'development'; interface IndexedDBContextValue { // Core CRUD operations saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise; loadFile: (fileId: FileId) => Promise; loadMetadata: (fileId: FileId) => Promise; deleteFile: (fileId: FileId) => Promise; // Batch operations loadAllMetadata: () => Promise; loadLeafMetadata: () => Promise; // Only leaf files for recent files list deleteMultiple: (fileIds: FileId[]) => Promise; clearAll: () => Promise; // Utilities getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>; updateThumbnail: (fileId: FileId, thumbnail: string) => Promise; markFileAsProcessed: (fileId: FileId) => Promise; } const IndexedDBContext = createContext(null); interface IndexedDBProviderProps { children: React.ReactNode; } export function IndexedDBProvider({ children }: IndexedDBProviderProps) { // LRU File cache to avoid repeated ArrayBuffer→File conversions const fileCache = useRef(new Map()); const MAX_CACHE_SIZE = 50; // Maximum number of files to cache // LRU cache management const evictLRUEntries = useCallback(() => { if (fileCache.current.size <= MAX_CACHE_SIZE) return; // Convert to array and sort by last accessed time (oldest first) const entries = Array.from(fileCache.current.entries()) .sort(([, a], [, b]) => a.lastAccessed - b.lastAccessed); // Remove the least recently used entries const toRemove = entries.slice(0, fileCache.current.size - MAX_CACHE_SIZE); toRemove.forEach(([fileId]) => { fileCache.current.delete(fileId); }); if (DEBUG) console.log(`🗂️ Evicted ${toRemove.length} LRU cache entries`); }, []); const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise => { // Use existing thumbnail or generate new one if none provided const thumbnail = existingThumbnail || await generateThumbnailForFile(file); // Store in IndexedDB (no history data - that's handled by direct fileStorage calls now) const stirlingFile = createStirlingFile(file, fileId); await fileStorage.storeStirlingFile(stirlingFile, thumbnail, true); const storedFile = await fileStorage.getStirlingFileStub(fileId); // Cache the file object for immediate reuse fileCache.current.set(fileId, { file, lastAccessed: Date.now() }); evictLRUEntries(); // Return StirlingFileStub from the stored file (no conversion needed) if (!storedFile) { throw new Error(`Failed to retrieve stored file after saving: ${file.name}`); } return storedFile; }, []); const loadFile = useCallback(async (fileId: FileId): Promise => { // Check cache first const cached = fileCache.current.get(fileId); if (cached) { // Update last accessed time for LRU cached.lastAccessed = Date.now(); return cached.file; } // Load from IndexedDB const storedFile = await fileStorage.getStirlingFile(fileId); if (!storedFile) return null; // StirlingFile is already a File object, no reconstruction needed const file = storedFile; // Cache for future use with LRU eviction fileCache.current.set(fileId, { file, lastAccessed: Date.now() }); evictLRUEntries(); return file; }, [evictLRUEntries]); const loadMetadata = useCallback(async (fileId: FileId): Promise => { // Load stub directly from storage service return await fileStorage.getStirlingFileStub(fileId); }, []); const deleteFile = useCallback(async (fileId: FileId): Promise => { // Remove from cache fileCache.current.delete(fileId); // Remove from IndexedDB await fileStorage.deleteStirlingFile(fileId); }, []); const loadLeafMetadata = useCallback(async (): Promise => { const metadata = await fileStorage.getLeafStirlingFileStubs(); // Only get leaf files // All files are already StirlingFileStub objects, no processing needed return metadata; }, []); const loadAllMetadata = useCallback(async (): Promise => { const metadata = await fileStorage.getAllStirlingFileStubs(); // All files are already StirlingFileStub objects, no processing needed return metadata; }, []); const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise => { // Remove from cache fileIds.forEach(id => fileCache.current.delete(id)); // Remove from IndexedDB in parallel await Promise.all(fileIds.map(id => fileStorage.deleteStirlingFile(id))); }, []); const clearAll = useCallback(async (): Promise => { // Clear cache fileCache.current.clear(); // Clear IndexedDB await fileStorage.clearAll(); }, []); const getStorageStats = useCallback(async () => { return await fileStorage.getStorageStats(); }, []); const updateThumbnail = useCallback(async (fileId: FileId, thumbnail: string): Promise => { return await fileStorage.updateThumbnail(fileId, thumbnail); }, []); const markFileAsProcessed = useCallback(async (fileId: FileId): Promise => { return await fileStorage.markFileAsProcessed(fileId); }, []); const value: IndexedDBContextValue = { saveFile, loadFile, loadMetadata, deleteFile, loadAllMetadata, loadLeafMetadata, deleteMultiple, clearAll, getStorageStats, updateThumbnail, markFileAsProcessed }; return ( {children} ); } export function useIndexedDB() { const context = useContext(IndexedDBContext); if (!context) { throw new Error('useIndexedDB must be used within an IndexedDBProvider'); } return context; }