Predictions: Recording the User, Expert, Guest(s) & AI Predictions Per Fixture
Premier League Predictor: Astro
Chapter 5 · Predictions: Recording the User, Expert, Guest(s) & AI Predictions Per Fixture
Chapter 4 creates fixtures with home_score/away_score both still null. Before either of those gets filled in, four real sources each predict what they think will happen: the user, the BBC's expert, that gameweek's guest(s), and the BBC's own published AI prediction. This chapter builds the one table that records all four.
One Table, Four Sources
Three of the four sources — user, expert, AI — genuinely predict exactly once per fixture. The fourth, guest, is different by design: some weeks have one guest, some have several, and this app deliberately tracks every individual guest prediction rather than forcing them into one row before they've even been recorded.
Enum(PredictionSource) column type has no direct SQLite equivalent, since SQLite genuinely has no dedicated enum column type at all — every column is fundamentally text, integer, real, or blob. CHECK (source IN ('user', 'expert', 'guest', 'ai')) gives an equivalent real guarantee at the database level: an attempt to insert any other value throws a real SqliteError with code === 'SQLITE_CONSTRAINT_CHECK', exactly like the fixture chapter's own team-can't-play-itself rule.
A Partial Unique Index: Genuinely Supported by SQLite Too
A plain UNIQUE (fixture_id, source) would enforce "one prediction per source per fixture" — but it would apply to every source equally, including guest, breaking the whole point of allowing several distinctly-named guest predictions on the same fixture. What's actually needed is a unique rule that applies to three sources and deliberately doesn't apply to the fourth — a partial unique index:
CREATE UNIQUE INDEX ... ON table(columns) WHERE expr, enforcing uniqueness only among the rows that satisfy the condition. The SQL above is, in fact, close to a direct, verbatim port of the PostgreSQL version, not a workaround for a missing capability. It's a useful reminder that "feature X belongs to database Y" claims are worth checking against the other database's own real documentation before repeating them, rather than assuming a more familiar system's own feature list is the complete picture.
Recording (and Correcting) a Prediction: an Upsert
A person should be able to change their mind about a prediction right up until kickoff — the route below looks for an existing prediction before deciding whether to update it or create a new one:
SELECT that finds existing and the UPDATE/INSERT that follows it, there is no await anywhere — better-sqlite3's own calls are genuinely synchronous, so this entire find-then-write sequence runs to completion in one uninterrupted step, with no possibility of another request's own handler interleaving in the middle of it. That's a real, structural difference from an async database driver, where an awaited query genuinely yields control back to the event loop, letting a second concurrent request's own find-then-write race in before the first one finishes. No transaction wrapper is needed around this upsert for exactly that reason — not because upserts never need one in general, but because this specific stack's own synchronous calls already rule out the very race Chapter 3's own db.transaction() exists to guard against.
SqliteError, code === 'SQLITE_CONSTRAINT_UNIQUE', an unhelpful 500 unless caught. Checking for an existing row first, and updating it in place when one's found, is what actually lets someone correct a prediction before kickoff instead of just being told they can't submit again.
guest_name. Averaging multiple real scorelines (2-1 and 1-0 don't average into another valid scoreline) into the single "guest" figure the prediction league table eventually scores is a genuinely separate problem, deliberately left for Chapter 6, where results and scoring actually get built.
A Real Example Response
GET /api/fixtures/42/predictions for a fixture with two guests that week — the row from the database and the JSON response are already the same shape, with no reshaping step in between:
Recording Predictions From the Frontend
A compact page, reusable across all four sources — the guest form adds a name field, and as many guest rows as that week actually needs, with every interaction wired up via addEventListener exactly the way Chapter 4 established, not inline onclick attributes:
Every guest row gets its own submitPrediction('guest', guestName, home, away) call — each one a genuinely separate upsert, keyed by that specific guest's own name.
fixture.kickoff_time against the current time before accepting an upsert. That's an honest gap, not an oversight worth expanding this chapter to close; it's flagged here as a real candidate for a later refinement rather than pretended away.
Where This Course Is Headed
Entering real results — filling in the home_score/away_score this course's fixtures have carried as null since Chapter 4, and defining, at last, how a correct score and a correct result actually get calculated against every one of these four prediction sources, including how multiple guest predictions become one comparable figure (Chapter 6); the real league table (Chapter 7); the prediction league table, scoring every source this chapter records (Chapter 8); and promotion/relegation (Chapter 9).
Hands-On Exercises
Explain why a plain UNIQUE (fixture_id, source) index wouldn't work for the predictions table, what the WHERE source != 'guest' clause actually changes about which rows the unique rule applies to, and correct the common assumption that a conditional partial index like this is a PostgreSQL-only feature.
📄 View solutionExplain what would happen if the predictions route skipped the "find an existing row first" step and always ran a plain INSERT, for both a second user prediction on the same fixture and a second guest prediction from a guest who already predicted that fixture under the same name.
📄 View solutionRecord a user, an expert, an AI, and two separately-named guest predictions against a single real fixture, then call GET /api/fixtures/{id}/predictions and confirm all five rows come back with the correct source and guest_name values.
📄 View solutionChapter 5 Quick Reference
- predictions — one table, four sources (user/expert/guest/ai) validated by a CHECK constraint standing in for SQLite's missing ENUM type, keyed by fixture_id + source (+ guest_name for guests)
- Partial unique index — genuinely supported by SQLite since version 3.8.0 (2013), with syntax nearly identical to PostgreSQL's, correcting the sibling course's own "PostgreSQL-only" framing
- POST /api/fixtures/{id}/predictions — an upsert: finds an existing row for that source (and guest_name, if a guest) and updates it, or creates a new one
- No transaction needed here — better-sqlite3's synchronous calls mean the find-then-write sequence can't be interleaved by another request, unlike an async driver
- guest_name — required when source is guest, silently nulled for every other source
- Still open — no prediction deadline is enforced against kickoff_time yet
- Deferred to Chapter 6 — how multiple guest scorelines become the single "guest" figure the prediction league table actually scores
- Next chapter: Entering results and calculating correct score vs. correct result