graph_weaver 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +75 -54
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +34 -4
  12. data/docs/real_world.md +4 -4
  13. data/docs/scalars.md +206 -46
  14. data/docs/testing.md +191 -151
  15. data/docs/transports.md +47 -19
  16. data/docs/upgrading.md +210 -11
  17. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  18. data/lib/graph_weaver/client.rb +46 -13
  19. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  20. data/lib/graph_weaver/codegen/emit.rb +96 -39
  21. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +167 -98
  24. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  25. data/lib/graph_weaver/codegen.rb +279 -84
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +30 -7
  28. data/lib/graph_weaver/federation.rb +6 -5
  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 +39 -41
  38. data/lib/graph_weaver/internal/values.rb +184 -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 +5 -4
  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 +50 -11
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +96 -29
  50. data/lib/graph_weaver/tasks.rb +78 -29
  51. data/lib/graph_weaver/testing/cassette.rb +49 -65
  52. data/lib/graph_weaver/testing/coverage.rb +5 -4
  53. data/lib/graph_weaver/testing/failure.rb +10 -6
  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 +94 -808
  57. data/lib/graph_weaver/testing.rb +35 -84
  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 +203 -119
  63. metadata +10 -5
  64. data/lib/graph_weaver/schemas.rb +0 -48
  65. data/lib/graph_weaver/selection.rb +0 -120
  66. data/lib/graph_weaver/testing/values.rb +0 -98
