All files / src/helpers requestCache.ts

100% Statements 51/51
100% Branches 19/19
100% Functions 3/3
100% Lines 51/51

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 1211x 1x   1x   1x           1x   1x       1x 1x   1x       1x 1x                                                 1x             1x 64x 64x 64x 5x 5x 5x   64x   1x                                 1x 1x 1026x 1026x   1026x 1026x   1026x 78x   78x 78x 78x 64x 78x 4x 4x 78x 14x 14x   74x   73x 78x 55x 55x 55x 9x 9x 55x   73x 73x 1026x  
import { cstorage } from '@/utils/storage';
import { Logger } from '@/utils/Logger';
 
const logger = new Logger('requestCache');
 
/**
 * @group Helpers
 * @groupDescription Persistent, time-to-live caching for outbound API requests. Wraps an async
 * fetcher so results are served from `chrome.storage.local` first and only hit the network when
 * the cache is empty or stale — keeping ChemPal well under PubChem's 5-requests-per-second guidance.
 * @source
 */
 
/**
 * Three days in milliseconds — the default freshness window for a cached response.
 * @category Helpers
 * @source
 */
export const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
 
/**
 * Prefix applied to every cache key written to `chrome.storage.local`, so cached entries are easy
 * to identify and never collide with unrelated keys.
 * @source
 */
const CACHE_KEY_PREFIX = 'pubchem-cache';
 
/**
 * A cached value together with the epoch-ms timestamp at which it was stored.
 * @source
 */
interface CacheEntry<Result> {
  cachedAt: number;
  value: Result;
}
 
/**
 * Options controlling a {@link withTtlCache} wrapper.
 * @category Helpers
 * @source
 */
export interface TtlCacheOptions<Args extends unknown[]> {
  /** Namespace segment separating one wrapped function's keys from another's. */
  namespace: string;
  /** How long a cached entry stays fresh, in ms. Defaults to {@link THREE_DAYS_MS}. */
  ttlMs?: number;
  /** Derives the cache key from the call arguments. Defaults to `JSON.stringify(args)`. */
  keyFromArgs?: (...args: Args) => string;
}
 
/**
 * Narrows an arbitrary value read back from storage to a {@link CacheEntry}. The `Result` type is
 * unchecked at runtime (as with any deserialized value); it reflects what the wrapped function
 * returned when the entry was written.
 * @param value - The value read from `chrome.storage.local`
 * @returns True if `value` has the cache-entry shape
 * @source
 */
function isCacheEntry<Result>(value: unknown): value is CacheEntry<Result> {
  return (
    typeof value === 'object' &&
    value !== null &&
    typeof Reflect.get(value, 'cachedAt') === 'number' &&
    'value' in value
  );
}
 
/**
 * Wraps an async function with a persistent, time-to-live cache backed by `chrome.storage.local`.
 * The returned function checks the cache first and only invokes `fn` on a miss or when the cached
 * entry has expired. Only defined results are cached, so a failed or empty lookup (`undefined`) is
 * never negatively cached. Any storage error is swallowed and the call falls through to `fn`, so
 * the wrapper can never break a caller that runs without `chrome.storage`.
 * @category Helpers
 * @param fn - The async function to cache
 * @param options - Cache configuration; see {@link TtlCacheOptions}
 * @returns A drop-in replacement for `fn` that serves from cache when possible
 * @example
 * ```typescript
 * const getCidsByCas = withTtlCache(getCidsByCasUncached, { namespace: "cidsByCas" });
 * await getCidsByCas("15681-89-7"); // fetches, then caches for 3 days
 * await getCidsByCas("15681-89-7"); // served from cache, no network call
 * ```
 * @source
 */
export function withTtlCache<Args extends unknown[], Result>(
  fn: (...args: Args) => Promise<Result>,
  options: TtlCacheOptions<Args>,
): (...args: Args) => Promise<Result> {
  const { namespace, ttlMs = THREE_DAYS_MS } = options;
  const keyFromArgs = options.keyFromArgs ?? ((...args: Args) => JSON.stringify(args));
 
  return async (...args: Args): Promise<Result> => {
    const storageKey = `${CACHE_KEY_PREFIX}:${namespace}:${keyFromArgs(...args)}`;
 
    // Try the cache first; any storage failure falls through to the source.
    try {
      const stored = await cstorage.local.get(storageKey);
      const entry = stored[storageKey];
      if (isCacheEntry<Result>(entry) && Date.now() - entry.cachedAt < ttlMs) {
        return entry.value;
      }
    } catch (error) {
      logger.debug(`Cache read failed for "${storageKey}"; querying source`, { error });
    }
 
    const result = await fn(...args);
 
    // Skip caching undefined so failed/empty lookups aren't negatively cached for the whole TTL.
    if (result !== undefined) {
      try {
        await cstorage.local.set({ [storageKey]: { cachedAt: Date.now(), value: result } });
      } catch (error) {
        logger.debug(`Cache write failed for "${storageKey}"`, { error });
      }
    }
 
    return result;
  };
}