Source

src/common/cache/CacheService.js

const crypto = require('crypto');
const { CacheModes, kCACHE_DEFAULT_TTL } = require('./CacheConstants');
const utils = require('../../utils');

/**
 * @module Caching Layer
 * @fileoverview CacheService - Adapter-based caching layer with entity rehydration.
 *
 * This service provides a flexible caching abstraction that supports multiple backends
 * via the Adapter Pattern. It's integrated throughout the service layer to provide
 * automatic read-through caching with entity rehydration.
 *
 * **Adapter Pattern:**
 * ```
 * ┌─────────────────┐
 * │  CacheService   │  ← Application code uses this
 * └────────┬────────┘
 *          │ (adapter interface)
 *          ├─────────────────┬─────────────────┬──────────────────
 *          ↓                 ↓                 ↓
 *   ┌──────────────┐  ┌──────────────┐  ┌──────────────┐
 *   │  InMemory    │  │    Redis     │  │   Datadog    │
 *   │   Adapter    │  │   Adapter    │  │   Adapter    │
 *   └──────────────┘  └──────────────┘  └──────────────┘
 *   (tests, dev)      (production)      (observability)
 * ```
 *
 * **Cache Modes:**
 * - `DEFAULT`: Normal cache behavior (read-through, write-through)
 * - `SKIP`: Bypass cache entirely (always fetch fresh)
 * - `BUST`: Clear cache entry and fetch fresh data (no re-cache)
 * - `REFRESH`: Clear cache entry, fetch fresh, and update cache
 *
 * **Entity Rehydration:**
 * When used with the `withCacheable` mixin, cached data is automatically converted
 * back to Entity instances. This ensures type safety and method availability after
 * cache retrieval:
 * ```javascript
 * // First call - fetches from DB, returns IconEntity, caches plain object
 * const icon1 = await iconService.getById(123); // IconEntity instance
 *
 * // Second call - fetches from cache, rehydrates to IconEntity
 * const icon2 = await iconService.getById(123); // IconEntity instance (rehydrated)
 *
 * console.log(icon1 instanceof IconEntity); // true
 * console.log(icon2 instanceof IconEntity); // true (rehydrated from cache!)
 * ```
 *
 * **Production Integration:**
 * ```
 * HTTP Layer (Express/Fastify routes)
 *      ↓
 * CacheService.cacheHandler() middleware  ← HTTP-level caching
 *      ↓
 * Service Layer (with withCacheable mixin) ← Method-level caching
 *      ↓
 * Repository Layer (database queries)
 * ```
 *
 * **Key Features:**
 * - Adapter Pattern: Swap backends (InMemory, Redis, Datadog) without code changes
 * - Consistent Key Generation: Same parameters → same key (order-independent)
 * - User-Specific Caching: Automatic user ID inclusion for authenticated requests
 * - TTL Support: Time-to-live for automatic cache expiration
 * - Prefix-Based Invalidation: Clear all cache entries with specific prefix
 * - Entity Rehydration: Automatic conversion from plain objects to Entity instances
 * - Cache Mode Control: Client can control caching behavior via query params
 *
 * @example
 * // Basic setup with InMemory adapter (development/testing)
 * const cache = new CacheService(new NodeCacheAdapter());
 * const key = cache.getCacheKey('users:list', { page: 1, limit: 10 });
 *
 * @example
 * // Production setup with Redis adapter
 * const redis = require('redis');
 * const client = redis.createClient({ url: process.env.REDIS_URL });
 * const cache = new CacheService(new RedisAdapter(client));
 *
 * @example
 * // HTTP middleware for route-level caching
 * app.get('/api/icons',
 *   cache.cacheHandler('icons:list', async (req) => {
 *     return iconService.getAll(req.query);
 *   }, 3600)
 * );
 * // GET /api/icons              - Returns cached data if available
 * // GET /api/icons?cacheMode=skip    - Always fetches fresh
 * // GET /api/icons?cacheMode=refresh - Updates cache
 *
 * @example
 * // Service-level caching with entity rehydration (via withCacheable mixin)
 * class IconService extends withCacheable(BaseService) {
 *   // getById() automatically cached and rehydrated to IconEntity
 * }
 * const icon = await iconService.getById(123); // Cached after first call
 *
 * @see {@link withCacheable} For service-level caching mixin
 * @see {@link CacheModes} For available cache modes
 */