data/docs/upgrading.md CHANGED
@@ -1,4 +1,174 @@
1
- # Upgrading to 0.5.0
1
+ # Upgrading
2
+
3
+ ## Regenerate on every upgrade
4
+
5
+ **Any release can change what codegen emits.** Patch releases included — most of
6
+ them are fixes to a generated type, and a fix to a type is a change to the bytes.
7
+ 0.5.1 was a patch and moved three of them.
8
+
9
+ So `rake graph_weaver:generate` is part of upgrading the gem, every time, and
10
+ `rake graph_weaver:verify` is the detector: it fails when the checked-in Ruby
11
+ isn't what this version would write. Nothing beyond that is promised — there is
12
+ no "generated output is stable within a minor" rule to lean on. What each release
13
+ changed, and whether it needs a regenerate, is in the changelog.
14
+
15
+ A generated file's header names the release that wrote it, so the first `verify`
16
+ after an upgrade reports the tree as stale whether or not codegen actually
17
+ moved. That's the reminder working, not a false alarm.
18
+
19
+ Generation is deterministic, so the diff is exactly what the new version emits
20
+ differently and nothing else — worth reading rather than rubber-stamping.
21
+
22
+ ## Upgrading from 0.5.1
23
+
24
+ Much smaller than 0.5.0, and mostly mechanical. Three commands find most of it:
25
+
26
+ ```sh
27
+ rake graph_weaver:generate # 1. what codegen emits moved in several places
28
+ srb tc # 2. kwargs that got narrower are call-site errors
29
+ bundle exec rspec # 3. every deleted knob raises where it's still set
30
+ ```
31
+
32
+ The rest of this section is what those three don't catch.
33
+
34
+ ### Loose input coerces, so `coerce:` and `auto_coerce` are gone
35
+
36
+ `execute(first: params[:first])` converts the String to an `Integer` — for every
37
+ variable and every input-object field, with nothing to switch on. The old way of
38
+ buying that was `GraphWeaver.auto_coerce` or `register_scalar(…, coerce: true)`,
39
+ and both paid for it by **widening the emitted kwarg**, which switched off the
40
+ static check at every call site. Delete them:
41
+
42
+ ```ruby
43
+ GraphWeaver.auto_coerce = true # gone
44
+ GraphWeaver.register_scalar("Money", Money, coerce: true) # drop the coerce:
45
+ ```
46
+
47
+ Behavior is unchanged; the kwarg is not. It is now typed exactly as the schema
48
+ types it, so a call site passing a **literal** of the wrong type is a new
49
+ `srb tc` error — which is the point, since a literal is one you can just spell
50
+ right:
51
+
52
+ ```ruby
53
+ StargazersQuery.execute(first: "10") # srb tc error now
54
+ StargazersQuery.execute(first: params[:first]) # fine, and "10" becomes 10
55
+ ```
56
+
57
+ `cast:` is what a loose value converts through, so a custom scalar needs nothing
58
+ beyond the registration it already has. Bad input raises
59
+ `GraphWeaver::InputError` naming the variable, the operation and the value.
60
+
61
+ Two conversions got **stricter** at the same time, and either can bite an app
62
+ that was passing. A numeric string is now read as a wire format rather than as
63
+ Ruby source, so `"010"` is ten rather than eight and `"0x1f"` and `"1_0"` are
64
+ refused. And a `Boolean` refuses a String outright — every rule for `"0"` and
65
+ `"off"` is somebody's convention, so convert at the call site.
66
+
67
+ ### `nil` sends `null`
68
+
69
+ A variable passed `nil` now sends an explicit `null`; one left out is still left
70
+ out. That's what lets a mutation clear a field — and it changes what a kwarg fed
71
+ a possibly-missing value means:
72
+
73
+ ```ruby
74
+ UpdateProfile.execute!(bio: params[:bio]) # a missing param used to omit; now it clears the bio
75
+ ```
76
+
77
+ **Grep for kwargs fed straight from `params` or an optional attribute**, and
78
+ pass the keyword only when you mean it:
79
+
80
+ ```ruby
81
+ UpdateProfile.execute!(**(params[:bio] ? { bio: params[:bio] } : {}))
82
+ ```
83
+
84
+ Non-null variables are unaffected: they can't carry `null`, so `nil` there still
85
+ omits and the schema default applies. Input objects get the distinction only
86
+ where a Hash can express it — `coerce({nickname: nil})` sends null, `coerce({})`
87
+ omits, and a struct built with `.new` can't tell the two apart, so `nil` there
88
+ still means omit.
89
+
90
+ ### Renames
91
+
92
+ | before | after |
93
+ |---|---|
94
+ | `Retry.new(tries: n)`, `retries: { tries: n }` | `retries: n - 1` — one word everywhere, counting the attempts *after* the first, so `retries: 0` is one attempt and `GraphWeaver.new(url, retries: 3)` is four |
95
+ | `GraphWeaver.new(url, retries: { retries: 5, retry_codes: […] })` | `GraphWeaver.new(url, retries: 5, retry_codes: […])` — the other retry options sit beside the count; the Hash form read as a key nested in itself |
96
+ | `Retry.new(t, on: […])` | `Retry.new(t, retry_on: […])` |
97
+ | `Retry.new(t, base: 0.5, max: 30)` | `Retry.new(t, base_delay: 0.5, max_delay: 30)` — beside a count, `max: 30` read as a second, larger attempt count |
98
+ | `Codegen.generate(module_name:)` | `name:` — the spelling `GraphWeaver.parse` already used; `module_name:` now raises, naming its replacement |
99
+ | `Testing.config.null_chance = 0.3` | `graphql_fake(null_chance: 0.3)`, on the example that wants it |
100
+ | `Testing.config.mode = :literal` | `graphql_fake(values: :literal)`, likewise |
101
+ | `Testing::MODES` | `Testing::VALUE_STYLES` |
102
+ | `SchemaLoader.stale?(path)` | `SchemaLoader.diff(path).empty?` — and `diff` also names what moved |
103
+
104
+ The two `Testing.config` deletions are the ones worth a sentence. A suite-wide
105
+ `null_chance` answers a per-example question, so it nils an unrelated field one
106
+ run in ten, on a seed the failure doesn't name; move it onto the examples that
107
+ are *about* an empty state. (`config.default_mode` and the `graphql: :fake` tag
108
+ are untouched — the per-fake `mode:` became `values:` so the two can't be
109
+ confused for each other.) Every retry misspelling raises rather than being
110
+ ignored: the Hash form names its flat replacement, and a retry option passed
111
+ without `retries:` says so.
112
+
113
+ ### The internals moved behind `Internal`
114
+
115
+ The public surface is now what the docs name, what generated code calls, and the
116
+ `execute` slot; everything else sits under `GraphWeaver::Internal` or went
117
+ `private`, and a spec diffs the two so the next accidental promotion fails CI.
118
+ Nothing documented moved — skip this section unless `srb tc` or a
119
+ `NoMethodError` says otherwise.
120
+
121
+ What a suite might plausibly have reached for: the federation query planner and
122
+ its IR (`Internal::Planner`), the fake-value engine (`Internal::Values`), the
123
+ selection walk (`Internal::Selection` — so `FakeClient` no longer answers
124
+ `each_field` or `gather`), the cassette matching rules (`Internal::RequestKey`),
125
+ subgraph detection (was `Testing::Subgraphs`), `GraphWeaver.log` /
126
+ `.instrument` / `.filter_variables` (`Internal::Log` — `logger=`,
127
+ `instrumenter=` and `filter_parameters=` are unchanged), and
128
+ `Transport.operation_name` / `.mutation?` / `.log_tag`, which left the class you
129
+ subclass for `Internal::Wire`.
130
+
131
+ Two smaller edges. `SchemaDiff::Change`, `Cassette::Check`, `Coverage::Result`
132
+ and `InputStruct::Field` are `Data` now rather than `Struct`, so they hand out
133
+ no writers — read one, build a new one to change a field. And generated modules
134
+ keep their own plumbing to themselves: `DEFAULT_CLIENT`, `FIELDS` and `ONE_OF`
135
+ are emitted `private_constant`, so **regenerate**.
136
+
137
+ ### Behavior that changed under you
138
+
139
+ - **A mutation is no longer retried.** A timeout doesn't say whether the server
140
+ applied it, and a second `charge` is worse than a failed one. Pass
141
+ `retry_mutations: true` for an API whose mutations are idempotent.
142
+ - **A registration this schema can't match warns instead of failing
143
+ generation.** One registry serves a whole federated graph, so a name the
144
+ schema in hand doesn't declare may belong to the subgraph next door — see
145
+ [federation](federation.md#generating-for-a-federated-graph). Your typo is now
146
+ in the list `rake graph_weaver:generate` prints after the files, so read it.
147
+ - **`verify_generated!` fails when it finds no query documents.** A mistyped
148
+ `queries_paths` used to leave a CI gate green forever.
149
+ - **The local router refuses a `@fromContext` argument** rather than fetching
150
+ the field with it unset. Federation 2.8's `@context` machinery was on the
151
+ routing table's known list, so the argument was read and dropped. Per query,
152
+ like `@interfaceObject`: a subtree one subgraph answers whole still runs.
153
+ - **A `#trace` assertion may see one entry fewer.** Two `@requires` field sets
154
+ crossing into the same subgraph on the same `@key` now ride one entity fetch,
155
+ the way Apollo's do.
156
+ - **Fabricating a custom scalar registered as a class of your own needs a pin
157
+ for the type** — `Testing.config.overrides = { "Money" => "12.00" }`, or the
158
+ same key on one example's `graphql_fake`. Without one, `FakeClient` and
159
+ cassette anonymization refuse rather than feeding your cast a `"Money-1"`
160
+ placeholder. Scalars registered as `Time`, `Date`, `Integer`, `Float`,
161
+ `String` or `T::Boolean` need nothing.
162
+ - **Re-run `rake graph_weaver:cassettes:anonymize`** on any committed cassette
163
+ holding a registered custom scalar: the anonymizer used to write a value the
164
+ generated codec couldn't read back.
165
+ - **Generation refuses four more things**, each naming its fix — a
166
+ `register_scalar` whose Ruby type nothing can build out of JSON (a value
167
+ object of your own: give it a `cast:`), a result key that would shadow a constant the
168
+ file uses, an enum value that camelizes to nothing, and a narrowed fragment
169
+ whose `__typename` sits behind `@skip`/`@include`.
170
+
171
+ ## Upgrading to 0.5.0
2
172
 
3
173
  0.5.0 is one large breaking release. Almost all of it is caught mechanically,
4
174
  in this order:
@@ -6,7 +176,7 @@ in this order:
6
176
  ```sh
7
177
  # 1. rename the path settings first — generate won't load without them
8
178
  # (queries_path -> queries_paths, generated_path -> generated_paths,
9
- # fragments_path -> fragments_paths; see "One plurality rule" below)
179
+ # fragments_path -> fragments_paths; see "Path settings are lists" below)
10
180
 
11
181
  bundle exec tapioca gem graph_weaver # 2. regenerate the RBI
12
182
  rake graph_weaver:generate # 3. the emitted call shape changed
@@ -23,7 +193,7 @@ Regenerate the RBI and what remains is only your own call sites.
23
193
  Generated code is `# typed: strict`, so step 4 finds those for you. The rest of
24
194
  this page is what a typechecker can't see.
25
195
 
26
- ## `execute` means one thing now
196
+ ### `execute` means one thing now
27
197
 
28
198
  Every client answers the same call — `execute(query, variables:, operation_name:)`,
29
199
  returning the raw response hash. `Client` used to spell something else under
@@ -55,7 +225,7 @@ PersonQuery.execute(client: some_client, id: "1") # after
55
225
 
56
226
  `GraphWeaver.resolve_transport` is gone; nothing needs unwrapping any more.
57
227
 
58
- ## Path settings are lists
228
+ ### Path settings are lists
59
229
 
60
230
  `queries_paths`, `generated_paths`, `fragments_paths` — every entry is read.
61
231
  Assigning a String still works, so the change is the name:
@@ -67,9 +237,10 @@ GraphWeaver.queries_paths = "app/graphql/queries" # after
67
237
 
68
238
  `schema_path` stays singular: one run reads one schema.
69
239
 
70
- ## One reset
240
+ ### One reset
71
241
 
72
- `GraphWeaver.reset_registrations!` is the clean slate between tests. The four
242
+ `GraphWeaver.reset_registrations!` is the clean slate between tests, or between
243
+ generations for different schemas. The four
73
244
  narrow ones moved to where they live:
74
245
 
75
246
  ```ruby
@@ -78,12 +249,39 @@ GraphWeaver::Codegen.reset_scalars! # after (also reset_enums!, clear_scalar
78
249
  # reset_type_helpers!)
79
250
  ```
80
251
 
81
- ## Smaller renames
252
+ ### Generated names come from the response key, not the type
253
+
254
+ Nested structs used to be named for the GraphQL *type* they were cast from;
255
+ they are now named for the **response key that selects them**, camelized, and
256
+ the constant path reads like the query. The typechecker finds the call sites in
257
+ a `# typed: true` file (an unresolved constant is an `srb tc` error); in a
258
+ `# typed: false` file it is `uninitialized constant` at runtime, so grep for
259
+ `::Result::` there.
260
+
261
+ | selection | before (type) | after (key) |
262
+ |---|---|---|
263
+ | `person { pets { name } }` | `PersonQuery::Result::Person::Pet` | `PersonQuery::Result::Person::Pets` |
264
+ | `payrollRisk { score }` | `…::Result::RiskAssessment` | `…::Result::PayrollRisk` |
265
+ | `_entities(…) { ... on Product { … } }` | `…::Result::Product` | `…::Result::Entities::Product` |
266
+
267
+ The key is used verbatim — no pluralization, so a list field `pets` is `Pets`.
268
+ To pick the name yourself, alias the field: `pet: pets { name }` generates
269
+ `Pet`. Union and interface members keep their type-condition names, nested in
270
+ the container the field names. The payoff is that adding, removing or
271
+ reordering an unrelated selection can never rename a struct you reference.
272
+
273
+ **Enums moved out of the result tree.** Every schema enum a query touches is one
274
+ Ruby type in the shared module, `GraphQLTypes::Species`, so a value read from
275
+ one query hands straight into another's variable. A query module aliases the
276
+ enums its *variables* use (`AddPetMutation::Species` still works); an enum
277
+ reached only through a result is no longer nested under the struct that
278
+ carries it — `SearchQuery::Result::Search::Species` is `GraphQLTypes::Species`.
279
+
280
+ ### Smaller renames
82
281
 
83
282
  | before | after |
84
283
  |---|---|
85
284
  | `Testing.config.auto_fake = true` | `Testing.config.default_mode = :fake` |
86
- | `register_scalar(…, coerce: :to_s)` | `coerce: true`, or a `cast:`/`serialize:` pair |
87
285
  | a mutation's `…Query` module | `…Mutation` |
88
286
  | `graphql: :none` (rspec tag) | `graphql: false` |
89
287
 
@@ -103,7 +301,7 @@ graphql_in_process(MySchema) # in the example
103
301
  GraphWeaver::Testing.config.schema = MySchema # or once, for the whole suite
104
302
  ```
105
303
 
106
- ## Registering from Rails
304
+ ### Registering from Rails
107
305
 
108
306
  A registration naming one of your own constants belongs in a `to_prepare` block
109
307
  — the same place the in-process client goes, and for the same reason:
@@ -117,9 +315,10 @@ end
117
315
  ```
118
316
 
119
317
  Generation depends on `:environment`, which runs `to_prepare` too, so the
120
- registration is in place before it emits.
318
+ registration is in place before it emits — and at boot the generated files
319
+ load from a `to_prepare` block of their own, after yours.
121
320
 
122
- ## If you use the federation router
321
+ ### If you use the federation router
123
322
 
124
323
  Detection only sees *loaded* schema classes, and Rails does not eager load for
125
324
  rake or in the default test environment. Both are one line:
@@ -56,8 +56,11 @@ module GraphWeaver
56
56
  create_file "config/initializers/graph_weaver.rb", initializer
57
57
  end
58
58
 
59
+ # fragments too: the editor config below globs it, and a shared fragment
60
+ # then has an obvious home rather than being invented later
59
61
  def create_layout
60
62
  create_file File.join(GraphWeaver.queries_paths.first, ".keep"), ""
63
+ create_file File.join(GraphWeaver.fragments_paths.first, ".keep"), ""
61
64
  create_file File.join(GraphWeaver.generated_paths.first, ".keep"), ""
62
65
  end
63
66
 
@@ -85,10 +88,18 @@ module GraphWeaver
85
88
  rescue StandardError => e
86
89
  # the files above are the valuable part — don't lose them to a bad
87
90
  # token or an unreachable host
91
+ @schema_failed = true
88
92
  say_status :failed, "#{e.message} — retry with `#{refresh_command}`", :red
89
93
  end
90
94
 
91
95
  def next_steps
96
+ # generation reads the dump, so without one the step below can't run —
97
+ # say that next to it rather than leaving the red line above to be
98
+ # scrolled past. A re-run that already has a dump is not blocked.
99
+ if @schema_failed && !GraphWeaver::SchemaLoader.locate_path
100
+ say "\nThere's no schema dump yet, so `rake graph_weaver:generate` has nothing to read."
101
+ end
102
+
92
103
  say <<~TEXT
93
104
 
94
105
  Write a query in #{GraphWeaver.queries_paths.first}, then:
@@ -175,8 +186,12 @@ module GraphWeaver
175
186
 
176
187
  def auth_var = options[:auth] || GraphWeaver::SchemaLoader::DEFAULT_AUTH_ENV
177
188
 
189
+ # The command just typed, retyped. One rule for every source form, and
190
+ # the only one that always works: the files already written come back
191
+ # "identical", and --auth rides along — where schema:refresh has no flag
192
+ # for it, and with no dump written has no url to read either.
178
193
  def refresh_command
179
- (form == :url) ? "rake graph_weaver:schema:refresh" : "rails g graph_weaver:install #{source}"
194
+ "rails g graph_weaver:install #{source}#{" --auth #{options[:auth]}" if options[:auth]}"
180
195
  end
181
196
 
182
197
  def initializer
@@ -40,17 +40,28 @@ class GraphWeaver::Client
40
40
  # nothing to hand a context to — so the two can't word it differently
41
41
  CONTEXT_IN_PROCESS = "context: applies to a schema class executing in-process"
42
42
 
43
- def initialize(source, auth: nil, headers: {}, retries: false, transport: nil, cache: nil, ttl: nil,
44
- open_timeout: nil, read_timeout: nil, context: nil, &middleware)
43
+ # the whole rule, said wherever a retry option is refused
44
+ RETRY_RULE = "retries: is how many attempts follow the first; the other retry options sit beside it"
45
+ private_constant :CONTEXT_IN_PROCESS, :RETRY_RULE
46
+
47
+ def initialize(source, auth: nil, headers: {}, transport: nil, cache: nil, ttl: nil,
48
+ open_timeout: nil, read_timeout: nil, context: nil,
49
+ retries: false, backoff: nil, base_delay: nil, max_delay: nil, jitter: nil, retry_on: nil,
50
+ retry_if: nil, retry_codes: nil, retry_mutations: nil, sleeper: nil, &middleware)
45
51
  check_source!(source)
46
52
 
53
+ # Retry's options, spelled the same and passed straight through; nil
54
+ # is "not given", so their defaults stay in Retry alone
55
+ retry_options = { backoff:, base_delay:, max_delay:, jitter:, retry_on:, retry_if:,
56
+ retry_codes:, retry_mutations:, sleeper: }.compact
57
+
47
58
  if source.is_a?(String) && source.match?(URL)
48
59
  raise ArgumentError, CONTEXT_IN_PROCESS if context
49
60
 
50
61
  built = build_transport(source, auth:, headers:, kind: transport, open_timeout:, read_timeout:, &middleware)
51
- @transport = wrap_retries(built, retries)
62
+ @transport = wrap_retries(built, retries, retry_options)
52
63
  else
53
- if auth || middleware || retries || open_timeout || read_timeout
64
+ if auth || middleware || retries || open_timeout || read_timeout || !retry_options.empty?
54
65
  raise ArgumentError, "auth:/retries:/timeouts/middleware apply to a url — got a schema source"
55
66
  end
56
67
  if transport.is_a?(Symbol)
@@ -80,6 +91,7 @@ class GraphWeaver::Client
80
91
 
81
92
  @cache = cache
82
93
  @ttl = ttl
94
+ @schema_lock = Mutex.new
83
95
  end
84
96
 
85
97
  # The transport queries run through: a url-built transport, an
@@ -89,15 +101,21 @@ class GraphWeaver::Client
89
101
  attr_reader :transport
90
102
 
91
103
  # transport, when this client must be able to execute
92
- def transport!
104
+ private def transport!
93
105
  transport or raise GraphWeaver::Error,
94
106
  "this client has no transport (built from a schema dump) — pass a url or transport:"
95
107
  end
96
108
 
97
109
  # The schema, introspecting through the transport on first use (cached
98
110
  # per the client's cache:/ttl:) unless one was given up front.
111
+ #
112
+ # Locked because a cold Puma process serves its first requests
113
+ # concurrently: a bare ||= there is one full introspection round trip per
114
+ # in-flight thread, each of them also writing the cache file.
99
115
  def schema
100
- @schema ||= GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
116
+ @schema_lock.synchronize do
117
+ @schema ||= GraphWeaver::SchemaLoader.introspect(transport!, cache: @cache, ttl: @ttl)
118
+ end
101
119
  end
102
120
 
103
121
  # The client contract, same as every transport: a query and its
@@ -150,6 +168,11 @@ class GraphWeaver::Client
150
168
  def build_transport(url, auth:, headers:, kind:, open_timeout: nil, read_timeout: nil, &middleware)
151
169
  headers = headers.dup
152
170
  if auth
171
+ unless auth.is_a?(String)
172
+ raise ArgumentError, "auth: takes a token string, got #{auth.class} — other headers go in " \
173
+ "headers:, and a token that rotates goes in the Faraday middleware block"
174
+ end
175
+
153
176
  headers["Authorization"] ||= auth.include?(" ") ? auth : "Bearer #{auth}"
154
177
  end
155
178
 
@@ -163,7 +186,7 @@ class GraphWeaver::Client
163
186
  GraphWeaver::Transport::HTTP.new(url, headers:, **timeouts)
164
187
  end
165
188
 
166
- GraphWeaver.log(:info) { "transport: #{transport.class} -> #{url}" }
189
+ GraphWeaver::Internal::Log.log(:info) { "transport: #{transport.class} -> #{url}" }
167
190
  transport
168
191
  end
169
192
 
@@ -198,13 +221,23 @@ class GraphWeaver::Client
198
221
  GraphWeaver::Transport::Faraday.new(url, headers:, **timeouts, &middleware)
199
222
  end
200
223
 
201
- # retries: is off by default — true for Retry defaults, or a
202
- # Hash of its options
203
- def wrap_retries(transport, retries)
224
+ # retries: is off by default — a count, or true for Retry's default
225
+ # count. Without it nothing wraps the transport, so a retry option on
226
+ # its own would quietly do nothing.
227
+ def wrap_retries(transport, retries, options)
204
228
  case retries
205
- when true then GraphWeaver::Retry.new(transport)
206
- when false, nil then transport
207
- else GraphWeaver::Retry.new(transport, **retries)
229
+ when Integer then GraphWeaver::Retry.new(transport, retries:, **options)
230
+ when true then GraphWeaver::Retry.new(transport, **options)
231
+ when false, nil
232
+ raise ArgumentError, "#{options.keys.first}: needs retries: — #{RETRY_RULE}" if options.any?
233
+
234
+ transport
235
+ when Hash
236
+ # it used to take a Hash of Retry options, which read as a key nested in itself
237
+ flat = retries.map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
238
+ raise ArgumentError, "retries: no longer takes a Hash — pass GraphWeaver.new(url, #{flat})"
239
+ else
240
+ raise ArgumentError, "#{RETRY_RULE} — got #{retries.inspect}"
208
241
  end
209
242
  end
210
243
  end
@@ -51,9 +51,9 @@ class GraphWeaver::Codegen
51
51
  # since a module and the type it queries can share a name (module Query
52
52
  # on type Query would otherwise stutter).
53
53
  def qualify(node, message)
54
- return message if @module_name.nil? || @module_name == node.graphql_type
54
+ return message if @name.nil? || @name == node.graphql_type
55
55
 
56
- "#{@module_name}: #{message}"
56
+ "#{@name}: #{message}"
57
57
  end
58
58
 
59
59
  # An alias emits a plain instance method, so it is held to the same bar
@@ -86,6 +86,7 @@ class GraphWeaver::Codegen
86
86
  # ever satisfy it, so it's a typo (or a wire-cased name), not a path that
87
87
  # doesn't fit this query. optional: skips the latter, never this.
88
88
  UnknownSegment = Class.new(GraphWeaver::Error)
89
+ private_constant :ALIAS_RESERVED, :LIST_SELECTORS, :UnknownSegment
89
90
 
90
91
  # Walk a dotted path through this struct's selected shape, building the
91
92
  # delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
@@ -137,7 +138,7 @@ class GraphWeaver::Codegen
137
138
  unless field
138
139
  check_segment_exists!(node, name, obj, seg)
139
140
  props = obj.fields.map(&:prop)
140
- suggestion = GraphWeaver.did_you_mean(props, seg)
141
+ suggestion = GraphWeaver::Internal::Util.did_you_mean(props, seg)
141
142
  hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
142
143
  raise GraphWeaver::Error,
143
144
  "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
@@ -169,7 +170,7 @@ class GraphWeaver::Codegen
169
170
  hint = if prop != seg && known.include?(prop)
170
171
  # paths are the Ruby prop chain, not the GraphQL one — the classic miss
171
172
  " — GraphQL fields generate snake_case props; use '#{prop}'"
172
- elsif (suggestion = GraphWeaver.did_you_mean(known, prop))
173
+ elsif (suggestion = GraphWeaver::Internal::Util.did_you_mean(known, prop))
173
174
  " — did you mean '#{suggestion}'?"
174
175
  else
175
176
  " (has: #{known.sort.join(", ")})"