Skip to content

ADR-007: Hybrid Sanity Match Catalog

Status

Accepted

Context

Match programs on the website, the native Match Center, and related GraphQL consumers all surface professional fixtures. StatsPerform remains the authoritative source for official match facts (teams, schedule, scores, status). Editorial control lived elsewhere:

  • Match Filter (matchFilter) was a single Sanity document holding an array of StatsPerform IDs to hide. Editors had to copy opaque 25-character IDs from external tools. There was no browse UX, no per-match metadata, and no way to attach editorial actions.
  • Custom fixtures (friendlies, cup previews, promotional kickoff events) could not be represented in match lists at all without inventing fake StatsPerform records.
  • Per-match CTAs (VIP tickets, sponsor links) were not configurable in the CMS; consumers fell back to generic ticket URLs.

At the start of each season the Match Filter is empty, which made a one-off migration from the legacy document unnecessary. The team still needed a durable model that scales across men's and women's teams, supports hiding and CTAs per match, and allows fully manual entries without duplicating StatsPerform as the runtime source of truth.

Requirements

  • Editors can hide official matches and override ticket/action links without copying StatsPerform IDs.
  • Editors can create custom matches that appear alongside official fixtures in chronological lists.
  • StatsPerform data stays live at request time for official matches (scores, status, postponements).
  • Sync must not overwrite editorial fields (hidden, cta) when new official catalog rows are created.
  • Men's and women's competitions are both supported.
  • Web, native, and GraphQL consumers receive a unified match shape (source, primaryAction, embedded venue for custom matches).

Options Considered

Keep Match Filter only

Extend the filter document with more fields. Rejected because:

  • ID copy-paste workflow does not scale and is error-prone.
  • A single global document cannot hold per-match CTAs or custom match bodies.
  • No path to custom fixtures.

Sanity as sole source of truth for all matches

Store every official fixture entirely in Sanity and stop reading StatsPerform at runtime. Rejected because:

  • Duplicates data the platform already fetches for Match Center, live scores, and notifications.
  • Requires continuous bidirectional sync and conflict resolution for fast-changing match state.
  • Increases risk of stale scores/status on high-traffic surfaces.

Hybrid catalog (chosen)

StatsPerform supplies official match facts at runtime. Sanity stores a catalog overlay: lightweight synced documents for official matches (StatsPerform ID only) plus fully manual custom entries. A scheduled job creates missing official catalog rows; editors own visibility and CTAs.

Decision

We introduce matchCatalogEntry — a single Sanity document type with two modes — and wire it through the API, sanity-sdk, Studio, web, and native apps.

Document model

Field / conceptSynced official entry (isCustom: false)Custom entry (isCustom: true)
Document _idmatch-catalog-{statsperformId}Sanity-generated
API idStatsPerform IDcatalogId (custom-{uuid})
Match factsFrom StatsPerform at runtimeStored on the catalog entry
hiddenEditorial; hides official match in listsN/A (omit from list by not publishing)
ctaEditorial primary action overrideSame
statsperformIdRequired, validated; only synced field from StatsPerformMust be empty

Official catalog entries store only statsperformId (plus isCustom: false and default hidden: false). Match facts for official entries come from StatsPerform at runtime on web, native, and API consumers. Custom entries continue to store full match details on the document.

Catalog sync job

A cron job (matchCatalogSyncJob) runs daily at 0 1 * * * (every 15 minutes in the test dataset). It:

  1. Acquires a Redis lock (jobs:match-catalog-sync:0).
  2. Fetches men's and women's fixtures from StatsPerform for the current season (getMatchSeason evaluated per run, not at module load).
  3. Loads existing synced catalog entries from Sanity.
  4. Resolves operations per match: create (no catalog row yet) or none.
  5. Applies creates via sanity-sdk mutation createSyncCatalogEntries.

Editorial fields are never included in sync creates. JOBS_MATCHES_SYNC_SANITY_MODE controls behaviour: default mutates Sanity; debug logs operations only.

Runtime merge in the API

Official matches are fetched from StatsPerform as before. The matches repository enriches them through mergeMatchList:

StatsPerform fixtures


applyHiddenFilter (catalog.hidden per statsperformId)


