feat(graphql,admin): dual-client codegen foundation (brief Unit 2 + Unit 3)#902
Merged
Conversation
- seed-easter: insert Easter experience into admin's local Postgres, translated block-for-block from apps/cms/src/bootstrap/seed-easter.ts to admin's BlockSchema. Local-dev parity with Strapi's Easter; UI/E2E fixture without depending on Strapi or the R3 dump pipeline. - run-experience-dump: workstation wrapper around the R3 experience-content-dump service. Bypasses the GraphQL trigger + workflow runtime for local dev. Mirrors run-embeds.ts pattern. Both are dev tooling alongside existing run-embeds, run-sync, pull-mapping. Neither ships to production runtime.
…t plan Brief and plan that this PR implements: - docs/brainstorms/2026-05-05-consumer-migration-implementer-brief-requirements.md Web-implementer's brief for the Strapi to admin consumer-side migration. Captures the 7-unit migration shape, dual-client decision, parity-driven per-route flag rollout, and (as of 2026-05-07) the single-owner shape after tatai handed full execution + decision authority over. - docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md Implementation plan covering brief Unit 2 (admin SDL emit + drift CI) and Unit 3 (packages/graphql multi-schema codegen + adminGraphql factory + type-isolation test). All five technical decisions resolved inline (factory naming, schema-source convention, scalar mappings, CI shape, Turbo wiring). Implementation lands as the subsequent commits on this branch.
- apps/admin/src/scripts/print-schema.ts: emits admin's Pothos schema to apps/admin/schema.graphql via printSchema(lexicographicSortSchema( builder.toSchema())). Strips Pothos plugin directives (e.g. scope-auth's @authScopes) post-print so gql.tada's tsconfig parser can consume the SDL — auth is enforced at the resolver, not declared in the SDL surface consumers see. - apps/admin/schema.graphql: initial committed SDL artifact, 865 lines. Mirrors apps/cms/schema.graphql's role: the contract handoff between admin's TypeScript schema (Pothos) and packages/graphql's typed client (gql.tada). - apps/admin/package.json: adds schema:print script. - turbo.json: adds schema:print task with outputs declaration. Output verified byte-identical across consecutive runs (deterministic). The committed SDL is consumed by packages/graphql's gql.tada codegen in U3; consumers never introspect admin's running server (production introspection is disabled via @envelop/disable-introspection). Per docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md (U2).
apps/cms/schema.graphql is already in .prettierignore for the same reason — Strapi's GraphQL plugin auto-emits canonical SDL, and prettier reformatting GraphQL docstrings (single-line to multi-line) breaks the re-generation byte-equality the schema:print drift CI check depends on. Apply the same exclusion to: - apps/admin/schema.graphql: emitted by schema:print - packages/graphql/src/admin-graphql-env.d.ts: will be emitted by gql.tada codegen in U3 (added preemptively to mirror the existing graphql-env.d.ts entry) Re-emitted apps/admin/schema.graphql to its canonical (un-prettied) shape so the drift CI check (next commit) reads zero.
Mirror the existing graphql-generate job (lines 78-95) for admin's SDL artifact. Job runs: pnpm turbo run schema:print --filter=@forge/admin git diff --exit-code apps/admin/schema.graphql Gated on @forge/admin appearing in the affected outputs from Turbo's --affected detection, so the job only fires on PRs that actually touch admin. Catches the case where a Pothos schema change is merged without regenerating apps/admin/schema.graphql, which would silently make consumer codegen wrong. Per docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md (U2).
Configure packages/graphql for two introspection targets via gql.tada's
multi-schema config shape — `schemas: [...]` array in tsconfig with
per-entry name, schema path, and tadaOutputLocation. Each schema is
emitted to its own .d.ts file with a structurally-distinct `name`
property in the introspection type, which is what gives downstream
factories compile-time type isolation (U5 verifies).
Changes:
- packages/graphql/tsconfig.json: switches the gql.tada plugin entry
from single `schema` to `schemas: [{ name: 'strapi', ... }, { name:
'admin', ... }]`.
- packages/graphql/package.json: adds @forge/admin: workspace:* to
devDependencies, mirroring the existing @forge/cms entry. Codegen
reads admin's committed SDL via the workspace-relative path; admin
itself is not a runtime dependency.
- packages/graphql/src/admin-graphql-env.d.ts: NEW. Generated admin
introspection types (43KB). Committed alongside the source change
per repo convention (graphql-env.d.ts is committed too).
- packages/graphql/src/graphql-env.d.ts: regenerated against the
upstream Strapi schema refresh that landed in main; behavior-
preserving for existing callsites.
- turbo.json: extends the existing `generate` task — adds
apps/admin/schema.graphql to inputs (alongside apps/cms's) and
src/admin-graphql-env.d.ts to outputs. NO dependsOn edge: matches
the existing Strapi pattern where the SDL author runs schema:print
manually and CI's admin-schema-drift job catches drift if they
forget. dependsOn would re-build admin's full Pothos schema on every
local generate run.
- pnpm-lock.yaml: registers the new workspace link for @forge/admin.
R4 verified: apps/web typecheck passes against the new dual-target
codegen output. The adminGraphql() factory itself lands in U4.
Per docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md
(U3).
- packages/graphql/src/admin.ts: new factory module mirroring graphql.ts.
Exports adminGraphql = initGraphQLTada<{ introspection }>() bound to
admin's introspection types. Re-exports gql.tada's FragmentOf /
ResultOf / VariablesOf as AdminFragmentOf / AdminResultOf /
AdminVariablesOf so call sites visually distinguish admin types from
Strapi types.
- packages/graphql/src/index.ts: re-exports both factories side-by-side.
Strapi side keeps bare names (graphql, ResultOf, ...); admin side
carries the Admin* prefix.
- packages/graphql/package.json: adds ./admin subpath export, parity
with the existing ./graphql subpath.
- packages/graphql/AGENTS.md, CLAUDE.md: rewritten to cover the
dual-client conventions — which factory to use when, the auth-posture
convention with the acknowledged compile-time-guard gap, the
multi-schema generation flow (Strapi + admin), the U5 type-isolation
test path as the AE1 enforcement mechanism, and the live-introspection
rationale (admin disables it via @envelop/disable-introspection).
The dual-client is temporary scaffolding. When Strapi is decommissioned
and all consumer routes have moved to admin, this package collapses
back to single-target admin (adminGraphql renames to graphql; the
Strapi factory + types + graphql-env.d.ts get deleted in one PR).
Per docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md
(U4).
Compile-time enforcement that mixing Strapi-typed and admin-typed GraphQL result values fails TypeScript compilation (the AE1 origin acceptance criterion). The test runs as part of pnpm --filter @forge/graphql typecheck — no runtime test runner needed; it's a pure type-level check via @ts-expect-error directives. Two negative cases assert cross-schema assignment fails; two positive cases assert same-schema assignment compiles clean. If a directive is misplaced — missing from a negative case OR present on a positive case — typecheck fails by design, which is what makes the test meaningful (not a vacuous tautology). Query selection (critical): the two negative cases use queries against schema-EXCLUSIVE root fields — bibleBook(documentId) on Strapi (admin has no bibleBook root query) and experienceBySlug(locale, slug) on admin (Strapi has no experienceBySlug root query). The resulting ResultOf<...> shapes have NO overlapping property names, so structural typing rejects the cross-assignment. An inline file-header rule warns future contributors against substituting structurally-overlapping queries — that would silently make the directives unused and drop the real guard. Mutation-tested locally: deleting the @ts-expect-error on the negative case re-surfaces a real 'Property X is missing in type Y' error, confirming the directive is gating a real check. Per docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md (U5).
Applied seven mechanical fixes from the multi-agent code review on this branch: - ci.yml: extend graphql-generate's git diff --exit-code to also check packages/graphql/src/admin-graphql-env.d.ts (review finding #1). Without this, an admin Pothos change that regenerates one .d.ts but not the other lands silently. - packages/graphql/src/admin.ts: re-export readFragment so the ./admin subpath surface matches ./graphql (review finding #3). readFragment is schema-agnostic in gql.tada — works for both factories. - apps/admin/src/scripts/print-schema.ts: anchor the output path to the script's own location via fileURLToPath(import.meta.url), not process.cwd() (review finding #5). Wrap writeFileSync in try/catch with structured stderr output and exit(1) on failure (review finding #7). cwd-based resolution silently misplaced schema.graphql when invoked outside pnpm/Turbo; an unhandled write error produced a confusing git-diff failure rather than a named cause. - turbo.json: declare explicit inputs on the schema:print task so it invalidates only on Pothos source / printer changes, not on every apps/admin file touch (review finding #8). Mirrors the existing generate task's inputs declaration. - apps/admin/package.json: wire run-experience-dump as a pnpm script alongside run-embeds, run-sync, etc. (review finding #12). The script was committed earlier without a discoverable invocation path. - CLAUDE.md (root): split the GraphQL Change Flow into separate Strapi and admin sub-flows (review finding #9). The single-flow version still documented only the Strapi path post-PR, so an agent reading root context wouldn't know schema:print exists. - apps/admin/AGENTS.md: add 'SDL emission for consumer codegen' and 'Local-dev scripts' sections so an agent scoped to the admin package can discover schema:print and the other tsx-script tools (review finding #9). Mirrors how packages/graphql/AGENTS.md was updated in the U4 commit. R4 verification: packages/graphql + apps/admin typecheck pass. schema:print output unchanged (drift-clean after script-relative path change, confirmed by repeated invocation).
…isions Three findings that needed engineering judgment, all from the multi-agent code review: - print-schema.ts (review finding #2): replace regex-based directive stripping with AST round-trip via graphql-js parse/visit/print. The regex had three concrete failure modes flagged across six reviewers: (a) [^)]* in @authScopes(...) substring strips DESCRIPTION text that legitimately contains the pattern, silently corrupting committed SDL; (b) only handled single-line directive declarations + a narrow two-line on continuation; (c) broke on nested parens in directive args. The AST visitor removes nodes by kind+name, structurally aware, with no formatting heuristics. Pothos directive set is a single POTHOS_DIRECTIVE_NAMES Set extended as new plugins land. apps/admin/schema.graphql regenerated against the new stripper — output is byte-deterministic across runs (verified twice). Output shrinks from 25419 to 25369 chars due to graphql-js print's canonical formatting (no semantic difference). - dual-client.types.ts (review finding #4 + #11): expand AE1 coverage and clarify what the test actually exercises. Adds positive + negative cross-assignment cases for AdminFragmentOf/FragmentOf and AdminVariablesOf/VariablesOf — the original test only covered ResultOf, but R1's 'both factories independently typed' invariant should hold across all three utility types. File header rewritten to be honest about the mechanism: structural distinctness via schema-exclusive fields, NOT nominal factory branding (the AdminResultOf / etc. aliases carry no nominal information beyond the underlying ResultOf; AdminResultOf<typeof STRAPI_QUERY> would compile cleanly today). Header also explicitly notes what the test does NOT exercise: factory-level query rejection (which gql.tada represents via an error variant of the document type and only surfaces at consumer call sites), and 'as' casts (which deliberately bypass the structural check; documented as an as-cast ban in AGENTS.md / CLAUDE.md with an ESLint rule as a follow-up). Mutation-tested: deleting any of the six @ts-expect-error directives in negative cases re-surfaces the underlying type error, confirming the directives gate real checks. Review finding #6 (eager imports of the full mutation graph at SDL emit time) is deferred — the architectural restructure of builder.ts to lazy-load services is out of scope for this fix pass. Current setup works because skipValidation: !!process.env.CI bypasses zod env validation in CI; documented as residual risk in the review report and surfaces if env validation tightens or local-dev friction increases.
Defers review finding #6 from the multi-agent code review on feat/dual-client-codegen-unit-3 to a tracked roadmap ticket. The finding flags that print-schema.ts triggers Prisma client construction + env validation at SDL emit time via the side-effect import chain through @/graphql/schema. Current behavior is correct because: - CI sets CI=true, which triggers skipValidation: !!process.env.CI in apps/admin/src/config/env.ts:154 (zod skips). - Local dev contributors keep a populated .env via pnpm fetch-secrets. Two latent risks the ticket captures: 1. Future side-effect imports propagate. A new service that validates a third-party API key on module load gets triggered every time schema:print runs in CI, including in PRs unrelated to that service. 2. The skipValidation bypass is brittle. If env-validation policy tightens, the admin-schema-drift CI job breaks until the decoupling lands. The ticket sketches two candidate strategies (dedicated build-schema module vs conditional builder construction) and locks the verification criteria, including byte-identical SDL output and a regression test that asserts no Prisma client construction at module load.
|
🚅 Deployed to the forge-pr-902 environment in forge
|
…a pattern Captures the reusable architectural pattern surfaced by this PR's work: how to bridge a Pothos-defined TypeScript schema in one app to gql.tada-typed consumer code in other apps via a committed SDL artifact, including the six sub-decisions and one compile-time test that compose the pattern. Sub-decisions documented: 1. Multi-schema gql.tada config (`schemas: [...]` array with named entries; `name` discriminator is load-bearing for type isolation). 2. Two factories, one package (graphql + adminGraphql side-by-side; subpath exports for narrow imports; readFragment shared as schema-agnostic). 3. Committed SDL artifact via Pothos printSchema (lexicographicSortSchema for determinism; .prettierignore exclusion to avoid drift fights; script anchored via fileURLToPath, not process.cwd). 4. AST-based directive stripping via graphql-js parse/visit/print, NOT regex (regex has three concrete failure modes: description corruption, multi-line directive declarations, nested parens). 5. Inputs-based Turbo wiring, NOT dependsOn (avoids re-running the full Pothos schema build on every local generate; relies on schema-author discipline + CI drift gate). 6. Drift CI job mirroring graphql-generate (gated on Turbo affected detection; covers both directions: stale committed SDL AND missing regen of consumer .d.ts files). 7. Compile-time AE1 type-isolation test using @ts-expect-error on schema-exclusive selection sets, with three pitfalls called out: prose containing the literal pattern accidentally creates spurious directives (use block comments instead); typeof-only bindings need explicit eslint-disable in this repo's config; queries must select schema-exclusive fields or the structural distinctness mechanism doesn't fire. Cross-references three related docs (expo-graphql-schema-drift, mocked-shape-vs-real-contract-discipline, nextjs-route-shape-migration); follow-up cross-reference additions in those docs are noted as recommended next steps but deliberately not included in this commit to keep the doc-write atomic. Origin: PR #902 (this branch). Brief: docs/brainstorms/2026-05-05-... Plan: docs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.md 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ur-imazing
added a commit
that referenced
this pull request
May 7, 2026
…#907) * docs(plans): add Unit 1 consumer query/shape inventory plan Plans Unit 1 of the admin-core consumer migration parent plan (2026-04-22-001) — a documentation-only pass producing a single inventory at docs/admin-core-migration/query-inventory.md that catalogs every graphql() callsite in apps/web, apps/mobile, and apps/tv with variables, cache behavior, renderer dependencies, per-field admin-parity tagging, block __typename → admin t-literal mapping, and PUBLIC-tier classification. Decomposes into 5 sub-units: per-app passes (U1-U3, parallel-safe), block mapping (U4), and PUBLIC classification + verification (U5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(plans): fix Unit 1 plan glossary table broken by prettier The "Synthetic watch blocks" glossary row contained an inline TypeScript union type with raw pipe characters, which prettier (run by lint-staged on commit) parsed as table-column separators and split the row across 6 phantom columns. Rewrite the description to enumerate the discriminants as comma-separated inline-code spans instead, which renders correctly in GitHub Markdown and survives prettier reformatting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(plans): apply ce-doc-review fixes for Unit 1 plan vs PR #902 Fixes 6 P1+P2 findings + 1 P2-load-bearing finding from /ce-doc-review against PR #902 (admin SDL emit + dual-client codegen): P1 — must-fix: - hybridSearch → search: admin's GraphQL field is named `search` (file is `hybrid-search.ts`); plan referenced `hybridSearch` in 3 places - apps/admin/src/auth/permissions.ts is the wrong PUBLIC-tier source; PUBLIC is defined per-field via `authScopes: { public: true }` in apps/admin/src/graphql/queries/*.ts and types/experience.ts - SCENE_RECOMMENDATIONS in apps/web/src/lib/recommendations.ts uses raw Apollo gql tag, not gql.tada graphql() — escapes the rg "graphql\(" sweep. R7 verification + Glossary "every operation" expanded to also run rg "= gql\`" across all three consumer apps - sceneRecommendations ↔ web:GET_VIDEO_BY_SLUG was a category error in U5 — the actual sceneRecommendations consumer is web:SCENE_RECOMMENDATIONS (the raw-gql callsite). Conflating the two would feed Unit 2 a wrong PUBLIC field set P2 — high: - U4 admin `t` enumeration: blocks.ts has 19 z.literal values, not 17. containerSlot (container-only, admin-additional) and quizButton (section-content-only) were unstated. Per-scope distribution now spelled out across BlockSchema / SectionContentBlockSchema / ContainerContentBlockSchema - draftMode() preview path: apps/web/src/app/api/preview/route.ts exists with STRAPI_PREVIEW_SECRET; U5's heuristic now scans for draftMode().isEnabled reads in addition to GraphQL variables/headers - U4 Files cross-reads apps/admin/schema.graphql (post-#902) and flags the JSON-blocks seam: ExperienceLocale.blocks is exposed as JSON, not as a typed union; Unit 5/6 adapters must parse JSON before discriminating on `t` P3+P4 findings appended to Open Questions for implementer judgment: R1 distribution, web ~9 callsite framing, searchMode-already-PUBLIC, ownerId/archivedAt PUBLIC leak, adminGraphql() forward-reference, parallelism scope. Terminology normalization (scope union) applied inline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(admin-core-migration): apps/web query & shape inventory (U1) Creates docs/admin-core-migration/query-inventory.md with: - header banner + regeneration instructions (dual-rg sweep) - scope & conventions: operation key format, field-tagging legend, PUBLIC-classification legend, adminGraphql() forward-reference - apps/web operations section — 10 operations: - 9 gql.tada graphql(): GET_EXPERIENCE, GET_WATCH_EXPERIENCE, GET_WATCH_SETTINGS, GET_ROUTE_VIDEO, getWatchVideoOperation, getWatchVideoBySlugOperation, SEMANTIC_SEARCH, GET_DEMO_VIDEO, GET_VIDEO_BY_SLUG - 1 raw Apollo gql: SCENE_RECOMMENDATIONS (would have escaped a gql.tada-only sweep) - synthetic WatchBlock 6-kind subsection — explicitly framed as NOT Strapi __typenames, NOT a row in the future block-mapping table - placeholder sections for U2 (mobile), U3 (TV), U4 (block mapping), U5 (PUBLIC classification + verification) Per docs/plans/2026-05-07-001-feat-consumer-migration-unit-1-query-inventory-plan.md (U1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(admin-core-migration): apps/mobile query & shape inventory (U2) Populates ## apps/mobile Operations section with 3 standalone operations: - mobile:GET_WATCH_EXPERIENCE — useQuery cache-and-network, composes ContainerFragment + SectionFragment + 12 leaf fragments - mobile:LIST_EXPERIENCES — useQuery cache-and-network, metadata only (no blocks selection — divergence vs TV's videoHero-block selection) - mobile:SEMANTIC_SEARCH — getApolloClient().query no-cache; does NOT select searchMode (divergence vs TV/web — flagged for U3 cross-ref) Verification: - rg "graphql\(" apps/mobile/src → 18 matches (1 banner JSDoc, 17 real callsites: 3 ops + 14 fragments via composite chain) - rg "= gql\`" apps/mobile/src → 0 matches (no raw-gql in mobile) Mobile/TV deliberate divergences captured per plan: U3 will cross- reference these from the TV section. Per docs/plans/2026-05-07-001-feat-consumer-migration-unit-1-query-inventory-plan.md (U2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(admin-core-migration): apps/tv query & shape inventory (U3) Populates ## apps/tv Operations with 3 standalone operations: - tv:GET_WATCH_EXPERIENCE — composes ContainerFragment + SectionFragment + 12 leaf fragments (mirrors mobile structure) - tv:LIST_EXPERIENCES — selects Experience.blocks with inline ComponentSectionsVideoHero for focus-driven home hero (TV-only; mobile selects metadata only — bidirectional cross-ref captured) - tv:SEMANTIC_SEARCH — selects searchMode HybridSearchMode! (TV+web do, mobile does not). Note: searchMode is already PUBLIC on both Strapi (today) and admin (apps/admin/src/graphql/queries/hybrid-search.ts:86), confirmation of intentional public exposure — NOT a Unit 2 decision Verification: - rg "graphql\(" apps/tv/src → 18 matches (1 banner JSDoc, 17 real callsites = 3 ops + 14 fragments) - rg "= gql\`" apps/tv/src → 0 matches - Normalizer drift check: no drift. apps/tv/src/lib/normalizer.ts TYPENAME_TO_KIND is byte-identical to mobile's Bonus finding logged: TV apolloClient sets global watchQuery fetchPolicy: cache-and-network default that mobile does not; per-op cache-behavior notes call this out so U5/U6 don't read overrides in isolation. Per docs/plans/2026-05-07-001-feat-consumer-migration-unit-1-query-inventory-plan.md (U3). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(admin-core-migration): block typename → admin t mapping (U4) Populates ## Block __typename → Admin Discriminator Mapping with the single canonical table joining every Strapi ComponentSections* typename selected by any consumer (web + mobile + TV) to its admin t literal. Main mapping table — 17 rows: - 16 actively-selected ComponentSections* typenames - 1 defensive: ComponentSectionsCard (in mobile/TV TYPENAME_TO_KIND but unreached by any consumer fragment) - 0 missing rows: every selected Strapi typename has an admin counterpart. Unit 2 (PUBLIC schema widening) inherits no new typename gaps from this pass Critical seams documented: - JSON-blocks-seam header note above the table: admin exposes ExperienceLocale.blocks as a JSON scalar, NOT a typed union. Unit 5/6 adapters parse JSON before discriminating on Zod runtime literal t — different shape than just renaming __typename selections - 3 admin scope unions enumerated with member counts: BlockSchema (17), SectionContentBlockSchema (13, +quizButton), ContainerContentBlockSchema (10, +containerSlot) - quizButton scope restriction annotated: section-content-only; adapter MUST reject outside section.content; reached only via SectionFragment Admin-additional subsection (separate, BELOW main table): - videoRecommendations, containerSlot — admin-only nesting concepts, no Strapi counterparts. Forward-looking, not migration concerns Cross-read of apps/admin/schema.graphql deferred until PR #902 merges (file absent on this branch). Logged in verification. Mobile↔TV TYPENAME_TO_KIND parity reconfirmed (no drift since U3). Per docs/plans/2026-05-07-001-feat-consumer-migration-unit-1-query-inventory-plan.md (U4). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(admin-core-migration): public classification + verification + assembly (U5) Final pass on the query & shape inventory: PUBLIC tripartite classification (16 total operations): - PUBLIC-current (8): the 4 admin PUBLIC queries already cover *:GET_WATCH_EXPERIENCE, web:GET_EXPERIENCE, all *:SEMANTIC_SEARCH, web:SCENE_RECOMMENDATIONS (the raw-gql callsite, NOT GET_VIDEO_BY_SLUG) - PUBLIC-eligible-needs-widening (8): feeds Unit 2 admin PUBLIC schema widening — web GET_WATCH_SETTINGS / GET_ROUTE_VIDEO / GET_VIDEO_BY_SLUG / GET_DEMO_VIDEO / getWatchVideoOperation / getWatchVideoBySlugOperation + mobile/tv LIST_EXPERIENCES - MUST-stay-authenticated (0): structurally empty today. Preview infrastructure exists at apps/web/src/app/api/preview/route.ts gated on STRAPI_PREVIEW_SECRET; no current operation branches on draftMode().isEnabled. Future preview-flow operations MUST be classified MUST-stay-authenticated regardless of name Multi-channel preview scan: - rg "publicationState: PREVIEW" consumer apps → 0 hits - rg "draftMode\(" consumer apps → 1 hit (the route toggle only) - rg "isEnabled" consumer apps → 0 hits - No unexpected auth-header pattern Admin PUBLIC verification: - All 4 authScopes: { public: true } annotations confirmed in the worktree (line drift vs plan recorded in verification log) Verification cross-check: - rg "graphql\(" + rg "= gql\`" across web/mobile/tv → 0 orphans in either direction (every rg line has an inventory entry, no inventory entry lacks an rg line) Fields flagged for Unit 2 access-control review: - Experience.ownerId — admin-side, exposed via PUBLIC experienceBySlug, no Strapi parity, carries internal user identifier - Experience.archivedAt — same shape; internal lifecycle state End-to-end assembly: no structural fix-ups required. Per-app subsection structure consistent across web/mobile/tv. Bidirectional mobile↔TV divergence cross-references verified. No copy-paste drift. Per docs/plans/2026-05-07-001-feat-consumer-migration-unit-1-query-inventory-plan.md (U5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(admin-core-migration): fix table syntax + clarify ?-tag legend Tier 1 review findings on the inventory: - HIGH: admin-additional t-literals table at line 1335 had an unescaped pipe inside `sceneRecommendations(videoId | slug, locale, limit)` plus a 4-column divider on a 3-column table. GitHub would render the videoRecommendations row malformed. Escaped the pipe and trimmed the divider. - LOW: field-tagging legend's ? entry implied this inventory's internal U4 resolves all ? tags. Clarified the two-layer reality: internal U4 resolves typename-level parity only (4 of 17 bumped to adapter-required); field-level ? resolution is owned by the parent migration plan's Unit 4 (parity harness), which is downstream of this whole document. No structural changes; rendering and clarity only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(plans): mark Unit 1 plan completed All 5 sub-units shipped on this branch (commits f4e402d through b622292 plus Tier 1 review fix f80e7fa). Inventory is at docs/admin-core-migration/query-inventory.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(solutions): compound learning — graphql callsite dual-pattern sweep New best-practice doc captures a near-miss caught during /ce-doc-review on the Unit 1 consumer-migration plan: a single-pattern rg sweep on `graphql\(` silently drops raw Apollo `gql\`...\`` callsites in codebases that mix gql.tada with @apollo/client (which apps/web does). The concrete near-miss: SCENE_RECOMMENDATIONS in apps/web/src/lib/recommendations.ts uses raw Apollo gql, sits next to GET_VIDEO_BY_SLUG (gql.tada) in the same file, and would have been silently dropped from the Unit 1 inventory if doc-review had not flagged the gap. The cascade: Unit 2 builds admin's PUBLIC sceneRecommendations contract using GET_VIDEO_BY_SLUG's thin video- shell field set, consumers continue selecting similarity/themes/ demographics/spiritualContext, recommendation strip silently renders empty in production with no build or test failure. The rule: when inventorying GraphQL callsites in any app that uses @apollo/client as a dep, run BOTH `rg "graphql\("` and `rg "= gql\`"`. The union is the inventory. Cross-references: - mocked-shape-vs-real-contract-discipline: 6th worked instance added to the META home's table (verification-command coverage of dual-form DSLs is the same shape as a single mock satisfying multiple branches) - dual-client-gql-tada-multi-schema-codegen-pattern: consumer-side complement note added to "Verifying the pattern is working locally" + Related row pointing to this new doc Discoverability: root CLAUDE.md already surfaces docs/solutions/ via the Known Patterns list — no instruction-file edit needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ur-imazing
added a commit
that referenced
this pull request
May 10, 2026
…er-2 review discipline Captures two high-yield learnings from PR #915's review and recovery cycle: 1. **Opt-in scaffolding env vars must be `.optional()`** (docs/solutions/runtime-errors/required-env-var-without-default-broke-railway-deploy-20260511.md) ADMIN_GRAPHQL_URL was specced as required z.url() with no default. The U5 plan correctly anticipated the failure mode AND wrote the mitigation ("deploy env var to all environments BEFORE PR merge"), but the mitigation was treated as a routine documentation note and the boot-fail-fast became a deploy block. The fix moves the precondition from operator deploy-checklist discipline into the schema: required vars are reserved for code paths the default mode consumes; opt-in scaffolding vars must be optional so default mode has zero new env-var prerequisites. Includes prevention rules, a vi.stubEnv test pattern that would catch the same trap, and the "reliability persona presumption-of-correctness" decision rule for env-var findings flagged at P2+ confidence 75+. 2. **Tier-2 ce-code-review is mandatory before push** (docs/solutions/workflow-issues/ce-code-review-tier-2-mandatory-before-push-20260511.md) Knowledge-track learning: when /ce-work Phase 2 ends with tests green, the instinct to push is the signal that shipping-workflow.md is being skipped. Tier-2 triggers (>=400 LOC + multi-dir, >=1000 LOC, or sensitive surface) make multi-persona review non-optional. Captures the full shipping checklist (simplify, tier-1 vs tier-2 escalation, Residual Work Gate, Operational Validation Plan) + the per-finding routing table from Stage 5 step 6b + a counter-example (PR #902 ran it correctly). Plus CLAUDE.md "Known Patterns" entries pointing at both docs so future agents see them at session start. Both docs cross-link to each other — the Railway boot-fail is a concrete instance of the broader Tier-2-before-push discipline. Related: docs/solutions/auth/better-auth-secret-must-not-fallback-to-hardcoded-value.md, docs/solutions/developer-experience/env-matrix-drift-from-runtime-requirements-20260421.md, docs/solutions/platform/admin-manager-enrichment-trigger-endpoint-20260506.md, docs/solutions/best-practices/review-fix-round-2-sibling-call-site-regressions-20260421.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ur-imazing
added a commit
that referenced
this pull request
May 10, 2026
* docs(plans): u5 web canary — dual-read parity signal pipeline
Plan for unit 5 of feat-104 admin-core consumer migration. Scoped to
two modes: strapi (default) + dual-read. Admin-mode rendering deferred
to U5b. Built via /ce-plan + /ce-doc-review with 5 reviewer personas.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): u1 — FORGE_CONTENT_API flag + content-api-mode helper
Adds the dual-read parity canary's mode flag scaffolding:
- FORGE_CONTENT_API server env var with two values: strapi (default)
+ dual-read. U5b adds admin-with-fallback / admin later.
- ADMIN_GRAPHQL_URL server env var with warn-only host allowlist
(jesusfilm.org / railway.app / local / localhost), mirroring
NEXT_PUBLIC_CANONICAL_ORIGIN's refine shape.
- content-api-mode.ts: ContentApiMode union, getContentApiMode()
reading from env at module scope (ISR-safe — no headers/cookies),
normalizeContentApiMode() defensive layer for unknown inputs.
- Top-of-file deletion checklist cross-referencing the U4 bridge
and the harness's own checklist at packages/graphql/src/parity/
index.ts.
- 12 unit tests covering happy paths, edge cases, and the
module-cache-vs-mutate semantic.
Plan: docs/plans/2026-05-08-001-feat-consumer-migration-web-canary-unit-5-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): u2 — admin Apollo client + experienceBySlug operation
Stands up the server-side admin GraphQL surface for U5's dual-read
canary:
- admin-client.ts: anonymous Apollo singleton mirroring client.ts.
AbortSignal.timeout(3000) constructed inside the fetch override
per-call (NOT module scope — would share one signal across all
calls and fire 3s after process boot).
- fragments/admin-experience.ts: GetAdminExperienceBySlug operation
via adminGraphql() factory. Selects the 10 ExperienceLocale fields
the parity bridge consumes, including blocks (JSON scalar — shape
validation lands at normalizeAdmin via BlocksSchema).
- Re-export from fragments/index.ts.
- 2 unit tests: singleton check + per-call AbortSignal foot-gun guard.
Plan: docs/plans/2026-05-08-001-feat-consumer-migration-web-canary-unit-5-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): u4 — parity-bridge with input adaptation + value-stripped log
Implements the consumer-side bridge between U2's admin/Strapi clients
and the U4 parity harness in @forge/graphql/parity:
- runDualReadComparison(outcome) takes a tagged-union per-side fetch
outcome (ok/error/timeout) and routes to one of five log events:
diff, admin_timeout, harness_error (with subkind), both_failed,
strapi_failed_admin_succeeded (the U5b advance gating signal).
- Input adaptation at the boundary, not by changing harness:
* Strapi: synthesize locale from urlLocale if response lacks it
* Admin: remap metaDescription → description (admin schema field
name vs harness's AdminExperienceLocaleInput field name)
- R13 enforcement: production log payload carries ONLY counts per
channel + RFC6901 JSON-Pointer paths + timings. Raw ValueDiff and
SemanticDiff strapi/admin field values are STRIPPED — content
fields (titles, descriptions, URLs) must not reach Vercel/Railway
log search and bypass CMS access control.
- Dev-only opt-in: FORGE_PARITY_DEBUG=1 includes diffSamples with raw
values for first 3 diffs. Production strips unconditionally.
- 14 tests covering happy path, locale synth, metaDescription remap,
R13 enforcement (negative+positive), all 5 events, all 4 harness
error subkinds, JSON-parseability, and event-name union pinning.
Plan: docs/plans/2026-05-08-001-feat-consumer-migration-web-canary-unit-5-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): u3 — fetchSlugExperience dual-read branch + locale audit
Wires U2's admin client and U4's parity bridge into content.ts:
- New internal helper fetchSlugExperience(locale, slug) called only
from resolveSlugPage's slug-equality case at line 376. The homepage
path (resolveHomepage) and legacy-homepage call still use
getExperienceByFilters directly — out of U5 scope.
- strapi mode: identical to current behavior (calls
getExperienceByFilters with the slug filter).
- dual-read mode: Promise.all over both fetches (each timed via
performance.now). Hands the orchestrated outcome to the parity
bridge's runDualReadComparison. Returns Strapi to the user; admin
failures or timeouts never affect user-facing render.
- AbortError / TimeoutError on the admin side classified as the
bridge's "timeout" outcome; other errors classified as "error" so
the bridge can emit the right log event.
- watchExperienceFragment now selects `locale` so normalizeStrapi has
the field it requires (bridge synthesis remains as defense-in-depth).
- typeof window guard on env reads in admin-client.ts and
content-api-mode.ts so server-only env vars don't throw via
t3-oss/env-nextjs's Proxy when these modules are transitively
imported by client components (mirrors client.ts pattern).
- Test infra: vitest.setup.ts + .env.ci + .env.example get
ADMIN_GRAPHQL_URL placeholders. content.test.ts gains parallel
mocks for admin-client, content-api-mode, parity-bridge.
- 10 new test scenarios cover: strapi-mode no-admin-touch, dual-read
happy path, admin throws (typed ApolloError shape), admin AbortError
→ timeout classification, Strapi-fails-admin-OK gating signal,
both-fail propagation, resolveSlugPage shape stability across modes,
the 5-mode regression snapshot, dual-read with admin shadow, and
the WatchExperience-fragment-has-locale pin.
Plan: docs/plans/2026-05-08-001-feat-consumer-migration-web-canary-unit-5-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(admin-core-migration): inventory entry for adminExperienceBySlugOperation
Adds the U5 admin-side query to the canonical consumer-migration
inventory so future graphql() callsite sweeps catch it without drift.
Plan: docs/plans/2026-05-08-001-feat-consumer-migration-web-canary-unit-5-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): hard-reject auth.jesusfilm.org from ADMIN_GRAPHQL_URL
Defense-in-depth on the U5 host allowlist. Admin's auth host (PR #909)
returns 404 on /api/* by design — pointing ADMIN_GRAPHQL_URL there would
silently emit forge.parity.harness_error events on every dual-read
request instead of failing at boot. Mirrors the rejection pattern in
packages/graphql/src/parity/live-config.ts:24 (REJECTED_HOSTS).
Surface: if ADMIN_GRAPHQL_URL hostname is in the reject set,
z.url().refine() throws at boot with a clear message pointing at
admin.jesusfilm.org as the correct host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(web): simplify u5 — fewer casts, shared allowlist refine
Three simplification fixes from /simplify pass:
1. content.ts: drop the `as DualReadOutcome["admin"]` cast and the
double `isAbortTimeoutError` call in fetchAdminSlugExperience by
restructuring to direct branches with an `elapsed()` helper.
Behavior identical; type narrowing now exact.
2. content.ts: drop the message-substring fallback in isAbortTimeoutError.
Per CLAUDE.md (AWS NoSuchKey classification): never branch on the
error MESSAGE — match error.name first. The substring fallback would
misclassify any GraphQL error mentioning "timeout" as
forge.parity.admin_timeout, polluting the canary's gating signal.
3. env.ts: extract `softHostAllowlistRefine(varName, exacts, suffixes)`
helper used by both ADMIN_GRAPHQL_URL (server) and
NEXT_PUBLIC_CANONICAL_ORIGIN (client). Same shape, different lists.
~25 LOC saved; behavior identical (still warn-only).
Plus minor: extract `PARITY_ROUTE = "[slug]" as const` in parity-bridge.ts
(7 literal sites consolidated) and inline the single-element
STRAPI_ALIAS_MODES array into a flat test case in content.test.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): u5 — apply ce-code-review findings (3 P1, 4 P2)
Resolves 7 actionable findings from /ce-code-review (12 reviewers, autofix
mode, plan-explicit). Each fix corroborated by 2+ reviewers.
P1 — boot-fail risk: env.ts FORGE_CONTENT_API z.enum widened from
[strapi, dual-read] to all four origin-R7 values. An operator pre-setting
"admin" or "admin-with-fallback" no longer bricks boot. content-api-mode.ts
now wires normalizeContentApiMode (dead code → load-bearing) — U5b values
coerce to "strapi" with a warn until U5b ships admin-mode rendering.
P1 — log content-leak risk (R13 defense-in-depth): the parity bridge's
FORGE_PARITY_DEBUG check now requires BOTH the explicit flag AND
NODE_ENV !== "production". A production typo of FORGE_PARITY_DEBUG=1
becomes a no-op. Also registered FORGE_PARITY_DEBUG in env.ts schema for
boot-time visibility.
P1 — bridge sync-throw risk: content.ts wraps runDualReadComparison in
try/catch. If the bridge throws (circular ref, throwing toString,
JSON.stringify failure on BigInt), the user still gets Strapi —
forge.parity.canary_failed event logs the error.
P2 — admin_missing event: split the comparator_unknown bucket so admin
returning null while Strapi has data (typical during backfill) emits
forge.parity.admin_missing distinctly. Real comparator failures stay in
harness_error/comparator_unknown. Restores R-18a gating-signal fidelity.
P2 — Apollo networkError walk: isAbortTimeoutError extended to walk
error.networkError (Apollo Client v4 surfaces transport errors there) and
its cause chain. Real timeouts no longer misclassify as harness_error/
admin_fetch_error. Extracted hasTimeoutOrAbortName helper.
P2 — test coverage: 11 new tests covering Apollo result.error path
(distinct from rejection), TimeoutError name, networkError walk,
networkError.cause chain walk, U5b values coerced with warn, R13
defense-in-depth holds when both flags set, the new admin_missing event,
both-null and Strapi-null-admin-OK edge cases, and bridge sync-throw
recovery emits forge.parity.canary_failed.
PARITY_LOG_EVENTS now pins 7 events (was 5): admin_missing and
canary_failed added.
Plan: docs/plans/2026-05-08-001-feat-consumer-migration-web-canary-unit-5-plan.md
Run artifact: /tmp/compound-engineering/ce-code-review/20260508-160116-a106c03a/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(plans): u5 plan status active → completed
Implementation shipped, /ce-code-review applied, 307/307 tests pass.
Plan retires; ongoing operator tasks (flipping FORGE_CONTENT_API in
production envs, monitoring forge.parity.* log events, advancing through
R-18a thresholds) belong to runbook + U5b.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): u5 round-2 — apply ce-code-review findings (3 P2, 4 P3)
Round-2 ce-code-review verified all round-1 findings resolved and
surfaced 9 new findings. 4 safe_auto + 5 gated_auto/manual applied; 1
manual deferred (admin_missing subkind discrimination needs admin-side
cooperation).
P2 fixes:
- canary_failed log payload now carries `timings: {strapiMs, adminMs}`,
matching the ParityLogPayload contract every other parity event uses.
Operator dashboards filtering on timings.* no longer hit undefined.
- FORGE_CONTENT_API enum is now whitespace+case-tolerant via
z.preprocess(trim+lowercase) → enum, so "DUAL-READ" or "dual-read \n"
(common operator-typo failure modes) coerce cleanly instead of
bricking boot. The runtime narrower in content-api-mode.ts handles
unknown values; this fix removes the gap where the schema rejected
before the narrower could run.
P3 fixes:
- parity-bridge.ts now reads FORGE_PARITY_DEBUG via the typed env (not
process.env), so the registered z.enum schema is actually load-bearing
at the runtime read site instead of cosmetic boot-time validation.
- admin-client.ts timeoutFetch now combines a caller-supplied init.signal
with the 3s timeout via AbortSignal.any() (Node 20.3+), preserving
external cancellation. Falls back to timeout-only on older runtimes.
- Deletion-checklist comment in parity-bridge.ts updated from "five
parity log event names" to seven (admin_missing + canary_failed
added in round 1 weren't propagated to the deletion list).
- .env.example documents FORGE_PARITY_DEBUG with allowed values "0"|"1",
the production-safety note, and the retire-with-U5 marker.
- normalizeContentApiMode admin-with-fallback test now asserts the warn
message body (was call-count-only).
- 5 internal types in parity-bridge.ts (ParityLogEvent, HarnessErrorSubkind,
SideOutcome, StrapiExperienceResponse, AdminExperienceResponse,
ParityLogPayload) dropped from the export surface — zero non-test
consumers and the deletion contract is cleaner with a smaller
export footprint.
Manual fix added as comment-only (no concrete shape):
- fetchStrapiSlugExperience now warns future maintainers in a docblock
that adding strapi-side timeout classification requires updating the
bridge's branch table (currently has a defensive narrowing return for
the unreachable case).
Manual deferred:
- admin_missing event commingles backfill gap / Pothos shield-plugin
null / replication lag without subkind discrimination. Requires
admin-side response classification to fix; deferred to U5b operator
triage protocol.
Run artifact: /tmp/compound-engineering/ce-code-review/round-2/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): u5 — make ADMIN_GRAPHQL_URL optional to unblock Railway deploy
Railway's @forge/web deploy was failing because ADMIN_GRAPHQL_URL is a
NEW required env var that the service hadn't been configured with. The
default mode (FORGE_CONTENT_API=strapi) never invokes the admin client
— it's byte-identical to current main — so requiring a new env var just
to deploy was an unnecessary regression.
Fix: make ADMIN_GRAPHQL_URL optional in env.ts. The Zod refines (auth-
host hard-reject + soft-allowlist warn) still run when a value IS
provided, but absence no longer bricks boot. admin-client.ts coalesces
to empty string so the Apollo HttpLink construction stays the same.
Operational behavior:
- FORGE_CONTENT_API=strapi (default) + ADMIN_GRAPHQL_URL unset:
admin client never invoked. Identical to main.
- FORGE_CONTENT_API=dual-read + ADMIN_GRAPHQL_URL unset:
admin queries fail with non-URL fetch error → caught by
fetchAdminSlugExperience → emits forge.parity.harness_error subkind
admin_fetch_error. Operator notices in logs and configures the var.
This is the round-2 reliability finding rel-r2-1 ("ADMIN_GRAPHQL_URL is
required without .optional() or .default(); boot fails when unset, even
in default strapi mode where admin-client is never invoked"). I rated it
P2 in round 2 but skipped applying — that was the wrong call. This PR's
Railway deploy proved it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(solutions): compound two learnings from U5 — env-var schema + tier-2 review discipline
Captures two high-yield learnings from PR #915's review and recovery cycle:
1. **Opt-in scaffolding env vars must be `.optional()`**
(docs/solutions/runtime-errors/required-env-var-without-default-broke-railway-deploy-20260511.md)
ADMIN_GRAPHQL_URL was specced as required z.url() with no default. The
U5 plan correctly anticipated the failure mode AND wrote the
mitigation ("deploy env var to all environments BEFORE PR merge"),
but the mitigation was treated as a routine documentation note and
the boot-fail-fast became a deploy block. The fix moves the
precondition from operator deploy-checklist discipline into the
schema: required vars are reserved for code paths the default mode
consumes; opt-in scaffolding vars must be optional so default mode
has zero new env-var prerequisites. Includes prevention rules,
a vi.stubEnv test pattern that would catch the same trap, and the
"reliability persona presumption-of-correctness" decision rule for
env-var findings flagged at P2+ confidence 75+.
2. **Tier-2 ce-code-review is mandatory before push**
(docs/solutions/workflow-issues/ce-code-review-tier-2-mandatory-before-push-20260511.md)
Knowledge-track learning: when /ce-work Phase 2 ends with tests
green, the instinct to push is the signal that shipping-workflow.md
is being skipped. Tier-2 triggers (>=400 LOC + multi-dir,
>=1000 LOC, or sensitive surface) make multi-persona review
non-optional. Captures the full shipping checklist (simplify,
tier-1 vs tier-2 escalation, Residual Work Gate, Operational
Validation Plan) + the per-finding routing table from Stage 5
step 6b + a counter-example (PR #902 ran it correctly).
Plus CLAUDE.md "Known Patterns" entries pointing at both docs so future
agents see them at session start.
Both docs cross-link to each other — the Railway boot-fail is a concrete
instance of the broader Tier-2-before-push discipline.
Related: docs/solutions/auth/better-auth-secret-must-not-fallback-to-hardcoded-value.md,
docs/solutions/developer-experience/env-matrix-drift-from-runtime-requirements-20260421.md,
docs/solutions/platform/admin-manager-enrichment-trigger-endpoint-20260506.md,
docs/solutions/best-practices/review-fix-round-2-sibling-call-site-regressions-20260421.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Lands the foundational dual-client typed codegen scaffolding for the consumer-side Strapi → admin migration. After this PR,
packages/graphqlexports two factories side-by-side (graphql()for Strapi,adminGraphql()for admin), each bound to its own committed SDL artifact via gql.tada multi-schema codegen.This is brief Unit 2 + Unit 3 of the seven-unit consumer migration. No consumer-side migration code is attached —
apps/web,apps/mobile, andapps/tvcontinue to read from Strapi unchanged. The newadminGraphql()factory has zero callsites at merge; Unit 5 (web canary) is the first consumer.What ships
Admin SDL emit pipeline (Unit 2):
apps/admin/src/scripts/print-schema.ts— emits Pothos schema viaprintSchema(lexicographicSortSchema(builder.toSchema())). Uses an AST-based directive stripper (graphql-jsparse/visit/print) to remove Pothos plugin directives the gql.tada parser can't tolerate. Output is byte-deterministic.apps/admin/schema.graphql— committed SDL artifact (~865 lines)apps/admin/package.json—schema:printscript wiring.github/workflows/ci.yml— newadmin-schema-driftjob mirroring the existinggraphql-generatepattern, gated on@forge/adminaffected via Turboturbo.json— newschema:printtask with explicitinputsdeclarationMulti-schema codegen + adminGraphql factory (Unit 3):
packages/graphql/tsconfig.json— gql.tada plugin migrated toschemas: [...]array (Strapi + admin)packages/graphql/package.json— adds@forge/admin: workspace:*devDep +./adminsubpath exportpackages/graphql/src/admin.ts— newadminGraphql()factory +AdminResultOf/AdminFragmentOf/AdminVariablesOftype utilities + sharedreadFragmentre-exportpackages/graphql/src/admin-graphql-env.d.ts— generated admin introspection types (committed)packages/graphql/src/__tests__/dual-client.types.ts— compile-time AE1 enforcement test covering result/fragment/variables cross-assignment in both directionspackages/graphql/AGENTS.md+CLAUDE.md— rewritten for the dual-client worldDocumentation:
docs/brainstorms/2026-05-05-consumer-migration-implementer-brief-requirements.mdand Unit 3 plandocs/plans/2026-05-05-001-feat-dual-client-codegen-unit-3-plan.mdcommitted alongside the implementationCLAUDE.mdGraphQL Change Flow split into Strapi + admin sub-flowsapps/admin/AGENTS.mddocumentsschema:printand the existing local-dev script setdocs/roadmap/platform/feat-120-decouple-admin-sdl-emit-from-runtime-graph.mddefers a follow-up architectural concern surfaced during code reviewUnrelated chore (one isolated commit):
apps/admin/src/scripts/seed-easter.ts(1308 lines) — Easter experience seed for local UI/E2E fixtures, translated from Strapi's existing seedapps/admin/src/scripts/run-experience-dump.ts(159 lines) — workstation wrapper around admin's R3 experience-content-dump service383025db— these are pure dev tooling alongsiderun-embeds/run-sync/pull:mapping. Neither ships to production runtime.Code review
Multi-agent review run on this branch (10 reviewers: correctness, testing, maintainability, project-standards, agent-native, learnings, api-contract, reliability, adversarial, kieran-typescript).
readFragmentsymmetry (chore(tooling): repo-wide lint rollout #3), test mechanism documentation (Repository linting setup #4), cwd path (chore(tooling): add missing repo config best practices #5), try/catch (chore(deps): Bump actions/checkout from 4 to 6 #7), Turboinputs(chore(deps): Bump actions/github-script from 7 to 8 #8), admin-side docs (chore(deps): Update hashicorp/aws requirement from ~> 5.0 to ~> 6.32 in /infra/aws #9),run-experience-dumpwiring (chore(deps-dev): Bump the dev-dependencies group with 5 updates #12)AdminFragmentOf/AdminVariablesOfcoverage (chore(deps): Update vercel/vercel requirement from ~> 2.0 to ~> 4.6 in /infra/vercel #11) and test mechanism documentation (Repository linting setup #4)unknown(chore(deps): Bump actions/setup-node from 4 to 6 #10); plan acknowledges this as reversible if it bites in Unit 5feat-120captures the architectural concern thatprint-schema.tstriggers Prisma + env validation at SDL emit time. Current setup works becauseskipValidation: !!process.env.CIbypasses zod in CIVerification
pnpm --filter @forge/graphql typecheck✓pnpm --filter @forge/admin typecheck✓pnpm --filter @forge/web typecheck✓ (R4: existing Strapi callsites unchanged)pnpm --filter @forge/mobile typecheck✓pnpm --filter @forge/tv typecheck✓pnpm --filter @forge/graphql lint✓pnpm --filter @forge/graphql generateidempotent (no diff after re-run)pnpm --filter @forge/admin schema:printbyte-deterministic across consecutive runs@ts-expect-errordirective in negative cases re-surfaces the underlying type errorOut of scope
Unit 1 (callsite inventory), Unit 2 admin PUBLIC schema widenings (
videoBySlug/videos/watchSettingetc.), Unit 4 (parity comparator harness), Unit 5 (web canary slice + block adapter), Unit 6 (mobile + TV migration), and Unit 7 (rollout runbook + observability) are all separate plans/PRs.Test plan
admin-schema-driftandgraphql-generatejobs on this PR — both passgit diff --exit-codeclauses catch drift correctly (intentional drift test in a follow-up PR)apps/web,apps/mobile,apps/tv,apps/manager) typecheck in CI🤖 Generated with Claude Code