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
@@ -14,13 +14,14 @@ module GraphWeaver
14
14
  class MissingRecording < GraphWeaver::Error
15
15
  # how many recorded variable sets to print before summarizing
16
16
  SHOWN = 5
17
+ private_constant :SHOWN
17
18
 
18
19
  def initialize(path:, query:, variables:, recorded:, size:)
19
20
  super([
20
- "no recording for this request in #{path}",
21
- " variables: #{Cassette.normalize_variables(variables).inspect}",
21
+ "no recording for this request in #{GraphWeaver::Internal::Util.relative(path)}",
22
+ " variables: #{GraphWeaver::Internal::Log.filter_variables(Internal::RequestKey.normalize_variables(variables)).inspect}",
22
23
  " #{self.class.recorded_summary(recorded, size)}",
23
- " query: #{Cassette.summarize(query)}",
24
+ " query: #{Internal::RequestKey.summarize(query)}",
24
25
  "re-record it (GRAPHWEAVER_RECORD=1 with a client:), or delete the cassette to start over.",
25
26
  ].join("\n"))
26
27
  end
@@ -29,8 +30,9 @@ module GraphWeaver
29
30
  return "no entry recorded for this query (#{size} in the cassette)" if recorded.empty?
30
31
 
31
32
  more = recorded.size > SHOWN ? " (+#{recorded.size - SHOWN} more)" : ""
33
+ shown = recorded.first(SHOWN).map { |set| GraphWeaver::Internal::Log.filter_variables(set).inspect }
32
34
  "#{recorded.size} #{(recorded.size == 1) ? "entry" : "entries"} recorded for this query, " \
33
- "with variables #{recorded.first(SHOWN).map(&:inspect).join(", ")}#{more}"
35
+ "with variables #{shown.join(", ")}#{more}"
34
36
  end
35
37
  end
36
38
 
@@ -51,15 +53,17 @@ module GraphWeaver
51
53
  elsif config.record
52
54
  # record mode without a client would quietly serve the stale
53
55
  # recording — the one thing "re-record everything" didn't ask for
54
- raise GraphWeaver::Error, "record mode is on but no `client:` was given for #{file.path} " \
56
+ raise GraphWeaver::Error, "record mode is on but no `client:` was given for " \
57
+ "#{GraphWeaver::Internal::Util.relative(file.path)} " \
55
58
  "— pass a live `client:` to re-record it, or turn record mode off " \
56
59
  "(GRAPHWEAVER_RECORD / Testing.config.record)."
57
60
  elsif file.exist?
58
61
  Replayer.new(file)
59
62
  else
60
63
  # a first run, not a missing recording: there is no request yet
61
- raise GraphWeaver::Error, "#{file.path} doesn't exist and no `client:` was given to " \
62
- "record with — pass `client:` on the first run, or commit the cassette."
64
+ raise GraphWeaver::Error, "#{GraphWeaver::Internal::Util.relative(file.path)} doesn't exist " \
65
+ "and no `client:` was given to record with — pass `client:` on the first run, " \
66
+ "or commit the cassette."
63
67
  end
64
68
  end
65
69
  end
@@ -72,14 +76,14 @@ module GraphWeaver
72
76
  # found. `checked` is how many recordings a module claimed: a run that
73
77
  # claimed none proved nothing, which is a different answer from "all
74
78
  # good" — the same distinction `federation:diff` draws.
75
- Check = Struct.new(:path, :checked, :skipped, :stale, keyword_init: true) do
79
+ Check = Data.define(:path, :checked, :skipped, :stale) do
76
80
  def ok? = stale.empty?
77
81
 
78
82
  def report
79
83
  counted = ["#{checked} checked"]
80
84
  counted << "#{skipped} not sent by any query module" if skipped.positive?
81
85
 
82
- ["#{path}: #{stale.size} stale (#{counted.join(", ")})"] +
86
+ ["#{GraphWeaver::Internal::Util.relative(path)}: #{stale.size} stale (#{counted.join(", ")})"] +
83
87
  stale.flat_map do |entry|
