graph_weaver 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +6 -13
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. data/lib/graph_weaver/testing/values.rb +0 -98
data/README.md CHANGED
@@ -42,14 +42,47 @@ result.person&.nmae
42
42
  isn't defensive, it's the schema talking. A field you misspelled, or never
43
43
  selected, is a typecheck error rather than a `NoMethodError` in production.
44
44
 
45
- Typed structs are the part every generator gets right. What decides whether you're
46
- still happy six months in is everything around them.
45
+ ## Start here
46
+
47
+ ```ruby
48
+ # Gemfile
49
+ gem "graph_weaver"
50
+ ```
51
+
52
+ In Rails, setup is one command:
53
+
54
+ ```sh
55
+ rails g graph_weaver:install https://api.example.com/graphql
56
+ ```
57
+
58
+ which writes the initializer, the `app/graphql` layout, the editor config and the
59
+ schema dump. **[Getting started](docs/getting_started.md)** walks the production
60
+ setup end to end.
61
+
62
+ You then rarely type `rake graph_weaver:generate` again: while the dev server is
63
+ up, a `.graphql` edit regenerates and reloads before the next request, the way a
64
+ route change does. Run it when you're ready to commit the Ruby.
65
+
66
+ Or skip the build step and poke at an API from a console —
67
+ anything holding a schema parses, and the module runs on what parsed it:
68
+
69
+ ```ruby
70
+ api = GraphWeaver.new("https://countries.trevorblades.com/")
71
+ CountryQuery = api.parse("queries/country.graphql") # a path or a raw string
72
+ CountryQuery.execute!(code: "JP").country&.capital # => "Tokyo"
73
+
74
+ api.run!("query { continents { name } }").continents # or no module at all
75
+ ```
76
+
77
+ The **[examples](examples/)** run that path for real, smallest first: a public API
78
+ in 30 lines, a paginated search, the production path against GitHub, and the
79
+ federated graph below.
47
80
 
48
81
  ## Precise types are expensive to fake, so it fakes them for you
49
82
 
50
83
  Generation makes result types exact, which makes them tedious to build by hand —
51
84
  and most generators stop there and leave you the fixtures. GraphWeaver ships the
52
- fabricator. One line in the spec helper:
85
+ fakes. One line in the spec helper:
53
86
 
54
87
  ```ruby
55
88
  require "graph_weaver/rspec"
@@ -68,77 +101,64 @@ end
68
101
  ```
69
102
 
70
103
  No fixture, no stub, no HTTP — and the values are seeded from rspec's own seed, so
71
- `--seed 4242` hands back that same person and a failure reproduces. The tag also
72
- picks a *real* client when you want one: `:in_process` runs your resolvers,
73
- `:router` runs them across a federated graph. Field-level failure simulation and
74
- record/replay cassettes with anonymization are in [testing](docs/testing.md).
104
+ `--seed 4242` hands back that same person and a failure reproduces.
105
+
106
+ Random data answers "does this render". When the example is *about* the data, pin
107
+ the fields it's about and let the rest stay fabricated:
108
+
109
+ ```ruby
110
+ graphql_fake("Person.name" => "Ada", "Person.pets" => [{ "name" => "Shelby" }, {}])
111
+
112
+ person.name # => "Ada"
113
+ person.pets.first.name # => "Shelby" — the second pet is still fabricated
114
+ person.pets.size # => 2 — a pinned list is as long as you write it
115
+ ```
116
+
117
+ Keys are schema names — a field, or a whole type: `"Person" => build(:person)`
118
+ reads the selected fields off your factory's object and fabricates the rest. They
119
+ are checked and spellchecked, so a typo raises instead of leaving the example
120
+ green against random data. The tag also picks a *real* client
121
+ when you want one: `:in_process` runs your resolvers, `:router` runs them across a
122
+ federated graph. Field-level failure simulation and record/replay cassettes with
123
+ anonymization are in [testing](docs/testing.md).
75
124
 
76
125
  ## Federation without a gateway
77
126
 
78
127
  When your app is both a GraphQL client and a subgraph, the local router plans a
79
128
  query across the composed supergraph and runs your **real resolvers** over the
80
129
  boundary — no gateway process, no node, no sockets. That's
