UNPKG

mcp-ynab

Version:

Model Context Protocol server for YNAB integration

487 lines (399 loc) 15.9 kB
/** * GlobalCacheManager - "Cache Big, Serve Small" strategy for YNAB API optimization * Part of Phase 2.5 cache optimization system */ import { RequestTracker } from './request-tracker.js'; import { SubsetServer } from './subset-server.js'; class GlobalCacheManager { constructor() { this.requestTracker = new RequestTracker(); this.subsetServer = new SubsetServer(); // Global cache structure this.cache = { budgets: new Map(), // budgetId -> budget data datasets: new Map(), // budgetId -> { transactions, accounts, categories, etc. } metadata: new Map() // budgetId -> { knowledge, lastFullRefresh, etc. } }; // Session state tracking this.sessionState = { lastActivity: Date.now(), callFrequency: 0, activeSession: false, recentTools: [] }; // Dataset TTL configuration (in milliseconds) this.datasetTTL = { // Tier 1: Dynamic Data (Aggressive Refresh) categories: 5 * 60 * 1000, // 5 minutes accounts: 3 * 60 * 1000, // 3 minutes // Tier 2: Semi-Static Data (Moderate Refresh) categoryStructure: 30 * 60 * 1000, // 30 minutes accountMetadata: 20 * 60 * 1000, // 20 minutes payees: 60 * 60 * 1000, // 60 minutes // Tier 3: Static Data (Conservative Refresh) scheduledTransactions: 60 * 60 * 1000, // 60 minutes transactions: 45 * 60 * 1000, // 45 minutes months: 30 * 60 * 1000 // 30 minutes }; // Active session TTL adjustments this.activeSessionTTL = { categories: 2 * 60 * 1000, // 2 minutes when active accounts: 1 * 60 * 1000 // 1 minute when active }; // Cleanup interval this.cleanupInterval = setInterval(() => { this.cleanup(); }, 10 * 60 * 1000); // Every 10 minutes console.error('[GlobalCacheManager] Initialized with cache big, serve small strategy'); } /** * Update session state based on activity */ updateSessionState(tool = null) { const now = Date.now(); const timeSinceLastActivity = now - this.sessionState.lastActivity; // Track call frequency if (timeSinceLastActivity < 60000) { // Within 1 minute this.sessionState.callFrequency++; } else { this.sessionState.callFrequency = 1; } // Determine if session is active this.sessionState.activeSession = this.sessionState.callFrequency >= 3 || timeSinceLastActivity < 5 * 60 * 1000; this.sessionState.lastActivity = now; // Track recent tools if (tool) { this.sessionState.recentTools.unshift(tool); if (this.sessionState.recentTools.length > 10) { this.sessionState.recentTools = this.sessionState.recentTools.slice(0, 10); } } } /** * Get effective TTL for a dataset based on session state */ getEffectiveTTL(datasetType) { const baseTTL = this.datasetTTL[datasetType] || 15 * 60 * 1000; // 15 min default if (this.sessionState.activeSession && this.activeSessionTTL[datasetType]) { return this.activeSessionTTL[datasetType]; } return baseTTL; } /** * Check if dataset is expired */ isDatasetExpired(budgetId, datasetType) { const datasets = this.cache.datasets.get(budgetId); if (!datasets || !datasets[datasetType]) { return true; } const dataset = datasets[datasetType]; const ttl = this.getEffectiveTTL(datasetType); const age = Date.now() - dataset.fetchedAt; return age > ttl; } /** * Store dataset with metadata */ storeDataset(budgetId, datasetType, data, knowledge = null) { if (!this.cache.datasets.has(budgetId)) { this.cache.datasets.set(budgetId, {}); } const datasets = this.cache.datasets.get(budgetId); datasets[datasetType] = { data, fetchedAt: Date.now(), knowledge, accessCount: 0, lastAccessed: Date.now() }; console.error(`[GlobalCacheManager] Stored ${datasetType} dataset for budget ${budgetId} (${Array.isArray(data) ? data.length : 'N/A'} items)`); } /** * Get dataset from cache */ getDataset(budgetId, datasetType) { const datasets = this.cache.datasets.get(budgetId); if (!datasets || !datasets[datasetType]) { return null; } const dataset = datasets[datasetType]; // Check if expired if (this.isDatasetExpired(budgetId, datasetType)) { console.error(`[GlobalCacheManager] Dataset ${datasetType} expired for budget ${budgetId}`); return null; } // Update access stats dataset.accessCount++; dataset.lastAccessed = Date.now(); return dataset.data; } /** * Smart cache invalidation based on knowledge property */ checkKnowledgeChange(budgetId, datasetType, newKnowledge) { const datasets = this.cache.datasets.get(budgetId); if (!datasets || !datasets[datasetType]) { return true; // No cached data, need to fetch } const dataset = datasets[datasetType]; // If YNAB knowledge changed, invalidate cache if (dataset.knowledge && newKnowledge && dataset.knowledge !== newKnowledge) { console.error(`[GlobalCacheManager] Knowledge changed for ${datasetType}, invalidating cache`); delete datasets[datasetType]; return true; } return false; } /** * Get or fetch transactions with smart caching */ async getTransactions(ynabClient, budgetId, criteria = {}) { this.updateSessionState('transactions'); // Try to serve from cache first const cachedTransactions = this.getDataset(budgetId, 'transactions'); if (cachedTransactions) { console.error('[GlobalCacheManager] Serving transactions from cache'); return this.subsetServer.filterTransactions(cachedTransactions, criteria); } // Check rate limits before fetching if (!this.requestTracker.canMakeRequest()) { const usage = this.requestTracker.getCurrentUsage(); throw new Error(`Rate limit exceeded: ${usage.used}/${usage.limit} requests used in last hour`); } // Fetch all transactions (over-fetch strategy) console.error('[GlobalCacheManager] Fetching all transactions from API'); const requestId = this.requestTracker.trackRequest('/transactions', budgetId, 'transactions'); try { // Fetch all transactions without filters (over-fetch) const allTransactions = await ynabClient.getTransactions(budgetId, {}); // Store in cache this.storeDataset(budgetId, 'transactions', allTransactions); // Return filtered subset return this.subsetServer.filterTransactions(allTransactions, criteria); } catch (error) { console.error(`[GlobalCacheManager] Failed to fetch transactions: ${error.message}`); throw error; } } /** * Get or fetch accounts with smart caching */ async getAccounts(ynabClient, budgetId, criteria = {}) { this.updateSessionState('accounts'); // Try to serve from cache first const cachedAccounts = this.getDataset(budgetId, 'accounts'); if (cachedAccounts) { console.error('[GlobalCacheManager] Serving accounts from cache'); return this.subsetServer.filterAccounts(cachedAccounts, criteria); } // Check rate limits if (!this.requestTracker.canMakeRequest()) { const usage = this.requestTracker.getCurrentUsage(); throw new Error(`Rate limit exceeded: ${usage.used}/${usage.limit} requests used in last hour`); } // Fetch all accounts console.error('[GlobalCacheManager] Fetching all accounts from API'); const requestId = this.requestTracker.trackRequest('/accounts', budgetId, 'accounts'); try { const allAccounts = await ynabClient.getAccounts(budgetId); // Store in cache this.storeDataset(budgetId, 'accounts', allAccounts); // Return filtered subset return this.subsetServer.filterAccounts(allAccounts, criteria); } catch (error) { console.error(`[GlobalCacheManager] Failed to fetch accounts: ${error.message}`); throw error; } } /** * Get or fetch categories with smart caching */ async getCategories(ynabClient, budgetId, month = null, criteria = {}) { this.updateSessionState('categories'); // For month-specific requests, check if we have that month's data cached const cacheKey = month ? `categories_${month}` : 'categories'; const cachedCategories = this.getDataset(budgetId, cacheKey); if (cachedCategories) { console.error(`[GlobalCacheManager] Serving categories from cache (${cacheKey})`); return this.subsetServer.filterCategories(cachedCategories, criteria); } // Check rate limits if (!this.requestTracker.canMakeRequest()) { const usage = this.requestTracker.getCurrentUsage(); throw new Error(`Rate limit exceeded: ${usage.used}/${usage.limit} requests used in last hour`); } // Fetch categories console.error(`[GlobalCacheManager] Fetching categories from API (${cacheKey})`); const requestId = this.requestTracker.trackRequest('/categories', budgetId, 'categories'); try { const allCategories = await ynabClient.getCategories(budgetId, month); // Store in cache this.storeDataset(budgetId, cacheKey, allCategories); // Return filtered subset return this.subsetServer.filterCategories(allCategories, criteria); } catch (error) { console.error(`[GlobalCacheManager] Failed to fetch categories: ${error.message}`); throw error; } } /** * Get or fetch payees with smart caching */ async getPayees(ynabClient, budgetId, criteria = {}) { this.updateSessionState('payees'); const cachedPayees = this.getDataset(budgetId, 'payees'); if (cachedPayees) { console.error('[GlobalCacheManager] Serving payees from cache'); return this.subsetServer.filterPayees(cachedPayees, criteria); } // Check rate limits if (!this.requestTracker.canMakeRequest()) { const usage = this.requestTracker.getCurrentUsage(); throw new Error(`Rate limit exceeded: ${usage.used}/${usage.limit} requests used in last hour`); } console.error('[GlobalCacheManager] Fetching payees from API'); const requestId = this.requestTracker.trackRequest('/payees', budgetId, 'payees'); try { const allPayees = await ynabClient.getPayees(budgetId); // Store in cache this.storeDataset(budgetId, 'payees', allPayees); // Return filtered subset return this.subsetServer.filterPayees(allPayees, criteria); } catch (error) { console.error(`[GlobalCacheManager] Failed to fetch payees: ${error.message}`); throw error; } } /** * Get or fetch scheduled transactions with smart caching */ async getScheduledTransactions(ynabClient, budgetId, criteria = {}) { this.updateSessionState('scheduled_transactions'); const cachedScheduled = this.getDataset(budgetId, 'scheduledTransactions'); if (cachedScheduled) { console.error('[GlobalCacheManager] Serving scheduled transactions from cache'); return this.subsetServer.filterScheduledTransactions(cachedScheduled, criteria); } // Check rate limits if (!this.requestTracker.canMakeRequest()) { const usage = this.requestTracker.getCurrentUsage(); throw new Error(`Rate limit exceeded: ${usage.used}/${usage.limit} requests used in last hour`); } console.error('[GlobalCacheManager] Fetching scheduled transactions from API'); const requestId = this.requestTracker.trackRequest('/scheduled_transactions', budgetId, 'scheduled_transactions'); try { const allScheduled = await ynabClient.getScheduledTransactions(budgetId); // Store in cache this.storeDataset(budgetId, 'scheduledTransactions', allScheduled); // Return filtered subset return this.subsetServer.filterScheduledTransactions(allScheduled, criteria); } catch (error) { console.error(`[GlobalCacheManager] Failed to fetch scheduled transactions: ${error.message}`); throw error; } } /** * Force refresh a dataset (when fresh data is critical) */ async forceRefresh(ynabClient, budgetId, datasetType) { console.error(`[GlobalCacheManager] Force refreshing ${datasetType} for budget ${budgetId}`); // Clear cached data const datasets = this.cache.datasets.get(budgetId); if (datasets && datasets[datasetType]) { delete datasets[datasetType]; } // Fetch fresh data based on type switch (datasetType) { case 'transactions': return await this.getTransactions(ynabClient, budgetId); case 'accounts': return await this.getAccounts(ynabClient, budgetId); case 'categories': return await this.getCategories(ynabClient, budgetId); case 'payees': return await this.getPayees(ynabClient, budgetId); case 'scheduledTransactions': return await this.getScheduledTransactions(ynabClient, budgetId); default: throw new Error(`Unknown dataset type: ${datasetType}`); } } /** * Cleanup expired data and old request logs */ cleanup() { // Clean up expired datasets let cleanedDatasets = 0; for (const [budgetId, datasets] of this.cache.datasets.entries()) { for (const [datasetType, dataset] of Object.entries(datasets)) { if (this.isDatasetExpired(budgetId, datasetType)) { delete datasets[datasetType]; cleanedDatasets++; } } } // Clean up request tracker this.requestTracker.cleanup(); if (cleanedDatasets > 0) { console.error(`[GlobalCacheManager] Cleaned up ${cleanedDatasets} expired datasets`); } } /** * Get cache statistics */ getStats() { const stats = { budgets: this.cache.budgets.size, datasets: {}, requestTracker: this.requestTracker.getStatus(), sessionState: this.sessionState, totalMemoryUsage: 0 }; // Count datasets by type for (const [budgetId, datasets] of this.cache.datasets.entries()) { for (const [datasetType, dataset] of Object.entries(datasets)) { if (!stats.datasets[datasetType]) { stats.datasets[datasetType] = { count: 0, totalItems: 0, totalAccesses: 0, averageAge: 0 }; } stats.datasets[datasetType].count++; stats.datasets[datasetType].totalAccesses += dataset.accessCount; if (Array.isArray(dataset.data)) { stats.datasets[datasetType].totalItems += dataset.data.length; } // Calculate average age const age = Date.now() - dataset.fetchedAt; stats.datasets[datasetType].averageAge += age; // Rough memory usage estimate stats.totalMemoryUsage += JSON.stringify(dataset.data).length; } } // Calculate averages for (const [datasetType, datasetStats] of Object.entries(stats.datasets)) { if (datasetStats.count > 0) { datasetStats.averageAge = Math.round(datasetStats.averageAge / datasetStats.count / 1000); // Convert to seconds } } return stats; } /** * Destroy the cache manager */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } this.requestTracker.destroy(); this.cache.budgets.clear(); this.cache.datasets.clear(); this.cache.metadata.clear(); console.error('[GlobalCacheManager] Destroyed'); } } export { GlobalCacheManager };