All files / src/browser/shared safe-storage.ts

89.47% Statements 34/38
78.94% Branches 15/19
100% Functions 3/3
90.62% Lines 29/32

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                                            6x 6x                                                   6x 6x 6x   5x   2x 2x         3x 3x 3x 9x 9x 6x 6x 6x 6x 6x 6x         6x               3x 3x 3x 4x     3x 3x 3x   1x   1x            
/**
 * @file Safe localStorage wrapper with quota-aware eviction.
 *
 * Wraps `localStorage.setItem` so that `QuotaExceededError` (and the legacy
 * Firefox `NS_ERROR_DOM_QUOTA_REACHED`) does not leak as an unhandled
 * exception or noisy console error. On quota errors the helper evicts the
 * oldest entries that share the caller's key prefix and retries once. If the
 * payload still does not fit (e.g. a single oversized blob), the write is
 * silently skipped — the caller will simply re-fetch on the next page load.
 *
 * @intelligence Resilient browser-cache layer — keeps dashboards functional
 * when committee/election CSV blobs exceed the ~5 MB localStorage quota.
 */
 
import { logger } from './logger.js';
 
/**
 * Detect whether an error represents a quota-exceeded condition.
 * Covers DOMException name 'QuotaExceededError', legacy code 22, and the
 * historical Firefox name 'NS_ERROR_DOM_QUOTA_REACHED' (code 1014).
 */
function isQuotaError(e: unknown): boolean {
  Iif (!(e instanceof DOMException)) return false;
  return (
    e.name === 'QuotaExceededError' ||
    e.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
    e.code === 22 ||
    e.code === 1014
  );
}
 
/**
 * Attempt `localStorage.setItem`. On quota exceeded, evict half of the
 * entries sharing `evictionPrefix` (oldest-timestamp first when payloads are
 * JSON `{ timestamp }` objects) and retry once. Other errors are logged and
 * swallowed.
 *
 * @param key Full storage key to write.
 * @param payload Serialized payload to store.
 * @param evictionPrefix Prefix identifying related entries that may be
 *   evicted to make room. Typically the caller's namespace, e.g.
 *   `'committees-cache:'`.
 * @returns `true` if the write succeeded, `false` otherwise.
 */
export function safeSetItem(
  key: string,
  payload: string,
  evictionPrefix: string,
): boolean {
  try {
    localStorage.setItem(key, payload);
    return true;
  } catch (e: unknown) {
    if (!isQuotaError(e)) {
      // Non-quota error (e.g. SecurityError when storage is disabled) — warn and bail.
      logger.warn('safeSetItem: non-quota storage error', e);
      return false;
    }
  }
 
  // Quota exceeded — collect candidates with the same prefix.
  const candidates: { key: string; timestamp: number }[] = [];
  try {
    for (let i = 0; i < localStorage.length; i++) {
      const k = localStorage.key(i);
      if (!k || !k.startsWith(evictionPrefix) || k === key) continue;
      let ts = 0;
      try {
        const raw = localStorage.getItem(k);
        Eif (raw) {
          const parsed = JSON.parse(raw) as { timestamp?: unknown };
          Eif (typeof parsed.timestamp === 'number') ts = parsed.timestamp;
        }
      } catch {
        // Non-JSON entry under this prefix — treat as oldest so it gets purged first.
      }
      candidates.push({ key: k, timestamp: ts });
    }
  } catch {
    // Iteration over localStorage failed (e.g. storage disabled mid-flight) — give up.
    return false;
  }
 
  // Evict oldest half (at minimum one).
  candidates.sort((a, b) => a.timestamp - b.timestamp);
  const removeCount = Math.max(1, Math.ceil(candidates.length / 2));
  for (const entry of candidates.slice(0, removeCount)) {
    try { localStorage.removeItem(entry.key); } catch { /* ignore */ }
  }
 
  try {
    localStorage.setItem(key, payload);
    return true;
  } catch (retryErr) {
    Eif (isQuotaError(retryErr)) {
      // Single payload still too large — skip silently. Caller will re-fetch on next load.
      return false;
    }
    logger.warn('safeSetItem: retry failed', retryErr);
    return false;
  }
}