Promotion & Relegation: Auto-Calculating the Bottom Three, Manually Entering the Promoted Three
Premier League Predictor: Astro
Chapter 9 · Promotion & Relegation: Auto-Calculating the Bottom Three, Manually Entering the Promoted Three
A real Premier League season ends with three of its 20 teams relegated and three new ones promoted up from the Championship. Because season_teams is scoped per season (Chapter 2), "next season's 20 teams" is really just a brand-new set of season_teams rows — this chapter is about deciding, and inserting, exactly which team_ids those rows should point at.
The Bottom Three Are Auto-Calculated — No Refactor Required
Both sibling courses reach this chapter needing to first refactor their own Chapter 7 league-table logic out of an inline route and into a separate, reusable function before they can call it a second time here. This course never has that step to do: Chapter 7's own getLeagueTable() was written into src/lib/leagueTable.ts from the very start, with a tip-box at the time explicitly naming this exact reuse as the reason. Finding the real bottom three is nothing more than calling the same function again, now with a different question in mind:
getLeagueTable() already returns its rows sorted ORDER BY points DESC, goal_difference DESC, goals_for DESC, the worst-performing team is always the very last element of the returned array, regardless of how many teams are actually in it. Array.prototype.slice(-3) reads as "the last three elements" and slice(0, -3) as "every element except the last three" — for a genuine 20-team table, that's positions 18-20 and positions 1-17 respectively, with no index arithmetic to get wrong by hand.
getRelegated() or getSurvivors() checks that a season is actually over, or that every one of its 20 teams played a full 38-gameweek schedule. The rollover route below adds exactly one real safeguard — checking the table has exactly 20 rows before proceeding — but doesn't verify anything about gameweeks played. Calling this mid-season would return a real, well-formed answer to the wrong question, not an error.
Rolling the Survivors Forward: An Ordinary Loop, Not a Bulk Insert
The Django & MySQL sibling carries its own 17 survivors forward with a real bulk_create() call, wrapped in transaction.atomic(). better-sqlite3 has no equivalent bulk-insert API at all — there's no method that accepts an array of rows and inserts them all in one call. The identical atomicity guarantee is reached the same way every other multi-write operation in this course has reached it since Chapter 3: wrap a plain, ordinary for loop of individual .run() calls in db.transaction().
INSERT statements run inside db.transaction() either all commit together or, if any one of them throws — including the deliberate 'ALREADY_ROLLED_OVER' guard above — none of them do. That's the identical all-or-nothing guarantee bulk_create() gives the Django sibling, reached through a genuinely different, plainer mechanism: there's no special bulk-insert code path here at all, just the same db.transaction(fn) wrapper this course has reused since Chapter 3, wrapped around an ordinary loop instead of a single statement.
season_teams row — rather than a single coarse check for "does the new season have any rows in it at all" — matters for a genuinely realistic reason: an admin might reasonably add one or more of the three promoted teams via Chapter 3's own route before ever calling rollover, simply because they already know which clubs came up. A blanket "any row exists yet" check would see those promoted-team rows and refuse to run the rollover at all, even though none of them collide with any of the 17 survivor team_ids the loop is actually about to insert. Checking each survivor specifically means the promoted teams' own already-added rows are simply irrelevant to this check — only a genuine duplicate among the 17 survivors themselves ever trips the guard.
db.transaction(), a real failure partway through — a crash, an unrelated thrown error — rolls back every insert that transaction attempted, leaving the new season's own survivor rows exactly as empty as before the call started. A retry after that kind of failure simply reinserts all 17 from scratch with no conflicts to detect; the per-team check above isn't a resume mechanism for a half-finished transaction, since the transaction wrapper already guarantees there's no such thing as "half-finished" to resume.
The Three Promoted Teams: Reusing Chapter 3's Own Route, Not Rebuilding It
A promoted club needs exactly the same handling Chapter 3 already built: check whether this club has a historical teams row already (a team coming back up after a previous relegation) or genuinely needs a new one, then add a season_teams row for the new season. That's precisely what POST /api/seasons/{seasonId}/teams already does — so rather than building a second, parallel route here that duplicates that exact reuse-or-create logic, the three promoted teams are simply added to the new season with three ordinary calls to the route Chapter 3 already wrote:
teams row as appropriate, and adds them to the new season. The rollover route above only automates the part that genuinely is mechanical — carrying forward the 17 teams this app's own data already knows survived.
Relegating a Team Deletes Nothing
It's worth being precise about what "relegated" actually means here, since nothing in this chapter runs a single DELETE statement. The old season's own season_teams rows — including the three relegated teams' own rows — are never touched by any of the code above. Relegation is really just an omission: the rollover loop only ever inserts rows for the 17 survivors, so a relegated team simply never gets a row in the new season's own season_teams at all. Its historical membership in every season it actually played in, including the one it just got relegated from, stays exactly as recorded, permanently — the same "teams persist forever, season_teams tracks membership per season" split Chapter 2 established from the very beginning of this course.
A Read-Only Relegation Preview
Deciding to run the rollover route is a real, one-way step for a season — a lightweight, read-only route lets an admin check who's actually getting relegated first, with no risk of accidentally triggering anything:
Where This Course Is Headed
Astro Islands and interactivity — replacing the hardcoded seasonId/gameweekId/fixtureId values every chapter since Chapter 4 has deliberately left in place with a real, working selector (Chapter 10); deployment (Chapter 11); and the capstone, mounting this app directly onto the real, live Astro site (Chapter 12).
Hands-On Exercises
Explain why this chapter needs no refactoring step to reuse Chapter 7's own league-table logic, and contrast that directly with what the FastAPI and Django sibling courses had to do at this exact point in their own Chapter 9s.
📄 View solutionExplain why the rollover route checks each survivor individually for an existing season_teams row inside the loop, rather than checking once, up front, whether the new season already has any season_teams rows at all before starting, and explain separately why a genuine partial-failure retry never needs this guard to "resume" anything.
📄 View solutionSet up a complete 20-team season with a full league table, call GET /api/seasons/{seasonId}/relegation-preview to confirm the correct bottom three, then call POST /api/seasons/{seasonId}/rollover against a newly created next season and confirm the new season's season_teams contains exactly the 17 survivors — followed by three calls to Chapter 3's own POST /api/seasons/{id}/teams route to add three promoted clubs, bringing the new season back up to 20.
📄 View solutionChapter 9 Quick Reference
- No refactor needed — getLeagueTable() has lived in its own module since Chapter 7 specifically for this reuse, unlike both sibling courses' own inline-query-to-function refactor at this point
- getRelegated()/getSurvivors() — table.slice(-3) and table.slice(0, -3) against the already-sorted league table; correct regardless of table size
- POST /api/seasons/{id}/rollover — verifies 20 teams exist, then inserts the 17 survivors' own season_teams rows for the new season, wrapped in db.transaction()
- Plain loop, not bulk_create() — better-sqlite3 has no bulk-insert API; the identical atomicity guarantee comes from wrapping an ordinary for loop in the same db.transaction() this course has used since Chapter 3
- Double-rollover guard checks per-team — a coarse "does the new season have any rows yet" check would wrongly block a legitimate rollover after promoted teams were already added via Chapter 3's own route; a genuine partial failure needs no such guard at all, since db.transaction() never leaves a half-finished result to resume
- Promoted teams reuse Chapter 3's own route — no second add-team endpoint is built; which clubs are promoted is real-world knowledge no query here could ever determine
- Relegation deletes nothing — the old season's own season_teams rows are untouched forever; a relegated team simply never gets a row in the new season
- GET /api/seasons/{id}/relegation-preview — a read-only check before the one-way rollover step is actually triggered
- Next chapter: Astro Islands & interactivity — a real gameweek/season selector, replacing every hardcoded ID since Chapter 4