84
88
  [" #{entry.module_name} #{entry.variables.inspect}", " #{entry.message}"]
85
89
  end
@@ -87,42 +91,66 @@ module GraphWeaver
87
91
  end
88
92
 
89
93
  # One recording the generated structs can no longer read.
90
- Stale = Struct.new(:module_name, :variables, :message, keyword_init: true)
94
+ Stale = Data.define(:module_name, :variables, :message)
95
+
96
+ # Shapes that are a credential whatever the field around them is
97
+ # called. Anonymization can't cover everything a cassette holds — the
98
+ # variables ARE the replay key, so they're written verbatim — so the
99
+ # bytes that reach disk get one look before anyone commits them.
100
+ # Deliberately narrow: a false alarm costs a glance, while a password
101
+ # like "hunter2" has no shape at all, so a quiet run is not a clean
102
+ # bill of health.
103
+ CREDENTIAL_SHAPES = {
104
+ "a JWT" => /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\./,
105
+ "an AWS access key" => /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
106
+ "a GitHub token" => /\b(?:gh[opusr]|github_pat)_[A-Za-z0-9_]{20,}/,
107
+ "a Slack token" => /\bxox[baprs]-[A-Za-z0-9-]{10,}/,
108
+ "a Stripe key" => /\bsk_(?:live|test)_[A-Za-z0-9]{10,}/,
109
+ "a private key" => /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
110
+ "an Authorization header" => /\bBearer\s+\S{16,}/,
111
+ }.freeze
91
112
 
92
113
  attr_reader :path
93
114
 
94
115
  def initialize(path)
95
116
  @path = Testing.cassette_path(path)
96
117
  @entries = File.exist?(@path) ? YAML.safe_load_file(@path, aliases: true) : []
118
+ @flagged = []
119
+ # record is read-modify-write; two threads recording through one
120
+ # cassette (a parallel spec run) would each save a snapshot missing
121
+ # the other's entry — atomic_write keeps the file whole, not complete
122
+ @lock = Mutex.new
97
123
  end
98
124
 
99
125
  def exist? = File.exist?(@path)
100
126
  def size = @entries.size
101
127
 
102
128
  def lookup(query, variables, operation_name = nil)
103
- wanted = self.class.key(query, variables, operation_name)
104
- @entries.find { |entry| self.class.entry_key(entry) == wanted }
129
+ wanted = Internal::RequestKey.for(query, variables, operation_name)
130
+ @entries.find { |entry| Internal::RequestKey.for_entry(entry) == wanted }
105
131
  end
106
132
 
107
133
  # every variables hash recorded for this query — what a miss needs
108
134
  # to show, since the variables are what usually differ
109
135
  def variants(query, operation_name = nil)
110
- normalized = self.class.normalize_query(query)
136
+ normalized = Internal::RequestKey.normalize_query(query)
111
137
  @entries.select do |entry|
112
- self.class.normalize_query(entry["query"]) == normalized && entry["operationName"] == operation_name
138
+ Internal::RequestKey.normalize_query(entry["query"]) == normalized && entry["operationName"] == operation_name
113
139
  end.map { |entry| entry["variables"] || {} }
114
140
  end
115
141
 
116
142
  def record(query, variables, response, operation_name = nil)
117
143
  entry = { "query" => query }
118
144
  entry["operationName"] = operation_name if operation_name
119
- entry["variables"] = self.class.normalize_variables(variables)
145
+ entry["variables"] = Internal::RequestKey.normalize_variables(variables)
120
146
  entry["response"] = response
121
147
 
122
- wanted = self.class.key(query, variables, operation_name)
123
- @entries.reject! { |existing| self.class.entry_key(existing) == wanted }
124
- @entries << entry
125
- save
148
+ wanted = Internal::RequestKey.for(query, variables, operation_name)
149
+ @lock.synchronize do
150
+ @entries.reject! { |existing| Internal::RequestKey.for_entry(existing) == wanted }
151
+ @entries << entry
152
+ save
153
+ end
126
154
  end
127
155
 
128
156
  # Replay every recording through `modules` — the generated query
