Our practice sessions have always saved a result: a score, a percentile, some metrics, a timestamp pair. Last week they started saving something extra, so a report can show you which items you got wrong rather than only how you did overall.
The migration is one line:
ALTER TABLE "game_sessions" ADD COLUMN "responses" jsonb;
Nullable, no default, no backfill. Every interesting decision in the feature follows from the fact that this column is the optional part of a row whose other columns are not optional. A session with no responses is a complete session. A session with no score is a bug.
So the question that shaped the code was not "how do I store this", it was "what happens when this specific thing goes wrong", asked three times.
At the write: an optional field that cannot fail the request
The session POST is a client payload. The new field is validated like everything else, except for the last two calls in the chain:
// The answer review's saved responses. A payload that fails validation is
// dropped rather than failing the request: losing the review is far better
// than losing the score.
responses: responsesSchema.optional().catch(undefined),
.catch(undefined) is the whole design decision in one word. Without it, a payload our schema does not recognise rejects the request, and a candidate who has just finished a timed sitting loses their result because a field we invented last week did not parse. With it, the row is written, the score and percentile are correct, and the only casualty is a review nobody has seen yet.
This is not an argument for .catch() everywhere. It is an argument for knowing which field you are looking at. On the score it would be indefensible; on an accessory field it is the only responsible option.
The validation itself is a discriminated union, and the only thing worth saying about it in public is that every array in it has a cap and every string in it has a maximum length. The column is open-ended storage filled from a client, and an open-ended jsonb column is how a table quietly becomes expensive to read. Bounds mean the worst case is a known number of kilobytes rather than whatever the request body happened to contain.
At the read: the review is derived, and the derivation is allowed to throw
Nothing stores the review. The column holds what the candidate did, and the review is assembled on request by pairing that against the material the session was played from:
let review: AnswerReview | null = null;
try {
review = await buildAnswerReview(session.game_id, session.responses);
} catch (reviewError) {
logError(`Answer review failed for session ${session.id}`, reviewError);
}
Two reasons it is derived rather than stored. The small one is size: storing the review would duplicate content into every session row that used it. The real one is that the assembly happens on the server, so the report receives only the items this session actually answered, instead of the browser receiving everything and rendering a subset. A client that is sent more than it displays is a client that has been sent it.
And the same rule as the write applies, in the other direction: a failure here costs the review, not the report. The report page renders with review: null and shows the score, the percentile and the feedback it always showed. The error goes to our monitoring, where it is our problem rather than the candidate's.
The builder also returns null rather than throwing for the ordinary cases: a session with no responses, a game that has no review, or responses that no longer match the material they were recorded against. A nullable column with a builder that returns null means every session saved before last week renders correctly today, with no migration, no backfill and no version flag. That is most of the value of a nullable column and it is easy to give away by writing a renderer that assumes its input exists.
Three renderers, which is three chances to disagree
The review shows up in the browser straight after a session, on the server-rendered report page, and in the PDF export. That is one shape of data and three rendering stacks, and the only thing keeping them consistent is that they consume the same typed DTO rather than each reading the column.
We learned that the hard way on a different feature and it held up here: a follow-up commit added document cards to one kind of review, and the change was the DTO plus all three renderers in one go. When the DTO is the contract, "all three" is a visible list. When each surface reads storage directly, it is a surprise in the one you forgot.
The export got it too. A user's data download now includes the responses column, because it is their answers; it would be an odd thing to show someone in a report and withhold from their export.
See it
The review sits behind a sign-in, because it is a review of your own session, but the surfaces around it are public:
- cogniprep.app/games/civil-service is the provider hub whose tests this shipped for first. Its heading, "Practise All Eight", is also the scope of the work: eight tests, one review DTO.
-
cogniprep.app/pricing is where the gating lives. The result card after a session is only a link to the report for Premium accounts, which is why the card component takes an optional
hrefand renders itself unwrapped when there is not one. An optional prop deciding whether a card is a link is a tidier way to express an entitlement than two copies of a card. - cogniprep.app/tests covers the formats themselves, if you want to see what "which items you got wrong" has to mean for a test that is not multiple choice.
Top comments (0)