graph_weaver 0.6.1 → 0.7.0

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. metadata +56 -1
data/CHANGELOG.md CHANGED
@@ -1,3 +1,1449 @@
1
+ ### v0.7.0 (2026-09-13)
2
+
3
+ **What you must do.** Every change here is 0.6.1 → 0.7.0, and a typical app
4
+ ticks two or three. [upgrading](docs/upgrading.md#upgrading-from-061) has the
5
+ same list with the greps that find each one.
6
+
7
+ - **Regenerate** — `rake graph_weaver:generate`. Every generated file moved:
8
+ reserved props take a trailing underscore, `as_json` is emitted beside
9
+ `from_h`, a `cast:` of your own gets the library's own guard, each module
10
+ carries the graph it came from, a `@key` that selects through a list types
11
+ its kwarg as a list, a block-built type helper is named for its graph and
12
+ its type, and `execute` makes its request through the
13
+ gem. 0.6.1's modules keep running, but `rake graph_weaver:verify` is red
14
+ until you regenerate, and one of them raises `GraphWeaver::Error` naming this
15
+ the moment anything asks it for `as_json`.
16
+ - **Rename two error classes.** `GraphWeaver::TypeError` → **`CastError`**,
17
+ `GraphWeaver::ValidationError` → **`QueryValidationError`**, at every rescue
18
+ site and in any `to_h["error"]` string you match on. No aliases.
19
+ - **Rename the rspec tag `graphql: false` to `graphql: :live`**, and
20
+ `config.default_mode = nil` to `:live`, which is now the default.
21
+ - **Rename the notification if you hardcoded it**: `"graph_weaver.execute"` →
22
+ `"execute.graph_weaver"`. Subscribing through `GraphWeaver::EXECUTE_EVENT`
23
+ needs nothing.
24
+ - **Read `payload[:http_status]`** wherever a subscriber branched on
25
+ `payload[:status] == 200` or on a 4xx/5xx — `:status` is a Symbol now.
26
+ - **Read `InputError#path.first`** wherever you read `#field` for the
27
+ *variable* name; `#field` is the input field that held the value now.
28
+ - **Refresh a schema dump introspected through a url carrying a credential**,
29
+ and rotate the token if that file was ever pushed — earlier versions recorded
30
+ the url verbatim in the dump's provenance.
31
+ - **Refresh a dump you deliberately keep behind your own schema class**, which
32
+ `verify` now fails on — or ask about no dump at all with
33
+ `verify_generated!(schema:)`.
34
+ - **Regenerate `@oneOf` inputs whose dump is `.json`**
35
+ (`rake graph_weaver:schema:refresh && rake graph_weaver:generate`): the newly
36
+ emitted `ONE_OF` starts refusing calls that set two fields, which your server
37
+ was refusing all along.
38
+ - **Move a `config.schema`, `config.router` or `config.context` set in a plain
39
+ `before` hook** into `GraphWeaver::Testing.configure` or an `around` — all
40
+ three are refused there now, where they used to silently change nothing.
41
+ - **Move `require "graph_weaver/rspec"` to `spec/rails_helper.rb`** if yours is
42
+ in `spec/support/` and you never uncommented rspec-rails' `spec/support`
43
+ glob: the require never ran, so every `graphql: :fake` example has been
44
+ hitting the real client.
45
+ - **Set `retries: 0`** if you wrapped a gateway in `Retry` and relied on it not
46
+ actually retrying: a 5xx/429 that arrives with a GraphQL errors body — which
47
+ is how Apollo Router answers — retries now, where it used to make one
48
+ attempt.
49
+ - **Update a spec that compares a `Testing::Router` error hash whole.** A
50
+ subgraph's error carries `extensions: {"service" => "<subgraph>"}` now, as
51
+ every real transport stamps it.
52
+ - **Drop `SUPERGRAPH=` from any task but `federation:*`**, which refuse it now
53
+ where they used to ignore it — a `SUPERGRAPH=… rake graph_weaver:queries:check`
54
+ in CI goes red instead of passing every query against the wrong schema.
55
+ - **Send the value the server expects** wherever a variable was a `File`, `IO`,
56
+ `Pathname` or plain object: refused at the wire now, where `JSON.generate`
57
+ used to ship its `#to_s`.
58
+
59
+ - **BREAKING: two error classes renamed, with no alias.**
60
+ `GraphWeaver::TypeError` is now **`GraphWeaver::CastError`** — it means the
61
+ response wouldn't cast into the generated structs, and the old name shadowed
62
+ a core class it doesn't descend from, so `rescue TypeError` inside the gem
63
+ read as Ruby's and `rescue GraphWeaver::TypeError` outside it read as a type
64
+ error in the caller's own code. `GraphWeaver::ValidationError` is now
65
+ **`GraphWeaver::QueryValidationError`** — it means the *query* failed schema
66
+ validation at build time, which a Rails reader would not guess from
67
+ "validation": their input's validation is `InputError`. The old names are
68
+ **gone**, not deprecated — rescuing one is a `NameError`, which is the loud
69
+ failure, and a constant alias would carry the confusion forward forever.
70
+ `GraphWeaver::Error` still catches both, as it always did.
71
+ - **Breaking: `graphql: false` is now `graphql: :live`.** The opt-out is the
72
+ app's own client, untouched — which is a mode like the other four, so it is
73
+ spelled like one. `false` is refused, and the refusal names `:live`.
74
+ `config.default_mode` is the suite-level spelling of the same thing, and it
75
+ now **defaults to `:live`** rather than to `nil`, which is no longer a value:
76
+ every example has exactly one mode, an untagged one takes
77
+ `config.default_mode`, and `graphql: :live` steps a single example back out
78
+ of a default the suite set. Behavior is unchanged — `:live` leaves
79
+ `GraphWeaver.client` exactly as it is, and it is still restored after the
80
+ example — but a helper now contradicts `graphql: :live` the way it
81
+ contradicts any other tag rather than quietly winning.
82
+ - **Breaking: `GraphWeaver::EXECUTE_EVENT` is now `"execute.graph_weaver"`.**
83
+ `<event>.<namespace>` is how every notification in this ecosystem is spelled
84
+ (`sql.active_record`, `execute_multiplex.graphql`), and it is what
85
+ `LogSubscriber.attach_to` and an APM's namespace routing key on — backwards,
86
+ the gem could not attach its own log subscriber without a puzzle.
87
+ **Subscribe through the constant and nothing changes**; a hardcoded
88
+ `"graph_weaver.execute"` silently stops matching, so grep for it.
89
+ - **Breaking: the payload's `:status` is a Symbol, and the HTTP status moved to
90
+ `:http_status`.** `:status` is now `:ok`, `:errors` (the response carried
91
+ GraphQL errors) or `:failed` (it raised) — a 200 carrying errors is not a
92
+ success, and only a symbol says that on both sides of the seam. In-process
93
+ `:http_status` is nil, where `:status` used to be a fabricated 200 so one
94
+ subscriber could read both sides of the seam.
95
+ - **A form can highlight the input field that was wrong, without reading
96
+ English — and `InputError#field` changed meaning (breaking).** `InputError`
97
+ now carries `#kind` (one of eight — `KINDS`), `#path` rooted at the variable
98
+ (`["where", "_and", 0, "_not", "species"]`, list indices included, so
99
+ `execute(ids: [1, 2, "x"])` reports `["ids", 2]`), `#coordinate`
100
+ (`"PetFilter.species"`), `#value` and `#details` (`members:`, `type:`,
101
+ `suggestion:`, `min:`…), so an app translates a stable key instead of parsing
102
+ a sentence ([i18n](docs/i18n.md)). **`#field` is now `#path`'s last *named*
103
+ segment** — the input field that actually held the value, which is the one a
104
+ form highlights, never an index — where it used to be re-branded on the way
105
+ out with the *variable* name; read `#path.first` for that. Nothing raises, so
106
+ the change passes unnoticed until the first refusal inside an input object.
107
+
108
+ **The structured half is spelled the way the schema is**, whichever side
109
+ refused: `#path`, `#field` and `#coordinate` are wire names — `["input",
110
+ "issuedOn"]`, `"externalId"`. It could not honestly be otherwise, since a
111
+ server can produce no spelling but its own, and a client-side refusal that
112
+ used the prop made `errors[e.field]` silently miss every server-detected
113
+ error, which is what a real Rails form did. The **prop** is what you type in
114
+ Ruby, and `#message` — the developer's line — names it (`external_id:
115
+ expected an Int`), so a form is `errors.add(e.field.underscore, …)` and
116
+ nothing else. An **unknown key** names no field, so the path echoes it
117
+ exactly as you wrote it and `details[:suggestion]` is the prop to type
118
+ instead. `filter_parameters` still judges by the prop, that list being
119
+ written in Ruby spelling — `api_key` has to keep matching what `apiKey`
120
+ holds.
121
+
122
+ **`#details[:type]` is the GraphQL type, never a Ruby class.** A String where
123
+ an input object belongs says `AdoptionInput`, not
124
+ `GraphQLTypes::AdoptionInput`, and a `register_scalar("Money", BigDecimal)`
125
+ field says `Money`, not `BigDecimal` — both used to render straight into
126
+ `"%{field} must be a %{type}."` on somebody's form. The messages still name
127
+ the Ruby you may pass, which is the developer's half. One gap left: the
128
+ sorbet fallback for a field no coercer covers still reports its Ruby type
129
+ (`metadata: expected T::Hash[…]`), which needs the field's GraphQL type
130
+ threaded through codegen's node tree. The format key is **`:pattern`**, not
131
+ `:format`, which is one of `I18n::RESERVED_KEYS` and raised
132
+ `I18n::ReservedInterpolationKey` out of [i18n](docs/i18n.md)'s own recipe the
133
+ moment an `:invalid_format` error reached it; a spec holds
134
+ `InputError::DETAILS` disjoint from that list so the next detail key can't
135
+ reintroduce the collision, and a server stating `extensions.input` sends
136
+ `"pattern"`.
137
+
138
+ `#value` goes through `filter_parameters`, and an unknown key carries no
139
+ `#value` at all — the key is what was wrong, and a typo'd key is by
140
+ definition not the filtered one it was meant to be. Both `#value` and
141
+ `#details` are always JSON-representable, at any depth (a non-finite Float
142
+ travels as `"NaN"` / `"Infinity"`), so `render json: e.to_h` can't turn a 422
143
+ into a 500 over exactly the values `Coerce` exists to refuse.
144
+ `InputError::VALUE_LIMIT` bounds what one can carry: an error is built for
145
+ whatever a caller sent and whatever a server echoed back, either of which can
146
+ be megabytes, and every raised one writes a `warn` line as well as landing in
147
+ `#to_h` — so each String `#value` holds at every depth, the value `#message`
148
+ quotes, and a sentence a server wrote are cut to 1024 bytes with `…(N more
149
+ bytes)` in place of the rest. A `@oneOf` input given one explicitly null
150
+ field is `kind: :missing` on that field rather than being told to "supply
151
+ exactly one field", which is what it had done, and a list element that isn't
152
+ what the list holds is a branded refusal naming the element rather than a raw
153
+ `NoMethodError`.
154
+
155
+ **And the other half of the question is answered too**:
156
+ `Response#input_errors` / `QueryError#input_errors` /
157
+ `GraphQLError#input_errors` read a **server's** rejection back into the same
158
+ `InputError` — from the `extensions.input` convention, graphql-ruby's
159
+ variable-coercion `problems`, a recognized `extensions.code`, or the argument
160
+ Hasura names in `extensions.path` (`"$.selectionSet.<field>.args.<name>"`,
161
+ list indices and nested input keys included), so `limit: -5` against anything
162
+ on that engine — PokeAPI, Nhost — comes back as an `InputError` on `path:
163
+ ["limit"]`, the field a form highlights. With Hasura the **path is the test,
164
+ not the code**: `validation-failed` is also what it sends for a query that
165
+ doesn't parse, and attaching your own `.graphql` file to a form field would
166
+ be worse than saying nothing. **`#path` is the input path the server stated,
167
+ or empty** — a GraphQL error's own `path` names a *selection* rather than an
168
+ input slot, so it is never stood in for one: a server that sends
169
+ `extensions.input.path` as a dotted String, or with a segment that is neither
170
+ a field name nor an index, gets `#path` `[]` and `#field` `nil` instead of a
171
+ plausible-looking field name for a slot the input hasn't got, which a form
172
+ doing `errors.add(e.field.underscore, …)` would have highlighted. The same
173
+ goes for a recognized `extensions.code` that names no `argumentName`; the
174
+ `kind` and the `details` the server did state still stand. A message goes
175
+ through the same filter, since graphql-ruby quotes the rejected value as a
176
+ matter of course; `#details` is checked for type as well as key; no
177
+ `#coordinate` is invented for a problem that states no path of its own; and a
178
+ server's sentence earns a `kind` only where one is honest (`:not_a_member`
179
+ with the enum's members, `:unknown` with the input coordinate, `:missing` for
180
+ a null it won't take), everything else staying `:refused` with the server's
181
+ own words — one Hasura sentence covers both `limit: -5` and `limit: "lots"`,
182
+ so `:out_of_range` would be a guess. A rejection that says nothing
183
+ identifying it as input — a bare `validates:` failure — is left alone rather
184
+ than guessed at; the convention is one `Validator` away
185
+ ([errors](docs/errors.md#when-the-server-rejects-the-input)).
186
+ - **A result now has real JSON, and it round-trips.** `result.to_json` was
187
+ Ruby's `Object#to_json` — the `#inspect` string, quoted
188
+ (`"\"#<PersonQuery::Result:0x000...>\""`) — with no exception and no
189
+ warning, so the first production log line or cache write that reached for it
190
+ silently stored nothing. Under Rails it was worse in a quieter way:
191
+ ActiveSupport's `Object#as_json` reflects over the ivars, so `render json:
192
+ result` shipped the *Ruby* prop names, trailing underscores included
193
+ (`{"when_":"2024-01-15"}` for a field the schema calls `when`). The rule now
194
+ is one sentence: **a result's JSON is the wire shape** — the response keys,
195
+ and each leaf back through its scalar registration's `serialize:` — so
196
+ `Result.from_h(JSON.parse(result.to_json)) == result`. `#as_json` is
197
+ generated beside `from_h` as its mirror, and `#to_json` goes through it, so
198
+ `render json:` and a plain `.to_json` agree. `#to_h` is unchanged and still
199
+ the Ruby view (Symbol prop names, `T::Enum` members, codec-built objects):
200
+ a Symbol-keyed hash can't be mistaken for a server's response, and a JSON
201
+ string can, so the JSON is the one that has to be true. The trip is as
202
+ faithful as each scalar's own `cast:`/`serialize:` pair — a `Time` goes back
203
+ out with the microseconds its registration writes, and a `cast:` with no
204
+ `serialize:` has no wire spelling at all, so its value reaches the encoder
205
+ as it is (the same reason an input can't send one). A `register_enum`
206
+ `fallback:` member is in no to-wire table — several wire values collapse into
207
+ it, and it is exactly the member a drifted response casts to — so serializing
208
+ one falls back to the member's own `#serialize`, which casts back to the
209
+ fallback; without a `fallback:` the table is total and a miss still raises.
210
+ The rule's reasoning is in [DECISIONS.md](DECISIONS.md).
211
+ - **A field named after a method every struct answers now generates, with a
212
+ trailing underscore.** `class` becomes the prop `class_`, `hash` becomes
213
+ `hash_`, and so on for `display`, `to_json`, `each`, and `supplied` on an
214
+ input — in results and input types alike. Both sides used to refuse: the
215
+ result side told you to alias the key in the query, and the input side told
216
+ you nothing you could act on, because a schema's field name is not yours to
217
+ rename. `class`, `hash` and `display` are columns somebody has, and a Hasura
218
+ `bool_exp` has one input field per column, so that refusal turned whole
219
+ schemas away. One rule, both directions: a prop may not shadow a method its
220
+ struct answers, so the prop — and only the prop — moves out of the way. The
221
+ wire is untouched, so the query you wrote, the request that goes out and the
222
+ response that comes back all keep the schema's spelling; `result.class` is
223
+ still Ruby's `class`, and `result.class_` is the field. The prop is the
224
+ field's one Ruby name — `.new`, `.coerce`, a **result's** `#to_h` and pattern
225
+ matching all use `class_`, while an **input** struct's `#to_h` is the wire
226
+ hash it would send (`{"class" => …}`) and input structs don't pattern-match
227
+ at all. An `InputError`'s structured half is the wire's throughout, so a
228
+ refusal on that field reports `#path` `["class"]` and `#coordinate`
229
+ `"Tricky.class"`. Generated source notes the rename on the line above the
230
+ prop (`# wire: class — reserved as a prop name`), which is the one
231
+ prop-vs-wire difference a reader can't infer. A key you aliased in the query
232
+ to get past the old refusal still generates from the alias — drop it and
233
+ regenerate if you want the field's own name back.
234
+
235
+ **The reserved names are a list the gem owns**, rather than whatever
236
+ `T::Struct` answered to in the generating process. Deriving them made
237
+ generation depend on require order: with ActiveSupport loaded first a result
238
+ key named `asJson` was refused, loaded second it became a prop that shadowed
239
+ the real `#as_json`, so `render json: result` serialized the field. The list
240
+ is what a struct answers — the public instance methods of `T::Struct` and
241
+ `Object`, the hooks Ruby or Rails call on an object that doesn't define one
242
+ (`initialize`, `to_ary`, `to_a`, `to_hash`, `to_str`, `to_int`, `to_proc`,
243
+ `to_json`, `as_json`, `to_param`, `to_query`, `try`, `presence`, `each`,
244
+ `deconstruct`, `deconstruct_keys`), and the methods the gem's own mixins
245
+ define. Kernel's *private* methods are deliberately not on it: a struct
246
+ doesn't answer them, and `format`, `select`, `test`, `open`, `load` and `pp`
247
+ are ordinary database columns. The rule itself has one public home,
248
+ `GraphWeaver::Codegen.prop_name`, for the parts of the gem outside generation
249
+ that have to agree with it.
250
+ - **A federation `@key` on a reserved field generates instead of refusing.**
251
+ `@key(fields: "class")` was refused with nowhere to send you — a subgraph's
252
+ `@key` field is not yours to rename — and `@key(fields: "hash")` emitted
253
+ `def self.slot(hash:)` beside `const :hash_`, so reading `slot.hash_` and
254
+ writing it back raised a bare `ArgumentError`. A key kwarg now takes the same
255
+ trailing underscore the prop does (`Representations.room(class_: …)`), and
256
+ still sends `"class"` on the wire. Regenerate if a `@key` of yours names such
257
+ a field. ([federation](docs/federation.md))
258
+ - **A `@key` that selects through a list stays a list.** `@key(fields: "id
259
+ lineItems { sku }")` over a `[LineItem!]!` typed its kwarg as one
260
+ `T::Hash[…]`: the correct list was refused and a single hash was accepted and
261
+ sent, which described an entity that doesn't exist. The kwarg is
262
+ `T::Array[T::Hash[…]]` now, the list stays a list on the wire, and the
263
+ generated key set spells the hop `"lineItems[]"`. No other `@key` shape
264
+ changes — **regenerate** if one of yours selects through a list.
265
+ - **A block-built type helper is named for its source, not for what is
266
+ loaded.** `GraphWeaver.extend_type("Widget") { … }` minted its module by
267
+ counting whatever `GraphWeaver::TypeHelpers` constants already existed, which
268
+ made the name a function of how many times *that process* had read the
269
+ registry: `rake graph_weaver:generate` baked `WidgetV3` while a plain boot
270
+ only ever creates `WidgetV1`, so `rails server` died on "includes
271
+ GraphWeaver::TypeHelpers::WidgetV3, but nothing registers it" while `verify`,
272
+ run moments earlier on the same tree, called it up to date. The module is
273
+ named for where the block is written and what it extends —
274
+ `TypeHelpers::Pet` at the top level, `TypeHelpers::Billing::Pet` in `graph
275
+ :billing` — so two graphs extending one type name each get their own
276
+ constant, and the same source counts the same way in every process and after
277
+ a `to_prepare` reload. **Regenerate.** The reasoning is in
278
+ [DECISIONS.md](DECISIONS.md).
279
+ - **Generation refuses an input type whose fields collide on one Ruby prop.**
280
+ Two input fields that underscore onto the same name — `nameWithOwner` and
281
+ `name_with_owner`, or `class` and `class_`, since `class` is renamed out of a
282
+ method's way — emitted `const :name_with_owner` twice, and the generated file
283
+ raised `ArgumentError` when it was *required*, from a trace with no schema in
284
+ it. It now refuses at generation, naming both fields, the type and the prop —
285
+ and says which variable reaches what (`$outer reaches Inner through inner`),
286
+ since the colliding type is usually several hops below the declaration you
287
+ wrote. The escape is to pass the object as a literal with a variable per
288
+ field, a schema's field name not being yours to rename; where the type is
289
+ reached through a list there is no such form, and the refusal says plainly
290
+ that no form of the query generates. The output side has always refused this;
291
+ the three real schemas swept (GitHub, PokeAPI, countries — 12,684 input
292
+ fields) contain no such pair.
293
+ - **An input type named `Result`, `QUERY` or `Representations` is refused**,
294
+ the way an enum by one of those names already was. Those are the constants
295
+ every generated query module defines, and nothing checked an input struct
296
+ against them: `input Result` emitted `class Result` twice, the second
297
+ reopening the first into one struct that answered for both the variable and
298
+ the response — generated code that looks authoritative and is wrong — while
299
+ `input QUERY` raised a bare `TypeError: QUERY is not a class` from Ruby,
300
+ about the document heredoc. The refusal names the type and the three
301
+ constants. No public schema in a 23-schema sweep declares one (SpaceX has an
302
+ *object* named `Result`, which is unaffected — a result class is named for
303
+ the response key, not the type).
304
+ - **`respond_to?` on a generated result struct no longer answers true for a
305
+ name that doesn't exist.** It said true for any near miss, so the standard
306
+ duck-typing guard was the thing that broke — `obj.pet if obj.respond_to?(:pet)`
307
+ raised the very `NoMethodError` the hint exists to explain. `method_missing`
308
+ still hints; the cost is that `struct.method(:nmae)` now raises Ruby's own
309
+ bare `NameError` rather than the hinted one.
310
+ - **Generated result structs behave like ordinary Ruby objects**: value `==`
311
+ (with `eql?` and `hash`, so a result works as a hash key),
312
+ `deconstruct_keys` for pattern matching, and `#to_h`. All three go the whole
313
+ way down a nested result. `#to_h` is the Ruby shape, not the wire's —
314
+ snake_case prop names as Symbol keys, nils kept, enums as their `T::Enum`
315
+ members — so it is a view, not something to send back to a server. A result
316
+ key that would collide with one of the new names (`deconstruct_keys`)
317
+ generates with a trailing underscore, as any other reserved name does.
318
+ - **A response whose shape drifted names the field and what arrived.** A server
319
+ that sends an object where a list belongs used to raise *"failed to cast
320
+ response into Q::Result::Person: Parameter 'data': Expected type
321
+ T::Hash[String, T.untyped], got T::Array[String]"* — the field named nowhere,
322
+ the struct named being the parent, and the "got" describing pairs `Hash#map`
323
+ had made on the way in rather than anything the server sent. It reads `pets:
324
+ expected a list, but the server sent an object` now, and points into the list
325
+ (`pets.0: expected an object, but the server sent null`) when an element is
326
+ the problem. A nullable sibling that is legitimately `null` is never the one
327
+ blamed: the guard that skips it reads the prop's type with its nilable-ness
328
+ intact, where it used to strip it and so never fired.
329
+ - **`Response#report` no longer calls a method a server's error path names.**
330
+ The walk that resolves `entity_ids` stepped through the typed data with
331
+ `respond_to?`, which is true of every `Object` method — so an error path with
332
+ a segment named `freeze` **froze the caller's result struct** and then
333
+ reported an id for a field that doesn't exist, `display` printed the struct to
334
+ stdout, and `tap`/`send`/`method` raised `LocalJumpError`/`ArgumentError` out
335
+ of error handling. It reads the struct's own props now, so a segment that
336
+ isn't a field of that struct resolves to nil — and a field whose prop took a
337
+ trailing underscore (`class` → `class_`) resolves for the first time.
338
+ - **The emitted `QUERY` is the query you wrote**, in two ways it wasn't.
339
+ Naming an anonymous operation splices the module's name into the operation's
340
+ own declaration at a position graphql-ruby reports — which is a byte offset
341
+ measured against character line starts, and a whole-document offset for a
342
+ token with no newline after it. A comment above a single-line `{ … }`, or any
343
+ multibyte character before the operation, put the name in the wrong place;
344
+ the emitted module still generated and `verify` still reported it up to date,
345
+ and every call failed on the wire. The splice is now measured correctly
346
+ **and** the spliced document is re-parsed — if it doesn't declare the name,
347
+ generation refuses and names the file rather than shipping a module that
348
+ can't run. The heredoc also used to right-strip every line, which silently
349
+ edits the *value* of a block-string argument — trailing whitespace inside
350
+ `"""…"""` is significant — so a query carrying one now sends what the
351
+ `.graphql` file says.
352
+ - **"Select `__typename`" now says why the ones you did select don't count.**
353
+ Putting `__typename` in every `... on Type` and nowhere else looks like
354
+ compliance — it is what Linear's published SDK documents do, and it refuses
355
+ 36 of their 250 operations — and the tag really is on the wire; it is just
356
+ read *after* the dispatch it would decide, and a member the query never named
357
+ would carry none at all. The refusal says so when that is the shape in front
358
+ of it, and keeps the short message otherwise. The rule is unchanged, and
359
+ [generated modules](docs/generated_modules.md#abstract-types) states it too.
360
+ - **Two codegen refusals name what you wrote.** Two result keys that generate
361
+ one class name now name both of them, the way the sibling prop-collision
362
+ message already did; and a module named `T` is refused rather than emitting
363
+ code that shadows Sorbet's `T` in its own body.
364
+ - **A date and a timestamp are refused for each other, both ways.** A date
365
+ reads back as a `Date` and a timestamp as a `Time`; the same rule now holds
366
+ for variables on the way out. `DateTime` is a `Date` to Ruby, so it passed
367
+ straight through the cast and went on the wire as a full timestamp —
368
+ `"2024-01-15T10:20:30+00:00"` where the schema said `ISO8601Date`, which a
369
+ lenient server truncated and a strict one refused — while a `Time` for an
370
+ `ISO8601Date`, and a `Date` for an `ISO8601DateTime`, surfaced Ruby's raw
371
+ *"no implicit conversion of Time into String"*. Truncating to the date here
372
+ would be the same guess made silently, so each now raises an `InputError`
373
+ naming the variable and the class: `$d of On: expected a Date, got a DateTime
374
+ — pass .to_date if dropping the time of day is what you meant`, and `$t of
375
+ At: expected a Time, got a Date — a Date has no time of day — pass the Time
376
+ you mean`. What a timestamp variable *accepts* grew to match — a `DateTime`
377
+ and the `ActiveSupport::TimeWithZone` from `Time.zone.now` both convert
378
+ losslessly, and both used to raise.
379
+ - **A scalar you cast yourself gets the library's guard and its verdict.** With
380
+ a registration like `register_scalar("Date", Date, cast: :iso8601, serialize:
381
+ :iso8601)`, the emitted guard was a bare `value.is_a?(Date)` pass-through —
382
+ so a `DateTime` (which *is* a `Date` to Ruby) went by untouched and your own
383
+ `serialize:` wrote `"2024-01-15T10:20:30+00:00"` into a field the schema says
384
+ holds a date, the same cross-type trap the built-in `Date` refuses. It is
385
+ refused here now, with the same words. Anything else wrong used to reach your
386
+ codec and come back as Ruby's own sentence about an argument you never
387
+ wrote — `$input of Probe: birthday: no implicit conversion of Integer into
388
+ String`, filed under `kind: :unparseable`. The verdict is the library's now,
389
+ whoever wrote the codec, and it splits the way Ruby itself does: a
390
+ `TypeError` means the *class* was wrong and reads `$input of Probe: birthday:
391
+ expected a Date, got 5` under `kind: :type_mismatch`, while an
392
+ `ArgumentError` means the *content* was and keeps the parser's own words
393
+ (`invalid date`, `kind: :unparseable`) — the difference between "send
394
+ something else" and "fix the text", which is the half a form needs. The same
395
+ split applies to any `TypeError` a `cast:` or `serialize:` raises. The guard
396
+ is emitted into your generated files, so a checked-in one keeps the old
397
+ `is_a?` until you regenerate.
398
+ - **"has no cast" now says which mistake you made.** Two unrelated
399
+ misconfigurations reached the same refusal: a registered class none of the
400
+ three probes (`.parse`, `.load`, `Kernel#Type`) matched, and a `type:` given
401
+ by *name*, which is never probed at all because there is no class in hand.
402
+ The advice differs — name a `cast:`, versus pass the class — but the sentence
403
+ didn't. Each now names its own cause: the class form lists the three probes
404
+ that found nothing, and the string form says it was registered by name and
405
+ shows the class form to switch to. The string form's no-probing behavior is
406
+ unchanged and correct;
407
+ [scalars](docs/scalars.md#registering-a-class-of-your-own) now says so where
408
+ the string form is introduced.
409
+ - **A type-string scalar's bad value is named like every other one.**
410
+ `register_scalar("Vector", "T::Array[Float]")` gives the field no coercer, so
411
+ only sorbet stands between the value and the struct — and the check that
412
+ turns sorbet's complaint back into the library's asked `#valid?`, which stops
413
+ at the outermost type. `[1, 2, 3]` for a `T::Array[Float]` passed it while
414
+ the struct's setter (which checks recursively) refused, so the refusal came
415
+ out blaming the list that *held* the struct, with empty `details` and
416
+ sorbet's wording: *"items: invalid input for LineItemInput: Parameter
417
+ 'vector': Can't set LineItemInput.vector to [1, 2, 3] (instance of Array) -
418
+ need a T::Array[Float]"*. It now asks the same question the setter does, so
419
+ it says *"vector: expected T::Array[Float], got [1, 2, 3]"* with
420
+ `coordinate: "LineItemInput.vector"`, `path: ["input", "items", 0, "vector"]`
421
+ and `details: {type:}` — the same shape a class-registered scalar's refusal
422
+ has always had. Lists of lists and nilable fields alike.
423
+ - **`register_scalar` warns when the Ruby type inherits `eql?`.** A result
424
+ compares its props with `eql?`, so a value object that inherits it compares
425
+ by identity: two results parsed from the same bytes are unequal, and neither
426
+ works as a hash key, while the leaf itself compares fine.
427
+ `alias_method :eql?, :==` plus a matching `hash` is the fix. The warning
428
+ fired only for a class that defined `==` and forgot `eql?`, though its own
429
+ comment described the whole hazard — a class overriding *neither* breaks
430
+ result equality identically and got nothing. One rule now: a registered class
431
+ that inherits `#eql?` warns. `String`, `Integer`, `Float`, `Date`, `Time`,
432
+ `DateTime` and `BigDecimal` all define it and stay silent; a `T::Enum` is
433
+ exempt, its values being singletons; and a class that gets `==` from
434
+ `Comparable` and stops there is a true positive, not a false one. See
435
+ [scalars](docs/scalars.md#registering-a-class-of-your-own).
436
+ - **A timestamp keeps its sub-second part on the way out.** `Time#iso8601`
437
+ takes no precision, so `"2024-01-15T10:20:30.500Z"` read off the wire went
438
+ back out as `"2024-01-15T10:20:30Z"` — half a second gone from an
439
+ `updatedAt` concurrency token, or from the `since:` a window is read on.
440
+ graphql-ruby's own `ISO8601DateTime` writes whole seconds, so a stock Ruby
441
+ server never showed it; **any JS/Apollo server writes milliseconds on every
442
+ timestamp**. A `Time` (or `DateTime`) that carries a fraction is now written
443
+ with microseconds, and one that doesn't sends the bytes it always has.
444
+ - **A `Float` variable must be a finite number.** `Kernel#Float("1e400")` is
445
+ `Infinity` rather than a raise (so is `(10**400).to_f`), and JSON has no
446
+ spelling for a non-finite number — the GraphQL spec excludes them from
447
+ `Float` outright. It used to travel as far as the transport, which blamed
448
+ the whole request (*"variables are not JSON-serializable"*); the refusal now
449
+ names the variable and the value. Every door is checked, so an actual
450
+ `Float::INFINITY` is refused as well as a string that parses to one — and
451
+ `register_scalar "Ratio", Float` now reads a whole number off the wire
452
+ exactly as the built-in `Float` does, which it didn't before.
453
+ - **A variable with no JSON form is refused at the wire.** `JSON.generate`
454
+ renders a value it doesn't know as that value's `#to_s`, so an `Upload!`
455
+ given a real file went out as `{"file":"#<File:0x00007f…>"}` — 200 back, no
456
+ error, and a memory address in the server's database. A `File`, an `IO`, a
457
+ `Pathname` or a plain object is refused before the body is built, naming the
458
+ variable and what to do instead; a `Date`, `Time`, `BigDecimal` or `Symbol`
459
+ still travels as its string. File uploads still need your own transport —
460
+ graph_weaver doesn't implement the GraphQL multipart request spec.
461
+ - **A variable's whole trip onto the wire is branded.** Serialization ran
462
+ *outside* the coercion's rescue, so anything it raised arrived as a bare
463
+ `NoMethodError` naming neither the variable nor the operation — now it reads
464
+ `$budget of Store: …`, the way a coercion failure already did.
465
+ - **`filter_parameters` scrubs a message's value at every depth, as it already
466
+ did `#value`.** The message side only ever asked whether the *variable's own*
467
+ name was filtered, so `execute(credentials: { token: "…" })` refused with the
468
+ secret quoted in the sentence — and in the `warn` line `Error#initialize`
469
+ writes — while `InputError#value` sitting beside it read `[FILTERED]`. The
470
+ value a message quotes now goes through the same scrub as `#value`, so a
471
+ filtered key one level in reads `got {"token" => "[FILTERED]"}`. Messages
472
+ change only where a filter matches; nothing else moved.
473
+ - **Every transport failure names the endpoint it failed against.**
474
+ `TransportError` and `ServerError` carry `#url`, say it in the message
475
+ (`HTTP 502: <html>… — POST https://api.example.com/graphql`) and in `#to_h` —
476
+ an app talking to two graphs used to get `HTTP 502` and nothing else, so every
477
+ investigation started with "which one". The url they name is the one the gem
478
+ is **willing to say**: a url's userinfo and any query parameter
479
+ `GraphWeaver.filter_parameters` already filters are folded to `[FILTERED]`, by
480
+ the same list that scrubs the variables line. That applies everywhere a
481
+ transport says its url — the boot line, the per-request `debug` lines,
482
+ `Transport#inspect`, and `payload[:url]` on `execute.graph_weaver`, i.e. your
483
+ APM — so `https://svc:hunter2@api.example.com/graphql?access_token=…` no
484
+ longer lands in a log file or a third-party trace store. `Transport#url` is
485
+ unchanged: it is where requests actually go, and `#safe_url` is the sayable
486
+ one.
487
+ - **A schema dump no longer records the credential that introspected it.** The
488
+ provenance stamp (`schema:refresh` and `cache:`) wrote the transport's url
489
+ verbatim, so a url carrying userinfo or an `?access_token=` landed in a file
490
+ that gets committed. It now records the endpoint bare — userinfo and any
491
+ query parameter `filter_parameters` filters are dropped, the rest kept — and
492
+ re-introspection still authenticates from the dump's `auth_env`, which is
493
+ where the token belonged. The loader's log lines and errors say the url the
494
+ way every transport failure now does, secrets marked `[FILTERED]`. A dump
495
+ written by an earlier version from such a url still holds the token: refresh
496
+ it once, and rotate the token if the file was pushed.
497
+ - **A `multipart/mixed` response is named rather than dumped.** Forcing an
498
+ incremental-delivery `Accept` used to raise `non-GraphQL response:` followed
499
+ by the whole multipart payload, which misdiagnoses a body that is perfectly
500
+ well-formed GraphQL — just more than one document. It says so now: "this
501
+ response is incremental delivery (@defer/@stream), which this client doesn't
502
+ read".
503
+ - **A response behind a UTF-8 BOM now parses.** RFC 8259 §8.1 lets a parser
504
+ ignore a leading BOM and Ruby's doesn't, so a .NET/IIS-fronted endpoint's
505
+ answer came back as `non-GraphQL response: {"data":…}` — a body that looks
506
+ perfectly good, broken by three invisible bytes. And **an empty body says so**
507
+ (`HTTP 204: empty response body`) rather than trailing off after the colon,
508
+ which read as a truncated message rather than as the answer it was.
509
+ - **Two more net/http failures arrive as `TransportError`.** A garbage status
510
+ line (`Net::HTTPBadResponse` — a misbehaving proxy, HTTP sent to a port
511
+ speaking something else, or a keep-alive socket that desynced) and a body
512
+ that isn't the gzip it claims to be (`Zlib::Error`) used to escape the
513
+ `GraphWeaver::Error` umbrella entirely, so `rescue GraphWeaver::Error` missed
514
+ them and `Retry` treated a retriable failure as fatal. Both are now
515
+ `TransportError` — which retries, on a fresh connection — as is
516
+ `Net::ProtocolError`. `Transport::Faraday` already classified all three this
517
+ way; the two shipped transports now agree.
518
+ - **A retry policy that was inert behind a gateway now fires.** `Retry` read
519
+ only the failures that *raised*, and Apollo Router answers everything it
520
+ decides itself with a GraphQL errors body — rate limiting is `503` plus
521
+ `REQUEST_RATE_LIMITED`, its own faults are `500` plus a code — so the body
522
+ won over the status and `retries: 3` made one attempt. One rule now: a
523
+ response retries when its status is one a `ServerError` retries on (5xx, 408,
524
+ 429), or when its error codes are named in `retry_codes:`. A `200` is never
525
+ retried on status, so a router's partial `GATEWAY_TIMEOUT` still needs
526
+ `retry_codes:` to opt in, and a mutation still gets one attempt unless
527
+ `retry_mutations: true`. `REQUEST_RATE_LIMITED` joins
528
+ `GraphQLError::THROTTLE_CODES`, so `#throttled?` answers it too. The
529
+ reasoning is in [DECISIONS.md](DECISIONS.md).
530
+ - **A retry delay can no longer kill the retry loop.** `base_delay:` and
531
+ `max_delay:` are refused if negative, where the typo is; a custom `backoff:`
532
+ returning one is floored at no wait. Either used to reach `Kernel#sleep`,
533
+ which raises `ArgumentError` — so one mistyped option reported as a bug
534
+ somewhere else, and the failure being retried was lost behind it.
535
+ - **`Transport::HTTP`'s connection pool is fork-safe.** A socket idle at `fork`
536
+ time was inherited by every child, and a round trip carries nothing saying
537
+ which process opened it — so forked workers interleaved requests on one fd and
538
+ a caller could receive a well-formed GraphQL response to *another process's*
539
+ query, with no exception anywhere. The trigger is the documented boot path:
540
+ Puma `preload_app!` (or Sidekiq) plus an initializer that introspects, which
541
+ leaves exactly one warm socket in the pool. The pool now belongs to the
542
+ process that built it — on the first request after a fork the inherited
543
+ sockets are abandoned (not closed: that would take down the fd the parent is
544
+ still using) and the pool's permits are rebuilt.
545
+ - **`GraphWeaver.new(url, pool_size: N)`** sizes the bundled HTTP transport's
546
+ connection pool from the url client, where before only `Transport::HTTP.new`
547
+ took it and an app had to set `RAILS_MAX_THREADS` to reach it. With
548
+ `transport: :faraday` it is refused rather than dropped — Faraday's adapter
549
+ owns its connections.
550
+ - **`Transport::Faraday#url` keeps a url's query string.** Faraday moves it
551
+ into the connection's default params and strips it from `url_prefix`, so
552
+ `Transport::Faraday.new("https://api.example.com/graphql?apiKey=…").url`
553
+ reported an endpoint no request goes to — the one the boot log line names,
554
+ and the one `graphql: :wire` serves a graph's resolvers at. `#url` is now
555
+ reconstructed from where requests actually go, through Faraday's own encoder
556
+ — so an Array param reads `a[]=1&a[]=2`, the way the wire carries it, and a
557
+ Hash param is not a URL-escaped `Hash#inspect`.
558
+ - **A request header can be a callable.** On `Transport::HTTP` a `headers:`
559
+ value answering `#call` is resolved per request rather than captured when the
560
+ transport was built, so a rotating credential needs no new transport:
561
+
562
+ ```ruby
563
+ GraphWeaver::Transport::HTTP.new(url, headers: {
564
+ "Authorization" => -> { "Bearer #{Tokens.fetch}" },
565
+ "X-Tenant" => -> { Current.tenant&.id }, # nil ⇒ header omitted
566
+ })
567
+ ```
568
+
569
+ A value (or a call) of `nil` sends no such header. `Transport::Faraday`
570
+ resolves one per request too, so a rotating credential means the same thing
571
+ on both bundled transports. **And `auth:` takes a token that rotates** —
572
+ `auth: -> { Tokens.fetch }` is resolved per request like any other callable
573
+ header value, where it used to raise and send you to Faraday's middleware.
574
+ - **A non-String header value is sent as its `to_s`** on both transports, rather
575
+ than escaping as `NoMethodError: undefined method 'strip'` from inside
576
+ net/http, naming neither graph_weaver nor the header. The documented
577
+ `"X-Tenant" => -> { Current.tenant&.id }` crashed in any app whose ids are
578
+ Integers. A callable is still resolved first and `nil` still drops the header.
579
+ - **Both transports name themselves to the graph.** Every request carries
580
+ `apollographql-client-name` and `apollographql-client-version`, which is what
581
+ an Apollo Router or GraphOS keys client attribution on — a real router's span
582
+ showed `client.name: ""` for everything this gem sent. The name is your Rails
583
+ application's, or `graph_weaver` outside Rails; the version is the gem's.
584
+ Override either in `headers:`, which is how one app names its several clients
585
+ apart. `Transport.default_headers` is the whole set; `DEFAULT_HEADERS` stays
586
+ the fixed half of it.
587
+ - **A prebuilt `Faraday::Connection` now sends graph_weaver's `User-Agent`.**
588
+ Faraday pre-fills its own on every connection, so the fill-in-the-blanks
589
+ `||=` never fired and the traffic attributed to `Faraday v…` — defeating the
590
+ header's whole purpose. A User-Agent you set yourself still wins.
591
+ - **`ServerError#headers` answers any casing.** Transports store response
592
+ headers downcased, so `e.headers["Retry-After"]` — the spelling the server
593
+ sent, and the one a caller reaches for — used to return nil. Lookup by name
594
+ (`#[]`, `#fetch`, `#dig`, `#key?`) now folds the case; iteration, `#keys` and
595
+ `#to_h` still yield the downcased spelling, so logs are unchanged.
596
+ - **A Rails app now sees its GraphQL calls without configuring anything.** The
597
+ railtie sets `GraphWeaver.instrumenter` to the `ActiveSupport::Notifications`
598
+ adapter (an instrumenter the app set is never replaced) and attaches
599
+ `GraphWeaver::LogSubscriber`, so every execution — over the wire and
600
+ in-process — is one notification an APM can subscribe to and one line in the
601
+ log: `GraphWeaver billing/InvoicesQuery (12.3ms) ok`, `… errors [THROTTLED]`,
602
+ `… failed GraphWeaver::TransportError` (unprefixed when the module names no
603
+ graph, so a single-schema app's log reads `GraphWeaver PersonQuery …`).
604
+ **One rule: the summary is info, the wire is debug** — this is the only
605
+ GraphWeaver line at info, so a production log gets one per operation and
606
+ nothing that can carry PII, and turning the logger up to debug adds the query
607
+ and variables *beneath* it rather than repeating it. Measured at ~4.5µs per
608
+ execution all told (0.13µs of that `ActiveSupport::Notifications` itself with
609
+ nothing subscribed), against a round trip measured in milliseconds.
610
+
611
+ **Both auto-wires are opt-out from an initializer.** `GraphWeaver.logger =
612
+ nil` and `GraphWeaver.instrumenter = nil` in `config/initializers` win,
613
+ because the railtie declares them first and `before:
614
+ :load_config_initializers` — Rails gives an initializer an implicit `after:`
615
+ of the previous one the railtie declared, which is how the fallback would
616
+ otherwise land *after* the app's own files and overwrite the nil it had just
617
+ set. An app that reached for `config.after_initialize { GraphWeaver.logger =
618
+ nil }` still works and can drop it. ([logging](docs/logging.md))
619
+ - **The instrumentation payload is a documented contract** ([logging](docs/logging.md#the-payload)),
620
+ the same shape whichever client slot ran the request: `:operation`,
621
+ `:graph`, `:client`, `:status` and `:duration_ms` always;
622
+ `:url`/`:http_status` over the wire, `:schema` in-process; `:error` (the
623
+ exception's class name) and `:code` — the first GraphQL error's code, or a
624
+ `ServerError`'s status, the one key to group an alert by — when there is
625
+ one; and `:retries` under a `Retry`, where each attempt is its own event
626
+ reading 0, 1, 2, so a call that took three goes no longer reads as three
627
+ unrelated slow requests. The query text and the variables are still
628
+ deliberately absent, and now pinned by a spec: `filter_parameters` governs
629
+ the log, which GraphWeaver writes itself, but the payload fans out to
630
+ subscribers that know none of those rules.
631
+
632
+ `:graph` is the Symbol a module was generated under — `nil` for one that
633
+ names none and for a client called directly — so a multi-graph app groups a
634
+ dashboard by graph without parsing operation names. It covers **one
635
+ request**: a generated `execute` labels the request it makes, each one when a
636
+ federated operation fans out, and never what a *server* does while answering,
637
+ so an in-process resolver that calls another API produces an event carrying
638
+ its own graph or none, rather than its caller's. It is read from what codegen
639
+ baked in, never inferred from the client — a wrong graph on a request is
640
+ worse than no graph. Bracketing a request this way is why a generated
641
+ `execute` is now `from_response(dispatch(variables, client:))`:
642
+ `GraphWeaver::QueryModule` owns the call, reading the module's own
643
+ `QUERY`/`OPERATION_NAME`, so nothing about it lands in every generated file.
644
+ Behavior, sigs and the `Response` envelope are identical.
645
+ - **A bare graphql-ruby schema class in a client slot is instrumented like
646
+ every other client.** `GraphWeaver.client = MyApp::Schema` and
647
+ `execute!(client: MyApp::Schema)` ran with no seam at all — no APM event, no
648
+ log line, not even at debug — while `GraphWeaver.new(MyApp::Schema)` around
649
+ the same class had both. So the in-process half of an app was invisible on
650
+ the dashboard, and turning the logger up showed it as a graph that never ran.
651
+ A schema class is now put through the same `InProcess` wrapper
652
+ `GraphWeaver.new(Schema)` builds, wherever a client is read — a graph's
653
+ `client "Billing::Schema"` included. ([logging](docs/logging.md))
654
+ - **An in-process client names an anonymous schema `"anonymous"`.** The
655
+ instrumentation payload's `:schema` was the schema's `#to_s`, which for one
656
+ built from SDL is its object address — a new value every boot, so an APM
657
+ grouping by it got a series per process rather than one per schema. A schema
658
+ class is still named by its constant, in the payload, the debug log and
659
+ `#inspect`. ([logging](docs/logging.md#the-payload))
660
+ - **Turning on debug logging no longer changes what happens.** The debug line
661
+ rendered the variables as JSON itself, *before* the guarded encode — so a
662
+ value with no JSON form (`NaN`, binary) raised a raw `JSON::GeneratorError`
663
+ past `rescue GraphWeaver::Error` whenever a logger was listening at debug,
664
+ and in-process it turned a query that ran into a `ServerError`. The line now
665
+ says `<unloggable: JSON::GeneratorError>` and the request carries on to the
666
+ same outcome it has with no logger set.
667
+ - **A non-federated app no longer logs a phantom "no routing table here"
668
+ warning.** `GraphWeaver::Error` writes a warn line as it is *constructed*,
669
+ and "is this source a composed supergraph?" was asked by calling
670
+ `SchemaLoader.routing_table` and rescuing its refusal — so every app whose
671
+ schema is an ordinary one warned, once per process, about a routing table it
672
+ had never asked for. The question has a predicate now,
673
+ `SchemaLoader.routing_table?`, and nothing builds an error to answer "no".
674
+ - **An app can have more than one schema.** `GraphWeaver.graph` declares one.
675
+ Everything a graph knows is said inside its block, in call style — six
676
+ settings and the three registrations you already write at the top level:
677
+
678
+ ```ruby
679
+ GraphWeaver.graph :billing do
680
+ schema -> { Billing::Schema }
681
+ queries "app/graphql/billing/queries"
682
+ output "app/graphql/billing/generated"
683
+ client "Billing::Schema"
684
+ namespace "Billing"
685
+ register_scalar "Money", BigDecimal
686
+ end
687
+ ```
688
+
689
+ `schema "x"` sets and a bare `schema` reads back; there is no `schema = "x"`
690
+ form, since the block is `instance_eval`'d and that would be a local variable
691
+ that silently does nothing — graphql-ruby's `field :name` convention. Anything
692
+ else the block calls is refused, naming the nine it takes, as is a graph name
693
+ that isn't a Symbol or a String. `client`, `namespace` and `types_module` each
694
+ take the constant or its name, since generated source spells it either way; a
695
+ setting a graph doesn't say falls back to the top-level one.
696
+
697
+ `generate!`, `verify_generated!`, `check_queries`, `load_generated!`,
698
+ `reload_generated!`, the rake tasks and watch mode all walk every graph, so
699
+ one `rake graph_weaver:generate` does the app and one `rake
700
+ graph_weaver:verify` gates it. `rake graph_weaver:graphs` lists what is
701
+ configured — settings, registrations, and the client each graph calls
702
+ (`rake -T` can't: a task description is baked before `:environment`). The
703
+ hand-rolled recipe this replaces — N `generate!` calls with
704
+ `reset_registrations!` between them — is gone from
705
+ [docs/federation.md](docs/federation.md).
706
+
707
+ **In Rails, declare graphs in the initializer and name an autoloaded schema
708
+ class with a lambda** — `schema -> { Billing::Schema }`. Zeitwerk is set up
709
+ after `config/initializers` run, so a bare constant there raises; the lambda
710
+ resolves when generation asks, and again after a dev reload has replaced the
711
+ class object. The block runs where you write it, registrations included, so a
712
+ registration naming one of your own constants is in the position a top-level
713
+ one is and has the same answer: declare that graph from a `to_prepare` block,
714
+ which is safe to re-run (the graph's name is its identity) and whose query
715
+ directories still reach the development file watcher, so an edit to its
716
+ `.graphql` regenerates before the next request.
717
+
718
+ **Nothing changes for a single-schema app**: the top-level settings *are* the
719
+ default graph, and top-level registrations still reach every graph, so a
720
+ `register_scalar` in an initializer can't be dropped by declaring a second
721
+ schema. `namespace` nests everything a graph generates, including its shared
722
+ types module (`Billing::GraphQLTypes`); without one, two files that generate
723
+ the same module refuse as they always have, and the message now names the
724
+ graphs and the fix.
725
+ - **Generation refuses a directory no declared graph covers.** Declaring a
726
+ graph replaces the implicit one the top-level settings describe, so an app
727
+ that declares one beside its existing `app/graphql/queries` would otherwise be
728
+ left with a directory nothing reads — `generate` skipping its files, `verify`
729
+ calling the tree up to date, `queries:check` passing a query containing
730
+ `nmae`, and the modules generated from it before the graph existed still
731
+ loading in production. `generate!`, `verify_generated!` and `check_queries`
732
+ refuse instead, naming the stray files and what each declared graph does
733
+ cover — and spelling the missing graph out in those paths, since "under
734
+ directories no declared graph covers" describes the reader's own settings
735
+ back at them. It says why that graph takes no `schema` or `namespace`: it
736
+ keeps reading the schema `schema_path` names, and every constant keeps its
737
+ name. The fix is to name the directory in a graph (`queries`/`output`),
738
+ declare a graph for it, or delete it; an app that declared no graph is
739
+ unaffected, its settings *being* its graph.
740
+ [getting started](docs/getting_started.md#more-than-one-schema) says it too.
741
+ - **Every test mode decides what to run against per graph, and the helpers say
742
+ which graph they mean.** With more than one schema the honest answer varies
743
+ per module: `graphql: :fake` for a billing module fabricates billing's shapes
744
+ with billing's own scalar registrations, where it used to refuse to pick a
745
+ schema for the suite. `graphql_fake`, `graphql_in_process` and
746
+ `graphql_router` are the stand-in for the modules of **one** graph, and
747
+ **`graph:` names it** — `graphql_fake(graph: :poke, "pokemon_v2_pokemon.name"
748
+ => "pikachu")`, `graphql_in_process(graph: :catalog)`, `graphql_router(graph:
749
+ :storefront, fake: …)`. The only way to say which used to be `schema:`,
750
+ matched by the identity of the object the graph's `schema` setting holds,
751
+ which only a graph running a live class in-process has: an app that is a pure
752
+ client of two remote APIs has none, since `SchemaLoader` builds a fresh
753
+ anonymous class per load, so the client, the declared dump path and the
754
+ graph's own name were all refused with `names none of this app's graphs`.
755
+ `graph:` takes the handle a graph has everywhere else, printed by `rake
756
+ graph_weaver:graphs` and baked into every module's `GRAPH`. `schema:` still
757
+ serves the single-graph override it always did, every refusal now leads with
758
+ `graph:`, and `graphql_router` — which had no way at all to say whose `fake:`
759
+ it was holding, and so could only refuse — gains one. They still refuse,
760
+ naming your graphs, when there is none they could reach.
761
+
762
+ **A helper sets the stand-in for the graph it names; the tag sets the mode
763
+ for every graph no helper named.** `graphql_router(graph: :store)` followed
764
+ by `graphql_fake(graph: :countries)` used to collapse to whichever was named
765
+ last — a helper reinstalled the example's one mode, which cleared the
766
+ per-graph stand-in table, so the first helper's graph was silently re-derived
767
+ under the second's mode: the plain graph raised `:router` plans across a
768
+ supergraph, or the federated one answered with fabricated data. So `graphql:
769
+ :router` plus `graphql_fake(graph: :countries)` reads as what it means, and
770
+ is no longer refused as contradicting the tag — a helper naming one graph of
771
+ several isn't speaking for the example. A helper that *does* speak for the
772
+ whole example (one graph, or no `graph:`/schema to narrow it) still can't
773
+ contradict its tag.
774
+
775
+ `:router` asks each graph where its own supergraph is: the one that graph
776
+ names, else `config.router[:supergraph]`, else the committed dump when it
777
+ carries `@join__*`. So an app that had already said where its supergraph is
778
+ doesn't repeat it in `config.router`, and a module is never planned against
779
+ **another graph's** — which used to fail blaming a stale dump ("schema may
780
+ have changed since generation"). A graph in no supergraph is refused by name
781
+ and pointed at `graphql: :in_process`; two graphs naming one supergraph share
782
+ one router, parsed once.
783
+
784
+ Generated modules carry a private `GRAPH` naming the graph they were generated
785
+ from, so **regenerate** — a multi-graph app whose modules predate this says so
786
+ and refuses rather than guessing. `FakeClient.new` takes `registry:` for a
787
+ caller that holds the graph, since a graph whose `schema` is a file can't be
788
+ matched back off the schema object. (The per-graph registry behind all of
789
+ this, `Codegen::Registry`, is internal, like the rest of codegen's IR.)
790
+ - **`graphql: :wire` runs a spec against your own transport.** The other tags
791
+ sit *in* the client slot, so the transport an app ships — APM tracing, a
792
+ caller tag, mTLS — never ran in a spec. `:wire` leaves `GraphWeaver.client`
793
+ where it is and serves your resolvers at the endpoint it posts to: the request
794
+ is serialized, posted through your middleware, and deserialized by `from_h`
795
+ over the server's own bytes. What sits behind each endpoint is what that
796
+ graph *is* — decided **per graph**, in descending faithfulness: its router
797
+ when it is in a composed supergraph, its live schema class when it has one,
798
+ else **a fake of its schema**. So the commonest shape there is, and the one
799
+ the README's Countries and GitHub examples are — a pure client of someone
800
+ else's API, with a committed dump and no resolvers to run — needs no
801
+ hand-written `GraphQL::Schema` shaped like your own query. One endpoint is
802
+ served per graph: the client that graph bakes into its modules with
803
+ `client:`, or `GraphWeaver.client` for a graph that bakes none — so an app
804
+ whose graphs all bake one needs no app default at all. A graph whose baked
805
+ client posts nowhere is refused by name rather than its requests leaving the
806
+ suite, and a graph with no schema at all is refused in `:wire`'s own voice,
807
+ naming the three places one can come from. The one candidate the other tags
808
+ fall back to and this one can't is `GraphWeaver.client`'s own schema —
809
+ reading it introspects the very endpoint `:wire` has stubbed.
810
+
811
+ **The pick is said out loud**, because it was invisible from inside the
812
+ example: an app whose client is a url transport and whose `Shop::Schema` no
813
+ one named got a fake, so its suite went green against fabricated data with
814
+ nothing said. One line per endpoint goes to `GraphWeaver.logger` (a Rails app
815
+ already has one) — `:wire serving Shop::Schema (in-process) at https://…` at
816
+ `info`, and at `warn`, naming the class and `config.schema`, when a fake stood
817
+ in while this process has a `GraphQL::Schema` class nothing pointed at. A
818
+ warning rather than a refusal, because a loaded class isn't proof you meant it
819
+ at *this* endpoint (a federated suite loads every subgraph's) and a fake
820
+ behind the wire is a thing to want: name the class for the suite, and call
821
+ `graphql_fake` in the examples that want fabricated data. Nothing else is
822
+ logged from a `:wire` example any more — the predicates deciding what to serve
823
+ raised-and-rescued to answer "no", and every `GraphWeaver::Error` writes a
824
+ `warn` line as it is built, so each example used to log two refusals that
825
+ never happened.
826
+
827
+ **A `graphql_*` helper says what goes behind the wire.** Under the other tags
828
+ a helper takes the client slot; under `:wire` it is served instead — the slot
829
+ has to keep your own client for the transport to run at all — so
830
+ `graphql_fake("Reader.orders" => [{ "status" => "PAID" }, {}])` pins one
831
+ `:wire` example's data exactly as it pins a `:fake` one's, and
832
+ `graphql_router(fake: …)` says per example what `config.router = { fake: … }`
833
+ says for the suite. A `:wire` example over a fake proves your transport — the
834
+ request your middleware wrote, the headers it sent, `from_h` reading real
835
+ JSON off a socket — but not that your `cast:` agrees with the real server,
836
+ since the fabricated bytes are written to match your own scalar
837
+ registrations.
838
+
839
+ It needs [webmock](https://github.com/bblimke/webmock) **enabled** — `require
840
+ "webmock/rspec"` in the spec helper — which hooks Net::HTTP, Faraday and
841
+ HTTPX, so every bundled transport runs unchanged. Having it in the Gemfile is
842
+ not enough: `Bundler.require` makes it *loaded* without installing the
843
+ adapters, so `:wire` checks before serving and names the line to add rather
844
+ than letting the first request leave the suite for the real endpoint. It
845
+ needs **rack** too — webmock's `to_rack` builds the Rack env with it and
846
+ doesn't depend on it — and the refusal names both in one sentence rather than
847
+ letting you find them one `bundle install` at a time.
848
+ `GraphWeaver::Testing::Endpoint` is the ordinary Rack app behind it, mountable
849
+ anywhere for anyone who'd rather have a real socket. See
850
+ [testing](docs/testing.md#over-the-wire--graphql-wire), which also covers
851
+ [making the served endpoint fail](docs/testing.md#making-the-served-endpoint-fail):
852
+ `:wire` adds one stub per endpoint and webmock answers with the last one
853
+ declared, so a `stub_request(…).to_return(status: 503)` in the example is a
854
+ **served** failure — your transport reads the status and `Retry-After` off a
855
+ real response and spends its real retry budget, which a `Failure` client
856
+ (which raises above the wire) can't reach.
857
+ - **A `context:` can be a proc**, called per request with the headers as sent —
858
+ the identity seam nothing above the wire could reach:
859
+
860
+ ```ruby
861
+ config.context = ->(headers) { { current_user: User.find_by(token: headers["Authorization"]) } }
862
+ ```
863
+
864
+ A hash still works, and is still the baseline `graphql_context` merges onto.
865
+ - **`config.context`, `config.schema` and `config.router` are refused once
866
+ they're too late**, under one rule: **configure at load, or in an `around` —
867
+ never in a plain `before`.** The tag builds (and under `:wire` serves) an
868
+ example's clients in a `before` hook of its own, which rspec runs ahead of any
869
+ group `before`, so a set there changed nothing and the example passed against
870
+ whatever the tag had already picked — a `config.context` that silently never
871
+ reached a resolver, a `config.schema` the fake never saw. The refusal fires
872
+ exactly when the clients are already built and names the `around` and the
873
+ per-example helper (`graphql_context`, `graphql_fake(schema:)`,
874
+ `graphql_router(fake:)`). Move any such `before` to an `around`, or to
875
+ `Testing.configure` in the spec helper.
876
+ - **The testing fakes refuse rather than silently do nothing.**
877
+ `fail_at: "pets.0.name"` matched nothing at all — indices were stripped
878
+ before comparing — so a spec named a list element, injected no failure, and
879
+ passed. An index is a path segment like any other now: state the ones you
880
+ mean and the rest match any position, so the plain `"pets.name"` still fails
881
+ the first element the walk reaches; a path that starts with an index, or
882
+ isn't a String, is refused. `Failure.graphql("boom", extensions: {…})` put
883
+ the extensions on the **response**, so the error carried none, and `code:`
884
+ was a bare "unknown keyword" — the keywords beside a message now shape that
885
+ error (`code:`, `extensions:`, `path:`, `locations:`), the hash form still
886
+ holds several, and anything else is refused by name. The response's own
887
+ `extensions:` went with them; `FakeClient` is where a whole response is
888
+ shaped. And **`Failure.timeout`** joins `.transport`/`.server`: a
889
+ `TransportError` whose `#cause` is net/http's `Net::ReadTimeout`, so a spec
890
+ says "it timed out" without naming net/http's classes.
891
+ - **A pin is told from an option by a lookup, not by casing.** The rule was "a
892
+ dot or a leading capital is a pin", so a lowercase type could not be pinned at
893
+ all: `graphql_fake("pokemon_v2_pokemon" => …)` against a Hasura API came back
894
+ as `a fake doesn't take pokemon_v2_pokemon:` and the pin was silently gone.
895
+ Now a key the fake takes is an option, a key **your schema** knows — a type,
896
+ enum, scalar, `Type.field` coordinate, or a field name — is a pin, and a key
897
+ that is neither is refused naming both and guessing across both. A key written
898
+ in the leading positional hash is only ever a pin, which is the spelling for a
899
+ schema whose own vocabulary collides with an option name. A keyword that is a
900
+ near-miss for a pin (`Persn: "Ada"`) now raises `ArgumentError` from the fake
901
+ rather than `GraphWeaver::Error` from the override check; the same key in the
902
+ leading hash is unchanged. And a **scalar** pin written as the Ruby object —
903
+ `overrides: { "Money" => Money.parse("12.00") }` — is serialized the way the
904
+ registration says, rather than reaching `from_h` as a `Money` and failing
905
+ there; an object pin's fields already followed that rule. Where the
906
+ registration can't serialize a value (a `serialize:` Proc builds source), the
907
+ fake refuses naming the scalar instead of letting the cast fail two layers
908
+ down.
909
+ - **`list_size:` also takes a Hash, saying how long ONE list is.** Every list
910
+ the fake reaches read the same setting, so nested unbounded lists multiply:
911
+ `rows { owner { … } tags }` at `list_size: 1600` fabricates 1600 rows and
912
+ 1600 tags in each of them, and per-row allocations double with every doubling
913
+ of the number (1,064 → 12,981 objects per row from 100 → 1600). Key the Hash
914
+ the way pins are keyed — a `"Type.field"` coordinate or a bare field name,
915
+ with `default:` for the rest — and the named list stays flat (205 objects per
916
+ row across the same sweep): `{ "Row.tags" => 3, default: 1000 }`. Integer and
917
+ Range mean exactly what they did; a key the schema doesn't know is refused
918
+ with a spellcheck, at the fake's door and at `Testing.configure`. Works the
919
+ same on `config.list_size`, `graphql_fake(list_size:)` and a router's `fake:`.
920
+ - **A test-time schema memo notices the file underneath it changing.**
921
+ `GraphWeaver::Testing.config.schema` keys its located dump on the resolved
922
+ path, so `GraphWeaver.schema_path=` and `root=` are no longer invisible to
923
+ `:fake` — only `Testing.reset!` used to clear it, which made the fabricated
924
+ shapes order-dependent across spec files. It still loads once while the file
925
+ stands still.
926
+ - **A `graphql:` tag now reaches a module generated with `client:`.** The baked
927
+ `DEFAULT_CLIENT` sits above `GraphWeaver.client`, which is the slot a tag
928
+ swapped, so `it "…", graphql: :fake` ran a bound module against its real
929
+ endpoint. The mode now stands in for that constant too — a per-call `client:`
930
+ and `MyQuery.client =` still win, and `:wire` still leaves every client where
931
+ it is. **If a spec relied on a bound module ignoring the tag, it now runs
932
+ against the fake.**
933
+ - **`@oneOf` was never enforced on any schema read from an introspection dump.**
934
+ Codegen emits `ONE_OF` from `one_of?` and `InputStruct#one_of!` is the only
935
+ thing that refuses two fields being set — but graphql-ruby's introspection
936
+ query omits `isOneOf` unless asked, and its loader drops the field even when
937
+ it is present, so every dump this gem has ever written said "not @oneOf" for
938
+ every input object and the enforcing struct was never generated. Both halves
939
+ are fixed, and a schema introspected now and one loaded back off the file that
940
+ writes are the same schema. Schemas built from SDL (`.graphql`/`.gql` dumps,
941
+ inline SDL, a live class) were always correct. A server too old to define
942
+ `isOneOf` *refuses* the query rather than answering null — PokeAPI's Hasura
943
+ does — so introspection asks for it and asks the baseline query again rather
944
+ than fail: one extra round trip, only where the first answer was going to be
945
+ an error either way.
946
+ - **`schema:refresh` and `schema:diff` work for a schema you serve yourself.**
947
+ Both refused unconditionally for a dump built from a graphql-ruby class
948
+ ("records no source url"), so the documented CI chain — `schema:diff &&
949
+ queries:check && verify && cassettes:check` — was permanently red for an
950
+ in-process app, and a runtime `QueryError` on a renamed field advised
951
+ `schema:refresh`, the one task guaranteed to refuse. One rule now, asked per
952
+ graph: **the dump is the contract generation reads; `refresh` rewrites it
953
+ from the graph's source, `diff` says how far that source has drifted from
954
+ it** — whichever the source is. A url is re-introspected as before; a live
955
+ schema class fills the same duck-typed slot, so it answers introspection
956
+ itself and nothing touches a network. Only a graph whose dump IS its source
957
+ still refuses, because there is nothing behind it to re-read. If you wired up
958
+ graphql-ruby's `GraphQL::RakeTask` to rebuild the dump for graph_weaver, you
959
+ can drop it: `schema:refresh` writes the same artifact, in whatever format
960
+ the dump already is, to the path graph_weaver reads. `schema:diff`'s verdict
961
+ now names the source it compared against (`… matches Catalog::Schema`,
962
+ `… matches https://api.example.com/graphql`) rather than "the server".
963
+ `SchemaLoader.refresh!` takes `schema:` and `path:` for the same reason;
964
+ `SchemaLoader.endpoint` and `Graph#dump_source` are the two new public names.
965
+
966
+ **`schema:refresh` also writes a graph's first dump.** Nothing wrote the
967
+ second one: the installer is single-graph, `URL=` names one endpoint for an
968
+ app that has one dump per graph, and `real_world.md`'s `GraphWeaver.new(url,
969
+ cache: true).schema` recipe writes the conventional `schema_path`. A graph
970
+ naming a dump that isn't there yet is introspected from the url its own
971
+ client posts to, and the dump records that url as its source like any other —
972
+ so two plain remote APIs are: declare both graphs with `schema
973
+ "app/graphql/poke/schema.json"` and `client "POKE"`, run `schema:refresh`,
974
+ run `generate`. `schema:diff` stops reading a missing dump as "generates from
975
+ that url directly", which is what a graph naming a live schema class does,
976
+ and names the file and the task instead. `Graph#named_dump_path` and
977
+ `SchemaLoader.dump_path?` are the new public names; `Graph#dump_path` still
978
+ means "the dump that is there".
979
+ - **`schema:refresh` no longer overwrites a composed supergraph with the API
980
+ schema behind it.** Introspection returns the merged shape a router serves,
981
+ with the `@join__*` routing table gone — so `rake graph_weaver:schema:refresh
982
+ URL=<router>` replaced a 5.8 KB supergraph with 1.6 KB of API schema and
983
+ exited 0, after which `federation:diff` failed and `schema:diff`/`verify`
984
+ went green against the wrong artifact. Any write that would trade a
985
+ supergraph dump for a non-supergraph one is refused, naming `rover supergraph
986
+ compose`; `schema:refresh` with no `URL=` says the same thing to a federated
987
+ app instead of suggesting one.
988
+ - **A task that can't honour `SUPERGRAPH=` says so.** The flag reaches the
989
+ `federation:*` tasks, and `queries:check`, `verify`, `generate` and the rest
990
+ ignored it in silence — so `SUPERGRAPH=public.graphql rake
991
+ graph_weaver:queries:check` reported every query valid against a supergraph
992
+ missing a field they select. They refuse it instead; honouring it would
993
+ collapse a multi-graph app into one unnamed graph, which for `generate` means
994
+ pruning the other graphs' generated files. Declare the supergraph on a graph
995
+ to check against it. The reasoning is in [DECISIONS.md](DECISIONS.md).
996
+ - **`verify` fails when the dump has fallen behind the schema class it was
997
+ built from.** Its question is "is what's checked in current", and the dump is
998
+ checked in too — for an app that serves its own schema it is an artifact
999
+ derived from code in the same repo, and everything downstream reads it, so
1000
+ `generate` and `verify` both called a tree up to date while the live
1001
+ resolvers had already moved. A CI gate that only reads exit codes saw
1002
+ nothing. A dump you deliberately keep behind your own schema is a `verify`
1003
+ failure now — refresh it, or name the schema explicitly
1004
+ (`verify_generated!(schema:)`), which asks about no dump at all. The check
1005
+ costs one in-process introspection per graph and never a network call: a
1006
+ dump that records a url stays `schema:diff`'s subject.
1007
+ - **A cassette holds one entry per request key, and the docs now say so.** The
1008
+ key is the query, its variables and the operation name together; re-recording
1009
+ a request replaces its entry and a request the file hasn't seen appends one,
1010
+ so `GRAPHWEAVER_RECORD=1` never leaves two entries for one request however
1011
+ often it runs. Unchanged behavior, newly stated and specced: editing a query
1012
+ changes the key, so the old entry stays behind as a recording nothing sends —
1013
+ what `cassettes:check` counts as "not sent by any query module", and what
1014
+ deleting the cassette and recording afresh clears. **`cassettes:check`'s
1015
+ stale advice names both causes** too: the generated structs move when a
1016
+ *registration* moves, not only when the schema dump does, and re-recording
1017
+ doesn't fix that half — so regenerating is offered for it by name.
1018
+ ([cassettes](docs/cassettes.md))
1019
+ - **An `@interfaceObject` no longer breaks the subgraph its interface's *other*
1020
+ implementers live in.** Apollo writes a bare `@join__field` — no `graph:` at
1021
+ all — on a concrete implementer's copy of a field really contributed through
1022
+ `@interfaceObject` elsewhere, and the routing table read that the way it reads
1023
+ no directive at all: "wherever the type lives". So `products` was said to
1024
+ resolve `Bundle.reviews`, which `Products::Schema` never defines — pinning
1025
+ `subgraphs: { "products" => Products::Schema }` raised `ConfigurationError` at
1026
+ `Router.new`, and under auto-detection `products` was reported absent and an
1027
+ unrelated `{ products { name price } }` refused `absent_subgraph`, advice
1028
+ about eager loading and all. The rule is now one sentence: **a field routes to
1029
+ exactly the subgraphs its `@join__field` names**, and only a field with *no*
1030
+ `@join__field` lives wherever its type does. Selecting the implementer's copy
1031
+ directly is refused `no_owner` ("the supergraph places `Bundle.reviews` in no
1032
+ subgraph") instead of being fetched from a subgraph that would not answer it.
1033
+ - **A `@requires` field and a plain one crossing into the same subgraph now ride
1034
+ one `_entities` call**, as Apollo's do, instead of two — they are split only
1035
+ when a prefetch didn't answer for some node, which is the one case their node
1036
+ sets differ. A dashboard query drops from 6 fetches to 5.
1037
+ - **A subgraph SDL that renames what it imports now loads.**
1038
+ `@link(import: [{name: "@key", as: "@primaryKey"}])` — the `@link` spec's own
1039
+ aliasing — raised an error blaming the file for a directive the file had
1040
+ declared. The `@link` header is read for every spelling it binds and then
1041
+ dropped from the schema, so `link__Import`/`link__Purpose` no longer appear in
1042
+ a schema loaded from subgraph SDL.
1043
+ - **Two more queries the local router refuses instead of answering them
1044
+ wrong.** A **`@fromContext` argument** was refused only from the
1045
+ crossing-aware half of the planner, so a query one subgraph answered whole
1046
+ skipped the check — and only a gateway injects a `@fromContext` value, so the
1047
+ resolver ran with the argument unset and the router returned a plausible
1048
+ wrong number. And a **progressive `@override(label:)`** wasn't refused at
1049
+ all: Federation 2.7 leaves both subgraphs resolving the field and lets the
1050
+ gateway split traffic by the label, where the router picked one and answered
1051
+ from it every time, silently and forever (new category
1052
+ `:progressive_override`; drop the label and composition drops the losing
1053
+ copy, which plans normally). Every refusal that must hold for a query however
1054
+ it is planned now runs above the single-subgraph shortcut, which is the one
1055
+ walk every plan passes through — see [DECISIONS.md](DECISIONS.md).
1056
+ - **`@defer`/`@stream` are refused by name** (`:incremental_delivery`) rather
1057
+ than by happening to fail validation — the answer would arrive in more than
1058
+ one payload, and the Apollo Router supports `@defer` for real. The scan runs
1059
+ above validation in both places that refuse, so the reason you get is this
1060
+ one and not graphql-ruby's "Directive @defer is not defined":
1061
+ `Testing::Router#execute` used to validate before it planned, and `rake
1062
+ graph_weaver:generate` had no check at all — it refused only while no schema
1063
+ declared the directive, and would have generated a module that dropped the
1064
+ deferred selections against a supergraph that did.
1065
+ - **A subgraph error out of `Testing::Router` carries `extensions: {"service"
1066
+ => "<subgraph>"}`.** Every real transport stamps which subgraph failed and a
1067
+ client branches on it, so a test asserting on an unstamped error passed here
1068
+ and broke in front of a gateway. The Apollo Router's spelling; a resolver's
1069
+ own `extensions` are left alone. **Update any spec that compares a router
1070
+ error hash whole** — and note that a production router with
1071
+ `include_subgraph_errors` omitted redacts the stamp along with the message
1072
+ ([testing](docs/testing.md)).
1073
+ - **`rake graph_weaver:federation:diff` sees three things it used to miss.**
1074
+ It no longer **calls an absent subgraph stale**: a schema was recognized by
1075
+ the types its subgraph declares, and two subgraphs extending one entity
1076
+ declare the same one — so a `prefs` running in another process was matched to
1077
+ its neighbour `accounts`, every field only `prefs` resolves was reported
1078
+ stale, and the gate failed red advising a recompose that would change
1079
+ nothing. A schema now also has to define something the supergraph attributes
1080
+ to that subgraph **alone**; one that shares everything it declares is "not
1081
+ here", which [docs/federation.md](docs/federation.md) already promised doesn't
1082
+ fail the task. `#skipped` (and the `not checked` section) now names those
1083
+ coordinates rather than the types.
1084
+
1085
+ It **compares a field's type, not just its name**. The check was coordinate
1086
+ presence — `Warehouse.code` going from `String!` to `ID!` under an untouched
1087
+ supergraph reported "matches the schemas here", and CI passed on a
1088
+ composition describing a graph nobody serves. Both types are compared and a
1089
+ disagreement is its own drift kind, **shape**, beside stale and not composed
1090
+ in, with both signatures in the message: `Warehouse.code (inventory): String!
1091
+ in the supergraph, ID! here`. `Drift#to_h` grows a `"shape"` key and `#drift?`
1092
+ counts it, so a suite asserting on either sees the new kind.
1093
+
1094
+ And it **names a subgraph you retired from the composition but left loaded**.
1095
+ Every check there walks the supergraph's subgraph list, so a Ruby schema the
1096
+ composition no longer places sat on the only side nothing looked at —
1097
+ recompose without `reviews`, leave `Reviews::Schema` defining everything it
1098
+ always did, and the report said "matches the schemas here (checked 3 of 3
1099
+ subgraphs)", exit 0. It is named on stderr ("not placed — no subgraph of any
1100
+ supergraph read here is:"), as a **warning rather than drift**: a process that
1101
+ loads a subgraph of a supergraph this run never reads is the same picture, and
1102
+ nothing tells the two apart. `Drift#unplaced` is the list for one supergraph
1103
+ and `Drift#subgraphs` is the names it does have; the task asks every graph in
1104
+ the run, so a multi-graph app's second supergraph places its own.
1105
+
1106
+ The `federation:*` tasks also ask the declared graphs where the supergraph is.
1107
+ `federation:diff`, `:subgraphs` and `:coverage` looked only at the
1108
+ conventional dump, so an app that had written
1109
+ `GraphWeaver.graph(:accounts) { schema "…/supergraph.graphql" }` had to repeat
1110
+ it in `SUPERGRAPH=` — and without the flag got a refusal describing a file it
1111
+ never named. Each task now runs once per declared graph whose schema is a
1112
+ composed supergraph, heading each report with the graph's name, and
1113
+ `:coverage` measures that graph's own `queries` rather than
1114
+ `GraphWeaver.queries_paths`. `SUPERGRAPH=` still overrides for one run, and a
1115
+ single-schema app with a composed dump prints exactly what it did.
1116
+ - **Three fixes in `rails g graph_weaver:install`.** It **wires the rspec
1117
+ require where rspec reads it**: the setup told you to put `require
1118
+ "graph_weaver/rspec"` in `spec/support/graph_weaver.rb`, and rspec-rails ships
1119
+ the `spec/support` glob **commented out**, so for anyone who hadn't
1120
+ uncommented it the require never ran and a `graphql: :fake` example silently
1121
+ went to the real client. The line now goes in `spec/rails_helper.rb` (under
1122
+ rspec-rails' own `require "rspec/rails"`), or `spec/spec_helper.rb` if that's
1123
+ all there is, or is printed when the app has neither — idempotent, so a re-run
1124
+ and a line you added by hand both leave it alone. It **no longer wires auth
1125
+ you didn't ask for**: the initializer carried `auth: ENV["GRAPHWEAVER_AUTH"]`
1126
+ on every url install, so a public API's setup read an ENV var nobody had set,
1127
+ while the `--auth` docs said the flag was "omitted entirely for a public API
1128
+ that needs no token" — without `--auth` that line is now written commented,
1129
+ which is both the honest default and how you add a token later. And it
1130
+ **keeps a schema dump you already have**: the dump was the one file it didn't
1131
+ write through `create_file`, so Thor never prompted on it and declining every
1132
+ conflict on a re-run still replaced it — a re-run naming a *different*
1133
+ endpoint silently overwrote the old graph's dump along with the source url it
1134
+ records. An existing dump is left alone and named (with where it was
1135
+ introspected from, when that isn't the source just given); delete it and
1136
+ re-run to re-introspect, or `rake graph_weaver:schema:refresh` to re-fetch in
1137
+ place. All three describe what the *next* install writes — existing
1138
+ initializers are untouched.
1139
+ - **`rails g graph_weaver:install` adds each graph's output directory to
1140
+ `AllCops: Exclude:`** when the app already has a `.rubocop.yml`, so plain
1141
+ `rubocop` stops reporting `Style/Documentation`, `Style/ClassAndModuleChildren`
1142
+ and `Metrics/*` on generated files. The block it appends carries
1143
+ `inherit_mode: merge: [Exclude]`, because RuboCop *replaces* an `Exclude`
1144
+ array on merge rather than unioning it — without that line the append would
1145
+ wipe the app's effective excludes, RuboCop's own `vendor`, `node_modules` and
1146
+ `tmp` defaults included, along with anything reaching the file through
1147
+ `inherit_from:`. It never creates the file, and it leaves an `AllCops:` you
1148
+ already have alone — a second one would replace it rather than merge —
1149
+ printing the lines to add instead, as it does for a multi-document
1150
+ `.rubocop.yml`, where rubocop reads only the first document and an appended
1151
+ block would land where nothing reads it.
1152
+ - **New: `rake graph_weaver:unused`, the over-fetch the other checks can't see.**
1153
+ `verify` says the Ruby matches the query; this asks whether the query still
1154
+ asks for what the app *uses*. It reads the props each query's generated
1155
+ structs carry, sweeps your `.rb`/`.erb`/`.slim`/`.haml`/`.jbuilder` once for
1156
+ every form a prop could be read by (`.sku`, `sku:`, `:sku`, `"sku"`), and
1157
+ names what nothing reads — by query file, selection, and the coordinate the
1158
+ query spells (`Person.bornOn`, not the prop `born_on`). `.rake` and
1159
+ `.builder` are swept too. Nothing is edited and it exits 0; `STRICT=1` exits
1160
+ 1 when anything is unread — `0`, `false`, `no`, `off` and empty are off, so
1161
+ the spelling a CI config uses to turn it off turns it off — and
1162
+ `PATHS=app,lib` narrows the sweep, refusing a path that isn't there rather
1163
+ than sweeping nothing and reporting every selection unread. A repo with no
1164
+ queries is told `no queries in <dir>`, the sentence its sibling `generate`
1165
+ already says, instead of being advised to generate nothing. A line handing a
1166
+ query module straight to `render json:` / `to_h` / `to_json` / `as_json` /
1167
+ `serialize` / `deconstruct_keys` reads every prop at once, so that module is
1168
+ excused and the line is quoted — matching a serializer by name is the softest
1169
+ thing here and a wrong excuse should be visible; the sweep follows the local
1170
+ a module was assigned to, so the ordinary two-line controller (`result =
1171
+ Q.execute!` then `render json: result.person`) is excused as well, and it
1172
+ says which local it followed. A graphql-ruby *type* file is skipped, in the
1173
+ `< Types::BaseObject` spelling graphql-ruby's own generator emits as well as
1174
+ the fully qualified one; a `Resolver` or `Mutation` is not, since a BFF reads
1175
+ the upstream graph exactly there. It matches names as text, which makes it a
1176
+ lint and not a proof — the task's own footer names what it is blind to, and
1177
+ carries the measured rate: on a real app half to two thirds of genuinely
1178
+ unread selections go unreported
1179
+ ([getting started](docs/getting_started.md#the-selections-nothing-reads)).
1180
+ - **Three things are refused where they used to go wrong later.** A `client`
1181
+ that isn't a constant is refused at generation: the value is spelled into
1182
+ every generated module, so a `client` given the endpoint url emitted
1183
+ `-> { https://api.example.com/graphql }` — a file that doesn't parse — from a
1184
+ run that reported success, and the message now names the constant to declare
1185
+ instead. A `cast:` or `serialize:` **proc that returns a value** is refused at
1186
+ registration: a proc there builds *source* for the generated file, so
1187
+ `cast: ->(v) { v.to_sym }` interpolated to nothing and every response failed
1188
+ far from the registration, blaming the codec; it is probed once when
1189
+ registered, and a non-String return names the spelling to use. And a router's
1190
+ `fake:` refuses `seed:`, as `graphql_fake` and `graphql_router` already did —
1191
+ rspec's `--seed` drives the fake, and a router is built once for the suite, so
1192
+ a seed there would pin every example to one run.
1193
+ (`GraphWeaver::Testing.config.seed` remains the override for a harness that
1194
+ isn't rspec.)
1195
+ - **A generated output nested deeper than a `generated_paths` glob reaches is
1196
+ hidden from Zeitwerk again, and loaded.** `app/graphql/a/b/generated` was read
1197
+ as already covered by the default `app/graphql/*/generated` — `File.fnmatch?`
1198
+ lets `*` cross a `/`, and `Dir.glob`, which expands the same pattern
1199
+ everywhere else, does not. It was then neither ignored nor required:
1200
+ production boot died on a `Zeitwerk::NameError` naming a constant you never
1201
+ wrote, and development simply had the modules missing. Nothing to do — a
1202
+ layout like `app/graphql/subgraphs/billing/generated` now works.
1203
+ - **A symlinked generated output is hidden from Zeitwerk, and refused when it
1204
+ is declared too late to hide.** The railtie resolved a path with
1205
+ `File.expand_path`, which doesn't follow symlinks, while Zeitwerk walks real
1206
+ directories — so an `output` that was a symlink, or an absolute one through a
1207
+ symlinked ancestor (the Capistrano `current/` shape), was ignored under a name
1208
+ Zeitwerk never visits and its modules died on `uninitialized constant`. The
1209
+ refusal that exists for a too-late output compared the same unresolved path
1210
+ against real autoload roots, so it stayed silent for exactly that spelling; it
1211
+ now fires, and names the output the way your graph spells it.
1212
+ - **A graph declared from `config.to_prepare` whose `output` no loader can be
1213
+ told to skip is refused at boot**, naming the graph and the fixes. Rails sets
1214
+ Zeitwerk up before `to_prepare` runs and Zeitwerk reads its ignore list only
1215
+ then, so such a directory cannot be hidden from autoloading — it used to fail
1216
+ in every environment with a Zeitwerk error that blamed a dropped
1217
+ `extend_type`. The refusal asks Zeitwerk which directories a loader would
1218
+ really walk, so an app that hid the output itself with
1219
+ `Rails.autoloaders.main.ignore(...)` is left alone. An output under
1220
+ `config.autoload_once_paths` is refused at the point that knows, and names the
1221
+ two routes that work — `Rails.autoloaders.once.ignore(...)` from
1222
+ `config/application.rb`, or generating somewhere that is not an autoload-once
1223
+ path — since Rails sets the `once` autoloader up in bootstrap, where nothing
1224
+ an initializer does can reach it.
1225
+ - **`config.graph_weaver` refuses a key the railtie doesn't read.** It takes
1226
+ `watch`; `config.graph_weaver.queries_paths = ...` was taken silently and did
1227
+ nothing. The refusal names `GraphWeaver.queries_paths =` as the setting that
1228
+ works, and covers every spelling of that write —
1229
+ `config.graph_weaver[:queries_paths] = ...` and `store` went straight to
1230
+ `Hash#[]=`, which is the silent no-op the refusal exists to prevent.
1231
+ - **`load_generated!` tells a `Zeitwerk::NameError` apart from a dropped
1232
+ `extend_type`/`register_enum`.** The old message sent you hunting for a
1233
+ registration that was still there; the new one says Zeitwerk owns the
1234
+ directory and names the fix (`GraphWeaver.generated_paths`, or the
1235
+ conventional `app/graphql/*/generated`).
1236
+ - **`GraphWeaver.configure do |config| ... end`** — `GraphWeaver::Testing.configure`
1237
+ existed and the top level didn't. `config` is `GraphWeaver` itself, so the
1238
+ flat `GraphWeaver.client = ...` spelling is the same call and stays valid.
1239
+ - **Four smaller fixes.** `GraphWeaver::Transport::Faraday` resolves without a
1240
+ require — the constant is autoloaded, so the `Transport::Faraday.new(url) { |conn| … }`
1241
+ that [docs/transports.md](docs/transports.md) shows works where you'd write
1242
+ it, an initializer, instead of raising `NameError` at boot (still opt-in:
1243
+ naming it is what loads faraday). `rails g graph_weaver:install` writes the
1244
+ current scalar spelling, `register_scalar("Money", BigDecimal)`, rather than a
1245
+ three-keyword registration for a scalar 0.6.1 registers for you. The
1246
+ unregistered-scalar report prints where the task that found it prints: one of
1247
+ `generate`'s and `verify`'s two registry advisories went only to the logger,
1248
+ which in Rails is `log/development.log`, so nobody running the task saw it —
1249
+ `GraphWeaver.untyped_scalars` is the list, beside
1250
+ `GraphWeaver.unmatched_registrations`, and it is grouped by graph when a run
1251
+ covers more than one — merged flat it read as "forgotten everywhere" for a
1252
+ scalar registered for one graph and forgotten for the next, so
1253
+ `GraphWeaver.untyped_scalars_by_graph` is the same fact kept apart. And `rake graph_weaver:cassettes:check`
1254
+ sees a namespaced graph's modules: it looked for top-level constants, so an
1255
+ app whose graphs set `namespace` found "0 generated modules", refused for
1256
+ having checked nothing, and blamed the cassette directory.
1257
+ - Internal: `bin/round-trip --hostile` now spoils an **input** leaf as well as
1258
+ a response one — a wrong type, an unparseable string, a value the enum
1259
+ doesn't have, a nil where null is illegal, at any depth including inside
1260
+ lists — and requires an `InputError` whose `#path` names the corrupted slot
1261
+ and whose `#kind` says what was wrong. The 40,000 clean round trips before it
1262
+ were evidence about response decoding and close to none about input errors;
1263
+ run against the GitHub schema it found the missing list index in `#path` on
1264
+ its own. It also reads a renamed prop now: a field whose Ruby name took a
1265
+ trailing underscore used to make the harness compare `class` against
1266
+ `class_` and call every such round trip lossy, which hid whatever else that
1267
+ query would have caught — and it calls `execute` with the kwarg a variable
1268
+ really becomes, since it used `prop_name` and an argument named `hash`
1269
+ (Linear's `Query.comment(hash:)`) went out as `hash_:` for `execute` to
1270
+ refuse. The rename exists so a *prop* can't shadow a method its struct
1271
+ answers; an execute kwarg shadows nothing and keeps the schema's spelling.
1272
+
1273
+ The harness also **reads a scalar off the registry rather than off its name**.
1274
+ Sweeping 23 public schemas turned up two declaring `scalar Time` (GitLab's,
1275
+ universe's) and one declaring `DateTimeOrDuration` (Linear's), and both
1276
+ oracles mis-read them: `Time` is a row in the harness's own legal/illegal
1277
+ tables, keyed there by the *Ruby* type a registration casts to, so an
1278
+ unregistered custom scalar of that name was spoiled as if it had a codec, and
1279
+ generated code was failed for accepting what `T.untyped` pass-through must
1280
+ accept. The lookup asks the Ruby type now, with a built-in's own name allowed
1281
+ to refine it. Separately, `bin/round-trip`'s stand-in registrations spelled
1282
+ out `serialize: :iso8601` for a timestamp, which is `Time#iso8601` and drops
1283
+ the sub-second the harness's wire expectation keeps; they name the class and
1284
+ stop, as docs/scalars.md has always said to. No library behavior changed —
1285
+ both were the harness lying about real schemas — and the registrations moved
1286
+ to `RoundTrip.register_scalars!` so the suite can hold them to it. And
1287
+ `railties` joins the development dependencies, so a spec can boot a real
1288
+ `Rails::Application` and assert the railtie's initializer order rather than
1289
+ reasoning about it — the ordering bug in the logger and instrumenter
1290
+ auto-wires was invisible to a suite that never booted Rails.
1291
+ - Docs: [logging](docs/logging.md) gains the payload table, the log line, and
1292
+ two-line adapters for OpenTelemetry (`in_span`) and Datadog
1293
+ (`Datadog::Tracing.trace`) — both run against the real gems. Datadog's
1294
+ Net::HTTP and Faraday contribs already trace the transport; the instrumenter
1295
+ adds the span *above* it, named for the operation, which is the one that
1296
+ means anything when every call is a POST to the same url. It also says what
1297
+ `filter_parameters` does and doesn't reach: what GraphWeaver composes, never
1298
+ a server- or resolver-authored `GraphQLError#message`.
1299
+ [errors](docs/errors.md) now says what a *server's* input rejection carries,
1300
+ and leads with what isn't portable rather than burying it: the section opens
1301
+ with the servers read by name (graphql-ruby, Apollo, Hasura), what marks an
1302
+ error as input on each, and a worked fallback for a server that marks
1303
+ nothing — including the nil-`#field` branch a `:refused` error takes, the
1304
+ deep-underscore a Rails JSON controller owes its params before `execute`, and
1305
+ the Hasura `where:` value that is legal to the comparison type and fails at
1306
+ Postgres as `data-exception`, which is neither code the arm reads. It also
1307
+ says what shape the convention's `path` must be, and that a
1308
+ `GraphQL::Schema::Validator` on a *field* is never told which list element it
1309
+ is validating — graphql-ruby coerces a list with a plain `map` — so a `qty`
1310
+ inside `lines: [LineInput!]!` reports `["input", "qty"]` for every element
1311
+ alike; install the validator on the **list argument** instead and index the
1312
+ coerced Array yourself (the recipe is there, and a spec runs it). And it says
1313
+ plainly that neither side collects every problem: `coerce` raises on the first
1314
+ field that won't convert (unknown keys and absent required fields being the
1315
+ two it does list in full), and graphql-ruby 2.6 aborts variable validation at
1316
+ the first `validates:` failure across the whole tree — so build the form
1317
+ expecting to iterate.
1318
+ New [i18n](docs/i18n.md) page proposes stable keys for input problems;
1319
+ nothing in it ships yet. It spells out which spelling a path is in and how it
1320
+ maps to a form field, the nil branch, and how to render `#path` when the
1321
+ index is the half that says which list row failed.
1322
+ [scalars](docs/scalars.md#registering-a-class-of-your-own) **leads with the
1323
+ money shapes that work internationally**. The worked `Money` example
1324
+ hardcoded `"USD"` in its cast — correct for the wire shape it assumed (a bare
1325
+ decimal), and silently wrong for any other currency, which comes back
1326
+ mislabelled with no error anywhere. That's the schema's shape, not the
1327
+ library's, so the section now orders the shapes by what they carry: an object
1328
+ scalar (`{"amount","currency"}`), one string carrying both, and an object type
1329
+ `Money { amount currency }` — which needs no `register_scalar` at all, just an
1330
+ `extend_type` — with the bare decimal last and marked single-currency-only.
1331
+ Also new there: register what your cast *returns*, not where the factory
1332
+ lives (`register_scalar("URL", URI)` runs fine and fails `srb tc` at every
1333
+ call site, because Sorbet's `URI` module payload has no `include Kernel` —
1334
+ register `URI::Generic`); pass-through means no invented codec, not an
1335
+ optional cast, since a class JSON can't parse into is still refused when a
1336
+ query reads the field; a `JSON`-narrowed coordinate opts that field out of
1337
+ `:fake` fabrication as well as hardening its shape; and which cross-type
1338
+ values are refused — Ruby **objects**, where a timestamp *string* for a `Date`
1339
+ parses and truncates, as graphql-ruby's own `ISO8601Date` does. And what a
1340
+ timestamp cast costs, measured: `Time.parse`, the reader every `DateTime`
1341
+ field already uses, is ~7× `Date.iso8601`, and `cast: :iso8601` is ~3×
1342
+ cheaper than it and stricter.
1343
+ [getting_started](docs/getting_started.md) cuts the first hour down to the
1344
+ single-schema path, with multi-schema as its own section further down, names
1345
+ the measured false-negative rate of `rake graph_weaver:unused` where the task
1346
+ is introduced — a lint you can act on is worth more than one you are invited
1347
+ to trust — gains the runnable CI script it never had, one per topology plus
1348
+ the GitHub Actions job, says that a validator installed by symbol is
1349
+ invisible to Zeitwerk (reference it from the same `to_prepare` block, above
1350
+ the schema), and answers the Sorbet question where the adoption decision is
1351
+ made rather than at the end. Its §5 also names the two `rover` commands that
1352
+ close the federated-CI gap below, and the Actions job grew a `federation:diff`
1353
+ step.
1354
+ [testing](docs/testing.md) gains `fail_at`'s path syntax, an
1355
+ `extensions.input` example, `Testing::Sequence` for ending a paging loop, what
1356
+ `to_timeout` does and doesn't prove (webmock stands in for the socket, so
1357
+ neither it nor a `sleep` in `to_return` can exercise a `read_timeout:` of
1358
+ yours), that a fake-mode pin is keyed by the schema's scalar name rather than
1359
+ the Ruby class, that `:fake` never runs your server's `validates:` or custom
1360
+ validators, and loses the "partial failure" label on a call that fails the
1361
+ whole response. New there: **a production Apollo Router redacts what
1362
+ `Testing::Router` hands you** — with `include_subgraph_errors` omitted, the
1363
+ default, a subgraph error becomes `{"message" => "Subgraph errors redacted",
1364
+ "path" => […]}` with the extensions emptied, so a spec asserting on a
1365
+ subgraph's message or on the `extensions.service` stamp passes here and fails
1366
+ in staging; it says what to assert on instead and gives the `Failure.graphql`
1367
+ that reproduces the redacted shape. Also the nested-list multiplication
1368
+ behind `list_size:`, measured.
1369
+ [federation](docs/federation.md#producing-a-supergraph) gains the step it
1370
+ never had — how to *produce* a supergraph: `federation_sdl` from the
1371
+ `apollo-federation` gem (named in prose for the first time), where other
1372
+ teams' subgraph SDL comes from, and `rover supergraph compose` with its
1373
+ config and its `--elv2-license` flag. It also writes down that
1374
+ [`router.trace`'s fetch count is the local router's plan, not a
1375
+ gateway's](docs/federation.md#the-local-router): the data is faithful, the
1376
+ cost isn't quite — the same dashboard query is 4 fetches through a real
1377
+ `@apollo/gateway`, because a `@requires` prefetch here is its own call even
1378
+ when it goes to the same subgraph as the plain read beside it, so an N+1
1379
+ regression assertion belongs on a bound or on the subgraph set rather than on
1380
+ an exact count. Also newly written down there: a `@join__` directive the
1381
+ routing table doesn't read refuses `Router.new` for the whole graph (an
1382
+ upgrade-timing event for every team sharing the supergraph), a document that
1383
+ fails ordinary GraphQL validation gets an `errors` response rather than an
1384
+ `Unplannable`, and apollo-federation's `orphan_types`-before-`query` ordering
1385
+ trap. It now opens with a signpost saying which of its sections belong to a
1386
+ client team, a subgraph team and the supergraph owner, and gains four more:
1387
+ **in CI**, because no task in the toolbox looks at the schema production is
1388
+ serving — they all compare the app to its own checked-in artifacts, and
1389
+ `schema:diff` can't be pointed at a supergraph — so it names the two `rover`
1390
+ commands that close it; **`@tag` and contract variants**, what codegen says
1391
+ against each (it refuses at the boundary, which is the guarantee), why
1392
+ `federation:diff` can't tell two variants apart, that nothing cross-checks the
1393
+ variant you generated against with the endpoint you call, and that a variant
1394
+ is mechanically just another `GraphWeaver.graph`; **two changes every gate
1395
+ calls clean**, a scalar swapped for one that still serializes as the same JSON
1396
+ kind (`String!` → `Currency!`, `"19.99"` → `"$19.99"`) and an enum value
1397
+ removed where no query names it literally, with `schema:diff`'s `breaking:
1398
+ true` the only signal for either; and that an `@override` migration is
1399
+ finished from the **old** side, since deleting the new owner's copy first
1400
+ hands the field back to the subgraph you were migrating away from and nothing
1401
+ reports it. Also there: `Federation::Drift`'s `subgraphs:` takes a
1402
+ resolver-less schema from `SchemaLoader.load(sdl)`, so a subgraph published as
1403
+ SDL by a team that doesn't write Ruby is fully compared — and detection unions
1404
+ every candidate that fits a subgraph, so diffing a *proposal* has to name it.
1405
+ [generated modules](docs/generated_modules.md#an-input-object-generates-its-whole-closure)
1406
+ states the rule the docs implied and never said — results are generated per
1407
+ selection set, input types by transitive closure, which is why one Hasura
1408
+ `$where` emits a thousand files — and documents the escape hatch that only
1409
+ ever appeared in an error message: write the filter as a query literal with a
1410
+ variable per leaf. It also says how far a result's immutability goes: its
1411
+ props, so the `String` or `Hash` a leaf holds is the one the response carried,
1412
+ as with `Struct` or `Data` — and that caching one wants `Marshal`, not YAML,
1413
+ since a `T::Enum` member is a singleton compared by identity and Psych
1414
+ rehydrates a duplicate, so `pet.species == Species::Dog` comes back false. It
1415
+ also names the one enum misuse nothing catches — `result.tier == "GOLD"` is
1416
+ silently `false`, on a generated `T::Enum` as on any other — and the
1417
+ sorbet-runtime switch that reports it
1418
+ (`T::Configuration.enable_legacy_t_enum_migration_mode` plus a
1419
+ `soft_assert_handler`), which belongs in your boot rather than in generated
1420
+ code because it covers your own enums too. And two costs: `verify_generated!`
1421
+ runs a full `generate!` however little changed, while
1422
+ `T::Configuration.default_checked_level` buys nothing for `from_h`. Its
1423
+ type-helpers section now shows that an `extend_type` mixin *can* carry sigs
1424
+ `srb tc` checks — declare the fields it reads as abstract sigs, which the
1425
+ struct's `const`s satisfy.
1426
+ [upgrading](docs/upgrading.md) opens each version section with a table of
1427
+ which changes apply to you, separates the grep hits that self-heal on
1428
+ regenerate from the ones to rename by hand, and ends on `graph_weaver:verify`.
1429
+ New [alternatives](docs/alternatives.md) compares the field — graphql-client,
1430
+ graphlient, artemis, hand-rolled — including where this gem loses. Also:
1431
+ browsing a live schema from a console
1432
+ ([real world](docs/real_world.md#browsing-the-schema)), the four settings
1433
+ rake-free generation needs to agree on, what `retries:` does and doesn't
1434
+ cover plus `pool_size:` and the pool's fork-safety, and that the request body
1435
+ carries no persisted-query id — so a gateway safelist with `require_id`
1436
+ refuses it, with the `Transport::HTTP` subclass that bolts APQ on today
1437
+ ([transports](docs/transports.md#retries)), that Rails' own default
1438
+ `filter_parameters` includes `:email` so a stock app redacts an ordinary field
1439
+ named `email` the day the gem is added ([logging](docs/logging.md#filtered-variables)),
1440
+ and keying a translation on the exception class where there is no `kind`
1441
+ ([i18n](docs/i18n.md#transport-and-server-failures)). And
1442
+ `spec/doc_samples_spec.rb` parses every fenced Ruby sample in README +
1443
+ `docs/` and resolves every link between them, so a sample that doesn't run
1444
+ can't ship — two that didn't now do (`Codegen.generate(client:)` takes the
1445
+ constant's *name*, and `Federation::Drift` needs `require
1446
+ "graph_weaver/federation"`).
1
1447
  ### v0.6.1 (2026-09-10)
2
1448
  - **A stdlib scalar registers with nothing but its class.**
3
1449
  `GraphWeaver.register_scalar("Decimal", BigDecimal)` now emits the whole
@@ -1090,7 +2536,7 @@ resolvers in-process: no gateway, no node, no sockets. It plans one shape — a
1090
2536
  query whose every field resolves in a single subgraph, passed to that subgraph
1091
2537
  verbatim — and raises `Unplannable` (a `GraphWeaver::Error`) for anything that
1092
2538
  crosses a boundary, at plan time, before any subgraph runs. See
1093
- [docs/testing.md](docs/testing.md#a-local-federation-router).
2539
+ [docs/testing.md](docs/testing.md#a-federated-graph--graphql-router).
1094
2540
 
1095
2541
  **`rake graph_weaver:federation:coverage SUPERGRAPH=…` says how much of your
1096
2542
  query set that router can plan**, and groups every refusal by what stopped it —