graph_weaver 0.6.1 → 0.7.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. metadata +56 -1
@@ -14,7 +14,9 @@
14
14
  # queries — so which question you're asking is the task name:
15
15
  #
16
16
  # rake graph_weaver:generate # queries_paths -> generated_paths.first
17
+ # rake graph_weaver:graphs # which graphs this app has, and where each writes
17
18
  # rake graph_weaver:verify # fail if generated files are stale (CI)
19
+ # rake graph_weaver:unused # report selections no code reads
18
20
  # rake graph_weaver:queries:check # fail if a query no longer validates (CI)
19
21
  # rake graph_weaver:schema:diff # fail if the server has drifted from the dump
20
22
  # rake graph_weaver:schema:refresh # re-introspect and rewrite the dump
@@ -28,20 +30,144 @@ module GraphWeaver
28
30
  module Internal
29
31
  # helpers the rake tasks share
30
32
  module Tasks
31
- # The composed supergraph a federation task reads: SUPERGRAPH=, else the
32
- # conventional dump when that is what it is. Aborts naming the task, so
33
- # the message says the command to retype.
34
- def self.supergraph!(task)
35
- ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path ||
36
- abort("pass the composed supergraph: rake graph_weaver:federation:#{task} " \
37
- "SUPERGRAPH=supergraph.graphql")
33
+ # What a federation task runs over: SUPERGRAPH= for one run, else every
34
+ # declared graph that names a composed supergraph an app that said
35
+ # where its supergraph is has already answered. Each entry is the graph
36
+ # and its supergraph; SUPERGRAPH= has no graph behind it, so a
37
+ # single-schema app's output is what it always was.
38
+ def self.supergraphs!(task)
39
+ return [[nil, ENV["SUPERGRAPH"]]] if ENV["SUPERGRAPH"]
40
+
41
+ found = GraphWeaver.graphs.filter_map do |graph|
42
+ supergraph = graph.supergraph
43
+ [graph, supergraph] if supergraph
44
+ end
45
+ found.empty? ? abort(no_supergraph(task)) : found
46
+ end
47
+
48
+ # The other half of :diff — every task here walks the supergraph's
49
+ # subgraph list, so a subgraph dropped from the composition whose Ruby
50
+ # class is still loaded was invisible to all of them. Asked once over
51
+ # the whole run: a multi-graph app's other supergraph places its own
52
+ # schemas, so only one no supergraph here has a subgraph for is named.
53
+ #
54
+ # A warning, not a failure. A process that loads a subgraph of a
55
+ # supergraph this run never reads is the same picture — the gem's own
56
+ # suite is one — and failing on it would break a setup that is fine.
57
+ def self.warn_unplaced(drifts)
58
+ unplaced = drifts.map(&:unplaced).reduce(:&).to_a
59
+ return if unplaced.empty?
60
+
61
+ names = drifts.flat_map(&:subgraphs).uniq.sort
62
+ warn "", "not placed — no subgraph of any supergraph read here is:",
63
+ *unplaced.map { |schema| " #{schema.name}" },
64
+ "A subgraph retired from a composition leaves exactly this behind, and so does a " \
65
+ "process that loads a subgraph of a supergraph nobody here reads — which is why " \
66
+ "this is a warning and not drift. The subgraphs read here: #{names.join(", ")}."
67
+ end
68
+
69
+ # SUPERGRAPH= names the supergraph the federation:* tasks run over, and
70
+ # nothing else can honour it: every other task reads the schema its
71
+ # graphs declare, and pointing one at an ad-hoc supergraph would collapse
72
+ # a multi-graph app into a single unnamed graph — which for `generate`
73
+ # means pruning the generated files of every graph it didn't cover. So
74
+ # refuse, rather than report a verdict about a different schema than the
75
+ # one just typed.
76
+ def self.refuse_supergraph_flag!
77
+ return unless ENV["SUPERGRAPH"]
78
+
79
+ abort "SUPERGRAPH= applies to the federation:* tasks (diff, subgraphs, coverage) — " \
80
+ "every other task reads the schema its graph declares. Name it there so every run " \
81
+ "finds it: GraphWeaver.graph(:api) { schema \"supergraph.graphql\" }."
82
+ end
83
+
84
+ # The spellings a CI config turns a flag off with. STRICT=0 used to be
85
+ # "set, therefore on" — the one answer nobody means by it.
86
+ OFF = %w[0 false no off].freeze
87
+
88
+ # An ENV flag, as a boolean: unset, empty and the OFF spellings are off,
89
+ # anything else is on.
90
+ def self.flag?(name)
91
+ value = ENV[name].to_s.strip
92
+ !value.empty? && !OFF.include?(value.downcase)
93
+ end
94
+
95
+ # Every directory the app's graphs read queries from, and whether any
96
+ # holds one. Two tasks reach an empty app and both should say the same
97
+ # sentence about it rather than report 0 of 0 and advise a generate that
98
+ # would generate nothing.
99
+ def self.query_dirs = GraphWeaver.graphs.flat_map(&:queries).uniq
100
+
101
+ def self.no_queries = "no queries in #{query_dirs.join(", ")}"
102
+
103
+ def self.queries? = GraphWeaver::Internal::Util.query_files(query_dirs).any?
104
+
105
+ # A section heading, so a multi-graph app can tell whose report it is
106
+ # reading. Nothing for the default graph: a single-schema app never
107
+ # said the word "graph" and its output shouldn't either.
108
+ def self.heading(graph) = ("graph #{graph.name.inspect}" if graph&.name)
109
+
110
+ # Which graphs a verdict is about — "graph :a, graph :b: " — or nothing
111
+ # at all, so a single-schema app's aborts read exactly as they did.
112
+ def self.whose(graphs)
113
+ named = graphs.filter_map { |graph| heading(graph) }
114
+ named.empty? ? "" : "#{named.join(", ")}: "
115
+ end
116
+
117
+ # Nothing composed anywhere. Says where it looked — one line per graph,
118
+ # because the adopter's question is "why didn't it find mine" — then the
119
+ # two ways to answer it.
120
+ def self.no_supergraph(task)
121
+ declared = GraphWeaver.graphs
122
+ example = declared.map(&:name).compact.first || :api
123
+ ["no composed supergraph here — a federation task reads the @join__* routing table, " \
124
+ "and nothing this app declares carries one:",
125
+ *declared.map { |graph| " #{looked_at(graph)}" },
126
+ "Pass one for this run — rake graph_weaver:federation:#{task} " \
127
+ "SUPERGRAPH=supergraph.graphql — or name it where the graph is declared, so every " \
128
+ "run finds it: GraphWeaver.graph(#{example.inspect}) { schema \"supergraph.graphql\" }."]
129
+ .join("\n")
130
+ end
131
+
132
+ # Where one graph's schema came from, in the three shapes it comes in.
133
+ def self.looked_at(graph)
134
+ label = graph.name ? "graph #{graph.name.inspect}" : "this app's schema"
135
+ path = graph.dump_path
136
+ return "#{label}: #{GraphWeaver::Internal::Util.relative(path)}" if path
137
+
138
+ live = graph.live_schema
139
+ found =
140
+ if live then "#{live.name}, a live class — composition is what writes a routing table"
141
+ else "nothing on disk at #{GraphWeaver.schema_path}"
142
+ end
143
+ "#{label}: #{found}"
38
144
  end
