graph_weaver 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +4 -4
  3. data/README.md +40 -88
  4. data/docs/alternatives.md +1 -7
  5. data/docs/cassettes.md +54 -59
  6. data/docs/editors.md +32 -47
  7. data/docs/errors.md +261 -369
  8. data/docs/federation.md +650 -837
  9. data/docs/generated_modules.md +380 -463
  10. data/docs/getting_started.md +211 -428
  11. data/docs/i18n.md +114 -177
  12. data/docs/logging.md +127 -116
  13. data/docs/real_world.md +26 -39
  14. data/docs/scalars.md +277 -310
  15. data/docs/testing.md +343 -486
  16. data/docs/transports.md +203 -268
  17. data/docs/upgrading.md +211 -560
  18. data/examples/README.md +38 -0
  19. data/examples/countries.rb +39 -0
  20. data/examples/federation.rb +62 -0
  21. data/examples/github/generate.rb +20 -0
  22. data/examples/github/generated/star_mutation.rb +126 -0
  23. data/examples/github/generated/stargazers_query.rb +232 -0
  24. data/examples/github/generated/starred_query.rb +151 -0
  25. data/examples/github/queries/star.graphql +8 -0
  26. data/examples/github/queries/stargazers.graphql +22 -0
  27. data/examples/github/queries/starred.graphql +11 -0
  28. data/examples/github/run.rb +43 -0
  29. data/examples/github/setup.rb +18 -0
  30. data/examples/rick_and_morty.rb +57 -0
  31. data/graph_weaver.gemspec +12 -3
  32. data/lib/graph_weaver/client.rb +30 -1
  33. data/lib/graph_weaver/codegen/emit.rb +5 -11
  34. data/lib/graph_weaver/codegen.rb +23 -55
  35. data/lib/graph_weaver/context_seam.rb +54 -0
  36. data/lib/graph_weaver/errors.rb +23 -15
  37. data/lib/graph_weaver/federation.rb +11 -2
  38. data/lib/graph_weaver/graph.rb +39 -29
  39. data/lib/graph_weaver/in_process.rb +15 -9
  40. data/lib/graph_weaver/internal/endpoint.rb +7 -5
  41. data/lib/graph_weaver/internal/headers.rb +19 -0
  42. data/lib/graph_weaver/internal/test_clients.rb +7 -11
  43. data/lib/graph_weaver/internal.rb +81 -13
  44. data/lib/graph_weaver/log_subscriber.rb +10 -2
  45. data/lib/graph_weaver/logging.rb +33 -13
  46. data/lib/graph_weaver/query_module.rb +44 -23
  47. data/lib/graph_weaver/retry.rb +12 -8
  48. data/lib/graph_weaver/rspec.rb +13 -24
  49. data/lib/graph_weaver/schema_loader.rb +52 -14
  50. data/lib/graph_weaver/tasks.rb +10 -2
  51. data/lib/graph_weaver/testing/cassette.rb +28 -5
  52. data/lib/graph_weaver/testing/endpoint.rb +14 -13
  53. data/lib/graph_weaver/testing/fake_client.rb +33 -3
  54. data/lib/graph_weaver/testing/router.rb +7 -3
  55. data/lib/graph_weaver/testing.rb +12 -4
  56. data/lib/graph_weaver/transport/http.rb +2 -2
  57. data/lib/graph_weaver/transport.rb +47 -23
  58. data/lib/graph_weaver/version.rb +1 -1
  59. data/lib/graph_weaver.rb +32 -10
  60. metadata +16 -3
  61. data/CHANGELOG.md +0 -3801
data/docs/federation.md CHANGED
@@ -1,36 +1,31 @@
1
1
  # Federation
2
2
 
