All files / src/services cacheService.server.ts

74.52% Statements 237/318
92.85% Branches 26/28
60% Functions 15/25
74.9% Lines 197/263

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 2642x 2x 2x 2x 2x   2x 2x 2x 2x 2x 2x 2x 2x 60x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 60x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x                     17x 17x 16x 5x 5x 2x 11x 11x 2x 2x 1x                     2x 12x 12x 10x   2x             2x 2x 2x 8x 8x 7x 2x 2x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 31x 31x 3x 31x 31x 32x 29x 29x 10x 10x   2x 2x 28x 28x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x                     2x 13x 2x 2x 13x 13x 4x       9x 2x 2x 9x 2x 2x 2x 9x 2x 2x 2x 2x 2x 2x 6x 2x 2x 2x           6x           6x 6x 14x 2x 4x 2x 2x 2x 2x   2x 2x 2x 2x                 2x 2x 2x 2x 2x 2x       3x   3x 3x 6x 2x 3x 2x 2x 2x 2x 2x   2x 3x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 60x  
// src/services/cacheService.server.ts
/**
 * @file Centralized caching service implementing the Cache-Aside pattern.
 * This service provides a reusable wrapper around Redis for caching read-heavy operations.
 * See ADR-009 for the caching strategy documentation.
 */
import type { Logger } from 'pino';
import { connection as redis } from './redis.server';
import { logger as globalLogger } from './logger.server';
 
/**
 * TTL values in seconds for different cache types.
 * These can be tuned based on data volatility and freshness requirements.
 */
export const CACHE_TTL = {
  /** Brand/store list - rarely changes, safe to cache for 1 hour */
  BRANDS: 60 * 60,
  /** Store list - rarely changes, safe to cache for 1 hour */
  STORES: 60 * 60,
  /** Individual store data with locations - cache for 1 hour */
  STORE: 60 * 60,
  /** Flyer list - changes when new flyers are added, cache for 5 minutes */
  FLYERS: 5 * 60,
  /** Individual flyer data - cache for 10 minutes */
  FLYER: 10 * 60,
  /** Flyer items - cache for 10 minutes */
  FLYER_ITEMS: 10 * 60,
  /** Statistics - can be slightly stale, cache for 5 minutes */
  STATS: 5 * 60,
  /** Most frequent sales - aggregated data, cache for 15 minutes */
  FREQUENT_SALES: 15 * 60,
  /** Categories - rarely changes, cache for 1 hour */
  CATEGORIES: 60 * 60,
} as const;
 
/**
 * Cache key prefixes for different data types.
 * Using consistent prefixes allows for pattern-based invalidation.
 */
export const CACHE_PREFIX = {
  BRANDS: 'cache:brands',
  STORES: 'cache:stores',
  STORE: 'cache:store',
  FLYERS: 'cache:flyers',
  FLYER: 'cache:flyer',
  FLYER_ITEMS: 'cache:flyer-items',
  STATS: 'cache:stats',
  FREQUENT_SALES: 'cache:frequent-sales',
  CATEGORIES: 'cache:categories',
} as const;
 
export interface CacheOptions {
  /** Time-to-live in seconds */
  ttl: number;
  /** Optional logger for this operation */
  logger?: Logger;
}
 
/**
 * Centralized cache service implementing the Cache-Aside pattern.
 * All cache operations are fail-safe - cache failures do not break the application.
 */
class CacheService {
  /**
   * Retrieves a value from cache.
   * @param key The cache key
   * @param logger Optional logger for this operation
   * @returns The cached value or null if not found/error
   */
  async get<T>(key: string, logger: Logger = globalLogger): Promise<T | null> {
    try {
      const cached = await redis.get(key);
      if (cached) {
        logger.debug({ cacheKey: key }, 'Cache hit');
        return JSON.parse(cached) as T;
      }
      logger.debug({ cacheKey: key }, 'Cache miss');
      return null;
    } catch (error) {
      logger.warn({ err: error, cacheKey: key }, 'Redis GET failed, proceeding without cache');
      return null;
    }
  }

  /**
   * Stores a value in cache with TTL.
   * @param key The cache key
   * @param value The value to cache (will be JSON stringified)
   * @param ttl Time-to-live in seconds
   * @param logger Optional logger for this operation
   */
  async set<T>(key: string, value: T, ttl: number, logger: Logger = globalLogger): Promise<void> {
    try {
      await redis.set(key, JSON.stringify(value), 'EX', ttl);
      logger.debug({ cacheKey: key, ttl }, 'Value cached');
    } catch (error) {
      logger.warn({ err: error, cacheKey: key }, 'Redis SET failed, value not cached');
    }
  }

  /**
   * Deletes a specific key from cache.
   * @param key The cache key to delete
   * @param logger Optional logger for this operation
   */
  async del(key: string, logger: Logger = globalLogger): Promise<void> {
    try {
      await redis.del(key);
      logger.debug({ cacheKey: key }, 'Cache key deleted');
    } catch (error) {
      logger.warn({ err: error, cacheKey: key }, 'Redis DEL failed');
    }
  }
 