@@ -138,10 +166,10 @@ module GraphWeaver
138
166
  # Matching is on the query text, which is the module that sent it — a
139
167
  # recording no module sends is skipped rather than guessed at.
140
168
  def check(modules)
141
- index = modules.to_h { |mod| [self.class.normalize_query(mod.const_get(:QUERY)), mod] }
169
+ index = modules.to_h { |mod| [Internal::RequestKey.normalize_query(mod.const_get(:QUERY)), mod] }
142
170
  checked = 0
143
171
  stale = @entries.filter_map do |entry|
144
- mod = index[self.class.normalize_query(entry["query"])] or next
172
+ mod = index[Internal::RequestKey.normalize_query(entry["query"])] or next
145
173
  checked += 1
146
174
 
147
175
  begin
@@ -158,51 +186,37 @@ module GraphWeaver
158
186
  # Replace recorded response values with fakes, preserving structure.
159
187
  # Walks each entry's query against the schema (like FakeClient,
160
188
  # but transforming what's there instead of generating from scratch).
161
- def anonymize!(schema:, seed: nil, mode: nil)
162
- anonymizer = Anonymizer.new(schema:, seed:, mode:)
189
+ def anonymize!(schema:, seed: nil, values: nil)
190
+ anonymizer = Anonymizer.new(schema:, seed:, values:)
163
191
  @entries.each do |entry|
164
- data = entry.dig("response", "data")
165
- entry["response"]["data"] = anonymizer.anonymize(entry["query"], data) if data
192
+ entry["response"] = anonymizer.anonymize(entry["query"], entry["response"]) if entry["response"]
166
193
  end
167
194
  save
168
195
  self
169
196
  end
170
197
 
171
- # The request's identity, exactly as the server sees it. operationName
172
- # is part of that: it picks the operation the document runs, so two
173
- # requests with identical text but different names are different
174
- # requests. Derived, never stored — the file holds the request once,
175
- # so a hand-edited entry can't disagree with what replay matches on.
176
- def self.key(query, variables, operation_name = nil)
177
- key = { "query" => normalize_query(query), "variables" => normalize_variables(variables) }
178
- key["operationName"] = operation_name if operation_name
179
- key
180
- end
181
-
182
- def self.entry_key(entry)
183
- key(entry["query"], entry["variables"], entry["operationName"])
184
- end
185
-
186
- def self.normalize_query(query) = query.gsub(/\s+/, " ").strip
187
-
188
- # one readable line: an error naming a 60-line query is a wall, not a hint
189
- def self.summarize(query, limit: 160)
190
- normalized = normalize_query(query)
191
- (normalized.length > limit) ? "#{normalized[0, limit]}…" : normalized
192
- end
193
-
194
- # JSON round-trip so symbol keys become strings — otherwise YAML.dump
195
- # writes Ruby symbols the safe loader rejects on the next run, and lookup
196
- # keys stay stable across processes
197
- def self.normalize_variables(variables)
198
- JSON.parse(JSON.generate(variables || {}))
199
- end
200
-
201
198
  private
202
199
 
203
200
  def save
201
+ yaml = YAML.dump(@entries)
204
202
  FileUtils.mkdir_p(File.dirname(@path))
205
- File.write(@path, YAML.dump(@entries))
203
+ GraphWeaver::Internal::Util.atomic_write(@path, yaml)
204
+ flag_credentials(yaml)
205
+ end
206
+
207
+ # Once per shape per cassette: a recording run saves after every
208
+ # request, and one line is a warning where forty is noise. On stderr
209
+ # rather than GraphWeaver.logger — the logger is silent by default,
210
+ # and this has to reach whoever is about to commit the file.
211
+ def flag_credentials(yaml)
212
+ found = CREDENTIAL_SHAPES.reject { |name, _| @flagged.include?(name) }
213
+ .select { |_, pattern| pattern.match?(yaml) }.keys
214
+ return if found.empty?
215
+
216
+ @flagged.concat(found)
217
+ warn "graph_weaver: #{GraphWeaver::Internal::Util.relative(@path)} contains #{found.join(", ")} — a cassette is committed as " \
218
+ "written, so review this one first. Testing.config.anonymize scrubs the response; the " \
219
+ "query and variables are the replay key and are recorded verbatim."
206
220
  end
