Files
vyndr/web/src/services/odds-cache.ts
T

132 lines
3.7 KiB
TypeScript

/**
* 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<T> {
payload: T;
fetched_at: string;
expires_at: string;
}
const DEFAULT_TTL_SECONDS = 300; // 5 min
export interface CachedFetchOptions<T> {
/**
* 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<T>;
/**
* If true, returns the cached row even after it has expired when
* the loader throws. Defaults to true.
*/
fallbackToStale?: boolean;
}
export async function cachedFetch<T>(opts: CachedFetchOptions<T>): Promise<T> {
const sb = getServiceRoleSupabase();
const now = new Date();
const ttl = opts.ttlSeconds ?? DEFAULT_TTL_SECONDS;
// 1. Try the cache.
let cached: CacheEntry<T> | 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<T>;
} 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<T>(
key: string,
sport: string,
dataType: string,
backendPath: string,
ttlSeconds = DEFAULT_TTL_SECONDS,
): Promise<T> {
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
return cachedFetch<T>({
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}`;
}