graph_weaver 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +4 -4
  3. data/README.md +40 -88
  4. data/docs/alternatives.md +1 -7
  5. data/docs/cassettes.md +54 -59
  6. data/docs/editors.md +32 -47
  7. data/docs/errors.md +261 -369
  8. data/docs/federation.md +650 -837
  9. data/docs/generated_modules.md +380 -463
  10. data/docs/getting_started.md +211 -428
  11. data/docs/i18n.md +114 -177
  12. data/docs/logging.md +127 -116
  13. data/docs/real_world.md +26 -39
  14. data/docs/scalars.md +277 -310
  15. data/docs/testing.md +343 -486
  16. data/docs/transports.md +203 -268
  17. data/docs/upgrading.md +211 -560
  18. data/examples/README.md +38 -0
  19. data/examples/countries.rb +39 -0
  20. data/examples/federation.rb +62 -0
  21. data/examples/github/generate.rb +20 -0
  22. data/examples/github/generated/star_mutation.rb +126 -0
  23. data/examples/github/generated/stargazers_query.rb +232 -0
  24. data/examples/github/generated/starred_query.rb +151 -0
  25. data/examples/github/queries/star.graphql +8 -0
  26. data/examples/github/queries/stargazers.graphql +22 -0
  27. data/examples/github/queries/starred.graphql +11 -0
  28. data/examples/github/run.rb +43 -0
  29. data/examples/github/setup.rb +18 -0
  30. data/examples/rick_and_morty.rb +57 -0
  31. data/graph_weaver.gemspec +12 -3
  32. data/lib/graph_weaver/client.rb +30 -1
  33. data/lib/graph_weaver/codegen/emit.rb +5 -11
  34. data/lib/graph_weaver/codegen.rb +23 -55
  35. data/lib/graph_weaver/context_seam.rb +54 -0
  36. data/lib/graph_weaver/errors.rb +23 -15
  37. data/lib/graph_weaver/federation.rb +11 -2
  38. data/lib/graph_weaver/graph.rb +39 -29
  39. data/lib/graph_weaver/in_process.rb +15 -9
  40. data/lib/graph_weaver/internal/endpoint.rb +7 -5
  41. data/lib/graph_weaver/internal/headers.rb +19 -0
  42. data/lib/graph_weaver/internal/test_clients.rb +7 -11
  43. data/lib/graph_weaver/internal.rb +81 -13
  44. data/lib/graph_weaver/log_subscriber.rb +10 -2
  45. data/lib/graph_weaver/logging.rb +33 -13
  46. data/lib/graph_weaver/query_module.rb +44 -23
  47. data/lib/graph_weaver/retry.rb +12 -8
  48. data/lib/graph_weaver/rspec.rb +13 -24
  49. data/lib/graph_weaver/schema_loader.rb +52 -14
  50. data/lib/graph_weaver/tasks.rb +10 -2
  51. data/lib/graph_weaver/testing/cassette.rb +28 -5
  52. data/lib/graph_weaver/testing/endpoint.rb +14 -13
  53. data/lib/graph_weaver/testing/fake_client.rb +33 -3
  54. data/lib/graph_weaver/testing/router.rb +7 -3
  55. data/lib/graph_weaver/testing.rb +12 -4
  56. data/lib/graph_weaver/transport/http.rb +2 -2
  57. data/lib/graph_weaver/transport.rb +47 -23
  58. data/lib/graph_weaver/version.rb +1 -1
  59. data/lib/graph_weaver.rb +32 -10
  60. metadata +16 -3
  61. data/CHANGELOG.md +0 -3801
