v2 Phase 1: ingestion + SQLite foundation

- Persistence via Node's built-in node:sqlite (zero native deps) on a Docker
  volume: response cache, source health, fixture↔provider id map, per-fixture
  enrichment, ingest log. Runtime bumped to node:24-slim + --experimental-sqlite.
- Resilient fetcher: DB cache + per-source rate-limit + jittered backoff +
  circuit-breaker (blocked sources trip + skip; UI reads DB, never breaks).
- ESPN hidden API as the PRIMARY rich source (works from the VPS where SofaScore
  403s): scoreboard (live scores w/ clock) + summary (venue, H2H, recent form,
  lineups, team stats). football-data / SofaScore are fallbacks.
- Scheduler: maps all 72 group fixtures to ESPN event ids, polls live on a
  dynamic cadence, enriches imminent fixtures into match_ext. /api/sources/health.
- Verified: DB populates (real H2H + form for opening matches), 22 tests pass,
  Docker image runs node:sqlite on the volume and persists across restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 15:21:01 +02:00
parent 9c7605391c
commit b6f62679c9
13 changed files with 697 additions and 88 deletions
+7 -4
View File
@@ -6,8 +6,9 @@ import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
import type { WebSocket } from 'ws';
import { TournamentState } from './tournament';
import { startLiveLoop } from './liveLoop';
import { startScheduler } from './ingest/scheduler';
import { ModelEngine } from './model';
import { db, healthAll } from './db/db';
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
const PORT = Number(process.env.PORT ?? 8787);
@@ -27,6 +28,7 @@ export function buildServer() {
catch { const err = new Error('Invalid JSON') as Error & { statusCode?: number }; err.statusCode = 400; done(err, undefined); }
});
db(); // open + migrate SQLite before anything reads it
const state = new TournamentState(STATIC_DIR);
const model = new ModelEngine(STATIC_DIR);
model.recompute(state.allFixtures());
@@ -52,6 +54,7 @@ export function buildServer() {
// ---- REST: snapshot + predictions (initial load + a no-WS fallback) ----
app.get('/api/snapshot', async () => state.snapshot());
app.get('/api/predictions', async () => model.current());
app.get('/api/sources/health', async () => ({ sources: healthAll() }));
app.get('/healthz', async () => ({ ok: true }));
// ---- dev-only: inject a score to exercise the live → standings → WS loop ----
@@ -102,9 +105,9 @@ export function buildServer() {
});
});
// ---- live polling loop (no-op unless a source is configured) ----
const stopLive = startLiveLoop(state, () => broadcast());
app.addHook('onClose', async () => stopLive());
// ---- ingestion scheduler (ESPN primary; football-data / SofaScore fallback) ----
const stopIngest = startScheduler(state, () => broadcast());
app.addHook('onClose', async () => stopIngest());
// ---- static SPA (prod) with history fallback ----
if (existsSync(STATIC_DIR)) {