145
+ private_class_method :looked_at
39
146
 
40
- # Registrations the run couldn't match, once each. The logger is the
41
- # runtime channel and is silent by default; this task's own output is the
42
- # build channel, and the build is where someone regenerating is looking.
43
- def self.report_unmatched
147
+ # What the run found worth saying about the registry, once each: the
148
+ # registrations it couldn't match, and the scalars nothing registered.
149
+ # The logger is the runtime channel and is silent by default (in Rails it
150
+ # writes to a file); this task's own output is the build channel, and the
151
+ # build is where someone regenerating is looking — so both advisories go
152
+ # there rather than one each way.
153
+ def self.report_registry
44
154
  GraphWeaver.unmatched_registrations.each { |message| puts message }
155
+ report = GraphWeaver::Internal::Util.untyped_scalars_report(GraphWeaver.untyped_scalars_by_graph)
156
+ puts report if report
157
+ end
158
+
159
+ # What one graph registers, for the graphs task — the answer an app
160
+ # otherwise reads out of every initializer by hand. A pre-registered
161
+ # built-in isn't app intent; one an app has REPLACED is, which is why
162
+ # this asks whether the entry is still the library's rather than
163
+ # dropping the built-in names.
164
+ def self.registrations(graph)
165
+ registry = graph.registry
166
+ {
167
+ "scalars" => registry.scalar_registry.keys.reject { |name| registry.builtin_scalar?(name) },
168
+ "enums" => registry.enum_registry.keys,
169
+ "extend_type" => registry.type_registry.keys,
170
+ }.filter_map { |kind, names| " #{kind}: #{names.sort.join(", ")}" if names.any? }
45
171
  end
