All files / src/utils SupplierCache.ts

81.93% Statements 195/238
75.75% Branches 25/33
87.5% Functions 7/8
81.93% Lines 195/238

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 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 3031x 1x                 1x 1x   1x                         1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x   1x 1x 1x 1x   1x 1x 1x 1x 1x   1x 1x 1x 1x   1x 1x 1x 1x 1x   1x 1x 1x 1x 1x   1x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x 99x   1x         1x 1x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x                                                   13x   1x                 1x 1x 15x 15x   1x       1x 1x 12x 12x 1x 1x 1x 1x 1x 1x 10x 10x 10x 10x 10x 10x   10x 10x   10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x   10x 10x 10x 10x   10x 12x         12x   1x       1x 1x 3x 3x 3x 3x 3x 1x 1x 1x 1x 1x 1x 1x 2x 3x           3x   1x       1x 1x 2x 2x 2x 2x 2x 2x 2x 2x         2x   1x     1x 1x 10x 9x 9x 9x 10x 1x 1x 1x 1x 1x 1x 1x 1x 10x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 2x 7x 10x       10x   1x     1x 1x     1x  
import { Logger } from '@/utils/Logger';
import {
  clearSupplierProductDataCache,
  clearSupplierQueryCache,
  deleteSupplierQueryCacheEntry,
  getSupplierProductDataCacheEntry,
  getSupplierQueryCacheEntry,
  putSupplierProductDataCacheEntry,
  putSupplierQueryCacheEntry,
} from '@/utils/idbCache';
import { getProductIdentityKey } from '@/helpers/productIdentity';
import { getErrorMessage } from '@/helpers/exceptions';
 
/**
 * Utility class for managing supplier data caching in IndexedDB.
 * Provides a robust caching system for both query results and product detail data.
 *
 * @remarks
 * The cache system uses two IndexedDB object stores:
 * 1. `supplierQueryCache` - Stores search query results (one record per query+supplier)
 * 2. `supplierProductDataCache` - Stores detailed product data (one record per product)
 *
 * Each store supports max 100 entries with LRU eviction handled by idbCache.
 * @category Utils
 * @typeParam T - The type of data being cached
 * @source
 */
export class SupplierCache {
  //The version of the cache format. Bumped 2 -> 3 when the product-data cache
  //moved from URL-based keys to supplier + unique-product-identity keys, so
  //stale query entries evict on read and repopulate under the new scheme. Bumped
  //3 -> 4 when per-product purchaseRestriction parsing was added, so pre-feature
  //entries (which lack it and would show as unrestricted) evict and re-parse.
  //
  //Deprecated as the invalidation lever: do NOT bump this for future format
  //changes. Cache-format changes are now expressed as migration steps under
  //src/migrations/steps (e.g. a step that calls clearSupplierQueryCache). The
  //read-time guard below is kept only as a lazy safety net for pre-migration
  //caches on existing installs, and can be removed once the app_meta version
  //marker is universally present.
  private static readonly CACHE_VERSION = 4;
 
  // The logger instance.
  private logger: Logger;
 
  // Name of the supplier this cache is bound to. Mixed into query keys and
  // product-data keys so two suppliers that happen to share a URL or query
  // string don't collide.
  private supplierName: string;
 
  // Class name of the supplier module that owns this cache (e.g.
  // "SupplierCarolina"). Stored on each cache entry's metadata to make it
  // possible to trace an entry back to the exact module that wrote it,
  // independent of the human-readable display name.
  private supplierModule: string;
 
  // When false, all reads return undefined and all writes no-op. Lets callers
  // honor userSettings.caching without every cache-call site needing its own
  // guard.
  private enabled: boolean;
 
  // Mirrors userSettings.doNotCacheEmptyResults. When true, a query that
  // returns zero results is not written to the cache, so a previously
  // out-of-stock supplier surfaces fresh results on the next search instead
  // of returning the cached empty list.
  private doNotCacheEmptyResults: boolean;
 
  // Mirrors userSettings.cacheTtlMinutes, stored here in milliseconds for
  // direct comparison against `Date.now() - cachedAt`. A value of 0 disables
  // TTL expiration (preserves the original behavior where entries live until
  // LRU eviction or version-mismatch eviction).
  private cacheTtlMs: number;
 
  constructor(
    supplierName: string,
    supplierModule: string,
    enabled: boolean = true,
    doNotCacheEmptyResults: boolean = false,
    cacheTtlMinutes: number = 0,
  ) {
    this.logger = new Logger(supplierName);
    this.supplierName = supplierName;
    this.supplierModule = supplierModule;
    this.enabled = enabled;
    this.doNotCacheEmptyResults = doNotCacheEmptyResults;
    // Coerce here so callers can hand us a string from a TextField without
    // every call site needing to remember to parse first. Negative or NaN
    // values fall back to 0 (TTL disabled).
    const minutes = Number(cacheTtlMinutes);
    this.cacheTtlMs = Number.isFinite(minutes) && minutes > 0 ? minutes * 60_000 : 0;
    this.logger.debug('SupplierCache initialized', {
      supplierName,
      supplierModule,
      enabled,
      doNotCacheEmptyResults,
      cacheTtlMinutes,
      cacheTtlMs: this.cacheTtlMs,
    });
  }
 