attachPrimaryActions (catalog.cta → primaryAction)

        ├──────────────────────────┐
        ▼                          ▼
official matches            custom matches from Sanity
        │                   (mapCustomCatalogEntries)
        └──────────┬───────────┘

        sortMatchesChronologically

        applyPaginationLimit (after merge)

Custom matches are modelled as CustomProfessionalMatch with source: "Custom". Official matches keep source: "StatsPerform". GraphQL exposes primaryAction and source; venue resolution skips StatsPerform lookup for custom matches and uses embedded venue data.

Kickoff times for custom matches use Amsterdam local time (ProfessionalMatchSchedule.fromAmsterdamLocalTime) so display is stable regardless of server timezone.

Match Filter retirement

The matchFilter schema remains in Studio with a deprecation notice. The API no longer reads matchFilter.matchIds. Hiding official matches is done exclusively via hidden on the corresponding catalog entry. No data migration script was added; the filter document is empty at season start.

Studio UX

Match Center structure gains a Match Catalog section:

  • Browse official matches — searchable list of upcoming synced entries; opens the catalog document for CTA/hidden editing.
  • Official matches / Custom matches — filtered document lists.
  • Match Filter (deprecated) — retained for reference only.

Client behaviour

SurfaceChange
Web (formatMatchData)Prefers primaryAction over ticketUrl; formats custom kickoff in local Rotterdam time
NativegetPrimaryMatchAction / isCustomMatch helpers on match cards
GraphQLprimaryAction, source on professional matches; custom venue short-circuit in resolvers
sanity-sdkmatchcenter/catalog queries and DTOs; matchcenter/catalog-sync mutations

Consequences

Positive

  • Editorial workflow without ID copy-paste. Browse synced official matches by StatsPerform ID; open one document to hide or set a CTA.
  • Custom fixtures in the same lists as official matches, sorted chronologically with correct pagination.
  • Clear separation of concerns. StatsPerform owns live official facts; Sanity owns editorial overlay and manual entries.
  • Non-destructive sync. Sync only creates missing official rows; hidden and cta are never overwritten.
  • Unified consumer contract. Web and native both consume primaryAction and distinguish custom vs official via source / ID prefix.

Negative

  • Two sources to reason about for official matches: runtime StatsPerform payload plus optional catalog row. Missing catalog row means no hide/CTA overlay but the match still appears.
  • Eventual consistency for Studio browse. New fixtures appear in the official catalog list after the next sync, not immediately when StatsPerform publishes them.
  • Custom matches require manual maintenance. Scores, status, and postponements are not synced from an external feed.
  • Larger repository path. List queries fetch catalog entries in parallel with StatsPerform and merge in memory before pagination.

Risks

  • Sync season window misconfiguration: if getMatchSeason is not evaluated per job run, fixtures near season boundaries could be missed. Mitigated by computing defaults in getDefaultMatchCatalogSyncJobOptions() on each job instantiation.
  • Pagination overflow when custom matches merge: merging custom rows after StatsPerform pagination can return more than limit items. Mitigated by applyPaginationLimit after enrichMatchesList.
  • Studio list shows IDs, not live fixture metadata. Official browse uses StatsPerform IDs; live match facts remain on consumer surfaces fed by StatsPerform.
  • Legacy Match Filter drift: deprecation banner still mentions legacy ID support while the API no longer reads them. Mitigated by updating Studio copy when the schema is removed.
  • apps/api/src/domains/professional/features/match-catalog-sync/ — cron job, sync operations, repository
  • apps/api/src/domains/professional/features/match-catalog/merge/ — runtime merge, hide filter, primary actions
  • apps/api/src/domains/professional/features/matches/models/custom-match.ts — custom match domain model
  • apps/sanity/schemas/documents/match-center/match-catalog-entry.ts — Sanity schema
  • apps/sanity/components/match-catalog-list/ — Studio browse UX
  • packages/sdks/sanity-sdk/src/matchcenter/catalog/ — read path
  • packages/sdks/sanity-sdk/src/matchcenter/catalog-sync/ — write path for sync job
  • ADR-006: Document Pathname Field — same pattern of stored computed fields plus async sync
  • Match center ADRs (legacy) — earlier Match Center integration decisions