Skip to content

feat(cms): gateway sync with Strapi v5 relation clearing#508

Merged
tataihono merged 44 commits intomainfrom
feat/cms-gateway-sync
Mar 20, 2026
Merged

feat(cms): gateway sync with Strapi v5 relation clearing#508
tataihono merged 44 commits intomainfrom
feat/cms-gateway-sync

Conversation

@tataihono
Copy link
Contributor

@tataihono tataihono commented Mar 19, 2026

Summary

  • Implements full gateway-to-CMS data sync for videos, variants, subtitles, languages, countries, and keywords using Apollo Client + codegen typed documents
  • Fixes stale manyToOne relation handling: uses { set: [] } (Strapi v5 relation clearing API) instead of undefined (preserves broken refs) or null (triggers Strapi bug)
  • Adds clearableRelation() helper in strapi-helpers.ts applied consistently across all 10 optional relation fields in 4 sync services
  • Performance: raw knex bulk insert for locale registration, skips plugin validation
  • Gates formatError stack traces behind NODE_ENV !== production
  • Removes test-relation debug endpoint

Key decisions

  • { set: [] } for relation clearing: In Strapi v5, undefined means "don't touch" (preserves stale refs), null triggers an internal bug. Only { set: [] } properly clears relations. Documented in docs/solutions/integration-issues/strapi-v5-manytone-relation-clearing.md.
  • Apollo Client for gateway queries: Uses codegen client preset with graphql() typed documents for type-safe gateway API calls.
  • Phased sync with selective scope: Sync runs in canonical order (languages → countries → keywords → videos → variants) with optional scope filtering.

Test plan

  • Triggered video-variants sync against live gateway — 4400+ variants processed with zero errors
  • TypeScript strict mode passes (tsc --noEmit)
  • Full sync run (all phases) on staging
  • Verify soft-delete pass correctly unpublishes removed records

🤖 Generated with Claude Code

…nd videos

Add content type schemas for Language, Country, Continent, CountryLanguage,
BibleBook, VideoOrigin, VideoEdition, MuxVideo, CloudflareR2, Keyword,
VideoVariant, VideoSubtitle, plus components for AudioPreview, BibleCitation,
CloudflareImage, and VariantDownload.

Extend existing Video content type with gateway fields (i18n, label enum,
variants, subtitles, etc.) while preserving existing title/slug/image fields.

Implement sync pipeline:
- Gateway GraphQL client with timeout, retry, and exponential backoff
- Language sync with dynamic Strapi i18n locale registration
- Country sync with continent deduplication and country-language junctions
- Video sync with limit/offset pagination (default page size 50)
- Upsert-by-gatewayId strategy with source tagging (gateway/manager)
- Soft-delete for removed records with circuit-breaker protection
- In-memory concurrency lock prevents overlapping syncs
- Configurable cron schedule via GATEWAY_SYNC_CRON env var
- Manual trigger via POST /api/gateway-sync/trigger
- Status endpoint via GET /api/gateway-sync/status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app
Copy link

railway-app bot commented Mar 19, 2026

🚅 Deployed to the forge-pr-508 environment in forge

Service Status Web Updated (UTC)
@forge/cms ✅ Success (View Logs) Web Mar 20, 2026 at 8:36 am
@forge/web ✅ Success (View Logs) Web Mar 20, 2026 at 8:35 am
@forge/manager ✅ Success (View Logs) Mar 20, 2026 at 8:34 am

tataihono and others added 2 commits March 19, 2026 08:22
…el gateway errors

- Use { connect: [{ documentId }] } format for manyToOne relations (Strapi v5 requirement)
- Use { set: [...] } for manyToMany keyword relations
- Catch page-level gateway errors in video pagination and stop gracefully instead of aborting
- Make gateway URL configurable via GATEWAY_SYNC_URL env var (default: root path /)
- Fix controller to use global strapi instance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 19, 2026 08:23 Destroyed
…odel

- Remove `primary: true` from title/description/snippet/studyQuestions/imageAlt
  queries to fetch all translations (not just primary)
