/** * Analytics Cache Service * * Provides caching layer for expensive analytics queries. * Uses PostgreSQL for persistence with configurable TTLs. * * Phase 3: Analytics Dashboards */ import { Pool } from 'pg'; export interface CacheEntry { key: string; data: T; computedAt: Date; expiresAt: Date; queryTimeMs?: number; } export interface CacheConfig { defaultTtlMinutes: number; } const DEFAULT_CONFIG: CacheConfig = { defaultTtlMinutes: 15, }; export class AnalyticsCache { private pool: Pool; private config: CacheConfig; private memoryCache: Map = new Map(); constructor(pool: Pool, config: Partial = {}) { this.pool = pool; this.config = { ...DEFAULT_CONFIG, ...config }; } /** * Get cached data or compute and cache it */ async getOrCompute( key: string, computeFn: () => Promise, ttlMinutes?: number ): Promise<{ data: T; fromCache: boolean; queryTimeMs: number }> { const ttl = ttlMinutes ?? this.config.defaultTtlMinutes; // Check memory cache first const memEntry = this.memoryCache.get(key); if (memEntry && new Date() < memEntry.expiresAt) { return { data: memEntry.data as T, fromCache: true, queryTimeMs: memEntry.queryTimeMs || 0 }; } // Check database cache const dbEntry = await this.getFromDb(key); if (dbEntry && new Date() < dbEntry.expiresAt) { this.memoryCache.set(key, dbEntry); return { data: dbEntry.data, fromCache: true, queryTimeMs: dbEntry.queryTimeMs || 0 }; } // Compute fresh data const startTime = Date.now(); const data = await computeFn(); const queryTimeMs = Date.now() - startTime; // Cache result const entry: CacheEntry = { key, data, computedAt: new Date(), expiresAt: new Date(Date.now() + ttl * 60 * 1000), queryTimeMs, }; await this.saveToDb(entry); this.memoryCache.set(key, entry); return { data, fromCache: false, queryTimeMs }; } /** * Get from database cache */ private async getFromDb(key: string): Promise | null> { try { const result = await this.pool.query(` SELECT cache_data, computed_at, expires_at, query_time_ms FROM analytics_cache WHERE cache_key = $1 AND expires_at > NOW() `, [key]); if (result.rows.length === 0) return null; const row = result.rows[0]; return { key, data: row.cache_data as T, computedAt: row.computed_at, expiresAt: row.expires_at, queryTimeMs: row.query_time_ms, }; } catch (error) { console.warn(`[AnalyticsCache] Failed to get from DB: ${error}`); return null; } } /** * Save to database cache */ private async saveToDb(entry: CacheEntry): Promise { try { await this.pool.query(` INSERT INTO analytics_cache (cache_key, cache_data, computed_at, expires_at, query_time_ms) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (cache_key) DO UPDATE SET cache_data = EXCLUDED.cache_data, computed_at = EXCLUDED.computed_at, expires_at = EXCLUDED.expires_at, query_time_ms = EXCLUDED.query_time_ms `, [entry.key, JSON.stringify(entry.data), entry.computedAt, entry.expiresAt, entry.queryTimeMs]); } catch (error) { console.warn(`[AnalyticsCache] Failed to save to DB: ${error}`); } } /** * Invalidate a cache entry */ async invalidate(key: string): Promise { this.memoryCache.delete(key); try { await this.pool.query('DELETE FROM analytics_cache WHERE cache_key = $1', [key]); } catch (error) { console.warn(`[AnalyticsCache] Failed to invalidate: ${error}`); } } /** * Invalidate all entries matching a pattern */ async invalidatePattern(pattern: string): Promise { // Clear memory cache for (const key of this.memoryCache.keys()) { if (key.includes(pattern)) { this.memoryCache.delete(key); } } try { const result = await this.pool.query( 'DELETE FROM analytics_cache WHERE cache_key LIKE $1', [`%${pattern}%`] ); return result.rowCount || 0; } catch (error) { console.warn(`[AnalyticsCache] Failed to invalidate pattern: ${error}`); return 0; } } /** * Clean expired entries */ async cleanExpired(): Promise { // Clean memory cache const now = new Date(); for (const [key, entry] of this.memoryCache.entries()) { if (now >= entry.expiresAt) { this.memoryCache.delete(key); } } try { const result = await this.pool.query('DELETE FROM analytics_cache WHERE expires_at < NOW()'); return result.rowCount || 0; } catch (error) { console.warn(`[AnalyticsCache] Failed to clean expired: ${error}`); return 0; } } /** * Get cache statistics */ async getStats(): Promise<{ memoryCacheSize: number; dbCacheSize: number; expiredCount: number; }> { try { const result = await this.pool.query(` SELECT COUNT(*) FILTER (WHERE expires_at > NOW()) as active, COUNT(*) FILTER (WHERE expires_at <= NOW()) as expired FROM analytics_cache `); return { memoryCacheSize: this.memoryCache.size, dbCacheSize: parseInt(result.rows[0]?.active || '0'), expiredCount: parseInt(result.rows[0]?.expired || '0'), }; } catch (error) { return { memoryCacheSize: this.memoryCache.size, dbCacheSize: 0, expiredCount: 0, }; } } } /** * Generate cache key with parameters */ export function cacheKey(prefix: string, params: Record = {}): string { const sortedParams = Object.keys(params) .sort() .filter(k => params[k] !== undefined && params[k] !== null) .map(k => `${k}=${params[k]}`) .join('&'); return sortedParams ? `${prefix}:${sortedParams}` : prefix; }