@v4/competition-engine

The only importer of the Competition Engine API: vendored contract, generated client, cached server fetchers, views and the Contentful merge.

2026-11-07

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.