UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

382 lines (324 loc) 10.5 kB
/** * CacheCompatibility - Backward compatibility layer for GlobalCacheManager * Allows existing tools to use new cache system without any code changes * Part of Phase 2.5 cache optimization system */ import { GlobalCacheManager } from './global-cache-manager.js'; import { YnabClient } from './ynab-client.js'; class CacheCompatibility { constructor() { this.globalCache = null; this.ynabClient = null; // Will be created lazily when first needed // Check if global cache is enabled (default: true unless explicitly disabled) const cacheEnabledEnv = process.env.GLOBAL_CACHE_ENABLED; this.enabled = cacheEnabledEnv !== 'false' && cacheEnabledEnv !== '0'; console.error(`[CacheCompatibility] GLOBAL_CACHE_ENABLED = ${cacheEnabledEnv} (resolved to: ${this.enabled})`); if (this.enabled) { this.globalCache = new GlobalCacheManager(); console.error('[CacheCompatibility] Global cache compatibility layer enabled'); } else { console.error('[CacheCompatibility] Global cache compatibility layer disabled'); } } /** * Get or create YnabClient instance (lazy initialization) */ getYnabClient() { if (!this.ynabClient) { this.ynabClient = new YnabClient(); console.error('[CacheCompatibility] YnabClient created lazily'); } return this.ynabClient; } /** * Check if a cache key maps to a dataset we can handle globally */ parseGlobalCacheKey(key) { // Parse cache keys used by existing tools // Examples: "transactions:budgetId:params", "accounts:budgetId", etc. const parts = key.split(':'); if (parts.length < 2) { return null; } const [datasetType, budgetId, ...paramsParts] = parts; let params = {}; if (paramsParts.length > 0) { try { params = JSON.parse(paramsParts.join(':')); } catch (error) { // Ignore parsing errors, use empty params } } // Map old cache key prefixes to global cache dataset types const datasetMapping = { 'transactions': 'transactions', 'accounts': 'accounts', 'categories': 'categories', 'payees': 'payees', 'scheduled_transactions': 'scheduledTransactions', 'budget_summary': null, // Complex aggregation, don't cache globally 'spending_report': null, // Complex report, don't cache globally 'cash_flow': null, // Complex analysis, don't cache globally 'net_worth': null, // Complex analysis, don't cache globally 'goals': null, // Derived from categories, handle differently 'overspending': null, // Analysis result, don't cache globally 'underspending': null // Analysis result, don't cache globally }; const globalDatasetType = datasetMapping[datasetType]; if (!globalDatasetType) { return null; // This data type should use traditional caching } return { datasetType: globalDatasetType, budgetId, params }; } /** * Try to serve request from global cache */ async tryGlobalCache(key, asyncFn) { if (!this.enabled || !this.globalCache) { return null; // Global cache not available } const globalKey = this.parseGlobalCacheKey(key); if (!globalKey) { return null; // Not a globally cacheable request } const { datasetType, budgetId, params } = globalKey; try { let result; switch (datasetType) { case 'transactions': result = await this.globalCache.getTransactions(this.getYnabClient(), budgetId, params); break; case 'accounts': result = await this.globalCache.getAccounts(this.getYnabClient(), budgetId, params); break; case 'categories': // Handle month parameter specially for categories const month = params.month || null; result = await this.globalCache.getCategories(this.getYnabClient(), budgetId, month, params); break; case 'payees': result = await this.globalCache.getPayees(this.getYnabClient(), budgetId, params); break; case 'scheduledTransactions': result = await this.globalCache.getScheduledTransactions(this.getYnabClient(), budgetId, params); break; default: return null; } console.error(`[CacheCompatibility] Served ${datasetType} from global cache for key: ${key}`); return result; } catch (error) { console.error(`[CacheCompatibility] Global cache failed for key ${key}: ${error.message}`); return null; // Fall back to traditional caching } } /** * Get cache statistics including global cache info */ getGlobalStats() { if (!this.enabled || !this.globalCache) { return { enabled: false }; } return { enabled: true, ...this.globalCache.getStats() }; } /** * Destroy compatibility layer */ destroy() { if (this.globalCache) { this.globalCache.destroy(); this.globalCache = null; } // YnabClient doesn't need special cleanup, just null it this.ynabClient = null; } } // Create singleton instance let cacheCompatibilityInstance = null; /** * Get or create the cache compatibility instance */ function getCacheCompatibility() { if (!cacheCompatibilityInstance) { cacheCompatibilityInstance = new CacheCompatibility(); } return cacheCompatibilityInstance; } /** * Enhanced CacheManager that tries global cache first, falls back to traditional caching * This maintains the exact same interface as the original CacheManager */ class EnhancedCacheManager { constructor(defaultTtlMinutes = 15) { // Initialize traditional cache as fallback this.traditionalCache = new Map(); this.defaultTtl = defaultTtlMinutes * 60 * 1000; // Get compatibility layer this.compatibility = getCacheCompatibility(); // Clean up expired entries every 5 minutes this.cleanupInterval = setInterval(() => { this.cleanup(); }, 5 * 60 * 1000); console.error(`[EnhancedCacheManager] Initialized with global cache ${this.compatibility.enabled ? 'enabled' : 'disabled'}`); } /** * Generate a cache key from parameters (same as original) */ generateKey(prefix, params = {}) { const sortedParams = Object.keys(params) .sort() .reduce((result, key) => { result[key] = params[key]; return result; }, {}); return `${prefix}:${JSON.stringify(sortedParams)}`; } /** * Get a value from cache (enhanced with global cache) */ get(key) { // Traditional cache lookup first (for non-globally cached items) const entry = this.traditionalCache.get(key); if (!entry) { return null; } // Check if expired if (Date.now() > entry.expiresAt) { this.traditionalCache.delete(key); return null; } return entry.value; } /** * Set a value in cache (same as original) */ set(key, value, ttlMinutes = null) { const ttl = ttlMinutes ? ttlMinutes * 60 * 1000 : this.defaultTtl; const expiresAt = Date.now() + ttl; this.traditionalCache.set(key, { value, expiresAt, createdAt: Date.now() }); } /** * Get or set a value with enhanced global cache support */ async getOrSet(key, asyncFn, ttlMinutes = null) { // First, try traditional cache let value = this.get(key); if (value !== null) { return value; } // Then, try global cache if enabled if (this.compatibility.enabled) { try { value = await this.compatibility.tryGlobalCache(key, asyncFn); if (value !== null) { // Store result in traditional cache as well for consistency this.set(key, value, ttlMinutes); return value; } } catch (error) { console.error(`[EnhancedCacheManager] Global cache attempt failed: ${error.message}`); // Continue to traditional caching } } // Fall back to traditional caching try { value = await asyncFn(); this.set(key, value, ttlMinutes); return value; } catch (error) { // Don't cache errors throw error; } } /** * Delete a specific key (same as original) */ delete(key) { return this.traditionalCache.delete(key); } /** * Clear all cache entries (same as original) */ clear() { this.traditionalCache.clear(); } /** * Clear cache entries by prefix (same as original) */ clearByPrefix(prefix) { const keysToDelete = []; for (const key of this.traditionalCache.keys()) { if (key.startsWith(prefix)) { keysToDelete.push(key); } } keysToDelete.forEach(key => this.traditionalCache.delete(key)); return keysToDelete.length; } /** * Remove expired entries (same as original) */ cleanup() { const now = Date.now(); const keysToDelete = []; for (const [key, entry] of this.traditionalCache.entries()) { if (now > entry.expiresAt) { keysToDelete.push(key); } } keysToDelete.forEach(key => this.traditionalCache.delete(key)); if (keysToDelete.length > 0) { console.log(`Cleaned up ${keysToDelete.length} expired cache entries`); } } /** * Get cache statistics (enhanced with global cache info) */ getStats() { const now = Date.now(); let expired = 0; let active = 0; for (const entry of this.traditionalCache.values()) { if (now > entry.expiresAt) { expired++; } else { active++; } } const traditionalStats = { total: this.traditionalCache.size, active, expired, defaultTtlMinutes: this.defaultTtl / (60 * 1000) }; const globalStats = this.compatibility.getGlobalStats(); return { traditional: traditionalStats, global: globalStats, enhanced: true }; } /** * Destroy the cache manager and cleanup interval (same as original) */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } this.clear(); // Don't destroy the compatibility layer as it's shared } } export { EnhancedCacheManager as CacheManager, getCacheCompatibility };