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/CLAUDE.md
CHANGED
|
@@ -8,6 +8,50 @@ A typed GraphQL client for Ruby: generates `# typed: strict` Ruby (nested
|
|
|
8
8
|
`T::Struct`s + a typed `execute`) from your queries, checked against the schema
|
|
9
9
|
at generation time. Sorbet is core to the product.
|
|
10
10
|
|
|
11
|
+
## Design principle — correct and simple, in that order
|
|
12
|
+
|
|
13
|
+
Adoption follows delight, and delight follows from a tool that solves the real
|
|
14
|
+
problem without making you think. So the bar for any change is: is it correct,
|
|
15
|
+
and is it the *simplest* thing that is correct? Complexity is not neutral — it
|
|
16
|
+
is confusion, bugs, and frustration, paid for by every future reader and user.
|
|
17
|
+
|
|
18
|
+
What this means when choosing between designs:
|
|
19
|
+
|
|
20
|
+
- **One rule beats a rule with exceptions.** A behavior you can state in a
|
|
21
|
+
sentence, and predict without reading the source, is worth more than one that
|
|
22
|
+
is marginally more capable. Generated class names come from the response key —
|
|
23
|
+
full stop — rather than from the type name with disambiguation-on-collision,
|
|
24
|
+
because the second rule can't be stated without describing its own edge cases.
|
|
25
|
+
- **Prefer removing a decision to adding a knob.** The transport default got
|
|
26
|
+
*better* by deleting auto-detection: one less thing to know, one less way to be
|
|
27
|
+
surprised, and the fast path became the default. Reach for a config option only
|
|
28
|
+
after the simple default has actually failed someone.
|
|
29
|
+
- **A convention can beat a capability.** One file holds one operation. That's a
|
|
30
|
+
convention, and it makes module naming derivable from the filename; supporting
|
|
31
|
+
multiple operations per file would be more capable and worse.
|
|
32
|
+
- **No spooky action at a distance.** Behavior should follow from the code in
|
|
33
|
+
front of you, not from what else is in the Gemfile, what ran first, or which
|
|
34
|
+
selection the walk happened to reach earlier.
|
|
35
|
+
- **Match the ecosystem's conventions** where one exists — a familiar shape costs
|
|
36
|
+
the user zero learning, which is the cheapest simplicity available.
|
|
37
|
+
- **Errors are part of the interface.** A good message names what went wrong,
|
|
38
|
+
where, and what to do about it. `optional: true` in the message beats the same
|
|
39
|
+
advice buried in docs. The best bug fix often makes an error impossible; the
|
|
40
|
+
next best makes it self-explanatory.
|
|
41
|
+
- **Refuse rather than guess.** When intent is ambiguous, fail loudly at
|
|
42
|
+
generation time. A silent wrong answer is the most expensive outcome this
|
|
43
|
+
library can produce, because the generated code looks authoritative.
|
|
44
|
+
|
|
45
|
+
When simplicity and capability genuinely conflict, say so out loud and pick
|
|
46
|
+
deliberately — but the default is simple.
|
|
47
|
+
|
|
48
|
+
**Docs are a complexity detector.** A section that belabors a topic is usually
|
|
49
|
+
not a writing problem — it is the library pushing complexity onto the reader.
|
|
50
|
+
Before expanding an explanation, ask whether the thing being explained should
|
|
51
|
+
exist. If two paragraphs are needed to say which of two ways applies, delete one
|
|
52
|
+
of the ways; the docs then shrink on their own, which is the signal the cut was
|
|
53
|
+
right.
|
|
54
|
+
|
|
11
55
|
## Sorbet typing policy — type by value, not for coverage
|
|
12
56
|
|
|
13
57
|
Sorbet being core does **not** mean every file should be `# typed: strict`. Type
|
|
@@ -20,10 +64,11 @@ where it pays off in developer experience; leave the rest at `# typed: true`.
|
|
|
20
64
|
the product.
|
|
21
65
|
- **`# typed: true` (loose) — dynamic / boundary internals.** The codegen
|
|
22
66
|
(`codegen.rb`, `codegen/nodes.rb`, `codegen/emit.rb`, `codegen/scalar_type.rb`,
|
|
23
|
-
`codegen/enum_type.rb`) walks graphql-ruby's
|
|
24
|
-
modules/strings dynamically; `client.rb`
|
|
25
|
-
duck-typed transport. Strict here is ~all
|
|
26
|
-
shape without catching anything.
|
|
67
|
+
`codegen/enum_type.rb`, `codegen/type_helpers.rb`) walks graphql-ruby's
|
|
68
|
+
approximately-typed AST and builds modules/strings dynamically; `client.rb`
|
|
69
|
+
wraps a graphql-ruby schema and a duck-typed transport. Strict here is ~all
|
|
70
|
+
`T.untyped` — paperwork that documents shape without catching anything.
|
|
71
|
+
**Don't promote these to strict.**
|
|
27
72
|
- Rule of thumb: if a sig would be mostly `T.untyped`, it isn't worth writing.
|
|
28
73
|
Concrete types = value; `T.untyped` sigs = paperwork.
|
|
29
74
|
- `railtie.rb` / `tasks.rb` are `# typed: ignore` (Rails/Rake DSL).
|
|
@@ -48,15 +93,62 @@ where it pays off in developer experience; leave the rest at `# typed: true`.
|
|
|
48
93
|
|
|
49
94
|
## Green before commit
|
|
50
95
|
|
|
96
|
+
`bundle exec` needs the rvm ruby — the default PATH ruby is 2.6 and can't even
|
|
97
|
+
load bundler:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
source ~/.rvm/scripts/rvm && rvm use 3.4.9
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Then:
|
|
104
|
+
|
|
51
105
|
```sh
|
|
52
|
-
bundle exec rspec
|
|
53
|
-
bundle exec srb tc
|
|
106
|
+
bundle exec rspec # full suite
|
|
107
|
+
bundle exec srb tc # Sorbet typecheck (CI gates on this too)
|
|
108
|
+
bundle exec ruby bin/generate # regenerate fixtures — must leave the tree clean
|
|
54
109
|
```
|
|
55
110
|
|
|
56
|
-
|
|
57
|
-
sig surfaces as an rspec failure, not only a `srb tc` error — a green suite
|
|
111
|
+
All three must pass. Sorbet sigs are runtime-checked by sorbet-runtime, so a
|
|
112
|
+
wrong sig surfaces as an rspec failure, not only a `srb tc` error — a green suite
|
|
58
113
|
validates the sigs against real usage.
|
|
59
114
|
|
|
115
|
+
Two more when the change could reach them:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
bundle exec rspec --order rand:1 # and a couple of other seeds
|
|
119
|
+
bundle exec ruby bin/federation-diff # the fixture supergraph still composes
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Order-independence is worth checking rather than assuming — four order-dependent
|
|
123
|
+
failures have hidden behind the default `:defined` order, and a *burst* of them
|
|
124
|
+
usually means one shared resource cascading rather than many bugs.
|
|
125
|
+
|
|
126
|
+
## Drive it from a throwaway app when the host seam changes
|
|
127
|
+
|
|
128
|
+
**The suite cannot test the gem's relationship with its host.** It is not a Rails
|
|
129
|
+
app, so anything that depends on Rails' boot order, Zeitwerk, or rake's task
|
|
130
|
+
graph is structurally invisible to it — and both bugs found that way were silent
|
|
131
|
+
in development and only appeared in production boot or when registrations
|
|
132
|
+
mattered:
|
|
133
|
+
|
|
134
|
+
- `rake graph_weaver:generate` never ran `:environment`, because the task asked
|
|
135
|
+
`Rake::Task.task_defined?("environment")` at *load* time and Rails defines it
|
|
136
|
+
*after* railties' `rake_tasks` blocks. Every `register_scalar`/`extend_type` in
|
|
137
|
+
an initializer was silently dropped at generation, and `verify` reported the
|
|
138
|
+
result up to date.
|
|
139
|
+
- `app/graphql/generated` sits under a Zeitwerk root while its files define
|
|
140
|
+
top-level constants, so eager loading raised `NameError`. Lazy dev boot was
|
|
141
|
+
fine; production was not.
|
|
142
|
+
|
|
143
|
+
So when the railtie, the rake tasks, the generator, or the documented install
|
|
144
|
+
path changes, spin up a scratch Rails app outside the repo, point its Gemfile at
|
|
145
|
+
your checkout, and actually use it. Worth exercising: **both** a remote endpoint
|
|
146
|
+
and the app's own graphql-ruby schema in-process; `RAILS_ENV=production` boot and
|
|
147
|
+
`rails zeitwerk:check`; a registration in `config/initializers` that must reach
|
|
148
|
+
generated output; the rake tasks end to end; and the testing harness from inside
|
|
149
|
+
the app's own specs. The app is disposable — rebuilding it is cheaper than the
|
|
150
|
+
bugs it catches.
|
|
151
|
+
|
|
60
152
|
## Version bumps
|
|
61
153
|
|
|
62
154
|
Bump `lib/graph_weaver/version.rb` and, in the **same commit**:
|
data/DECISIONS.md
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# Decisions
|
|
2
|
+
|
|
3
|
+
Roads not taken, and why. The code shows what was chosen; it is silent about
|
|
4
|
+
what was considered and rejected — and those are the ones that get re-litigated,
|
|
5
|
+
usually by someone with the same good instinct that was already followed to its
|
|
6
|
+
end once.
|
|
7
|
+
|
|
8
|
+
Only entries where the rejected path is *tempting* belong here. An obvious call
|
|
9
|
+
needs no record. What changed and when lives in `CHANGELOG.md`; the invariants
|
|
10
|
+
and principles live in `CLAUDE.md`; stated non-goals (subscriptions, `@defer`,
|
|
11
|
+
uploads, normalized caching, fragment masking, batching) live in `REVIEW.md` §7.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## The client slot stays duck-typed
|
|
16
|
+
|
|
17
|
+
**Considered:** formalizing `execute(query, variables:, operation_name:)` as a
|
|
18
|
+
Sorbet interface or a base class, so the thing every client implements is
|
|
19
|
+
declared rather than implied.
|
|
20
|
+
|
|
21
|
+
**Rejected because** a live graphql-ruby `Schema` class satisfies the slot and
|
|
22
|
+
cannot inherit from us. An interface would exclude the one implementation we
|
|
23
|
+
neither wrote nor control — and that implementation is the whole in-process
|
|
24
|
+
story. The openness is the feature.
|
|
25
|
+
|
|
26
|
+
Downstream of this: `Parsing` requires `#schema` of its includers and can't
|
|
27
|
+
declare it, hence one `T.unsafe(self)` in that mixin.
|
|
28
|
+
|
|
29
|
+
## `InProcess` lives outside `Transport::`
|
|
30
|
+
|
|
31
|
+
**Considered:** moving it under `Transport::`, or restructuring so
|
|
32
|
+
`Transport::HTTP` parents a native and a Faraday implementation and `InProcess`
|
|
33
|
+
slots in beside them.
|
|
34
|
+
|
|
35
|
+
**Rejected because** `Transport` is a base class, not a namespace: it owns the
|
|
36
|
+
GraphQL-over-HTTP flow (encode the body, classify network failures, `ServerError`
|
|
37
|
+
on non-2xx, parse). `InProcess` implements none of that. And the restructure
|
|
38
|
+
wouldn't achieve its goal anyway — a bare `Schema` class is in the client slot
|
|
39
|
+
too, so `Transport::` still wouldn't mean "things you can pass as `transport:`".
|
|
40
|
+
The taxonomy can't be clean because the slot is deliberately open.
|
|
41
|
+
|
|
42
|
+
Renaming the abstract base (`Transport::Base`) to stop it sharing a name with its
|
|
43
|
+
namespace is the smaller, honest version if this ever itches again.
|
|
44
|
+
|
|
45
|
+
## `Transport::HTTP` is the default; Faraday is opt-in
|
|
46
|
+
|
|
47
|
+
**Considered:** keeping the previous `defined?(::Faraday)` auto-detection.
|
|
48
|
+
|
|
49
|
+
**Rejected because** Faraday arrives transitively through stripe, octokit and
|
|
50
|
+
friends, so adding an unrelated gem silently changed your transport, your
|
|
51
|
+
timeouts, and your connection reuse (Faraday's default adapter reconnects per
|
|
52
|
+
request; measured 10 connections for 10 requests versus 1). Behaviour that
|
|
53
|
+
depends on what else is in the Gemfile is unreasonable-about-able. Same code,
|
|
54
|
+
same transport.
|
|
55
|
+
|
|
56
|
+
## Cassettes live in `spec/cassettes`, not `spec/fixtures`
|
|
57
|
+
|
|
58
|
+
**Considered:** `spec/fixtures/`, or a namespaced `spec/fixtures/graph_weaver/`,
|
|
59
|
+
as the more conventional home for test data.
|
|
60
|
+
|
|
61
|
+
**Rejected because** Rails globs the fixture path for `{**,*}/*.yml`
|
|
62
|
+
(`active_record/test_fixtures.rb`) — recursively, so a subdirectory doesn't save
|
|
63
|
+
you. `fixtures :all` would try to load cassettes as ActiveRecord fixtures. The
|
|
64
|
+
conventional-looking choice is the broken one.
|
|
65
|
+
|
|
66
|
+
## Directories organize queries; they don't namespace modules
|
|
67
|
+
|
|
68
|
+
**Considered:** deriving module names from nested query directories, so
|
|
69
|
+
`admin/pets.graphql` becomes `AdminPetsQuery` instead of colliding with
|
|
70
|
+
`pets.graphql`.
|
|
71
|
+
|
|
72
|
+
**Rejected because** it *moves* collisions rather than removing them —
|
|
73
|
+
`admin/pets.graphql` and `admin_pets.graphql` would then collide — so the
|
|
74
|
+
refusal has to exist either way, and the naming rule stops being statable
|
|
75
|
+
without describing which path segments count. A duplicate base name refuses,
|
|
76
|
+
naming both files.
|
|
77
|
+
|
|
78
|
+
## `#parse` requires a schema, so `Retry` doesn't have it
|
|
79
|
+
|
|
80
|
+
**Considered:** delegating `#parse` through `Retry` to whatever it wraps, so
|
|
81
|
+
`Retry.new(client).parse(q)` reads naturally.
|
|
82
|
+
|
|
83
|
+
**Rejected because** `respond_to?(:parse)` would then be true half the time and
|
|
84
|
+
false the other half — a `Retry` over a bare HTTP transport has no schema to
|
|
85
|
+
reach through to. The rule is "anything holding a schema can parse against it",
|
|
86
|
+
which is a domain, not an exception. `Retry` doesn't meet the precondition.
|
|
87
|
+
|
|
88
|
+
## Mutations generate `…Mutation` modules
|
|
89
|
+
|
|
90
|
+
**Considered, and initially rejected:** keeping the uniform `…Query` suffix,
|
|
91
|
+
because renaming breaks call sites *and* because the generated filename changes,
|
|
92
|
+
leaving a stale `_query.rb` that `load_generated!` keeps requiring.
|
|
93
|
+
|
|
94
|
+
**Reversed once generated-file pruning landed** — the stale-file half of the
|
|
95
|
+
objection dissolved, and the remaining "one rule beats a conditional one"
|
|
96
|
+
argument lost to `SaveListEntryMutation.execute!` being what a user types every
|
|
97
|
+
day. The generated filename mirrors the constant, so `ls generated/` still
|
|
98
|
+
answers "what's the constant".
|
|
99
|
+
|
|
100
|
+
## One shared types module, not three
|
|
101
|
+
|
|
102
|
+
**Considered:** keeping `GraphQLInputs`, `GraphQLUnions` and `GraphQLEnums`
|
|
103
|
+
separate on the grounds that their file shapes genuinely differ (a manifest plus
|
|
104
|
+
per-type files versus a single file).
|
|
105
|
+
|
|
106
|
+
**Rejected because** file layout is an implementation detail and the constant a
|
|
107
|
+
user types is not. Merging also *deleted* a mechanism: the artifacts used to
|
|
108
|
+
alias each other's constants across files, which lexical scope now handles for
|
|
109
|
+
free. Per-type files were extended to all three rather than dropped — adding an
|
|
110
|
+
enum value diffs one file.
|
|
111
|
+
|
|
112
|
+
## Subgraph mapping is derived, then verified — never guessed
|
|
113
|
+
|
|
114
|
+
**Considered:** requiring an explicit `subgraphs:` map, on the grounds that
|
|
115
|
+
auto-detection is guessing and a wrong guess silently points a test suite at the
|
|
116
|
+
wrong resolvers.
|
|
117
|
+
|
|
118
|
+
**Rejected because** matching on *what a schema defines* against the routing
|
|
119
|
+
table is a derivation with evidence, not a guess — and the ambiguous cases
|
|
120
|
+
(two candidates, or none) refuse rather than pick. Detection and validation are
|
|
121
|
+
the same check run in two directions, so there is no second code path to
|
|
122
|
+
disagree.
|
|
123
|
+
|
|
124
|
+
## Only some `ArgumentError`s were branded
|
|
125
|
+
|
|
126
|
+
**Considered:** two uniform answers. Brand all ~50 `raise ArgumentError` sites
|
|
127
|
+
under `GraphWeaver::Error`, so "everything descends from `Error`" is literally
|
|
128
|
+
true; or leave subgraph detection's refusals as `ArgumentError` and qualify the
|
|
129
|
+
sentence in `docs/errors.md`.
|
|
130
|
+
|
|
131
|
+
**Rejected because** the first throws away the one thing `ArgumentError`
|
|
132
|
+
communicates — you passed something wrong at this call site, like any Ruby
|
|
133
|
+
method — and `pool_size: must be >= 1` is exactly that. The second leaves the
|
|
134
|
+
refusals a `Testing::Router` user actually meets outside the umbrella the docs
|
|
135
|
+
point them at, which is where a spec helper rescues.
|
|
136
|
+
|
|
137
|
+
What survives is a line that can be stated: **what the library concludes,
|
|
138
|
+
having read your schema, is a `GraphWeaver::Error`; an argument wrong on its
|
|
139
|
+
face is an `ArgumentError`.** Subgraph mapping (`ConfigurationError`) and a
|
|
140
|
+
query file whose name can't spell a constant are verdicts; `cast:` not being a
|
|
141
|
+
Symbol is not. A rule with a stated boundary beats a uniform one that lies
|
|
142
|
+
about half its cases.
|
|
143
|
+
|
|
144
|
+
## An abstract type is bucketed on `__typename`, not planned away
|
|
145
|
+
|
|
146
|
+
**Considered:** keeping the `abstract_boundary` refusal, on the reasoning that a
|
|
147
|
+
representation needs one concrete `__typename` and the planner — which takes no
|
|
148
|
+
variables and runs before any fetch — cannot know it.
|
|
149
|
+
|
|
150
|
+
**Rejected because** the planner doesn't have to know it. It only has to plan
|
|
151
|
+
*every* possibility: the supergraph says which concrete types a subgraph can
|
|
152
|
+
answer a union or interface with, so the plan carries a branch per type and
|
|
153
|
+
execution picks the one the data came back as. Deciding at execution is the
|
|
154
|
+
existing precedent — `@skip`/`@include` already filter deferrals against the
|
|
155
|
+
variables in hand for exactly the same reason.
|
|
156
|
+
|
|
157
|
+
The corollary is smaller and sharper than the rule it replaced. A fragment
|
|
158
|
+
whose condition can't hold at a position — `... on Note` where the answering
|
|
159
|
+
subgraph's union holds no Note — is **dropped**, not refused, even though
|
|
160
|
+
"refuse rather than guess" pulls the other way. It isn't a guess: the fragment
|
|
161
|
+
can never match, so `{}` is the only answer, and a real `@apollo/gateway`
|
|
162
|
+
returns exactly that. What still refuses is the case where the supergraph
|
|
163
|
+
genuinely doesn't say — no `@join__unionMember`/`@join__implements`, and the
|
|
164
|
+
type in more than one subgraph — because then the branch list itself would be
|
|
165
|
+
invented.
|
|
166
|
+
|
|
167
|
+
## A nested field set crosses whole, or not at all
|
|
168
|
+
|
|
169
|
+
**Considered:** assembling a nested `@key`/`@requires` object from more than
|
|
170
|
+
one fetch — `store { id }` from the subgraph in hand and `store { region
|
|
171
|
+
{ code } }` from a prefetch, deep-merged into one representation. It is what
|
|
172
|
+
`@apollo/gateway` does internally (`deepMerge(entity, dataReceivedFromService)`),
|
|
173
|
+
and it would close the last nested case rather than refusing it.
|
|
174
|
+
|
|
175
|
+
**Rejected because** the only shapes that produce the split are ones where the
|
|
176
|
+
gateway is no longer an oracle. A field set reaching *through* a key field is
|
|
177
|
+
the common one, and there the gateway doesn't split at all: composition drops
|
|
178
|
+
`@external` from key fields — an entity's key is answerable by any subgraph
|
|
179
|
+
declaring it — so the extending subgraph looks able to resolve the whole path,
|
|
180
|
+
the gateway satisfies the `@requires` locally, and gets back whatever that
|
|
181
|
+
subgraph happens to hold. Merging would mean answering *better* than the
|
|
182
|
+
gateway, which under `0 wrong` is the same failure as answering worse. So a
|
|
183
|
+
root fed by two fetches refuses, naming both halves and where each comes from.
|
|
184
|
+
|
|
185
|
+
That trap is also why the fixture graph's nested `@requires` walks a plain
|
|
186
|
+
external field (`dimensions`) and not the nested `@key`'s object (`store`):
|
|
187
|
+
the first is diffable against a real gateway, the second isn't.
|
|
188
|
+
|
|
189
|
+
## The in-process router refuses rather than approximates
|
|
190
|
+
|
|
191
|
+
**Considered:** planning every query shape, falling back to a best-effort answer
|
|
192
|
+
where the semantics are uncertain.
|
|
193
|
+
|
|
194
|
+
**Rejected because** a test double that answers 5% of queries differently from
|
|
195
|
+
production is worse than one that answers 80% and declines the rest loudly. The
|
|
196
|
+
refusal boundary *is* the product. Non-null propagation is the concrete reason:
|
|
197
|
+
before that pass existed, three queries returned silently wrong data where the
|
|
198
|
+
real router returned `data: null`.
|
|
199
|
+
|
|
200
|
+
A corollary: a `--strict` mode for the drift differ was built and then deleted,
|
|
201
|
+
because once a partly-local supergraph became a supported setup, failing on any
|
|
202
|
+
skipped subgraph was wrong for every graph except a fully-local one — and that
|
|
203
|
+
one's report already says "checked 3 of 3".
|
|
204
|
+
|
|
205
|
+
## `federation:diff` fails when it checked *nothing*
|
|
206
|
+
|
|
207
|
+
**Considered:** leaving zero-checked as a pass, on the `--strict` reasoning
|
|
208
|
+
directly above — absence is supported, and the headline already says "checked 0
|
|
209
|
+
of 4".
|
|
210
|
+
|
|
211
|
+
**Rejected because** zero is not a small number of subgraphs, it is a different
|
|
212
|
+
kind of answer: the gate would pass whatever the subgraphs said, so a green run
|
|
213
|
+
carries no information at all. "Checked 3 of 4" did real work. And the failure
|
|
214
|
+
that produced it was silent — Rails leaves `rake_eager_load` false, so a stock
|
|
215
|
+
app's CI gated on nothing while printing honest prose. There is no setup where
|
|
216
|
+
you'd deliberately run this task against a supergraph none of whose subgraphs
|
|
217
|
+
are here; the abort says to drop it from CI if that's really you.
|
|
218
|
+
|
|
219
|
+
The rule stays statable in one sentence: it fails when it found drift, and when
|
|
220
|
+
it had nothing to look at.
|
|
221
|
+
|
|
222
|
+
## Output structs allow Ruby-keyword prop names
|
|
223
|
+
|
|
224
|
+
**Considered:** narrowing the ban to types with a registered `alias:` whose path
|
|
225
|
+
starts at the prop.
|
|
226
|
+
|
|
227
|
+
**Rejected because** the ban turned out to be unnecessary, not merely too broad.
|
|
228
|
+
All 33 producible keywords construct, deserialize and typecheck as props; the
|
|
229
|
+
only bare read is an `alias:` delegator's first hop, which now spells
|
|
230
|
+
`self.next`. The proposed narrowing would also have made generation depend on
|
|
231
|
+
unrelated global registry state.
|
|
232
|
+
|
|
233
|
+
## Queries directories are a list again
|
|
234
|
+
|
|
235
|
+
**Considered:** leaving `queries_path` singular, as 0.4.x made it — one
|
|
236
|
+
`generate!` run reads one directory against one schema, and a second entry
|
|
237
|
+
would produce modules at runtime that `rake graph_weaver:generate` never
|
|
238
|
+
generated and `verify` never checked.
|
|
239
|
+
|
|
240
|
+
**Rejected because** that failure was the *divergence*, not the plurality:
|
|
241
|
+
back then `load_queries!` walked the list and `generate!` read only its first
|
|
242
|
+
entry. Every reader now goes through `GraphWeaver.query_files`, so a second
|
|
243
|
+
directory is generated, verified and loaded alike — and a duplicate module
|
|
244
|
+
name across two directories refuses, as it already did within one. What
|
|
245
|
+
survives is the honest half of the argument: one run reads one *schema*, so
|
|
246
|
+
`schema_path` stays singular.
|
|
247
|
+
|
|
248
|
+
## Deferred, deliberately
|
|
249
|
+
|
|
250
|
+
- **`write_timeout` on `Transport::HTTP`** — a real gap (nothing bounds sending),
|
|
251
|
+
but not yet worth a kwarg. Note `open_timeout` *is* the connect timeout and
|
|
252
|
+
covers the TLS handshake too (`net-http`'s `ssl_socket_connect(s, @open_timeout)`);
|
|
253
|
+
`ssl_timeout` is the OpenSSL session timeout and not a handshake deadline.
|
|
254
|
+
- **A `net_http:` passthrough hash** — the answer if the timeout/TLS kwarg list
|
|
255
|
+
keeps growing. Deliberately a hash and not a block: the pool creates
|
|
256
|
+
connections lazily and on failure, so a block would run an unpredictable number
|
|
257
|
+
of times. Configuration survives that; behaviour doesn't.
|
|
258
|
+
|
|
259
|
+
## Coercion says *whether*, never *how*
|
|
260
|
+
|
|
261
|
+
**Considered:** keeping `coerce: <Symbol>` (`register_scalar("ID", String, coerce: :to_s)`),
|
|
262
|
+
which let a registration name the conversion as well as opt into it.
|
|
263
|
+
|
|
264
|
+
**Rejected because** it asked the user to answer a question the library already
|
|
265
|
+
answers — the conversion for every scalar that has one is derived from the
|
|
266
|
+
scalar itself, and a custom scalar's conversion is its `cast:`/`serialize:`
|
|
267
|
+
pair. Its documented showcase existed only to re-enable something deliberately
|
|
268
|
+
removed from the auto path: the feature arguing for its own removal.
|
|
269
|
+
|
|
270
|
+
**Also considered:** dropping the `Int`/`Float` conversion entirely, leaving
|
|
271
|
+
parse as the single coercion mechanism. **Rejected because** `first: params[:page_size]`
|
|
272
|
+
arriving as a String is the most common real coercion in a Rails app, and
|
|
273
|
+
without it `auto_coerce` would loosen nothing among the built-ins but `Date` —
|
|
274
|
+
capability loss wearing simplicity's clothes.
|
|
275
|
+
|
|
276
|
+
`coerce:` and `auto_coerce` both survive because they are one question at two
|
|
277
|
+
scopes — a global default with a local override, the standard shape.
|
|
278
|
+
|
|
279
|
+
## `config.schema` refuses a subgraph rather than splitting in two
|
|
280
|
+
|
|
281
|
+
**Considered:** splitting the setting, since it serves two masters — the schema
|
|
282
|
+
fakes are fabricated against, and the live class `:in_process` runs. In a
|
|
283
|
+
federated app you can't have both, and setting one to make `:in_process` work
|
|
284
|
+
silently repointed `:fake` at a fraction of the graph.
|
|
285
|
+
|
|
286
|
+
**Rejected because** the split is a second knob plus a rule about which one
|
|
287
|
+
applies, and it buys a capability nothing lost: `:router` runs a subgraph's
|
|
288
|
+
real resolvers too, stitched. The two masters only want different objects in a
|
|
289
|
+
federated app, so refusing a subgraph class is the smaller change that makes
|
|
290
|
+
`config.schema` mean one thing again — and the refusal `:in_process` already
|
|
291
|
+
raises ("a federated graph has no one schema class — tag those examples
|
|
292
|
+
`graphql: :router`") becomes the whole story instead of half of it.
|
|
293
|
+
|
|
294
|
+
## The `graphql:` tag names a mode; a client is built in the example
|
|
295
|
+
|
|
296
|
+
**Considered:** letting the tag carry a client — `graphql: Failure.throttled`,
|
|
297
|
+
or `graphql: FakeClient.new(overrides: …)` — which reads well and would make
|
|
298
|
+
the tag's value a description rather than the cleanup marker it had become.
|
|
299
|
+
|
|
300
|
+
**Rejected because** metadata is evaluated when the file loads: one client
|
|
301
|
+
object would be shared by every example in the group, built before
|
|
302
|
+
`Testing.configure` had run. Spooky at a distance, and stateful — `#requests`
|
|
303
|
+
would accumulate across examples.
|
|
304
|
+
|
|
305
|
+
What the reading was right about was the leak underneath, and that is fixed
|
|
306
|
+
elsewhere: `GraphWeaver.client` is snapshotted and restored around *every*
|
|
307
|
+
example, so the tag no longer earns its keep as a cleanup marker, and building
|
|
308
|
+
a client is a plain assignment in a `before` block. `graphql_fake(**options)`
|
|
309
|
+
exists only because a fake needs the schema derivation the tag was doing.
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
graph_weaver (0.
|
|
4
|
+
graph_weaver (0.5.0)
|
|
5
5
|
graphql (>= 2.6.7)
|
|
6
6
|
sorbet-runtime
|
|
7
7
|
|
|
@@ -42,7 +42,7 @@ GEM
|
|
|
42
42
|
google-protobuf (4.35.1-x86_64-linux-gnu)
|
|
43
43
|
bigdecimal
|
|
44
44
|
rake (~> 13.3)
|
|
45
|
-
graphql (2.6.
|
|
45
|
+
graphql (2.6.10)
|
|
46
46
|
base64
|
|
47
47
|
fiber-storage
|
|
48
48
|
logger
|
|
@@ -54,7 +54,7 @@ GEM
|
|
|
54
54
|
prism (>= 1.3.0)
|
|
55
55
|
rdoc (>= 4.0.0)
|
|
56
56
|
reline (>= 0.4.2)
|
|
57
|
-
json (2.
|
|
57
|
+
json (2.21.2)
|
|
58
58
|
logger (1.7.0)
|
|
59
59
|
net-http (0.9.1)
|
|
60
60
|
uri (>= 0.11.1)
|
|
@@ -99,16 +99,16 @@ GEM
|
|
|
99
99
|
rubydex (0.2.7-arm64-darwin)
|
|
100
100
|
rubydex (0.2.7-x86_64-darwin)
|
|
101
101
|
rubydex (0.2.7-x86_64-linux)
|
|
102
|
-
simplecov (1.
|
|
103
|
-
sorbet (0.6.
|
|
104
|
-
sorbet-static (= 0.6.
|
|
105
|
-
sorbet-runtime (0.6.
|
|
106
|
-
sorbet-static (0.6.
|
|
107
|
-
sorbet-static (0.6.
|
|
108
|
-
sorbet-static (0.6.
|
|
109
|
-
sorbet-static-and-runtime (0.6.
|
|
110
|
-
sorbet (= 0.6.
|
|
111
|
-
sorbet-runtime (= 0.6.
|
|
102
|
+
simplecov (1.1.1)
|
|
103
|
+
sorbet (0.6.13454)
|
|
104
|
+
sorbet-static (= 0.6.13454)
|
|
105
|
+
sorbet-runtime (0.6.13454)
|
|
106
|
+
sorbet-static (0.6.13454-aarch64-linux)
|
|
107
|
+
sorbet-static (0.6.13454-universal-darwin)
|
|
108
|
+
sorbet-static (0.6.13454-x86_64-linux)
|
|
109
|
+
sorbet-static-and-runtime (0.6.13454)
|
|
110
|
+
sorbet (= 0.6.13454)
|
|
111
|
+
sorbet-runtime (= 0.6.13454)
|
|
112
112
|
spoom (1.8.3)
|
|
113
113
|
erubi (>= 1.10.0)
|
|
114
114
|
prism (>= 0.28.0)
|
|
@@ -176,12 +176,12 @@ CHECKSUMS
|
|
|
176
176
|
google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
|
|
177
177
|
google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
|
|
178
178
|
google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
|
|
179
|
-
graph_weaver (0.
|
|
180
|
-
graphql (2.6.
|
|
179
|
+
graph_weaver (0.5.0)
|
|
180
|
+
graphql (2.6.10) sha256=9b7c8633767f516ff9d48a8d6305b2a00a2101c82aa871b92e69086944f9f83e
|
|
181
181
|
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
|
|
182
182
|
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
|
|
183
183
|
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
|
|
184
|
-
json (2.
|
|
184
|
+
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
|
|
185
185
|
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
|
186
186
|
net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
|
|
187
187
|
netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f
|
|
@@ -206,13 +206,13 @@ CHECKSUMS
|
|
|
206
206
|
rubydex (0.2.7-arm64-darwin) sha256=f0d28bbf4153568be79b671642424750053e0bea971b60ddf5cec19bf4563990
|
|
207
207
|
rubydex (0.2.7-x86_64-darwin) sha256=b002b259d118ac69de44470eff1597143318402c45630c47371f9542631447dc
|
|
208
208
|
rubydex (0.2.7-x86_64-linux) sha256=dacfade9fa42ce4469618da6dac07e69d5f3ac6a313b4caced5234c8f052419a
|
|
209
|
-
simplecov (1.
|
|
210
|
-
sorbet (0.6.
|
|
211
|
-
sorbet-runtime (0.6.
|
|
212
|
-
sorbet-static (0.6.
|
|
213
|
-
sorbet-static (0.6.
|
|
214
|
-
sorbet-static (0.6.
|
|
215
|
-
sorbet-static-and-runtime (0.6.
|
|
209
|
+
simplecov (1.1.1) sha256=25825ef13f0b2e74694d769817dad6ab8e90131dabdaa666e522fea105521e78
|
|
210
|
+
sorbet (0.6.13454) sha256=3273dda082b8aa5ecf79b7675e2b79127f202a45370d881124af3af19a3c2b35
|
|
211
|
+
sorbet-runtime (0.6.13454) sha256=b9441ae4bd265f51861c54240e8c5888f6355e3284885b21c5154b184e791d80
|
|
212
|
+
sorbet-static (0.6.13454-aarch64-linux) sha256=e09dd1e6cd7e63b3fb6c8c9c6785eeb55a6c4afecb0f932538ee4401b4552632
|
|
213
|
+
sorbet-static (0.6.13454-universal-darwin) sha256=6ac0cf10ae2b4e0e9ecabb886f9be739046424dc35d100e7888c8538a223f93a
|
|
214
|
+
sorbet-static (0.6.13454-x86_64-linux) sha256=569c52ea926e514ebfdc64825f65cd188b7aba5c1b480cc26e1870fd53d5e2c0
|
|
215
|
+
sorbet-static-and-runtime (0.6.13454) sha256=defc00684a6f8bd498bb70a4541f293b9232938a776137946247cba4baed3d20
|
|
216
216
|
spoom (1.8.3) sha256=32871fa189bbfa49cf557a50f819f23cc9a6ceefd0346caa7a6adc193becd5dd
|
|
217
217
|
tapioca (0.19.2) sha256=938731b07811aee8d23871b1aee8861d464fbaf2cfffbf79a62b0c869a5120ec
|
|
218
218
|
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
|
data/NOTES.md
CHANGED
|
@@ -174,9 +174,9 @@ design:
|
|
|
174
174
|
## Open questions
|
|
175
175
|
|
|
176
176
|
- interface-typed fields (vs fragment conditions, which work)
|
|
177
|
-
- name collisions:
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
177
|
+
- ~~name collisions~~ ANSWERED: path-based won. A generated type is named
|
|
178
|
+
for the response key that selects it, so the name is a function of the
|
|
179
|
+
field's own position — no walk order, no first-come-first-served, and an
|
|
180
|
+
unrelated selection can't move it. GraphQL aliases double as the explicit
|
|
181
|
+
naming escape hatch (`pet: pets` names the struct `Pet`)
|
|
182
182
|
- mutations/subscriptions (only query operations generate)
|