- Remove `aspectRatio: banner` from images query to fetch all images
- Remove `parents` from query (children already shows the relationship)
- Remove `childrenCount` and `availableLanguages` from Video schema
  (derived from children array and variants respectively)
- Reduce DEFAULT_PAGE_SIZE from 50 to 10 to avoid gateway lag
- Add VideoStudyQuestion as a proper collection type with i18n, order field,
  and manyToOne relation to Video (replaces JSON array)
- Add aspectRatio, url, blurhash to images component data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 19, 2026 08:45 Destroyed
…video sync query

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 19, 2026 08:54 Destroyed
…(requires auth)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 19, 2026 08:59 Destroyed
Add oneToMany reverse relations so all content types can be navigated
upward from child to parent:

- VideoEdition: variants, subtitles
- VideoOrigin: videos
- MuxVideo: variants
- CloudflareR2: variantAssets, vttSubtitles, srtSubtitles
- Language: videoVariants, videoSubtitles, keywords, countryLanguages,
  videosAsPrimaryLanguage
- Continent: countries
- Country: countryLanguages

Add inversedBy on corresponding manyToOne relations in VideoVariant,
VideoSubtitle, CountryLanguage, Keyword, Video, and Country schemas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 19, 2026 09:02 Destroyed
…ook details

- Convert BibleCitation from component to collection type with gatewayId,
  source tagging, and manyToOne relations to BibleBook and Video
- Add oneToMany reverse relation on BibleBook (citations) so videos can be
  found by bible book
- Add BibleBooks query to video sync to fetch full details (name, osisId,
  alternateName, paratextAbbreviation, isNewTestament, order)
- Sync bible books as a first pass before video pagination
- Remove old video.bible-citation component (replaced by collection type)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 19, 2026 09:08 Destroyed
- Remove old `image` media field (leftover from pre-gateway schema)
- Add `publishedAt` to video sync data (was queried but not mapped)
- Add `origin` to gateway query and sync VideoOrigin records per video
- All Video schema fields now correspond to data actually pulled from
  the gateway

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 19, 2026 09:11 Destroyed
The primaryLanguage relation already points to the Language record.
The gateway ID is still used internally to resolve the relation during
sync but is no longer stored as a separate field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 19, 2026 09:14 Destroyed
…rror

Strapi throws 'Cannot use in operator to search for id in undefined'
when a component field is explicitly set to undefined. Use spread to
only include audioPreview when it exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 19, 2026 09:18 Destroyed
…nect

Strapi v5 document service accepts { documentId } directly for manyToOne
relations. The { connect: [{ documentId }] } syntax was causing
"relation does not exist" errors when the related record was just created,
particularly for cross-i18n relationships (e.g. non-i18n CountryLanguage
referencing i18n Country).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 19, 2026 09:21 Destroyed
Strapi v5 document service accepts bare documentId strings for
manyToOne relation fields (not objects). The { documentId } object
format was causing "relation does not exist" errors, particularly
for cross-i18n relationships.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 19, 2026 09:27 Destroyed
When a non-i18n type (CountryLanguage) references an i18n type (Country,
Language), Strapi v5 needs explicit locale on the relation object for
reliable resolution. Use { documentId, locale: "en" } format for all
relations targeting i18n content types.

Based on Strapi v5 source analysis of transform/relations/utils/i18n.mjs
and transform/relations/transform/default-locale.mjs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…c logs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 20, 2026 02:56 Destroyed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 20, 2026 02:58 Destroyed
- Add @graphql-codegen/cli with typescript and typescript-operations plugins
- Extract all sync queries into .graphql files under queries/
- Generate typed query results from gateway introspection
- Replace console.log with strapi logger in gateway client
- Add pnpm codegen script

