graph_weaver 0.5.1 → 0.6.1

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +75 -54
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +34 -4
  12. data/docs/real_world.md +4 -4
  13. data/docs/scalars.md +206 -46
  14. data/docs/testing.md +191 -151
  15. data/docs/transports.md +47 -19
  16. data/docs/upgrading.md +210 -11
  17. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  18. data/lib/graph_weaver/client.rb +46 -13
  19. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  20. data/lib/graph_weaver/codegen/emit.rb +96 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +167 -98
  24. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  25. data/lib/graph_weaver/codegen.rb +279 -84
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +30 -7
  28. data/lib/graph_weaver/federation.rb +6 -5
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
  38. data/lib/graph_weaver/internal/values.rb +184 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +5 -4
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +50 -11
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +96 -29
  50. data/lib/graph_weaver/tasks.rb +78 -29
  51. data/lib/graph_weaver/testing/cassette.rb +49 -65
  52. data/lib/graph_weaver/testing/coverage.rb +5 -4
  53. data/lib/graph_weaver/testing/failure.rb +10 -6
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +94 -808
  57. data/lib/graph_weaver/testing.rb +35 -84
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +203 -119
  63. metadata +10 -5
  64. data/lib/graph_weaver/schemas.rb +0 -48
  65. data/lib/graph_weaver/selection.rb +0 -120
  66. data/lib/graph_weaver/testing/values.rb +0 -98
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af7804f6f324bf878d1b11ba4da8e2adc8e2ea4c055fb80a703e91d8ca4eb3cd
4
- data.tar.gz: fe6fd6340203a8937c836281342c0efcdb701203295e8a4fcf37260892a5afec
3
+ metadata.gz: 12bea1d4f3d38c00c496ec595788629ea6c12c9f7590fc7938976570d8c86917
4
+ data.tar.gz: e7f8e60574801cc70032a4e2502a05d6ec8f9ac170ab22b53fa12b4f2633835a
5
5
  SHA512:
