/** * Supabase-backed cache wrapper around upstream Odds API and backend * grading-engine calls. Keeps user-facing requests off the rate-limited * upstream API (500 req/mo on free tier) by serving from a 5-minute TTL * cache row in `odds_cache`. * * Cache key pattern: `{sport}:{data_type}:{date?}` * e.g. `nba:games:2026-05-18`, `mlb:props:2026-05-18`, `wnba:games:today` * * Failure mode: if Supabase is unreachable, we still call the loader so * a fresh response is returned. If both Supabase AND the loader fail, * the caller gets the stale cache row (if any) or the loader's thrown * error. */ import { getServiceRoleSupabase } from '@/lib/supabase'; interface CacheEntry { payload: T; fetched_at: string; expires_at: string; } const DEFAULT_TTL_SECONDS = 300; // 5 min export interface CachedFetchOptions { /** * Unique cache key. Reuse it across calls that want the same data. */ key: string; sport: string; dataType: string; /** How long the row stays fresh. Defaults to 300s. */ ttlSeconds?: number; /** Loader called on a miss. Must return a value or throw. */ loader: () => Promise; /** * If true, returns the cached row even after it has expired when * the loader throws. Defaults to true. */ fallbackToStale?: boolean; } export async function cachedFetch(opts: CachedFetchOptions): Promise { const sb = getServiceRoleSupabase(); const now = new Date(); const ttl = opts.ttlSeconds ?? DEFAULT_TTL_SECONDS; // 1. Try the cache. let cached: CacheEntry | null = null; if (sb) { try { const { data } = await sb .from('odds_cache') .select('payload, fetched_at, expires_at') .eq('cache_key', opts.key) .maybeSingle(); if (data) cached = data as CacheEntry; } catch { /* fall through to loader */ } } if (cached && new Date(cached.expires_at) > now) { return cached.payload; } // 2. Refresh via the loader. try { const fresh = await opts.loader(); if (sb) { const expires = new Date(now.getTime() + ttl * 1000).toISOString(); // upsert is racy but the conflict is harmless — last writer wins. await sb .from('odds_cache') .upsert( { cache_key: opts.key, sport: opts.sport, data_type: opts.dataType, payload: fresh as unknown as object, fetched_at: now.toISOString(), expires_at: expires, }, { onConflict: 'cache_key' }, ); } return fresh; } catch (err) { if (opts.fallbackToStale !== false && cached) return cached.payload; throw err; } } /** * Wrap a `fetch` against the BACKEND_URL with the cache. Useful for * routes that pass-through to the Express grading engine but want to * absorb its load spikes. */ export async function cachedBackendJson( key: string, sport: string, dataType: string, backendPath: string, ttlSeconds = DEFAULT_TTL_SECONDS, ): Promise { const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; return cachedFetch({ key, sport, dataType, ttlSeconds, loader: async () => { const res = await fetch(`${BACKEND_URL}${backendPath}`, { headers: { Accept: 'application/json' }, // Force fresh from backend so we control the TTL ourselves. cache: 'no-store', }); if (!res.ok) throw new Error(`backend ${backendPath} returned ${res.status}`); return (await res.json()) as T; }, }); } /** * Helper for daily-keyed caches. */ export function todayKey(sport: string, dataType: string): string { const d = new Date().toISOString().slice(0, 10); return `${sport.toLowerCase()}:${dataType}:${d}`; }