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 / concept | Synced official entry (isCustom: false) | Custom entry (isCustom: true) |
|---|---|---|
Document _id | match-catalog-{statsperformId} | Sanity-generated |
API id | StatsPerform ID | catalogId (custom-{uuid}) |
| Match facts | From StatsPerform at runtime | Stored on the catalog entry |
hidden | Editorial; hides official match in lists | N/A (omit from list by not publishing) |
cta | Editorial primary action override | Same |
statsperformId | Required, validated; only synced field from StatsPerform | Must 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:
- Acquires a Redis lock (
jobs:match-catalog-sync:0). - Fetches men's and women's fixtures from StatsPerform for the current season (
getMatchSeasonevaluated per run, not at module load). - Loads existing synced catalog entries from Sanity.
- Resolves operations per match:
create(no catalog row yet) ornone. - Applies creates via
sanity-sdkmutationcreateSyncCatalogEntries.
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
| Surface | Change |
|---|---|
Web (formatMatchData) | Prefers primaryAction over ticketUrl; formats custom kickoff in local Rotterdam time |
| Native | getPrimaryMatchAction / isCustomMatch helpers on match cards |
| GraphQL | primaryAction, source on professional matches; custom venue short-circuit in resolvers |
| sanity-sdk | matchcenter/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;
hiddenandctaare never overwritten. - Unified consumer contract. Web and native both consume
primaryActionand distinguish custom vs official viasource/ 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
getMatchSeasonis not evaluated per job run, fixtures near season boundaries could be missed. Mitigated by computing defaults ingetDefaultMatchCatalogSyncJobOptions()on each job instantiation. - Pagination overflow when custom matches merge: merging custom rows after StatsPerform pagination can return more than
limititems. Mitigated byapplyPaginationLimitafterenrichMatchesList. - 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.
Related
apps/api/src/domains/professional/features/match-catalog-sync/— cron job, sync operations, repositoryapps/api/src/domains/professional/features/match-catalog/merge/— runtime merge, hide filter, primary actionsapps/api/src/domains/professional/features/matches/models/custom-match.ts— custom match domain modelapps/sanity/schemas/documents/match-center/match-catalog-entry.ts— Sanity schemaapps/sanity/components/match-catalog-list/— Studio browse UXpackages/sdks/sanity-sdk/src/matchcenter/catalog/— read pathpackages/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