207
221
  end
208
222
 
@@ -227,9 +241,7 @@ module GraphWeaver
227
241
 
228
242
  def execute(query, variables: {}, operation_name: nil)
229
243
  response = @client.execute(query, variables:, operation_name:).to_h
230
- if @anonymizer && (data = response["data"])
231
- response = response.merge("data" => @anonymizer.anonymize(query, data))
232
- end
244
+ response = @anonymizer.anonymize(query, response) if @anonymizer
233
245
 
234
246
  @cassette.record(query, variables, response, operation_name)
235
247
  response
@@ -258,20 +270,54 @@ module GraphWeaver
258
270
  # fake values. Enums, booleans, __typename, and null positions are
259
271
  # preserved; ids map consistently so relationships survive.
260
272
  class Anonymizer
261
- include GraphWeaver::Selection
273
+ include GraphWeaver::Internal::Selection
274
+
275
+ # Keys under `errors`/`extensions` whose value describes the request
276
+ # rather than carrying data: `path` and `locations` point into the
277
+ # document, and `code` is the errors-world enum — call sites branch on
278
+ # it exactly as they branch on an enum in `data`, which is preserved
279
+ # for the same reason.
280
+ VERBATIM_KEYS = %w[path locations code].freeze
262
281
 
263
- def initialize(schema:, seed: nil, mode: nil)
282
+ def initialize(schema:, seed: nil, values: nil)
264
283
  @schema = schema
265
- @values = Values.new(seed:, mode:)
284
+ @values = Internal::Values.new(seed:, values:)
266
285
  end
267
286
 
268
- def anonymize(query, data)
269
- operation = load_operation(query)
287
+ # The whole response, not just `data`: an error message routinely
288
+ # quotes the input that caused it, and `extensions` is whatever the
289
+ # server felt like attaching. One rule — `data` is walked against the
290
+ # schema, everything else by shape.
291
+ def anonymize(query, response)
292
+ response.to_h do |key, value|
293
+ [key, (key == "data") ? data_value(query, value) : untyped_value(key, value)]
294
+ end
295
+ end
296
+
297
+ private
270
298
 
299
+ def data_value(query, data)
300
+ return if data.nil?
301
+
302
+ operation = load_operation(query)
271
303
  object_value(operation_root_type(operation), operation.selections, data)
272
304
  end
273
305
 
274
- private
306
+ # No schema stands behind errors or extensions, so shape is all there
307
+ # is to preserve: keys, nesting, list lengths, nulls and booleans
308
+ # survive; every string and number is replaced.
309
+ def untyped_value(key, value)
310
+ return value if VERBATIM_KEYS.include?(key)
311
+
312
+ case value
313
+ when Hash then value.to_h { |name, nested| [name, untyped_value(name, nested)] }
314
+ when Array then value.map { |element| untyped_value(key, element) }
315
+ when String then @values.scalar("String", key)
316
+ when Integer then @values.scalar("Int", key)
317
+ when Float then @values.scalar("Float", key)
318
+ else value
319
+ end
320
+ end
275
321
 
276
322
  # Anonymization walks recorded data, not a live dispatch. When the query
277
323
  # narrows an abstract type without selecting __typename (`named { name
@@ -295,54 +341,61 @@ module GraphWeaver
295
341
  end
296
342
 
297
343
  result = {}
298
- each_field(type, selections) do |key, node|
344
+ # gather (not each_field) so a key selected twice — `a { x } a { y }` —
345
+ # keeps the MERGED shape codegen's struct expects, not last-writer-wins
346
+ gather(type, selections).each do |key, nodes|
299
347
  next unless data.key?(key)
300
348
 
349
+ node = nodes.first
301
350
  result[key] = if node.name == "__typename"
302
351
  data[key]
303
352
  else
