All files / lib cache.ts

0% Statements 0/136
0% Branches 0/1
0% Functions 0/1
0% Lines 0/136

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                                                                                                                                                                                                                                                                                 
// SEO-ONLINE cache rules (do not deviate)
// ---------------------------------------------------------------------------
// 1. EVERY read that hits the database MUST go through `cached(key, loader)`.
//    The cache is checked first; on miss, the loader runs and the result is
//    stored before returning.
//
// 2. EVERY write (create / update / delete) MUST invalidate or refresh the
//    cache for the entities it touched.  Two acceptable patterns:
//
//      a) Write-through:  await prisma.update(...); await cacheSet(key, fresh)
//      b) Invalidate:     await prisma.update(...); await cacheInvalidate(key)
//                                                   await cacheInvalidatePattern('user:list:*')
//
//    Do NOT leave a stale key behind after a mutation — that's the #1 source
//    of confusing "I just saved it but the page shows the old value" bugs.
//
// 3. Key naming convention:  <scope>:<entity>[:detail][:filter=value]
//    Examples:
//      user:byId:<userId>
//      user:list:role=MASTER
//      inventory:byBranch:ADS
//      inventory:item:<itemId>
//      dashboard:stats
//      activity:recent:limit=20
//
// 4. Every key MUST have a TTL.  No infinite keys.  Default = 5 minutes.
//    Pick a TTL appropriate to how often the underlying data changes.
//
// 5. Toggle `process.env.CACHE_DISABLED=true` to bypass cache entirely while
//    debugging.  This makes every `cached()` call read straight from the loader.

import { redis } from './redis';

const DEFAULT_TTL_SECONDS = 300;
const SCAN_BATCH = 100;

const cacheDisabled = process.env.CACHE_DISABLED === 'true';

export type CacheOptions = {
  ttlSeconds?: number;
};

export async function cacheGet<T>(key: string): Promise<T | null> {
  if (cacheDisabled) return null;
  const raw = await redis.get(key);
  if (raw === null) return null;
  try {
    return JSON.parse(raw) as T;
  } catch {
    return null;
  }
}

export async function cacheSet<T>(
  key: string,
  value: T,
  opts: CacheOptions = {},
): Promise<void> {
  if (cacheDisabled) return;
  const ttl = opts.ttlSeconds ?? DEFAULT_TTL_SECONDS;
  await redis.set(key, JSON.stringify(value), 'EX', ttl);
}

export async function cacheInvalidate(...keys: string[]): Promise<void> {
  if (cacheDisabled || keys.length === 0) return;
  await redis.del(...keys);
}

/**
 * Drop every key matching a Redis glob pattern (e.g. `user:list:*`).
 * Uses SCAN — safe on large keyspaces but still O(N) so don't abuse.
 */
export async function cacheInvalidatePattern(pattern: string): Promise<number> {
  if (cacheDisabled) return 0;
  let cursor = '0';
  let deleted = 0;
  do {
    const [next, keys] = await redis.scan(
      cursor,
      'MATCH',
      pattern,
      'COUNT',
      SCAN_BATCH,
    );
    cursor = next;
    if (keys.length > 0) {
      deleted += await redis.del(...keys);
    }
  } while (cursor !== '0');
  return deleted;
}

/**
 * Drop every cached row whose **value** embeds a denormalised join — branch
 * names, subtype names, category names, issue type names, usernames.  Call
 * this after any mutation that renames or toggles a taxonomy entity OR
 * renames a user, because all the *:list:* keys hold pre-joined display
 * strings that would otherwise show stale text until TTL expires.
 *
 * Cost: O(N) over the matched keys but taxonomy / user-name mutations are
 * rare so the broad blast radius is acceptable.
 */
export async function invalidateJoinedDisplayCaches(): Promise<void> {
  await Promise.all([
    cacheInvalidatePattern('product:list:*'),
    cacheInvalidatePattern('transfer:list:*'),
    cacheInvalidatePattern('claim:*'),
    cacheInvalidatePattern('activity:*'),
    cacheInvalidatePattern('stats:*'),
  ]);
}

/**
 * Cache-aside read.  This is the ONLY function reads should call.
 *
 *   const user = await cached(
 *     `user:byId:${id}`,
 *     () => prisma.user.findUnique({ where: { id } }),
 *     { ttlSeconds: 600 },
 *   );
 */
export async function cached<T>(
  key: string,
  loader: () => Promise<T>,
  opts: CacheOptions = {},
): Promise<T> {
  if (!cacheDisabled) {
    const hit = await cacheGet<T>(key);
    if (hit !== null) return hit;
  }
  const fresh = await loader();
  if (fresh !== null && fresh !== undefined) {
    await cacheSet(key, fresh, opts);
  }
  return fresh;
}