132 lines
3.7 KiB
TypeScript
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}`;
|
|
}
|