  /**
   * Invalidates all cache keys matching a pattern.
   * Uses SCAN for safe iteration over large key sets.
   * @param pattern The pattern to match (e.g., 'cache:flyers*')
   * @param logger Optional logger for this operation
   * @returns The number of keys deleted
   */
  async invalidatePattern(pattern: string, logger: Logger = globalLogger): Promise<number> {
    let cursor = '0';
    let totalDeleted = 0;
 
    try {
      do {
        const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
        cursor = nextCursor;
        if (keys.length > 0) {
          const deletedCount = await redis.del(...keys);
          totalDeleted += deletedCount;
        }
      } while (cursor !== '0');
 
      logger.info({ pattern, totalDeleted }, 'Cache invalidation completed');
      return totalDeleted;
    } catch (error) {
      logger.error({ err: error, pattern }, 'Cache invalidation failed');
      throw error;
    }
  }
 
  /**
   * Implements the Cache-Aside pattern: try cache first, fall back to fetcher, cache result.
   * This is the primary method for adding caching to existing repository methods.
   *
   * @param key The cache key
   * @param fetcher Function that retrieves data from the source (e.g., database)
   * @param options Cache options including TTL
   * @returns The data (from cache or fetcher)
   *
   * @example
   * ```typescript
   * const brands = await cacheService.getOrSet(
   *   CACHE_PREFIX.BRANDS,
   *   () => this.db.query('SELECT * FROM stores'),
   *   { ttl: CACHE_TTL.BRANDS, logger }
   * );
   * ```
   */
  async getOrSet<T>(key: string, fetcher: () => Promise<T>, options: CacheOptions): Promise<T> {
    const logger = options.logger ?? globalLogger;
 
    // Try to get from cache first
    const cached = await this.get<T>(key, logger);
    if (cached !== null) {
      return cached;
    }

    // Cache miss - fetch from source
    const data = await fetcher();
 
    // Cache the result (fire-and-forget, don't await)
    this.set(key, data, options.ttl, logger).catch(() => {
      // Error already logged in set()
    });
 
    return data;
  }
 
  // --- Convenience methods for specific cache types ---
 
  /**
   * Invalidates all brand-related cache entries.
   */
  async invalidateBrands(logger: Logger = globalLogger): Promise<number> {
    return this.invalidatePattern(`${CACHE_PREFIX.BRANDS}*`, logger);
  }

  /**
   * Invalidates all flyer-related cache entries.
   */
  async invalidateFlyers(logger: Logger = globalLogger): Promise<number> {
    const patterns = [
      `${CACHE_PREFIX.FLYERS}*`,
      `${CACHE_PREFIX.FLYER}*`,
      `${CACHE_PREFIX.FLYER_ITEMS}*`,
    ];

    let total = 0;
    for (const pattern of patterns) {
      total += await this.invalidatePattern(pattern, logger);
    }
    return total;
  }
 
  /**
   * Invalidates cache for a specific flyer and its items.
   */
  async invalidateFlyer(flyerId: number, logger: Logger = globalLogger): Promise<void> {
    await Promise.all([
      this.del(`${CACHE_PREFIX.FLYER}:${flyerId}`, logger),
      this.del(`${CACHE_PREFIX.FLYER_ITEMS}:${flyerId}`, logger),
      // Also invalidate the flyers list since it may contain this flyer
      this.invalidatePattern(`${CACHE_PREFIX.FLYERS}*`, logger),
    ]);
  }

  /**
   * Invalidates all statistics cache entries.
   */
  async invalidateStats(logger: Logger = globalLogger): Promise<number> {
    return this.invalidatePattern(`${CACHE_PREFIX.STATS}*`, logger);
  }
 
  /**
   * Invalidates all store-related cache entries.
   * Called when stores are created, updated, or deleted.
   */
  async invalidateStores(logger: Logger = globalLogger): Promise<number> {
    const patterns = [`${CACHE_PREFIX.STORES}*`, `${CACHE_PREFIX.STORE}*`];

    let total = 0;
    for (const pattern of patterns) {
      total += await this.invalidatePattern(pattern, logger);
    }
    return total;
  }
 
  /**
   * Invalidates cache for a specific store and its locations.
   * Also invalidates the stores list cache since it may contain this store.
   */
  async invalidateStore(storeId: number, logger: Logger = globalLogger): Promise<void> {
    await Promise.all([
      this.del(`${CACHE_PREFIX.STORE}:${storeId}`, logger),
      // Also invalidate the stores list since it may contain this store
      this.invalidatePattern(`${CACHE_PREFIX.STORES}*`, logger),
    ]);
  }
 
  /**
   * Invalidates cache related to store locations for a specific store.
   * Called when locations are added or removed from a store.
   */
  async invalidateStoreLocations(storeId: number, logger: Logger = globalLogger): Promise<void> {
    // Invalidate the specific store and stores list
    await this.invalidateStore(storeId, logger);
  }
}
 
export const cacheService = new CacheService();