WI-020: The CatalogueSource seam and its fixture
WI-020: The CatalogueSource seam and its fixture
Problem
ADR-0010 put the catalogue behind an interface so that seven of nine build-order steps do not stall behind the BGG licensing email (WI-H01). The interface does not exist yet, so nothing downstream can be built against it.
Search (WI-021), the forge adapters (WI-025–WI-029) and the BGG sync worker (WI-024) all consume this seam. It is worth getting right once.
The real deliverable is the conformance suite
The interface is ten lines and nobody will get it wrong. What people will get wrong is the second implementation — the Wikidata adapter that returns an empty array where the fixture returns null, or that quietly drops provenance, or that treats limit as a suggestion.
So this item ships describeCatalogueSource(): an exported suite of behavioural tests that any implementation is run against. WI-024 and WI-026 do not write their own contract tests; they call this one. A contract that is only prose is a contract nobody checks.
In scope
packages/catalogue— a new workspace package.CatalogueSource, the interface:getBySourceId,search,all.CatalogueGame, the wire shape — deliberately not thegamesrow. Noid, no timestamps; those belong to the database, not to a source.- Per-field provenance on every record (ADR-0016). A source that cannot say where a field came from is not usable, so the schema rejects a populated field with no provenance rather than treating it as optional.
describeCatalogueSource(name, factory)— the conformance suite, exported from the package.FixtureCatalogueSource— 333 hand-authored games, passing that suite.toNewGame()/toProvenanceRows()— mappers into the Drizzle insert shapes.- Idempotent seeding of the fixture into the dev database, so WI-021 has something to search.
Out of scope
- Any network source. Wikidata is WI-026, Wikipedia WI-027, BGG WI-024.
- Search ranking or query parsing — WI-021. This package returns matches; it does not rank them well.
- The AI normalisation stage and its controlled vocabulary — WI-028 (ADR-0017).
The fixture data is hand-authored and not authoritative
333 real games, written from knowledge rather than harvested. Years, designers and player counts are believed correct; weights are approximations and mechanic tags are a pragmatic ad-hoc vocabulary, not ADR-0017’s controlled one.
Every field is therefore stamped source: "manual", licence: "n/a". This is not decoration:
- It is honest. A hand-typed year is a different kind of fact from a Wikidata year, and the provenance table is where that difference lives.
- It is detectable. Once WI-029 exports real data,
source = 'manual'is the query that finds everything still resting on a guess. - It carries no attribution obligation, unlike the CC BY-SA prose WI-027 will bring in.
Definition of done
-
packages/cataloguebuilds, lints and typechecks in the workspace. -
CatalogueGamecarries provenance for every populated field; a test asserts a record with a populated field and no provenance row for it is rejected. -
describeCatalogueSource()is exported from a dedicated./conformanceentry, so a future adapter can import it without the root entry dragging vitest into an application bundle. - The suite covers, at minimum: unknown id returns
null(notundefined, not a throw);searchis case- and accent-insensitive;limitis honoured exactly; an empty query returns nothing rather than everything. -
FixtureCatalogueSourcepasses the suite. - At least 180 games, each with title, year, designers, player counts and at least one mechanic.
- Slugs are unique — asserted, because
games_slug_keywill otherwise fail at seed time rather than at test time. -
toNewGame()output satisfiesnewGameSchemafrompackages/sharedfor every fixture record. - Seeding twice produces the same row count.
- Each new check proven able to fail by a seeded defect, per
../standards/testing.md. - Gate green.
Verification
pnpm --filter @tabletop/catalogue testpnpm --filter @tabletop/db testpnpm gateNotes
all() returns the full set rather than paginating. The fixture is bounded at a few hundred records and a network source will not implement all() meaningfully anyway — it exists for seeding, and the interface says so.
search here is a naive substring match on purpose. Postgres FTS and pg_trgm are the real search (WI-021); a source-level search that tried to be clever would be a second ranking implementation to keep in step with the first.