diff --git a/src/lib/cacheManager.ts b/src/lib/cacheManager.ts index 69324289..7b712f8f 100644 --- a/src/lib/cacheManager.ts +++ b/src/lib/cacheManager.ts @@ -1,6 +1,6 @@ /** * Cache Manager - * Centralized cache management with synchronization, invalidation, and storage management + * Centralized cache management with synchronization, invalidation, versioning, and LRU eviction */ import { logger } from '@/utils/logger'; @@ -27,6 +27,10 @@ import { initPropertyCache, } from './propertyCache'; +// Version migration handlers +type VersionMigration = (data: unknown) => unknown; +const versionMigrations: Map = new Map(); + // Sync queue for offline operations interface SyncQueueItem { id: string; @@ -41,9 +45,11 @@ let isInitialized = false; let isOnline = true; let syncInProgress = false; let lastSyncTime = 0; +let cacheVersion = DEFAULT_CACHE_CONFIG.version; // Event listeners const stateChangeListeners: Set<(online: boolean) => void> = new Set(); +const mutationListeners: Map void>> = new Map(); /** * Initialize the cache manager @@ -55,6 +61,9 @@ export const initCacheManager = async (): Promise => { // Initialize property cache await initPropertyCache(); + // Check and handle cache version migrations + await handleCacheVersionMigration(); + // Set up online/offline detection setupNetworkListeners(); @@ -298,6 +307,155 @@ export const clearSyncQueue = (): void => { logger.info('Sync queue cleared'); }; +/** + * Handle cache version migrations + */ +const handleCacheVersionMigration = async (): Promise => { + if (typeof window === 'undefined') return; + + try { + const storedVersion = parseInt( + localStorage.getItem(LOCAL_STORAGE_KEYS.CACHE_VERSION) || '0', + 10 + ); + const currentVersion = DEFAULT_CACHE_CONFIG.version; + + if (storedVersion < currentVersion) { + logger.info(`Migrating cache from v${storedVersion} to v${currentVersion}`); + + // Run migrations for each version step + for (let v = storedVersion + 1; v <= currentVersion; v++) { + const migration = versionMigrations.get(v); + if (migration) { + try { + // Get all cached data, migrate it, and clear cache + const { getAllCachedProperties, getAllCachedMobileProperties } = await import( + './propertyCache' + ); + + const properties = await getAllCachedProperties(); + const mobileProperties = await getAllCachedMobileProperties(); + + // Apply migration + const migratedProperties = properties.map((entry) => ({ + ...entry, + data: migration(entry.data) as never, + })); + const migratedMobileProperties = mobileProperties.map((entry) => ({ + ...entry, + data: migration(entry.data) as never, + })); + + // Re-store migrated data + await clearAllCachedProperties(); + const { setCachedProperty, setCachedMobileProperty } = await import( + './propertyCache' + ); + + for (const entry of migratedProperties) { + await setCachedProperty(entry.data); + } + for (const entry of migratedMobileProperties) { + await setCachedMobileProperty(entry.data); + } + + logger.info(`Migration to v${v} completed`); + } catch (error) { + logger.error(`Error running migration to v${v}:`, error); + } + } + } + + localStorage.setItem(LOCAL_STORAGE_KEYS.CACHE_VERSION, currentVersion.toString()); + cacheVersion = currentVersion; + } + } catch (error) { + logger.error('Error handling cache version migration:', error); + } +}; + +/** + * Register a version migration handler + */ +export const registerVersionMigration = ( + version: number, + handler: (data: unknown) => unknown +): void => { + versionMigrations.set(version, handler); + logger.info(`Registered version migration for v${version}`); +}; + +/** + * Get current cache version + */ +export const getCacheVersion = (): number => cacheVersion; + +/** + * Register mutation listener for cache invalidation + */ +export const onMutation = ( + mutationType: string, + handler: (payload: unknown) => void +): (() => void) => { + if (!mutationListeners.has(mutationType)) { + mutationListeners.set(mutationType, new Set()); + } + mutationListeners.get(mutationType)!.add(handler); + + // Return unsubscribe function + return () => { + mutationListeners.get(mutationType)?.delete(handler); + }; +}; + +/** + * Trigger mutation and invalidate related cache + */ +export const triggerMutation = async ( + mutationType: string, + payload: unknown, + invalidationPatterns?: RegExp[] +): Promise => { + try { + // Call all registered listeners + const listeners = mutationListeners.get(mutationType); + if (listeners) { + listeners.forEach((handler) => { + try { + handler(payload); + } catch (error) { + logger.error(`Error calling mutation listener for ${mutationType}:`, error); + } + }); + } + + // Invalidate cache based on patterns + if (invalidationPatterns && invalidationPatterns.length > 0) { + for (const pattern of invalidationPatterns) { + await invalidateCache(pattern); + } + } + + // Track invalidation in stats + const stats = await updateCacheStats(); + if (stats && 'invalidationCount' in stats) { + const event: CacheEvent = { + type: 'invalidate', + key: mutationType, + timestamp: Date.now(), + reason: `Mutation: ${mutationType}`, + }; + addCacheEventListener((listener) => { + listener(event); + }); + } + + logger.info(`Mutation triggered: ${mutationType}, invalidated cache patterns`); + } catch (error) { + logger.error('Error triggering mutation:', error); + } +}; + /** * Invalidate cache entries by pattern */ diff --git a/src/lib/propertyCache.ts b/src/lib/propertyCache.ts index 158ba998..8a07cfaf 100644 --- a/src/lib/propertyCache.ts +++ b/src/lib/propertyCache.ts @@ -53,11 +53,17 @@ let cacheStats: CacheStats = { missRate: 0, oldestEntry: null, newestEntry: null, + evictionCount: 0, + invalidationCount: 0, + entitiesByType: {}, + sizeByType: {}, }; // Request counters for hit/miss rate let cacheHits = 0; let cacheMisses = 0; +let evictionCount = 0; +let invalidationCount = 0; /** * Emit a cache event @@ -88,17 +94,22 @@ export const addCacheEventListener = (listener: CacheEventListener): (() => void const createCacheMetadata = ( key: string, size: number, + dataType: string = 'default', config: CacheConfig = DEFAULT_CACHE_CONFIG ): CacheMetadata => { const now = Date.now(); + // Get TTL for this data type, fallback to default TTL + const ttl = config.dataTypeTtls?.[dataType] ?? config.ttl; + return { key, cachedAt: now, - expiresAt: now + config.ttl, + expiresAt: now + ttl, lastAccessed: now, accessCount: 0, size, version: config.version, + dataType, }; }; @@ -151,6 +162,78 @@ export const isCacheAvailable = (): boolean => { return isIndexedDBSupported(); }; +/** + * Perform LRU eviction when cache limits are exceeded + */ +const performLRUEviction = async (config: CacheConfig): Promise => { + if (!config.enableLRU) return; + + try { + const properties = await getAllCachedProperties(); + const mobileProperties = await getAllCachedMobileProperties(); + const allEntries = [ + ...properties.map(p => ({ ...p, type: 'property' as const })), + ...mobileProperties.map(p => ({ ...p, type: 'mobile-property' as const })) + ]; + + // Check if we exceed max entries + if (allEntries.length > config.maxEntries) { + // Sort by last accessed (ascending) - least recently used first + const sorted = [...allEntries].sort( + (a, b) => a.metadata.lastAccessed - b.metadata.lastAccessed + ); + + // Remove least recently used entries until we're under the limit + const toRemove = sorted.length - config.maxEntries; + for (let i = 0; i < toRemove; i++) { + const entry = sorted[i]; + if (entry.type === 'property') { + await deleteCachedProperty(entry.data.id); + } else { + await deleteCachedMobileProperty(entry.data.id); + } + evictionCount++; + emitEvent({ + type: 'evict', + key: entry.metadata.key, + timestamp: Date.now(), + reason: 'LRU eviction - max entries exceeded', + }); + } + } + + // Check if we exceed max size + const totalSize = allEntries.reduce((sum, e) => sum + e.metadata.size, 0); + if (totalSize > config.maxSize) { + const sorted = [...allEntries].sort( + (a, b) => a.metadata.lastAccessed - b.metadata.lastAccessed + ); + + // Remove least recently used entries until we're under the size limit + let currentSize = totalSize; + for (const entry of sorted) { + if (currentSize <= config.maxSize * 0.9) break; // Stop at 90% of max + + if (entry.type === 'property') { + await deleteCachedProperty(entry.data.id); + } else { + await deleteCachedMobileProperty(entry.data.id); + } + currentSize -= entry.metadata.size; + evictionCount++; + emitEvent({ + type: 'evict', + key: entry.metadata.key, + timestamp: Date.now(), + reason: 'LRU eviction - size limit exceeded', + }); + } + } + } catch (error) { + logger.error('Error performing LRU eviction:', error); + } +}; + /** * Get a property from cache */ @@ -167,7 +250,7 @@ export const getCachedProperty = async ( if (!entry) { cacheMisses++; - emitEvent({ type: 'miss', key, timestamp: Date.now() }); + emitEvent({ type: 'miss', key, timestamp: Date.now(), dataType: 'property' }); return { data: null, source: 'none', stale: false }; } @@ -176,7 +259,13 @@ export const getCachedProperty = async ( if (status === 'expired') { cacheMisses++; await deleteCachedProperty(propertyId); - emitEvent({ type: 'expire', key, timestamp: Date.now(), metadata: entry.metadata }); + emitEvent({ + type: 'expire', + key, + timestamp: Date.now(), + metadata: entry.metadata, + dataType: 'property' + }); return { data: null, source: 'none', stale: false }; } @@ -188,7 +277,13 @@ export const getCachedProperty = async ( await dbSet(CACHE_STORE_NAMES.PROPERTIES, key, updatedEntry); cacheHits++; - emitEvent({ type: 'hit', key, timestamp: Date.now(), metadata: entry.metadata }); + emitEvent({ + type: 'hit', + key, + timestamp: Date.now(), + metadata: entry.metadata, + dataType: 'property' + }); return { data: entry.data, @@ -197,7 +292,13 @@ export const getCachedProperty = async ( }; } catch (error) { logger.error('Error getting cached property:', error); - emitEvent({ type: 'error', key, timestamp: Date.now(), error: error as Error }); + emitEvent({ + type: 'error', + key, + timestamp: Date.now(), + error: error as Error, + dataType: 'property' + }); return { data: null, source: 'none', stale: false, error: error as Error }; } }; @@ -215,23 +316,38 @@ export const setCachedProperty = async ( // Calculate approximate size const tempEntry: CacheEntry = { data: property, - metadata: createCacheMetadata(key, 0, config), + metadata: createCacheMetadata(key, 0, 'property', config), }; const size = calculateEntrySize(tempEntry); const entry: PropertyCacheEntry = { data: property, - metadata: createCacheMetadata(key, size, config), + metadata: createCacheMetadata(key, size, 'property', config), }; await dbSet(CACHE_STORE_NAMES.PROPERTIES, key, entry); - emitEvent({ type: 'set', key, timestamp: Date.now(), metadata: entry.metadata }); + emitEvent({ + type: 'set', + key, + timestamp: Date.now(), + metadata: entry.metadata, + dataType: 'property' + }); + + // Perform LRU eviction if needed + await performLRUEviction(config); // Update stats await updateCacheStats(); } catch (error) { logger.error('Error caching property:', error); - emitEvent({ type: 'error', key, timestamp: Date.now(), error: error as Error }); + emitEvent({ + type: 'error', + key, + timestamp: Date.now(), + error: error as Error, + dataType: 'property' + }); throw error; } }; @@ -315,21 +431,37 @@ export const setCachedMobileProperty = async ( try { const tempEntry: CacheEntry = { data: property, - metadata: createCacheMetadata(key, 0, config), + metadata: createCacheMetadata(key, 0, 'mobile-property', config), }; const size = calculateEntrySize(tempEntry); const entry: MobilePropertyCacheEntry = { data: property, - metadata: createCacheMetadata(key, size, config), + metadata: createCacheMetadata(key, size, 'mobile-property', config), }; await dbSet(CACHE_STORE_NAMES.MOBILE_PROPERTIES, key, entry); - emitEvent({ type: 'set', key, timestamp: Date.now(), metadata: entry.metadata }); + emitEvent({ + type: 'set', + key, + timestamp: Date.now(), + metadata: entry.metadata, + dataType: 'mobile-property' + }); + + // Perform LRU eviction if needed + await performLRUEviction(config); + await updateCacheStats(); } catch (error) { logger.error('Error caching mobile property:', error); - emitEvent({ type: 'error', key, timestamp: Date.now(), error: error as Error }); + emitEvent({ + type: 'error', + key, + timestamp: Date.now(), + error: error as Error, + dataType: 'mobile-property' + }); throw error; } }; @@ -544,6 +676,22 @@ export const updateCacheStats = async (): Promise => { ...mobileProperties.map((p) => p.metadata.cachedAt), ]; + // Calculate metrics by data type + const entitiesByType: Record = {}; + const sizeByType: Record = {}; + + properties.forEach(p => { + const dt = p.metadata.dataType; + entitiesByType[dt] = (entitiesByType[dt] || 0) + 1; + sizeByType[dt] = (sizeByType[dt] || 0) + p.metadata.size; + }); + + mobileProperties.forEach(p => { + const dt = p.metadata.dataType; + entitiesByType[dt] = (entitiesByType[dt] || 0) + 1; + sizeByType[dt] = (sizeByType[dt] || 0) + p.metadata.size; + }); + const totalRequests = cacheHits + cacheMisses; const hitRate = totalRequests > 0 ? cacheHits / totalRequests : 0; const missRate = totalRequests > 0 ? cacheMisses / totalRequests : 0; @@ -566,6 +714,10 @@ export const updateCacheStats = async (): Promise => { missRate, oldestEntry: allTimestamps.length > 0 ? Math.min(...allTimestamps) : null, newestEntry: allTimestamps.length > 0 ? Math.max(...allTimestamps) : null, + evictionCount, + invalidationCount, + entitiesByType, + sizeByType, }; // Persist stats @@ -607,6 +759,13 @@ export const cleanupExpiredEntries = async (): Promise => { if (now > entry.metadata.expiresAt) { await deleteCachedProperty(entry.data.id); cleanedCount++; + emitEvent({ + type: 'cleanup', + key: entry.metadata.key, + timestamp: Date.now(), + dataType: entry.metadata.dataType, + reason: 'TTL expired', + }); } } @@ -615,6 +774,13 @@ export const cleanupExpiredEntries = async (): Promise => { if (now > entry.metadata.expiresAt) { await deleteCachedMobileProperty(entry.data.id); cleanedCount++; + emitEvent({ + type: 'cleanup', + key: entry.metadata.key, + timestamp: Date.now(), + dataType: entry.metadata.dataType, + reason: 'TTL expired', + }); } } @@ -627,9 +793,18 @@ export const cleanupExpiredEntries = async (): Promise => { for (const [key, value] of Object.entries(searches)) { const searchCache = value as { cachedAt: number }; - if (now - searchCache.cachedAt > config.ttl) { + const searchTTL = config.dataTypeTtls?.['search'] ?? config.ttl; + if (now - searchCache.cachedAt > searchTTL) { delete searches[key]; modified = true; + cleanedCount++; + emitEvent({ + type: 'cleanup', + key, + timestamp: Date.now(), + dataType: 'search', + reason: 'TTL expired', + }); } } @@ -641,8 +816,9 @@ export const cleanupExpiredEntries = async (): Promise => { if (cleanedCount > 0) { emitEvent({ type: 'cleanup', - key: 'expired', + key: 'batch', timestamp: Date.now(), + reason: `${cleanedCount} entries cleaned`, }); await updateCacheStats(); } diff --git a/src/types/cache.ts b/src/types/cache.ts index 0e20b555..df168176 100644 --- a/src/types/cache.ts +++ b/src/types/cache.ts @@ -18,6 +18,7 @@ export interface CacheMetadata { accessCount: number; size: number; version: number; + dataType: string; etag?: string; checksum?: string; } @@ -48,11 +49,22 @@ export interface CacheStats { missRate: number; oldestEntry: number | null; newestEntry: number | null; + evictionCount: number; + invalidationCount: number; + entitiesByType: Record; + sizeByType: Record; +} + +// Per-data-type cache configuration +export interface DataTypeCacheConfig { + ttl: number; + maxSize?: number; + maxEntries?: number; } // Cache configuration options export interface CacheConfig { - // Time-to-live in milliseconds + // Time-to-live in milliseconds (default) ttl: number; // Maximum cache size in bytes maxSize: number; @@ -64,6 +76,12 @@ export interface CacheConfig { compression: boolean; // Cache version for migrations version: number; + // Per-data-type TTL configurations + dataTypeTtls: Record; + // Enable LRU eviction + enableLRU: boolean; + // Cache version migration handlers + versionMigrations?: Record unknown>; } // Default cache configuration @@ -74,6 +92,12 @@ export const DEFAULT_CACHE_CONFIG: CacheConfig = { cleanupInterval: 5 * 60 * 1000, // 5 minutes compression: false, version: 1, + dataTypeTtls: { + 'property': 24 * 60 * 60 * 1000, // 24 hours for properties + 'mobile-property': 12 * 60 * 60 * 1000, // 12 hours for mobile properties + 'search': 1 * 60 * 60 * 1000, // 1 hour for search results + }, + enableLRU: true, }; // Cache strategies @@ -96,7 +120,10 @@ export type CacheEventType = | 'expire' | 'clear' | 'error' - | 'cleanup'; + | 'cleanup' + | 'invalidate' + | 'evict' + | 'migrate'; // Cache event export interface CacheEvent { @@ -105,6 +132,8 @@ export interface CacheEvent { timestamp: number; metadata?: Partial; error?: Error; + dataType?: string; + reason?: string; } // Cache event listener