Source

src/common/cache/adapters/RedisCacheAdapter.js

const IORedis = require('ioredis');

/**
 * @module Caching Layer
 * @fileoverview RedisCacheAdapter - Redis-based cache adapter for production.
 *
 * RedisCacheAdapter
 * Stores values as JSON strings in Redis and rehydrates them into Entity instances
 * when reading via `get()` using the provided repository + entityClass context.
 */
class RedisCacheAdapter {
    /**
     * @param {Object} [options]
     * @param {string} [options.url]           Redis connection URL (e.g., redis://localhost:6379)
     * @param {number} [options.ttl=60]        Default TTL for keys (seconds)
     * @param {Object} [options.redisOptions]  Additional ioredis options
     */
    constructor({ url, ttl = 60, redisOptions = {} } = {}) {
        this.ttl = ttl;
        this.redis = url ? new IORedis(url, redisOptions) : new IORedis(redisOptions);
    }

    /**
     * Get a value by key, JSON-parse it, and rehydrate into Entity instances (if context provided).
     * @param {string} key
     * @param {Object} [ctx]
     * @param {Object} [ctx.repository]        Repository with wrapEntity()
     * @param {Function} [ctx.entityClass]     Entity class constructor
     * @returns {Promise<*>}
     */
    async get(key, ctx = {}) {
        const str = await this.redis.get(key);
        if (str == null) return null;

        let parsed;
        try {
            parsed = JSON.parse(str);
        } catch {
            // If somehow non-JSON snuck in, just return raw string (unlikely in our usage)
            return str;
        }

        const { repository, entityClass } = ctx;
        if (!repository || !entityClass) return parsed;

        // Rehydrate into proper Entity/Entities
        if (Array.isArray(parsed)) {
            return parsed.map(v => repository.wrapEntity(v, entityClass));
        }
        return repository.wrapEntity(parsed, entityClass);
    }

    /**
     * Set a value by key, storing JSON. Accepts entities or plain objects/arrays.
     * @param {string} key
     * @param {*} value
     * @param {number} [ttlSeconds]            Optional TTL override (seconds)
     * @param {Object} [ctx]                    Unused for Redis adapter
     * @returns {Promise<boolean>}
     */
    async set(key, value, ttlSeconds, _ctx = {}) {
        // Convert to JSON-safe form (strip methods)
        const json = JSON.stringify(value);
        const ttl = typeof ttlSeconds === 'number' ? ttlSeconds : this.ttl;
        if (ttl > 0) {
            await this.redis.set(key, json, 'EX', ttl);
        } else {
            await this.redis.set(key, json);
        }
        return true;
    }

    /**
     * Delete a specific key.
     * @param {string} key
     * @returns {Promise<boolean>}
     */
    async del(key) {
        await this.redis.del(key);
        return true;
    }

    /**
     * Invalidate all keys beginning with the prefix.
     * Uses SCAN to avoid blocking Redis on large keyspaces.
     * @param {string} prefix
     * @returns {Promise<boolean>}
     */
    async invalidatePrefix(prefix) {
        const pattern = `${prefix}*`;
        let cursor = '0';
        do {
            const [next, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 1000);
            cursor = next;
            if (keys.length) {
                await this.redis.del(...keys);
            }
        } while (cursor !== '0');
        return true;
    }

    /**
     * Close the Redis connection.
     * @returns {Promise<boolean>}
     */
    async close() {
        await this.redis.quit();
        return true;
    }
}

module.exports = RedisCacheAdapter;