46
172
 
47
173
  # Neither task that needs the committed dump can take one itself, so both
@@ -50,6 +176,35 @@ module GraphWeaver
50
176
  "no schema dump at #{GraphWeaver.schema_path} — take one: " \
51
177
  "rake graph_weaver:schema:refresh URL=https://api.example.com/graphql"
52
178
  end
179
+
180
+ # What the schema tasks work on, one entry per graph: the dump
181
+ # generation reads and the source behind it.
182
+ #
183
+ # One rule — the dump is the contract generation reads; `refresh`
184
+ # rewrites it from the graph's source, `diff` says how far that source
185
+ # has drifted from it, whichever the source is. A graph whose schema IS
186
+ # a live class reads no dump at all, so it has neither.
187
+ def self.dumps = GraphWeaver.graphs.map { |graph| [graph, graph.dump_path, graph.dump_source] }
188
+
189
+ # A graph that generates straight from a schema class has no dump
190
+ # between the code and the output — so there is nothing here to
191
+ # refresh or compare, and nothing that can be stale.
192
+ def self.no_dump_needed(graph, source)
193
+ whose = heading(graph) || "this app"
194
+ # a dump this graph names and hasn't written yet — refresh writes it
195
+ # from the url its modules post to, so there is one task to name
196
+ if (missing = graph.named_dump_path)
197
+ return "#{whose}: no schema dump at #{missing} yet — take one: " \
198
+ "rake graph_weaver:schema:refresh"
199
+ end
200
+ return "#{whose}: #{no_dump}" unless source
201
+
202
+ "#{whose} generates from #{source_name(source)} directly — no dump to keep in step"
203
+ end
204
+
205
+ # How a dump's source reads in a report: a url as itself, a schema
206
+ # class by name.
207
+ def self.source_name(source) = source.is_a?(Module) ? GraphWeaver::SchemaLoader.endpoint(source) : source
53
208
  end
54
209
  end
55
210
  end
@@ -73,13 +228,22 @@ namespace :graph_weaver do
73
228
  GraphWeaver.skip_generated_load = false
74
229
  end
75
230
 
231
+ # Plumbing, like :environment: the dependency every task that reads the
232
+ # schema its graphs declare carries, so a SUPERGRAPH= it cannot honour is
233
+ # refused once rather than ignored nine times.
234
+ task own_schema: :environment do
235
+ GraphWeaver::Internal::Tasks.refuse_supergraph_flag!
236
+ end
237
+
76
238
  # the default, not GraphWeaver.queries_paths: a desc is baked when this file
77
239
  # loads, which in Rails is before :environment has run an initializer that
78
240
  # moves it — interpolating would print the default as though it were the setting
79
241
  desc "Generate typed query modules (default app/graphql/queries -> app/graphql/generated)"
80
- task generate: :environment do
81
- glob = File.join(GraphWeaver::Internal::Util.resolve(GraphWeaver.generated_paths.first), "**/*.rb")
82
- before = Dir[glob]
242
+ task generate: :own_schema do
243
+ # every graph's output, not just the default one's: this is the "pruned"
244
+ # report, and a file deleted in one graph is as much a diff as in another
245
+ globs = GraphWeaver.graphs.map { |graph| File.join(GraphWeaver::Internal::Util.resolve(graph.output), "**/*.rb") }
246
+ before = globs.flat_map { |glob| Dir[glob] }
83
247
 
84
248
  # schema auto-located at GraphWeaver.schema_path, any supported extension
85
249
  written = GraphWeaver.generate!
@@ -88,57 +252,149 @@ namespace :graph_weaver do
88
252
  puts "#{written.size - changed.size} already up to date" if changed.size < written.size
