@v4/contentful-contracts

Versioned JSON field contracts read by the site, plus what this repo holds of the Contentful apps: their registry and installs.

2026-11-07

Overview

Some Contentful fields hold a JSON document written by one of our Contentful apps — the ticker's configuration, an image's geo rules, a download block's links. @v4/contentful-contracts holds one versioned contract per such field: the zod schema, the upgrade chain and the pure logic both sides need. The app uses it to validate and migrate what editors type; the site uses it to read the field without a hand-rolled guard.

Nothing in the package imports React, the app SDK or the DOM, which is what makes it usable from a Next server component and a Contentful iframe alike. It is raw TypeScript (no build), sideEffects: false, and its only dependency is zod.

What this repo has. The apps' source code (apps/contentful/*) and their kit (@v4/contentful-ui) live in the Publishing Hub template, not here; they are built and released from there. This repo has this package, because the site reads the fields the apps write; the registry of apps the Euro League space installs (packages/model/apps/registry.json, eleven apps), the generated lock of their definition ids (packages/model/apps/apps.lock.json), and sync-apps, which migrate.mjs runs to install and bind them in an environment. The rules are in .claude/rules/model/contentful-apps.md.

The core: @v4/contentful-contracts

const contract = defineFieldContract<T>({ name, version, schema, upgrades?, salvage? });
contract.migrate(value);            // → { value, status, fromVersion }
contract.sanitize(value, fallback); // → { value: T, status, valid }
contract.isValid(value);
contract.hasUpgradePath(from);
  • name is the registry slug of the app that writes the field; version is the shape this bundle writes, carried on the document as a top-level version.
  • upgrades[n] migrates a document at version n to n + 1.
  • VersionStatus says where a stored document sits:
    • current — the same version;
    • upgraded — older, upgraded in memory through the chain;
    • newer — written by a NEWER bundle; left byte-for-byte alone so an older reader never destroys keys it cannot see, and the editor shows a warning instead of editing blind;
    • unknown — no usable version (pre-versioning, hand-edited, or a gap in the chain); the schema decides whether it is usable.
  • An ARRAY document (the downloads list) has nowhere to carry a version and is always read as current.
  • salvageByShape(stored, fallback) is the default recovery when a migrated document still fails the schema: editor data is never thrown away — per key, the stored value is kept when it has the same JavaScript shape as the fallback's, and unknown keys are kept.

Other exports: the types FieldContract, FieldContractOptions, MigrationResult, SanitizeResult, VersionStatus.

The one rule: every schema that describes part of a persisted document must .passthrough(). sanitize() results are written back to the field, so a key the schema strips is a key permanently destroyed.

The field contracts

Each subpath is one field. The app slug is its entry in packages/model/apps/registry.json.

Subpath Field (content type → field) App Main exports
./ticker siteSettings.tickerConfig ticker tickerContract, readTickerConfig, selectTickerItems; the path matcher (normalisePath, matchesPattern, isPathExcluded, resolvePageGroups, messageTargetsPath); the schedule (isWithinSchedule, messageScheduleState, countLiveMessages); defaults (createDefaultTickerConfig, DEFAULT_PAGE_GROUPS, UNIVERSAL_GROUP_ID, MIN_TICKER_ITEMS)
./geo-rules image.geoRules, imageLink.geoRules image-geo-rules createGeoRulesContract (one per host type), geoRulesSchema, parseGeoRules, selectGeoRules, applyGeoToItem / applyGeoToGroup, isSponsorVisible, resolveAlternative, needsGeoResolution, describeGeoRules; countries (canonicalCountry, isKnownCountry, allCountries, searchCountries, DEPRECATED_COUNTRY_ALIASES)
./downloads downloadBlock.downloadLinks downloads downloadsContract, downloadLinkSchema, readDownloadLinks, the title and file-info length limits. A bare, versionless array
./social-channels socialChannels on footerBlock, profile, settingsAppSettings social-channels The ONE channel vocabulary (SOCIAL_CHANNELS, SOCIAL_CHANNEL_KEYS, SOCIAL_CHANNEL_ALIASES, canonicalChannel, channelName), socialChannelsContract, readSocialChannels
./player-profile-details profile.customDetails player-profile-details playerProfileDetailsContract, readPlayerProfileDetails, PLAYER_PROFILE_DETAILS_VERSION; honour logos stored as real Asset links (assetLink), row ids (createRowId); upgrades the older versionless shape
./social-embed socialEmbed.socialContent social-embed socialEmbedContract, socialEmbedFor, readSocialEmbed, isSupportedSocialUrl — an oEmbed-shaped document fetched through the site's oEmbed proxy (an installation parameter)
./paths contentPage.fullPath, pagesPackage.fullPath path-generator pathFieldsSchema, joinPath, normaliseSegment, slugFor, resolveChain (cycle and missing-parent detection), fullPathsForChain, planCascade, MAX_PATH_DEPTH (25). The publish-time cascade runs the same functions
./match-data fixture.matchData match-data Manual match state as the same normalised snapshot as provider data, plus a legacy Opta reader. It belongs to the football domain, which this site does not have, so it is inert here

Two of these exist because copies drifted: the ticker's matcher.fixtures.json is the conformance suite both sides run (written after two copies of the matcher had diverged), and ./social-channels replaced three diverging channel lists, one of which spelt snap-chat.

Consumers in this repo

Only @v4/model depends on it (packages/model/package.json), in its transforms:

File Reads
packages/model/src/core/transforms/site-chrome.ts readTickerConfig; parseGeoRules, selectGeoRules
packages/model/src/core/transforms/sponsor-geo.ts applyGeoToItem, canonicalCountry, isKnownCountry, needsGeoResolution — footer sponsor geo
packages/model/src/content/transforms/content-blocks.ts readSocialEmbed

Changing a contract is changing what an app writes and what the site reads at once: bump version, add the upgrades step, and keep .passthrough(). Because the apps are released from the template, a contract change belongs in the template and reaches this repo through /sync-upstream (see Take template updates).

Scripts and tests

test / test:watch (vitest), lint, check-types — no build. Suites: field-contract.test.ts and one per subpath.