UNPKG

@twofeetup/clickup-mcp

Version:

Optimized ClickUp MCP Server - High-performance AI integration with consolidated tools and response optimization

268 lines (267 loc) 7.71 kB
/** * SPDX-FileCopyrightText: © 2025 Sjoerd Tiemensma * SPDX-License-Identifier: MIT * * Cache Service * * Provides caching for frequently accessed data to reduce API calls * Implements TTL-based expiration and cache invalidation */ import { Logger } from '../logger.js'; const logger = new Logger('CacheService'); /** * Generic cache service with TTL support */ export class CacheService { constructor(defaultTTL = 5 * 60 * 1000) { this.defaultTTL = defaultTTL; this.cache = new Map(); this.hits = 0; this.misses = 0; logger.info('CacheService initialized', { defaultTTL }); // Periodically clean up expired entries setInterval(() => this.cleanup(), 60 * 1000); // Every minute } /** * Get value from cache */ get(key) { const entry = this.cache.get(key); if (!entry) { this.misses++; logger.debug('Cache miss', { key }); return null; } // Check if expired if (Date.now() > entry.expiresAt) { this.cache.delete(key); this.misses++; logger.debug('Cache expired', { key }); return null; } this.hits++; logger.debug('Cache hit', { key }); return entry.data; } /** * Set value in cache with optional TTL */ set(key, value, ttl) { const expiryTime = ttl || this.defaultTTL; const now = Date.now(); this.cache.set(key, { data: value, expiresAt: now + expiryTime, createdAt: now }); logger.debug('Cache set', { key, ttl: expiryTime }); } /** * Delete value from cache */ delete(key) { const deleted = this.cache.delete(key); if (deleted) { logger.debug('Cache deleted', { key }); } return deleted; } /** * Clear all cache entries */ clear() { this.cache.clear(); this.hits = 0; this.misses = 0; logger.info('Cache cleared'); } /** * Clear cache entries matching a pattern */ clearPattern(pattern) { const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern; let count = 0; for (const key of this.cache.keys()) { if (regex.test(key)) { this.cache.delete(key); count++; } } logger.info('Cache cleared by pattern', { pattern: pattern.toString(), count }); return count; } /** * Remove expired entries */ cleanup() { const now = Date.now(); let removed = 0; for (const [key, entry] of this.cache.entries()) { if (now > entry.expiresAt) { this.cache.delete(key); removed++; } } if (removed > 0) { logger.debug('Cache cleanup', { removed }); } } /** * Get cache statistics */ getStats() { const total = this.hits + this.misses; return { hits: this.hits, misses: this.misses, size: this.cache.size, hitRate: total > 0 ? this.hits / total : 0 }; } /** * Get or set a value (fetch if not cached) */ async getOrSet(key, fetchFn, ttl) { // Try to get from cache const cached = this.get(key); if (cached !== null) { return cached; } // Fetch and cache const value = await fetchFn(); this.set(key, value, ttl); return value; } } /** * Workspace-specific cache with predefined TTLs */ export class WorkspaceCache { constructor() { this.cache = new CacheService(WorkspaceCache.TTLs.HIERARCHY); logger.info('WorkspaceCache initialized'); } /** * Get workspace hierarchy */ getHierarchy(teamId) { return this.cache.get(`hierarchy:${teamId}`); } /** * Set workspace hierarchy */ setHierarchy(teamId, data) { this.cache.set(`hierarchy:${teamId}`, data, WorkspaceCache.TTLs.HIERARCHY); } /** * Get workspace members */ getMembers(teamId) { return this.cache.get(`members:${teamId}`); } /** * Set workspace members */ setMembers(teamId, members) { this.cache.set(`members:${teamId}`, members, WorkspaceCache.TTLs.MEMBERS); } /** * Get space tags */ getTags(spaceId) { return this.cache.get(`tags:${spaceId}`); } /** * Set space tags */ setTags(spaceId, tags) { this.cache.set(`tags:${spaceId}`, tags, WorkspaceCache.TTLs.TAGS); } /** * Get custom fields for a list */ getCustomFields(listId) { return this.cache.get(`custom_fields:${listId}`); } /** * Set custom fields for a list */ setCustomFields(listId, fields) { this.cache.set(`custom_fields:${listId}`, fields, WorkspaceCache.TTLs.CUSTOM_FIELDS); } /** * Invalidate all caches for a workspace */ invalidateWorkspace(teamId) { this.cache.clearPattern(`^(hierarchy|members|tags|custom_fields):${teamId}`); logger.info('Workspace cache invalidated', { teamId }); } /** * Invalidate member cache when members change */ invalidateMembers(teamId) { this.cache.delete(`members:${teamId}`); logger.info('Members cache invalidated', { teamId }); } /** * Invalidate tags cache when tags change */ invalidateTags(spaceId) { this.cache.delete(`tags:${spaceId}`); logger.info('Tags cache invalidated', { spaceId }); } /** * Clear all caches */ clear() { this.cache.clear(); } /** * Get cache statistics */ getStats() { return this.cache.getStats(); } } // Cache TTLs (in milliseconds) WorkspaceCache.TTLs = { HIERARCHY: 5 * 60 * 1000, // 5 minutes MEMBERS: 10 * 60 * 1000, // 10 minutes TAGS: 15 * 60 * 1000, // 15 minutes STATUSES: 30 * 60 * 1000, // 30 minutes CUSTOM_FIELDS: 30 * 60 * 1000 // 30 minutes }; // Export singleton instances export const cacheService = new CacheService(); export const workspaceCache = new WorkspaceCache(); /** * Invalidate all workspace-related caches after modifications * Call this after creating, updating, or deleting tasks, lists, folders, etc. */ export function invalidateWorkspaceCaches() { logger.info('Invalidating workspace caches after modification'); // Clear workspace hierarchy cache (will be refreshed on next request) workspaceCache.clear(); // Clear general caches cacheService.clearPattern(/^(container|hierarchy|members|tags|custom_fields):/); logger.debug('Workspace caches invalidated'); } /** * Refresh workspace caches in the background after a modification * This ensures the cache is pre-warmed for the next request without blocking the response * @param workspaceService - The workspace service instance to use for refreshing */ export function refreshWorkspaceCachesInBackground(workspaceService) { // Start background refresh without awaiting (async () => { try { logger.info('Starting background cache refresh after modification'); await workspaceService.getWorkspaceHierarchy(true); logger.info('Background cache refresh completed'); } catch (error) { logger.error('Background cache refresh failed', { error: error.message }); // Don't throw - this is a background operation } })(); }