89
253
  # generated files are checked in, so a delete this task made is a diff the
90
254
  # user is about to find; a run that printed nothing at all had done both
91
- (before - Dir[glob]).each { |path| puts "pruned #{GraphWeaver::Internal::Util.relative(path)}" }
92
- puts "no queries in #{GraphWeaver.queries_paths.join(", ")}" if written.empty?
93
- GraphWeaver::Internal::Tasks.report_unmatched
255
+ (before - globs.flat_map { |glob| Dir[glob] }).each do |path|
256
+ puts "pruned #{GraphWeaver::Internal::Util.relative(path)}"
257
+ end
258
+ puts GraphWeaver::Internal::Tasks.no_queries if written.empty?
259
+ GraphWeaver::Internal::Tasks.report_registry
94
260
  rescue GraphWeaver::Error => e
95
261
  # a typo'd query is a user error — the message names file, position and
96
262
  # fix, and a rake backtrace through codegen only buries it
97
263
  abort e.message
98
264
  end
99
265
 
266
+ # `rake -T` can't name them: a desc is baked when this file loads, and in
267
+ # Rails that is before :environment, so before the initializer that declares
268
+ # them has run. This is the task that can.
269
+ desc "List the configured graphs and where each one generates"
270
+ task graphs: :own_schema do
271
+ GraphWeaver.graphs.each do |graph|
272
+ name = graph.name ? graph.name.inspect : "(the default graph — GraphWeaver's own settings)"
273
+ puts "#{name} #{Array(graph.queries).join(", ")} -> #{GraphWeaver::Internal::Util.relative(graph.output)}"
274
+ puts " namespace: #{graph.namespace}" if graph.namespace
275
+ # which server a graph's modules call — the one thing this task couldn't
276
+ # say. A graph that bakes none falls back to GraphWeaver.client, which is
277
+ # an app-wide setting and not this task's subject.
278
+ puts " client: #{graph.client}" if graph.client
279
+ # a registration is scoped to one graph, and nothing else says which
280
+ GraphWeaver::Internal::Tasks.registrations(graph).each { |line| puts line }
281
+ end
282
+ end
283
+
100
284
  desc "Verify generated query modules are up to date"
101
- task verify: :environment do
285
+ task verify: :own_schema do
102
286
  GraphWeaver.verify_generated!
103
287
  puts "generated queries up to date"
104
- GraphWeaver::Internal::Tasks.report_unmatched
288
+ GraphWeaver::Internal::Tasks.report_registry
289
+ rescue GraphWeaver::Error => e
290
+ abort e.message
291
+ end
292
+
293
+ # The over-fetch nothing else here can see. verify says the Ruby matches the
294
+ # query; this asks whether the query still asks for what the app uses — the
295
+ # drift that happens on the app's side, silently, when a template stops
296
+ # reading a field. Nothing is edited, and the default exit is 0: a finding is
297
+ # a prompt to look.
298
+ desc "Report selections whose generated props no code reads (PATHS= to scope, STRICT=1 to fail)"
299
+ task unused: :own_schema do
300
+ require "graph_weaver/internal/unused"
301
+
302
+ # an app with no queries has nothing to over-fetch: sweeping it to report
303
+ # 0 of 0 and advise `rake graph_weaver:generate` answers a question nobody
304
+ # asked, where its sibling says the one true thing in one line
305
+ next puts GraphWeaver::Internal::Tasks.no_queries unless GraphWeaver::Internal::Tasks.queries?
306
+
307
+ # like cassettes:check, this reads generated modules — they are what says
308
+ # which props a query produced
309
+ GraphWeaver.load_generated!
310
+ unused = GraphWeaver::Internal::Unused.new(paths: ENV["PATHS"]&.split(","))
311
+ puts unused.report
312
+
313
+ # abort writes to unbuffered stderr; the report above went to
314
+ # block-buffered stdout, so a piped CI log shows it first
315
+ $stdout.flush
316
+ count = unused.findings.size
317
+ if count.positive? && GraphWeaver::Internal::Tasks.flag?("STRICT")
318
+ abort "#{count} #{(count == 1) ? "selection" : "selections"} nothing reads — drop " \
319
+ "#{(count == 1) ? "it" : "them"} from the query and regenerate (rake graph_weaver:generate)"
320
+ end
105
321
  rescue GraphWeaver::Error => e
