Skip to content

feat(cli): compile GraphQL SDL split across files into one schema - #17513

Merged
rishabh-fern merged 7 commits into
mainfrom
devin/1787602339-graphql-multi-file-schemas
Aug 25, 2026
Merged

feat(cli): compile GraphQL SDL split across files into one schema#17513
rishabh-fern merged 7 commits into
mainfrom
devin/1787602339-graphql-multi-file-schemas

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Description

A GraphQL schema owned by several teams is normally shipped as several SDL files (Apollo Federation subgraphs). Individually those files are not valid schemas: they reference types defined by sibling files and each does extend type Query/extend type Mutation. The importer was buildSchema(sdl) on a single file, so such a spec failed with Unknown directive "@link", and after removing that, Unknown type "ProfileImages".

GraphQL specs that share a namespace (the name: on a spec in generators.yml, already used as the GraphQL namespace) are now parsed together and merged into one schema before conversion. Specs in different namespaces — and specs in different API workspaces — stay independent, so unrelated products are never merged.

Merging composes the files the way supergraph composition would, for documentation purposes only: runtime composition semantics (@requires/@provides execution, ownership validation) are deliberately not modeled.

parse each file
  → drop federation directives (@link/@key/@external/@shareable/@tag/…) and their definitions
  → drop @inaccessible members, cascading to everything that references them
  → normalize `extend type X` into a definition and merge members by name into type X
  → merge schema root operation types
  → buildASTSchema(document)   // assumeValidSDL only as a fallback, with a warning

Federation directives describe how a graph is assembled at runtime, not what a consumer can call; Apollo strips them when deriving the client-facing API schema from a supergraph, so the merged document matches what a consumer sees via introspection. Standard directives are kept — @deprecated is now surfaced as availability: "Deprecated", with the reason appended to the description (the FDR shape has no dedicated field for it), on operations, arguments, object/interface/input fields and enum values. A bare @deprecated is not annotated with a reason, because graphql-js fills in the placeholder "No longer supported", which availability already conveys.

@inaccessible is not merely stripped: composition omits those members from the client-facing schema, so they are removed. Removal cascades, because dropping a type leaves dangling references that would fail the schema build — fields (including fields whose arguments reference a removed type), input fields, union members and implemented interfaces referencing a removed type are dropped, and a type emptied by that cascade is itself removed, repeating until nothing changes. Root operation types are omitted from the schema definition if the cascade removed them.

The cascade also drops implements I from any type it left unable to satisfy I. Dropping an @inaccessible field from an implementor while the interface still declares it produces a document that buildASTSchema accepts but validateSchema rejects, so without this the merge could only ever emit an invalid schema in that case. implements is preserved whenever the contract still holds.

Conflicts are reported rather than failing the build, and the first declaration wins:

  • A member declared in two files is kept from the first file. Comparison is by shape (type + argument types) only, so the extremely common federated pattern of redeclaring a shared/key field (id: ID! @external, or the same field documented differently in two subgraphs) is not a conflict. A member declared twice with differing shapes within one file is reported too, with a message naming the single file.
  • A directive definition repeated across files keeps the first. Last-wins would leave usages in earlier files referencing arguments the surviving definition no longer declares, which fails assertValidSDL and drags the whole document onto the lenient fallback path. Identical redeclarations are not a conflict.

