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