@v4/competition-engine
The only importer of the Competition Engine API: vendored contract, generated client, cached server fetchers, views and the Contentful merge.
Overview
@v4/competition-engine is the integration layer for the Competition Engine — the
read-only Go API in stadionHQ/stadion-euroleague that serves EuroLeague Basketball's
teams, players, coaches, games, standings and statistics (ingested from
api-live.euroleague.net into the engine's own Postgres). Its defining rule, the
@v4/media precedent, is that it is the only place the engine's contract is
imported: no route, component or app touches the API directly. Everything downstream
consumes the package's own normalised views.
How the engine fits the basketball domain — the cache classes, live data and the editorial merge — is explained in Basketball and the Competition Engine; this page is the package reference.
It is club-only: the basketball domain's package, pruned with the domain
(packages/model/domains.json).
The contract is vendored and the client generated
| Piece | Where | How it is kept honest |
|---|---|---|
| The OpenAPI contract | spec/openapi.yaml + spec/SOURCE |
Copied from the engine repo at a pinned commit by pnpm sync-spec; pnpm check-spec reports when main has moved. Never hand-edited. |
| The TypeScript types | src/__generated__/api.d.ts |
pnpm generate runs openapi-typescript over the vendored spec; the verify script fails CI when the committed types and the spec disagree. |
| The HTTP client | src/client.ts |
openapi-fetch over the generated paths: a path, parameter or response field the contract does not have is a compile error. No runtime SDK to bundle. |
openapi-typescript + openapi-fetch were chosen over a runtime SDK generator because
the client is fetch-based: Next's cache options reach fetch untouched, which is what
the media package's react-query lesson was about.
Configuration
One call, from club.config, before any fetch:
// apps/web/src/lib/competition-engine.ts
configureCompetitionEngine({
baseUrl: clubConfig.competitionEngine.baseUrl, // COMPETITION_ENGINE_BASE_URL
competitions: { euroleague: "E", eurocup: "U", nextgen: "J" },
});
The package has no default address. apps/web/club.config.ts reads
COMPETITION_ENGINE_BASE_URL with a default of the local instance
(env.server("COMPETITION_ENGINE_BASE_URL", { default: "http://localhost:8080" });
mise run up && mise run bootstrap in the engine repo starts it on :8080), and each
deployed environment sets the hosted instance's origin. competitions is the section slug → competition code fallback for a section
whose Contentful entry carries no dataProviderId (the CMS wins when it has one —
competitionCodeFor). An unconfigured package throws at the first fetch, by name.
The engine's crests and headshots come from media-cdn.incrowdsports.com and
media-cdn.cortextech.io; deriveImageHosts admits both when the engine is configured.
What it exports
| Entry | Contents |
|---|---|
@v4/competition-engine |
Everything below except the mocks, plus: config (configureCompetitionEngine, competitionEngineConfig, resetCompetitionEngineConfig, COMPETITION_CODES / isCompetitionCode), the server fetchers (server-only), the competition helpers (competitionCodeFor, ELB_COMPETITIONS), the team keys (TEAM_KEYS_BY_CLUB / teamKeyForClub), the errors (EngineError, engineFetchError, isEngineError) and the generated contract types paths / components for a route handler that forwards a raw response |
@v4/competition-engine/views |
the normalisers and view types alone — no server-only, so client islands and tests can import them |
@v4/competition-engine/tags |
the cache vocabulary: CE_TAG, CE_TTL, CE_LIFE, cacheOptions, noStore, ENGINE_TIMEOUT_MS |
@v4/competition-engine/merge |
mergeTeam / mergePlayer / mergeVenue over @v4/model/basketball's fragments |
@v4/competition-engine/mocks |
the recorded fixtures (pnpm record-fixtures against a running engine) as raw responses and as views, for stories and tests |
Dependencies: openapi-fetch and server-only, plus @v4/model (the merge's
fragments), @v4/image (the ImageRef type) and @v4/ghost-core (the BasketballTeamKey type).
See Server, views and the merge for the fetchers, the
cache classes and the Contentful join. The wiring plan the package implements is
.claude/plans/basketball-api-wiring.md.
Server, views and the merge
Server fetchers (src/server.ts)
One fetcher per endpoint, each React.cache-deduped within a render pass and read through
a use cache boundary across requests — tagged, and lived by its class's cacheLife
profile (CE_LIFE). A 404 answers null (the page decides — usually notFound());
any other failure throws — and every call aborts after ENGINE_TIMEOUT_MS (10 s), so a
hung engine cannot hold a page open. The throw is never silent data: the data areas wrap
their routes in @v4/basketball-app's guardEngine, which logs the address and renders
the area's own unavailable state ("Data unavailable right now", noindex metadata) in place
of the site's error page. A failure — an error status or a fetch that never answered — is
cached only for FAILED_LIFE (revalidated after 30 s), so a page holding the unavailable
state asks the engine again within half a minute rather than after its class's TTL.
| Class | Fetchers | TTL |
|---|---|---|
structural |
getSeasons, getTeams, getTeam, getTeamRoster, searchTeams, getSeasonPlayers, getCompetitionPlayers, searchPlayers, getPlayer, getGameFilters, getCoach, getSeasonCoaches, getCompetitionCoaches |
300 s |
computed |
getTeamStats, getPlayerStats, getPlayerGames, getPlayerRecords, getStandings, getHeadToHead, getPlayerStatsTable, getPlayerLeaders, getTeamStatsTable, getTeamLeaders |
300 s |
games |
getGames — scores land after games |
60 s |
gameDetail |
getGame (fixture, status, clock, scores — the players are getBoxScore's since the engine's b61bd82) |
60 s |
| live | getScoreboard (/games), and a game's getBoxScore, getGameTeamStats, getLivePlays, getLiveShots, getLiveFlow — cache: "no-store" while the game can move (the client island polls them by nextPollSeconds); passed "final" they read through the played class (300 s) |
— |
The contract at b61bd82
The contract at b61bd82 (30 Oct 2026) removed /live/games, /seasons/{s}/games/filters
and /seasons/{s}/games/{g}/live: a day's games are /games, the schedule's rounds sit
under their phase at /seasons/{s}/phases (getGameFilters flattens them back, so its view
did not change), and a game's players moved from the game to /boxscore (with the rates and
plus/minus) beside a new /team-stats. Two quieter changes the views absorb: a club's form
is now sent OLDEST first (the view reverses it, so form[0] is still the latest result), and
head-to-head names both clubs in full with their wins and the next meeting, filterable by
competition and since (A-7 answered).
Cache lifetimes and tags
Each class is a cacheLife profile in CE_LIFE (tags.ts): revalidate is the TTL,
expire holds a stale answer for an hour while a fresh one is fetched, and stale is how
long the browser's router reuses a page without asking.
| Class | stale |
revalidate |
expire |
|---|---|---|---|
structural |
60 s | 300 s | 3600 s |
computed |
60 s | 300 s | 3600 s |
games |
30 s | 60 s | 3600 s |
gameDetail |
30 s | 60 s | 3600 s |
played |
60 s | 300 s | 3600 s |
getSeasonCoaches and getCompetitionCoaches feed the coach pages and the coaches index.
Tags (CE_TAG): every response carries ce, then ce:competition:<code>,
ce:season:<code>, ce:team:<club>, ce:player:<code>, ce:coach:<code> or
ce:game:<season>:<code> as applies. Every response expires by its TTL unless the
ingester's post-run POST /api/revalidate (below, wiring plan A-12) expires its tags
first, which turns the TTLs into a ceiling.
Revalidation after an ingest (A-12)
The ingester calls the site's revalidation route after each run, with the same secret the
Contentful webhook uses (CONTENTFUL_WEBHOOK_SECRET, sent as x-revalidate-secret):
curl -X POST https://<site>/api/revalidate \
-H 'x-revalidate-secret: <secret>' -H 'content-type: application/json' \
-d '{ "source": "competition-engine",
"seasons": ["E2026"],
"teams": ["BAR", "MAD"],
"players": ["003469"],
"coaches": ["001234"],
"games": [{ "season": "E2026", "code": 247 }] }'
Every field is optional; a tags array of ready-made ce:* names is also accepted. The
route answers { "revalidated": [...] } with the tags it expired: the coarse ce always
(the edge carries only that one for the data areas, so the run purges every data page
there) plus ce:season:<code>, ce:team:<club>, ce:player:<code>, ce:coach:<code> and
ce:game:<season>:<code> for what the run touched, which expire the origin's fetches
precisely. A run that names nothing still clears the data areas. The mapping is
tagsForEngineRun in apps/web/src/lib/revalidation.ts; after a live game the schedule's
60 s TTL is the ceiling anyway, so the call matters most for standings, stats and rosters.
How a read is cached
Under Cache Components a Next fetch hint
(next: { tags, revalidate }) does not cache: an uncached fetch makes the render
request-time, and every data-area page was rendered per request and sent no-store until
26 Oct 2026. So openapi-fetch's per-call fetch option is the seam (cacheOptions in
src/tags.ts): the Request it builds goes to cachedEngineGet (src/cached-fetch.ts),
a use cache boundary keyed on the URL that calls cacheTag with the read's tags and
cacheLife with its class's profile, fetches with AbortSignal.timeout(ENGINE_TIMEOUT_MS)
and returns the response as a RECORD (status, type, body text — a Response cannot be
cached); cacheOptions rebuilds the Response for openapi-fetch, and a recorded
rejection becomes the same EngineError a live one did. The live endpoints keep
cache: "no-store" on a plain fetch (noStore). globalThis.fetch is read at call time,
so a test's stub applies; server.test.ts mocks next/cache and asserts the tags and
life each fetcher asks for, and that no next hint is sent.
The pages are cached on top of it: partialPrefetching upgrades each data-area address
to a cached page after its first visit (the catch-alls list one placeholder,
__prerender, so the build never reads the engine), and they read Contentful through the
delivery client so draft mode cannot make them request-time.
Views (src/views/)
The engine's snake_case, everything-nullable responses normalised once into shapes the
app can trust: names title-cased (VEZENKOV, SASHA → Sasha Vezenkov, without a
locale, so a Turkish runtime cannot dot an I), socials made into URLs from bare
handles, null read as undefined, dates kept ISO, numbers kept raw (the page adapters
format by locale). The statistics lines — dozens of snake_case numbers each — go through
one camelKeys mapper with a type-level CamelKeys<T>, so a line the contract gains
appears in the view without a hand-written field.
Four contract/engine disagreements the views absorb, found on the recorded data and the
first live season: a team's next game is null before round 1 though the contract says
never, a live side's totals is null before tip-off, a roster player's season line is
null for a player yet to play, and a player's season-stats peers group is null with no
games played. All four read as undefined (asks A-14 to A-16 and A-19 in the wiring plan).
A live sweep of every view over the engine's responses for E2026, U2026 and E2025 (teams,
rosters, stats, standings, games, players, leaders, live — 488 responses) is the check to
re-run after a spec sync.
The Contentful merge (src/merge.ts)
The model (club/0038, .claude/plans/competition-engine-content-model.md) makes the
engine the source of truth and gives editors a short list of fields — the spreadsheet's 17. @v4/model/basketball's fragments select exactly those plus the join keys
(dataProviderId = the engine's club or venue code, personCode for a player, slug
for the address), and the merge lays an entry over the engine's view:
| Entity | Editorial wins when set | Join |
|---|---|---|
| team | editorialName → name; website, ticketsUrl, the three socials; crest → crestImage; sortHierarchy |
entryForCode(entries, clubCode) |
| player | alias → knownAs; heightCm; bio, achievements; the socials; headshot, actionImage |
playerEntryForCode(entries, personCode) |
| venue | name, capacity, address |
entryForCode(entries, venueCode) |
"Set" means a non-empty string or a number; an empty editorial field means "the
engine's". A missing entry is normal (the sync has not run, a new club): every merge
takes null and answers the engine's view plus the slug the address falls back to
(teamSlug, playerSlug — the name slugified, the caller appending the code on a
collision).
Fixtures and mocks
pnpm record-fixtures records one small response per endpoint from a running engine
(the local one by default) into src/__fixtures__/; src/mocks.ts exposes them typed,
raw (competitionEngineFixtures) and as views (competitionEngineMocks). The view
tests pin every normaliser against them — real responses, not hand-idealised shapes.