data/CHANGELOG.md DELETED
@@ -1,3801 +0,0 @@
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"`).
1447
- ### v0.6.1 (2026-09-10)
1448
- - **A stdlib scalar registers with nothing but its class.**
1449
- `GraphWeaver.register_scalar("Decimal", BigDecimal)` now emits the whole
1450
- codec: `BigDecimal(...)` reads the wire value, `to_s("F")` writes it back,
1451
- and the generated file requires `bigdecimal`. The careful spelling people
1452
- reached for was wrong — `serialize: :to_s` puts `"0.125e2"` on the wire —
1453
- so the library owns it. Cast inference gained Kernel's conversion functions
1454
- (`BigDecimal()`, and any `Kernel#Type` your app defines for a type the wire
1455
- can't already be), and `serialize:` accepts `[:method, *args]` for a
1456
- serializer that takes arguments. `cast:`/`serialize:`/`requires:` are
1457
- unchanged, for a class of your own.
1458
- - **Scalar names that are conventions are registered already**: graphql-ruby's
1459
- `ISO8601Date`, `ISO8601DateTime`, `BigInt` and `JSON`, plus `DateTime` —
1460
- what GitHub, Shopify and most hand-written schemas call an ISO 8601
1461
- timestamp. A date deserializes into `Date` and a timestamp into `Time`, so
1462
- nothing invents a midnight; `BigInt` reads the decimal string graphql-ruby
1463
- writes as well as a JSON number; `JSON` is registered as `T.untyped` on
1464
- purpose, which also drops it from the "unregistered custom scalars" report.
1465
- A schema that means something else by one of these names fails loudly — the
1466
- cast raises, naming the field — and one `register_scalar` overrides it, as
1467
- it always could. **Regenerate** if a query of yours reads a scalar with one
1468
- of those names: its prop was `T.untyped` and now has a type.
1469
-
1470
- ### v0.6.0 (2026-09-09)
1471
- - **One rule for relative path settings: they resolve against
1472
- `GraphWeaver.root`** — `Rails.root` in a Rails app, the working directory
1473
- otherwise. There were two: cassettes resolved against `Rails.root` while
1474
- the schema dump, queries and generated directories resolved against the
1475
- process's working directory, and the railtie patched three of the four
1476
- places that mattered. A Rails app started anywhere but its own root — a
1477
- `bin/rails server` or an `rspec` run from a subdirectory — loaded no
1478
- generated modules at all. An absolute setting is untouched, and the settings
1479
- keep returning what you configured, so `graphql.config.yml` stays portable.
1480
- Set `GraphWeaver.root` to override. The rule runs both ways: a path is
1481
- resolved against the root on access and reported relative to it on output,
1482
- so `generate!`'s return value, `changed_files`, `check_queries`' keys, the
1483
- rake tasks' `wrote`/`pruned` lines and the file an error names all come back
1484
- in the short form you configured. `cache:` also accepts a `Pathname` now,
1485
- as `schema:` and `query:` always have.
1486
- - **One spelling for the generated module's name: `name:`.** `GraphWeaver.parse`
1487
- took `name:` while `Codegen.generate` took `module_name:`. **Breaking:**
1488
- `module_name:` now raises naming `name:`. A parsed module also names itself
1489
- in errors — `GraphWeaver.parse::PersonQuery::Result::Person` instead of a
1490
- hex address — and assigning it to a constant upgrades every nested struct
1491
- to the real path.
1492
- - **`generate!` leaves a file alone when its content hasn't changed**, so the
1493
- rake task prints `wrote` only for files it wrote and `N already up to date`
1494
- for the rest, and a watching dev server reloads only what moved.
1495
- `GraphWeaver.changed_files` says which. A generated file now says which
1496
- release wrote it (`# Generated by GraphWeaver 0.6.0 — do not edit.`), so the
1497
- first `verify` after an upgrade reports the tree as stale. **Regenerate.**
1498
- - **A fake refuses an option it doesn't take**, naming the accepted ones and a
1499
- did-you-mean, at every door (`FakeClient.new`, `graphql_fake`,
1500
- `Router.new(fake:)`, `graphql_router(fake:)`) — it was a raw
1501
- `unknown keyword` from inside the fabricator. A scalar the fake can't
1502
- fabricate is now reported at its path (`at reader.orders.0.total`) with the
1503
- coordinate to override (`"Order.total"`).
1504
- - **A supergraph using federation 2.8's `@context`/`@fromContext` is refused
1505
- per query rather than routed with the argument unset.** `@join__field` was
1506
- on the routing table's known list, so its `contextArguments:` was read and
1507
- dropped: `Testing::Router` planned a fetch for the contextual field with the
1508
- argument empty. Apollo composes such a graph even when the context-setting
1509
- type and the contextual field sit in different subgraphs, so nothing
1510
- upstream caught it. The refusal fires only where the router would plan that
1511
- fetch itself; a subtree one subgraph answers whole still runs. New
1512
- `Unplannable` category `:context_argument`.
1513
- - **The local router makes one entity fetch where it made two** when an entity
1514
- has two `@requires` fields crossing into the same subgraph on the same
1515
- `@key`. Both sent the identical representation; Apollo makes one. Specs
1516
- asserting on `#trace` for such a query see one fewer entry. A missing `@key`
1517
- on a representation now names the entity type in `InputError#struct`, as a
1518
- coercion failure already did.
1519
- - **Three rake and generator polish items.** `rake -T` no longer prints a
1520
- queries path it can't know — a task description is baked before
1521
- `:environment`, so it presented the default as your setting; it now names
1522
- the defaults as defaults. `cassettes:anonymize` prints `no recordings in
1523
- <dir>` instead of nothing. `rails g graph_weaver:install` scaffolds
1524
- `app/graphql/fragments/.keep`, the directory its editor config already
1525
- globbed; the getting-started note names `rails g graphql:install
1526
- --skip-query-logs` for an app without Active Record.
1527
- - **`filter_parameters` scrubs error messages, not just the debug log.** A
1528
- rejected value reached the log twice: on the `variables=` line at debug, and
1529
- in the message every error writes at `warn` — above the gate, as free text.
1530
- One rule now covers both: a message the library composes about a value you
1531
- supplied names it only when the key it arrived under isn't filtered. So
1532
- `$password of Login: [FILTERED]`, while `$count of Compute: expected an Int,
1533
- got "lots"` is unchanged — the value is usually the whole diagnosis. Applies
1534
- to execute kwargs, input fields at any depth, federation `@key` fields, and
1535
- the missing-cassette report. A wrong-typed input field with no coercer now
1536
- says which field (`cents: expected BigDecimal, got 5`) instead of sorbet's
1537
- sentence, and the local federation router no longer logs variables
1538
- unscrubbed.
1539
- - **The public surface is what the docs name, what generated code calls, and
1540
- the `execute` slot — 421 names, down from about 700.** Everything else moved
1541
- under `GraphWeaver::Internal`, whose one rule is that nothing in it is API,
1542
- or went `private`. `spec/public_surface_spec.rb` diffs what's reachable from
1543
- `GraphWeaver` against a checked-in list, so the next accidental promotion
1544
- fails CI instead of shipping. **Breaking** only for code reaching past the
1545
- documented door: the federation query planner and its IR are
1546
- `Internal::Planner`; the fake-value engine is `Internal::Values`; the
1547
- selection walk is `Internal::Selection` (so `FakeClient` no longer answers
1548
- to `each_field` or `gather`); the cassette matching rules are
1549
- `Internal::RequestKey`; `GraphWeaver.log`/`.instrument`/`.filter_variables`
1550
- are `Internal::Log` (`logger=`, `instrumenter=`, `filter_parameters=` are
1551
- unchanged); `Transport.operation_name`/`.mutation?`/`.log_tag` left the
1552
- class you subclass for `Internal::Wire`. `SchemaDiff::Change`,
1553
- `Cassette::Check`, `Coverage::Result` and `InputStruct::Field` are `Data`,
1554
- not `Struct`, so they no longer hand out writers. `Codegen::ScalarType` is
1555
- private from load rather than from the first `reset_scalars!` — the
1556
- `private_constant` had landed inside the method body. Generated modules also
1557
- stop exposing their plumbing: `DEFAULT_CLIENT`, `FIELDS` and `ONE_OF` are
1558
- emitted as `private_constant`. **Regenerate**; nothing you call changes.
1559
- - **The retry options are flat.** `retries:` is how many attempts follow the
1560
- first; every other `Retry` option now sits beside it on the client —
1561
- `GraphWeaver.new(url, retries: 5, backoff: :linear, retry_codes:
1562
- ["THROTTLED"])`. **Breaking:** `retries:` no longer takes a Hash (`retries:
1563
- { retries: 5 }` read as a key nested in itself), and three options are
1564
- spelled out now that they sit next to a count: `on:` is `retry_on:`,
1565
- `base:`/`max:` are `base_delay:`/`max_delay:` — `retries: 5, max: 30`
1566
- invited reading `max:` as a second attempt count. The Hash form names its
1567
- flat replacement, and a retry option passed without `retries:` says so.
1568
- - **A representation's key fields are coerced, like an `execute` kwarg.**
1569
- `Representations.user(id: params[:id])` typechecked statically and then
1570
- raised sorbet-runtime's unbranded `TypeError` at runtime. The generated
1571
- builders now carry `.checked(:never)` and run each key field through the
1572
- registered scalar's own conversion; a value that converts to nothing raises
1573
- `GraphWeaver::InputError` naming the representation and the field.
1574
- **Regenerate.**
1575
- - **The library's internals are private.** Everything not documented and not
1576
- named by generated code is now `private` / `private_class_method` /
1577
- `private_constant`: codegen's IR and walk, the schema loader's detection
1578
- tables, the transport's log patterns, `Retry`'s default predicate, the
1579
- file-naming helpers. **Breaking:** `GraphWeaver::Selection`'s methods are
1580
- private in every class that mixes it in, so `Testing::FakeClient` no longer
1581
- answers to `each_field`, `gather` or `load_operation` — never a supported
1582
- door. The runtime that emitted source calls into (`cast_data`,
1583
- `check_envelope!`, `Coerce`, `Hints`, `InputStruct`, `QueryModule`,
1584
- `Representation`) stays public and says in its comments that it is
1585
- generated code's, not yours.
1586
- - **A registration another schema owns warns instead of failing generation.**
1587
- One registry serves a whole graph — federation composes scalars and types
1588
- by name — but validation ran against the single schema in hand, so the
1589
- natural multi-subgraph pattern (register once, generate each query against
1590
- its own subgraph) hard-failed. Generation now fails only on what the schema
1591
- can *disprove*: a name it declares as something else, or a coordinate whose
1592
- field it declares as a composite. Everything else warns, naming the schema
1593
- and suggesting a corrected name. `rake graph_weaver:generate` and `verify`
1594
- print that list once per run after the files;
1595
- `GraphWeaver.unmatched_registrations` is the same list for a Rakefile.
1596
- **Breaking**: a typo'd registration that used to stop generation is now a
1597
- printed warning. `docs/federation.md` gains "Generating for a federated
1598
- graph": through the gateway, generate against the supergraph; calling
1599
- subgraphs directly, one client each with `reset_registrations!` between.
1600
- - **Variables coerce inside `execute` now, and the sig stays as narrow as the
1601
- schema.** `execute(first: params[:first])` works — the String becomes an
1602
- Integer, an ISO-8601 string a `Date`, a model's Integer primary key an `ID` —
1603
- while `execute(first: "20")` written literally in a typed file is still an
1604
- `srb tc` error. The only way to accept loose input used to be `coerce: true`
1605
- / `GraphWeaver.auto_coerce`, which bought it by *widening* the emitted kwarg
1606
- and so switched off the static check at every call site. The generated sigs
1607
- are now `.checked(:never)` so an untyped value reaches the body; coercion is
1608
- the check that replaces sorbet-runtime's, and it refuses more than sorbet
1609
- did. Bad input raises `GraphWeaver::InputError` naming the variable, the
1610
- operation and the value — the same 422 rescue point as an input-object
1611
- field. **Breaking:** `GraphWeaver.auto_coerce` and `register_scalar(coerce:)`
1612
- are gone; a scalar that had `coerce: true` keeps its behaviour but its kwarg
1613
- narrows, so a call site passing a literal String is now an `srb tc` error.
1614
- `cast:` is what a loose value converts through. **Regenerate.**
1615
- - **Numeric strings are read as a wire format, not as Ruby source.** `"010"`
1616
- is ten, not eight, and `"0x1f"` and `"1_0"` are refused, in variables and
1617
- responses alike — `Kernel#Integer` accepted all three, which made a
1618
- zero-padded form field silently mean something else. An `Int` refuses a
1619
- `Float` it can't hold losslessly (`2.5`), and a `Boolean` refuses a String:
1620
- Ruby has no `Kernel#Boolean`, so every rule for `"0"`/`"off"` is a
1621
- convention the library declines to pick — convert at the call site.
1622
- - **A faked subgraph takes the options every other fake takes, and
1623
- `graphql_router` says them per example.** `subgraphs: { "reviews" => :fake }`
1624
- built its fake with nothing, so `overrides:`, `list_size:` and `null_chance:`
1625
- had nowhere to go. `fake:` carries them, on `Router.new`, on
1626
- `Testing.config.router`, and per example through `graphql_router(fake:)`,
1627
- which mirrors `graphql_fake`. One `fake:` covers every faked subgraph —
1628
- coordinate-keyed overrides already say which type they mean. A `fail_at:`
1629
- inside an `_entities` fetch used to null the field and report no error; it
1630
- now comes back as an error on the caller's path.
1631
- - **A subgraph two loaded schema classes fit is refused by the query that
1632
- reaches it, not by `Router.new`.** In an app where several loaded schemas
1633
- each satisfy a foundational subgraph, building a router raised for
1634
- subgraphs the query under test never touched — and only when eager loading
1635
- happened to be on. The router now builds, every query that avoids the
1636
- subgraph runs, and one that reaches it raises naming the candidates, saying
1637
- they came from loaded schema classes, and showing the `subgraphs:` form that
1638
- pins one. A class named explicitly still fails at construction.
1639
- `router.ambiguous` lists them. `Testing::Subgraphs.resolve` now returns a
1640
- `Resolution` (`#served`, `#ambiguous`).
1641
- - **A `.graphql` edit reaches the next request in development.** The query
1642
- directories and the schema dump join Rails' own reloaders, and the
1643
- `to_prepare` that loads the generated modules now regenerates first — after
1644
- your registrations, so an `extend_type` in a `to_prepare` is in place before
1645
- codegen reads it. A query that doesn't compile is logged at `error` with its
1646
- file and position while the modules already loaded keep serving. Development
1647
- only, while the server is running; `config.graph_weaver.watch = false` turns
1648
- it off, and `rake graph_weaver:verify` still gates CI on the committed files.
1649
- `GraphWeaver.reload_generated!` does the same by hand after regenerating in
1650
- another terminal, and generated files are now written to a temp file and
1651
- renamed, so a `rake graph_weaver:generate` beside a watching dev server can't
1652
- hand the running app a prefix that no longer parses.
1653
- - **A variable passed `nil` now sends `null`; one left out is still left
1654
- out.** GraphQL tells an absent variable from an explicit null — `bio: null`
1655
- clears a bio, omitting it does nothing — and a Ruby kwarg with a nil default
1656
- cannot, so both spellings omitted the key and no generated mutation could
1657
- clear a field. An optional nullable variable now records whether the keyword
1658
- was supplied. Input objects get the same distinction where a Hash can express
1659
- it: `coerce({nickname: nil})` sends null, `coerce({})` omits; a struct built
1660
- with `.new` can't tell, so nil there still means omit. A non-null variable
1661
- can't carry null, so nil there also still omits and its schema default
1662
- applies. **Breaking**: `bio: params[:bio]` with a missing param now sends
1663
- `null` where it used to omit — pass the keyword only when you mean to.
1664
- **Regenerate.**
1665
- - **A response that won't cast says which field, and whose bug it is.** A
1666
- leaf's cast raises about the value alone — "invalid date" on a struct holding
1667
- four dates located nothing — so each casting leaf now carries its response
1668
- key. An `ID` the server sent unquoted is out of spec but read like a
1669
- graph_weaver bug in sorbet's words; the message now says which it is and how
1670
- to take it anyway. An enum value the generated enum doesn't hold now names
1671
- the legal values and says the likely cause is drift, matching the input
1672
- side. **Regenerate.**
1673
- - **`register_scalar` with a type the wire can't build is refused at
1674
- generation.** `register_scalar("Money", BigDecimal)` produced a `BigDecimal`
1675
- prop and no cast, so every response failed its prop check at runtime, far
1676
- from the initializer that caused it. Generation now refuses it where a query
1677
- reads that scalar back, naming the field and how to give it a `cast:`.
1678
- `docs/scalars.md` now tabulates what the wire carries in both directions.
1679
- - **A fake takes pins, keyed by a scalar type, an object type, or a field.**
1680
- A pin says what the fake uses instead of inventing a value: a wire value,
1681
- an object it reads the selected fields off — a FactoryBot build, a model, a
1682
- `Struct`; a field the object doesn't answer is still fabricated, and a Ruby
1683
- value goes on the wire the way its scalar registration serializes it — or a
1684
- proc handed the seeded `Random`. Pins lead and options follow, in one call;
1685
- suite-wide, the same hash is `Testing.config.overrides`, and a router's
1686
- `fake:` takes it for the subgraphs it fakes.
1687
-
1688
- graphql_fake("Money" => "12.00", "Person" => build(:person), "Order.total" => "999.00")
1689
-
1690
- A scalar registered as your own class — `register_scalar("Money", Money,
1691
- cast: :parse)` — told codegen how to read the wire value but left the
1692
- testing harness guessing what to write, so every fake response touching it
1693
- died inside `from_h` blaming `Money.parse` for a `"Money-1"` placeholder.
1694
- The type pin is the answer, from every door including cassette
1695
- anonymization; without one, fabrication refuses, naming the scalar, the
1696
- field, and the pin to add. Scalars registered as `Time`, `Date`, `Integer`,
1697
- `Float`, `String` or `T::Boolean` need nothing. Fakes also now honor a
1698
- per-field `register_scalar("User.birthday", Date)`, matching codegen's
1699
- resolution. `graphql_fake` and `graphql_router(fake:)` refuse a per-example
1700
- `seed:` — `rspec --seed` is the mechanism, and `config.seed` the suite-wide
1701
- one for a harness that isn't rspec.
1702
- - **`Testing.config.null_chance` and `Testing.config.mode` are gone; both
1703
- are per-fake now.** A suite-wide answer to a per-example question nils an
1704
- unrelated field one run in ten. **Move them:** `graphql_fake(null_chance:
1705
- 0.3)` and `graphql_fake(values: :literal)` (the per-fake `mode:` is renamed
1706
- `values:` so it can't be confused with `graphql: :fake` /
1707
- `config.default_mode`, which stay). The auto-detect — faker when the gem is
1708
- loaded, else literal — is the only suite-wide value style.
1709
- `Testing::MODES` is now `VALUE_STYLES`.
1710
- - **`retries:` takes the count you'd write, and means the same thing
1711
- everywhere.** `GraphWeaver.new(url, retries: 3)` raised and pointed at
1712
- `retries: { tries: 3 }` — a second word for the same number that disagreed
1713
- about whether it counted the first attempt. One word now: `retries:` is how
1714
- many attempts follow the first, on the client and on `Retry` alike, so
1715
- `retries: 0` is one attempt. **Rename** `Retry.new(tries: n)` to
1716
- `Retry.new(retries: n - 1)`; the default is unchanged in effect.
1717
- - **`Response#to_h`.** Every error class answered `#to_h`; the envelope didn't.
1718
- It returns `{"data" =>, "errors" =>, "extensions" =>}` with each error as its
1719
- JSON-ready hash. `data` stays the typed struct rather than re-serialized:
1720
- `T::Struct#serialize` gives snake_case keys, drops nulls, and leaves a
1721
- registered scalar as its Ruby object — output that would pass for the
1722
- server's response without being one.
1723
- - **Sensitive variables are scrubbed from the debug log.** A `login(password:)`
1724
- mutation's variables were one log-level switch away from the log. Matching
1725
- values are replaced with `[FILTERED]` at any depth, on the wire and
1726
- in-process paths. Rails apps configure nothing: the railtie adopts the app's
1727
- own `config.filter_parameters`. Elsewhere,
1728
- `GraphWeaver.filter_parameters = [:password, /token/]`; the default list is
1729
- `[:password, :token, :secret, :authorization]`, matched as case-insensitive
1730
- substrings, and `[]` turns filtering off.
1731
- - **`schema:diff` names what changed, not just that something did.** It
1732
- reported `schema.json is stale` and stopped, so learning what moved meant
1733
- refreshing and reading a `git diff` of a 3 MB dump. It now prints one line
1734
- per change at its schema coordinate, breaking ones first — types, fields,
1735
- arguments, nullability, enum values, union members, interfaces and
1736
- deprecations — and still exits non-zero on any drift. Breaking is judged from
1737
- the client's side, so nullability is directional: an output losing `!` breaks
1738
- a generated struct, an input gaining one breaks a query that omits it.
1739
- `SchemaLoader.stale?` is now `SchemaLoader.diff`, returning the summary;
1740
- `diff(path).empty?` is what `stale?` answered.
1741
- - **A Rails app whose generated code includes an autoloaded helper now
1742
- boots.** `extend_type("Pet", PetHelpers)` and `register_enum("Species",
1743
- PetKind)` name constants your app autoloads, so the library tells you to
1744
- register them from a `to_prepare` block — but the railtie required the
1745
- generated files before Zeitwerk was set up and before `to_prepare` ran, so
1746
- the `include PetHelpers` those registrations emit raised `NameError` at every
1747
- boot, in every environment. They now load from a `to_prepare` block of their
1748
- own: after the autoloader, after your registrations, before eager loading.
1749
- - **A fragment on the abstract type no longer narrows the field away.**
1750
- `named { __typename ... on Named { name } ... on Pet { species } }` read the
1751
- `Named` fragment as a type condition and narrowed to Pet, so a Person came
1752
- back as `nil` and the `name` the server sent went on the floor — while the
1753
- same selection spelled bare dispatched correctly. Two ways of writing one
1754
- query disagreed, and the fragment is how anyone shares interface fields.
1755
- **Regenerate** to pick it up.
1756
- - **A `Float` field reads the whole number a JSON encoder writes.** JSON has
1757
- one number type, so `1.0` reaches Ruby as an `Integer` from graphql-js and
1758
- Go's `encoding/json` alike, and `const :amount, Float` refused a response the
1759
- spec calls legal. Widened with `Kernel#Float`, which still refuses what
1760
- `.to_f` would quietly turn into `0.0`. **Regenerate** to pick it up.
1761
- - **A guarded `__typename` no longer makes a narrowing miss look like a
1762
- match.** Narrowing without a readable tag decides on emptiness, but a
1763
- `__typename` behind `@skip`/`@include` still arrives for the member narrowing
1764
- means to filter, so the wrong member was cast. That query now refuses at
1765
- generation, with the message that already names the fix.
1766
- - **Generation refuses what Ruby would misread.** A result key that would
1767
- shadow a constant the file uses (`date` beside a `Date` scalar nested a
1768
- `class Date < T::Struct` and typed the scalar prop as that struct) is refused
1769
- naming both keys; so is an enum value that camelizes to nothing (`_`), which
1770
- emitted a file that died at load with a syntax error.
1771
- - **A bad enum value in a variable says what the enum accepts**, as an
1772
- `InputError` — it raised a bare `KeyError` naming an anonymous module as a
1773
- top-level variable while the same mistake inside an input object was already
1774
- branded. A missing required input field now lists every missing key instead
1775
- of sorbet's symptom for the first prop it reached. **Regenerate** to pick up
1776
- the new emitted coercion.
1777
- - **A named fragment now crosses a subgraph boundary.** A stitched fetch is
1778
- built from selections alone, so a spread that survived into one named a
1779
- fragment the subgraph had never seen and the whole subtree came back
1780
- `Fragment X was used, but not defined`. Every spread a fetch carries is now
1781
- spelled as the inline fragment it is.
1782
- - **A fake's value for a custom scalar comes from what you registered it
1783
- as.** It was picked off the schema's *name* for the scalar, so a `Timestamp`
1784
- registered as `Time` got `"Timestamp-1"` and every fake response touching it
1785
- died inside the codec codegen had emitted for it. A scalar registered as
1786
- `Time` now gets iso8601, one registered as `Integer` an Integer; an
1787
- unregistered one keeps its placeholder. Cassette anonymization runs through
1788
- the same engine and had the same hole, which made `cassettes:check` fail on
1789
- a cassette the anonymizer had just written. **Re-run
1790
- `rake graph_weaver:cassettes:anonymize`** on a cassette holding a registered
1791
- custom scalar.
1792
- - **A mutation is no longer retried.** A read timeout, a 502 or a reset socket
1793
- does not say whether the server applied the request, and a second `charge`
1794
- is worse than a failed one — so `Retry` gives a mutation one attempt and says
1795
- so on the logger. Pass `retry_mutations: true` for an API whose mutations are
1796
- idempotent. Each retry now logs the wait and the attempt number.
1797
- - **A connection that dies mid-body is a dropped connection.** net/http's
1798
- `ignore_eof` default handed back the bytes that arrived when a socket closed
1799
- short of its `Content-Length`, so a half-sent response reached you as a 200
1800
- with a truncated body — a permanent `ServerError` that `Retry` would not
1801
- retry. It now raises the retriable `TransportError` it is.
1802
- - **An in-process query gets its own context.** graphql-ruby writes a
1803
- resolver's `context[...] =` into the hash it is handed, and one `InProcess`
1804
- is normally the whole app's client — so a flag set by one request was still
1805
- there for the next, and racing between them under Puma.
1806
- - **A response that carries neither `data` nor `errors` is refused.** A client
1807
- returning `nil`, one keying the envelope by symbol, one that typo'd `"dat"`:
1808
- each produced a `Response` reporting `success?` with `data` nil.
1809
- `from_response` is documented public API and symbolized keys are the
1810
- likeliest mistake at that seam, so it now brands, naming the keys it found;
1811
- a client answering with a String brands too, instead of dying on `#to_h`.
1812
- **Regenerate** to pick that up.
1813
- - **`@skip`/`@include` are evaluated by the fake against the variables you
1814
- passed**, declared defaults included — the way a server and
1815
- `Testing::Router` already did, so one query no longer carries a key under
1816
- `graphql: :fake` and not under `graphql: :router`. A faked subgraph does the
1817
- same for directives inside its `_entities` selection. A `first:`/`last:`
1818
- arriving as a variable caps the fabricated list like a literal, a cap below
1819
- zero reads as a page of none, and `null_chance` reaches list positions.
1820
- - **An alias spelling a response key the router carries a `@key` under is
1821
- refused.** The router injects the `@key` it crosses on under a reserved key
1822
- and strips it from the answer, so `_gw_weight: weight` came back without
1823
- `_gw_weight` at all. It joins the `shadowed_key` refusal, which already
1824
- covered Apollo's half of the same collision.
1825
- - **A `@requires` field set excluded by `@skip`/`@include` no longer
1826
- prefetches.** The router ran a subgraph fetch a real router never makes —
1827
- which matters for a test double, where a resolver that runs is one your
1828
- example can observe.
1829
- - **`verify_generated!` fails over zero query documents**, and `generate!`
1830
- warns. A mistyped `queries_paths` left `verify` returning true having
1831
- compared nothing, so a CI gate stayed green forever.
1832
- - **`rake graph_weaver:generate` says what it pruned, and where it looked when
1833
- there was nothing to generate.** Deleting a `.graphql` deletes the checked-in
1834
- file it produced, and the task printed nothing and exited 0; so did a run
1835
- with no queries, the state every install starts in.
1836
- - **`Failure.server` takes the headers a backoff branches on.**
1837
- `ServerError#retry_after` and `#throttled?` read `Retry-After`, and there was
1838
- no way to set one: `Failure.server(status: 429, headers: { "retry-after" =>
1839
- "2" })`. `Failure.transport`'s message now names the class it caught, the
1840
- way the bundled transports do.
1841
- - **Cassette recording is safe across threads.** `record` was
1842
- read-modify-write; two threads recording through one cassette each saved a
1843
- snapshot missing the other's entry.
1844
- - **A `.json` schema dump that isn't JSON says which file and what it holds.**
1845
- A truncated download or a login page saved over the dump raised a bare
1846
- `JSON::ParserError`, outside `GraphWeaver::Error` and naming neither.
1847
- `rake graph_weaver:schema:refresh` — the fix — no longer trips over it.
1848
- - **Setup mistakes say what to do.** A schema source that looks like a url
1849
- (`localhost:4000/graphql` included) gets the url advice first; a non-token
1850
- `auth:`, a `retries:` that is neither true nor a Hash, a url
1851
- `Transport::HTTP` cannot POST to, a `parse` path without a `.graphql`
1852
- extension, a cache directory that can't be written, `schema:refresh URL=`
1853
- with a file path, and `schema:diff` with no dump all name the fix. A
1854
- `ServerError` carries the advice its status implies: a 3xx names where the
1855
- server pointed (redirects are not followed) and a 401/403 names `auth:`. A
1856
- spec with a `graphql:` tag but no `graph_weaver/rspec` is told to require
1857
- it, a misspelled tag reports one failure instead of two, `InputError` no
1858
- longer prints sorbet's `Caller:` frame, a generated file whose registered
1859
- constant is gone names the registration, and `rails g graph_weaver:install`
1860
- names a retry that works when introspection fails.
1861
- - **The GitHub example's checked-in modules were regenerated**, and a spec now
1862
- keeps them in step with the emitter.
1863
- - **A cold process no longer introspects once per in-flight thread.** A
1864
- url-built client fetches its schema lazily, and Puma serves its first
1865
- requests concurrently — so eight threads arriving together meant eight full
1866
- introspection round trips, each of them also writing the schema cache. One
1867
- now, whoever asks first.
1868
- - **The schema cache and cassettes are written atomically.** `File.write`
1869
- truncates before it writes, so an interrupted run — or a second writer, a
1870
- rake task beside a running app — could leave a half-written file where a
1871
- committed artifact used to be. Both now write beside the target and rename,
1872
- which is atomic: a reader sees the old file or the new one.
1873
- - **The connection pool keeps its ceiling under an interrupt.** An async raise
1874
- landing between taking a permit and the `ensure` that returns it would have
1875
- leaked one for the life of the process, and `Rack::Timeout` raises exactly
1876
- that way; the gap is closed. Its saturation warning is also once now rather
1877
- than once per racing thread.
1878
-
1879
- ### v0.5.1 (2026-09-07)
1880
- - **A union's catch-all struct keeps the fields an interface fragment asked
1881
- for.** `... on Named { name }` under a union types `name` on every member the
1882
- query named, but the catch-all — the member it didn't name, including one the
1883
- schema grows later — carried only `__typename`, so a `name` the server
1884
- actually sent was dropped on the floor. Those fields are `T.nilable`: the
1885
- member that arrives needn't implement the interface. **Regenerate** to pick
1886
- it up.
1887
- - **`schema_stale?` now recognises the drift a graphql-ruby server reports.**
1888
- It matched Apollo's one flat code plus a message pattern, but graphql-ruby —
1889
- which this library ships as its in-process client — names the rule that fired
1890
- in `extensions.code`. So a renamed type, a removed argument, an argument that
1891
- became required and four other drift shapes all raised without the one
1892
- sentence telling you to refresh the dump and regenerate, and `to_h` reported
1893
- `"schema_stale": false`.
1894
- - **A cast failure keeps the server's own explanation.** A field that came back
1895
- null *with a reason* — a permission rule, a partial outage — raised only
1896
- Sorbet's nil complaint, because the generated envelope built `data` before it
1897
- read `errors`. The reason is now in the message. **Regenerate** to pick it up.
1898
- - **Cassette anonymization missed `errors` and `extensions`.** It walked
1899
- `data` and nothing else, so a rejected request's error message — which
1900
- routinely quotes the input that caused it — and whatever the server hangs
1901
- off `extensions` went to disk verbatim, under a flag that says real data
1902
- never reaches it. **Re-run `rake graph_weaver:cassettes:anonymize` on any
1903
- cassette you've already committed**, or re-record it. Neither key has a
1904
- schema behind it, so both are now walked by shape: keys, nesting, nulls and
1905
- booleans survive and every string and number is replaced, with `path`,
1906
- `locations` and an error's `extensions.code` kept because they describe the
1907
- request rather than the data.
1908
- - **Recording flags a credential that lands in the file.** A cassette gets
1909
- committed as written, and its query and variables can't be anonymized —
1910
- they're the key replay matches on. So the bytes headed for disk are checked
1911
- for the shapes that are unmistakable (a JWT, `AKIA…`, `ghp_…`, `xox…`,
1912
- `sk_live_…`, a PEM block, a `Bearer` header) and named on stderr once per
1913
- cassette. Nothing is rewritten; a password like `hunter2` has no shape, so a
1914
- quiet run still isn't a clean bill of health.
1915
- - **An anonymized cassette no longer drops a key selected twice.** The
1916
- anonymizer kept the last occurrence's sub-selection where codegen merges
1917
- them, so `a { x } a { y }` came back holding only `y` — a recording that
1918
- then failed its own `cassettes:check`.
1919
- - **Three generated types disagreed with the schema.** `srb tc` proves the
1920
- generated code is self-consistent, not that it is true, so each of these was a
1921
- lie the typechecker endorsed. **Regenerate** to pick them up:
1922
- - A field reached only through a `@skip`/`@include`-guarded occurrence, where
1923
- the same key is also selected unguarded, was typed as guaranteed. The server
1924
- legitimately omits it, so `from_h` raised `key not found` on a valid
1925
- response — and a union whose `__typename` arrived that way crashed its own
1926
- dispatch. Those children are nilable now, and the `__typename` case refuses
1927
- at generation as it already did for the plainer spelling.
1928
- - A narrowed abstract inside a non-null list made the *list* nilable
1929
- (`[Thing!]!` with `... on Widget` → `T.nilable(T::Array[…])`). Only the
1930
- elements can be nil; the array is always there.
1931
- - An input field the schema gives a default is optional, but a non-null one
1932
- emitted `const :x, String, default: nil` — so `x.upcase` typechecked and
1933
- was a `NoMethodError`.
1934
- - **`rake graph_weaver:federation:coverage` no longer counts a subgraph as
1935
- served here when two loaded schemas fit it.** `Router.new` refuses that case
1936
- — picking either would be a coin flip — so the report was promising a run
1937
- the suite can't have. It now lands in the "nothing here serves what they
1938
- reach" list, whose advice (name a schema for those subgraphs) is the fix.
1939
- - **A supergraph that renamed the join spec is now refused instead of read as
1940
- empty.** `@link(url: ".../join/v0.3", as: "j")` spells every marker `j__`,
1941
- which the routing table doesn't follow — so it reported a composed graph with
1942
- *no subgraphs* and nothing unsupported, and `Testing::Router` built happily on
1943
- top of that. It now lands in `unsupported`, so the router and
1944
- `federation:coverage` refuse at construction and say why.
1945
- - **`rake graph_weaver:cassettes:anonymize` now finds an SDL schema dump.** It
1946
- opened `GraphWeaver.schema_path` directly instead of locating the dump the
1947
- way every sibling task does, so an app whose committed dump is
1948
- `schema.graphql` (what the generator writes for a supergraph) got a raw
1949
- `Errno::ENOENT`. A missing dump now says so.
1950
- - **`rake graph_weaver:queries:check` now authenticates with the token your
1951
- app uses.** It re-introspects the url the dump records, but read
1952
- `GRAPHWEAVER_AUTH` rather than the ENV var the dump named — so an app
1953
- installed with `--auth MY_TOKEN` reached its private API while the check
1954
- 401'd. Every re-introspection now goes through `SchemaLoader.source_transport`,
1955
- which is also now public.
1956
- - **A `graphql: :router` example now starts from the same fabricated data
1957
- whether it runs alone or after two hundred others.** The router is built once
1958
- for the suite, and a faked subgraph kept fabricating from wherever the last
1959
- example left its sequence — so `rspec spec/foo_spec.rb:12` disagreed with the
1960
- same example in a full run, which is the opposite of what `--seed` promises.
1961
- `Router#reset!` is the example boundary (`reset_trace` still clears only the
1962
- trace); the rspec tag calls it for you.
1963
- - **An override key given as a Symbol now pins.** `graphql_fake(overrides: { name: "Ada" })`
1964
- validated clean and silently fabricated a random value, because lookup is by
1965
- String — the exact silent-green failure the override validation exists to
1966
- prevent.
1967
- - **A helper no longer contradicts `config.default_mode`.** With
1968
- `default_mode = :fake`, an untagged example calling `graphql_in_process`
1969
- raised *"this example is tagged graphql: :fake"* — naming a tag that wasn't
1970
- there, and blocking the untagged form the docs recommend. Only an explicit
1971
- tag can disagree with a helper now.
1972
- - **A variable default that isn't a Boolean no longer reaches a subgraph.** An
1973
- enum or input-object default is a parser AST node; sending one put a
1974
- back-pointer to the lexer on the wire, and an input object raised
1975
- `NoMethodError` outside any rescue. Only `@skip`/`@include` read these, and
1976
- they read Booleans; a subgraph applies its own defaults from the variable
1977
- declaration it already receives.
1978
- - The local router asks the schema for one type by name rather than rebuilding
1979
- the whole type map, at four sites — one of them per response row. On a
1980
- 1200-type supergraph a stitched query over 200 rows was **5x** slower than it
1981
- needed to be; the cost is now flat in schema size.
1982
- - **`graphql: :in_process` ran the committed schema dump instead of your live
1983
- schema class.** A dump loads as an anonymous `GraphQL::Schema` subclass, which
1984
- looks like a runnable class and has no resolvers — so every app that followed
1985
- `getting_started.md` (the generator always commits a dump) got a graphql-ruby
1986
- 500 blaming its own resolver, and the error that was written to explain this
1987
- was unreachable. Nothing to do; if you worked around it with
1988
- `Testing.config.schema = MySchema`, that still works and still wins.
1989
- - **`GraphWeaver.parse(query:)` accepts a `Pathname`.** `schema:` already did,
1990
- so `GraphWeaver.parse(schema: Rails.root.join("schema.graphql"), query:
1991
- Rails.root.join("app/graphql/queries/person.graphql"))` died on the query
1992
- argument with `undefined method 'end_with?'`. Same for `client.parse(path)`.
1993
- - **`rake graph_weaver:cassettes:check` and `:anonymize` now find cassettes
1994
- from any working directory.** Both read `config.cassette_dir` raw, while
1995
- `Cassette.new` resolves it against `Rails.root` — so run from anywhere but
1996
- the app root, `check` aborted with "this checked nothing, so it proved
1997
- nothing" and `anonymize` silently did nothing, both while a spec run found
1998
- the same files fine. They now use the same resolution the recordings do.
1999
- - **An `extend_type(alias:)` name a struct instance already answers to now
2000
- refuses** instead of silently overriding it. `alias: { hash: "…" }` emitted
2001
- `def hash`, which breaks every `Hash` and `Set` holding that struct;
2002
- `inspect`, `to_s`, `method` and `class` were the same story. A *wire field*
2003
- by any of those names was already refused, so this is the same rule reaching
2004
- the same case from the other side. **If generation now refuses an alias you
2005
- had**, rename it — the accessor it generated was overriding a Ruby method.
2006
-
2007
- ### v0.5.0 (2026-09-07)
2008
- - **`graphql_in_process(SomeSchema)`** runs one example against that schema
2009
- class's real resolvers — the sibling of `graphql_fake`, and how a federated
2010
- app tests a single subgraph directly rather than through the stitched graph.
2011
- `graphql: :in_process` is that call with no argument, which runs
2012
- `config.schema` when it is a live class, so a non-federated app needs
2013
- nothing. A suite testing several subgraphs names one per example, which no
2014
- suite-wide setting could express.
2015
- - **`Testing::Router` crosses a boundary on a nested `@key` or `@requires`.**
2016
- `@key(fields: "id organization { id }")` and
2017
- `@requires(fields: "origin { lat lon }")` used to refuse; they now plan, to
2018
- any depth, with the object carried in the representation the way the SDL
2019
- spells it (a null inner object included). **Nothing to do** — queries your
2020
- suite marked "run this one against a real router" may now simply run. Where
2021
- a type declares more than one `@key`, the plan takes the first one the
2022
- fetching subgraph can supply.
2023
- - **`:nested_field_set` narrowed rather than disappeared.** It now names only
2024
- a nested field set no one *fetch* can build — `origin` resolving in one
2025
- subgraph and `origin.lat` in another, or a nested `@key` whose object a
2026
- `@requires` would half-fill from somewhere else. Either way the object
2027
- would arrive in pieces, and a representation comes from one fetch. If you
2028
- group refusals by category, that bucket shrinks; its message and
2029
- `docs/federation.md`'s table say what is left. `:chained_requires` is
2030
- unchanged and still a different refusal.
2031
- - **`Testing.config.router` takes `subgraphs:` without `supergraph:`.** It
2032
- raised — "must be the arguments to build one, e.g. `{ supergraph: … }`" —
2033
- even where the committed dump already is the supergraph, which is the case
2034
- the docs call "no config at all" and the single most likely config a
2035
- federated app writes (marking a remote subgraph `:fake`). Either key alone
2036
- is enough now; a missing `supergraph:` derives exactly as it does with no
2037
- `config.router` at all.
2038
- - **`graphql_fake(**options)`** builds the example's fake where the example
2039
- can say what it needs — `graphql_fake(overrides: { "Reader.orders" => [{}, {}] })`
2040
- — and returns it, so `#requests` is in reach. `graphql: :fake` is this call
2041
- with no options. Options had nowhere to go before: the tag builds its client
2042
- in a `config.before(:each)`, which rspec runs ahead of every group hook, so
2043
- `Testing.config.overrides` set in a `before` block was always too late and
2044
- failed silently, as wrong data.
2045
- - **`GraphWeaver.client` is snapshotted and restored around *every* example**,
2046
- not only a tagged one. `graphql: false` used not to restore while
2047
- `graphql: :fake` did, which made "tag `:fake`, then throw the client away"
2048
- the idiom for cleanup. Building your own client is now a plain assignment in
2049
- a `before` block. An example that deliberately leaked a client into later
2050
- examples no longer can.
2051
- - **`graphql: :none` is gone** — a second spelling of `graphql: false`, which
2052
- stays. Change any `:none` tag to `false`.
2053
- - **`GraphWeaver.client!` names the tag** when `graph_weaver/rspec` is loaded:
2054
- `no client configured — tag the example graphql: :fake (or :in_process /
2055
- :router), or build one with graphql_fake`. "Set `GraphWeaver.client=`" was
2056
- advice for the wrong file.
2057
- - **An override pins a subtree by naming only the fields the test is about.**
2058
- `overrides: { "Reader.orders" => [{ "status" => "PAID" }, {}] }` pins the
2059
- list's length and merges each element onto fabricated data — the rest of the
2060
- selection is still generated. It used to *replace*, so pinning one nested
2061
- field meant hand-writing the whole selection set in wire casing, and
2062
- under-supplying died as `key not found: "book"` at cast time. A pinned key
2063
- the query doesn't select is now refused (spellchecked, and naming the
2064
- response keys it could have been), for the same reason a typo'd coordinate
2065
- is. At a union or interface, a pinned object names its `"__typename"` and
2066
- gets that member rather than a random one.
2067
- - **An override of `nil` pins the field null.** It used to read as "no
2068
- override" and fabricate a value.
2069
- - **`Testing::FakeClient#requests`** records every `execute` in order
2070
- (`{ query:, variables:, operation_name: }`) — "did we send the right
2071
- variables", and "did we call it at all", without a hand-rolled spy.
2072
- - **`FakeClient`'s selection-walking internals are private** (`each_field`,
2073
- `gather`, `load_operation`, …). Nothing documented called them; if you did,
2074
- `Object.new.extend(GraphWeaver::Selection)` is the supported host.
2075
- - **The local router refuses a `@requires` whose field set names another
2076
- `@requires` field** (`chained_requires`). It used to answer: a prefetch sends
2077
- the entity's own `@key` and nothing else, so the inner requirement never
2078
- arrived and the field was computed from a representation missing its input —
2079
- the same field then holding two different values in one response. The
2080
- one-hop limit was documented but not enforced.
2081
- - **An error path no longer names an injected `@key`/`@requires` alias.** A
2082
- stitched error came back as `["thing", "_gw_w"]`, a field no schema contains;
2083
- it is `["thing", "w"]` now, as a real router reports it.
2084
- - **`@skip`/`@include` driven by a variable's declared default was ignored on a
2085
- boundary-crossing field**, so `query($show: Boolean = true) { … @include(if: $show) }`
2086
- called with no variables silently dropped the field. graphql-ruby applies an
2087
- operation's defaults; the local router read only what the caller passed.
2088
- - **`Testing::FakeClient` raises a GraphQL validation error for an unknown
2089
- field**, as every other client in the slot does. It used to die with
2090
- `NoMethodError: undefined method 'type' for nil` from inside the fabricator —
2091
- undiagnosable, and the commonest mistake there is: a query drifting ahead of
2092
- the schema dump, or a typo in one written inside an example.
2093
- - **The local test router plans a union or interface at a subgraph boundary.**
2094
- `search { ... on Track { playCount } ... on Artist { upcomingEvents { … } } }`
2095
- — a feed, a search page, any polymorphic list — used to be refused
2096
- (`abstract_boundary`), because a representation names one concrete
2097
- `__typename` and the planner runs before any data exists. It now plans one
2098
- branch per concrete type the supergraph says the subgraph can answer with,
2099
- asks the fetch for `__typename`, and buckets the returned objects by it at
2100
- execution time — one `_entities` fetch per concrete type, which is what a
2101
- real router does. Nothing to change; queries that were refused now run.
2102
- - The `crosses_subgraph` refusal category is **gone**, and
2103
- `abstract_boundary` now means only one thing: the supergraph doesn't record
2104
- which concrete types a subgraph answers an abstract type with (no
2105
- `@join__unionMember`/`@join__implements`, and the type in more than one
2106
- subgraph). Match on `Unplannable#category` for either of those and you must
2107
- change it.
2108
- - A fragment whose type condition can't hold at a position is now dropped
2109
- rather than refused — `... on Note` under a field whose subgraph has no
2110
- Note in that union never matches, and a real router answers `{}` there too.
2111
- - **A query file whose name can't spell a constant now names the file.**
2112
- `01_home_featured.graphql` reported `module_name: must be a constant name,
2113
- got "01HomeFeaturedQuery"` and left you to find which of thirty files it
2114
- meant; it now names the path and says the fix is a rename. It's a
2115
- `GraphWeaver::Error` too, so `rake graph_weaver:generate` aborts on it
2116
- instead of burying it under a backtrace — rescue `ArgumentError` for this
2117
- and you must change it. An explicit `module_name:` argument still raises
2118
- `ArgumentError`.
2119
- - **`rails g graph_weaver:install <supergraph>` recognises a composed
2120
- supergraph** and says what changes because of it: the `federation:*` tasks,
2121
- and that `graphql: :router` runs specs against your real subgraph resolvers.
2122
- The initializer it writes says so too.
2123
- - **Every `rake graph_weaver:federation:*` task was a silent no-op in a stock
2124
- Rails app.** Rails defaults `config.rake_eager_load` to false, and subgraph
2125
- detection only sees *loaded* schema classes — so `federation:subgraphs`
2126
- reported `nil` for every subgraph and `federation:diff` printed "checked 0 of
2127
- 4 subgraphs" and exited **0**, a CI gate permanently green while checking
2128
- nothing. The tasks now eager-load the app first, and detection resolves.
2129
- - **`federation:diff` fails when it compared against nothing** (exit 1, naming
2130
- what to do). Comparing against *some* subgraphs is still a pass — a
2131
- partly-local supergraph is a supported setup — but a comparison against none
2132
- of them proved nothing. If your subgraphs all run elsewhere, drop the task
2133
- from CI; it has nothing there to gate.
2134
- - **The absent-subgraph refusal names the cause.** It advised `subgraphs: { … }`
2135
- on a `Router.new` an rspec `graphql: :router` example never calls. It now
2136
- leads with the usual cause — the schema class isn't loaded yet, so
2137
- eager-load it — and names `GraphWeaver::Testing.config.router = { subgraphs:
2138
- … }` as the way to name one under the tag.
2139
- - **Subgraph-mapping refusals are `GraphWeaver::ConfigurationError` (was
2140
- `ArgumentError`)** — `rescue GraphWeaver::Error` around `Testing::Router.new`
2141
- now catches them, as `docs/errors.md` said it would. Rescue `ArgumentError`
2142
- for these and you must change it. The rule the docs now state: what the
2143
- library *concludes*, having read your schema, is a `GraphWeaver::Error`; an
2144
- argument wrong on its face (`pool_size: must be >= 1`) stays an
2145
- `ArgumentError`. An ambiguous-detection refusal also names each candidate
2146
- schema once, instead of repeating a reloaded constant.
2147
- - **The local test router refused every mutation that stitched below its root**,
2148
- with a message claiming its root fields "span subgraphs" when there was one
2149
- root field in one subgraph — and advice (split it per subgraph) that couldn't
2150
- be followed. It now plans any mutation whose root fields share a subgraph:
2151
- they go over as one document and that subgraph serializes them, and what
2152
- hangs below a root is an ordinary read afterwards. The refusal is left for
2153
- root fields that genuinely span subgraphs, where the message is true.
2154
- - **The local router honoured `@skip`/`@include` on a field but ignored it on a
2155
- fragment spread or inline fragment that crossed a subgraph boundary** — it
2156
- answered a selection the operation had excluded, and ran an extra subgraph
2157
- fetch to do it. Folding a same-type fragment into its parent dropped the
2158
- fragment node, and its directives with it. They now move onto the selections
2159
- they guarded, and a fetch is skipped entirely when everything it was for is
2160
- excluded (so `trace` matches what a real router does). A field and the
2161
- fragment around it both carrying the same directive refuses, as
2162
- `conditional_fragment` — one selection can't hold two conditions of one name.
2163
- - **`rake graph_weaver:federation:diff` reported false drift for every input
2164
- object**, so a correct supergraph failed the CI gate with advice to recompose
2165
- something that wasn't broken. An input object exposes its members as
2166
- `arguments`, not `fields`, and the check only asked for fields.
2167
- - **A `Pathname` works anywhere a schema path does** — `GraphWeaver.new`,
2168
- `generate!(schema:)`, `SchemaLoader.load`. `Rails.root.join("schema.graphql")`
2169
- previously failed as `undefined method 'lstrip' for an instance of Pathname`.
2170
- - **`Transport::HTTP`'s `pool_size:` defaults to `RAILS_MAX_THREADS`** (else 5,
2171
- as before) — the variable Rails already sizes its own connection pool from,
2172
- because it is the same question. A threaded app that raised its thread count
2173
- no longer silently queues behind five sockets.
2174
- - **A saturated pool says so.** The first request that has to wait for a
2175
- connection logs a warning naming the wait and the ceiling; later ones log at
2176
- debug. Queueing was previously indistinguishable from a slow server, which
2177
- mattered most under a fiber scheduler — `async`/Falcon multiplexes fine, but
2178
- nothing sets `RAILS_MAX_THREADS` there, so the default capped it at 5 with no
2179
- signal. See `docs/transports.md`.
2180
-
2181
- #### One `execute`, one way to pass a client (**breaking**)
2182
-
2183
- Every client answers the same call — `execute(query, variables:,
2184
- operation_name:)`, returning the raw response hash. Three objects a user holds
2185
- used to disagree with that, and each disagreement was a bug waiting.
2186
-
2187
- - **`Client#execute` is that contract now**, so `GraphWeaver::Retry.new(client)`,
2188
- `Testing::Sequence.new(client, fake)` and a cassette recorder over a client
2189
- all work. Its one-shot parse-and-run sugar moved to **`Client#run` /
2190
- `#run!`** (and `GraphWeaver.run` / `.run!` for the throwaway-client form):
2191
- **rename `client.execute!(query, **vars)` to `client.run!(query, **vars)`.**
2192
- - **A generated module takes the per-call client as a kwarg**: rename
2193
- `PersonQuery.execute(some_client, id: "1")` to
2194
- `PersonQuery.execute(client: some_client, id: "1")`. Variables are
2195
- unaffected — `$client` was already refused at generation. It is also what
2196
- makes a mistyped variable name itself: `PersonQuery.execute(id: "1")` on a
2197
- query that declares no variables now raises `unknown keyword: :id` instead of
2198
- blaming the client. **Regenerate** (`rake graph_weaver:generate`);
2199
- `rake graph_weaver:verify` fails until you do.
2200
- - **`GraphWeaver.resolve_transport` is gone.** Nothing needs unwrapping any
2201
- more. A client that can't execute is still refused, by the module it was
2202
- passed to — whose name is now in the message.
2203
- - A module parsed from a `Client` bakes that client rather than its transport.
2204
- For a client built from a schema *dump* (no transport), `execute` now says
2205
- `this client has no transport (built from a schema dump)` instead of quietly
2206
- running on `GraphWeaver.client`.
2207
- - `$transport` is a usable GraphQL variable name again — the generated body has
2208
- no such local.
2209
-
2210
- #### One reset, one plurality rule (**breaking**)
2211
-
2212
- - **`GraphWeaver.reset_scalars!`, `clear_scalars!`, `reset_enums!` and
2213
- `reset_type_helpers!` are gone.** `GraphWeaver.reset_registrations!` is the
2214
- clean slate between tests; to reset one registry, call the same name on
2215
- `GraphWeaver::Codegen`.
2216
- - **Every directory setting is a list — `queries_paths`, `generated_paths`,
2217
- `fragments_paths`** — and *every entry is read*, by `generate!`,
2218
- `verify_generated!`, `check_queries` and `load_queries!` alike. (0.4.x
2219
- dropped a plural `queries_paths` because only `load_queries!` walked it;
2220
- that divergence is what the singular was protecting against, and it is gone.)
2221
- **Rename any `queries_path` / `generated_path` / `fragments_path` you set or
2222
- read** — assigning a String still works (`GraphWeaver.queries_paths =
2223
- "app/graphql/queries"`), and `generated_paths.first` is the one directory
2224
- `generate!` writes into. `schema_path` stays singular: a run reads one
2225
- schema, so a second entry would name a dump nothing opens.
2226
-
2227
- - `rake graph_weaver:queries:check` prints an unparseable query's position once
2228
- rather than twice, and `GraphWeaver.check_queries` returns the documented
2229
- `"message"` / `"line"` / `"column"` shape for parse errors too — the position
2230
- is no longer folded into the message.
2231
- - Docs: a spec-local `generated_paths` entry needs an explicit
2232
- `GraphWeaver.load_generated!` (in Rails the Railtie has already run by then),
2233
- and belongs outside `spec/support/`, whose files rspec-rails requires itself
2234
- in sorted order. A registration naming one of your own constants goes in a
2235
- `to_prepare` block — the same rule the in-process client already follows —
2236
- rather than the `require Rails.root.join(...)` dance.
2237
- - **Removing an `extend_type` registration no longer bricks the app.**
2238
- Generated files carry `include GraphWeaver::TypeHelpers::Foo`, so dropping
2239
- the registration made boot fail — and because `rake graph_weaver:generate`
2240
- depends on `:environment`, the regeneration that would repair it failed the
2241
- same way. The graph_weaver tasks now skip loading generated modules (none of
2242
- them reads one), so `rake graph_weaver:generate` repairs the tree. Outside a
2243
- task, the dangling include now raises a `GraphWeaver::Error` naming the
2244
- registration that went missing and how to recover, instead of a bare
2245
- `NameError` pointing into generated code.
2246
- - **A cancelled request no longer leaks its socket.** `Transport::HTTP`
2247
- closed a connection of unknown state with a bare `rescue`, which catches
2248
- only `StandardError`. A fiber scheduler cancels with `Async::Stop`, which
2249
- descends from `Exception`, so cancelling an in-flight request walked past
2250
- the cleanup and left the socket open until GC. Affects any app under
2251
- `async`/Falcon with per-request timeouts. Nothing to do — the fix is
2252
- internal.
2253
- - `rake graph_weaver:queries:check` and `federation:diff` flush stdout before
2254
- aborting, so a piped CI log shows the details before the verdict rather
2255
- than after it.
2256
- - `generate!`, `verify_generated!` and `check_queries` accept a **path or SDL
2257
- string** for `schema:`, like every other schema slot in the library. A String
2258
- used to reach `schema.validate` as itself and die with `undefined method
2259
- 'validate' for an instance of String`.
2260
-
2261
- - **`#parse` on anything that holds a schema**, not just `Client`:
2262
- `GraphWeaver::InProcess`, `Testing::FakeClient` and `Testing::Router` gain it
2263
- (and `#load_queries!`) from the new `GraphWeaver::Parsing` mixin. Replace
2264
- `GraphWeaver.parse(schema: router.schema, client: router, query: q)` with
2265
- `router.parse(q)`. `Retry` holds no schema and has no `#parse` — parse from
2266
- what it wraps. Nothing changes for `Client`, and the client contract is
2267
- untouched: a bare `GraphQL::Schema` class still fills the client slot.
2268
- - **`graphql: false` (or `graphql: :none`) opts an example out of
2269
- `config.default_mode`** — no client is installed, so the example can wire its
2270
- own. Previously a default swept up every untagged example with no way out,
2271
- and both spellings raised "is not a mode"; that message now names the opt-out
2272
- alongside the modes.
2273
- - Docs: `graphql_context` is setup, so `docs/testing.md` now shows it in a
2274
- `before` block for a group sharing one identity, with the inline form kept
2275
- for one-offs. The per-example reset runs ahead of group hooks, so a
2276
- group-level `before` re-applies from the same baseline every time.
2277
-
2278
- - `examples/federation.rb` — a runnable federated-testing example, and the first
2279
- one that needs no network: three real subgraphs, a boundary-crossing query
2280
- through a generated module, `router.trace`, and a plan-time refusal. Guarded
2281
- by `spec/examples_spec.rb`, which runs it.
2282
- - Docs: the local router moved from `docs/testing.md` to `docs/federation.md`.
2283
- `testing.md` now covers which client an example runs against; `federation.md`
2284
- covers what a federated graph does. No behaviour change — update any bookmark
2285
- to `docs/testing.md#the-in-process-router--graphql-router`, now
2286
- `docs/federation.md#the-local-router`.
2287
- - **`graphql: :in_process` no longer hunts for the live schema class.** It runs
2288
- against `GraphWeaver::Testing.config.schema`, or the schema class your client
2289
- already runs in-process — one sentence, no heuristic. The third fallback
2290
- (`Testing::LiveSchema`, which searched every loaded `GraphQL::Schema` for one
2291
- defining everything the reference schema declares) is **deleted**. It only
2292
- ever applied to an app whose client points at a *different* API, and under
2293
- Zeitwerk it failed on the first `:in_process` example anyway, since an
2294
- autoloaded schema isn't loaded until something names it. Such an app now sets
2295
- `config.schema = MySchema`; when neither source is there, the error says so.
2296
- - **One positive predicate on `Response`, not two.** `#ok?` is gone; `#success?`
2297
- is the survivor, so the pair is `errors?` / `success?`. `success?` is what
2298
- `Process::Status` and `Faraday::Response` call it, and `ok?` reads as HTTP
2299
- 200 — which a GraphQL response carrying errors also is. Semantics unchanged:
2300
- partial data alongside top-level errors is **not** a success.
2301
- - **`Testing::Config#auto_fake` is gone** — it was the pre-tag spelling of
2302
- `config.default_mode = :fake`. Use that.
2303
- - **`rake graph_weaver:federation:diff` no longer loads the test harness.**
2304
- `Federation::Drift` needed one thing from it — the list of loaded schema
2305
- classes — and did `require "graph_weaver/testing"` from inside itself to get
2306
- it, pulling faker into a task that fabricates nothing. That question, and
2307
- "does this schema define this coordinate", now live in `GraphWeaver::Schemas`,
2308
- shared with `Testing::Subgraphs`. Measured over the fixture supergraph, the
2309
- task loads 15 files instead of 323 (253 of them faker's).
2310
- `Testing::Subgraphs.loaded` moved with it: call `GraphWeaver::Schemas.loaded`.
2311
- - Internal: `codegen/enum_type.rb` held `extend_type`, the type-helper and alias
2312
- registries and `GraphWeaver::TypeHelpers` — none of them enums, so a search
2313
- for `extend_type` landed in a file named for something else. Those moved to
2314
- `codegen/type_helpers.rb`; `enum_type.rb` now holds `EnumType` and the enum
2315
- registry, mirroring `scalar_type.rb`. No API change.
2316
- - **`register_enum` and `extend_type` say where to register** when handed a
2317
- constant's *name* instead of the constant. Passing a String is the natural
2318
- workaround for "`uninitialized constant PetKind` in my initializer", and the
2319
- answer is Rails' own: autoloading is set up after `config/initializers` run,
2320
- so register from a `Rails.application.config.to_prepare` block — which
2321
- `rake graph_weaver:generate` also runs before generating. Both registries
2322
- still take the constant itself; a name would be a second spelling that
2323
- couldn't reach `fallback:` or `map:` anyway, since those name enum *members*.
2324
-
2325
- #### Scalar coercion is one switch (**breaking**)
2326
-
2327
- `coerce:` takes `true`/`false` only — the Symbol form is gone. It was a third
2328
- way to ask one question (*may a variable of this scalar accept loose input?*)
2329
- and the only one that also made you answer *how*, which the scalar already
2330
- knows: `Int`/`Float` convert, anything with a `cast:`/`serialize:` pair parses,
2331
- and a pass-through scalar can't.
2332
-
2333
- **What to do:**
2334
-
2335
- - `coerce: :to_i` / `coerce: :to_f` — write `coerce: true`. Generated output is
2336
- unchanged.
2337
- - `coerce: :to_s` on `String`/`ID` — drop it and call `.to_s` at the call site.
2338
- Those have nothing to convert from, so `coerce: true` on one raises now
2339
- instead of emitting a no-op.
2340
- - any other Symbol on a custom scalar — give the scalar a `cast:`/`serialize:`
2341
- pair and `coerce: true`.
2342
-
2343
- `GraphWeaver.auto_coerce` is unaffected, and `coerce: true` is now exactly what
2344
- it turns on for one scalar instead of all of them.
2345
-
2346
- #### One shared module, not three (**breaking** — regenerate)
2347
-
2348
- `GraphQLInputs`, `GraphQLUnions` and `GraphQLEnums` are now one `GraphQLTypes`.
2349
- They were three constants, three config knobs and three file shapes for one
2350
- idea — a type shared across query modules — and the rule now fits in a
2351
- sentence: **a type shared across query modules lives in the shared module and is
2352
- aliased in.**
2353
-
2354
- **What to do:** run `rake graph_weaver:generate` (`verify` fails until you do,
2355
- naming the stale files). Every generated file changes: a query module opens with
2356
- one `require_relative "types"` instead of up to three, and its aliases read
2357
- `AdoptionInput = GraphQLTypes::AdoptionInput`. On disk, `enums.rb`, `inputs.rb`,
2358
- `inputs/` and `unions.rb` become `types.rb` (the manifest) plus one file per
2359
- type under `types/` — the old files are pruned for you, since pruning keys off
2360
- the generated header. If you referenced `GraphQLInputs::PetFilter` (or the other
2361
- two) by hand, spell it `GraphQLTypes::PetFilter`.
2362
-
2363
- `GraphWeaver.inputs_module=` / `unions_module=` / `enums_module=` are now
2364
- `GraphWeaver.types_module=`, and `generate!`/`verify_generated!` take one
2365
- `types_module:` in place of three.
2366
-
2367
- One namespace also removes the aliasing *between* the shared artifacts: an input
2368
- struct's props and a union member's selections spell their enums bare now, being
2369
- lexically inside the same module. The manifest requires the enum files first for
2370
- that reason.
2371
-
2372
- New: a shared fragment whose name is already a schema type in that module is
2373
- refused at generation, naming both — a fragment is named by you, a type by the
2374
- schema, and one module is one namespace. Previously they lived apart and could
2375
- never meet.
2376
-
2377
- #### Has anyone changed a subgraph without recomposing?
2378
-
2379
- rake graph_weaver:federation:diff SUPERGRAPH=supergraph.graphql
2380
-
2381
- A committed supergraph is a snapshot of a composition, and nothing checked that
2382
- it still described your subgraphs — so it could quietly promise a graph that no
2383
- longer exists. This reads the routing table against the subgraph schemas loaded
2384
- in this process, needs **no network**, and exits non-zero on drift, so it gates
2385
- a PR alongside `graph_weaver:verify`.
2386
-
2387
- Both directions, because they mean opposite things: **stale** (the supergraph
2388
- carries `Product.weight`, nothing here defines it — recompose) and **not
2389
- composed in** (a schema here defines `Product.dimensions`, the supergraph
2390
- doesn't carry it — publish the subgraph). Comparison is deliberately looser
2391
- than field-set equality, which would be wrong both ways: a subgraph carries
2392
- federation plumbing no supergraph has, and `@external`/`@shareable` put a field
2393
- in more than one subgraph.
2394
-
2395
- A supergraph is routinely only partly local, so the report names three states —
2396
- checked, not here, and answered with fabricated data — and the headline counts
2397
- them. Only drift fails; absence is a supported setup.
2398
- `GraphWeaver::Federation::Drift` is the same thing as data (`#to_h`, `#drift?`).
2399
-
2400
- #### Validation errors name the subgraph behind the type
2401
-
2402
- When the schema dump is a composed supergraph, `rake graph_weaver:queries:check`
2403
- brands each error with who resolves the type it points at:
2404
-
2405
- app/graphql/queries/product.graphql
2406
- 4:5 Field 'dimensions' doesn't exist on type 'Product' (products, reviews)
2407
-
2408
- `Product.dimensions` says what broke; `(products, reviews)` says whose code to
2409
- look at. A plain schema has no routing table and is unaffected.
2410
-
2411
- - New: `SchemaLoader::RoutingTable#declared_fields`, `#declares?`, `#responsible`.
2412
-
2413
- #### A partly-local supergraph now works
2414
-
2415
- The testing router serves a supergraph composed from several services when only
2416
- some of them run in this process. A subgraph no loaded schema defines is
2417
- **absent** rather than an error at construction, so the router builds and every
2418
- query that doesn't reach those fields runs normally. A query that does reach
2419
- them is refused at plan time, before anything executes, naming the subgraph and
2420
- the field that reached for it.
2421
-
2422
- subgraphs: { "reviews" => :fake } # answer an absent subgraph with fabricated data
2423
-
2424
- Faking is opt-in and never silent: a faked fetch is marked `faked: true` in
2425
- `router.trace`, logged at `:warn` per fetch, and listed by `router.faked` and
2426
- `#inspect`. It is deliberately not surfaced as a response error — that would
2427
- make `execute!` raise, defeating the point.
2428
-
2429
- - `Testing::Subgraphs.resolve` now returns only the subgraphs this process
2430
- serves instead of raising when one has no candidate. Two candidates still
2431
- refuse, naming both.
2432
-
2433
- #### One tag picks what a test runs against
2434
-
2435
- `auto_fake` and `config.router` each installed a client for **every** example
2436
- and refused to coexist, so a suite had to choose fakes or real resolvers once,
2437
- for everything — and running in-process against a live schema had no configured
2438
- mode at all. Now an rspec tag says it per example, or per group:
2439
-
2440
- it "renders the empty state", graphql: :fake do … end
2441
- it "authorizes drafts", graphql: :in_process do … end
2442
- describe "checkout", graphql: :router do … end
2443
-
2444
- `rspec --tag graphql:router` runs one mode's examples. The tag is namespaced
2445
- under one `graphql:` key on purpose: a bare `:fake` or `:router` would collide
2446
- with an app's own metadata and silently change which client an unrelated
2447
- example runs against.
2448
-
2449
- **Nothing needs configuring.** Each mode derives what it runs against and
2450
- refuses — naming what it looked for — rather than guessing. The schema is
2451
- `config.schema` if you set one, else the committed dump, else the schema
2452
- `GraphWeaver.client` talks to. `:in_process` finds the live schema *class*: the
2453
- one your client already runs in-process, else the loaded class defining
2454
- everything that schema declares (the rule `subgraphs:` detection already uses).
2455
- `:router` plans against the dump when the dump is a composed supergraph — a
2456
- federated suite whose checked-in dump is the supergraph needs no config at all.
2457
-
2458
- - **New:** `graphql_context(current_user: user)` sets the context your
2459
- resolvers see. It merges onto `config.context` and is reset before the next
2460
- example, so an example running as somebody else can't leak into the one
2461
- after. Pass a block to scope it. Under `graphql: :fake` it refuses — there
2462
- are no resolvers to receive it.
2463
- - **New:** `config.default_mode` is what an untagged example runs against
2464
- (`nil`, the default, leaves `GraphWeaver.client` alone). It replaces
2465
- `config.auto_fake`, which still works as the old spelling of
2466
- `default_mode = :fake`.
2467
- - **New:** `config.context` — the baseline every `:in_process` and `:router`
2468
- example starts from. `config.router = { context: … }` now refuses and points
2469
- here; the per-example reset would have overwritten it.
2470
- - `GraphWeaver.execute`, `.new` and `Client.new` now refuse a *client* where a
2471
- schema source belongs — an `InProcess`, `Retry`, transport or fake used to
2472
- crash with `undefined method 'lstrip'`. The message names both ways to say
2473
- what you meant.
2474
-
2475
- #### Ruby-keyword field names now generate
2476
-
2477
- A result key that underscores to a Ruby keyword — `pageInfo { next }`,
2478
- `filter { in }` — no longer refuses to generate. A prop is only ever read off
2479
- a receiver, so `const :next` is fine; the one bare read, an `alias:`
2480
- delegator's first hop, now spells `self.next`. Output props keep only the ban
2481
- the input side already had: names every `T::Struct` already answers to
2482
- (`class`, `hash`, `serialize`). If you aliased a query around this, you can
2483
- drop the alias and regenerate. `GraphWeaver::Codegen::RESERVED_PROPS` is gone
2484
- — `STRUCT_METHODS` is the whole rule now.
2485
-
2486
- - `optional: true` on an `alias:` no longer hides a path segment the schema has
2487
- no field for. It still skips a field this query didn't select — that is what
2488
- it is for — but a typo, or the classic `findPets` where the path is the Ruby
2489
- prop chain, now raises and says which of the two it looks like. If an
2490
- optional alias resolved only through a query-level rename (`{ renamed: meta }`),
2491
- it will now raise on queries that don't select that key.
2492
- - **New:** `GraphWeaver.reset_enums!`, `GraphWeaver.reset_type_helpers!` and
2493
- `GraphWeaver.reset_registrations!` — the registry resets scalars already had.
2494
- `reset_registrations!` is the clean slate to reach for between tests.
2495
- - An alias error no longer names the same type twice when a query module and
2496
- its root type share a name.
2497
-
2498
- #### Testing::Router now plans a real query, not just a single-subgraph one
2499
-
2500
- `GraphWeaver::Testing::Router` used to hand one operation to one subgraph
2501
- verbatim and refuse anything that crossed a boundary. It now splits at the
2502
- crossing, refetches the entity from its `@key` through
2503
- `_entities(representations:)`, and stitches — batching every node at a level
2504
- into one call, running root query fields that span subgraphs as one fetch
2505
- each, and fetching a `@requires` field set from the subgraph that holds it
2506
- before the field that needs it. On the demo corpus that moves 10/17 queries
2507
- plannable to 17/17. It still refuses, at plan time, every shape it can't
2508
- answer the way a real router would.
2509
-
2510
- `subgraphs:` is now **optional**: each subgraph's Ruby schema is derived from
2511
- what the loaded schemas define, and refuses rather than guesses when two
2512
- match or none do. An explicit map (or a partial one) still wins, and is now
2513
- checked the same way — a mis-wired entry fails at construction naming what it
2514
- doesn't define, instead of surfacing three fetches later.
2515
-
2516
- - **New:** `config.router = { supergraph: "supergraph.graphql" }` in
2517
- `graph_weaver/rspec` runs every example against your real subgraph
2518
- resolvers.
2519
- - **New:** `rake graph_weaver:federation:subgraphs` prints the subgraph map
2520
- detection sees, with the evidence for each match.
2521
- - **New:** `Testing::Router#context` is settable, so one example can run as a
2522
- different user without rebuilding the router.
2523
- - `Testing::Unplannable`'s `:requires` category is **gone** — the gap it named
2524
- is closed. `:root_fields_span` now applies only to mutations (query roots
2525
- are planned). New categories: `:no_key`, `:abstract_boundary`,
2526
- `:nested_field_set`, `:shadowed_key`.
2527
- - The coverage report's second line now names every subgraph a query touches
2528
- (`accounts+reviews`), not just the one it ran in.
2529
- - `rake graph_weaver:schema:diff`, `schema:refresh` and `cassettes:anonymize`
2530
- now load the Rails environment first, so an initializer's settings apply.
2531
-
2532
- **`GraphWeaver::Testing::Router` — a local federation router for tests.** Give
2533
- it a supergraph and your subgraph schema classes and it satisfies the client
2534
- slot, so `GraphWeaver.client = router` runs every generated module against real
2535
- resolvers in-process: no gateway, no node, no sockets. It plans one shape — a
2536
- query whose every field resolves in a single subgraph, passed to that subgraph
2537
- verbatim — and raises `Unplannable` (a `GraphWeaver::Error`) for anything that
2538
- crosses a boundary, at plan time, before any subgraph runs. See
2539
- [docs/testing.md](docs/testing.md#a-federated-graph--graphql-router).
2540
-
2541
- **`rake graph_weaver:federation:coverage SUPERGRAPH=…` says how much of your
2542
- query set that router can plan**, and groups every refusal by what stopped it —
2543
- the number that decides whether wiring it up is worth it. Planning needs the
2544
- supergraph alone, so it runs in CI with no subgraph loadable.
2545
-
2546
- **A supergraph's routing table is now readable:
2547
- `GraphWeaver::SchemaLoader.routing_table(supergraph)`.** `load` strips the
2548
- `@join__*` machinery to get the API schema; this keeps it — `owners("Product",
2549
- "shippingEstimate") # => ["reviews"]`, each type's `@key` field sets, and which
2550
- copies are `@external`. A `@join__` directive it hasn't been taught lands in
2551
- `unsupported` rather than being skipped.
2552
-
2553
- **`Representations.<entity>` for an entity the query didn't select now says
2554
- what to do.** Builders are query-driven, so `Representations.warehouse(...)`
2555
- raised a bare `NoMethodError` naming nothing. It now names the builders this
2556
- query does have and the selection to add (`... on Warehouse { __typename }`).
2557
-
2558
- **Shared-fragment directories are scanned recursively, and `.gql` files count.**
2559
- The scan was `fragments/*.graphql`, so `fragments/person/fields.graphql` — how
2560
- anyone with sixty fragments organizes them — was skipped in silence, and a
2561
- `.gql` file was ignored even though `parse("x.gql")` reads one. A duplicate
2562
- fragment name now names both files that define it.
2563
-
2564
- **Query directories are scanned the same way — recursively, `.gql` included.**
2565
- `queries/admin/pets.graphql` produced nothing at all: no file, no error.
2566
- `generate!`, `check_queries` and `client.load_queries!` now walk the tree, and
2567
- `.gql` no longer leaks its extension into the module name. Directories organize
2568
- queries but do not namespace them — `queries/admin/pets.graphql` is still
2569
- `PetsQuery` in `pets_query.rb` — so two files with the same base name are
2570
- refused at generation, naming both, rather than one silently overwriting the
2571
- other's generated file. The scaffolded `graphql.config.yml` matches
2572
- (`**/*.{graphql,gql}`).
2573
-
2574
- **`execute` now takes one kwarg per declared variable, always — a single
2575
- required input-object variable is no longer flattened into per-field kwargs.**
2576
- `mutation($input: AdoptionInput!)` generated `execute!(name:, species:, …)`,
2577
- but adding any second variable generated `execute!(input:, …)` instead — so an
2578
- unrelated edit to a query silently reshaped every call site, and the rule
2579
- couldn't be stated without its exception. It also made a schema's own field
2580
- names load-bearing: a field named `client` or `in` can't be a kwarg and can't
2581
- be renamed, so flattening quietly declined and the surface moved again.
2582
- **Rewrite affected call sites to pass the input as one kwarg:**
2583
- `AdoptMutation.execute!(input: { name: "Rex", species: "DOG" })`, or
2584
- `input: AdoptMutation::AdoptionInput.new(name: "Rex", species: Species::Dog)`
2585
- for the field-by-field static check.
2586
-
2587
- **An input field named after a Ruby keyword no longer makes a schema
2588
- ungeneratable.** `StringQueryOperatorInput.in` — the standard Hasura/Gatsby
2589
- filter shape — raised "would become prop 'in', which collides with a Ruby
2590
- keyword", with no way out: an input field is the schema's name, not yours, and
2591
- `extend_type alias:` is output-only. But `prop :in` is legal Ruby, and nothing
2592
- reads an input prop bare (`serialize` goes through `public_send`), so the
2593
- refusal was over-broad. Input fields named `in`, `end`, `def`, `nil` and the
2594
- rest now generate. A field colliding with a method every struct defines
2595
- (`serialize`, `to_h`, `class`, `hash`) is still refused — those break at
2596
- require time. Output structs are unchanged: a result key *can* be renamed, in
2597
- the query.
2598
-
2599
- **A variable named `$client` no longer generates a file that won't parse.**
2600
- `query($client: ID!)` emitted `def self.execute(client = nil, client:)` — a
2601
- `SyntaxError` raised at app boot from `load_generated!`, arbitrarily far from
2602
- the query that caused it, while `verify_generated!` reported the tree as
2603
- current. Generation now refuses `$client`, `$variables` and `$transport` — the
2604
- three locals the generated `execute` body owns — naming the fix. **Rename such
2605
- a variable in the query (`query($clientId: ID!)`) before regenerating.**
2606
-
2607
- **`auto_coerce` no longer erases the typing of String/ID variables.** It mapped
2608
- both to `#to_s`, which widened their kwargs to `T.anything` — the majority of
2609
- real variables, statically unchecked, in exchange for a cast that can't fail.
2610
- `auto_coerce` now covers only the conversions that are conversions (`Int`→`to_i`,
2611
- `Float`→`to_f`) plus scalars with a full cast/serialize pair. **If you relied on
2612
- a String/ID kwarg accepting anything, opt in per scalar:**
2613
- `GraphWeaver.register_scalar("ID", String, coerce: :to_s)`.
2614
-
2615
- **An anonymous operation is now named after its module — in the query text and
2616
- in `OPERATION_NAME`.** Requests started carrying `operationName` so servers and
2617
- APMs can attribute traffic, but the constant was only set when the `.graphql`
2618
- document named its operation — and anonymous is what the docs show, so every
2619
- trace arrived `anonymous` and the feature did nothing for the documented happy
2620
- path. `person.graphql` holding `query($id: ID!) { ... }` now emits
2621
- `query PersonQuery($id: ID!) { ... }` with `OPERATION_NAME = "PersonQuery"`.
2622
- Both halves move together: a server rejects an `operationName` its document
2623
- doesn't declare. A document that names its own operation is left untouched.
2624
-
2625
- **Cassette files no longer store the request twice — re-record them.** Every
2626
- entry carried a `key:` (the normalized query + variables) *and* a `query:` and
2627
- `variables:` again, and replay matched on `key:` alone: editing the half a
2628
- reviewer reads changed nothing, editing the other half broke replay while the
2629
- file still looked right. The key is now derived from `query`/`variables`/
2630
- `operationName` at load, so the file holds the request once and diffs are real.
2631
- **Existing cassettes must be re-recorded** (`GRAPHWEAVER_RECORD=1`, or delete
2632
- the file) — this also covers cassettes of anonymous operations, which stopped
2633
- matching when entries started keying on `operationName`.
2634
-
2635
- **`MissingRecording` now prints the variables — the part that usually differs.**
2636
- It printed the whole query and omitted the variables entirely, so the common
2637
- miss (same query, different variables) showed you 60 lines identical to the
2638
- YAML and nothing about the mismatch. The message now leads with the request's
2639
- variables, says what was recorded for that query (`1 entry recorded for this
2640
- query, with variables {"id" => "1"}`), and prints the query as one truncated
2641
- line.
2642
-
2643
- **A first run with no cassette and no `client:` no longer raises
2644
- `MissingRecording`.** There is no request yet, so it raises `GraphWeaver::Error`
2645
- naming the actual situation. **Rescue `GraphWeaver::Error` if you were catching
2646
- `MissingRecording` for this case.**
2647
-
2648
- **`Cassette.use` is now `GraphWeaver::Testing.cassette` — rename your calls.**
2649
- It never returned a `Cassette`; it returns a *client* (a recorder or a replayer)
2650
- to hand to `execute`, and the name said otherwise. `Cassette` is now only the
2651
- file — `.new`, `#size`, `#anonymize!`.
2652
-
2653
- **Record mode with no `client:` now raises instead of replaying.**
2654
- `GRAPHWEAVER_RECORD=1` on a `Testing.cassette(name)` call with nothing to record
2655
- against quietly served the stale recording, so "re-record everything" produced a
2656
- half-refreshed cassette set with no signal. **Pass `client:` to every call you
2657
- want re-recorded.**
2658
-
2659
- **`Recorder.new(..., anonymize:)` is gone.** It was unreachable through the
2660
- factory and duplicated `Testing.config.anonymize`. **Set the config flag** —
2661
- that's the one way to anonymize, with `rake graph_weaver:cassettes:anonymize`
2662
- as the cleanup tool for cassettes recorded before you turned it on.
2663
-
2664
- **`FakeClient.new` no longer requires `schema:`.** Every other option fell back
2665
- to `Testing.config`; this one didn't, even though `config.schema` already
2666
- auto-locates the committed dump. `FakeClient.new` now works on its own, and
2667
- says what to set when no schema resolves at all.
2668
-
2669
- **`GraphWeaver.queries_paths` (plural) is gone — use `queries_path`.**
2670
- `generate!` and `check_queries` read the singular (the first entry) while
2671
- `load_queries!` walked the whole list, so a second queries directory produced
2672
- modules at runtime that `rake graph_weaver:generate` never generated and
2673
- `verify` never checked — silently. Queries are single-schema by design. **If
2674
- you appended a second queries directory, fold it into the first** (or run a
2675
- second `generate!` with its own `queries:`). `generated_paths` and
2676
- `fragments_paths` stay plural; they genuinely load from several places.
2677
-
2678
- **One GraphQL enum is now one Ruby type.** A schema enum a query touches — as
2679
- a variable, in a result, or both — is emitted once per schema into
2680
- `generated/enums.rb` as `GraphQLEnums::<Enum>`, and every query module aliases
2681
- it. Before, an enum read out of a result got a class named for the response key
2682
- and nested in the struct that selected it (`SearchQuery::Result::Search::Pet::Species`),
2683
- while the same enum used as a variable got a module-level one — so whether a
2684
- schema enum was one Ruby type or three depended on what else the query happened
2685
- to reference, and handing a value from one query into another's variable raised
2686
- a `TypeError` that wasn't even a `GraphWeaver::Error`.
2687
-
2688
- **Regenerate, and expect enum constants to move.** A nested enum path in app
2689
- code becomes the query module's own alias — `SearchQuery::Species` — or
2690
- `GraphQLEnums::Species`; `srb tc` finds them all. The enums a shared fragment's
2691
- union members select are hoisted too, so `unions.rb` now aliases them rather
2692
- than re-emitting them.
2693
-
2694
- **The shared module names no longer depend on your output directory.** They are
2695
- `GraphQLInputs`, `GraphQLUnions` and `GraphQLEnums`, full stop. The old rule
2696
- camelized the parent of `generated/` unless it was on a hardcoded blocklist, so
2697
- `output: "gen2"` gave you `Gen2Inputs` and renaming `app/graphql/generated` to
2698
- `app/gql/generated` renamed a public constant. **A multi-schema layout must now
2699
- name its modules explicitly** — `GraphWeaver.inputs_module=` /
2700
- `unions_module=` / `enums_module=`, or `generate!(inputs_module:, ...)` — in the
2701
- same initializer that already gives each schema its paths. `GraphWeaver.inputs_module`
2702
- and `unions_module` no longer take an output-path argument.
2703
-
2704
- **One registration registry, not two.** `Client#register_scalar`,
2705
- `#register_enum`, `#register_enums` and `#extend_type` are **deleted** — a
2706
- client-scoped registration was invisible to `GraphWeaver.generate!` (the rake
2707
- tasks have no client), so the console typed a field richly and the checked-in
2708
- code silently generated `T.untyped`. **Move any `client.register_*` /
2709
- `client.extend_type` call to the `GraphWeaver.` form** (an initializer, next to
2710
- the rest of your config). The one thing client scoping bought — two servers
2711
- disagreeing about a scalar — is what the per-field coordinate form is for:
2712
- `GraphWeaver.register_scalar("User.birthday", Date)`.
2713
-
2714
- Also gone with it: `GraphWeaver.register_enums` (bulk) — there was never a
2715
- `register_scalars` to match it, so call `register_enum` per line — and
2716
- `GraphWeaver.reject_positional_map!`, now folded into the one
2717
- `Codegen.register_enum` that every door reaches (so all three doors give the
2718
- same "the value map is a keyword" error instead of a bare arity complaint).
2719
- `Codegen.parse` / `.generate` / `.generate_inputs` / `.generate_unions` no
2720
- longer take `scalars:`/`enums:`/`types:`.
2721
-
2722
- **`generate!` now takes a Client where it takes a schema** — `GraphWeaver.generate!(schema: api)`,
2723
- `verify_generated!`, `check_queries` and `parse` all accept one, so the object
2724
- you built in the console is the object the build step wants and no schema dump
2725
- is needed. `client:` still means what it meant (a constant name to bake as
2726
- `DEFAULT_CLIENT`) and still refuses a live object.
2727
- **Rails integration fixes, found by running the gem in a real Rails app.**
2728
-
2729
- - **Production boot no longer raises `uninitialized constant
2730
- Generated::PersonQuery`.** The default `generated_path` is
2731
- `app/graphql/generated`, which Zeitwerk claims as an autoload root, while
2732
- the files there define top-level constants. Development (lazy) was fine and
2733
- eager loading was not, so this only showed up in production or
2734
- `rails zeitwerk:check`. The Railtie now hides the generated directory from
2735
- the loader; nothing to configure.
2736
- - **`rake graph_weaver:generate` runs your initializer again.** The tasks
2737
- asked whether Rails' `:environment` task existed at *load* time, but Rails
2738
- defines it after every Railtie's `rake_tasks` block, so the answer was
2739
- always no. Generation and `verify` therefore ran without booting the app —
2740
- silently dropping every `register_scalar` / `register_enum` / `extend_type`
2741
- in `config/initializers`, and generating code that disagreed with the
2742
- running app. **Regenerate**: if you register anything in an initializer,
2743
- your committed generated files are wrong, and `rake graph_weaver:verify`
2744
- will now say so.
2745
- - `generate`, `verify` and `schema:diff` report a `GraphWeaver::Error` the
2746
- way `schema:refresh` already did — the message, and a non-zero exit,
2747
- instead of a rake backtrace through codegen.
2748
-
2749
- **`rails g graph_weaver:install` takes any source `GraphWeaver.new` takes.**
2750
- The source is one positional argument — an endpoint, a schema class or an
2751
- existing dump all work the same way:
2752
-
2753
- ```sh
2754
- rails g graph_weaver:install https://api.example.com/graphql
2755
- rails g graph_weaver:install MyApp::Schema # in-process, no socket
2756
- rails g graph_weaver:install db/schema.graphql # a dump you already have
2757
- ```
2758
-
2759
- The initializer reflects the form chosen: a schema class is resolved in a
2760
- `to_prepare` block (it is autoloaded, so an initializer can not read it, and a
2761
- dev reload replaces the class object), and a dump you already have becomes
2762
- `GraphWeaver.schema_path` rather than being copied. `--auth` and the
2763
- introspection step are url-only; a source that can not use them, a constant
2764
- that does not resolve, and a class that is not a schema are all refused
2765
- before any file is written.
2766
-
2767
- **Generated struct names now come from the query's own field names.** A struct
2768
- is named for the response key that selects it — `stargazers` becomes
2769
- `Stargazers`, `edges` becomes `Edges` — so its name is a function of its own
2770
- position in the query and nothing else. Names came from GraphQL *type* names
2771
- before, disambiguated by field name only on collision, which meant **a second
2772
- selection of the same type renamed the first**: a silent break in checked-in
2773
- code your app references. Deep queries could also collide outright and refuse
2774
- to generate.
2775
-
2776
- **Regenerate, and expect renames.** Nearly every nested struct changes name
2777
- (`PersonQuery::Result::Person::Pet` becomes `...::Person::Pets`), and app code
2778
- naming one won't typecheck until it's updated — `srb tc` finds them all. The
2779
- payoff: adding, removing, or reordering an unrelated selection can never move
2780
- a name again.
2781
-
2782
- - The key is used verbatim, with no pluralization heuristic — a list field
2783
- `pets` generates `Pets`. To pick a different name, alias the field in the
2784
- query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
2785
- - Union and interface members keep their type-condition names (`... on Book`
2786
- gives `Book`), inside a container named for the field; a union hoisted from
2787
- a shared fragment is still named for the fragment.
2788
- - Two ties that walk order used to settle now resolve on their own: fields
2789
- sharing one collapsed union type take the first of their keys
2790
- alphabetically, and a name that would shadow the struct it nests in
2791
- (`pet { pet { ... } }`) takes a numeric suffix (`Pet2`).
2792
-
2793
- **Requests now send `operationName`** — every graph_weaver request used to be
2794
- anonymous in Apollo Studio, Hasura, and any APM that keys traces, rate limits
2795
- and slow-query reports on it. Generated modules emit their operation name as
2796
- `OPERATION_NAME` beside `QUERY` and send it on the wire; a raw query string
2797
- handed to a transport falls back to the name in the document. In-process
2798
- execution passes it to `Schema.execute(operation_name:)`, which also makes a
2799
- multi-operation document selectable there.
2800
-
2801
- To get the benefit, **name your operations** — `query Person($id: ID!)`, not
2802
- `query($id: ID!)` — and regenerate. An anonymous operation still works and
2803
- sends no `operationName`.
2804
-
2805
- Three breaking changes come with it:
2806
- - **The client-slot contract widened to
2807
- `execute(query, variables:, operation_name: nil)`.** If you wrote your own
2808
- transport, client, or test double, add the kwarg — a client that doesn't
2809
- accept it now raises `ArgumentError: unknown keyword: :operation_name`. A
2810
- graphql-ruby `Schema` class already takes it, so bare schemas in the client
2811
- slot are unaffected. Subclasses of `GraphWeaver::Transport` only implement
2812
- `post(body)` and need no change.
2813
- - **Cassettes are keyed on `operationName` too**, so two operations in one
2814
- document can't collide. Cassettes recorded from a *named* operation before
2815
- this release no longer match — re-record them
2816
- (`GRAPHWEAVER_RECORD=1 bundle exec rspec`). Anonymous ones are unaffected.
2817
- - **`GraphWeaver::Transport.log_tag` takes an operation name, not a query
2818
- string** (`log_tag(query)` → `log_tag(operation_name)`); the constant
2819
- `Transport::OPERATION_NAME` is now `Transport::OPERATION_NAME_PATTERN`, since
2820
- generated modules define an `OPERATION_NAME` of their own.
2821
-
2822
- Codegen bug fixes from the library review (all with regression coverage):
2823
- - Narrowing (`... on X` and nothing else) now reads the match off `__typename`
2824
- when the selection carries it, instead of off "the object came back empty".
2825
- Selecting `__typename` guaranteed a non-empty object, so **every non-matching
2826
- member was cast into `X`'s struct** — loudly when it had a non-null field,
2827
- silently when all its fields were nullable. Regenerate: any query mixing
2828
- `__typename` with a single type condition (the `_entities { __typename
2829
- ... on Widget { … } }` federation shape) was mistyped and now filters
2830
- correctly.
2831
- - A dispatched union/interface now requires its `__typename` to be unaliased and
2832
- free of `@skip`/`@include` — `from_h` reads it unguarded, so either would have
2833
- raised at runtime. Fix the selection if generation now refuses it.
2834
- - **Unions and interfaces generate per named condition, plus one catch-all
2835
- `Other`** — not one struct per schema member. A two-condition query against
2836
- GitHub's `Node` (278 implementations) went from 5,386 lines / 279 structs to
2837
- 162 lines / 4. **Regenerate, and expect member names to move**: a type your
2838
- query names no fields on is now `Other` rather than its own struct, so a
2839
- `case` over the members needs an `Other` branch (`T.absurd` will tell you).
2840
- In exchange, a `__typename` the query doesn't name — including a **member the
2841
- schema grows after you generate** — deserializes into `Other` instead of
2842
- raising `unexpected __typename`, so adding a union member upstream stays the
2843
- non-breaking change GraphQL says it is.
2844
- - `@skip`/`@include` on an inline fragment or a named spread now makes the
2845
- fields under it nilable, as it always did for a directly-marked field —
2846
- previously they kept non-null typing and a `data.fetch`, so a skipped block
2847
- raised `key not found`. The narrowing guard sees the fragment's own directive
2848
- too. Conversely, a field selected both conditionally and unconditionally is no
2849
- longer over-nilable: one unguaranteed selection doesn't unmake the guarantee.
2850
- - List variables coerce per element, so an enum inside a list accepts its wire
2851
- value the way a scalar enum already did (`sort: ["POPULARITY_DESC"]` used to
2852
- raise `NoMethodError: undefined method 'serialize' for String`). Input-object
2853
- and custom-scalar elements coerce in lists too.
2854
-
2855
- - Federation schemas that previously wouldn't load now do:
2856
- - a supergraph whose `schema` definition carries a non-`@link` directive
2857
- (`@tag`, `@composeDirective`, a composed custom one) no longer dies with a
2858
- `GraphQL::ParseError` pointing into a document you never wrote.
2859
- - **raw subgraph SDL loads** — what `rover subgraph fetch`, `_service { sdl }`,
2860
- or your service repo's `.graphql` gives you. The federation directives a
2861
- subgraph applies but doesn't declare (`@key`, `@external`, `@shareable`, …)
2862
- are supplied on load, for both fed-1 and `@link`-style subgraphs. Note the
2863
- `@inaccessible` subtraction stays supergraph-only: a subgraph keeps those
2864
- fields, because it is not the public contract.
2865
- - A schema that won't build now raises `GraphWeaver::Error` naming the artifact
2866
- we took the source for (supergraph / subgraph / plain SDL / introspection),
2867
- instead of whatever graphql-ruby's internals happened to raise — a
2868
- `NoMethodError`, a `ParseError` pointing into a document you never wrote, a
2869
- bare `RuntimeError`. **Rescuing the raw graphql-ruby classes no longer
2870
- catches these.** The `@inaccessible` cascade also prunes a directive
2871
- definition's own arguments.
2872
- - **Single-line SDL loads.** `SchemaLoader.load("type Query { hi: String }")` —
2873
- the shape you type in a console — was rejected as "unsupported schema format",
2874
- because a string had to contain a newline to count as content rather than a
2875
- path.
2876
- - Rejecting a schema source is branded too, so the error class no longer depends
2877
- on which branch rejected it: an unsupported format and an unreadable file both
2878
- raise `GraphWeaver::Error` (were `ArgumentError` and `Errno::ENOENT`). A bare
2879
- host now says so — `"graphql.anilist.co" looks like a host; did you mean
2880
- "https://graphql.anilist.co"?` — instead of pointing at the file system.
2881
- - Cassette recording accepts a `GraphWeaver::Client` — the call
2882
- `docs/cassettes.md` shows (`Testing.cassette("github", client: live)`),
2883
- which failed with `ArgumentError: missing keywords`. And a client that can't
2884
- `execute` is now rejected on the spot, with its class named, rather than
2885
- surfacing later as `NoMethodError … for an instance of Hash`.
2886
- - Generated structs answer `respond_to?` the way `method_missing` behaves, so
2887
- `struct.method(:nmae)` gets the same "did you mean" hint the direct call does.
2888
- - `@oneOf` input objects enforce exactly one field. The schema can't express it
2889
- — every `@oneOf` field is nullable — so the struct accepted zero or many and
2890
- the server rejected the round trip; supplying the wrong number now raises
2891
- `GraphWeaver::InputError` naming the type and the keys. **Regenerate** to pick
2892
- it up.
2893
- - An enum whose values differ only in case (`enum E { active ACTIVE }`) is
2894
- refused at generation naming both wire values, instead of emitting two
2895
- `Active` constants and raising `RuntimeError: Enum values must be assigned to
2896
- constants` when the file loads. **Map such an enum onto one of yours**
2897
- (`register_enum`). `AB`/`A_B` and `IN_PROGRESS`/`INPROGRESS` still generate
2898
- fine — they name distinct constants.
2899
- - A `.graphql` file that won't parse raises `GraphWeaver::ValidationError`
2900
- **naming the file**, instead of a bare `GraphQL::ParseError` whose `[6, 1]`
2901
- pointed into a document you never wrote — fragment inlining parses on the
2902
- `generate!` path before `Codegen#generate`'s rescue could brand it. Fragment
2903
- files get the same treatment.
2904
- - Generated `from_response` shape-checks the envelope, so a malformed one stays
2905
- under `GraphWeaver::Error`. A non-object `data`, a `Hash` (or an array of
2906
- strings) for `errors`, and non-object `extensions` all escaped as a raw Sorbet
2907
- `TypeError` — the `data` one from `from_h`'s sig, before the struct's own
2908
- rescue could see it. A body that isn't an object at all deserialized to an
2909
- empty envelope (`String#[]` answers `"data"` with nil); it now raises.
2910
- - The generated `from_h` rescues `StandardError`, not just
2911
- `TypeError`/`ArgumentError`/`KeyError` — a registered scalar whose cast raises
2912
- anything else (`JSON::ParserError`, `URI::InvalidURIError`, your
2913
- `Money::ParseError`) escaped the umbrella. **Regenerate** to pick both up.
2914
- - A document holding more than one operation is refused at generation. Only the
2915
- first was ever typed, and the whole document went on the wire with no
2916
- `operationName`, so the request came back "Must provide operation name" —
2917
- **split multi-operation files into one operation each.**
2918
- - Result keys are checked before they become props, so generation refuses what
2919
- used to be an unloadable file. Two keys that underscore to the same prop
2920
- (`{ name Name: name }` — a plain alias, no exotic schema needed) raised
2921
- `ArgumentError: Attempted to redefine prop :name` at require time; so did a
2922
- field named `class`, `hash`, `send` or `frozen?`, which `T::Props` won't let a
2923
- struct redefine. **Alias the field in the query** (`classValue: class`) — the
2924
- error names the key and the spelling. The same reserved set now covers input
2925
- fields, which only checked Ruby keywords and `serialize`/`to_h` before.
2926
- - **Global registrations are validated against the schema**, like client-scoped
2927
- ones always were: `GraphWeaver.extend_type("Medai", …)` (or `register_scalar` /
2928
- `register_enum`) used to be a silent no-op, which is the failure mode
2929
- `docs/getting_started.md` step 3 walks you straight into — it now raises at
2930
- generation with the spellchecked hint. Registrations are global (see above),
2931
- so **drop any that names a type the schema you generate against doesn't
2932
- have**. The built-in scalars are exempt — a schema with no `Date` isn't a
2933
- mistake.
2934
- - `extend_type(requires:)` and `register_enum(requires:)` check each path is
2935
- loadable at registration, as `register_scalar(requires:)` already did and
2936
- `docs/scalars.md` already promised — a typo fails now, not in the generated
2937
- file.
2938
- - Docs: `docs/testing.md` passed the client to generated `execute` as a `client:`
2939
- kwarg — it's positional. `README.md` had module naming backwards for the
2940
- documented path (a file's module comes from the **file** name, not the
2941
- operation name). `docs/federation.md` covers subgraph SDL, federation v1
2942
- supergraphs, and that `@inaccessible` is subtracted only on the supergraph
2943
- path. `docs/cassettes.md` names `MissingRecording` correctly.
2944
- - **Federation namespaces are derived from the schema's own `@link`/`@core`
2945
- declarations** instead of a hardcoded `join__`/`link__`/`core__` list — the
2946
- spec URL's name segment gives the namespace, `as:` renames it, and `import:`
2947
- binds names into the root namespace (`{name: "@key", as: "@myKey"}` included).
2948
- Four things this fixes:
2949
- - a graph using fed-2.5+ auth (`@requiresScopes`/`@policy`/`@context`) no
2950
- longer leaks `federation__Scope`, `federation__Policy` or
2951
- `context__ContextFieldValue` into `schema.types`;
2952
- - a supergraph that renamed a spec (`@link(url: ".../join/v0.3", as: "j")`)
2953
- strips its `j__*` machinery — it previously failed to load at all;
2954
- - **a renamed `@inaccessible`** (`import: [{name: "@inaccessible", as:
2955
- "@private"}]`, or `as:` on the inaccessible spec) hides what it marks. It
2956
- was missed entirely before, so the derived API schema kept fields the
2957
- router does not serve and codegen over-permitted them. **Regenerate** if
2958
- your supergraph renames it.
2959
- - a `@core`-only fed-1 schema, and any composed graph carrying no `@join__`
2960
- marker, is now recognized as composed rather than loaded as plain SDL
2961
- (`core__Purpose` used to survive, and `@inaccessible` went unsubtracted).
2962
-
2963
- - **Subgraph SDL loads with the entity resolver it serves.** No published
2964
- subgraph SDL contains `_entities`/`_service` — `rover subgraph fetch` and
2965
- `_service { sdl }` both print the schema, where the plumbing is implicit — so
2966
- the one query only a subgraph can describe couldn't be typed against the
2967
- artifact you have. Weaver now supplies `_Any`, `_Service` and an `_Entity`
2968
- union over the file's own `@key`'d types, alongside the `@key`/`@external`
2969
- definitions it already supplied. Supergraphs and plain SDL are untouched;
2970
- a file declaring its own `_entities` keeps it.
2971
- - **Typed `_entities` representations.** A query selecting entities now
2972
- generates a `Representations` builder per entity it can resolve, typed from
2973
- the `@key(fields:)` directives the subgraph SDL carries:
2974
- `UserQuery::Representations.user(id: "1")` → `{"__typename" => "User", "id"
2975
- => "1"}`. `__typename` is injected, key fields are typed from the schema, and
2976
- a single `@key` makes them **required kwargs** — so an incomplete
2977
- representation is an `srb tc` error, not a round trip. Compound (`"upc sku"`)
2978
- and nested (`"organization { id }"`) key sets are parsed as the selection
2979
- sets they are; a type with two alternative keys takes them optionally and
2980
- raises `GraphWeaver::InputError` naming the type and what's missing when
2981
- neither is satisfied. Builders are emitted only for entities the query
2982
- actually reaches, and a key marked `resolvable: false` gets none.
2983
- **`Representations` joins `Result`/`QUERY` as a reserved module-level name**
2984
- — a shared fragment hoisting to it is now refused.
2985
-
2986
- Transport improvements from the same review:
2987
- - **`Transport::HTTP` pools its connections** (`pool_size:`, default 5) instead
2988
- of serializing every request behind one socket and one mutex. The mutex was
2989
- held across the whole network round trip, so one transport — which is what
2990
- `GraphWeaver.client = api` gives a Rails app — allowed exactly one request in
2991
- flight process-wide. Against a 10 ms-latency server, 8 threads × 10 calls:
2992
- 1059 ms before, 281 ms with the default pool of 5 (~3.8×). Sockets still open
2993
- lazily, stay keep-alive, and are dropped on any error so the next call
2994
- reconnects. **Lower `pool_size:` if your server counts connections per
2995
- client**; raise it to match a threaded web server's thread count.
2996
- - Both transports now send `Accept: application/graphql-response+json,
2997
- application/json;q=0.9` — the media type GraphQL-over-HTTP requires a
2998
- conforming client to accept, so a spec-conformant server can finally use the
2999
- newer status-code semantics — and `User-Agent: graph_weaver/<version>`, so
3000
- server operators can attribute the traffic. Previously the only header sent
3001
- was `Content-Type`, and net/http supplied `Accept: */*`. `headers:` still
3002
- overrides both; a prebuilt `Faraday::Connection` keeps whatever it carries.
3003
- - **`Transport::Faraday` takes `open_timeout:`/`read_timeout:` and defaults them
3004
- to 10s/30s**, the same as `Transport::HTTP`. It had no timeout knobs at all,
3005
- so it inherited net/http's 60s/60s — 6× and 2× the documented defaults. Both
3006
- timeouts now also thread through the client: `GraphWeaver.new(url,
3007
- read_timeout: 5)` works whichever transport is picked. Passing a timeout
3008
- alongside a prebuilt `Faraday::Connection` raises, as `headers:` already did.
3009
- The Faraday transport also logs its adapter at `:info` — the default
3010
- `net_http` one opens a connection per request, which was invisible.
3011
- - **New `GraphWeaver::InProcess`**, wrapping a live graphql-ruby schema class —
3012
- `GraphWeaver.new(MySchema, context: { current_user: user })`. In-process
3013
- execution worked but was blind in three ways: nothing supplied a `context:`,
3014
- so a resolver reading `context[:current_user]` got nil (surfacing as "Cannot
3015
- return null for non-nullable field Query.me"); all logging lived in
3016
- `Transport#execute`, which an in-process schema bypasses, so not one line at
3017
- DEBUG; and a resolver raise came out as a bare `RuntimeError` where the same
3018
- failure over HTTP is a `ServerError`, so `rescue GraphWeaver::Error` caught
3019
- one and missed the other. A resolver raise is now a `ServerError` (status
3020
- 500) with the original kept as `#cause` — in-process, the real backtrace is
3021
- the point. **A bare schema class still works in any client slot**; the
3022
- wrapper is an upgrade, not a requirement.
3023
- - **`ServerError` carries the response `#headers`** (names downcased), plus
3024
- `#retry_after` (seconds or HTTP-date, per RFC 9110) and `#rate_limited?`. The
3025
- `Net::HTTPResponse` was always in hand and thrown away, so recovering
3026
- `x-ratelimit-remaining` or a request id meant monkey-patching the transport.
3027
- A `post` override may now return a third element, the headers; returning the
3028
- documented `[status, body]` pair stays correct.
3029
- - **`Retry` honours `Retry-After`** — the server's delay wins over the
3030
- configured backoff, clamped to `max:` and not jittered. Related: **408 and
3031
- 429 now retry by default.** They were treated as ordinary 4xx ("your bug,
3032
- retrying won't fix it"), which for the one status that exists to say "come
3033
- back later" was exactly backwards, and left `Retry` incorrect against GitHub
3034
- and Shopify. Pass `retry_if:` to restore the old behaviour.
3035
- - **A throttling predicate, spelled the same everywhere**: `ServerError#throttled?`
3036
- (429, or a 503 that says when to come back) and `QueryError#throttled?` /
3037
- `Response#throttled?` (a throttle code in the errors array). An API says "slow
3038
- down" with an HTTP status or with a code in a 200 body, and callers shouldn't
3039
- have to know which. The codes are `GraphWeaver::GraphQLError::THROTTLE_CODES`
3040
- — Shopify's `THROTTLED`, GitHub's `RATE_LIMITED`, and the common Apollo/Hasura
3041
- spellings — so `retry_codes:` takes the constant instead of a hand-written
3042
- string. `QueryError#to_h` gains `"throttled"` alongside `"schema_stale"`.
3043
- - `Transport::HTTP` takes `ca_file:`/`ca_path:`/`cert:`/`key:`/`verify_mode:`,
3044
- forwarded to `Net::HTTP.start` — a private CA or mTLS no longer means
3045
- switching to Faraday, which was the real but undiscoverable answer. Passing
3046
- one to an `http://` url raises instead of quietly doing nothing.
3047
- - **An instrumentation seam**: `GraphWeaver.instrumenter = ->(event, payload,
3048
- &block) { ... }`, a no-op until set, wrapping every request — over the wire
3049
- and in-process, one seam for both. `ActiveSupport::Notifications` becomes a
3050
- two-line adapter. The one event is `GraphWeaver::EXECUTE_EVENT`; its payload
3051
- carries `:url`, `:schema`, `:operation` and `:status`, and deliberately not
3052
- the query or variables (those are PII, and belong at debug on the logger
3053
- where the level gates them). See `docs/logging.md`.
3054
- Developer-experience fixes (all with regression coverage):
3055
- - **FakeClient override keys are validated against the schema.** A typo'd key
3056
- (`"Person.nmae" => "Daniel"`) pinned nothing, and the example passed against
3057
- random fake data — a test that had quietly stopped checking what it claims to.
3058
- Keys now raise, spellchecked, at `FakeClient.new` and at `Testing.configure`
3059
- when a schema is already set. Bare field-name keys (`"name"`) still work;
3060
- **fix or drop any key that doesn't name a field in your schema.**
3061
- - Codegen validation errors name the position they already captured: each
3062
- message is prefixed `4:5`, and `queries/typo.graphql:4:5` when the file is
3063
- known (`Codegen.new`/`Codegen.generate` take it as `path:`), instead of
3064
- leaving a project of thirty query files to search by hand.
3065
- - A strict `alias:` whose path doesn't fit a query now names the query that
3066
- failed and ends with `— pass optional: true to skip selections that don't
3067
- fit`, the documented way out.
3068
- - Generation lists the custom scalars it had no registration for at `info`
3069
- (`3 unregistered custom scalars → T.untyped: …`). Informational — a scalar
3070
- without a codec is a legitimate choice, just no longer a silent one.
3071
- - `Response#ok?` (and `#success?`) — the positive form of `errors?`.
3072
- - `FakeClient#schema` reads back the schema responses are fabricated against,
3073
- which is how to reach it under `auto_fake`, where `GraphWeaver.client` is the
3074
- fake; `Testing.config.schema` reads back too.
3075
- New:
3076
- - **`rake graph_weaver:queries:check` — which of your queries a schema change
3077
- broke.** Re-introspects the url the dump records (leaving the dump alone) and
3078
- validates every checked-in query against the server as it is now,
3079
- reporting file plus line:col plus message and exiting non-zero on any
3080
- failure, so it drops into CI. `GraphWeaver.check_queries` returns the same
3081
- thing as data (`{path => [{"message", "line", "column"}]}`, empty when
3082
- everything validates); pass `schema:` to check a schema you already have
3083
- without touching the network. Complements `graph_weaver:verify`, which asks
3084
- the different question of whether the committed Ruby is stale.
3085
- - `verify_generated!` (and `rake graph_weaver:verify`) compares generated files
3086
- with line endings normalized, so a checkout under git's `autocrlf` no longer
3087
- reports every generated file as stale.
3088
- - New [editor support](docs/editors.md) doc: the `graphql.config.yml` that gives
3089
- VS Code and RubyMine validation, autocomplete and hover docs in your
3090
- `.graphql` files — no JS project, no gem code, five lines of YAML.
3091
- - **Byte-identical generation is now a stated guarantee**, not just a property:
3092
- the same schema and queries produce the same files on any machine, in any
3093
- order (`docs/generated_modules.md`). It was already true and spec-enforced;
3094
- it was documented nowhere.
3095
-
3096
- **Faraday is no longer auto-selected — `GraphWeaver.new(url)` always builds
3097
- `Transport::HTTP`.** Selection used to be `defined?(::Faraday)`, and faraday
3098
- rides into most bundles transitively (stripe, octokit, ...), so adding an
3099
- unrelated gem silently swapped your transport, its timeouts, and its connection
3100
- behaviour. The accidental default was also the slower one: `Transport::HTTP`
3101
- pools persistent sockets (1 TCP connection for 10 requests) where Faraday's
3102
- default `net_http` adapter reconnects per request (10 for 10) — a full TLS
3103
- handshake each time over HTTPS.
3104
-
3105
- **What you must do:** if you were relying on the auto-pick, ask for Faraday
3106
- explicitly — `GraphWeaver.new(url, transport: :faraday)`. A middleware block
3107
- still implies it (`GraphWeaver.new(url) { |conn| ... }`), since the block is
3108
- Faraday's. Faraday is otherwise unchanged and fully supported. Alongside a url,
3109
- `transport:` now takes `:http` (the default) or `:faraday` rather than a
3110
- built transport object — passing an object there used to raise "pass a url or
3111
- transport:, not both" and now raises naming the two symbols. Alongside a schema
3112
- source it still takes a built transport, and now rejects a Symbol. The client
3113
- logs which transport it built at `info`.
3114
-
3115
- `docs/transports.md` gains the recipe for giving Faraday the connection reuse
3116
- `Transport::HTTP` has by default: the `:net_http_persistent` adapter, the two
3117
- gems it needs, and the version pairing (Faraday 2.x requires
3118
- `faraday-net_http_persistent` **2.x**; 1.2.0 raises `NoMethodError: undefined
3119
- method 'dependency'` at load). graph_weaver depends on neither and never
3120
- selects it for you.
3121
-
3122
- **Generated files are pruned when their query disappears.** Renaming or
3123
- deleting a `.graphql` used to leave its `.rb` behind forever: `load_generated!`
3124
- kept requiring it, its module kept resolving against a query that no longer
3125
- existed, and `verify_generated!` stayed silent — the pruning only covered
3126
- `inputs/*.rb` and `unions.rb`. `generate!` now deletes any generated file the
3127
- plan no longer produces, and `verify_generated!` reports it as stale.
3128
-
3129
- Only files carrying the `# Generated by GraphWeaver — do not edit.` header are
3130
- ever deleted, so a hand-written file in the output directory survives. **What
3131
- you must do:** nothing, unless you were relying on a lingering module — the
3132
- next `generate!` removes it, and CI's `rake graph_weaver:verify` will name it
3133
- first.
3134
-
3135
- **Mutations now generate `…Mutation` modules, not `…Query`.**
3136
- `save_list_entry.graphql` holding a `mutation` produces
3137
- `SaveListEntryMutation` in `save_list_entry_mutation.rb`;
3138
- `SaveListEntryQuery.execute!` read wrong for a write. Queries are unchanged.
3139
- The rule is one rule — the camelized file name plus the operation the file
3140
- defines — and all three naming sites follow it: `generate!`,
3141
- `GraphWeaver.parse(path)`, and `client.load_queries!`. The operation name
3142
- written *inside* the file still names nothing; it goes on the wire as
3143
- `operationName`.
3144
-
3145
- **What you must do:** regenerate (`rake graph_weaver:generate`) and rename the
3146
- call sites of any mutation module — `AdoptQuery` → `AdoptMutation`, including
3147
- nested constants like `AdoptQuery::AdoptionInput`. Regeneration prunes the old
3148
- `*_query.rb` files, and `rake graph_weaver:verify` names anything missed.
3149
- Changing a file's `query` to `mutation` from here on renames its constant the
3150
- same way, which CI now catches rather than letting it drift.
3151
-
3152
- **Generated modules get their client plumbing from
3153
- `GraphWeaver::QueryModule`.** `client`/`client=` carry no per-query type
3154
- information, so every generated file repeated the same fifteen untyped lines;
3155
- they now live in the gem, beside the input-struct runtime, and a module says
3156
- `extend GraphWeaver::QueryModule` instead. `execute`, `execute!`,
3157
- `from_response` and `from_response!` stay generated — their sigs are your
3158
- query's types. A baked `client:` constant is emitted as `DEFAULT_CLIENT`,
3159
- still resolved on first use so a module can load before the initializer that
3160
- builds its client, and resolution is unchanged: per call → per module → baked
3161
- constant → `GraphWeaver.client`.
3162
-
3163
- **What you must do:** regenerate (`rake graph_weaver:generate`). The files
3164
- change; nothing about how you call them does.
3165
- Error-message and console ergonomics from the same review:
3166
- - **Validation errors name the query file and render one per line**, compiler
3167
- style — `invalid query in app/graphql/queries/person.graphql:` followed by an
3168
- indented `4:5 Field 'nmae' doesn't exist on type 'Person'` per error. They
3169
- arrived as one joined line with no file at all, because `generate!` had the
3170
- path in hand and never passed it to codegen, so thirty query files left you
3171
- hunting for a bare `4:5`. `ValidationError#errors` and `#to_h` keep the shape
3172
- `rake graph_weaver:queries:check` reads; only the message text changed, and
3173
- **it is multi-line now** — update anything matching on it.
3174
- - **`register_enum("Species", PetKind, {"DOG" => :dog})` says the value map is a
3175
- keyword**, and shows the call with `map:` in it. Guessing the map as a third
3176
- positional argument used to get Ruby's `wrong number of arguments (given 3,
3177
- expected 2)`, which never mentions `map:`.
3178
- - **`load_queries!` logs when it replaces an already-loaded module**, at
3179
- `:info`, before swapping the constant: `replacing PersonQuery — objects built
3180
- from the previous module stay instances of it`. Reloading is unchanged and
3181
- still what the method is for; it just isn't silent about the structs it
3182
- orphans, which is how a console session ends up with an `is_a?` that fails
3183
- for no visible reason.
3184
- **Rails install generator.**
3185
- `rails g graph_weaver:install https://api.example.com/graphql` writes
3186
- `config/initializers/graph_weaver.rb`, the `app/graphql/queries` and
3187
- `app/graphql/generated` directories, `graphql.config.yml` (schema autocomplete
3188
- and validation for `.graphql` files in VS Code / RubyMine) and the schema dump
3189
- — replacing the console step the getting-started guide used to open with.
3190
- `--auth` names the ENV var holding the token (default `GRAPHWEAVER_AUTH`),
3191
- `--no-schema` skips the introspection. Re-running prompts on conflict like any
3192
- Rails generator.
3193
-
3194
- **`rake graph_weaver:schema:refresh` can now create the first dump.** It read
3195
- its url from an existing dump's provenance stamp, so it couldn't bootstrap one
3196
- — pass `URL=https://api.example.com/graphql` and it will, and both the
3197
- no-dump and no-provenance messages now name that fix. The same logic is
3198
- `GraphWeaver::SchemaLoader.refresh!(url:, auth:)`, which is what the generator
3199
- calls.
3200
-
3201
- **Pointing a client at a url that isn't a GraphQL endpoint now says so.** A
3202
- REST base url, a GraphiQL page or a proxy that ate the path answers 200 with
3203
- well-formed JSON, and `.schema` raised a bare `KeyError`/`NoMethodError` out of
3204
- graphql-ruby — unbranded, no url, and it escaped `rescue GraphWeaver::Error`
3205
- (a 404 on the same path was already branded and clear). Introspection now
3206
- checks for `data.__schema` and raises `GraphWeaver::Error` naming the endpoint
3207
- and the first 200 characters of what came back.
3208
-
3209
- **A subgraph's own `FieldSet` / `Scope` / `Policy` type no longer collides with
3210
- weaver's.** Loading subgraph SDL injects the federation directive definitions
3211
- the file applies but doesn't declare, and the scalars they reference went in
3212
- unnamespaced — so a subgraph that owns a type by one of those names either had
3213
- it shadowed or failed to build, with advice pointing at the wrong file. Those
3214
- three are now `federation__FieldSet` / `federation__Scope` /
3215
- `federation__Policy`. `_Any` / `_Entity` / `_Service` keep their names — those
3216
- are spec-mandated and queryable.
3217
-
3218
- **`rake graph_weaver:queries:check` no longer compares an in-process app's
3219
- schema against itself.** For an app whose schema is its own graphql-ruby class
3220
- there is no server to re-introspect, so the check degraded to re-reading the
3221
- committed dump — reporting phantom errors about the app's own schema, a field
3222
- you just added reading as "doesn't exist". When `GraphWeaver.client` executes
3223
- in-process (a `Client` wrapping a schema class, or the class itself), the check
3224
- now validates against the live class. Network clients are unchanged.
3225
-
3226
- **The two dead-end "records no source url" messages now say what to do.** A
3227
- dump taken from a schema class is rebuilt from code, not re-fetched — both
3228
- `schema:refresh` and `schema:diff` say that instead of naming a `URL=` that
3229
- doesn't exist for you.
3230
-
3231
- **Two rake tasks are renamed so each one names its own subject.** There were
3232
- three checks and two of them were called `verify`, while the one people run
3233
- most — "did schema drift break my queries?" — lived under `schema:` and doesn't
3234
- check the schema. **Update your CI:**
3235
-
3236
- | Was | Now | Asks |
3237
- |---|---|---|
3238
- | `graph_weaver:schema:check` | `graph_weaver:queries:check` | do my checked-in queries still validate? |
3239
- | `graph_weaver:schema:verify` | `graph_weaver:schema:diff` | has the server drifted from the dump? |
3240
-
3241
- `graph_weaver:verify` (is the committed Ruby fresh?) and
3242
- `graph_weaver:schema:refresh` are unchanged. No aliases — the old names are
3243
- gone.
3244
-
3245
- **The instrumentation payload now carries `:status` in-process too.** `InProcess`
3246
- brands a resolver raise as `ServerError(500)` precisely so callers needn't
3247
- branch on which side of the seam a query ran — but the payload had no `:status`
3248
- in-process and no `:schema` over the wire, so a subscriber had to branch
3249
- anyway. A successful in-process execute now sets `:status` to 200; a failure
3250
- still rides the exception the hook already sees.
3251
-
3252
- **`extend_type` and `alias:` moved from `docs/scalars.md` to
3253
- `docs/generated_modules.md`** — they decorate a generated struct, and now sit
3254
- next to what a generated struct looks like. **Update any bookmark to
3255
- `scalars.md#type-helpers-your-logic-on-generated-structs`**; it is
3256
- `generated_modules.md#type-helpers` now. `scalars.md` still owns
3257
- `register_scalar` and `register_enum`.
3258
-
3259
- - **`Testing::Router#trace` accumulates across executes and is reset
3260
- explicitly** — `router.reset_trace`. It used to clear itself at the top of
3261
- every `execute`, which made it answer about the *last* query rather than the
3262
- code path: a service object running two queries reported only the second's
3263
- fetches, and an example that ran nothing read the previous example's, so an
3264
- assertion could pass on another example's work and fail under `--order rand`.
3265
- The rspec `graphql: :router` tag resets it per example. **An example that
3266
- asserts on the trace after more than one `execute` now sees both**, and
3267
- wants a `reset_trace` in between if it meant only the last one.
3268
- - **The absent-subgraph refusal leads with the half that applies.** It opened
3269
- with "Rails autoloads, so the class is probably just not loaded yet" — right
3270
- often enough to lead with, except when eager loading is already on, and then
3271
- the library can *ask* rather than send you to a setting you already have. It
3272
- now checks `config.eager_load` / `config.rake_eager_load` and, when either is
3273
- on, says the subgraph runs elsewhere and puts `=> :fake` first instead of at
3274
- the end of a 60-word sentence.
3275
- - **A refusal spells a nested `@key`/`@requires` field set the way your schema
3276
- does** — `"origin { lat lon }"`, not `"origin.lat", "origin.lon"`. The dotted
3277
- form is this library's parse of it and matches nothing you can grep for.
3278
- - **One `@interfaceObject` no longer disables the whole router.** It refused at
3279
- construction, for the entire supergraph, so a single directive made
3280
- `Testing::Router` unusable even for queries that never touch the type — one
3281
- corpus had to be split into two graphs over it. It is now a per-query refusal
3282
- (`Unplannable#category` `:interface_object`) keyed on the types the query
3283
- actually reaches, and `federation:coverage` counts it as one refusal among
3284
- others rather than aborting. Routing an `@interfaceObject` is still not
3285
- implemented; this only makes the refusal proportionate.
3286
- `RoutingTable#unsupported` no longer lists them —
3287
- `RoutingTable#interface_objects` does, as `{"Media" => ["catalog"]}`.
3288
- - `docs/federation.md`'s refusal table now lists **every** `Unplannable`
3289
- category, and a spec keeps it that way. Five were missing, `chained_requires`
3290
- and `conditional_fragment` among them.
3291
- - **`federation:coverage` counts what your suite can *run*, not only what
3292
- plans.** `5/5 queries plannable locally (100%)` was optimistic in exactly the
3293
- partly-local shape the docs call the usual migration one: a query resolving
3294
- in a subgraph another service serves plans fine and a spec still can't run
3295
- it. The headline now reads `…, 2 servable here`, and the queries reaching
3296
- past what's loaded are listed with the subgraph each needs. Plan-only is
3297
- still the design — with no subgraph loaded (the SDL-alone CI run) the second
3298
- number is dropped and the report says it counted planning only.
3299
- `Coverage#servable` and `#elsewhere` are the programmatic side, and
3300
- `Coverage::Result` gained `absent` / `servable?`.
3301
- - **`Testing::Failure.stale_schema` drops its `schema:` / `seed:` sampling.**
3302
- It picked a random real type/field so a fabricated error string would look
3303
- plausible — but no assertion can depend on which one it picks, so it was
3304
- decoration with three kwargs and an RNG behind it. `stale_schema(type:,
3305
- field:)` names the casualty when the message matters, and the bare call still
3306
- trips `schema_stale?`. Passing `schema:`/`seed:` now raises `ArgumentError`.
3307
- - **`rake graph_weaver:cassettes:check`** — replays every recording through the
3308
- generated modules and fails when one no longer casts. A cassette is the only
3309
- artifact recorded from a foreign server, and nothing else here notices when
3310
- that server's answers drift out of the shape the structs were generated for:
3311
- `verify`, `queries:check` and `schema:diff` all ask about the local side. It
3312
- needs no network, so it belongs beside `verify` in a PR run. A recording no
3313
- generated module sends is skipped and counted, and checking *none* of them
3314
- fails, like `federation:diff`. `Testing::Cassette#check` is the programmatic
3315
- side.
3316
- - **A cast failure no longer prints sorbet-runtime's `Caller:` frame.**
3317
- `GraphWeaver::TypeError`'s message ended with `Caller:
3318
- .../sorbet-runtime/.../call_validation.rb:331` — a path into the gem, never
3319
- into the code with the problem, and the only location the message offered.
3320
-
3321
- ### v0.4.6 (2026-07-30)
3322
- Bug fixes from a full-library review (all with regression coverage):
3323
- - alias: a nested-object/enum leaf (`meta.sub`) now qualifies its constant
3324
- (`Meta::Sub`) instead of emitting a bare `Sub` that raised NameError; alias
3325
- names/segments are validated as identifiers (were interpolated verbatim,
3326
- allowing injection); `optional:` no longer swallows a reserved-name/collision
3327
- mistake; a real field named `first`/`last` resolves as a field.
3328
- - Shared unions: a hoisted member selecting a mapped enum now emits its
3329
- `<NAME>_FROM_WIRE` table into `unions.rb` (was a NameError at `from_h`); a
3330
- fragment whose name collides with `Result`/`QUERY` is refused.
3331
- - A named interface fragment holding inline `... on X` conditions now dispatches
3332
- instead of silently dropping those fields; fragment cycles raise a clear error
3333
- in the FakeClient/Anonymizer walkers instead of `SystemStackError`.
3334
- - Malformed responses/inputs stay under `GraphWeaver::Error`: a non-2xx
3335
- `errors: null` body keeps its status; a 2xx non-object body is a `ServerError`;
3336
- `data!` on null-data/no-errors raises `QueryError`; `coerce(non-Hash)` raises
3337
- `InputError`.
3338
- - Testing harness: FakeClient/Anonymizer merge duplicate result keys (were
3339
- fabricating shapes the generated struct couldn't cast); `fail_at` fires every
3340
- execute; symbol-keyed cassette variables no longer crash on reload; the
3341
- Anonymizer keeps concrete-fragment fields when data lacks `__typename`.
3342
- - Federation: a user type named `link` is no longer dropped; an all-`@inaccessible`
3343
- schema raises a pointed error. Client accepts `retries: nil` on a schema
3344
- source; `register_scalar` rejects an anonymous class.
3345
- - FakeClient: an Integer `list_size` now means exactly that length (a Range
3346
- randomizes within it). Codegen rejects two variables that underscore to the
3347
- same kwarg (`$userId` + `$user_id`). The Faraday transport raises rather than
3348
- silently dropping `headers:`/block when handed a prebuilt connection.
3349
-
3350
- ### v0.4.5 (2026-07-30)
3351
- - `alias:` paths gain list-element selectors: a `first`/`last` segment picks one
3352
- element out of a list hop, always nilable — `alias: { entity: "_entities.first" }`
3353
- yields `def entity = _entities&.first`, and navigation continues into the
3354
- element (`_entities.first.name`). Typed from the selection: a single inline
3355
- fragment lands on the concrete member (`T.nilable(Widget)`), a multi-fragment
3356
- selection on the union. Selectors are checked against the node shape — `.first`
3357
- on a non-list raises. Cleanly retires the `result._entities&.first&.field`
3358
- boilerplate of single-entity federation `_entities` queries.
3359
- - `extend_type(..., optional: true)` makes its aliases lenient: a query whose
3360
- selection doesn't fit the path omits the accessor instead of failing
3361
- generation. For an alias on a universal type (a `Query` accessor a strict alias
3362
- would force every query to satisfy), or one that only fits some selections.
3363
-
3364
- ### v0.4.4 (2026-07-30)
3365
- - Supergraph loading now derives the **API schema**: `@inaccessible` elements
3366
- (present in the federated graph but hidden from what the router serves) are
3367
- removed on load, cascading — a field/argument/union-member/interface
3368
- referencing a removed type goes too, and a type left empty is removed in turn.
3369
- So codegen validates against exactly what clients can query, with no
3370
- over-permitting and no Apollo JS tooling to subtract the API schema first.
3371
- Plain (non-federation) SDL is untouched.
3372
-
3373
- ### v0.4.3 (2026-07-30)
3374
- - Federation-aware supergraph loading: `SchemaLoader` (and `Client.new`) now
3375
- load a composed Apollo Federation v2 supergraph SDL directly. When the `@join__*`
3376
- markers are present it strips the composition machinery — the synthetic
3377
- `join__*`/`link__*` types and directive definitions, and every `@join__*`/`@link`
3378
- application — via an AST rewrite before `from_definition`, so the merged type
3379
- shapes load cleanly with nothing federation-internal leaking into
3380
- `schema.types`. Plain SDL is untouched. A query can now be typed against the
3381
- composed supergraph, not just per-subgraph schema objects.
3382
- - Removed `directive_defaults_patch.rb` (the graphql-ruby monkeypatch); the
3383
- preprocessor supersedes it and `graphql-ruby` fixed the underlying issue. The
3384
- gem now requires `graphql >= 2.6.7`.
3385
-
3386
- ### v0.4.2 (2026-07-30)
3387
- - `extend_type` accepts `alias:` — project a selected field (possibly nested)
3388
- onto a flat, typed accessor emitted into the struct body:
3389
- `extend_type("Widget", alias: { tag: "meta.tag" })` generates a sig'd
3390
- `def tag = meta&.tag`. Retires hand-written value objects that only flattened
3391
- a passthrough field. Takes a `{ name => path }` hash, a bare path string
3392
- (accessor named after the last segment), or an array of paths. Typed from the
3393
- selection — a nullable hop makes the accessor nilable and nil-safe; the leaf
3394
- may be a scalar, enum, or nested struct. Validated per query at generation: an
3395
- unselected/misspelled segment (with `did you mean`), a list hop, or a name
3396
- collision raises. Stacks and is client-scopable like the mixin forms.
3397
-
3398
- ### v0.4.1 (2026-07-29)
3399
- - Generated `execute!` forwards its kwargs to `execute` via hash shorthand
3400
- (`execute(client, name:, species:)` rather than `name: name, species: species`)
3401
- — cosmetic only. Regenerate to refresh (`verify` flags the drift otherwise).
3402
-
3403
- ### v0.4.0 (2026-07-28)
3404
- - Shared unions: when a named shared fragment is the whole selection on a union
3405
- field (`feed { ...FeedItemFields }`), its type is hoisted once into a
3406
- `GraphQLUnions` module and every query that spreads it aliases the same type —
3407
- so a union selected across many queries is one Ruby type family (one
3408
- exhaustive `case … T.absurd`), not a fresh dispatch module per query. No flag:
3409
- the shared fragment is the opt-in. Triggers only for an exact lone spread;
3410
- mixing other fields, or shadowing with a query-local fragment, keeps the union
3411
- inlined. Module name derives from the output path (override with
3412
- `GraphWeaver.unions_module=`); dynamic `parse` still inlines.
3413
- - Removed the `shared_inputs:` option from `generate!` / `verify_generated!`.
3414
- Directory-based generation always emits each input type once into a shared
3415
- module — the self-contained-module opt-out added complexity for little value.
3416
- Single-query `parse` / `Codegen.generate` still inline their types (there's
3417
- no set to share against). Only affects callers who passed
3418
- `shared_inputs: false`.
3419
-
3420
- ### v0.3.4 (2026-07-29)
3421
- - Shared fragments: define reusable named fragments once (default
3422
- `app/graphql/fragments`, configurable via `GraphWeaver.fragments_paths`) and
3423
- spread them from any query. Each query inlines only the fragments it
3424
- transitively spreads, so the sent query stays self-contained. Fragment files
3425
- hold only fragments; names are unique across them. Works in `generate!` and
3426
- dynamic `parse`.
3427
-
3428
- ### v0.3.3 (2026-07-29)
3429
- - Union member-type dedup: a union selected more than once on a struct now
3430
- collapses to one Ruby type family instead of a distinct per-field family with
3431
- identical members — so a consumer gets a single exhaustive
3432
- `case … T.absurd` across every field of that union. Structurally different
3433
- selections stay distinct types. (First cut: same-struct siblings; regenerate
3434
- checked-in modules to pick it up.)
3435
-
3436
- ### v0.3.2 (2026-07-29)
3437
- - `register_scalar` accepts a `Type.field` coordinate to override how one
3438
- field's scalar deserializes — so the same scalar can be different Ruby types
3439
- across fields (`register_scalar("User.birthday", Date)` while
3440
- `ISO8601DateTime` stays a `Time` elsewhere). Field overrides win over the
3441
- scalar-name registration; both stack global-then-client. The coordinate is
3442
- validated against the schema (a typo'd or non-scalar field raises). Same
3443
- method, same signature — a `.` in the name selects the field form.
3444
-
3445
- ### v0.3.1 (2026-07-28)
3446
- - `GraphWeaver.extend_t_sig` controls whether generated modules/structs emit
3447
- `extend T::Sig`. Default (`nil`) auto-detects a global T::Sig injection
3448
- (`class Module; include T::Sig`) and skips the now-redundant `extend` — so
3449
- generated code stays clean under rubocop's `Sorbet/RedundantExtendTSig`.
3450
- Force with `true`/`false`; `false` requires the global include.
3451
-
3452
- ### v0.3.0 (2026-07-28)
3453
- - Renamed `register_type` to `extend_type` to disambiguate intent: it
3454
- *decorates* a generated struct with mixin modules/helpers — it doesn't
3455
- define or replace a type's deserialization (that's `register_scalar` /
3456
- `register_enum`, for leaf types, whose Ruby shape is fixed; a composite's
3457
- shape is per-query, so there's nothing fixed to replace). No deprecation —
3458
- the old name is dropped.
3459
- - Invalid query input now raises `GraphWeaver::InputError` (under the
3460
- `GraphWeaver::Error` umbrella) instead of a raw `ArgumentError` /
3461
- `KeyError` / sorbet `TypeError`: an unknown or typo'd input key, a
3462
- missing required field, an out-of-range enum, or a wrong-typed field
3463
- when an input object is built from a hash through `coerce`. Carries
3464
- `#field` / `#struct` and a JSON-ready `#to_h` — one rescue point for
3465
- returning a 422 at an API boundary. Top-level *scalar* kwargs still
3466
- fail like any Ruby method call (sorbet `TypeError` / `ArgumentError`).
3467
-
3468
- ### v0.2.2 (2026-07-22)
3469
- - Generated modules expose from_response / from_response! alongside
3470
- execute / execute!: deserialize a raw GraphQL response (fetched by any
3471
- client) into the typed envelope without going through the transport.
3472
- execute now delegates to from_response
3473
-
3474
- ### v0.2.1 (2026-07-13)
3475
- - Conventional paths are appendable lists: queries_paths /
3476
- generated_paths (singular accessors read the first entry, so existing
3477
- config keeps working); load_generated!, Client#load_queries!, and the
3478
- Railtie walk every entry — append spec/support/graphql/* from a spec
3479
- helper to load test-only queries. Entries may be globs, and the
3480
- generated default includes app/graphql/*/generated so per-schema
3481
- layouts auto-load
3482
- - inputs_module derives from the output path: multi-schema layouts name
3483
- each schema's module after its directory
3484
- (app/graphql/github/generated -> GithubInputs), the conventional
3485
- layout keeps GraphQLInputs; GraphWeaver.inputs_module= and
3486
- generate!(inputs_module:) still override
3487
- - Shared types split one-file-per-type: generated/inputs/ holds each
3488
- input struct/enum in its own small file (PokeAPI: 573 files, median
3489
- 24 lines vs one 11.5k-line blob) with inputs.rb as the manifest
3490
- (forward declarations make load order irrelevant); regeneration
3491
- prunes files for types the schema dropped, verify flags strays;
3492
- generate!/verify take inputs_module: per invocation (multi-schema
3493
- apps generate into different modules)
3494
- - Shared input types: generate! emits every variable type (input
3495
- structs + their enums + mapped-enum tables) ONCE per schema into
3496
- generated/inputs.rb (module GraphQLInputs; GraphWeaver.inputs_module=
3497
- renames, shared_inputs: false opts out), with query modules aliasing
3498
- only what their own surface references — AdoptQuery::AdoptionInput
3499
- keeps working and shared types gain one identity across modules.
3500
- Three filtered Hasura queries: 34,684 lines inline -> 11,754 shared
3501
- (~90 lines per query module)
3502
- - BREAKING (vs 0.2.0): auto_fake is opt-in again — require
3503
- "graph_weaver/rspec" no longer swaps every example onto a fake;
3504
- set config.auto_fake = true explicitly (the schema still auto-locates
3505
- once you do). Less magic, no unexpected behavior
3506
- - Generated input structs are table-driven: typed consts + a per-field
3507
- FIELDS table (conversions as lambdas) interpreted by the
3508
- GraphWeaver::InputStruct runtime, replacing unrolled
3509
- serialize/coerce/value_at per struct — a bool_exp-heavy PokeAPI module
3510
- shrinks 29k -> 11.5k lines (-60%) with identical behavior (nil
3511
- omission, wire-value enums, nested/recursive coercion, spellchecked
3512
- unknown keys all covered by the existing suite)
3513
- - Internal: Node base class for the codegen IR protocol; module
3514
- assembly moved from Codegen#generate into Emit#emit_module
3515
- (byte-identical output)
3516
-
3517
- ### v0.2.0 (2026-07-12)
3518
- - Cleanup pass (staff-engineer review): scalar registrations get the
3519
- same typo validation as enums/types; cassette replay stops recomputing
3520
- its key per entry; dependency-order DFS uses hash bookkeeping (big
3521
- bool_exp graphs); require/vocabulary residue swept; the vestigial
3522
- graph_weaver/testing/rspec shim removed
3523
- - BREAKING: "client" replaces "executor" across the whole surface.
3524
- Generated modules: the per-call override is an optional POSITIONAL
3525
- first argument — PersonQuery.execute(github, id: "1") — so variables
3526
- own the entire kwarg namespace and NOTHING is reserved (a $client or
3527
- $executor variable is fine; only Ruby keywords refuse); per-module is
3528
- MyQuery.client=, the baked param is client:. GraphWeaver.executor= is
3529
- gone — GraphWeaver.client= is the one ambient slot (auto_fake swaps
3530
- it per example; explicit clients are self-contained and never see it).
3531
- Client#executor is now Client#transport (transport: to bring your
3532
- own); SchemaLoader.introspect/stale? speak transport. Renames:
3533
- FakeExecutor => Testing::FakeClient, SequenceExecutor =>
3534
- Testing::Sequence, RetryExecutor => GraphWeaver::Retry,
3535
- Recording/ReplayExecutor => Recorder/Replayer, Cassette.use(client:)
3536
- - Live federation integration: two Ruby subgraphs (apollo-federation
3537
- gem) composed and routed by a real Apollo gateway (node harness under
3538
- spec/support/federation), with GraphWeaver introspecting through the
3539
- router and executing a query stitched across BOTH subgraphs — part of
3540
- make integration. Complements the existing supergraph-SDL codegen spec
3541
- - graphql-over-http: a non-2xx response carrying a GraphQL errors body
3542
- (Apollo Server/Router send request errors as 4xx JSON) flows into the
3543
- Response envelope so QueryError sees the structured errors; only
3544
- non-GraphQL bodies (proxy pages) raise ServerError
3545
- - Fix: input-struct serialize used bare locals (result/value) that a
3546
- same-named prop silently shadowed — a field named "result" dropped
3547
- its value onto the wrong target; generated locals now wear the
3548
- reserved __gw prefix (GraphQL reserves __-names, so no collision is
3549
- possible)
3550
- - Input fields and variables whose Ruby name would be a keyword
3551
- (nil/def/end/...), a generated method (serialize/to_h), or the
3552
- reserved executor kwarg now refuse at generation with a pointed
3553
- error instead of emitting broken code
3554
- - Non-JSON 200 bodies (proxy error pages) classify as ServerError, and
3555
- unserializable variables (NaN/Infinity) raise GraphWeaver::Error —
3556
- raw JSON::* errors no longer escape the umbrella
3557
- - Transports redact on inspect/to_s (class + url only) — Authorization
3558
- headers can't leak through logs or exception dumps
3559
- - Narrowed `... on X` selections require at least one unconditional
3560
- field: with every field behind @skip/@include, a matching response is
3561
- {} — byte-identical to a non-match — so generation refuses rather
3562
- than silently dropping real matches to nil
3563
- - Integration spec against Hasura's PokeAPI: snake_case codegen,
3564
- recursive bool_exp variable filtering, untyped jsonb pass-through
3565
- (make integration)
3566
- - BREAKING: ValidationError now descends from GraphWeaver::Error (was
3567
- ArgumentError) — one `rescue GraphWeaver::Error` catches everything
3568
- - Input-struct .coerce raises on unknown hash keys with a spellchecked
3569
- hint — a typo'd filter key no longer silently drops off the wire
3570
- - Client registrations (register_type/register_enum) validate at the
3571
- call site when the schema is already loaded; lazy clients still
3572
- validate at generation
3573
- - Unregistered custom scalars emit bare T.untyped (not
3574
- T.nilable(T.untyped), an srb tc error under typed: strict)
3575
- - Wire log lines carry [req N OperationName] tags; long queries
3576
- (introspection) truncate at debug
3577
- - Logging: GraphWeaver.logger (any stdlib-compatible Logger; Rails.logger
3578
- auto-wired by the railtie) — wire traffic + timings at debug,
3579
- introspection/cache/codegen at info, every raised error at warn
3580
- - Recursive input types generate — self- and mutually-referential inputs
3581
- (Hasura's bool_exp filter surface) emit dependency-ordered structs with
3582
- runtime forward declarations for cycles, so variable-driven Hasura
3583
- filtering works; previously raised "recursive input type"
3584
- - Fix: snake_case GraphQL type names (Hasura, PostGraphile) camelize
3585
- into valid Ruby constants — pokemon_v2_pokemon => PokemonV2Pokemon
3586
- (previously generated a SyntaxError); wire names (__typename dispatch,
3587
- registry keys) are untouched
3588
- - Everything raised is rescuable: unparseable queries wrap as
3589
- ValidationError (GraphQL::ParseError no longer leaks), and internal
3590
- NotImplementedError raises (recursive inputs, unsupported kinds,
3591
- subscriptions) became GraphWeaver::Error
3592
- - Transport::HTTP takes open_timeout:/read_timeout: (defaults 10s/30s);
3593
- timeouts surface as retriable TransportError
3594
- - Transport::HTTP reuses its connection (keep-alive, mutex-serialized,
3595
- keep_alive_timeout: for the idle window); any failure drops the socket
3596
- so the next call starts fresh
3597
- - GraphQLError#code also reads a top-level "type" (GitHub's dialect:
3598
- NOT_FOUND, FORBIDDEN) when extensions.code is absent
3599
- - Typo'd client-scoped registrations raise at generation with a
3600
- spellchecked hint (register_type("Pett") => "did you mean 'Pet'?")
3601
- instead of silently no-oping
3602
- - Abstract selections narrow: __typename is only required when the
3603
- selection varies by concrete type. Interface-level-fields-only
3604
- selections generate one shared struct (no dispatch); a single
3605
- `... on X` condition generates X's struct, always nilable — a
3606
- non-matching runtime type casts to nil, so narrowing doubles as
3607
- filtering
3608
- - Zero-config rspec: require "graph_weaver/rspec" now defaults
3609
- auto_fake on and auto-locates the schema from the committed dump
3610
- (config.schema= / config.auto_fake = false to override) — one line is
3611
- the whole test setup in a conventional app
3612
- - examples/: runnable demos, all directly executable — countries.rb
3613
- (public API, no auth, all dynamic), rick_and_morty.rb (filtered
3614
- search, pagination, a block-built type helper), and github/ (auth,
3615
- checked-in generated modules; stars the repo ⭐ then tours the
3616
- stargazers, their top repos, and what else they've starred); excluded
3617
- from the gem package
3618
- - Fix: requires: now load before codec probing, so inference sees
3619
- methods the required file provides — register_scalar("DateTime", Time,
3620
- requires: "time") correctly infers Time.parse in a fresh process
3621
- (previously the cast was silently skipped unless "time" was already
3622
- loaded)
3623
- - docs/quickstart.md renamed to docs/getting_started.md
3624
- - Rails Railtie: the graph_weaver:* rake tasks self-register (no
3625
- Rakefile edit) and depend on :environment, and generated modules load
3626
- at boot (after initializers) when generated_path exists; outside
3627
- Rails, require "graph_weaver/tasks" and call load_generated! as before
3628
- - BREAKING (vs 0.1.0): reset_scalars! lost its coerce: flavor —
3629
- GraphWeaver.auto_coerce = true is the one way to default-coerce
3630
- (broader: convertible built-ins AND full cast/serialize scalars,
3631
- resolved lazily, per-registration coerce: still wins)
3632
- - GraphWeaver.client= — the blessed global wiring: assign the app's
3633
- default client and generated modules resolve through it (per call ->
3634
- per module -> baked -> executor= -> client). executor= stays as the
3635
- low-level override, so test fakes still win
3636
- - Enum mappings: register_enum("Species", PetKind) (+ bulk
3637
- register_enums, client-scoped variants) — generated code speaks YOUR
3638
- T::Enum, with the wire mapping inferred by name, pinned via map:,
3639
- exhaustiveness-checked at generation (fails naming gaps), and
3640
- fallback: to absorb unknown wire values on cast (inputs stay strict);
3641
- translation tables emitted into the source (X_FROM_WIRE / X_TO_WIRE)
3642
- - Type helpers: register_type("Pet", PetHelpers) (global or
3643
- client-scoped, additive) — app-owned modules included into every
3644
- struct generated from that GraphQL type, so derived values live as
3645
- methods beside the honest wire data and srb tc checks them against
3646
- each query's selection. Or build the mixin inline with a block
3647
- (module_eval'd into an auto-named GraphWeaver::TypeHelpers constant —
3648
- quick decoration, invisible to srb tc)
3649
- - BREAKING (vs 0.1.0): register_scalar takes the type positionally —
3650
- register_scalar("Money", Money, requires: ...) — matching the new
3651
- registrars: the GraphQL name + your Ruby type up front, options as
3652
- kwargs
3653
- - GraphWeaver::Client — transport, schema, and scalars for one server in
3654
- one object: GraphWeaver.new(url_or_schema) takes a url (transport
3655
- built, schema introspected lazily per cache:/ttl:) or a schema source
3656
- (live class — also the in-process executor — or a path/SDL/dump);
3657
- #parse and #execute/#execute! bind the implicit schema + transport;
3658
- #register_scalar scopes scalar mappings to the client (overlaying the
3659
- global registry), so two servers can disagree about a scalar type
3660
- - BREAKING: GraphWeaver.connect removed — GraphWeaver.new(url) replaces
3661
- it (wire generated modules with GraphWeaver.executor = client.executor)
3662
- - BREAKING: the one-shots are now GraphWeaver.execute(url_or_schema,
3663
- query, **variables) / execute! — Client#execute on a throwaway client;
3664
- variables are plain kwargs, as on a generated module
3665
- - Client#load_queries! — parse every query file into modules named like
3666
- generation would name them (reloadable; namespace: to scope): the
3667
- no-build-step analog of generate! + load_generated!
3668
- - Introspected schema dumps record provenance (source url + timestamp):
3669
- a parsable SDL header comment, a "graph_weaver" sibling key in JSON —
3670
- read it back with SchemaLoader.provenance(path), check drift with
3671
- SchemaLoader.stale?(path) or rake graph_weaver:schema:verify, rewrite
3672
- with rake graph_weaver:schema:refresh (GRAPHWEAVER_AUTH for tokens)
3673
- - generate!/verify_generated!/rake auto-locate the schema dump at
3674
- schema_path in any supported format; SchemaLoader.locate is public
3675
- - Calling a result field by its camelCase wire name raises a pointed
3676
- NoMethodError naming the snake_case prop that does exist
3677
- (result.addPet => "use 'add_pet'"), and near-miss typos in either
3678
- casing get a spellchecked suggestion (result.addPt => "did you mean
3679
- 'add_pet'?") — the runtime companion to srb tc's static flag
3680
- - BREAKING: an operation whose only variable is a required input object
3681
- (the Relay convention) now flattens the input's fields into execute's
3682
- kwargs — AdoptQuery.execute!(name:, species:) instead of
3683
- execute!(input: {...}); multi-variable / nullable-input operations
3684
- keep the input: kwarg (struct or hash)
3685
- - Enum kwargs accept the T::Enum or its wire value (T.any(Enum, String))
3686
- everywhere — variables now match input-hash fields
3687
- - BREAKING: HttpExecutor / FaradayExecutor are now Transport::HTTP /
3688
- Transport::Faraday, subclasses of the new abstract GraphWeaver::Transport
3689
- base, which owns the shared flow (encode, TransportError reclassify,
3690
- non-2xx ServerError, parse) — a custom transport just implements
3691
- post(body) => [status, body]. Opt-in require moved:
3692
- "graph_weaver/faraday_executor" -> "graph_weaver/transport/faraday"
3693
- - SchemaLoader.introspect cache: reuses a fresh dump in ANY supported
3694
- format before re-introspecting (an existing schema.graphql wins over
3695
- writing schema.json), and accepts :json / :graphql / :gql to pick the
3696
- format at GraphWeaver.schema_path's location
3697
- - rubydoc.info rendering: ship .yardopts (markdown markup, docs/ guides
3698
- as extra files) and re-indent docstring examples so code blocks and
3699
- backticks render; make docs previews locally
3700
- - GraphWeaver.connect(url, auth:, headers:, retries:): one-shot setup —
3701
- best transport (Faraday when the app loads it; detection is defined?,
3702
- never a require), bearer/verbatim auth, opt-in RetryExecutor wrapping
3703
- (true / options Hash; off by default), wired in as the global executor
3704
- - Generation workflow: GraphWeaver.generate! (queries dir -> generated
3705
- dir), verify_generated! (the freshness guard — raises naming stale
3706
- files), load_generated! (factory_bot-style explicit loading), rake
3707
- tasks (require "graph_weaver/tasks": graph_weaver:generate / :verify),
3708
- all defaulting to configurable conventional paths (queries_path /
3709
- generated_path / schema_path)
3710
- - GraphWeaver.auto_coerce = true: default input coercion for scalars
3711
- without an explicit coerce:, resolved lazily at generation time (no
3712
- reset_scalars! ordering dance) — convertible built-ins take their
3713
- conversion, cast/serialize pairs take parse-style coercion
3714
- - SchemaLoader.introspect cache: true — caches at GraphWeaver.schema_path,
3715
- in the format the extension picks: .json (verbatim wire artifact) or
3716
- .graphql/.gql (SDL — human-readable, PR-reviewable diffs);
3717
- the same dump rake graph_weaver:generate reads
3718
- - docs/transports.md: connect, the executor contract, Faraday, retries
3719
- - Cassette workflow: GRAPHWEAVER_RECORD=1 / config.record force
3720
- re-recording; config.anonymize scrubs responses as they are recorded
3721
- (caller sees the anonymized data too, so assertions hold on replay);
3722
- rake graph_weaver:cassettes:anonymize; docs/cassettes.md guide
3723
- - auto_coerce reaches input-object fields: raw scalar values inside
3724
- input hashes coerce via the registry, mutations included
3725
- - RetryExecutor: composable retries over any transport — tries:,
3726
- exponential/linear/custom backoff with jitter and max clamp,
3727
- retry-by-error-class (5xx yes, 4xx no by default; retry_if: override)
3728
- and retry-by-GraphQL-code (retry_codes: ["THROTTLED"])
3729
-
3730
- ### v0.1.0 (2026-07-11)
3731
- - Structured errors: execute returns a typed Response envelope (#data/#data!,
3732
- #errors, #errors?, #extensions) instead of raising on GraphQL errors, so
3733
- partial data and top-level extensions (cost/throttle) survive. Error classes
3734
- under GraphWeaver::Error — TransportError (network), ServerError (non-2xx
3735
- HTTP, #status/#body), QueryError (#errors/#data/#extensions/#codes),
3736
- ValidationError (build-time) — plus a GraphQLError value object with #code.
3737
- Transport-error classification is an extensible Set (GraphWeaver.transport_errors
3738
- / register_transport_error): each transport seeds its own network exceptions
3739
- and apps can add more (e.g. a connection-pool timeout).
3740
- The envelope is a single generic GraphWeaver::Response[Result] (no per-query
3741
- wrapper class). execute! is the shortcut for execute(...).data! — the typed
3742
- result or a raised QueryError — on both generated modules and the one-shot
3743
- GraphWeaver.execute!/execute.
3744
- BREAKING: module #execute returns Response; use #execute! (or #data!) for
3745
- the old raise-or-result behavior. GraphWeaver.execute now returns the
3746
- envelope too; GraphWeaver.execute! returns the result.
3747
- - GraphWeaver.register_scalar: custom scalar deserialization into rich Ruby
3748
- objects. cast/serialize inferred from a class type via paired codecs
3749
- (.parse/#to_s or .load/.dump), or given as a Symbol/Proc (:itself opts out);
3750
- requires: emits (validated, and require-checked when type: is a class)
3751
- requires into generated source — the built-in Date scalar carries
3752
- require "date" so Date-using queries are self-contained; coerce: true lets a
3753
- variable accept the value or its raw input (coerce: :to_f for a built-in
3754
- conversion), casting/converting the latter — reset_scalars!(coerce: true)
3755
- reloads the built-ins coercible; built-in scalars pre-registered in one
3756
- overridable registry (reset_scalars!/clear_scalars!)
3757
- - FaradayExecutor: url, Faraday connection, or middleware block
3758
- - GraphWeaver.executor default transport; per-module executor= override
3759
- - GraphWeaver.parse and GraphWeaver.execute (dynamic queries)
3760
- - Codegen.generate shorthand; executor: takes a constant; module_name
3761
- derived from operation or file name
3762
- - Error ergonomics: schema_stale? (validation-shaped rejections hint at
3763
- regeneration), errors_at(path) + each_error/errors_by_field filtering,
3764
- #report (field-keyed rollup with entity ids resolved from partial
3765
- data), #to_h across the hierarchy (JSON-ready machine output), and
3766
- GraphWeaver::TypeError wrapping cast failures with the failing struct
3767
- - SchemaLoader: introspect(executor, cache:, ttl:) fetches schemas from
3768
- live endpoints with file caching; load accepts introspection JSON /
3769
- SDL content / Hashes as well as paths (cache round-trips)
3770
- - GraphWeaver::Testing (require "graph_weaver/testing", or
3771
- "graph_weaver/rspec" for the rspec integration): FakeExecutor
3772
- fabricates schema-correct castable responses (mode: :faker semantic
3773
- values / :literal; overrides by GraphQL name; seeded; list_size /
3774
- null_chance), failure simulation (Failure.transport/server/graphql/
3775
- throttled/stale_schema, SequenceExecutor for retries, fail_at: with
3776
- spec-correct null propagation, corrupt: for derived type mismatches),
3777
- cassette record/replay above the transport, and Cassette#anonymize!
3778
- (shape-preserving, consistent id mapping). rspec: seed follows
3779
- --seed; auto_fake installs a fake executor per example
3780
- - one-off integration specs against live GitHub + Countries APIs
3781
- (make integration)
3782
- - Input objects: INPUT_OBJECT variables generate module-level T::Structs
3783
- with serialize (aliased to_h) producing the wire hash; execute kwargs
3784
- also accept plain hashes, normalized + type-checked via the generated
3785
- .coerce (underscored Symbol/String keys, enums as instances or wire
3786
- values, nested inputs as hashes)
3787
- - fields under @skip/@include generate nilable regardless of schema
3788
- nullability; FakeExecutor honors first/last/limit when sizing lists
3789
- - eval hardening for parse: module names must be constant names, and
3790
- QUERY heredocs can't be terminated early by block strings
3791
- - GraphWeaver::Selection: one shared query-walk (codegen, FakeExecutor,
3792
- anonymizer); codegen split into scalar_type / nodes / emit
3793
- - docs/: generated_modules, real_world, scalars, errors, testing;
3794
- README slimmed to pitch + quickstart
3795
-
3796
- ### v0.0.1 (2026-07-07)
3797
- - voila: typed codegen (T::Structs, T::Enums, typed variable kwargs)
3798
- - queries + mutations; fragments, unions, interfaces, enums, custom scalars
3799
- - schema sources: live class, introspection JSON, SDL (incl. supergraph)
3800
- - pluggable executor: in-process schema or HTTP
3801
- - dynamic (no-build) mode for development