cache.ts•11.2 kB
/**
* Cache service for projects and labels with TTL management
* Based on research.md caching strategy for read-heavy operations
*/
import {
TodoistProject,
TodoistLabel,
TodoistSection,
} from '../types/todoist.js';
import { logger } from '../middleware/logging.js';
/**
* Cache entry with TTL management
*/
interface CacheEntry<T> {
data: T;
timestamp: Date;
ttl: number; // TTL in milliseconds
}
/**
* Cache statistics for monitoring
*/
interface CacheStats {
hits: number;
misses: number;
entries: number;
hitRate: number;
}
/**
* LRU Cache implementation with TTL support
*/
class LRUCache<K, V> {
private cache = new Map<K, CacheEntry<V>>();
private readonly maxSize: number;
private hits = 0;
private misses = 0;
constructor(maxSize: number = 1000) {
this.maxSize = maxSize;
}
get(key: K): V | null {
const entry = this.cache.get(key);
if (!entry) {
this.misses++;
return null;
}
// Check if entry is expired
if (this.isExpired(entry)) {
this.cache.delete(key);
this.misses++;
return null;
}
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, entry);
this.hits++;
return entry.data;
}
set(key: K, value: V, ttl: number): void {
// Remove oldest entries if at max capacity
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
const entry: CacheEntry<V> = {
data: value,
timestamp: new Date(),
ttl,
};
this.cache.set(key, entry);
}
delete(key: K): boolean {
return this.cache.delete(key);
}
clear(): void {
this.cache.clear();
this.hits = 0;
this.misses = 0;
}
getStats(): CacheStats {
this.cleanupExpired();
const total = this.hits + this.misses;
return {
hits: this.hits,
misses: this.misses,
entries: this.cache.size,
hitRate: total > 0 ? this.hits / total : 0,
};
}
private isExpired(entry: CacheEntry<V>): boolean {
const now = Date.now();
const entryTime = entry.timestamp.getTime();
return now - entryTime > entry.ttl;
}
private cleanupExpired(): void {
for (const [key, entry] of this.cache.entries()) {
if (this.isExpired(entry)) {
this.cache.delete(key);
}
}
}
// Get all non-expired entries
getAllValid(): Map<K, V> {
const validEntries = new Map<K, V>();
for (const [key, entry] of this.cache.entries()) {
if (!this.isExpired(entry)) {
validEntries.set(key, entry.data);
}
}
return validEntries;
}
}
/**
* Cache service for Todoist entities with appropriate TTL settings
*/
export class CacheService {
private readonly projectsCache: LRUCache<string, TodoistProject[]>;
private readonly labelsCache: LRUCache<string, TodoistLabel[]>;
private readonly sectionsCache: LRUCache<string, TodoistSection[]>;
// TTL constants based on research.md specifications
private readonly PROJECTS_TTL = 30 * 60 * 1000; // 30 minutes
private readonly LABELS_TTL = 30 * 60 * 1000; // 30 minutes
private readonly SECTIONS_TTL = 15 * 60 * 1000; // 15 minutes
// Cache keys
private readonly PROJECTS_KEY = 'all_projects';
private readonly LABELS_KEY = 'all_labels';
constructor(maxCacheSize: number = 1000) {
this.projectsCache = new LRUCache<string, TodoistProject[]>(maxCacheSize);
this.labelsCache = new LRUCache<string, TodoistLabel[]>(maxCacheSize);
this.sectionsCache = new LRUCache<string, TodoistSection[]>(maxCacheSize);
}
// Projects cache operations
getProjects(): TodoistProject[] | null {
return this.projectsCache.get(this.PROJECTS_KEY);
}
setProjects(projects: TodoistProject[]): void {
this.projectsCache.set(this.PROJECTS_KEY, projects, this.PROJECTS_TTL);
}
invalidateProjects(): void {
this.projectsCache.delete(this.PROJECTS_KEY);
}
// Labels cache operations
getLabels(): TodoistLabel[] | null {
return this.labelsCache.get(this.LABELS_KEY);
}
setLabels(labels: TodoistLabel[]): void {
this.labelsCache.set(this.LABELS_KEY, labels, this.LABELS_TTL);
}
invalidateLabels(): void {
this.labelsCache.delete(this.LABELS_KEY);
}
/**
* Add or update a single label in the cache
* Used after create or update operations
*/
upsertLabel(label: TodoistLabel): void {
const labels = this.getLabels() || [];
const existingIndex = labels.findIndex(l => l.id === label.id);
if (existingIndex >= 0) {
// Update existing label
labels[existingIndex] = label;
} else {
// Add new label
labels.push(label);
}
this.setLabels(labels);
}
/**
* Remove a single label from the cache
* Used after delete operations
*/
removeLabel(labelId: string): void {
const labels = this.getLabels();
if (!labels) return;
const filtered = labels.filter(l => l.id !== labelId);
this.setLabels(filtered);
}
// Sections cache operations (per project)
getSections(projectId: string): TodoistSection[] | null {
return this.sectionsCache.get(`project_${projectId}`);
}
setSections(projectId: string, sections: TodoistSection[]): void {
this.sectionsCache.set(`project_${projectId}`, sections, this.SECTIONS_TTL);
}
invalidateSections(projectId: string): void {
this.sectionsCache.delete(`project_${projectId}`);
}
invalidateAllSections(): void {
// Clear all section caches
const validSections = this.sectionsCache.getAllValid();
for (const key of validSections.keys()) {
if (typeof key === 'string' && key.startsWith('project_')) {
this.sectionsCache.delete(key);
}
}
}
// Project lookup utilities with cache integration
findProjectById(projectId: string): TodoistProject | null {
const projects = this.getProjects();
if (!projects) return null;
return projects.find(p => p.id === projectId) || null;
}
findProjectByName(name: string): TodoistProject | null {
const projects = this.getProjects();
if (!projects) return null;
return (
projects.find(p => p.name.toLowerCase() === name.toLowerCase()) || null
);
}
// Label lookup utilities with cache integration
findLabelById(labelId: string): TodoistLabel | null {
const labels = this.getLabels();
if (!labels) return null;
return labels.find(l => l.id === labelId) || null;
}
findLabelByName(name: string): TodoistLabel | null {
const labels = this.getLabels();
if (!labels) return null;
return (
labels.find(l => l.name.toLowerCase() === name.toLowerCase()) || null
);
}
// Section lookup utilities with cache integration
findSectionById(projectId: string, sectionId: string): TodoistSection | null {
const sections = this.getSections(projectId);
if (!sections) return null;
return sections.find(s => s.id === sectionId) || null;
}
findSectionByName(projectId: string, name: string): TodoistSection | null {
const sections = this.getSections(projectId);
if (!sections) return null;
return (
sections.find(s => s.name.toLowerCase() === name.toLowerCase()) || null
);
}
// Cache invalidation for entity modifications
invalidateRelatedCaches(
entityType: 'project' | 'section' | 'label',
entityId?: string
): void {
switch (entityType) {
case 'project':
this.invalidateProjects();
if (entityId) {
this.invalidateSections(entityId);
}
break;
case 'section':
// When sections change, we need to invalidate the specific project's sections
// Since we don't know which project, we'll need the project ID passed separately
// or invalidate all sections (less efficient but safer)
this.invalidateAllSections();
break;
case 'label':
this.invalidateLabels();
break;
}
}
// Warm up cache with fresh data
async warmupCache(
projectsFetcher: () => Promise<TodoistProject[]>,
labelsFetcher: () => Promise<TodoistLabel[]>,
sectionsFetcher: (projectId: string) => Promise<TodoistSection[]>
): Promise<void> {
try {
// Fetch and cache projects
const projects = await projectsFetcher();
this.setProjects(projects);
// Fetch and cache labels
const labels = await labelsFetcher();
this.setLabels(labels);
// Fetch and cache sections for each project
for (const project of projects) {
try {
const sections = await sectionsFetcher(project.id);
this.setSections(project.id, sections);
} catch (error) {
// Continue with other projects if one fails
logger.warn(`Failed to cache sections for project ${project.id}`, {
error,
});
}
}
} catch (error) {
logger.error('Cache warmup failed', { error });
throw error;
}
}
// Cache statistics and monitoring
getStats(): DetailedCacheStats {
const projectStats = this.projectsCache.getStats();
const labelStats = this.labelsCache.getStats();
const sectionStats = this.sectionsCache.getStats();
return {
projects: projectStats,
labels: labelStats,
sections: sectionStats,
overall: {
totalEntries:
projectStats.entries + labelStats.entries + sectionStats.entries,
averageHitRate:
(projectStats.hitRate + labelStats.hitRate + sectionStats.hitRate) /
3,
},
};
}
// Clear all caches
clearAll(): void {
this.projectsCache.clear();
this.labelsCache.clear();
this.sectionsCache.clear();
}
// Check cache health (expired entries, hit rates)
getHealthStatus(): {
healthy: boolean;
stats: DetailedCacheStats;
recommendations: string[];
} {
const stats = this.getStats();
const isHealthy =
stats.overall.totalEntries < 10000 && // Not too many entries
stats.overall.averageHitRate > 0.5; // Good hit rate
return {
healthy: isHealthy,
stats,
recommendations: this.getOptimizationRecommendations(stats),
};
}
private getOptimizationRecommendations(stats: DetailedCacheStats): string[] {
const recommendations: string[] = [];
if (stats.overall.averageHitRate < 0.3) {
recommendations.push(
'Consider adjusting TTL values - low hit rate detected'
);
}
if (stats.overall.totalEntries > 5000) {
recommendations.push(
'Consider reducing cache size limits - high memory usage'
);
}
if (stats.projects.hitRate < 0.8) {
recommendations.push(
'Projects cache hit rate is low - consider longer TTL'
);
}
if (stats.sections.hitRate < 0.6) {
recommendations.push(
'Sections cache hit rate is low - may need optimization'
);
}
return recommendations;
}
}
interface DetailedCacheStats {
projects: CacheStats;
labels: CacheStats;
sections: CacheStats;
overall: {
totalEntries: number;
averageHitRate: number;
};
}