mcp-ynab
Version:
Model Context Protocol server for YNAB integration
382 lines (324 loc) • 10.5 kB
JavaScript
/**
* 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 };