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
@@ -19,27 +19,32 @@ end
19
19
  # GraphWeaver::Testing.configure do |config|
20
20
  # config.schema = MySchema # overrides the derived schema
21
21
  # config.router = { subgraphs: { "reviews" => :fake } } # or supergraph:,
22
- # # when it isn't the dump
22
+ # # when it isn't the dump, or
23
+ # # fake: for how those fabricate
23
24
  # config.context = { current_user: } # baseline GraphQL context
24
25
  # config.default_mode = :fake # untagged examples (graph_weaver/rspec)
25
26
  # config.seed = 42 # reproducible fakes
26
- # config.mode = :faker # or :literal; nil = auto
27
27
  # config.overrides = { "Person.name" => "Daniel" }
28
28
  # config.list_size = 2..4
29
- # config.null_chance = 0.1 # nullable fields go nil sometimes
30
29
  # config.cassette_dir = "spec/cassettes"
31
30
  # end
32
31
  #
33
- # mode picks how values are fabricated:
34
- # :faker — semantic, field-name matched (requires the faker gem)
35
- # :literal plain type-derived values ("name-1", seeded numbers)
36
- # nil — auto: :faker when the gem is loaded, else :literal
32
+ # Anything whose honest answer differs per example belongs on the fake, not
33
+ # here: graphql_fake(null_chance: 1.0) for the empty-state example,
34
+ # graphql_fake(values: :literal) for the one that reads better without
35
+ # faker's prose, and overrides:/list_size: to say what one example's data is.
37
36
  #
38
37
  # rspec users: require "graph_weaver/rspec" instead — it hooks the suite
39
38
  # (seed from rspec, a client per example from its `graphql:` tag).
40
39
  module GraphWeaver
41
40
  module Testing
42
- MODES = [:faker, :literal].freeze
41
+ # How a fake writes its values, named by the `values:` a fake takes —
42
+ # graphql_fake(values: :literal), FakeClient.new(values:):
43
+ #
44
+ # :faker semantic, matched on the field name (needs the faker gem)
45
+ # :literal plain type-derived values ("name-1", seeded numbers)
46
+ # nil auto: :faker when the gem is loaded, else :literal
47
+ VALUE_STYLES = %i[faker literal].freeze
43
48
 
44
49
  # What an example can run against, named by the rspec tag that selects
45
50
  # it — `it "…", graphql: :in_process` (see graph_weaver/rspec):
@@ -50,16 +55,17 @@ module GraphWeaver
50
55
  CLIENT_MODES = %i[fake in_process router].freeze
51
56
 
52
57
  class Config
53
- attr_accessor :schema, :overrides, :seed, :list_size, :null_chance, :cassette_dir, :context,
58
+ attr_accessor :overrides, :seed, :list_size, :cassette_dir, :context,
54
59
  :record, :anonymize
55
- attr_reader :mode, :router, :default_mode
60
+ # #schema is written plainly and read with a fallback (below), the way
61
+ # #router and #default_mode are read plainly and written with a check
62
+ attr_writer :schema
63
+ attr_reader :router, :default_mode
56
64
 
57
65
  def initialize
58
66
  @overrides = {}
59
67
  @seed = nil
60
68
  @list_size = 1..3
61
- @null_chance = 0.0
62
- @mode = nil # auto
63
69
  @schema = nil
64
70
  @located = nil # the committed dump, once located
65
71
  # not under spec/fixtures: `fixtures :all` globs that path for
@@ -97,14 +103,6 @@ module GraphWeaver
97
103
  # that never asks for one.
98
104
  def explicit_schema = @schema
99
105
 
100
- def mode=(mode)
101
- unless mode.nil? || MODES.include?(mode)
102
- raise ArgumentError, "mode: must be one of #{MODES.inspect} (or nil for auto), got #{mode.inspect}"
103
- end
104
-
105
- @mode = mode
106
- end
107
-
108
106
  def default_mode=(mode)
109
107
  unless mode.nil? || CLIENT_MODES.include?(mode)
110
108
  raise ArgumentError,
@@ -115,13 +113,14 @@ module GraphWeaver
115
113
  @default_mode = mode
116
114
  end
117
115
 