106
322
  abort e.message
107
323
  end
108
324
 
109
325
  namespace :schema do
110
- # both re-introspect from the url recorded in the dump
111
- # (GRAPHWEAVER_AUTH supplies a token for private APIs)
112
-
113
- desc "Fail when the server's schema has drifted from the local dump"
114
- task diff: :environment do
115
- path = GraphWeaver::SchemaLoader.locate_path or abort GraphWeaver::Internal::Tasks.no_dump
116
- diff = GraphWeaver::SchemaLoader.diff(path)
117
- dump = GraphWeaver::Internal::Util.relative(path)
118
- if diff.empty?
119
- puts "#{dump} matches the server"
120
- else
326
+ # One rule, per graph: the dump is the contract generation reads;
327
+ # :refresh rewrites it from the graph's source and :diff says how far
328
+ # that source has drifted from it — whichever the source is. A url is
329
+ # re-introspected (GRAPHWEAVER_AUTH supplies a token for private APIs);
330
+ # a graphql-ruby schema class this process runs answers introspection
331
+ # itself, so an app that serves its own schema needs no network.
332
+
333
+ desc "Fail when the schema behind the dump has drifted from it"
334
+ task diff: :own_schema do
335
+ subjects = GraphWeaver::Internal::Tasks.dumps
336
+ abort GraphWeaver::Internal::Tasks.no_dump if subjects.none? { |_, path, _| path }
337
+
338
+ stale = subjects.filter_map do |graph, path, source|
339
+ heading = GraphWeaver::Internal::Tasks.heading(graph)
340
+ puts heading if heading
341
+ # a graph that names a live class generates straight from it: no
342
+ # dump between the code and the output, so nothing can be stale
343
+ next puts GraphWeaver::Internal::Tasks.no_dump_needed(graph, source) unless path
344
+
345
+ # a schema class answers introspection itself; left nil, diff builds
346
+ # the dump's own transport, auth and all
347
+ diff = GraphWeaver::SchemaLoader.diff(path, transport: (source if source.is_a?(Module)))
348
+ dump = GraphWeaver::Internal::Util.relative(path)
349
+ next puts "#{dump} matches #{GraphWeaver::Internal::Tasks.source_name(source)}" if diff.empty?
350
+
121
351
  puts diff.report
122
- # abort writes to unbuffered stderr; the summary above went to
123
- # block-buffered stdout, so a piped CI log shows it first
124
- $stdout.flush
125
- abort "#{dump} is stale the server's schema has drifted (rake graph_weaver:schema:refresh)"
352
+ dump
353
+ end
354
+
355
+ # abort writes to unbuffered stderr; the summaries above went to
356
+ # block-buffered stdout, so a piped CI log shows them first
357
+ $stdout.flush
358
+ unless stale.empty?
359
+ abort "#{stale.join(", ")} is stale — the schema behind it has drifted " \
360
+ "(rake graph_weaver:schema:refresh)"
126
361
  end
127
362
  rescue GraphWeaver::Error => e
128
- # e.g. a dump with no recorded url — same clean exit as :refresh
363
+ # e.g. a dump that is its own source — same clean exit as :refresh
129
364
  abort e.message
130
365
  end
131
366
 
132
- desc "Re-introspect and rewrite the local dump (URL= to bootstrap the first one)"
133
- task refresh: :environment do
367
+ desc "Rewrite the local dump from the schema behind it (URL= to bootstrap the first one)"
368
+ task refresh: :own_schema do
134
369
  # anything else in URL= reaches introspection as a schema *source*, and
135
370
  # fails talking about file extensions rather than the flag just typed
136
371
  if ENV["URL"] && !ENV["URL"].match?(GraphWeaver::Client::URL)
137
372
  abort "URL= takes an endpoint: rake graph_weaver:schema:refresh URL=https://api.example.com/graphql"
138
373
  end
139
374
 
