{"version":3,"file":"market-cache.mjs","names":[],"sources":["../../../src/services/market-cache.ts"],"sourcesContent":["/**\n * MarketIntel Cache Layer — TTL-based caching for market data API calls.\n *\n * Wraps DexScreener, CoinGecko, and other price/market data APIs with\n * a configurable TTL cache. Benefits:\n *\n * 1. Reduces API calls — avoids redundant fetches within the TTL window\n * 2. Avoids rate limits — DexScreener (300/min), CoinGecko (30/min free)\n * 3. Faster responses — cached data returns instantly\n * 4. Resilience — stale data served when API is down (with warning)\n *\n * Cache is in-memory (resets on restart). Entries are keyed by\n * normalized request parameters.\n */\n\n// ─── Types ───────────────────────────────────────────────────────────────\n\nexport interface CacheConfig {\n  /** Default TTL for cached entries in ms. Default: 30_000 (30s). */\n  defaultTtlMs?: number;\n  /** TTL overrides per data category. */\n  ttlOverrides?: Partial<Record<CacheCategory, number>>;\n  /** Max number of cache entries. Default: 500. */\n  maxEntries?: number;\n  /** Whether to serve stale data when the upstream fetch fails. Default: true. */\n  serveStaleOnError?: boolean;\n}\n\nexport type CacheCategory =\n  | 'trending'\n  | 'new_pairs'\n  | 'token_price'\n  | 'token_search'\n  | 'token_profile'\n  | 'whale_data'\n  | 'leaderboard'\n  | 'gas_price';\n\nexport interface CacheEntry<T = unknown> {\n  key: string;\n  category: CacheCategory;\n  data: T;\n  cachedAt: number;\n  expiresAt: number;\n  hitCount: number;\n  stale: boolean;\n}\n\nexport interface CacheStats {\n  entries: number;\n  hits: number;\n  misses: number;\n  staleServes: number;\n  evictions: number;\n  hitRate: number;\n  byCategory: Record<string, { entries: number; hits: number }>;\n}\n\n// ─── Default TTLs per category ──────────────────────────────────────────\n\nconst DEFAULT_TTLS: Record<CacheCategory, number> = {\n  trending: 60_000,       // 1 minute — changes frequently\n  new_pairs: 30_000,      // 30s — new pairs appear often\n  token_price: 15_000,    // 15s — prices move fast\n  token_search: 60_000,   // 1 minute — search results stable\n  token_profile: 120_000, // 2 minutes — profiles rarely change\n  whale_data: 60_000,     // 1 minute\n  leaderboard: 300_000,   // 5 minutes — rankings stable\n  gas_price: 10_000,      // 10s — gas changes rapidly\n};\n\n// ─── Market Cache ───────────────────────────────────────────────────────\n\nclass MarketCache {\n  private cache = new Map<string, CacheEntry>();\n  private config: Required<CacheConfig>;\n  private stats = { hits: 0, misses: 0, staleServes: 0, evictions: 0 };\n\n  constructor(config: CacheConfig = {}) {\n    this.config = {\n      defaultTtlMs: config.defaultTtlMs ?? 30_000,\n      ttlOverrides: config.ttlOverrides ?? {},\n      maxEntries: config.maxEntries ?? 500,\n      serveStaleOnError: config.serveStaleOnError ?? true,\n    };\n  }\n\n  /**\n   * Get a cached value, or fetch it using the provided function.\n   * This is the primary interface — wraps any async fetch with caching.\n   */\n  async getOrFetch<T>(\n    category: CacheCategory,\n    key: string,\n    fetcher: () => Promise<T>,\n  ): Promise<T> {\n    const cacheKey = this.buildKey(category, key);\n    const existing = this.cache.get(cacheKey);\n\n    // Cache hit (not expired)\n    if (existing && Date.now() < existing.expiresAt) {\n      this.stats.hits++;\n      existing.hitCount++;\n      return existing.data as T;\n    }\n\n    // Cache miss or expired — fetch fresh data\n    try {\n      const data = await fetcher();\n      this.set(category, key, data);\n      this.stats.misses++;\n      return data;\n    } catch (err) {\n      // If we have stale data and serveStaleOnError is enabled, return it\n      if (existing && this.config.serveStaleOnError) {\n        this.stats.staleServes++;\n        existing.stale = true;\n        return existing.data as T;\n      }\n      throw err;\n    }\n  }\n\n  /**\n   * Manually set a cache entry.\n   */\n  set<T>(category: CacheCategory, key: string, data: T): void {\n    const cacheKey = this.buildKey(category, key);\n    const ttl = this.getTtl(category);\n\n    // Evict if at capacity\n    if (this.cache.size >= this.config.maxEntries && !this.cache.has(cacheKey)) {\n      this.evictOldest();\n    }\n\n    this.cache.set(cacheKey, {\n      key: cacheKey,\n      category,\n      data,\n      cachedAt: Date.now(),\n      expiresAt: Date.now() + ttl,\n      hitCount: 0,\n      stale: false,\n    });\n  }\n\n  /**\n   * Get a cached value (without fetching). Returns null if not cached or expired.\n   */\n  get<T>(category: CacheCategory, key: string): T | null {\n    const cacheKey = this.buildKey(category, key);\n    const entry = this.cache.get(cacheKey);\n\n    if (!entry) return null;\n\n    if (Date.now() >= entry.expiresAt) {\n      // Expired but still in cache — mark as stale\n      entry.stale = true;\n      return null;\n    }\n\n    this.stats.hits++;\n    entry.hitCount++;\n    return entry.data as T;\n  }\n\n  /**\n   * Invalidate a specific cache entry.\n   */\n  invalidate(category: CacheCategory, key: string): boolean {\n    const cacheKey = this.buildKey(category, key);\n    return this.cache.delete(cacheKey);\n  }\n\n  /**\n   * Invalidate all entries in a category.\n   */\n  invalidateCategory(category: CacheCategory): number {\n    let count = 0;\n    for (const [key, entry] of this.cache) {\n      if (entry.category === category) {\n        this.cache.delete(key);\n        count++;\n      }\n    }\n    return count;\n  }\n\n  /**\n   * Clear all cache entries.\n   */\n  clear(): void {\n    this.cache.clear();\n  }\n\n  /**\n   * Get cache statistics.\n   */\n  getStats(): CacheStats {\n    const byCategory: Record<string, { entries: number; hits: number }> = {};\n\n    for (const entry of this.cache.values()) {\n      if (!byCategory[entry.category]) {\n        byCategory[entry.category] = { entries: 0, hits: 0 };\n      }\n      byCategory[entry.category]!.entries++;\n      byCategory[entry.category]!.hits += entry.hitCount;\n    }\n\n    const totalRequests = this.stats.hits + this.stats.misses;\n    return {\n      entries: this.cache.size,\n      hits: this.stats.hits,\n      misses: this.stats.misses,\n      staleServes: this.stats.staleServes,\n      evictions: this.stats.evictions,\n      hitRate: totalRequests > 0 ? Math.round((this.stats.hits / totalRequests) * 10000) / 100 : 0,\n      byCategory,\n    };\n  }\n\n  /**\n   * Get all entries for diagnostics (without data payloads).\n   */\n  getEntryMetadata(): Array<{\n    key: string;\n    category: CacheCategory;\n    cachedAt: number;\n    expiresAt: number;\n    hitCount: number;\n    stale: boolean;\n    ageMs: number;\n    ttlRemainingMs: number;\n  }> {\n    const now = Date.now();\n    return Array.from(this.cache.values()).map(e => ({\n      key: e.key,\n      category: e.category,\n      cachedAt: e.cachedAt,\n      expiresAt: e.expiresAt,\n      hitCount: e.hitCount,\n      stale: e.stale || now >= e.expiresAt,\n      ageMs: now - e.cachedAt,\n      ttlRemainingMs: Math.max(0, e.expiresAt - now),\n    }));\n  }\n\n  // ── Internal ──────────────────────────────────────────────────────────\n\n  private buildKey(category: CacheCategory, key: string): string {\n    return `${category}:${key}`;\n  }\n\n  private getTtl(category: CacheCategory): number {\n    return this.config.ttlOverrides?.[category]\n      ?? DEFAULT_TTLS[category]\n      ?? this.config.defaultTtlMs;\n  }\n\n  private evictOldest(): void {\n    // Evict the entry with the oldest cachedAt that is either expired or least-hit\n    let oldestKey: string | null = null;\n    let oldestTime = Infinity;\n\n    // First pass: try to evict an expired entry\n    for (const [key, entry] of this.cache) {\n      if (Date.now() >= entry.expiresAt) {\n        this.cache.delete(key);\n        this.stats.evictions++;\n        return;\n      }\n    }\n\n    // Second pass: evict the oldest entry\n    for (const [key, entry] of this.cache) {\n      if (entry.cachedAt < oldestTime) {\n        oldestTime = entry.cachedAt;\n        oldestKey = key;\n      }\n    }\n\n    if (oldestKey) {\n      this.cache.delete(oldestKey);\n      this.stats.evictions++;\n    }\n  }\n}\n\n// ─── Singleton ───────────────────────────────────────────────────────────\n\nlet _instance: MarketCache | null = null;\n\nexport function getMarketCache(config?: CacheConfig): MarketCache {\n  if (!_instance) {\n    _instance = new MarketCache(config);\n  }\n  return _instance;\n}\n\nexport function resetMarketCache(): void {\n  _instance = null;\n}\n"],"mappings":"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"}