118
- # Router arguments — both keys optional, and each answers a different
116
+ # Router arguments — every key optional, and each answers a different
119
117
  # question. `supergraph:` is for one derivation can't find; without it
120
118
  # the conventional dump is used, when that dump is itself a supergraph.
121
119
  # `subgraphs:` is for what derivation can't settle, or for `"reviews"
122
120
  # => :fake`, which fabricates a subgraph this process doesn't serve —
123
121
  # the commonest reason to configure a router at all, and no reason to
124
- # have to restate where the supergraph is.
122
+ # have to restate where the supergraph is. `fake:` says how those
123
+ # fabricate; graphql_router(fake: …) says it for one example.
125
124
  def router=(arguments)
126
125
  unless arguments.nil? || arguments.is_a?(Hash)
127
126
  raise ArgumentError, "router: must be the arguments to build one, e.g. " \
@@ -134,7 +133,7 @@ module GraphWeaver
134
133
  raise ArgumentError, "router: context: is set as config.context — the baseline every " \
135
134
  ":in_process and :router example starts from"
136
135
  end
137
- unknown = (arguments&.keys || []) - %i[supergraph subgraphs]
136
+ unknown = (arguments&.keys || []) - %i[supergraph subgraphs fake]
138
137
  raise ArgumentError, "router: doesn't take #{unknown.join(", ")}" if unknown.any?
139
138
 
140
139
  @router = arguments
@@ -145,14 +144,18 @@ module GraphWeaver
145
144
  # #context is settable, so an example that runs as someone else sets
146
145
  # that rather than rebuilding — the rspec hook resets it each time.
147
146
  def built_router
148
- @built_router ||= Router.new(supergraph: supergraph!, subgraphs: @router && @router[:subgraphs])
147
+ @built_router ||= Router.new(
148
+ supergraph: supergraph!,
149
+ subgraphs: @router && @router[:subgraphs],
150
+ fake: (@router && @router[:fake]) || {},
151
+ )
149
152
  end
150
153
 
151
154
  # The composed supergraph :router plans against — named, or the
152
155
  # conventional dump when that's what it is. A client can't supply
153
156
  # one: its schema is the API schema a router serves, with the
154
157
  # @join__* routing table stripped out.
155
- def supergraph!
158
+ private def supergraph!
156
159
  return @router[:supergraph] if @router&.key?(:supergraph)
157
160
 
158
161
  path = GraphWeaver::SchemaLoader.locate_path
@@ -169,7 +172,10 @@ module GraphWeaver
169
172
  # client already runs in-process. Only a live class has resolvers, so
170
173
  # there is nothing else to fall back to: a dump is type information.
171
174
  def schema_class!