304
- field_value(type, node, data[key])
353
+ field_value(type, node.name, nodes.flat_map(&:selections), data[key])
305
354
  end
306
355
  end
307
356
 
308
357
  result
309
358
  end
310
359
 
311
- def field_value(parent_type, node, value)
360
+ def field_value(parent_type, name, selections, value)
312
361
  # a field from a `... on Member` fragment lives on the member, not the
313
362
  # abstract type we're walking (no __typename to narrow by), so fall back
314
363
  # to whichever possible type declares it
315
- field = @schema.get_field(parent_type.graphql_name, node.name) ||
316
- @schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name, node.name) }.first
317
- type_value(field.type, node, value)
364
+ field = @schema.get_field(parent_type.graphql_name, name) ||
365
+ @schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name, name) }.first
366
+ type_value(field.type, name, selections, value, "#{parent_type.graphql_name}.#{name}")
318
367
  end
319
368
 
320
- def type_value(type, node, value)
369
+ def type_value(type, name, selections, value, coordinate = nil)
321
370
  return if value.nil? # preserve null positions
322
371
 
323
372
  case type.kind.name
324
373
  when "NON_NULL"
325
- type_value(type.of_type, node, value)
374
+ type_value(type.of_type, name, selections, value, coordinate)
326
375
  when "LIST"
327
- value.map { |element| type_value(type.of_type, node, element) }
376
+ value.map { |element| type_value(type.of_type, name, selections, element, coordinate) }
328
377
  when "SCALAR"
329
- scalar_value(type.graphql_name, node.name, value)
378
+ scalar_value(type.graphql_name, name, value, coordinate)
330
379
  when "ENUM"
331
380
  value # enums aren't PII; preserving them keeps semantics
332
381
  when "OBJECT", "UNION", "INTERFACE"
333
- object_value(type, node.selections, value)
382
+ object_value(type, selections, value)
334
383
  else
335
384
  value
336
385
  end
337
386
  end
338
387
 
339
- def scalar_value(type_name, field_name, value)
388
+ def scalar_value(type_name, field_name, value, coordinate = nil)
340
389
  case type_name
341
390
  when "ID" then @values.mapped_id(value)
342
391
  when "Boolean" then value # not PII; preserves branching behavior
343
- else @values.scalar(type_name, field_name)
392
+ else @values.scalar(type_name, field_name, coordinate)
344
393
  end
345
394
  end
346
395
  end
396
+
397
+ # the scrubbing walk anonymize! runs — reached through a cassette,
398
+ # never named
399
+ private_constant :Anonymizer
347
400
  end
348
401
  end
@@ -26,7 +26,7 @@ module GraphWeaver
26
26
  # plan stitches across a boundary. `absent` names the subgraphs that
27
27
  # plan reaches which nothing in this process serves — plannable and
28
28
  # runnable-here are different questions.
29
- Result = Struct.new(:path, :subgraph, :absent, :category, :detail) do
29
+ Result = Data.define(:path, :subgraph, :absent, :category, :detail) do
30
30
  def servable? = category.nil? && absent.empty?
31
31
  end
32
32
 
@@ -35,32 +35,26 @@ module GraphWeaver
35
35
  .transform_values(&:first)
36
36
  .merge(invalid: "doesn't validate against the supergraph")
37
37
  .freeze
38
+ private_constant :LABELS
38
39
 
39
40
  attr_reader :results
40
41
 
41
42
  def initialize(supergraph:, queries: GraphWeaver.queries_paths, fragments: GraphWeaver.fragments_paths)
42
43
  source = supergraph.to_s
43
44
  table = GraphWeaver::SchemaLoader.routing_table(source)
44
- unless table.unsupported.empty?
45
- # the same refusal the Router makes at construction: with the table
46
- # incomplete, every number this would report is a guess
47
- raise Unplannable.new(
48
- "this supergraph uses federation constructs the local router doesn't read: " +
49
- table.unsupported.join("; "),
50
- category: :unsupported_federation,
51
- )
52
- end
45
+ # with the table incomplete, every number this would report is a guess
46
+ Unplannable.unsupported!(table)
53
47
 