  /**
   * Generates a cache key based on the query and the bound supplier name.
   * The limit is intentionally excluded as it only affects how many results are returned,
   * not the actual search results themselves.
   * @source
   */
  generateCacheKey(query: string): string {
    const data = `${query || ''}:${this.supplierName}`;
    this.logger.debug('Generating cache key', {
      query,
      supplierName: this.supplierName,
      data,
    });
    try {
      // Try browser's btoa first
      const key = btoa(data);
      this.logger.debug('Generated cache key', { key });
      return key;
    } catch {
      try {
        // Fallback to Node's Buffer if available
        if (typeof Buffer !== 'undefined') {
          const key = Buffer.from(data).toString('base64');
          this.logger.debug('Generated cache key (Buffer)', { key });
          return key;
        }
        // If neither is available, use a simple hash function
        let hash = 0;
        for (let i = 0; i < data.length; i++) {
          const char = data.charCodeAt(i);
          hash = (hash << 5) - hash + char;
          hash = hash & hash; // Convert to 32bit integer
        }
        const key = hash.toString(36);
        this.logger.debug('Generated cache key (hash)', { key });
        return key;
      } catch (error) {
        this.logger.error(`Error generating cache key: ${getErrorMessage(error)}`, { error });
        // Fallback to a simple string if all else fails
        const key = data.replace(/[^a-zA-Z0-9]/g, '_');
        this.logger.debug('Generated cache key (fallback)', { key });
        return key;
      }
    }
  }
 
  /**
   * Generates a cache key for product detail data from the supplier's stable
   * product identity (`SupplierBase.getUniqueProductKey`) and the bound
   * supplier name. The key is independent of the product URL, so a product
   * enriched under one search hydrates any other search that surfaces it — even
   * when the URL varies between the query and detail phases. Shares its shape
   * with the exclusion key ({@link getProductIdentityKey}) so cache and
   * exclusion agree.
   * @source
   */
  getProductIdentityCacheKey(identity: string): string {
    return getProductIdentityKey(identity, this.supplierName);
  }
 
  /**
   * Stores query results in the cache.
   * LRU eviction (max 100 entries) is handled by idbCache.
   * @source
   */
  async cacheQueryResults(query: string, results: unknown[], limit: number): Promise<void> {
    if (!this.enabled) return;
    if (this.doNotCacheEmptyResults && results.length === 0) {
      this.logger.debug('Skipping empty-result cache write per doNotCacheEmptyResults', {
        query,
        supplierName: this.supplierName,
      });
      return;
    }
    this.logger.debug('[SupplierCache] Caching query results', {
      query,
      supplierName: this.supplierName,
      results,
      limit,
    });
 
    try {
      const key = this.generateCacheKey(query);
 
      const entry: CachedData<unknown> = {
        data: results,
        __cacheMetadata: {
          cachedAt: Date.now(),
          version: SupplierCache.CACHE_VERSION,
          query,
          supplier: this.supplierName,
          supplierModule: this.supplierModule,
          resultCount: results.length,
          limit,
        },
      };
 
      this.logger.debug('Cached query results', {
        key,
        metadata: entry.__cacheMetadata,
      });
 
      await putSupplierQueryCacheEntry(key, entry);
    } catch (error) {
      this.logger.error(`Error storing query results in cache: ${getErrorMessage(error)}`, {
        error,
      });
    }
  }
 
  /**
   * Retrieves cached product data for a given key.
   * Updates the timestamp on access to prevent premature eviction.
   * @source
   */
  async getCachedProductData(key: string): Promise<Maybe<Record<string, unknown>>> {
    if (!this.enabled) return undefined;
    this.logger.debug('[SupplierCache] Getting cached product data', { key });
    try {
      const cached = await getSupplierProductDataCacheEntry(key);
      if (cached) {
        // Refresh timestamp on access
        await putSupplierProductDataCacheEntry(key, {
          data: cached.data,
          timestamp: Date.now(),
        });
        return cached.data;
      }
      return undefined;
    } catch (error) {
      this.logger.error(`Error retrieving product data from cache: ${getErrorMessage(error)}`, {
        error,
      });
      return undefined;
    }
  }
 
  /**
   * Stores product data in the cache.
   * LRU eviction (max 100 entries) is handled by idbCache.
   * @source
   */
  async cacheProductData(key: string, data: Record<string, unknown>): Promise<void> {
    if (!this.enabled) return;
    this.logger.debug('Caching product data', { key, data });
    try {
      await putSupplierProductDataCacheEntry(key, {
        data,
        timestamp: Date.now(),
      });
    } catch (error) {
      this.logger.error(`Error storing product data in cache: ${getErrorMessage(error)}`, {
        error,
      });
    }
  }
 
  /**
   * Retrieves a cached query entry by key.
   * @source
   */
  async getCachedQueryEntry(key: string): Promise<CachedData<unknown> | undefined> {
    if (!this.enabled) return undefined;
    this.logger.debug('Getting cached query entry', { key });
    try {
      const cached = await getSupplierQueryCacheEntry(key);
      if (cached && cached.__cacheMetadata.version !== SupplierCache.CACHE_VERSION) {
        this.logger.debug('Evicting stale cache entry due to version mismatch', {
          key,
          cachedVersion: cached.__cacheMetadata.version,
          currentVersion: SupplierCache.CACHE_VERSION,
        });
        await deleteSupplierQueryCacheEntry(key);
        return undefined;
      }
      if (cached && this.cacheTtlMs > 0) {
        const age = Date.now() - cached.__cacheMetadata.cachedAt;
        if (age > this.cacheTtlMs) {
          this.logger.debug('Evicting expired cache entry due to TTL', {
            key,
            ageMs: age,
            ttlMs: this.cacheTtlMs,
          });
          await deleteSupplierQueryCacheEntry(key);
          return undefined;
        }
      }
      return cached;
    } catch (error) {
      this.logger.error(`Error retrieving query cache entry: ${getErrorMessage(error)}`, { error });
      return undefined;
    }
  }
 
  /**
   * Clears both the query cache and product data cache from IndexedDB.
   * @source
   */
  static async clearAll(): Promise<void> {
    await Promise.all([clearSupplierQueryCache(), clearSupplierProductDataCache()]);
  }
}