import { CacheProvider } from '../interfaces/cache-provider'; import { getLogger } from '../utils/logger'; /** * Options for the tag invalidator */ export interface TagInvalidatorOptions { /** * Key prefix for tag sets */ tagPrefix?: string; /** * Whether to enable logging */ enableLogging?: boolean; /** * Log level */ logLevel?: 'debug' | 'info' | 'warn' | 'error'; } /** * Tag-based cache invalidator * Allows invalidating cache entries based on tags */ export class TagInvalidator { private provider: CacheProvider; private options: TagInvalidatorOptions; private logger: any; /** * Creates a new TagInvalidator * @param provider Cache provider to invalidate * @param options Tag invalidator options */ constructor( provider: CacheProvider, options: TagInvalidatorOptions = {} ) { this.provider = provider; this.options = { tagPrefix: 'tag:', enableLogging: false, logLevel: 'info', ...options }; this.logger = getLogger('cache:invalidator:tag', this.options); } /** * Set a value in the cache with associated tags * @param key Cache key * @param value Value to cache * @param tags Tags to associate with the key * @param ttl Time-to-live in milliseconds (optional) * @returns Promise resolving to true if successful */ async set(key: string, value: T, tags: string[], ttl?: number): Promise { try { // Step 1: Set the value in the cache const setResult = await this.provider.set(key, value, ttl); if (!setResult) { this.logger.warn(`Failed to set value for key: ${key}`); return false; } // Step 2: Associate the key with each tag const tagPromises = tags.map(tag => this.addKeyToTag(tag, key)); await Promise.all(tagPromises); this.logger.debug(`Set key ${key} with tags: ${tags.join(', ')}`); return true; } catch (error) { this.logger.error(`Error setting tagged key: ${key}`, error); return false; } } /** * Invalidate all keys associated with a tag * @param tag Tag to invalidate * @returns Promise resolving to the number of invalidated keys */ async invalidateByTag(tag: string): Promise { try { // Step 1: Get all keys associated with the tag const keys = await this.getKeysByTag(tag); if (keys.length === 0) { this.logger.debug(`No keys found for tag: ${tag}`); return 0; } // Step 2: Delete all keys await this.provider.deleteMany(keys); // Step 3: Delete the tag set itself await this.provider.delete(this.getTagKey(tag)); this.logger.info(`Invalidated ${keys.length} keys with tag: ${tag}`); return keys.length; } catch (error) { this.logger.error(`Error invalidating by tag: ${tag}`, error); throw error; } } /** * Invalidate keys associated with multiple tags * @param tags Tags to invalidate * @param operator 'AND' requires keys to have all tags, 'OR' requires any tag * @returns Promise resolving to the number of invalidated keys */ async invalidateByTags(tags: string[], operator: 'AND' | 'OR' = 'OR'): Promise { if (tags.length === 0) { return 0; } try { // Get keys for each tag const keySets = await Promise.all(tags.map(tag => this.getKeysByTag(tag))); // Combine keys based on operator let keysToInvalidate: string[]; if (operator === 'AND') { // Keys must have all tags keysToInvalidate = keySets.reduce((intersection, keys) => { return intersection.filter(key => keys.includes(key)); }, keySets[0] || []); } else { // Keys can have any tag (OR) keysToInvalidate = Array.from(new Set(keySets.flat())); } if (keysToInvalidate.length === 0) { this.logger.debug(`No keys found for tags: ${tags.join(', ')} with operator ${operator}`); return 0; } // Delete all matching keys await this.provider.deleteMany(keysToInvalidate); this.logger.info(`Invalidated ${keysToInvalidate.length} keys with tags: ${tags.join(', ')} (${operator})`); return keysToInvalidate.length; } catch (error) { this.logger.error(`Error invalidating by tags: ${tags.join(', ')}`, error); throw error; } } /** * Associate a key with a tag * @param tag Tag to associate * @param key Cache key * @returns Promise resolving when completed */ private async addKeyToTag(tag: string, key: string): Promise { const tagKey = this.getTagKey(tag); // Check if we're using Redis (it has native set support) if ('sadd' in this.provider) { // Use Redis SADD directly await (this.provider as any).sadd(tagKey, key); return; } // Fallback implementation using regular cache operations try { // Get existing keys for this tag const keys = await this.getKeysByTag(tag); // Add the new key if it doesn't exist if (!keys.includes(key)) { keys.push(key); // Save the updated set back to cache await this.provider.set(tagKey, keys); } } catch (error) { this.logger.error(`Error adding key ${key} to tag ${tag}`, error); throw error; } } /** * Get all keys associated with a tag * @param tag Tag to get keys for * @returns Promise resolving to array of keys */ private async getKeysByTag(tag: string): Promise { const tagKey = this.getTagKey(tag); // Check if we're using Redis (it has native set support) if ('smembers' in this.provider) { // Use Redis SMEMBERS directly return await (this.provider as any).smembers(tagKey); } // Fallback implementation using regular cache operations try { const keys = await this.provider.get(tagKey); return keys || []; } catch (error) { this.logger.error(`Error getting keys for tag ${tag}`, error); return []; } } /** * Get the cache key for a tag set * @param tag Tag name * @returns Cache key for the tag set */ private getTagKey(tag: string): string { return `${this.options.tagPrefix}${tag}`; } }