WI-010: Domain schema
WI-010: Domain schema
Reviewed. All four design questions were resolved at spec review; question 1 produced ADR-0021. Questions 2–4 were settled on the recommendations recorded below. Ready to implement.
Problem
packages/db has a placeholder table. Everything in M1–M4 — collection management, lists, session logging, the feed, discovery — builds on the real schema, and two parts of it are expensive to change later:
- The Player/User split.
docs/architecture.md§3 calls retrofitting it “very painful”; adding it now “costs one nullable foreign key”. - Ratings on sessions, not shelves (ADR-0006). This is the difference between this product and a shelf-first tracker, and it cannot be bolted on — Goodreads is the cautionary example.
In scope
Tables, with the constraints each ADR requires:
| Table | Notes |
|---|---|
users | UUID PK. clerk_user_id as an external reference only — nothing foreign-keys to it (ADR-0005). Created lazily on first authenticated request. |
games | Catalogue entry. Array columns for mechanics/tags, JSONB for cached source payloads (ADR-0003). |
game_field_provenance | Per-field source, licence, confidence (ADR-0016, ADR-0017). Per-field, not per-record: one row legitimately mixes CC0 and CC BY-SA. |
players | Not users. Optional user_id, supports claim-and-merge. Minimal PII — display name only until claimed (security-and-secrets.md). |
shelf_entries | Standing relationship: owned/wishlist, current rating, review. Rating is a projection, never written directly. |
sessions | One play. Everything nullable except game_id and played_at (ADR-0007). Carries its own rating. |
session_participants | Join to players. Must tolerate a null user_id throughout. |
lists / list_entries | Curated lists, ordered. |
Plus: FTS (tsvector) and pg_trgm indexes on game titles for typo-tolerant search, and the migration generated via drizzle-kit generate.
Out of scope
followsand the feed — WI-040.- Zod schemas via
drizzle-zod— WI-011. - Any API route, query layer or repository — WI-012 onward.
- The catalogue seeder’s own tables — WI-025.
- Backfilling real game data — WI-020.
Design decisions (resolved at spec review)
1. How is the shelf rating projection maintained? → Database trigger. Decided at review; recorded as ADR-0021.
Consequence that lands on this work item: Drizzle does not model triggers, so it goes in a hand-written custom migration and is invisible to pnpm --filter db check — a dropped trigger would not register as drift. The invariant tests below are what actually guard it. The trigger must also handle deletes, re-projecting to the next most recent session or to null.
2. Does a user deletion anonymise or cascade? → Anonymise. security-and-secrets.md says deleting a user must not orphan sessions other people participated in — anonymise the participant, keep the session. So ON DELETE SET NULL on players.user_id plus a tombstone on users, not a cascade. This pairs with decision 3: the player row is what survives, carrying the session history with it.
3. What does claim-and-merge actually do to history? → The player row survives. When a player is claimed by a user, the player row persists with user_id set, rather than session rows being rewritten to point at the user. Past sessions stay attributed and nothing is destroyed — rewriting history is irreversible and would damage other people’s session records.
4. Is games.id our UUID, or a natural key from the source? → Our own UUID. ADR-0016 has multiple catalogue sources with their own identifiers, and ADR-0010 requires them to be swappable. Source identifiers become separate nullable columns; otherwise the CatalogueSource seam leaks into every foreign key in the schema.
Definition of done
Behavioural lines each need a test (testing.md); the four invariant tests below are named there as never-weaken tests.
- All tables exist with the constraints above; migration generated by
drizzle-kit generate, not hand-written. -
pnpm --filter db checkpasses (drift + journal integrity). - Invariant 1: a second session rating updates the shelf projection and leaves the first session’s rating intact.
- Invariant 2: a
session_participantwhose player has a nulluser_idround-trips through every participant query. - Invariant 3:
INSERTintosessionswith onlygame_idandplayed_atsucceeds. - Invariant 4: schema assertion — no table foreign-keys to
clerk_user_id. - The projection is maintained by a trigger; writing
shelf_entries.ratingdirectly raises an error rather than silently no-op’ing, so a caller’s bug surfaces. - Deleting the most recent session re-projects to the next most recent, or to null if none remain — with a test. A trigger handling only inserts leaves a stale rating.
- A test asserts the trigger’s behaviour, not its existence —
check-schema.mjscannot see triggers (ADR-0021). - Deleting a user leaves co-participants’ sessions intact — with a test.
- Trigram search returns a match for a misspelt game title.
- FTS returns a match on a title word.
- Seed data covers each table; seeding twice is idempotent.
- The placeholder
_pipeline_checktable and its migration are removed.
Verification
pnpm --filter db db:uppnpm --filter db migratepnpm --filter db seed && pnpm --filter db seed # idempotentpnpm --filter db checkpnpm gateThen, for the invariants specifically:
pnpm --filter db test -t "invariant"Notes
This spec deliberately does not include column-level DDL. The four open questions change the shape of at least three tables, and writing the columns first would bury those decisions in a diff rather than surfacing them for review. That is the point of needs:spec-review.
The _pipeline_check table from WI-006 exists only to prove the harness; removing it here is part of the work, not an afterthought.