Behavior notes for existing users:

  • GraphQLSpec and generators.yml are unchanged — no config migration; a single-file spec takes exactly the same path (one-element group).
  • SDL validation is not dropped. The merged document is built with ordinary buildASTSchema and validateSchema, and problems are logged. assumeValidSDL: true is used only if the strict build throws and the lenient build succeeds — merged subgraphs can legitimately carry constructs that only resolve at composition time (e.g. a directive whose definition lives in the supergraph), and failing the docs build on those would make federation unusable. If the schema cannot be built at all, the original error is rethrown, i.e. genuinely broken SDL still fails the build as before.
  • The one output-visible change is for a workspace with multiple GraphQL specs in the same namespace. Note this includes every spec declared under a plain api: specs: list, since those all carry namespace: undefined — namespaces only come from api: namespaces:. Those specs now merge instead of being converted independently. Previously identically-named types and operations collided in the Object.assign that combined the per-spec results, so the last spec silently won; now the first declaration wins and the collision is reported as a warning naming both files. Neither behavior keeps both definitions — the operation ID is unnamespaced in both cases — so this is a change in which definition survives plus a new diagnostic, not new data loss. Users in this configuration who were relying on the old last-wins ordering should either reorder their specs or give them distinct name: namespaces. packages/cli/docs-resolver/src/__test__/fixtures/graphql-ambiguous-operations is an example of this layout.
  • Snapshot churn is limited to @deprecated now appearing: one operation in account-schema.

Verified against the 9 real federated subgraph files from the Autodesk User Profile V2 API (the motivating case): 122 types, 24 queries, 22 mutations, no unresolved references, no conflicts reported, no validation warnings, 29 deprecated operations and 120 deprecated fields/enum values surfaced.

Unblocks ingesting multi-file/federated GraphQL specs at all; the schema-derived Types section and type link-out (which needs fern-api/fern-platform#14190 plus CLI nav generation and frontend work) is separate and still to come.

Changes Made

  • mergeGraphQlDocuments: merges any number of SDL documents into one DocumentNode, stripping federation directives recursively (type, field, argument, input field, enum value) and reporting shape conflicts. Types, members and directive definitions are all first-wins, and each conflict names the file that actually won.
  • @inaccessible members and types are removed, with iterative cascading removal of everything that references them, followed by a pass that drops implements clauses the cascade left unsatisfiable.
  • GraphQLConverter accepts AbsoluteFilePath | AbsoluteFilePath[], merges the documents, builds the schema with validation (warning + lenient fallback for composition-only constructs), and logs conflicts naming both files (or the single file, for an in-file duplicate).
  • @deprecatedavailability + reason in the description, on operations, arguments, fields and enum values; the placeholder reason graphql-js supplies for a bare @deprecated is suppressed.
  • groupGraphQLSpecsByNamespace in api-workspace-commons, used by both conversion call sites (OSSWorkspace, DocsDefinitionResolver).
  • federated-subgraphs fixture (3 subgraphs: cross-file type references, extend type Query/Mutation, @link/@key/@external/@shareable, @deprecated, plus an @inaccessible type and field).

Testing

  • Unit tests added/updated — mergeGraphQlDocuments.test.ts (root-type merging, directive stripping vs. @deprecated, @inaccessible cascade including inaccessible types/fields/input types/union members, a type emptied by inaccessible members, key-field redeclaration is not a conflict, differing field type is first-wins + reported, implements dropped when the cascade leaves an interface unsatisfied and kept when it does not, kind-mismatch conflicts naming the winning file, directive-definition first-wins and identical-redeclaration, in-file duplicate members) and the converter fixture, whose snapshot is unchanged by the inaccessible additions precisely because those members are dropped; @fern-api/graphql-to-fdr, @fern-api/docs-resolver, @fern-api/lazy-fern-workspace suites pass.
  • Manual testing completed — ran the converter over the 9 Autodesk subgraph files and the 4 BigCommerce schemas (no warnings, output unchanged apart from the intended @deprecated metadata); an unknown directive warns and continues, while a dangling type reference still fails with the same error as before.

Link to Devin session: https://app.devin.ai/sessions/e6690308e11e4256bc49a58ccf798ebc


Open in Devin Review

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review. (Configure)

Open in Devin Review

"requires",
"provides",
"shareable",
"inaccessible",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Inaccessible fields still shown in docs

stripFederationDirectives removes @inaccessible but keeps the field or type it marks. Federation composition drops those from the consumer-facing API, so the generated docs expose members consumers cannot query.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct on federation semantics — composition omits @inaccessible members from the client-facing API schema, so keeping them is inconsistent with the stated goal of matching what a consumer sees. Not fixing it in this PR, deliberately:

Dropping a member is easy, but dropping an inaccessible type has to cascade: every field/argument/input field/union member that references it must go too, iteratively, or buildASTSchema fails on a dangling reference — and if a cascade empties an object type, that type has to be dropped as well. That's a fair amount of logic I'd rather land with its own fixture than bolt onto this PR.

Practical impact today is zero: neither the Autodesk subgraphs nor the BigCommerce schemas use @inaccessible, and since the directive definition is dropped along with @link, the converter never renders it either way — the member is simply still listed. Filing as a follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Folded into this PR after all (5858959): @inaccessible members are now removed rather than just having the directive stripped, with the cascade described above — fields (including fields whose arguments reference a removed type), input fields, union members and implemented interfaces referencing a removed type are dropped, a type emptied by the cascade is removed too, and the pass repeats until nothing changes. Root operation types are dropped from the schema definition if the cascade removed them.

Covered by two tests in mergeGraphQlDocuments.test.ts plus @inaccessible cases in the federated-subgraphs fixture — the converter snapshot is unchanged by those fixture additions precisely because the members never reach the output.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Docs Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-08-25T04:12:59Z).

