@v4/model

The Contentful content model as code: migrations, the vendored schema, generated types, curated fragments, transforms and the dictionary.

2026-11-07Status: Implemented

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 modelMigrationState singleton 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, so sync-schema derives an environment-scoped delivery key via the CMA api_keys endpoint.

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 touching migrations/** or baseline/**, 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.yml verify): 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 touching migrations/** 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

  1. The first curated fragment's guessed field names — codegen refused them (the gate working on day one).
  2. 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.
  3. The runner's own ledger bootstrap in develop grew the live schema — the drift gate went red within the hour, exactly as designed.
  4. 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.