140
- path, url = GraphWeaver::SchemaLoader.refresh!(url: ENV["URL"])
141
- puts "refreshed #{GraphWeaver::Internal::Util.relative(path)} from #{url}"
375
+ # URL= names one endpoint, so it bootstraps the conventional dump —
376
+ # before any graph has a dump to read a source off
377
+ if ENV["URL"]
378
+ path, source = GraphWeaver::SchemaLoader.refresh!(url: ENV["URL"])
379
+ next puts "refreshed #{GraphWeaver::Internal::Util.relative(path)} from #{source}"
380
+ end
381
+
382
+ GraphWeaver::Internal::Tasks.dumps.each do |graph, path, source|
383
+ heading = GraphWeaver::Internal::Tasks.heading(graph)
384
+ puts heading if heading
385
+ # a graph that names a live class generates straight from it — no
386
+ # dump to write. One that names a dump it hasn't got yet has one, and
387
+ # its source is the url its modules already post to: that bootstraps a
388
+ # second graph's dump, which URL= can't, naming one endpoint for an
389
+ # app with one per graph. The default graph names neither, so it lands
390
+ # below and refresh! bootstraps its first dump (or says how).
391
+ path ||= graph.named_dump_path
392
+ next puts GraphWeaver::Internal::Tasks.no_dump_needed(graph, source) if !path && graph.named_schema?
393
+
394
+ written, from = GraphWeaver::SchemaLoader.refresh!(url: (source unless source.is_a?(Module)),
395
+ schema: (source if source.is_a?(Module)), path:)
396
+ puts "refreshed #{GraphWeaver::Internal::Util.relative(written)} from #{from}"
397
+ end
142
398
  rescue GraphWeaver::Error => e
143
399
  abort e.message
144
400
  end
@@ -146,7 +402,7 @@ namespace :graph_weaver do
146
402
 
147
403
  namespace :queries do
148
404
  desc "Report checked-in queries that no longer validate against the server's schema"
149
- task check: :environment do
405
+ task check: :own_schema do
150
406
  failures = GraphWeaver.check_queries
151
407
  failures.each do |path, errors|
152
408
  puts path
@@ -176,73 +432,100 @@ namespace :graph_weaver do
176
432
  end
177
433
 
178
434
  # needs no network, so it gates a PR the way verify does
179
- desc "Fail when a subgraph here changed and the supergraph wasn't recomposed (SUPERGRAPH=)"
435
+ desc "Fail when a subgraph here changed and the supergraph wasn't recomposed (SUPERGRAPH= overrides)"
180
436
  task diff: :loaded do
181
437
  require "graph_weaver/federation"
182
438
 
183
- supergraph = GraphWeaver::Internal::Tasks.supergraph!("diff")
184
- drift = GraphWeaver::Federation::Drift.new(supergraph:)
185
- puts drift.report
439
+ # every graph's supergraph is its own gate: one that drifted fails the
440
+ # run whatever its neighbours say, and so does one that checked nothing
441
+ checked = GraphWeaver::Internal::Tasks.supergraphs!("diff").map do |graph, supergraph|
442
+ heading = GraphWeaver::Internal::Tasks.heading(graph)
443
+ puts heading if heading
444
+ drift = GraphWeaver::Federation::Drift.new(supergraph:)
445
+ puts drift.report
446
+ puts if heading
447
+ [graph, drift]
448
+ end
186
449
 
187
450
  # A partly-local supergraph is a supported setup, so a subgraph this
188
451
  # process doesn't serve isn't a failure — but comparing against NONE
189
452
  # of them is: the gate passes whatever the subgraphs say, which is
190
453
  # worse than failing.
191
454
  $stdout.flush
192
- abort "the supergraph is out of date recompose it and commit the result" if drift.drift?
193
- if drift.vacuous?
194
- abort "this checked nothing, so it proved nothing. No schema in this process defines what " \
195
- "the supergraph says any of its subgraphs resolves load them (in Rails, that is " \
196
- "config.eager_load / config.rake_eager_load), or, if they all run elsewhere, drop this " \
197
- "task from CI: there is nothing here for it to gate."
455
+ # not .any? the default graph is nil in this slot, and [nil].any? is false
456
+ stale = checked.select { |_, drift| drift.drift? }.map(&:first)
457
+ unless stale.empty?
458
+ abort "#{GraphWeaver::Internal::Tasks.whose(stale)}the supergraph is out of date — " \
459
+ "recompose it and commit the result"
198
460
  end