Fixture main PR Delta
docs 255.9s (n=5) 267.4s (35 versions) +11.5s (+4.5%)

Docs generation runs fern generate --docs --preview end-to-end against the benchmark fixture with 35 API versions (each version: markdown processing + OpenAPI-to-IR + FDR upload).
Delta is computed against the nightly baseline on main.
Baseline from nightly run(s) on main (latest: 2026-08-25T04:12:59Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-08-25 19:27 UTC

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-08-25T04:12:59Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 75s (n=5) 111s (n=5) 64s -11s (-14.7%)
go-sdk square 136s (n=5) 285s (n=5) 126s -10s (-7.4%)
java-sdk square 239s (n=5) 279s (n=5) 196s -43s (-18.0%)
php-sdk square 68s (n=5) N/A 57s -11s (-16.2%)
python-sdk square 152s (n=5) 245s (n=5) 116s -36s (-23.7%)
ruby-sdk-v2 square 95s (n=5) 129s (n=5) 89s -6s (-6.3%)
rust-sdk square 227s (n=5) 215s (n=5) 160s -67s (-29.5%)
swift-sdk square 60s (n=5) 450s (n=5) 56s -4s (-6.7%)
ts-sdk square 138s (n=5) 150s (n=5) 99s -39s (-28.3%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-08-25T04:12:59Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-08-25 19:28 UTC

rishabh-fern and others added 2 commits August 24, 2026 20:45
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Five fixes to the multi-file GraphQL merge:

- The @inaccessible cascade could only produce an invalid schema. Dropping
  a field from a type that implements an interface left the interface
  unsatisfied; assertValidSDL does not check that, so the strict build
  succeeded and validateSchema reported the failure as a warning. Added
  pruneUnsatisfiedInterfaces, which drops `implements I` where the cascade
  left the type without a field I requires. `implements` is preserved when
  the contract is still satisfied.

- Kind-mismatch conflicts named the wrong files: for a memberless type the
  `kept` path fell through to the incoming file, so the message named the
  dropped file as the winner. MergedType now carries its declaring file.

- Directive definitions were last-wins, inconsistent with first-wins for
  types and silent. A later differing definition left earlier usages
  referencing arguments that no longer existed, forcing the whole document
  onto the assumeValidSDL fallback. Now first-wins with a reported
  conflict; identical redeclarations are not a conflict.

- A member declared twice with differing shapes within one file was
  silently dropped. The signature comparison alone covers the entity-key
  redeclaration case the file guard was added for, so the guard is gone
  and the converter has a distinct message for a single-file duplicate.

- graphql-js defaults deprecationReason to "No longer supported" for a
  bare @deprecated, which was appended to the description even though
  availability already says it.

Co-Authored-By: Claude <noreply@anthropic.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Runtime verification — GraphQL multi-file / federated schema merging

Tested end-to-end at 05a41ecee7f with a local fern docs dev preview against Autodesk's 9 Apollo Federation subgraph SDL files (each individually invalid) plus BigCommerce's 4 single-file schemas.

Merged: 9 subgraphs → one API reference, 24 queries / 22 mutations

userProfile (coreprofile.graphql) and profileImages (imgprofile.graphql) render side by side in the same nav group; no conversion errors logged.

Merged sidebar - 24 queries
Cross-file operation page with args and return type

Baseline on main: same project renders only 17 queries

main logs Failed to process GraphQL spec …inferredinfo-base.graphql: Unknown directive "@link" (+ @key/@external/@shareable, many Unknown type …), drops every coreprofile operation and shows no deprecation badges — while still exiting 0.

main baseline - 17 queries

Deprecation: reason kept when given, suppressed when bare

getUserProfile shows badge + Deprecated: … Use 'userProfile' query instead.. Adding a reasonless @deprecated to a query (no fixture had one) yields badge-only with no Deprecated: line, while its reasoned sibling on the same schema keeps its text.

getUserProfile badge + reason
Bare @deprecated - badge only

Regression — single-file BigCommerce schemas (Account / Admin / B2B / Storefront)

All four namespaces render; opened operation pages in Storefront (site), Admin (store) and B2B (companyRole). Storefront's deprecated mutations keep their reason text, e.g. updateCartLocale and company addAddressDeprecated: Alpha version. Do not use in production.

Storefront updateCartLocale
B2B companyRole

Namespace separation + one pre-existing sharp edge

Moving 2 of the 9 specs to name: other-product proves unrelated namespaces are not merged: only Other Product renders (6 queries / 5 mutations, other-product_ type prefixes). The remaining 7-spec group then has dangling cross-namespace references and is dropped entirely with Unknown type "ProfileImages" logged, while fern check still reports 0 errors — i.e. docs could be published missing 7 of 9 subgraphs without a hard failure. Confirmed pre-existing: on main the same specs fail per-file (Unknown directive "@link", 14 × Unknown type) and are equally absent, also with Found 0 errors, exit 0.

Recording of the run is attached in the Devin session.

rishabh-fern and others added 2 commits August 25, 2026 15:44
…e cases

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Edge cases from review — results and fixes

All 13 are now covered by tests (mergeGraphQlDocuments.test.ts 12 → 24 tests, GraphQLConverter.test.ts +1, new api-workspace-commons/src/__test__/Spec.test.ts). Four needed code changes; the rest were verified as-is and are now pinned by assertions.

Tier 1 — fixed (all four were real)

# Was Now
1 Examples last-wins across merged specs, silent First-wins + warn naming the operation key (Multiple GraphQL examples provided for "query:getUser"…)
2 schema { query: … } last-wins, orphaning the earlier root First-wins + conflict { typeName: "schema", memberName: "query", kept, dropped }
3 extend type X before type X dropped the real definition's type-level directives and flipped field order The real definition is promoted to base (description, directives, member order) and the extension's members are appended, regardless of spec order
4 parse() error didn't say which file Error is prefixed with the spec path: bad.graphql: Syntax Error: Expected Name, found "{"

On 4 I kept the fail-the-group behavior deliberately: the group is one schema, so continuing without one subgraph produces dangling references and a half-documented API — worse than a build failure that names the file. Happy to revisit if you'd rather it skip and warn.

Tier 2 — @inaccessible cascade, all already correct, now asserted

  • Enum with every value @inaccessible → enum removed, referencing field removed.
  • @inaccessible scalar → scalar and every field returning it removed.
  • @inaccessible interface → interface removed and dropped from implementors' implements, implementor's own fields untouched.
  • Mutation fully pruned → dropped from the schema definition, Query intact. Query fully pruned → empty document, warning only, no crash.
  • Reference cycle (A.b: B, B.a: A, B.gone: Gone @inaccessible) → loop terminates, only gone removed.

Tier 3

  • Unions with different members per file are unioned in declaration order (Ok | Failed | Pending), no conflict — asserted, not accidental.
  • Federation v1 type Foo @extends @key(...) merges identically to extend type.
  • Executable definitions (query Foo { a }) in a spec file are now dropped instead of riding along in the type-system document.
  • Same spec path listed twice is a no-op, no conflicts.
  • Namespaced + unnamespaced specs stay in separate groups (groupGraphQLSpecsByNamespace now has a test; the unnamespaced key is "").

Regression re-check on the fixed build

autodesk (9 files): 122 types, 24 queries, 22 mutations
account:             80 types,  4 queries,  9 mutations
admin:              572 types,  7 queries, 47 mutations
b2b:                278 types, 89 queries, 52 mutations
storefront:         773 types, 13 queries, 53 mutations
warnings: none

Identical to the pre-fix numbers, and zero warnings — none of the new diagnostics fire on real schemas.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@deprecated → badge + reason, verified at f2e6756d74f

Rebuilt the prod CLI on this HEAD and ran a local fern docs dev preview over the 9 Autodesk federation subgraphs (all name: user-profile). Dev log is clean for this project — no Multiple GraphQL examples, Invalid GraphQL schema, conflict, Failed to process or Unknown type/directive lines.

The reason: argument renders as bold Deprecated: prose next to a red Deprecated badge, at every level.

Operation-level (getUserProfile):

Operation-level: getUserProfile

Response object fields:

Response fields with reasons

Nested fields, input fields, and bare @deprecated

Expanding personalInfo inside the response shows nested deprecations; the updateUserProfile mutation's UserInput shows a deprecated input field; and a reasonless @deprecated (injected temporarily into the throwaway fixture, since the schema's "bare" ones are SDL comments rather than directives) yields badge-only with no Deprecated: line, while reasoned siblings keep theirs.

Nested personalInfo fields

Deprecated input field optIn

Bare @deprecated - badge only

Note: deprecated members sort after non-deprecated ones inside each expanded object panel.

Written by Devin

rishabh-fern and others added 2 commits August 25, 2026 14:21
…ed GraphQL query root

A subgraph never defines the federation directives it uses -- it imports them
via @link -- so any directive missing from FEDERATION_DIRECTIVES survives the
merge with no definition behind it. buildASTSchema then fails with "Unknown
directive", and the converter falls back to assumeValidSDL, which silences SDL
validation for every other problem in the same files. The list stopped at
federation v2.7, so a v2.8 subgraph (@context, @fromcontext) or a v2.9 one
(@cost, @listsize) took that fallback unnecessarily and lost validation for
everything else. Adding the four keeps those schemas on the strict path.

Separately, the @inaccessible cascade had no floor. Dropping an emptied Mutation
still leaves a usable schema and is tested as such, but when the cascade reaches
the query root the merged document ends up with no types at all: the docs
silently publish an API with no operations, reported only as a validateSchema
warning. The merge now throws instead, naming the root and the @inaccessible
types responsible. Both call sites already catch and log converter failures, so
this surfaces the problem without failing the docs build.

Also correct the changelog: specs that declare no `name` share the unnamed
group, so they are merged too -- not only specs with a namespace in common.

Co-Authored-By: Claude <noreply@anthropic.com>
@rishabh-fern
rishabh-fern merged commit 72dd49f into main Aug 25, 2026
232 checks passed
@rishabh-fern
rishabh-fern deleted the devin/1787602339-graphql-multi-file-schemas branch August 25, 2026 21:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants