Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user