461
+ vacuous = checked.select { |_, drift| drift.vacuous? }.map(&:first)
462
+ unless vacuous.empty?
463
+ abort "#{GraphWeaver::Internal::Tasks.whose(vacuous)}this checked nothing, so it proved " \
464
+ "nothing. No schema in this process defines what the supergraph says any of its " \
465
+ "subgraphs resolves — load them (in Rails, that is config.eager_load / " \
466
+ "config.rake_eager_load), or, if they all run elsewhere, drop this task from CI: there " \
467
+ "is nothing here for it to gate."
468
+ end
469
+ GraphWeaver::Internal::Tasks.warn_unplaced(checked.map(&:last))
199
470
  rescue GraphWeaver::Error => e
200
471
  abort e.message
201
472
  end
202
473
 
203
- desc "Show which loaded schema serves each subgraph, as a paste-ready map (SUPERGRAPH=)"
474
+ desc "Show which loaded schema serves each subgraph, as a paste-ready map (SUPERGRAPH= overrides)"
204
475
  task subgraphs: :loaded do
205
476
  require "graph_weaver/testing"
206
477
 
207
- supergraph = GraphWeaver::Internal::Tasks.supergraph!("subgraphs")
478
+ GraphWeaver::Internal::Tasks.supergraphs!("subgraphs").each do |graph, supergraph|
479
+ heading = GraphWeaver::Internal::Tasks.heading(graph)
480
+ puts heading if heading
208
481
 
