graph_weaver 0.7.0 → 0.7.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.
- checksums.yaml +4 -4
- data/Gemfile.lock +4 -4
- data/README.md +40 -88
- data/docs/alternatives.md +1 -7
- data/docs/cassettes.md +54 -59
- data/docs/editors.md +32 -47
- data/docs/errors.md +261 -369
- data/docs/federation.md +650 -837
- data/docs/generated_modules.md +370 -459
- data/docs/getting_started.md +211 -428
- data/docs/i18n.md +114 -177
- data/docs/logging.md +127 -116
- data/docs/real_world.md +26 -39
- data/docs/scalars.md +277 -310
- data/docs/testing.md +340 -486
- data/docs/transports.md +191 -263
- data/docs/upgrading.md +188 -560
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +12 -3
- data/lib/graph_weaver/client.rb +22 -1
- data/lib/graph_weaver/codegen.rb +5 -1
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +23 -15
- data/lib/graph_weaver/federation.rb +11 -2
- data/lib/graph_weaver/in_process.rb +15 -9
- data/lib/graph_weaver/internal/endpoint.rb +7 -5
- data/lib/graph_weaver/internal/headers.rb +19 -0
- data/lib/graph_weaver/internal.rb +66 -13
- data/lib/graph_weaver/log_subscriber.rb +10 -2
- data/lib/graph_weaver/logging.rb +33 -13
- data/lib/graph_weaver/query_module.rb +8 -0
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/schema_loader.rb +52 -14
- data/lib/graph_weaver/testing/cassette.rb +28 -5
- data/lib/graph_weaver/testing/endpoint.rb +14 -13
- data/lib/graph_weaver/testing/fake_client.rb +33 -3
- data/lib/graph_weaver/testing/router.rb +7 -3
- data/lib/graph_weaver/transport/http.rb +2 -2
- data/lib/graph_weaver/transport.rb +47 -23
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +22 -1
- metadata +16 -3
- data/CHANGELOG.md +0 -3801
data/CHANGELOG.md
DELETED
|
@@ -1,3801 +0,0 @@
|
|
|
1
|
-
### v0.7.0 (2026-09-13)
|
|
2
|
-
|
|
3
|
-
**What you must do.** Every change here is 0.6.1 → 0.7.0, and a typical app
|
|
4
|
-
ticks two or three. [upgrading](docs/upgrading.md#upgrading-from-061) has the
|
|
5
|
-
same list with the greps that find each one.
|
|
6
|
-
|
|
7
|
-
- **Regenerate** — `rake graph_weaver:generate`. Every generated file moved:
|
|
8
|
-
reserved props take a trailing underscore, `as_json` is emitted beside
|
|
9
|
-
`from_h`, a `cast:` of your own gets the library's own guard, each module
|
|
10
|
-
carries the graph it came from, a `@key` that selects through a list types
|
|
11
|
-
its kwarg as a list, a block-built type helper is named for its graph and
|
|
12
|
-
its type, and `execute` makes its request through the
|
|
13
|
-
gem. 0.6.1's modules keep running, but `rake graph_weaver:verify` is red
|
|
14
|
-
until you regenerate, and one of them raises `GraphWeaver::Error` naming this
|
|
15
|
-
the moment anything asks it for `as_json`.
|
|
16
|
-
- **Rename two error classes.** `GraphWeaver::TypeError` → **`CastError`**,
|
|
17
|
-
`GraphWeaver::ValidationError` → **`QueryValidationError`**, at every rescue
|
|
18
|
-
site and in any `to_h["error"]` string you match on. No aliases.
|
|
19
|
-
- **Rename the rspec tag `graphql: false` to `graphql: :live`**, and
|
|
20
|
-
`config.default_mode = nil` to `:live`, which is now the default.
|
|
21
|
-
- **Rename the notification if you hardcoded it**: `"graph_weaver.execute"` →
|
|
22
|
-
`"execute.graph_weaver"`. Subscribing through `GraphWeaver::EXECUTE_EVENT`
|
|
23
|
-
needs nothing.
|
|
24
|
-
- **Read `payload[:http_status]`** wherever a subscriber branched on
|
|
25
|
-
`payload[:status] == 200` or on a 4xx/5xx — `:status` is a Symbol now.
|
|
26
|
-
- **Read `InputError#path.first`** wherever you read `#field` for the
|
|
27
|
-
*variable* name; `#field` is the input field that held the value now.
|
|
28
|
-
- **Refresh a schema dump introspected through a url carrying a credential**,
|
|
29
|
-
and rotate the token if that file was ever pushed — earlier versions recorded
|
|
30
|
-
the url verbatim in the dump's provenance.
|
|
31
|
-
- **Refresh a dump you deliberately keep behind your own schema class**, which
|
|
32
|
-
`verify` now fails on — or ask about no dump at all with
|
|
33
|
-
`verify_generated!(schema:)`.
|
|
34
|
-
- **Regenerate `@oneOf` inputs whose dump is `.json`**
|
|
35
|
-
(`rake graph_weaver:schema:refresh && rake graph_weaver:generate`): the newly
|
|
36
|
-
emitted `ONE_OF` starts refusing calls that set two fields, which your server
|
|
37
|
-
was refusing all along.
|
|
38
|
-
- **Move a `config.schema`, `config.router` or `config.context` set in a plain
|
|
39
|
-
`before` hook** into `GraphWeaver::Testing.configure` or an `around` — all
|
|
40
|
-
three are refused there now, where they used to silently change nothing.
|
|
41
|
-
- **Move `require "graph_weaver/rspec"` to `spec/rails_helper.rb`** if yours is
|
|
42
|
-
in `spec/support/` and you never uncommented rspec-rails' `spec/support`
|
|
43
|
-
glob: the require never ran, so every `graphql: :fake` example has been
|
|
44
|
-
hitting the real client.
|
|
45
|
-
- **Set `retries: 0`** if you wrapped a gateway in `Retry` and relied on it not
|
|
46
|
-
actually retrying: a 5xx/429 that arrives with a GraphQL errors body — which
|
|
47
|
-
is how Apollo Router answers — retries now, where it used to make one
|
|
48
|
-
attempt.
|
|
49
|
-
- **Update a spec that compares a `Testing::Router` error hash whole.** A
|
|
50
|
-
subgraph's error carries `extensions: {"service" => "<subgraph>"}` now, as
|
|
51
|
-
every real transport stamps it.
|
|
52
|
-
- **Drop `SUPERGRAPH=` from any task but `federation:*`**, which refuse it now
|
|
53
|
-
where they used to ignore it — a `SUPERGRAPH=… rake graph_weaver:queries:check`
|
|
54
|
-
in CI goes red instead of passing every query against the wrong schema.
|
|
55
|
-
- **Send the value the server expects** wherever a variable was a `File`, `IO`,
|
|
56
|
-
`Pathname` or plain object: refused at the wire now, where `JSON.generate`
|
|
57
|
-
used to ship its `#to_s`.
|
|
58
|
-
|
|
59
|
-
- **BREAKING: two error classes renamed, with no alias.**
|
|
60
|
-
`GraphWeaver::TypeError` is now **`GraphWeaver::CastError`** — it means the
|
|
61
|
-
response wouldn't cast into the generated structs, and the old name shadowed
|
|
62
|
-
a core class it doesn't descend from, so `rescue TypeError` inside the gem
|
|
63
|
-
read as Ruby's and `rescue GraphWeaver::TypeError` outside it read as a type
|
|
64
|
-
error in the caller's own code. `GraphWeaver::ValidationError` is now
|
|
65
|
-
**`GraphWeaver::QueryValidationError`** — it means the *query* failed schema
|
|
66
|
-
validation at build time, which a Rails reader would not guess from
|
|
67
|
-
"validation": their input's validation is `InputError`. The old names are
|
|
68
|
-
**gone**, not deprecated — rescuing one is a `NameError`, which is the loud
|
|
69
|
-
failure, and a constant alias would carry the confusion forward forever.
|
|
70
|
-
`GraphWeaver::Error` still catches both, as it always did.
|
|
71
|
-
- **Breaking: `graphql: false` is now `graphql: :live`.** The opt-out is the
|
|
72
|
-
app's own client, untouched — which is a mode like the other four, so it is
|
|
73
|
-
spelled like one. `false` is refused, and the refusal names `:live`.
|
|
74
|
-
`config.default_mode` is the suite-level spelling of the same thing, and it
|
|
75
|
-
now **defaults to `:live`** rather than to `nil`, which is no longer a value:
|
|
76
|
-
every example has exactly one mode, an untagged one takes
|
|
77
|
-
`config.default_mode`, and `graphql: :live` steps a single example back out
|
|
78
|
-
of a default the suite set. Behavior is unchanged — `:live` leaves
|
|
79
|
-
`GraphWeaver.client` exactly as it is, and it is still restored after the
|
|
80
|
-
example — but a helper now contradicts `graphql: :live` the way it
|
|
81
|
-
contradicts any other tag rather than quietly winning.
|
|
82
|
-
- **Breaking: `GraphWeaver::EXECUTE_EVENT` is now `"execute.graph_weaver"`.**
|
|
83
|
-
`<event>.<namespace>` is how every notification in this ecosystem is spelled
|
|
84
|
-
(`sql.active_record`, `execute_multiplex.graphql`), and it is what
|
|
85
|
-
`LogSubscriber.attach_to` and an APM's namespace routing key on — backwards,
|
|
86
|
-
the gem could not attach its own log subscriber without a puzzle.
|
|
87
|
-
**Subscribe through the constant and nothing changes**; a hardcoded
|
|
88
|
-
`"graph_weaver.execute"` silently stops matching, so grep for it.
|
|
89
|
-
- **Breaking: the payload's `:status` is a Symbol, and the HTTP status moved to
|
|
90
|
-
`:http_status`.** `:status` is now `:ok`, `:errors` (the response carried
|
|
91
|
-
GraphQL errors) or `:failed` (it raised) — a 200 carrying errors is not a
|
|
92
|
-
success, and only a symbol says that on both sides of the seam. In-process
|
|
93
|
-
`:http_status` is nil, where `:status` used to be a fabricated 200 so one
|
|
94
|
-
subscriber could read both sides of the seam.
|
|
95
|
-
- **A form can highlight the input field that was wrong, without reading
|
|
96
|
-
English — and `InputError#field` changed meaning (breaking).** `InputError`
|
|
97
|
-
now carries `#kind` (one of eight — `KINDS`), `#path` rooted at the variable
|
|
98
|
-
(`["where", "_and", 0, "_not", "species"]`, list indices included, so
|
|
99
|
-
`execute(ids: [1, 2, "x"])` reports `["ids", 2]`), `#coordinate`
|
|
100
|
-
(`"PetFilter.species"`), `#value` and `#details` (`members:`, `type:`,
|
|
101
|
-
`suggestion:`, `min:`…), so an app translates a stable key instead of parsing
|
|
102
|
-
a sentence ([i18n](docs/i18n.md)). **`#field` is now `#path`'s last *named*
|
|
103
|
-
segment** — the input field that actually held the value, which is the one a
|
|
104
|
-
form highlights, never an index — where it used to be re-branded on the way
|
|
105
|
-
out with the *variable* name; read `#path.first` for that. Nothing raises, so
|
|
106
|
-
the change passes unnoticed until the first refusal inside an input object.
|
|
107
|
-
|
|
108
|
-
**The structured half is spelled the way the schema is**, whichever side
|
|
109
|
-
refused: `#path`, `#field` and `#coordinate` are wire names — `["input",
|
|
110
|
-
"issuedOn"]`, `"externalId"`. It could not honestly be otherwise, since a
|
|
111
|
-
server can produce no spelling but its own, and a client-side refusal that
|
|
112
|
-
used the prop made `errors[e.field]` silently miss every server-detected
|
|
113
|
-
error, which is what a real Rails form did. The **prop** is what you type in
|
|
114
|
-
Ruby, and `#message` — the developer's line — names it (`external_id:
|
|
115
|
-
expected an Int`), so a form is `errors.add(e.field.underscore, …)` and
|
|
116
|
-
nothing else. An **unknown key** names no field, so the path echoes it
|
|
117
|
-
exactly as you wrote it and `details[:suggestion]` is the prop to type
|
|
118
|
-
instead. `filter_parameters` still judges by the prop, that list being
|
|
119
|
-
written in Ruby spelling — `api_key` has to keep matching what `apiKey`
|
|
120
|
-
holds.
|
|
121
|
-
|
|
122
|
-
**`#details[:type]` is the GraphQL type, never a Ruby class.** A String where
|
|
123
|
-
an input object belongs says `AdoptionInput`, not
|
|
124
|
-
`GraphQLTypes::AdoptionInput`, and a `register_scalar("Money", BigDecimal)`
|
|
125
|
-
field says `Money`, not `BigDecimal` — both used to render straight into
|
|
126
|
-
`"%{field} must be a %{type}."` on somebody's form. The messages still name
|
|
127
|
-
the Ruby you may pass, which is the developer's half. One gap left: the
|
|
128
|
-
sorbet fallback for a field no coercer covers still reports its Ruby type
|
|
129
|
-
(`metadata: expected T::Hash[…]`), which needs the field's GraphQL type
|
|
130
|
-
threaded through codegen's node tree. The format key is **`:pattern`**, not
|
|
131
|
-
`:format`, which is one of `I18n::RESERVED_KEYS` and raised
|
|
132
|
-
`I18n::ReservedInterpolationKey` out of [i18n](docs/i18n.md)'s own recipe the
|
|
133
|
-
moment an `:invalid_format` error reached it; a spec holds
|
|
134
|
-
`InputError::DETAILS` disjoint from that list so the next detail key can't
|
|
135
|
-
reintroduce the collision, and a server stating `extensions.input` sends
|
|
136
|
-
`"pattern"`.
|
|
137
|
-
|
|
138
|
-
`#value` goes through `filter_parameters`, and an unknown key carries no
|
|
139
|
-
`#value` at all — the key is what was wrong, and a typo'd key is by
|
|
140
|
-
definition not the filtered one it was meant to be. Both `#value` and
|
|
141
|
-
`#details` are always JSON-representable, at any depth (a non-finite Float
|
|
142
|
-
travels as `"NaN"` / `"Infinity"`), so `render json: e.to_h` can't turn a 422
|
|
143
|
-
into a 500 over exactly the values `Coerce` exists to refuse.
|
|
144
|
-
`InputError::VALUE_LIMIT` bounds what one can carry: an error is built for
|
|
145
|
-
whatever a caller sent and whatever a server echoed back, either of which can
|
|
146
|
-
be megabytes, and every raised one writes a `warn` line as well as landing in
|
|
147
|
-
`#to_h` — so each String `#value` holds at every depth, the value `#message`
|
|
148
|
-
quotes, and a sentence a server wrote are cut to 1024 bytes with `…(N more
|
|
149
|
-
bytes)` in place of the rest. A `@oneOf` input given one explicitly null
|
|
150
|
-
field is `kind: :missing` on that field rather than being told to "supply
|
|
151
|
-
exactly one field", which is what it had done, and a list element that isn't
|
|
152
|
-
what the list holds is a branded refusal naming the element rather than a raw
|
|
153
|
-
`NoMethodError`.
|
|
154
|
-
|
|
155
|
-
**And the other half of the question is answered too**:
|
|
156
|
-
`Response#input_errors` / `QueryError#input_errors` /
|
|
157
|
-
`GraphQLError#input_errors` read a **server's** rejection back into the same
|
|
158
|
-
`InputError` — from the `extensions.input` convention, graphql-ruby's
|
|
159
|
-
variable-coercion `problems`, a recognized `extensions.code`, or the argument
|
|
160
|
-
Hasura names in `extensions.path` (`"$.selectionSet.<field>.args.<name>"`,
|
|
161
|
-
list indices and nested input keys included), so `limit: -5` against anything
|
|
162
|
-
on that engine — PokeAPI, Nhost — comes back as an `InputError` on `path:
|
|
163
|
-
["limit"]`, the field a form highlights. With Hasura the **path is the test,
|
|
164
|
-
not the code**: `validation-failed` is also what it sends for a query that
|
|
165
|
-
doesn't parse, and attaching your own `.graphql` file to a form field would
|
|
166
|
-
be worse than saying nothing. **`#path` is the input path the server stated,
|
|
167
|
-
or empty** — a GraphQL error's own `path` names a *selection* rather than an
|
|
168
|
-
input slot, so it is never stood in for one: a server that sends
|
|
169
|
-
`extensions.input.path` as a dotted String, or with a segment that is neither
|
|
170
|
-
a field name nor an index, gets `#path` `[]` and `#field` `nil` instead of a
|
|
171
|
-
plausible-looking field name for a slot the input hasn't got, which a form
|
|
172
|
-
doing `errors.add(e.field.underscore, …)` would have highlighted. The same
|
|
173
|
-
goes for a recognized `extensions.code` that names no `argumentName`; the
|
|
174
|
-
`kind` and the `details` the server did state still stand. A message goes
|
|
175
|
-
through the same filter, since graphql-ruby quotes the rejected value as a
|
|
176
|
-
matter of course; `#details` is checked for type as well as key; no
|
|
177
|
-
`#coordinate` is invented for a problem that states no path of its own; and a
|
|
178
|
-
server's sentence earns a `kind` only where one is honest (`:not_a_member`
|
|
179
|
-
with the enum's members, `:unknown` with the input coordinate, `:missing` for
|
|
180
|
-
a null it won't take), everything else staying `:refused` with the server's
|
|
181
|
-
own words — one Hasura sentence covers both `limit: -5` and `limit: "lots"`,
|
|
182
|
-
so `:out_of_range` would be a guess. A rejection that says nothing
|
|
183
|
-
identifying it as input — a bare `validates:` failure — is left alone rather
|
|
184
|
-
than guessed at; the convention is one `Validator` away
|
|
185
|
-
([errors](docs/errors.md#when-the-server-rejects-the-input)).
|
|
186
|
-
- **A result now has real JSON, and it round-trips.** `result.to_json` was
|
|
187
|
-
Ruby's `Object#to_json` — the `#inspect` string, quoted
|
|
188
|
-
(`"\"#<PersonQuery::Result:0x000...>\""`) — with no exception and no
|
|
189
|
-
warning, so the first production log line or cache write that reached for it
|
|
190
|
-
silently stored nothing. Under Rails it was worse in a quieter way:
|
|
191
|
-
ActiveSupport's `Object#as_json` reflects over the ivars, so `render json:
|
|
192
|
-
result` shipped the *Ruby* prop names, trailing underscores included
|
|
193
|
-
(`{"when_":"2024-01-15"}` for a field the schema calls `when`). The rule now
|
|
194
|
-
is one sentence: **a result's JSON is the wire shape** — the response keys,
|
|
195
|
-
and each leaf back through its scalar registration's `serialize:` — so
|
|
196
|
-
`Result.from_h(JSON.parse(result.to_json)) == result`. `#as_json` is
|
|
197
|
-
generated beside `from_h` as its mirror, and `#to_json` goes through it, so
|
|
198
|
-
`render json:` and a plain `.to_json` agree. `#to_h` is unchanged and still
|
|
199
|
-
the Ruby view (Symbol prop names, `T::Enum` members, codec-built objects):
|
|
200
|
-
a Symbol-keyed hash can't be mistaken for a server's response, and a JSON
|
|
201
|
-
string can, so the JSON is the one that has to be true. The trip is as
|
|
202
|
-
faithful as each scalar's own `cast:`/`serialize:` pair — a `Time` goes back
|
|
203
|
-
out with the microseconds its registration writes, and a `cast:` with no
|
|
204
|
-
`serialize:` has no wire spelling at all, so its value reaches the encoder
|
|
205
|
-
as it is (the same reason an input can't send one). A `register_enum`
|
|
206
|
-
`fallback:` member is in no to-wire table — several wire values collapse into
|
|
207
|
-
it, and it is exactly the member a drifted response casts to — so serializing
|
|
208
|
-
one falls back to the member's own `#serialize`, which casts back to the
|
|
209
|
-
fallback; without a `fallback:` the table is total and a miss still raises.
|
|
210
|
-
The rule's reasoning is in [DECISIONS.md](DECISIONS.md).
|
|
211
|
-
- **A field named after a method every struct answers now generates, with a
|
|
212
|
-
trailing underscore.** `class` becomes the prop `class_`, `hash` becomes
|
|
213
|
-
`hash_`, and so on for `display`, `to_json`, `each`, and `supplied` on an
|
|
214
|
-
input — in results and input types alike. Both sides used to refuse: the
|
|
215
|
-
result side told you to alias the key in the query, and the input side told
|
|
216
|
-
you nothing you could act on, because a schema's field name is not yours to
|
|
217
|
-
rename. `class`, `hash` and `display` are columns somebody has, and a Hasura
|
|
218
|
-
`bool_exp` has one input field per column, so that refusal turned whole
|
|
219
|
-
schemas away. One rule, both directions: a prop may not shadow a method its
|
|
220
|
-
struct answers, so the prop — and only the prop — moves out of the way. The
|
|
221
|
-
wire is untouched, so the query you wrote, the request that goes out and the
|
|
222
|
-
response that comes back all keep the schema's spelling; `result.class` is
|
|
223
|
-
still Ruby's `class`, and `result.class_` is the field. The prop is the
|
|
224
|
-
field's one Ruby name — `.new`, `.coerce`, a **result's** `#to_h` and pattern
|
|
225
|
-
matching all use `class_`, while an **input** struct's `#to_h` is the wire
|
|
226
|
-
hash it would send (`{"class" => …}`) and input structs don't pattern-match
|
|
227
|
-
at all. An `InputError`'s structured half is the wire's throughout, so a
|
|
228
|
-
refusal on that field reports `#path` `["class"]` and `#coordinate`
|
|
229
|
-
`"Tricky.class"`. Generated source notes the rename on the line above the
|
|
230
|
-
prop (`# wire: class — reserved as a prop name`), which is the one
|
|
231
|
-
prop-vs-wire difference a reader can't infer. A key you aliased in the query
|
|
232
|
-
to get past the old refusal still generates from the alias — drop it and
|
|
233
|
-
regenerate if you want the field's own name back.
|
|
234
|
-
|
|
235
|
-
**The reserved names are a list the gem owns**, rather than whatever
|
|
236
|
-
`T::Struct` answered to in the generating process. Deriving them made
|
|
237
|
-
generation depend on require order: with ActiveSupport loaded first a result
|
|
238
|
-
key named `asJson` was refused, loaded second it became a prop that shadowed
|
|
239
|
-
the real `#as_json`, so `render json: result` serialized the field. The list
|
|
240
|
-
is what a struct answers — the public instance methods of `T::Struct` and
|
|
241
|
-
`Object`, the hooks Ruby or Rails call on an object that doesn't define one
|
|
242
|
-
(`initialize`, `to_ary`, `to_a`, `to_hash`, `to_str`, `to_int`, `to_proc`,
|
|
243
|
-
`to_json`, `as_json`, `to_param`, `to_query`, `try`, `presence`, `each`,
|
|
244
|
-
`deconstruct`, `deconstruct_keys`), and the methods the gem's own mixins
|
|
245
|
-
define. Kernel's *private* methods are deliberately not on it: a struct
|
|
246
|
-
doesn't answer them, and `format`, `select`, `test`, `open`, `load` and `pp`
|
|
247
|
-
are ordinary database columns. The rule itself has one public home,
|
|
248
|
-
`GraphWeaver::Codegen.prop_name`, for the parts of the gem outside generation
|
|
249
|
-
that have to agree with it.
|
|
250
|
-
- **A federation `@key` on a reserved field generates instead of refusing.**
|
|
251
|
-
`@key(fields: "class")` was refused with nowhere to send you — a subgraph's
|
|
252
|
-
`@key` field is not yours to rename — and `@key(fields: "hash")` emitted
|
|
253
|
-
`def self.slot(hash:)` beside `const :hash_`, so reading `slot.hash_` and
|
|
254
|
-
writing it back raised a bare `ArgumentError`. A key kwarg now takes the same
|
|
255
|
-
trailing underscore the prop does (`Representations.room(class_: …)`), and
|
|
256
|
-
still sends `"class"` on the wire. Regenerate if a `@key` of yours names such
|
|
257
|
-
a field. ([federation](docs/federation.md))
|
|
258
|
-
- **A `@key` that selects through a list stays a list.** `@key(fields: "id
|
|
259
|
-
lineItems { sku }")` over a `[LineItem!]!` typed its kwarg as one
|
|
260
|
-
`T::Hash[…]`: the correct list was refused and a single hash was accepted and
|
|
261
|
-
sent, which described an entity that doesn't exist. The kwarg is
|
|
262
|
-
`T::Array[T::Hash[…]]` now, the list stays a list on the wire, and the
|
|
263
|
-
generated key set spells the hop `"lineItems[]"`. No other `@key` shape
|
|
264
|
-
changes — **regenerate** if one of yours selects through a list.
|
|
265
|
-
- **A block-built type helper is named for its source, not for what is
|
|
266
|
-
loaded.** `GraphWeaver.extend_type("Widget") { … }` minted its module by
|
|
267
|
-
counting whatever `GraphWeaver::TypeHelpers` constants already existed, which
|
|
268
|
-
made the name a function of how many times *that process* had read the
|
|
269
|
-
registry: `rake graph_weaver:generate` baked `WidgetV3` while a plain boot
|
|
270
|
-
only ever creates `WidgetV1`, so `rails server` died on "includes
|
|
271
|
-
GraphWeaver::TypeHelpers::WidgetV3, but nothing registers it" while `verify`,
|
|
272
|
-
run moments earlier on the same tree, called it up to date. The module is
|
|
273
|
-
named for where the block is written and what it extends —
|
|
274
|
-
`TypeHelpers::Pet` at the top level, `TypeHelpers::Billing::Pet` in `graph
|
|
275
|
-
:billing` — so two graphs extending one type name each get their own
|
|
276
|
-
constant, and the same source counts the same way in every process and after
|
|
277
|
-
a `to_prepare` reload. **Regenerate.** The reasoning is in
|
|
278
|
-
[DECISIONS.md](DECISIONS.md).
|
|
279
|
-
- **Generation refuses an input type whose fields collide on one Ruby prop.**
|
|
280
|
-
Two input fields that underscore onto the same name — `nameWithOwner` and
|
|
281
|
-
`name_with_owner`, or `class` and `class_`, since `class` is renamed out of a
|
|
282
|
-
method's way — emitted `const :name_with_owner` twice, and the generated file
|
|
283
|
-
raised `ArgumentError` when it was *required*, from a trace with no schema in
|
|
284
|
-
it. It now refuses at generation, naming both fields, the type and the prop —
|
|
285
|
-
and says which variable reaches what (`$outer reaches Inner through inner`),
|
|
286
|
-
since the colliding type is usually several hops below the declaration you
|
|
287
|
-
wrote. The escape is to pass the object as a literal with a variable per
|
|
288
|
-
field, a schema's field name not being yours to rename; where the type is
|
|
289
|
-
reached through a list there is no such form, and the refusal says plainly
|
|
290
|
-
that no form of the query generates. The output side has always refused this;
|
|
291
|
-
the three real schemas swept (GitHub, PokeAPI, countries — 12,684 input
|
|
292
|
-
fields) contain no such pair.
|
|
293
|
-
- **An input type named `Result`, `QUERY` or `Representations` is refused**,
|
|
294
|
-
the way an enum by one of those names already was. Those are the constants
|
|
295
|
-
every generated query module defines, and nothing checked an input struct
|
|
296
|
-
against them: `input Result` emitted `class Result` twice, the second
|
|
297
|
-
reopening the first into one struct that answered for both the variable and
|
|
298
|
-
the response — generated code that looks authoritative and is wrong — while
|
|
299
|
-
`input QUERY` raised a bare `TypeError: QUERY is not a class` from Ruby,
|
|
300
|
-
about the document heredoc. The refusal names the type and the three
|
|
301
|
-
constants. No public schema in a 23-schema sweep declares one (SpaceX has an
|
|
302
|
-
*object* named `Result`, which is unaffected — a result class is named for
|
|
303
|
-
the response key, not the type).
|
|
304
|
-
- **`respond_to?` on a generated result struct no longer answers true for a
|
|
305
|
-
name that doesn't exist.** It said true for any near miss, so the standard
|
|
306
|
-
duck-typing guard was the thing that broke — `obj.pet if obj.respond_to?(:pet)`
|
|
307
|
-
raised the very `NoMethodError` the hint exists to explain. `method_missing`
|
|
308
|
-
still hints; the cost is that `struct.method(:nmae)` now raises Ruby's own
|
|
309
|
-
bare `NameError` rather than the hinted one.
|
|
310
|
-
- **Generated result structs behave like ordinary Ruby objects**: value `==`
|
|
311
|
-
(with `eql?` and `hash`, so a result works as a hash key),
|
|
312
|
-
`deconstruct_keys` for pattern matching, and `#to_h`. All three go the whole
|
|
313
|
-
way down a nested result. `#to_h` is the Ruby shape, not the wire's —
|
|
314
|
-
snake_case prop names as Symbol keys, nils kept, enums as their `T::Enum`
|
|
315
|
-
members — so it is a view, not something to send back to a server. A result
|
|
316
|
-
key that would collide with one of the new names (`deconstruct_keys`)
|
|
317
|
-
generates with a trailing underscore, as any other reserved name does.
|
|
318
|
-
- **A response whose shape drifted names the field and what arrived.** A server
|
|
319
|
-
that sends an object where a list belongs used to raise *"failed to cast
|
|
320
|
-
response into Q::Result::Person: Parameter 'data': Expected type
|
|
321
|
-
T::Hash[String, T.untyped], got T::Array[String]"* — the field named nowhere,
|
|
322
|
-
the struct named being the parent, and the "got" describing pairs `Hash#map`
|
|
323
|
-
had made on the way in rather than anything the server sent. It reads `pets:
|
|
324
|
-
expected a list, but the server sent an object` now, and points into the list
|
|
325
|
-
(`pets.0: expected an object, but the server sent null`) when an element is
|
|
326
|
-
the problem. A nullable sibling that is legitimately `null` is never the one
|
|
327
|
-
blamed: the guard that skips it reads the prop's type with its nilable-ness
|
|
328
|
-
intact, where it used to strip it and so never fired.
|
|
329
|
-
- **`Response#report` no longer calls a method a server's error path names.**
|
|
330
|
-
The walk that resolves `entity_ids` stepped through the typed data with
|
|
331
|
-
`respond_to?`, which is true of every `Object` method — so an error path with
|
|
332
|
-
a segment named `freeze` **froze the caller's result struct** and then
|
|
333
|
-
reported an id for a field that doesn't exist, `display` printed the struct to
|
|
334
|
-
stdout, and `tap`/`send`/`method` raised `LocalJumpError`/`ArgumentError` out
|
|
335
|
-
of error handling. It reads the struct's own props now, so a segment that
|
|
336
|
-
isn't a field of that struct resolves to nil — and a field whose prop took a
|
|
337
|
-
trailing underscore (`class` → `class_`) resolves for the first time.
|
|
338
|
-
- **The emitted `QUERY` is the query you wrote**, in two ways it wasn't.
|
|
339
|
-
Naming an anonymous operation splices the module's name into the operation's
|
|
340
|
-
own declaration at a position graphql-ruby reports — which is a byte offset
|
|
341
|
-
measured against character line starts, and a whole-document offset for a
|
|
342
|
-
token with no newline after it. A comment above a single-line `{ … }`, or any
|
|
343
|
-
multibyte character before the operation, put the name in the wrong place;
|
|
344
|
-
the emitted module still generated and `verify` still reported it up to date,
|
|
345
|
-
and every call failed on the wire. The splice is now measured correctly
|
|
346
|
-
**and** the spliced document is re-parsed — if it doesn't declare the name,
|
|
347
|
-
generation refuses and names the file rather than shipping a module that
|
|
348
|
-
can't run. The heredoc also used to right-strip every line, which silently
|
|
349
|
-
edits the *value* of a block-string argument — trailing whitespace inside
|
|
350
|
-
`"""…"""` is significant — so a query carrying one now sends what the
|
|
351
|
-
`.graphql` file says.
|
|
352
|
-
- **"Select `__typename`" now says why the ones you did select don't count.**
|
|
353
|
-
Putting `__typename` in every `... on Type` and nowhere else looks like
|
|
354
|
-
compliance — it is what Linear's published SDK documents do, and it refuses
|
|
355
|
-
36 of their 250 operations — and the tag really is on the wire; it is just
|
|
356
|
-
read *after* the dispatch it would decide, and a member the query never named
|
|
357
|
-
would carry none at all. The refusal says so when that is the shape in front
|
|
358
|
-
of it, and keeps the short message otherwise. The rule is unchanged, and
|
|
359
|
-
[generated modules](docs/generated_modules.md#abstract-types) states it too.
|
|
360
|
-
- **Two codegen refusals name what you wrote.** Two result keys that generate
|
|
361
|
-
one class name now name both of them, the way the sibling prop-collision
|
|
362
|
-
message already did; and a module named `T` is refused rather than emitting
|
|
363
|
-
code that shadows Sorbet's `T` in its own body.
|
|
364
|
-
- **A date and a timestamp are refused for each other, both ways.** A date
|
|
365
|
-
reads back as a `Date` and a timestamp as a `Time`; the same rule now holds
|
|
366
|
-
for variables on the way out. `DateTime` is a `Date` to Ruby, so it passed
|
|
367
|
-
straight through the cast and went on the wire as a full timestamp —
|
|
368
|
-
`"2024-01-15T10:20:30+00:00"` where the schema said `ISO8601Date`, which a
|
|
369
|
-
lenient server truncated and a strict one refused — while a `Time` for an
|
|
370
|
-
`ISO8601Date`, and a `Date` for an `ISO8601DateTime`, surfaced Ruby's raw
|
|
371
|
-
*"no implicit conversion of Time into String"*. Truncating to the date here
|
|
372
|
-
would be the same guess made silently, so each now raises an `InputError`
|
|
373
|
-
naming the variable and the class: `$d of On: expected a Date, got a DateTime
|
|
374
|
-
— pass .to_date if dropping the time of day is what you meant`, and `$t of
|
|
375
|
-
At: expected a Time, got a Date — a Date has no time of day — pass the Time
|
|
376
|
-
you mean`. What a timestamp variable *accepts* grew to match — a `DateTime`
|
|
377
|
-
and the `ActiveSupport::TimeWithZone` from `Time.zone.now` both convert
|
|
378
|
-
losslessly, and both used to raise.
|
|
379
|
-
- **A scalar you cast yourself gets the library's guard and its verdict.** With
|
|
380
|
-
a registration like `register_scalar("Date", Date, cast: :iso8601, serialize:
|
|
381
|
-
:iso8601)`, the emitted guard was a bare `value.is_a?(Date)` pass-through —
|
|
382
|
-
so a `DateTime` (which *is* a `Date` to Ruby) went by untouched and your own
|
|
383
|
-
`serialize:` wrote `"2024-01-15T10:20:30+00:00"` into a field the schema says
|
|
384
|
-
holds a date, the same cross-type trap the built-in `Date` refuses. It is
|
|
385
|
-
refused here now, with the same words. Anything else wrong used to reach your
|
|
386
|
-
codec and come back as Ruby's own sentence about an argument you never
|
|
387
|
-
wrote — `$input of Probe: birthday: no implicit conversion of Integer into
|
|
388
|
-
String`, filed under `kind: :unparseable`. The verdict is the library's now,
|
|
389
|
-
whoever wrote the codec, and it splits the way Ruby itself does: a
|
|
390
|
-
`TypeError` means the *class* was wrong and reads `$input of Probe: birthday:
|
|
391
|
-
expected a Date, got 5` under `kind: :type_mismatch`, while an
|
|
392
|
-
`ArgumentError` means the *content* was and keeps the parser's own words
|
|
393
|
-
(`invalid date`, `kind: :unparseable`) — the difference between "send
|
|
394
|
-
something else" and "fix the text", which is the half a form needs. The same
|
|
395
|
-
split applies to any `TypeError` a `cast:` or `serialize:` raises. The guard
|
|
396
|
-
is emitted into your generated files, so a checked-in one keeps the old
|
|
397
|
-
`is_a?` until you regenerate.
|
|
398
|
-
- **"has no cast" now says which mistake you made.** Two unrelated
|
|
399
|
-
misconfigurations reached the same refusal: a registered class none of the
|
|
400
|
-
three probes (`.parse`, `.load`, `Kernel#Type`) matched, and a `type:` given
|
|
401
|
-
by *name*, which is never probed at all because there is no class in hand.
|
|
402
|
-
The advice differs — name a `cast:`, versus pass the class — but the sentence
|
|
403
|
-
didn't. Each now names its own cause: the class form lists the three probes
|
|
404
|
-
that found nothing, and the string form says it was registered by name and
|
|
405
|
-
shows the class form to switch to. The string form's no-probing behavior is
|
|
406
|
-
unchanged and correct;
|
|
407
|
-
[scalars](docs/scalars.md#registering-a-class-of-your-own) now says so where
|
|
408
|
-
the string form is introduced.
|
|
409
|
-
- **A type-string scalar's bad value is named like every other one.**
|
|
410
|
-
`register_scalar("Vector", "T::Array[Float]")` gives the field no coercer, so
|
|
411
|
-
only sorbet stands between the value and the struct — and the check that
|
|
412
|
-
turns sorbet's complaint back into the library's asked `#valid?`, which stops
|
|
413
|
-
at the outermost type. `[1, 2, 3]` for a `T::Array[Float]` passed it while
|
|
414
|
-
the struct's setter (which checks recursively) refused, so the refusal came
|
|
415
|
-
out blaming the list that *held* the struct, with empty `details` and
|
|
416
|
-
sorbet's wording: *"items: invalid input for LineItemInput: Parameter
|
|
417
|
-
'vector': Can't set LineItemInput.vector to [1, 2, 3] (instance of Array) -
|
|
418
|
-
need a T::Array[Float]"*. It now asks the same question the setter does, so
|
|
419
|
-
it says *"vector: expected T::Array[Float], got [1, 2, 3]"* with
|
|
420
|
-
`coordinate: "LineItemInput.vector"`, `path: ["input", "items", 0, "vector"]`
|
|
421
|
-
and `details: {type:}` — the same shape a class-registered scalar's refusal
|
|
422
|
-
has always had. Lists of lists and nilable fields alike.
|
|
423
|
-
- **`register_scalar` warns when the Ruby type inherits `eql?`.** A result
|
|
424
|
-
compares its props with `eql?`, so a value object that inherits it compares
|
|
425
|
-
by identity: two results parsed from the same bytes are unequal, and neither
|
|
426
|
-
works as a hash key, while the leaf itself compares fine.
|
|
427
|
-
`alias_method :eql?, :==` plus a matching `hash` is the fix. The warning
|
|
428
|
-
fired only for a class that defined `==` and forgot `eql?`, though its own
|
|
429
|
-
comment described the whole hazard — a class overriding *neither* breaks
|
|
430
|
-
result equality identically and got nothing. One rule now: a registered class
|
|
431
|
-
that inherits `#eql?` warns. `String`, `Integer`, `Float`, `Date`, `Time`,
|
|
432
|
-
`DateTime` and `BigDecimal` all define it and stay silent; a `T::Enum` is
|
|
433
|
-
exempt, its values being singletons; and a class that gets `==` from
|
|
434
|
-
`Comparable` and stops there is a true positive, not a false one. See
|
|
435
|
-
[scalars](docs/scalars.md#registering-a-class-of-your-own).
|
|
436
|
-
- **A timestamp keeps its sub-second part on the way out.** `Time#iso8601`
|
|
437
|
-
takes no precision, so `"2024-01-15T10:20:30.500Z"` read off the wire went
|
|
438
|
-
back out as `"2024-01-15T10:20:30Z"` — half a second gone from an
|
|
439
|
-
`updatedAt` concurrency token, or from the `since:` a window is read on.
|
|
440
|
-
graphql-ruby's own `ISO8601DateTime` writes whole seconds, so a stock Ruby
|
|
441
|
-
server never showed it; **any JS/Apollo server writes milliseconds on every
|
|
442
|
-
timestamp**. A `Time` (or `DateTime`) that carries a fraction is now written
|
|
443
|
-
with microseconds, and one that doesn't sends the bytes it always has.
|
|
444
|
-
- **A `Float` variable must be a finite number.** `Kernel#Float("1e400")` is
|
|
445
|
-
`Infinity` rather than a raise (so is `(10**400).to_f`), and JSON has no
|
|
446
|
-
spelling for a non-finite number — the GraphQL spec excludes them from
|
|
447
|
-
`Float` outright. It used to travel as far as the transport, which blamed
|
|
448
|
-
the whole request (*"variables are not JSON-serializable"*); the refusal now
|
|
449
|
-
names the variable and the value. Every door is checked, so an actual
|
|
450
|
-
`Float::INFINITY` is refused as well as a string that parses to one — and
|
|
451
|
-
`register_scalar "Ratio", Float` now reads a whole number off the wire
|
|
452
|
-
exactly as the built-in `Float` does, which it didn't before.
|
|
453
|
-
- **A variable with no JSON form is refused at the wire.** `JSON.generate`
|
|
454
|
-
renders a value it doesn't know as that value's `#to_s`, so an `Upload!`
|
|
455
|
-
given a real file went out as `{"file":"#<File:0x00007f…>"}` — 200 back, no
|
|
456
|
-
error, and a memory address in the server's database. A `File`, an `IO`, a
|
|
457
|
-
`Pathname` or a plain object is refused before the body is built, naming the
|
|
458
|
-
variable and what to do instead; a `Date`, `Time`, `BigDecimal` or `Symbol`
|
|
459
|
-
still travels as its string. File uploads still need your own transport —
|
|
460
|
-
graph_weaver doesn't implement the GraphQL multipart request spec.
|
|
461
|
-
- **A variable's whole trip onto the wire is branded.** Serialization ran
|
|
462
|
-
*outside* the coercion's rescue, so anything it raised arrived as a bare
|
|
463
|
-
`NoMethodError` naming neither the variable nor the operation — now it reads
|
|
464
|
-
`$budget of Store: …`, the way a coercion failure already did.
|
|
465
|
-
- **`filter_parameters` scrubs a message's value at every depth, as it already
|
|
466
|
-
did `#value`.** The message side only ever asked whether the *variable's own*
|
|
467
|
-
name was filtered, so `execute(credentials: { token: "…" })` refused with the
|
|
468
|
-
secret quoted in the sentence — and in the `warn` line `Error#initialize`
|
|
469
|
-
writes — while `InputError#value` sitting beside it read `[FILTERED]`. The
|
|
470
|
-
value a message quotes now goes through the same scrub as `#value`, so a
|
|
471
|
-
filtered key one level in reads `got {"token" => "[FILTERED]"}`. Messages
|
|
472
|
-
change only where a filter matches; nothing else moved.
|
|
473
|
-
- **Every transport failure names the endpoint it failed against.**
|
|
474
|
-
`TransportError` and `ServerError` carry `#url`, say it in the message
|
|
475
|
-
(`HTTP 502: <html>… — POST https://api.example.com/graphql`) and in `#to_h` —
|
|
476
|
-
an app talking to two graphs used to get `HTTP 502` and nothing else, so every
|
|
477
|
-
investigation started with "which one". The url they name is the one the gem
|
|
478
|
-
is **willing to say**: a url's userinfo and any query parameter
|
|
479
|
-
`GraphWeaver.filter_parameters` already filters are folded to `[FILTERED]`, by
|
|
480
|
-
the same list that scrubs the variables line. That applies everywhere a
|
|
481
|
-
transport says its url — the boot line, the per-request `debug` lines,
|
|
482
|
-
`Transport#inspect`, and `payload[:url]` on `execute.graph_weaver`, i.e. your
|
|
483
|
-
APM — so `https://svc:hunter2@api.example.com/graphql?access_token=…` no
|
|
484
|
-
longer lands in a log file or a third-party trace store. `Transport#url` is
|
|
485
|
-
unchanged: it is where requests actually go, and `#safe_url` is the sayable
|
|
486
|
-
one.
|
|
487
|
-
- **A schema dump no longer records the credential that introspected it.** The
|
|
488
|
-
provenance stamp (`schema:refresh` and `cache:`) wrote the transport's url
|
|
489
|
-
verbatim, so a url carrying userinfo or an `?access_token=` landed in a file
|
|
490
|
-
that gets committed. It now records the endpoint bare — userinfo and any
|
|
491
|
-
query parameter `filter_parameters` filters are dropped, the rest kept — and
|
|
492
|
-
re-introspection still authenticates from the dump's `auth_env`, which is
|
|
493
|
-
where the token belonged. The loader's log lines and errors say the url the
|
|
494
|
-
way every transport failure now does, secrets marked `[FILTERED]`. A dump
|
|
495
|
-
written by an earlier version from such a url still holds the token: refresh
|
|
496
|
-
it once, and rotate the token if the file was pushed.
|
|
497
|
-
- **A `multipart/mixed` response is named rather than dumped.** Forcing an
|
|
498
|
-
incremental-delivery `Accept` used to raise `non-GraphQL response:` followed
|
|
499
|
-
by the whole multipart payload, which misdiagnoses a body that is perfectly
|
|
500
|
-
well-formed GraphQL — just more than one document. It says so now: "this
|
|
501
|
-
response is incremental delivery (@defer/@stream), which this client doesn't
|
|
502
|
-
read".
|
|
503
|
-
- **A response behind a UTF-8 BOM now parses.** RFC 8259 §8.1 lets a parser
|
|
504
|
-
ignore a leading BOM and Ruby's doesn't, so a .NET/IIS-fronted endpoint's
|
|
505
|
-
answer came back as `non-GraphQL response: {"data":…}` — a body that looks
|
|
506
|
-
perfectly good, broken by three invisible bytes. And **an empty body says so**
|
|
507
|
-
(`HTTP 204: empty response body`) rather than trailing off after the colon,
|
|
508
|
-
which read as a truncated message rather than as the answer it was.
|
|
509
|
-
- **Two more net/http failures arrive as `TransportError`.** A garbage status
|
|
510
|
-
line (`Net::HTTPBadResponse` — a misbehaving proxy, HTTP sent to a port
|
|
511
|
-
speaking something else, or a keep-alive socket that desynced) and a body
|
|
512
|
-
that isn't the gzip it claims to be (`Zlib::Error`) used to escape the
|
|
513
|
-
`GraphWeaver::Error` umbrella entirely, so `rescue GraphWeaver::Error` missed
|
|
514
|
-
them and `Retry` treated a retriable failure as fatal. Both are now
|
|
515
|
-
`TransportError` — which retries, on a fresh connection — as is
|
|
516
|
-
`Net::ProtocolError`. `Transport::Faraday` already classified all three this
|
|
517
|
-
way; the two shipped transports now agree.
|
|
518
|
-
- **A retry policy that was inert behind a gateway now fires.** `Retry` read
|
|
519
|
-
only the failures that *raised*, and Apollo Router answers everything it
|
|
520
|
-
decides itself with a GraphQL errors body — rate limiting is `503` plus
|
|
521
|
-
`REQUEST_RATE_LIMITED`, its own faults are `500` plus a code — so the body
|
|
522
|
-
won over the status and `retries: 3` made one attempt. One rule now: a
|
|
523
|
-
response retries when its status is one a `ServerError` retries on (5xx, 408,
|
|
524
|
-
429), or when its error codes are named in `retry_codes:`. A `200` is never
|
|
525
|
-
retried on status, so a router's partial `GATEWAY_TIMEOUT` still needs
|
|
526
|
-
`retry_codes:` to opt in, and a mutation still gets one attempt unless
|
|
527
|
-
`retry_mutations: true`. `REQUEST_RATE_LIMITED` joins
|
|
528
|
-
`GraphQLError::THROTTLE_CODES`, so `#throttled?` answers it too. The
|
|
529
|
-
reasoning is in [DECISIONS.md](DECISIONS.md).
|
|
530
|
-
- **A retry delay can no longer kill the retry loop.** `base_delay:` and
|
|
531
|
-
`max_delay:` are refused if negative, where the typo is; a custom `backoff:`
|
|
532
|
-
returning one is floored at no wait. Either used to reach `Kernel#sleep`,
|
|
533
|
-
which raises `ArgumentError` — so one mistyped option reported as a bug
|
|
534
|
-
somewhere else, and the failure being retried was lost behind it.
|
|
535
|
-
- **`Transport::HTTP`'s connection pool is fork-safe.** A socket idle at `fork`
|
|
536
|
-
time was inherited by every child, and a round trip carries nothing saying
|
|
537
|
-
which process opened it — so forked workers interleaved requests on one fd and
|
|
538
|
-
a caller could receive a well-formed GraphQL response to *another process's*
|
|
539
|
-
query, with no exception anywhere. The trigger is the documented boot path:
|
|
540
|
-
Puma `preload_app!` (or Sidekiq) plus an initializer that introspects, which
|
|
541
|
-
leaves exactly one warm socket in the pool. The pool now belongs to the
|
|
542
|
-
process that built it — on the first request after a fork the inherited
|
|
543
|
-
sockets are abandoned (not closed: that would take down the fd the parent is
|
|
544
|
-
still using) and the pool's permits are rebuilt.
|
|
545
|
-
- **`GraphWeaver.new(url, pool_size: N)`** sizes the bundled HTTP transport's
|
|
546
|
-
connection pool from the url client, where before only `Transport::HTTP.new`
|
|
547
|
-
took it and an app had to set `RAILS_MAX_THREADS` to reach it. With
|
|
548
|
-
`transport: :faraday` it is refused rather than dropped — Faraday's adapter
|
|
549
|
-
owns its connections.
|
|
550
|
-
- **`Transport::Faraday#url` keeps a url's query string.** Faraday moves it
|
|
551
|
-
into the connection's default params and strips it from `url_prefix`, so
|
|
552
|
-
`Transport::Faraday.new("https://api.example.com/graphql?apiKey=…").url`
|
|
553
|
-
reported an endpoint no request goes to — the one the boot log line names,
|
|
554
|
-
and the one `graphql: :wire` serves a graph's resolvers at. `#url` is now
|
|
555
|
-
reconstructed from where requests actually go, through Faraday's own encoder
|
|
556
|
-
— so an Array param reads `a[]=1&a[]=2`, the way the wire carries it, and a
|
|
557
|
-
Hash param is not a URL-escaped `Hash#inspect`.
|
|
558
|
-
- **A request header can be a callable.** On `Transport::HTTP` a `headers:`
|
|
559
|
-
value answering `#call` is resolved per request rather than captured when the
|
|
560
|
-
transport was built, so a rotating credential needs no new transport:
|
|
561
|
-
|
|
562
|
-
```ruby
|
|
563
|
-
GraphWeaver::Transport::HTTP.new(url, headers: {
|
|
564
|
-
"Authorization" => -> { "Bearer #{Tokens.fetch}" },
|
|
565
|
-
"X-Tenant" => -> { Current.tenant&.id }, # nil ⇒ header omitted
|
|
566
|
-
})
|
|
567
|
-
```
|
|
568
|
-
|
|
569
|
-
A value (or a call) of `nil` sends no such header. `Transport::Faraday`
|
|
570
|
-
resolves one per request too, so a rotating credential means the same thing
|
|
571
|
-
on both bundled transports. **And `auth:` takes a token that rotates** —
|
|
572
|
-
`auth: -> { Tokens.fetch }` is resolved per request like any other callable
|
|
573
|
-
header value, where it used to raise and send you to Faraday's middleware.
|
|
574
|
-
- **A non-String header value is sent as its `to_s`** on both transports, rather
|
|
575
|
-
than escaping as `NoMethodError: undefined method 'strip'` from inside
|
|
576
|
-
net/http, naming neither graph_weaver nor the header. The documented
|
|
577
|
-
`"X-Tenant" => -> { Current.tenant&.id }` crashed in any app whose ids are
|
|
578
|
-
Integers. A callable is still resolved first and `nil` still drops the header.
|
|
579
|
-
- **Both transports name themselves to the graph.** Every request carries
|
|
580
|
-
`apollographql-client-name` and `apollographql-client-version`, which is what
|
|
581
|
-
an Apollo Router or GraphOS keys client attribution on — a real router's span
|
|
582
|
-
showed `client.name: ""` for everything this gem sent. The name is your Rails
|
|
583
|
-
application's, or `graph_weaver` outside Rails; the version is the gem's.
|
|
584
|
-
Override either in `headers:`, which is how one app names its several clients
|
|
585
|
-
apart. `Transport.default_headers` is the whole set; `DEFAULT_HEADERS` stays
|
|
586
|
-
the fixed half of it.
|
|
587
|
-
- **A prebuilt `Faraday::Connection` now sends graph_weaver's `User-Agent`.**
|
|
588
|
-
Faraday pre-fills its own on every connection, so the fill-in-the-blanks
|
|
589
|
-
`||=` never fired and the traffic attributed to `Faraday v…` — defeating the
|
|
590
|
-
header's whole purpose. A User-Agent you set yourself still wins.
|
|
591
|
-
- **`ServerError#headers` answers any casing.** Transports store response
|
|
592
|
-
headers downcased, so `e.headers["Retry-After"]` — the spelling the server
|
|
593
|
-
sent, and the one a caller reaches for — used to return nil. Lookup by name
|
|
594
|
-
(`#[]`, `#fetch`, `#dig`, `#key?`) now folds the case; iteration, `#keys` and
|
|
595
|
-
`#to_h` still yield the downcased spelling, so logs are unchanged.
|
|
596
|
-
- **A Rails app now sees its GraphQL calls without configuring anything.** The
|
|
597
|
-
railtie sets `GraphWeaver.instrumenter` to the `ActiveSupport::Notifications`
|
|
598
|
-
adapter (an instrumenter the app set is never replaced) and attaches
|
|
599
|
-
`GraphWeaver::LogSubscriber`, so every execution — over the wire and
|
|
600
|
-
in-process — is one notification an APM can subscribe to and one line in the
|
|
601
|
-
log: `GraphWeaver billing/InvoicesQuery (12.3ms) ok`, `… errors [THROTTLED]`,
|
|
602
|
-
`… failed GraphWeaver::TransportError` (unprefixed when the module names no
|
|
603
|
-
graph, so a single-schema app's log reads `GraphWeaver PersonQuery …`).
|
|
604
|
-
**One rule: the summary is info, the wire is debug** — this is the only
|
|
605
|
-
GraphWeaver line at info, so a production log gets one per operation and
|
|
606
|
-
nothing that can carry PII, and turning the logger up to debug adds the query
|
|
607
|
-
and variables *beneath* it rather than repeating it. Measured at ~4.5µs per
|
|
608
|
-
execution all told (0.13µs of that `ActiveSupport::Notifications` itself with
|
|
609
|
-
nothing subscribed), against a round trip measured in milliseconds.
|
|
610
|
-
|
|
611
|
-
**Both auto-wires are opt-out from an initializer.** `GraphWeaver.logger =
|
|
612
|
-
nil` and `GraphWeaver.instrumenter = nil` in `config/initializers` win,
|
|
613
|
-
because the railtie declares them first and `before:
|
|
614
|
-
:load_config_initializers` — Rails gives an initializer an implicit `after:`
|
|
615
|
-
of the previous one the railtie declared, which is how the fallback would
|
|
616
|
-
otherwise land *after* the app's own files and overwrite the nil it had just
|
|
617
|
-
set. An app that reached for `config.after_initialize { GraphWeaver.logger =
|
|
618
|
-
nil }` still works and can drop it. ([logging](docs/logging.md))
|
|
619
|
-
- **The instrumentation payload is a documented contract** ([logging](docs/logging.md#the-payload)),
|
|
620
|
-
the same shape whichever client slot ran the request: `:operation`,
|
|
621
|
-
`:graph`, `:client`, `:status` and `:duration_ms` always;
|
|
622
|
-
`:url`/`:http_status` over the wire, `:schema` in-process; `:error` (the
|
|
623
|
-
exception's class name) and `:code` — the first GraphQL error's code, or a
|
|
624
|
-
`ServerError`'s status, the one key to group an alert by — when there is
|
|
625
|
-
one; and `:retries` under a `Retry`, where each attempt is its own event
|
|
626
|
-
reading 0, 1, 2, so a call that took three goes no longer reads as three
|
|
627
|
-
unrelated slow requests. The query text and the variables are still
|
|
628
|
-
deliberately absent, and now pinned by a spec: `filter_parameters` governs
|
|
629
|
-
the log, which GraphWeaver writes itself, but the payload fans out to
|
|
630
|
-
subscribers that know none of those rules.
|
|
631
|
-
|
|
632
|
-
`:graph` is the Symbol a module was generated under — `nil` for one that
|
|
633
|
-
names none and for a client called directly — so a multi-graph app groups a
|
|
634
|
-
dashboard by graph without parsing operation names. It covers **one
|
|
635
|
-
request**: a generated `execute` labels the request it makes, each one when a
|
|
636
|
-
federated operation fans out, and never what a *server* does while answering,
|
|
637
|
-
so an in-process resolver that calls another API produces an event carrying
|
|
638
|
-
its own graph or none, rather than its caller's. It is read from what codegen
|
|
639
|
-
baked in, never inferred from the client — a wrong graph on a request is
|
|
640
|
-
worse than no graph. Bracketing a request this way is why a generated
|
|
641
|
-
`execute` is now `from_response(dispatch(variables, client:))`:
|
|
642
|
-
`GraphWeaver::QueryModule` owns the call, reading the module's own
|
|
643
|
-
`QUERY`/`OPERATION_NAME`, so nothing about it lands in every generated file.
|
|
644
|
-
Behavior, sigs and the `Response` envelope are identical.
|
|
645
|
-
- **A bare graphql-ruby schema class in a client slot is instrumented like
|
|
646
|
-
every other client.** `GraphWeaver.client = MyApp::Schema` and
|
|
647
|
-
`execute!(client: MyApp::Schema)` ran with no seam at all — no APM event, no
|
|
648
|
-
log line, not even at debug — while `GraphWeaver.new(MyApp::Schema)` around
|
|
649
|
-
the same class had both. So the in-process half of an app was invisible on
|
|
650
|
-
the dashboard, and turning the logger up showed it as a graph that never ran.
|
|
651
|
-
A schema class is now put through the same `InProcess` wrapper
|
|
652
|
-
`GraphWeaver.new(Schema)` builds, wherever a client is read — a graph's
|
|
653
|
-
`client "Billing::Schema"` included. ([logging](docs/logging.md))
|
|
654
|
-
- **An in-process client names an anonymous schema `"anonymous"`.** The
|
|
655
|
-
instrumentation payload's `:schema` was the schema's `#to_s`, which for one
|
|
656
|
-
built from SDL is its object address — a new value every boot, so an APM
|
|
657
|
-
grouping by it got a series per process rather than one per schema. A schema
|
|
658
|
-
class is still named by its constant, in the payload, the debug log and
|
|
659
|
-
`#inspect`. ([logging](docs/logging.md#the-payload))
|
|
660
|
-
- **Turning on debug logging no longer changes what happens.** The debug line
|
|
661
|
-
rendered the variables as JSON itself, *before* the guarded encode — so a
|
|
662
|
-
value with no JSON form (`NaN`, binary) raised a raw `JSON::GeneratorError`
|
|
663
|
-
past `rescue GraphWeaver::Error` whenever a logger was listening at debug,
|
|
664
|
-
and in-process it turned a query that ran into a `ServerError`. The line now
|
|
665
|
-
says `<unloggable: JSON::GeneratorError>` and the request carries on to the
|
|
666
|
-
same outcome it has with no logger set.
|
|
667
|
-
- **A non-federated app no longer logs a phantom "no routing table here"
|
|
668
|
-
warning.** `GraphWeaver::Error` writes a warn line as it is *constructed*,
|
|
669
|
-
and "is this source a composed supergraph?" was asked by calling
|
|
670
|
-
`SchemaLoader.routing_table` and rescuing its refusal — so every app whose
|
|
671
|
-
schema is an ordinary one warned, once per process, about a routing table it
|
|
672
|
-
had never asked for. The question has a predicate now,
|
|
673
|
-
`SchemaLoader.routing_table?`, and nothing builds an error to answer "no".
|
|
674
|
-
- **An app can have more than one schema.** `GraphWeaver.graph` declares one.
|
|
675
|
-
Everything a graph knows is said inside its block, in call style — six
|
|
676
|
-
settings and the three registrations you already write at the top level:
|
|
677
|
-
|
|
678
|
-
```ruby
|
|
679
|
-
GraphWeaver.graph :billing do
|
|
680
|
-
schema -> { Billing::Schema }
|
|
681
|
-
queries "app/graphql/billing/queries"
|
|
682
|
-
output "app/graphql/billing/generated"
|
|
683
|
-
client "Billing::Schema"
|
|
684
|
-
namespace "Billing"
|
|
685
|
-
register_scalar "Money", BigDecimal
|
|
686
|
-
end
|
|
687
|
-
```
|
|
688
|
-
|
|
689
|
-
`schema "x"` sets and a bare `schema` reads back; there is no `schema = "x"`
|
|
690
|
-
form, since the block is `instance_eval`'d and that would be a local variable
|
|
691
|
-
that silently does nothing — graphql-ruby's `field :name` convention. Anything
|
|
692
|
-
else the block calls is refused, naming the nine it takes, as is a graph name
|
|
693
|
-
that isn't a Symbol or a String. `client`, `namespace` and `types_module` each
|
|
694
|
-
take the constant or its name, since generated source spells it either way; a
|
|
695
|
-
setting a graph doesn't say falls back to the top-level one.
|
|
696
|
-
|
|
697
|
-
`generate!`, `verify_generated!`, `check_queries`, `load_generated!`,
|
|
698
|
-
`reload_generated!`, the rake tasks and watch mode all walk every graph, so
|
|
699
|
-
one `rake graph_weaver:generate` does the app and one `rake
|
|
700
|
-
graph_weaver:verify` gates it. `rake graph_weaver:graphs` lists what is
|
|
701
|
-
configured — settings, registrations, and the client each graph calls
|
|
702
|
-
(`rake -T` can't: a task description is baked before `:environment`). The
|
|
703
|
-
hand-rolled recipe this replaces — N `generate!` calls with
|
|
704
|
-
`reset_registrations!` between them — is gone from
|
|
705
|
-
[docs/federation.md](docs/federation.md).
|
|
706
|
-
|
|
707
|
-
**In Rails, declare graphs in the initializer and name an autoloaded schema
|
|
708
|
-
class with a lambda** — `schema -> { Billing::Schema }`. Zeitwerk is set up
|
|
709
|
-
after `config/initializers` run, so a bare constant there raises; the lambda
|
|
710
|
-
resolves when generation asks, and again after a dev reload has replaced the
|
|
711
|
-
class object. The block runs where you write it, registrations included, so a
|
|
712
|
-
registration naming one of your own constants is in the position a top-level
|
|
713
|
-
one is and has the same answer: declare that graph from a `to_prepare` block,
|
|
714
|
-
which is safe to re-run (the graph's name is its identity) and whose query
|
|
715
|
-
directories still reach the development file watcher, so an edit to its
|
|
716
|
-
`.graphql` regenerates before the next request.
|
|
717
|
-
|
|
718
|
-
**Nothing changes for a single-schema app**: the top-level settings *are* the
|
|
719
|
-
default graph, and top-level registrations still reach every graph, so a
|
|
720
|
-
`register_scalar` in an initializer can't be dropped by declaring a second
|
|
721
|
-
schema. `namespace` nests everything a graph generates, including its shared
|
|
722
|
-
types module (`Billing::GraphQLTypes`); without one, two files that generate
|
|
723
|
-
the same module refuse as they always have, and the message now names the
|
|
724
|
-
graphs and the fix.
|
|
725
|
-
- **Generation refuses a directory no declared graph covers.** Declaring a
|
|
726
|
-
graph replaces the implicit one the top-level settings describe, so an app
|
|
727
|
-
that declares one beside its existing `app/graphql/queries` would otherwise be
|
|
728
|
-
left with a directory nothing reads — `generate` skipping its files, `verify`
|
|
729
|
-
calling the tree up to date, `queries:check` passing a query containing
|
|
730
|
-
`nmae`, and the modules generated from it before the graph existed still
|
|
731
|
-
loading in production. `generate!`, `verify_generated!` and `check_queries`
|
|
732
|
-
refuse instead, naming the stray files and what each declared graph does
|
|
733
|
-
cover — and spelling the missing graph out in those paths, since "under
|
|
734
|
-
directories no declared graph covers" describes the reader's own settings
|
|
735
|
-
back at them. It says why that graph takes no `schema` or `namespace`: it
|
|
736
|
-
keeps reading the schema `schema_path` names, and every constant keeps its
|
|
737
|
-
name. The fix is to name the directory in a graph (`queries`/`output`),
|
|
738
|
-
declare a graph for it, or delete it; an app that declared no graph is
|
|
739
|
-
unaffected, its settings *being* its graph.
|
|
740
|
-
[getting started](docs/getting_started.md#more-than-one-schema) says it too.
|
|
741
|
-
- **Every test mode decides what to run against per graph, and the helpers say
|
|
742
|
-
which graph they mean.** With more than one schema the honest answer varies
|
|
743
|
-
per module: `graphql: :fake` for a billing module fabricates billing's shapes
|
|
744
|
-
with billing's own scalar registrations, where it used to refuse to pick a
|
|
745
|
-
schema for the suite. `graphql_fake`, `graphql_in_process` and
|
|
746
|
-
`graphql_router` are the stand-in for the modules of **one** graph, and
|
|
747
|
-
**`graph:` names it** — `graphql_fake(graph: :poke, "pokemon_v2_pokemon.name"
|
|
748
|
-
=> "pikachu")`, `graphql_in_process(graph: :catalog)`, `graphql_router(graph:
|
|
749
|
-
:storefront, fake: …)`. The only way to say which used to be `schema:`,
|
|
750
|
-
matched by the identity of the object the graph's `schema` setting holds,
|
|
751
|
-
which only a graph running a live class in-process has: an app that is a pure
|
|
752
|
-
client of two remote APIs has none, since `SchemaLoader` builds a fresh
|
|
753
|
-
anonymous class per load, so the client, the declared dump path and the
|
|
754
|
-
graph's own name were all refused with `names none of this app's graphs`.
|
|
755
|
-
`graph:` takes the handle a graph has everywhere else, printed by `rake
|
|
756
|
-
graph_weaver:graphs` and baked into every module's `GRAPH`. `schema:` still
|
|
757
|
-
serves the single-graph override it always did, every refusal now leads with
|
|
758
|
-
`graph:`, and `graphql_router` — which had no way at all to say whose `fake:`
|
|
759
|
-
it was holding, and so could only refuse — gains one. They still refuse,
|
|
760
|
-
naming your graphs, when there is none they could reach.
|
|
761
|
-
|
|
762
|
-
**A helper sets the stand-in for the graph it names; the tag sets the mode
|
|
763
|
-
for every graph no helper named.** `graphql_router(graph: :store)` followed
|
|
764
|
-
by `graphql_fake(graph: :countries)` used to collapse to whichever was named
|
|
765
|
-
last — a helper reinstalled the example's one mode, which cleared the
|
|
766
|
-
per-graph stand-in table, so the first helper's graph was silently re-derived
|
|
767
|
-
under the second's mode: the plain graph raised `:router` plans across a
|
|
768
|
-
supergraph, or the federated one answered with fabricated data. So `graphql:
|
|
769
|
-
:router` plus `graphql_fake(graph: :countries)` reads as what it means, and
|
|
770
|
-
is no longer refused as contradicting the tag — a helper naming one graph of
|
|
771
|
-
several isn't speaking for the example. A helper that *does* speak for the
|
|
772
|
-
whole example (one graph, or no `graph:`/schema to narrow it) still can't
|
|
773
|
-
contradict its tag.
|
|
774
|
-
|
|
775
|
-
`:router` asks each graph where its own supergraph is: the one that graph
|
|
776
|
-
names, else `config.router[:supergraph]`, else the committed dump when it
|
|
777
|
-
carries `@join__*`. So an app that had already said where its supergraph is
|
|
778
|
-
doesn't repeat it in `config.router`, and a module is never planned against
|
|
779
|
-
**another graph's** — which used to fail blaming a stale dump ("schema may
|
|
780
|
-
have changed since generation"). A graph in no supergraph is refused by name
|
|
781
|
-
and pointed at `graphql: :in_process`; two graphs naming one supergraph share
|
|
782
|
-
one router, parsed once.
|
|
783
|
-
|
|
784
|
-
Generated modules carry a private `GRAPH` naming the graph they were generated
|
|
785
|
-
from, so **regenerate** — a multi-graph app whose modules predate this says so
|
|
786
|
-
and refuses rather than guessing. `FakeClient.new` takes `registry:` for a
|
|
787
|
-
caller that holds the graph, since a graph whose `schema` is a file can't be
|
|
788
|
-
matched back off the schema object. (The per-graph registry behind all of
|
|
789
|
-
this, `Codegen::Registry`, is internal, like the rest of codegen's IR.)
|
|
790
|
-
- **`graphql: :wire` runs a spec against your own transport.** The other tags
|
|
791
|
-
sit *in* the client slot, so the transport an app ships — APM tracing, a
|
|
792
|
-
caller tag, mTLS — never ran in a spec. `:wire` leaves `GraphWeaver.client`
|
|
793
|
-
where it is and serves your resolvers at the endpoint it posts to: the request
|
|
794
|
-
is serialized, posted through your middleware, and deserialized by `from_h`
|
|
795
|
-
over the server's own bytes. What sits behind each endpoint is what that
|
|
796
|
-
graph *is* — decided **per graph**, in descending faithfulness: its router
|
|
797
|
-
when it is in a composed supergraph, its live schema class when it has one,
|
|
798
|
-
else **a fake of its schema**. So the commonest shape there is, and the one
|
|
799
|
-
the README's Countries and GitHub examples are — a pure client of someone
|
|
800
|
-
else's API, with a committed dump and no resolvers to run — needs no
|
|
801
|
-
hand-written `GraphQL::Schema` shaped like your own query. One endpoint is
|
|
802
|
-
served per graph: the client that graph bakes into its modules with
|
|
803
|
-
`client:`, or `GraphWeaver.client` for a graph that bakes none — so an app
|
|
804
|
-
whose graphs all bake one needs no app default at all. A graph whose baked
|
|
805
|
-
client posts nowhere is refused by name rather than its requests leaving the
|
|
806
|
-
suite, and a graph with no schema at all is refused in `:wire`'s own voice,
|
|
807
|
-
naming the three places one can come from. The one candidate the other tags
|
|
808
|
-
fall back to and this one can't is `GraphWeaver.client`'s own schema —
|
|
809
|
-
reading it introspects the very endpoint `:wire` has stubbed.
|
|
810
|
-
|
|
811
|
-
**The pick is said out loud**, because it was invisible from inside the
|
|
812
|
-
example: an app whose client is a url transport and whose `Shop::Schema` no
|
|
813
|
-
one named got a fake, so its suite went green against fabricated data with
|
|
814
|
-
nothing said. One line per endpoint goes to `GraphWeaver.logger` (a Rails app
|
|
815
|
-
already has one) — `:wire serving Shop::Schema (in-process) at https://…` at
|
|
816
|
-
`info`, and at `warn`, naming the class and `config.schema`, when a fake stood
|
|
817
|
-
in while this process has a `GraphQL::Schema` class nothing pointed at. A
|
|
818
|
-
warning rather than a refusal, because a loaded class isn't proof you meant it
|
|
819
|
-
at *this* endpoint (a federated suite loads every subgraph's) and a fake
|
|
820
|
-
behind the wire is a thing to want: name the class for the suite, and call
|
|
821
|
-
`graphql_fake` in the examples that want fabricated data. Nothing else is
|
|
822
|
-
logged from a `:wire` example any more — the predicates deciding what to serve
|
|
823
|
-
raised-and-rescued to answer "no", and every `GraphWeaver::Error` writes a
|
|
824
|
-
`warn` line as it is built, so each example used to log two refusals that
|
|
825
|
-
never happened.
|
|
826
|
-
|
|
827
|
-
**A `graphql_*` helper says what goes behind the wire.** Under the other tags
|
|
828
|
-
a helper takes the client slot; under `:wire` it is served instead — the slot
|
|
829
|
-
has to keep your own client for the transport to run at all — so
|
|
830
|
-
`graphql_fake("Reader.orders" => [{ "status" => "PAID" }, {}])` pins one
|
|
831
|
-
`:wire` example's data exactly as it pins a `:fake` one's, and
|
|
832
|
-
`graphql_router(fake: …)` says per example what `config.router = { fake: … }`
|
|
833
|
-
says for the suite. A `:wire` example over a fake proves your transport — the
|
|
834
|
-
request your middleware wrote, the headers it sent, `from_h` reading real
|
|
835
|
-
JSON off a socket — but not that your `cast:` agrees with the real server,
|
|
836
|
-
since the fabricated bytes are written to match your own scalar
|
|
837
|
-
registrations.
|
|
838
|
-
|
|
839
|
-
It needs [webmock](https://github.com/bblimke/webmock) **enabled** — `require
|
|
840
|
-
"webmock/rspec"` in the spec helper — which hooks Net::HTTP, Faraday and
|
|
841
|
-
HTTPX, so every bundled transport runs unchanged. Having it in the Gemfile is
|
|
842
|
-
not enough: `Bundler.require` makes it *loaded* without installing the
|
|
843
|
-
adapters, so `:wire` checks before serving and names the line to add rather
|
|
844
|
-
than letting the first request leave the suite for the real endpoint. It
|
|
845
|
-
needs **rack** too — webmock's `to_rack` builds the Rack env with it and
|
|
846
|
-
doesn't depend on it — and the refusal names both in one sentence rather than
|
|
847
|
-
letting you find them one `bundle install` at a time.
|
|
848
|
-
`GraphWeaver::Testing::Endpoint` is the ordinary Rack app behind it, mountable
|
|
849
|
-
anywhere for anyone who'd rather have a real socket. See
|
|
850
|
-
[testing](docs/testing.md#over-the-wire--graphql-wire), which also covers
|
|
851
|
-
[making the served endpoint fail](docs/testing.md#making-the-served-endpoint-fail):
|
|
852
|
-
`:wire` adds one stub per endpoint and webmock answers with the last one
|
|
853
|
-
declared, so a `stub_request(…).to_return(status: 503)` in the example is a
|
|
854
|
-
**served** failure — your transport reads the status and `Retry-After` off a
|
|
855
|
-
real response and spends its real retry budget, which a `Failure` client
|
|
856
|
-
(which raises above the wire) can't reach.
|
|
857
|
-
- **A `context:` can be a proc**, called per request with the headers as sent —
|
|
858
|
-
the identity seam nothing above the wire could reach:
|
|
859
|
-
|
|
860
|
-
```ruby
|
|
861
|
-
config.context = ->(headers) { { current_user: User.find_by(token: headers["Authorization"]) } }
|
|
862
|
-
```
|
|
863
|
-
|
|
864
|
-
A hash still works, and is still the baseline `graphql_context` merges onto.
|
|
865
|
-
- **`config.context`, `config.schema` and `config.router` are refused once
|
|
866
|
-
they're too late**, under one rule: **configure at load, or in an `around` —
|
|
867
|
-
never in a plain `before`.** The tag builds (and under `:wire` serves) an
|
|
868
|
-
example's clients in a `before` hook of its own, which rspec runs ahead of any
|
|
869
|
-
group `before`, so a set there changed nothing and the example passed against
|
|
870
|
-
whatever the tag had already picked — a `config.context` that silently never
|
|
871
|
-
reached a resolver, a `config.schema` the fake never saw. The refusal fires
|
|
872
|
-
exactly when the clients are already built and names the `around` and the
|
|
873
|
-
per-example helper (`graphql_context`, `graphql_fake(schema:)`,
|
|
874
|
-
`graphql_router(fake:)`). Move any such `before` to an `around`, or to
|
|
875
|
-
`Testing.configure` in the spec helper.
|
|
876
|
-
- **The testing fakes refuse rather than silently do nothing.**
|
|
877
|
-
`fail_at: "pets.0.name"` matched nothing at all — indices were stripped
|
|
878
|
-
before comparing — so a spec named a list element, injected no failure, and
|
|
879
|
-
passed. An index is a path segment like any other now: state the ones you
|
|
880
|
-
mean and the rest match any position, so the plain `"pets.name"` still fails
|
|
881
|
-
the first element the walk reaches; a path that starts with an index, or
|
|
882
|
-
isn't a String, is refused. `Failure.graphql("boom", extensions: {…})` put
|
|
883
|
-
the extensions on the **response**, so the error carried none, and `code:`
|
|
884
|
-
was a bare "unknown keyword" — the keywords beside a message now shape that
|
|
885
|
-
error (`code:`, `extensions:`, `path:`, `locations:`), the hash form still
|
|
886
|
-
holds several, and anything else is refused by name. The response's own
|
|
887
|
-
`extensions:` went with them; `FakeClient` is where a whole response is
|
|
888
|
-
shaped. And **`Failure.timeout`** joins `.transport`/`.server`: a
|
|
889
|
-
`TransportError` whose `#cause` is net/http's `Net::ReadTimeout`, so a spec
|
|
890
|
-
says "it timed out" without naming net/http's classes.
|
|
891
|
-
- **A pin is told from an option by a lookup, not by casing.** The rule was "a
|
|
892
|
-
dot or a leading capital is a pin", so a lowercase type could not be pinned at
|
|
893
|
-
all: `graphql_fake("pokemon_v2_pokemon" => …)` against a Hasura API came back
|
|
894
|
-
as `a fake doesn't take pokemon_v2_pokemon:` and the pin was silently gone.
|
|
895
|
-
Now a key the fake takes is an option, a key **your schema** knows — a type,
|
|
896
|
-
enum, scalar, `Type.field` coordinate, or a field name — is a pin, and a key
|
|
897
|
-
that is neither is refused naming both and guessing across both. A key written
|
|
898
|
-
in the leading positional hash is only ever a pin, which is the spelling for a
|
|
899
|
-
schema whose own vocabulary collides with an option name. A keyword that is a
|
|
900
|
-
near-miss for a pin (`Persn: "Ada"`) now raises `ArgumentError` from the fake
|
|
901
|
-
rather than `GraphWeaver::Error` from the override check; the same key in the
|
|
902
|
-
leading hash is unchanged. And a **scalar** pin written as the Ruby object —
|
|
903
|
-
`overrides: { "Money" => Money.parse("12.00") }` — is serialized the way the
|
|
904
|
-
registration says, rather than reaching `from_h` as a `Money` and failing
|
|
905
|
-
there; an object pin's fields already followed that rule. Where the
|
|
906
|
-
registration can't serialize a value (a `serialize:` Proc builds source), the
|
|
907
|
-
fake refuses naming the scalar instead of letting the cast fail two layers
|
|
908
|
-
down.
|
|
909
|
-
- **`list_size:` also takes a Hash, saying how long ONE list is.** Every list
|
|
910
|
-
the fake reaches read the same setting, so nested unbounded lists multiply:
|
|
911
|
-
`rows { owner { … } tags }` at `list_size: 1600` fabricates 1600 rows and
|
|
912
|
-
1600 tags in each of them, and per-row allocations double with every doubling
|
|
913
|
-
of the number (1,064 → 12,981 objects per row from 100 → 1600). Key the Hash
|
|
914
|
-
the way pins are keyed — a `"Type.field"` coordinate or a bare field name,
|
|
915
|
-
with `default:` for the rest — and the named list stays flat (205 objects per
|
|
916
|
-
row across the same sweep): `{ "Row.tags" => 3, default: 1000 }`. Integer and
|
|
917
|
-
Range mean exactly what they did; a key the schema doesn't know is refused
|
|
918
|
-
with a spellcheck, at the fake's door and at `Testing.configure`. Works the
|
|
919
|
-
same on `config.list_size`, `graphql_fake(list_size:)` and a router's `fake:`.
|
|
920
|
-
- **A test-time schema memo notices the file underneath it changing.**
|
|
921
|
-
`GraphWeaver::Testing.config.schema` keys its located dump on the resolved
|
|
922
|
-
path, so `GraphWeaver.schema_path=` and `root=` are no longer invisible to
|
|
923
|
-
`:fake` — only `Testing.reset!` used to clear it, which made the fabricated
|
|
924
|
-
shapes order-dependent across spec files. It still loads once while the file
|
|
925
|
-
stands still.
|
|
926
|
-
- **A `graphql:` tag now reaches a module generated with `client:`.** The baked
|
|
927
|
-
`DEFAULT_CLIENT` sits above `GraphWeaver.client`, which is the slot a tag
|
|
928
|
-
swapped, so `it "…", graphql: :fake` ran a bound module against its real
|
|
929
|
-
endpoint. The mode now stands in for that constant too — a per-call `client:`
|
|
930
|
-
and `MyQuery.client =` still win, and `:wire` still leaves every client where
|
|
931
|
-
it is. **If a spec relied on a bound module ignoring the tag, it now runs
|
|
932
|
-
against the fake.**
|
|
933
|
-
- **`@oneOf` was never enforced on any schema read from an introspection dump.**
|
|
934
|
-
Codegen emits `ONE_OF` from `one_of?` and `InputStruct#one_of!` is the only
|
|
935
|
-
thing that refuses two fields being set — but graphql-ruby's introspection
|
|
936
|
-
query omits `isOneOf` unless asked, and its loader drops the field even when
|
|
937
|
-
it is present, so every dump this gem has ever written said "not @oneOf" for
|
|
938
|
-
every input object and the enforcing struct was never generated. Both halves
|
|
939
|
-
are fixed, and a schema introspected now and one loaded back off the file that
|
|
940
|
-
writes are the same schema. Schemas built from SDL (`.graphql`/`.gql` dumps,
|
|
941
|
-
inline SDL, a live class) were always correct. A server too old to define
|
|
942
|
-
`isOneOf` *refuses* the query rather than answering null — PokeAPI's Hasura
|
|
943
|
-
does — so introspection asks for it and asks the baseline query again rather
|
|
944
|
-
than fail: one extra round trip, only where the first answer was going to be
|
|
945
|
-
an error either way.
|
|
946
|
-
- **`schema:refresh` and `schema:diff` work for a schema you serve yourself.**
|
|
947
|
-
Both refused unconditionally for a dump built from a graphql-ruby class
|
|
948
|
-
("records no source url"), so the documented CI chain — `schema:diff &&
|
|
949
|
-
queries:check && verify && cassettes:check` — was permanently red for an
|
|
950
|
-
in-process app, and a runtime `QueryError` on a renamed field advised
|
|
951
|
-
`schema:refresh`, the one task guaranteed to refuse. One rule now, asked per
|
|
952
|
-
graph: **the dump is the contract generation reads; `refresh` rewrites it
|
|
953
|
-
from the graph's source, `diff` says how far that source has drifted from
|
|
954
|
-
it** — whichever the source is. A url is re-introspected as before; a live
|
|
955
|
-
schema class fills the same duck-typed slot, so it answers introspection
|
|
956
|
-
itself and nothing touches a network. Only a graph whose dump IS its source
|
|
957
|
-
still refuses, because there is nothing behind it to re-read. If you wired up
|
|
958
|
-
graphql-ruby's `GraphQL::RakeTask` to rebuild the dump for graph_weaver, you
|
|
959
|
-
can drop it: `schema:refresh` writes the same artifact, in whatever format
|
|
960
|
-
the dump already is, to the path graph_weaver reads. `schema:diff`'s verdict
|
|
961
|
-
now names the source it compared against (`… matches Catalog::Schema`,
|
|
962
|
-
`… matches https://api.example.com/graphql`) rather than "the server".
|
|
963
|
-
`SchemaLoader.refresh!` takes `schema:` and `path:` for the same reason;
|
|
964
|
-
`SchemaLoader.endpoint` and `Graph#dump_source` are the two new public names.
|
|
965
|
-
|
|
966
|
-
**`schema:refresh` also writes a graph's first dump.** Nothing wrote the
|
|
967
|
-
second one: the installer is single-graph, `URL=` names one endpoint for an
|
|
968
|
-
app that has one dump per graph, and `real_world.md`'s `GraphWeaver.new(url,
|
|
969
|
-
cache: true).schema` recipe writes the conventional `schema_path`. A graph
|
|
970
|
-
naming a dump that isn't there yet is introspected from the url its own
|
|
971
|
-
client posts to, and the dump records that url as its source like any other —
|
|
972
|
-
so two plain remote APIs are: declare both graphs with `schema
|
|
973
|
-
"app/graphql/poke/schema.json"` and `client "POKE"`, run `schema:refresh`,
|
|
974
|
-
run `generate`. `schema:diff` stops reading a missing dump as "generates from
|
|
975
|
-
that url directly", which is what a graph naming a live schema class does,
|
|
976
|
-
and names the file and the task instead. `Graph#named_dump_path` and
|
|
977
|
-
`SchemaLoader.dump_path?` are the new public names; `Graph#dump_path` still
|
|
978
|
-
means "the dump that is there".
|
|
979
|
-
- **`schema:refresh` no longer overwrites a composed supergraph with the API
|
|
980
|
-
schema behind it.** Introspection returns the merged shape a router serves,
|
|
981
|
-
with the `@join__*` routing table gone — so `rake graph_weaver:schema:refresh
|
|
982
|
-
URL=<router>` replaced a 5.8 KB supergraph with 1.6 KB of API schema and
|
|
983
|
-
exited 0, after which `federation:diff` failed and `schema:diff`/`verify`
|
|
984
|
-
went green against the wrong artifact. Any write that would trade a
|
|
985
|
-
supergraph dump for a non-supergraph one is refused, naming `rover supergraph
|
|
986
|
-
compose`; `schema:refresh` with no `URL=` says the same thing to a federated
|
|
987
|
-
app instead of suggesting one.
|
|
988
|
-
- **A task that can't honour `SUPERGRAPH=` says so.** The flag reaches the
|
|
989
|
-
`federation:*` tasks, and `queries:check`, `verify`, `generate` and the rest
|
|
990
|
-
ignored it in silence — so `SUPERGRAPH=public.graphql rake
|
|
991
|
-
graph_weaver:queries:check` reported every query valid against a supergraph
|
|
992
|
-
missing a field they select. They refuse it instead; honouring it would
|
|
993
|
-
collapse a multi-graph app into one unnamed graph, which for `generate` means
|
|
994
|
-
pruning the other graphs' generated files. Declare the supergraph on a graph
|
|
995
|
-
to check against it. The reasoning is in [DECISIONS.md](DECISIONS.md).
|
|
996
|
-
- **`verify` fails when the dump has fallen behind the schema class it was
|
|
997
|
-
built from.** Its question is "is what's checked in current", and the dump is
|
|
998
|
-
checked in too — for an app that serves its own schema it is an artifact
|
|
999
|
-
derived from code in the same repo, and everything downstream reads it, so
|
|
1000
|
-
`generate` and `verify` both called a tree up to date while the live
|
|
1001
|
-
resolvers had already moved. A CI gate that only reads exit codes saw
|
|
1002
|
-
nothing. A dump you deliberately keep behind your own schema is a `verify`
|
|
1003
|
-
failure now — refresh it, or name the schema explicitly
|
|
1004
|
-
(`verify_generated!(schema:)`), which asks about no dump at all. The check
|
|
1005
|
-
costs one in-process introspection per graph and never a network call: a
|
|
1006
|
-
dump that records a url stays `schema:diff`'s subject.
|
|
1007
|
-
- **A cassette holds one entry per request key, and the docs now say so.** The
|
|
1008
|
-
key is the query, its variables and the operation name together; re-recording
|
|
1009
|
-
a request replaces its entry and a request the file hasn't seen appends one,
|
|
1010
|
-
so `GRAPHWEAVER_RECORD=1` never leaves two entries for one request however
|
|
1011
|
-
often it runs. Unchanged behavior, newly stated and specced: editing a query
|
|
1012
|
-
changes the key, so the old entry stays behind as a recording nothing sends —
|
|
1013
|
-
what `cassettes:check` counts as "not sent by any query module", and what
|
|
1014
|
-
deleting the cassette and recording afresh clears. **`cassettes:check`'s
|
|
1015
|
-
stale advice names both causes** too: the generated structs move when a
|
|
1016
|
-
*registration* moves, not only when the schema dump does, and re-recording
|
|
1017
|
-
doesn't fix that half — so regenerating is offered for it by name.
|
|
1018
|
-
([cassettes](docs/cassettes.md))
|
|
1019
|
-
- **An `@interfaceObject` no longer breaks the subgraph its interface's *other*
|
|
1020
|
-
implementers live in.** Apollo writes a bare `@join__field` — no `graph:` at
|
|
1021
|
-
all — on a concrete implementer's copy of a field really contributed through
|
|
1022
|
-
`@interfaceObject` elsewhere, and the routing table read that the way it reads
|
|
1023
|
-
no directive at all: "wherever the type lives". So `products` was said to
|
|
1024
|
-
resolve `Bundle.reviews`, which `Products::Schema` never defines — pinning
|
|
1025
|
-
`subgraphs: { "products" => Products::Schema }` raised `ConfigurationError` at
|
|
1026
|
-
`Router.new`, and under auto-detection `products` was reported absent and an
|
|
1027
|
-
unrelated `{ products { name price } }` refused `absent_subgraph`, advice
|
|
1028
|
-
about eager loading and all. The rule is now one sentence: **a field routes to
|
|
1029
|
-
exactly the subgraphs its `@join__field` names**, and only a field with *no*
|
|
1030
|
-
`@join__field` lives wherever its type does. Selecting the implementer's copy
|
|
1031
|
-
directly is refused `no_owner` ("the supergraph places `Bundle.reviews` in no
|
|
1032
|
-
subgraph") instead of being fetched from a subgraph that would not answer it.
|
|
1033
|
-
- **A `@requires` field and a plain one crossing into the same subgraph now ride
|
|
1034
|
-
one `_entities` call**, as Apollo's do, instead of two — they are split only
|
|
1035
|
-
when a prefetch didn't answer for some node, which is the one case their node
|
|
1036
|
-
sets differ. A dashboard query drops from 6 fetches to 5.
|
|
1037
|
-
- **A subgraph SDL that renames what it imports now loads.**
|
|
1038
|
-
`@link(import: [{name: "@key", as: "@primaryKey"}])` — the `@link` spec's own
|
|
1039
|
-
aliasing — raised an error blaming the file for a directive the file had
|
|
1040
|
-
declared. The `@link` header is read for every spelling it binds and then
|
|
1041
|
-
dropped from the schema, so `link__Import`/`link__Purpose` no longer appear in
|
|
1042
|
-
a schema loaded from subgraph SDL.
|
|
1043
|
-
- **Two more queries the local router refuses instead of answering them
|
|
1044
|
-
wrong.** A **`@fromContext` argument** was refused only from the
|
|
1045
|
-
crossing-aware half of the planner, so a query one subgraph answered whole
|
|
1046
|
-
skipped the check — and only a gateway injects a `@fromContext` value, so the
|
|
1047
|
-
resolver ran with the argument unset and the router returned a plausible
|
|
1048
|
-
wrong number. And a **progressive `@override(label:)`** wasn't refused at
|
|
1049
|
-
all: Federation 2.7 leaves both subgraphs resolving the field and lets the
|
|
1050
|
-
gateway split traffic by the label, where the router picked one and answered
|
|
1051
|
-
from it every time, silently and forever (new category
|
|
1052
|
-
`:progressive_override`; drop the label and composition drops the losing
|
|
1053
|
-
copy, which plans normally). Every refusal that must hold for a query however
|
|
1054
|
-
it is planned now runs above the single-subgraph shortcut, which is the one
|
|
1055
|
-
walk every plan passes through — see [DECISIONS.md](DECISIONS.md).
|
|
1056
|
-
- **`@defer`/`@stream` are refused by name** (`:incremental_delivery`) rather
|
|
1057
|
-
than by happening to fail validation — the answer would arrive in more than
|
|
1058
|
-
one payload, and the Apollo Router supports `@defer` for real. The scan runs
|
|
1059
|
-
above validation in both places that refuse, so the reason you get is this
|
|
1060
|
-
one and not graphql-ruby's "Directive @defer is not defined":
|
|
1061
|
-
`Testing::Router#execute` used to validate before it planned, and `rake
|
|
1062
|
-
graph_weaver:generate` had no check at all — it refused only while no schema
|
|
1063
|
-
declared the directive, and would have generated a module that dropped the
|
|
1064
|
-
deferred selections against a supergraph that did.
|
|
1065
|
-
- **A subgraph error out of `Testing::Router` carries `extensions: {"service"
|
|
1066
|
-
=> "<subgraph>"}`.** Every real transport stamps which subgraph failed and a
|
|
1067
|
-
client branches on it, so a test asserting on an unstamped error passed here
|
|
1068
|
-
and broke in front of a gateway. The Apollo Router's spelling; a resolver's
|
|
1069
|
-
own `extensions` are left alone. **Update any spec that compares a router
|
|
1070
|
-
error hash whole** — and note that a production router with
|
|
1071
|
-
`include_subgraph_errors` omitted redacts the stamp along with the message
|
|
1072
|
-
([testing](docs/testing.md)).
|
|
1073
|
-
- **`rake graph_weaver:federation:diff` sees three things it used to miss.**
|
|
1074
|
-
It no longer **calls an absent subgraph stale**: a schema was recognized by
|
|
1075
|
-
the types its subgraph declares, and two subgraphs extending one entity
|
|
1076
|
-
declare the same one — so a `prefs` running in another process was matched to
|
|
1077
|
-
its neighbour `accounts`, every field only `prefs` resolves was reported
|
|
1078
|
-
stale, and the gate failed red advising a recompose that would change
|
|
1079
|
-
nothing. A schema now also has to define something the supergraph attributes
|
|
1080
|
-
to that subgraph **alone**; one that shares everything it declares is "not
|
|
1081
|
-
here", which [docs/federation.md](docs/federation.md) already promised doesn't
|
|
1082
|
-
fail the task. `#skipped` (and the `not checked` section) now names those
|
|
1083
|
-
coordinates rather than the types.
|
|
1084
|
-
|
|
1085
|
-
It **compares a field's type, not just its name**. The check was coordinate
|
|
1086
|
-
presence — `Warehouse.code` going from `String!` to `ID!` under an untouched
|
|
1087
|
-
supergraph reported "matches the schemas here", and CI passed on a
|
|
1088
|
-
composition describing a graph nobody serves. Both types are compared and a
|
|
1089
|
-
disagreement is its own drift kind, **shape**, beside stale and not composed
|
|
1090
|
-
in, with both signatures in the message: `Warehouse.code (inventory): String!
|
|
1091
|
-
in the supergraph, ID! here`. `Drift#to_h` grows a `"shape"` key and `#drift?`
|
|
1092
|
-
counts it, so a suite asserting on either sees the new kind.
|
|
1093
|
-
|
|
1094
|
-
And it **names a subgraph you retired from the composition but left loaded**.
|
|
1095
|
-
Every check there walks the supergraph's subgraph list, so a Ruby schema the
|
|
1096
|
-
composition no longer places sat on the only side nothing looked at —
|
|
1097
|
-
recompose without `reviews`, leave `Reviews::Schema` defining everything it
|
|
1098
|
-
always did, and the report said "matches the schemas here (checked 3 of 3
|
|
1099
|
-
subgraphs)", exit 0. It is named on stderr ("not placed — no subgraph of any
|
|
1100
|
-
supergraph read here is:"), as a **warning rather than drift**: a process that
|
|
1101
|
-
loads a subgraph of a supergraph this run never reads is the same picture, and
|
|
1102
|
-
nothing tells the two apart. `Drift#unplaced` is the list for one supergraph
|
|
1103
|
-
and `Drift#subgraphs` is the names it does have; the task asks every graph in
|
|
1104
|
-
the run, so a multi-graph app's second supergraph places its own.
|
|
1105
|
-
|
|
1106
|
-
The `federation:*` tasks also ask the declared graphs where the supergraph is.
|
|
1107
|
-
`federation:diff`, `:subgraphs` and `:coverage` looked only at the
|
|
1108
|
-
conventional dump, so an app that had written
|
|
1109
|
-
`GraphWeaver.graph(:accounts) { schema "…/supergraph.graphql" }` had to repeat
|
|
1110
|
-
it in `SUPERGRAPH=` — and without the flag got a refusal describing a file it
|
|
1111
|
-
never named. Each task now runs once per declared graph whose schema is a
|
|
1112
|
-
composed supergraph, heading each report with the graph's name, and
|
|
1113
|
-
`:coverage` measures that graph's own `queries` rather than
|
|
1114
|
-
`GraphWeaver.queries_paths`. `SUPERGRAPH=` still overrides for one run, and a
|
|
1115
|
-
single-schema app with a composed dump prints exactly what it did.
|
|
1116
|
-
- **Three fixes in `rails g graph_weaver:install`.** It **wires the rspec
|
|
1117
|
-
require where rspec reads it**: the setup told you to put `require
|
|
1118
|
-
"graph_weaver/rspec"` in `spec/support/graph_weaver.rb`, and rspec-rails ships
|
|
1119
|
-
the `spec/support` glob **commented out**, so for anyone who hadn't
|
|
1120
|
-
uncommented it the require never ran and a `graphql: :fake` example silently
|
|
1121
|
-
went to the real client. The line now goes in `spec/rails_helper.rb` (under
|
|
1122
|
-
rspec-rails' own `require "rspec/rails"`), or `spec/spec_helper.rb` if that's
|
|
1123
|
-
all there is, or is printed when the app has neither — idempotent, so a re-run
|
|
1124
|
-
and a line you added by hand both leave it alone. It **no longer wires auth
|
|
1125
|
-
you didn't ask for**: the initializer carried `auth: ENV["GRAPHWEAVER_AUTH"]`
|
|
1126
|
-
on every url install, so a public API's setup read an ENV var nobody had set,
|
|
1127
|
-
while the `--auth` docs said the flag was "omitted entirely for a public API
|
|
1128
|
-
that needs no token" — without `--auth` that line is now written commented,
|
|
1129
|
-
which is both the honest default and how you add a token later. And it
|
|
1130
|
-
**keeps a schema dump you already have**: the dump was the one file it didn't
|
|
1131
|
-
write through `create_file`, so Thor never prompted on it and declining every
|
|
1132
|
-
conflict on a re-run still replaced it — a re-run naming a *different*
|
|
1133
|
-
endpoint silently overwrote the old graph's dump along with the source url it
|
|
1134
|
-
records. An existing dump is left alone and named (with where it was
|
|
1135
|
-
introspected from, when that isn't the source just given); delete it and
|
|
1136
|
-
re-run to re-introspect, or `rake graph_weaver:schema:refresh` to re-fetch in
|
|
1137
|
-
place. All three describe what the *next* install writes — existing
|
|
1138
|
-
initializers are untouched.
|
|
1139
|
-
- **`rails g graph_weaver:install` adds each graph's output directory to
|
|
1140
|
-
`AllCops: Exclude:`** when the app already has a `.rubocop.yml`, so plain
|
|
1141
|
-
`rubocop` stops reporting `Style/Documentation`, `Style/ClassAndModuleChildren`
|
|
1142
|
-
and `Metrics/*` on generated files. The block it appends carries
|
|
1143
|
-
`inherit_mode: merge: [Exclude]`, because RuboCop *replaces* an `Exclude`
|
|
1144
|
-
array on merge rather than unioning it — without that line the append would
|
|
1145
|
-
wipe the app's effective excludes, RuboCop's own `vendor`, `node_modules` and
|
|
1146
|
-
`tmp` defaults included, along with anything reaching the file through
|
|
1147
|
-
`inherit_from:`. It never creates the file, and it leaves an `AllCops:` you
|
|
1148
|
-
already have alone — a second one would replace it rather than merge —
|
|
1149
|
-
printing the lines to add instead, as it does for a multi-document
|
|
1150
|
-
`.rubocop.yml`, where rubocop reads only the first document and an appended
|
|
1151
|
-
block would land where nothing reads it.
|
|
1152
|
-
- **New: `rake graph_weaver:unused`, the over-fetch the other checks can't see.**
|
|
1153
|
-
`verify` says the Ruby matches the query; this asks whether the query still
|
|
1154
|
-
asks for what the app *uses*. It reads the props each query's generated
|
|
1155
|
-
structs carry, sweeps your `.rb`/`.erb`/`.slim`/`.haml`/`.jbuilder` once for
|
|
1156
|
-
every form a prop could be read by (`.sku`, `sku:`, `:sku`, `"sku"`), and
|
|
1157
|
-
names what nothing reads — by query file, selection, and the coordinate the
|
|
1158
|
-
query spells (`Person.bornOn`, not the prop `born_on`). `.rake` and
|
|
1159
|
-
`.builder` are swept too. Nothing is edited and it exits 0; `STRICT=1` exits
|
|
1160
|
-
1 when anything is unread — `0`, `false`, `no`, `off` and empty are off, so
|
|
1161
|
-
the spelling a CI config uses to turn it off turns it off — and
|
|
1162
|
-
`PATHS=app,lib` narrows the sweep, refusing a path that isn't there rather
|
|
1163
|
-
than sweeping nothing and reporting every selection unread. A repo with no
|
|
1164
|
-
queries is told `no queries in <dir>`, the sentence its sibling `generate`
|
|
1165
|
-
already says, instead of being advised to generate nothing. A line handing a
|
|
1166
|
-
query module straight to `render json:` / `to_h` / `to_json` / `as_json` /
|
|
1167
|
-
`serialize` / `deconstruct_keys` reads every prop at once, so that module is
|
|
1168
|
-
excused and the line is quoted — matching a serializer by name is the softest
|
|
1169
|
-
thing here and a wrong excuse should be visible; the sweep follows the local
|
|
1170
|
-
a module was assigned to, so the ordinary two-line controller (`result =
|
|
1171
|
-
Q.execute!` then `render json: result.person`) is excused as well, and it
|
|
1172
|
-
says which local it followed. A graphql-ruby *type* file is skipped, in the
|
|
1173
|
-
`< Types::BaseObject` spelling graphql-ruby's own generator emits as well as
|
|
1174
|
-
the fully qualified one; a `Resolver` or `Mutation` is not, since a BFF reads
|
|
1175
|
-
the upstream graph exactly there. It matches names as text, which makes it a
|
|
1176
|
-
lint and not a proof — the task's own footer names what it is blind to, and
|
|
1177
|
-
carries the measured rate: on a real app half to two thirds of genuinely
|
|
1178
|
-
unread selections go unreported
|
|
1179
|
-
([getting started](docs/getting_started.md#the-selections-nothing-reads)).
|
|
1180
|
-
- **Three things are refused where they used to go wrong later.** A `client`
|
|
1181
|
-
that isn't a constant is refused at generation: the value is spelled into
|
|
1182
|
-
every generated module, so a `client` given the endpoint url emitted
|
|
1183
|
-
`-> { https://api.example.com/graphql }` — a file that doesn't parse — from a
|
|
1184
|
-
run that reported success, and the message now names the constant to declare
|
|
1185
|
-
instead. A `cast:` or `serialize:` **proc that returns a value** is refused at
|
|
1186
|
-
registration: a proc there builds *source* for the generated file, so
|
|
1187
|
-
`cast: ->(v) { v.to_sym }` interpolated to nothing and every response failed
|
|
1188
|
-
far from the registration, blaming the codec; it is probed once when
|
|
1189
|
-
registered, and a non-String return names the spelling to use. And a router's
|
|
1190
|
-
`fake:` refuses `seed:`, as `graphql_fake` and `graphql_router` already did —
|
|
1191
|
-
rspec's `--seed` drives the fake, and a router is built once for the suite, so
|
|
1192
|
-
a seed there would pin every example to one run.
|
|
1193
|
-
(`GraphWeaver::Testing.config.seed` remains the override for a harness that
|
|
1194
|
-
isn't rspec.)
|
|
1195
|
-
- **A generated output nested deeper than a `generated_paths` glob reaches is
|
|
1196
|
-
hidden from Zeitwerk again, and loaded.** `app/graphql/a/b/generated` was read
|
|
1197
|
-
as already covered by the default `app/graphql/*/generated` — `File.fnmatch?`
|
|
1198
|
-
lets `*` cross a `/`, and `Dir.glob`, which expands the same pattern
|
|
1199
|
-
everywhere else, does not. It was then neither ignored nor required:
|
|
1200
|
-
production boot died on a `Zeitwerk::NameError` naming a constant you never
|
|
1201
|
-
wrote, and development simply had the modules missing. Nothing to do — a
|
|
1202
|
-
layout like `app/graphql/subgraphs/billing/generated` now works.
|
|
1203
|
-
- **A symlinked generated output is hidden from Zeitwerk, and refused when it
|
|
1204
|
-
is declared too late to hide.** The railtie resolved a path with
|
|
1205
|
-
`File.expand_path`, which doesn't follow symlinks, while Zeitwerk walks real
|
|
1206
|
-
directories — so an `output` that was a symlink, or an absolute one through a
|
|
1207
|
-
symlinked ancestor (the Capistrano `current/` shape), was ignored under a name
|
|
1208
|
-
Zeitwerk never visits and its modules died on `uninitialized constant`. The
|
|
1209
|
-
refusal that exists for a too-late output compared the same unresolved path
|
|
1210
|
-
against real autoload roots, so it stayed silent for exactly that spelling; it
|
|
1211
|
-
now fires, and names the output the way your graph spells it.
|
|
1212
|
-
- **A graph declared from `config.to_prepare` whose `output` no loader can be
|
|
1213
|
-
told to skip is refused at boot**, naming the graph and the fixes. Rails sets
|
|
1214
|
-
Zeitwerk up before `to_prepare` runs and Zeitwerk reads its ignore list only
|
|
1215
|
-
then, so such a directory cannot be hidden from autoloading — it used to fail
|
|
1216
|
-
in every environment with a Zeitwerk error that blamed a dropped
|
|
1217
|
-
`extend_type`. The refusal asks Zeitwerk which directories a loader would
|
|
1218
|
-
really walk, so an app that hid the output itself with
|
|
1219
|
-
`Rails.autoloaders.main.ignore(...)` is left alone. An output under
|
|
1220
|
-
`config.autoload_once_paths` is refused at the point that knows, and names the
|
|
1221
|
-
two routes that work — `Rails.autoloaders.once.ignore(...)` from
|
|
1222
|
-
`config/application.rb`, or generating somewhere that is not an autoload-once
|
|
1223
|
-
path — since Rails sets the `once` autoloader up in bootstrap, where nothing
|
|
1224
|
-
an initializer does can reach it.
|
|
1225
|
-
- **`config.graph_weaver` refuses a key the railtie doesn't read.** It takes
|
|
1226
|
-
`watch`; `config.graph_weaver.queries_paths = ...` was taken silently and did
|
|
1227
|
-
nothing. The refusal names `GraphWeaver.queries_paths =` as the setting that
|
|
1228
|
-
works, and covers every spelling of that write —
|
|
1229
|
-
`config.graph_weaver[:queries_paths] = ...` and `store` went straight to
|
|
1230
|
-
`Hash#[]=`, which is the silent no-op the refusal exists to prevent.
|
|
1231
|
-
- **`load_generated!` tells a `Zeitwerk::NameError` apart from a dropped
|
|
1232
|
-
`extend_type`/`register_enum`.** The old message sent you hunting for a
|
|
1233
|
-
registration that was still there; the new one says Zeitwerk owns the
|
|
1234
|
-
directory and names the fix (`GraphWeaver.generated_paths`, or the
|
|
1235
|
-
conventional `app/graphql/*/generated`).
|
|
1236
|
-
- **`GraphWeaver.configure do |config| ... end`** — `GraphWeaver::Testing.configure`
|
|
1237
|
-
existed and the top level didn't. `config` is `GraphWeaver` itself, so the
|
|
1238
|
-
flat `GraphWeaver.client = ...` spelling is the same call and stays valid.
|
|
1239
|
-
- **Four smaller fixes.** `GraphWeaver::Transport::Faraday` resolves without a
|
|
1240
|
-
require — the constant is autoloaded, so the `Transport::Faraday.new(url) { |conn| … }`
|
|
1241
|
-
that [docs/transports.md](docs/transports.md) shows works where you'd write
|
|
1242
|
-
it, an initializer, instead of raising `NameError` at boot (still opt-in:
|
|
1243
|
-
naming it is what loads faraday). `rails g graph_weaver:install` writes the
|
|
1244
|
-
current scalar spelling, `register_scalar("Money", BigDecimal)`, rather than a
|
|
1245
|
-
three-keyword registration for a scalar 0.6.1 registers for you. The
|
|
1246
|
-
unregistered-scalar report prints where the task that found it prints: one of
|
|
1247
|
-
`generate`'s and `verify`'s two registry advisories went only to the logger,
|
|
1248
|
-
which in Rails is `log/development.log`, so nobody running the task saw it —
|
|
1249
|
-
`GraphWeaver.untyped_scalars` is the list, beside
|
|
1250
|
-
`GraphWeaver.unmatched_registrations`, and it is grouped by graph when a run
|
|
1251
|
-
covers more than one — merged flat it read as "forgotten everywhere" for a
|
|
1252
|
-
scalar registered for one graph and forgotten for the next, so
|
|
1253
|
-
`GraphWeaver.untyped_scalars_by_graph` is the same fact kept apart. And `rake graph_weaver:cassettes:check`
|
|
1254
|
-
sees a namespaced graph's modules: it looked for top-level constants, so an
|
|
1255
|
-
app whose graphs set `namespace` found "0 generated modules", refused for
|
|
1256
|
-
having checked nothing, and blamed the cassette directory.
|
|
1257
|
-
- Internal: `bin/round-trip --hostile` now spoils an **input** leaf as well as
|
|
1258
|
-
a response one — a wrong type, an unparseable string, a value the enum
|
|
1259
|
-
doesn't have, a nil where null is illegal, at any depth including inside
|
|
1260
|
-
lists — and requires an `InputError` whose `#path` names the corrupted slot
|
|
1261
|
-
and whose `#kind` says what was wrong. The 40,000 clean round trips before it
|
|
1262
|
-
were evidence about response decoding and close to none about input errors;
|
|
1263
|
-
run against the GitHub schema it found the missing list index in `#path` on
|
|
1264
|
-
its own. It also reads a renamed prop now: a field whose Ruby name took a
|
|
1265
|
-
trailing underscore used to make the harness compare `class` against
|
|
1266
|
-
`class_` and call every such round trip lossy, which hid whatever else that
|
|
1267
|
-
query would have caught — and it calls `execute` with the kwarg a variable
|
|
1268
|
-
really becomes, since it used `prop_name` and an argument named `hash`
|
|
1269
|
-
(Linear's `Query.comment(hash:)`) went out as `hash_:` for `execute` to
|
|
1270
|
-
refuse. The rename exists so a *prop* can't shadow a method its struct
|
|
1271
|
-
answers; an execute kwarg shadows nothing and keeps the schema's spelling.
|
|
1272
|
-
|
|
1273
|
-
The harness also **reads a scalar off the registry rather than off its name**.
|
|
1274
|
-
Sweeping 23 public schemas turned up two declaring `scalar Time` (GitLab's,
|
|
1275
|
-
universe's) and one declaring `DateTimeOrDuration` (Linear's), and both
|
|
1276
|
-
oracles mis-read them: `Time` is a row in the harness's own legal/illegal
|
|
1277
|
-
tables, keyed there by the *Ruby* type a registration casts to, so an
|
|
1278
|
-
unregistered custom scalar of that name was spoiled as if it had a codec, and
|
|
1279
|
-
generated code was failed for accepting what `T.untyped` pass-through must
|
|
1280
|
-
accept. The lookup asks the Ruby type now, with a built-in's own name allowed
|
|
1281
|
-
to refine it. Separately, `bin/round-trip`'s stand-in registrations spelled
|
|
1282
|
-
out `serialize: :iso8601` for a timestamp, which is `Time#iso8601` and drops
|
|
1283
|
-
the sub-second the harness's wire expectation keeps; they name the class and
|
|
1284
|
-
stop, as docs/scalars.md has always said to. No library behavior changed —
|
|
1285
|
-
both were the harness lying about real schemas — and the registrations moved
|
|
1286
|
-
to `RoundTrip.register_scalars!` so the suite can hold them to it. And
|
|
1287
|
-
`railties` joins the development dependencies, so a spec can boot a real
|
|
1288
|
-
`Rails::Application` and assert the railtie's initializer order rather than
|
|
1289
|
-
reasoning about it — the ordering bug in the logger and instrumenter
|
|
1290
|
-
auto-wires was invisible to a suite that never booted Rails.
|
|
1291
|
-
- Docs: [logging](docs/logging.md) gains the payload table, the log line, and
|
|
1292
|
-
two-line adapters for OpenTelemetry (`in_span`) and Datadog
|
|
1293
|
-
(`Datadog::Tracing.trace`) — both run against the real gems. Datadog's
|
|
1294
|
-
Net::HTTP and Faraday contribs already trace the transport; the instrumenter
|
|
1295
|
-
adds the span *above* it, named for the operation, which is the one that
|
|
1296
|
-
means anything when every call is a POST to the same url. It also says what
|
|
1297
|
-
`filter_parameters` does and doesn't reach: what GraphWeaver composes, never
|
|
1298
|
-
a server- or resolver-authored `GraphQLError#message`.
|
|
1299
|
-
[errors](docs/errors.md) now says what a *server's* input rejection carries,
|
|
1300
|
-
and leads with what isn't portable rather than burying it: the section opens
|
|
1301
|
-
with the servers read by name (graphql-ruby, Apollo, Hasura), what marks an
|
|
1302
|
-
error as input on each, and a worked fallback for a server that marks
|
|
1303
|
-
nothing — including the nil-`#field` branch a `:refused` error takes, the
|
|
1304
|
-
deep-underscore a Rails JSON controller owes its params before `execute`, and
|
|
1305
|
-
the Hasura `where:` value that is legal to the comparison type and fails at
|
|
1306
|
-
Postgres as `data-exception`, which is neither code the arm reads. It also
|
|
1307
|
-
says what shape the convention's `path` must be, and that a
|
|
1308
|
-
`GraphQL::Schema::Validator` on a *field* is never told which list element it
|
|
1309
|
-
is validating — graphql-ruby coerces a list with a plain `map` — so a `qty`
|
|
1310
|
-
inside `lines: [LineInput!]!` reports `["input", "qty"]` for every element
|
|
1311
|
-
alike; install the validator on the **list argument** instead and index the
|
|
1312
|
-
coerced Array yourself (the recipe is there, and a spec runs it). And it says
|
|
1313
|
-
plainly that neither side collects every problem: `coerce` raises on the first
|
|
1314
|
-
field that won't convert (unknown keys and absent required fields being the
|
|
1315
|
-
two it does list in full), and graphql-ruby 2.6 aborts variable validation at
|
|
1316
|
-
the first `validates:` failure across the whole tree — so build the form
|
|
1317
|
-
expecting to iterate.
|
|
1318
|
-
New [i18n](docs/i18n.md) page proposes stable keys for input problems;
|
|
1319
|
-
nothing in it ships yet. It spells out which spelling a path is in and how it
|
|
1320
|
-
maps to a form field, the nil branch, and how to render `#path` when the
|
|
1321
|
-
index is the half that says which list row failed.
|
|
1322
|
-
[scalars](docs/scalars.md#registering-a-class-of-your-own) **leads with the
|
|
1323
|
-
money shapes that work internationally**. The worked `Money` example
|
|
1324
|
-
hardcoded `"USD"` in its cast — correct for the wire shape it assumed (a bare
|
|
1325
|
-
decimal), and silently wrong for any other currency, which comes back
|
|
1326
|
-
mislabelled with no error anywhere. That's the schema's shape, not the
|
|
1327
|
-
library's, so the section now orders the shapes by what they carry: an object
|
|
1328
|
-
scalar (`{"amount","currency"}`), one string carrying both, and an object type
|
|
1329
|
-
`Money { amount currency }` — which needs no `register_scalar` at all, just an
|
|
1330
|
-
`extend_type` — with the bare decimal last and marked single-currency-only.
|
|
1331
|
-
Also new there: register what your cast *returns*, not where the factory
|
|
1332
|
-
lives (`register_scalar("URL", URI)` runs fine and fails `srb tc` at every
|
|
1333
|
-
call site, because Sorbet's `URI` module payload has no `include Kernel` —
|
|
1334
|
-
register `URI::Generic`); pass-through means no invented codec, not an
|
|
1335
|
-
optional cast, since a class JSON can't parse into is still refused when a
|
|
1336
|
-
query reads the field; a `JSON`-narrowed coordinate opts that field out of
|
|
1337
|
-
`:fake` fabrication as well as hardening its shape; and which cross-type
|
|
1338
|
-
values are refused — Ruby **objects**, where a timestamp *string* for a `Date`
|
|
1339
|
-
parses and truncates, as graphql-ruby's own `ISO8601Date` does. And what a
|
|
1340
|
-
timestamp cast costs, measured: `Time.parse`, the reader every `DateTime`
|
|
1341
|
-
field already uses, is ~7× `Date.iso8601`, and `cast: :iso8601` is ~3×
|
|
1342
|
-
cheaper than it and stricter.
|
|
1343
|
-
[getting_started](docs/getting_started.md) cuts the first hour down to the
|
|
1344
|
-
single-schema path, with multi-schema as its own section further down, names
|
|
1345
|
-
the measured false-negative rate of `rake graph_weaver:unused` where the task
|
|
1346
|
-
is introduced — a lint you can act on is worth more than one you are invited
|
|
1347
|
-
to trust — gains the runnable CI script it never had, one per topology plus
|
|
1348
|
-
the GitHub Actions job, says that a validator installed by symbol is
|
|
1349
|
-
invisible to Zeitwerk (reference it from the same `to_prepare` block, above
|
|
1350
|
-
the schema), and answers the Sorbet question where the adoption decision is
|
|
1351
|
-
made rather than at the end. Its §5 also names the two `rover` commands that
|
|
1352
|
-
close the federated-CI gap below, and the Actions job grew a `federation:diff`
|
|
1353
|
-
step.
|
|
1354
|
-
[testing](docs/testing.md) gains `fail_at`'s path syntax, an
|
|
1355
|
-
`extensions.input` example, `Testing::Sequence` for ending a paging loop, what
|
|
1356
|
-
`to_timeout` does and doesn't prove (webmock stands in for the socket, so
|
|
1357
|
-
neither it nor a `sleep` in `to_return` can exercise a `read_timeout:` of
|
|
1358
|
-
yours), that a fake-mode pin is keyed by the schema's scalar name rather than
|
|
1359
|
-
the Ruby class, that `:fake` never runs your server's `validates:` or custom
|
|
1360
|
-
validators, and loses the "partial failure" label on a call that fails the
|
|
1361
|
-
whole response. New there: **a production Apollo Router redacts what
|
|
1362
|
-
`Testing::Router` hands you** — with `include_subgraph_errors` omitted, the
|
|
1363
|
-
default, a subgraph error becomes `{"message" => "Subgraph errors redacted",
|
|
1364
|
-
"path" => […]}` with the extensions emptied, so a spec asserting on a
|
|
1365
|
-
subgraph's message or on the `extensions.service` stamp passes here and fails
|
|
1366
|
-
in staging; it says what to assert on instead and gives the `Failure.graphql`
|
|
1367
|
-
that reproduces the redacted shape. Also the nested-list multiplication
|
|
1368
|
-
behind `list_size:`, measured.
|
|
1369
|
-
[federation](docs/federation.md#producing-a-supergraph) gains the step it
|
|
1370
|
-
never had — how to *produce* a supergraph: `federation_sdl` from the
|
|
1371
|
-
`apollo-federation` gem (named in prose for the first time), where other
|
|
1372
|
-
teams' subgraph SDL comes from, and `rover supergraph compose` with its
|
|
1373
|
-
config and its `--elv2-license` flag. It also writes down that
|
|
1374
|
-
[`router.trace`'s fetch count is the local router's plan, not a
|
|
1375
|
-
gateway's](docs/federation.md#the-local-router): the data is faithful, the
|
|
1376
|
-
cost isn't quite — the same dashboard query is 4 fetches through a real
|
|
1377
|
-
`@apollo/gateway`, because a `@requires` prefetch here is its own call even
|
|
1378
|
-
when it goes to the same subgraph as the plain read beside it, so an N+1
|
|
1379
|
-
regression assertion belongs on a bound or on the subgraph set rather than on
|
|
1380
|
-
an exact count. Also newly written down there: a `@join__` directive the
|
|
1381
|
-
routing table doesn't read refuses `Router.new` for the whole graph (an
|
|
1382
|
-
upgrade-timing event for every team sharing the supergraph), a document that
|
|
1383
|
-
fails ordinary GraphQL validation gets an `errors` response rather than an
|
|
1384
|
-
`Unplannable`, and apollo-federation's `orphan_types`-before-`query` ordering
|
|
1385
|
-
trap. It now opens with a signpost saying which of its sections belong to a
|
|
1386
|
-
client team, a subgraph team and the supergraph owner, and gains four more:
|
|
1387
|
-
**in CI**, because no task in the toolbox looks at the schema production is
|
|
1388
|
-
serving — they all compare the app to its own checked-in artifacts, and
|
|
1389
|
-
`schema:diff` can't be pointed at a supergraph — so it names the two `rover`
|
|
1390
|
-
commands that close it; **`@tag` and contract variants**, what codegen says
|
|
1391
|
-
against each (it refuses at the boundary, which is the guarantee), why
|
|
1392
|
-
`federation:diff` can't tell two variants apart, that nothing cross-checks the
|
|
1393
|
-
variant you generated against with the endpoint you call, and that a variant
|
|
1394
|
-
is mechanically just another `GraphWeaver.graph`; **two changes every gate
|
|
1395
|
-
calls clean**, a scalar swapped for one that still serializes as the same JSON
|
|
1396
|
-
kind (`String!` → `Currency!`, `"19.99"` → `"$19.99"`) and an enum value
|
|
1397
|
-
removed where no query names it literally, with `schema:diff`'s `breaking:
|
|
1398
|
-
true` the only signal for either; and that an `@override` migration is
|
|
1399
|
-
finished from the **old** side, since deleting the new owner's copy first
|
|
1400
|
-
hands the field back to the subgraph you were migrating away from and nothing
|
|
1401
|
-
reports it. Also there: `Federation::Drift`'s `subgraphs:` takes a
|
|
1402
|
-
resolver-less schema from `SchemaLoader.load(sdl)`, so a subgraph published as
|
|
1403
|
-
SDL by a team that doesn't write Ruby is fully compared — and detection unions
|
|
1404
|
-
every candidate that fits a subgraph, so diffing a *proposal* has to name it.
|
|
1405
|
-
[generated modules](docs/generated_modules.md#an-input-object-generates-its-whole-closure)
|
|
1406
|
-
states the rule the docs implied and never said — results are generated per
|
|
1407
|
-
selection set, input types by transitive closure, which is why one Hasura
|
|
1408
|
-
`$where` emits a thousand files — and documents the escape hatch that only
|
|
1409
|
-
ever appeared in an error message: write the filter as a query literal with a
|
|
1410
|
-
variable per leaf. It also says how far a result's immutability goes: its
|
|
1411
|
-
props, so the `String` or `Hash` a leaf holds is the one the response carried,
|
|
1412
|
-
as with `Struct` or `Data` — and that caching one wants `Marshal`, not YAML,
|
|
1413
|
-
since a `T::Enum` member is a singleton compared by identity and Psych
|
|
1414
|
-
rehydrates a duplicate, so `pet.species == Species::Dog` comes back false. It
|
|
1415
|
-
also names the one enum misuse nothing catches — `result.tier == "GOLD"` is
|
|
1416
|
-
silently `false`, on a generated `T::Enum` as on any other — and the
|
|
1417
|
-
sorbet-runtime switch that reports it
|
|
1418
|
-
(`T::Configuration.enable_legacy_t_enum_migration_mode` plus a
|
|
1419
|
-
`soft_assert_handler`), which belongs in your boot rather than in generated
|
|
1420
|
-
code because it covers your own enums too. And two costs: `verify_generated!`
|
|
1421
|
-
runs a full `generate!` however little changed, while
|
|
1422
|
-
`T::Configuration.default_checked_level` buys nothing for `from_h`. Its
|
|
1423
|
-
type-helpers section now shows that an `extend_type` mixin *can* carry sigs
|
|
1424
|
-
`srb tc` checks — declare the fields it reads as abstract sigs, which the
|
|
1425
|
-
struct's `const`s satisfy.
|
|
1426
|
-
[upgrading](docs/upgrading.md) opens each version section with a table of
|
|
1427
|
-
which changes apply to you, separates the grep hits that self-heal on
|
|
1428
|
-
regenerate from the ones to rename by hand, and ends on `graph_weaver:verify`.
|
|
1429
|
-
New [alternatives](docs/alternatives.md) compares the field — graphql-client,
|
|
1430
|
-
graphlient, artemis, hand-rolled — including where this gem loses. Also:
|
|
1431
|
-
browsing a live schema from a console
|
|
1432
|
-
([real world](docs/real_world.md#browsing-the-schema)), the four settings
|
|
1433
|
-
rake-free generation needs to agree on, what `retries:` does and doesn't
|
|
1434
|
-
cover plus `pool_size:` and the pool's fork-safety, and that the request body
|
|
1435
|
-
carries no persisted-query id — so a gateway safelist with `require_id`
|
|
1436
|
-
refuses it, with the `Transport::HTTP` subclass that bolts APQ on today
|
|
1437
|
-
([transports](docs/transports.md#retries)), that Rails' own default
|
|
1438
|
-
`filter_parameters` includes `:email` so a stock app redacts an ordinary field
|
|
1439
|
-
named `email` the day the gem is added ([logging](docs/logging.md#filtered-variables)),
|
|
1440
|
-
and keying a translation on the exception class where there is no `kind`
|
|
1441
|
-
([i18n](docs/i18n.md#transport-and-server-failures)). And
|
|
1442
|
-
`spec/doc_samples_spec.rb` parses every fenced Ruby sample in README +
|
|
1443
|
-
`docs/` and resolves every link between them, so a sample that doesn't run
|
|
1444
|
-
can't ship — two that didn't now do (`Codegen.generate(client:)` takes the
|
|
1445
|
-
constant's *name*, and `Federation::Drift` needs `require
|
|
1446
|
-
"graph_weaver/federation"`).
|
|
1447
|
-
### v0.6.1 (2026-09-10)
|
|
1448
|
-
- **A stdlib scalar registers with nothing but its class.**
|
|
1449
|
-
`GraphWeaver.register_scalar("Decimal", BigDecimal)` now emits the whole
|
|
1450
|
-
codec: `BigDecimal(...)` reads the wire value, `to_s("F")` writes it back,
|
|
1451
|
-
and the generated file requires `bigdecimal`. The careful spelling people
|
|
1452
|
-
reached for was wrong — `serialize: :to_s` puts `"0.125e2"` on the wire —
|
|
1453
|
-
so the library owns it. Cast inference gained Kernel's conversion functions
|
|
1454
|
-
(`BigDecimal()`, and any `Kernel#Type` your app defines for a type the wire
|
|
1455
|
-
can't already be), and `serialize:` accepts `[:method, *args]` for a
|
|
1456
|
-
serializer that takes arguments. `cast:`/`serialize:`/`requires:` are
|
|
1457
|
-
unchanged, for a class of your own.
|
|
1458
|
-
- **Scalar names that are conventions are registered already**: graphql-ruby's
|
|
1459
|
-
`ISO8601Date`, `ISO8601DateTime`, `BigInt` and `JSON`, plus `DateTime` —
|
|
1460
|
-
what GitHub, Shopify and most hand-written schemas call an ISO 8601
|
|
1461
|
-
timestamp. A date deserializes into `Date` and a timestamp into `Time`, so
|
|
1462
|
-
nothing invents a midnight; `BigInt` reads the decimal string graphql-ruby
|
|
1463
|
-
writes as well as a JSON number; `JSON` is registered as `T.untyped` on
|
|
1464
|
-
purpose, which also drops it from the "unregistered custom scalars" report.
|
|
1465
|
-
A schema that means something else by one of these names fails loudly — the
|
|
1466
|
-
cast raises, naming the field — and one `register_scalar` overrides it, as
|
|
1467
|
-
it always could. **Regenerate** if a query of yours reads a scalar with one
|
|
1468
|
-
of those names: its prop was `T.untyped` and now has a type.
|
|
1469
|
-
|
|
1470
|
-
### v0.6.0 (2026-09-09)
|
|
1471
|
-
- **One rule for relative path settings: they resolve against
|
|
1472
|
-
`GraphWeaver.root`** — `Rails.root` in a Rails app, the working directory
|
|
1473
|
-
otherwise. There were two: cassettes resolved against `Rails.root` while
|
|
1474
|
-
the schema dump, queries and generated directories resolved against the
|
|
1475
|
-
process's working directory, and the railtie patched three of the four
|
|
1476
|
-
places that mattered. A Rails app started anywhere but its own root — a
|
|
1477
|
-
`bin/rails server` or an `rspec` run from a subdirectory — loaded no
|
|
1478
|
-
generated modules at all. An absolute setting is untouched, and the settings
|
|
1479
|
-
keep returning what you configured, so `graphql.config.yml` stays portable.
|
|
1480
|
-
Set `GraphWeaver.root` to override. The rule runs both ways: a path is
|
|
1481
|
-
resolved against the root on access and reported relative to it on output,
|
|
1482
|
-
so `generate!`'s return value, `changed_files`, `check_queries`' keys, the
|
|
1483
|
-
rake tasks' `wrote`/`pruned` lines and the file an error names all come back
|
|
1484
|
-
in the short form you configured. `cache:` also accepts a `Pathname` now,
|
|
1485
|
-
as `schema:` and `query:` always have.
|
|
1486
|
-
- **One spelling for the generated module's name: `name:`.** `GraphWeaver.parse`
|
|
1487
|
-
took `name:` while `Codegen.generate` took `module_name:`. **Breaking:**
|
|
1488
|
-
`module_name:` now raises naming `name:`. A parsed module also names itself
|
|
1489
|
-
in errors — `GraphWeaver.parse::PersonQuery::Result::Person` instead of a
|
|
1490
|
-
hex address — and assigning it to a constant upgrades every nested struct
|
|
1491
|
-
to the real path.
|
|
1492
|
-
- **`generate!` leaves a file alone when its content hasn't changed**, so the
|
|
1493
|
-
rake task prints `wrote` only for files it wrote and `N already up to date`
|
|
1494
|
-
for the rest, and a watching dev server reloads only what moved.
|
|
1495
|
-
`GraphWeaver.changed_files` says which. A generated file now says which
|
|
1496
|
-
release wrote it (`# Generated by GraphWeaver 0.6.0 — do not edit.`), so the
|
|
1497
|
-
first `verify` after an upgrade reports the tree as stale. **Regenerate.**
|
|
1498
|
-
- **A fake refuses an option it doesn't take**, naming the accepted ones and a
|
|
1499
|
-
did-you-mean, at every door (`FakeClient.new`, `graphql_fake`,
|
|
1500
|
-
`Router.new(fake:)`, `graphql_router(fake:)`) — it was a raw
|
|
1501
|
-
`unknown keyword` from inside the fabricator. A scalar the fake can't
|
|
1502
|
-
fabricate is now reported at its path (`at reader.orders.0.total`) with the
|
|
1503
|
-
coordinate to override (`"Order.total"`).
|
|
1504
|
-
- **A supergraph using federation 2.8's `@context`/`@fromContext` is refused
|
|
1505
|
-
per query rather than routed with the argument unset.** `@join__field` was
|
|
1506
|
-
on the routing table's known list, so its `contextArguments:` was read and
|
|
1507
|
-
dropped: `Testing::Router` planned a fetch for the contextual field with the
|
|
1508
|
-
argument empty. Apollo composes such a graph even when the context-setting
|
|
1509
|
-
type and the contextual field sit in different subgraphs, so nothing
|
|
1510
|
-
upstream caught it. The refusal fires only where the router would plan that
|
|
1511
|
-
fetch itself; a subtree one subgraph answers whole still runs. New
|
|
1512
|
-
`Unplannable` category `:context_argument`.
|
|
1513
|
-
- **The local router makes one entity fetch where it made two** when an entity
|
|
1514
|
-
has two `@requires` fields crossing into the same subgraph on the same
|
|
1515
|
-
`@key`. Both sent the identical representation; Apollo makes one. Specs
|
|
1516
|
-
asserting on `#trace` for such a query see one fewer entry. A missing `@key`
|
|
1517
|
-
on a representation now names the entity type in `InputError#struct`, as a
|
|
1518
|
-
coercion failure already did.
|
|
1519
|
-
- **Three rake and generator polish items.** `rake -T` no longer prints a
|
|
1520
|
-
queries path it can't know — a task description is baked before
|
|
1521
|
-
`:environment`, so it presented the default as your setting; it now names
|
|
1522
|
-
the defaults as defaults. `cassettes:anonymize` prints `no recordings in
|
|
1523
|
-
<dir>` instead of nothing. `rails g graph_weaver:install` scaffolds
|
|
1524
|
-
`app/graphql/fragments/.keep`, the directory its editor config already
|
|
1525
|
-
globbed; the getting-started note names `rails g graphql:install
|
|
1526
|
-
--skip-query-logs` for an app without Active Record.
|
|
1527
|
-
- **`filter_parameters` scrubs error messages, not just the debug log.** A
|
|
1528
|
-
rejected value reached the log twice: on the `variables=` line at debug, and
|
|
1529
|
-
in the message every error writes at `warn` — above the gate, as free text.
|
|
1530
|
-
One rule now covers both: a message the library composes about a value you
|
|
1531
|
-
supplied names it only when the key it arrived under isn't filtered. So
|
|
1532
|
-
`$password of Login: [FILTERED]`, while `$count of Compute: expected an Int,
|
|
1533
|
-
got "lots"` is unchanged — the value is usually the whole diagnosis. Applies
|
|
1534
|
-
to execute kwargs, input fields at any depth, federation `@key` fields, and
|
|
1535
|
-
the missing-cassette report. A wrong-typed input field with no coercer now
|
|
1536
|
-
says which field (`cents: expected BigDecimal, got 5`) instead of sorbet's
|
|
1537
|
-
sentence, and the local federation router no longer logs variables
|
|
1538
|
-
unscrubbed.
|
|
1539
|
-
- **The public surface is what the docs name, what generated code calls, and
|
|
1540
|
-
the `execute` slot — 421 names, down from about 700.** Everything else moved
|
|
1541
|
-
under `GraphWeaver::Internal`, whose one rule is that nothing in it is API,
|
|
1542
|
-
or went `private`. `spec/public_surface_spec.rb` diffs what's reachable from
|
|
1543
|
-
`GraphWeaver` against a checked-in list, so the next accidental promotion
|
|
1544
|
-
fails CI instead of shipping. **Breaking** only for code reaching past the
|
|
1545
|
-
documented door: the federation query planner and its IR are
|
|
1546
|
-
`Internal::Planner`; the fake-value engine is `Internal::Values`; the
|
|
1547
|
-
selection walk is `Internal::Selection` (so `FakeClient` no longer answers
|
|
1548
|
-
to `each_field` or `gather`); the cassette matching rules are
|
|
1549
|
-
`Internal::RequestKey`; `GraphWeaver.log`/`.instrument`/`.filter_variables`
|
|
1550
|
-
are `Internal::Log` (`logger=`, `instrumenter=`, `filter_parameters=` are
|
|
1551
|
-
unchanged); `Transport.operation_name`/`.mutation?`/`.log_tag` left the
|
|
1552
|
-
class you subclass for `Internal::Wire`. `SchemaDiff::Change`,
|
|
1553
|
-
`Cassette::Check`, `Coverage::Result` and `InputStruct::Field` are `Data`,
|
|
1554
|
-
not `Struct`, so they no longer hand out writers. `Codegen::ScalarType` is
|
|
1555
|
-
private from load rather than from the first `reset_scalars!` — the
|
|
1556
|
-
`private_constant` had landed inside the method body. Generated modules also
|
|
1557
|
-
stop exposing their plumbing: `DEFAULT_CLIENT`, `FIELDS` and `ONE_OF` are
|
|
1558
|
-
emitted as `private_constant`. **Regenerate**; nothing you call changes.
|
|
1559
|
-
- **The retry options are flat.** `retries:` is how many attempts follow the
|
|
1560
|
-
first; every other `Retry` option now sits beside it on the client —
|
|
1561
|
-
`GraphWeaver.new(url, retries: 5, backoff: :linear, retry_codes:
|
|
1562
|
-
["THROTTLED"])`. **Breaking:** `retries:` no longer takes a Hash (`retries:
|
|
1563
|
-
{ retries: 5 }` read as a key nested in itself), and three options are
|
|
1564
|
-
spelled out now that they sit next to a count: `on:` is `retry_on:`,
|
|
1565
|
-
`base:`/`max:` are `base_delay:`/`max_delay:` — `retries: 5, max: 30`
|
|
1566
|
-
invited reading `max:` as a second attempt count. The Hash form names its
|
|
1567
|
-
flat replacement, and a retry option passed without `retries:` says so.
|
|
1568
|
-
- **A representation's key fields are coerced, like an `execute` kwarg.**
|
|
1569
|
-
`Representations.user(id: params[:id])` typechecked statically and then
|
|
1570
|
-
raised sorbet-runtime's unbranded `TypeError` at runtime. The generated
|
|
1571
|
-
builders now carry `.checked(:never)` and run each key field through the
|
|
1572
|
-
registered scalar's own conversion; a value that converts to nothing raises
|
|
1573
|
-
`GraphWeaver::InputError` naming the representation and the field.
|
|
1574
|
-
**Regenerate.**
|
|
1575
|
-
- **The library's internals are private.** Everything not documented and not
|
|
1576
|
-
named by generated code is now `private` / `private_class_method` /
|
|
1577
|
-
`private_constant`: codegen's IR and walk, the schema loader's detection
|
|
1578
|
-
tables, the transport's log patterns, `Retry`'s default predicate, the
|
|
1579
|
-
file-naming helpers. **Breaking:** `GraphWeaver::Selection`'s methods are
|
|
1580
|
-
private in every class that mixes it in, so `Testing::FakeClient` no longer
|
|
1581
|
-
answers to `each_field`, `gather` or `load_operation` — never a supported
|
|
1582
|
-
door. The runtime that emitted source calls into (`cast_data`,
|
|
1583
|
-
`check_envelope!`, `Coerce`, `Hints`, `InputStruct`, `QueryModule`,
|
|
1584
|
-
`Representation`) stays public and says in its comments that it is
|
|
1585
|
-
generated code's, not yours.
|
|
1586
|
-
- **A registration another schema owns warns instead of failing generation.**
|
|
1587
|
-
One registry serves a whole graph — federation composes scalars and types
|
|
1588
|
-
by name — but validation ran against the single schema in hand, so the
|
|
1589
|
-
natural multi-subgraph pattern (register once, generate each query against
|
|
1590
|
-
its own subgraph) hard-failed. Generation now fails only on what the schema
|
|
1591
|
-
can *disprove*: a name it declares as something else, or a coordinate whose
|
|
1592
|
-
field it declares as a composite. Everything else warns, naming the schema
|
|
1593
|
-
and suggesting a corrected name. `rake graph_weaver:generate` and `verify`
|
|
1594
|
-
print that list once per run after the files;
|
|
1595
|
-
`GraphWeaver.unmatched_registrations` is the same list for a Rakefile.
|
|
1596
|
-
**Breaking**: a typo'd registration that used to stop generation is now a
|
|
1597
|
-
printed warning. `docs/federation.md` gains "Generating for a federated
|
|
1598
|
-
graph": through the gateway, generate against the supergraph; calling
|
|
1599
|
-
subgraphs directly, one client each with `reset_registrations!` between.
|
|
1600
|
-
- **Variables coerce inside `execute` now, and the sig stays as narrow as the
|
|
1601
|
-
schema.** `execute(first: params[:first])` works — the String becomes an
|
|
1602
|
-
Integer, an ISO-8601 string a `Date`, a model's Integer primary key an `ID` —
|
|
1603
|
-
while `execute(first: "20")` written literally in a typed file is still an
|
|
1604
|
-
`srb tc` error. The only way to accept loose input used to be `coerce: true`
|
|
1605
|
-
/ `GraphWeaver.auto_coerce`, which bought it by *widening* the emitted kwarg
|
|
1606
|
-
and so switched off the static check at every call site. The generated sigs
|
|
1607
|
-
are now `.checked(:never)` so an untyped value reaches the body; coercion is
|
|
1608
|
-
the check that replaces sorbet-runtime's, and it refuses more than sorbet
|
|
1609
|
-
did. Bad input raises `GraphWeaver::InputError` naming the variable, the
|
|
1610
|
-
operation and the value — the same 422 rescue point as an input-object
|
|
1611
|
-
field. **Breaking:** `GraphWeaver.auto_coerce` and `register_scalar(coerce:)`
|
|
1612
|
-
are gone; a scalar that had `coerce: true` keeps its behaviour but its kwarg
|
|
1613
|
-
narrows, so a call site passing a literal String is now an `srb tc` error.
|
|
1614
|
-
`cast:` is what a loose value converts through. **Regenerate.**
|
|
1615
|
-
- **Numeric strings are read as a wire format, not as Ruby source.** `"010"`
|
|
1616
|
-
is ten, not eight, and `"0x1f"` and `"1_0"` are refused, in variables and
|
|
1617
|
-
responses alike — `Kernel#Integer` accepted all three, which made a
|
|
1618
|
-
zero-padded form field silently mean something else. An `Int` refuses a
|
|
1619
|
-
`Float` it can't hold losslessly (`2.5`), and a `Boolean` refuses a String:
|
|
1620
|
-
Ruby has no `Kernel#Boolean`, so every rule for `"0"`/`"off"` is a
|
|
1621
|
-
convention the library declines to pick — convert at the call site.
|
|
1622
|
-
- **A faked subgraph takes the options every other fake takes, and
|
|
1623
|
-
`graphql_router` says them per example.** `subgraphs: { "reviews" => :fake }`
|
|
1624
|
-
built its fake with nothing, so `overrides:`, `list_size:` and `null_chance:`
|
|
1625
|
-
had nowhere to go. `fake:` carries them, on `Router.new`, on
|
|
1626
|
-
`Testing.config.router`, and per example through `graphql_router(fake:)`,
|
|
1627
|
-
which mirrors `graphql_fake`. One `fake:` covers every faked subgraph —
|
|
1628
|
-
coordinate-keyed overrides already say which type they mean. A `fail_at:`
|
|
1629
|
-
inside an `_entities` fetch used to null the field and report no error; it
|
|
1630
|
-
now comes back as an error on the caller's path.
|
|
1631
|
-
- **A subgraph two loaded schema classes fit is refused by the query that
|
|
1632
|
-
reaches it, not by `Router.new`.** In an app where several loaded schemas
|
|
1633
|
-
each satisfy a foundational subgraph, building a router raised for
|
|
1634
|
-
subgraphs the query under test never touched — and only when eager loading
|
|
1635
|
-
happened to be on. The router now builds, every query that avoids the
|
|
1636
|
-
subgraph runs, and one that reaches it raises naming the candidates, saying
|
|
1637
|
-
they came from loaded schema classes, and showing the `subgraphs:` form that
|
|
1638
|
-
pins one. A class named explicitly still fails at construction.
|
|
1639
|
-
`router.ambiguous` lists them. `Testing::Subgraphs.resolve` now returns a
|
|
1640
|
-
`Resolution` (`#served`, `#ambiguous`).
|
|
1641
|
-
- **A `.graphql` edit reaches the next request in development.** The query
|
|
1642
|
-
directories and the schema dump join Rails' own reloaders, and the
|
|
1643
|
-
`to_prepare` that loads the generated modules now regenerates first — after
|
|
1644
|
-
your registrations, so an `extend_type` in a `to_prepare` is in place before
|
|
1645
|
-
codegen reads it. A query that doesn't compile is logged at `error` with its
|
|
1646
|
-
file and position while the modules already loaded keep serving. Development
|
|
1647
|
-
only, while the server is running; `config.graph_weaver.watch = false` turns
|
|
1648
|
-
it off, and `rake graph_weaver:verify` still gates CI on the committed files.
|
|
1649
|
-
`GraphWeaver.reload_generated!` does the same by hand after regenerating in
|
|
1650
|
-
another terminal, and generated files are now written to a temp file and
|
|
1651
|
-
renamed, so a `rake graph_weaver:generate` beside a watching dev server can't
|
|
1652
|
-
hand the running app a prefix that no longer parses.
|
|
1653
|
-
- **A variable passed `nil` now sends `null`; one left out is still left
|
|
1654
|
-
out.** GraphQL tells an absent variable from an explicit null — `bio: null`
|
|
1655
|
-
clears a bio, omitting it does nothing — and a Ruby kwarg with a nil default
|
|
1656
|
-
cannot, so both spellings omitted the key and no generated mutation could
|
|
1657
|
-
clear a field. An optional nullable variable now records whether the keyword
|
|
1658
|
-
was supplied. Input objects get the same distinction where a Hash can express
|
|
1659
|
-
it: `coerce({nickname: nil})` sends null, `coerce({})` omits; a struct built
|
|
1660
|
-
with `.new` can't tell, so nil there still means omit. A non-null variable
|
|
1661
|
-
can't carry null, so nil there also still omits and its schema default
|
|
1662
|
-
applies. **Breaking**: `bio: params[:bio]` with a missing param now sends
|
|
1663
|
-
`null` where it used to omit — pass the keyword only when you mean to.
|
|
1664
|
-
**Regenerate.**
|
|
1665
|
-
- **A response that won't cast says which field, and whose bug it is.** A
|
|
1666
|
-
leaf's cast raises about the value alone — "invalid date" on a struct holding
|
|
1667
|
-
four dates located nothing — so each casting leaf now carries its response
|
|
1668
|
-
key. An `ID` the server sent unquoted is out of spec but read like a
|
|
1669
|
-
graph_weaver bug in sorbet's words; the message now says which it is and how
|
|
1670
|
-
to take it anyway. An enum value the generated enum doesn't hold now names
|
|
1671
|
-
the legal values and says the likely cause is drift, matching the input
|
|
1672
|
-
side. **Regenerate.**
|
|
1673
|
-
- **`register_scalar` with a type the wire can't build is refused at
|
|
1674
|
-
generation.** `register_scalar("Money", BigDecimal)` produced a `BigDecimal`
|
|
1675
|
-
prop and no cast, so every response failed its prop check at runtime, far
|
|
1676
|
-
from the initializer that caused it. Generation now refuses it where a query
|
|
1677
|
-
reads that scalar back, naming the field and how to give it a `cast:`.
|
|
1678
|
-
`docs/scalars.md` now tabulates what the wire carries in both directions.
|
|
1679
|
-
- **A fake takes pins, keyed by a scalar type, an object type, or a field.**
|
|
1680
|
-
A pin says what the fake uses instead of inventing a value: a wire value,
|
|
1681
|
-
an object it reads the selected fields off — a FactoryBot build, a model, a
|
|
1682
|
-
`Struct`; a field the object doesn't answer is still fabricated, and a Ruby
|
|
1683
|
-
value goes on the wire the way its scalar registration serializes it — or a
|
|
1684
|
-
proc handed the seeded `Random`. Pins lead and options follow, in one call;
|
|
1685
|
-
suite-wide, the same hash is `Testing.config.overrides`, and a router's
|
|
1686
|
-
`fake:` takes it for the subgraphs it fakes.
|
|
1687
|
-
|
|
1688
|
-
graphql_fake("Money" => "12.00", "Person" => build(:person), "Order.total" => "999.00")
|
|
1689
|
-
|
|
1690
|
-
A scalar registered as your own class — `register_scalar("Money", Money,
|
|
1691
|
-
cast: :parse)` — told codegen how to read the wire value but left the
|
|
1692
|
-
testing harness guessing what to write, so every fake response touching it
|
|
1693
|
-
died inside `from_h` blaming `Money.parse` for a `"Money-1"` placeholder.
|
|
1694
|
-
The type pin is the answer, from every door including cassette
|
|
1695
|
-
anonymization; without one, fabrication refuses, naming the scalar, the
|
|
1696
|
-
field, and the pin to add. Scalars registered as `Time`, `Date`, `Integer`,
|
|
1697
|
-
`Float`, `String` or `T::Boolean` need nothing. Fakes also now honor a
|
|
1698
|
-
per-field `register_scalar("User.birthday", Date)`, matching codegen's
|
|
1699
|
-
resolution. `graphql_fake` and `graphql_router(fake:)` refuse a per-example
|
|
1700
|
-
`seed:` — `rspec --seed` is the mechanism, and `config.seed` the suite-wide
|
|
1701
|
-
one for a harness that isn't rspec.
|
|
1702
|
-
- **`Testing.config.null_chance` and `Testing.config.mode` are gone; both
|
|
1703
|
-
are per-fake now.** A suite-wide answer to a per-example question nils an
|
|
1704
|
-
unrelated field one run in ten. **Move them:** `graphql_fake(null_chance:
|
|
1705
|
-
0.3)` and `graphql_fake(values: :literal)` (the per-fake `mode:` is renamed
|
|
1706
|
-
`values:` so it can't be confused with `graphql: :fake` /
|
|
1707
|
-
`config.default_mode`, which stay). The auto-detect — faker when the gem is
|
|
1708
|
-
loaded, else literal — is the only suite-wide value style.
|
|
1709
|
-
`Testing::MODES` is now `VALUE_STYLES`.
|
|
1710
|
-
- **`retries:` takes the count you'd write, and means the same thing
|
|
1711
|
-
everywhere.** `GraphWeaver.new(url, retries: 3)` raised and pointed at
|
|
1712
|
-
`retries: { tries: 3 }` — a second word for the same number that disagreed
|
|
1713
|
-
about whether it counted the first attempt. One word now: `retries:` is how
|
|
1714
|
-
many attempts follow the first, on the client and on `Retry` alike, so
|
|
1715
|
-
`retries: 0` is one attempt. **Rename** `Retry.new(tries: n)` to
|
|
1716
|
-
`Retry.new(retries: n - 1)`; the default is unchanged in effect.
|
|
1717
|
-
- **`Response#to_h`.** Every error class answered `#to_h`; the envelope didn't.
|
|
1718
|
-
It returns `{"data" =>, "errors" =>, "extensions" =>}` with each error as its
|
|
1719
|
-
JSON-ready hash. `data` stays the typed struct rather than re-serialized:
|
|
1720
|
-
`T::Struct#serialize` gives snake_case keys, drops nulls, and leaves a
|
|
1721
|
-
registered scalar as its Ruby object — output that would pass for the
|
|
1722
|
-
server's response without being one.
|
|
1723
|
-
- **Sensitive variables are scrubbed from the debug log.** A `login(password:)`
|
|
1724
|
-
mutation's variables were one log-level switch away from the log. Matching
|
|
1725
|
-
values are replaced with `[FILTERED]` at any depth, on the wire and
|
|
1726
|
-
in-process paths. Rails apps configure nothing: the railtie adopts the app's
|
|
1727
|
-
own `config.filter_parameters`. Elsewhere,
|
|
1728
|
-
`GraphWeaver.filter_parameters = [:password, /token/]`; the default list is
|
|
1729
|
-
`[:password, :token, :secret, :authorization]`, matched as case-insensitive
|
|
1730
|
-
substrings, and `[]` turns filtering off.
|
|
1731
|
-
- **`schema:diff` names what changed, not just that something did.** It
|
|
1732
|
-
reported `schema.json is stale` and stopped, so learning what moved meant
|
|
1733
|
-
refreshing and reading a `git diff` of a 3 MB dump. It now prints one line
|
|
1734
|
-
per change at its schema coordinate, breaking ones first — types, fields,
|
|
1735
|
-
arguments, nullability, enum values, union members, interfaces and
|
|
1736
|
-
deprecations — and still exits non-zero on any drift. Breaking is judged from
|
|
1737
|
-
the client's side, so nullability is directional: an output losing `!` breaks
|
|
1738
|
-
a generated struct, an input gaining one breaks a query that omits it.
|
|
1739
|
-
`SchemaLoader.stale?` is now `SchemaLoader.diff`, returning the summary;
|
|
1740
|
-
`diff(path).empty?` is what `stale?` answered.
|
|
1741
|
-
- **A Rails app whose generated code includes an autoloaded helper now
|
|
1742
|
-
boots.** `extend_type("Pet", PetHelpers)` and `register_enum("Species",
|
|
1743
|
-
PetKind)` name constants your app autoloads, so the library tells you to
|
|
1744
|
-
register them from a `to_prepare` block — but the railtie required the
|
|
1745
|
-
generated files before Zeitwerk was set up and before `to_prepare` ran, so
|
|
1746
|
-
the `include PetHelpers` those registrations emit raised `NameError` at every
|
|
1747
|
-
boot, in every environment. They now load from a `to_prepare` block of their
|
|
1748
|
-
own: after the autoloader, after your registrations, before eager loading.
|
|
1749
|
-
- **A fragment on the abstract type no longer narrows the field away.**
|
|
1750
|
-
`named { __typename ... on Named { name } ... on Pet { species } }` read the
|
|
1751
|
-
`Named` fragment as a type condition and narrowed to Pet, so a Person came
|
|
1752
|
-
back as `nil` and the `name` the server sent went on the floor — while the
|
|
1753
|
-
same selection spelled bare dispatched correctly. Two ways of writing one
|
|
1754
|
-
query disagreed, and the fragment is how anyone shares interface fields.
|
|
1755
|
-
**Regenerate** to pick it up.
|
|
1756
|
-
- **A `Float` field reads the whole number a JSON encoder writes.** JSON has
|
|
1757
|
-
one number type, so `1.0` reaches Ruby as an `Integer` from graphql-js and
|
|
1758
|
-
Go's `encoding/json` alike, and `const :amount, Float` refused a response the
|
|
1759
|
-
spec calls legal. Widened with `Kernel#Float`, which still refuses what
|
|
1760
|
-
`.to_f` would quietly turn into `0.0`. **Regenerate** to pick it up.
|
|
1761
|
-
- **A guarded `__typename` no longer makes a narrowing miss look like a
|
|
1762
|
-
match.** Narrowing without a readable tag decides on emptiness, but a
|
|
1763
|
-
`__typename` behind `@skip`/`@include` still arrives for the member narrowing
|
|
1764
|
-
means to filter, so the wrong member was cast. That query now refuses at
|
|
1765
|
-
generation, with the message that already names the fix.
|
|
1766
|
-
- **Generation refuses what Ruby would misread.** A result key that would
|
|
1767
|
-
shadow a constant the file uses (`date` beside a `Date` scalar nested a
|
|
1768
|
-
`class Date < T::Struct` and typed the scalar prop as that struct) is refused
|
|
1769
|
-
naming both keys; so is an enum value that camelizes to nothing (`_`), which
|
|
1770
|
-
emitted a file that died at load with a syntax error.
|
|
1771
|
-
- **A bad enum value in a variable says what the enum accepts**, as an
|
|
1772
|
-
`InputError` — it raised a bare `KeyError` naming an anonymous module as a
|
|
1773
|
-
top-level variable while the same mistake inside an input object was already
|
|
1774
|
-
branded. A missing required input field now lists every missing key instead
|
|
1775
|
-
of sorbet's symptom for the first prop it reached. **Regenerate** to pick up
|
|
1776
|
-
the new emitted coercion.
|
|
1777
|
-
- **A named fragment now crosses a subgraph boundary.** A stitched fetch is
|
|
1778
|
-
built from selections alone, so a spread that survived into one named a
|
|
1779
|
-
fragment the subgraph had never seen and the whole subtree came back
|
|
1780
|
-
`Fragment X was used, but not defined`. Every spread a fetch carries is now
|
|
1781
|
-
spelled as the inline fragment it is.
|
|
1782
|
-
- **A fake's value for a custom scalar comes from what you registered it
|
|
1783
|
-
as.** It was picked off the schema's *name* for the scalar, so a `Timestamp`
|
|
1784
|
-
registered as `Time` got `"Timestamp-1"` and every fake response touching it
|
|
1785
|
-
died inside the codec codegen had emitted for it. A scalar registered as
|
|
1786
|
-
`Time` now gets iso8601, one registered as `Integer` an Integer; an
|
|
1787
|
-
unregistered one keeps its placeholder. Cassette anonymization runs through
|
|
1788
|
-
the same engine and had the same hole, which made `cassettes:check` fail on
|
|
1789
|
-
a cassette the anonymizer had just written. **Re-run
|
|
1790
|
-
`rake graph_weaver:cassettes:anonymize`** on a cassette holding a registered
|
|
1791
|
-
custom scalar.
|
|
1792
|
-
- **A mutation is no longer retried.** A read timeout, a 502 or a reset socket
|
|
1793
|
-
does not say whether the server applied the request, and a second `charge`
|
|
1794
|
-
is worse than a failed one — so `Retry` gives a mutation one attempt and says
|
|
1795
|
-
so on the logger. Pass `retry_mutations: true` for an API whose mutations are
|
|
1796
|
-
idempotent. Each retry now logs the wait and the attempt number.
|
|
1797
|
-
- **A connection that dies mid-body is a dropped connection.** net/http's
|
|
1798
|
-
`ignore_eof` default handed back the bytes that arrived when a socket closed
|
|
1799
|
-
short of its `Content-Length`, so a half-sent response reached you as a 200
|
|
1800
|
-
with a truncated body — a permanent `ServerError` that `Retry` would not
|
|
1801
|
-
retry. It now raises the retriable `TransportError` it is.
|
|
1802
|
-
- **An in-process query gets its own context.** graphql-ruby writes a
|
|
1803
|
-
resolver's `context[...] =` into the hash it is handed, and one `InProcess`
|
|
1804
|
-
is normally the whole app's client — so a flag set by one request was still
|
|
1805
|
-
there for the next, and racing between them under Puma.
|
|
1806
|
-
- **A response that carries neither `data` nor `errors` is refused.** A client
|
|
1807
|
-
returning `nil`, one keying the envelope by symbol, one that typo'd `"dat"`:
|
|
1808
|
-
each produced a `Response` reporting `success?` with `data` nil.
|
|
1809
|
-
`from_response` is documented public API and symbolized keys are the
|
|
1810
|
-
likeliest mistake at that seam, so it now brands, naming the keys it found;
|
|
1811
|
-
a client answering with a String brands too, instead of dying on `#to_h`.
|
|
1812
|
-
**Regenerate** to pick that up.
|
|
1813
|
-
- **`@skip`/`@include` are evaluated by the fake against the variables you
|
|
1814
|
-
passed**, declared defaults included — the way a server and
|
|
1815
|
-
`Testing::Router` already did, so one query no longer carries a key under
|
|
1816
|
-
`graphql: :fake` and not under `graphql: :router`. A faked subgraph does the
|
|
1817
|
-
same for directives inside its `_entities` selection. A `first:`/`last:`
|
|
1818
|
-
arriving as a variable caps the fabricated list like a literal, a cap below
|
|
1819
|
-
zero reads as a page of none, and `null_chance` reaches list positions.
|
|
1820
|
-
- **An alias spelling a response key the router carries a `@key` under is
|
|
1821
|
-
refused.** The router injects the `@key` it crosses on under a reserved key
|
|
1822
|
-
and strips it from the answer, so `_gw_weight: weight` came back without
|
|
1823
|
-
`_gw_weight` at all. It joins the `shadowed_key` refusal, which already
|
|
1824
|
-
covered Apollo's half of the same collision.
|
|
1825
|
-
- **A `@requires` field set excluded by `@skip`/`@include` no longer
|
|
1826
|
-
prefetches.** The router ran a subgraph fetch a real router never makes —
|
|
1827
|
-
which matters for a test double, where a resolver that runs is one your
|
|
1828
|
-
example can observe.
|
|
1829
|
-
- **`verify_generated!` fails over zero query documents**, and `generate!`
|
|
1830
|
-
warns. A mistyped `queries_paths` left `verify` returning true having
|
|
1831
|
-
compared nothing, so a CI gate stayed green forever.
|
|
1832
|
-
- **`rake graph_weaver:generate` says what it pruned, and where it looked when
|
|
1833
|
-
there was nothing to generate.** Deleting a `.graphql` deletes the checked-in
|
|
1834
|
-
file it produced, and the task printed nothing and exited 0; so did a run
|
|
1835
|
-
with no queries, the state every install starts in.
|
|
1836
|
-
- **`Failure.server` takes the headers a backoff branches on.**
|
|
1837
|
-
`ServerError#retry_after` and `#throttled?` read `Retry-After`, and there was
|
|
1838
|
-
no way to set one: `Failure.server(status: 429, headers: { "retry-after" =>
|
|
1839
|
-
"2" })`. `Failure.transport`'s message now names the class it caught, the
|
|
1840
|
-
way the bundled transports do.
|
|
1841
|
-
- **Cassette recording is safe across threads.** `record` was
|
|
1842
|
-
read-modify-write; two threads recording through one cassette each saved a
|
|
1843
|
-
snapshot missing the other's entry.
|
|
1844
|
-
- **A `.json` schema dump that isn't JSON says which file and what it holds.**
|
|
1845
|
-
A truncated download or a login page saved over the dump raised a bare
|
|
1846
|
-
`JSON::ParserError`, outside `GraphWeaver::Error` and naming neither.
|
|
1847
|
-
`rake graph_weaver:schema:refresh` — the fix — no longer trips over it.
|
|
1848
|
-
- **Setup mistakes say what to do.** A schema source that looks like a url
|
|
1849
|
-
(`localhost:4000/graphql` included) gets the url advice first; a non-token
|
|
1850
|
-
`auth:`, a `retries:` that is neither true nor a Hash, a url
|
|
1851
|
-
`Transport::HTTP` cannot POST to, a `parse` path without a `.graphql`
|
|
1852
|
-
extension, a cache directory that can't be written, `schema:refresh URL=`
|
|
1853
|
-
with a file path, and `schema:diff` with no dump all name the fix. A
|
|
1854
|
-
`ServerError` carries the advice its status implies: a 3xx names where the
|
|
1855
|
-
server pointed (redirects are not followed) and a 401/403 names `auth:`. A
|
|
1856
|
-
spec with a `graphql:` tag but no `graph_weaver/rspec` is told to require
|
|
1857
|
-
it, a misspelled tag reports one failure instead of two, `InputError` no
|
|
1858
|
-
longer prints sorbet's `Caller:` frame, a generated file whose registered
|
|
1859
|
-
constant is gone names the registration, and `rails g graph_weaver:install`
|
|
1860
|
-
names a retry that works when introspection fails.
|
|
1861
|
-
- **The GitHub example's checked-in modules were regenerated**, and a spec now
|
|
1862
|
-
keeps them in step with the emitter.
|
|
1863
|
-
- **A cold process no longer introspects once per in-flight thread.** A
|
|
1864
|
-
url-built client fetches its schema lazily, and Puma serves its first
|
|
1865
|
-
requests concurrently — so eight threads arriving together meant eight full
|
|
1866
|
-
introspection round trips, each of them also writing the schema cache. One
|
|
1867
|
-
now, whoever asks first.
|
|
1868
|
-
- **The schema cache and cassettes are written atomically.** `File.write`
|
|
1869
|
-
truncates before it writes, so an interrupted run — or a second writer, a
|
|
1870
|
-
rake task beside a running app — could leave a half-written file where a
|
|
1871
|
-
committed artifact used to be. Both now write beside the target and rename,
|
|
1872
|
-
which is atomic: a reader sees the old file or the new one.
|
|
1873
|
-
- **The connection pool keeps its ceiling under an interrupt.** An async raise
|
|
1874
|
-
landing between taking a permit and the `ensure` that returns it would have
|
|
1875
|
-
leaked one for the life of the process, and `Rack::Timeout` raises exactly
|
|
1876
|
-
that way; the gap is closed. Its saturation warning is also once now rather
|
|
1877
|
-
than once per racing thread.
|
|
1878
|
-
|
|
1879
|
-
### v0.5.1 (2026-09-07)
|
|
1880
|
-
- **A union's catch-all struct keeps the fields an interface fragment asked
|
|
1881
|
-
for.** `... on Named { name }` under a union types `name` on every member the
|
|
1882
|
-
query named, but the catch-all — the member it didn't name, including one the
|
|
1883
|
-
schema grows later — carried only `__typename`, so a `name` the server
|
|
1884
|
-
actually sent was dropped on the floor. Those fields are `T.nilable`: the
|
|
1885
|
-
member that arrives needn't implement the interface. **Regenerate** to pick
|
|
1886
|
-
it up.
|
|
1887
|
-
- **`schema_stale?` now recognises the drift a graphql-ruby server reports.**
|
|
1888
|
-
It matched Apollo's one flat code plus a message pattern, but graphql-ruby —
|
|
1889
|
-
which this library ships as its in-process client — names the rule that fired
|
|
1890
|
-
in `extensions.code`. So a renamed type, a removed argument, an argument that
|
|
1891
|
-
became required and four other drift shapes all raised without the one
|
|
1892
|
-
sentence telling you to refresh the dump and regenerate, and `to_h` reported
|
|
1893
|
-
`"schema_stale": false`.
|
|
1894
|
-
- **A cast failure keeps the server's own explanation.** A field that came back
|
|
1895
|
-
null *with a reason* — a permission rule, a partial outage — raised only
|
|
1896
|
-
Sorbet's nil complaint, because the generated envelope built `data` before it
|
|
1897
|
-
read `errors`. The reason is now in the message. **Regenerate** to pick it up.
|
|
1898
|
-
- **Cassette anonymization missed `errors` and `extensions`.** It walked
|
|
1899
|
-
`data` and nothing else, so a rejected request's error message — which
|
|
1900
|
-
routinely quotes the input that caused it — and whatever the server hangs
|
|
1901
|
-
off `extensions` went to disk verbatim, under a flag that says real data
|
|
1902
|
-
never reaches it. **Re-run `rake graph_weaver:cassettes:anonymize` on any
|
|
1903
|
-
cassette you've already committed**, or re-record it. Neither key has a
|
|
1904
|
-
schema behind it, so both are now walked by shape: keys, nesting, nulls and
|
|
1905
|
-
booleans survive and every string and number is replaced, with `path`,
|
|
1906
|
-
`locations` and an error's `extensions.code` kept because they describe the
|
|
1907
|
-
request rather than the data.
|
|
1908
|
-
- **Recording flags a credential that lands in the file.** A cassette gets
|
|
1909
|
-
committed as written, and its query and variables can't be anonymized —
|
|
1910
|
-
they're the key replay matches on. So the bytes headed for disk are checked
|
|
1911
|
-
for the shapes that are unmistakable (a JWT, `AKIA…`, `ghp_…`, `xox…`,
|
|
1912
|
-
`sk_live_…`, a PEM block, a `Bearer` header) and named on stderr once per
|
|
1913
|
-
cassette. Nothing is rewritten; a password like `hunter2` has no shape, so a
|
|
1914
|
-
quiet run still isn't a clean bill of health.
|
|
1915
|
-
- **An anonymized cassette no longer drops a key selected twice.** The
|
|
1916
|
-
anonymizer kept the last occurrence's sub-selection where codegen merges
|
|
1917
|
-
them, so `a { x } a { y }` came back holding only `y` — a recording that
|
|
1918
|
-
then failed its own `cassettes:check`.
|
|
1919
|
-
- **Three generated types disagreed with the schema.** `srb tc` proves the
|
|
1920
|
-
generated code is self-consistent, not that it is true, so each of these was a
|
|
1921
|
-
lie the typechecker endorsed. **Regenerate** to pick them up:
|
|
1922
|
-
- A field reached only through a `@skip`/`@include`-guarded occurrence, where
|
|
1923
|
-
the same key is also selected unguarded, was typed as guaranteed. The server
|
|
1924
|
-
legitimately omits it, so `from_h` raised `key not found` on a valid
|
|
1925
|
-
response — and a union whose `__typename` arrived that way crashed its own
|
|
1926
|
-
dispatch. Those children are nilable now, and the `__typename` case refuses
|
|
1927
|
-
at generation as it already did for the plainer spelling.
|
|
1928
|
-
- A narrowed abstract inside a non-null list made the *list* nilable
|
|
1929
|
-
(`[Thing!]!` with `... on Widget` → `T.nilable(T::Array[…])`). Only the
|
|
1930
|
-
elements can be nil; the array is always there.
|
|
1931
|
-
- An input field the schema gives a default is optional, but a non-null one
|
|
1932
|
-
emitted `const :x, String, default: nil` — so `x.upcase` typechecked and
|
|
1933
|
-
was a `NoMethodError`.
|
|
1934
|
-
- **`rake graph_weaver:federation:coverage` no longer counts a subgraph as
|
|
1935
|
-
served here when two loaded schemas fit it.** `Router.new` refuses that case
|
|
1936
|
-
— picking either would be a coin flip — so the report was promising a run
|
|
1937
|
-
the suite can't have. It now lands in the "nothing here serves what they
|
|
1938
|
-
reach" list, whose advice (name a schema for those subgraphs) is the fix.
|
|
1939
|
-
- **A supergraph that renamed the join spec is now refused instead of read as
|
|
1940
|
-
empty.** `@link(url: ".../join/v0.3", as: "j")` spells every marker `j__`,
|
|
1941
|
-
which the routing table doesn't follow — so it reported a composed graph with
|
|
1942
|
-
*no subgraphs* and nothing unsupported, and `Testing::Router` built happily on
|
|
1943
|
-
top of that. It now lands in `unsupported`, so the router and
|
|
1944
|
-
`federation:coverage` refuse at construction and say why.
|
|
1945
|
-
- **`rake graph_weaver:cassettes:anonymize` now finds an SDL schema dump.** It
|
|
1946
|
-
opened `GraphWeaver.schema_path` directly instead of locating the dump the
|
|
1947
|
-
way every sibling task does, so an app whose committed dump is
|
|
1948
|
-
`schema.graphql` (what the generator writes for a supergraph) got a raw
|
|
1949
|
-
`Errno::ENOENT`. A missing dump now says so.
|
|
1950
|
-
- **`rake graph_weaver:queries:check` now authenticates with the token your
|
|
1951
|
-
app uses.** It re-introspects the url the dump records, but read
|
|
1952
|
-
`GRAPHWEAVER_AUTH` rather than the ENV var the dump named — so an app
|
|
1953
|
-
installed with `--auth MY_TOKEN` reached its private API while the check
|
|
1954
|
-
401'd. Every re-introspection now goes through `SchemaLoader.source_transport`,
|
|
1955
|
-
which is also now public.
|
|
1956
|
-
- **A `graphql: :router` example now starts from the same fabricated data
|
|
1957
|
-
whether it runs alone or after two hundred others.** The router is built once
|
|
1958
|
-
for the suite, and a faked subgraph kept fabricating from wherever the last
|
|
1959
|
-
example left its sequence — so `rspec spec/foo_spec.rb:12` disagreed with the
|
|
1960
|
-
same example in a full run, which is the opposite of what `--seed` promises.
|
|
1961
|
-
`Router#reset!` is the example boundary (`reset_trace` still clears only the
|
|
1962
|
-
trace); the rspec tag calls it for you.
|
|
1963
|
-
- **An override key given as a Symbol now pins.** `graphql_fake(overrides: { name: "Ada" })`
|
|
1964
|
-
validated clean and silently fabricated a random value, because lookup is by
|
|
1965
|
-
String — the exact silent-green failure the override validation exists to
|
|
1966
|
-
prevent.
|
|
1967
|
-
- **A helper no longer contradicts `config.default_mode`.** With
|
|
1968
|
-
`default_mode = :fake`, an untagged example calling `graphql_in_process`
|
|
1969
|
-
raised *"this example is tagged graphql: :fake"* — naming a tag that wasn't
|
|
1970
|
-
there, and blocking the untagged form the docs recommend. Only an explicit
|
|
1971
|
-
tag can disagree with a helper now.
|
|
1972
|
-
- **A variable default that isn't a Boolean no longer reaches a subgraph.** An
|
|
1973
|
-
enum or input-object default is a parser AST node; sending one put a
|
|
1974
|
-
back-pointer to the lexer on the wire, and an input object raised
|
|
1975
|
-
`NoMethodError` outside any rescue. Only `@skip`/`@include` read these, and
|
|
1976
|
-
they read Booleans; a subgraph applies its own defaults from the variable
|
|
1977
|
-
declaration it already receives.
|
|
1978
|
-
- The local router asks the schema for one type by name rather than rebuilding
|
|
1979
|
-
the whole type map, at four sites — one of them per response row. On a
|
|
1980
|
-
1200-type supergraph a stitched query over 200 rows was **5x** slower than it
|
|
1981
|
-
needed to be; the cost is now flat in schema size.
|
|
1982
|
-
- **`graphql: :in_process` ran the committed schema dump instead of your live
|
|
1983
|
-
schema class.** A dump loads as an anonymous `GraphQL::Schema` subclass, which
|
|
1984
|
-
looks like a runnable class and has no resolvers — so every app that followed
|
|
1985
|
-
`getting_started.md` (the generator always commits a dump) got a graphql-ruby
|
|
1986
|
-
500 blaming its own resolver, and the error that was written to explain this
|
|
1987
|
-
was unreachable. Nothing to do; if you worked around it with
|
|
1988
|
-
`Testing.config.schema = MySchema`, that still works and still wins.
|
|
1989
|
-
- **`GraphWeaver.parse(query:)` accepts a `Pathname`.** `schema:` already did,
|
|
1990
|
-
so `GraphWeaver.parse(schema: Rails.root.join("schema.graphql"), query:
|
|
1991
|
-
Rails.root.join("app/graphql/queries/person.graphql"))` died on the query
|
|
1992
|
-
argument with `undefined method 'end_with?'`. Same for `client.parse(path)`.
|
|
1993
|
-
- **`rake graph_weaver:cassettes:check` and `:anonymize` now find cassettes
|
|
1994
|
-
from any working directory.** Both read `config.cassette_dir` raw, while
|
|
1995
|
-
`Cassette.new` resolves it against `Rails.root` — so run from anywhere but
|
|
1996
|
-
the app root, `check` aborted with "this checked nothing, so it proved
|
|
1997
|
-
nothing" and `anonymize` silently did nothing, both while a spec run found
|
|
1998
|
-
the same files fine. They now use the same resolution the recordings do.
|
|
1999
|
-
- **An `extend_type(alias:)` name a struct instance already answers to now
|
|
2000
|
-
refuses** instead of silently overriding it. `alias: { hash: "…" }` emitted
|
|
2001
|
-
`def hash`, which breaks every `Hash` and `Set` holding that struct;
|
|
2002
|
-
`inspect`, `to_s`, `method` and `class` were the same story. A *wire field*
|
|
2003
|
-
by any of those names was already refused, so this is the same rule reaching
|
|
2004
|
-
the same case from the other side. **If generation now refuses an alias you
|
|
2005
|
-
had**, rename it — the accessor it generated was overriding a Ruby method.
|
|
2006
|
-
|
|
2007
|
-
### v0.5.0 (2026-09-07)
|
|
2008
|
-
- **`graphql_in_process(SomeSchema)`** runs one example against that schema
|
|
2009
|
-
class's real resolvers — the sibling of `graphql_fake`, and how a federated
|
|
2010
|
-
app tests a single subgraph directly rather than through the stitched graph.
|
|
2011
|
-
`graphql: :in_process` is that call with no argument, which runs
|
|
2012
|
-
`config.schema` when it is a live class, so a non-federated app needs
|
|
2013
|
-
nothing. A suite testing several subgraphs names one per example, which no
|
|
2014
|
-
suite-wide setting could express.
|
|
2015
|
-
- **`Testing::Router` crosses a boundary on a nested `@key` or `@requires`.**
|
|
2016
|
-
`@key(fields: "id organization { id }")` and
|
|
2017
|
-
`@requires(fields: "origin { lat lon }")` used to refuse; they now plan, to
|
|
2018
|
-
any depth, with the object carried in the representation the way the SDL
|
|
2019
|
-
spells it (a null inner object included). **Nothing to do** — queries your
|
|
2020
|
-
suite marked "run this one against a real router" may now simply run. Where
|
|
2021
|
-
a type declares more than one `@key`, the plan takes the first one the
|
|
2022
|
-
fetching subgraph can supply.
|
|
2023
|
-
- **`:nested_field_set` narrowed rather than disappeared.** It now names only
|
|
2024
|
-
a nested field set no one *fetch* can build — `origin` resolving in one
|
|
2025
|
-
subgraph and `origin.lat` in another, or a nested `@key` whose object a
|
|
2026
|
-
`@requires` would half-fill from somewhere else. Either way the object
|
|
2027
|
-
would arrive in pieces, and a representation comes from one fetch. If you
|
|
2028
|
-
group refusals by category, that bucket shrinks; its message and
|
|
2029
|
-
`docs/federation.md`'s table say what is left. `:chained_requires` is
|
|
2030
|
-
unchanged and still a different refusal.
|
|
2031
|
-
- **`Testing.config.router` takes `subgraphs:` without `supergraph:`.** It
|
|
2032
|
-
raised — "must be the arguments to build one, e.g. `{ supergraph: … }`" —
|
|
2033
|
-
even where the committed dump already is the supergraph, which is the case
|
|
2034
|
-
the docs call "no config at all" and the single most likely config a
|
|
2035
|
-
federated app writes (marking a remote subgraph `:fake`). Either key alone
|
|
2036
|
-
is enough now; a missing `supergraph:` derives exactly as it does with no
|
|
2037
|
-
`config.router` at all.
|
|
2038
|
-
- **`graphql_fake(**options)`** builds the example's fake where the example
|
|
2039
|
-
can say what it needs — `graphql_fake(overrides: { "Reader.orders" => [{}, {}] })`
|
|
2040
|
-
— and returns it, so `#requests` is in reach. `graphql: :fake` is this call
|
|
2041
|
-
with no options. Options had nowhere to go before: the tag builds its client
|
|
2042
|
-
in a `config.before(:each)`, which rspec runs ahead of every group hook, so
|
|
2043
|
-
`Testing.config.overrides` set in a `before` block was always too late and
|
|
2044
|
-
failed silently, as wrong data.
|
|
2045
|
-
- **`GraphWeaver.client` is snapshotted and restored around *every* example**,
|
|
2046
|
-
not only a tagged one. `graphql: false` used not to restore while
|
|
2047
|
-
`graphql: :fake` did, which made "tag `:fake`, then throw the client away"
|
|
2048
|
-
the idiom for cleanup. Building your own client is now a plain assignment in
|
|
2049
|
-
a `before` block. An example that deliberately leaked a client into later
|
|
2050
|
-
examples no longer can.
|
|
2051
|
-
- **`graphql: :none` is gone** — a second spelling of `graphql: false`, which
|
|
2052
|
-
stays. Change any `:none` tag to `false`.
|
|
2053
|
-
- **`GraphWeaver.client!` names the tag** when `graph_weaver/rspec` is loaded:
|
|
2054
|
-
`no client configured — tag the example graphql: :fake (or :in_process /
|
|
2055
|
-
:router), or build one with graphql_fake`. "Set `GraphWeaver.client=`" was
|
|
2056
|
-
advice for the wrong file.
|
|
2057
|
-
- **An override pins a subtree by naming only the fields the test is about.**
|
|
2058
|
-
`overrides: { "Reader.orders" => [{ "status" => "PAID" }, {}] }` pins the
|
|
2059
|
-
list's length and merges each element onto fabricated data — the rest of the
|
|
2060
|
-
selection is still generated. It used to *replace*, so pinning one nested
|
|
2061
|
-
field meant hand-writing the whole selection set in wire casing, and
|
|
2062
|
-
under-supplying died as `key not found: "book"` at cast time. A pinned key
|
|
2063
|
-
the query doesn't select is now refused (spellchecked, and naming the
|
|
2064
|
-
response keys it could have been), for the same reason a typo'd coordinate
|
|
2065
|
-
is. At a union or interface, a pinned object names its `"__typename"` and
|
|
2066
|
-
gets that member rather than a random one.
|
|
2067
|
-
- **An override of `nil` pins the field null.** It used to read as "no
|
|
2068
|
-
override" and fabricate a value.
|
|
2069
|
-
- **`Testing::FakeClient#requests`** records every `execute` in order
|
|
2070
|
-
(`{ query:, variables:, operation_name: }`) — "did we send the right
|
|
2071
|
-
variables", and "did we call it at all", without a hand-rolled spy.
|
|
2072
|
-
- **`FakeClient`'s selection-walking internals are private** (`each_field`,
|
|
2073
|
-
`gather`, `load_operation`, …). Nothing documented called them; if you did,
|
|
2074
|
-
`Object.new.extend(GraphWeaver::Selection)` is the supported host.
|
|
2075
|
-
- **The local router refuses a `@requires` whose field set names another
|
|
2076
|
-
`@requires` field** (`chained_requires`). It used to answer: a prefetch sends
|
|
2077
|
-
the entity's own `@key` and nothing else, so the inner requirement never
|
|
2078
|
-
arrived and the field was computed from a representation missing its input —
|
|
2079
|
-
the same field then holding two different values in one response. The
|
|
2080
|
-
one-hop limit was documented but not enforced.
|
|
2081
|
-
- **An error path no longer names an injected `@key`/`@requires` alias.** A
|
|
2082
|
-
stitched error came back as `["thing", "_gw_w"]`, a field no schema contains;
|
|
2083
|
-
it is `["thing", "w"]` now, as a real router reports it.
|
|
2084
|
-
- **`@skip`/`@include` driven by a variable's declared default was ignored on a
|
|
2085
|
-
boundary-crossing field**, so `query($show: Boolean = true) { … @include(if: $show) }`
|
|
2086
|
-
called with no variables silently dropped the field. graphql-ruby applies an
|
|
2087
|
-
operation's defaults; the local router read only what the caller passed.
|
|
2088
|
-
- **`Testing::FakeClient` raises a GraphQL validation error for an unknown
|
|
2089
|
-
field**, as every other client in the slot does. It used to die with
|
|
2090
|
-
`NoMethodError: undefined method 'type' for nil` from inside the fabricator —
|
|
2091
|
-
undiagnosable, and the commonest mistake there is: a query drifting ahead of
|
|
2092
|
-
the schema dump, or a typo in one written inside an example.
|
|
2093
|
-
- **The local test router plans a union or interface at a subgraph boundary.**
|
|
2094
|
-
`search { ... on Track { playCount } ... on Artist { upcomingEvents { … } } }`
|
|
2095
|
-
— a feed, a search page, any polymorphic list — used to be refused
|
|
2096
|
-
(`abstract_boundary`), because a representation names one concrete
|
|
2097
|
-
`__typename` and the planner runs before any data exists. It now plans one
|
|
2098
|
-
branch per concrete type the supergraph says the subgraph can answer with,
|
|
2099
|
-
asks the fetch for `__typename`, and buckets the returned objects by it at
|
|
2100
|
-
execution time — one `_entities` fetch per concrete type, which is what a
|
|
2101
|
-
real router does. Nothing to change; queries that were refused now run.
|
|
2102
|
-
- The `crosses_subgraph` refusal category is **gone**, and
|
|
2103
|
-
`abstract_boundary` now means only one thing: the supergraph doesn't record
|
|
2104
|
-
which concrete types a subgraph answers an abstract type with (no
|
|
2105
|
-
`@join__unionMember`/`@join__implements`, and the type in more than one
|
|
2106
|
-
subgraph). Match on `Unplannable#category` for either of those and you must
|
|
2107
|
-
change it.
|
|
2108
|
-
- A fragment whose type condition can't hold at a position is now dropped
|
|
2109
|
-
rather than refused — `... on Note` under a field whose subgraph has no
|
|
2110
|
-
Note in that union never matches, and a real router answers `{}` there too.
|
|
2111
|
-
- **A query file whose name can't spell a constant now names the file.**
|
|
2112
|
-
`01_home_featured.graphql` reported `module_name: must be a constant name,
|
|
2113
|
-
got "01HomeFeaturedQuery"` and left you to find which of thirty files it
|
|
2114
|
-
meant; it now names the path and says the fix is a rename. It's a
|
|
2115
|
-
`GraphWeaver::Error` too, so `rake graph_weaver:generate` aborts on it
|
|
2116
|
-
instead of burying it under a backtrace — rescue `ArgumentError` for this
|
|
2117
|
-
and you must change it. An explicit `module_name:` argument still raises
|
|
2118
|
-
`ArgumentError`.
|
|
2119
|
-
- **`rails g graph_weaver:install <supergraph>` recognises a composed
|
|
2120
|
-
supergraph** and says what changes because of it: the `federation:*` tasks,
|
|
2121
|
-
and that `graphql: :router` runs specs against your real subgraph resolvers.
|
|
2122
|
-
The initializer it writes says so too.
|
|
2123
|
-
- **Every `rake graph_weaver:federation:*` task was a silent no-op in a stock
|
|
2124
|
-
Rails app.** Rails defaults `config.rake_eager_load` to false, and subgraph
|
|
2125
|
-
detection only sees *loaded* schema classes — so `federation:subgraphs`
|
|
2126
|
-
reported `nil` for every subgraph and `federation:diff` printed "checked 0 of
|
|
2127
|
-
4 subgraphs" and exited **0**, a CI gate permanently green while checking
|
|
2128
|
-
nothing. The tasks now eager-load the app first, and detection resolves.
|
|
2129
|
-
- **`federation:diff` fails when it compared against nothing** (exit 1, naming
|
|
2130
|
-
what to do). Comparing against *some* subgraphs is still a pass — a
|
|
2131
|
-
partly-local supergraph is a supported setup — but a comparison against none
|
|
2132
|
-
of them proved nothing. If your subgraphs all run elsewhere, drop the task
|
|
2133
|
-
from CI; it has nothing there to gate.
|
|
2134
|
-
- **The absent-subgraph refusal names the cause.** It advised `subgraphs: { … }`
|
|
2135
|
-
on a `Router.new` an rspec `graphql: :router` example never calls. It now
|
|
2136
|
-
leads with the usual cause — the schema class isn't loaded yet, so
|
|
2137
|
-
eager-load it — and names `GraphWeaver::Testing.config.router = { subgraphs:
|
|
2138
|
-
… }` as the way to name one under the tag.
|
|
2139
|
-
- **Subgraph-mapping refusals are `GraphWeaver::ConfigurationError` (was
|
|
2140
|
-
`ArgumentError`)** — `rescue GraphWeaver::Error` around `Testing::Router.new`
|
|
2141
|
-
now catches them, as `docs/errors.md` said it would. Rescue `ArgumentError`
|
|
2142
|
-
for these and you must change it. The rule the docs now state: what the
|
|
2143
|
-
library *concludes*, having read your schema, is a `GraphWeaver::Error`; an
|
|
2144
|
-
argument wrong on its face (`pool_size: must be >= 1`) stays an
|
|
2145
|
-
`ArgumentError`. An ambiguous-detection refusal also names each candidate
|
|
2146
|
-
schema once, instead of repeating a reloaded constant.
|
|
2147
|
-
- **The local test router refused every mutation that stitched below its root**,
|
|
2148
|
-
with a message claiming its root fields "span subgraphs" when there was one
|
|
2149
|
-
root field in one subgraph — and advice (split it per subgraph) that couldn't
|
|
2150
|
-
be followed. It now plans any mutation whose root fields share a subgraph:
|
|
2151
|
-
they go over as one document and that subgraph serializes them, and what
|
|
2152
|
-
hangs below a root is an ordinary read afterwards. The refusal is left for
|
|
2153
|
-
root fields that genuinely span subgraphs, where the message is true.
|
|
2154
|
-
- **The local router honoured `@skip`/`@include` on a field but ignored it on a
|
|
2155
|
-
fragment spread or inline fragment that crossed a subgraph boundary** — it
|
|
2156
|
-
answered a selection the operation had excluded, and ran an extra subgraph
|
|
2157
|
-
fetch to do it. Folding a same-type fragment into its parent dropped the
|
|
2158
|
-
fragment node, and its directives with it. They now move onto the selections
|
|
2159
|
-
they guarded, and a fetch is skipped entirely when everything it was for is
|
|
2160
|
-
excluded (so `trace` matches what a real router does). A field and the
|
|
2161
|
-
fragment around it both carrying the same directive refuses, as
|
|
2162
|
-
`conditional_fragment` — one selection can't hold two conditions of one name.
|
|
2163
|
-
- **`rake graph_weaver:federation:diff` reported false drift for every input
|
|
2164
|
-
object**, so a correct supergraph failed the CI gate with advice to recompose
|
|
2165
|
-
something that wasn't broken. An input object exposes its members as
|
|
2166
|
-
`arguments`, not `fields`, and the check only asked for fields.
|
|
2167
|
-
- **A `Pathname` works anywhere a schema path does** — `GraphWeaver.new`,
|
|
2168
|
-
`generate!(schema:)`, `SchemaLoader.load`. `Rails.root.join("schema.graphql")`
|
|
2169
|
-
previously failed as `undefined method 'lstrip' for an instance of Pathname`.
|
|
2170
|
-
- **`Transport::HTTP`'s `pool_size:` defaults to `RAILS_MAX_THREADS`** (else 5,
|
|
2171
|
-
as before) — the variable Rails already sizes its own connection pool from,
|
|
2172
|
-
because it is the same question. A threaded app that raised its thread count
|
|
2173
|
-
no longer silently queues behind five sockets.
|
|
2174
|
-
- **A saturated pool says so.** The first request that has to wait for a
|
|
2175
|
-
connection logs a warning naming the wait and the ceiling; later ones log at
|
|
2176
|
-
debug. Queueing was previously indistinguishable from a slow server, which
|
|
2177
|
-
mattered most under a fiber scheduler — `async`/Falcon multiplexes fine, but
|
|
2178
|
-
nothing sets `RAILS_MAX_THREADS` there, so the default capped it at 5 with no
|
|
2179
|
-
signal. See `docs/transports.md`.
|
|
2180
|
-
|
|
2181
|
-
#### One `execute`, one way to pass a client (**breaking**)
|
|
2182
|
-
|
|
2183
|
-
Every client answers the same call — `execute(query, variables:,
|
|
2184
|
-
operation_name:)`, returning the raw response hash. Three objects a user holds
|
|
2185
|
-
used to disagree with that, and each disagreement was a bug waiting.
|
|
2186
|
-
|
|
2187
|
-
- **`Client#execute` is that contract now**, so `GraphWeaver::Retry.new(client)`,
|
|
2188
|
-
`Testing::Sequence.new(client, fake)` and a cassette recorder over a client
|
|
2189
|
-
all work. Its one-shot parse-and-run sugar moved to **`Client#run` /
|
|
2190
|
-
`#run!`** (and `GraphWeaver.run` / `.run!` for the throwaway-client form):
|
|
2191
|
-
**rename `client.execute!(query, **vars)` to `client.run!(query, **vars)`.**
|
|
2192
|
-
- **A generated module takes the per-call client as a kwarg**: rename
|
|
2193
|
-
`PersonQuery.execute(some_client, id: "1")` to
|
|
2194
|
-
`PersonQuery.execute(client: some_client, id: "1")`. Variables are
|
|
2195
|
-
unaffected — `$client` was already refused at generation. It is also what
|
|
2196
|
-
makes a mistyped variable name itself: `PersonQuery.execute(id: "1")` on a
|
|
2197
|
-
query that declares no variables now raises `unknown keyword: :id` instead of
|
|
2198
|
-
blaming the client. **Regenerate** (`rake graph_weaver:generate`);
|
|
2199
|
-
`rake graph_weaver:verify` fails until you do.
|
|
2200
|
-
- **`GraphWeaver.resolve_transport` is gone.** Nothing needs unwrapping any
|
|
2201
|
-
more. A client that can't execute is still refused, by the module it was
|
|
2202
|
-
passed to — whose name is now in the message.
|
|
2203
|
-
- A module parsed from a `Client` bakes that client rather than its transport.
|
|
2204
|
-
For a client built from a schema *dump* (no transport), `execute` now says
|
|
2205
|
-
`this client has no transport (built from a schema dump)` instead of quietly
|
|
2206
|
-
running on `GraphWeaver.client`.
|
|
2207
|
-
- `$transport` is a usable GraphQL variable name again — the generated body has
|
|
2208
|
-
no such local.
|
|
2209
|
-
|
|
2210
|
-
#### One reset, one plurality rule (**breaking**)
|
|
2211
|
-
|
|
2212
|
-
- **`GraphWeaver.reset_scalars!`, `clear_scalars!`, `reset_enums!` and
|
|
2213
|
-
`reset_type_helpers!` are gone.** `GraphWeaver.reset_registrations!` is the
|
|
2214
|
-
clean slate between tests; to reset one registry, call the same name on
|
|
2215
|
-
`GraphWeaver::Codegen`.
|
|
2216
|
-
- **Every directory setting is a list — `queries_paths`, `generated_paths`,
|
|
2217
|
-
`fragments_paths`** — and *every entry is read*, by `generate!`,
|
|
2218
|
-
`verify_generated!`, `check_queries` and `load_queries!` alike. (0.4.x
|
|
2219
|
-
dropped a plural `queries_paths` because only `load_queries!` walked it;
|
|
2220
|
-
that divergence is what the singular was protecting against, and it is gone.)
|
|
2221
|
-
**Rename any `queries_path` / `generated_path` / `fragments_path` you set or
|
|
2222
|
-
read** — assigning a String still works (`GraphWeaver.queries_paths =
|
|
2223
|
-
"app/graphql/queries"`), and `generated_paths.first` is the one directory
|
|
2224
|
-
`generate!` writes into. `schema_path` stays singular: a run reads one
|
|
2225
|
-
schema, so a second entry would name a dump nothing opens.
|
|
2226
|
-
|
|
2227
|
-
- `rake graph_weaver:queries:check` prints an unparseable query's position once
|
|
2228
|
-
rather than twice, and `GraphWeaver.check_queries` returns the documented
|
|
2229
|
-
`"message"` / `"line"` / `"column"` shape for parse errors too — the position
|
|
2230
|
-
is no longer folded into the message.
|
|
2231
|
-
- Docs: a spec-local `generated_paths` entry needs an explicit
|
|
2232
|
-
`GraphWeaver.load_generated!` (in Rails the Railtie has already run by then),
|
|
2233
|
-
and belongs outside `spec/support/`, whose files rspec-rails requires itself
|
|
2234
|
-
in sorted order. A registration naming one of your own constants goes in a
|
|
2235
|
-
`to_prepare` block — the same rule the in-process client already follows —
|
|
2236
|
-
rather than the `require Rails.root.join(...)` dance.
|
|
2237
|
-
- **Removing an `extend_type` registration no longer bricks the app.**
|
|
2238
|
-
Generated files carry `include GraphWeaver::TypeHelpers::Foo`, so dropping
|
|
2239
|
-
the registration made boot fail — and because `rake graph_weaver:generate`
|
|
2240
|
-
depends on `:environment`, the regeneration that would repair it failed the
|
|
2241
|
-
same way. The graph_weaver tasks now skip loading generated modules (none of
|
|
2242
|
-
them reads one), so `rake graph_weaver:generate` repairs the tree. Outside a
|
|
2243
|
-
task, the dangling include now raises a `GraphWeaver::Error` naming the
|
|
2244
|
-
registration that went missing and how to recover, instead of a bare
|
|
2245
|
-
`NameError` pointing into generated code.
|
|
2246
|
-
- **A cancelled request no longer leaks its socket.** `Transport::HTTP`
|
|
2247
|
-
closed a connection of unknown state with a bare `rescue`, which catches
|
|
2248
|
-
only `StandardError`. A fiber scheduler cancels with `Async::Stop`, which
|
|
2249
|
-
descends from `Exception`, so cancelling an in-flight request walked past
|
|
2250
|
-
the cleanup and left the socket open until GC. Affects any app under
|
|
2251
|
-
`async`/Falcon with per-request timeouts. Nothing to do — the fix is
|
|
2252
|
-
internal.
|
|
2253
|
-
- `rake graph_weaver:queries:check` and `federation:diff` flush stdout before
|
|
2254
|
-
aborting, so a piped CI log shows the details before the verdict rather
|
|
2255
|
-
than after it.
|
|
2256
|
-
- `generate!`, `verify_generated!` and `check_queries` accept a **path or SDL
|
|
2257
|
-
string** for `schema:`, like every other schema slot in the library. A String
|
|
2258
|
-
used to reach `schema.validate` as itself and die with `undefined method
|
|
2259
|
-
'validate' for an instance of String`.
|
|
2260
|
-
|
|
2261
|
-
- **`#parse` on anything that holds a schema**, not just `Client`:
|
|
2262
|
-
`GraphWeaver::InProcess`, `Testing::FakeClient` and `Testing::Router` gain it
|
|
2263
|
-
(and `#load_queries!`) from the new `GraphWeaver::Parsing` mixin. Replace
|
|
2264
|
-
`GraphWeaver.parse(schema: router.schema, client: router, query: q)` with
|
|
2265
|
-
`router.parse(q)`. `Retry` holds no schema and has no `#parse` — parse from
|
|
2266
|
-
what it wraps. Nothing changes for `Client`, and the client contract is
|
|
2267
|
-
untouched: a bare `GraphQL::Schema` class still fills the client slot.
|
|
2268
|
-
- **`graphql: false` (or `graphql: :none`) opts an example out of
|
|
2269
|
-
`config.default_mode`** — no client is installed, so the example can wire its
|
|
2270
|
-
own. Previously a default swept up every untagged example with no way out,
|
|
2271
|
-
and both spellings raised "is not a mode"; that message now names the opt-out
|
|
2272
|
-
alongside the modes.
|
|
2273
|
-
- Docs: `graphql_context` is setup, so `docs/testing.md` now shows it in a
|
|
2274
|
-
`before` block for a group sharing one identity, with the inline form kept
|
|
2275
|
-
for one-offs. The per-example reset runs ahead of group hooks, so a
|
|
2276
|
-
group-level `before` re-applies from the same baseline every time.
|
|
2277
|
-
|
|
2278
|
-
- `examples/federation.rb` — a runnable federated-testing example, and the first
|
|
2279
|
-
one that needs no network: three real subgraphs, a boundary-crossing query
|
|
2280
|
-
through a generated module, `router.trace`, and a plan-time refusal. Guarded
|
|
2281
|
-
by `spec/examples_spec.rb`, which runs it.
|
|
2282
|
-
- Docs: the local router moved from `docs/testing.md` to `docs/federation.md`.
|
|
2283
|
-
`testing.md` now covers which client an example runs against; `federation.md`
|
|
2284
|
-
covers what a federated graph does. No behaviour change — update any bookmark
|
|
2285
|
-
to `docs/testing.md#the-in-process-router--graphql-router`, now
|
|
2286
|
-
`docs/federation.md#the-local-router`.
|
|
2287
|
-
- **`graphql: :in_process` no longer hunts for the live schema class.** It runs
|
|
2288
|
-
against `GraphWeaver::Testing.config.schema`, or the schema class your client
|
|
2289
|
-
already runs in-process — one sentence, no heuristic. The third fallback
|
|
2290
|
-
(`Testing::LiveSchema`, which searched every loaded `GraphQL::Schema` for one
|
|
2291
|
-
defining everything the reference schema declares) is **deleted**. It only
|
|
2292
|
-
ever applied to an app whose client points at a *different* API, and under
|
|
2293
|
-
Zeitwerk it failed on the first `:in_process` example anyway, since an
|
|
2294
|
-
autoloaded schema isn't loaded until something names it. Such an app now sets
|
|
2295
|
-
`config.schema = MySchema`; when neither source is there, the error says so.
|
|
2296
|
-
- **One positive predicate on `Response`, not two.** `#ok?` is gone; `#success?`
|
|
2297
|
-
is the survivor, so the pair is `errors?` / `success?`. `success?` is what
|
|
2298
|
-
`Process::Status` and `Faraday::Response` call it, and `ok?` reads as HTTP
|
|
2299
|
-
200 — which a GraphQL response carrying errors also is. Semantics unchanged:
|
|
2300
|
-
partial data alongside top-level errors is **not** a success.
|
|
2301
|
-
- **`Testing::Config#auto_fake` is gone** — it was the pre-tag spelling of
|
|
2302
|
-
`config.default_mode = :fake`. Use that.
|
|
2303
|
-
- **`rake graph_weaver:federation:diff` no longer loads the test harness.**
|
|
2304
|
-
`Federation::Drift` needed one thing from it — the list of loaded schema
|
|
2305
|
-
classes — and did `require "graph_weaver/testing"` from inside itself to get
|
|
2306
|
-
it, pulling faker into a task that fabricates nothing. That question, and
|
|
2307
|
-
"does this schema define this coordinate", now live in `GraphWeaver::Schemas`,
|
|
2308
|
-
shared with `Testing::Subgraphs`. Measured over the fixture supergraph, the
|
|
2309
|
-
task loads 15 files instead of 323 (253 of them faker's).
|
|
2310
|
-
`Testing::Subgraphs.loaded` moved with it: call `GraphWeaver::Schemas.loaded`.
|
|
2311
|
-
- Internal: `codegen/enum_type.rb` held `extend_type`, the type-helper and alias
|
|
2312
|
-
registries and `GraphWeaver::TypeHelpers` — none of them enums, so a search
|
|
2313
|
-
for `extend_type` landed in a file named for something else. Those moved to
|
|
2314
|
-
`codegen/type_helpers.rb`; `enum_type.rb` now holds `EnumType` and the enum
|
|
2315
|
-
registry, mirroring `scalar_type.rb`. No API change.
|
|
2316
|
-
- **`register_enum` and `extend_type` say where to register** when handed a
|
|
2317
|
-
constant's *name* instead of the constant. Passing a String is the natural
|
|
2318
|
-
workaround for "`uninitialized constant PetKind` in my initializer", and the
|
|
2319
|
-
answer is Rails' own: autoloading is set up after `config/initializers` run,
|
|
2320
|
-
so register from a `Rails.application.config.to_prepare` block — which
|
|
2321
|
-
`rake graph_weaver:generate` also runs before generating. Both registries
|
|
2322
|
-
still take the constant itself; a name would be a second spelling that
|
|
2323
|
-
couldn't reach `fallback:` or `map:` anyway, since those name enum *members*.
|
|
2324
|
-
|
|
2325
|
-
#### Scalar coercion is one switch (**breaking**)
|
|
2326
|
-
|
|
2327
|
-
`coerce:` takes `true`/`false` only — the Symbol form is gone. It was a third
|
|
2328
|
-
way to ask one question (*may a variable of this scalar accept loose input?*)
|
|
2329
|
-
and the only one that also made you answer *how*, which the scalar already
|
|
2330
|
-
knows: `Int`/`Float` convert, anything with a `cast:`/`serialize:` pair parses,
|
|
2331
|
-
and a pass-through scalar can't.
|
|
2332
|
-
|
|
2333
|
-
**What to do:**
|
|
2334
|
-
|
|
2335
|
-
- `coerce: :to_i` / `coerce: :to_f` — write `coerce: true`. Generated output is
|
|
2336
|
-
unchanged.
|
|
2337
|
-
- `coerce: :to_s` on `String`/`ID` — drop it and call `.to_s` at the call site.
|
|
2338
|
-
Those have nothing to convert from, so `coerce: true` on one raises now
|
|
2339
|
-
instead of emitting a no-op.
|
|
2340
|
-
- any other Symbol on a custom scalar — give the scalar a `cast:`/`serialize:`
|
|
2341
|
-
pair and `coerce: true`.
|
|
2342
|
-
|
|
2343
|
-
`GraphWeaver.auto_coerce` is unaffected, and `coerce: true` is now exactly what
|
|
2344
|
-
it turns on for one scalar instead of all of them.
|
|
2345
|
-
|
|
2346
|
-
#### One shared module, not three (**breaking** — regenerate)
|
|
2347
|
-
|
|
2348
|
-
`GraphQLInputs`, `GraphQLUnions` and `GraphQLEnums` are now one `GraphQLTypes`.
|
|
2349
|
-
They were three constants, three config knobs and three file shapes for one
|
|
2350
|
-
idea — a type shared across query modules — and the rule now fits in a
|
|
2351
|
-
sentence: **a type shared across query modules lives in the shared module and is
|
|
2352
|
-
aliased in.**
|
|
2353
|
-
|
|
2354
|
-
**What to do:** run `rake graph_weaver:generate` (`verify` fails until you do,
|
|
2355
|
-
naming the stale files). Every generated file changes: a query module opens with
|
|
2356
|
-
one `require_relative "types"` instead of up to three, and its aliases read
|
|
2357
|
-
`AdoptionInput = GraphQLTypes::AdoptionInput`. On disk, `enums.rb`, `inputs.rb`,
|
|
2358
|
-
`inputs/` and `unions.rb` become `types.rb` (the manifest) plus one file per
|
|
2359
|
-
type under `types/` — the old files are pruned for you, since pruning keys off
|
|
2360
|
-
the generated header. If you referenced `GraphQLInputs::PetFilter` (or the other
|
|
2361
|
-
two) by hand, spell it `GraphQLTypes::PetFilter`.
|
|
2362
|
-
|
|
2363
|
-
`GraphWeaver.inputs_module=` / `unions_module=` / `enums_module=` are now
|
|
2364
|
-
`GraphWeaver.types_module=`, and `generate!`/`verify_generated!` take one
|
|
2365
|
-
`types_module:` in place of three.
|
|
2366
|
-
|
|
2367
|
-
One namespace also removes the aliasing *between* the shared artifacts: an input
|
|
2368
|
-
struct's props and a union member's selections spell their enums bare now, being
|
|
2369
|
-
lexically inside the same module. The manifest requires the enum files first for
|
|
2370
|
-
that reason.
|
|
2371
|
-
|
|
2372
|
-
New: a shared fragment whose name is already a schema type in that module is
|
|
2373
|
-
refused at generation, naming both — a fragment is named by you, a type by the
|
|
2374
|
-
schema, and one module is one namespace. Previously they lived apart and could
|
|
2375
|
-
never meet.
|
|
2376
|
-
|
|
2377
|
-
#### Has anyone changed a subgraph without recomposing?
|
|
2378
|
-
|
|
2379
|
-
rake graph_weaver:federation:diff SUPERGRAPH=supergraph.graphql
|
|
2380
|
-
|
|
2381
|
-
A committed supergraph is a snapshot of a composition, and nothing checked that
|
|
2382
|
-
it still described your subgraphs — so it could quietly promise a graph that no
|
|
2383
|
-
longer exists. This reads the routing table against the subgraph schemas loaded
|
|
2384
|
-
in this process, needs **no network**, and exits non-zero on drift, so it gates
|
|
2385
|
-
a PR alongside `graph_weaver:verify`.
|
|
2386
|
-
|
|
2387
|
-
Both directions, because they mean opposite things: **stale** (the supergraph
|
|
2388
|
-
carries `Product.weight`, nothing here defines it — recompose) and **not
|
|
2389
|
-
composed in** (a schema here defines `Product.dimensions`, the supergraph
|
|
2390
|
-
doesn't carry it — publish the subgraph). Comparison is deliberately looser
|
|
2391
|
-
than field-set equality, which would be wrong both ways: a subgraph carries
|
|
2392
|
-
federation plumbing no supergraph has, and `@external`/`@shareable` put a field
|
|
2393
|
-
in more than one subgraph.
|
|
2394
|
-
|
|
2395
|
-
A supergraph is routinely only partly local, so the report names three states —
|
|
2396
|
-
checked, not here, and answered with fabricated data — and the headline counts
|
|
2397
|
-
them. Only drift fails; absence is a supported setup.
|
|
2398
|
-
`GraphWeaver::Federation::Drift` is the same thing as data (`#to_h`, `#drift?`).
|
|
2399
|
-
|
|
2400
|
-
#### Validation errors name the subgraph behind the type
|
|
2401
|
-
|
|
2402
|
-
When the schema dump is a composed supergraph, `rake graph_weaver:queries:check`
|
|
2403
|
-
brands each error with who resolves the type it points at:
|
|
2404
|
-
|
|
2405
|
-
app/graphql/queries/product.graphql
|
|
2406
|
-
4:5 Field 'dimensions' doesn't exist on type 'Product' (products, reviews)
|
|
2407
|
-
|
|
2408
|
-
`Product.dimensions` says what broke; `(products, reviews)` says whose code to
|
|
2409
|
-
look at. A plain schema has no routing table and is unaffected.
|
|
2410
|
-
|
|
2411
|
-
- New: `SchemaLoader::RoutingTable#declared_fields`, `#declares?`, `#responsible`.
|
|
2412
|
-
|
|
2413
|
-
#### A partly-local supergraph now works
|
|
2414
|
-
|
|
2415
|
-
The testing router serves a supergraph composed from several services when only
|
|
2416
|
-
some of them run in this process. A subgraph no loaded schema defines is
|
|
2417
|
-
**absent** rather than an error at construction, so the router builds and every
|
|
2418
|
-
query that doesn't reach those fields runs normally. A query that does reach
|
|
2419
|
-
them is refused at plan time, before anything executes, naming the subgraph and
|
|
2420
|
-
the field that reached for it.
|
|
2421
|
-
|
|
2422
|
-
subgraphs: { "reviews" => :fake } # answer an absent subgraph with fabricated data
|
|
2423
|
-
|
|
2424
|
-
Faking is opt-in and never silent: a faked fetch is marked `faked: true` in
|
|
2425
|
-
`router.trace`, logged at `:warn` per fetch, and listed by `router.faked` and
|
|
2426
|
-
`#inspect`. It is deliberately not surfaced as a response error — that would
|
|
2427
|
-
make `execute!` raise, defeating the point.
|
|
2428
|
-
|
|
2429
|
-
- `Testing::Subgraphs.resolve` now returns only the subgraphs this process
|
|
2430
|
-
serves instead of raising when one has no candidate. Two candidates still
|
|
2431
|
-
refuse, naming both.
|
|
2432
|
-
|
|
2433
|
-
#### One tag picks what a test runs against
|
|
2434
|
-
|
|
2435
|
-
`auto_fake` and `config.router` each installed a client for **every** example
|
|
2436
|
-
and refused to coexist, so a suite had to choose fakes or real resolvers once,
|
|
2437
|
-
for everything — and running in-process against a live schema had no configured
|
|
2438
|
-
mode at all. Now an rspec tag says it per example, or per group:
|
|
2439
|
-
|
|
2440
|
-
it "renders the empty state", graphql: :fake do … end
|
|
2441
|
-
it "authorizes drafts", graphql: :in_process do … end
|
|
2442
|
-
describe "checkout", graphql: :router do … end
|
|
2443
|
-
|
|
2444
|
-
`rspec --tag graphql:router` runs one mode's examples. The tag is namespaced
|
|
2445
|
-
under one `graphql:` key on purpose: a bare `:fake` or `:router` would collide
|
|
2446
|
-
with an app's own metadata and silently change which client an unrelated
|
|
2447
|
-
example runs against.
|
|
2448
|
-
|
|
2449
|
-
**Nothing needs configuring.** Each mode derives what it runs against and
|
|
2450
|
-
refuses — naming what it looked for — rather than guessing. The schema is
|
|
2451
|
-
`config.schema` if you set one, else the committed dump, else the schema
|
|
2452
|
-
`GraphWeaver.client` talks to. `:in_process` finds the live schema *class*: the
|
|
2453
|
-
one your client already runs in-process, else the loaded class defining
|
|
2454
|
-
everything that schema declares (the rule `subgraphs:` detection already uses).
|
|
2455
|
-
`:router` plans against the dump when the dump is a composed supergraph — a
|
|
2456
|
-
federated suite whose checked-in dump is the supergraph needs no config at all.
|
|
2457
|
-
|
|
2458
|
-
- **New:** `graphql_context(current_user: user)` sets the context your
|
|
2459
|
-
resolvers see. It merges onto `config.context` and is reset before the next
|
|
2460
|
-
example, so an example running as somebody else can't leak into the one
|
|
2461
|
-
after. Pass a block to scope it. Under `graphql: :fake` it refuses — there
|
|
2462
|
-
are no resolvers to receive it.
|
|
2463
|
-
- **New:** `config.default_mode` is what an untagged example runs against
|
|
2464
|
-
(`nil`, the default, leaves `GraphWeaver.client` alone). It replaces
|
|
2465
|
-
`config.auto_fake`, which still works as the old spelling of
|
|
2466
|
-
`default_mode = :fake`.
|
|
2467
|
-
- **New:** `config.context` — the baseline every `:in_process` and `:router`
|
|
2468
|
-
example starts from. `config.router = { context: … }` now refuses and points
|
|
2469
|
-
here; the per-example reset would have overwritten it.
|
|
2470
|
-
- `GraphWeaver.execute`, `.new` and `Client.new` now refuse a *client* where a
|
|
2471
|
-
schema source belongs — an `InProcess`, `Retry`, transport or fake used to
|
|
2472
|
-
crash with `undefined method 'lstrip'`. The message names both ways to say
|
|
2473
|
-
what you meant.
|
|
2474
|
-
|
|
2475
|
-
#### Ruby-keyword field names now generate
|
|
2476
|
-
|
|
2477
|
-
A result key that underscores to a Ruby keyword — `pageInfo { next }`,
|
|
2478
|
-
`filter { in }` — no longer refuses to generate. A prop is only ever read off
|
|
2479
|
-
a receiver, so `const :next` is fine; the one bare read, an `alias:`
|
|
2480
|
-
delegator's first hop, now spells `self.next`. Output props keep only the ban
|
|
2481
|
-
the input side already had: names every `T::Struct` already answers to
|
|
2482
|
-
(`class`, `hash`, `serialize`). If you aliased a query around this, you can
|
|
2483
|
-
drop the alias and regenerate. `GraphWeaver::Codegen::RESERVED_PROPS` is gone
|
|
2484
|
-
— `STRUCT_METHODS` is the whole rule now.
|
|
2485
|
-
|
|
2486
|
-
- `optional: true` on an `alias:` no longer hides a path segment the schema has
|
|
2487
|
-
no field for. It still skips a field this query didn't select — that is what
|
|
2488
|
-
it is for — but a typo, or the classic `findPets` where the path is the Ruby
|
|
2489
|
-
prop chain, now raises and says which of the two it looks like. If an
|
|
2490
|
-
optional alias resolved only through a query-level rename (`{ renamed: meta }`),
|
|
2491
|
-
it will now raise on queries that don't select that key.
|
|
2492
|
-
- **New:** `GraphWeaver.reset_enums!`, `GraphWeaver.reset_type_helpers!` and
|
|
2493
|
-
`GraphWeaver.reset_registrations!` — the registry resets scalars already had.
|
|
2494
|
-
`reset_registrations!` is the clean slate to reach for between tests.
|
|
2495
|
-
- An alias error no longer names the same type twice when a query module and
|
|
2496
|
-
its root type share a name.
|
|
2497
|
-
|
|
2498
|
-
#### Testing::Router now plans a real query, not just a single-subgraph one
|
|
2499
|
-
|
|
2500
|
-
`GraphWeaver::Testing::Router` used to hand one operation to one subgraph
|
|
2501
|
-
verbatim and refuse anything that crossed a boundary. It now splits at the
|
|
2502
|
-
crossing, refetches the entity from its `@key` through
|
|
2503
|
-
`_entities(representations:)`, and stitches — batching every node at a level
|
|
2504
|
-
into one call, running root query fields that span subgraphs as one fetch
|
|
2505
|
-
each, and fetching a `@requires` field set from the subgraph that holds it
|
|
2506
|
-
before the field that needs it. On the demo corpus that moves 10/17 queries
|
|
2507
|
-
plannable to 17/17. It still refuses, at plan time, every shape it can't
|
|
2508
|
-
answer the way a real router would.
|
|
2509
|
-
|
|
2510
|
-
`subgraphs:` is now **optional**: each subgraph's Ruby schema is derived from
|
|
2511
|
-
what the loaded schemas define, and refuses rather than guesses when two
|
|
2512
|
-
match or none do. An explicit map (or a partial one) still wins, and is now
|
|
2513
|
-
checked the same way — a mis-wired entry fails at construction naming what it
|
|
2514
|
-
doesn't define, instead of surfacing three fetches later.
|
|
2515
|
-
|
|
2516
|
-
- **New:** `config.router = { supergraph: "supergraph.graphql" }` in
|
|
2517
|
-
`graph_weaver/rspec` runs every example against your real subgraph
|
|
2518
|
-
resolvers.
|
|
2519
|
-
- **New:** `rake graph_weaver:federation:subgraphs` prints the subgraph map
|
|
2520
|
-
detection sees, with the evidence for each match.
|
|
2521
|
-
- **New:** `Testing::Router#context` is settable, so one example can run as a
|
|
2522
|
-
different user without rebuilding the router.
|
|
2523
|
-
- `Testing::Unplannable`'s `:requires` category is **gone** — the gap it named
|
|
2524
|
-
is closed. `:root_fields_span` now applies only to mutations (query roots
|
|
2525
|
-
are planned). New categories: `:no_key`, `:abstract_boundary`,
|
|
2526
|
-
`:nested_field_set`, `:shadowed_key`.
|
|
2527
|
-
- The coverage report's second line now names every subgraph a query touches
|
|
2528
|
-
(`accounts+reviews`), not just the one it ran in.
|
|
2529
|
-
- `rake graph_weaver:schema:diff`, `schema:refresh` and `cassettes:anonymize`
|
|
2530
|
-
now load the Rails environment first, so an initializer's settings apply.
|
|
2531
|
-
|
|
2532
|
-
**`GraphWeaver::Testing::Router` — a local federation router for tests.** Give
|
|
2533
|
-
it a supergraph and your subgraph schema classes and it satisfies the client
|
|
2534
|
-
slot, so `GraphWeaver.client = router` runs every generated module against real
|
|
2535
|
-
resolvers in-process: no gateway, no node, no sockets. It plans one shape — a
|
|
2536
|
-
query whose every field resolves in a single subgraph, passed to that subgraph
|
|
2537
|
-
verbatim — and raises `Unplannable` (a `GraphWeaver::Error`) for anything that
|
|
2538
|
-
crosses a boundary, at plan time, before any subgraph runs. See
|
|
2539
|
-
[docs/testing.md](docs/testing.md#a-federated-graph--graphql-router).
|
|
2540
|
-
|
|
2541
|
-
**`rake graph_weaver:federation:coverage SUPERGRAPH=…` says how much of your
|
|
2542
|
-
query set that router can plan**, and groups every refusal by what stopped it —
|
|
2543
|
-
the number that decides whether wiring it up is worth it. Planning needs the
|
|
2544
|
-
supergraph alone, so it runs in CI with no subgraph loadable.
|
|
2545
|
-
|
|
2546
|
-
**A supergraph's routing table is now readable:
|
|
2547
|
-
`GraphWeaver::SchemaLoader.routing_table(supergraph)`.** `load` strips the
|
|
2548
|
-
`@join__*` machinery to get the API schema; this keeps it — `owners("Product",
|
|
2549
|
-
"shippingEstimate") # => ["reviews"]`, each type's `@key` field sets, and which
|
|
2550
|
-
copies are `@external`. A `@join__` directive it hasn't been taught lands in
|
|
2551
|
-
`unsupported` rather than being skipped.
|
|
2552
|
-
|
|
2553
|
-
**`Representations.<entity>` for an entity the query didn't select now says
|
|
2554
|
-
what to do.** Builders are query-driven, so `Representations.warehouse(...)`
|
|
2555
|
-
raised a bare `NoMethodError` naming nothing. It now names the builders this
|
|
2556
|
-
query does have and the selection to add (`... on Warehouse { __typename }`).
|
|
2557
|
-
|
|
2558
|
-
**Shared-fragment directories are scanned recursively, and `.gql` files count.**
|
|
2559
|
-
The scan was `fragments/*.graphql`, so `fragments/person/fields.graphql` — how
|
|
2560
|
-
anyone with sixty fragments organizes them — was skipped in silence, and a
|
|
2561
|
-
`.gql` file was ignored even though `parse("x.gql")` reads one. A duplicate
|
|
2562
|
-
fragment name now names both files that define it.
|
|
2563
|
-
|
|
2564
|
-
**Query directories are scanned the same way — recursively, `.gql` included.**
|
|
2565
|
-
`queries/admin/pets.graphql` produced nothing at all: no file, no error.
|
|
2566
|
-
`generate!`, `check_queries` and `client.load_queries!` now walk the tree, and
|
|
2567
|
-
`.gql` no longer leaks its extension into the module name. Directories organize
|
|
2568
|
-
queries but do not namespace them — `queries/admin/pets.graphql` is still
|
|
2569
|
-
`PetsQuery` in `pets_query.rb` — so two files with the same base name are
|
|
2570
|
-
refused at generation, naming both, rather than one silently overwriting the
|
|
2571
|
-
other's generated file. The scaffolded `graphql.config.yml` matches
|
|
2572
|
-
(`**/*.{graphql,gql}`).
|
|
2573
|
-
|
|
2574
|
-
**`execute` now takes one kwarg per declared variable, always — a single
|
|
2575
|
-
required input-object variable is no longer flattened into per-field kwargs.**
|
|
2576
|
-
`mutation($input: AdoptionInput!)` generated `execute!(name:, species:, …)`,
|
|
2577
|
-
but adding any second variable generated `execute!(input:, …)` instead — so an
|
|
2578
|
-
unrelated edit to a query silently reshaped every call site, and the rule
|
|
2579
|
-
couldn't be stated without its exception. It also made a schema's own field
|
|
2580
|
-
names load-bearing: a field named `client` or `in` can't be a kwarg and can't
|
|
2581
|
-
be renamed, so flattening quietly declined and the surface moved again.
|
|
2582
|
-
**Rewrite affected call sites to pass the input as one kwarg:**
|
|
2583
|
-
`AdoptMutation.execute!(input: { name: "Rex", species: "DOG" })`, or
|
|
2584
|
-
`input: AdoptMutation::AdoptionInput.new(name: "Rex", species: Species::Dog)`
|
|
2585
|
-
for the field-by-field static check.
|
|
2586
|
-
|
|
2587
|
-
**An input field named after a Ruby keyword no longer makes a schema
|
|
2588
|
-
ungeneratable.** `StringQueryOperatorInput.in` — the standard Hasura/Gatsby
|
|
2589
|
-
filter shape — raised "would become prop 'in', which collides with a Ruby
|
|
2590
|
-
keyword", with no way out: an input field is the schema's name, not yours, and
|
|
2591
|
-
`extend_type alias:` is output-only. But `prop :in` is legal Ruby, and nothing
|
|
2592
|
-
reads an input prop bare (`serialize` goes through `public_send`), so the
|
|
2593
|
-
refusal was over-broad. Input fields named `in`, `end`, `def`, `nil` and the
|
|
2594
|
-
rest now generate. A field colliding with a method every struct defines
|
|
2595
|
-
(`serialize`, `to_h`, `class`, `hash`) is still refused — those break at
|
|
2596
|
-
require time. Output structs are unchanged: a result key *can* be renamed, in
|
|
2597
|
-
the query.
|
|
2598
|
-
|
|
2599
|
-
**A variable named `$client` no longer generates a file that won't parse.**
|
|
2600
|
-
`query($client: ID!)` emitted `def self.execute(client = nil, client:)` — a
|
|
2601
|
-
`SyntaxError` raised at app boot from `load_generated!`, arbitrarily far from
|
|
2602
|
-
the query that caused it, while `verify_generated!` reported the tree as
|
|
2603
|
-
current. Generation now refuses `$client`, `$variables` and `$transport` — the
|
|
2604
|
-
three locals the generated `execute` body owns — naming the fix. **Rename such
|
|
2605
|
-
a variable in the query (`query($clientId: ID!)`) before regenerating.**
|
|
2606
|
-
|
|
2607
|
-
**`auto_coerce` no longer erases the typing of String/ID variables.** It mapped
|
|
2608
|
-
both to `#to_s`, which widened their kwargs to `T.anything` — the majority of
|
|
2609
|
-
real variables, statically unchecked, in exchange for a cast that can't fail.
|
|
2610
|
-
`auto_coerce` now covers only the conversions that are conversions (`Int`→`to_i`,
|
|
2611
|
-
`Float`→`to_f`) plus scalars with a full cast/serialize pair. **If you relied on
|
|
2612
|
-
a String/ID kwarg accepting anything, opt in per scalar:**
|
|
2613
|
-
`GraphWeaver.register_scalar("ID", String, coerce: :to_s)`.
|
|
2614
|
-
|
|
2615
|
-
**An anonymous operation is now named after its module — in the query text and
|
|
2616
|
-
in `OPERATION_NAME`.** Requests started carrying `operationName` so servers and
|
|
2617
|
-
APMs can attribute traffic, but the constant was only set when the `.graphql`
|
|
2618
|
-
document named its operation — and anonymous is what the docs show, so every
|
|
2619
|
-
trace arrived `anonymous` and the feature did nothing for the documented happy
|
|
2620
|
-
path. `person.graphql` holding `query($id: ID!) { ... }` now emits
|
|
2621
|
-
`query PersonQuery($id: ID!) { ... }` with `OPERATION_NAME = "PersonQuery"`.
|
|
2622
|
-
Both halves move together: a server rejects an `operationName` its document
|
|
2623
|
-
doesn't declare. A document that names its own operation is left untouched.
|
|
2624
|
-
|
|
2625
|
-
**Cassette files no longer store the request twice — re-record them.** Every
|
|
2626
|
-
entry carried a `key:` (the normalized query + variables) *and* a `query:` and
|
|
2627
|
-
`variables:` again, and replay matched on `key:` alone: editing the half a
|
|
2628
|
-
reviewer reads changed nothing, editing the other half broke replay while the
|
|
2629
|
-
file still looked right. The key is now derived from `query`/`variables`/
|
|
2630
|
-
`operationName` at load, so the file holds the request once and diffs are real.
|
|
2631
|
-
**Existing cassettes must be re-recorded** (`GRAPHWEAVER_RECORD=1`, or delete
|
|
2632
|
-
the file) — this also covers cassettes of anonymous operations, which stopped
|
|
2633
|
-
matching when entries started keying on `operationName`.
|
|
2634
|
-
|
|
2635
|
-
**`MissingRecording` now prints the variables — the part that usually differs.**
|
|
2636
|
-
It printed the whole query and omitted the variables entirely, so the common
|
|
2637
|
-
miss (same query, different variables) showed you 60 lines identical to the
|
|
2638
|
-
YAML and nothing about the mismatch. The message now leads with the request's
|
|
2639
|
-
variables, says what was recorded for that query (`1 entry recorded for this
|
|
2640
|
-
query, with variables {"id" => "1"}`), and prints the query as one truncated
|
|
2641
|
-
line.
|
|
2642
|
-
|
|
2643
|
-
**A first run with no cassette and no `client:` no longer raises
|
|
2644
|
-
`MissingRecording`.** There is no request yet, so it raises `GraphWeaver::Error`
|
|
2645
|
-
naming the actual situation. **Rescue `GraphWeaver::Error` if you were catching
|
|
2646
|
-
`MissingRecording` for this case.**
|
|
2647
|
-
|
|
2648
|
-
**`Cassette.use` is now `GraphWeaver::Testing.cassette` — rename your calls.**
|
|
2649
|
-
It never returned a `Cassette`; it returns a *client* (a recorder or a replayer)
|
|
2650
|
-
to hand to `execute`, and the name said otherwise. `Cassette` is now only the
|
|
2651
|
-
file — `.new`, `#size`, `#anonymize!`.
|
|
2652
|
-
|
|
2653
|
-
**Record mode with no `client:` now raises instead of replaying.**
|
|
2654
|
-
`GRAPHWEAVER_RECORD=1` on a `Testing.cassette(name)` call with nothing to record
|
|
2655
|
-
against quietly served the stale recording, so "re-record everything" produced a
|
|
2656
|
-
half-refreshed cassette set with no signal. **Pass `client:` to every call you
|
|
2657
|
-
want re-recorded.**
|
|
2658
|
-
|
|
2659
|
-
**`Recorder.new(..., anonymize:)` is gone.** It was unreachable through the
|
|
2660
|
-
factory and duplicated `Testing.config.anonymize`. **Set the config flag** —
|
|
2661
|
-
that's the one way to anonymize, with `rake graph_weaver:cassettes:anonymize`
|
|
2662
|
-
as the cleanup tool for cassettes recorded before you turned it on.
|
|
2663
|
-
|
|
2664
|
-
**`FakeClient.new` no longer requires `schema:`.** Every other option fell back
|
|
2665
|
-
to `Testing.config`; this one didn't, even though `config.schema` already
|
|
2666
|
-
auto-locates the committed dump. `FakeClient.new` now works on its own, and
|
|
2667
|
-
says what to set when no schema resolves at all.
|
|
2668
|
-
|
|
2669
|
-
**`GraphWeaver.queries_paths` (plural) is gone — use `queries_path`.**
|
|
2670
|
-
`generate!` and `check_queries` read the singular (the first entry) while
|
|
2671
|
-
`load_queries!` walked the whole list, so a second queries directory produced
|
|
2672
|
-
modules at runtime that `rake graph_weaver:generate` never generated and
|
|
2673
|
-
`verify` never checked — silently. Queries are single-schema by design. **If
|
|
2674
|
-
you appended a second queries directory, fold it into the first** (or run a
|
|
2675
|
-
second `generate!` with its own `queries:`). `generated_paths` and
|
|
2676
|
-
`fragments_paths` stay plural; they genuinely load from several places.
|
|
2677
|
-
|
|
2678
|
-
**One GraphQL enum is now one Ruby type.** A schema enum a query touches — as
|
|
2679
|
-
a variable, in a result, or both — is emitted once per schema into
|
|
2680
|
-
`generated/enums.rb` as `GraphQLEnums::<Enum>`, and every query module aliases
|
|
2681
|
-
it. Before, an enum read out of a result got a class named for the response key
|
|
2682
|
-
and nested in the struct that selected it (`SearchQuery::Result::Search::Pet::Species`),
|
|
2683
|
-
while the same enum used as a variable got a module-level one — so whether a
|
|
2684
|
-
schema enum was one Ruby type or three depended on what else the query happened
|
|
2685
|
-
to reference, and handing a value from one query into another's variable raised
|
|
2686
|
-
a `TypeError` that wasn't even a `GraphWeaver::Error`.
|
|
2687
|
-
|
|
2688
|
-
**Regenerate, and expect enum constants to move.** A nested enum path in app
|
|
2689
|
-
code becomes the query module's own alias — `SearchQuery::Species` — or
|
|
2690
|
-
`GraphQLEnums::Species`; `srb tc` finds them all. The enums a shared fragment's
|
|
2691
|
-
union members select are hoisted too, so `unions.rb` now aliases them rather
|
|
2692
|
-
than re-emitting them.
|
|
2693
|
-
|
|
2694
|
-
**The shared module names no longer depend on your output directory.** They are
|
|
2695
|
-
`GraphQLInputs`, `GraphQLUnions` and `GraphQLEnums`, full stop. The old rule
|
|
2696
|
-
camelized the parent of `generated/` unless it was on a hardcoded blocklist, so
|
|
2697
|
-
`output: "gen2"` gave you `Gen2Inputs` and renaming `app/graphql/generated` to
|
|
2698
|
-
`app/gql/generated` renamed a public constant. **A multi-schema layout must now
|
|
2699
|
-
name its modules explicitly** — `GraphWeaver.inputs_module=` /
|
|
2700
|
-
`unions_module=` / `enums_module=`, or `generate!(inputs_module:, ...)` — in the
|
|
2701
|
-
same initializer that already gives each schema its paths. `GraphWeaver.inputs_module`
|
|
2702
|
-
and `unions_module` no longer take an output-path argument.
|
|
2703
|
-
|
|
2704
|
-
**One registration registry, not two.** `Client#register_scalar`,
|
|
2705
|
-
`#register_enum`, `#register_enums` and `#extend_type` are **deleted** — a
|
|
2706
|
-
client-scoped registration was invisible to `GraphWeaver.generate!` (the rake
|
|
2707
|
-
tasks have no client), so the console typed a field richly and the checked-in
|
|
2708
|
-
code silently generated `T.untyped`. **Move any `client.register_*` /
|
|
2709
|
-
`client.extend_type` call to the `GraphWeaver.` form** (an initializer, next to
|
|
2710
|
-
the rest of your config). The one thing client scoping bought — two servers
|
|
2711
|
-
disagreeing about a scalar — is what the per-field coordinate form is for:
|
|
2712
|
-
`GraphWeaver.register_scalar("User.birthday", Date)`.
|
|
2713
|
-
|
|
2714
|
-
Also gone with it: `GraphWeaver.register_enums` (bulk) — there was never a
|
|
2715
|
-
`register_scalars` to match it, so call `register_enum` per line — and
|
|
2716
|
-
`GraphWeaver.reject_positional_map!`, now folded into the one
|
|
2717
|
-
`Codegen.register_enum` that every door reaches (so all three doors give the
|
|
2718
|
-
same "the value map is a keyword" error instead of a bare arity complaint).
|
|
2719
|
-
`Codegen.parse` / `.generate` / `.generate_inputs` / `.generate_unions` no
|
|
2720
|
-
longer take `scalars:`/`enums:`/`types:`.
|
|
2721
|
-
|
|
2722
|
-
**`generate!` now takes a Client where it takes a schema** — `GraphWeaver.generate!(schema: api)`,
|
|
2723
|
-
`verify_generated!`, `check_queries` and `parse` all accept one, so the object
|
|
2724
|
-
you built in the console is the object the build step wants and no schema dump
|
|
2725
|
-
is needed. `client:` still means what it meant (a constant name to bake as
|
|
2726
|
-
`DEFAULT_CLIENT`) and still refuses a live object.
|
|
2727
|
-
**Rails integration fixes, found by running the gem in a real Rails app.**
|
|
2728
|
-
|
|
2729
|
-
- **Production boot no longer raises `uninitialized constant
|
|
2730
|
-
Generated::PersonQuery`.** The default `generated_path` is
|
|
2731
|
-
`app/graphql/generated`, which Zeitwerk claims as an autoload root, while
|
|
2732
|
-
the files there define top-level constants. Development (lazy) was fine and
|
|
2733
|
-
eager loading was not, so this only showed up in production or
|
|
2734
|
-
`rails zeitwerk:check`. The Railtie now hides the generated directory from
|
|
2735
|
-
the loader; nothing to configure.
|
|
2736
|
-
- **`rake graph_weaver:generate` runs your initializer again.** The tasks
|
|
2737
|
-
asked whether Rails' `:environment` task existed at *load* time, but Rails
|
|
2738
|
-
defines it after every Railtie's `rake_tasks` block, so the answer was
|
|
2739
|
-
always no. Generation and `verify` therefore ran without booting the app —
|
|
2740
|
-
silently dropping every `register_scalar` / `register_enum` / `extend_type`
|
|
2741
|
-
in `config/initializers`, and generating code that disagreed with the
|
|
2742
|
-
running app. **Regenerate**: if you register anything in an initializer,
|
|
2743
|
-
your committed generated files are wrong, and `rake graph_weaver:verify`
|
|
2744
|
-
will now say so.
|
|
2745
|
-
- `generate`, `verify` and `schema:diff` report a `GraphWeaver::Error` the
|
|
2746
|
-
way `schema:refresh` already did — the message, and a non-zero exit,
|
|
2747
|
-
instead of a rake backtrace through codegen.
|
|
2748
|
-
|
|
2749
|
-
**`rails g graph_weaver:install` takes any source `GraphWeaver.new` takes.**
|
|
2750
|
-
The source is one positional argument — an endpoint, a schema class or an
|
|
2751
|
-
existing dump all work the same way:
|
|
2752
|
-
|
|
2753
|
-
```sh
|
|
2754
|
-
rails g graph_weaver:install https://api.example.com/graphql
|
|
2755
|
-
rails g graph_weaver:install MyApp::Schema # in-process, no socket
|
|
2756
|
-
rails g graph_weaver:install db/schema.graphql # a dump you already have
|
|
2757
|
-
```
|
|
2758
|
-
|
|
2759
|
-
The initializer reflects the form chosen: a schema class is resolved in a
|
|
2760
|
-
`to_prepare` block (it is autoloaded, so an initializer can not read it, and a
|
|
2761
|
-
dev reload replaces the class object), and a dump you already have becomes
|
|
2762
|
-
`GraphWeaver.schema_path` rather than being copied. `--auth` and the
|
|
2763
|
-
introspection step are url-only; a source that can not use them, a constant
|
|
2764
|
-
that does not resolve, and a class that is not a schema are all refused
|
|
2765
|
-
before any file is written.
|
|
2766
|
-
|
|
2767
|
-
**Generated struct names now come from the query's own field names.** A struct
|
|
2768
|
-
is named for the response key that selects it — `stargazers` becomes
|
|
2769
|
-
`Stargazers`, `edges` becomes `Edges` — so its name is a function of its own
|
|
2770
|
-
position in the query and nothing else. Names came from GraphQL *type* names
|
|
2771
|
-
before, disambiguated by field name only on collision, which meant **a second
|
|
2772
|
-
selection of the same type renamed the first**: a silent break in checked-in
|
|
2773
|
-
code your app references. Deep queries could also collide outright and refuse
|
|
2774
|
-
to generate.
|
|
2775
|
-
|
|
2776
|
-
**Regenerate, and expect renames.** Nearly every nested struct changes name
|
|
2777
|
-
(`PersonQuery::Result::Person::Pet` becomes `...::Person::Pets`), and app code
|
|
2778
|
-
naming one won't typecheck until it's updated — `srb tc` finds them all. The
|
|
2779
|
-
payoff: adding, removing, or reordering an unrelated selection can never move
|
|
2780
|
-
a name again.
|
|
2781
|
-
|
|
2782
|
-
- The key is used verbatim, with no pluralization heuristic — a list field
|
|
2783
|
-
`pets` generates `Pets`. To pick a different name, alias the field in the
|
|
2784
|
-
query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
|
|
2785
|
-
- Union and interface members keep their type-condition names (`... on Book`
|
|
2786
|
-
gives `Book`), inside a container named for the field; a union hoisted from
|
|
2787
|
-
a shared fragment is still named for the fragment.
|
|
2788
|
-
- Two ties that walk order used to settle now resolve on their own: fields
|
|
2789
|
-
sharing one collapsed union type take the first of their keys
|
|
2790
|
-
alphabetically, and a name that would shadow the struct it nests in
|
|
2791
|
-
(`pet { pet { ... } }`) takes a numeric suffix (`Pet2`).
|
|
2792
|
-
|
|
2793
|
-
**Requests now send `operationName`** — every graph_weaver request used to be
|
|
2794
|
-
anonymous in Apollo Studio, Hasura, and any APM that keys traces, rate limits
|
|
2795
|
-
and slow-query reports on it. Generated modules emit their operation name as
|
|
2796
|
-
`OPERATION_NAME` beside `QUERY` and send it on the wire; a raw query string
|
|
2797
|
-
handed to a transport falls back to the name in the document. In-process
|
|
2798
|
-
execution passes it to `Schema.execute(operation_name:)`, which also makes a
|
|
2799
|
-
multi-operation document selectable there.
|
|
2800
|
-
|
|
2801
|
-
To get the benefit, **name your operations** — `query Person($id: ID!)`, not
|
|
2802
|
-
`query($id: ID!)` — and regenerate. An anonymous operation still works and
|
|
2803
|
-
sends no `operationName`.
|
|
2804
|
-
|
|
2805
|
-
Three breaking changes come with it:
|
|
2806
|
-
- **The client-slot contract widened to
|
|
2807
|
-
`execute(query, variables:, operation_name: nil)`.** If you wrote your own
|
|
2808
|
-
transport, client, or test double, add the kwarg — a client that doesn't
|
|
2809
|
-
accept it now raises `ArgumentError: unknown keyword: :operation_name`. A
|
|
2810
|
-
graphql-ruby `Schema` class already takes it, so bare schemas in the client
|
|
2811
|
-
slot are unaffected. Subclasses of `GraphWeaver::Transport` only implement
|
|
2812
|
-
`post(body)` and need no change.
|
|
2813
|
-
- **Cassettes are keyed on `operationName` too**, so two operations in one
|
|
2814
|
-
document can't collide. Cassettes recorded from a *named* operation before
|
|
2815
|
-
this release no longer match — re-record them
|
|
2816
|
-
(`GRAPHWEAVER_RECORD=1 bundle exec rspec`). Anonymous ones are unaffected.
|
|
2817
|
-
- **`GraphWeaver::Transport.log_tag` takes an operation name, not a query
|
|
2818
|
-
string** (`log_tag(query)` → `log_tag(operation_name)`); the constant
|
|
2819
|
-
`Transport::OPERATION_NAME` is now `Transport::OPERATION_NAME_PATTERN`, since
|
|
2820
|
-
generated modules define an `OPERATION_NAME` of their own.
|
|
2821
|
-
|
|
2822
|
-
Codegen bug fixes from the library review (all with regression coverage):
|
|
2823
|
-
- Narrowing (`... on X` and nothing else) now reads the match off `__typename`
|
|
2824
|
-
when the selection carries it, instead of off "the object came back empty".
|
|
2825
|
-
Selecting `__typename` guaranteed a non-empty object, so **every non-matching
|
|
2826
|
-
member was cast into `X`'s struct** — loudly when it had a non-null field,
|
|
2827
|
-
silently when all its fields were nullable. Regenerate: any query mixing
|
|
2828
|
-
`__typename` with a single type condition (the `_entities { __typename
|
|
2829
|
-
... on Widget { … } }` federation shape) was mistyped and now filters
|
|
2830
|
-
correctly.
|
|
2831
|
-
- A dispatched union/interface now requires its `__typename` to be unaliased and
|
|
2832
|
-
free of `@skip`/`@include` — `from_h` reads it unguarded, so either would have
|
|
2833
|
-
raised at runtime. Fix the selection if generation now refuses it.
|
|
2834
|
-
- **Unions and interfaces generate per named condition, plus one catch-all
|
|
2835
|
-
`Other`** — not one struct per schema member. A two-condition query against
|
|
2836
|
-
GitHub's `Node` (278 implementations) went from 5,386 lines / 279 structs to
|
|
2837
|
-
162 lines / 4. **Regenerate, and expect member names to move**: a type your
|
|
2838
|
-
query names no fields on is now `Other` rather than its own struct, so a
|
|
2839
|
-
`case` over the members needs an `Other` branch (`T.absurd` will tell you).
|
|
2840
|
-
In exchange, a `__typename` the query doesn't name — including a **member the
|
|
2841
|
-
schema grows after you generate** — deserializes into `Other` instead of
|
|
2842
|
-
raising `unexpected __typename`, so adding a union member upstream stays the
|
|
2843
|
-
non-breaking change GraphQL says it is.
|
|
2844
|
-
- `@skip`/`@include` on an inline fragment or a named spread now makes the
|
|
2845
|
-
fields under it nilable, as it always did for a directly-marked field —
|
|
2846
|
-
previously they kept non-null typing and a `data.fetch`, so a skipped block
|
|
2847
|
-
raised `key not found`. The narrowing guard sees the fragment's own directive
|
|
2848
|
-
too. Conversely, a field selected both conditionally and unconditionally is no
|
|
2849
|
-
longer over-nilable: one unguaranteed selection doesn't unmake the guarantee.
|
|
2850
|
-
- List variables coerce per element, so an enum inside a list accepts its wire
|
|
2851
|
-
value the way a scalar enum already did (`sort: ["POPULARITY_DESC"]` used to
|
|
2852
|
-
raise `NoMethodError: undefined method 'serialize' for String`). Input-object
|
|
2853
|
-
and custom-scalar elements coerce in lists too.
|
|
2854
|
-
|
|
2855
|
-
- Federation schemas that previously wouldn't load now do:
|
|
2856
|
-
- a supergraph whose `schema` definition carries a non-`@link` directive
|
|
2857
|
-
(`@tag`, `@composeDirective`, a composed custom one) no longer dies with a
|
|
2858
|
-
`GraphQL::ParseError` pointing into a document you never wrote.
|
|
2859
|
-
- **raw subgraph SDL loads** — what `rover subgraph fetch`, `_service { sdl }`,
|
|
2860
|
-
or your service repo's `.graphql` gives you. The federation directives a
|
|
2861
|
-
subgraph applies but doesn't declare (`@key`, `@external`, `@shareable`, …)
|
|
2862
|
-
are supplied on load, for both fed-1 and `@link`-style subgraphs. Note the
|
|
2863
|
-
`@inaccessible` subtraction stays supergraph-only: a subgraph keeps those
|
|
2864
|
-
fields, because it is not the public contract.
|
|
2865
|
-
- A schema that won't build now raises `GraphWeaver::Error` naming the artifact
|
|
2866
|
-
we took the source for (supergraph / subgraph / plain SDL / introspection),
|
|
2867
|
-
instead of whatever graphql-ruby's internals happened to raise — a
|
|
2868
|
-
`NoMethodError`, a `ParseError` pointing into a document you never wrote, a
|
|
2869
|
-
bare `RuntimeError`. **Rescuing the raw graphql-ruby classes no longer
|
|
2870
|
-
catches these.** The `@inaccessible` cascade also prunes a directive
|
|
2871
|
-
definition's own arguments.
|
|
2872
|
-
- **Single-line SDL loads.** `SchemaLoader.load("type Query { hi: String }")` —
|
|
2873
|
-
the shape you type in a console — was rejected as "unsupported schema format",
|
|
2874
|
-
because a string had to contain a newline to count as content rather than a
|
|
2875
|
-
path.
|
|
2876
|
-
- Rejecting a schema source is branded too, so the error class no longer depends
|
|
2877
|
-
on which branch rejected it: an unsupported format and an unreadable file both
|
|
2878
|
-
raise `GraphWeaver::Error` (were `ArgumentError` and `Errno::ENOENT`). A bare
|
|
2879
|
-
host now says so — `"graphql.anilist.co" looks like a host; did you mean
|
|
2880
|
-
"https://graphql.anilist.co"?` — instead of pointing at the file system.
|
|
2881
|
-
- Cassette recording accepts a `GraphWeaver::Client` — the call
|
|
2882
|
-
`docs/cassettes.md` shows (`Testing.cassette("github", client: live)`),
|
|
2883
|
-
which failed with `ArgumentError: missing keywords`. And a client that can't
|
|
2884
|
-
`execute` is now rejected on the spot, with its class named, rather than
|
|
2885
|
-
surfacing later as `NoMethodError … for an instance of Hash`.
|
|
2886
|
-
- Generated structs answer `respond_to?` the way `method_missing` behaves, so
|
|
2887
|
-
`struct.method(:nmae)` gets the same "did you mean" hint the direct call does.
|
|
2888
|
-
- `@oneOf` input objects enforce exactly one field. The schema can't express it
|
|
2889
|
-
— every `@oneOf` field is nullable — so the struct accepted zero or many and
|
|
2890
|
-
the server rejected the round trip; supplying the wrong number now raises
|
|
2891
|
-
`GraphWeaver::InputError` naming the type and the keys. **Regenerate** to pick
|
|
2892
|
-
it up.
|
|
2893
|
-
- An enum whose values differ only in case (`enum E { active ACTIVE }`) is
|
|
2894
|
-
refused at generation naming both wire values, instead of emitting two
|
|
2895
|
-
`Active` constants and raising `RuntimeError: Enum values must be assigned to
|
|
2896
|
-
constants` when the file loads. **Map such an enum onto one of yours**
|
|
2897
|
-
(`register_enum`). `AB`/`A_B` and `IN_PROGRESS`/`INPROGRESS` still generate
|
|
2898
|
-
fine — they name distinct constants.
|
|
2899
|
-
- A `.graphql` file that won't parse raises `GraphWeaver::ValidationError`
|
|
2900
|
-
**naming the file**, instead of a bare `GraphQL::ParseError` whose `[6, 1]`
|
|
2901
|
-
pointed into a document you never wrote — fragment inlining parses on the
|
|
2902
|
-
`generate!` path before `Codegen#generate`'s rescue could brand it. Fragment
|
|
2903
|
-
files get the same treatment.
|
|
2904
|
-
- Generated `from_response` shape-checks the envelope, so a malformed one stays
|
|
2905
|
-
under `GraphWeaver::Error`. A non-object `data`, a `Hash` (or an array of
|
|
2906
|
-
strings) for `errors`, and non-object `extensions` all escaped as a raw Sorbet
|
|
2907
|
-
`TypeError` — the `data` one from `from_h`'s sig, before the struct's own
|
|
2908
|
-
rescue could see it. A body that isn't an object at all deserialized to an
|
|
2909
|
-
empty envelope (`String#[]` answers `"data"` with nil); it now raises.
|
|
2910
|
-
- The generated `from_h` rescues `StandardError`, not just
|
|
2911
|
-
`TypeError`/`ArgumentError`/`KeyError` — a registered scalar whose cast raises
|
|
2912
|
-
anything else (`JSON::ParserError`, `URI::InvalidURIError`, your
|
|
2913
|
-
`Money::ParseError`) escaped the umbrella. **Regenerate** to pick both up.
|
|
2914
|
-
- A document holding more than one operation is refused at generation. Only the
|
|
2915
|
-
first was ever typed, and the whole document went on the wire with no
|
|
2916
|
-
`operationName`, so the request came back "Must provide operation name" —
|
|
2917
|
-
**split multi-operation files into one operation each.**
|
|
2918
|
-
- Result keys are checked before they become props, so generation refuses what
|
|
2919
|
-
used to be an unloadable file. Two keys that underscore to the same prop
|
|
2920
|
-
(`{ name Name: name }` — a plain alias, no exotic schema needed) raised
|
|
2921
|
-
`ArgumentError: Attempted to redefine prop :name` at require time; so did a
|
|
2922
|
-
field named `class`, `hash`, `send` or `frozen?`, which `T::Props` won't let a
|
|
2923
|
-
struct redefine. **Alias the field in the query** (`classValue: class`) — the
|
|
2924
|
-
error names the key and the spelling. The same reserved set now covers input
|
|
2925
|
-
fields, which only checked Ruby keywords and `serialize`/`to_h` before.
|
|
2926
|
-
- **Global registrations are validated against the schema**, like client-scoped
|
|
2927
|
-
ones always were: `GraphWeaver.extend_type("Medai", …)` (or `register_scalar` /
|
|
2928
|
-
`register_enum`) used to be a silent no-op, which is the failure mode
|
|
2929
|
-
`docs/getting_started.md` step 3 walks you straight into — it now raises at
|
|
2930
|
-
generation with the spellchecked hint. Registrations are global (see above),
|
|
2931
|
-
so **drop any that names a type the schema you generate against doesn't
|
|
2932
|
-
have**. The built-in scalars are exempt — a schema with no `Date` isn't a
|
|
2933
|
-
mistake.
|
|
2934
|
-
- `extend_type(requires:)` and `register_enum(requires:)` check each path is
|
|
2935
|
-
loadable at registration, as `register_scalar(requires:)` already did and
|
|
2936
|
-
`docs/scalars.md` already promised — a typo fails now, not in the generated
|
|
2937
|
-
file.
|
|
2938
|
-
- Docs: `docs/testing.md` passed the client to generated `execute` as a `client:`
|
|
2939
|
-
kwarg — it's positional. `README.md` had module naming backwards for the
|
|
2940
|
-
documented path (a file's module comes from the **file** name, not the
|
|
2941
|
-
operation name). `docs/federation.md` covers subgraph SDL, federation v1
|
|
2942
|
-
supergraphs, and that `@inaccessible` is subtracted only on the supergraph
|
|
2943
|
-
path. `docs/cassettes.md` names `MissingRecording` correctly.
|
|
2944
|
-
- **Federation namespaces are derived from the schema's own `@link`/`@core`
|
|
2945
|
-
declarations** instead of a hardcoded `join__`/`link__`/`core__` list — the
|
|
2946
|
-
spec URL's name segment gives the namespace, `as:` renames it, and `import:`
|
|
2947
|
-
binds names into the root namespace (`{name: "@key", as: "@myKey"}` included).
|
|
2948
|
-
Four things this fixes:
|
|
2949
|
-
- a graph using fed-2.5+ auth (`@requiresScopes`/`@policy`/`@context`) no
|
|
2950
|
-
longer leaks `federation__Scope`, `federation__Policy` or
|
|
2951
|
-
`context__ContextFieldValue` into `schema.types`;
|
|
2952
|
-
- a supergraph that renamed a spec (`@link(url: ".../join/v0.3", as: "j")`)
|
|
2953
|
-
strips its `j__*` machinery — it previously failed to load at all;
|
|
2954
|
-
- **a renamed `@inaccessible`** (`import: [{name: "@inaccessible", as:
|
|
2955
|
-
"@private"}]`, or `as:` on the inaccessible spec) hides what it marks. It
|
|
2956
|
-
was missed entirely before, so the derived API schema kept fields the
|
|
2957
|
-
router does not serve and codegen over-permitted them. **Regenerate** if
|
|
2958
|
-
your supergraph renames it.
|
|
2959
|
-
- a `@core`-only fed-1 schema, and any composed graph carrying no `@join__`
|
|
2960
|
-
marker, is now recognized as composed rather than loaded as plain SDL
|
|
2961
|
-
(`core__Purpose` used to survive, and `@inaccessible` went unsubtracted).
|
|
2962
|
-
|
|
2963
|
-
- **Subgraph SDL loads with the entity resolver it serves.** No published
|
|
2964
|
-
subgraph SDL contains `_entities`/`_service` — `rover subgraph fetch` and
|
|
2965
|
-
`_service { sdl }` both print the schema, where the plumbing is implicit — so
|
|
2966
|
-
the one query only a subgraph can describe couldn't be typed against the
|
|
2967
|
-
artifact you have. Weaver now supplies `_Any`, `_Service` and an `_Entity`
|
|
2968
|
-
union over the file's own `@key`'d types, alongside the `@key`/`@external`
|
|
2969
|
-
definitions it already supplied. Supergraphs and plain SDL are untouched;
|
|
2970
|
-
a file declaring its own `_entities` keeps it.
|
|
2971
|
-
- **Typed `_entities` representations.** A query selecting entities now
|
|
2972
|
-
generates a `Representations` builder per entity it can resolve, typed from
|
|
2973
|
-
the `@key(fields:)` directives the subgraph SDL carries:
|
|
2974
|
-
`UserQuery::Representations.user(id: "1")` → `{"__typename" => "User", "id"
|
|
2975
|
-
=> "1"}`. `__typename` is injected, key fields are typed from the schema, and
|
|
2976
|
-
a single `@key` makes them **required kwargs** — so an incomplete
|
|
2977
|
-
representation is an `srb tc` error, not a round trip. Compound (`"upc sku"`)
|
|
2978
|
-
and nested (`"organization { id }"`) key sets are parsed as the selection
|
|
2979
|
-
sets they are; a type with two alternative keys takes them optionally and
|
|
2980
|
-
raises `GraphWeaver::InputError` naming the type and what's missing when
|
|
2981
|
-
neither is satisfied. Builders are emitted only for entities the query
|
|
2982
|
-
actually reaches, and a key marked `resolvable: false` gets none.
|
|
2983
|
-
**`Representations` joins `Result`/`QUERY` as a reserved module-level name**
|
|
2984
|
-
— a shared fragment hoisting to it is now refused.
|
|
2985
|
-
|
|
2986
|
-
Transport improvements from the same review:
|
|
2987
|
-
- **`Transport::HTTP` pools its connections** (`pool_size:`, default 5) instead
|
|
2988
|
-
of serializing every request behind one socket and one mutex. The mutex was
|
|
2989
|
-
held across the whole network round trip, so one transport — which is what
|
|
2990
|
-
`GraphWeaver.client = api` gives a Rails app — allowed exactly one request in
|
|
2991
|
-
flight process-wide. Against a 10 ms-latency server, 8 threads × 10 calls:
|
|
2992
|
-
1059 ms before, 281 ms with the default pool of 5 (~3.8×). Sockets still open
|
|
2993
|
-
lazily, stay keep-alive, and are dropped on any error so the next call
|
|
2994
|
-
reconnects. **Lower `pool_size:` if your server counts connections per
|
|
2995
|
-
client**; raise it to match a threaded web server's thread count.
|
|
2996
|
-
- Both transports now send `Accept: application/graphql-response+json,
|
|
2997
|
-
application/json;q=0.9` — the media type GraphQL-over-HTTP requires a
|
|
2998
|
-
conforming client to accept, so a spec-conformant server can finally use the
|
|
2999
|
-
newer status-code semantics — and `User-Agent: graph_weaver/<version>`, so
|
|
3000
|
-
server operators can attribute the traffic. Previously the only header sent
|
|
3001
|
-
was `Content-Type`, and net/http supplied `Accept: */*`. `headers:` still
|
|
3002
|
-
overrides both; a prebuilt `Faraday::Connection` keeps whatever it carries.
|
|
3003
|
-
- **`Transport::Faraday` takes `open_timeout:`/`read_timeout:` and defaults them
|
|
3004
|
-
to 10s/30s**, the same as `Transport::HTTP`. It had no timeout knobs at all,
|
|
3005
|
-
so it inherited net/http's 60s/60s — 6× and 2× the documented defaults. Both
|
|
3006
|
-
timeouts now also thread through the client: `GraphWeaver.new(url,
|
|
3007
|
-
read_timeout: 5)` works whichever transport is picked. Passing a timeout
|
|
3008
|
-
alongside a prebuilt `Faraday::Connection` raises, as `headers:` already did.
|
|
3009
|
-
The Faraday transport also logs its adapter at `:info` — the default
|
|
3010
|
-
`net_http` one opens a connection per request, which was invisible.
|
|
3011
|
-
- **New `GraphWeaver::InProcess`**, wrapping a live graphql-ruby schema class —
|
|
3012
|
-
`GraphWeaver.new(MySchema, context: { current_user: user })`. In-process
|
|
3013
|
-
execution worked but was blind in three ways: nothing supplied a `context:`,
|
|
3014
|
-
so a resolver reading `context[:current_user]` got nil (surfacing as "Cannot
|
|
3015
|
-
return null for non-nullable field Query.me"); all logging lived in
|
|
3016
|
-
`Transport#execute`, which an in-process schema bypasses, so not one line at
|
|
3017
|
-
DEBUG; and a resolver raise came out as a bare `RuntimeError` where the same
|
|
3018
|
-
failure over HTTP is a `ServerError`, so `rescue GraphWeaver::Error` caught
|
|
3019
|
-
one and missed the other. A resolver raise is now a `ServerError` (status
|
|
3020
|
-
500) with the original kept as `#cause` — in-process, the real backtrace is
|
|
3021
|
-
the point. **A bare schema class still works in any client slot**; the
|
|
3022
|
-
wrapper is an upgrade, not a requirement.
|
|
3023
|
-
- **`ServerError` carries the response `#headers`** (names downcased), plus
|
|
3024
|
-
`#retry_after` (seconds or HTTP-date, per RFC 9110) and `#rate_limited?`. The
|
|
3025
|
-
`Net::HTTPResponse` was always in hand and thrown away, so recovering
|
|
3026
|
-
`x-ratelimit-remaining` or a request id meant monkey-patching the transport.
|
|
3027
|
-
A `post` override may now return a third element, the headers; returning the
|
|
3028
|
-
documented `[status, body]` pair stays correct.
|
|
3029
|
-
- **`Retry` honours `Retry-After`** — the server's delay wins over the
|
|
3030
|
-
configured backoff, clamped to `max:` and not jittered. Related: **408 and
|
|
3031
|
-
429 now retry by default.** They were treated as ordinary 4xx ("your bug,
|
|
3032
|
-
retrying won't fix it"), which for the one status that exists to say "come
|
|
3033
|
-
back later" was exactly backwards, and left `Retry` incorrect against GitHub
|
|
3034
|
-
and Shopify. Pass `retry_if:` to restore the old behaviour.
|
|
3035
|
-
- **A throttling predicate, spelled the same everywhere**: `ServerError#throttled?`
|
|
3036
|
-
(429, or a 503 that says when to come back) and `QueryError#throttled?` /
|
|
3037
|
-
`Response#throttled?` (a throttle code in the errors array). An API says "slow
|
|
3038
|
-
down" with an HTTP status or with a code in a 200 body, and callers shouldn't
|
|
3039
|
-
have to know which. The codes are `GraphWeaver::GraphQLError::THROTTLE_CODES`
|
|
3040
|
-
— Shopify's `THROTTLED`, GitHub's `RATE_LIMITED`, and the common Apollo/Hasura
|
|
3041
|
-
spellings — so `retry_codes:` takes the constant instead of a hand-written
|
|
3042
|
-
string. `QueryError#to_h` gains `"throttled"` alongside `"schema_stale"`.
|
|
3043
|
-
- `Transport::HTTP` takes `ca_file:`/`ca_path:`/`cert:`/`key:`/`verify_mode:`,
|
|
3044
|
-
forwarded to `Net::HTTP.start` — a private CA or mTLS no longer means
|
|
3045
|
-
switching to Faraday, which was the real but undiscoverable answer. Passing
|
|
3046
|
-
one to an `http://` url raises instead of quietly doing nothing.
|
|
3047
|
-
- **An instrumentation seam**: `GraphWeaver.instrumenter = ->(event, payload,
|
|
3048
|
-
&block) { ... }`, a no-op until set, wrapping every request — over the wire
|
|
3049
|
-
and in-process, one seam for both. `ActiveSupport::Notifications` becomes a
|
|
3050
|
-
two-line adapter. The one event is `GraphWeaver::EXECUTE_EVENT`; its payload
|
|
3051
|
-
carries `:url`, `:schema`, `:operation` and `:status`, and deliberately not
|
|
3052
|
-
the query or variables (those are PII, and belong at debug on the logger
|
|
3053
|
-
where the level gates them). See `docs/logging.md`.
|
|
3054
|
-
Developer-experience fixes (all with regression coverage):
|
|
3055
|
-
- **FakeClient override keys are validated against the schema.** A typo'd key
|
|
3056
|
-
(`"Person.nmae" => "Daniel"`) pinned nothing, and the example passed against
|
|
3057
|
-
random fake data — a test that had quietly stopped checking what it claims to.
|
|
3058
|
-
Keys now raise, spellchecked, at `FakeClient.new` and at `Testing.configure`
|
|
3059
|
-
when a schema is already set. Bare field-name keys (`"name"`) still work;
|
|
3060
|
-
**fix or drop any key that doesn't name a field in your schema.**
|
|
3061
|
-
- Codegen validation errors name the position they already captured: each
|
|
3062
|
-
message is prefixed `4:5`, and `queries/typo.graphql:4:5` when the file is
|
|
3063
|
-
known (`Codegen.new`/`Codegen.generate` take it as `path:`), instead of
|
|
3064
|
-
leaving a project of thirty query files to search by hand.
|
|
3065
|
-
- A strict `alias:` whose path doesn't fit a query now names the query that
|
|
3066
|
-
failed and ends with `— pass optional: true to skip selections that don't
|
|
3067
|
-
fit`, the documented way out.
|
|
3068
|
-
- Generation lists the custom scalars it had no registration for at `info`
|
|
3069
|
-
(`3 unregistered custom scalars → T.untyped: …`). Informational — a scalar
|
|
3070
|
-
without a codec is a legitimate choice, just no longer a silent one.
|
|
3071
|
-
- `Response#ok?` (and `#success?`) — the positive form of `errors?`.
|
|
3072
|
-
- `FakeClient#schema` reads back the schema responses are fabricated against,
|
|
3073
|
-
which is how to reach it under `auto_fake`, where `GraphWeaver.client` is the
|
|
3074
|
-
fake; `Testing.config.schema` reads back too.
|
|
3075
|
-
New:
|
|
3076
|
-
- **`rake graph_weaver:queries:check` — which of your queries a schema change
|
|
3077
|
-
broke.** Re-introspects the url the dump records (leaving the dump alone) and
|
|
3078
|
-
validates every checked-in query against the server as it is now,
|
|
3079
|
-
reporting file plus line:col plus message and exiting non-zero on any
|
|
3080
|
-
failure, so it drops into CI. `GraphWeaver.check_queries` returns the same
|
|
3081
|
-
thing as data (`{path => [{"message", "line", "column"}]}`, empty when
|
|
3082
|
-
everything validates); pass `schema:` to check a schema you already have
|
|
3083
|
-
without touching the network. Complements `graph_weaver:verify`, which asks
|
|
3084
|
-
the different question of whether the committed Ruby is stale.
|
|
3085
|
-
- `verify_generated!` (and `rake graph_weaver:verify`) compares generated files
|
|
3086
|
-
with line endings normalized, so a checkout under git's `autocrlf` no longer
|
|
3087
|
-
reports every generated file as stale.
|
|
3088
|
-
- New [editor support](docs/editors.md) doc: the `graphql.config.yml` that gives
|
|
3089
|
-
VS Code and RubyMine validation, autocomplete and hover docs in your
|
|
3090
|
-
`.graphql` files — no JS project, no gem code, five lines of YAML.
|
|
3091
|
-
- **Byte-identical generation is now a stated guarantee**, not just a property:
|
|
3092
|
-
the same schema and queries produce the same files on any machine, in any
|
|
3093
|
-
order (`docs/generated_modules.md`). It was already true and spec-enforced;
|
|
3094
|
-
it was documented nowhere.
|
|
3095
|
-
|
|
3096
|
-
**Faraday is no longer auto-selected — `GraphWeaver.new(url)` always builds
|
|
3097
|
-
`Transport::HTTP`.** Selection used to be `defined?(::Faraday)`, and faraday
|
|
3098
|
-
rides into most bundles transitively (stripe, octokit, ...), so adding an
|
|
3099
|
-
unrelated gem silently swapped your transport, its timeouts, and its connection
|
|
3100
|
-
behaviour. The accidental default was also the slower one: `Transport::HTTP`
|
|
3101
|
-
pools persistent sockets (1 TCP connection for 10 requests) where Faraday's
|
|
3102
|
-
default `net_http` adapter reconnects per request (10 for 10) — a full TLS
|
|
3103
|
-
handshake each time over HTTPS.
|
|
3104
|
-
|
|
3105
|
-
**What you must do:** if you were relying on the auto-pick, ask for Faraday
|
|
3106
|
-
explicitly — `GraphWeaver.new(url, transport: :faraday)`. A middleware block
|
|
3107
|
-
still implies it (`GraphWeaver.new(url) { |conn| ... }`), since the block is
|
|
3108
|
-
Faraday's. Faraday is otherwise unchanged and fully supported. Alongside a url,
|
|
3109
|
-
`transport:` now takes `:http` (the default) or `:faraday` rather than a
|
|
3110
|
-
built transport object — passing an object there used to raise "pass a url or
|
|
3111
|
-
transport:, not both" and now raises naming the two symbols. Alongside a schema
|
|
3112
|
-
source it still takes a built transport, and now rejects a Symbol. The client
|
|
3113
|
-
logs which transport it built at `info`.
|
|
3114
|
-
|
|
3115
|
-
`docs/transports.md` gains the recipe for giving Faraday the connection reuse
|
|
3116
|
-
`Transport::HTTP` has by default: the `:net_http_persistent` adapter, the two
|
|
3117
|
-
gems it needs, and the version pairing (Faraday 2.x requires
|
|
3118
|
-
`faraday-net_http_persistent` **2.x**; 1.2.0 raises `NoMethodError: undefined
|
|
3119
|
-
method 'dependency'` at load). graph_weaver depends on neither and never
|
|
3120
|
-
selects it for you.
|
|
3121
|
-
|
|
3122
|
-
**Generated files are pruned when their query disappears.** Renaming or
|
|
3123
|
-
deleting a `.graphql` used to leave its `.rb` behind forever: `load_generated!`
|
|
3124
|
-
kept requiring it, its module kept resolving against a query that no longer
|
|
3125
|
-
existed, and `verify_generated!` stayed silent — the pruning only covered
|
|
3126
|
-
`inputs/*.rb` and `unions.rb`. `generate!` now deletes any generated file the
|
|
3127
|
-
plan no longer produces, and `verify_generated!` reports it as stale.
|
|
3128
|
-
|
|
3129
|
-
Only files carrying the `# Generated by GraphWeaver — do not edit.` header are
|
|
3130
|
-
ever deleted, so a hand-written file in the output directory survives. **What
|
|
3131
|
-
you must do:** nothing, unless you were relying on a lingering module — the
|
|
3132
|
-
next `generate!` removes it, and CI's `rake graph_weaver:verify` will name it
|
|
3133
|
-
first.
|
|
3134
|
-
|
|
3135
|
-
**Mutations now generate `…Mutation` modules, not `…Query`.**
|
|
3136
|
-
`save_list_entry.graphql` holding a `mutation` produces
|
|
3137
|
-
`SaveListEntryMutation` in `save_list_entry_mutation.rb`;
|
|
3138
|
-
`SaveListEntryQuery.execute!` read wrong for a write. Queries are unchanged.
|
|
3139
|
-
The rule is one rule — the camelized file name plus the operation the file
|
|
3140
|
-
defines — and all three naming sites follow it: `generate!`,
|
|
3141
|
-
`GraphWeaver.parse(path)`, and `client.load_queries!`. The operation name
|
|
3142
|
-
written *inside* the file still names nothing; it goes on the wire as
|
|
3143
|
-
`operationName`.
|
|
3144
|
-
|
|
3145
|
-
**What you must do:** regenerate (`rake graph_weaver:generate`) and rename the
|
|
3146
|
-
call sites of any mutation module — `AdoptQuery` → `AdoptMutation`, including
|
|
3147
|
-
nested constants like `AdoptQuery::AdoptionInput`. Regeneration prunes the old
|
|
3148
|
-
`*_query.rb` files, and `rake graph_weaver:verify` names anything missed.
|
|
3149
|
-
Changing a file's `query` to `mutation` from here on renames its constant the
|
|
3150
|
-
same way, which CI now catches rather than letting it drift.
|
|
3151
|
-
|
|
3152
|
-
**Generated modules get their client plumbing from
|
|
3153
|
-
`GraphWeaver::QueryModule`.** `client`/`client=` carry no per-query type
|
|
3154
|
-
information, so every generated file repeated the same fifteen untyped lines;
|
|
3155
|
-
they now live in the gem, beside the input-struct runtime, and a module says
|
|
3156
|
-
`extend GraphWeaver::QueryModule` instead. `execute`, `execute!`,
|
|
3157
|
-
`from_response` and `from_response!` stay generated — their sigs are your
|
|
3158
|
-
query's types. A baked `client:` constant is emitted as `DEFAULT_CLIENT`,
|
|
3159
|
-
still resolved on first use so a module can load before the initializer that
|
|
3160
|
-
builds its client, and resolution is unchanged: per call → per module → baked
|
|
3161
|
-
constant → `GraphWeaver.client`.
|
|
3162
|
-
|
|
3163
|
-
**What you must do:** regenerate (`rake graph_weaver:generate`). The files
|
|
3164
|
-
change; nothing about how you call them does.
|
|
3165
|
-
Error-message and console ergonomics from the same review:
|
|
3166
|
-
- **Validation errors name the query file and render one per line**, compiler
|
|
3167
|
-
style — `invalid query in app/graphql/queries/person.graphql:` followed by an
|
|
3168
|
-
indented `4:5 Field 'nmae' doesn't exist on type 'Person'` per error. They
|
|
3169
|
-
arrived as one joined line with no file at all, because `generate!` had the
|
|
3170
|
-
path in hand and never passed it to codegen, so thirty query files left you
|
|
3171
|
-
hunting for a bare `4:5`. `ValidationError#errors` and `#to_h` keep the shape
|
|
3172
|
-
`rake graph_weaver:queries:check` reads; only the message text changed, and
|
|
3173
|
-
**it is multi-line now** — update anything matching on it.
|
|
3174
|
-
- **`register_enum("Species", PetKind, {"DOG" => :dog})` says the value map is a
|
|
3175
|
-
keyword**, and shows the call with `map:` in it. Guessing the map as a third
|
|
3176
|
-
positional argument used to get Ruby's `wrong number of arguments (given 3,
|
|
3177
|
-
expected 2)`, which never mentions `map:`.
|
|
3178
|
-
- **`load_queries!` logs when it replaces an already-loaded module**, at
|
|
3179
|
-
`:info`, before swapping the constant: `replacing PersonQuery — objects built
|
|
3180
|
-
from the previous module stay instances of it`. Reloading is unchanged and
|
|
3181
|
-
still what the method is for; it just isn't silent about the structs it
|
|
3182
|
-
orphans, which is how a console session ends up with an `is_a?` that fails
|
|
3183
|
-
for no visible reason.
|
|
3184
|
-
**Rails install generator.**
|
|
3185
|
-
`rails g graph_weaver:install https://api.example.com/graphql` writes
|
|
3186
|
-
`config/initializers/graph_weaver.rb`, the `app/graphql/queries` and
|
|
3187
|
-
`app/graphql/generated` directories, `graphql.config.yml` (schema autocomplete
|
|
3188
|
-
and validation for `.graphql` files in VS Code / RubyMine) and the schema dump
|
|
3189
|
-
— replacing the console step the getting-started guide used to open with.
|
|
3190
|
-
`--auth` names the ENV var holding the token (default `GRAPHWEAVER_AUTH`),
|
|
3191
|
-
`--no-schema` skips the introspection. Re-running prompts on conflict like any
|
|
3192
|
-
Rails generator.
|
|
3193
|
-
|
|
3194
|
-
**`rake graph_weaver:schema:refresh` can now create the first dump.** It read
|
|
3195
|
-
its url from an existing dump's provenance stamp, so it couldn't bootstrap one
|
|
3196
|
-
— pass `URL=https://api.example.com/graphql` and it will, and both the
|
|
3197
|
-
no-dump and no-provenance messages now name that fix. The same logic is
|
|
3198
|
-
`GraphWeaver::SchemaLoader.refresh!(url:, auth:)`, which is what the generator
|
|
3199
|
-
calls.
|
|
3200
|
-
|
|
3201
|
-
**Pointing a client at a url that isn't a GraphQL endpoint now says so.** A
|
|
3202
|
-
REST base url, a GraphiQL page or a proxy that ate the path answers 200 with
|
|
3203
|
-
well-formed JSON, and `.schema` raised a bare `KeyError`/`NoMethodError` out of
|
|
3204
|
-
graphql-ruby — unbranded, no url, and it escaped `rescue GraphWeaver::Error`
|
|
3205
|
-
(a 404 on the same path was already branded and clear). Introspection now
|
|
3206
|
-
checks for `data.__schema` and raises `GraphWeaver::Error` naming the endpoint
|
|
3207
|
-
and the first 200 characters of what came back.
|
|
3208
|
-
|
|
3209
|
-
**A subgraph's own `FieldSet` / `Scope` / `Policy` type no longer collides with
|
|
3210
|
-
weaver's.** Loading subgraph SDL injects the federation directive definitions
|
|
3211
|
-
the file applies but doesn't declare, and the scalars they reference went in
|
|
3212
|
-
unnamespaced — so a subgraph that owns a type by one of those names either had
|
|
3213
|
-
it shadowed or failed to build, with advice pointing at the wrong file. Those
|
|
3214
|
-
three are now `federation__FieldSet` / `federation__Scope` /
|
|
3215
|
-
`federation__Policy`. `_Any` / `_Entity` / `_Service` keep their names — those
|
|
3216
|
-
are spec-mandated and queryable.
|
|
3217
|
-
|
|
3218
|
-
**`rake graph_weaver:queries:check` no longer compares an in-process app's
|
|
3219
|
-
schema against itself.** For an app whose schema is its own graphql-ruby class
|
|
3220
|
-
there is no server to re-introspect, so the check degraded to re-reading the
|
|
3221
|
-
committed dump — reporting phantom errors about the app's own schema, a field
|
|
3222
|
-
you just added reading as "doesn't exist". When `GraphWeaver.client` executes
|
|
3223
|
-
in-process (a `Client` wrapping a schema class, or the class itself), the check
|
|
3224
|
-
now validates against the live class. Network clients are unchanged.
|
|
3225
|
-
|
|
3226
|
-
**The two dead-end "records no source url" messages now say what to do.** A
|
|
3227
|
-
dump taken from a schema class is rebuilt from code, not re-fetched — both
|
|
3228
|
-
`schema:refresh` and `schema:diff` say that instead of naming a `URL=` that
|
|
3229
|
-
doesn't exist for you.
|
|
3230
|
-
|
|
3231
|
-
**Two rake tasks are renamed so each one names its own subject.** There were
|
|
3232
|
-
three checks and two of them were called `verify`, while the one people run
|
|
3233
|
-
most — "did schema drift break my queries?" — lived under `schema:` and doesn't
|
|
3234
|
-
check the schema. **Update your CI:**
|
|
3235
|
-
|
|
3236
|
-
| Was | Now | Asks |
|
|
3237
|
-
|---|---|---|
|
|
3238
|
-
| `graph_weaver:schema:check` | `graph_weaver:queries:check` | do my checked-in queries still validate? |
|
|
3239
|
-
| `graph_weaver:schema:verify` | `graph_weaver:schema:diff` | has the server drifted from the dump? |
|
|
3240
|
-
|
|
3241
|
-
`graph_weaver:verify` (is the committed Ruby fresh?) and
|
|
3242
|
-
`graph_weaver:schema:refresh` are unchanged. No aliases — the old names are
|
|
3243
|
-
gone.
|
|
3244
|
-
|
|
3245
|
-
**The instrumentation payload now carries `:status` in-process too.** `InProcess`
|
|
3246
|
-
brands a resolver raise as `ServerError(500)` precisely so callers needn't
|
|
3247
|
-
branch on which side of the seam a query ran — but the payload had no `:status`
|
|
3248
|
-
in-process and no `:schema` over the wire, so a subscriber had to branch
|
|
3249
|
-
anyway. A successful in-process execute now sets `:status` to 200; a failure
|
|
3250
|
-
still rides the exception the hook already sees.
|
|
3251
|
-
|
|
3252
|
-
**`extend_type` and `alias:` moved from `docs/scalars.md` to
|
|
3253
|
-
`docs/generated_modules.md`** — they decorate a generated struct, and now sit
|
|
3254
|
-
next to what a generated struct looks like. **Update any bookmark to
|
|
3255
|
-
`scalars.md#type-helpers-your-logic-on-generated-structs`**; it is
|
|
3256
|
-
`generated_modules.md#type-helpers` now. `scalars.md` still owns
|
|
3257
|
-
`register_scalar` and `register_enum`.
|
|
3258
|
-
|
|
3259
|
-
- **`Testing::Router#trace` accumulates across executes and is reset
|
|
3260
|
-
explicitly** — `router.reset_trace`. It used to clear itself at the top of
|
|
3261
|
-
every `execute`, which made it answer about the *last* query rather than the
|
|
3262
|
-
code path: a service object running two queries reported only the second's
|
|
3263
|
-
fetches, and an example that ran nothing read the previous example's, so an
|
|
3264
|
-
assertion could pass on another example's work and fail under `--order rand`.
|
|
3265
|
-
The rspec `graphql: :router` tag resets it per example. **An example that
|
|
3266
|
-
asserts on the trace after more than one `execute` now sees both**, and
|
|
3267
|
-
wants a `reset_trace` in between if it meant only the last one.
|
|
3268
|
-
- **The absent-subgraph refusal leads with the half that applies.** It opened
|
|
3269
|
-
with "Rails autoloads, so the class is probably just not loaded yet" — right
|
|
3270
|
-
often enough to lead with, except when eager loading is already on, and then
|
|
3271
|
-
the library can *ask* rather than send you to a setting you already have. It
|
|
3272
|
-
now checks `config.eager_load` / `config.rake_eager_load` and, when either is
|
|
3273
|
-
on, says the subgraph runs elsewhere and puts `=> :fake` first instead of at
|
|
3274
|
-
the end of a 60-word sentence.
|
|
3275
|
-
- **A refusal spells a nested `@key`/`@requires` field set the way your schema
|
|
3276
|
-
does** — `"origin { lat lon }"`, not `"origin.lat", "origin.lon"`. The dotted
|
|
3277
|
-
form is this library's parse of it and matches nothing you can grep for.
|
|
3278
|
-
- **One `@interfaceObject` no longer disables the whole router.** It refused at
|
|
3279
|
-
construction, for the entire supergraph, so a single directive made
|
|
3280
|
-
`Testing::Router` unusable even for queries that never touch the type — one
|
|
3281
|
-
corpus had to be split into two graphs over it. It is now a per-query refusal
|
|
3282
|
-
(`Unplannable#category` `:interface_object`) keyed on the types the query
|
|
3283
|
-
actually reaches, and `federation:coverage` counts it as one refusal among
|
|
3284
|
-
others rather than aborting. Routing an `@interfaceObject` is still not
|
|
3285
|
-
implemented; this only makes the refusal proportionate.
|
|
3286
|
-
`RoutingTable#unsupported` no longer lists them —
|
|
3287
|
-
`RoutingTable#interface_objects` does, as `{"Media" => ["catalog"]}`.
|
|
3288
|
-
- `docs/federation.md`'s refusal table now lists **every** `Unplannable`
|
|
3289
|
-
category, and a spec keeps it that way. Five were missing, `chained_requires`
|
|
3290
|
-
and `conditional_fragment` among them.
|
|
3291
|
-
- **`federation:coverage` counts what your suite can *run*, not only what
|
|
3292
|
-
plans.** `5/5 queries plannable locally (100%)` was optimistic in exactly the
|
|
3293
|
-
partly-local shape the docs call the usual migration one: a query resolving
|
|
3294
|
-
in a subgraph another service serves plans fine and a spec still can't run
|
|
3295
|
-
it. The headline now reads `…, 2 servable here`, and the queries reaching
|
|
3296
|
-
past what's loaded are listed with the subgraph each needs. Plan-only is
|
|
3297
|
-
still the design — with no subgraph loaded (the SDL-alone CI run) the second
|
|
3298
|
-
number is dropped and the report says it counted planning only.
|
|
3299
|
-
`Coverage#servable` and `#elsewhere` are the programmatic side, and
|
|
3300
|
-
`Coverage::Result` gained `absent` / `servable?`.
|
|
3301
|
-
- **`Testing::Failure.stale_schema` drops its `schema:` / `seed:` sampling.**
|
|
3302
|
-
It picked a random real type/field so a fabricated error string would look
|
|
3303
|
-
plausible — but no assertion can depend on which one it picks, so it was
|
|
3304
|
-
decoration with three kwargs and an RNG behind it. `stale_schema(type:,
|
|
3305
|
-
field:)` names the casualty when the message matters, and the bare call still
|
|
3306
|
-
trips `schema_stale?`. Passing `schema:`/`seed:` now raises `ArgumentError`.
|
|
3307
|
-
- **`rake graph_weaver:cassettes:check`** — replays every recording through the
|
|
3308
|
-
generated modules and fails when one no longer casts. A cassette is the only
|
|
3309
|
-
artifact recorded from a foreign server, and nothing else here notices when
|
|
3310
|
-
that server's answers drift out of the shape the structs were generated for:
|
|
3311
|
-
`verify`, `queries:check` and `schema:diff` all ask about the local side. It
|
|
3312
|
-
needs no network, so it belongs beside `verify` in a PR run. A recording no
|
|
3313
|
-
generated module sends is skipped and counted, and checking *none* of them
|
|
3314
|
-
fails, like `federation:diff`. `Testing::Cassette#check` is the programmatic
|
|
3315
|
-
side.
|
|
3316
|
-
- **A cast failure no longer prints sorbet-runtime's `Caller:` frame.**
|
|
3317
|
-
`GraphWeaver::TypeError`'s message ended with `Caller:
|
|
3318
|
-
.../sorbet-runtime/.../call_validation.rb:331` — a path into the gem, never
|
|
3319
|
-
into the code with the problem, and the only location the message offered.
|
|
3320
|
-
|
|
3321
|
-
### v0.4.6 (2026-07-30)
|
|
3322
|
-
Bug fixes from a full-library review (all with regression coverage):
|
|
3323
|
-
- alias: a nested-object/enum leaf (`meta.sub`) now qualifies its constant
|
|
3324
|
-
(`Meta::Sub`) instead of emitting a bare `Sub` that raised NameError; alias
|
|
3325
|
-
names/segments are validated as identifiers (were interpolated verbatim,
|
|
3326
|
-
allowing injection); `optional:` no longer swallows a reserved-name/collision
|
|
3327
|
-
mistake; a real field named `first`/`last` resolves as a field.
|
|
3328
|
-
- Shared unions: a hoisted member selecting a mapped enum now emits its
|
|
3329
|
-
`<NAME>_FROM_WIRE` table into `unions.rb` (was a NameError at `from_h`); a
|
|
3330
|
-
fragment whose name collides with `Result`/`QUERY` is refused.
|
|
3331
|
-
- A named interface fragment holding inline `... on X` conditions now dispatches
|
|
3332
|
-
instead of silently dropping those fields; fragment cycles raise a clear error
|
|
3333
|
-
in the FakeClient/Anonymizer walkers instead of `SystemStackError`.
|
|
3334
|
-
- Malformed responses/inputs stay under `GraphWeaver::Error`: a non-2xx
|
|
3335
|
-
`errors: null` body keeps its status; a 2xx non-object body is a `ServerError`;
|
|
3336
|
-
`data!` on null-data/no-errors raises `QueryError`; `coerce(non-Hash)` raises
|
|
3337
|
-
`InputError`.
|
|
3338
|
-
- Testing harness: FakeClient/Anonymizer merge duplicate result keys (were
|
|
3339
|
-
fabricating shapes the generated struct couldn't cast); `fail_at` fires every
|
|
3340
|
-
execute; symbol-keyed cassette variables no longer crash on reload; the
|
|
3341
|
-
Anonymizer keeps concrete-fragment fields when data lacks `__typename`.
|
|
3342
|
-
- Federation: a user type named `link` is no longer dropped; an all-`@inaccessible`
|
|
3343
|
-
schema raises a pointed error. Client accepts `retries: nil` on a schema
|
|
3344
|
-
source; `register_scalar` rejects an anonymous class.
|
|
3345
|
-
- FakeClient: an Integer `list_size` now means exactly that length (a Range
|
|
3346
|
-
randomizes within it). Codegen rejects two variables that underscore to the
|
|
3347
|
-
same kwarg (`$userId` + `$user_id`). The Faraday transport raises rather than
|
|
3348
|
-
silently dropping `headers:`/block when handed a prebuilt connection.
|
|
3349
|
-
|
|
3350
|
-
### v0.4.5 (2026-07-30)
|
|
3351
|
-
- `alias:` paths gain list-element selectors: a `first`/`last` segment picks one
|
|
3352
|
-
element out of a list hop, always nilable — `alias: { entity: "_entities.first" }`
|
|
3353
|
-
yields `def entity = _entities&.first`, and navigation continues into the
|
|
3354
|
-
element (`_entities.first.name`). Typed from the selection: a single inline
|
|
3355
|
-
fragment lands on the concrete member (`T.nilable(Widget)`), a multi-fragment
|
|
3356
|
-
selection on the union. Selectors are checked against the node shape — `.first`
|
|
3357
|
-
on a non-list raises. Cleanly retires the `result._entities&.first&.field`
|
|
3358
|
-
boilerplate of single-entity federation `_entities` queries.
|
|
3359
|
-
- `extend_type(..., optional: true)` makes its aliases lenient: a query whose
|
|
3360
|
-
selection doesn't fit the path omits the accessor instead of failing
|
|
3361
|
-
generation. For an alias on a universal type (a `Query` accessor a strict alias
|
|
3362
|
-
would force every query to satisfy), or one that only fits some selections.
|
|
3363
|
-
|
|
3364
|
-
### v0.4.4 (2026-07-30)
|
|
3365
|
-
- Supergraph loading now derives the **API schema**: `@inaccessible` elements
|
|
3366
|
-
(present in the federated graph but hidden from what the router serves) are
|
|
3367
|
-
removed on load, cascading — a field/argument/union-member/interface
|
|
3368
|
-
referencing a removed type goes too, and a type left empty is removed in turn.
|
|
3369
|
-
So codegen validates against exactly what clients can query, with no
|
|
3370
|
-
over-permitting and no Apollo JS tooling to subtract the API schema first.
|
|
3371
|
-
Plain (non-federation) SDL is untouched.
|
|
3372
|
-
|
|
3373
|
-
### v0.4.3 (2026-07-30)
|
|
3374
|
-
- Federation-aware supergraph loading: `SchemaLoader` (and `Client.new`) now
|
|
3375
|
-
load a composed Apollo Federation v2 supergraph SDL directly. When the `@join__*`
|
|
3376
|
-
markers are present it strips the composition machinery — the synthetic
|
|
3377
|
-
`join__*`/`link__*` types and directive definitions, and every `@join__*`/`@link`
|
|
3378
|
-
application — via an AST rewrite before `from_definition`, so the merged type
|
|
3379
|
-
shapes load cleanly with nothing federation-internal leaking into
|
|
3380
|
-
`schema.types`. Plain SDL is untouched. A query can now be typed against the
|
|
3381
|
-
composed supergraph, not just per-subgraph schema objects.
|
|
3382
|
-
- Removed `directive_defaults_patch.rb` (the graphql-ruby monkeypatch); the
|
|
3383
|
-
preprocessor supersedes it and `graphql-ruby` fixed the underlying issue. The
|
|
3384
|
-
gem now requires `graphql >= 2.6.7`.
|
|
3385
|
-
|
|
3386
|
-
### v0.4.2 (2026-07-30)
|
|
3387
|
-
- `extend_type` accepts `alias:` — project a selected field (possibly nested)
|
|
3388
|
-
onto a flat, typed accessor emitted into the struct body:
|
|
3389
|
-
`extend_type("Widget", alias: { tag: "meta.tag" })` generates a sig'd
|
|
3390
|
-
`def tag = meta&.tag`. Retires hand-written value objects that only flattened
|
|
3391
|
-
a passthrough field. Takes a `{ name => path }` hash, a bare path string
|
|
3392
|
-
(accessor named after the last segment), or an array of paths. Typed from the
|
|
3393
|
-
selection — a nullable hop makes the accessor nilable and nil-safe; the leaf
|
|
3394
|
-
may be a scalar, enum, or nested struct. Validated per query at generation: an
|
|
3395
|
-
unselected/misspelled segment (with `did you mean`), a list hop, or a name
|
|
3396
|
-
collision raises. Stacks and is client-scopable like the mixin forms.
|
|
3397
|
-
|
|
3398
|
-
### v0.4.1 (2026-07-29)
|
|
3399
|
-
- Generated `execute!` forwards its kwargs to `execute` via hash shorthand
|
|
3400
|
-
(`execute(client, name:, species:)` rather than `name: name, species: species`)
|
|
3401
|
-
— cosmetic only. Regenerate to refresh (`verify` flags the drift otherwise).
|
|
3402
|
-
|
|
3403
|
-
### v0.4.0 (2026-07-28)
|
|
3404
|
-
- Shared unions: when a named shared fragment is the whole selection on a union
|
|
3405
|
-
field (`feed { ...FeedItemFields }`), its type is hoisted once into a
|
|
3406
|
-
`GraphQLUnions` module and every query that spreads it aliases the same type —
|
|
3407
|
-
so a union selected across many queries is one Ruby type family (one
|
|
3408
|
-
exhaustive `case … T.absurd`), not a fresh dispatch module per query. No flag:
|
|
3409
|
-
the shared fragment is the opt-in. Triggers only for an exact lone spread;
|
|
3410
|
-
mixing other fields, or shadowing with a query-local fragment, keeps the union
|
|
3411
|
-
inlined. Module name derives from the output path (override with
|
|
3412
|
-
`GraphWeaver.unions_module=`); dynamic `parse` still inlines.
|
|
3413
|
-
- Removed the `shared_inputs:` option from `generate!` / `verify_generated!`.
|
|
3414
|
-
Directory-based generation always emits each input type once into a shared
|
|
3415
|
-
module — the self-contained-module opt-out added complexity for little value.
|
|
3416
|
-
Single-query `parse` / `Codegen.generate` still inline their types (there's
|
|
3417
|
-
no set to share against). Only affects callers who passed
|
|
3418
|
-
`shared_inputs: false`.
|
|
3419
|
-
|
|
3420
|
-
### v0.3.4 (2026-07-29)
|
|
3421
|
-
- Shared fragments: define reusable named fragments once (default
|
|
3422
|
-
`app/graphql/fragments`, configurable via `GraphWeaver.fragments_paths`) and
|
|
3423
|
-
spread them from any query. Each query inlines only the fragments it
|
|
3424
|
-
transitively spreads, so the sent query stays self-contained. Fragment files
|
|
3425
|
-
hold only fragments; names are unique across them. Works in `generate!` and
|
|
3426
|
-
dynamic `parse`.
|
|
3427
|
-
|
|
3428
|
-
### v0.3.3 (2026-07-29)
|
|
3429
|
-
- Union member-type dedup: a union selected more than once on a struct now
|
|
3430
|
-
collapses to one Ruby type family instead of a distinct per-field family with
|
|
3431
|
-
identical members — so a consumer gets a single exhaustive
|
|
3432
|
-
`case … T.absurd` across every field of that union. Structurally different
|
|
3433
|
-
selections stay distinct types. (First cut: same-struct siblings; regenerate
|
|
3434
|
-
checked-in modules to pick it up.)
|
|
3435
|
-
|
|
3436
|
-
### v0.3.2 (2026-07-29)
|
|
3437
|
-
- `register_scalar` accepts a `Type.field` coordinate to override how one
|
|
3438
|
-
field's scalar deserializes — so the same scalar can be different Ruby types
|
|
3439
|
-
across fields (`register_scalar("User.birthday", Date)` while
|
|
3440
|
-
`ISO8601DateTime` stays a `Time` elsewhere). Field overrides win over the
|
|
3441
|
-
scalar-name registration; both stack global-then-client. The coordinate is
|
|
3442
|
-
validated against the schema (a typo'd or non-scalar field raises). Same
|
|
3443
|
-
method, same signature — a `.` in the name selects the field form.
|
|
3444
|
-
|
|
3445
|
-
### v0.3.1 (2026-07-28)
|
|
3446
|
-
- `GraphWeaver.extend_t_sig` controls whether generated modules/structs emit
|
|
3447
|
-
`extend T::Sig`. Default (`nil`) auto-detects a global T::Sig injection
|
|
3448
|
-
(`class Module; include T::Sig`) and skips the now-redundant `extend` — so
|
|
3449
|
-
generated code stays clean under rubocop's `Sorbet/RedundantExtendTSig`.
|
|
3450
|
-
Force with `true`/`false`; `false` requires the global include.
|
|
3451
|
-
|
|
3452
|
-
### v0.3.0 (2026-07-28)
|
|
3453
|
-
- Renamed `register_type` to `extend_type` to disambiguate intent: it
|
|
3454
|
-
*decorates* a generated struct with mixin modules/helpers — it doesn't
|
|
3455
|
-
define or replace a type's deserialization (that's `register_scalar` /
|
|
3456
|
-
`register_enum`, for leaf types, whose Ruby shape is fixed; a composite's
|
|
3457
|
-
shape is per-query, so there's nothing fixed to replace). No deprecation —
|
|
3458
|
-
the old name is dropped.
|
|
3459
|
-
- Invalid query input now raises `GraphWeaver::InputError` (under the
|
|
3460
|
-
`GraphWeaver::Error` umbrella) instead of a raw `ArgumentError` /
|
|
3461
|
-
`KeyError` / sorbet `TypeError`: an unknown or typo'd input key, a
|
|
3462
|
-
missing required field, an out-of-range enum, or a wrong-typed field
|
|
3463
|
-
when an input object is built from a hash through `coerce`. Carries
|
|
3464
|
-
`#field` / `#struct` and a JSON-ready `#to_h` — one rescue point for
|
|
3465
|
-
returning a 422 at an API boundary. Top-level *scalar* kwargs still
|
|
3466
|
-
fail like any Ruby method call (sorbet `TypeError` / `ArgumentError`).
|
|
3467
|
-
|
|
3468
|
-
### v0.2.2 (2026-07-22)
|
|
3469
|
-
- Generated modules expose from_response / from_response! alongside
|
|
3470
|
-
execute / execute!: deserialize a raw GraphQL response (fetched by any
|
|
3471
|
-
client) into the typed envelope without going through the transport.
|
|
3472
|
-
execute now delegates to from_response
|
|
3473
|
-
|
|
3474
|
-
### v0.2.1 (2026-07-13)
|
|
3475
|
-
- Conventional paths are appendable lists: queries_paths /
|
|
3476
|
-
generated_paths (singular accessors read the first entry, so existing
|
|
3477
|
-
config keeps working); load_generated!, Client#load_queries!, and the
|
|
3478
|
-
Railtie walk every entry — append spec/support/graphql/* from a spec
|
|
3479
|
-
helper to load test-only queries. Entries may be globs, and the
|
|
3480
|
-
generated default includes app/graphql/*/generated so per-schema
|
|
3481
|
-
layouts auto-load
|
|
3482
|
-
- inputs_module derives from the output path: multi-schema layouts name
|
|
3483
|
-
each schema's module after its directory
|
|
3484
|
-
(app/graphql/github/generated -> GithubInputs), the conventional
|
|
3485
|
-
layout keeps GraphQLInputs; GraphWeaver.inputs_module= and
|
|
3486
|
-
generate!(inputs_module:) still override
|
|
3487
|
-
- Shared types split one-file-per-type: generated/inputs/ holds each
|
|
3488
|
-
input struct/enum in its own small file (PokeAPI: 573 files, median
|
|
3489
|
-
24 lines vs one 11.5k-line blob) with inputs.rb as the manifest
|
|
3490
|
-
(forward declarations make load order irrelevant); regeneration
|
|
3491
|
-
prunes files for types the schema dropped, verify flags strays;
|
|
3492
|
-
generate!/verify take inputs_module: per invocation (multi-schema
|
|
3493
|
-
apps generate into different modules)
|
|
3494
|
-
- Shared input types: generate! emits every variable type (input
|
|
3495
|
-
structs + their enums + mapped-enum tables) ONCE per schema into
|
|
3496
|
-
generated/inputs.rb (module GraphQLInputs; GraphWeaver.inputs_module=
|
|
3497
|
-
renames, shared_inputs: false opts out), with query modules aliasing
|
|
3498
|
-
only what their own surface references — AdoptQuery::AdoptionInput
|
|
3499
|
-
keeps working and shared types gain one identity across modules.
|
|
3500
|
-
Three filtered Hasura queries: 34,684 lines inline -> 11,754 shared
|
|
3501
|
-
(~90 lines per query module)
|
|
3502
|
-
- BREAKING (vs 0.2.0): auto_fake is opt-in again — require
|
|
3503
|
-
"graph_weaver/rspec" no longer swaps every example onto a fake;
|
|
3504
|
-
set config.auto_fake = true explicitly (the schema still auto-locates
|
|
3505
|
-
once you do). Less magic, no unexpected behavior
|
|
3506
|
-
- Generated input structs are table-driven: typed consts + a per-field
|
|
3507
|
-
FIELDS table (conversions as lambdas) interpreted by the
|
|
3508
|
-
GraphWeaver::InputStruct runtime, replacing unrolled
|
|
3509
|
-
serialize/coerce/value_at per struct — a bool_exp-heavy PokeAPI module
|
|
3510
|
-
shrinks 29k -> 11.5k lines (-60%) with identical behavior (nil
|
|
3511
|
-
omission, wire-value enums, nested/recursive coercion, spellchecked
|
|
3512
|
-
unknown keys all covered by the existing suite)
|
|
3513
|
-
- Internal: Node base class for the codegen IR protocol; module
|
|
3514
|
-
assembly moved from Codegen#generate into Emit#emit_module
|
|
3515
|
-
(byte-identical output)
|
|
3516
|
-
|
|
3517
|
-
### v0.2.0 (2026-07-12)
|
|
3518
|
-
- Cleanup pass (staff-engineer review): scalar registrations get the
|
|
3519
|
-
same typo validation as enums/types; cassette replay stops recomputing
|
|
3520
|
-
its key per entry; dependency-order DFS uses hash bookkeeping (big
|
|
3521
|
-
bool_exp graphs); require/vocabulary residue swept; the vestigial
|
|
3522
|
-
graph_weaver/testing/rspec shim removed
|
|
3523
|
-
- BREAKING: "client" replaces "executor" across the whole surface.
|
|
3524
|
-
Generated modules: the per-call override is an optional POSITIONAL
|
|
3525
|
-
first argument — PersonQuery.execute(github, id: "1") — so variables
|
|
3526
|
-
own the entire kwarg namespace and NOTHING is reserved (a $client or
|
|
3527
|
-
$executor variable is fine; only Ruby keywords refuse); per-module is
|
|
3528
|
-
MyQuery.client=, the baked param is client:. GraphWeaver.executor= is
|
|
3529
|
-
gone — GraphWeaver.client= is the one ambient slot (auto_fake swaps
|
|
3530
|
-
it per example; explicit clients are self-contained and never see it).
|
|
3531
|
-
Client#executor is now Client#transport (transport: to bring your
|
|
3532
|
-
own); SchemaLoader.introspect/stale? speak transport. Renames:
|
|
3533
|
-
FakeExecutor => Testing::FakeClient, SequenceExecutor =>
|
|
3534
|
-
Testing::Sequence, RetryExecutor => GraphWeaver::Retry,
|
|
3535
|
-
Recording/ReplayExecutor => Recorder/Replayer, Cassette.use(client:)
|
|
3536
|
-
- Live federation integration: two Ruby subgraphs (apollo-federation
|
|
3537
|
-
gem) composed and routed by a real Apollo gateway (node harness under
|
|
3538
|
-
spec/support/federation), with GraphWeaver introspecting through the
|
|
3539
|
-
router and executing a query stitched across BOTH subgraphs — part of
|
|
3540
|
-
make integration. Complements the existing supergraph-SDL codegen spec
|
|
3541
|
-
- graphql-over-http: a non-2xx response carrying a GraphQL errors body
|
|
3542
|
-
(Apollo Server/Router send request errors as 4xx JSON) flows into the
|
|
3543
|
-
Response envelope so QueryError sees the structured errors; only
|
|
3544
|
-
non-GraphQL bodies (proxy pages) raise ServerError
|
|
3545
|
-
- Fix: input-struct serialize used bare locals (result/value) that a
|
|
3546
|
-
same-named prop silently shadowed — a field named "result" dropped
|
|
3547
|
-
its value onto the wrong target; generated locals now wear the
|
|
3548
|
-
reserved __gw prefix (GraphQL reserves __-names, so no collision is
|
|
3549
|
-
possible)
|
|
3550
|
-
- Input fields and variables whose Ruby name would be a keyword
|
|
3551
|
-
(nil/def/end/...), a generated method (serialize/to_h), or the
|
|
3552
|
-
reserved executor kwarg now refuse at generation with a pointed
|
|
3553
|
-
error instead of emitting broken code
|
|
3554
|
-
- Non-JSON 200 bodies (proxy error pages) classify as ServerError, and
|
|
3555
|
-
unserializable variables (NaN/Infinity) raise GraphWeaver::Error —
|
|
3556
|
-
raw JSON::* errors no longer escape the umbrella
|
|
3557
|
-
- Transports redact on inspect/to_s (class + url only) — Authorization
|
|
3558
|
-
headers can't leak through logs or exception dumps
|
|
3559
|
-
- Narrowed `... on X` selections require at least one unconditional
|
|
3560
|
-
field: with every field behind @skip/@include, a matching response is
|
|
3561
|
-
{} — byte-identical to a non-match — so generation refuses rather
|
|
3562
|
-
than silently dropping real matches to nil
|
|
3563
|
-
- Integration spec against Hasura's PokeAPI: snake_case codegen,
|
|
3564
|
-
recursive bool_exp variable filtering, untyped jsonb pass-through
|
|
3565
|
-
(make integration)
|
|
3566
|
-
- BREAKING: ValidationError now descends from GraphWeaver::Error (was
|
|
3567
|
-
ArgumentError) — one `rescue GraphWeaver::Error` catches everything
|
|
3568
|
-
- Input-struct .coerce raises on unknown hash keys with a spellchecked
|
|
3569
|
-
hint — a typo'd filter key no longer silently drops off the wire
|
|
3570
|
-
- Client registrations (register_type/register_enum) validate at the
|
|
3571
|
-
call site when the schema is already loaded; lazy clients still
|
|
3572
|
-
validate at generation
|
|
3573
|
-
- Unregistered custom scalars emit bare T.untyped (not
|
|
3574
|
-
T.nilable(T.untyped), an srb tc error under typed: strict)
|
|
3575
|
-
- Wire log lines carry [req N OperationName] tags; long queries
|
|
3576
|
-
(introspection) truncate at debug
|
|
3577
|
-
- Logging: GraphWeaver.logger (any stdlib-compatible Logger; Rails.logger
|
|
3578
|
-
auto-wired by the railtie) — wire traffic + timings at debug,
|
|
3579
|
-
introspection/cache/codegen at info, every raised error at warn
|
|
3580
|
-
- Recursive input types generate — self- and mutually-referential inputs
|
|
3581
|
-
(Hasura's bool_exp filter surface) emit dependency-ordered structs with
|
|
3582
|
-
runtime forward declarations for cycles, so variable-driven Hasura
|
|
3583
|
-
filtering works; previously raised "recursive input type"
|
|
3584
|
-
- Fix: snake_case GraphQL type names (Hasura, PostGraphile) camelize
|
|
3585
|
-
into valid Ruby constants — pokemon_v2_pokemon => PokemonV2Pokemon
|
|
3586
|
-
(previously generated a SyntaxError); wire names (__typename dispatch,
|
|
3587
|
-
registry keys) are untouched
|
|
3588
|
-
- Everything raised is rescuable: unparseable queries wrap as
|
|
3589
|
-
ValidationError (GraphQL::ParseError no longer leaks), and internal
|
|
3590
|
-
NotImplementedError raises (recursive inputs, unsupported kinds,
|
|
3591
|
-
subscriptions) became GraphWeaver::Error
|
|
3592
|
-
- Transport::HTTP takes open_timeout:/read_timeout: (defaults 10s/30s);
|
|
3593
|
-
timeouts surface as retriable TransportError
|
|
3594
|
-
- Transport::HTTP reuses its connection (keep-alive, mutex-serialized,
|
|
3595
|
-
keep_alive_timeout: for the idle window); any failure drops the socket
|
|
3596
|
-
so the next call starts fresh
|
|
3597
|
-
- GraphQLError#code also reads a top-level "type" (GitHub's dialect:
|
|
3598
|
-
NOT_FOUND, FORBIDDEN) when extensions.code is absent
|
|
3599
|
-
- Typo'd client-scoped registrations raise at generation with a
|
|
3600
|
-
spellchecked hint (register_type("Pett") => "did you mean 'Pet'?")
|
|
3601
|
-
instead of silently no-oping
|
|
3602
|
-
- Abstract selections narrow: __typename is only required when the
|
|
3603
|
-
selection varies by concrete type. Interface-level-fields-only
|
|
3604
|
-
selections generate one shared struct (no dispatch); a single
|
|
3605
|
-
`... on X` condition generates X's struct, always nilable — a
|
|
3606
|
-
non-matching runtime type casts to nil, so narrowing doubles as
|
|
3607
|
-
filtering
|
|
3608
|
-
- Zero-config rspec: require "graph_weaver/rspec" now defaults
|
|
3609
|
-
auto_fake on and auto-locates the schema from the committed dump
|
|
3610
|
-
(config.schema= / config.auto_fake = false to override) — one line is
|
|
3611
|
-
the whole test setup in a conventional app
|
|
3612
|
-
- examples/: runnable demos, all directly executable — countries.rb
|
|
3613
|
-
(public API, no auth, all dynamic), rick_and_morty.rb (filtered
|
|
3614
|
-
search, pagination, a block-built type helper), and github/ (auth,
|
|
3615
|
-
checked-in generated modules; stars the repo ⭐ then tours the
|
|
3616
|
-
stargazers, their top repos, and what else they've starred); excluded
|
|
3617
|
-
from the gem package
|
|
3618
|
-
- Fix: requires: now load before codec probing, so inference sees
|
|
3619
|
-
methods the required file provides — register_scalar("DateTime", Time,
|
|
3620
|
-
requires: "time") correctly infers Time.parse in a fresh process
|
|
3621
|
-
(previously the cast was silently skipped unless "time" was already
|
|
3622
|
-
loaded)
|
|
3623
|
-
- docs/quickstart.md renamed to docs/getting_started.md
|
|
3624
|
-
- Rails Railtie: the graph_weaver:* rake tasks self-register (no
|
|
3625
|
-
Rakefile edit) and depend on :environment, and generated modules load
|
|
3626
|
-
at boot (after initializers) when generated_path exists; outside
|
|
3627
|
-
Rails, require "graph_weaver/tasks" and call load_generated! as before
|
|
3628
|
-
- BREAKING (vs 0.1.0): reset_scalars! lost its coerce: flavor —
|
|
3629
|
-
GraphWeaver.auto_coerce = true is the one way to default-coerce
|
|
3630
|
-
(broader: convertible built-ins AND full cast/serialize scalars,
|
|
3631
|
-
resolved lazily, per-registration coerce: still wins)
|
|
3632
|
-
- GraphWeaver.client= — the blessed global wiring: assign the app's
|
|
3633
|
-
default client and generated modules resolve through it (per call ->
|
|
3634
|
-
per module -> baked -> executor= -> client). executor= stays as the
|
|
3635
|
-
low-level override, so test fakes still win
|
|
3636
|
-
- Enum mappings: register_enum("Species", PetKind) (+ bulk
|
|
3637
|
-
register_enums, client-scoped variants) — generated code speaks YOUR
|
|
3638
|
-
T::Enum, with the wire mapping inferred by name, pinned via map:,
|
|
3639
|
-
exhaustiveness-checked at generation (fails naming gaps), and
|
|
3640
|
-
fallback: to absorb unknown wire values on cast (inputs stay strict);
|
|
3641
|
-
translation tables emitted into the source (X_FROM_WIRE / X_TO_WIRE)
|
|
3642
|
-
- Type helpers: register_type("Pet", PetHelpers) (global or
|
|
3643
|
-
client-scoped, additive) — app-owned modules included into every
|
|
3644
|
-
struct generated from that GraphQL type, so derived values live as
|
|
3645
|
-
methods beside the honest wire data and srb tc checks them against
|
|
3646
|
-
each query's selection. Or build the mixin inline with a block
|
|
3647
|
-
(module_eval'd into an auto-named GraphWeaver::TypeHelpers constant —
|
|
3648
|
-
quick decoration, invisible to srb tc)
|
|
3649
|
-
- BREAKING (vs 0.1.0): register_scalar takes the type positionally —
|
|
3650
|
-
register_scalar("Money", Money, requires: ...) — matching the new
|
|
3651
|
-
registrars: the GraphQL name + your Ruby type up front, options as
|
|
3652
|
-
kwargs
|
|
3653
|
-
- GraphWeaver::Client — transport, schema, and scalars for one server in
|
|
3654
|
-
one object: GraphWeaver.new(url_or_schema) takes a url (transport
|
|
3655
|
-
built, schema introspected lazily per cache:/ttl:) or a schema source
|
|
3656
|
-
(live class — also the in-process executor — or a path/SDL/dump);
|
|
3657
|
-
#parse and #execute/#execute! bind the implicit schema + transport;
|
|
3658
|
-
#register_scalar scopes scalar mappings to the client (overlaying the
|
|
3659
|
-
global registry), so two servers can disagree about a scalar type
|
|
3660
|
-
- BREAKING: GraphWeaver.connect removed — GraphWeaver.new(url) replaces
|
|
3661
|
-
it (wire generated modules with GraphWeaver.executor = client.executor)
|
|
3662
|
-
- BREAKING: the one-shots are now GraphWeaver.execute(url_or_schema,
|
|
3663
|
-
query, **variables) / execute! — Client#execute on a throwaway client;
|
|
3664
|
-
variables are plain kwargs, as on a generated module
|
|
3665
|
-
- Client#load_queries! — parse every query file into modules named like
|
|
3666
|
-
generation would name them (reloadable; namespace: to scope): the
|
|
3667
|
-
no-build-step analog of generate! + load_generated!
|
|
3668
|
-
- Introspected schema dumps record provenance (source url + timestamp):
|
|
3669
|
-
a parsable SDL header comment, a "graph_weaver" sibling key in JSON —
|
|
3670
|
-
read it back with SchemaLoader.provenance(path), check drift with
|
|
3671
|
-
SchemaLoader.stale?(path) or rake graph_weaver:schema:verify, rewrite
|
|
3672
|
-
with rake graph_weaver:schema:refresh (GRAPHWEAVER_AUTH for tokens)
|
|
3673
|
-
- generate!/verify_generated!/rake auto-locate the schema dump at
|
|
3674
|
-
schema_path in any supported format; SchemaLoader.locate is public
|
|
3675
|
-
- Calling a result field by its camelCase wire name raises a pointed
|
|
3676
|
-
NoMethodError naming the snake_case prop that does exist
|
|
3677
|
-
(result.addPet => "use 'add_pet'"), and near-miss typos in either
|
|
3678
|
-
casing get a spellchecked suggestion (result.addPt => "did you mean
|
|
3679
|
-
'add_pet'?") — the runtime companion to srb tc's static flag
|
|
3680
|
-
- BREAKING: an operation whose only variable is a required input object
|
|
3681
|
-
(the Relay convention) now flattens the input's fields into execute's
|
|
3682
|
-
kwargs — AdoptQuery.execute!(name:, species:) instead of
|
|
3683
|
-
execute!(input: {...}); multi-variable / nullable-input operations
|
|
3684
|
-
keep the input: kwarg (struct or hash)
|
|
3685
|
-
- Enum kwargs accept the T::Enum or its wire value (T.any(Enum, String))
|
|
3686
|
-
everywhere — variables now match input-hash fields
|
|
3687
|
-
- BREAKING: HttpExecutor / FaradayExecutor are now Transport::HTTP /
|
|
3688
|
-
Transport::Faraday, subclasses of the new abstract GraphWeaver::Transport
|
|
3689
|
-
base, which owns the shared flow (encode, TransportError reclassify,
|
|
3690
|
-
non-2xx ServerError, parse) — a custom transport just implements
|
|
3691
|
-
post(body) => [status, body]. Opt-in require moved:
|
|
3692
|
-
"graph_weaver/faraday_executor" -> "graph_weaver/transport/faraday"
|
|
3693
|
-
- SchemaLoader.introspect cache: reuses a fresh dump in ANY supported
|
|
3694
|
-
format before re-introspecting (an existing schema.graphql wins over
|
|
3695
|
-
writing schema.json), and accepts :json / :graphql / :gql to pick the
|
|
3696
|
-
format at GraphWeaver.schema_path's location
|
|
3697
|
-
- rubydoc.info rendering: ship .yardopts (markdown markup, docs/ guides
|
|
3698
|
-
as extra files) and re-indent docstring examples so code blocks and
|
|
3699
|
-
backticks render; make docs previews locally
|
|
3700
|
-
- GraphWeaver.connect(url, auth:, headers:, retries:): one-shot setup —
|
|
3701
|
-
best transport (Faraday when the app loads it; detection is defined?,
|
|
3702
|
-
never a require), bearer/verbatim auth, opt-in RetryExecutor wrapping
|
|
3703
|
-
(true / options Hash; off by default), wired in as the global executor
|
|
3704
|
-
- Generation workflow: GraphWeaver.generate! (queries dir -> generated
|
|
3705
|
-
dir), verify_generated! (the freshness guard — raises naming stale
|
|
3706
|
-
files), load_generated! (factory_bot-style explicit loading), rake
|
|
3707
|
-
tasks (require "graph_weaver/tasks": graph_weaver:generate / :verify),
|
|
3708
|
-
all defaulting to configurable conventional paths (queries_path /
|
|
3709
|
-
generated_path / schema_path)
|
|
3710
|
-
- GraphWeaver.auto_coerce = true: default input coercion for scalars
|
|
3711
|
-
without an explicit coerce:, resolved lazily at generation time (no
|
|
3712
|
-
reset_scalars! ordering dance) — convertible built-ins take their
|
|
3713
|
-
conversion, cast/serialize pairs take parse-style coercion
|
|
3714
|
-
- SchemaLoader.introspect cache: true — caches at GraphWeaver.schema_path,
|
|
3715
|
-
in the format the extension picks: .json (verbatim wire artifact) or
|
|
3716
|
-
.graphql/.gql (SDL — human-readable, PR-reviewable diffs);
|
|
3717
|
-
the same dump rake graph_weaver:generate reads
|
|
3718
|
-
- docs/transports.md: connect, the executor contract, Faraday, retries
|
|
3719
|
-
- Cassette workflow: GRAPHWEAVER_RECORD=1 / config.record force
|
|
3720
|
-
re-recording; config.anonymize scrubs responses as they are recorded
|
|
3721
|
-
(caller sees the anonymized data too, so assertions hold on replay);
|
|
3722
|
-
rake graph_weaver:cassettes:anonymize; docs/cassettes.md guide
|
|
3723
|
-
- auto_coerce reaches input-object fields: raw scalar values inside
|
|
3724
|
-
input hashes coerce via the registry, mutations included
|
|
3725
|
-
- RetryExecutor: composable retries over any transport — tries:,
|
|
3726
|
-
exponential/linear/custom backoff with jitter and max clamp,
|
|
3727
|
-
retry-by-error-class (5xx yes, 4xx no by default; retry_if: override)
|
|
3728
|
-
and retry-by-GraphQL-code (retry_codes: ["THROTTLED"])
|
|
3729
|
-
|
|
3730
|
-
### v0.1.0 (2026-07-11)
|
|
3731
|
-
- Structured errors: execute returns a typed Response envelope (#data/#data!,
|
|
3732
|
-
#errors, #errors?, #extensions) instead of raising on GraphQL errors, so
|
|
3733
|
-
partial data and top-level extensions (cost/throttle) survive. Error classes
|
|
3734
|
-
under GraphWeaver::Error — TransportError (network), ServerError (non-2xx
|
|
3735
|
-
HTTP, #status/#body), QueryError (#errors/#data/#extensions/#codes),
|
|
3736
|
-
ValidationError (build-time) — plus a GraphQLError value object with #code.
|
|
3737
|
-
Transport-error classification is an extensible Set (GraphWeaver.transport_errors
|
|
3738
|
-
/ register_transport_error): each transport seeds its own network exceptions
|
|
3739
|
-
and apps can add more (e.g. a connection-pool timeout).
|
|
3740
|
-
The envelope is a single generic GraphWeaver::Response[Result] (no per-query
|
|
3741
|
-
wrapper class). execute! is the shortcut for execute(...).data! — the typed
|
|
3742
|
-
result or a raised QueryError — on both generated modules and the one-shot
|
|
3743
|
-
GraphWeaver.execute!/execute.
|
|
3744
|
-
BREAKING: module #execute returns Response; use #execute! (or #data!) for
|
|
3745
|
-
the old raise-or-result behavior. GraphWeaver.execute now returns the
|
|
3746
|
-
envelope too; GraphWeaver.execute! returns the result.
|
|
3747
|
-
- GraphWeaver.register_scalar: custom scalar deserialization into rich Ruby
|
|
3748
|
-
objects. cast/serialize inferred from a class type via paired codecs
|
|
3749
|
-
(.parse/#to_s or .load/.dump), or given as a Symbol/Proc (:itself opts out);
|
|
3750
|
-
requires: emits (validated, and require-checked when type: is a class)
|
|
3751
|
-
requires into generated source — the built-in Date scalar carries
|
|
3752
|
-
require "date" so Date-using queries are self-contained; coerce: true lets a
|
|
3753
|
-
variable accept the value or its raw input (coerce: :to_f for a built-in
|
|
3754
|
-
conversion), casting/converting the latter — reset_scalars!(coerce: true)
|
|
3755
|
-
reloads the built-ins coercible; built-in scalars pre-registered in one
|
|
3756
|
-
overridable registry (reset_scalars!/clear_scalars!)
|
|
3757
|
-
- FaradayExecutor: url, Faraday connection, or middleware block
|
|
3758
|
-
- GraphWeaver.executor default transport; per-module executor= override
|
|
3759
|
-
- GraphWeaver.parse and GraphWeaver.execute (dynamic queries)
|
|
3760
|
-
- Codegen.generate shorthand; executor: takes a constant; module_name
|
|
3761
|
-
derived from operation or file name
|
|
3762
|
-
- Error ergonomics: schema_stale? (validation-shaped rejections hint at
|
|
3763
|
-
regeneration), errors_at(path) + each_error/errors_by_field filtering,
|
|
3764
|
-
#report (field-keyed rollup with entity ids resolved from partial
|
|
3765
|
-
data), #to_h across the hierarchy (JSON-ready machine output), and
|
|
3766
|
-
GraphWeaver::TypeError wrapping cast failures with the failing struct
|
|
3767
|
-
- SchemaLoader: introspect(executor, cache:, ttl:) fetches schemas from
|
|
3768
|
-
live endpoints with file caching; load accepts introspection JSON /
|
|
3769
|
-
SDL content / Hashes as well as paths (cache round-trips)
|
|
3770
|
-
- GraphWeaver::Testing (require "graph_weaver/testing", or
|
|
3771
|
-
"graph_weaver/rspec" for the rspec integration): FakeExecutor
|
|
3772
|
-
fabricates schema-correct castable responses (mode: :faker semantic
|
|
3773
|
-
values / :literal; overrides by GraphQL name; seeded; list_size /
|
|
3774
|
-
null_chance), failure simulation (Failure.transport/server/graphql/
|
|
3775
|
-
throttled/stale_schema, SequenceExecutor for retries, fail_at: with
|
|
3776
|
-
spec-correct null propagation, corrupt: for derived type mismatches),
|
|
3777
|
-
cassette record/replay above the transport, and Cassette#anonymize!
|
|
3778
|
-
(shape-preserving, consistent id mapping). rspec: seed follows
|
|
3779
|
-
--seed; auto_fake installs a fake executor per example
|
|
3780
|
-
- one-off integration specs against live GitHub + Countries APIs
|
|
3781
|
-
(make integration)
|
|
3782
|
-
- Input objects: INPUT_OBJECT variables generate module-level T::Structs
|
|
3783
|
-
with serialize (aliased to_h) producing the wire hash; execute kwargs
|
|
3784
|
-
also accept plain hashes, normalized + type-checked via the generated
|
|
3785
|
-
.coerce (underscored Symbol/String keys, enums as instances or wire
|
|
3786
|
-
values, nested inputs as hashes)
|
|
3787
|
-
- fields under @skip/@include generate nilable regardless of schema
|
|
3788
|
-
nullability; FakeExecutor honors first/last/limit when sizing lists
|
|
3789
|
-
- eval hardening for parse: module names must be constant names, and
|
|
3790
|
-
QUERY heredocs can't be terminated early by block strings
|
|
3791
|
-
- GraphWeaver::Selection: one shared query-walk (codegen, FakeExecutor,
|
|
3792
|
-
anonymizer); codegen split into scalar_type / nodes / emit
|
|
3793
|
-
- docs/: generated_modules, real_world, scalars, errors, testing;
|
|
3794
|
-
README slimmed to pitch + quickstart
|
|
3795
|
-
|
|
3796
|
-
### v0.0.1 (2026-07-07)
|
|
3797
|
-
- voila: typed codegen (T::Structs, T::Enums, typed variable kwargs)
|
|
3798
|
-
- queries + mutations; fragments, unions, interfaces, enums, custom scalars
|
|
3799
|
-
- schema sources: live class, introspection JSON, SDL (incl. supergraph)
|
|
3800
|
-
- pluggable executor: in-process schema or HTTP
|
|
3801
|
-
- dynamic (no-build) mode for development
|