WI-030: Collections
WI-030: Collections
Problem
Everything so far is browsing. You can search 333 games, sign in, and have a user row created — but nothing is yours. This is the first item that puts your data behind the account.
shelf_entries has existed since WI-010 and has never been written to.
In scope
GET /me/shelf— your shelf, optionally filtered by status.PUT /me/shelf/:slug— put a game on a shelf, or move it.GET /me/shelf/:slug— one game’s state.- The query layer in
packages/db.
Out of scope
- Mobile UI. The routes come first so the screen has something real to call, exactly as WI-012 preceded WI-017.
- Session logging — WI-032. Ratings arrive with sessions, not here.
- Lists — WI-031. A curated list is a different shape from a shelf.
Three states, and why removal is not deletion
owned, wishlist, none.
Removing a game sets none rather than deleting the row, because the shelf’s rating and review are projections of sessions you actually played (ADR-0006). Deleting would discard the projection while the sessions behind it survive, and re-adding the game would look as though those opinions never happened.
So none rows are hidden from the default listing — they are history, not shelf membership — while remaining addressable with ?status=none.
What a client is not allowed to say
rating and review are absent from the request contract. Both are maintained by a database trigger and writing either directly raises (ADR-0021).
A request carrying them is not rejected — extra keys are simply ignored — because a 400 would imply the field is nearly valid. It is not a field a client has.
The trap this shares with WI-013
Setting a status is an upsert, not select-then-insert. Two taps in quick succession, or the app open on two devices, otherwise race and the loser violates shelf_entries_user_game_key. It is the same shape as the lazy user row, and it presents the same way: fine in testing, broken in use.
Definition of done
- A signed-out request is rejected; a new user’s shelf is empty.
- One user cannot see or affect another’s shelf — the point of the item.
- Adding, then moving, a game leaves one row.
- Six concurrent adds of the same game produce one row and six successes.
- Removal hides a game from the default listing but keeps it addressable.
- An invalid status, body, slug or filter is a 400 — never a 500.
- An unknown slug is a 404, resolved server-side rather than trusting a client id.
- A client-supplied
ratingorreviewis ignored, not honoured. - Each check proven able to fail.
- Gate green.
Verification
pnpm --filter @tabletop/api testpnpm gateNotes
The slug is resolved to a game id server-side. Accepting an id from the client would make a foreign key violation the error message, which is worse than a 404 and leaks that the id space is guessable.
packages/db gained no zod dependency for this. The three statuses are a constant there and the request schema is built from it in apps/api, so a status added to the schema cannot be silently rejected at the edge.