6
- metadata.gz: 1e5bd51d719bec6ddb81a75bab2bc68bb7b7ab9fd0c656fbbb0fd2497433ebe733893bda80e7d5dc1b4735487c1d787c5d10156dd7b0f8634e6740907af4fe01
7
- data.tar.gz: 4fd87cae3d8c320c9dfb61b08f4464d3d2978c831a35c0b26c4262b27929e90da52e1a528d411b6b1689d45e24edca5d2727c3e9dc42bb5bf6202589d0973d71
6
+ metadata.gz: e913ad4e5104a983b7ba0d87ae838de4a6e9e238aa7fc70c3d8d673ee24faba7eecf2734c7d403443cd2acdf93d4723adbc01bc0fc485e738df608f63adf43c1
7
+ data.tar.gz: cc1d344c3f99c5d587a3abb5e385626048d4821766c458ae7a7401b639ab9851c6c253d618282b9e314310bae88583240b0770588b06c898052e7a96d8472975
data/CHANGELOG.md CHANGED
@@ -1,3 +1,435 @@
1
+ ### v0.6.1 (2026-09-10)
2
+ - **A stdlib scalar registers with nothing but its class.**
3
+ `GraphWeaver.register_scalar("Decimal", BigDecimal)` now emits the whole
4
+ codec: `BigDecimal(...)` reads the wire value, `to_s("F")` writes it back,
5
+ and the generated file requires `bigdecimal`. The careful spelling people
6
+ reached for was wrong — `serialize: :to_s` puts `"0.125e2"` on the wire —
7
+ so the library owns it. Cast inference gained Kernel's conversion functions
8
+ (`BigDecimal()`, and any `Kernel#Type` your app defines for a type the wire
9
+ can't already be), and `serialize:` accepts `[:method, *args]` for a
10
+ serializer that takes arguments. `cast:`/`serialize:`/`requires:` are
11
+ unchanged, for a class of your own.
12
+ - **Scalar names that are conventions are registered already**: graphql-ruby's
13
+ `ISO8601Date`, `ISO8601DateTime`, `BigInt` and `JSON`, plus `DateTime` —
14
+ what GitHub, Shopify and most hand-written schemas call an ISO 8601
15
+ timestamp. A date deserializes into `Date` and a timestamp into `Time`, so
16
+ nothing invents a midnight; `BigInt` reads the decimal string graphql-ruby
17
+ writes as well as a JSON number; `JSON` is registered as `T.untyped` on
18
+ purpose, which also drops it from the "unregistered custom scalars" report.
19
+ A schema that means something else by one of these names fails loudly — the
20
+ cast raises, naming the field — and one `register_scalar` overrides it, as
21
+ it always could. **Regenerate** if a query of yours reads a scalar with one
22
+ of those names: its prop was `T.untyped` and now has a type.
23
+
24
+ ### v0.6.0 (2026-09-09)
25
+ - **One rule for relative path settings: they resolve against
26
+ `GraphWeaver.root`** — `Rails.root` in a Rails app, the working directory
27
+ otherwise. There were two: cassettes resolved against `Rails.root` while
28
+ the schema dump, queries and generated directories resolved against the
29
+ process's working directory, and the railtie patched three of the four
30
+ places that mattered. A Rails app started anywhere but its own root — a
31
+ `bin/rails server` or an `rspec` run from a subdirectory — loaded no
32
+ generated modules at all. An absolute setting is untouched, and the settings
33
+ keep returning what you configured, so `graphql.config.yml` stays portable.
34
+ Set `GraphWeaver.root` to override. The rule runs both ways: a path is
35
+ resolved against the root on access and reported relative to it on output,
36
+ so `generate!`'s return value, `changed_files`, `check_queries`' keys, the
37
+ rake tasks' `wrote`/`pruned` lines and the file an error names all come back
38
+ in the short form you configured. `cache:` also accepts a `Pathname` now,
39
+ as `schema:` and `query:` always have.
40
+ - **One spelling for the generated module's name: `name:`.** `GraphWeaver.parse`
41
+ took `name:` while `Codegen.generate` took `module_name:`. **Breaking:**
42
+ `module_name:` now raises naming `name:`. A parsed module also names itself
43
+ in errors — `GraphWeaver.parse::PersonQuery::Result::Person` instead of a
44
+ hex address — and assigning it to a constant upgrades every nested struct
45
+ to the real path.
46
+ - **`generate!` leaves a file alone when its content hasn't changed**, so the
47
+ rake task prints `wrote` only for files it wrote and `N already up to date`
48
+ for the rest, and a watching dev server reloads only what moved.
49
+ `GraphWeaver.changed_files` says which. A generated file now says which
50
+ release wrote it (`# Generated by GraphWeaver 0.6.0 — do not edit.`), so the
51
+ first `verify` after an upgrade reports the tree as stale. **Regenerate.**
52
+ - **A fake refuses an option it doesn't take**, naming the accepted ones and a
53
+ did-you-mean, at every door (`FakeClient.new`, `graphql_fake`,
54
+ `Router.new(fake:)`, `graphql_router(fake:)`) — it was a raw
55
+ `unknown keyword` from inside the fabricator. A scalar the fake can't
56
+ fabricate is now reported at its path (`at reader.orders.0.total`) with the
57
+ coordinate to override (`"Order.total"`).
58
+ - **A supergraph using federation 2.8's `@context`/`@fromContext` is refused
59
+ per query rather than routed with the argument unset.** `@join__field` was
60
+ on the routing table's known list, so its `contextArguments:` was read and
61
+ dropped: `Testing::Router` planned a fetch for the contextual field with the
62
+ argument empty. Apollo composes such a graph even when the context-setting
63
+ type and the contextual field sit in different subgraphs, so nothing
64
+ upstream caught it. The refusal fires only where the router would plan that
65
+ fetch itself; a subtree one subgraph answers whole still runs. New
66
+ `Unplannable` category `:context_argument`.
67
+ - **The local router makes one entity fetch where it made two** when an entity
68
+ has two `@requires` fields crossing into the same subgraph on the same
69
+ `@key`. Both sent the identical representation; Apollo makes one. Specs
70
+ asserting on `#trace` for such a query see one fewer entry. A missing `@key`
71
+ on a representation now names the entity type in `InputError#struct`, as a
72
+ coercion failure already did.
73
+ - **Three rake and generator polish items.** `rake -T` no longer prints a
74
+ queries path it can't know — a task description is baked before
75
+ `:environment`, so it presented the default as your setting; it now names
76
+ the defaults as defaults. `cassettes:anonymize` prints `no recordings in
77
+ <dir>` instead of nothing. `rails g graph_weaver:install` scaffolds
78
+ `app/graphql/fragments/.keep`, the directory its editor config already
79
+ globbed; the getting-started note names `rails g graphql:install
80
+ --skip-query-logs` for an app without Active Record.
81
+ - **`filter_parameters` scrubs error messages, not just the debug log.** A
82
+ rejected value reached the log twice: on the `variables=` line at debug, and
83
+ in the message every error writes at `warn` — above the gate, as free text.
84
+ One rule now covers both: a message the library composes about a value you
85
+ supplied names it only when the key it arrived under isn't filtered. So
86
+ `$password of Login: [FILTERED]`, while `$count of Compute: expected an Int,
87
+ got "lots"` is unchanged — the value is usually the whole diagnosis. Applies
88
+ to execute kwargs, input fields at any depth, federation `@key` fields, and
89
+ the missing-cassette report. A wrong-typed input field with no coercer now
90
+ says which field (`cents: expected BigDecimal, got 5`) instead of sorbet's
91
+ sentence, and the local federation router no longer logs variables
92
+ unscrubbed.
93
+ - **The public surface is what the docs name, what generated code calls, and
94
+ the `execute` slot — 421 names, down from about 700.** Everything else moved
95
+ under `GraphWeaver::Internal`, whose one rule is that nothing in it is API,
96
+ or went `private`. `spec/public_surface_spec.rb` diffs what's reachable from
97
+ `GraphWeaver` against a checked-in list, so the next accidental promotion
98
+ fails CI instead of shipping. **Breaking** only for code reaching past the
99
+ documented door: the federation query planner and its IR are
100
+ `Internal::Planner`; the fake-value engine is `Internal::Values`; the
101
+ selection walk is `Internal::Selection` (so `FakeClient` no longer answers
102
+ to `each_field` or `gather`); the cassette matching rules are
103
+ `Internal::RequestKey`; `GraphWeaver.log`/`.instrument`/`.filter_variables`
104
+ are `Internal::Log` (`logger=`, `instrumenter=`, `filter_parameters=` are
105
+ unchanged); `Transport.operation_name`/`.mutation?`/`.log_tag` left the
106
+ class you subclass for `Internal::Wire`. `SchemaDiff::Change`,
107
+ `Cassette::Check`, `Coverage::Result` and `InputStruct::Field` are `Data`,
108
+ not `Struct`, so they no longer hand out writers. `Codegen::ScalarType` is
109
+ private from load rather than from the first `reset_scalars!` — the
110
+ `private_constant` had landed inside the method body. Generated modules also
111
+ stop exposing their plumbing: `DEFAULT_CLIENT`, `FIELDS` and `ONE_OF` are
112
+ emitted as `private_constant`. **Regenerate**; nothing you call changes.
113
+ - **The retry options are flat.** `retries:` is how many attempts follow the
114
+ first; every other `Retry` option now sits beside it on the client —
115
+ `GraphWeaver.new(url, retries: 5, backoff: :linear, retry_codes:
116
+ ["THROTTLED"])`. **Breaking:** `retries:` no longer takes a Hash (`retries:
117
+ { retries: 5 }` read as a key nested in itself), and three options are
118
+ spelled out now that they sit next to a count: `on:` is `retry_on:`,
119
+ `base:`/`max:` are `base_delay:`/`max_delay:` — `retries: 5, max: 30`
120
+ invited reading `max:` as a second attempt count. The Hash form names its
121
+ flat replacement, and a retry option passed without `retries:` says so.
122
+ - **A representation's key fields are coerced, like an `execute` kwarg.**
123
+ `Representations.user(id: params[:id])` typechecked statically and then
124
+ raised sorbet-runtime's unbranded `TypeError` at runtime. The generated
125
+ builders now carry `.checked(:never)` and run each key field through the
126
+ registered scalar's own conversion; a value that converts to nothing raises
127
+ `GraphWeaver::InputError` naming the representation and the field.
128
+ **Regenerate.**
129
+ - **The library's internals are private.** Everything not documented and not
130
+ named by generated code is now `private` / `private_class_method` /
131
+ `private_constant`: codegen's IR and walk, the schema loader's detection
132
+ tables, the transport's log patterns, `Retry`'s default predicate, the
133
+ file-naming helpers. **Breaking:** `GraphWeaver::Selection`'s methods are
134
+ private in every class that mixes it in, so `Testing::FakeClient` no longer
135
+ answers to `each_field`, `gather` or `load_operation` — never a supported
136
+ door. The runtime that emitted source calls into (`cast_data`,
137
+ `check_envelope!`, `Coerce`, `Hints`, `InputStruct`, `QueryModule`,
138
+ `Representation`) stays public and says in its comments that it is
139
+ generated code's, not yours.
140
+ - **A registration another schema owns warns instead of failing generation.**
141
+ One registry serves a whole graph — federation composes scalars and types
142
+ by name — but validation ran against the single schema in hand, so the
143
+ natural multi-subgraph pattern (register once, generate each query against
144
+ its own subgraph) hard-failed. Generation now fails only on what the schema
145
+ can *disprove*: a name it declares as something else, or a coordinate whose
146
+ field it declares as a composite. Everything else warns, naming the schema
147
+ and suggesting a corrected name. `rake graph_weaver:generate` and `verify`
148
+ print that list once per run after the files;
149
+ `GraphWeaver.unmatched_registrations` is the same list for a Rakefile.
150
+ **Breaking**: a typo'd registration that used to stop generation is now a
151
+ printed warning. `docs/federation.md` gains "Generating for a federated
152
+ graph": through the gateway, generate against the supergraph; calling
153
+ subgraphs directly, one client each with `reset_registrations!` between.
154
+ - **Variables coerce inside `execute` now, and the sig stays as narrow as the
155
+ schema.** `execute(first: params[:first])` works — the String becomes an
156
+ Integer, an ISO-8601 string a `Date`, a model's Integer primary key an `ID` —
157
+ while `execute(first: "20")` written literally in a typed file is still an
158
+ `srb tc` error. The only way to accept loose input used to be `coerce: true`
159
+ / `GraphWeaver.auto_coerce`, which bought it by *widening* the emitted kwarg
160
+ and so switched off the static check at every call site. The generated sigs
161
+ are now `.checked(:never)` so an untyped value reaches the body; coercion is
162
+ the check that replaces sorbet-runtime's, and it refuses more than sorbet
163
+ did. Bad input raises `GraphWeaver::InputError` naming the variable, the
164
+ operation and the value — the same 422 rescue point as an input-object
165
+ field. **Breaking:** `GraphWeaver.auto_coerce` and `register_scalar(coerce:)`
166
+ are gone; a scalar that had `coerce: true` keeps its behaviour but its kwarg
167
+ narrows, so a call site passing a literal String is now an `srb tc` error.
168
+ `cast:` is what a loose value converts through. **Regenerate.**
169
+ - **Numeric strings are read as a wire format, not as Ruby source.** `"010"`
170
+ is ten, not eight, and `"0x1f"` and `"1_0"` are refused, in variables and
171
+ responses alike — `Kernel#Integer` accepted all three, which made a
172
+ zero-padded form field silently mean something else. An `Int` refuses a
173
+ `Float` it can't hold losslessly (`2.5`), and a `Boolean` refuses a String:
174
+ Ruby has no `Kernel#Boolean`, so every rule for `"0"`/`"off"` is a
175
+ convention the library declines to pick — convert at the call site.
176
+ - **A faked subgraph takes the options every other fake takes, and
177
+ `graphql_router` says them per example.** `subgraphs: { "reviews" => :fake }`
178
+ built its fake with nothing, so `overrides:`, `list_size:` and `null_chance:`
179
+ had nowhere to go. `fake:` carries them, on `Router.new`, on
180
+ `Testing.config.router`, and per example through `graphql_router(fake:)`,
181
+ which mirrors `graphql_fake`. One `fake:` covers every faked subgraph —
182
+ coordinate-keyed overrides already say which type they mean. A `fail_at:`
183
+ inside an `_entities` fetch used to null the field and report no error; it
184
+ now comes back as an error on the caller's path.
185
+ - **A subgraph two loaded schema classes fit is refused by the query that
186
+ reaches it, not by `Router.new`.** In an app where several loaded schemas
187
+ each satisfy a foundational subgraph, building a router raised for
188
+ subgraphs the query under test never touched — and only when eager loading
189
+ happened to be on. The router now builds, every query that avoids the
190
+ subgraph runs, and one that reaches it raises naming the candidates, saying
191
+ they came from loaded schema classes, and showing the `subgraphs:` form that
192
+ pins one. A class named explicitly still fails at construction.
193
+ `router.ambiguous` lists them. `Testing::Subgraphs.resolve` now returns a
194
+ `Resolution` (`#served`, `#ambiguous`).
195
+ - **A `.graphql` edit reaches the next request in development.** The query
196
+ directories and the schema dump join Rails' own reloaders, and the
197
+ `to_prepare` that loads the generated modules now regenerates first — after
198
+ your registrations, so an `extend_type` in a `to_prepare` is in place before
199
+ codegen reads it. A query that doesn't compile is logged at `error` with its
200
+ file and position while the modules already loaded keep serving. Development
201
+ only, while the server is running; `config.graph_weaver.watch = false` turns
202
+ it off, and `rake graph_weaver:verify` still gates CI on the committed files.
203
+ `GraphWeaver.reload_generated!` does the same by hand after regenerating in
204
+ another terminal, and generated files are now written to a temp file and
205
+ renamed, so a `rake graph_weaver:generate` beside a watching dev server can't
206
+ hand the running app a prefix that no longer parses.
207
+ - **A variable passed `nil` now sends `null`; one left out is still left
208
+ out.** GraphQL tells an absent variable from an explicit null — `bio: null`
209
+ clears a bio, omitting it does nothing — and a Ruby kwarg with a nil default
210
+ cannot, so both spellings omitted the key and no generated mutation could
211
+ clear a field. An optional nullable variable now records whether the keyword
212
+ was supplied. Input objects get the same distinction where a Hash can express
213
+ it: `coerce({nickname: nil})` sends null, `coerce({})` omits; a struct built
214
+ with `.new` can't tell, so nil there still means omit. A non-null variable
215
+ can't carry null, so nil there also still omits and its schema default
216
+ applies. **Breaking**: `bio: params[:bio]` with a missing param now sends
217
+ `null` where it used to omit — pass the keyword only when you mean to.
218
+ **Regenerate.**
219
+ - **A response that won't cast says which field, and whose bug it is.** A
220
+ leaf's cast raises about the value alone — "invalid date" on a struct holding
221
+ four dates located nothing — so each casting leaf now carries its response
222
+ key. An `ID` the server sent unquoted is out of spec but read like a
223
+ graph_weaver bug in sorbet's words; the message now says which it is and how
224
+ to take it anyway. An enum value the generated enum doesn't hold now names
225
+ the legal values and says the likely cause is drift, matching the input
226
+ side. **Regenerate.**
227
+ - **`register_scalar` with a type the wire can't build is refused at
228
+ generation.** `register_scalar("Money", BigDecimal)` produced a `BigDecimal`
229
+ prop and no cast, so every response failed its prop check at runtime, far
230
+ from the initializer that caused it. Generation now refuses it where a query
231
+ reads that scalar back, naming the field and how to give it a `cast:`.
232
+ `docs/scalars.md` now tabulates what the wire carries in both directions.
233
+ - **A fake takes pins, keyed by a scalar type, an object type, or a field.**
234
+ A pin says what the fake uses instead of inventing a value: a wire value,
235
+ an object it reads the selected fields off — a FactoryBot build, a model, a
236
+ `Struct`; a field the object doesn't answer is still fabricated, and a Ruby
237
+ value goes on the wire the way its scalar registration serializes it — or a
238
+ proc handed the seeded `Random`. Pins lead and options follow, in one call;
239
+ suite-wide, the same hash is `Testing.config.overrides`, and a router's
240
+ `fake:` takes it for the subgraphs it fakes.
241
+
242
+ graphql_fake("Money" => "12.00", "Person" => build(:person), "Order.total" => "999.00")
243
+
244
+ A scalar registered as your own class — `register_scalar("Money", Money,
245
+ cast: :parse)` — told codegen how to read the wire value but left the
246
+ testing harness guessing what to write, so every fake response touching it
247
+ died inside `from_h` blaming `Money.parse` for a `"Money-1"` placeholder.
248
+ The type pin is the answer, from every door including cassette
249
+ anonymization; without one, fabrication refuses, naming the scalar, the
250
+ field, and the pin to add. Scalars registered as `Time`, `Date`, `Integer`,
251
+ `Float`, `String` or `T::Boolean` need nothing. Fakes also now honor a
252
+ per-field `register_scalar("User.birthday", Date)`, matching codegen's
253
+ resolution. `graphql_fake` and `graphql_router(fake:)` refuse a per-example
254
+ `seed:` — `rspec --seed` is the mechanism, and `config.seed` the suite-wide
255
+ one for a harness that isn't rspec.
256
+ - **`Testing.config.null_chance` and `Testing.config.mode` are gone; both
257
+ are per-fake now.** A suite-wide answer to a per-example question nils an
258
+ unrelated field one run in ten. **Move them:** `graphql_fake(null_chance:
259
+ 0.3)` and `graphql_fake(values: :literal)` (the per-fake `mode:` is renamed
260
+ `values:` so it can't be confused with `graphql: :fake` /
261
+ `config.default_mode`, which stay). The auto-detect — faker when the gem is
262
+ loaded, else literal — is the only suite-wide value style.
263
+ `Testing::MODES` is now `VALUE_STYLES`.
264
+ - **`retries:` takes the count you'd write, and means the same thing
265
+ everywhere.** `GraphWeaver.new(url, retries: 3)` raised and pointed at
266
+ `retries: { tries: 3 }` — a second word for the same number that disagreed
267
+ about whether it counted the first attempt. One word now: `retries:` is how
268
+ many attempts follow the first, on the client and on `Retry` alike, so
269
+ `retries: 0` is one attempt. **Rename** `Retry.new(tries: n)` to
270
+ `Retry.new(retries: n - 1)`; the default is unchanged in effect.
271
+ - **`Response#to_h`.** Every error class answered `#to_h`; the envelope didn't.
272
+ It returns `{"data" =>, "errors" =>, "extensions" =>}` with each error as its
273
+ JSON-ready hash. `data` stays the typed struct rather than re-serialized:
274
+ `T::Struct#serialize` gives snake_case keys, drops nulls, and leaves a
275
+ registered scalar as its Ruby object — output that would pass for the
276
+ server's response without being one.
277
+ - **Sensitive variables are scrubbed from the debug log.** A `login(password:)`
278
+ mutation's variables were one log-level switch away from the log. Matching
279
+ values are replaced with `[FILTERED]` at any depth, on the wire and
280
+ in-process paths. Rails apps configure nothing: the railtie adopts the app's
281
+ own `config.filter_parameters`. Elsewhere,
282
+ `GraphWeaver.filter_parameters = [:password, /token/]`; the default list is
283
+ `[:password, :token, :secret, :authorization]`, matched as case-insensitive
284
+ substrings, and `[]` turns filtering off.
285
+ - **`schema:diff` names what changed, not just that something did.** It
286
+ reported `schema.json is stale` and stopped, so learning what moved meant
287
+ refreshing and reading a `git diff` of a 3 MB dump. It now prints one line
288
+ per change at its schema coordinate, breaking ones first — types, fields,
289
+ arguments, nullability, enum values, union members, interfaces and
290
+ deprecations — and still exits non-zero on any drift. Breaking is judged from
291
+ the client's side, so nullability is directional: an output losing `!` breaks
292
+ a generated struct, an input gaining one breaks a query that omits it.
293
+ `SchemaLoader.stale?` is now `SchemaLoader.diff`, returning the summary;
294
+ `diff(path).empty?` is what `stale?` answered.
295
+ - **A Rails app whose generated code includes an autoloaded helper now
296
+ boots.** `extend_type("Pet", PetHelpers)` and `register_enum("Species",
297
+ PetKind)` name constants your app autoloads, so the library tells you to
298
+ register them from a `to_prepare` block — but the railtie required the
299
+ generated files before Zeitwerk was set up and before `to_prepare` ran, so
300
+ the `include PetHelpers` those registrations emit raised `NameError` at every
301
+ boot, in every environment. They now load from a `to_prepare` block of their
302
+ own: after the autoloader, after your registrations, before eager loading.
303
+ - **A fragment on the abstract type no longer narrows the field away.**
304
+ `named { __typename ... on Named { name } ... on Pet { species } }` read the
305
+ `Named` fragment as a type condition and narrowed to Pet, so a Person came
306
+ back as `nil` and the `name` the server sent went on the floor — while the
307
+ same selection spelled bare dispatched correctly. Two ways of writing one
308
+ query disagreed, and the fragment is how anyone shares interface fields.
309
+ **Regenerate** to pick it up.
310
+ - **A `Float` field reads the whole number a JSON encoder writes.** JSON has
311
+ one number type, so `1.0` reaches Ruby as an `Integer` from graphql-js and
312
+ Go's `encoding/json` alike, and `const :amount, Float` refused a response the
313
+ spec calls legal. Widened with `Kernel#Float`, which still refuses what
314
+ `.to_f` would quietly turn into `0.0`. **Regenerate** to pick it up.
315
+ - **A guarded `__typename` no longer makes a narrowing miss look like a
316
+ match.** Narrowing without a readable tag decides on emptiness, but a
317
+ `__typename` behind `@skip`/`@include` still arrives for the member narrowing
318
+ means to filter, so the wrong member was cast. That query now refuses at
319
+ generation, with the message that already names the fix.
320
+ - **Generation refuses what Ruby would misread.** A result key that would
321
+ shadow a constant the file uses (`date` beside a `Date` scalar nested a
322
+ `class Date < T::Struct` and typed the scalar prop as that struct) is refused
323
+ naming both keys; so is an enum value that camelizes to nothing (`_`), which
324
+ emitted a file that died at load with a syntax error.
325
+ - **A bad enum value in a variable says what the enum accepts**, as an
326
+ `InputError` — it raised a bare `KeyError` naming an anonymous module as a
327
+ top-level variable while the same mistake inside an input object was already
328
+ branded. A missing required input field now lists every missing key instead
329
+ of sorbet's symptom for the first prop it reached. **Regenerate** to pick up
330
+ the new emitted coercion.
331
+ - **A named fragment now crosses a subgraph boundary.** A stitched fetch is
332
+ built from selections alone, so a spread that survived into one named a
333
+ fragment the subgraph had never seen and the whole subtree came back
334
+ `Fragment X was used, but not defined`. Every spread a fetch carries is now
335
+ spelled as the inline fragment it is.
336
+ - **A fake's value for a custom scalar comes from what you registered it
337
+ as.** It was picked off the schema's *name* for the scalar, so a `Timestamp`
338
+ registered as `Time` got `"Timestamp-1"` and every fake response touching it
339
+ died inside the codec codegen had emitted for it. A scalar registered as
340
+ `Time` now gets iso8601, one registered as `Integer` an Integer; an
341
+ unregistered one keeps its placeholder. Cassette anonymization runs through
342
+ the same engine and had the same hole, which made `cassettes:check` fail on
343
+ a cassette the anonymizer had just written. **Re-run
344
+ `rake graph_weaver:cassettes:anonymize`** on a cassette holding a registered
345
+ custom scalar.
346
+ - **A mutation is no longer retried.** A read timeout, a 502 or a reset socket
347
+ does not say whether the server applied the request, and a second `charge`
348
+ is worse than a failed one — so `Retry` gives a mutation one attempt and says
349
+ so on the logger. Pass `retry_mutations: true` for an API whose mutations are
350
+ idempotent. Each retry now logs the wait and the attempt number.
351
+ - **A connection that dies mid-body is a dropped connection.** net/http's
352
+ `ignore_eof` default handed back the bytes that arrived when a socket closed
353
+ short of its `Content-Length`, so a half-sent response reached you as a 200
354
+ with a truncated body — a permanent `ServerError` that `Retry` would not
355
+ retry. It now raises the retriable `TransportError` it is.
356
+ - **An in-process query gets its own context.** graphql-ruby writes a
357
+ resolver's `context[...] =` into the hash it is handed, and one `InProcess`
358
+ is normally the whole app's client — so a flag set by one request was still
359
+ there for the next, and racing between them under Puma.
360
+ - **A response that carries neither `data` nor `errors` is refused.** A client
361
+ returning `nil`, one keying the envelope by symbol, one that typo'd `"dat"`:
362
+ each produced a `Response` reporting `success?` with `data` nil.
363
+ `from_response` is documented public API and symbolized keys are the
364
+ likeliest mistake at that seam, so it now brands, naming the keys it found;
365
+ a client answering with a String brands too, instead of dying on `#to_h`.
366
+ **Regenerate** to pick that up.
367
+ - **`@skip`/`@include` are evaluated by the fake against the variables you
368
+ passed**, declared defaults included — the way a server and
369
+ `Testing::Router` already did, so one query no longer carries a key under
370
+ `graphql: :fake` and not under `graphql: :router`. A faked subgraph does the
371
+ same for directives inside its `_entities` selection. A `first:`/`last:`
372
+ arriving as a variable caps the fabricated list like a literal, a cap below
373
+ zero reads as a page of none, and `null_chance` reaches list positions.
374
+ - **An alias spelling a response key the router carries a `@key` under is
375
+ refused.** The router injects the `@key` it crosses on under a reserved key
376
+ and strips it from the answer, so `_gw_weight: weight` came back without
377
+ `_gw_weight` at all. It joins the `shadowed_key` refusal, which already
378
+ covered Apollo's half of the same collision.
379
+ - **A `@requires` field set excluded by `@skip`/`@include` no longer
380
+ prefetches.** The router ran a subgraph fetch a real router never makes —
381
+ which matters for a test double, where a resolver that runs is one your
382
+ example can observe.
383
+ - **`verify_generated!` fails over zero query documents**, and `generate!`
384
+ warns. A mistyped `queries_paths` left `verify` returning true having
385
+ compared nothing, so a CI gate stayed green forever.
386
+ - **`rake graph_weaver:generate` says what it pruned, and where it looked when
387
+ there was nothing to generate.** Deleting a `.graphql` deletes the checked-in
388
+ file it produced, and the task printed nothing and exited 0; so did a run
389
+ with no queries, the state every install starts in.
390
+ - **`Failure.server` takes the headers a backoff branches on.**
391
+ `ServerError#retry_after` and `#throttled?` read `Retry-After`, and there was
392
+ no way to set one: `Failure.server(status: 429, headers: { "retry-after" =>
393
+ "2" })`. `Failure.transport`'s message now names the class it caught, the
394
+ way the bundled transports do.
395
+ - **Cassette recording is safe across threads.** `record` was
396
+ read-modify-write; two threads recording through one cassette each saved a
397
+ snapshot missing the other's entry.
398
+ - **A `.json` schema dump that isn't JSON says which file and what it holds.**
399
+ A truncated download or a login page saved over the dump raised a bare
400
+ `JSON::ParserError`, outside `GraphWeaver::Error` and naming neither.
401
+ `rake graph_weaver:schema:refresh` — the fix — no longer trips over it.
402
+ - **Setup mistakes say what to do.** A schema source that looks like a url
403
+ (`localhost:4000/graphql` included) gets the url advice first; a non-token
404
+ `auth:`, a `retries:` that is neither true nor a Hash, a url
405
+ `Transport::HTTP` cannot POST to, a `parse` path without a `.graphql`
406
+ extension, a cache directory that can't be written, `schema:refresh URL=`
407
+ with a file path, and `schema:diff` with no dump all name the fix. A
408
+ `ServerError` carries the advice its status implies: a 3xx names where the
409
+ server pointed (redirects are not followed) and a 401/403 names `auth:`. A
410
+ spec with a `graphql:` tag but no `graph_weaver/rspec` is told to require
411
+ it, a misspelled tag reports one failure instead of two, `InputError` no
412
+ longer prints sorbet's `Caller:` frame, a generated file whose registered
413
+ constant is gone names the registration, and `rails g graph_weaver:install`
414
+ names a retry that works when introspection fails.
415
+ - **The GitHub example's checked-in modules were regenerated**, and a spec now
416
+ keeps them in step with the emitter.
417
+ - **A cold process no longer introspects once per in-flight thread.** A
418
+ url-built client fetches its schema lazily, and Puma serves its first
419
+ requests concurrently — so eight threads arriving together meant eight full
420
+ introspection round trips, each of them also writing the schema cache. One
421
+ now, whoever asks first.
422
+ - **The schema cache and cassettes are written atomically.** `File.write`
423
+ truncates before it writes, so an interrupted run — or a second writer, a
424
+ rake task beside a running app — could leave a half-written file where a
425
+ committed artifact used to be. Both now write beside the target and rename,
426
+ which is atomic: a reader sees the old file or the new one.
427
+ - **The connection pool keeps its ceiling under an interrupt.** An async raise
428
+ landing between taking a permit and the `ensure` that returns it would have
429
+ leaked one for the life of the process, and `Rack::Timeout` raises exactly
430
+ that way; the gap is closed. Its saturation warning is also once now rather
431
+ than once per racing thread.
432
+
1
433
  ### v0.5.1 (2026-09-07)
2
434
  - **A union's catch-all struct keeps the fields an interface fragment asked
3
435
  for.** `... on Named { name }` under a union types `name` on every member the
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- graph_weaver (0.5.1)
4
+ graph_weaver (0.6.1)
5
5
  graphql (>= 2.6.7)
6
6
  sorbet-runtime
7
7
 
@@ -99,16 +99,16 @@ GEM
99
99
  rubydex (0.2.7-arm64-darwin)
100
100
  rubydex (0.2.7-x86_64-darwin)
101
101
  rubydex (0.2.7-x86_64-linux)
102
- simplecov (1.1.1)
103
- sorbet (0.6.13454)
104
- sorbet-static (= 0.6.13454)
105
- sorbet-runtime (0.6.13454)
106
- sorbet-static (0.6.13454-aarch64-linux)
107
- sorbet-static (0.6.13454-universal-darwin)
108
- sorbet-static (0.6.13454-x86_64-linux)
109
- sorbet-static-and-runtime (0.6.13454)
110
- sorbet (= 0.6.13454)
111
- sorbet-runtime (= 0.6.13454)
102
+ simplecov (1.2.0)
103
+ sorbet (0.6.13485)
104
+ sorbet-static (= 0.6.13485)
105
+ sorbet-runtime (0.6.13485)
106
+ sorbet-static (0.6.13485-aarch64-linux)
107
+ sorbet-static (0.6.13485-universal-darwin)
108
+ sorbet-static (0.6.13485-x86_64-linux)
109
+ sorbet-static-and-runtime (0.6.13485)
110
+ sorbet (= 0.6.13485)
111
+ sorbet-runtime (= 0.6.13485)
112
112
  spoom (1.8.3)
113
113
  erubi (>= 1.10.0)
114
114
  prism (>= 0.28.0)
@@ -176,7 +176,7 @@ CHECKSUMS
176
176
  google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
177
177
  google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
178
178
  google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
179
- graph_weaver (0.5.1)
179
+ graph_weaver (0.6.1)
180
180
  graphql (2.6.10) sha256=9b7c8633767f516ff9d48a8d6305b2a00a2101c82aa871b92e69086944f9f83e
181
181
  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
182
182
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
@@ -206,13 +206,13 @@ CHECKSUMS
206
206
  rubydex (0.2.7-arm64-darwin) sha256=f0d28bbf4153568be79b671642424750053e0bea971b60ddf5cec19bf4563990
207
207
  rubydex (0.2.7-x86_64-darwin) sha256=b002b259d118ac69de44470eff1597143318402c45630c47371f9542631447dc
208
208
  rubydex (0.2.7-x86_64-linux) sha256=dacfade9fa42ce4469618da6dac07e69d5f3ac6a313b4caced5234c8f052419a
209
- simplecov (1.1.1) sha256=25825ef13f0b2e74694d769817dad6ab8e90131dabdaa666e522fea105521e78
210
- sorbet (0.6.13454) sha256=3273dda082b8aa5ecf79b7675e2b79127f202a45370d881124af3af19a3c2b35
211
- sorbet-runtime (0.6.13454) sha256=b9441ae4bd265f51861c54240e8c5888f6355e3284885b21c5154b184e791d80
212
- sorbet-static (0.6.13454-aarch64-linux) sha256=e09dd1e6cd7e63b3fb6c8c9c6785eeb55a6c4afecb0f932538ee4401b4552632
213
- sorbet-static (0.6.13454-universal-darwin) sha256=6ac0cf10ae2b4e0e9ecabb886f9be739046424dc35d100e7888c8538a223f93a
214
- sorbet-static (0.6.13454-x86_64-linux) sha256=569c52ea926e514ebfdc64825f65cd188b7aba5c1b480cc26e1870fd53d5e2c0
215
- sorbet-static-and-runtime (0.6.13454) sha256=defc00684a6f8bd498bb70a4541f293b9232938a776137946247cba4baed3d20
209
+ simplecov (1.2.0) sha256=ea6acd05eece5a41990e2a5171c57d15700d329326c7666c85ee8c6a0dd0977e
210
+ sorbet (0.6.13485) sha256=b885d6a2fcde57bc46c3cdd597b9014b82619dee0f811a6fdf36f57c8f82c380
211
+ sorbet-runtime (0.6.13485) sha256=2982504e662eb424e515b3aa1c8139070451a014b6c1af2302250ed82495b4cc
212
+ sorbet-static (0.6.13485-aarch64-linux) sha256=5d802793633363442f189615fdb6c6d1be726b402d78a3d63eab96ca74b98d40
213
+ sorbet-static (0.6.13485-universal-darwin) sha256=6c7c4551872d556b37bf1f1a3495509b9fd9479b4a080bd57f9c5db0e8116658
214
+ sorbet-static (0.6.13485-x86_64-linux) sha256=e22d88df0bba5a97e664a329a2212ea59e6a2020626597a162c60c5ddfb4f9b4
215
+ sorbet-static-and-runtime (0.6.13485) sha256=b893346f9e1a244133071ad3c3d06b8ddf70c1b8246bb594c9aaa5cfc778df5f
216
216
  spoom (1.8.3) sha256=32871fa189bbfa49cf557a50f819f23cc9a6ceefd0346caa7a6adc193becd5dd
217
217
  tapioca (0.19.2) sha256=938731b07811aee8d23871b1aee8861d464fbaf2cfffbf79a62b0c869a5120ec
218
218
  thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73