3
- For an app that is a client of a federated graph, a subgraph in one, or both.
4
- Two halves: **generating** against a composed supergraph (which mostly means
5
- "point it at the file and forget"), and the **local router**, which runs a
6
- stitched query against your own resolvers in-process so specs need no gateway.
7
-
8
- GraphWeaver takes any federation artifact a supergraph, an API schema, a
9
- subgraph SDL, or a live router — and recognizes which it got.
10
- `SchemaLoader.load` (and `Client.new(path_or_sdl)`) accept each as an SDL file
11
- or an introspection dump.
12
-
13
- **Which half is yours** depends on what your app does with the graph, and most
14
- of them are only one:
15
-
16
- - **You call the gateway and compose nothing.** [Generating against a
17
- supergraph](#generating-against-a-supergraph) and [the local
18
- router](#the-local-router) are the whole document for you. Skip
19
- [producing a supergraph](#producing-a-supergraph) and [has the supergraph
20
- been recomposed?](#has-the-supergraph-been-recomposed) — those are the
21
- composer's.
3
+ For an app that is a client of a federated graph, a subgraph in one, or both. Two
4
+ halves: **generating** against a composed supergraph, which mostly means "point it
5
+ at the file and forget", and the **local router**, which runs a stitched query
6
+ against your own resolvers in-process so specs need no gateway. Any federation
7
+ artifact works — a supergraph, an API schema, a subgraph SDL, or a live router;
8
+ `SchemaLoader.load` (and `Client.new(path_or_sdl)`) recognize which they got, as an
9
+ SDL file or an introspection dump.
10
+
11
+ **Which half is yours** depends on what your app does with the graph, and most apps
12
+ are only one:
13
+
14
+ - **You call the gateway and compose nothing.** [Generating for a federated
15
+ graph](#generating-for-a-federated-graph) and [the local
16
+ router](#the-local-router) are the whole document for you.
22
17
  - **You publish a subgraph.** Add [generating against a
23
- subgraph](#generating-against-a-subgraph) and `federation:diff`, which is
24
- what tells you your change needs a recompose.
25
- - **You own the supergraph.** All of it, plus [in CI](#in-ci): the checks here
26
- compare an app to its own artifacts, and the pre-deploy check against the
27
- live graph is `rover`'s.
18
+ subgraph](#generating-against-a-subgraph) and [a subgraph that calls its own
19
+ graph](#a-subgraph-that-calls-its-own-graph).
20
+ - **You own the supergraph.** All of it, plus [producing
21
+ one](#producing-a-supergraph), [`federation:diff`](#has-the-supergraph-been-recomposed)
22
+ and [in CI](#in-ci).
28
23
 
29
24
  ## Generating for a federated graph
30
25
 
31
26
  **Queries go through the gateway?** Generate against the supergraph. It is the
32
- whole graph in one schema, so every registration matches and there is nothing
33
- else to decide.
27
+ whole graph in one schema, so every registration matches and there is nothing else
28
+ to decide.
34
29
 
35
30
  **Calling subgraphs directly?** One graph per subgraph, declared once:
36
31
 
@@ -42,289 +37,467 @@ GraphWeaver.graph :billing do
42
37
  namespace "Billing"
43
38
  register_scalar "Money", Money
44
39
  end
45
-
46
- GraphWeaver.graph :directory do
47
- schema "directory.graphql"
48
- queries "app/graphql/directory"
49
- output "app/graphql/generated/directory"
50
- namespace "Directory"
51
- register_scalar "Person.birthday", Date
52
- end
53
40
  ```
54
41
 
55
- One `rake graph_weaver:generate` generates both, and each subgraph is held only
56
- to the registrations declared for it. (Naming a live subgraph *class* from a
42
+ One `rake graph_weaver:generate` generates every graph, and each subgraph is held
43
+ only to the registrations declared for it. (Naming a live subgraph *class* from a
57
44
  Rails initializer takes a lambda — `schema -> { Billing::Schema }` — see
58
45
  [getting started](getting_started.md#more-than-one-schema).)
59
46
 
60
47
  Registrations made at the *top* level still reach every graph, because names
61
- compose by identity across a graph — `Money` is one Ruby type wherever it
62
- appears, and `Person` is one entity even though a single subgraph owns
63
- `birthday`. So a registration a given subgraph doesn't declare is not an error;
64
- generation warns and carries on. `rake graph_weaver:generate` and `verify` print
65
- the list once per run, after the files:
48
+ compose by identity across a graph — `Money` is one Ruby type wherever it appears,
49
+ and `Person` is one entity even though a single subgraph owns `birthday`. So a
50
+ registration a given subgraph doesn't declare is not an error; generation warns
51
+ and carries on, printing the list once per run:
66
52
 
67
53
  ```
68
54
  register_scalar("Money") matches no scalar in Billing::Schema — a typo, or a registration for another schema
69
55
  ```
70
56
 
71
- `GraphWeaver.unmatched_registrations` is that same list as data, for a Rakefile
72
- or a spec that would rather gate on it than read it. Moving a registration into
73
- the graph block that needs it is what makes those lines go away.
57
+ `GraphWeaver.unmatched_registrations` is that same list as data, and moving a
58
+ registration into the graph block that needs it is what makes the lines go away.
59
+ Entity fields work the same way: a subgraph carrying `Person` for its `@key` alone
60
+ sees a top-level `register_scalar("Person.birthday", Date)` as a field it doesn't
61
+ own — a warning, not a failure. What a subgraph *can* disprove still fails
62
+ generation: a name it declares as something else, or a coordinate whose field it
63
+ declares as a composite. Neither is redeemable by any schema in the graph.
74
64
 
75
- This holds for entity fields too, which is the case that would otherwise bite:
76
- every subgraph referencing an entity declares it, so a subgraph carrying
77
- `Person` for its `@key` alone sees a top-level
78
- `register_scalar("Person.birthday", Date)` as a field it doesn't own — a
79
- warning, not a failure.
65
+ ### Generating against a supergraph
80
66
 
81
- What a subgraph *can* disprove still fails generation: a name it declares as
82
- something else (`register_scalar("Species")` where `Species` is an enum), and a
83
- coordinate whose field it declares as a composite. Neither is redeemable by any
84
- schema in the graph.
67
+ A supergraph SDL works as-is. On load, GraphWeaver strips the composition
68
+ machinery the synthetic `join__*`/`link__*` types and directive definitions, and
69
+ every `@join__*`/`@link` application on the real types so codegen sees the
70
+ merged graph's ordinary type shapes. Field shapes (nullability, args, enums,
71
+ inputs) are identical to the API schema, so your generated structs are correct.
85
72
 
86
- ## Producing a supergraph
73
+ **Which names count as machinery is read off the schema**, not a fixed list.
74
+ Federation namespaces itself through [`@link`](https://specs.apollo.dev/link/v1.0/)
75
+ (v2) or [`@core`](https://specs.apollo.dev/core/v0.2/) (v1), and those declarations
76
+ are applied as written: the spec URL's name segment gives the namespace
77
+ (`https://specs.apollo.dev/join/v0.3` → `join__`), `as:` renames it, `import:`
78
+ binds names into the root namespace. So a fed 2.5+ graph's `@requiresScopes` /
79
+ `@policy` / `@context` machinery strips the same way `join__` does, a renamed
80
+ `@inaccessible` still hides what it marks, and v1 supergraphs load identically. A
81
+ schema that declares nothing still gets the `join__`/`link__`/`core__` floor.
82
+
83
+ A supergraph is also a **superset** of the API schema: it carries elements the
84
+ public API hides, marked `@inaccessible`. Loading strips every `@inaccessible`
85
+ element and cascades — a field, argument, union member or interface referencing a
86
+ removed type goes too, and a type left empty is removed in turn — so codegen
87
+ validates against what clients can actually query, with no need for Apollo's JS
88
+ tooling to subtract the API schema first. (The derivation is diffed against
89
+ Apollo's own `composeServices` + `toAPISchema()` in
90
+ [`spec/integration/api_schema_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/integration/api_schema_spec.rb).)
91
+ Two bounds: the directive is matched by the **local name it was linked under**, so
92
+ an `import:` alias subtracts what that alias marks; and the subtraction runs **only
93
+ on the supergraph path** — plain and subgraph SDL are taken at face value.
94
+ Directives that hide nothing keep their field: `@requiresScopes` / `@policy` /
95
+ `@authenticated` enforce at runtime, `@tag` / `@requires` / `@provides` /
96
+ `@external` are metadata.
87
97
 
88
- GraphWeaver consumes a supergraph; composing one is Apollo's job. Composition
89
- takes one SDL file per subgraph, so the question is where each file comes from.
98
+ ### Contracts and variants
90
99
 
91
- **Yours.** [`apollo-federation`](https://github.com/Gusto/apollo-federation-ruby)
92
- is the gem that makes a graphql-ruby schema a subgraph `@key`,
93
- `resolve_reference`, the directives and it adds `federation_sdl` to the schema
94
- class, which prints exactly what a composer wants:
100
+ A contract variant is a supergraph built from the same subgraphs with some
101
+ coordinates filtered out: a subgraph marks them `@tag(name: "internal")`, and
102
+ GraphOS builds a second supergraph where everything carrying that tag is
103
+ `@inaccessible`. Nothing special is needed to generate against one — the
104
+ subtraction above is exactly what makes it the narrower schema its clients see. A
105
+ query selecting an internal-only field generates against **internal** and, against
106
+ **public**, fails codegen with a `QueryValidationError` naming the field, because
107
+ the load subtracted it before validation saw it. That is the guarantee: a client
108
+ generated against the variant it calls cannot select something the router will
109
+ reject.
110
+
111
+ One app calling two variants is
112
+ [two graphs](getting_started.md#more-than-one-schema), each with its own `schema`,
113
+ `queries`, `output` and a `namespace` if any module name would collide; a variant
114
+ is mechanically just another graph. But **nothing cross-checks the variant you
115
+ generated against with the endpoint you call** — generate against internal, deploy
116
+ against public, and every check stays green until a live 400 — and
117
+ **`federation:diff` can't tell the variants apart** either, since it reads the
118
+ routing table and `@inaccessible` is a directive the table doesn't carry. Point
119
+ each graph at the variant it actually calls.
120
+
121
+ ## The local router
122
+
123
+ Specs for a federated app have a bad choice: fake the whole graph, or boot a
124
+ gateway. `Testing::Router` is the third one. It takes the composed supergraph and
125
+ the Ruby schema classes serving its subgraphs, plans the query, and satisfies the
126
+ [client contract](transports.md) — so a generated module runs against your **real
127
+ resolvers**, in-process, with no gateway, no node and no sockets. It is not a mock:
128
+ your resolvers run, which is the whole point.
129
+
130
+ In rspec that's the [`graphql: :router`](testing.md#a-federated-graph--graphql-router)
131
+ tag and there is nothing to pass — the tag builds it, once for the suite. It finds
132
+ the supergraph where you have already said it is: the schema a
133
+ [graph](getting_started.md#more-than-one-schema) declares when that schema is
134
+ composed, else `Testing.config.router = { supergraph: … }` if you named one
135
+ there, else the committed dump when *that* is, else the dump your own client was
136
+ built from (`GraphWeaver.new("supergraph.graphql")` keeps its path). Two graphs
137
+ may name one supergraph; two naming different ones is refused rather than picked
138
+ between.
139
+ Outside rspec, build it yourself:
95
140
 
96
141
  ```ruby
97
- File.write("supergraph/accounts.graphql", Accounts::Schema.federation_sdl)
142
+ GraphWeaver.client = GraphWeaver::Testing::Router.new(
143
+ supergraph: Rails.root.join("supergraph.graphql"),
144
+ context: { current_user: user },
145
+ )
98
146
  ```
99
147
 
100
- One ordering trap in that gem: declare `orphan_types` **before** `query` in the
101
- schema class. It computes the `_Entity` union when `query` is called, so an
102
- `orphan_types` after it drops those types from the printed SDL and from
103
- `_entities` with no error at all, while `Schema.types` still lists them.
148
+ `context:` reaches every subgraph, because every subgraph is a Ruby call here. A
149
+ request's **headers** don't: the gateway and the Apollo Router both start a subgraph
150
+ call with none of the client's unless you configure forwarding, so don't let a spec
151
+ conclude an auth header arrived somewhere it wouldn't.
104
152
 
105
- `orphan_types` is also what an **extend-only type** needs one this subgraph
106
- contributes fields to but never returns from its own `Query`, the shape a
107
- warehouse/inventory split produces. Nothing reaches it from a root field, so
108
- graphql-ruby never visits it, `federation_sdl` never prints it, and the fields
109
- simply don't compose: no error from graphql-ruby, none from apollo-federation,
110
- and the first sign is a supergraph missing fields you wrote. Name every such
111
- type in `orphan_types`, before `query`.
153
+ `router.trace` records the fetches made since the last `reset_trace`, in order
154
+ (subgraph, query, variables); the same lines go to `GraphWeaver.logger` at
155
+ `:debug`, and the rspec tag resets it before each example. It **accumulates across
156
+ executes**, because the question worth asking is which subgraphs a code path
157
+ touched. But the count is the **local router's plan, not the gateway's**: the data
158
+ is faithful a real gateway answers byte-identically, or this refuses while a
159
+ [`@requires` prefetch](#what-it-plans) is still its own call where a gateway merges
160
+ it into the read beside it. So assert on a **bound**
161
+ (`expect(router.trace.size).to be <= 8`) or on the **subgraph set**
162
+ (`router.trace.map { _1[:subgraph] }.uniq`): both move when an N+1 appears, and
163
+ neither pins a number production doesn't have.
112
164
 
113
- **Everyone else's.** From the team that runs it: a file they publish, `rover
114
- subgraph fetch` against their endpoint, or your schema registry.
165
+ The router hands back a result hash *above* the wire, so the transport your app
166
+ ships never runs. When that transport is the thing under test — a caller tag, an
167
+ APM header, mTLS — [`graphql: :wire`](testing.md#over-the-wire--graphql-wire) serves
168
+ this same router at the endpoint your client posts to and leaves your client in
169
+ place: real serialization, the same plan over the same resolvers, `from_h` over the
170
+ server's own bytes, and a `context:` proc reading the headers that arrived. It
171
+ refuses exactly what the router refuses — a hop, not a capability — and the hop is
172
+ served through webmock, so that tag needs `require "webmock/rspec"`.
173
+
174
+ **[`examples/federation.rb`](https://github.com/dpep/graph_weaver/blob/main/examples/federation.rb)**
175
+ is the whole shape in one runnable file, and
176
+ [`spec/router_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/router_spec.rb)
177
+ is the exhaustive reference — every plan shape and every refusal, each as a named
178
+ example.
115
179
 
116
- **Composed.** [`rover supergraph compose`](https://www.apollographql.com/docs/rover/commands/supergraphs)
117
- reads a config naming each subgraph's routing url and SDL file, and prints the
118
- supergraph to stdout:
180
+ ### What it refuses
119
181
 
120
- ```yaml
121
- # supergraph-config.yaml
122
- federation_version: =2.14.4 # rover wants an exact one
123
- subgraphs:
124
- accounts:
125
- routing_url: https://accounts.internal/graphql
126
- schema: { file: ./accounts.graphql }
127
- ```
182
+ Everything it can't plan **faithfully** raises `GraphWeaver::Testing::Unplannable`
183
+ (a `GraphWeaver::Error`), at plan time, before any subgraph runs — so a refusal is
184
+ never a half-executed query. A double that approximated the rest of Apollo's
185
+ planner would let a test pass on an answer production disagrees with, which is the
186
+ most expensive thing this library can produce. Each refusal names the coordinate
187
+ that stopped it and what to do.
128
188
 
129
- ```sh
130
- rover supergraph compose --config supergraph-config.yaml \
131
- --elv2-license accept > supergraph.graphql
132
- ```
189
+ Every category, in the words `Unplannable#label` uses (`#category` is the matching
190
+ symbol):
133
191
 
134
- `--elv2-license accept` accepts the Elastic license on the composition binary
135
- rover downloads; without it rover asks, and a CI job has nobody to answer.
136
- Commit the result from here it is an ordinary schema dump, and
137
- [`federation:diff`](#has-the-supergraph-been-recomposed) is what catches it
138
- going stale.
192
+ | Refusal | Why |
193
+ |---|---|
194
+ | no `@key` to cross the boundary on | an entity fetch sends a representation built from a `@key`; with none there is nothing to send |
195
+ | an abstract type the supergraph doesn't break down | bucketing needs the concrete types a subgraph answers a union or interface with, and `@join__unionMember`/`@join__implements` is where a supergraph records that. A composition old enough to carry neither leaves nothing but a guess |
196
+ | an `@interfaceObject` the routing table can't attribute | one subgraph resolves a whole interface's implementations, so the supergraph never says which subgraph answers each of its fields. Per query, not per graph: a query that doesn't reach the type plans as if the directive weren't there |
197
+ | a `@fromContext` argument no fetch here can supply | federation 2.8's `@context`/`@fromContext` fills a field's argument from a selection on an ancestor, and only the gateway that planned the fetch knows what to put there — a subgraph's own resolver never fills one, so this is refused on any path, including the one where a single subgraph answers the whole query. Per query, like `@interfaceObject` |
198
+ | a response delivered in more than one payload | `@defer`/`@stream` stream the rest of the answer over a multipart body after the first payload, and this router answers in one. Refused by name rather than left to validation, so the guarantee doesn't rest on whether the composed schema happens to declare the directive |
199
+ | a progressive `@override` still rolling out | federation 2.7's `@override(label:)` leaves *both* subgraphs resolving the field — the gateway splits traffic per request by the label's rule, and a local router can't evaluate a rollout percentage. Finish the rollout (drop the label) and composition drops the losing copy, which plans normally |
200
+ | a `@requires` whose field set names another `@requires` field | the router satisfies a `@requires` with one fetch, so it can't first satisfy that field's own requirement |
201
+ | a nested field set no one fetch can build | a nested field set crosses as one object, so one fetch has to answer the whole of it. Nesting itself is fine — this is the set whose fields are split across subgraphs, so the object would arrive half-built from each |
202
+ | `@skip`/`@include` on both a fragment and its field | one selection can't carry two conditions of the same name. Spell the condition once |
203
+ | an alias shadowing an injected `@key` | a fetch carries the `@key` it crosses on under a response key — Apollo under the field's own name, the local router under a reserved one — and an alias spelling either claims a key the fetch needs |
204
+ | a mutation's root fields span subgraphs | root mutation fields run in series, and splitting them across subgraphs would run them in whatever order the plan happened to. Sharing one subgraph they're fine, stitching below them and all. Query roots are independent, so those are always fine |
205
+ | the routing table names no subgraph | nothing can route a field the supergraph doesn't place |
206
+ | a subgraph nothing here serves | it's served by another process, so there is nothing here to ask — unless you [fake it](#a-supergraph-only-partly-local) |
207
+ | introspection mixed with data | introspection is answered from the composed API schema and data from the subgraphs, and the two can't be merged. Split them into two operations |
208
+ | the document isn't one operation | pass `operation_name:` naming one of them |
209
+ | not a query or a mutation | the router plans against the composed schema's query and mutation roots; a subscription has neither |
210
+ | a fragment the document never defines | define it, or point the query at the file that does — validation rejects it first, so what you actually get back is an `errors` response |
211
+ | a federation construct the routing table doesn't read | an incomplete table makes every answer about this supergraph a guess. The one refusal raised **at construction**, before a single query |
212
+ | nested deeper than the router walks | past the walk's depth limit, which validation would have rejected first |
139
213
 
140
- Already on a node toolchain? `@apollo/composition` composes in-process with no
141
- rover install. The suite does it that way, and its two files are the whole loop
142
- end to endschema classes, `federation_sdl`, composed supergraph:
143
- [`recompose.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/support/federation/recompose.rb)
144
- driving [`compose.mjs`](https://github.com/dpep/graph_weaver/blob/main/spec/support/federation/compose.mjs).
214
+ A document that fails ordinary GraphQL validation never reaches any of this: it
215
+ gets the same `errors` response a plain client gets. The construction-time refusal
216
+ is worth planning around, though a `@join__` directive the table doesn't read
217
+ refuses `Router.new` for the **whole graph**, so one team adopting a newer
218
+ federation feature is an upgrade-timing event for every team that tests with
219
+ `:router`. A subgraph two loaded schemas both fit raises a `ConfigurationError`
220
+ instead, being a wiring mistake rather than a query the router declines, but it
221
+ raises where every other one does: on the query that reaches the subgraph.
145
222
 
146
- ## Generating against a supergraph
223
+ ### Which schema serves which subgraph
147
224
 
148
- A supergraph SDL works as-is. On load, GraphWeaver strips the composition
149
- machinery the synthetic `join__*`/`link__*` types and directive definitions,
150
- and every `@join__*`/`@link` application on the real types so codegen sees the
151
- merged graph's ordinary type shapes with no federation plumbing in
152
- `schema.types`. Field shapes (nullability, args, enums, inputs) are identical to
153
- the API schema, so your generated structs are correct.
225
+ `subgraphs:` is optional. Left out, each one is **derived from what the loaded
226
+ schemas define**: a schema serves subgraph `s` when it defines every type and field
227
+ the routing table says `s` resolves, plus at least one coordinate attributed to
228
+ that subgraph **alone** (what two subgraphs share can't tell them apart). That's
229
+ evidence rather than a guess, and a wrong guess would point a suite at the wrong
230
+ resolvers and still pass — so exactly one match is used. Neither other outcome
231
+ refuses at construction, since which classes are loaded is not a fact about the
232
+ query you're running: **no** match means the subgraph is served somewhere else
233
+ (below), and **two** means detection can't say which class serves it. Both are
234
+ refused by the query that reaches the subgraph's fields, each naming its own fix —
235
+ for two, `subgraphs: { "reviews" => App::Reviews::Schema }` pins it, and
236
+ `router.ambiguous` lists them.
154
237
 
155
- **Which names count as machinery is read off the schema**, not a fixed list.
156
- Federation namespaces itself through [`@link`](https://specs.apollo.dev/link/v1.0/)
157
- (v2) or [`@core`](https://specs.apollo.dev/core/v0.2/) (v1), and those
158
- declarations are applied as written: the spec URL's name segment gives the
159
- namespace (`https://specs.apollo.dev/join/v0.3` → `join__`), `as:` renames it,
160
- `import:` binds names into the root namespace. So a fed 2.5+ graph's
161
- `@requiresScopes` / `@policy` / `@context` machinery strips the same way
162
- `join__` does, a renamed `@inaccessible` still hides what it marks, and v1
163
- supergraphs (`@core` + `@join__owner`) load identically. A schema that declares
164
- nothing still gets the `join__`/`link__`/`core__` floor.
165
-
166
- ### `@inaccessible`
167
-
168
- A supergraph is a **superset** of the API schema: it carries elements the public
169
- API hides, marked `@inaccessible`. You'll meet the directive rolling out a change
170
- to a **shared type** — add the field to one subgraph marked `@inaccessible` so
171
- composition doesn't require every subgraph to have it yet, roll it out, then drop
172
- the directive to publish it. Apollo **contracts** use the same directive the
173
- other way round, and get a section of their own [below](#contracts-and-variants).
174
-
175
- Loading strips every `@inaccessible` element and cascades: a
176
- field/argument/union-member/interface referencing a removed type goes too, and a
177
- type left empty is removed in turn. So codegen validates against what clients can
178
- actually query, with no need for Apollo's JS tooling to subtract the API schema
179
- first — feed it the raw supergraph and you get the router's contract. The
180
- derivation is diffed against Apollo's own `composeServices` + `toAPISchema()` in
181
- [`spec/integration/api_schema_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/integration/api_schema_spec.rb).
182
-
183
- Two bounds. The directive is matched by the **local name it was linked under**, so
184
- an `import:` alias subtracts what that alias marks. And the subtraction runs
185
- **only on the supergraph path** — plain and subgraph SDL are taken at face value,
186
- where `@inaccessible` stays a directive and its fields stay queryable. Directives
187
- that hide nothing keep their field and are ignored: `@requiresScopes` / `@policy`
188
- / `@authenticated` enforce at runtime, `@tag` / `@requires` / `@provides` /
189
- `@external` are metadata.
238
+ Name them yourself when you'd rather have the wiring committed, or when detection
239
+ can't settle it including partially, with the rest derived. Either way the map is
240
+ **checked** at construction, so a swapped pair fails naming what's missing rather
241
+ than surfacing as a mystery three fetches later:
190
242
 
191
- ### Contracts and variants
243
+ ```
244
+ subgraphs["accounts"] is Products::Schema, which doesn't define Query.me,
245
+ Query.user, Query.users, User, User.email and 1 more — the supergraph says
246
+ accounts resolves them. Did two entries get swapped?
247
+ ```
192
248
 
193
- A contract variant is a supergraph built from the same subgraphs with some
194
- coordinates filtered out: a subgraph marks them `@tag(name: "internal")`, and
195
- GraphOS builds a second supergraph where everything carrying that tag is
196
- `@inaccessible`. Nothing special is needed to generate against one it is a
197
- supergraph, and the `@inaccessible` subtraction above is exactly what makes it
198
- the narrower schema its clients see:
199
-
200
- ```graphql
201
- # reviews, the subgraph
202
- type User @key(fields: "id") {
203
- id: ID!
204
- supportTier: String! @tag(name: "internal")
249
+ Detection only sees what's **loaded**, and in Rails an autoloaded schema isn't until
250
+ something references it which is why an unmatched subgraph reads as absent. The
251
+ `federation:*` rake tasks eager-load the app for you; a spec suite is your own
252
+ `config.eager_load`, which Rails leaves off outside CI. To see what detection sees,
253
+ and get a map to paste:
254
+
255
+ ```
256
+ $ rake graph_weaver:federation:subgraphs
257
+ subgraphs: {
258
+ "accounts" => Accounts::Schema, # matched: defines Query.me, Query.user, Query.users
259
+ "products" => Products::Schema, # matched: defines Product.name, Product.price, Product.weight
205
260
  }
206
261
  ```
207
262
 
208
- ```graphql
209
- # the internal variant's supergraph the tag rides along, nothing is hidden
210
- supportTier: String! @join__field(graph: REVIEWS)
211
- # the public variant's supergraph what the contract build adds
212
- supportTier: String! @inaccessible @join__field(graph: REVIEWS)
263
+ A row nothing matched comes back `nil`, naming the coordinates it looked for —
264
+ that's the map to fill in, or the subgraph that lives elsewhere.
265
+
266
+ ### A supergraph only partly local
267
+
268
+ The usual migration shape: the supergraph is composed from several services and
269
+ only **some** of them run in your process. Requiring a Ruby schema for the rest
270
+ would refuse the whole suite over fields most of your queries never touch, so a
271
+ subgraph nothing here defines is **absent**, and the router builds and runs anyway.
272
+ Absence costs you exactly the queries that reach into it:
273
+
274
+ ```ruby
275
+ router.absent # => ["shipping"]
276
+ router.execute("{ me { username reviews { body } } }") # real data, as always
277
+ router.execute("{ shipments { carrier } }") # GraphWeaver::Testing::Unplannable
278
+ ```
279
+
280
+ That refusal is a plan-time one like every other, so nothing has executed when it
281
+ raises, and it names the subgraph, the field that reached for it, and both ways out
282
+ — name a schema for it, or fake it:
283
+
284
+ ```ruby
285
+ subgraphs: { "shipping" => :fake } # any other absent subgraph still refuses
213
286
  ```
214
287
 
215
- Generate a query selecting `supportTier` against **internal** and it generates;
216
- against **public**, codegen refuses at the boundary
217
- `QueryValidationError` naming `supportTier`, because the load subtracted the
218
- field before validation saw it. That is the guarantee: a client generated
219
- against the variant it calls cannot select something the router will reject.
220
-
221
- Two things to know, both of which bit this document into existence:
222
-
223
- - **`federation:diff` can't tell the variants apart.** It reads the routing
224
- table, and `@inaccessible` is a directive the table doesn't carry, so both
225
- variants report "matches the schemas here" against the same subgraphs. That
226
- is the same [directive blindness](#has-the-supergraph-been-recomposed) as
227
- everywhere else, not a contract-specific hole.
228
- - **Nothing cross-checks the variant you generated against with the endpoint
229
- you call.** `verify`, `queries:check` and `generate` take the schema as
230
- given; generate against internal, deploy against public, and every check
231
- stays green until a live 400. Point each graph at the variant it actually
232
- calls, and let the graph declaration be the single place that says so.
233
-
234
- One app calling two variants is [two graphs](getting_started.md#more-than-one-schema)
235
- — each with its own `schema`, `queries`, `output` and a `namespace` if any
236
- module name would collide. There is no contract-specific spelling; a variant is
237
- mechanically just another graph.
288
+ If the subgraph *is* here and detection just couldn't see it — a Rails schema class
289
+ nothing has referenced yet loading it is the fix, which in a spec suite means
290
+ `config.eager_load = true`. `=> :fake` is the other one: mid-migration, letting an
291
+ absent subgraph answer with schema-correct fabricated data exercises the rest of
292
+ the query. It speaks the whole subgraph contract, `_entities(representations:)`
293
+ included, so it works under a stitched fetch as well as at a root field.
294
+
295
+ Refusing stays the default, and the opt-in is **per subgraph** on purpose: silently
296
+ substituting invented data is the failure mode this library keeps designing
297
+ against. For the same reason faking is **loud** every faked fetch is marked
298
+ `faked: true` in `router.trace` and logged at `:warn`, `router.faked` lists them,
299
+ and `router.inspect` shows what's served, faked and absent. Values come from the
300
+ same engine as [`graphql: :fake`](testing.md#fabricated-data--graphql-fake), and
301
+ `fake:` says how they fabricate: the [pins](testing.md#pins) and options a fake
302
+ takes, in one hash, on `Router.new` outside rspec, on `Testing.config.router` for
303
+ the suite, or on `graphql_router(fake: { "Shipment.carrier" => "UPS" })` for the one
304
+ example that cares. One `fake:` covers every faked subgraph, since a pin's key
305
+ already says which type it means, and the router is still built once for the suite
306
+ only the options last one example.
238
307
 
239
- ### The routing table
308
+ ## Generating against a subgraph
240
309
 
241
- Stripping the machinery answers "what does this graph look like". The other
242
- question a supergraph answers is "who resolves what", and
243
- `SchemaLoader.routing_table` keeps that side rather than discarding it:
310
+ A raw subgraph SDL `rover subgraph fetch`, `_service { sdl }`, or the `.graphql`
311
+ in a service repo loads too. It applies `@key`/`@external`/`@shareable`/… without
312
+ declaring them (federation v1 leaves them implicit, v2 imports them via `@link`),
313
+ so the missing definitions are supplied on load; anything the file declares itself
314
+ wins. Whatever the `@link` header says the directives are called is what's supplied
315
+ — the bare `@key`, the namespaced `@federation__key`, or `@primaryKey` from
316
+ `import: [{name: "@key", as: "@primaryKey"}]`. The header itself is read and then
317
+ dropped: it describes the file, not the graph. The federation directives generate
318
+ no code either way — codegen is query-driven.
319
+
320
+ Reach for this when the subgraph is what you have, or to type an `_entities` query.
321
+ But a subgraph is one service's slice of the graph, and its field shapes are not
322
+ always the composed ones (an `@external` field is a reference, not something that
323
+ subgraph serves) — for a client of the whole graph, feed the composed artifact.
324
+
325
+ ### `_entities`
326
+
327
+ Every subgraph serves the entity resolver
328
+ `_entities(representations: [_Any!]!): [_Entity]!`, and **no subgraph SDL contains
329
+ it**: `_service { sdl }` and `rover subgraph fetch` print the *published* schema,
330
+ where the plumbing is implicit. So it's supplied on the subgraph path — `_Any`,
331
+ `_Service`, and an `_Entity` union over the file's own `@key`'d types — the same
332
+ way the `@key`/`@external` definitions are. A file that declares its own keeps it.
333
+
334
+ The read side is a normal union selection; `alias:` turns the single-entity case
335
+ into a clean accessor (see
336
+ [flat accessors](generated_modules.md#flat-accessors-with-alias)):
244
337
 
245
338
  ```ruby
246
- table = GraphWeaver::SchemaLoader.routing_table("supergraph.graphql")
339
+ GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional: true)
340
+ ```
247
341
 
248
- table.subgraphs # => ["accounts", "products", "reviews"]
249
- table.owners("Product", "shippingEstimate") # => ["reviews"]
250
- table.owners("User", "username") # => ["accounts"] — the @external copy isn't an owner
251
- table.keys("User", "accounts") # => [["id"]]
252
- table.field("Product", "shippingEstimate").requires # => "price weight"
253
- table.possible_types("Purchasable", "products") # => ["Bundle", "Product"]
342
+ The **input** side is generated. A representation must carry `__typename` and
343
+ satisfy one of the entity's `@key` field sets — both hard requirements of the
344
+ subgraph spec, and neither expressible in a bare `[_Any!]!`so a query selecting
345
+ entities gets a `Representations` builder per entity it reaches, typed from the
346
+ `@key` directives:
347
+
348
+ ```ruby
349
+ UserQuery::Representations.user(id: "1")
350
+ # => {"__typename" => "User", "id" => "1"}
351
+
352
+ UserQuery.execute(reps: [UserQuery::Representations.user(id: "1")])
353
+ ```
354
+
355
+ Key field sets are selection sets, so they're parsed as such:
356
+
357
+ | `@key(fields:)` | Builder |
358
+ |---|---|
359
+ | `"id"` | `Representations.user(id: "1")` |
360
+ | `"upc sku"` (compound) | `Representations.product(upc: "u", sku: 42)` |
361
+ | `"id organization { id }"` (nested) | `Representations.listing(id: "1", organization: { id: "o" })` |
362
+ | `"id lineItems { sku }"` over a `[LineItem!]!` | `Representations.order(id: "1", line_items: [{ sku: "a" }, { sku: "b" }])` |
363
+ | `"id"` **and** `"serial"` (alternatives) | `Representations.variant(id: "1")` *or* `(serial: "s")` |
364
+
365
+ A key field the schema declares as a **list** takes a list, and stays one on the
366
+ wire — a single object there would describe an entity that doesn't exist. A type
367
+ with one `@key` types its fields as **required kwargs**, so an incomplete
368
+ representation is an `srb tc` error rather than a round trip; what a sig can't say
369
+ is checked at runtime and raises `GraphWeaver::InputError` naming the type and the
370
+ field:
371
+
372
+ ```
373
+ Variant representation satisfies none of its @keys — supply "id", or "serial"
374
+ Listing representation is missing @key "organization.id"
375
+ Product representation sku: expected an Int, got "forty-two"
254
376
  ```
255
377
 
256
- Subgraphs are named the way `@join__graph(name:)` names them the strings a
257
- router config and `rover` use, not the SDL's uppercase enum spelling. A `@key`
258
- field set comes back as dotted paths (`"id organization { id }"` `["id",
259
- "organization.id"]`), so a nested one is recognizable by its shape. A field
260
- with no `@join__field` at all lives wherever its type does; that omission is
261
- how the composer says "everywhere". `possible_types` answers the abstract
262
- side the concrete types one subgraph can answer a union or interface with,
263
- from `@join__unionMember`/`@join__implements` and `nil` where the supergraph
264
- doesn't say, which is a different fact from "none".
265
-
266
- A `@join__` directive the table hasn't been taught lands in `#unsupported`
267
- rather than being skipped, and callers refuse on a non-empty list: a table that
268
- silently ignores half a spec version answers confidently and wrongly.
269
- `#interface_objects` is the one construct kept out of that list
270
- (`{"Media" => ["catalog"]}`), because it's a fact about one *type* rather than
271
- about the table — the router refuses the queries that reach it and plans the rest.
272
-
273
- The table is what [`Testing::Router`](#the-local-router) plans against, and it's
274
- a reasonable read on its own — "which subgraph owns this field" is the sentence a
275
- good error message wants. So when the schema dump is a composed supergraph,
276
- `rake graph_weaver:queries:check` brands each validation error with the subgraphs
277
- behind the type it names:
378
+ Key fields take the same loose input an `execute` kwarg does, so a `params[:sku]`
379
+ String converts to the `Int` the `@key` declares. A `@key` field whose name a
380
+ generated method can't take as a kwarg `class`, `hash`, or a Ruby keyword — takes
381
+ a trailing underscore, the same one its prop took, and still sends the schema's
382
+ spelling on the wire: a subgraph's `@key` field is not yours to rename, so weaver
383
+ renames its own side rather than refusing. Only the declared key fields reach the
384
+ wire, builders are emitted only for the entities a query actually selects, and a
385
+ `@key(..., resolvable: false)` builds nothing, since it declares a key this subgraph
386
+ does *not* answer for. Every shape above is a named example in
387
+ [`spec/federation_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/federation_spec.rb).
278
388
 
389
+ **Off-label**, a subgraph that generates against its *own* SDL gets a typed gate for
390
+ a raw incoming representation, before `resolve_reference` sees one — with two
391
+ bounds: a single-`@key` builder takes required kwargs, so splatting a hash that is
392
+ short a field raises Ruby's `ArgumentError` rather than an `InputError`, and where
393
+ two key sets overlap the first fully supplied one wins silently.
394
+
395
+ ## A subgraph that calls its own graph
396
+
397
+ A subgraph resolver that reaches for the composed graph — a cross-cutting report, a
398
+ field easier to answer through the gateway than by hand — is an ordinary thing to
399
+ write and the one shape **every** test tier is blind to, because each of them
400
+ intercepts exactly that call. Under `:in_process` the client points back at the
401
+ subgraph under test, so the loopback runs against a schema with none of those root
402
+ fields. Under `:router` it re-enters the router it is already inside. Under `:wire`
403
+ it is a second POST to the endpoint WebMock has stubbed. All three answer; none of
404
+ them is the address production will use.
405
+
406
+ So give the call its own graph, beside the one for the subgraph itself, with a
407
+ client distinct from the one the app uses to reach the gateway from outside:
408
+
409
+ ```ruby
410
+ # config/initializers/graph_weaver.rb — beside GraphWeaver.graph :reviews,
411
+ # whose client is Reviews::Schema
412
+ GraphWeaver.graph :platform do
413
+ schema "app/graphql/supergraph.graphql"
414
+ queries "app/graphql/platform/queries"
415
+ output "app/graphql/platform/generated"
416
+ client GraphWeaver.new(ENV.fetch("PLATFORM_GRAPHQL_URL"))
417
+ namespace "Platform"
418
+ end
279
419
  ```
280
- app/graphql/queries/product.graphql
281
- 4:5 Field 'dimensions' doesn't exist on type 'Product' (products, reviews)
420
+
421
+ The resolver then calls `Platform::ProductsQuery.execute!` instead of
422
+ `GraphWeaver.client`, which buys three things. The target is named where someone
423
+ deploying can see it (`rake graph_weaver:graphs` prints it, and `ENV.fetch` fails at
424
+ boot rather than at the first request). A helper stands in for one graph at a time,
425
+ so a spec has to say `graphql_router(graph: :reviews)`, and can't cover the
426
+ loopback by accident. And with two graphs `GraphWeaver.client` under a mode refuses
427
+ by name rather than reaching a real endpoint, so a stray call is loud.
428
+
429
+ What none of that gives you is proof the url resolves. That is a smoke request
430
+ against a running gateway, and it is the only thing that will.
431
+
432
+ ## Producing a supergraph
433
+
434
+ GraphWeaver consumes a supergraph; composing one is Apollo's job. Composition takes
435
+ one SDL file per subgraph, so the question is where each file comes from.
436
+
437
+ **Yours.** [`apollo-federation`](https://github.com/Gusto/apollo-federation-ruby)
438
+ is the gem that makes a graphql-ruby schema a subgraph — `@key`,
439
+ `resolve_reference`, the directives — and it adds `federation_sdl`, which prints
440
+ exactly what a composer wants:
441
+
442
+ ```ruby
443
+ File.write("supergraph/accounts.graphql", Accounts::Schema.federation_sdl)
282
444
  ```
283
445
 
284
- `Product.dimensions` says what broke; `(products, reviews)` says whose code to
285
- look at. `check_queries` carries the same list as a `"subgraphs"` key. A plain
286
- schema has no routing table, so nothing changes for it.
446
+ Two traps in that gem. Declare `orphan_types` **before** `query`: it computes the
447
+ `_Entity` union when `query` is called, so an `orphan_types` after it drops those
448
+ types from the printed SDL and from `_entities` with no error at all. That is also
449
+ what an **extend-only type** needs — one this subgraph adds fields to but never
450
+ returns from its own `Query` — since nothing reaches it from a root field, so
451
+ `federation_sdl` never prints it and the fields don't compose. Second, it writes its
452
+ directives **un-imported** (`@federation__key`), while `@apollo/composition`
453
+ (2.14.4) asserts on the short spelling: get a `fields:` argument wrong and instead
454
+ of "Cannot query field `nosuchfield` on type `User`" you get `Error: Unexpected
455
+ element: federation__key` and a JS stack. Only the diagnosis is lost — adding
456
+ `@link(import: ["@key", "@provides"])` gets the real message back.
457
+
458
+ **Everyone else's.** From the team that runs it: a file they publish, `rover
459
+ subgraph fetch` against their endpoint, or your schema registry.
287
460
 
288
- ### Has the supergraph been recomposed?
461
+ **Composed.** [`rover supergraph compose`](https://www.apollographql.com/docs/rover/commands/supergraphs)
462
+ reads a config naming each subgraph's routing url and SDL file, and prints the
463
+ supergraph to stdout:
289
464
 
290
- A committed supergraph is a snapshot of a composition. Change a subgraph and
291
- skip the recompose and it quietly describes a graph that no longer exists —
292
- the failure that bites a federated app mid-migration, and the one the other
293
- checks don't ask about. `graph_weaver:verify` asks whether the generated Ruby
294
- is fresh, `schema:diff` whether whatever your dump came from has drifted,
295
- `queries:check` whether drift broke a query. This asks whether the supergraph
296
- still describes your subgraphs:
465
+ ```yaml
466
+ # supergraph-config.yaml
467
+ federation_version: =2.14.4 # rover wants an exact one
468
+ subgraphs:
469
+ accounts:
470
+ routing_url: https://accounts.internal/graphql
471
+ schema: { file: ./accounts.graphql }
472
+ ```
297
473
 
298
474
  ```sh
299
- rake graph_weaver:federation:diff
475
+ rover supergraph compose --config supergraph-config.yaml \
476
+ --elv2-license accept > supergraph.graphql
300
477
  ```
301
478
 
302
- **Every `federation:*` task finds the supergraph the same way: it runs once per
303
- declared graph whose schema is a composed one, and heads each report with that
304
- graph's name.** An app that wrote `GraphWeaver.graph(:accounts) { schema
305
- "app/graphql/accounts/supergraph.graphql" }` has already said where its
306
- supergraph is, and a graph that is in none — a plain API schema, a live schema
307
- class is simply not a subject for these tasks. A single-schema app whose
308
- committed dump is a composed supergraph is that one graph, unnamed, and its
309
- output says nothing about graphs at all — until it declares a second graph
310
- beside it, at which point it has to name the first one too
311
- ([why](getting_started.md#more-than-one-schema)). `SUPERGRAPH=supergraph.graphql`
312
- overrides all of that for one run.
313
-
314
- `SUPERGRAPH=` reaches the `federation:*` tasks and **nothing else**: every other
315
- task reads the schema its graph declares, and one pointed at an ad-hoc
316
- supergraph would collapse a multi-graph app into a single unnamed graph — which
317
- for `generate` means pruning the generated files of every graph that graph
318
- didn't cover. So they refuse it rather than ignore it, which is what
319
- `SUPERGRAPH=… rake graph_weaver:queries:check` used to do while reporting every
320
- query valid. To check queries against a supergraph, declare it:
321
- `GraphWeaver.graph(:api) { schema "supergraph.graphql" }`.
479
+ `--elv2-license accept` accepts the Elastic license on the composition binary rover
480
+ downloads; without it rover asks, and a CI job has nobody to answer. Commit the
481
+ result from here it is an ordinary schema dump, and
482
+ [`federation:diff`](#has-the-supergraph-been-recomposed) is what catches it going
483
+ stale. Already on a node toolchain? `@apollo/composition` composes in-process with
484
+ no rover install; the suite does it that way, in
485
+ [`recompose.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/support/federation/recompose.rb)
486
+ driving [`compose.mjs`](https://github.com/dpep/graph_weaver/blob/main/spec/support/federation/compose.mjs).
487
+
488
+ ## Has the supergraph been recomposed?
322
489
 
323
- It reads the routing table and the subgraph schemas loaded in this process
324
- **no network** and exits non-zero on drift, so CI can gate on it ([in
325
- CI](#in-ci)):
490
+ A committed supergraph is a snapshot of a composition. Change a subgraph and skip
491
+ the recompose and it quietly describes a graph that no longer exists the failure
492
+ that bites a federated app mid-migration, and the one the other checks don't ask
493
+ about. `verify` asks whether the generated Ruby is fresh, `schema:diff` whether
494
+ whatever your dump came from has drifted, `queries:check` whether drift broke a
495
+ query. This asks whether the supergraph still describes your subgraphs, reading the
496
+ routing table and the subgraph schemas loaded in this process — **no network** —
497
+ and exiting non-zero on drift, so CI can gate on it:
326
498
 
327
499
  ```
500
+ $ rake graph_weaver:federation:diff
328
501
  supergraph.graphql: 1 stale, 1 shape, 1 not composed in (checked 1 of 3 subgraphs)
329
502
 
330
503
  stale — the supergraph carries these, no schema here defines them (recompose):
@@ -344,160 +517,124 @@ not checked — answered with fabricated data:
344
517
  ```
345
518
 
346
519
  Three kinds, because they mean different things: **stale** is "recompose",
347
- **shape** is "recompose" for a field neither side dropped, and **not composed
348
- in** is "publish the subgraph". Stale and shape name the subgraph the
349
- supergraph blames whose code to look at, whose team to talk to. Field-set
350
- comparison is deliberately looser than equality, since a subgraph carries
351
- plumbing (`_entities`, `_service`) no supergraph has and a field can
352
- legitimately sit in more than one subgraph (`@external` copies, `@shareable`) —
353
- so one candidate schema agreeing settles it.
354
-
355
- **What is compared is a coordinate's presence and its type, and nothing else.**
356
- `Warehouse.code` going from `String!` to `ID!` under a committed composition is
357
- the shape row above; the `@key` it is part of, a field's arguments, its
358
- directives, and everything a type says about itself beyond its fields are not
359
- read, and a change to any of them still reports clean. The one asymmetry worth
360
- knowing is an `@override` migration: a supergraph
361
- published *ahead* of the code is caught (the old side's field is `stale`), but
362
- code ahead of the supergraph — the new side declaring the field with
363
- `@override` before the recompose — is not, because both sides carry the field
364
- and its type, and only the `@override` marker, which lives in
365
- apollo-federation's own bookkeeping rather than in the schema this reads, says
366
- ownership is moving. Recompose and the report catches up; until then it is the
367
- one drift this check can't see. The check compares **schema objects**, not SDL
368
- text, so how a subgraph spells its directives `@key`, `@federation__key`, or a
369
- name it imported them under can't affect what it reports.
370
-
371
- **Finish an `@override` migration from the old side.** Once the new owner
372
- resolves the field, the original owner's copy is dead code that composition
373
- still reads — so deleting the *new* side's `@override` copy first doesn't remove
374
- the field from the graph, it hands ownership back to the subgraph you were
375
- migrating away from, and every check here reports clean because the coordinate
376
- and its type never moved. Delete the old owner's copy first; the recompose after
377
- that is what makes the field's removal visible at all.
378
-
379
- ### Two changes every gate calls clean
380
-
381
- `federation:diff` reads coordinates and types; `queries:check` and `generate`
382
- read what a query *says*. Two ordinary schema changes fall between them, and on
383
- both, `schema:diff`'s `breaking: true` line is the only warning anyone gets —
384
- once, in the run that first sees it:
385
-
386
- - **A scalar swapped for one that serializes the same way.** `Widget.price`
387
- going `String!` → `Currency!` is a breaking row in `schema:diff`. But a client
388
- that hasn't regenerated has `const :price, String`, and a `Currency` that
389
- still arrives as a JSON string — `"$19.99"` where `"19.99"` used to be —
390
- satisfies that prop exactly. Sorbet asks whether it is *a* String, which it
391
- is; nothing downstream of that knows the format changed, and `"$19.99".to_f`
392
- is `0.0`. Regenerating is what surfaces it, and only if the client
393
- [registers the scalar](scalars.md#registering-a-class-of-your-own) rather than
394
- leaving it a String.
395
- - **An enum value removed.** `Status.ACTIVE` disappearing is a breaking row too,
396
- and `queries:check` and `generate` are both clean for every query that selects
397
- a `status` field without naming `ACTIVE` in the document — validation has
398
- nothing to check a value against unless the value is written down. The
399
- generated `T::Enum` keeps the constant and keeps deserializing it; the server
400
- simply never sends it again. Harmless in itself, and a live signal that the
401
- branch handling it is dead.
402
-
403
- So a supergraph owner announcing either of these should not expect a client's CI
404
- to notice. Deprecate first — `schema:diff` reports a deprecation's arrival, and
405
- that is the one place it shows up, since generated code carries no trace of it.
406
-
407
- **A supergraph is routinely only partly local**, so the report names three
408
- states rather than two: checked, not here (running elsewhere — or the subgraph
409
- is gone), and [faked](#the-local-router). A clean report that quietly checked one
410
- subgraph of three would be actively misleading, so the headline counts them and
411
- the sections name them. Only drift fails the task; absence is a supported
412
- setup. Checking **none** of them fails too — "checked 0 of 4" attached to exit 0
413
- is a gate that passes whatever the subgraphs say. (It won't come up in a Rails
414
- app whose subgraphs are here: the `federation:*` tasks eager-load for you — see
415
- [which schema serves which subgraph](#which-schema-serves-which-subgraph).)
416
-
417
- The mirror of all that is a subgraph **retired** from the composition whose Ruby
418
- class is still loaded. Every check here walks the supergraph's subgraph list, so
419
- that one sat on the only side nothing looked at, and the report read "matches
420
- the schemas here (checked 3 of 3 subgraphs)". It is now named, on stderr:
421
-
422
- ```
423
- not placed — no subgraph of any supergraph read here is:
424
- Reviews::Schema
425
- ```
520
+ **shape** is "recompose" for a field neither side dropped, and **not composed in**
521
+ is "publish the subgraph". Stale and shape name the subgraph the supergraph blames.
522
+ Field-set comparison is deliberately looser than equality, since a subgraph carries
523
+ plumbing no supergraph has and a field can legitimately sit in more than one
524
+ (`@external` copies, `@shareable`), so one candidate schema agreeing settles it.
525
+
526
+ **A supergraph is routinely only partly local**, so the report counts three states:
527
+ checked, not here, and [faked](#a-supergraph-only-partly-local). Only drift fails
528
+ the task absence is a supported setup but checking **none** of them fails too,
529
+ since "checked 0 of 4" attached to exit 0 is a gate that passes whatever the
530
+ subgraphs say. The mirror of that is a subgraph **retired** from the composition
531
+ whose Ruby class is still loaded: every check walks the supergraph's subgraph list,
532
+ so that one sits on the side nothing looks at, and it is named on stderr as `not
533
+ placed`. A warning rather than drift, deliberately — a process that loads a
534
+ subgraph of a supergraph this run never reads is the same picture. (A schema is a
535
+ subgraph here if it serves `Query._service`.)
536
+
537
+ **Every `federation:*` task finds the supergraph the same way**: once per declared
538
+ graph whose schema is a composed one, each report headed with that graph's name. A
539
+ graph that is in none is simply not a subject for these tasks.
540
+ `SUPERGRAPH=supergraph.graphql` overrides that for one run and reaches the
541
+ `federation:*` tasks and **nothing else** every other task reads the schema its
542
+ graph declares, and one pointed at an ad-hoc supergraph would collapse a
543
+ multi-graph app into a single unnamed graph, so they refuse it rather than ignore
544
+ it. To check queries against a supergraph, declare it:
545
+ `GraphWeaver.graph(:api) { schema "supergraph.graphql" }`.
426
546
 
427
- A **warning, not drift**, deliberately: a process that loads a subgraph of a
428
- supergraph this run never reads is the same picture, and nothing on either side
429
- tells the two apart. A schema is a subgraph here if it serves `Query._service`,
430
- which is how a gateway reads one to compose it. `Drift#unplaced` is the list for
431
- one supergraph; the task asks every graph in the run, so a multi-graph app's
432
- second supergraph places its own schemas rather than having them reported
433
- against the first.
434
-
435
- A schema is recognized by the types the supergraph says its subgraph declares,
436
- plus at least one coordinate attributed to that subgraph **alone**. What two
437
- subgraphs share can't tell them apart — every subgraph has a `Query`, and the
438
- entity `accounts` and `prefs` both extend is declared by both — so a subgraph
439
- whose own fields are nowhere in this process is "not here", not stale.
440
- Detection is therefore what drift breaks, so the same
441
- `subgraphs:` map [`Testing::Router`](#the-local-router) takes is accepted here,
442
- and a named schema skips detection:
547
+ `Drift` is the API under the task, and it takes the same `subgraphs:` map
548
+ [`Testing::Router`](#the-local-router) does which matters twice. **Name the schema
549
+ whenever you are diffing a *proposal***, since detection unions every loaded schema
550
+ that fits a subgraph, so a console session holding both the changed SDL and the
551
+ unmodified class reports clean. And because `Drift` **never calls a resolver**, it
552
+ takes what the router can't a **subgraph SDL** from a non-Ruby team loads into a
553
+ resolver-less schema it compares like any other:
443
554
 
444
555
  ```ruby
445
556
  require "graph_weaver/federation" # the rake tasks do this for you
446
557
 
447
558
  GraphWeaver::Federation::Drift.new(
448
559
  supergraph: "supergraph.graphql",
449
- subgraphs: { "products" => Products::Schema, "inventory" => :fake },
560
+ subgraphs: { "products" => Products::Schema, "inventory" => :fake,
561
+ "accounts" => GraphWeaver::SchemaLoader.load(File.read("accounts.graphql")) },
450
562
  ).report
451
563
  ```
452
564
 
453
565
  `#to_h` is the JSON-ready `{"stale" => …, "shape" => …, "uncomposed" => …,
454
566
  "skipped" => …, "faked" => …}` — a `shape` entry is `{"subgraphs" => […],
455
- "supergraph" => "String!", "here" => ["ID!"]}` — and `#drift?` is what the task
456
- exits on. `#unplaced` sits
457
- outside both, being the warning above rather than drift.
567
+ "supergraph" => "String!", "here" => ["ID!"]}` — `#drift?` is what the task exits
568
+ on, and `#unplaced` is the warning above rather than drift.
458
569
 
459
- **`Drift` never calls a resolver**, so what it accepts in that map is wider than
460
- what `Testing::Router` needs. A **subgraph SDL** — `rover subgraph fetch`,
461
- `_service { sdl }`, whatever a non-Ruby team publishes — loads into a
462
- resolver-less schema that `Drift` compares like any other:
570
+ ### What federation:diff can't see
463
571
 
464
- ```ruby
465
- GraphWeaver::Federation::Drift.new(
466
- supergraph: "supergraph.graphql",
467
- subgraphs: { "accounts" => GraphWeaver::SchemaLoader.load(File.read("accounts.graphql")) },
468
- ).report
572
+ **What is compared is a coordinate's presence and its type, and nothing else.** The
573
+ `@key` it is part of, a field's arguments, its directives, and everything a type
574
+ says about itself beyond its fields are not read, and a change to any of them
575
+ reports clean. Two ordinary subgraph edits land in that gap, and both break the
576
+ *next* composition while the report stays green: a **`@key` added, removed or
577
+ turned `resolvable: false`** is a directive, so nothing here reads it (the last of
578
+ those makes recomposition impossible and still reports clean); and a **field one
579
+ subgraph adds that another already owns, neither marked `@shareable`**, is
580
+ invisible because the coordinate is already in the supergraph, so "not composed in"
581
+ doesn't fire, while its original owner still declares it, so neither does "stale".
582
+
583
+ So **`federation:diff` answers "did you forget to recompose the schema you have",
584
+ never "would the next recompose succeed"** — the second question needs the JS
585
+ composer, a network and npm dependency this deliberately doesn't take. A clean
586
+ report says so on its own line rather than leaving you to infer it:
587
+
588
+ ```
589
+ supergraph.graphql: matches the schemas here, field for field and type for type (checked 3 of 3 subgraphs)
590
+ not compared: @key (added, removed, or made unresolvable), and one field two subgraphs define without @shareable — recompose to catch those
469
591
  ```
470
592
 
471
- That is the real answer to "what does a Python subgraph look like to these
472
- tasks": to the router it is `:fake` or absent, but to `federation:diff` it is a
473
- fully compared citizen.
593
+ An `@override` migration is asymmetric for the same reason: a supergraph published
594
+ *ahead* of the code is caught (the old side's field is `stale`), but code ahead of
595
+ the supergraph is not, because both sides carry the field and its type and only the
596
+ `@override` marker says ownership is moving. **Finish an `@override` migration from
597
+ the old side** — deleting the *new* side's copy first hands ownership back to the
598
+ subgraph you were migrating away from, and every check reports clean because the
599
+ coordinate never moved.
474
600
 
475
- **Name the schema whenever you are diffing a *proposal*.** Detection unions
476
- every loaded schema that fits a subgraph, so a console session that builds the
477
- changed SDL while the unmodified `Reviews::Schema` is still loaded reports
478
- clean the real class supplies the field the proposal dropped, and both are
479
- candidates for "reviews". `subgraphs:` naming the one you mean is what makes
480
- the answer about your proposal.
601
+ ### Two changes every gate calls clean
602
+
603
+ `federation:diff` reads coordinates and types; `queries:check` and `generate` read
604
+ what a query *says*. Two ordinary schema changes fall between them, and on both,
605
+ `schema:diff`'s `breaking: true` line is the only warning anyone gets once, in
606
+ the run that first sees it:
607
+
608
+ - **A scalar swapped for one that serializes the same way.** `Widget.price` going
609
+ `String!` → `Currency!` leaves a client that hasn't regenerated with
610
+ `const :price, String`, which a `Currency` arriving as a JSON string — `"$19.99"`
611
+ where `"19.99"` used to be — satisfies exactly. Sorbet asks whether it is *a*
612
+ String, which it is, and `"$19.99".to_f` is `0.0`. Regenerating surfaces it, and
613
+ only if the client [registers the scalar](scalars.md#registering-a-class-of-your-own).
614
+ - **An enum value removed.** `queries:check` and `generate` are clean for every
615
+ query that selects a `status` field without naming `ACTIVE` in the document —
616
+ validation has nothing to check a value against unless the value is written down.
617
+ The generated `T::Enum` keeps the constant and keeps deserializing it; the server
618
+ simply never sends it again.
619
+
620
+ So a supergraph owner announcing either of these should not expect a client's CI to
621
+ notice. Deprecate first: `schema:diff` reports a deprecation's arrival, and that is
622
+ the one place it shows up, since generated code carries no trace of it.
481
623
 
482
624
  ## In CI
483
625
 
484
626
  `federation:diff` needs no network, so it belongs beside the other checks in the
485
- normal PR run — the [GitHub Actions job](getting_started.md#5-verify-in-ci) has
486
- the step. Add it where the subgraph classes live: an app that only *calls* the
487
- gateway loads none of them, and the task aborts rather than pass having checked
488
- nothing ([above](#has-the-supergraph-been-recomposed)).
489
-
490
- **What that job does not do is look at the schema production is serving**, and
491
- on a federated graph nothing here can. Every check in it compares the app to
492
- artifacts checked in beside it: `verify` to the generated Ruby, `queries:check`
493
- and `unused` to the dump, `federation:diff` to the subgraph classes in this
494
- process. `schema:diff` is the one that reads a live source, and it can't be
495
- pointed at a supergraph a composed supergraph records no source url because no
496
- endpoint serves one, and a production router refuses introspection by default
497
- (`{"message": "introspection has been disabled", "extensions": {"code":
498
- "INTROSPECTION_DISABLED"}}`). Hot-reload a router onto a supergraph that dropped
499
- a field your queries select and all of it still exits 0 while every one of those
500
- requests fails.
627
+ normal PR run — the [GitHub Actions job](getting_started.md#5-verify-in-ci) has the
628
+ step. Add it where the subgraph classes live: an app that only *calls* the gateway
629
+ loads none of them, and the task aborts rather than pass having checked nothing.
630
+
631
+ **What that job does not do is look at the schema production is serving**, and on a
632
+ federated graph nothing here can: every check in it compares the app to artifacts
633
+ checked in beside it. `schema:diff` is the one that reads a live source, and it
634
+ can't be pointed at a supergraph a composed supergraph records no source url
635
+ because no endpoint serves one, and a production router refuses introspection by
636
+ default. Hot-reload a router onto a supergraph that dropped a field your queries
637
+ select and all of it still exits 0 while every one of those requests fails.
501
638
 
502
639
  That gap is Apollo's to close, and it has two commands for it:
503
640
 
@@ -506,209 +643,73 @@ rover subgraph check my-graph@prod --name products --schema products.graphql
506
643
  rover supergraph fetch my-graph@prod # then recompose and diff what you get back
507
644
  ```
508
645
 
509
- `rover subgraph check` asks GraphOS whether publishing this subgraph would break
510
- the composition or a client operation registered against the variant — the
511
- pre-merge half. `rover supergraph fetch` hands you the supergraph the router is
512
- running, which is the artifact `federation:diff` should be pointed at when what
513
- you want to know is "does the deployed graph still answer my queries", rather
514
- than "is my checked-in supergraph consistent with my checked-in subgraphs".
646
+ `rover subgraph check` asks GraphOS whether publishing this subgraph would break the
647
+ composition or a client operation registered against the variant — the pre-merge
648
+ half. `rover supergraph fetch` hands you the supergraph the router is running, which
649
+ is what `federation:diff` should be pointed at when the question is "does the
650
+ deployed graph still answer my queries".
515
651
 
516
- Nothing in this gem talks to GraphOS, and the runtime half is where a federated
517
- app finds out: a query the served supergraph rejects comes back with
652
+ Nothing in this gem talks to GraphOS, so without them the runtime is where a
653
+ federated app finds out: a query the served supergraph rejects comes back with
518
654
  `schema_stale?` true and a message naming the repair
519
- ([errors → stale schemas](errors.md#stale-schemas)). That is detection at the
520
- point of damage, which is exactly why the two `rover` commands belong in the
521
- same job as the five tasks.
522
-
523
- ## The local router
524
-
525
- Specs for a federated app have a bad choice: fake the whole graph, or boot a
526
- gateway. `Testing::Router` is the third one. It takes the composed supergraph
527
- and the Ruby schema classes serving its subgraphs, plans the query, and
528
- satisfies the [client contract](transports.md) — so a generated module runs
529
- against your **real resolvers**, in-process, with no gateway, no node and no
530
- sockets. It is not a mock: your resolvers run, which is the whole point.
655
+ ([errors → stale schemas](errors.md#stale-schemas)). That is detection at the point
656
+ of damage, which is why those two commands belong in the same job as the five tasks.
531
657
 
532
- ```ruby
533
- GraphWeaver.client = GraphWeaver::Testing::Router.new(
534
- supergraph: Rails.root.join("supergraph.graphql"),
535
- context: { current_user: user },
536
- )
537
- ```
658
+ ## Details
538
659
 
539
- `context:` reaches every subgraph, because every subgraph is a Ruby call here.
540
- Neither real transport does that with a request's **headers** — the gateway and
541
- the Apollo Router both start a subgraph call with none of the client's, unless
542
- you configure the forwarding — so don't let a spec conclude an auth header
543
- arrived somewhere it wouldn't.
544
-
545
- In rspec that's the [`graphql: :router`](testing.md#a-federated-graph--graphql-router)
546
- tag and there is nothing to pass — the tag builds it, once for the suite. It
547
- finds the supergraph where you have already said it is: `Testing.config.router
548
- = { supergraph: … }` if you named one there, else the schema a
549
- [graph](getting_started.md#more-than-one-schema) declares when that schema is
550
- composed, else the committed dump when *that* is. Two graphs may name one
551
- supergraph; two naming different ones is refused rather than picked between.
552
- `router.trace` records the fetches made since the last `reset_trace`, in order
553
- (subgraph, query, variables); the same lines go to `GraphWeaver.logger` at
554
- `:debug`. It **accumulates across executes**, because the question worth asking
555
- is which subgraphs a code path touched and a service object rarely runs one
556
- query. The rspec tag resets it before each example; outside rspec call
557
- `router.reset_trace` around the code path you're measuring.
558
-
559
- The count is the **local router's plan, not the gateway's**. The data is
560
- faithful — a real gateway answers byte-identically, or this refuses — but the
561
- cost isn't quite: everything crossing into one subgraph from one level rides one
562
- call, and a [`@requires` prefetch](#what-it-plans) is still its own call even
563
- when it goes to the same subgraph as the plain read beside it, where a gateway
564
- merges the two. A dashboard query a gateway does in 4 fetches takes 5 here. So
565
- assert on a **bound** (`expect(router.trace.size).to be <= 8`) or on the
566
- **subgraph set** (`router.trace.map { _1[:subgraph] }.uniq`): both move when an
567
- N+1 appears, and neither pins a number production doesn't have.
568
-
569
- The router hands back a result hash *above* the wire, so the transport your app
570
- ships never runs. When that transport is the thing under test — a caller tag, an
571
- APM header, mTLS — [`graphql: :wire`](testing.md#over-the-wire--graphql-wire)
572
- serves this same router at the endpoint your client posts to and leaves your
573
- client in place: real serialization, the same plan over the same resolvers,
574
- `from_h` over the server's own bytes, and a `context:` proc reading the headers
575
- that arrived. It refuses exactly what the router refuses — a hop, not a
576
- capability. The hop is served through webmock, so that tag needs
577
- `require "webmock/rspec"` in the spec helper —
578
- [testing](testing.md#over-the-wire--graphql-wire) has the rest.
579
-
580
- **[`examples/federation.rb`](https://github.com/dpep/graph_weaver/blob/main/examples/federation.rb)** is the whole shape
581
- in one runnable file, and the only example that needs no network: three real
582
- subgraphs, a boundary-crossing query through a generated module, the trace,
583
- and a refusal. [`spec/router_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/router_spec.rb) is the
584
- exhaustive reference — every plan shape, every refusal, the partly-local
585
- graph and the `:fake` opt-in, each as a named example.
586
-
587
- ### Which schema serves which subgraph
588
-
589
- `subgraphs:` is optional. Left out, each one is **derived from what the loaded
590
- schemas define**: a schema serves subgraph `s` when it defines every type and
591
- field the routing table says `s` resolves. That's evidence rather than a guess,
592
- and a wrong guess would point a suite at the wrong resolvers and still pass — so
593
- exactly one match is used. Neither other outcome refuses at construction, since
594
- which classes are loaded is not a fact about the query you're running: **no**
595
- match means the subgraph is served somewhere else (next section), and **two**
596
- means detection can't say which loaded schema class serves it. Both are refused
597
- by the query that reaches the subgraph's fields, each naming its own fix — for
598
- two, `subgraphs: { "reviews" => App::Reviews::Schema }` pins it, and
599
- `router.ambiguous` lists them.
660
+ ### The routing table
600
661
 
601
- Name them yourself when you'd rather have the wiring committed, or when
602
- detection can't settle it including partially, with the rest derived:
662
+ Stripping the machinery answers "what does this graph look like". The other question
663
+ a supergraph answers is "who resolves what", and `SchemaLoader.routing_table` keeps
664
+ that side rather than discarding it:
603
665
 
604
666
  ```ruby
605
- subgraphs: { "accounts" => Accounts::Schema } # products, reviews derived
606
- ```
607
-
608
- Either way the map is **checked** at construction, so a swapped pair fails
609
- naming what's missing rather than surfacing as a mystery three fetches later:
610
-
611
- ```
612
- subgraphs["accounts"] is Products::Schema, which doesn't define Query.me,
613
- Query.user, Query.users, User, User.email and 1 more — the supergraph says
614
- accounts resolves them. Did two entries get swapped?
615
- ```
616
-
617
- Detection only sees what's **loaded**, and in Rails an autoloaded schema isn't
618
- until something references it — which is why an unmatched subgraph reads as
619
- absent. The `federation:*` rake tasks eager-load the app for you; a spec suite
620
- is your own `config.eager_load`, which Rails leaves off outside CI. To see what
621
- detection sees, and get a map to paste:
667
+ table = GraphWeaver::SchemaLoader.routing_table("supergraph.graphql")
622
668
 
623
- ```
624
- $ rake graph_weaver:federation:subgraphs
625
- subgraphs: {
626
- "accounts" => Accounts::Schema, # matched: defines Query.me, Query.user, Query.users
627
- "products" => Products::Schema, # matched: defines Product.name, Product.price, Product.weight
628
- "reviews" => Reviews::Schema, # matched: defines Product.reviews, Product.shippingEstimate, Query.feed
629
- }
669
+ table.subgraphs # => ["accounts", "products", "reviews"]
670
+ table.owners("Product", "shippingEstimate") # => ["reviews"]
671
+ table.owners("User", "username") # => ["accounts"] — the @external copy isn't an owner
672
+ table.keys("User", "accounts") # => [["id"]]
673
+ table.field("Product", "shippingEstimate").requires # => "price weight"
674
+ table.possible_types("Purchasable", "products") # => ["Bundle", "Product"]
630
675
  ```
631
676
 
632
- A row nothing matched comes back `nil`, naming the coordinates it looked for
633
- that's the map to fill in, or the subgraph that lives elsewhere.
677
+ Subgraphs are named the way `@join__graph(name:)` names them the strings a router
678
+ config and `rover` use, not the SDL's uppercase enum spelling. A `@key` field set
679
+ comes back as dotted paths (`"id organization { id }"` → `["id",
680
+ "organization.id"]`). A field with no `@join__field` at all lives wherever its type
681
+ does; that omission is how the composer says "everywhere". `possible_types` answers
682
+ the abstract side, from `@join__unionMember`/`@join__implements`, and is `nil` where
683
+ the supergraph doesn't say — a different fact from "none". A `@join__` directive the
684
+ table hasn't been taught lands in `#unsupported` rather than being skipped, and
685
+ callers refuse on a non-empty list: a table that silently ignores half a spec
686
+ version answers confidently and wrongly. `#interface_objects` is the one construct
687
+ kept out of that list (`{"Media" => ["catalog"]}`), being a fact about one *type*
688
+ rather than about the table — the router refuses the queries that reach it and plans
689
+ the rest.
634
690
 
635
- ### A supergraph only partly local
636
-
637
- The usual migration shape: the supergraph is composed from several services and
638
- only **some** of them run in your process. The rest are routed over the network,
639
- so there is no Ruby schema here to serve them — and requiring one would refuse
640
- the whole suite over fields most of your queries never touch.
691
+ The table is also what a good error message wants. When the schema dump is a
692
+ composed supergraph, `rake graph_weaver:queries:check` brands each validation error
693
+ with the subgraphs behind the type it names, and `check_queries` carries the same
694
+ list as a `"subgraphs"` key:
641
695
 
642
- So a subgraph nothing here defines is **absent**, and the router builds and runs
643
- anyway. Absence costs you exactly the queries that reach into it:
644
-
645
- ```ruby
646
- router.absent # => ["shipping"]
647
- router.execute("{ me { username reviews { body } } }") # real data, as always
648
- router.execute("{ shipments { carrier } }") # GraphWeaver::Testing::Unplannable
649
696
  ```
650
-
651
- That refusal is a plan-time one like every other, so nothing has executed when
652
- it raises, and it names the subgraph, the field that reached for it, and both
653
- ways out — name a schema for it, or fake it:
654
-
655
- ```ruby
656
- subgraphs: { "shipping" => :fake } # any other absent subgraph still refuses
657
- ```
658
-
659
- If the subgraph *is* here and detection just couldn't see it — a Rails schema
660
- class nothing has referenced yet — loading it is the fix, and in a spec suite
661
- that means `config.eager_load = true` (naming it in
662
- `Testing.config.router = { subgraphs: … }` works too). `=> :fake` is the
663
- other one: mid-migration, letting an absent subgraph answer with
664
- schema-correct fabricated data exercises the rest of the query. It speaks the
665
- whole subgraph contract, `_entities(representations:)` included, so it works
666
- under a stitched fetch as well as at a root field.
667
-
668
- Refusing stays the default, and the opt-in is **per subgraph** on purpose:
669
- silently substituting invented data is the failure mode this library keeps
670
- designing against. For the same reason faking is **loud** — every faked fetch
671
- is marked `faked: true` in `router.trace` and logged at `:warn`.
672
- `router.faked` lists them, and `router.inspect` shows what's served, faked and
673
- absent. Values come from the same engine as
674
- [`graphql: :fake`](testing.md#fabricated-data--graphql-fake), so `config.seed`,
675
- `config.overrides` and the rest apply.
676
-
677
- `fake:` says how they fabricate — the [pins](testing.md#pins) and options a
678
- fake takes, in one hash. It goes on `Router.new` outside rspec, on
679
- `Testing.config.router` for the suite, and on `graphql_router` for the one
680
- example that cares:
681
-
682
- ```ruby
683
- Testing.config.router = { subgraphs: { "shipping" => :fake },
684
- fake: { list_size: 2 } }
685
-
686
- it "shows the carrier" do
687
- graphql_router(fake: { "Shipment.carrier" => "UPS" })
688
- ...
689
- end
697
+ app/graphql/queries/product.graphql
698
+ 4:5 Field 'dimensions' doesn't exist on type 'Product' (products, reviews)
690
699
  ```
691
700
 
692
- One `fake:` covers every faked subgraph, because a pin's key
693
- (`"Shipment.carrier"`) already says which type it means. And `graphql_router` is
694
- `graphql: :router` with somewhere to put arguments — the router itself is still
695
- built once for the suite, and the options last one example.
696
-
697
701
  ### What it plans
698
702
 
699
703
  An operation that resolves in **one subgraph** goes over verbatim. One that
700
704
  **crosses a boundary** is split at the crossing: the plan injects the entity's
701
705
  `@key` under a reserved alias, refetches it from the owning subgraph through
702
- `_entities(representations:)`, and stitches the answer back. Every node at one
703
- level goes in **one** `_entities` call, so a list of users and all their
704
- reviews' products is three fetches, not one per row and everything that level
705
- crosses into one subgraph shares that call, a `@requires` field alongside a
706
- plain one included. Root fields that resolve
707
- in different subgraphs get one fetch each. A `@provides` copy is read in place,
708
- so nothing leaves the subgraph for a field the copy already holds.
706
+ `_entities(representations:)`, and stitches the answer back. Every node at one level
707
+ goes in **one** `_entities` call, so a list of users and all their reviews' products
708
+ is three fetches, not one per row. Root fields that resolve in different subgraphs
709
+ get one fetch each, and a `@provides` copy is read in place.
709
710
 
710
- A **`@requires` field set** is supplied by the router rather than by the
711
- subgraph that declares the field, so it's a fetch before the fetch:
711
+ A **`@requires` field set** is supplied by the router rather than by the subgraph
712
+ that declares the field, so it's a fetch before the fetch:
712
713
 
713
714
  ```ruby
714
715
  router.reset_trace
@@ -717,24 +718,21 @@ router.trace.map { _1[:subgraph] } # => ["reviews", "products", "reviews"]
717
718
  ```
718
719
 
719
720
  `shippingEstimate` resolves in `reviews` and `@requires "price weight"`, which
720
- `products` owns — so the plan fetches those into hidden keys, hands them back
721
- in the representation, and only then asks for the estimate. One hop only: the
722
- key for the first fetch has to come from the subgraph already in hand, so a
723
- chain can't grow a chain. And two `@requires` field sets crossing into the same
724
- subgraph on the same `@key` ride **one** prefetch, as Apollo's do — the
725
- representations would be identical, so a second call would only re-run the
726
- resolvers.
727
-
728
- A **nested field set** `@key(fields: "id organization { id }")`,
729
- `@requires(fields: "origin { lat lon }")` is a selection set like any other,
730
- so it crosses as one: the fetch asks for `organization { id }` under a
731
- reserved alias, and the representation carries the object back in the shape
732
- the SDL spells it, to any depth, nulls and all. Where a type has more than one
733
- `@key`, the plan takes the first one the fetching subgraph can supply.
734
-
735
- A **union or interface at a boundary** — a feed, a search page, any
736
- polymorphic list — is planned per concrete type, because a representation names
737
- one concrete `__typename` and which one an object has isn't in the query:
721
+ `products` owns — so the plan fetches those into hidden keys, hands them back in the
722
+ representation, and only then asks for the estimate. One hop only: the key for the
723
+ first fetch has to come from the subgraph already in hand, so a chain can't grow a
724
+ chain. Two `@requires` sets crossing into the same subgraph on the same `@key` ride
725
+ one prefetch, as Apollo's do.
726
+
727
+ A **nested field set** — `@key(fields: "id organization { id }")` — crosses as one
728
+ object: the fetch asks for `organization { id }` under a reserved alias, and the
729
+ representation carries it back in the shape the SDL spells, to any depth, nulls and
730
+ all. Where a type has more than one `@key`, the plan takes the first the fetching
731
+ subgraph can supply.
732
+
733
+ A **union or interface at a boundary** is planned per concrete type, because a
734
+ representation names one `__typename` and which one an object has isn't in the
735
+ query:
738
736
 
739
737
  ```ruby
740
738
  router.reset_trace
@@ -742,136 +740,61 @@ router.execute("{ purchasables { name ... on Product { reviews { body } } } }")
742
740
  router.trace.map { _1[:subgraph] } # => ["products", "reviews"]
743
741
  ```
744
742
 
745
- The plan holds a branch per type the supergraph says that subgraph can answer
746
- with; the fetch asks for `__typename` under a reserved alias, and the objects
747
- that come back are bucketed by it — one `_entities` fetch per concrete type,
748
- none for a bucket nothing lands in. A fragment whose condition can't hold there
749
- (`... on Note` where that subgraph's union has no Note) never matches, so it is
750
- dropped, which is the answer a real router gives too.
751
-
752
- Three things it does that a naive merge doesn't, and that being wrong about
753
- would be worse than refusing:
754
-
755
- - **Null propagation over the merged tree.** A stitched fetch can put a null
756
- where the composed schema says non-null, and no subgraph is in a position to
757
- notice. The router re-applies GraphQL's propagation rules to the merged
758
- result, so a subtree the real router would have nulled comes back null here.
759
- - **Error re-pathing, and a stamp saying which subgraph failed.** A subgraph
760
- reports `_entities.2.shippingEstimate`; you get
761
- `topProducts.2.shippingEstimate`, with `extensions.service` naming the
762
- subgraph — the Apollo Router's spelling, because it is the current product
763
- (the deprecated `@apollo/gateway` says `extensions.serviceName` and adds
764
- `code: "DOWNSTREAM_SERVICE_ERROR"`, which isn't a code any subgraph here
765
- set). Whatever the resolver put in `extensions` is left alone, including its
766
- own `service`. `locations` are dropped rather than pointing into a query you
767
- never wrote. That is for a `GraphQL::ExecutionError`, which is what `errors`
768
- carries; a resolver that raises anything else propagates as a Ruby exception
769
- out of `execute`, the same way it would from graphql-ruby on its own, and
770
- nothing here catches it into a response.
771
- - **`@skip`/`@include` on a stitched field.** A skipped field comes back
772
- *absent*, not null.
773
-
774
- Introspection is answered from the composed API schema, never from a subgraph,
775
- which would reply with its own slice — the one split a real router also makes.
776
-
777
- **A wire fault at one subgraph has no representation here.** A subgraph is a
778
- Ruby call, not a socket: `:router` fetches in-process, and `:wire` stubs one
779
- endpoint in front of the whole router. So a timeout, an HTTP 500, malformed
780
- JSON, or a bare `errors` with no `data` — anything that is a property of the
781
- *transport* to one subgraph — is out of reach under `:router`; `:wire` plus
782
- `Failure` covers those faults for the one stubbed endpoint, which is the whole
783
- graph rather than any subgraph in it. A subgraph that *fails* is expressible
784
- — raise from its resolver, or fake it — but partial availability is a
785
- gateway's property, not this double's.
786
-
787
- ### What it refuses
788
-
789
- Everything it can't plan **faithfully** raises
790
- `GraphWeaver::Testing::Unplannable` (a `GraphWeaver::Error`), at plan time,
791
- before any subgraph runs — so a refusal is never a half-executed query. A double
792
- that approximated the rest of Apollo's planner would let a test pass on an
793
- answer production disagrees with, which is the most expensive thing this library
794
- can produce. Each refusal names the coordinate that stopped it and what to do —
795
- `examples/federation.rb` prints one.
796
-
797
- Every category, in the words `Unplannable#label` uses (`#category` is the
798
- matching symbol):
799
-
800
- | Refusal | Why |
801
- |---|---|
802
- | no `@key` to cross the boundary on | an entity fetch sends a representation built from a `@key`; with none there is nothing to send |
803
- | an abstract type the supergraph doesn't break down | bucketing needs the concrete types a subgraph answers a union or interface with, and `@join__unionMember`/`@join__implements` is where a supergraph records that. A composition old enough to carry neither leaves nothing but a guess |
804
- | an `@interfaceObject` the routing table can't attribute | one subgraph resolves a whole interface's implementations, so the supergraph never says which subgraph answers each of its fields. Per query, not per graph: a query that doesn't reach the type plans as if the directive weren't there |
805
- | a `@fromContext` argument no fetch here can supply | federation 2.8's `@context`/`@fromContext` fills a field's argument from a selection on an ancestor, and only the gateway that planned the fetch knows what to put there — a subgraph's own resolver never fills one, so this is refused on any path, including the one where a single subgraph answers the whole query. Per query, like `@interfaceObject`: a query that doesn't reach the field plans as if the directive weren't there |
806
- | a response delivered in more than one payload | `@defer`/`@stream` stream the rest of the answer over a multipart body after the first payload, and this router answers in one. Refused by name rather than left to validation, so the guarantee doesn't rest on whether the composed schema happens to declare the directive |
807
- | a progressive `@override` still rolling out | federation 2.7's `@override(label:)` leaves *both* subgraphs resolving the field — composition decides nothing, the gateway splits traffic per request by the label's rule. A local router can't evaluate a rollout percentage, so it would answer from one side every time. Finish the rollout (drop the label) and composition drops the losing copy, which plans normally |
808
- | a `@requires` whose field set names another `@requires` field | the router satisfies a `@requires` with one fetch, so it can't first satisfy that field's own requirement |
809
- | a nested field set no one fetch can build | a nested field set crosses as one object, so one fetch has to answer the whole of it. Nesting itself is fine — this is the set whose fields are split across subgraphs, so the object would arrive half-built from each |
810
- | `@skip`/`@include` on both a fragment and its field | one selection can't carry two conditions of the same name. Spell the condition once |
811
- | an alias shadowing an injected `@key` | a fetch carries the `@key` it crosses on under a response key — Apollo under the field's own name, the local router under a reserved one — and an alias spelling either claims a key the fetch needs |
812
- | a mutation's root fields span subgraphs | root mutation fields run in series, and splitting them across subgraphs would run them in whatever order the plan happened to. Sharing one subgraph they're fine, stitching below them and all — that's an ordinary read afterwards. Query roots are independent, so those are always fine |
813
- | the routing table names no subgraph | nothing can route a field the supergraph doesn't place |
814
- | a subgraph nothing here serves | it's served by another process, so there is nothing here to ask — unless you fake it (above) |
815
- | introspection mixed with data | introspection is answered from the composed API schema and data from the subgraphs, and the two can't be merged. Split them into two operations |
816
- | the document isn't one operation | pass `operation_name:` naming one of them |
817
- | not a query or a mutation | the router plans against the composed schema's query and mutation roots; a subscription has neither |
818
- | a fragment the document never defines | define it, or point the query at the file that does — validation rejects it first, so what you actually get back is an `errors` response |
819
- | a federation construct the routing table doesn't read | an incomplete table makes every answer about this supergraph a guess. The one refusal raised **at construction**, before a single query |
820
- | nested deeper than the router walks | past the walk's depth limit, which validation would have rejected first |
821
-
822
- A document that fails ordinary GraphQL validation — an undefined fragment, a
823
- subscription against a schema with no `Subscription` root — never reaches any of
824
- this: it gets the same `errors` response a plain client gets, not an
825
- `Unplannable`.
826
-
827
- The construction-time one is worth planning around. A `@join__` directive the
828
- table doesn't read refuses `Router.new` for the **whole graph**, so one team
829
- adopting a newer federation feature in their own subgraph is an upgrade-timing
830
- event for every team that tests with `:router` — including the ones whose
831
- queries never go near it.
832
-
833
- A subgraph two loaded schemas both fit raises a `ConfigurationError` rather than
834
- an `Unplannable` — it's a wiring mistake, not a query the router declines — but
835
- it raises where every other one does, on the query that reaches the subgraph.
836
- Which classes happen to be loaded is not a fact about the query under test.
837
- Naming a class in `subgraphs:` *is* a claim, so a wrong one still fails at
838
- construction.
839
-
840
- A double that quietly answered *differently* from the router would be worse than
841
- no double at all, so
743
+ The plan holds a branch per type the supergraph says that subgraph can answer with;
744
+ the fetch asks for `__typename` under a reserved alias and buckets what comes back
745
+ by it — one `_entities` fetch per concrete type, none for an empty bucket. A
746
+ fragment whose condition can't hold there is dropped, as a real router drops it.
747
+
748
+ Three things it does that a naive merge doesn't, and that being wrong about would be
749
+ worse than refusing: it **re-applies GraphQL's null propagation** to the merged tree,
750
+ where a stitched fetch can put a null the composed schema says can't be there and no
751
+ subgraph is in a position to notice; it **re-paths errors and stamps the subgraph**,
752
+ so a subgraph's `_entities.2.shippingEstimate` reaches you as
753
+ `topProducts.2.shippingEstimate` with `extensions.service` naming it (whatever the
754
+ resolver put in `extensions` is left alone, and `locations` are dropped rather than
755
+ pointing into a query you never wrote that is for a `GraphQL::ExecutionError`, and
756
+ a resolver raising anything else propagates out of `execute` as a Ruby exception);
757
+ and a field **`@skip`/`@include` removes comes back absent, not null**. Introspection
758
+ is answered from the composed API schema, never from a subgraph, which is the one
759
+ split a real router also makes.
760
+
761
+ **A wire fault at one subgraph has no representation here.** A subgraph is a Ruby
762
+ call, not a socket: `:router` fetches in-process, and `:wire` stubs one endpoint in
763
+ front of the whole router. So a timeout, an HTTP 500, malformed JSON, or a bare
764
+ `errors` with no `data` anything that is a property of the *transport* to one
765
+ subgraph is out of reach. A subgraph that *fails* is expressible (raise from its
766
+ resolver, or fake it), but partial availability is a gateway's property, not this
767
+ double's.
768
+
769
+ ### How faithful is it, really
770
+
771
+ A double that quietly answered *differently* from the router would be worse than no
772
+ double at all, so
842
773
  [`spec/integration/router_parity_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/integration/router_parity_spec.rb)
843
- serves the demo subgraphs over HTTP, boots a real `@apollo/gateway` on the same
844
- supergraph, and runs the whole corpus through both — plus boundary probes and
845
- queries where a subgraph deliberately **fails**, which are the cases where a
846
- merge that doesn't re-propagate hands back a populated tree while the real router
847
- answers `data: null`. Three outcomes, one of them a defect: match, refuse, or
848
- answer differently, and the spec fails on the third. `make integration` runs it
849
- (node required).
850
-
851
- **"A real router" is two things, and they disagree.** Everything measured above
852
- is against `@apollo/gateway` the deprecated JS gateway, which is what the
853
- parity spec boots. The Rust Apollo Router, which is the current product,
854
- differs from it on a subgraph 500's error shape, on malformed JSON from a
855
- subgraph, on `@defer` (it supports it, behind an `Accept: multipart/mixed`
856
- header; the gateway doesn't know the directive), and on introspection, which it
857
- disables by default. So when a refusal says "run this one against a real
858
- router", run it against the one you deploy not against the one these numbers
859
- came from.
860
-
861
- **And a production router shows you less than this one does.** A subgraph error
862
- arrives here with its message and an `extensions: {"service" => …}` stamp; an
863
- Apollo Router with `include_subgraph_errors` omitted — the default — answers
864
- `{"message" => "Subgraph errors redacted", "path" => […]}` with the extensions
865
- emptied. A spec asserting on the message or the stamp therefore passes here and
866
- against a dev router and fails in staging. `path` is what survives, so that is
867
- what to assert on: [testing → production redacts what this router hands
868
- you](testing.md#production-redacts-what-this-router-hands-you).
774
+ (`make integration`, node required) serves the demo subgraphs over HTTP, boots a
775
+ real `@apollo/gateway` on the same supergraph, and runs the whole corpus through
776
+ both — plus boundary probes and queries where a subgraph deliberately **fails**,
777
+ the cases where a merge that doesn't re-propagate hands back a populated tree while
778
+ the real router answers `data: null`. Match, refuse, or answer differently: the
779
+ spec fails on the third.
780
+
781
+ **But "a real router" is two things, and they disagree** — and a production one
782
+ shows you less than either. Everything above is measured against `@apollo/gateway`,
783
+ the deprecated JS gateway; the Rust Apollo Router differs on a subgraph 500's error
784
+ shape, on malformed JSON from a subgraph, on `@defer` (it supports it, behind an
785
+ `Accept: multipart/mixed` header) and on introspection, which it disables by
786
+ default and with `include_subgraph_errors` omitted it answers `{"message" =>
787
+ "Subgraph errors redacted", "path" => […]}` where this router hands you the
788
+ subgraph's own message and its `service` stamp. So run a refusal you're unsure
789
+ about against the router you deploy, and assert on `path` rather than a message
790
+ that only survives locally:
791
+ [testing → production redacts what this router hands you](testing.md#production-redacts-what-this-router-hands-you).
869
792
 
870
793
  ### Is it worth wiring up? Measure.
871
794
 
872
- The router's value is one number — the fraction of *your* queries it can plan —
873
- and that depends on the shape of your graph and of your queries, so measure it
874
- rather than guess:
795
+ The router's value is one number — the fraction of *your* queries it can plan — and
796
+ that depends on the shape of your graph and of your queries, so measure it rather
797
+ than guess:
875
798
 
876
799
  ```
877
800
  $ rake graph_weaver:federation:coverage
@@ -879,129 +802,19 @@ $ rake graph_weaver:federation:coverage
879
802
  accounts 4, reviews 4, products+reviews 3, accounts+reviews 2, products 2, accounts+products 1, accounts+products+reviews 1
880
803
  ```
881
804
 
882
- **Two numbers, because they answer different questions.** *Plannable* is about
883
- the graph — could the router split this query faithfully at all. *Servable
884
- here* is what your suite actually gets: every subgraph that plan reaches is one
885
- this process serves. In a [partly-local supergraph](#a-supergraph-only-partly-local)
886
- they differ, and the plannable number alone reads optimistically — sketched
887
- here on a graph whose `billing` and `shipping` run elsewhere:
888
-
889
- ```
890
- 5/5 queries plannable locally (100%), 2 servable here
891
- accounts 1, billing 1, reviews 1, reviews+shipping 1, shipping 1
892
-
893
- plannable, but nothing here serves what they reach (3) — name a schema for those subgraphs, fake them (subgraphs: { "shipping" => :fake }), or run these against a real router:
894
- invoices.graphql billing
895
- shipping_quotes.graphql shipping
896
- tracking.graphql shipping
897
- ```
898
-
899
- `QUERIES=` picks the directory; by default each graph's report measures that
900
- graph's own `queries`, since a query written against one supergraph says
901
- nothing about the next one along. Planning
902
- needs the supergraph and nothing else, so this runs in CI with the SDL alone —
903
- with no subgraph loaded the report drops the second number and says it counted
904
- planning only. The subgraph line says which subgraphs each query touches, and
905
- anything refused is listed after it grouped by category, so one glance says
906
- whether the gap is one construct or many. (The first run above is the demo graph
907
- in `spec/support/federation`, not a real app's mix.)
908
-
909
- ## Generating against a subgraph
910
-
911
- A raw subgraph SDL — `rover subgraph fetch`, `_service { sdl }`, or the
912
- `.graphql` in a service repo — loads too. It applies `@key`/`@external`/
913
- `@shareable`/… without declaring them (federation v1 leaves them implicit, v2
914
- imports them via `@link`), so the missing definitions are supplied on load;
915
- anything the file declares itself wins. Whatever the `@link` header says the
916
- directives are called is what's supplied — the bare `@key`, the namespaced
917
- `@federation__key`, or `@primaryKey` from
918
- `import: [{name: "@key", as: "@primaryKey"}]`. The header itself is read and
919
- then dropped: it describes the file, not the graph. The federation directives
920
- generate no code either way — codegen is query-driven.
921
-
922
- Reach for this when the subgraph is what you have, or to type an `_entities`
923
- query (below). But a subgraph is one service's slice of the graph, and its
924
- field shapes are not always the composed ones (an `@external` field is a
925
- reference, not something that subgraph serves) — for a client of the whole
926
- graph, feed the composed artifact.
927
-
928
- ### `_entities`
929
-
930
- Every subgraph serves the entity resolver
931
- `_entities(representations: [_Any!]!): [_Entity]!`, and **no subgraph SDL
932
- contains it**: `_service { sdl }` and `rover subgraph fetch` print the
933
- *published* schema, where the plumbing is implicit. So it's supplied on the
934
- subgraph path — `_Any`, `_Service`, and an `_Entity` union over the file's own
935
- `@key`'d types — the same way the `@key`/`@external` definitions are. A file
936
- that declares its own keeps it.
937
-
938
- The read side is a normal union selection; `alias:` turns the
939
- single-entity case into a clean accessor (see
940
- [flat accessors](generated_modules.md#flat-accessors-with-alias)):
941
-
942
- ```ruby
943
- GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional: true)
944
- ```
945
-
946
- The **input** side is generated. A representation must carry `__typename` and
947
- satisfy one of the entity's `@key` field sets — both hard requirements of the
948
- subgraph spec, and neither expressible in a bare `[_Any!]!`. So a query
949
- selecting entities gets a `Representations` builder per entity it can resolve,
950
- typed from the `@key` directives:
951
-
952
- ```ruby
953
- UserQuery::Representations.user(id: "1")
954
- # => {"__typename" => "User", "id" => "1"}
955
-
956
- UserQuery.execute(reps: [UserQuery::Representations.user(id: "1")])
957
- ```
958
-
959
- Key field sets are selection sets, so they're parsed as such:
960
-
961
- | `@key(fields:)` | Builder |
962
- |---|---|
963
- | `"id"` | `Representations.user(id: "1")` |
964
- | `"upc sku"` (compound) | `Representations.product(upc: "u", sku: 42)` |
965
- | `"id organization { id }"` (nested) | `Representations.listing(id: "1", organization: { id: "o" })` |
966
- | `"id lineItems { sku }"` over a `[LineItem!]!` | `Representations.order(id: "1", line_items: [{ sku: "a" }, { sku: "b" }])` |
967
- | `"id"` **and** `"serial"` (alternatives) | `Representations.variant(id: "1")` *or* `(serial: "s")` |
968
-
969
- A key field the schema declares as a **list** takes a list, and stays one on
970
- the wire — a single object there would describe an entity that doesn't exist,
971
- so it's refused rather than sent. The error spells the list hop `lineItems[]`,
972
- which is also how the generated key set records it.
973
-
974
- A type with one `@key` types its fields as **required kwargs**, so an
975
- incomplete representation is an `srb tc` error rather than a round trip. What a
976
- sig can't say is checked at runtime and raises `GraphWeaver::InputError` naming
977
- the type and the field:
978
-
979
- ```
980
- Variant representation satisfies none of its @keys — supply "id", or "serial"
981
- Listing representation is missing @key "organization.id"
982
- ```
983
-
984
- Key fields take the same loose input an `execute` kwarg does — a `params[:sku]`
985
- String converts to the `Int` the `@key` declares — and a value that converts to
986
- nothing raises `GraphWeaver::InputError` naming the representation and the
987
- field:
988
-
989
- ```
990
- Product representation sku: expected an Int, got "forty-two"
991
- ```
992
-
993
- A `@key` field whose name a generated method can't take as a kwarg — `class`,
994
- `hash`, or a Ruby keyword — takes a trailing underscore, the same one its prop
995
- took: `@key(fields: "class")` builds `Representations.room(class_: "suite")`
996
- and still sends `"class"` on the wire. A subgraph's `@key` field is not yours
997
- to rename, so weaver renames its own side rather than refusing.
998
-
999
- Only the declared key fields reach the wire — an extra key in a nested hash is
1000
- dropped. Builders are emitted **only for the entities a query's `_entities`
1001
- selection reaches** (codegen is query-driven, so a subgraph with fifty entities
1002
- emits nothing for the forty-nine you didn't name), and a
1003
- `@key(..., resolvable: false)` declares a key this subgraph does *not* answer
1004
- for, so it builds nothing. Key fields typed as scalars get their registered Ruby
1005
- type; anything else (a nested selection) is an open `Hash` the runtime narrows.
1006
- Every shape above is a named example in
1007
- [`spec/federation_spec.rb`](https://github.com/dpep/graph_weaver/blob/main/spec/federation_spec.rb).
805
+ **Two numbers, because they answer different questions.** *Plannable* is about the
806
+ graph — could the router split this query faithfully at all. *Servable here* is what
807
+ your suite actually gets: every subgraph that plan reaches is one this process
808
+ serves. In a [partly-local supergraph](#a-supergraph-only-partly-local) they differ,
809
+ and the plannable number alone reads optimistically — so the report lists the
810
+ queries that plan but reach a subgraph nothing here serves, each with the subgraph
811
+ that stopped it and the three ways out (name a schema, fake it, or run it against a
812
+ real router).
813
+
814
+ `QUERIES=` picks the directory; by default each graph's report measures that graph's
815
+ own `queries`, since a query written against one supergraph says nothing about the
816
+ next one along. Planning needs the supergraph and nothing else, so this runs in CI
817
+ with the SDL alone — with no subgraph loaded the report drops the second number and
818
+ says it counted planning only. Anything refused is listed grouped by category, so
819
+ one glance says whether the gap is one construct or many. (The run above is the demo
820
+ graph in `spec/support/federation`, not a real app's mix.)