/**
 * Cache service with adapter pattern for flexible backend support.
 *
 * CacheService provides a unified caching API that works with multiple backends
 * (InMemory, Redis, Datadog) via adapter pattern. It's used at two levels:
 *
 * 1. **HTTP Layer**: Via cacheHandler() middleware for route-level caching
 * 2. **Service Layer**: Via withCacheable() mixin for method-level caching with entity rehydration
 *
 * **Adapter Requirements:**
 * All adapters must implement this interface:
 * ```typescript
 * interface CacheAdapter {
 *   get(key: string): Promise<any>;
 *   set(key: string, value: any, ttl?: number): Promise<void>;
 *   del(keys: string | string[]): Promise<void>;
 *   keys(): Promise<string[]>;
 * }
 * ```
 *
 * **Cache Key Generation:**
 * Keys are deterministic - same parameters always generate same key regardless of order:
 * ```javascript
 * cache.getCacheKey('icons', { page: 1, limit: 10 });
 * cache.getCacheKey('icons', { limit: 10, page: 1 });
 * // Both produce: 'icons:a1b2c3d4e5f6...'
 * ```
 *
 * **Performance Characteristics:**
 * - InMemory: ~0.01ms per operation (development/testing)
 * - Redis: ~1-5ms per operation (production, network overhead)
 * - Datadog: ~10-50ms per operation (observability, write-heavy)
 *
 * @class CacheService
 *
 * @example
 * // Development setup with InMemory adapter
 * const NodeCache = require('node-cache');
 * const cache = new CacheService(new NodeCacheAdapter(new NodeCache()));
 *
 * @example
 * // Production setup with Redis adapter
 * const redis = require('redis');
 * const client = await redis.createClient({
 *   url: process.env.REDIS_URL,
 *   socket: { reconnectStrategy: (retries) => Math.min(retries * 50, 500) }
 * }).connect();
 * const cache = new CacheService(new RedisAdapter(client));
 *
 * @example
 * // HTTP route caching
 * app.get('/api/icons/:id',
 *   cache.cacheHandler('icons:detail', async (req) => {
 *     return iconService.getById(req.params.id);
 *   }, 3600)
 * );
 *
 * @example
 * // Service-level caching (automatic with withCacheable mixin)
 * class IconService extends withCacheable(BaseService) {
 *   // All methods like getById() are automatically cached
 * }
 */
class CacheService {
    /**
     * Construct CacheService with cache adapter.
     *
     * The adapter pattern allows swapping cache backends without changing application code.
     * Common adapters include:
     * - **NodeCacheAdapter**: In-memory caching for development/testing
     * - **RedisAdapter**: Production-ready distributed caching
     * - **DatadogAdapter**: Metrics collection and observability
     *
     * **Adapter Interface:**
     * All adapters must implement: get(), set(), del(), keys()
     *
     * @param {Object} adapter - Cache adapter implementing required interface
     * @param {Function} adapter.get - Retrieve value by key: `async get(key: string) => any`
     * @param {Function} adapter.set - Store value with optional TTL: `async set(key: string, value: any, ttl?: number) => void`
     * @param {Function} adapter.del - Delete one or more keys: `async del(keys: string | string[]) => void`
     * @param {Function} adapter.keys - List all keys: `async keys() => string[]`
     *
     * @throws {Error} If adapter is invalid or missing required methods
     *
     * @example
     * // InMemory adapter for development
     * const cache = new CacheService(new NodeCacheAdapter());
     *
     * @example
     * // Redis adapter for production
     * const redisClient = redis.createClient({ url: process.env.REDIS_URL });
     * await redisClient.connect();
     * const cache = new CacheService(new RedisAdapter(redisClient));
     *
     * @example
     * // Custom adapter implementation
     * class CustomAdapter {
     *   async get(key) { return null; }
     *   async set(key, value, ttl) { return; }
     *   async del(keys) { return; }
     *   async keys() { return []; }
     * }
     * const cache = new CacheService(new CustomAdapter());
     */
    constructor(adapter) {
        if (!adapter || typeof adapter.get !== 'function') {
            throw new Error('A valid cache adapter is required');
        }

        /**
         * The cache adapter instance.
         * @type {Object}
         * @private
         */
        this.adapter = adapter;
    }

    /**
     * Generates a consistent cache key from base key, parameters, and user ID.
     *
     * Keys are generated by:
     * 1. Sorting parameter keys alphabetically
     * 2. Serializing values (objects sorted by keys)
     * 3. Appending user ID if provided
     * 4. Hashing the combined string with MD5
     *
     * This ensures the same parameters always generate the same key,
     * regardless of parameter order.
     *
     * @param {string} baseKey - The base cache key (e.g., 'users:list', 'products:detail')
     * @param {Object} [params={}] - Query parameters or options
     * @param {number|string} [userId=null] - User ID for user-specific caching
     *
     * @returns {string} Hashed cache key in format 'baseKey:hash'
     *
     * @example
     * const key1 = cache.getCacheKey('icons', { page: 1, limit: 10 });
     * const key2 = cache.getCacheKey('icons', { limit: 10, page: 1 });
     * // key1 === key2 (order doesn't matter)
     *
     * @example
     * // User-specific caching
     * const key = cache.getCacheKey('favorites', { page: 1 }, userId);
     * // Returns: 'favorites:abc123def456...'
     *
     * @example
     * // With object parameters
     * const key = cache.getCacheKey('search', {
     *   filters: { category: 'icons', style: 'outline' },
     *   sort: 'name'
     * });
     */
    getCacheKey(baseKey, params = {}, userId = null) {
        const sortedKeys = Object.keys(params).sort();
        const keyParts = sortedKeys.map(key => {
            const val = params[key];
            const serialized = typeof val === 'object'
                ? JSON.stringify(val, Object.keys(val).sort())
                : String(val);
            return `${key}:${serialized}`;
        });

        if (userId) {
            keyParts.push(`userId:${userId}`);
        }

        const rawKey = `${baseKey}:${keyParts.join('|')}`;
        const hash = crypto.createHash('md5').update(rawKey).digest('hex');
        return `${baseKey}:${hash}`;
    }