54
48
  # Planning still runs with `absent` empty — a query is plannable or
55
49
  # not whoever is serving. Which subgraphs are *here* is asked
56
50
  # separately, from evidence (a loaded schema defining what the table
57
51
  # says one resolves), and never refuses: with nothing loaded the
58
52
  # answer is simply "none", which is the SDL-alone CI run.
59
- @planner = Router::Planner.new(table:, schema: GraphWeaver::SchemaLoader.load(source))
60
- @absent = table.subgraphs.reject { |name| Subgraphs.candidates(table, name).any? }
53
+ @planner = Internal::Planner.new(table:, schema: GraphWeaver::SchemaLoader.load(source))
54
+ @absent = table.subgraphs.reject { |name| Internal::Subgraphs.served?(table, name) }
61
55
  @local = @absent.size < table.subgraphs.size
62
56
  @shared = GraphWeaver::Codegen.load_fragments(fragments)
63
- @results = GraphWeaver.query_files(queries).map { |path| measure(path) }
57
+ @results = GraphWeaver::Internal::Util.query_files(queries).map { |path| measure(path) }
64
58
  end
65
59
 
66
60
  def plannable = @results.count { |result| result.category.nil? }
@@ -144,8 +138,10 @@ module GraphWeaver
144
138
  # the directory every query came from, so the report's file column is
145
139
  # filenames rather than the same path repeated
146
140
  def shared_dir
147
- dirs = @results.map { |result| File.dirname(result.path) }.uniq
148
- dirs.one? ? "#{dirs.first}/" : ""
141
+ @shared_dir ||= begin
142
+ dirs = @results.map { |result| File.dirname(result.path) }.uniq
143
+ dirs.one? ? "#{dirs.first}/" : ""
144
+ end
149
145
  end
150
146
 
151
147
  def measure(path)
@@ -22,19 +22,23 @@ module GraphWeaver
22
22
  include Kernel # for sorbet
23
23
  module_function
24
24
 
25
- # the request never reaches the server — cause preserved, like the
26
- # bundled transports do
25
+ # the request never reaches the server — cause preserved, and the
26
+ # message shaped as the bundled transports shape it
27
27
  def transport(message = "simulated network failure", cause: SocketError)
28
28
  FailureClient.new do
29
29
  raise cause, message
30
30
  rescue cause => e
31
- raise GraphWeaver::TransportError, e.message
31
+ raise GraphWeaver::TransportError, "#{e.class}: #{e.message}"
32
32
  end
33
33
  end
34
34
 
35
- # the server answered non-2xx
36
- def server(status: 500, body: "simulated server error")
37
- FailureClient.new { raise GraphWeaver::ServerError.new(status:, body:) }
35
+ # The server answered non-2xx. headers: is where the answer to "wait,
36
+ # then" lives ServerError#retry_after and #throttled? read it, so a
37
+ # backoff is only exercised by a failure that carries one:
38
+ #
39
+ # Failure.server(status: 429, headers: { "retry-after" => "2" })
40
+ def server(status: 500, body: "simulated server error", headers: {})
41
+ FailureClient.new { raise GraphWeaver::ServerError.new(status:, body:, headers:) }
38
42
  end
39
43
 
40
44
  # top-level GraphQL errors: strings, or hashes with message/path/
@@ -51,8 +55,10 @@ module GraphWeaver
51
55
  end
52
56
 
53
57
  def throttled
54
- # array-wrapped so the hash can't parse as kwargs
55
- graphql([{ message: "rate limited", extensions: { code: "THROTTLED" } }])
58
+ # a code from the list #throttled? recognizes, not one spelled here —
59
+ # a fake that doesn't trip the predicate it exists to exercise is worse
60
+ # than no fake. (array-wrapped so the hash can't parse as kwargs)
61
+ graphql([{ message: "rate limited", extensions: { code: GraphWeaver::GraphQLError::THROTTLE_CODES.first } }])
56
62
  end
57
63
 
58
64
  # A validation-shaped rejection — trips schema_stale? and its