Skip to content

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 the games row. No id, 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/catalogue builds, lints and typechecks in the workspace.
  • CatalogueGame carries 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 ./conformance entry, 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 (not undefined, not a throw); search is case- and accent-insensitive; limit is honoured exactly; an empty query returns nothing rather than everything.
  • FixtureCatalogueSource passes 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_key will otherwise fail at seed time rather than at test time.
  • toNewGame() output satisfies newGameSchema from packages/shared for 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

Terminal window
pnpm --filter @tabletop/catalogue test
pnpm --filter @tabletop/db test
pnpm gate

Notes

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.