81
- [`examples/federation.rb`](examples/federation.rb), the example that needs no network:
130
+ [`examples/federation.rb`](examples/federation.rb), the example that needs no
131
+ network. Part of what it prints:
82
132
 
83
133
  ```
84
- $ bundle exec examples/federation.rb
85
- #<GraphWeaver::Testing::Router subgraphs=["accounts", "products", "reviews"]>
86
-
87
- dpep reviewed 2 products:
88
- Table ($899) — Love it
89
- Couch ($1299) — Too expensive
90
-
91
134
  fetches:
92
135
  → accounts root fields
93
136
  → reviews _entities × 1 User
94
137
  → products _entities × 2 Product
95
138
  ```
96
139
 
97
- The trace is the query plan: every node at a level in one `_entities` call, so two
98
- products cost one fetch. Anything it can't answer *faithfully* it refuses at plan
99
- time rather than guessing — and it's diffed against a real `@apollo/gateway` over
100
- the same supergraph, currently 42 queries identical, 1 refused, 0 wrong
140
+ The trace is the query plan: every node at a level goes in one `_entities` call,
141
+ so two products cost one fetch. Anything it can't answer *faithfully* it refuses
142
+ at plan time rather than guessing — the example prints one of those too, naming
143
+ the coordinate that stopped it and what to rename. And it's diffed against a real
144
+ `@apollo/gateway` over the same supergraph:
145
+ currently 73 queries identical, 2 refused, 0 wrong
101
146
  ([`spec/integration/router_parity_spec.rb`](spec/integration/router_parity_spec.rb)).
102
147
  See [federation](docs/federation.md).
103
148
 
104
149
  ## The schema keeps itself honest
105
150
 
106
151
  The lifecycle is rake tasks, not a CI pipeline you assemble yourself:
