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/REVIEW.md DELETED
@@ -1,946 +0,0 @@
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.