    /**
     * Express/Fastify middleware for automatic request caching.
     *
     * Supports cache modes via query parameter `?cacheMode=skip|bust|refresh`:
     * - DEFAULT: Return cached data if available, otherwise fetch and cache
     * - SKIP: Bypass cache, always fetch fresh data
     * - BUST: Clear cache entry, fetch fresh data, don't re-cache
     * - REFRESH: Clear cache entry, fetch fresh data, cache the result
     *
     * The middleware automatically:
     * - Generates cache keys from request parameters
     * - Includes user ID in cache key if authenticated
     * - Adds `fromCache: true/false` to response
     * - Handles errors with next()
     *
     * @param {string} baseKey - Base cache key for this route
     * @param {Function} handler - Async function that returns data: `async (req) => data`
     * @param {number} [ttl=kCACHE_DEFAULT_TTL] - Time-to-live in seconds
     *
     * @returns {Function} Express/Fastify middleware function
     *
     * @example
     * // Basic usage
     * app.get('/api/icons',
     *   cache.cacheHandler('icons:list', async (req) => {
     *     return iconService.getAll(req.query);
     *   }, 3600)
     * );
     *
     * @example
     * // Client can control cache behavior:
     * // GET /api/icons?cacheMode=skip    - Always fresh
     * // GET /api/icons?cacheMode=bust    - Clear and fetch
     * // GET /api/icons?cacheMode=refresh - Update cache
     *
     * @example
     * // With authentication
     * app.get('/api/favorites',
     *   authenticate,
     *   cache.cacheHandler('favorites', async (req) => {
     *     return favoriteService.getUserFavorites(req.user.id);
     *   })
     * );
     * // Cache key includes user.id automatically
     */
    cacheHandler(baseKey, handler, ttl = kCACHE_DEFAULT_TTL) {
        return async (req, res, next) => {
            try {
                const params = utils.getRequestVars(req);
                const userId = req?.user?.id;
                const fullKey = this.getCacheKey(baseKey, params, userId);
                const mode = (req.query.cacheMode || CacheModes.DEFAULT).toLowerCase();

                const formatResult = (data, fromCache) => {
                    if (typeof data === 'object' && data !== null) {
                        return { ...data, fromCache };
                    }
                    return { data, fromCache };
                };

                const getMode = (value) => {
                    return Object.values(CacheModes).includes(value)
                        ? value
                        : CacheModes.DEFAULT;
                };

                switch (getMode(mode)) {
                    case CacheModes.SKIP:
                        return res.status(200).json(formatResult(await handler(req), false));

                    case CacheModes.BUST:
                        await this.adapter.del(fullKey);
                        return res.status(200).json(formatResult(await handler(req), false));

                    case CacheModes.REFRESH:
                        await this.adapter.del(fullKey);
                        const refreshed = await handler(req);
                        await this.adapter.set(fullKey, refreshed, ttl);
                        return res.status(200).json(formatResult(refreshed, false));

                    default:
                        const cached = await this.adapter.get(fullKey);
                        if (cached) {
                            return res.status(200).json(formatResult(cached, true));
                        }
                        const result = await handler(req);
                        await this.adapter.set(fullKey, result, ttl);
                        return res.status(200).json(formatResult(result, false));
                }
            }
            catch (err) {
                next(err);
            }
        };
    }

    /**
     * Clears cache entries by base key prefix or custom matcher function.
     *
     * Useful for cache invalidation when data changes:
     * - Clear all keys with a specific prefix
     * - Clear keys matching custom criteria
     * - Clear all keys (no parameters)
     *
     * @async
     * @param {Object} [options={}] - Clearing options
     * @param {string} [options.baseKey] - Clear all keys starting with this prefix
     * @param {Function} [options.matcher] - Custom function to filter keys: `(key) => boolean`
     *
     * @returns {Promise<number>} Number of keys cleared
     *
     * @example
     * // Clear all icon-related cache entries
     * await cache.clearCache({ baseKey: 'icons' });
     * // Clears: icons:list, icons:detail:*, etc.
     *
     * @example
     * // Clear with custom matcher
     * await cache.clearCache({
     *   matcher: (key) => key.includes('user:123')
     * });
     *
     * @example
     * // Clear all cache entries
     * await cache.clearCache();
     *
     * @example
     * // Clear after data update
     * await iconService.updateIcon(id, data);
     * await cache.clearCache({ baseKey: 'icons' });
     */
    async clearCache({ baseKey, matcher } = {}) {
        const keys = await this.adapter.keys();
        let matched = keys;

        if (typeof matcher === 'function') {
            matched = keys.filter(matcher);
        }
        else if (baseKey) {
            matched = keys.filter(k => k.startsWith(`${baseKey}:`));
        }

        await this.adapter.del(matched);
        console.log(`[CacheService] Cleared ${matched.length} key(s)`, matched);
        return matched.length;
    }
}

module.exports = CacheService;