107
- `schema:refresh` re-introspects the committed dump, `schema:diff` fails when the
108
- server has drifted, `queries:check` names the queries that drift broke and where,
109
- and `verify` fails when the checked-in Ruby is stale. Generation is deterministic
110
- — same schema and queries, byte-identical files — so regenerating never shows a
111
- diff you didn't earn. See [getting started](docs/getting_started.md#5-verify-in-ci).
112
-
113
- ## Start here
114
-
115
- ```ruby
116
- # Gemfile
117
- gem "graph_weaver"
118
- ```
119
-
120
- In Rails, setup is then one command:
121
-
122
- ```sh
123
- rails g graph_weaver:install https://api.example.com/graphql
124
- ```
125
-
126
- which writes the initializer, the `app/graphql` layout, the editor config and the
127
- schema dump. **[Getting started](docs/getting_started.md)** walks the production
128
- setup end to end. Or skip the build step entirely and poke at an API from a
129
- console — anything holding a schema parses, and the module runs on what parsed it:
152
+ `schema:refresh` re-introspects the committed dump, `schema:diff` names what
153
+ changed when the server has drifted, `queries:check` names the queries that
154
+ drift broke and where, and `verify` fails when the checked-in Ruby is stale.
155
+ Generation is deterministic — same schema and queries, byte-identical files — so
156
+ regenerating never shows a diff you didn't earn. See
157
+ [getting started](docs/getting_started.md#5-verify-in-ci).
130
158
 
131
- ```ruby
132
- api = GraphWeaver.new("https://countries.trevorblades.com/")
133
- CountryQuery = api.parse("queries/country.graphql") # a path or a raw string
134
- CountryQuery.execute!(code: "JP").country&.capital # => "Tokyo"
135
-
136
- api.run!("query { continents { name } }").continents # or no module at all
137
- ```
138
-
139
- The **[examples](examples/)** run that path for real, smallest first: a public API
140
- in 30 lines, a paginated search, the production path against GitHub, and the
141
- federated graph above.
159
+ **Any release can change what codegen emits**, patch releases included — fixing a
160
+ generated type is a byte change. So `rake graph_weaver:generate` is part of every
161
+ upgrade, and `verify` is what tells you when you've skipped it.
142
162
 
143
163
  #### Also in the box
144
164
 
@@ -160,6 +180,7 @@ federated graph above.
160
180
  - **[Editor support](docs/editors.md)** — five lines of YAML for schema autocomplete in `.graphql` files, no JS project
161
181
  - **[Against a real API](docs/real_world.md)** — introspecting a live endpoint, GitHub end to end
162
182
  - **[Logging](docs/logging.md)** — point `GraphWeaver.logger` at any Logger
183
+ - **[Upgrading](docs/upgrading.md)** — regenerate on every bump, and what 0.5.0 moved
163
184
 
164
185
  ----
165
186
  ## Development
data/docs/cassettes.md CHANGED
@@ -82,9 +82,29 @@ preserving everything that makes the recording faithful:
82
82
  | enums, booleans, `__typename` | numbers, dates |
83
83
  | id *relationships* (same original id → same fake id) | the id values themselves |
84
84
 
85
- It needs the schema — it walks each recorded query's selections to know which
86
- values are enums, dates, ids. Variables are NOT anonymized: they're the replay
87
- matching key, so don't record with secret variables.
85
+ `data` is walked against the schema — which is why it needs one, to know which
86
+ values are enums, dates, ids. `errors` and `extensions` have none behind them,
87
+ so they're walked by shape instead: keys, nesting and structure survive, every
88
+ string and number is replaced. `path`, `locations` and an error's
89
+ `extensions.code` are kept, because they describe the request rather than the
90
+ data — and call sites branch on `code` the way they branch on an enum.
91
+
92
+ **The query and its variables are not anonymized.** They're the key replay
93
+ matches on, so scrubbing them would make the recording unfindable. A mutation's
94
+ input is often the sensitive part, so record with placeholder variables, or
95
+ don't record that request.
96
+
97
+ Recording says so when the bytes it wrote look like a credential:
98
+
99
+ ```
100
+ graph_weaver: spec/cassettes/github.yml contains a JWT, a GitHub token — a
101
+ cassette is committed as written, so review this one first. …
102
+ ```
103
+
104
+ It recognizes tokens by shape — a JWT, `AKIA…`, `ghp_…`, `xox…`, `sk_live_…`, a
105
+ PEM block, a `Bearer` header — which is every credential that is unmistakable
106
+ and nothing else. A password like `hunter2` has no shape, so a quiet run is not
107
+ a clean bill of health: **read a cassette before committing it.**
88
108
 
89
109
  For cassettes recorded before the flag was on:
90
110
 
@@ -93,7 +113,12 @@ rake graph_weaver:cassettes:anonymize # every cassette in cassette_dir, in pla
93
113
  ```
94
114
 
95
115
  Anonymization preserves shape, so an anonymized cassette still passes
96
- `cassettes:check`.
116
+ `cassettes:check` — including a custom scalar, whose replacement is the same
117
+ one [`FakeClient`](testing.md#fabricated-data--graphql-fake) would fabricate.
118
+ A scalar registered as *your own* class needs a pin for the type in
119
+ `Testing.config.overrides` (`{ "Money" => "12.00" }` — [pins](testing.md#pins)),
120
+ which the anonymizer reads too; without one, anonymizing refuses rather than
121
+ writing a value the codec can't read back.
97
122
 
98
123
  ## Cassette or FakeClient?
99
124
 
data/docs/editors.md CHANGED
@@ -22,7 +22,9 @@ That's the whole setup. The paths are graph_weaver's conventions
22
22
  (`GraphWeaver.schema_path`, `queries_paths`, `fragments_paths`) — if you moved
23
23
  them, move these to match. Include the fragments directory: an editor
24
24
  validating a query that spreads a shared fragment reports `Unknown fragment`
25
- unless the fragment files are in `documents` too.
25
+ unless the fragment files are in `documents` too. The generator writes that
26
+ line whether or not you have fragments yet — the directory it names doesn't
27
+ exist until you add one, and a glob matching nothing is fine.
26
28
 
27
29
  An SDL dump works just as well if you took one (`cache: :graphql`):
28
30
 
data/docs/errors.md CHANGED
@@ -1,8 +1,18 @@
1
1
  # Errors
2
2
 
3
- `execute` returns a typed **`Response` envelope** rather than raising on GraphQL
4
- errors so partial data and top-level `extensions` (cost, throttle) survive.
5
- `execute!` is the shortcut when you just want the result:
3
+ What comes back when something goes wrong, and what to rescue. Read it when you
4
+ write the first `rescue` around a query, or when you need to tell "the network
5
+ broke" apart from "the server said no" apart from "the response didn't fit".
6
+
7
+ Two names travel together here, and they're close enough to trip on:
8
+
9
+ - **`Result`** — the struct holding *this* query's data, e.g. `PersonQuery::Result`.
10
+ - **`Response`** — the **envelope** around it, `GraphWeaver::Response[Result]`,
11
+ carrying `data`, `errors` and `extensions`.
12
+
13
+ `execute` returns the envelope rather than raising on GraphQL errors, so partial
14
+ data and top-level `extensions` (cost, throttle) survive. `execute!` is the
15
+ shortcut to the result:
6
16
 
7
17
  ```ruby
8
18
  PersonQuery.execute!(id: "1") # => Result, or raises QueryError (== execute(...).data!)
@@ -30,14 +40,16 @@ subclass says where it failed:
30
40
 
31
41
  | Class | When |
32
42
  |-------|------|
33
- | `TransportError` | never reached the server — DNS, connection refused, TLS, timeout |
43
+ | `TransportError` | no response came back — DNS, connection refused, TLS, timeout, a socket that died mid-body |
34
44
  | `ServerError` | reached it, non-2xx HTTP — `#status`, `#body`, `#headers`, `#retry_after`, `#throttled?` |
35
45
  | `QueryError` | 200 body with top-level GraphQL errors — `#errors`, `#data`, `#extensions`, `#codes`, `#throttled?` |
36
46
  | `TypeError` | the response wouldn't cast into the generated structs — `#struct`, `#cause` |
37
47
  | `InputError` | the variables wouldn't build into the generated input structs — unknown/typo'd key, missing required field, out-of-range enum, wrong-typed field, wrong number of @oneOf fields — `#field`, `#struct` |
38
48
  | `ValidationError` | build time: the query didn't validate against the schema |
49
+ | `Codegen::Aliases::UnknownSegment` | build time: an [`alias:`](generated_modules.md#flat-accessors-with-alias) path names a field no type here has — a typo, so `optional: true` won't skip it |
39
50
  | `ConfigurationError` | setup judged against your schema — which Ruby schema serves which subgraph (`Testing::Router`, `federation:diff`) |
40
51
  | `Testing::Unplannable` | the local test router won't plan this operation — `#category`, `#detail` |
52
+ | `Testing::MissingRecording` | a [cassette](cassettes.md) holds no entry for this request — the message prints the variables, and the ones it did record |
41
53
 
42
54
  An argument that is wrong *on its face* raises a plain `ArgumentError` instead
43
55
  (`pool_size: must be >= 1`, `cast: must be a Symbol, Proc, :itself, or nil`),
@@ -68,17 +80,34 @@ Or skip the hand-rolling: [`Retry`](transports.md#retries) wraps any client and
68
80
  already defaults to exactly the policy above — transport failures always,
69
81
  `ServerError` on 5xx plus 408/429, and GraphQL error codes you name.
70
82
 
71
- **Top-level scalar variables** fail like any Ruby method call, *outside* the
72
- hierarchy on purpose — passing the wrong Ruby type for a scalar kwarg is a
73
- programming bug, not caller input: a wrong-typed one raises sorbet-runtime's
74
- `TypeError` ("Parameter 'page': Expected type T.nilable(Integer), got type
75
- String"), a missing required one a plain `ArgumentError` ("missing keyword: :id").
83
+ **A status with an obvious next step says it.** A 3xx appends "redirects are
84
+ not followed" and the `Location` to repoint the client at replaying a POST,
85
+ with its `Authorization` header, at a host the server named isn't the
86
+ library's call. A 401 or 403 appends "check `auth:` the token, and its
87
+ scopes".
88
+
89
+ **Everything you pass to `execute` is caller input**, so a value that won't
90
+ convert raises `GraphWeaver::InputError` — top-level scalar variables included.
91
+ They name the variable and the operation, since the value alone locates nothing
92
+ in an app that runs a hundred queries:
93
+
94
+ ```
95
+ $count of Compute: expected an Int, got "lots"
96
+ ```
97
+
98
+ The value is usually the whole diagnosis, so it is quoted — unless the key it
99
+ arrived under is one your `filter_parameters` covers, in which case the message
100
+ reads `$password of Login: [FILTERED]`. Error messages reach the log at `warn`,
101
+ above the level that gates the variables line, so they are scrubbed by the same
102
+ list ([logging](logging.md#filtered-variables)).
103
+
104
+ A *missing* required kwarg is still a plain `ArgumentError` ("missing keyword:
105
+ :id") — that's Ruby's, and it is a programming bug rather than bad input.
76
106
 
77
- **Input-object variables** are the caller-input case, so they're *inside* the
78
- hierarchy. When you pass an input object as a hash (or struct) it's built
79
- through the generated `coerce`, and anything wrong in there raises
80
- `GraphWeaver::InputError` — one rescue point for turning invalid input into a
81
- 422:
107
+ **What's inside an input object** reports the same way. Pass one as a hash (or
108
+ struct) and it's built through the generated `coerce`, and anything wrong in
109
+ there raises `GraphWeaver::InputError` too so one rescue point turns invalid
110
+ input into a 422:
82
111
 
83
112
  ```ruby
84
113
  rescue GraphWeaver::InputError => e
@@ -90,7 +119,16 @@ end
90
119
  ```
91
120
 
92
121
  A nested filter reports the innermost input type, so the error points at the
93
- input that actually held the bad field.
122
+ input that actually held the bad field. Passing something that is neither — a
123
+ bare `String` where the input goes — reports the same way. A call site that
124
+ *spells* the wrong type is caught earlier and better, by `srb tc`: the sig is
125
+ as narrow as the schema, and only untyped values reach the runtime check
126
+ ([why](generated_modules.md#variables-become-typed-kwargs)).
127
+
128
+ `#struct` is the generated input struct *class* where generation produced one,
129
+ and the GraphQL type *name* where it didn't — a federation representation
130
+ builds a plain Hash, so an entity has only its name to give. `to_h`'s
131
+ `"struct"` is the name either way, so branch on that.
94
132
 
95
133
  Business/validation failures returned *as data* (Shopify-style `userErrors { field
96
134
  message code }`) aren't errors here — they're just fields you selected, so they
@@ -137,6 +175,14 @@ response.report
137
175
  `GraphQLError#field` strips list indices (`people.3.email` → `people.email`) —
138
176
  the stable grouping key; the raw `#path` keeps indices for exact location.
139
177
 
178
+ `Response#to_h` decomposes the envelope the same way: `{"data" =>, "errors" =>,
179
+ "extensions" =>}`, with each error as its JSON-ready hash. `data` stays the
180
+ typed struct — it is deliberately not re-serialized, because `T::Struct#serialize`
181
+ would give snake_case keys where the wire is camelCase, drop null fields, and
182
+ leave a registered scalar as the Ruby object its codec built. That output would
183
+ look like the server's response without being one, so serialize the typed data
184
+ yourself when you need to re-emit it.
185
+
140
186
  ## Stale schemas
141
187
 
142
188
  GraphQL has no schema-version signal, so a schema change surfaces as the
@@ -151,6 +197,19 @@ refresh the schema cache.
151
197
  When wire data disagrees with the types the schema promised at generation time
152
198
  (a nil where non-null was declared, a malformed scalar, an unknown enum value),
153
199
  casting raises `GraphWeaver::TypeError` naming the failing generated struct,
154
- with the original exception as `#cause`. Simulate one in tests with
200
+ with the original exception as `#cause`.
201
+
202
+ A cast's own complaint is about the value and nothing else — "invalid date"
203
+ locates nothing on a struct holding four of them — so a casting leaf also
204
+ carries **its response key**, and three failures that keep happening say whose
205
+ bug it is rather than leaving you sorbet's words:
206
+
207
+ | what came back | what the message adds |
208
+ |---|---|
209
+ | an `ID` the server sent unquoted | GraphQL serializes `ID` as a JSON string, so this is the server out of spec — plus how to take it anyway (`register_scalar("ID", "T.untyped")`) |
210
+ | an enum value the generated enum doesn't hold | the values it does hold, and that drift is the likely cause: regenerate, or `register_enum(fallback:)` to absorb them |
211
+ | a field the server nulled **with a reason** | the server's own explanation, rather than only sorbet's nil complaint |
212
+
213
+ Simulate one in tests with
155
214
  `GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")` — see
156
215
  [testing](testing.md).