172
- runnable(schema) || GraphWeaver.live_schema ||
175
+ # explicit_schema, not schema: the latter falls back to the committed
176
+ # dump, which loads as an anonymous GraphQL::Schema subclass — runnable
177
+ # by every test that matters, and holding not one resolver.
178
+ runnable(explicit_schema) || GraphWeaver::Internal::Util.live_schema ||
173
179
  raise(GraphWeaver::Error, ":in_process runs your resolvers, so it needs the live " \
174
180
  "GraphQL::Schema class — and GraphWeaver.client isn't running one in-process to " \
175
181
  "borrow. Name it in the example — graphql_in_process(MySchema) — or set " \
@@ -213,81 +219,32 @@ module GraphWeaver
213
219
  yield config
214
220
  # a typo'd override key pins nothing and the test still passes, so
215
221
  # catch it here — while the block that set it is still on the stack
216
- validate_overrides!(config.explicit_schema, config.overrides) if config.explicit_schema
222
+ Internal::Overrides.validate!(config.explicit_schema, config.overrides) if config.explicit_schema
217
223
  config
218
224
  end
219
225
 
220
- # Override keys name schema coordinates: "Type.field", or a bare field
221
- # name matching that field on any type. Anything else is a typo that
222
- # would silently fabricate random data instead of pinning a value.
223
- def validate_overrides!(schema, overrides)
224
- overrides.each_key { |key| validate_override_key!(schema, key.to_s) }
225
- end
226
-
227
226
  # back to defaults — between tests, or to undo an experiment
228
227
  def reset!
229
228
  @config = nil
230
229
  end
231
230
 
232
231
  # resolve a cassette name ("github") against cassette_dir; paths
233
- # with separators or extensions pass through
232
+ # with separators or extensions name the file themselves
234
233
  def cassette_path(name)
235
- return name if name.include?("/") || name.end_with?(".yml", ".yaml")
234
+ return Internal::Util.resolve(name) if name.include?("/") || name.end_with?(".yml", ".yaml")
236
235
 
237
236
  File.join(cassette_dir, "#{name}.yml")
238
237
  end
239
238
 
240
- # The configured directory, against Rails.root when there is one — a rake
241
- # task runs from wherever it runs from; the cassettes don't move.
242
- # const_get rather than a bare Rails: sorbet can't resolve a constant the
243
- # gem doesn't depend on.
244
- def cassette_dir
245
- dir = config.cassette_dir
246
- root = (Object.const_get(:Rails).root if Object.const_defined?(:Rails))
247
- root ? File.join(root.to_s, dir) : dir
248
- rescue NoMethodError
249
- dir # something else named Rails
250
- end
251
-
252
- private
253
-
254
- def validate_override_key!(schema, key)
255
- type_name, field_name = key.split(".", 2)
256
- # introspection fields (__typename) are real but absent from #fields
257
- return if (field_name || type_name).start_with?("__")
258
-
259
- if field_name.nil?
260
- known = field_names(schema)
261
- return if known.include?(type_name)
262
-
263
- bad_override!(key, "matches no field in this schema", known, type_name)
264
- end
265
-
266
- type = schema.get_type(type_name)
267
- unless type.respond_to?(:fields)
268
- bad_override!(key, "names no object type in this schema", schema.types.keys, type_name)
269
- end
270
- return if type.fields.key?(field_name)
271
-
272
- bad_override!(key, "is not a field of #{type_name}", type.fields.keys, field_name)
273
- end
274
-
275
- def bad_override!(key, problem, dictionary, term)
276
- suggestion = GraphWeaver.did_you_mean(dictionary, term)
277
- hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
278
- raise GraphWeaver::Error, "override key #{key.inspect} #{problem}#{hint}"
279
- end
280
-
281
- # Every output field name in the schema — walked only when a bare key
282
- # asks for it.
283
- def field_names(schema)
284
- schema.types.each_value.flat_map { |type| type.respond_to?(:fields) ? type.fields.keys : [] }.uniq
285
- end
239
+ # The configured directory as a real path — a rake task or an rspec run
240
+ # starts from wherever it starts from; the cassettes don't move.
241
+ def cassette_dir = Internal::Util.resolve(config.cassette_dir)
286
242
  end
287
243
  end
288
244
  end
289
245
 
290
- require_relative "testing/values"
246
+ require_relative "internal/overrides"
247
+ require_relative "internal/values"
291
248
  require_relative "testing/fake_client"
292
249
  require_relative "testing/fake_subgraph"
293
250
  require_relative "testing/failure"
@@ -61,7 +61,7 @@ module GraphWeaver
61
61
  # which adapter got picked decides socket reuse — Faraday's
62
62
  # default net_http one opens a connection per request. Naming it
63
63
  # is the cheapest way to make that discoverable.
64
- GraphWeaver.log(:info) { "faraday transport #{@url} (adapter: #{@connection.builder.adapter})" }
64
+ GraphWeaver::Internal::Log.log(:info) { "faraday transport #{@url} (adapter: #{@connection.builder.adapter})" }
65
65
  end
66
66
 
67
67
  private
@@ -39,7 +39,18 @@ module GraphWeaver
39
39
  raise ArgumentError, "pool_size: must be >= 1" unless pool_size >= 1
40
40
 
41
41
  @url = url
42
- @uri = URI(url)
42
+ # a scheme-less url parses as a *path* with no host, and the failure
43
+ # then lands as far away as Net::HTTP.start(nil, nil)
44
+ uri = begin
45
+ URI(url)
46
+ rescue URI::InvalidURIError
47
+ nil
48
+ end
49
+ unless uri && %w[http https].include?(uri.scheme)
50
+ raise ArgumentError, "expected an http(s) url, got #{url.inspect}"
51
+ end
52
+
53
+ @uri = uri
43
54
  @headers = headers
44
55
  @open_timeout = open_timeout
45
56
  @read_timeout = read_timeout
@@ -63,7 +74,7 @@ module GraphWeaver
63
74
  # live connections, LIFO — a warm socket beats opening a cold one,
64
75
  # so a single-threaded caller keeps reusing the same one
65
76
  @idle = []
66
- @idle_lock = Mutex.new
77
+ @lock = Mutex.new # guards @idle and @saturated
67
78
  end
68
79
 
69
80
  private
@@ -84,12 +95,14 @@ module GraphWeaver
84
95
  # is the concurrency ceiling.
85
96
  def with_connection
86
97
  acquire_permit
87
- http = nil
88
-
98
+ # nothing between acquiring the permit and the ensure that returns it:
99
+ # an async interrupt (Rack::Timeout, a fiber cancel) landing in that
100
+ # gap would leak a permit, shrinking the pool for the process's life
89
101
  begin
90
- http = @idle_lock.synchronize { @idle.pop } || connect
102
+ http = nil
103
+ http = @lock.synchronize { @idle.pop } || connect
91
104
  result = yield http
92
- @idle_lock.synchronize { @idle.push(http) }
105
+ @lock.synchronize { @idle.push(http) }
93
106
  result
94
107
  rescue Exception
95
108
  # socket state is unknown — drop it, leaving the slot empty so
@@ -107,14 +120,19 @@ module GraphWeaver
107
120
  # when idle past keep_alive_timeout, so a server-closed keep-alive
108
121
  # socket doesn't produce spurious failures.
109
122
  def connect
110
- GraphWeaver.log(:debug) { "connecting to #{@uri.hostname}:#{@uri.port}" }
111
- Net::HTTP.start(
123
+ GraphWeaver::Internal::Log.log(:debug) { "connecting to #{@uri.hostname}:#{@uri.port}" }
124
+ http = Net::HTTP.start(
112
125
  @uri.hostname, @uri.port,
113
126
  use_ssl: @uri.scheme == "https",
114
127
  open_timeout: @open_timeout, read_timeout: @read_timeout,
115
128
  keep_alive_timeout: @keep_alive_timeout,
116
129
  **@ssl,
117
130
  )
131
+ # net/http otherwise returns the bytes that did arrive, so a
132
+ # connection dying mid-body reads as a server answering with garbage —
133
+ # a permanent ServerError — instead of the retriable EOFError it is
134
+ http.ignore_eof = false
135
+ http
118
136
  end
119
137
 
120
138
  # Take a permit, saying so when none is free. A queued request is
@@ -129,9 +147,8 @@ module GraphWeaver
129
147
  @permits.pop
130
148
  waited = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
131
149
 
132
- first = !@saturated
133
- @saturated = true
134
- GraphWeaver.log(first ? :warn : :debug) do
150
+ first = @lock.synchronize { !@saturated && (@saturated = true) }
151
+ GraphWeaver::Internal::Log.log(first ? :warn : :debug) do
135
152
  "connection pool saturated: waited #{waited}ms for 1 of #{@pool_size} connections to " \
136
153
  "#{@uri.hostname} — raise pool_size: to this process's concurrency"
137
154
  end
@@ -140,7 +157,7 @@ module GraphWeaver
140
157
  def disconnect(http)
141
158
  return unless http
142
159
 
143
- GraphWeaver.log(:debug) { "dropping connection to #{@uri.hostname}:#{@uri.port}" }
160
+ GraphWeaver::Internal::Log.log(:debug) { "dropping connection to #{@uri.hostname}:#{@uri.port}" }
144
161
  http.finish if http.started?
145
162
  rescue IOError
146
163
  # already closed
@@ -5,6 +5,7 @@ require "json"
5
5
  require "sorbet-runtime"
6
6
 
7
7
  require_relative "errors"
8
+ require_relative "internal"
8
9
  require_relative "version"
9
10
 
10
11
  # Base class for the bundled network transports — Transport::HTTP
@@ -48,10 +49,10 @@ class GraphWeaver::Transport
48
49
  # slow-query reports on. Generated modules pass their OPERATION_NAME;
49
50
  # a raw query string falls back to the name in the document itself.
50
51
  def execute(query, variables: {}, operation_name: nil)
51
- operation_name ||= GraphWeaver::Transport.operation_name(query)
52
+ operation_name ||= GraphWeaver::Internal::Wire.operation_name(query)
52
53
  payload = { url:, operation: operation_name }
53
54
 
54
- GraphWeaver.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
55
+ GraphWeaver::Internal::Log.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
55
56
  perform(query, variables, operation_name, payload)
56
57
  end
57
58
  end
@@ -61,11 +62,13 @@ class GraphWeaver::Transport
61
62
  private def perform(query, variables, operation_name, payload)
62
63
  # tag pairs this request's log lines (threads interleave), and names
63
64
  # the operation so the log says WHICH query, not just the url
64
- tag = GraphWeaver.logger && GraphWeaver::Transport.log_tag(operation_name)
65
+ tag = GraphWeaver.logger && GraphWeaver::Internal::Wire.log_tag(operation_name)
65
66
 
66
- # full query + variables at debug only — they can carry PII
67
- GraphWeaver.log(:debug) do
68
- "POST #{url} #{tag} variables=#{JSON.generate(variables)}\n#{GraphWeaver::Transport.truncate_for_log(query)}"
67
+ # full query + variables at debug only — they can carry PII, and the
68
+ # sensitive keys are scrubbed even there (GraphWeaver.filter_parameters)
69
+ GraphWeaver::Internal::Log.log(:debug) do
70
+ filtered = JSON.generate(GraphWeaver::Internal::Log.filter_variables(variables))
71
+ "POST #{url} #{tag} variables=#{filtered}\n#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
69
72
  end
70
73
 
71
74
  # camelCase because it's the graphql-over-http request field, not a
@@ -84,7 +87,7 @@ class GraphWeaver::Transport
84
87
  # headers is optional: a third-party subclass returning the
85
88
  # documented [status, body] pair simply has none
86
89
  status, body, headers = begin
87
- GraphWeaver.log_timed(:debug, "POST #{url} #{tag} completed") do
90
+ GraphWeaver::Internal::Log.log_timed(:debug, "POST #{url} #{tag} completed") do
88
91
  post(encoded)
89
92
  end
90
93
  rescue *GraphWeaver.transport_errors.to_a => e
@@ -93,7 +96,7 @@ class GraphWeaver::Transport
93
96
  end
94
97
 
95
98
  payload[:status] = status
96
- GraphWeaver.log(:debug) { "HTTP #{status} #{tag} from #{url} (#{body.to_s.bytesize} bytes)" }
99
+ GraphWeaver::Internal::Log.log(:debug) { "HTTP #{status} #{tag} from #{url} (#{body.to_s.bytesize} bytes)" }
97
100
 
98
101
  parsed = parse_body(body)
99
102
 
@@ -139,32 +142,6 @@ class GraphWeaver::Transport
139
142
  end
140
143
  alias to_s inspect
141
144
 
142
- # The name of the document's FIRST operation, nil when anonymous. Only
143
- # the fallback for a raw query string handed straight to a transport —
144
- # generated modules pass their OPERATION_NAME, parsed properly.
145
- OPERATION_NAME_PATTERN = /\A\s*(?:query|mutation|subscription)\s+([A-Za-z_]\w*)/
146
- def self.operation_name(query)
147
- query[OPERATION_NAME_PATTERN, 1]
148
- end
149
-
150
- # "[req 3 FilteredPokemon]" — a per-process request id plus the
151
- # operation name, when there is one
152
- REQUEST_MUTEX = Mutex.new
153
-
154
- def self.log_tag(operation_name = nil)
155
- id = REQUEST_MUTEX.synchronize { @request_count = (@request_count || 0) + 1 }
156
- "[req #{id}#{" #{operation_name}" if operation_name}]"
157
- end
158
-
159
- # keep debug readable: a 100-line introspection query would drown the
160
- # log — the INFO introspection line already carries the timing
161
- LOG_QUERY_LIMIT = 600
162
- def self.truncate_for_log(query)
163
- return query if query.length <= LOG_QUERY_LIMIT
164
-
165
- "#{query[0, LOG_QUERY_LIMIT]}... (truncated, #{query.bytesize} bytes total)"
166
- end
167
-
168
145
  private
169
146
 
170
147
  # POST the JSON body to the endpoint; return [status code, raw body]
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.5.0"
2
+ VERSION = "0.6.0"
3
3
  end