feat(cli): compile GraphQL SDL split across files into one schema - #17513
Conversation
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
| "requires", | ||
| "provides", | ||
| "shareable", | ||
| "inaccessible", |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Docs Generation Benchmark ResultsComparing PR branch against median of 5 nightly run(s) on
Docs generation runs |
SDK Generation Benchmark ResultsComparing PR branch against median of 5 nightly run(s) on Full benchmark table (click to expand)
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 |
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>
Runtime verification — GraphQL multi-file / federated schema mergingTested end-to-end at Merged: 9 subgraphs → one API reference, 24 queries / 22 mutations
Baseline on
|
…hql-multi-file-schemas
…e cases Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Edge cases from review — results and fixesAll 13 are now covered by tests ( Tier 1 — fixed (all four were real)
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
Tier 3
Regression re-check on the fixed buildIdentical to the pre-fix numbers, and zero warnings — none of the new diagnostics fire on real schemas. |
|
…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>
…hql-multi-file-schemas





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 wasbuildSchema(sdl)on a single file, so such a spec failed withUnknown directive "@link", and after removing that,Unknown type "ProfileImages".GraphQL specs that share a namespace (the
name:on a spec ingenerators.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/@providesexecution, ownership validation) are deliberately not modeled.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 —
@deprecatedis now surfaced asavailability: "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@deprecatedis not annotated with a reason, because graphql-js fills in the placeholder"No longer supported", whichavailabilityalready conveys.@inaccessibleis 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 Ifrom any type it left unable to satisfyI. Dropping an@inaccessiblefield from an implementor while the interface still declares it produces a document thatbuildASTSchemaaccepts butvalidateSchemarejects, so without this the merge could only ever emit an invalid schema in that case.implementsis preserved whenever the contract still holds.Conflicts are reported rather than failing the build, and the first declaration wins:
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.assertValidSDLand drags the whole document onto the lenient fallback path. Identical redeclarations are not a conflict.Behavior notes for existing users:
GraphQLSpecandgenerators.ymlare unchanged — no config migration; a single-file spec takes exactly the same path (one-element group).buildASTSchemaandvalidateSchema, and problems are logged.assumeValidSDL: trueis 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.api: specs:list, since those all carrynamespace: undefined— namespaces only come fromapi: namespaces:. Those specs now merge instead of being converted independently. Previously identically-named types and operations collided in theObject.assignthat 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 distinctname:namespaces.packages/cli/docs-resolver/src/__test__/fixtures/graphql-ambiguous-operationsis an example of this layout.@deprecatednow appearing: one operation inaccount-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 oneDocumentNode, 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.@inaccessiblemembers and types are removed, with iterative cascading removal of everything that references them, followed by a pass that dropsimplementsclauses the cascade left unsatisfiable.GraphQLConverteracceptsAbsoluteFilePath | 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).@deprecated→availability+ reason in the description, on operations, arguments, fields and enum values; the placeholder reason graphql-js supplies for a bare@deprecatedis suppressed.groupGraphQLSpecsByNamespaceinapi-workspace-commons, used by both conversion call sites (OSSWorkspace,DocsDefinitionResolver).federated-subgraphsfixture (3 subgraphs: cross-file type references,extend type Query/Mutation,@link/@key/@external/@shareable,@deprecated, plus an@inaccessibletype and field).Testing
mergeGraphQlDocuments.test.ts(root-type merging, directive stripping vs.@deprecated,@inaccessiblecascade 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,implementsdropped 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-workspacesuites pass.@deprecatedmetadata); 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