graph_weaver 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1314 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -154
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +7 -0
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +181 -21
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -170
  62. metadata +20 -3
data/CHANGELOG.md CHANGED
@@ -1,3 +1,1317 @@
1
+ ### v0.5.0 (2026-09-07)
2
+ - **`graphql_in_process(SomeSchema)`** runs one example against that schema
3
+ class's real resolvers — the sibling of `graphql_fake`, and how a federated
4
+ app tests a single subgraph directly rather than through the stitched graph.
5
+ `graphql: :in_process` is that call with no argument, which runs
6
+ `config.schema` when it is a live class, so a non-federated app needs
7
+ nothing. A suite testing several subgraphs names one per example, which no
8
+ suite-wide setting could express.
9
+ - **`Testing::Router` crosses a boundary on a nested `@key` or `@requires`.**
10
+ `@key(fields: "id organization { id }")` and
11
+ `@requires(fields: "origin { lat lon }")` used to refuse; they now plan, to
12
+ any depth, with the object carried in the representation the way the SDL
13
+ spells it (a null inner object included). **Nothing to do** — queries your
14
+ suite marked "run this one against a real router" may now simply run. Where
15
+ a type declares more than one `@key`, the plan takes the first one the
16
+ fetching subgraph can supply.
17
+ - **`:nested_field_set` narrowed rather than disappeared.** It now names only
18
+ a nested field set no one *fetch* can build — `origin` resolving in one
19
+ subgraph and `origin.lat` in another, or a nested `@key` whose object a
20
+ `@requires` would half-fill from somewhere else. Either way the object
21
+ would arrive in pieces, and a representation comes from one fetch. If you
22
+ group refusals by category, that bucket shrinks; its message and
23
+ `docs/federation.md`'s table say what is left. `:chained_requires` is
24
+ unchanged and still a different refusal.
25
+ - **`Testing.config.router` takes `subgraphs:` without `supergraph:`.** It
26
+ raised — "must be the arguments to build one, e.g. `{ supergraph: … }`" —
27
+ even where the committed dump already is the supergraph, which is the case
28
+ the docs call "no config at all" and the single most likely config a
29
+ federated app writes (marking a remote subgraph `:fake`). Either key alone
30
+ is enough now; a missing `supergraph:` derives exactly as it does with no
31
+ `config.router` at all.
32
+ - **`graphql_fake(**options)`** builds the example's fake where the example
33
+ can say what it needs — `graphql_fake(overrides: { "Reader.orders" => [{}, {}] })`
34
+ — and returns it, so `#requests` is in reach. `graphql: :fake` is this call
35
+ with no options. Options had nowhere to go before: the tag builds its client
36
+ in a `config.before(:each)`, which rspec runs ahead of every group hook, so
37
+ `Testing.config.overrides` set in a `before` block was always too late and
38
+ failed silently, as wrong data.
39
+ - **`GraphWeaver.client` is snapshotted and restored around *every* example**,
40
+ not only a tagged one. `graphql: false` used not to restore while
41
+ `graphql: :fake` did, which made "tag `:fake`, then throw the client away"
42
+ the idiom for cleanup. Building your own client is now a plain assignment in
43
+ a `before` block. An example that deliberately leaked a client into later
44
+ examples no longer can.
45
+ - **`graphql: :none` is gone** — a second spelling of `graphql: false`, which
46
+ stays. Change any `:none` tag to `false`.
47
+ - **`GraphWeaver.client!` names the tag** when `graph_weaver/rspec` is loaded:
48
+ `no client configured — tag the example graphql: :fake (or :in_process /
49
+ :router), or build one with graphql_fake`. "Set `GraphWeaver.client=`" was
50
+ advice for the wrong file.
51
+ - **An override pins a subtree by naming only the fields the test is about.**
52
+ `overrides: { "Reader.orders" => [{ "status" => "PAID" }, {}] }` pins the
53
+ list's length and merges each element onto fabricated data — the rest of the
54
+ selection is still generated. It used to *replace*, so pinning one nested
55
+ field meant hand-writing the whole selection set in wire casing, and
56
+ under-supplying died as `key not found: "book"` at cast time. A pinned key
57
+ the query doesn't select is now refused (spellchecked, and naming the
58
+ response keys it could have been), for the same reason a typo'd coordinate
59
+ is. At a union or interface, a pinned object names its `"__typename"` and
60
+ gets that member rather than a random one.
61
+ - **An override of `nil` pins the field null.** It used to read as "no
62
+ override" and fabricate a value.
63
+ - **`Testing::FakeClient#requests`** records every `execute` in order
64
+ (`{ query:, variables:, operation_name: }`) — "did we send the right
65
+ variables", and "did we call it at all", without a hand-rolled spy.
66
+ - **`FakeClient`'s selection-walking internals are private** (`each_field`,
67
+ `gather`, `load_operation`, …). Nothing documented called them; if you did,
68
+ `Object.new.extend(GraphWeaver::Selection)` is the supported host.
69
+ - **The local router refuses a `@requires` whose field set names another
70
+ `@requires` field** (`chained_requires`). It used to answer: a prefetch sends
71
+ the entity's own `@key` and nothing else, so the inner requirement never
72
+ arrived and the field was computed from a representation missing its input —
73
+ the same field then holding two different values in one response. The
74
+ one-hop limit was documented but not enforced.
75
+ - **An error path no longer names an injected `@key`/`@requires` alias.** A
76
+ stitched error came back as `["thing", "_gw_w"]`, a field no schema contains;
77
+ it is `["thing", "w"]` now, as a real router reports it.
78
+ - **`@skip`/`@include` driven by a variable's declared default was ignored on a
79
+ boundary-crossing field**, so `query($show: Boolean = true) { … @include(if: $show) }`
80
+ called with no variables silently dropped the field. graphql-ruby applies an
81
+ operation's defaults; the local router read only what the caller passed.
82
+ - **`Testing::FakeClient` raises a GraphQL validation error for an unknown
83
+ field**, as every other client in the slot does. It used to die with
84
+ `NoMethodError: undefined method 'type' for nil` from inside the fabricator —
85
+ undiagnosable, and the commonest mistake there is: a query drifting ahead of
86
+ the schema dump, or a typo in one written inside an example.
87
+ - **The local test router plans a union or interface at a subgraph boundary.**
88
+ `search { ... on Track { playCount } ... on Artist { upcomingEvents { … } } }`
89
+ — a feed, a search page, any polymorphic list — used to be refused
90
+ (`abstract_boundary`), because a representation names one concrete
91
+ `__typename` and the planner runs before any data exists. It now plans one
92
+ branch per concrete type the supergraph says the subgraph can answer with,
93
+ asks the fetch for `__typename`, and buckets the returned objects by it at
94
+ execution time — one `_entities` fetch per concrete type, which is what a
95
+ real router does. Nothing to change; queries that were refused now run.
96
+ - The `crosses_subgraph` refusal category is **gone**, and
97
+ `abstract_boundary` now means only one thing: the supergraph doesn't record
98
+ which concrete types a subgraph answers an abstract type with (no
99
+ `@join__unionMember`/`@join__implements`, and the type in more than one
100
+ subgraph). Match on `Unplannable#category` for either of those and you must
101
+ change it.
102
+ - A fragment whose type condition can't hold at a position is now dropped
103
+ rather than refused — `... on Note` under a field whose subgraph has no
104
+ Note in that union never matches, and a real router answers `{}` there too.
105
+ - **A query file whose name can't spell a constant now names the file.**
106
+ `01_home_featured.graphql` reported `module_name: must be a constant name,
107
+ got "01HomeFeaturedQuery"` and left you to find which of thirty files it
108
+ meant; it now names the path and says the fix is a rename. It's a
109
+ `GraphWeaver::Error` too, so `rake graph_weaver:generate` aborts on it
110
+ instead of burying it under a backtrace — rescue `ArgumentError` for this
111
+ and you must change it. An explicit `module_name:` argument still raises
112
+ `ArgumentError`.
113
+ - **`rails g graph_weaver:install <supergraph>` recognises a composed
114
+ supergraph** and says what changes because of it: the `federation:*` tasks,
115
+ and that `graphql: :router` runs specs against your real subgraph resolvers.
116
+ The initializer it writes says so too.
117
+ - **Every `rake graph_weaver:federation:*` task was a silent no-op in a stock
118
+ Rails app.** Rails defaults `config.rake_eager_load` to false, and subgraph
119
+ detection only sees *loaded* schema classes — so `federation:subgraphs`
120
+ reported `nil` for every subgraph and `federation:diff` printed "checked 0 of
121
+ 4 subgraphs" and exited **0**, a CI gate permanently green while checking
122
+ nothing. The tasks now eager-load the app first, and detection resolves.
123
+ - **`federation:diff` fails when it compared against nothing** (exit 1, naming
124
+ what to do). Comparing against *some* subgraphs is still a pass — a
125
+ partly-local supergraph is a supported setup — but a comparison against none
126
+ of them proved nothing. If your subgraphs all run elsewhere, drop the task
127
+ from CI; it has nothing there to gate.
128
+ - **The absent-subgraph refusal names the cause.** It advised `subgraphs: { … }`
129
+ on a `Router.new` an rspec `graphql: :router` example never calls. It now
130
+ leads with the usual cause — the schema class isn't loaded yet, so
131
+ eager-load it — and names `GraphWeaver::Testing.config.router = { subgraphs:
132
+ … }` as the way to name one under the tag.
133
+ - **Subgraph-mapping refusals are `GraphWeaver::ConfigurationError` (was
134
+ `ArgumentError`)** — `rescue GraphWeaver::Error` around `Testing::Router.new`
135
+ now catches them, as `docs/errors.md` said it would. Rescue `ArgumentError`
136
+ for these and you must change it. The rule the docs now state: what the
137
+ library *concludes*, having read your schema, is a `GraphWeaver::Error`; an
138
+ argument wrong on its face (`pool_size: must be >= 1`) stays an
139
+ `ArgumentError`. An ambiguous-detection refusal also names each candidate
140
+ schema once, instead of repeating a reloaded constant.
141
+ - **The local test router refused every mutation that stitched below its root**,
142
+ with a message claiming its root fields "span subgraphs" when there was one
143
+ root field in one subgraph — and advice (split it per subgraph) that couldn't
144
+ be followed. It now plans any mutation whose root fields share a subgraph:
145
+ they go over as one document and that subgraph serializes them, and what
146
+ hangs below a root is an ordinary read afterwards. The refusal is left for
147
+ root fields that genuinely span subgraphs, where the message is true.
148
+ - **The local router honoured `@skip`/`@include` on a field but ignored it on a
149
+ fragment spread or inline fragment that crossed a subgraph boundary** — it
150
+ answered a selection the operation had excluded, and ran an extra subgraph
151
+ fetch to do it. Folding a same-type fragment into its parent dropped the
152
+ fragment node, and its directives with it. They now move onto the selections
153
+ they guarded, and a fetch is skipped entirely when everything it was for is
154
+ excluded (so `trace` matches what a real router does). A field and the
155
+ fragment around it both carrying the same directive refuses, as
156
+ `conditional_fragment` — one selection can't hold two conditions of one name.
157
+ - **`rake graph_weaver:federation:diff` reported false drift for every input
158
+ object**, so a correct supergraph failed the CI gate with advice to recompose
159
+ something that wasn't broken. An input object exposes its members as
160
+ `arguments`, not `fields`, and the check only asked for fields.
161
+ - **A `Pathname` works anywhere a schema path does** — `GraphWeaver.new`,
162
+ `generate!(schema:)`, `SchemaLoader.load`. `Rails.root.join("schema.graphql")`
163
+ previously failed as `undefined method 'lstrip' for an instance of Pathname`.
164
+ - **`Transport::HTTP`'s `pool_size:` defaults to `RAILS_MAX_THREADS`** (else 5,
165
+ as before) — the variable Rails already sizes its own connection pool from,
166
+ because it is the same question. A threaded app that raised its thread count
167
+ no longer silently queues behind five sockets.
168
+ - **A saturated pool says so.** The first request that has to wait for a
169
+ connection logs a warning naming the wait and the ceiling; later ones log at
170
+ debug. Queueing was previously indistinguishable from a slow server, which
171
+ mattered most under a fiber scheduler — `async`/Falcon multiplexes fine, but
172
+ nothing sets `RAILS_MAX_THREADS` there, so the default capped it at 5 with no
173
+ signal. See `docs/transports.md`.
174
+
175
+ #### One `execute`, one way to pass a client (**breaking**)
176
+
177
+ Every client answers the same call — `execute(query, variables:,
178
+ operation_name:)`, returning the raw response hash. Three objects a user holds
179
+ used to disagree with that, and each disagreement was a bug waiting.
180
+
181
+ - **`Client#execute` is that contract now**, so `GraphWeaver::Retry.new(client)`,
182
+ `Testing::Sequence.new(client, fake)` and a cassette recorder over a client
183
+ all work. Its one-shot parse-and-run sugar moved to **`Client#run` /
184
+ `#run!`** (and `GraphWeaver.run` / `.run!` for the throwaway-client form):
185
+ **rename `client.execute!(query, **vars)` to `client.run!(query, **vars)`.**
186
+ - **A generated module takes the per-call client as a kwarg**: rename
187
+ `PersonQuery.execute(some_client, id: "1")` to
188
+ `PersonQuery.execute(client: some_client, id: "1")`. Variables are
189
+ unaffected — `$client` was already refused at generation. It is also what
190
+ makes a mistyped variable name itself: `PersonQuery.execute(id: "1")` on a
191
+ query that declares no variables now raises `unknown keyword: :id` instead of
192
+ blaming the client. **Regenerate** (`rake graph_weaver:generate`);
193
+ `rake graph_weaver:verify` fails until you do.
194
+ - **`GraphWeaver.resolve_transport` is gone.** Nothing needs unwrapping any
195
+ more. A client that can't execute is still refused, by the module it was
196
+ passed to — whose name is now in the message.
197
+ - A module parsed from a `Client` bakes that client rather than its transport.
198
+ For a client built from a schema *dump* (no transport), `execute` now says
199
+ `this client has no transport (built from a schema dump)` instead of quietly
200
+ running on `GraphWeaver.client`.
201
+ - `$transport` is a usable GraphQL variable name again — the generated body has
202
+ no such local.
203
+
204
+ #### One reset, one plurality rule (**breaking**)
205
+
206
+ - **`GraphWeaver.reset_scalars!`, `clear_scalars!`, `reset_enums!` and
207
+ `reset_type_helpers!` are gone.** `GraphWeaver.reset_registrations!` is the
208
+ clean slate between tests; to reset one registry, call the same name on
209
+ `GraphWeaver::Codegen`.
210
+ - **Every directory setting is a list — `queries_paths`, `generated_paths`,
211
+ `fragments_paths`** — and *every entry is read*, by `generate!`,
212
+ `verify_generated!`, `check_queries` and `load_queries!` alike. (0.4.x
213
+ dropped a plural `queries_paths` because only `load_queries!` walked it;
214
+ that divergence is what the singular was protecting against, and it is gone.)
215
+ **Rename any `queries_path` / `generated_path` / `fragments_path` you set or
216
+ read** — assigning a String still works (`GraphWeaver.queries_paths =
217
+ "app/graphql/queries"`), and `generated_paths.first` is the one directory
218
+ `generate!` writes into. `schema_path` stays singular: a run reads one
219
+ schema, so a second entry would name a dump nothing opens.
220
+
221
+ - `rake graph_weaver:queries:check` prints an unparseable query's position once
222
+ rather than twice, and `GraphWeaver.check_queries` returns the documented
223
+ `"message"` / `"line"` / `"column"` shape for parse errors too — the position
224
+ is no longer folded into the message.
225
+ - Docs: a spec-local `generated_paths` entry needs an explicit
226
+ `GraphWeaver.load_generated!` (in Rails the Railtie has already run by then),
227
+ and belongs outside `spec/support/`, whose files rspec-rails requires itself
228
+ in sorted order. A registration naming one of your own constants goes in a
229
+ `to_prepare` block — the same rule the in-process client already follows —
230
+ rather than the `require Rails.root.join(...)` dance.
231
+ - **Removing an `extend_type` registration no longer bricks the app.**
232
+ Generated files carry `include GraphWeaver::TypeHelpers::Foo`, so dropping
233
+ the registration made boot fail — and because `rake graph_weaver:generate`
234
+ depends on `:environment`, the regeneration that would repair it failed the
235
+ same way. The graph_weaver tasks now skip loading generated modules (none of
236
+ them reads one), so `rake graph_weaver:generate` repairs the tree. Outside a
237
+ task, the dangling include now raises a `GraphWeaver::Error` naming the
238
+ registration that went missing and how to recover, instead of a bare
239
+ `NameError` pointing into generated code.
240
+ - **A cancelled request no longer leaks its socket.** `Transport::HTTP`
241
+ closed a connection of unknown state with a bare `rescue`, which catches
242
+ only `StandardError`. A fiber scheduler cancels with `Async::Stop`, which
243
+ descends from `Exception`, so cancelling an in-flight request walked past
244
+ the cleanup and left the socket open until GC. Affects any app under
245
+ `async`/Falcon with per-request timeouts. Nothing to do — the fix is
246
+ internal.
247
+ - `rake graph_weaver:queries:check` and `federation:diff` flush stdout before
248
+ aborting, so a piped CI log shows the details before the verdict rather
249
+ than after it.
250
+ - `generate!`, `verify_generated!` and `check_queries` accept a **path or SDL
251
+ string** for `schema:`, like every other schema slot in the library. A String
252
+ used to reach `schema.validate` as itself and die with `undefined method
253
+ 'validate' for an instance of String`.
254
+
255
+ - **`#parse` on anything that holds a schema**, not just `Client`:
256
+ `GraphWeaver::InProcess`, `Testing::FakeClient` and `Testing::Router` gain it
257
+ (and `#load_queries!`) from the new `GraphWeaver::Parsing` mixin. Replace
258
+ `GraphWeaver.parse(schema: router.schema, client: router, query: q)` with
259
+ `router.parse(q)`. `Retry` holds no schema and has no `#parse` — parse from
260
+ what it wraps. Nothing changes for `Client`, and the client contract is
261
+ untouched: a bare `GraphQL::Schema` class still fills the client slot.
262
+ - **`graphql: false` (or `graphql: :none`) opts an example out of
263
+ `config.default_mode`** — no client is installed, so the example can wire its
264
+ own. Previously a default swept up every untagged example with no way out,
265
+ and both spellings raised "is not a mode"; that message now names the opt-out
266
+ alongside the modes.
267
+ - Docs: `graphql_context` is setup, so `docs/testing.md` now shows it in a
268
+ `before` block for a group sharing one identity, with the inline form kept
269
+ for one-offs. The per-example reset runs ahead of group hooks, so a
270
+ group-level `before` re-applies from the same baseline every time.
271
+
272
+ - `examples/federation.rb` — a runnable federated-testing example, and the first
273
+ one that needs no network: three real subgraphs, a boundary-crossing query
274
+ through a generated module, `router.trace`, and a plan-time refusal. Guarded
275
+ by `spec/examples_spec.rb`, which runs it.
276
+ - Docs: the local router moved from `docs/testing.md` to `docs/federation.md`.
277
+ `testing.md` now covers which client an example runs against; `federation.md`
278
+ covers what a federated graph does. No behaviour change — update any bookmark
279
+ to `docs/testing.md#the-in-process-router--graphql-router`, now
280
+ `docs/federation.md#the-local-router`.
281
+ - **`graphql: :in_process` no longer hunts for the live schema class.** It runs
282
+ against `GraphWeaver::Testing.config.schema`, or the schema class your client
283
+ already runs in-process — one sentence, no heuristic. The third fallback
284
+ (`Testing::LiveSchema`, which searched every loaded `GraphQL::Schema` for one
285
+ defining everything the reference schema declares) is **deleted**. It only
286
+ ever applied to an app whose client points at a *different* API, and under
287
+ Zeitwerk it failed on the first `:in_process` example anyway, since an
288
+ autoloaded schema isn't loaded until something names it. Such an app now sets
289
+ `config.schema = MySchema`; when neither source is there, the error says so.
290
+ - **One positive predicate on `Response`, not two.** `#ok?` is gone; `#success?`
291
+ is the survivor, so the pair is `errors?` / `success?`. `success?` is what
292
+ `Process::Status` and `Faraday::Response` call it, and `ok?` reads as HTTP
293
+ 200 — which a GraphQL response carrying errors also is. Semantics unchanged:
294
+ partial data alongside top-level errors is **not** a success.
295
+ - **`Testing::Config#auto_fake` is gone** — it was the pre-tag spelling of
296
+ `config.default_mode = :fake`. Use that.
297
+ - **`rake graph_weaver:federation:diff` no longer loads the test harness.**
298
+ `Federation::Drift` needed one thing from it — the list of loaded schema
299
+ classes — and did `require "graph_weaver/testing"` from inside itself to get
300
+ it, pulling faker into a task that fabricates nothing. That question, and
301
+ "does this schema define this coordinate", now live in `GraphWeaver::Schemas`,
302
+ shared with `Testing::Subgraphs`. Measured over the fixture supergraph, the
303
+ task loads 15 files instead of 323 (253 of them faker's).
304
+ `Testing::Subgraphs.loaded` moved with it: call `GraphWeaver::Schemas.loaded`.
305
+ - Internal: `codegen/enum_type.rb` held `extend_type`, the type-helper and alias
306
+ registries and `GraphWeaver::TypeHelpers` — none of them enums, so a search
307
+ for `extend_type` landed in a file named for something else. Those moved to
308
+ `codegen/type_helpers.rb`; `enum_type.rb` now holds `EnumType` and the enum
309
+ registry, mirroring `scalar_type.rb`. No API change.
310
+ - **`register_enum` and `extend_type` say where to register** when handed a
311
+ constant's *name* instead of the constant. Passing a String is the natural
312
+ workaround for "`uninitialized constant PetKind` in my initializer", and the
313
+ answer is Rails' own: autoloading is set up after `config/initializers` run,
314
+ so register from a `Rails.application.config.to_prepare` block — which
315
+ `rake graph_weaver:generate` also runs before generating. Both registries
316
+ still take the constant itself; a name would be a second spelling that
317
+ couldn't reach `fallback:` or `map:` anyway, since those name enum *members*.
318
+
319
+ #### Scalar coercion is one switch (**breaking**)
320
+
321
+ `coerce:` takes `true`/`false` only — the Symbol form is gone. It was a third
322
+ way to ask one question (*may a variable of this scalar accept loose input?*)
323
+ and the only one that also made you answer *how*, which the scalar already
324
+ knows: `Int`/`Float` convert, anything with a `cast:`/`serialize:` pair parses,
325
+ and a pass-through scalar can't.
326
+
327
+ **What to do:**
328
+
329
+ - `coerce: :to_i` / `coerce: :to_f` — write `coerce: true`. Generated output is
330
+ unchanged.
331
+ - `coerce: :to_s` on `String`/`ID` — drop it and call `.to_s` at the call site.
332
+ Those have nothing to convert from, so `coerce: true` on one raises now
333
+ instead of emitting a no-op.
334
+ - any other Symbol on a custom scalar — give the scalar a `cast:`/`serialize:`
335
+ pair and `coerce: true`.
336
+
337
+ `GraphWeaver.auto_coerce` is unaffected, and `coerce: true` is now exactly what
338
+ it turns on for one scalar instead of all of them.
339
+
340
+ #### One shared module, not three (**breaking** — regenerate)
341
+
342
+ `GraphQLInputs`, `GraphQLUnions` and `GraphQLEnums` are now one `GraphQLTypes`.
343
+ They were three constants, three config knobs and three file shapes for one
344
+ idea — a type shared across query modules — and the rule now fits in a
345
+ sentence: **a type shared across query modules lives in the shared module and is
346
+ aliased in.**
347
+
348
+ **What to do:** run `rake graph_weaver:generate` (`verify` fails until you do,
349
+ naming the stale files). Every generated file changes: a query module opens with
350
+ one `require_relative "types"` instead of up to three, and its aliases read
351
+ `AdoptionInput = GraphQLTypes::AdoptionInput`. On disk, `enums.rb`, `inputs.rb`,
352
+ `inputs/` and `unions.rb` become `types.rb` (the manifest) plus one file per
353
+ type under `types/` — the old files are pruned for you, since pruning keys off
354
+ the generated header. If you referenced `GraphQLInputs::PetFilter` (or the other
355
+ two) by hand, spell it `GraphQLTypes::PetFilter`.
356
+
357
+ `GraphWeaver.inputs_module=` / `unions_module=` / `enums_module=` are now
358
+ `GraphWeaver.types_module=`, and `generate!`/`verify_generated!` take one
359
+ `types_module:` in place of three.
360
+
361
+ One namespace also removes the aliasing *between* the shared artifacts: an input
362
+ struct's props and a union member's selections spell their enums bare now, being
363
+ lexically inside the same module. The manifest requires the enum files first for
364
+ that reason.
365
+
366
+ New: a shared fragment whose name is already a schema type in that module is
367
+ refused at generation, naming both — a fragment is named by you, a type by the
368
+ schema, and one module is one namespace. Previously they lived apart and could
369
+ never meet.
370
+
371
+ #### Has anyone changed a subgraph without recomposing?
372
+
373
+ rake graph_weaver:federation:diff SUPERGRAPH=supergraph.graphql
374
+
375
+ A committed supergraph is a snapshot of a composition, and nothing checked that
376
+ it still described your subgraphs — so it could quietly promise a graph that no
377
+ longer exists. This reads the routing table against the subgraph schemas loaded
378
+ in this process, needs **no network**, and exits non-zero on drift, so it gates
379
+ a PR alongside `graph_weaver:verify`.
380
+
381
+ Both directions, because they mean opposite things: **stale** (the supergraph
382
+ carries `Product.weight`, nothing here defines it — recompose) and **not
383
+ composed in** (a schema here defines `Product.dimensions`, the supergraph
384
+ doesn't carry it — publish the subgraph). Comparison is deliberately looser
385
+ than field-set equality, which would be wrong both ways: a subgraph carries
386
+ federation plumbing no supergraph has, and `@external`/`@shareable` put a field
387
+ in more than one subgraph.
388
+
389
+ A supergraph is routinely only partly local, so the report names three states —
390
+ checked, not here, and answered with fabricated data — and the headline counts
391
+ them. Only drift fails; absence is a supported setup.
392
+ `GraphWeaver::Federation::Drift` is the same thing as data (`#to_h`, `#drift?`).
393
+
394
+ #### Validation errors name the subgraph behind the type
395
+
396
+ When the schema dump is a composed supergraph, `rake graph_weaver:queries:check`
397
+ brands each error with who resolves the type it points at:
398
+
399
+ app/graphql/queries/product.graphql
400
+ 4:5 Field 'dimensions' doesn't exist on type 'Product' (products, reviews)
401
+
402
+ `Product.dimensions` says what broke; `(products, reviews)` says whose code to
403
+ look at. A plain schema has no routing table and is unaffected.
404
+
405
+ - New: `SchemaLoader::RoutingTable#declared_fields`, `#declares?`, `#responsible`.
406
+
407
+ #### A partly-local supergraph now works
408
+
409
+ The testing router serves a supergraph composed from several services when only
410
+ some of them run in this process. A subgraph no loaded schema defines is
411
+ **absent** rather than an error at construction, so the router builds and every
412
+ query that doesn't reach those fields runs normally. A query that does reach
413
+ them is refused at plan time, before anything executes, naming the subgraph and
414
+ the field that reached for it.
415
+
416
+ subgraphs: { "reviews" => :fake } # answer an absent subgraph with fabricated data
417
+
418
+ Faking is opt-in and never silent: a faked fetch is marked `faked: true` in
419
+ `router.trace`, logged at `:warn` per fetch, and listed by `router.faked` and
420
+ `#inspect`. It is deliberately not surfaced as a response error — that would
421
+ make `execute!` raise, defeating the point.
422
+
423
+ - `Testing::Subgraphs.resolve` now returns only the subgraphs this process
424
+ serves instead of raising when one has no candidate. Two candidates still
425
+ refuse, naming both.
426
+
427
+ #### One tag picks what a test runs against
428
+
429
+ `auto_fake` and `config.router` each installed a client for **every** example
430
+ and refused to coexist, so a suite had to choose fakes or real resolvers once,
431
+ for everything — and running in-process against a live schema had no configured
432
+ mode at all. Now an rspec tag says it per example, or per group:
433
+
434
+ it "renders the empty state", graphql: :fake do … end
435
+ it "authorizes drafts", graphql: :in_process do … end
436
+ describe "checkout", graphql: :router do … end
437
+
438
+ `rspec --tag graphql:router` runs one mode's examples. The tag is namespaced
439
+ under one `graphql:` key on purpose: a bare `:fake` or `:router` would collide
440
+ with an app's own metadata and silently change which client an unrelated
441
+ example runs against.
442
+
443
+ **Nothing needs configuring.** Each mode derives what it runs against and
444
+ refuses — naming what it looked for — rather than guessing. The schema is
445
+ `config.schema` if you set one, else the committed dump, else the schema
446
+ `GraphWeaver.client` talks to. `:in_process` finds the live schema *class*: the
447
+ one your client already runs in-process, else the loaded class defining
448
+ everything that schema declares (the rule `subgraphs:` detection already uses).
449
+ `:router` plans against the dump when the dump is a composed supergraph — a
450
+ federated suite whose checked-in dump is the supergraph needs no config at all.
451
+
452
+ - **New:** `graphql_context(current_user: user)` sets the context your
453
+ resolvers see. It merges onto `config.context` and is reset before the next
454
+ example, so an example running as somebody else can't leak into the one
455
+ after. Pass a block to scope it. Under `graphql: :fake` it refuses — there
456
+ are no resolvers to receive it.
457
+ - **New:** `config.default_mode` is what an untagged example runs against
458
+ (`nil`, the default, leaves `GraphWeaver.client` alone). It replaces
459
+ `config.auto_fake`, which still works as the old spelling of
460
+ `default_mode = :fake`.
461
+ - **New:** `config.context` — the baseline every `:in_process` and `:router`
462
+ example starts from. `config.router = { context: … }` now refuses and points
463
+ here; the per-example reset would have overwritten it.
464
+ - `GraphWeaver.execute`, `.new` and `Client.new` now refuse a *client* where a
465
+ schema source belongs — an `InProcess`, `Retry`, transport or fake used to
466
+ crash with `undefined method 'lstrip'`. The message names both ways to say
467
+ what you meant.
468
+
469
+ #### Ruby-keyword field names now generate
470
+
471
+ A result key that underscores to a Ruby keyword — `pageInfo { next }`,
472
+ `filter { in }` — no longer refuses to generate. A prop is only ever read off
473
+ a receiver, so `const :next` is fine; the one bare read, an `alias:`
474
+ delegator's first hop, now spells `self.next`. Output props keep only the ban
475
+ the input side already had: names every `T::Struct` already answers to
476
+ (`class`, `hash`, `serialize`). If you aliased a query around this, you can
477
+ drop the alias and regenerate. `GraphWeaver::Codegen::RESERVED_PROPS` is gone
478
+ — `STRUCT_METHODS` is the whole rule now.
479
+
480
+ - `optional: true` on an `alias:` no longer hides a path segment the schema has
481
+ no field for. It still skips a field this query didn't select — that is what
482
+ it is for — but a typo, or the classic `findPets` where the path is the Ruby
483
+ prop chain, now raises and says which of the two it looks like. If an
484
+ optional alias resolved only through a query-level rename (`{ renamed: meta }`),
485
+ it will now raise on queries that don't select that key.
486
+ - **New:** `GraphWeaver.reset_enums!`, `GraphWeaver.reset_type_helpers!` and
487
+ `GraphWeaver.reset_registrations!` — the registry resets scalars already had.
488
+ `reset_registrations!` is the clean slate to reach for between tests.
489
+ - An alias error no longer names the same type twice when a query module and
490
+ its root type share a name.
491
+
492
+ #### Testing::Router now plans a real query, not just a single-subgraph one
493
+
494
+ `GraphWeaver::Testing::Router` used to hand one operation to one subgraph
495
+ verbatim and refuse anything that crossed a boundary. It now splits at the
496
+ crossing, refetches the entity from its `@key` through
497
+ `_entities(representations:)`, and stitches — batching every node at a level
498
+ into one call, running root query fields that span subgraphs as one fetch
499
+ each, and fetching a `@requires` field set from the subgraph that holds it
500
+ before the field that needs it. On the demo corpus that moves 10/17 queries
501
+ plannable to 17/17. It still refuses, at plan time, every shape it can't
502
+ answer the way a real router would.
503
+
504
+ `subgraphs:` is now **optional**: each subgraph's Ruby schema is derived from
505
+ what the loaded schemas define, and refuses rather than guesses when two
506
+ match or none do. An explicit map (or a partial one) still wins, and is now
507
+ checked the same way — a mis-wired entry fails at construction naming what it
508
+ doesn't define, instead of surfacing three fetches later.
509
+
510
+ - **New:** `config.router = { supergraph: "supergraph.graphql" }` in
511
+ `graph_weaver/rspec` runs every example against your real subgraph
512
+ resolvers.
513
+ - **New:** `rake graph_weaver:federation:subgraphs` prints the subgraph map
514
+ detection sees, with the evidence for each match.
515
+ - **New:** `Testing::Router#context` is settable, so one example can run as a
516
+ different user without rebuilding the router.
517
+ - `Testing::Unplannable`'s `:requires` category is **gone** — the gap it named
518
+ is closed. `:root_fields_span` now applies only to mutations (query roots
519
+ are planned). New categories: `:no_key`, `:abstract_boundary`,
520
+ `:nested_field_set`, `:shadowed_key`.
521
+ - The coverage report's second line now names every subgraph a query touches
522
+ (`accounts+reviews`), not just the one it ran in.
523
+ - `rake graph_weaver:schema:diff`, `schema:refresh` and `cassettes:anonymize`
524
+ now load the Rails environment first, so an initializer's settings apply.
525
+
526
+ **`GraphWeaver::Testing::Router` — a local federation router for tests.** Give
527
+ it a supergraph and your subgraph schema classes and it satisfies the client
528
+ slot, so `GraphWeaver.client = router` runs every generated module against real
529
+ resolvers in-process: no gateway, no node, no sockets. It plans one shape — a
530
+ query whose every field resolves in a single subgraph, passed to that subgraph
531
+ verbatim — and raises `Unplannable` (a `GraphWeaver::Error`) for anything that
532
+ crosses a boundary, at plan time, before any subgraph runs. See
533
+ [docs/testing.md](docs/testing.md#a-local-federation-router).
534
+
535
+ **`rake graph_weaver:federation:coverage SUPERGRAPH=…` says how much of your
536
+ query set that router can plan**, and groups every refusal by what stopped it —
537
+ the number that decides whether wiring it up is worth it. Planning needs the
538
+ supergraph alone, so it runs in CI with no subgraph loadable.
539
+
540
+ **A supergraph's routing table is now readable:
541
+ `GraphWeaver::SchemaLoader.routing_table(supergraph)`.** `load` strips the
542
+ `@join__*` machinery to get the API schema; this keeps it — `owners("Product",
543
+ "shippingEstimate") # => ["reviews"]`, each type's `@key` field sets, and which
544
+ copies are `@external`. A `@join__` directive it hasn't been taught lands in
545
+ `unsupported` rather than being skipped.
546
+
547
+ **`Representations.<entity>` for an entity the query didn't select now says
548
+ what to do.** Builders are query-driven, so `Representations.warehouse(...)`
549
+ raised a bare `NoMethodError` naming nothing. It now names the builders this
550
+ query does have and the selection to add (`... on Warehouse { __typename }`).
551
+
552
+ **Shared-fragment directories are scanned recursively, and `.gql` files count.**
553
+ The scan was `fragments/*.graphql`, so `fragments/person/fields.graphql` — how
554
+ anyone with sixty fragments organizes them — was skipped in silence, and a
555
+ `.gql` file was ignored even though `parse("x.gql")` reads one. A duplicate
556
+ fragment name now names both files that define it.
557
+
558
+ **Query directories are scanned the same way — recursively, `.gql` included.**
559
+ `queries/admin/pets.graphql` produced nothing at all: no file, no error.
560
+ `generate!`, `check_queries` and `client.load_queries!` now walk the tree, and
561
+ `.gql` no longer leaks its extension into the module name. Directories organize
562
+ queries but do not namespace them — `queries/admin/pets.graphql` is still
563
+ `PetsQuery` in `pets_query.rb` — so two files with the same base name are
564
+ refused at generation, naming both, rather than one silently overwriting the
565
+ other's generated file. The scaffolded `graphql.config.yml` matches
566
+ (`**/*.{graphql,gql}`).
567
+
568
+ **`execute` now takes one kwarg per declared variable, always — a single
569
+ required input-object variable is no longer flattened into per-field kwargs.**
570
+ `mutation($input: AdoptionInput!)` generated `execute!(name:, species:, …)`,
571
+ but adding any second variable generated `execute!(input:, …)` instead — so an
572
+ unrelated edit to a query silently reshaped every call site, and the rule
573
+ couldn't be stated without its exception. It also made a schema's own field
574
+ names load-bearing: a field named `client` or `in` can't be a kwarg and can't
575
+ be renamed, so flattening quietly declined and the surface moved again.
576
+ **Rewrite affected call sites to pass the input as one kwarg:**
577
+ `AdoptMutation.execute!(input: { name: "Rex", species: "DOG" })`, or
578
+ `input: AdoptMutation::AdoptionInput.new(name: "Rex", species: Species::Dog)`
579
+ for the field-by-field static check.
580
+
581
+ **An input field named after a Ruby keyword no longer makes a schema
582
+ ungeneratable.** `StringQueryOperatorInput.in` — the standard Hasura/Gatsby
583
+ filter shape — raised "would become prop 'in', which collides with a Ruby
584
+ keyword", with no way out: an input field is the schema's name, not yours, and
585
+ `extend_type alias:` is output-only. But `prop :in` is legal Ruby, and nothing
586
+ reads an input prop bare (`serialize` goes through `public_send`), so the
587
+ refusal was over-broad. Input fields named `in`, `end`, `def`, `nil` and the
588
+ rest now generate. A field colliding with a method every struct defines
589
+ (`serialize`, `to_h`, `class`, `hash`) is still refused — those break at
590
+ require time. Output structs are unchanged: a result key *can* be renamed, in
591
+ the query.
592
+
593
+ **A variable named `$client` no longer generates a file that won't parse.**
594
+ `query($client: ID!)` emitted `def self.execute(client = nil, client:)` — a
595
+ `SyntaxError` raised at app boot from `load_generated!`, arbitrarily far from
596
+ the query that caused it, while `verify_generated!` reported the tree as
597
+ current. Generation now refuses `$client`, `$variables` and `$transport` — the
598
+ three locals the generated `execute` body owns — naming the fix. **Rename such
599
+ a variable in the query (`query($clientId: ID!)`) before regenerating.**
600
+
601
+ **`auto_coerce` no longer erases the typing of String/ID variables.** It mapped
602
+ both to `#to_s`, which widened their kwargs to `T.anything` — the majority of
603
+ real variables, statically unchecked, in exchange for a cast that can't fail.
604
+ `auto_coerce` now covers only the conversions that are conversions (`Int`→`to_i`,
605
+ `Float`→`to_f`) plus scalars with a full cast/serialize pair. **If you relied on
606
+ a String/ID kwarg accepting anything, opt in per scalar:**
607
+ `GraphWeaver.register_scalar("ID", String, coerce: :to_s)`.
608
+
609
+ **An anonymous operation is now named after its module — in the query text and
610
+ in `OPERATION_NAME`.** Requests started carrying `operationName` so servers and
611
+ APMs can attribute traffic, but the constant was only set when the `.graphql`
612
+ document named its operation — and anonymous is what the docs show, so every
613
+ trace arrived `anonymous` and the feature did nothing for the documented happy
614
+ path. `person.graphql` holding `query($id: ID!) { ... }` now emits
615
+ `query PersonQuery($id: ID!) { ... }` with `OPERATION_NAME = "PersonQuery"`.
616
+ Both halves move together: a server rejects an `operationName` its document
617
+ doesn't declare. A document that names its own operation is left untouched.
618
+
619
+ **Cassette files no longer store the request twice — re-record them.** Every
620
+ entry carried a `key:` (the normalized query + variables) *and* a `query:` and
621
+ `variables:` again, and replay matched on `key:` alone: editing the half a
622
+ reviewer reads changed nothing, editing the other half broke replay while the
623
+ file still looked right. The key is now derived from `query`/`variables`/
624
+ `operationName` at load, so the file holds the request once and diffs are real.
625
+ **Existing cassettes must be re-recorded** (`GRAPHWEAVER_RECORD=1`, or delete
626
+ the file) — this also covers cassettes of anonymous operations, which stopped
627
+ matching when entries started keying on `operationName`.
628
+
629
+ **`MissingRecording` now prints the variables — the part that usually differs.**
630
+ It printed the whole query and omitted the variables entirely, so the common
631
+ miss (same query, different variables) showed you 60 lines identical to the
632
+ YAML and nothing about the mismatch. The message now leads with the request's
633
+ variables, says what was recorded for that query (`1 entry recorded for this
634
+ query, with variables {"id" => "1"}`), and prints the query as one truncated
635
+ line.
636
+
637
+ **A first run with no cassette and no `client:` no longer raises
638
+ `MissingRecording`.** There is no request yet, so it raises `GraphWeaver::Error`
639
+ naming the actual situation. **Rescue `GraphWeaver::Error` if you were catching
640
+ `MissingRecording` for this case.**
641
+
642
+ **`Cassette.use` is now `GraphWeaver::Testing.cassette` — rename your calls.**
643
+ It never returned a `Cassette`; it returns a *client* (a recorder or a replayer)
644
+ to hand to `execute`, and the name said otherwise. `Cassette` is now only the
645
+ file — `.new`, `#size`, `#anonymize!`.
646
+
647
+ **Record mode with no `client:` now raises instead of replaying.**
648
+ `GRAPHWEAVER_RECORD=1` on a `Testing.cassette(name)` call with nothing to record
649
+ against quietly served the stale recording, so "re-record everything" produced a
650
+ half-refreshed cassette set with no signal. **Pass `client:` to every call you
651
+ want re-recorded.**
652
+
653
+ **`Recorder.new(..., anonymize:)` is gone.** It was unreachable through the
654
+ factory and duplicated `Testing.config.anonymize`. **Set the config flag** —
655
+ that's the one way to anonymize, with `rake graph_weaver:cassettes:anonymize`
656
+ as the cleanup tool for cassettes recorded before you turned it on.
657
+
658
+ **`FakeClient.new` no longer requires `schema:`.** Every other option fell back
659
+ to `Testing.config`; this one didn't, even though `config.schema` already
660
+ auto-locates the committed dump. `FakeClient.new` now works on its own, and
661
+ says what to set when no schema resolves at all.
662
+
663
+ **`GraphWeaver.queries_paths` (plural) is gone — use `queries_path`.**
664
+ `generate!` and `check_queries` read the singular (the first entry) while
665
+ `load_queries!` walked the whole list, so a second queries directory produced
666
+ modules at runtime that `rake graph_weaver:generate` never generated and
667
+ `verify` never checked — silently. Queries are single-schema by design. **If
668
+ you appended a second queries directory, fold it into the first** (or run a
669
+ second `generate!` with its own `queries:`). `generated_paths` and
670
+ `fragments_paths` stay plural; they genuinely load from several places.
671
+
672
+ **One GraphQL enum is now one Ruby type.** A schema enum a query touches — as
673
+ a variable, in a result, or both — is emitted once per schema into
674
+ `generated/enums.rb` as `GraphQLEnums::<Enum>`, and every query module aliases
675
+ it. Before, an enum read out of a result got a class named for the response key
676
+ and nested in the struct that selected it (`SearchQuery::Result::Search::Pet::Species`),
677
+ while the same enum used as a variable got a module-level one — so whether a
678
+ schema enum was one Ruby type or three depended on what else the query happened
679
+ to reference, and handing a value from one query into another's variable raised
680
+ a `TypeError` that wasn't even a `GraphWeaver::Error`.
681
+
682
+ **Regenerate, and expect enum constants to move.** A nested enum path in app
683
+ code becomes the query module's own alias — `SearchQuery::Species` — or
684
+ `GraphQLEnums::Species`; `srb tc` finds them all. The enums a shared fragment's
685
+ union members select are hoisted too, so `unions.rb` now aliases them rather
686
+ than re-emitting them.
687
+
688
+ **The shared module names no longer depend on your output directory.** They are
689
+ `GraphQLInputs`, `GraphQLUnions` and `GraphQLEnums`, full stop. The old rule
690
+ camelized the parent of `generated/` unless it was on a hardcoded blocklist, so
691
+ `output: "gen2"` gave you `Gen2Inputs` and renaming `app/graphql/generated` to
692
+ `app/gql/generated` renamed a public constant. **A multi-schema layout must now
693
+ name its modules explicitly** — `GraphWeaver.inputs_module=` /
694
+ `unions_module=` / `enums_module=`, or `generate!(inputs_module:, ...)` — in the
695
+ same initializer that already gives each schema its paths. `GraphWeaver.inputs_module`
696
+ and `unions_module` no longer take an output-path argument.
697
+
698
+ **One registration registry, not two.** `Client#register_scalar`,
699
+ `#register_enum`, `#register_enums` and `#extend_type` are **deleted** — a
700
+ client-scoped registration was invisible to `GraphWeaver.generate!` (the rake
701
+ tasks have no client), so the console typed a field richly and the checked-in
702
+ code silently generated `T.untyped`. **Move any `client.register_*` /
703
+ `client.extend_type` call to the `GraphWeaver.` form** (an initializer, next to
704
+ the rest of your config). The one thing client scoping bought — two servers
705
+ disagreeing about a scalar — is what the per-field coordinate form is for:
706
+ `GraphWeaver.register_scalar("User.birthday", Date)`.
707
+
708
+ Also gone with it: `GraphWeaver.register_enums` (bulk) — there was never a
709
+ `register_scalars` to match it, so call `register_enum` per line — and
710
+ `GraphWeaver.reject_positional_map!`, now folded into the one
711
+ `Codegen.register_enum` that every door reaches (so all three doors give the
712
+ same "the value map is a keyword" error instead of a bare arity complaint).
713
+ `Codegen.parse` / `.generate` / `.generate_inputs` / `.generate_unions` no
714
+ longer take `scalars:`/`enums:`/`types:`.
715
+
716
+ **`generate!` now takes a Client where it takes a schema** — `GraphWeaver.generate!(schema: api)`,
717
+ `verify_generated!`, `check_queries` and `parse` all accept one, so the object
718
+ you built in the console is the object the build step wants and no schema dump
719
+ is needed. `client:` still means what it meant (a constant name to bake as
720
+ `DEFAULT_CLIENT`) and still refuses a live object.
721
+ **Rails integration fixes, found by running the gem in a real Rails app.**
722
+
723
+ - **Production boot no longer raises `uninitialized constant
724
+ Generated::PersonQuery`.** The default `generated_path` is
725
+ `app/graphql/generated`, which Zeitwerk claims as an autoload root, while
726
+ the files there define top-level constants. Development (lazy) was fine and
727
+ eager loading was not, so this only showed up in production or
728
+ `rails zeitwerk:check`. The Railtie now hides the generated directory from
729
+ the loader; nothing to configure.
730
+ - **`rake graph_weaver:generate` runs your initializer again.** The tasks
731
+ asked whether Rails' `:environment` task existed at *load* time, but Rails
732
+ defines it after every Railtie's `rake_tasks` block, so the answer was
733
+ always no. Generation and `verify` therefore ran without booting the app —
734
+ silently dropping every `register_scalar` / `register_enum` / `extend_type`
735
+ in `config/initializers`, and generating code that disagreed with the
736
+ running app. **Regenerate**: if you register anything in an initializer,
737
+ your committed generated files are wrong, and `rake graph_weaver:verify`
738
+ will now say so.
739
+ - `generate`, `verify` and `schema:diff` report a `GraphWeaver::Error` the
740
+ way `schema:refresh` already did — the message, and a non-zero exit,
741
+ instead of a rake backtrace through codegen.
742
+
743
+ **`rails g graph_weaver:install` takes any source `GraphWeaver.new` takes.**
744
+ The source is one positional argument — an endpoint, a schema class or an
745
+ existing dump all work the same way:
746
+
747
+ ```sh
748
+ rails g graph_weaver:install https://api.example.com/graphql
749
+ rails g graph_weaver:install MyApp::Schema # in-process, no socket
750
+ rails g graph_weaver:install db/schema.graphql # a dump you already have
751
+ ```
752
+
753
+ The initializer reflects the form chosen: a schema class is resolved in a
754
+ `to_prepare` block (it is autoloaded, so an initializer can not read it, and a
755
+ dev reload replaces the class object), and a dump you already have becomes
756
+ `GraphWeaver.schema_path` rather than being copied. `--auth` and the
757
+ introspection step are url-only; a source that can not use them, a constant
758
+ that does not resolve, and a class that is not a schema are all refused
759
+ before any file is written.
760
+
761
+ **Generated struct names now come from the query's own field names.** A struct
762
+ is named for the response key that selects it — `stargazers` becomes
763
+ `Stargazers`, `edges` becomes `Edges` — so its name is a function of its own
764
+ position in the query and nothing else. Names came from GraphQL *type* names
765
+ before, disambiguated by field name only on collision, which meant **a second
766
+ selection of the same type renamed the first**: a silent break in checked-in
767
+ code your app references. Deep queries could also collide outright and refuse
768
+ to generate.
769
+
770
+ **Regenerate, and expect renames.** Nearly every nested struct changes name
771
+ (`PersonQuery::Result::Person::Pet` becomes `...::Person::Pets`), and app code
772
+ naming one won't typecheck until it's updated — `srb tc` finds them all. The
773
+ payoff: adding, removing, or reordering an unrelated selection can never move
774
+ a name again.
775
+
776
+ - The key is used verbatim, with no pluralization heuristic — a list field
777
+ `pets` generates `Pets`. To pick a different name, alias the field in the
778
+ query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
779
+ - Union and interface members keep their type-condition names (`... on Book`
780
+ gives `Book`), inside a container named for the field; a union hoisted from
781
+ a shared fragment is still named for the fragment.
782
+ - Two ties that walk order used to settle now resolve on their own: fields
783
+ sharing one collapsed union type take the first of their keys
784
+ alphabetically, and a name that would shadow the struct it nests in
785
+ (`pet { pet { ... } }`) takes a numeric suffix (`Pet2`).
786
+
787
+ **Requests now send `operationName`** — every graph_weaver request used to be
788
+ anonymous in Apollo Studio, Hasura, and any APM that keys traces, rate limits
789
+ and slow-query reports on it. Generated modules emit their operation name as
790
+ `OPERATION_NAME` beside `QUERY` and send it on the wire; a raw query string
791
+ handed to a transport falls back to the name in the document. In-process
792
+ execution passes it to `Schema.execute(operation_name:)`, which also makes a
793
+ multi-operation document selectable there.
794
+
795
+ To get the benefit, **name your operations** — `query Person($id: ID!)`, not
796
+ `query($id: ID!)` — and regenerate. An anonymous operation still works and
797
+ sends no `operationName`.
798
+
799
+ Three breaking changes come with it:
800
+ - **The client-slot contract widened to
801
+ `execute(query, variables:, operation_name: nil)`.** If you wrote your own
802
+ transport, client, or test double, add the kwarg — a client that doesn't
803
+ accept it now raises `ArgumentError: unknown keyword: :operation_name`. A
804
+ graphql-ruby `Schema` class already takes it, so bare schemas in the client
805
+ slot are unaffected. Subclasses of `GraphWeaver::Transport` only implement
806
+ `post(body)` and need no change.
807
+ - **Cassettes are keyed on `operationName` too**, so two operations in one
808
+ document can't collide. Cassettes recorded from a *named* operation before
809
+ this release no longer match — re-record them
810
+ (`GRAPHWEAVER_RECORD=1 bundle exec rspec`). Anonymous ones are unaffected.
811
+ - **`GraphWeaver::Transport.log_tag` takes an operation name, not a query
812
+ string** (`log_tag(query)` → `log_tag(operation_name)`); the constant
813
+ `Transport::OPERATION_NAME` is now `Transport::OPERATION_NAME_PATTERN`, since
814
+ generated modules define an `OPERATION_NAME` of their own.
815
+
816
+ Codegen bug fixes from the library review (all with regression coverage):
817
+ - Narrowing (`... on X` and nothing else) now reads the match off `__typename`
818
+ when the selection carries it, instead of off "the object came back empty".
819
+ Selecting `__typename` guaranteed a non-empty object, so **every non-matching
820
+ member was cast into `X`'s struct** — loudly when it had a non-null field,
821
+ silently when all its fields were nullable. Regenerate: any query mixing
822
+ `__typename` with a single type condition (the `_entities { __typename
823
+ ... on Widget { … } }` federation shape) was mistyped and now filters
824
+ correctly.
825
+ - A dispatched union/interface now requires its `__typename` to be unaliased and
826
+ free of `@skip`/`@include` — `from_h` reads it unguarded, so either would have
827
+ raised at runtime. Fix the selection if generation now refuses it.
828
+ - **Unions and interfaces generate per named condition, plus one catch-all
829
+ `Other`** — not one struct per schema member. A two-condition query against
830
+ GitHub's `Node` (278 implementations) went from 5,386 lines / 279 structs to
831
+ 162 lines / 4. **Regenerate, and expect member names to move**: a type your
832
+ query names no fields on is now `Other` rather than its own struct, so a
833
+ `case` over the members needs an `Other` branch (`T.absurd` will tell you).
834
+ In exchange, a `__typename` the query doesn't name — including a **member the
835
+ schema grows after you generate** — deserializes into `Other` instead of
836
+ raising `unexpected __typename`, so adding a union member upstream stays the
837
+ non-breaking change GraphQL says it is.
838
+ - `@skip`/`@include` on an inline fragment or a named spread now makes the
839
+ fields under it nilable, as it always did for a directly-marked field —
840
+ previously they kept non-null typing and a `data.fetch`, so a skipped block
841
+ raised `key not found`. The narrowing guard sees the fragment's own directive
842
+ too. Conversely, a field selected both conditionally and unconditionally is no
843
+ longer over-nilable: one unguaranteed selection doesn't unmake the guarantee.
844
+ - List variables coerce per element, so an enum inside a list accepts its wire
845
+ value the way a scalar enum already did (`sort: ["POPULARITY_DESC"]` used to
846
+ raise `NoMethodError: undefined method 'serialize' for String`). Input-object
847
+ and custom-scalar elements coerce in lists too.
848
+
849
+ - Federation schemas that previously wouldn't load now do:
850
+ - a supergraph whose `schema` definition carries a non-`@link` directive
851
+ (`@tag`, `@composeDirective`, a composed custom one) no longer dies with a
852
+ `GraphQL::ParseError` pointing into a document you never wrote.
853
+ - **raw subgraph SDL loads** — what `rover subgraph fetch`, `_service { sdl }`,
854
+ or your service repo's `.graphql` gives you. The federation directives a
855
+ subgraph applies but doesn't declare (`@key`, `@external`, `@shareable`, …)
856
+ are supplied on load, for both fed-1 and `@link`-style subgraphs. Note the
857
+ `@inaccessible` subtraction stays supergraph-only: a subgraph keeps those
858
+ fields, because it is not the public contract.
859
+ - A schema that won't build now raises `GraphWeaver::Error` naming the artifact
860
+ we took the source for (supergraph / subgraph / plain SDL / introspection),
861
+ instead of whatever graphql-ruby's internals happened to raise — a
862
+ `NoMethodError`, a `ParseError` pointing into a document you never wrote, a
863
+ bare `RuntimeError`. **Rescuing the raw graphql-ruby classes no longer
864
+ catches these.** The `@inaccessible` cascade also prunes a directive
865
+ definition's own arguments.
866
+ - **Single-line SDL loads.** `SchemaLoader.load("type Query { hi: String }")` —
867
+ the shape you type in a console — was rejected as "unsupported schema format",
868
+ because a string had to contain a newline to count as content rather than a
869
+ path.
870
+ - Rejecting a schema source is branded too, so the error class no longer depends
871
+ on which branch rejected it: an unsupported format and an unreadable file both
872
+ raise `GraphWeaver::Error` (were `ArgumentError` and `Errno::ENOENT`). A bare
873
+ host now says so — `"graphql.anilist.co" looks like a host; did you mean
874
+ "https://graphql.anilist.co"?` — instead of pointing at the file system.
875
+ - Cassette recording accepts a `GraphWeaver::Client` — the call
876
+ `docs/cassettes.md` shows (`Testing.cassette("github", client: live)`),
877
+ which failed with `ArgumentError: missing keywords`. And a client that can't
878
+ `execute` is now rejected on the spot, with its class named, rather than
879
+ surfacing later as `NoMethodError … for an instance of Hash`.
880
+ - Generated structs answer `respond_to?` the way `method_missing` behaves, so
881
+ `struct.method(:nmae)` gets the same "did you mean" hint the direct call does.
882
+ - `@oneOf` input objects enforce exactly one field. The schema can't express it
883
+ — every `@oneOf` field is nullable — so the struct accepted zero or many and
884
+ the server rejected the round trip; supplying the wrong number now raises
885
+ `GraphWeaver::InputError` naming the type and the keys. **Regenerate** to pick
886
+ it up.
887
+ - An enum whose values differ only in case (`enum E { active ACTIVE }`) is
888
+ refused at generation naming both wire values, instead of emitting two
889
+ `Active` constants and raising `RuntimeError: Enum values must be assigned to
890
+ constants` when the file loads. **Map such an enum onto one of yours**
891
+ (`register_enum`). `AB`/`A_B` and `IN_PROGRESS`/`INPROGRESS` still generate
892
+ fine — they name distinct constants.
893
+ - A `.graphql` file that won't parse raises `GraphWeaver::ValidationError`
894
+ **naming the file**, instead of a bare `GraphQL::ParseError` whose `[6, 1]`
895
+ pointed into a document you never wrote — fragment inlining parses on the
896
+ `generate!` path before `Codegen#generate`'s rescue could brand it. Fragment
897
+ files get the same treatment.
898
+ - Generated `from_response` shape-checks the envelope, so a malformed one stays
899
+ under `GraphWeaver::Error`. A non-object `data`, a `Hash` (or an array of
900
+ strings) for `errors`, and non-object `extensions` all escaped as a raw Sorbet
901
+ `TypeError` — the `data` one from `from_h`'s sig, before the struct's own
902
+ rescue could see it. A body that isn't an object at all deserialized to an
903
+ empty envelope (`String#[]` answers `"data"` with nil); it now raises.
904
+ - The generated `from_h` rescues `StandardError`, not just
905
+ `TypeError`/`ArgumentError`/`KeyError` — a registered scalar whose cast raises
906
+ anything else (`JSON::ParserError`, `URI::InvalidURIError`, your
907
+ `Money::ParseError`) escaped the umbrella. **Regenerate** to pick both up.
908
+ - A document holding more than one operation is refused at generation. Only the
909
+ first was ever typed, and the whole document went on the wire with no
910
+ `operationName`, so the request came back "Must provide operation name" —
911
+ **split multi-operation files into one operation each.**
912
+ - Result keys are checked before they become props, so generation refuses what
913
+ used to be an unloadable file. Two keys that underscore to the same prop
914
+ (`{ name Name: name }` — a plain alias, no exotic schema needed) raised
915
+ `ArgumentError: Attempted to redefine prop :name` at require time; so did a
916
+ field named `class`, `hash`, `send` or `frozen?`, which `T::Props` won't let a
917
+ struct redefine. **Alias the field in the query** (`classValue: class`) — the
918
+ error names the key and the spelling. The same reserved set now covers input
919
+ fields, which only checked Ruby keywords and `serialize`/`to_h` before.
920
+ - **Global registrations are validated against the schema**, like client-scoped
921
+ ones always were: `GraphWeaver.extend_type("Medai", …)` (or `register_scalar` /
922
+ `register_enum`) used to be a silent no-op, which is the failure mode
923
+ `docs/getting_started.md` step 3 walks you straight into — it now raises at
924
+ generation with the spellchecked hint. Registrations are global (see above),
925
+ so **drop any that names a type the schema you generate against doesn't
926
+ have**. The built-in scalars are exempt — a schema with no `Date` isn't a
927
+ mistake.
928
+ - `extend_type(requires:)` and `register_enum(requires:)` check each path is
929
+ loadable at registration, as `register_scalar(requires:)` already did and
930
+ `docs/scalars.md` already promised — a typo fails now, not in the generated
931
+ file.
932
+ - Docs: `docs/testing.md` passed the client to generated `execute` as a `client:`
933
+ kwarg — it's positional. `README.md` had module naming backwards for the
934
+ documented path (a file's module comes from the **file** name, not the
935
+ operation name). `docs/federation.md` covers subgraph SDL, federation v1
936
+ supergraphs, and that `@inaccessible` is subtracted only on the supergraph
937
+ path. `docs/cassettes.md` names `MissingRecording` correctly.
938
+ - **Federation namespaces are derived from the schema's own `@link`/`@core`
939
+ declarations** instead of a hardcoded `join__`/`link__`/`core__` list — the
940
+ spec URL's name segment gives the namespace, `as:` renames it, and `import:`
941
+ binds names into the root namespace (`{name: "@key", as: "@myKey"}` included).
942
+ Four things this fixes:
943
+ - a graph using fed-2.5+ auth (`@requiresScopes`/`@policy`/`@context`) no
944
+ longer leaks `federation__Scope`, `federation__Policy` or
945
+ `context__ContextFieldValue` into `schema.types`;
946
+ - a supergraph that renamed a spec (`@link(url: ".../join/v0.3", as: "j")`)
947
+ strips its `j__*` machinery — it previously failed to load at all;
948
+ - **a renamed `@inaccessible`** (`import: [{name: "@inaccessible", as:
949
+ "@private"}]`, or `as:` on the inaccessible spec) hides what it marks. It
950
+ was missed entirely before, so the derived API schema kept fields the
951
+ router does not serve and codegen over-permitted them. **Regenerate** if
952
+ your supergraph renames it.
953
+ - a `@core`-only fed-1 schema, and any composed graph carrying no `@join__`
954
+ marker, is now recognized as composed rather than loaded as plain SDL
955
+ (`core__Purpose` used to survive, and `@inaccessible` went unsubtracted).
956
+
957
+ - **Subgraph SDL loads with the entity resolver it serves.** No published
958
+ subgraph SDL contains `_entities`/`_service` — `rover subgraph fetch` and
959
+ `_service { sdl }` both print the schema, where the plumbing is implicit — so
960
+ the one query only a subgraph can describe couldn't be typed against the
961
+ artifact you have. Weaver now supplies `_Any`, `_Service` and an `_Entity`
962
+ union over the file's own `@key`'d types, alongside the `@key`/`@external`
963
+ definitions it already supplied. Supergraphs and plain SDL are untouched;
964
+ a file declaring its own `_entities` keeps it.
965
+ - **Typed `_entities` representations.** A query selecting entities now
966
+ generates a `Representations` builder per entity it can resolve, typed from
967
+ the `@key(fields:)` directives the subgraph SDL carries:
968
+ `UserQuery::Representations.user(id: "1")` → `{"__typename" => "User", "id"
969
+ => "1"}`. `__typename` is injected, key fields are typed from the schema, and
970
+ a single `@key` makes them **required kwargs** — so an incomplete
971
+ representation is an `srb tc` error, not a round trip. Compound (`"upc sku"`)
972
+ and nested (`"organization { id }"`) key sets are parsed as the selection
973
+ sets they are; a type with two alternative keys takes them optionally and
974
+ raises `GraphWeaver::InputError` naming the type and what's missing when
975
+ neither is satisfied. Builders are emitted only for entities the query
976
+ actually reaches, and a key marked `resolvable: false` gets none.
977
+ **`Representations` joins `Result`/`QUERY` as a reserved module-level name**
978
+ — a shared fragment hoisting to it is now refused.
979
+
980
+ Transport improvements from the same review:
981
+ - **`Transport::HTTP` pools its connections** (`pool_size:`, default 5) instead
982
+ of serializing every request behind one socket and one mutex. The mutex was
983
+ held across the whole network round trip, so one transport — which is what
984
+ `GraphWeaver.client = api` gives a Rails app — allowed exactly one request in
985
+ flight process-wide. Against a 10 ms-latency server, 8 threads × 10 calls:
986
+ 1059 ms before, 281 ms with the default pool of 5 (~3.8×). Sockets still open
987
+ lazily, stay keep-alive, and are dropped on any error so the next call
988
+ reconnects. **Lower `pool_size:` if your server counts connections per
989
+ client**; raise it to match a threaded web server's thread count.
990
+ - Both transports now send `Accept: application/graphql-response+json,
991
+ application/json;q=0.9` — the media type GraphQL-over-HTTP requires a
992
+ conforming client to accept, so a spec-conformant server can finally use the
993
+ newer status-code semantics — and `User-Agent: graph_weaver/<version>`, so
994
+ server operators can attribute the traffic. Previously the only header sent
995
+ was `Content-Type`, and net/http supplied `Accept: */*`. `headers:` still
996
+ overrides both; a prebuilt `Faraday::Connection` keeps whatever it carries.
997
+ - **`Transport::Faraday` takes `open_timeout:`/`read_timeout:` and defaults them
998
+ to 10s/30s**, the same as `Transport::HTTP`. It had no timeout knobs at all,
999
+ so it inherited net/http's 60s/60s — 6× and 2× the documented defaults. Both
1000
+ timeouts now also thread through the client: `GraphWeaver.new(url,
1001
+ read_timeout: 5)` works whichever transport is picked. Passing a timeout
1002
+ alongside a prebuilt `Faraday::Connection` raises, as `headers:` already did.
1003
+ The Faraday transport also logs its adapter at `:info` — the default
1004
+ `net_http` one opens a connection per request, which was invisible.
1005
+ - **New `GraphWeaver::InProcess`**, wrapping a live graphql-ruby schema class —
1006
+ `GraphWeaver.new(MySchema, context: { current_user: user })`. In-process
1007
+ execution worked but was blind in three ways: nothing supplied a `context:`,
1008
+ so a resolver reading `context[:current_user]` got nil (surfacing as "Cannot
1009
+ return null for non-nullable field Query.me"); all logging lived in
1010
+ `Transport#execute`, which an in-process schema bypasses, so not one line at
1011
+ DEBUG; and a resolver raise came out as a bare `RuntimeError` where the same
1012
+ failure over HTTP is a `ServerError`, so `rescue GraphWeaver::Error` caught
1013
+ one and missed the other. A resolver raise is now a `ServerError` (status
1014
+ 500) with the original kept as `#cause` — in-process, the real backtrace is
1015
+ the point. **A bare schema class still works in any client slot**; the
1016
+ wrapper is an upgrade, not a requirement.
1017
+ - **`ServerError` carries the response `#headers`** (names downcased), plus
1018
+ `#retry_after` (seconds or HTTP-date, per RFC 9110) and `#rate_limited?`. The
1019
+ `Net::HTTPResponse` was always in hand and thrown away, so recovering
1020
+ `x-ratelimit-remaining` or a request id meant monkey-patching the transport.
1021
+ A `post` override may now return a third element, the headers; returning the
1022
+ documented `[status, body]` pair stays correct.
1023
+ - **`Retry` honours `Retry-After`** — the server's delay wins over the
1024
+ configured backoff, clamped to `max:` and not jittered. Related: **408 and
1025
+ 429 now retry by default.** They were treated as ordinary 4xx ("your bug,
1026
+ retrying won't fix it"), which for the one status that exists to say "come
1027
+ back later" was exactly backwards, and left `Retry` incorrect against GitHub
1028
+ and Shopify. Pass `retry_if:` to restore the old behaviour.
1029
+ - **A throttling predicate, spelled the same everywhere**: `ServerError#throttled?`
1030
+ (429, or a 503 that says when to come back) and `QueryError#throttled?` /
1031
+ `Response#throttled?` (a throttle code in the errors array). An API says "slow
1032
+ down" with an HTTP status or with a code in a 200 body, and callers shouldn't
1033
+ have to know which. The codes are `GraphWeaver::GraphQLError::THROTTLE_CODES`
1034
+ — Shopify's `THROTTLED`, GitHub's `RATE_LIMITED`, and the common Apollo/Hasura
1035
+ spellings — so `retry_codes:` takes the constant instead of a hand-written
1036
+ string. `QueryError#to_h` gains `"throttled"` alongside `"schema_stale"`.
1037
+ - `Transport::HTTP` takes `ca_file:`/`ca_path:`/`cert:`/`key:`/`verify_mode:`,
1038
+ forwarded to `Net::HTTP.start` — a private CA or mTLS no longer means
1039
+ switching to Faraday, which was the real but undiscoverable answer. Passing
1040
+ one to an `http://` url raises instead of quietly doing nothing.
1041
+ - **An instrumentation seam**: `GraphWeaver.instrumenter = ->(event, payload,
1042
+ &block) { ... }`, a no-op until set, wrapping every request — over the wire
1043
+ and in-process, one seam for both. `ActiveSupport::Notifications` becomes a
1044
+ two-line adapter. The one event is `GraphWeaver::EXECUTE_EVENT`; its payload
1045
+ carries `:url`, `:schema`, `:operation` and `:status`, and deliberately not
1046
+ the query or variables (those are PII, and belong at debug on the logger
1047
+ where the level gates them). See `docs/logging.md`.
1048
+ Developer-experience fixes (all with regression coverage):
1049
+ - **FakeClient override keys are validated against the schema.** A typo'd key
1050
+ (`"Person.nmae" => "Daniel"`) pinned nothing, and the example passed against
1051
+ random fake data — a test that had quietly stopped checking what it claims to.
1052
+ Keys now raise, spellchecked, at `FakeClient.new` and at `Testing.configure`
1053
+ when a schema is already set. Bare field-name keys (`"name"`) still work;
1054
+ **fix or drop any key that doesn't name a field in your schema.**
1055
+ - Codegen validation errors name the position they already captured: each
1056
+ message is prefixed `4:5`, and `queries/typo.graphql:4:5` when the file is
1057
+ known (`Codegen.new`/`Codegen.generate` take it as `path:`), instead of
1058
+ leaving a project of thirty query files to search by hand.
1059
+ - A strict `alias:` whose path doesn't fit a query now names the query that
1060
+ failed and ends with `— pass optional: true to skip selections that don't
1061
+ fit`, the documented way out.
1062
+ - Generation lists the custom scalars it had no registration for at `info`
1063
+ (`3 unregistered custom scalars → T.untyped: …`). Informational — a scalar
1064
+ without a codec is a legitimate choice, just no longer a silent one.
1065
+ - `Response#ok?` (and `#success?`) — the positive form of `errors?`.
1066
+ - `FakeClient#schema` reads back the schema responses are fabricated against,
1067
+ which is how to reach it under `auto_fake`, where `GraphWeaver.client` is the
1068
+ fake; `Testing.config.schema` reads back too.
1069
+ New:
1070
+ - **`rake graph_weaver:queries:check` — which of your queries a schema change
1071
+ broke.** Re-introspects the url the dump records (leaving the dump alone) and
1072
+ validates every checked-in query against the server as it is now,
1073
+ reporting file plus line:col plus message and exiting non-zero on any
1074
+ failure, so it drops into CI. `GraphWeaver.check_queries` returns the same
1075
+ thing as data (`{path => [{"message", "line", "column"}]}`, empty when
1076
+ everything validates); pass `schema:` to check a schema you already have
1077
+ without touching the network. Complements `graph_weaver:verify`, which asks
1078
+ the different question of whether the committed Ruby is stale.
1079
+ - `verify_generated!` (and `rake graph_weaver:verify`) compares generated files
1080
+ with line endings normalized, so a checkout under git's `autocrlf` no longer
1081
+ reports every generated file as stale.
1082
+ - New [editor support](docs/editors.md) doc: the `graphql.config.yml` that gives
1083
+ VS Code and RubyMine validation, autocomplete and hover docs in your
1084
+ `.graphql` files — no JS project, no gem code, five lines of YAML.
1085
+ - **Byte-identical generation is now a stated guarantee**, not just a property:
1086
+ the same schema and queries produce the same files on any machine, in any
1087
+ order (`docs/generated_modules.md`). It was already true and spec-enforced;
1088
+ it was documented nowhere.
1089
+
1090
+ **Faraday is no longer auto-selected — `GraphWeaver.new(url)` always builds
1091
+ `Transport::HTTP`.** Selection used to be `defined?(::Faraday)`, and faraday
1092
+ rides into most bundles transitively (stripe, octokit, ...), so adding an
1093
+ unrelated gem silently swapped your transport, its timeouts, and its connection
1094
+ behaviour. The accidental default was also the slower one: `Transport::HTTP`
1095
+ pools persistent sockets (1 TCP connection for 10 requests) where Faraday's
1096
+ default `net_http` adapter reconnects per request (10 for 10) — a full TLS
1097
+ handshake each time over HTTPS.
1098
+
1099
+ **What you must do:** if you were relying on the auto-pick, ask for Faraday
1100
+ explicitly — `GraphWeaver.new(url, transport: :faraday)`. A middleware block
1101
+ still implies it (`GraphWeaver.new(url) { |conn| ... }`), since the block is
1102
+ Faraday's. Faraday is otherwise unchanged and fully supported. Alongside a url,
1103
+ `transport:` now takes `:http` (the default) or `:faraday` rather than a
1104
+ built transport object — passing an object there used to raise "pass a url or
1105
+ transport:, not both" and now raises naming the two symbols. Alongside a schema
1106
+ source it still takes a built transport, and now rejects a Symbol. The client
1107
+ logs which transport it built at `info`.
1108
+
1109
+ `docs/transports.md` gains the recipe for giving Faraday the connection reuse
1110
+ `Transport::HTTP` has by default: the `:net_http_persistent` adapter, the two
1111
+ gems it needs, and the version pairing (Faraday 2.x requires
1112
+ `faraday-net_http_persistent` **2.x**; 1.2.0 raises `NoMethodError: undefined
1113
+ method 'dependency'` at load). graph_weaver depends on neither and never
1114
+ selects it for you.
1115
+
1116
+ **Generated files are pruned when their query disappears.** Renaming or
1117
+ deleting a `.graphql` used to leave its `.rb` behind forever: `load_generated!`
1118
+ kept requiring it, its module kept resolving against a query that no longer
1119
+ existed, and `verify_generated!` stayed silent — the pruning only covered
1120
+ `inputs/*.rb` and `unions.rb`. `generate!` now deletes any generated file the
1121
+ plan no longer produces, and `verify_generated!` reports it as stale.
1122
+
1123
+ Only files carrying the `# Generated by GraphWeaver — do not edit.` header are
1124
+ ever deleted, so a hand-written file in the output directory survives. **What
1125
+ you must do:** nothing, unless you were relying on a lingering module — the
1126
+ next `generate!` removes it, and CI's `rake graph_weaver:verify` will name it
1127
+ first.
1128
+
1129
+ **Mutations now generate `…Mutation` modules, not `…Query`.**
1130
+ `save_list_entry.graphql` holding a `mutation` produces
1131
+ `SaveListEntryMutation` in `save_list_entry_mutation.rb`;
1132
+ `SaveListEntryQuery.execute!` read wrong for a write. Queries are unchanged.
1133
+ The rule is one rule — the camelized file name plus the operation the file
1134
+ defines — and all three naming sites follow it: `generate!`,
1135
+ `GraphWeaver.parse(path)`, and `client.load_queries!`. The operation name
1136
+ written *inside* the file still names nothing; it goes on the wire as
1137
+ `operationName`.
1138
+
1139
+ **What you must do:** regenerate (`rake graph_weaver:generate`) and rename the
1140
+ call sites of any mutation module — `AdoptQuery` → `AdoptMutation`, including
1141
+ nested constants like `AdoptQuery::AdoptionInput`. Regeneration prunes the old
1142
+ `*_query.rb` files, and `rake graph_weaver:verify` names anything missed.
1143
+ Changing a file's `query` to `mutation` from here on renames its constant the
1144
+ same way, which CI now catches rather than letting it drift.
1145
+
1146
+ **Generated modules get their client plumbing from
1147
+ `GraphWeaver::QueryModule`.** `client`/`client=` carry no per-query type
1148
+ information, so every generated file repeated the same fifteen untyped lines;
1149
+ they now live in the gem, beside the input-struct runtime, and a module says
1150
+ `extend GraphWeaver::QueryModule` instead. `execute`, `execute!`,
1151
+ `from_response` and `from_response!` stay generated — their sigs are your
1152
+ query's types. A baked `client:` constant is emitted as `DEFAULT_CLIENT`,
1153
+ still resolved on first use so a module can load before the initializer that
1154
+ builds its client, and resolution is unchanged: per call → per module → baked
1155
+ constant → `GraphWeaver.client`.
1156
+
1157
+ **What you must do:** regenerate (`rake graph_weaver:generate`). The files
1158
+ change; nothing about how you call them does.
1159
+ Error-message and console ergonomics from the same review:
1160
+ - **Validation errors name the query file and render one per line**, compiler
1161
+ style — `invalid query in app/graphql/queries/person.graphql:` followed by an
1162
+ indented `4:5 Field 'nmae' doesn't exist on type 'Person'` per error. They
1163
+ arrived as one joined line with no file at all, because `generate!` had the
1164
+ path in hand and never passed it to codegen, so thirty query files left you
1165
+ hunting for a bare `4:5`. `ValidationError#errors` and `#to_h` keep the shape
1166
+ `rake graph_weaver:queries:check` reads; only the message text changed, and
1167
+ **it is multi-line now** — update anything matching on it.
1168
+ - **`register_enum("Species", PetKind, {"DOG" => :dog})` says the value map is a
1169
+ keyword**, and shows the call with `map:` in it. Guessing the map as a third
1170
+ positional argument used to get Ruby's `wrong number of arguments (given 3,
1171
+ expected 2)`, which never mentions `map:`.
1172
+ - **`load_queries!` logs when it replaces an already-loaded module**, at
1173
+ `:info`, before swapping the constant: `replacing PersonQuery — objects built
1174
+ from the previous module stay instances of it`. Reloading is unchanged and
1175
+ still what the method is for; it just isn't silent about the structs it
1176
+ orphans, which is how a console session ends up with an `is_a?` that fails
1177
+ for no visible reason.
1178
+ **Rails install generator.**
1179
+ `rails g graph_weaver:install https://api.example.com/graphql` writes
1180
+ `config/initializers/graph_weaver.rb`, the `app/graphql/queries` and
1181
+ `app/graphql/generated` directories, `graphql.config.yml` (schema autocomplete
1182
+ and validation for `.graphql` files in VS Code / RubyMine) and the schema dump
1183
+ — replacing the console step the getting-started guide used to open with.
1184
+ `--auth` names the ENV var holding the token (default `GRAPHWEAVER_AUTH`),
1185
+ `--no-schema` skips the introspection. Re-running prompts on conflict like any
1186
+ Rails generator.
1187
+
1188
+ **`rake graph_weaver:schema:refresh` can now create the first dump.** It read
1189
+ its url from an existing dump's provenance stamp, so it couldn't bootstrap one
1190
+ — pass `URL=https://api.example.com/graphql` and it will, and both the
1191
+ no-dump and no-provenance messages now name that fix. The same logic is
1192
+ `GraphWeaver::SchemaLoader.refresh!(url:, auth:)`, which is what the generator
1193
+ calls.
1194
+
1195
+ **Pointing a client at a url that isn't a GraphQL endpoint now says so.** A
1196
+ REST base url, a GraphiQL page or a proxy that ate the path answers 200 with
1197
+ well-formed JSON, and `.schema` raised a bare `KeyError`/`NoMethodError` out of
1198
+ graphql-ruby — unbranded, no url, and it escaped `rescue GraphWeaver::Error`
1199
+ (a 404 on the same path was already branded and clear). Introspection now
1200
+ checks for `data.__schema` and raises `GraphWeaver::Error` naming the endpoint
1201
+ and the first 200 characters of what came back.
1202
+
1203
+ **A subgraph's own `FieldSet` / `Scope` / `Policy` type no longer collides with
1204
+ weaver's.** Loading subgraph SDL injects the federation directive definitions
1205
+ the file applies but doesn't declare, and the scalars they reference went in
1206
+ unnamespaced — so a subgraph that owns a type by one of those names either had
1207
+ it shadowed or failed to build, with advice pointing at the wrong file. Those
1208
+ three are now `federation__FieldSet` / `federation__Scope` /
1209
+ `federation__Policy`. `_Any` / `_Entity` / `_Service` keep their names — those
1210
+ are spec-mandated and queryable.
1211
+
1212
+ **`rake graph_weaver:queries:check` no longer compares an in-process app's
1213
+ schema against itself.** For an app whose schema is its own graphql-ruby class
1214
+ there is no server to re-introspect, so the check degraded to re-reading the
1215
+ committed dump — reporting phantom errors about the app's own schema, a field
1216
+ you just added reading as "doesn't exist". When `GraphWeaver.client` executes
1217
+ in-process (a `Client` wrapping a schema class, or the class itself), the check
1218
+ now validates against the live class. Network clients are unchanged.
1219
+
1220
+ **The two dead-end "records no source url" messages now say what to do.** A
1221
+ dump taken from a schema class is rebuilt from code, not re-fetched — both
1222
+ `schema:refresh` and `schema:diff` say that instead of naming a `URL=` that
1223
+ doesn't exist for you.
1224
+
1225
+ **Two rake tasks are renamed so each one names its own subject.** There were
1226
+ three checks and two of them were called `verify`, while the one people run
1227
+ most — "did schema drift break my queries?" — lived under `schema:` and doesn't
1228
+ check the schema. **Update your CI:**
1229
+
1230
+ | Was | Now | Asks |
1231
+ |---|---|---|
1232
+ | `graph_weaver:schema:check` | `graph_weaver:queries:check` | do my checked-in queries still validate? |
1233
+ | `graph_weaver:schema:verify` | `graph_weaver:schema:diff` | has the server drifted from the dump? |
1234
+
1235
+ `graph_weaver:verify` (is the committed Ruby fresh?) and
1236
+ `graph_weaver:schema:refresh` are unchanged. No aliases — the old names are
1237
+ gone.
1238
+
1239
+ **The instrumentation payload now carries `:status` in-process too.** `InProcess`
1240
+ brands a resolver raise as `ServerError(500)` precisely so callers needn't
1241
+ branch on which side of the seam a query ran — but the payload had no `:status`
1242
+ in-process and no `:schema` over the wire, so a subscriber had to branch
1243
+ anyway. A successful in-process execute now sets `:status` to 200; a failure
1244
+ still rides the exception the hook already sees.
1245
+
1246
+ **`extend_type` and `alias:` moved from `docs/scalars.md` to
1247
+ `docs/generated_modules.md`** — they decorate a generated struct, and now sit
1248
+ next to what a generated struct looks like. **Update any bookmark to
1249
+ `scalars.md#type-helpers-your-logic-on-generated-structs`**; it is
1250
+ `generated_modules.md#type-helpers` now. `scalars.md` still owns
1251
+ `register_scalar` and `register_enum`.
1252
+
1253
+ - **`Testing::Router#trace` accumulates across executes and is reset
1254
+ explicitly** — `router.reset_trace`. It used to clear itself at the top of
1255
+ every `execute`, which made it answer about the *last* query rather than the
1256
+ code path: a service object running two queries reported only the second's
1257
+ fetches, and an example that ran nothing read the previous example's, so an
1258
+ assertion could pass on another example's work and fail under `--order rand`.
1259
+ The rspec `graphql: :router` tag resets it per example. **An example that
1260
+ asserts on the trace after more than one `execute` now sees both**, and
1261
+ wants a `reset_trace` in between if it meant only the last one.
1262
+ - **The absent-subgraph refusal leads with the half that applies.** It opened
1263
+ with "Rails autoloads, so the class is probably just not loaded yet" — right
1264
+ often enough to lead with, except when eager loading is already on, and then
1265
+ the library can *ask* rather than send you to a setting you already have. It
1266
+ now checks `config.eager_load` / `config.rake_eager_load` and, when either is
1267
+ on, says the subgraph runs elsewhere and puts `=> :fake` first instead of at
1268
+ the end of a 60-word sentence.
1269
+ - **A refusal spells a nested `@key`/`@requires` field set the way your schema
1270
+ does** — `"origin { lat lon }"`, not `"origin.lat", "origin.lon"`. The dotted
1271
+ form is this library's parse of it and matches nothing you can grep for.
1272
+ - **One `@interfaceObject` no longer disables the whole router.** It refused at
1273
+ construction, for the entire supergraph, so a single directive made
1274
+ `Testing::Router` unusable even for queries that never touch the type — one
1275
+ corpus had to be split into two graphs over it. It is now a per-query refusal
1276
+ (`Unplannable#category` `:interface_object`) keyed on the types the query
1277
+ actually reaches, and `federation:coverage` counts it as one refusal among
1278
+ others rather than aborting. Routing an `@interfaceObject` is still not
1279
+ implemented; this only makes the refusal proportionate.
1280
+ `RoutingTable#unsupported` no longer lists them —
1281
+ `RoutingTable#interface_objects` does, as `{"Media" => ["catalog"]}`.
1282
+ - `docs/federation.md`'s refusal table now lists **every** `Unplannable`
1283
+ category, and a spec keeps it that way. Five were missing, `chained_requires`
1284
+ and `conditional_fragment` among them.
1285
+ - **`federation:coverage` counts what your suite can *run*, not only what
1286
+ plans.** `5/5 queries plannable locally (100%)` was optimistic in exactly the
1287
+ partly-local shape the docs call the usual migration one: a query resolving
1288
+ in a subgraph another service serves plans fine and a spec still can't run
1289
+ it. The headline now reads `…, 2 servable here`, and the queries reaching
1290
+ past what's loaded are listed with the subgraph each needs. Plan-only is
1291
+ still the design — with no subgraph loaded (the SDL-alone CI run) the second
1292
+ number is dropped and the report says it counted planning only.
1293
+ `Coverage#servable` and `#elsewhere` are the programmatic side, and
1294
+ `Coverage::Result` gained `absent` / `servable?`.
1295
+ - **`Testing::Failure.stale_schema` drops its `schema:` / `seed:` sampling.**
1296
+ It picked a random real type/field so a fabricated error string would look
1297
+ plausible — but no assertion can depend on which one it picks, so it was
1298
+ decoration with three kwargs and an RNG behind it. `stale_schema(type:,
1299
+ field:)` names the casualty when the message matters, and the bare call still
1300
+ trips `schema_stale?`. Passing `schema:`/`seed:` now raises `ArgumentError`.
1301
+ - **`rake graph_weaver:cassettes:check`** — replays every recording through the
1302
+ generated modules and fails when one no longer casts. A cassette is the only
1303
+ artifact recorded from a foreign server, and nothing else here notices when
1304
+ that server's answers drift out of the shape the structs were generated for:
1305
+ `verify`, `queries:check` and `schema:diff` all ask about the local side. It
1306
+ needs no network, so it belongs beside `verify` in a PR run. A recording no
1307
+ generated module sends is skipped and counted, and checking *none* of them
1308
+ fails, like `federation:diff`. `Testing::Cassette#check` is the programmatic
1309
+ side.
1310
+ - **A cast failure no longer prints sorbet-runtime's `Caller:` frame.**
1311
+ `GraphWeaver::TypeError`'s message ended with `Caller:
1312
+ .../sorbet-runtime/.../call_validation.rb:331` — a path into the gem, never
1313
+ into the code with the problem, and the only location the message offered.
1314
+
1
1315
  ### v0.4.6 (2026-07-30)
2
1316
  Bug fixes from a full-library review (all with regression coverage):
3
1317
  - alias: a nested-object/enum leaf (`meta.sub`) now qualifies its constant