95 lines
3.0 KiB
TypeScript
95 lines
3.0 KiB
TypeScript
import { Router } from 'express';
|
||
import { desc } from 'drizzle-orm';
|
||
import { db } from '../db/client';
|
||
import { arbaShowsCache } from '../db/schema';
|
||
import { toSnakeCase } from '../utils/case';
|
||
import { fetchArbaShows, ArbaShow } from '../lib/arba';
|
||
|
||
const router = Router();
|
||
|
||
// ARBA only updates Mon–Fri during business hours, so the cache is considered
|
||
// fresh for a day; the first request after that triggers a re-scrape.
|
||
const STALE_MS = 24 * 60 * 60 * 1000;
|
||
|
||
interface CacheEntry {
|
||
fetchedAt: string;
|
||
shows: ArbaShow[];
|
||
}
|
||
|
||
// Latest (and only) cache row, parsed back into objects — or null if never run.
|
||
function readCache(): CacheEntry | null {
|
||
const row = db.select().from(arbaShowsCache).orderBy(desc(arbaShowsCache.id)).limit(1).get();
|
||
if (!row) return null;
|
||
return { fetchedAt: row.fetchedAt, shows: JSON.parse(row.data) as ArbaShow[] };
|
||
}
|
||
|
||
// Scrape ARBA and replace the cache with the fresh result (one row only).
|
||
async function refreshCache(): Promise<CacheEntry> {
|
||
const shows = await fetchArbaShows();
|
||
const fetchedAt = new Date().toISOString();
|
||
const data = JSON.stringify(shows);
|
||
db.transaction((tx) => {
|
||
tx.delete(arbaShowsCache).run();
|
||
tx.insert(arbaShowsCache).values({ fetchedAt, data }).run();
|
||
});
|
||
return { fetchedAt, shows };
|
||
}
|
||
|
||
function isStale(entry: CacheEntry): boolean {
|
||
const fetchedMs = Date.parse(entry.fetchedAt);
|
||
return Number.isNaN(fetchedMs) || Date.now() - fetchedMs > STALE_MS;
|
||
}
|
||
|
||
function respond(res: import('express').Response, entry: CacheEntry, stale: boolean): void {
|
||
res.json({
|
||
fetched_at: entry.fetchedAt,
|
||
stale,
|
||
shows: entry.shows.map((show) => toSnakeCase(show as unknown as Record<string, unknown>)),
|
||
});
|
||
}
|
||
|
||
// Return the cached ARBA listing, re-scraping first if the cache is missing or
|
||
// older than STALE_MS. If a needed scrape fails but we still hold a cache, we
|
||
// serve the stale cache (flagged) rather than erroring — the design doc's "cache
|
||
// remains available until it can be refreshed".
|
||
router.get('/arba', async (req, res, next) => {
|
||
try {
|
||
let entry = readCache();
|
||
|
||
if (!entry || isStale(entry)) {
|
||
try {
|
||
entry = await refreshCache();
|
||
respond(res, entry, false);
|
||
return;
|
||
} catch (err) {
|
||
if (!entry) {
|
||
res
|
||
.status(502)
|
||
.json({ error: `Could not load ARBA shows: ${(err as Error).message}` });
|
||
return;
|
||
}
|
||
// Fall through and serve the stale cache we already have.
|
||
console.error('ARBA refresh failed, serving stale cache:', err);
|
||
respond(res, entry, true);
|
||
return;
|
||
}
|
||
}
|
||
|
||
respond(res, entry, false);
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
// Force a re-scrape. On failure the previous cache is left untouched.
|
||
router.post('/arba/refresh', async (req, res) => {
|
||
try {
|
||
const entry = await refreshCache();
|
||
respond(res, entry, false);
|
||
} catch (err) {
|
||
res.status(502).json({ error: `ARBA refresh failed: ${(err as Error).message}` });
|
||
}
|
||
});
|
||
|
||
export default router;
|