import NodeCache from 'node-cache'; import { CacheProvider, CacheStats } from '../interfaces/cache-provider'; import { MemoryCacheOptions } from '../interfaces/cache-options'; import { serialize, deserialize } from '../utils/serializer'; /** * In-memory cache provider implementation */ export class MemoryCache implements CacheProvider { private cache: NodeCache; private options: MemoryCacheOptions; private stats: { hits: number; misses: number; }; private logger: any; /** * Creates a new MemoryCache instance * @param options Memory cache options */ constructor(options: MemoryCacheOptions = {}) { this.options = { ttl: 3600000, // 1 hour default keyPrefix: 'cache:', serialize: true, enableLogging: false, logLevel: 'info', maxItems: 1000, deleteExpiredOnGet: true, checkExpirationInterval: 60000, // 1 minute ...options, }; this.cache = new NodeCache({ stdTTL: this.options.ttl ? this.options.ttl / 1000 : undefined, // Convert to seconds checkperiod: this.options.checkExpirationInterval ? this.options.checkExpirationInterval / 1000 : undefined, useClones: false, // Don't clone objects (we serialize them) deleteOnExpire: true, maxKeys: this.options.maxItems, }); this.stats = { hits: 0, misses: 0, }; // Initialize logger if enabled if (this.options.enableLogging) { try { // Try to use a logger from @unidev-hub/logger if available this.logger = require('@unidev-hub/logger').getLogger('cache:memory'); } catch (error) { // Fallback to console this.logger = { debug: console.debug, info: console.info, warn: console.warn, error: console.error, }; } } } /** * Get a prefixed key * @param key Original key * @returns Prefixed key */ private getKey(key: string): string { return `${this.options.keyPrefix}${key}`; } /** * Log a cache operation if logging is enabled * @param level Log level * @param message Message to log * @param data Additional data to log */ private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: any): void { if (this.options.enableLogging && this.logger) { // Only log if the level is at or above the configured level const levels = ['debug', 'info', 'warn', 'error']; const configLevelIndex = levels.indexOf(this.options.logLevel || 'info'); const logLevelIndex = levels.indexOf(level); if (logLevelIndex >= configLevelIndex) { this.logger[level](message, data); } } } /** * Get a value from the cache * @param key Cache key * @returns Promise resolving to the cached value or undefined if not found */ async get(key: string): Promise { const prefixedKey = this.getKey(key); const value = this.cache.get(prefixedKey); if (value === undefined) { this.stats.misses++; this.log('debug', `Cache miss for key: ${key}`); return undefined; } this.stats.hits++; this.log('debug', `Cache hit for key: ${key}`); if (!this.options.serialize) { return value as unknown as T; } // Deserialize the value try { return deserialize(value); } catch (error) { this.log('error', `Failed to deserialize value for key: ${key}`, error); return undefined; } } /** * Set a value in the cache * @param key Cache key * @param value Value to cache * @param ttl Time-to-live in milliseconds (optional) * @returns Promise resolving to true if successful */ async set(key: string, value: T, ttl?: number): Promise { const prefixedKey = this.getKey(key); // Convert TTL to seconds for NodeCache const ttlSeconds = ttl ? ttl / 1000 : undefined; try { let valueToStore: any = value; // Serialize the value if needed if (this.options.serialize) { valueToStore = serialize(value); } this.cache.set(prefixedKey, valueToStore, ttlSeconds || 0); this.log('debug', `Cache set for key: ${key}`, { ttl: ttl || this.options.ttl }); return true; } catch (error) { this.log('error', `Failed to set cache for key: ${key}`, error); return false; } } /** * Delete a value from the cache * @param key Cache key * @returns Promise resolving to true if successful */ async delete(key: string): Promise { const prefixedKey = this.getKey(key); const deleted = this.cache.del(prefixedKey); this.log('debug', `Cache delete for key: ${key}`, { deleted: deleted > 0 }); return deleted > 0; } /** * Check if a key exists in the cache * @param key Cache key * @returns Promise resolving to true if the key exists */ async has(key: string): Promise { const prefixedKey = this.getKey(key); const exists = this.cache.has(prefixedKey); this.log('debug', `Cache check for key: ${key}`, { exists }); return exists; } /** * Clear all values from the cache * @returns Promise resolving to true if successful */ async clear(): Promise { this.cache.flushAll(); this.log('info', 'Cache cleared'); return true; } /** * Get multiple values from the cache * @param keys Array of cache keys * @returns Promise resolving to a map of key-value pairs for found items */ async getMany(keys: string[]): Promise> { const result = new Map(); const prefixedKeys = keys.map(key => this.getKey(key)); const values = this.cache.mget(prefixedKeys); for (const key of keys) { const prefixedKey = this.getKey(key); const value = values[prefixedKey]; if (value !== undefined) { this.stats.hits++; if (this.options.serialize) { try { result.set(key, deserialize(value)); } catch (error) { this.log('error', `Failed to deserialize value for key: ${key}`, error); } } else { result.set(key, value as unknown as T); } } else { this.stats.misses++; } } this.log('debug', `Cache getMany for keys: ${keys.join(', ')}`, { requested: keys.length, found: result.size, }); return result; } /** * Set multiple values in the cache * @param entries Map of key-value pairs to cache * @param ttl Time-to-live in milliseconds (optional) * @returns Promise resolving to true if successful */ async setMany(entries: Map, ttl?: number): Promise { // Convert TTL to seconds for NodeCache const ttlSeconds = ttl ? ttl / 1000 : undefined; try { for (const [key, value] of entries.entries()) { const prefixedKey = this.getKey(key); let valueToStore: any = value; if (this.options.serialize) { valueToStore = serialize(value); } this.cache.set(prefixedKey, valueToStore, ttlSeconds || 0); } this.log('debug', `Cache setMany for ${entries.size} keys`, { ttl: ttl || this.options.ttl, }); return true; } catch (error) { this.log('error', 'Failed to set multiple cache entries', error); return false; } } /** * Delete multiple values from the cache * @param keys Array of cache keys to delete * @returns Promise resolving to true if successful */ async deleteMany(keys: string[]): Promise { const prefixedKeys = keys.map(key => this.getKey(key)); const deleted = this.cache.del(prefixedKeys); this.log('debug', `Cache deleteMany for keys: ${keys.join(', ')}`, { deleted, requested: keys.length, }); return deleted > 0; } /** * Get the Time-To-Live (TTL) for a cache key * @param key Cache key * @returns Promise resolving to TTL in milliseconds, or undefined if not found */ async getTtl(key: string): Promise { const prefixedKey = this.getKey(key); const ttl = this.cache.getTtl(prefixedKey); if (ttl === undefined || ttl === 0) { return undefined; } // Convert from seconds (Node-Cache) to milliseconds const ttlMs = Math.max(0, ttl - Date.now()); this.log('debug', `Cache getTtl for key: ${key}`, { ttl: ttlMs }); return ttlMs; } /** * Update the Time-To-Live (TTL) for a cache key * @param key Cache key * @param ttl New TTL in milliseconds * @returns Promise resolving to true if successful */ async updateTtl(key: string, ttl: number): Promise { const prefixedKey = this.getKey(key); // Convert to seconds for Node-Cache const ttlSeconds = ttl / 1000; const success = this.cache.ttl(prefixedKey, ttlSeconds); this.log('debug', `Cache updateTtl for key: ${key}`, { ttl, success }); return success; } /** * Increment a numeric value in the cache * @param key Cache key * @param increment Amount to increment (default: 1) * @returns Promise resolving to the new value */ async increment(key: string, increment: number = 1): Promise { const prefixedKey = this.getKey(key); // Get current value let value = this.cache.get(prefixedKey); // If not found or not a number, start from 0 if (typeof value !== 'number') { value = 0; } // Increment the value const newValue = value + increment; // Save back to cache this.cache.set(prefixedKey, newValue); this.log('debug', `Cache increment for key: ${key}`, { increment, oldValue: value, newValue, }); return newValue; } /** * Decrement a numeric value in the cache * @param key Cache key * @param decrement Amount to decrement (default: 1) * @returns Promise resolving to the new value */ async decrement(key: string, decrement: number = 1): Promise { return this.increment(key, -decrement); } /** * Connect to the cache backend (no-op for memory cache) * @returns Promise resolving when connected */ async connect(): Promise { this.log('info', 'Memory cache initialized'); return Promise.resolve(); } /** * Disconnect from the cache backend (no-op for memory cache) * @returns Promise resolving when disconnected */ async disconnect(): Promise { this.cache.flushAll(); this.cache.close(); this.log('info', 'Memory cache shutdown'); return Promise.resolve(); } /** * Get cache stats * @returns Promise resolving to cache statistics */ async getStats(): Promise { const stats = this.cache.getStats(); const size = this.cache.keys().length; const hits = this.stats.hits; const misses = this.stats.misses; const total = hits + misses; const hitRatio = total > 0 ? hits / total : 0; return { size, hits, misses, hitRatio, keys: this.cache.keys().length, vsize: stats.vsize, ksize: stats.ksize, }; } }