209
- # Testing::Router derives this map itself; this is for reading what
210
- # detection sees when it refuses, and for committing the map instead.
211
- table = GraphWeaver::SchemaLoader.routing_table(supergraph)
212
- rows = table.subgraphs.map do |name|
213
- found = GraphWeaver::Internal::Subgraphs.candidates(table, name)
214
- sought = GraphWeaver::Internal::Subgraphs.expected(table, name)
215
- [name, found, sought]
216
- end
217
- width = rows.map { |name, found, _| %("#{name}" => #{found.first&.name || "nil"},).length }.max
218
-
219
- puts "subgraphs: {"
220
- rows.each do |name, found, sought|
221
- entry = %( "#{name}" => #{found.one? ? found.first.name : "nil"},).ljust(width + 2)
222
- # fields first: every schema has a Query, so only the fields say why
223
- evidence = (sought.grep(/\./) | sought).first(3).join(", ")
224
- note = if found.one?
225
- "# matched: defines #{evidence}"
226
- elsif found.any?
227
- "# AMBIGUOUS: #{found.map(&:name).sort.join(", ")} all match — pick one"
228
- else
229
- "# no loaded schema defines #{evidence} — fill this in"
482
+ # Testing::Router derives this map itself; this is for reading what
483
+ # detection sees when it refuses, and for committing the map instead.
484
+ table = GraphWeaver::SchemaLoader.routing_table(supergraph)
485
+ rows = table.subgraphs.map do |name|
486
+ found = GraphWeaver::Internal::Subgraphs.candidates(table, name)
487
+ sought = GraphWeaver::Internal::Subgraphs.expected(table, name)
488
+ [name, found, sought]
489
+ end
490
+ width = rows.map { |name, found, _| %("#{name}" => #{found.first&.name || "nil"},).length }.max
491
+
492
+ puts "subgraphs: {"
493
+ rows.each do |name, found, sought|
494
+ entry = %( "#{name}" => #{found.one? ? found.first.name : "nil"},).ljust(width + 2)
495
+ # fields first: every schema has a Query, so only the fields say why
496
+ evidence = (sought.grep(/\./) | sought).first(3).join(", ")
497
+ note = if found.one?
498
+ "# matched: defines #{evidence}"
499
+ elsif found.any?
500
+ "# AMBIGUOUS: #{found.map(&:name).sort.join(", ")} all match — pick one"
501
+ else
502
+ "# no loaded schema defines #{evidence} — fill this in"
503
+ end
504
+ puts "#{entry} #{note}"
230
505
  end
231
- puts "#{entry} #{note}"
506
+ puts "}"
507
+ puts if heading
232
508
  end
233
- puts "}"
234
509
  rescue GraphWeaver::Error => e
235
510
  abort e.message
236
511
  end
237
512
 
238
- desc "Report how many queries the local test router can plan (SUPERGRAPH=, QUERIES=)"
513
+ desc "Report how many queries the local test router can plan (SUPERGRAPH=/QUERIES= override)"
239
514
  task coverage: :loaded do
240
515
  require "graph_weaver/testing"
241
516
 
242
- puts GraphWeaver::Testing::Coverage.new(
243
- supergraph: GraphWeaver::Internal::Tasks.supergraph!("coverage"),
244
- queries: ENV["QUERIES"] || GraphWeaver.queries_paths,
245
- ).report
517
+ GraphWeaver::Internal::Tasks.supergraphs!("coverage").each do |graph, supergraph|
518
+ heading = GraphWeaver::Internal::Tasks.heading(graph)
519
+ puts heading if heading
520
+ # the graph's own queries, not the top-level setting: a graph that
521
+ # names its own supergraph names its own queries too, and measuring
522
+ # the neighbour's against this one reports a coverage nobody has
523
+ puts GraphWeaver::Testing::Coverage.new(
524
+ supergraph:,
525
+ queries: ENV["QUERIES"] || (graph ? graph.queries : GraphWeaver.queries_paths),
526
+ ).report
527
+ puts if heading
528
+ end
246
529
  rescue GraphWeaver::Error => e
247
530
  # a supergraph the routing table can't read fully is itself the answer:
248
531
  # nothing is plannable, and the message says which construct
@@ -258,16 +541,22 @@ namespace :graph_weaver do
258
541
  # as a cast error naming a struct and a sorbet frame — nothing points at
259
542
  # the stale file.
260
543
  desc "Fail when a recorded response no longer casts into the generated structs"
261
- task check: :environment do
544
+ task check: :own_schema do
262
545
  require "graph_weaver/testing"
263
546
 
264
547
  # unlike its siblings this task reads generated modules — they are what
265
548
  # a recording is checked against
266
549
  GraphWeaver.load_generated!
267
- modules = GraphWeaver::Internal::Util.query_files.filter_map do |path|
268
- name = GraphWeaver::Internal::Util.module_name(path, File.read(path))
269
- Object.const_get(name) if Object.const_defined?(name)
270
- end
550
+ # asked of each graph, not of the top-level settings: a graph's queries
551
+ # are its own and its constants live under its namespace, so a top-level
552
+ # lookup finds nothing in a namespaced app and then refuses for having
553
+ # checked nothing
554
+ modules = GraphWeaver.graphs.flat_map do |graph|
555
+ GraphWeaver::Internal::Util.query_files(graph.queries).filter_map do |path|
556
+ name = graph.generated_names(path, File.read(path)).first
557
+ Object.const_get(name) if Object.const_defined?(name)
558
+ end
559
+ end.uniq
271
560
 
272
561
  # Testing.cassette_dir, not config.cassette_dir: the configured path is
273
562
  # relative by default and rake runs from wherever it runs from
@@ -281,10 +570,12 @@ namespace :graph_weaver do
281
570
  stale = checks.sum { |check| check.stale.size }
282
571
  $stdout.flush
283
572
  if stale.positive?
573
+ # the structs move when a REGISTRATION moves, not only when the dump
574
+ # does — and re-recording a fresh response doesn't fix that half
284
575
  abort "#{stale} stale #{(stale == 1) ? "recording" : "recordings"} — the recorded server's " \
285
- "answers no longer fit the structs generated from your schema. Re-record " \
286
- "(GRAPHWEAVER_RECORD=1, with a live client:), or regenerate if it was the schema dump " \
287
- "that moved: rake graph_weaver:generate."
576
+ "answers no longer fit the structs generated from your schema. Regenerate if the schema " \
577
+ "dump or a registration moved (rake graph_weaver:generate), or re-record if the server's " \
578
+ "answer did (GRAPHWEAVER_RECORD=1, with a live client:)."
288
579
  end
289
580
  if checks.sum(&:checked).zero?
290
581
  # a green run that compared nothing is worse than a failure: it would
@@ -298,7 +589,7 @@ namespace :graph_weaver do
298
589
  end
299
590
 
300
591
  desc "Anonymize every cassette in Testing.config.cassette_dir (PII-safe to commit)"
301
- task anonymize: :environment do
592
+ task anonymize: :own_schema do
302
593
  require "graph_weaver/testing"
303
594
 
304
595
  # locate, not schema_path: the dump is whichever supported extension is