graph_weaver 0.4.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1314 -0
- data/CLAUDE.md +100 -8
- data/DECISIONS.md +309 -0
- data/Gemfile.lock +23 -23
- data/NOTES.md +5 -5
- data/PLAN.md +106 -135
- data/README.md +115 -96
- data/REVIEW.md +946 -0
- data/docs/cassettes.md +75 -48
- data/docs/editors.md +82 -0
- data/docs/errors.md +32 -30
- data/docs/federation.md +520 -48
- data/docs/generated_modules.md +352 -137
- data/docs/getting_started.md +237 -67
- data/docs/logging.md +35 -6
- data/docs/real_world.md +21 -15
- data/docs/scalars.md +49 -154
- data/docs/testing.md +299 -52
- data/docs/transports.md +129 -30
- data/docs/upgrading.md +112 -0
- data/graph_weaver.gemspec +3 -1
- data/lib/generators/graph_weaver/install_generator.rb +259 -0
- data/lib/graph_weaver/client.rb +114 -111
- data/lib/graph_weaver/codegen/aliases.rb +217 -0
- data/lib/graph_weaver/codegen/emit.rb +272 -258
- data/lib/graph_weaver/codegen/enum_type.rb +27 -124
- data/lib/graph_weaver/codegen/nodes.rb +72 -13
- data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
- data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
- data/lib/graph_weaver/codegen.rb +593 -334
- data/lib/graph_weaver/errors.rb +127 -10
- data/lib/graph_weaver/federation.rb +272 -0
- data/lib/graph_weaver/hints.rb +9 -1
- data/lib/graph_weaver/in_process.rb +90 -0
- data/lib/graph_weaver/input_struct.rb +14 -2
- data/lib/graph_weaver/logging.rb +29 -0
- data/lib/graph_weaver/parsing.rb +67 -0
- data/lib/graph_weaver/query_module.rb +55 -0
- data/lib/graph_weaver/railtie.rb +23 -1
- data/lib/graph_weaver/representation.rb +74 -0
- data/lib/graph_weaver/response.rb +7 -0
- data/lib/graph_weaver/retry.rb +29 -8
- data/lib/graph_weaver/rspec.rb +214 -16
- data/lib/graph_weaver/schema_loader.rb +794 -59
- data/lib/graph_weaver/schemas.rb +46 -0
- data/lib/graph_weaver/selection.rb +43 -8
- data/lib/graph_weaver/tasks.rb +216 -21
- data/lib/graph_weaver/testing/cassette.rb +160 -61
- data/lib/graph_weaver/testing/coverage.rb +165 -0
- data/lib/graph_weaver/testing/failure.rb +10 -23
- data/lib/graph_weaver/testing/fake_client.rb +181 -21
- data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
- data/lib/graph_weaver/testing/router.rb +1431 -0
- data/lib/graph_weaver/testing/subgraphs.rb +130 -0
- data/lib/graph_weaver/testing.rb +204 -14
- data/lib/graph_weaver/transport/faraday.rb +28 -10
- data/lib/graph_weaver/transport/http.rb +99 -36
- data/lib/graph_weaver/transport.rb +67 -14
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +389 -170
- metadata +20 -3
data/REVIEW.md
ADDED
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
# Library review — 2026-09-05
|
|
2
|
+
|
|
3
|
+
A six-agent review of graph_weaver 0.4.6: codegen/runtime corner cases, fresh-install
|
|
4
|
+
DX against a live public API, Apollo Federation coverage, transport/performance, a
|
|
5
|
+
competitive analysis of the field, and a survey of what users of peer libraries
|
|
6
|
+
actually complain about.
|
|
7
|
+
|
|
8
|
+
Sibling of `PLAN.md` (the roadmap) and `NOTES.md` (the research notebook). This is the
|
|
9
|
+
findings document: what's broken, what's missing, and what the field says is worth
|
|
10
|
+
building. Items are marked **PROVEN** (a repro was run) or **REASONED** (argued from
|
|
11
|
+
code or spec, not executed). Findings verified independently a second time are marked
|
|
12
|
+
**✓verified**.
|
|
13
|
+
|
|
14
|
+
Baseline at review time: 308 examples green, `srb tc` clean, tree clean.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. The one-paragraph version
|
|
19
|
+
|
|
20
|
+
The library is in better shape than its version number suggests, and its instincts are
|
|
21
|
+
right: query-driven codegen, a typed error envelope, a real testing harness, and schema
|
|
22
|
+
lifecycle management are four of the seven structural problems of this category, and
|
|
23
|
+
graph_weaver has independently landed on good answers to all four — while under-selling
|
|
24
|
+
every one of them. The defects worth fixing are concentrated and specific: one silent
|
|
25
|
+
data-corruption bug in union narrowing, one documented-but-absent nullability behaviour,
|
|
26
|
+
a federation loader that rejects the artifact teams actually have, a default HTTP
|
|
27
|
+
transport that serializes every request in the process, and one measured violation of
|
|
28
|
+
its own stated "generate only what the query touches" invariant that produces 5,386
|
|
29
|
+
lines for a two-condition query. Fix those and the remaining gaps are features, not
|
|
30
|
+
faults.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 2. Confirmed bugs
|
|
35
|
+
|
|
36
|
+
Ranked by severity. These are the implementation backlog.
|
|
37
|
+
|
|
38
|
+
### B1 — Narrowing + `__typename` silently casts members into the wrong struct
|
|
39
|
+
**PROVEN ✓verified · HIGH · `codegen.rb:416`, `codegen/nodes.rb:260-262`**
|
|
40
|
+
|
|
41
|
+
When a selection narrows to a single type condition (`... on Person { … }`) *and* also
|
|
42
|
+
selects `__typename`, codegen still takes the narrowing branch. But narrowing's
|
|
43
|
+
"this isn't my type" test is *did the object come back empty?* — and selecting
|
|
44
|
+
`__typename` guarantees it never is.
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
query { search(term: "el") { __typename ... on Person { email } } }
|
|
48
|
+
# emits: v3.empty? ? nil : Person.from_h(v3)
|
|
49
|
+
|
|
50
|
+
Result.from_h("search" => [{ "__typename" => "Person", "email" => "d@e.f" },
|
|
51
|
+
{ "__typename" => "Pet" }])
|
|
52
|
+
# => [Result::Person __typename="Person" email="d@e.f",
|
|
53
|
+
# Result::Person __typename="Pet" email=nil] # <- a Pet, typed as a Person
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
If the member has a non-null field you get a confusing `TypeError: key not found`.
|
|
57
|
+
If its fields are all nullable you get **silent corruption**. This is the exact shape
|
|
58
|
+
of a federation `_entities { __typename ... on Widget { … } }` query, and "always
|
|
59
|
+
select `__typename`" is a widespread habit.
|
|
60
|
+
|
|
61
|
+
`docs/generated_modules.md:252` claims narrowing "skips the dispatch (and the
|
|
62
|
+
`__typename`) entirely" — the code allows it anyway.
|
|
63
|
+
|
|
64
|
+
**Fix:** when `__typename` is present, dispatch on the tag rather than on emptiness:
|
|
65
|
+
`data["__typename"] == "Pet" ? Pet.from_h(data) : nil`. Strictly better than the
|
|
66
|
+
emptiness heuristic, and it removes the need for the `unconditional_field?` guard in
|
|
67
|
+
this case.
|
|
68
|
+
|
|
69
|
+
### B2 — `@skip`/`@include` on a fragment doesn't make its fields nilable
|
|
70
|
+
**PROVEN · HIGH · `codegen.rb:471-475`, `selection.rb:42-53`**
|
|
71
|
+
|
|
72
|
+
Directive handling inspects *field* nodes only; `Selection#each_field` recurses into
|
|
73
|
+
inline fragments and named spreads without carrying their directives down. Fields
|
|
74
|
+
reached through a conditional fragment keep non-null typing and get `data.fetch(...)`.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
query Q($s: Boolean!) { people { id ... on Person @skip(if: $s) { name } } }
|
|
78
|
+
Result.from_h("people" => [{ "id" => "1" }])
|
|
79
|
+
# => GraphWeaver::TypeError: key not found: "name"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Identical for a named spread with a directive. Directive-on-a-spread is legal GraphQL
|
|
83
|
+
and the ordinary way to make a whole block conditional; the README advertises
|
|
84
|
+
"`@skip`/`@include` nullability" without qualification.
|
|
85
|
+
|
|
86
|
+
**Fix:** thread a conditional flag through `each_field` so `object_node` strips
|
|
87
|
+
`NonNull` for anything under a conditional fragment. `FakeClient` and `Anonymizer` walk
|
|
88
|
+
the same `Selection` module, so they stay in step for free.
|
|
89
|
+
|
|
90
|
+
### B3 — Abstract types generate a struct per schema member, not per selected condition
|
|
91
|
+
**PROVEN ✓verified · HIGH · `codegen.rb:693`**
|
|
92
|
+
|
|
93
|
+
`union_members` maps over `@schema.possible_types(type)`. Measured against the GitHub
|
|
94
|
+
schema already committed at `examples/github/schema.json`, where `Node` has 278
|
|
95
|
+
possible types:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
query GetThread($id: ID!) {
|
|
99
|
+
node(id: $id) {
|
|
100
|
+
__typename
|
|
101
|
+
... on PullRequestReviewThread { isResolved }
|
|
102
|
+
... on Issue { title }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
→ **5,386 lines / 193 KB**, 278 `T::Struct`s of which **275 hold nothing but
|
|
108
|
+
`const :__typename, String`**, and a `T.any` with 278 members. Generation itself is
|
|
109
|
+
fast (0.021s); the cost is checked-in volume, `srb tc` load, PR reviewability, and a
|
|
110
|
+
`T.absurd` exhaustiveness story that is unwritable at 278 branches.
|
|
111
|
+
|
|
112
|
+
This violates the stated invariant in `CLAUDE.md` — "codegen is query-driven … only for
|
|
113
|
+
the types a query actually touches" — in exactly one place. It has an exact twin in the
|
|
114
|
+
wild: [genqlient #416](https://github.com/Khan/genqlient/issues/416), and The Guild
|
|
115
|
+
conceded the same disease in
|
|
116
|
+
[graphql-codegen v6](https://the-guild.dev/graphql/hive/blog/graphql-codegen-client-v6-202604)
|
|
117
|
+
("large generated files filled with types you never use").
|
|
118
|
+
|
|
119
|
+
**Fix:** emit a member struct only for types the selection actually names, plus **one**
|
|
120
|
+
catch-all `Other` carrying the interface-level fields. This answers the maintainer
|
|
121
|
+
objection raised on genqlient #416 ("the server can still return a type you have no
|
|
122
|
+
fragment on") and mirrors the shape of its PR #419. `T.absurd` becomes writable *and
|
|
123
|
+
stays writable* when the schema grows a type. Add a spec asserting generated size is
|
|
124
|
+
O(named conditions), not O(possible types) — the bound is the feature.
|
|
125
|
+
|
|
126
|
+
### B4 — A schema-level directive makes a supergraph unloadable
|
|
127
|
+
**PROVEN ✓verified · HIGH · `schema_loader.rb` (`strip_federation`)**
|
|
128
|
+
|
|
129
|
+
Any directive on the `schema` definition — `@tag`, `@composeDirective`, or a
|
|
130
|
+
`@composeDirective`'d custom SCHEMA directive — produces:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
GraphQL::ParseError: Expected LCURLY, actual: DIRECTIVE ("directive") at [4, 1]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Root cause is in graphql-ruby's printer, isolated:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
GraphQL.parse('schema @foo { query: Query } directive @foo on SCHEMA type Query { hi: String }')
|
|
140
|
+
.definitions.first.to_query_string
|
|
141
|
+
# => "schema\n @foo" <- directives printed, `{ query: Query }` body omitted
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`SchemaDefinition#to_query_string` omits the root-types body when root type names are
|
|
145
|
+
the GraphQL defaults but still prints retained directives, so the reprint emits a
|
|
146
|
+
braceless `schema @tag(...)`. `@link`/`@core` are stripped, so current specs never hit
|
|
147
|
+
it; anything else on `schema` does. The error's line number refers to a document the
|
|
148
|
+
user never wrote.
|
|
149
|
+
|
|
150
|
+
**Fix:** strip *all* directives from the `SchemaDefinition` node (codegen never reads
|
|
151
|
+
them), or drop the node when root types are conventional.
|
|
152
|
+
|
|
153
|
+
### B5 — Subgraph SDL cannot be loaded at all
|
|
154
|
+
**PROVEN · HIGH · `schema_loader.rb`**
|
|
155
|
+
|
|
156
|
+
A raw subgraph schema — from `rover subgraph fetch`, `_service { sdl }`, or the
|
|
157
|
+
`.graphql` in a service repo — dies with an unbranded
|
|
158
|
+
`NoMethodError: undefined method 'get_argument' for an instance of GraphQL::Schema::LateBoundType`.
|
|
159
|
+
|
|
160
|
+
This is the artifact teams most often have, and it is the *only* way to type an
|
|
161
|
+
`_entities` query, since `_entities`/`_service` are deliberately absent from a
|
|
162
|
+
supergraph.
|
|
163
|
+
|
|
164
|
+
**Fix (proven in probe):** detect a subgraph SDL and prepend the federation directive
|
|
165
|
+
definitions it references but doesn't define. Add `subgraph_sdl?` alongside
|
|
166
|
+
`federation_sdl?` plus a `SUBGRAPH_DIRECTIVE_DEFS` constant; inject only definitions not
|
|
167
|
+
already present. Both fed-1-style and `@link`-style subgraphs loaded cleanly once
|
|
168
|
+
definitions were injected.
|
|
169
|
+
|
|
170
|
+
### B6 — Result enums and variable enums are incompatible types
|
|
171
|
+
**PROVEN ✓verified · MEDIUM · `codegen.rb:454-462` vs `codegen.rb:745-746`**
|
|
172
|
+
|
|
173
|
+
A result field's enum builds a fresh nested `EnumNode`; the same GraphQL enum as a
|
|
174
|
+
variable registers in `@variable_enums` (hoisted to `GraphQLInputs`). Nothing reconciles
|
|
175
|
+
them.
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
result enum class: M1::Result::Pet::Species
|
|
179
|
+
variable enum class: M1::Species
|
|
180
|
+
equal? false
|
|
181
|
+
round-trip: TypeError: Parameter 'species': Expected T.any(M1::Species, String),
|
|
182
|
+
got M1::Result::Pet::Species
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Reading a value out and feeding it back — echoing a filter, re-submitting a status — is
|
|
186
|
+
an obvious move and fails at both `srb tc` and runtime. Found independently by two
|
|
187
|
+
agents from opposite directions (reading the emitter; hitting it live after a mutation).
|
|
188
|
+
|
|
189
|
+
**Fix:** when a result field's enum is also a variable type in the same module, reuse
|
|
190
|
+
the shared `@variable_enums[name]` node.
|
|
191
|
+
|
|
192
|
+
### B7 — Enum-in-a-list variables reject wire strings
|
|
193
|
+
**PROVEN · MEDIUM**
|
|
194
|
+
|
|
195
|
+
`docs/generated_modules.md:179` states unconditionally that enum variables "accept the
|
|
196
|
+
enum or its wire value". True for a scalar enum, false inside a list:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
execute!(type: "ANIME") # works
|
|
200
|
+
execute!(type: "ANIME", sort: ["POPULARITY_DESC"]) # NoMethodError: undefined method 'serialize' for String
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
"type" => (type.is_a?(MediaType) ? type : MediaType.deserialize(type)).serialize, # coerces
|
|
205
|
+
variables["sort"] = sort.map { |v1| v1&.then { |v2| v2.serialize } } # doesn't
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Sorbet's runtime doesn't deep-check array elements, so the sig doesn't catch it either;
|
|
209
|
+
the user gets a raw `NoMethodError` naming neither the variable nor the enum.
|
|
210
|
+
|
|
211
|
+
**Fix:** emit the same `is_a? ? : .deserialize` inside the map, widen the element type.
|
|
212
|
+
|
|
213
|
+
### B8 — Cassette recording is broken for the call the docs show
|
|
214
|
+
**PROVEN ✓verified · MEDIUM · `testing/cassette.rb:133`**
|
|
215
|
+
|
|
216
|
+
`docs/cassettes.md:13` shows `Cassette.use("github", client: live)`. Passing a `Client`
|
|
217
|
+
— which the kwarg name invites and the doc demonstrates — fails:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
c = GraphWeaver::Testing::Cassette.use("anilist", client: live)
|
|
221
|
+
SearchMediaQuery.execute!(c, search: "x", type: "ANIME")
|
|
222
|
+
# ArgumentError: missing keywords: :search, :type
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`Recorder` calls `@client.execute(query, variables:)`, but `Client#execute` is
|
|
226
|
+
`execute(query, **variables)` — the one-shot surface, not the transport contract. So
|
|
227
|
+
`variables:` is swallowed as a kwarg named `variables`. This is the duck-typed-client
|
|
228
|
+
invariant leaking: every other call site unwraps via `GraphWeaver.resolve_transport`;
|
|
229
|
+
the recorder forgot.
|
|
230
|
+
|
|
231
|
+
**Fix:** `@client = GraphWeaver.resolve_transport(client)` in `Recorder#initialize`.
|
|
232
|
+
|
|
233
|
+
### B9 — Global registrations are never validated, so typos silently no-op
|
|
234
|
+
**PROVEN ✓verified · MEDIUM · `codegen.rb:383-387`**
|
|
235
|
+
|
|
236
|
+
`validate_registrations!` iterates only the client-scoped `@enums`/`@scalars`/`@types`.
|
|
237
|
+
The global registries — the path `getting_started.md` step 3 explicitly recommends — are
|
|
238
|
+
never walked.
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
GraphWeaver.extend_type("Medai", MediaHelpers) # typo
|
|
242
|
+
GraphWeaver.generate! # SUCCEEDS. Helper never applied. No warning.
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The good error already exists and fires only on the client-scoped path:
|
|
246
|
+
`extend_type("Medai") matches no type in this schema — did you mean 'Media'?`
|
|
247
|
+
|
|
248
|
+
**Fix:** walk the global registries too.
|
|
249
|
+
|
|
250
|
+
### B10 — Two result keys that underscore to the same prop emit an unloadable file
|
|
251
|
+
**PROVEN · MEDIUM · `codegen.rb:402`**
|
|
252
|
+
|
|
253
|
+
`prop = underscore(key)` with no collision check — the exact check added for variable
|
|
254
|
+
kwargs in v0.4.6. Generation succeeds; the file can't be loaded.
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
query { person(id: "1") { name Name: name } }
|
|
258
|
+
# => ArgumentError: Attempted to redefine prop :name
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Reachable with a plain alias; no exotic schema needed.
|
|
262
|
+
|
|
263
|
+
**Fix:** mirror the variable-collision check in `object_node`.
|
|
264
|
+
|
|
265
|
+
### B11 — Output props aren't checked against reserved names
|
|
266
|
+
**PROVEN · MEDIUM · asymmetry with `codegen.rb:766-770`**
|
|
267
|
+
|
|
268
|
+
`input_node` validates props against Ruby keywords and generated methods; output structs
|
|
269
|
+
validate nothing. A GraphQL field named `class`, `hash`, `send`, `to_h` or `freeze`
|
|
270
|
+
generates a file that raises at `require` time. Fields named `class` are not
|
|
271
|
+
hypothetical.
|
|
272
|
+
|
|
273
|
+
**Fix:** run the same reserved-name check on output props, raising at generation with a
|
|
274
|
+
suggestion to alias in the query.
|
|
275
|
+
|
|
276
|
+
### B12 — A multi-operation document silently types only the first operation
|
|
277
|
+
**PROVEN · MEDIUM · `selection.rb:22`**
|
|
278
|
+
|
|
279
|
+
`load_operation` takes `.first`, `emit_module` puts the *whole* document in `QUERY`, and
|
|
280
|
+
no transport sends `operationName`. So a file holding two operations types one and sends
|
|
281
|
+
a request the server must reject with "Must provide operation name". Several operations
|
|
282
|
+
per file is a common habit.
|
|
283
|
+
|
|
284
|
+
**Fix:** raise at generation when the document holds more than one `OperationDefinition`
|
|
285
|
+
(the small, honest change), or plumb `operationName` through — see F4.
|
|
286
|
+
|
|
287
|
+
### B13 — `from_response` lets malformed envelopes escape as raw Sorbet `TypeError`
|
|
288
|
+
**PROVEN · MEDIUM · `emit.rb:485-493`**
|
|
289
|
+
|
|
290
|
+
v0.4.6 branded the transport-level malformed-body cases, but `from_response` is
|
|
291
|
+
documented public API and is unguarded. A non-Hash `data`, a Hash `errors`, an array of
|
|
292
|
+
strings for `errors`, and a non-Hash `extensions` all escape the `GraphWeaver::Error`
|
|
293
|
+
umbrella.
|
|
294
|
+
|
|
295
|
+
**Fix:** shape-check in the emitted `from_response`.
|
|
296
|
+
|
|
297
|
+
### B14 — `spec/integration/federation_spec.rb:95` calls a method that doesn't exist
|
|
298
|
+
**PROVEN ✓verified · LOW (test-only)**
|
|
299
|
+
|
|
300
|
+
`router.executor` — `Client` exposes `transport`/`transport!`. `executor` survives only
|
|
301
|
+
as a generated-`execute` kwarg. Hidden because `:integration` specs are excluded from
|
|
302
|
+
the default run; with the call fixed, the spec passes against a live gateway.
|
|
303
|
+
|
|
304
|
+
**Fix:** rename, and consider a CI job that at least *loads* the integration specs — a
|
|
305
|
+
stale method reference survived several releases.
|
|
306
|
+
|
|
307
|
+
### B15 — Smaller confirmed items
|
|
308
|
+
|
|
309
|
+
| | Finding | Where |
|
|
310
|
+
|---|---|---|
|
|
311
|
+
| B15a | `GraphQL::ParseError` escapes unbranded — `inline_fragments` parses before `Codegen#generate`'s rescue | `codegen.rb:336,344` |
|
|
312
|
+
| B15b | A custom scalar whose cast raises anything but `TypeError`/`ArgumentError`/`KeyError` escapes the umbrella (`JSON::ParserError`, `Money::ParseError`) | `emit.rb:356` |
|
|
313
|
+
| B15c | Enum values differing only in case collide into one constant (`enum E { active ACTIVE }`), raising at load not generation | `emit.rb:303` |
|
|
314
|
+
| B15d | `@skip` on the narrowing inline fragment itself isn't caught by the all-conditional guard | `codegen.rb:431`, `659-664` |
|
|
315
|
+
| B15e | `@skip` on `__typename` breaks a dispatched union's unguarded `data.fetch("__typename")` | `codegen.rb:687`, `emit.rb:387` |
|
|
316
|
+
| B15f | A field selected both conditionally and unconditionally is typed over-nilably (`any?` should be `all?`) | `codegen.rb:473` |
|
|
317
|
+
| B15g | `@oneOf` input objects get no exactly-one validation | `codegen.rb:758-777` |
|
|
318
|
+
| B15h | `extend_type(requires:)` isn't checked for loadability, unlike `register_scalar(requires:)`; the emitted `require` also needs `$LOAD_PATH` (true in Rails, not a plain app) | |
|
|
319
|
+
| B15i | Directive-definition arguments aren't pruned by the `@inaccessible` cascade — bare `RuntimeError`. Composition almost certainly forecloses this; the defect is the unbranded error | |
|
|
320
|
+
| B15j | `Hints` defines `method_missing` without `respond_to_missing?` | `hints.rb:43` |
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## 3. Ergonomics, errors, and documentation
|
|
325
|
+
|
|
326
|
+
Cheap, high-ratio fixes. Most are a line or two.
|
|
327
|
+
|
|
328
|
+
### Documentation defects
|
|
329
|
+
- **`docs/testing.md:75-79`** passes the client as a `client:` kwarg across five lines;
|
|
330
|
+
generated `execute` takes it **positionally**. The same file gets it right on line 19.
|
|
331
|
+
Highest doc-damage-per-character in the repo. **PROVEN ✓verified**
|
|
332
|
+
- **`README.md:84`** — "module names derive from the operation name (`query GetPerson`
|
|
333
|
+
→ `GetPerson`)" is true only for `parse` on a *raw string*. For a file — both
|
|
334
|
+
`parse(path)` and the rake task, i.e. the documented production path — the operation
|
|
335
|
+
name is ignored and it's `<FileName>Query`. `docs/generated_modules.md:310` states it
|
|
336
|
+
correctly. **PROVEN**
|
|
337
|
+
- **`docs/generated_modules.md:252`** says narrowing skips `__typename` entirely; the
|
|
338
|
+
code allows it (see B1).
|
|
339
|
+
- **`docs/generated_modules.md:179`** overstates enum wire-value acceptance (see B7).
|
|
340
|
+
- **`docs/federation.md`** doesn't mention that federation **v1** supergraphs
|
|
341
|
+
(`@core`/`@join__owner`) load correctly — proven — and doesn't note that
|
|
342
|
+
`@inaccessible` is only subtracted on the supergraph path.
|
|
343
|
+
- **`docs/cassettes.md`** references `MissingRecording` as though nested under
|
|
344
|
+
`Cassette`; it's `GraphWeaver::Testing::MissingRecording`.
|
|
345
|
+
- **`schema_loader.rb:214`** recommends `.graphql` for reviewable diffs and says both
|
|
346
|
+
formats "load back identically" — true semantically, misleading on cost: **374 ms vs
|
|
347
|
+
173 ms** on GitHub's schema. One sentence fixes it.
|
|
348
|
+
|
|
349
|
+
### Error messages worth improving
|
|
350
|
+
- **Codegen errors never name the query file**, and drop line/col they already capture.
|
|
351
|
+
`ValidationError` populates `line`/`column` via `validation_detail` then joins only
|
|
352
|
+
`message`; `generation_plan` has `path` in scope and never passes it. Wanted:
|
|
353
|
+
```
|
|
354
|
+
invalid query in app/graphql/queries/typo.graphql:
|
|
355
|
+
4:5 Field 'titel' doesn't exist on type 'Media' (Did you mean `title`?)
|
|
356
|
+
```
|
|
357
|
+
- **A strict `alias:` breaks unrelated queries** and the message doesn't name its own
|
|
358
|
+
fix. `optional: true` is documented and resolves it; given how consistently this
|
|
359
|
+
library puts the fix *in* the message, this one should end with
|
|
360
|
+
`— pass optional: true to skip selections that don't fit`, and say which query failed.
|
|
361
|
+
- **`GraphWeaver.resolve_transport` passes anything through unchecked**, so a bad client
|
|
362
|
+
surfaces as `NoMethodError … for an instance of Hash`. A one-line guard would have
|
|
363
|
+
made the `docs/testing.md` error self-diagnosing.
|
|
364
|
+
- **`rake graph_weaver:schema:refresh` can't bootstrap** and says so unhelpfully — it
|
|
365
|
+
needs the url recorded in the dump. Since `GraphWeaver.client` is already a url client
|
|
366
|
+
at that point, refresh could use it.
|
|
367
|
+
- **A bare host** (`GraphWeaver.new("graphql.anilist.co")`) reports "unsupported schema
|
|
368
|
+
format"; the cause is the missing scheme. Also an `ArgumentError`, not a
|
|
369
|
+
`GraphWeaver::Error`.
|
|
370
|
+
- **Unregistered custom scalars silently become `T.untyped`** with no output. For a
|
|
371
|
+
library whose pitch is exact result shapes, this deserves a line:
|
|
372
|
+
`3 unregistered custom scalars → T.untyped: CountryCode, FuzzyDateInt, Json`.
|
|
373
|
+
|
|
374
|
+
### Ergonomic gaps
|
|
375
|
+
- **`FakeClient` override keys aren't validated.** `@overrides.fetch("Person.nmae")`
|
|
376
|
+
silently does nothing and the test passes against random data — a test that has
|
|
377
|
+
quietly stopped pinning what it thinks it pins. `Codegen.validate_registration!`
|
|
378
|
+
already validates `Type.field` coordinates with spellchecked errors; call it from
|
|
379
|
+
`FakeClient#initialize`. Apollo Kotlin has the same problem
|
|
380
|
+
([#4435](https://github.com/apollographql/apollo-kotlin/issues/4435), 11 reactions);
|
|
381
|
+
graph_weaver's global `Testing.configure` is already the thing they're asking for.
|
|
382
|
+
- **No documented way to reach the schema inside an `auto_fake` spec** —
|
|
383
|
+
`FakeClient#schema` doesn't exist though it holds one, and `Testing.config.schema` is
|
|
384
|
+
documented only as a writer.
|
|
385
|
+
- **Mutations generate `…Query` modules** — `SaveListEntryQuery.execute!` reads wrong
|
|
386
|
+
for a write.
|
|
387
|
+
- **`load_queries!` silently replaces loaded constants**, so previously-built structs
|
|
388
|
+
become instances of an orphaned class and `is_a?` starts failing. Painful in a console.
|
|
389
|
+
- **`Response` has no `ok?`/`success?`** — `errors?` is the idiom, but people reach for
|
|
390
|
+
the positive.
|
|
391
|
+
- **`register_enum("X", Y, {…})`** (guessing a positional map) gives a bare
|
|
392
|
+
`ArgumentError: wrong number of arguments` with no hint that `map:` is the kwarg.
|
|
393
|
+
|
|
394
|
+
### Packaging
|
|
395
|
+
The packaged gem installs and runs clean from a scratch `GEM_HOME` — **no `s.files`
|
|
396
|
+
gaps**. It does ship `CLAUDE.md`, `PLAN.md`, `NOTES.md`, `Gemfile.lock` and now this
|
|
397
|
+
file; harmless, but they're internal.
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## 4. Feature gaps
|
|
402
|
+
|
|
403
|
+
### Federation
|
|
404
|
+
|
|
405
|
+
| Item | Status | Note |
|
|
406
|
+
|---|---|---|
|
|
407
|
+
| Composed fed-2 supergraph SDL | works | join v0.5, `@join__implements`/`@unionMember`/`@enumValue`/`isInterfaceObject`/`overrideLabel` all strip cleanly |
|
|
408
|
+
| Federation **v1** supergraph (`@core`/`@join__owner`) | works | undocumented; docs claim v2 only |
|
|
409
|
+
| Router API schema / live introspection | works | integration spec passes against a real `@apollo/gateway` |
|
|
410
|
+
| **Raw subgraph SDL** | **broken** | B5 |
|
|
411
|
+
| **Any `schema`-level directive** | **broken** | B4 |
|
|
412
|
+
| `@inaccessible` cascade | **correct** | stress-tested against Apollo's composition rules; both spec-mandated residual behaviours implemented, the one genuinely-reachable edge (inaccessible Mutation root) handled, loop terminates |
|
|
413
|
+
| `@link` `import:` renames, default namespacing (`@federation__inaccessible`) | silently wrong | hidden fields survive into the API schema |
|
|
414
|
+
| `federation__Scope` / `federation__Policy` / `context__ContextFieldValue` | leak into `schema.types` | any graph using `@requiresScopes`/`@policy`/`@context` — i.e. fed 2.5+ auth |
|
|
415
|
+
| `_entities` representations | read side good, input side untyped | `alias: {entity: "_entities.first"}` works well; `representations:` is bare `T::Array[T.untyped]` |
|
|
416
|
+
| `@defer`/`@stream` | unsupported | fails cleanly; see §7 |
|
|
417
|
+
|
|
418
|
+
**F1 — Derive `@link` namespaces instead of hardcoding prefixes.** Replace
|
|
419
|
+
`FEDERATION_PREFIXES = %w[join__ link__ core__]` with prefixes computed from the schema's
|
|
420
|
+
own `@link`/`@core` declarations — the URL's penultimate path segment, overridden by
|
|
421
|
+
`as:`, plus the `import:` list with `{name:, as:}` renames. This is precisely the
|
|
422
|
+
[core spec's](https://specs.apollo.dev/core/v0.1/) API-derivation rule. **One change
|
|
423
|
+
fixes four defects**: the `federation__*` leak, the `@link(as:)` leak, renamed
|
|
424
|
+
`@inaccessible` being missed, and the `@core`-only leak — and it restores the invariant
|
|
425
|
+
`docs/federation.md` already promises. Metadata proven reachable off the parsed
|
|
426
|
+
`SchemaDefinition`. *Effort: M.*
|
|
427
|
+
|
|
428
|
+
**F2 — Typed `_entities` representations.** With B5 fixed, parse `@key(fields:)` during
|
|
429
|
+
subgraph load and emit a per-member builder
|
|
430
|
+
(`UserQuery::Representations.user(id: "1")` → `{"__typename" => "User", "id" => "1"}`).
|
|
431
|
+
Today the user hand-builds representations with no check that `__typename` is present or
|
|
432
|
+
that the key field set is satisfied — both hard requirements of the subgraph spec.
|
|
433
|
+
Stays within the leaf-codec/decoration invariant: this is generation, not a new
|
|
434
|
+
deserializer path. *Effort: M–L; worth a design note first.*
|
|
435
|
+
|
|
436
|
+
**F3 — Brand `SchemaLoader` failures under `GraphWeaver::Error`.** v0.4.6 made "malformed
|
|
437
|
+
inputs stay branded" an invariant; the loader violates it on every federation-shaped
|
|
438
|
+
failure — `NoMethodError`, `GraphQL::ParseError`, `InvalidDefaultValueError`, bare
|
|
439
|
+
`RuntimeError`. Pairs naturally with B5: *"this looks like a subgraph SDL; weaver can
|
|
440
|
+
load it, but you fed it as X."* *Effort: S.*
|
|
441
|
+
|
|
442
|
+
### Transport
|
|
443
|
+
|
|
444
|
+
**F4 — Send an `Accept` header and `operationName`.** **PROVEN by wire capture.** Both
|
|
445
|
+
transports send only `Content-Type: application/json`; net/http supplies `accept: */*`.
|
|
446
|
+
The [GraphQL-over-HTTP draft](https://graphql.github.io/graphql-over-http/draft/) says a
|
|
447
|
+
conforming client **MUST** send `application/graphql-response+json`. So a spec-conformant
|
|
448
|
+
server has no signal to use the newer media type and we stay on legacy status-code
|
|
449
|
+
semantics forever. Add a real `User-Agent` in the same change — server operators
|
|
450
|
+
currently can't attribute traffic.
|
|
451
|
+
|
|
452
|
+
`operationName` is never sent either, so every request is anonymous in Apollo Studio,
|
|
453
|
+
Hasura, and every APM that keys traces on it. Codegen already knows the name; emit it as
|
|
454
|
+
a constant beside `QUERY` and widen the contract to
|
|
455
|
+
`execute(query, variables:, operation_name: nil)` — the optional kwarg keeps the
|
|
456
|
+
duck-typed slot intact, and `Schema.execute` happens to accept `operation_name:` too.
|
|
457
|
+
*Effort: header, an hour; operationName, ~1 day (ripples to FakeClient/Cassette/Retry).*
|
|
458
|
+
|
|
459
|
+
**F5 — Connection pool in `Transport::HTTP`.** **PROVEN ✓verified.** `@mutex.synchronize`
|
|
460
|
+
wraps the entire round trip, so one transport = one in-flight request process-wide — and
|
|
461
|
+
`GraphWeaver.client = api` is the documented pattern. Measured against a 10 ms-latency
|
|
462
|
+
server, 8 threads × 5 calls: **488 ms shared vs 72 ms with 8 transports (~6.8×)**. A
|
|
463
|
+
prototyped `SizedQueue` pool of 5 gave **1152 ms → 291 ms (4.0×)** at 8 threads × 10
|
|
464
|
+
calls. Invisible on localhost because the penalty scales with *server latency*, not CPU.
|
|
465
|
+
~40 lines, entirely inside `transport/http.rb`'s private section, plus a `pool_size:`.
|
|
466
|
+
*Effort: ~0.5 day. Highest-value transport item.*
|
|
467
|
+
|
|
468
|
+
**F6 — Faraday timeouts and passthrough.** Faraday is **auto-selected** on
|
|
469
|
+
`defined?(::Faraday)` — and Faraday rides in transitively via stripe/octokit — so most
|
|
470
|
+
Rails apps get it without choosing it. It opens **a connection per request** (20 TCP
|
|
471
|
+
accepts for 20 requests) and `Transport::Faraday.new` takes no timeout knobs, leaving
|
|
472
|
+
net/http's **60 s/60 s** — 6× and 2× the documented `Transport::HTTP` defaults, on the
|
|
473
|
+
transport you're more likely to get. `GraphWeaver.new(url, …)` exposes no
|
|
474
|
+
`open_timeout:`/`read_timeout:` at all. A missing timeout is an outage, not a slowdown.
|
|
475
|
+
Also consider preferring `:net_http_persistent` when loadable, or logging which adapter
|
|
476
|
+
was auto-picked. *Effort: ~0.5 day.*
|
|
477
|
+
|
|
478
|
+
**F7 — In-process is a second-class citizen.** **PROVEN.** Correct, but blind:
|
|
479
|
+
- **No `context:`** — `Schema.execute` accepts one, nothing supplies it. A resolver
|
|
480
|
+
reading `context[:current_user]` gets `nil` and it surfaces as
|
|
481
|
+
`Cannot return null for non-nullable field Query.me`. For server-side composition,
|
|
482
|
+
context *is* the request.
|
|
483
|
+
- **Zero logging** — all logging lives in `Transport#execute`, which an in-process
|
|
484
|
+
schema bypasses entirely. Not one line at DEBUG.
|
|
485
|
+
- **Errors unbranded** — a resolver raise surfaces as raw `RuntimeError` in-process vs
|
|
486
|
+
`GraphWeaver::ServerError` over HTTP, so `rescue GraphWeaver::Error` catches one and
|
|
487
|
+
misses the other.
|
|
488
|
+
|
|
489
|
+
A ~50-line `GraphWeaver::InProcess` wrapper closes all three (verified working), wired
|
|
490
|
+
into `Client#initialize` so `GraphWeaver.new(MySchema, context: {…})` works. Logging
|
|
491
|
+
belongs to the client slot, not to `Transport` — this is the right home. *Effort: ~0.5 day.*
|
|
492
|
+
|
|
493
|
+
**F8 — Preserve the HTTP response.** `Transport::HTTP#post` returns `[status, body]`;
|
|
494
|
+
`ServerError` carries `status`/`body` only; `Retry` has **zero** references to
|
|
495
|
+
`Retry-After` or 429. Real code monkey-patches the transport to recover headers —
|
|
496
|
+
libraries.io overrides `GraphQL::Client::HTTP#execute` wholesale to capture
|
|
497
|
+
`x-ratelimit-remaining` and build `rate_limited?`/`unauthorized?` predicates. Don't widen
|
|
498
|
+
the duck-typed contract: add `headers` to `ServerError` (the `Net::HTTPResponse` is
|
|
499
|
+
already in hand), teach `Retry` to honour `Retry-After`, and optionally expose
|
|
500
|
+
`last_response_headers` on a transport. This is the difference between being
|
|
501
|
+
retry-correct against GitHub/Shopify and not. *Effort: S–M.*
|
|
502
|
+
|
|
503
|
+
**F9 — An instrumentation seam.** `logging.rb` is the entire observability story: no
|
|
504
|
+
`ActiveSupport::Notifications`, no callback, no way for an APM to time a call or count
|
|
505
|
+
errors by code. For a gem that ships a railtie, that's the notable omission.
|
|
506
|
+
`GraphWeaver.instrumenter = ->(event, payload, &blk) { … }` defaulting to a no-op, called
|
|
507
|
+
in `Transport#execute` and in the `InProcess` wrapper — one seam, both paths, and
|
|
508
|
+
`ActiveSupport::Notifications` becomes a 2-line adapter. *Effort: ~1 day.*
|
|
509
|
+
|
|
510
|
+
**F10 — `RateLimitError` / a throttle predicate.** `retry_codes: ["THROTTLED"]` exists
|
|
511
|
+
and `THROTTLED` appears three times in the docs as a hand-written string; everyone
|
|
512
|
+
writes this class themselves. Promote it to a named `QueryError` subclass recognizing
|
|
513
|
+
common codes plus HTTP 429 once F8 lands. *Effort: XS.*
|
|
514
|
+
|
|
515
|
+
**F11 — CA/mTLS knobs on `Transport::HTTP`.** Today the answer is "use Faraday", which
|
|
516
|
+
is a real answer but undiscoverable. Four kwargs forwarded to `Net::HTTP.start`.
|
|
517
|
+
*Effort: ~2 h.*
|
|
518
|
+
|
|
519
|
+
### Category-level features
|
|
520
|
+
|
|
521
|
+
**F12 — Union `fallback:` for forward compatibility.** `emit_union` generates
|
|
522
|
+
`else raise … "unexpected __typename"`. An upstream team adding a union member — a
|
|
523
|
+
**non-breaking** change by every schema-evolution convention — hard-fails every response
|
|
524
|
+
carrying it, though your selected fields remain valid. `register_enum(…, fallback:)`
|
|
525
|
+
already exists for exactly this, and `docs/scalars.md` sells it as letting responses
|
|
526
|
+
"keep flowing instead of raising". Enums get forward-compatibility; unions, the more
|
|
527
|
+
common evolution point in a federated graph, don't. Both Rust clients treat this as
|
|
528
|
+
first-class (cynic *requires* a fallback variant). **Honest tension:** a fallback weakens
|
|
529
|
+
the `T.absurd` story — with one, exhaustiveness is over *generated* members rather than
|
|
530
|
+
*schema* members. That's the right trade, and it's why it must be opt-in and documented
|
|
531
|
+
plainly. Note B3 largely subsumes this: the catch-all `Other` is the same mechanism.
|
|
532
|
+
*Effort: S.*
|
|
533
|
+
|
|
534
|
+
**F13 — Hoist shared object fragments.** Already done for **unions** (v0.4.0: a lone
|
|
535
|
+
shared spread hoists into `GraphQLUnions`, aliased per query). Not for object fields, so
|
|
536
|
+
`fragment PersonFields on Person` spread into three queries yields three structurally
|
|
537
|
+
identical, mutually incompatible structs — a helper written against one won't typecheck
|
|
538
|
+
against the others. This is the property that decides whether checked-in codegen scales
|
|
539
|
+
past a dozen queries, and 80% of the machinery exists (`lone_shared_spread`,
|
|
540
|
+
`generate_unions`, `shared_artifacts`, `derive_module`). genqlient reached the same
|
|
541
|
+
answer via its `flatten` directive. *Effort: M, mostly test surface.*
|
|
542
|
+
|
|
543
|
+
**F14 — A pagination helper.** The most-requested thing nobody in the "typed structs, no
|
|
544
|
+
normalized cache" tier has shipped:
|
|
545
|
+
[genqlient #357](https://github.com/Khan/genqlient/issues/357) (open since 2024-10,
|
|
546
|
+
maintainer agrees, no design), graphql-codegen #5212 (24 reactions), Apollo Kotlin #3807.
|
|
547
|
+
Real code hand-rolls it every time — and writes **two near-identical queries** differing
|
|
548
|
+
only by `after: $cursor`, which graph_weaver already makes unnecessary (a nil variable
|
|
549
|
+
is omitted from the wire).
|
|
550
|
+
|
|
551
|
+
The structural advantage is real: the genqlient requester named the blocker as "the
|
|
552
|
+
field name varies per query" — exactly what a *query-driven* generator knows at build
|
|
553
|
+
time. Detect `pageInfo { hasNextPage endCursor }` plus a nullable cursor variable and
|
|
554
|
+
emit `each_page`/`each_node` returning a lazy `Enumerator` — a shape Ruby has and Go
|
|
555
|
+
argued about for two years.
|
|
556
|
+
|
|
557
|
+
**The warning, stated plainly:** three ecosystems examined this and declined —
|
|
558
|
+
ariadne-codegen closed [#383](https://github.com/mirumee/ariadne-codegen/issues/383) as
|
|
559
|
+
"tricky to implement in a way that fits all use cases", and Apollo Kotlin *deleted* its
|
|
560
|
+
pagination codegen (PR #6735). Their stated reasons (arbitrary field paths, forward vs
|
|
561
|
+
backward, nested connections, `nodes` shorthand vs `edges { node }`) are not obviously
|
|
562
|
+
wrong. "Nobody built it" is weaker evidence of opportunity than it looks. This is also
|
|
563
|
+
the first feature that turns a generated module into a mini-runtime orchestrating several
|
|
564
|
+
requests — defensible, still query-driven, but a real line to cross. Gate behind opt-in.
|
|
565
|
+
**Cheap prerequisite worth doing regardless:** a `docs/pagination.md` noting that one
|
|
566
|
+
query with a nullable `$cursor` suffices. *Effort: M–H, heuristic-heavy.*
|
|
567
|
+
|
|
568
|
+
**F15 — Query documents validated against a refreshed schema.** Given a new schema,
|
|
569
|
+
report which checked-in operations no longer validate. `Codegen#generate` already calls
|
|
570
|
+
`@schema.validate(@query)` and builds per-error detail; the re-introspection machinery
|
|
571
|
+
already exists for `graph_weaver:schema:verify`. Add a `graph_weaver:schema:check` task
|
|
572
|
+
reporting instead of raising.
|
|
573
|
+
|
|
574
|
+
Why this is more interesting than it sounds: **it answers a question Apollo's paid tier
|
|
575
|
+
structurally cannot.** [GraphOS operations checks](https://www.apollographql.com/docs/graphos/platform/schema-management/checks)
|
|
576
|
+
run against *historical usage metrics* — a 7-day window, a 10,000 distinct-operation cap,
|
|
577
|
+
requiring a metrics pipeline — so they answer "did anyone *use* this field lately", not
|
|
578
|
+
"does my repository still compile". `graphql-inspector validate` is the only
|
|
579
|
+
document-driven tool and it's JS. graphql-ruby's own docs point users at
|
|
580
|
+
`GraphQL::StaticValidation` and tell them to write the rake task themselves.
|
|
581
|
+
*Effort: ~1 day. Best value-per-hour on this list.*
|
|
582
|
+
|
|
583
|
+
**F16 — `@semanticNonNull`.** Nullability fatigue is a theme in *every* ecosystem
|
|
584
|
+
(six of graphql-codegen's top-20 issues). It's measurable in this repo's own showcase:
|
|
585
|
+
`examples/rick_and_morty.rb` lines 41–55 is a 15-line loop containing **5 `&.`, 2
|
|
586
|
+
`.compact`, 1 `.to_a`**. `@semanticNonNull` is a *schema* directive ("null only on
|
|
587
|
+
error"), which fits the model exactly — `SchemaLoader` reads it, `object_node` emits
|
|
588
|
+
`String` instead of `T.nilable(String)`, `from_h` raises if a null does arrive. Sorbet is
|
|
589
|
+
the type system best served by it, because `T.nilable` is *more* intrusive than TS's `?`.
|
|
590
|
+
For schemas you don't own, a local override registry
|
|
591
|
+
(`GraphWeaver.non_null("Person.email")`) alongside `register_scalar`.
|
|
592
|
+
See [Apollo's nullability docs](https://www.apollographql.com/docs/kotlin/advanced/nullability)
|
|
593
|
+
and [graphql/nullability-wg](https://github.com/graphql/nullability-wg/discussions/58).
|
|
594
|
+
*Effort: M.*
|
|
595
|
+
|
|
596
|
+
**F17 — Nil-vs-error, the question nobody has answered.** Relay's maintainers state it
|
|
597
|
+
best, in [#4416](https://github.com/facebook/relay/issues/4416): *"An ecosystem-wide
|
|
598
|
+
tradeoff (ecosystem-wide because no GraphQL client has addressed this before): discard
|
|
599
|
+
queries with errors, or not be able to discern whether a null is error or not."*
|
|
600
|
+
Nine years, 64 reactions on the original issue.
|
|
601
|
+
|
|
602
|
+
graph_weaver is **one step away**: `Response#errors_at(path)`/`#report` already carry
|
|
603
|
+
path-indexed errors with entity ids. The missing piece is going from a struct instance
|
|
604
|
+
back to its path — `from_h` already knows the path as it descends. Start with a
|
|
605
|
+
documented `Response#null_because_of_error?(path)` helper; even the string-based version,
|
|
606
|
+
*named and documented*, is ahead of the field. *Effort: M for the honest version.*
|
|
607
|
+
|
|
608
|
+
**F18 — Persisted-query manifest.** graph_weaver knows every operation at build time,
|
|
609
|
+
which is exactly what makes a manifest possible — and **nothing in Ruby generates one
|
|
610
|
+
from client documents** (graphql-ruby's OperationStore is GraphQL-Pro at $1,100/yr and
|
|
611
|
+
server-side; the free gem is server-side too). **Design note: three manifest formats have
|
|
612
|
+
converged** — Apollo's, Relay's, and graphql-codegen's — and GraphQL Hive accepts all
|
|
613
|
+
three, while `rover persisted-queries publish` takes `--manifest-format apollo|relay`.
|
|
614
|
+
**Emit one of those; do not invent a fourth.** The APQ runtime half fits the duck-typed
|
|
615
|
+
slot exactly as `Retry` does — a client wrapping a client, `lib/graph_weaver/persisted.rb`
|
|
616
|
+
beside `retry.rb`. Note APQ grants **zero** safelisting (Apollo files it under
|
|
617
|
+
*performance*); safelisting requires `apq: {enabled: false}`. *Effort: M.*
|
|
618
|
+
|
|
619
|
+
**F19 — `graphql.config.yml` in the docs.** Five lines of YAML, zero gem code:
|
|
620
|
+
|
|
621
|
+
```yaml
|
|
622
|
+
schema: app/graphql/schema.json
|
|
623
|
+
documents: app/graphql/queries/**/*.graphql
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
[vscode-graphql](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql)
|
|
627
|
+
(2.8M installs) **requires** a graphql-config file; the JetBrains plugin (6.1M downloads)
|
|
628
|
+
reads the same one; graphql-config supports introspection JSON directly. This buys
|
|
629
|
+
validation-as-you-type, field/argument autocomplete, go-to-definition into the schema and
|
|
630
|
+
hover docs — for a Ruby repo, with no JS project. It also makes `graphql-inspector
|
|
631
|
+
validate` and `@graphql-eslint` available over the same globs in CI. Ruby developers
|
|
632
|
+
simply don't know this works. *Effort: an hour. Best value-per-line in this document.*
|
|
633
|
+
|
|
634
|
+
**F20 — Per-operation codegen knobs.** Every graph_weaver knob is schema-wide (global or
|
|
635
|
+
client-scoped); there is no per-query escape hatch at all. genqlient's `# @genqlient`
|
|
636
|
+
directives are the model — and most of them graph_weaver already has or doesn't need
|
|
637
|
+
(`bind` ≈ `register_scalar`, better; `for` ≈ the `Type.field` coordinate; `omitempty`/
|
|
638
|
+
`pointer` are Go nil workarounds). Genuinely missing: **`typename`** (name the generated
|
|
639
|
+
type for a field) and **`alias`** (a Ruby prop name *without* a GraphQL alias that changes
|
|
640
|
+
the wire query). `examples/github/generated/stargazers_query.rb` is six levels deep with
|
|
641
|
+
two distinct structs both wanting to be `Repository`; today the only rename lever changes
|
|
642
|
+
what you send. *Effort: M. Papercut relief, not new capability.*
|
|
643
|
+
|
|
644
|
+
**F21 — Smaller items.** A `rake graph_weaver:init URL=… AUTH=…` (the only manual step in
|
|
645
|
+
an otherwise copy-paste setup is "open a Rails console and run
|
|
646
|
+
`GraphWeaver.new(url, cache: true).schema`"). A shipped CLI (`bin/generate` is fixture
|
|
647
|
+
tooling — long-standing PLAN item). Parse/execute memoization (~3× cost re-generating per
|
|
648
|
+
call — PLAN item). Shared input structs across modules (one Hasura `bool_exp` drags ~28k
|
|
649
|
+
lines into *every* module — PLAN item). Structured logging payload
|
|
650
|
+
(`{event:, url:, ms:}` + a scrub hook — PLAN item). An unused-selection lint
|
|
651
|
+
(`rake graph_weaver:unused`) — with checked-in structs and Sorbet, "this prop is never
|
|
652
|
+
read" is statically answerable in Ruby in a way it isn't in most ecosystems, recovering
|
|
653
|
+
the one real benefit graphql-client's data masking bought without its runtime error.
|
|
654
|
+
|
|
655
|
+
---
|
|
656
|
+
|
|
657
|
+
## 5. Competitive position
|
|
658
|
+
|
|
659
|
+
*All external figures checked 2026-09-05.*
|
|
660
|
+
|
|
661
|
+
**The core claim holds.** No maintained Ruby gem offers per-query static types. Three
|
|
662
|
+
independent checks agree: graphql-client's
|
|
663
|
+
[Tapioca compiler PR #7](https://github.com/github-community-projects/graphql-client/pull/7)
|
|
664
|
+
has been stalled since 2024-11 *and* targets schema-wide RBIs rather than per-operation
|
|
665
|
+
result shapes anyway; a GitHub search for Ruby + GraphQL + Sorbet returns exactly two
|
|
666
|
+
repos; the other, `yogurt`, last released 2020-11-26 at 3 stars. State it with the
|
|
667
|
+
caveat that makes it honest: **this idea was tried once and died** — though yogurt's own
|
|
668
|
+
README concedes it lacked named fragments and that the author "probably got a lot of the
|
|
669
|
+
decisions wrong", so it isn't a clean verdict on the thesis.
|
|
670
|
+
|
|
671
|
+
**The market read is the most important correction this review produced.**
|
|
672
|
+
graphql-client's 94.2M downloads looks like an entrenched incumbent. It isn't:
|
|
673
|
+
|
|
674
|
+
- **Shopify removed it.** Gemspec diff, v9.5.1 vs current `main`: v9.5.1 declared
|
|
675
|
+
`graphql-client`; current declares `httparty`, `oj`, `sorbet-runtime` — and no
|
|
676
|
+
`graphql` or `graphql-client` at all.
|
|
677
|
+
[BREAKING_CHANGES_FOR_V10.md](https://github.com/Shopify/shopify-api-ruby/blob/main/BREAKING_CHANGES_FOR_V10.md)
|
|
678
|
+
names the reason: *"There is no need to dump the schema to a local JSON file before
|
|
679
|
+
using it anymore."* The schema-dump requirement — graphql-client's whole validation
|
|
680
|
+
model — was a stated motivation for leaving.
|
|
681
|
+
- **A CI bot is a large share of what remains.** `gitlab-triage` alone is 24.1M downloads,
|
|
682
|
+
reinstalled every pipeline run. graphlient's 32.3M is a wrapper, not an independent
|
|
683
|
+
choice.
|
|
684
|
+
- **Deliberate adoption today:** graphlient ~1,894/day, artemis ~173/day.
|
|
685
|
+
|
|
686
|
+
So the frame is **not "a big market with a weak incumbent"** but **"a small, quiet market
|
|
687
|
+
where nobody is defending the position"** — 136 Stack Overflow questions tagged
|
|
688
|
+
`graphql-ruby` against 20,775 for `graphql` (~0.65%), and zero Reddit or HN threads
|
|
689
|
+
discussing Ruby GraphQL client choice. Sentiment isn't negative; the topic doesn't
|
|
690
|
+
register.
|
|
691
|
+
|
|
692
|
+
**Strategic implication:** favour cheap, high-leverage moves over long builds. Winning an
|
|
693
|
+
undefended position is mostly a distribution problem, and the payoff for a six-month
|
|
694
|
+
feature programme is capped by a demand ceiling no feature will lift. F19 (an hour), F15
|
|
695
|
+
(a day), the doc fixes in §3, F4 (an hour) — then spend the reclaimed time on a
|
|
696
|
+
comparison page and a post aimed at Sorbet shops.
|
|
697
|
+
|
|
698
|
+
**The best pitch line available:** Shopify's current SDK depends on `sorbet-runtime` and
|
|
699
|
+
still returns GraphQL responses as `Hash{String, Untyped}`. A Sorbet shop, shipping a
|
|
700
|
+
Sorbet-typed SDK, with untyped GraphQL — the gap drawn by the biggest vendor in the space.
|
|
701
|
+
|
|
702
|
+
### Where graph_weaver wins
|
|
703
|
+
Per-query static types (uncontested). Custom scalar deserialization — graphql-client has
|
|
704
|
+
[an open issue](https://github.com/github-community-projects/graphql-client/issues/17)
|
|
705
|
+
since 2024-02 whose workaround is monkey-patching `GraphQL::Schema::BUILT_IN_TYPES`, posted
|
|
706
|
+
with a 🤢. Federation (graphql-client has an open
|
|
707
|
+
[federated-router crash](https://github.com/github-community-projects/graphql-client/issues/78)).
|
|
708
|
+
The error model — graphql-client currently carries
|
|
709
|
+
[#67 "Network errors are discarded"](https://github.com/github-community-projects/graphql-client/issues/67)
|
|
710
|
+
(a 403 surfacing as `KeyError: key not found: "data"`) and
|
|
711
|
+
[#75 "Errors not populating correctly"](https://github.com/github-community-projects/graphql-client/issues/75).
|
|
712
|
+
Fragments for plain reuse — graphql-client forces Relay-style data masking and users file
|
|
713
|
+
[#76](https://github.com/github-community-projects/graphql-client/issues/76) asking to
|
|
714
|
+
escape it. Testing. And `verify_generated!`, which genqlient — its closest peer — lacks.
|
|
715
|
+
|
|
716
|
+
### Where a competitor is better
|
|
717
|
+
**Institutional safety**: graphql-client is GitHub's with a decade of production use, and
|
|
718
|
+
"no static types" is a cost many teams accept over "4 stars, one author, two months old".
|
|
719
|
+
**graphlient is quietly the healthiest Ruby client** — 0.9.0 shipped 2026-08-02, more
|
|
720
|
+
recent than graphql-client's last release — and for "call this API, don't make me think"
|
|
721
|
+
it's the right answer while graph_weaver is over-engineered for the job.
|
|
722
|
+
**Concurrency** (F5). **Checklist breadth** — no subscriptions, `@defer`, uploads,
|
|
723
|
+
batching, persisted queries.
|
|
724
|
+
|
|
725
|
+
### The honest weakest point
|
|
726
|
+
**Bus factor against surface area.** ~5,500 lines, 844 in `codegen.rb` alone, one author.
|
|
727
|
+
Codegen is unforgiving: the v0.4.6 changelog is a list of edge cases where generated code
|
|
728
|
+
raised `NameError` at runtime, all caught in a single review sweep. That's healthy
|
|
729
|
+
diligence *and* a measure of how much surface there is to get wrong. `verify_generated!`
|
|
730
|
+
mitigates drift; nothing mitigates the maintainer.
|
|
731
|
+
|
|
732
|
+
**Second:** the Sorbet bet has a horizon. Sorbet remains dominant, but the direction of
|
|
733
|
+
travel is toward RBS, and Sorbet's own RBS comment support is experimental **and does not
|
|
734
|
+
do runtime type checking** — precisely the property generated `sig`s depend on. Fine
|
|
735
|
+
near-term; worth keeping the emission format pluggable rather than assuming `sig {}`
|
|
736
|
+
forever.
|
|
737
|
+
|
|
738
|
+
### What the field says graph_weaver already got right
|
|
739
|
+
Four of the seven structural problems of this category, all under-advertised:
|
|
740
|
+
|
|
741
|
+
- **Testing** is under-served *everywhere* — genqlient [#108](https://github.com/Khan/genqlient/issues/108)
|
|
742
|
+
open 5 years (wandb wrote a whole `gqlmock` package themselves), Apollo Kotlin's
|
|
743
|
+
[#6076 MegaIssue: testing utilities](https://github.com/apollographql/apollo-kotlin/issues/6076),
|
|
744
|
+
a gql.tada RFC at 11 reactions, a 22-comment confusion thread at graphql-codegen. The
|
|
745
|
+
mechanism is always identical: generation makes result types *precise*, which makes them
|
|
746
|
+
*expensive to construct by hand*, and nobody ships the fabricator. graph_weaver ships
|
|
747
|
+
the most complete answer of anything surveyed — and it's one README bullet.
|
|
748
|
+
- **Schema lifecycle**: genqlient's **#1 open issue by reactions (22)** is
|
|
749
|
+
[remote-schema config](https://github.com/Khan/genqlient/issues/207), whose reporter
|
|
750
|
+
describes hand-building the exact CI job that `cache: true` +
|
|
751
|
+
`rake graph_weaver:schema:refresh`/`:verify` already is.
|
|
752
|
+
- **Error handling**: the "wrapper destroys information" failure recurs in every
|
|
753
|
+
ecosystem, and real Ruby and Go code hand-rolls a 40-line error layer that
|
|
754
|
+
`Response` + `#report` + `#to_h` deletes.
|
|
755
|
+
- **Determinism**: graphql-codegen has four separate issues about non-deterministic
|
|
756
|
+
output ([#5106](https://github.com/dotansimha/graphql-code-generator/issues/5106),
|
|
757
|
+
14 reactions, and friends). graph_weaver sorts throughout and generation is
|
|
758
|
+
byte-identical across runs — but this is **not stated as a guarantee anywhere**. It
|
|
759
|
+
should be, with a spec asserting it. (Also: `verify_generated!` does exact
|
|
760
|
+
`File.read == source`, so a CRLF checkout reproduces graphql-codegen's
|
|
761
|
+
[#10309](https://github.com/dotansimha/graphql-code-generator/issues/10309) —
|
|
762
|
+
normalize line endings.)
|
|
763
|
+
|
|
764
|
+
**The competitive story is not "typed structs" — everyone has those. It's "typed structs
|
|
765
|
+
*and* you can test them *and* the schema keeps itself honest."**
|
|
766
|
+
|
|
767
|
+
---
|
|
768
|
+
|
|
769
|
+
## 6. Performance and maintainability
|
|
770
|
+
|
|
771
|
+
Measured on Apple Silicon, Ruby 3.4, laptop with other things running; ratios trustworthy,
|
|
772
|
+
absolute ms ±10–20%.
|
|
773
|
+
|
|
774
|
+
**Codegen is linear and a non-issue.** ~9–11 µs per field selection, flat across a 16×
|
|
775
|
+
range in width and 13× in depth; fragment spreads likewise linear. Real-world: 0.02s for
|
|
776
|
+
the pathological 278-type case in B3. **No O(n²) anywhere.** Any refactor of the AST walk
|
|
777
|
+
can be judged purely on readability.
|
|
778
|
+
|
|
779
|
+
**The hot runtime path is proportionate — and the brief's premise was wrong.**
|
|
780
|
+
`from_h` costs ~1.8 µs/struct, linear across three orders of magnitude. Where it goes:
|
|
781
|
+
|
|
782
|
+
- **58% garbage collection** (sweeping + marking), driven by ~6 objects and 2.3 hashes
|
|
783
|
+
per struct — inherent to `T::Struct` keyword construction.
|
|
784
|
+
- **Sorbet sig checking is only ~6%**, not the bottleneck. Disabling runtime sig checks
|
|
785
|
+
entirely saved 6%. The real Sorbet cost is `T::Props`' per-prop setter validation,
|
|
786
|
+
which that flag doesn't govern — and even that is ~0.83 µs of the 1.80. A plain
|
|
787
|
+
`Struct` is 0.29 µs, so **`T::Struct` costs ~0.54 µs/struct over plain — that is the
|
|
788
|
+
price of the product, and it's the right price.**
|
|
789
|
+
- **`Date.iso8601` is 25% of `from_h`** on a query with one date per three structs — the
|
|
790
|
+
largest *addressable* slice, and it's a docs fix: `register_scalar` with an explicit
|
|
791
|
+
cheaper `cast:` beats the inferred regexp-based `Date.iso8601`.
|
|
792
|
+
|
|
793
|
+
End to end over localhost, 600 structs: casting is 81% of 1.32 ms — which says "casting
|
|
794
|
+
adds ~1.1 ms of CPU", not "the gem is slow". Against a real 30 ms API call it's ~3%.
|
|
795
|
+
**Recommendation: do nothing** for typical queries; it matters only for a 10,000-row
|
|
796
|
+
report (55 ms) or a 50,000-row export (284 ms).
|
|
797
|
+
|
|
798
|
+
**Schema loading is the one Rails number worth knowing.** GitHub's 2.87 MB dump:
|
|
799
|
+
`SchemaLoader.load(.json)` **173 ms**; the same schema as SDL **374 ms** (2.2×). A cached
|
|
800
|
+
schema is fully re-parsed every boot — the cache saves the round trip, not the parse. But
|
|
801
|
+
the production path pays **0 ms**: the railtie only `require`s generated `.rb` files and
|
|
802
|
+
`client.schema` is lazy. You pay it in dev consoles and CI `verify_generated!`. Worth
|
|
803
|
+
documenting, not worth engineering around.
|
|
804
|
+
|
|
805
|
+
**Memory is clean** — 2 heap slots retained after 10× a 15,000-struct cast. **Per-request
|
|
806
|
+
allocation is 9 objects / 1.1 µs** with nothing to hoist; the `rescue` splat is lazily
|
|
807
|
+
evaluated (0 calls on 1000 successes). One real waste — `log_tag` runs a regex over the
|
|
808
|
+
whole query whenever a logger merely *exists*, costing +1.32 µs/request at `:info` for a
|
|
809
|
+
tag nothing prints — is **0.004% of a 30 ms call and not worth a commit.**
|
|
810
|
+
|
|
811
|
+
### Maintainability
|
|
812
|
+
The decomposition is principled; don't undo it. `nodes.rb` is genuinely good — a new leaf
|
|
813
|
+
kind is a new class implementing five methods and `emit.rb` doesn't change.
|
|
814
|
+
|
|
815
|
+
**Load-bearing complexity, leave alone:** `object_node`'s five-way kind dispatch (all four
|
|
816
|
+
abstract-type branches draw names from the same `taken` pool and the ordering between them
|
|
817
|
+
is semantic — splitting it turns one readable decision table into four coupled files); the
|
|
818
|
+
string-append emitter (you can grep a line of *generated output* and land on the line of
|
|
819
|
+
`emit.rb` that produced it — for a code generator that beats elegance); `Emit` reading
|
|
820
|
+
eight ivars off the host (all "state of one generation run"; revisit only with a second
|
|
821
|
+
emission target).
|
|
822
|
+
|
|
823
|
+
**Worth doing:**
|
|
824
|
+
- **R1 — extract `codegen/aliases.rb`** (~130 lines). The alias subsystem has its own
|
|
825
|
+
vocabulary and touches the rest through exactly one seam (`node.aliases =
|
|
826
|
+
resolve_aliases(node)`). Same extraction already made for scalars and enums, so it's
|
|
827
|
+
consistent rather than novel. *1–2 h, mechanical, spec-covered.*
|
|
828
|
+
- **R2 — `build_variables(operation)`** (~25 lines out of `generate`'s 72), leaving
|
|
829
|
+
`generate` reading as a seven-line pipeline. *30 min.*
|
|
830
|
+
- **R3 — a `QueryModule` mixin** for the identical ~15-line untyped `@client` plumbing
|
|
831
|
+
repeated in every generated file. Precedent and rationale already in
|
|
832
|
+
`input_struct.rb:11-16`. **Scope limit:** `execute`/`from_response` must stay generated —
|
|
833
|
+
their sigs *are* the product. *~0.5 day; regenerates every fixture.*
|
|
834
|
+
|
|
835
|
+
**Explicitly churn:** rewriting the emitter as templates/AST; strategy objects for
|
|
836
|
+
`object_node`; promoting codegen to `# typed: strict` (CLAUDE.md forbids it and the
|
|
837
|
+
measurement backs the policy); splitting `emit.rb` further.
|
|
838
|
+
|
|
839
|
+
---
|
|
840
|
+
|
|
841
|
+
## 7. Explicit non-goals
|
|
842
|
+
|
|
843
|
+
Worth recording as decisions rather than omissions.
|
|
844
|
+
|
|
845
|
+
- **`@defer`/`@stream`.** The spec ratified a
|
|
846
|
+
[September 2025 edition](https://spec.graphql.org/September2025/) — its first since 2021
|
|
847
|
+
— and incremental delivery **was not in it**; it's on its third or fourth attempt (PRs
|
|
848
|
+
#742 and #1034 closed as superseded, #1110 still Stage 2 Draft after six years). Apollo
|
|
849
|
+
Client 4.1 ships **two mutually incompatible handlers** for two wire formats. And in
|
|
850
|
+
Ruby, `@defer` is GraphQL-Pro only at $1,100/yr, so a Ruby client would rarely meet a
|
|
851
|
+
Ruby server that supports it. Implementing this in 2026 means betting on one of two
|
|
852
|
+
unratified formats. Failing cleanly — which it does — is the right level of support.
|
|
853
|
+
- **Subscriptions.** A persistent duplex transport, a different response lifecycle, and an
|
|
854
|
+
execution model a request-scoped Rails process doesn't have. Unchecked on
|
|
855
|
+
graphql-client's 1.0 TODO since 2024 with essentially no user pressure. Rejecting at
|
|
856
|
+
*generation* time, as today, is the best possible failure. Make it a **stated non-goal**
|
|
857
|
+
so it reads as a decision, not an omission.
|
|
858
|
+
- **File uploads.** Apollo removed built-in support in Server 3.0 on CSRF grounds
|
|
859
|
+
(`multipart/form-data` POSTs without a preflight); the spec repo has been frozen since
|
|
860
|
+
2025-03.
|
|
861
|
+
- **Normalized caching.** Apollo's `InMemoryCache`, urql's Graphcache and Relay's store all
|
|
862
|
+
solve *component-graph consistency* — several components rendering the same entity, a
|
|
863
|
+
mutation updating it, all re-rendering without a refetch. A Ruby backend has no component
|
|
864
|
+
tree, no long-lived client-side store, and usually a request-scoped process. You'd import
|
|
865
|
+
normalization, GC, cache policies and a class of staleness bugs to solve a problem the
|
|
866
|
+
deployment shape doesn't have. `Rails.cache` around `execute` is the right size. *(Marked
|
|
867
|
+
as inference: no authoritative source states this directly.)*
|
|
868
|
+
- **Fragment masking.** The evidence is unusually direct: graphql-client **already has it**
|
|
869
|
+
and users file issues asking to escape it. Masking enforces component data-colocation in
|
|
870
|
+
a component UI framework; graph_weaver's callers are services and jobs. graph_weaver's
|
|
871
|
+
shared fragments are the feature graphql-client users are asking *for* — adding masking
|
|
872
|
+
would convert an advantage into their complaint. **The mistake to avoid is being talked
|
|
873
|
+
into it.**
|
|
874
|
+
- **Watch mode.** graphql-codegen's watch complaints exist because JS builds are slow and
|
|
875
|
+
the loop is long. graph_weaver generated the pathological case in 0.021s, and
|
|
876
|
+
`client.parse`/`load_queries!` already provide a no-build-step dev mode — the gql.tada
|
|
877
|
+
escape hatch, from inside a codegen tool. Say that in the docs and move on.
|
|
878
|
+
- **Batching / multiplex.** Doesn't fit the one-query-per-module design; would need a new
|
|
879
|
+
`Batch` object. Skip unless demand appears.
|
|
880
|
+
- **`near-operation-file` layout.** Colocation is a *component-tree* concern that pays in
|
|
881
|
+
React. In Rails, queries live in one directory and the flat layout is correct.
|
|
882
|
+
- **Formalizing the client slot as a Sorbet interface.** Already in `CLAUDE.md`; the
|
|
883
|
+
research supports it. A graphql-ruby `Schema` class satisfies the contract without
|
|
884
|
+
inheriting anything, and that's what makes `FakeClient`, `Failure.*`, `Sequence` and
|
|
885
|
+
`Cassette` compose. `Retry` is a client wrapping a client; so should an APQ decorator be.
|
|
886
|
+
- **A "replace a composite's deserializer" path**, even though genqlient's `bind` can bind
|
|
887
|
+
composite types. It can because a Go struct's shape is fixed by its type; a composite's
|
|
888
|
+
shape here varies per query, so binding one is correct for exactly one selection.
|
|
889
|
+
- **Generating the whole schema.** genql is the cautionary example — artifact size scales
|
|
890
|
+
with schema, not query count; its prebuilt SDKs run to 19.7 MB. Query-driven codegen is
|
|
891
|
+
why a supergraph's `join__*` types emit nothing.
|
|
892
|
+
|
|
893
|
+
---
|
|
894
|
+
|
|
895
|
+
## 8. Recommended order
|
|
896
|
+
|
|
897
|
+
**Tier 1 — correctness, do first.**
|
|
898
|
+
B1 (silent corruption) · B4, B5 (federation loader) · B2 (documented behaviour absent) ·
|
|
899
|
+
B6, B7 (enums) · B8, B9 (silent no-ops) · B14 (dead spec) · the `docs/testing.md` and
|
|
900
|
+
`README.md:84` fixes in §3.
|
|
901
|
+
|
|
902
|
+
**Tier 2 — the measured wins.**
|
|
903
|
+
B3 (5,386 → tens of lines; also largely subsumes F12) · F5 (connection pool, ~4×) ·
|
|
904
|
+
F4 (`Accept` + `User-Agent`, an hour; then `operationName`) · F6 (Faraday timeouts — a
|
|
905
|
+
missing timeout is an outage) · F7 (`InProcess` wrapper: context, logging, branded errors).
|
|
906
|
+
|
|
907
|
+
**Tier 3 — cheap leverage, disproportionate payoff.**
|
|
908
|
+
F19 (`graphql.config.yml`, an hour) · F15 (`schema:check`, a day) · F3, F10, B15 items ·
|
|
909
|
+
the error-message and docs work in §3 · state determinism as a guarantee and normalize
|
|
910
|
+
line endings in `verify_generated!` · reposition testing and schema-lifecycle as headline
|
|
911
|
+
features rather than single bullets.
|
|
912
|
+
|
|
913
|
+
**Tier 4 — real projects, decide deliberately.**
|
|
914
|
+
F1 (`@link` namespaces) · F13 (object-fragment hoisting) · F2 (`_entities`
|
|
915
|
+
representations) · F8/F9 (response metadata, instrumentation) · F16 (`@semanticNonNull`) ·
|
|
916
|
+
F17 (nil-vs-error) · F18 (persisted manifest) · F14 (pagination — read its warning first) ·
|
|
917
|
+
R1–R3.
|
|
918
|
+
|
|
919
|
+
---
|
|
920
|
+
|
|
921
|
+
## 9. Method and provenance
|
|
922
|
+
|
|
923
|
+
Six parallel Opus agents, each required to prove claims by execution rather than
|
|
924
|
+
inspection, working read-only against a green baseline (308 examples) with scratch probes
|
|
925
|
+
outside the repo. The tree was verified byte-identical after every agent.
|
|
926
|
+
|
|
927
|
+
Findings marked **✓verified** were independently re-run by the coordinating session:
|
|
928
|
+
B1 (the mis-typed `Pet`), B3 (5,386 lines / 275 vacuous structs — the agent reported 278;
|
|
929
|
+
the precise count is 275 vacuous, 2 real, 1 incidental), B4 (the braceless `schema @foo`
|
|
930
|
+
reprint), B6 (the enum round-trip `TypeError`), B8, B9, B14, and F5's mutex scope.
|
|
931
|
+
|
|
932
|
+
Two findings were reached **independently by two agents from opposite directions** — B6
|
|
933
|
+
(from reading the emitter; from a live mutation round-trip) and the missing `Accept`
|
|
934
|
+
header (from a raw-socket wire capture; from reading both transports). Independent
|
|
935
|
+
corroboration raises confidence materially.
|
|
936
|
+
|
|
937
|
+
Calibration notes worth keeping: the perf agent **disproved the brief's premise** that
|
|
938
|
+
Sorbet sig checking dominates casting cost (it's 6%), and recommended *doing nothing* on
|
|
939
|
+
the hot path. The federation agent separated defects that Apollo's composition rules make
|
|
940
|
+
**unreachable** from ones that hit real users, and confirmed the `@inaccessible` cascade —
|
|
941
|
+
the thing most expected to break — is correct. The competitive agent argued **against its
|
|
942
|
+
own earlier draft** on market size after checking Shopify's gemspec directly. Reports that
|
|
943
|
+
only confirm the hypothesis they were given are worth less than these were.
|
|
944
|
+
|
|
945
|
+
External figures checked 2026-09-05; every external claim in §5 and §7 carries a citation
|
|
946
|
+
in the source reports.
|