Skip to content

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 rating or review is ignored, not honoured.
  • Each check proven able to fail.
  • Gate green.

Verification

Terminal window
pnpm --filter @tabletop/api test
pnpm gate

Notes

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.