v1 and v2 parity
Measuring whether /api/v2 returns the same data as the /api routes it replaces, pair by pair, against the real database.
/api/v2 is not a copy of /api. It changes the envelope (data, page,
links), the casing, the id type (strings, and always the public provider id),
the timestamps (RFC 3339) and the errors (Problem Details). None of that is a
difference in the data. What this suite measures is everything else: a fixture
v1 lists and v2 does not, a count that disagrees, a stat with another value, a
board sorted differently, a field a v1 client reads that v2 dropped, or a v2
error where v1 answers.
It runs from packages/tests/api-contract:
pnpm --filter statshub-api-contract contract:v1-v2 # every case, against a private API built from your tree
pnpm --filter statshub-api-contract inventory:v1-v2 # rebuild the pair inventorycontract:v1-v2 wraps the run in scripts/with-test-api.sh, so it builds
apps/api from the working tree, starts it on 127.0.0.1 against the real
database opened read-only, and hands the run a paid (super admin) and a free
test token for the signed-in cases. Both surfaces are served by that one
process, so the same request order and the same data are on both sides. The
machine-readable result lands in reports/v1-v2-parity.json; the console
prints every failing case with its findings.
To run a slice while working on one handler, start the private API once and point the CLI at it:
bash scripts/with-test-api.sh bash -c 'cd packages/tests/api-contract && \
bun run src/client-parity-cli.ts --only=teams-,players-roster --max-requests=2000'--only matches a substring of the case id or of either path; commas separate
alternatives. --max-requests is a hard ceiling the run refuses to exceed,
--concurrency=N (1 to 8) runs cases side by side, and --json=path writes the
full result.
The inventory
client-cases/v1-v2-inventory.json pairs every v2 GET with the v1 operations it
replaces or partly covers. It is generated from apps/api/docs/openapi.json,
openapi-v2.json and v2-capability-mappings.json, and it records for each
side the Go handler and its file:line, the first-party client files that still
request the path, and the ids of the cases that cover the pair. It also lists v2
GETs with no v1 counterpart and v1 GETs with no v2 counterpart.
bun run scripts/v1-v2-inventory.ts --check # exit 1 while any pair has no caseRegenerate it whenever a route or a mapping changes; a pair that appears there without a case is a route nobody has compared.
Writing a case
Cases live in client-cases/<domain>.json. Each one names a v1 request, a v2
request, and how to compare them:
exactfor v2 routes that delegate to the v1 handler (the bridges ininternal/api/v2_bridge*.goand the views inv2_user_views.go): the bodies must be identical.legacyPath/v2Pathcompare a subtree,normalizeapplies to both sides, andvolatilemasks values that move between two reads a second apart — live prices, not anything a fix could change.fieldsfor designed v2 resources: each field maps a v1 selector to a v2 selector and names the normalizations that remove the wire-format change between them.statusfor routes whose body is not JSON.
Selectors are dotted paths with [*] for every element, [0] for one, and
{alias=path,…} to compare rows as tuples:
data[*].{id=events.id,home=events.homeTeamId} against
data[*].{id=id,home=homeTeam.id}.
Normalizations each remove exactly one representation change: number (string
ids and numeric strings), unix-seconds, ms-to-seconds, utc-day,
legacy-status (v2's scheduled/live/cancelled), loose-keys (case and
_), boolean, string, lowercase, empty-as-null, round-N, and for
collections sort (a set, not a sequence), unique and count. A field's
relation is equal by default, or v2-subset when a paged v2 collection is
compared with a complete v1 list. v2Pages follows links.next and joins every
page's data first, which is usually the better answer.
Pair ids with care. v1 /api/team/{id} and /api/player/{id} resolve an
internal id first; v2 always means the public id. Add ?idType=external to the
v1 request (or use the internal id) so both sides name the same entity. Phases
work the same way without the flag: v1 /api/tournament/{id}/… takes LaLiga as
internal 24, and every /api/v2/phases/{phaseId}/… route takes it as public
36.
A case whose two sides return the same error is reported as inconclusive, not as a pass: it proves only that both refused a request, which usually means its ids went stale.
When the two versions answer with different statuses on purpose, declare it
with expectStatus: { "legacy": 404, "v2": 200 }. Any other pair of statuses
is still a finding, and when both match the bodies are compared anyway, so
select the subtree they share with legacyPath/v2Path.
The database is the real one
Every case is a GET, or a v1 POST the case declares a read with
legacyReadOnlyPost after the handler has been checked. The private API refuses
writes at the driver, but a case that would write is still a case that should
not exist.
Deliberate differences
Where v2 answers differently on purpose, the case says so rather than failing on every run. Each of these is encoded in its case, and a change to one is a change to the v2 contract.
-
An empty basketball day is a page, not a missing resource.
/api/basketball/events/by-dateanswers404 {"message", "data": []}on a day with no fixtures;/api/v2/basketball/fixturesanswers200 {"data": []}, like every designed v2 collection. The v1 404 is kept for the clients that already read it. Casebasketball-fixtures-by-date-empty-day(expectStatus, comparesdataon both sides). -
A round is listed once. v1
/api/unique-tournament/{id}/{season}/roundsgroups fixtures by round and slug, so a round whose fixtures carry both a blank and a named slug is listed twice, once blank, in no order. v2/roundshas one row per round, ordered, with the named slug. Caseleagues-rounds-champions-leaguecompares the round numbers as sets and requires every v2 (round, slug) to be one v1 lists (v2-subset). -
A v2 last-games page holds at most 50 fixtures. v1
/api/team/{id}/last-gamesserves whateverlimitasks for (Racing'slimit=500answers all 187 finished fixtures); v2/teams/{teamId}/last-gamesserves 50, because every fixture carries both team sheets. A largerlimitis served as 50 rather than refused, since released Expo builds ask for 60, andpagination.limitandpagination.totalPagesreport the page actually served, so a caller that wants the whole history follows the pages (app-router'ssrc/lib/team-last-games.ts). Casesteams-last-games-limit-500-racing(v2limit=500against v1's own 50-row page) andteams-last-games-racing-last-page(page 4). -
nationalon a v2 season row is the team's flag. v1'sisNationalin/api/team/{id}/tournaments-and-seasonsand/api/player/{id}/tournaments-and-seasonsistournaments.is_national, which is false on all 3,677 phases in the database, so v1 never marks a competition national and app-router's club/national splits (teamDomesticLeague, the player page's national tab) read the bridged v1 field and filter nothing. v2/teams/{id}/seasonsand/players/{id}/seasonssetnationalfrom the team the fixtures were played for, which is what Expo renders as "International" beside a competition. Caseteams-seasons-netherlands-national-flagpins it to/api/team/{id}'snational;teams-seasons-netherlands-competitionsno longer compares the v1 flag. -
v2 refuses v1's AGScollection parameters.
tournamentId,locationandfixtureIdare 400unknown-query-parameteron/teams/{teamId}/player-fixture-statistics, which takescompetitionId,seasonId,venue,opponentIdand answers one row per player and fixture. app-router's AGS and player-card boards read the AGScollection payload, soapps/web-app-router/api-compat.jsroutes requests carrying those names to/api/team/{id}/players/AGScollection. Caseteams-player-fixture-statistics-client-paramsexpects200/400;teams-player-fixture-statistics-arsenal-chelseacompares the data. -
A finished fixture's bet-builder positions come from the played lineup. v2
/fixtures/{id}/bet-builder-lineupsanswers fromplayer_statistics_eventonce a match has been played (sourceconfirmed); v1/api/event/{id}/predicted-teams-lineupreads thepredicted_lineupsteamsheet. Both name the same eleven, but the teamsheet swaps a central left/right pair (RCB/LCB, RCDM/LCDM, RCM/LCM) in about one pair in eleven, on either side: across 150 finished fixtures of September 2026 the two tables disagreed on 100 of 1,092 pairs, and the players' match heatmaps sided withplayer_statistics_eventin 92 of them (for Manchester United v Manchester City, Guéhi played right of Dias). So v2 is kept, and casefixtures-detail-bet-builder-vs-predicted-lineupcompares the starters without their positions. The teamsheet itself is fed by the lineup ingest, not by this API. -
v2 commentary statistics leave out players with nothing counted.
/fixtures/commentary-statisticsand/fixtures/{id}/commentary-statisticsdrop a player whose every counter is zero; v1/api/event/extra-stats-batchlists him with zeros (19 lines against 16 for Manchester United v Manchester City). Expo'sfetchExtraStatsBatchkeys its result by every fixture it asked for, so a missing line reads as no commentary figures for that player. Casesfixtures-lists-commentary-*require every v2 line to be a v1 line (v2-subset). -
Live-score status is the fixture's status. v1
/api/event/live-scoresreports the lastlive_event_statisticssnapshot'smatch_status, which stays "2nd half" after full time (18 of 22 in-play rows on 25 September were finished fixtures); v2/fixtures/live-scoresprefersevents.status(finished,live). The only v1 caller left isapps/web's homepage, which reads the scores and takes the status from the fixture row, so nothing renders the v1 string. Casefixtures-lists-live-scores-statuschecks that both sides give every fixture a status;fixtures-lists-live-scores-todaycompares the scores. -
v2 player odds name the player, not the book's label. v2
/fixtures/{id}/player-oddsreturnsplayers.name("Benjamin Šeško"); v1'splayerOddsMapcarries the label the book used ("Benjamin Sesko"). The prices are the same: for Manchester United v Manchester City and Fulham v Manchester United every (player, line, side, book, price) v1 quotes is in v2, apart from v1 listing a book's line-less assist price and its 0.5 price as two entries where v2 keeps the lower one. Casesfixtures-detail-player-odds-*compare how many squad players are priced. -
World Cup card boards are competition-scoped when a competition is named. v1
/api/world-cup/player-cards,/team-cardsand/refereesjudge a hard-coded 2026 World Cup roster on each player's last 50 national-team (or club) appearances, each team's last 30 matches anywhere and each official's career. v2/rankings/{player,team,referee}-cardswith acompetitionIdrank the competition's own fixtures, so for the World Cup the players (73 of 100), the team figures and the officials (52 against 42) differ. A request that names no competition — which is what app-router's World Cup card hub sends, withscope=national|club|career|international— is answered with the v1 World Cup board and payload. Casestools-{player,team,referee}-cards-world-cupcompare statuses (teams: the same 48);tools-*-cards-world-cup-board-*compare the competition-less request with v1 exactly. -
A v2 price-outlier page holds at most 100 rows. v1
/api/props/outliersserves up to 1,000 rows a request (limit); v2/rankings/price-outlierspages at 100, the ceiling of the v2 pagination contract (/docs/api/v2/pagination-audit-and-rollout-plan), and a caller wanting the whole board reads the pages. The rows and their order are the same: casetools-price-outliers-nations-league-all-pagesjoins ten v2 pages and compares them with one v1 request. -
v1 player trends read at most 2,000 props.
/api/props/player-trendsreads the 2,000 screener rows with the best stored L5 rate, as the production route does (.limit(2000)), so a competition-wide board is whatever survives that cut: 919 trends for the Nations League where v2/rankings/player-prop-trendspages all 3,693. Fixture and player scopes stay under the cap and match row for row. Casestools-player-prop-trends-*. -
An empty favourites list is a v1 404.
/api/user/{id}/favoriteand/favorite-playersanswer404 {"message": "No favorite … found"}for an empty list, as the production routes do; v2/me/favorite-teamsand/me/favorite-playersanswer200 {"data": []}. Casesaccount-favorite-{teams,players}-{paid,free}(expectStatus; the test accounts own no favourites). -
Listing bankrolls creates none on v2. v1
GET /api/tracker/bankrollsinserts a default "Units" bankroll for a caller who has none; v2/me/bankrollshas no side effect and answers[]. No first-party client reads/me/bankrolls: app-router reads the/me/bankrolls-viewbridge, which keeps v1's behaviour, and its tracker copes with an empty list. On the read-only test database v1's insert fails, so casesaccount-bankrolls-{paid,free}expect500/200. -
An empty lineup-projection board has no pages on v2. v1
/api/lineups/playersreportstotalPages: 1for zero rows, as the production route does (Math.max(1, …)); v2/lineup-projectionsreports0, the rule every v2 page-number collection follows (/docs/api/v2/pagination-audit-and-rollout-plan). Casefixtures-lists-lineup-projections-gamesleavestotalPagesout, since a games filter empties once its fixtures kick off; the non-empty cases compare it.
Settled: team and player season lists
v1 and v2 used to answer "which seasons" with two different questions. The decision was v1's: every season of every competition phase the team (or any of the player's teams, and his current club) has a fixture in, the seasons it spent elsewhere included.
- v2
/teams/{id}/seasonslists the same (competition, season) pairs as v1/api/team/{id}/tournaments-and-seasons;fixturesandfinishedcount the team's own fixtures, so a season it took no part in reports0. - v2
/players/{id}/seasonslists the same pairs as v1/api/player/{id}/tournaments-and-seasons;playedmarks the seasons with the player's own aggregate or fixture rows. - Expo reads both lists page by page, since a long career can run past one 100-row page (Haaland's list is 96 rows).
Cases teams-seasons-* and players-seasons-* compare the lists exactly.
Fixed in v1: /api/manager and /api/country/{slug}
Two production quirks the port had kept are fixed, by decision, so v1 and v2
agree. /api/manager lists (and POST inserts) managers rather than players,
and /api/country/{slug} reads its path segment rather than ?slug=. No
first-party client calls either. Cases content-managers-list,
content-country-spain-path-only and content-country-england-query.
Parity stack
Running the legacy Next.js API and the Go port side by side against one seeded database, to find out what the port changed.
Go against the Next.js routes
Both stacks on one box and one database, loaded from 1 to 2,048 concurrent connections until they stopped answering. Go serves 39× the requests on work that touches no database, and exactly the same number on work that does.