Key type differences found vs manual types:
- videoEdition is non-nullable on subtitles and variants (was nullable)
- label, source, quality, aspectRatio are enums (were strings)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 20, 2026 04:33 Destroyed
- All queries tagged with /* GraphQL */ and named for codegen discovery
- Codegen now scans .ts files directly (documents: "**/*.ts")
- Replace all manual types with generated ones:
  - GatewayVideo = SyncVideosQuery["videos"][number]
  - GatewayLanguage = SyncLanguagesQuery["languages"][number]
  - GatewayCountry = SyncCountriesQuery["countries"][number]
  - GatewayVariant = SyncVideoVariantsQuery["videoVariants"][number]
  - GatewayKeyword = SyncKeywordsQuery["keywords"][number]
- Delete .graphql files (queries live inline in TS, single source of truth)
- Run pnpm codegen to regenerate after query changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 20, 2026 04:47 Destroyed
…nt preset codegen

- Replace hand-rolled fetch client with Apollo Client (no-cache, error handling)
- Codegen generates types from inline /* GraphQL */ tagged queries in .ts files
- Types in gql/gateway-types.ts, derived via SyncXQuery["field"][number] pattern
- Delete .graphql files and old generated/ directory
- All sync files use generated types, single source of truth

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 20, 2026 04:51 Destroyed
- Use @graphql-codegen/client-preset for fully typed query documents
- All queries use graphql() from generated gql/ — no manual type passing
- Apollo Client used directly via getGatewayClient().query({ query })
- Types derived via ResultOf<typeof QUERY>["field"][number]
- Schema sourced from JesusFilm/core main branch with handleAsSDL: true
- Deduped graphql package versions to fix schemaExtensions error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 20, 2026 05:12 Destroyed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 20, 2026 05:13 Destroyed
Pre-filter new locales before loop, log progress every 100 instead
of every single locale. Reduces log noise and slightly improves speed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 20, 2026 05:26 Destroyed
Use direct DB insert only — the plugin create() does validation and
lifecycle hooks that add ~500ms per locale. With 2000+ locales this
adds 15+ minutes. Direct DB insert is ~10x faster.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 20, 2026 05:38 Destroyed
Insert locales in batches of 500 via raw knex instead of one-at-a-time
through Strapi ORM. Should reduce 20+ minutes to seconds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 20, 2026 06:06 Destroyed
tataihono and others added 2 commits March 20, 2026 07:13
When syncing video variants and subtitles, unresolvable manyToOne
relations (videoEdition, muxVideo) were passed as `undefined`, which
in Strapi v5 means "don't touch" — preserving stale references to
deleted/unpublished editions. This caused publish-time validation
failures. Using `null` instead triggered an internal Strapi v5 bug.

Fix: use `{ set: [] }` (Strapi's relation clearing API) to explicitly
clear unresolvable relations. Also removes the test-relation debug
endpoint and improves formatError to include stack traces.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add centralized `clearableRelation()` helper to strapi-helpers.ts that
returns `{ set: [] }` for undefined docIds — applied to all 10 optional
relation fields across sync-videos, sync-video-variants, sync-countries,
and sync-keywords. Also gates formatError stack traces behind
NODE_ENV !== production and replaces all inline error formatting with
the formatError helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 20, 2026 07:22 Destroyed
@tataihono tataihono changed the title feat(cms): add gateway data sync pipeline feat(cms): gateway sync with Strapi v5 relation clearing Mar 20, 2026
Remove unused type aliases (GatewayCountry, GatewayKeyword) and their
imports, drop unused catch variable in sync-languages batch fallback,
and regenerate packages/graphql graphql-env.d.ts to match updated CMS
schema. Also updates plan with completed phase checkboxes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 20, 2026 07:53 Destroyed
Video.image (Strapi media) was replaced by Video.images (repeatable
CloudflareImage component). Update all GraphQL fragments and component
code to use images[0].url instead of image.url. Remove the unused
raw-parse watchExperience query from packages/graphql — web uses its
own typed version and mobile has a local copy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@railway-app railway-app bot temporarily deployed to forge / forge-pr-508 March 20, 2026 08:31 Destroyed
@tataihono tataihono merged commit d1e7d3c into main Mar 20, 2026
26 checks passed
@tataihono tataihono deleted the feat/cms-gateway-sync branch March 20, 2026 08:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant