Regenerate whichever version you're on, then read
the one section that is yours: from 0.7.7, from
0.7.6, from
0.7.5, from
0.7.4, from 0.7.3, from
0.7.1, from 0.7.0 or from
0.6.1. Coming from 0.6.0 or older, the path is that
version's own upgrade notes — read them at the tag they shipped under
(git show v0.7.1:docs/upgrading.md), then this page from 0.6.1 down.
Any release can change what codegen emits, patch releases included — most of
them are fixes to a generated type, and a fix to a type is a change to the bytes.
So rake graph_weaver:generate is part of upgrading the gem, every time, and
rake graph_weaver:verify is the detector: it fails when the checked-in Ruby
isn't what this version would write. Nothing beyond that is promised — there is no
"generated output is stable within a minor" rule to lean on, and what each release
changed is in the changelog.
A generated file's header names the release that wrote it, so the first verify
after an upgrade reports the tree as stale whether or not codegen actually moved.
That's the reminder working, not a false alarm. Generation is deterministic, so
the diff is exactly what the new version emits differently and nothing else —
worth reading rather than rubber-stamping.
A patch release about load time. Nothing in it reaches an app that reads its dump and generates; one edge moved, below. Read the left column and skip what isn't yours; the changelog says why each one moved.
| applies if you… | what changed |
|---|---|
build a client from a schema dump — GraphWeaver.new("schema.graphql"), or a graph naming one |
it is parsed on first use, not at construction, so a production boot never parses it. A missing or unreadable path still refuses at construction, naming it; a file that exists but isn't a schema refuses at the first schema, parse or check_query. rake graph_weaver:verify catches that one in CI |
call client.check_query per query against a supergraph client |
the routing table is built once per file version rather than per call — nothing to change, ~200 ms back per call on a 2000-type supergraph |
| generate anonymous operations whose comments carry non-ASCII text, or check out with CRLF | they generate under any parser graphql-ruby is configured with; nothing to change unless you had named an operation to dodge a "could not name the anonymous operation" refusal |
Then regenerate, and the gate:
rake graph_weaver:generate
rake graph_weaver:verifyRead the left column and skip what isn't yours; the changelog says why each one moved.
| applies if you… | what changed |
|---|---|
set namespace: on any graph — rake graph_weaver:graphs lists them |
its shared types module is <Namespace>::Types, not <Namespace>::GraphQLTypes. Regenerate, then move app code naming <Namespace>::GraphQLTypes::X to <Namespace>::Types::X — srb tc finds every site. Keep the old name with types_module "GraphQLTypes" in that graph's block, at the cost of the autoloadable layout below |
run Rails, and have a graph whose namespace: is the constant its output path spells |
that tree is left to Zeitwerk — autoloaded on first reference, reloaded with the app, and no longer required at boot, so booting stops defining its parent namespace chain. Nothing to do; the Railtie says which side each graph fell on at debug. Every other tree is hidden and required exactly as before |
Then regenerate, and the gate:
rake graph_weaver:generate
rake graph_weaver:verifyA patch release. The regenerate is what changes behaviour — every module gains the per-call event — and the rest reaches an app only where it already relied on the old wording. Read the left column and skip what isn't yours; the changelog says why each one moved.
| applies if you… | what changed |
|---|---|
subscribe to execute.graph_weaver, or read GraphWeaver::LogSubscriber's line |
a second event, operation.graph_weaver, covers one execute start to typed result — retries, backoff and the cast — and the request events nest inside it. The info log line is the operation's now: failed GraphWeaver::CastError where it read ok, present under a graphql: tag, one line per retried call; the per-attempt line writes at debug. A module generated by 0.7.5 emits neither until regenerated |
spread a shared fragment on an interface or union that selects no ... on, or exactly one — grep -rn 'fragment .* on' app/graphql/fragments |
it hoists into GraphQLTypes::<Fragment> like every other shared fragment; app code naming YourQuery::Result::Field moves, and srb tc finds each site |
run rake graph_weaver:queries:check in CI for a graph that names its own schema — rake graph_weaver:graphs lists them |
it re-introspects that graph's server (the dump's url, or the client's url or schema class) where it used to check the committed file, so CI needs the same reach a single-schema app already had. The verdict names what it checked against either way |
keep a composed supergraph on a graph that also names a client |
schema:refresh steps over it with the recompose line where 0.7.5 could have overwritten it with the API schema |
parse rake graph_weaver:unused output, or run it STRICT=1 |
a before_action-loaded @result is credited again, and the coordinate printed comes from the query, not from any word in the file |
Then regenerate, and the gate:
rake graph_weaver:generate
rake graph_weaver:verifyRead the left column and skip what isn't yours; the changelog says why each one moved.
| applies if you… | what changed |
|---|---|
spread a shared fragment as a whole field — grep -rln '{ *\.\.\.[A-Za-z]* *}' app/graphql/queries |
on an object type it now hoists into GraphQLTypes under the fragment's name, as it already did on a union. App code naming the struct it used to produce (PetQuery::Result::Pet) moves to GraphQLTypes::PetFields — one type instead of one per query, so a T.type_alias { T.any(…) } written to paper over that goes away. Regenerating and running srb tc finds every site |
register an abstract! mixin — grep -rn 'extend_type' app config lib, then check which of those modules call abstract! |
generation refuses where a query doesn't select everything the mixin declares, instead of leaving it for your srb tc. The message names the struct, the members and the fixes: select them in that query, or select the type through one shared fragment so a single hoisted struct answers for every query |
keep a hand-maintained schema dump on a graph that also names a client — rake graph_weaver:graphs lists both per graph |
rake graph_weaver:schema:refresh rewrites that dump from the client where it used to refuse and exit 1. If the file is the source of truth, don't run refresh for that graph, or drop the client from it |
call Codegen#used_union_names |
it is used_fragment_names — the set it reports now includes hoisted object fragments |
Then regenerate, and the gate:
rake graph_weaver:generate
rake graph_weaver:verifyA patch release. One change can reach an app that never touched it — how a timestamp is read off the wire — one more reaches an app that maps an enum onto its own, and the rest is in the regenerate. Read the left column and skip what isn't yours; the changelog says why each one moved.
| applies if you… | what changed |
|---|---|
register a timestamp scalar — grep -rn 'register_scalar.*Time' app config lib, which finds Time and DateTime alike |
it reads the wire with Time.iso8601 rather than Time.parse, so a spelling graphql-ruby's coerce_result never writes is refused: a bare date, seconds omitted, basic format ("20240115T102030Z"), a space and a zone name, "Jan 15 2024 10:20". Same for a variable going out. A server that writes one of those starts failing the cast — cast: :parse keeps the tolerant reader: register_scalar("DateTime", Time, cast: :parse) |
have DateTime or ISO8601DateTime in your schema and register neither |
same change: the gem registers both as Time, so the row above is yours without a line of your own |
map an enum onto your own T::Enum — grep -rn 'register_enum' app config lib — where the schema declares one value under two spellings (LEGACY_MODE beside legacy_mode) |
inference is case/underscore-insensitive, so both landed on one member and the wire table silently sent whichever sorted last: the deprecated one. Generation refuses now, naming the pair — alias: { "legacy_mode" => "LEGACY_MODE" } says which spelling goes out |
Then regenerate, and the gate:
# the timestamp cast is emitted, the input-struct FIELDS table grew a column,
# and a graph with a block-form extend_type gains a type_helpers.rbi — commit
# it with the rest
rake graph_weaver:generate
# red while any checked-in file is still what 0.7.3 wrote; 0.7.3's input
# structs raise `ArgumentError: missing keyword: :type` at load until then
rake graph_weaver:verifyA patch release, and one change with a shape: a generated module no longer carries a client of its own — the graph it belongs to resolves one. Read the left column and skip what isn't yours; the changelog says why each one moved.
| applies if you… | what changed |
|---|---|
call generate!, verify_generated!, Codegen.new or Codegen.generate yourself — grep -rn "client:" config lib Rakefile |
the client: kwarg is gone, and the call raises unknown keyword: :client. Say it once on the graph (client in a GraphWeaver.graph block), or as the app default (GraphWeaver.client =) for modules in no declared graph. Graph#client answers that object now, not the name of the constant holding it |
assign a generated module's client — grep -rn "\.client *=" app config lib (a hit on GraphWeaver.client = is the app default, and still fine) |
the writer is private, and MyQuery.client = … raises NoMethodError. A module's client comes from its graph, from client: on the call, or — for a module you parsed — from GraphWeaver.parse(client:), which client.parse and load_queries! already pass |
Then regenerate, and the gate:
# generated files no longer carry a DEFAULT_CLIENT
rake graph_weaver:generate
# red while any checked-in file is still what 0.7.1 wrote
rake graph_weaver:verifyA patch release of fixes, and a typical app ticks none of these. Read the left column and skip what isn't yours; the changelog says why each one moved.
| applies if you… | what changed |
|---|---|
read payload[:code] in an alert or a dashboard expecting an HTTP status |
it is a GraphQL error code or nothing now — the number is on :http_status, where it already was |
assert a fabricated userErrors is non-empty under graphql: :fake |
a list field whose name ends in errors fabricates [] — pin it ({ "userErrors" => [{ "message" => "…" }] }) to fabricate failures |
grep or parse the debug log for [req 3 …] |
the tag names the process: [req 4123-3 …] |
run more than one graph with cache: true |
the second graph caches to schema-<url digest>.json of its own instead of sharing the first's dump — cache: "<path>" names it yourself |
| commit your cassette directory | recording takes a <cassette>.yml.lock sidecar — gitignore *.yml.lock |
rescue ArgumentError around the union-dispatch refusal |
it is a GraphWeaver::Error naming the query file now |
wrote a client of your own with a bare attr_accessor :context and mount it behind Testing::Endpoint |
include GraphWeaver::ContextSeam — the lock lives on whoever owns the field |
read CHANGELOG.md out of the installed gem |
it isn't packaged any more; changelog_uri points at blob/v<version> |
send a File, IO, Pathname or plain object as a variable under graphql: :in_process or :fake |
refused there too now, as it already was over the wire — a test that "proved" an upload works starts failing |
| retry a 429/503 that arrives with a GraphQL errors body | a Retry-After header wins over the configured backoff now, as it already did for a raised ServerError — nothing raises |
Mostly mechanical. Everything that wants your hands, or changes under you, is one row below; read the left column and skip what isn't yours. A typical app ticks two or three.
| applies if you… | what changed |
|---|---|
| run a Rails app that configures no logger or instrumenter | you start logging one info line per GraphQL call — a production log-volume change, first bullet below |
wrap a gateway in Retry |
a 5xx/429 that arrives with an errors body retries now — real traffic, below; retries: 0 opts out |
| commit a schema dump introspected through a url carrying a token | refresh it, and rotate the token if that file was pushed — the dump recorded the url verbatim |
| keep a schema dump deliberately behind your own schema class | verify fails on it now |
| check in a composed supergraph as your dump | schema:refresh refuses it rather than overwriting it with the API schema — recompose instead |
use @oneOf input types and commit a .json dump |
@oneOf starts being enforced client-side once you regenerate |
call result.to_json, or render json: result |
it is the wire shape now, not #inspect or your prop names |
send a File, IO or Pathname as a variable |
refused at the wire, where JSON.generate used to ship its #to_s |
pin a lowercase type name in graphql_fake |
it works now; a near-miss keyword raises ArgumentError |
tag specs graphql: false, or set config.default_mode = nil |
both refused — renames |
rescue GraphWeaver::TypeError or GraphWeaver::ValidationError |
both constants are gone, with no alias — renames |
subscribe to "graph_weaver.execute" |
the event is "execute.graph_weaver" — renames |
index a hash by an InputError's #field |
it names the input field now, not the variable — nothing raises |
read an InputError's #details[:type] |
it is the GraphQL type now, never a Ruby class — nothing raises |
read payload[:status] in an instrumentation subscriber |
it is a Symbol; the HTTP status moved to :http_status — nothing raises |
call respond_to? on a result struct |
it stopped answering true for props that don't exist — nothing raises |
generate a module with a baked client: |
a graphql: tag now reaches it |
set config.context, config.schema or config.router from a before hook |
all three refused — they are suite setup |
pass a DateTime where the schema says Date |
refused — pass .to_date |
register a scalar with your own cast:/serialize: |
the same guard as the built-ins, and a proc that returns a value is refused |
have a field named class, hash, display, to_json, each or supplied |
the prop takes a trailing underscore |
have an entity @key that selects through a list |
the kwarg is a list now, not one hash — regenerate |
build a type helper with extend_type("Widget") { … } |
its constant is named for its graph and its type — regenerate |
adopt GraphWeaver.graph |
every queries directory then needs one |
write config.graph_weaver.<anything but watch> |
refused at boot |
pass SUPERGRAPH= to any task but federation:* |
refused, where it was ignored — a CI step that did it goes red |
compare a Testing::Router error hash whole in a spec |
a subgraph error carries extensions: {"service" => …} now |
pass seed: to graphql_router(fake: …) |
refused |
require graph_weaver/rspec from spec/support/ |
check the glob is uncommented — rspec-rails ships it commented out |
adopt graphql: :wire |
it needs require "webmock/rspec", not just the gem |
Then five commands, in order:
# 1. the two renames your own code holds
grep -rn "GraphWeaver::TypeError\|GraphWeaver::ValidationError" app lib spec
grep -rn "graph_weaver.execute" app lib config spec # the old event name
# 2. rewrite the dump: it drops a credential the url carried, and picks up
# isOneOf. Skip only if your dump is SDL and records no source url — and
# a composed supergraph refuses, since introspection can't rebuild one:
# `rover supergraph compose` is what rewrites that.
rake graph_weaver:schema:refresh
# 3. regenerate — also the graph name in every module, the underscored
# reserved props, as_json, and the client: and cast:/serialize: refusals
rake graph_weaver:generate
# 4. the renamed tag, the deleted nil, the seed: refusal
bundle exec rspec
# 5. the gate: red while any checked-in file is still what 0.6.1 wrote
rake graph_weaver:verifyTwo kinds of file answer that first grep, and only one needs your hands. Hits
under your generated directory (app/graphql/generated/ by default) are the old
names in machine-written code — step 3 rewrites them. Hits anywhere else are
yours: CastError and QueryValidationError, renamed by hand.
| before | after |
|---|---|
graphql: false (rspec tag) |
graphql: :live — the opt-out is your own client, which is a mode like the other four; false is refused, naming it |
config.default_mode = nil |
config.default_mode = :live, which is now the default — every example has exactly one mode, and nil is no longer a value it reads back |
GraphWeaver::TypeError |
GraphWeaver::CastError — the response wouldn't cast into the generated structs; the old name shadowed a core class it doesn't descend from. No alias: the old constant is gone, so a stale rescue is a NameError |
GraphWeaver::ValidationError |
GraphWeaver::QueryValidationError — build time, the query against the schema. Your input's validation is InputError. No alias here either |
"graph_weaver.execute" |
"execute.graph_weaver" — <event>.<namespace>, the way every notification in this ecosystem is spelled, and what LogSubscriber.attach_to and an APM's namespace routing key on. Subscribe through GraphWeaver::EXECUTE_EVENT and there is nothing to rename; a hardcoded string silently stops matching |
The first one reaches every Rails app that never configured logging, and it is the only one here that shows up in production rather than in your code.
- A Rails app logs one line per GraphQL call, and emits one notification. The
railtie sets
GraphWeaver.instrumenterto theActiveSupport::Notificationsadapter and attachesGraphWeaver::LogSubscriber, so an app that configured neither getsGraphWeaver billing/InvoicesQuery (12.3ms) okat info — the query and variables stay at debug. To opt out, setGraphWeaver.logger = nilorGraphWeaver.instrumenter = nilinconfig/initializers, which now takes effect (an app that worked around that withconfig.after_initializecan drop it). In-process calls are in scope too: a bare schema class in a client slot goes through the same wrapper, so it produces events and log lines where it produced none. See logging. - A
Retryin front of a gateway starts actually retrying. It read only the failures that raised, and Apollo Router answers everything it decides itself with a GraphQL errors body, soretries: 3made one attempt. A response retries now when its status is one aServerErrorretries on (5xx, 408, 429), or when its error codes are named inretry_codes:. This is real traffic you weren't sending — if the inert policy was what you wanted,retries: 0. - A task that can't honour
SUPERGRAPH=refuses instead of ignoring it. The flag reaches thefederation:*tasks and nothing else, soSUPERGRAPH=public.graphql rake graph_weaver:queries:checkused to report every query valid against a supergraph missing fields they select. A CI step that passes it togenerate,verifyorqueries:checkgoes red; drop the flag, or declare the supergraph on a graph. InputError#fieldnames the input field, not the variable. It is#path's last named segment — the slot that actually held the bad value — where it used to be re-branded with the variable name. Nothing raises; the value just differs once a refusal happens inside an input object. Readerror.path.firstwherever you wanted the variable. An index is a position rather than a field, soexecute(ids: [1, 2, "x"])reports#path["ids", 2]and#field"ids".- The instrumentation payload's
:statusis a Symbol, and the HTTP status moved to:http_status.:statusis:ok,:errorsor:failed— a 200 carrying errors is not a success, and only a symbol says that on both sides of the seam. Nothing raises: a subscriber comparing it to an Integer just stops matching. A subscriber that branched on a 4xx/5xx reads:http_statusnow, which is nil in-process. The whole payload is a documented contract — logging. respond_to?on a result struct no longer answers true for a name that doesn't exist. It used to say true for any near miss, which broke the standard duck-typing guard. A branch that read the old answer now takes the other path, andstruct.method(:nmae)raises Ruby's bareNameError;struct.nmaestill hints.- A
graphql:tag reaches a module generated withclient:. The baked client used to sit above the slot a tag swaps, so a bound module ran against its real endpoint undergraphql: :fake. If a spec relied on that, passclient:on the call, or tag the examplegraphql: :live. config.context,config.schemaandconfig.routerare suite setup. Setting any of the three once an example is running refuses, naming the per-example helper (graphql_context,graphql_fake(schema:),graphql_router(fake:)). The tag builds an example's clients in abeforehook of its own, which rspec runs ahead of any groupbefore, so a set there was read too late and silently changed nothing. Move it to anaround, or toGraphWeaver::Testing.configurein the spec helper.result.to_jsonis real JSON, and it is the wire shape. It used to be Ruby'sObject#to_json— the#inspectstring, quoted — while under Railsrender json: resultshipped the Ruby prop names. Both now produce the response keys, each leaf back through its scalar registration'sserialize:, soResult.from_h(JSON.parse(result.to_json)) == result.#to_his unchanged and still the Ruby view. Anything that parsed the old output is reading something different now — andas_jsonis emitted code, so a struct generated by 0.6.1 raisesGraphWeaver::Errornaming this until you regenerate.- A schema dump introspected through a credentialed url still holds the token.
The provenance stamp wrote the transport's url verbatim. It records the endpoint
bare now — userinfo and any query parameter
filter_parametersfilters are dropped — and re-introspection still authenticates from the dump'sauth_env. Runrake graph_weaver:schema:refreshonce, and rotate the token if that file was ever pushed. verifyfails when the dump has fallen behind the schema class it was built from. For an app that serves its own schema the dump is an artifact derived from code in the same repo, sogenerateandverifyboth called a tree up to date while the live resolvers had already moved. A dump you deliberately keep behind your own schema is a red gate now —rake graph_weaver:schema:refresh, or ask about no dump at all withverify_generated!(schema:). It costs one in-process introspection per graph and never a network call.@oneOfstarts being enforced if your dump is.json. graphql-ruby's introspection query omitsisOneOfunless asked, so every dump this gem had written said "not @oneOf" for every input object. Regenerate and the emittedONE_OFstarts refusing calls that set two fields — which your server was refusing all along, so the failure moves from the wire intoexecute. SDL dumps, inline SDL and a live class were always correct.- A fake pin is told from an option by a schema lookup, not by casing. A
lowercase type could not be pinned at all (
graphql_fake("pokemon_v2_pokemon" => …)against a Hasura API); those pins work now. The other side of it: a keyword that is a near-miss for a pin (Persn: "Ada") raisesArgumentErrorfrom the fake rather thanGraphWeaver::Errorfrom the override check — the same key written in the leading positional hash is unchanged, and is the spelling for a schema whose vocabulary collides with an option name. - A
DateTimegiven for aDatevariable is refused.DateTimeis aDateto Ruby, so it used to pass the cast untouched and go on the wire as a full timestamp where the schema saidISO8601Date. Pass.to_date. The pairings that already raised — aTimefor a date, aDatefor a timestamp — now raise a brandedInputErrorrather than Ruby's "no implicit conversion of Time into String", and aDateTimeorTime.zone.nowfor a timestamp converts losslessly where it used to raise. - A
cast:of your own gets the same guard and the same verdict. A registration likeregister_scalar("Date", Date, cast: :iso8601, serialize: :iso8601)emitted a barevalue.is_a?(Date)pass-through, so aDateTimewent by untouched and yourserialize:wrote a full timestamp into a date field — pass.to_datethere too. Anything else wrong used to arrive as Ruby's own sentence underkind: :unparseable; the verdict is the library's now and splits the way Ruby does — aTypeErrorfrom a codec readsexpected a Date, got 5under:type_mismatch, anArgumentErrorkeeps the parser's words under:unparseable.#details[:type]is the GraphQL type now, never a Ruby class. A spec matching the old message, or branching on:unparseablefor a wrong class, needs updating — and the guard is emitted into your generated files, so a checked-in one keeps the old behavior until you regenerate. - A field whose name a struct already answers to now generates as
name_.classbecomesclass_, and so on forhash,display,to_json,eachand (on an input)supplied. Only the Ruby name moves: the wire keeps the schema's spelling in both directions, so a refusal on that field still reports#path["class"], and an input struct's#to_his still the wire hash. A key you aliased in the query to get past the old refusal still generates from that alias — drop the alias and regenerate if you want the field's own name back. The names that take an underscore are a list the gem owns (T::StructandObject's public instance methods, the hooks Ruby and Rails call on an object, and the gem's own mixins) rather than whatever the generating process happened to have loaded, so a few more names move than 0.6.1 touched; Kernel's private methods are not on it, soformat,select,openandloadstay ordinary props. A federation@keyon such a field follows the same rule instead of being refused — the kwarg takes the underscore and"class"still goes on the wire, so regenerate if a@keyof yours names one. Generated source marks each rename on the line above the prop, so read the regenerate diff. - If you adopt
GraphWeaver.graph, every queries directory needs a graph. Declaring one replaces the implicit graph your top-level settings describe, so a graph declared beside an existingapp/graphql/queriesused to leave that directory unread.generate!,verify_generated!andcheck_queriesrefuse now, naming the stray files. Name the directory in a graph, declare a graph for it, or delete it. An app that declares no graph is unaffected. - A
clientthat isn't a constant is refused at generation. Its value is spelled into every module the graph generates, soclientgiven an endpoint url emitted a file that doesn't parse, from a run that reported success. Declare the constant and name it —CLIENT = GraphWeaver.new(url), thenclient "CLIENT". - A
cast:orserialize:proc that returns a value is refused at registration. A proc there builds source for the generated file, socast: ->(v) { v.to_sym }interpolated to nothing and every response failed far from the registration. It is probed once when registered now: return the source (cast: ->(v) { "Money.parse(#{v})" }) or name a method instead. config.graph_weaverrefuses a key the railtie doesn't read, at boot. It takeswatch;config.graph_weaver.queries_paths = …was taken silently and did nothing. Move it toGraphWeaver.queries_paths =.- A router's
fake:refusesseed:, asgraphql_fakealready did. A router is built once for the suite, so a seed there would pin every example to one run —rspec --seed 1234reproduces the fabricated data along with the test order, andGraphWeaver::Testing.config.seedis the override for a harness that isn't rspec. - Check that your
require "graph_weaver/rspec"actually runs. The old setup put it inspec/support/graph_weaver.rb, and rspec-rails ships thespec/supportglob commented out — so if you never uncommented it, the tag did nothing and everygraphql: :fakeexample has been hitting the real client.rails g graph_weaver:installwrites the line intospec/rails_helper.rbinstead; move yours there if the glob isn't live. graphql: :wire, if you adopt it, needs webmock enabled —require "webmock/rspec"in the spec helper. Having it in the Gemfile is not enough:Bundler.requireloads webmock without installing its adapters, and the tag refuses before the first request.- Regenerate, as ever. Generated modules carry a private
GRAPHnaming the graph they were generated from, and a multi-schema app whose modules predate it refuses rather than guessing which schema a module belongs to. A generatedexecutealso makes its request through the gem now, which is what lets an event name the graph; 0.6.1's modules keep working, butverifyreports the tree out of date until you regenerate. Result structs also gained==/eql?/hash,deconstruct_keys,#to_hand#as_json.