All files / src/common/cache etag-cache.service.ts

95.45% Statements 42/44
100% Branches 13/13
100% Functions 9/9
95.23% Lines 40/42

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 995x 5x                 5x 31x 31x     31x       38x 38x   97x 49x 3x   46x     94x 2x   92x         38x 38x 38x 2x   36x               23x 23x 10x       13x 13x 3x 3x     10x       22x 22x   1x   21x         21x 21x       2x         3x 3x 7x 4x           34x      
import { Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
 
interface CacheEntry {
  data: any;
  etag: string;
  timestamp: number;
}
 
@Injectable()
export class ETagCacheService {
  private readonly logger = new Logger(ETagCacheService.name);
  private cache = new Map<string, CacheEntry>();
 
  // Default TTL: 30 seconds for most endpoints
  private defaultTtl = 30 * 1000;
 
  // Safe stringify that handles circular references
  private safeStringify(data: any): string {
    const seen = new WeakSet();
    return JSON.stringify(data, (key, value) => {
      // Skip objects that we've seen before (circular refs)
      if (typeof value === 'object' && value !== null) {
        if (seen.has(value)) {
          return '[Circular]';
        }
        seen.add(value);
      }
      // Skip non-serializable types
      if (typeof value === 'function' || typeof value === 'symbol') {
        return undefined;
      }
      return value;
    });
  }
 
  computeETag(data: any): string | null {
    try {
      const jsonStr = this.safeStringify(data);
      if (!jsonStr) {
        return null;
      }
      return createHash('md5').update(jsonStr).digest('hex');
    } catch (err) {
      this.logger.warn(`Failed to compute ETag: ${err.message}`);
      return null;
    }
  }
 
  get(key: string, ttl = this.defaultTtl): CacheEntry | null {
    const entry = this.cache.get(key);
    if (!entry) {
      return null;
    }
 
    // Check if expired
    const now = Date.now();
    if (now - entry.timestamp > ttl) {
      this.cache.delete(key);
      return null;
    }
 
    return entry;
  }
 
  set(key: string, data: any): string | null {
    const etag = this.computeETag(data);
    if (!etag) {
      // Skip caching if ETag can't be computed (e.g., circular refs)
      return null;
    }
    const entry: CacheEntry = {
      data,
      etag,
      timestamp: Date.now(),
    };
    this.cache.set(key, entry);
    return etag;
  }
 
  invalidate(key: string): void {
    this.cache.delete(key);
  }
 
  invalidatePattern(pattern: string): void {
    // Invalidate all keys matching a pattern (e.g., 'sessions:*')
    const regex = new RegExp(pattern);
    for (const key of this.cache.keys()) {
      if (regex.test(key)) {
        this.cache.delete(key);
      }
    }
  }
 
  clear(): void {
    this.cache.clear();
  }
}