@v4/model
The Contentful content model as code: migrations, the vendored schema, generated types, curated fragments, transforms and the dictionary.
Overview
Implemented — packages/model (@v4/model) is where the Euro League site's Contentful
content model lives, Doctrine-style: the model is code, changes are migrations, and CI proves
every change before any environment mutates. Editors' changes made in the Contentful web app are
drift to restate in a migration, never the source of truth.
Space of record: 12gd2cvfgb6b ("Euro League"), development environment develop. The model
scripts resolve CONTENTFUL_SPACE_ID from the environment or the root .env and refuse to run
without it — they never fall back to another space.
domains.json owns 84 content types across 12 domain entries. It is the ownership map this
repo inherited with the package, so it still names domains the site did not take (sport-common,
football, gamification, retail, hospitality, ads, app, and quarantine, which never
composes). This site composes content, video and basketball on top of the core baseline
(composition.json). Every migration this project owns lives in migrations/club/ (below); the
domain directories (core/, content/, video/) belong to the template and change only through
/sync-upstream pull requests — see Take template updates.
Every content type, with its fields and relationships, is catalogued in the Content models reference — grouped by owning domain, with a field table (type, required, links-to) per type. See also Concepts › Content model for how the model-as-code spine works.
The spine
migrations/<domain>/*.cjs (DOMAIN-SLICED — the composition's domains in canonical
order, then migrations/club/*.cjs; ownership map in
domains.json, this environment's bolt-ons in composition.json)
→ vendored schema.graphql (introspected, deterministic SDL)
→ src/__generated__/ (graphql-codegen client-preset; committed, drift-gated)
→ src/<domain>/fragments/*.graphql (curated per surface — pruned, never select-all;
subpath exports ./core, ./content,
./video, ./basketball, ./dictionary)
→ src/<domain>/transforms/ (XFieldsFragment → component props; rich-text normaliser)
→ apps (import ONLY from @v4/model, never Contentful SDKs)
Subpath exports
| Subpath | Source | Holds |
|---|---|---|
@v4/model |
src/index.ts |
Generated GraphQL types, graphql(), createModelClient, the core and content surfaces re-exported, webhook payload guards (parseWebhookPayload), the synchronous dictionary (createDictionary, DICTIONARY_KEYS) |
@v4/model/core |
src/core/ |
Shared fragments and transforms (links, images, error page), localePath / sitePath, the sections snapshot and SPORT_AREAS (sections.ts), sectionCapabilities, competitionMarker, date formatting |
@v4/model/content |
src/content/ |
The content domain's fragments and transforms |
@v4/model/video |
src/video/ |
VideoLandingSettings and VideoCategoryCard fragments, the category-card transform |
@v4/model/basketball |
src/basketball/ |
Team, player, venue, hero-slide and season-sponsor fragments and the transforms the Competition Engine merge reads |
@v4/model/dictionary |
src/dictionary/runtime.ts |
getDictionary / configureDictionary (server-only) |
package.json also still declares ./football (pointing at dist/football/), but src/football/
was never part of this repo, so the subpath resolves to nothing. Do not import it.
Other areas under src/: preview/ (the preview environment), webhooks/ (the payload schema
and the revalidate webhook), and __generated__/. The Contentful app registry lives beside
the source in apps/ (registry.json, apps.lock.json, external-widgets.json); see
.claude/rules/model/contentful-apps.md. The apps' source is not in this repo: it is built and
released from the template, and this repo declares and installs them (see
@v4/contentful-contracts).
Migrations in this repo
| Directory | Holds |
|---|---|
migrations/core/ |
0001-baseline-core.cjs (generated — never hand-edited), 0002, 0003 |
migrations/content/ |
0001-baseline-content.cjs plus 0001–0007 |
migrations/video/ |
0000-baseline-video.cjs plus 0001–0005 (videoPlaylist, videoCollection, videoRail, videoLandingPage, siteSettings.defaultVideoLandingPage, field hygiene) |
migrations/club/ |
Every migration this project owns — 61 numbered files from 0001-add-scoreplay-image-field.cjs to 0063-season-sponsors.cjs, plus text-tags-planner.cjs (a shared helper, not a migration) |
The basketball domain ships no template migrations: its types (competitions, teams, players,
venues, games, season sponsors) are created by club migrations (0035-competitions.cjs,
0038-basketball-types.cjs and later).
The rules for adding to these directories are in
Migrations in this repo: every new
migration goes in club/, never a domain directory (a file of ours in content/ collides with the
next template migration of the same number when /sync-upstream brings it in); a generated baseline may share its number and always
runs first (scripts/migration-order.mjs) — which is why content/0001-baseline-content.cjs sits
beside content/0001-news-listing-content-blocks-limit.cjs; and a moved migration is declared in
migrations/renamed.json (five entries here, e.g. club/0034-competitions.cjs →
club/0035-competitions.cjs), so the runner reads the old ledger id as the new one rather than
running the file again.
Video's data reads come from the platform API via @v4/media; src/video/ reads only what the
platform has no endpoint for — the landing-settings pointer and the landing's category block.
The shape deliberately mirrors the proven @v4/tokens pipeline: vendored source → committed
generated artefacts → CI drift gate.
Dictionary keys
src/dictionary/keys.ts owns the localised-copy key vocabulary (seeded from 766
entries on 1 September; about 1,480 keys now) with default en-GB copy; Contentful owns the labels
editors maintain per locale. The generated maps cover en-GB, en-US, fr-FR and it-IT, but
this site runs one locale, en-US (club.config.ts), and a seeded en-US label beats the
vocabulary default — so the copy on the site can differ from keys.ts with nothing wrong.
The package's part is the vocabulary, the generated maps and the two ways to read them:
await getDictionary(locale) from @v4/model/dictionary (a cached, tag-revalidated read of the
live labels) and the synchronous createDictionary(locale) snapshot from the root. Both are typed
against keys.ts, interpolation included, so a missing key or a missing parameter is a type
error, never a runtime miss. sync-dictionary pulls the labels into typed per-locale maps
(committed, but NOT drift-gated — a label is editorial copy Contentful owns), push-dictionary
seeds entries for new keys without ever touching existing labels, and
sync-dictionary promote --from=<env> --to=<env> moves labels between environments (protected
targets refused outside CI); locales are added via scripts/add-locale.mjs — both walked by the
/model-dictionary and /model-locale skills. The pull reports placeholder drift — a
translation whose params differ from the default copy's (four live cases found on day one).
How the dictionary works is in Content model;
adding, syncing and consuming a key — typed interpolation, t.scope, the server-first pattern —
is the Dictionary keys guide.
Commands
pnpm --filter @v4/model build # tsc → dist/ (the app consumes dist)
pnpm --filter @v4/model sync-schema # introspect develop → vendor schema.graphql
pnpm --filter @v4/model generate # graphql-codegen → src/__generated__
pnpm --filter @v4/model drift # sync + generate + git diff (the local pre-push check)
pnpm --filter @v4/model generate-baseline # regenerate core/0001 from baseline/
pnpm --filter @v4/model sync-dictionary # pull labels → src/__generated__/dictionary
pnpm --filter @v4/model push-dictionary # seed entries for new keys (never touches labels)
pnpm --filter @v4/model reset-dictionary
pnpm --filter @v4/model sync-webhooks # the space's revalidate webhooks
pnpm --filter @v4/model sync-preview-urls # the space's preview URLs
pnpm --filter @v4/model lint-apps # the Contentful app registry, offline
pnpm --filter @v4/model sync-apps -- --env=<env> [--apply|--check]
node packages/model/scripts/migrate.mjs --env=<env> [--dry|--assume-baseline] [--skip-apps]
node packages/model/scripts/lint-migrations.mjs
pnpm --filter @v4/model seed -- --env=<env> [--only=<domains>] [--dry] # /model-seed: demo content
drift also regenerates and diffs the codegen of @v4/web, @v4/content-app and
@v4/video-app; @v4/basketball-app is not in it.
One-off and data scripts in scripts/: import-articles.mjs (foreign CMS export → seed
fixtures), migrate-text-tags.mjs (the data step for the text-tags retirement),
merge-players-into-profile.mjs, delete-old-players.mjs, backfill-article-competition.mjs,
mux-catalogue.mjs. release-apps.mjs (upload and activate the Contentful app bundles) came with
the package but is the template's release step: the apps' source and bundles are not in this repo.
Demo content (/model-seed). A bootstrapped space has the model but no
entries. scripts/seed.mjs populates an env from reviewed per-domain fixtures
(seed/<domain>/*.json) — assets + entries created and published in dependency
order over $ref/$refs/$asset, idempotent (match-by-slug update, never
duplicate), auto-scoped to the content types present in the target env, and
guarded against protected envs like the migrate runner.
Guard rails (all proven live)
The rules themselves — Actions validate while the deploy pipeline mutates, the runner's refusal of
the protected environments, expand/contract with its // contract: marker, and the
per-environment ledger — are explained in
Migrations & the deploy contract. Each was
proven live before it was relied on:
- The protected-environment guard (
master,production,staging,staging-release, refused outside CI) — verified by pointing the runner at master and watching it refuse. - The per-environment ledger (a
modelMigrationStatesingleton entry, written only by the runner) — proven end to end on a throwaway environment: baseline assumed → a test migration applied → ledger advanced → environment deleted. - One secret:
CONTENTFUL_CMA_TOKEN. The GraphQL API takes delivery tokens, sosync-schemaderives an environment-scoped delivery key via the CMAapi_keysendpoint.
A surface end to end: the article page
The article page shows the whole spine on one surface. It lives in @v4/content-app, mounted
through news/[...path] (content-app routes): the
ArticlePageFields fragment, the articlePageFromFragment transform (data-only — ReactNode
slots stay app-owned), the page query that spreads the fragment (queries live in the route
package, which has its own codegen against the vendored schema), and the typed client. The client
serialises documents with print(), not document.loc (which client-preset documents do not
carry), pinned by a test.
CI
The workflows are described in full in The model PR gate and The model deploy pipeline; in short:
- PR gate (
model-pr-gate.yml): on any PR touchingmigrations/**orbaseline/**, copy develop into a throwaway environment, scope the delivery key to it, lint + apply the pending migrations, introspect the migrated environment, regenerate, and fail on any diff against the committed artefacts. Always cleans up: environment deleted, key unscoped. - Drift gate (in
ci.ymlverify): re-introspect develop and regenerate on every PR — red the moment the live model and the committed artefacts disagree, whoever moved. - Apply (
model-apply-develop.yml): pushes to develop touchingmigrations/**apply to the develop environment only.
The deterministic SDL (hard-won)
The gate's first runs found that introspection output is not stable across environments with
an identical model: a long-lived environment serves a stale cached GraphQL schema (missing
newer content-type descriptions), and Contentful's internal directive definitions
(@enumMapper, @fieldResolver, …) track the backend version serving the environment, not the
model. sync-schema therefore vendors a deterministic SDL — lexicographicSortSchema, all
descriptions stripped via an AST visit, all directive definitions dropped. And one FIELD is
dropped: a content type whose id is another type's id plus Collection (videoCollection beside
video) claims the same Query field as that type's auto-generated entry collection, and Contentful
hands videoCollection to whichever of the two was published last — so the name flips between an
environment and a fresh copy of it whenever either type is republished (club/0027 on the PR gate, 13 Oct 2026).
sync-schema strips the ambiguous name in both forms, on Query and the linking-collections types;
nothing queries it. Type shape is the contract. Never re-add docstrings, directive definitions
or the ambiguous collection field to the vendored schema.
What the gates caught before any human did
- The first curated fragment's guessed field names — codegen refused them (the gate working on day one).
- A fresh environment is in no API key's scope and introspects an empty schema (0 types, no error) — the PR gate now scopes/unscopes the key around the ephemeral environment.
- The runner's own ledger bootstrap in develop grew the live schema — the drift gate went red within the hour, exactly as designed.
- Copy-vs-source docstring and directive nondeterminism (above).
Outstanding
Locale-variant fragment generation (a planned generator — until it lands, do not hand-write locale forks of a fragment), the Flightcontrol migrate step (needs dashboard access), production editor-role lockdown, and the rest of Phase 5 hardening (transform contract tests, fragment complexity budgets).
Scheduled drift detection SHIPPED on 28 Sep 2026 as model-drift-watch.yml: a read-only
comparison of the live develop space against packages/model every three hours, reporting
to Slack — so a content type created in the web app is named within the hour rather than
surfacing as an unexplained red build on somebody else's PR, which is how player was found.
The PR-time gate in ci.yml still runs, and now announces its own failures.
Full plan and deployment contract: .claude/reference/model/contentful-model-plan.md. Rules:
.claude/rules/model/contentful-model.md.