graph_weaver 0.7.4 → 0.7.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f631eac2f0c9bbd78b50cfe1b7a0178911a8939cc697d57328bc63b3b60cd0d7
4
- data.tar.gz: 771837c86ab744d47de6b1f58e990575ee7618d9cb362e871614f6aeebcded18
3
+ metadata.gz: 53034719b6dcd597d098091606e08b6f4a33a15a89826374cf6b14df841aa0fc
4
+ data.tar.gz: cb64c3e12070f522a44353066aaac80ea7faf9df7d8a564a1abfcd053584049b
5
5
  SHA512:
6
- metadata.gz: 8f24e3485d8ff839de3a9fd626b2c0038c0617c322b6249742aa90eae3552073d3cbf3335de430364072bfa990304689a7e9ad3db652ae527d46ac711573a276
7
- data.tar.gz: a484029fbbdd141af8be0586d8a830e2439f13fdbbc1b9f4826840f289af3e000a81457749657e5573cd67c4ae2017aa2929bfb34a300e9777669b9265c22eb5
6
+ metadata.gz: 6b320bc47a5839ef8561f44ac12b78eba36cc8b33078397f2818db59ce1268fef5f3b6b4aa347ef1e21443c9e213dd1ea6c5a297d14d8fa5c651ff8bcacd3052
7
+ data.tar.gz: 35abfec468ca9f50327c7f6a33a7f881884c004daf294019dd0a27c12498c010ec4383404a86d7015ed74616d114978e62e081703acffbcba58b9b3abad69add
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- graph_weaver (0.7.4)
4
+ graph_weaver (0.7.5)
5
5
  graphql (>= 2.6.7)
6
6
  sorbet-runtime
7
7
 
@@ -299,7 +299,7 @@ CHECKSUMS
299
299
  google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
300
300
  google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
301
301
  google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
302
- graph_weaver (0.7.4)
302
+ graph_weaver (0.7.5)
303
303
  graphql (2.6.10) sha256=9b7c8633767f516ff9d48a8d6305b2a00a2101c82aa871b92e69086944f9f83e
304
304
  hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
305
305
  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
data/README.md CHANGED
@@ -146,6 +146,7 @@ when you've skipped it.
146
146
  - **[Against a real API](docs/real_world.md)** — introspecting a live endpoint, GitHub end to end
147
147
  - **[Logging](docs/logging.md)** — point `GraphWeaver.logger` at any Logger
148
148
  - **[Upgrading](docs/upgrading.md)** — regenerate on every bump, and what each release moved
149
+ - **[Migrating](docs/migrating.md)** — coming off graphql-client or a hand-rolled client, commit by commit
149
150
  - **[Alternatives](docs/alternatives.md)** — graphql-client, graphlient, artemis, hand-rolled: what each is good at, and where this one loses
150
151
 
151
152
  ----
data/docs/errors.md CHANGED
@@ -172,7 +172,10 @@ A nested filter reports the innermost input type, so the error points at the inp
172
172
  that actually held the bad field; passing something that is neither — a bare
173
173
  `String` where the input goes — reports the same way. A call site that *spells* the
174
174
  wrong type is caught earlier and better, by `srb tc`
175
- ([why](generated_modules.md#variables-become-typed-kwargs)).
175
+ ([why](generated_modules.md#variables-become-typed-kwargs)). And once a boundary
176
+ of your own between that call site and `execute` carries a sig, sorbet-runtime
177
+ checks the value there first: the caller sees a plain `TypeError`, and none of
178
+ the structured, translatable detail below ever reaches it.
176
179
 
177
180
  The one-shot `GraphWeaver.run` / `run!` mirror `execute` / `execute!`.
178
181
 
@@ -464,7 +467,7 @@ than leaving you sorbet's words:
464
467
  | what came back | what the message adds |
465
468
  |---|---|
466
469
  | an `ID` the server sent unquoted | GraphQL serializes `ID` as a JSON string, so this is the server out of spec — plus how to take it anyway (`register_scalar("ID", "T.untyped")`) |
467
- | an enum value the generated enum doesn't hold | the values it does hold, and that drift is the likely cause: regenerate, or `register_enum(fallback:)` to absorb them |
470
+ | an enum value the generated enum doesn't hold | the values it does hold, and that drift is the likely cause: regenerate, or `register_enum("Species", fallback: true)` to absorb them into `Other` |
468
471
  | a field the server nulled **with a reason** | the server's own explanation, rather than only sorbet's nil complaint |
469
472
 
470
473
  Simulate one in tests with
@@ -61,6 +61,18 @@ name your operations**: an anonymous document is named after the module in the
61
61
  emitted `QUERY` *and* in `OPERATION_NAME` — both, since a server rejects an
62
62
  `operationName` its document doesn't declare.
63
63
 
64
+ Code that takes *any* generated module — a persisted-query manifest, a
65
+ transport of your own — types it as `GraphWeaver::QueryModule` and reads the
66
+ same two through `query_string` and `operation_name`, which carry sigs. That is
67
+ the spelling rubocop-sorbet allows: `mod.const_get(:QUERY)` on a `Module` is
68
+ `Sorbet/ConstantsFromStrings`, and the `T.unsafe` that gets around it is
69
+ `Sorbet/ForbidTUnsafe`.
70
+
71
+ ```ruby
72
+ sig { params(mod: GraphWeaver::QueryModule).returns(String) }
73
+ def manifest_line(mod) = "#{mod.operation_name}: #{mod.query_string.bytesize} bytes"
74
+ ```
75
+
64
76
  A `Result` is an **ordinary Ruby object**: value `==` (with `eql?` and `hash`,
65
77
  so a result works as a hash key), `deconstruct_keys` for pattern matching,
66
78
  `#to_h`, and `#to_json`/`#as_json`. All of them go the whole way down a nested
@@ -167,10 +179,11 @@ query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
167
179
 
168
180
  **Union and interface members** are the one name that doesn't come from a key:
169
181
  they take the type condition that produces them (`... on Book` → `Book`) inside
170
- the container named for the field, plus the catch-all `Other`; a union hoisted
171
- out of a shared fragment is named for the fragment; and several fields sharing
172
- one collapsed union type take the first of their keys alphabetically. Still
173
- position-determined, all of it.
182
+ the container named for the field, plus the catch-all `Other`; a type
183
+ [hoisted](#a-shared-fragment-is-one-type) out of a shared fragment is named for
184
+ the fragment; and several fields sharing one collapsed union type take the first
185
+ of their keys alphabetically. Still position-determined, all of it — a fragment
186
+ name is a source fact like a result key.
174
187
 
175
188
  Two collisions are handled rather than left to surprise you. A name that would
176
189
  shadow the struct it nests in (`pet { pet { ... } }`) takes a numeric suffix
@@ -321,6 +334,12 @@ Both spellings then cast to `Status::LegacyMode`, only the target gets a
321
334
  constant, and a variable sends the target — see
322
335
  [scalars.md](scalars.md#two-spellings-one-value).
323
336
 
337
+ A value the *server* adds after you generate is drift, and casting it raises by
338
+ default. `register_enum("Species", fallback: true)` gives the generated enum one
339
+ extra member, `Other`, that every undeclared wire value casts to — the same
340
+ catch-all an abstract type's dispatch always carries. A variable can't send it;
341
+ see [scalars.md](scalars.md#values-the-server-hasnt-told-you-about-yet).
342
+
324
343
  `register_enum` replaces the generated `T::Enum` with your own app enum — see
325
344
  [scalars.md](scalars.md#enums-map-onto-your-own-tenum). Dynamic `parse` emits the
326
345
  enums into the query module itself; there's no cross-query set to share against,
@@ -369,6 +388,46 @@ schema's spelling in both directions, so the query, the request, the response,
369
388
  and `#as_json`/`#to_json` are untouched and `render json: result` never leaks a
370
389
  trailing underscore.
371
390
 
391
+ ### A shared fragment is one type
392
+
393
+ **A whole field selected as exactly one named shared fragment is one type in
394
+ `GraphQLTypes`, named for the fragment, which each query aliases.** Object or
395
+ abstract, same sentence:
396
+
397
+ ```graphql
398
+ # app/graphql/fragments/pet_fields.graphql
399
+ fragment PetFields on Pet { name species birthday }
400
+
401
+ # two queries, one Ruby type
402
+ query Roster { pets { ...PetFields } }
403
+ query Shelter { shelter { pets { ...PetFields } } }
404
+ ```
405
+
406
+ ```ruby
407
+ GraphQLTypes::PetFields # the struct, generated once
408
+ RosterQuery::PetFields # the alias each query opens with
409
+ sig { params(pet: GraphQLTypes::PetFields).returns(String) } # nameable by your app
410
+ ```
411
+
412
+ That is what makes the fragment the unit of reuse on *both* sides of the wire: a
413
+ presenter, a serializer or a policy can name its argument's type, and a union
414
+ spread this way gives one exhaustive `case … T.absurd` everywhere instead of a
415
+ fresh dispatch module per query.
416
+
417
+ It is opt-in by how you write the query, and only the exact shape hoists —
418
+ `{ ...PetFields id }`, `{ ...PetFields ...Vaccinations }`, a spread carrying
419
+ `@skip`/`@include`, a fragment the query file defines itself, and a fragment
420
+ written on some other type than the field's all stay a position-named struct
421
+ where they are. A spread *inside* a hoisted fragment is inlined into it rather
422
+ than hoisted again. Like shared inputs, it's a `generate!`-directory concern;
423
+ dynamic `parse` inlines everything.
424
+
425
+ Registrations follow the type, not the position, so `extend_type("Pet", …)`
426
+ mixins and `alias:` accessors land on the hoisted struct exactly as they would
427
+ on a local one. An `alias:` path on the *parent* may end on the hoisted struct
428
+ (`alias: { first_pet: "pets.first" }`) but not read through it — the struct
429
+ belongs to another module — and says so at generation.
430
+
372
431
  ### Abstract types
373
432
 
374
433
  An abstract field emits **one struct per type condition the selection names**,
@@ -402,11 +461,10 @@ off `__typename` when the selection carries one unaliased and unguarded, and off
402
461
  narrowed fragment, or one whose `__typename` is itself guarded, is refused: a
403
462
  match would be indistinguishable from a miss.
404
463
 
405
- When a whole union field is selected as one named *shared* fragment
406
- (`{ ...FeedItemFields }`), that type is hoisted once into `GraphQLTypes` — named
407
- for the fragment — and each query aliases it, so the same union is one Ruby type
408
- family across queries, not a fresh dispatch module per query. Like shared inputs,
409
- it's a `generate!`-directory concern; dynamic `parse` inlines.
464
+ A whole union field selected as one named *shared* fragment
465
+ (`{ ...FeedItemFields }`) is [hoisted](#a-shared-fragment-is-one-type) into
466
+ `GraphQLTypes` like any other, so the same union is one Ruby type family across
467
+ queries rather than a fresh dispatch module per query.
410
468
 
411
469
  ### Consuming a union — dispatch on the class, not `__typename`
412
470
 
@@ -438,8 +496,9 @@ you handle it.** It is exhaustive over the members *this query asked about*, plu
438
496
  cover: two *differently-selected* occurrences of the same union are distinct type
439
497
  families (`Result::Item::Book` is not `Result::FeaturedItem::Book`), so a `case`
440
498
  written for one won't span the other. Select the union through a shared fragment
441
- to hold it as one type across queries ([above](#abstract-types)); if all you have
442
- is the bare tag, `__typename` is the common denominator, unchecked.
499
+ to hold it as one type across queries
500
+ ([above](#a-shared-fragment-is-one-type)); if all you have is the bare tag,
501
+ `__typename` is the common denominator, unchecked.
443
502
 
444
503
  ## Type helpers
445
504
 
@@ -503,6 +562,22 @@ the one thing you couldn't add by hand: a `const` has no sig to put it in.
503
562
  `T.unsafe(self).name` also silences it, at the cost of checking nothing. Either
504
563
  beats `# typed: false` for a helper you want checked.
505
564
 
565
+ **An `abstract!` mixin has a precondition: every query must select what it
566
+ declares.** The mixin goes into *every* struct generated from the type, so a
567
+ query selecting a subset has nothing to satisfy the rest, and generation refuses
568
+ — naming the struct, the mixin, the members, and the two ways out: select them
569
+ in that query, or select the type through one
570
+ [shared fragment](#a-shared-fragment-is-one-type), which hoists a single struct
571
+ for every query to share. The second is usually the answer: a shape several
572
+ queries lean on is a fragment.
573
+
574
+ One sig a mixin cannot write is one over a *generated* type — `sig {
575
+ abstract.returns(GraphQLTypes::Species) }` raises `NameError` at generation,
576
+ because registrations load before the enum that generation is about to write.
577
+ The field's sig has to be `T.untyped` there. The struct's own `const` still
578
+ carries the real type, so a call site reading `pet.species` is checked; only the
579
+ mixin's view of it isn't.
580
+
506
581
  ### Flat accessors with `alias:`
507
582
 
508
583
  The one derivation the generator can type for you is a plain projection — a
@@ -528,7 +603,8 @@ alias: { label: "name", tag: "meta.tag" }
528
603
  ```
529
604
 
530
605
  The path is the **Ruby** accessor chain, so its segments are snake_case props
531
- (`name_with_owner.tag`), not wire names. It's typed from the selection: any
606
+ (`name_with_owner.tag`), not wire names. It may end on a
607
+ [hoisted](#a-shared-fragment-is-one-type) struct but not read through one. It's typed from the selection: any
532
608
  nullable hop makes the accessor nilable and inserts `&.`; the leaf can be a
533
609
  scalar, enum, or nested struct. It's validated against each query at generation —
534
610
  an unselected or misspelled segment (`did you mean 'tag'?`), a selector on a
@@ -679,8 +755,8 @@ it goes. The schema dump is step 0: codegen reads it, never a live endpoint, and
679
755
  generating without one fails pointing at exactly that.
680
756
 
681
757
  **A type shared across query modules lives in `GraphQLTypes` and is aliased in.**
682
- Input types, schema enums, and unions hoisted from shared fragments are all one
683
- kind of thing — a type that would otherwise be copied into every query that
758
+ Input types, schema enums, and the types hoisted from shared fragments are all
759
+ one kind of thing — a type that would otherwise be copied into every query that
684
760
  touches it — so they live in one module, one file each, and a query module that
685
761
  uses any of them opens with `require_relative "types"`. Rename the constant
686
762
  (`GraphWeaver.types_module=`, or `generate!(types_module:)`) when one app
@@ -716,6 +792,43 @@ spot a *schema* change for you — `schema:diff`, `schema:refresh`, `queries:che
716
792
  — are in [getting started](getting_started.md#5-verify-in-ci); a
717
793
  [`schema_stale?`](errors.md) error in production is the late signal.
718
794
 
795
+ ### Make your tooling treat generated/ as generated
796
+
797
+ Three readers, three tellings — and `rails g graph_weaver:install` writes the
798
+ two that live in files:
799
+
800
+ - **You.** Every generated file opens with `# Generated by GraphWeaver
801
+ <version> — do not edit.`
802
+ - **Rubocop.** The output directory goes into `AllCops: Exclude:` — machine-written
803
+ code isn't yours to style, and plain `rubocop` otherwise fires
804
+ `Style/Documentation` on every struct and `Metrics/*` on every `from_h`.
805
+ - **GitHub.** `linguist-generated` collapses these files in a pull-request diff
806
+ (shown as "Load diff") and drops them from the repository's language
807
+ breakdown. It is display-only: they stay versioned, expandable, and `git diff`
808
+ here is unchanged.
809
+
810
+ An app that didn't run the installer adds the two by hand:
811
+
812
+ ```yaml
813
+ # .rubocop.yml — inherit_mode keeps rubocop's own Exclude defaults
814
+ AllCops:
815
+ inherit_mode:
816
+ merge:
817
+ - Exclude
818
+ Exclude:
819
+ - "app/graphql/generated/**/*"
820
+ ```
821
+
822
+ ```text
823
+ # .gitattributes — gitignore-style, so `dir/**` is everything beneath it
824
+ app/graphql/generated/** linguist-generated
825
+ ```
826
+
827
+ `git check-attr linguist-generated app/graphql/generated/types.rb` answers `set`
828
+ when the pattern is right. One line per graph in a multi-schema app, and
829
+ [linguist's overrides](https://github.com/github-linguist/linguist/blob/main/docs/overrides.md)
830
+ has the other attributes it reads.
831
+
719
832
  ### Loading what it wrote
720
833
 
721
834
  In Rails, loading is automatic — the Railtie requires every generated file at
@@ -6,6 +6,8 @@ Follow it once when you add the gem to an app. Rails is assumed;
6
6
  [not Rails?](#not-rails) covers the differences. Exploring an API from a console
7
7
  instead? Start with [dynamic mode](real_world.md) — no build step. No Sorbet in
8
8
  your app? None needed — [Sorbet, with or without](#sorbet-with-or-without).
9
+ Already have a GraphQL client? [Migrating](migrating.md) is the order to move
10
+ off it in.
9
11
 
10
12
  ## 1. Install
11
13
 
@@ -96,6 +98,11 @@ What it wrote:
96
98
  marked "do not edit," so the output directory is added to `AllCops: Exclude:`.
97
99
  An `AllCops:` you already have is left alone (a second one would replace it,
98
100
  not merge); the generator prints the line to add.
101
+ - **`.gitattributes`** — `app/graphql/generated/** linguist-generated`, which is
102
+ how GitHub is told the same thing: it collapses those files in a pull-request
103
+ diff and leaves them out of the repository's language breakdown. Display only,
104
+ so they stay versioned and expandable
105
+ ([the three tellings](generated_modules.md#make-your-tooling-treat-generated-as-generated)).
99
106
 
100
107
  Rake needs no wiring: in Rails the `graph_weaver:*` tasks register themselves and
101
108
  depend on `:environment`, so your initializer runs first. The generated modules
@@ -165,10 +172,11 @@ Fragment files hold only fragments (no operations), and names are unique across
165
172
  them. Point elsewhere with `GraphWeaver.fragments_paths` (an appendable list,
166
173
  default `app/graphql/fragments`).
167
174
 
168
- One payoff: when a shared fragment *is* the whole selection on a union field,
169
- its type is hoisted once into `GraphQLTypes` and every query that spreads it
170
- gets the same Ruby type — so one exhaustive `case T.absurd` works everywhere.
171
- See [abstract types](generated_modules.md#abstract-types).
175
+ One payoff: when a shared fragment *is* the whole selection on a field, its type
176
+ is hoisted once into `GraphQLTypes` under the fragment's name and every query
177
+ that spreads it gets the same Ruby type — so a presenter can take a
178
+ `GraphQLTypes::PersonFields`, and a union's `case … T.absurd` works everywhere.
179
+ See [hoisting](generated_modules.md#a-shared-fragment-is-one-type).
172
180
 
173
181
  ## 4. Test against fakes
174
182
 
@@ -216,9 +224,10 @@ Five questions, five tasks — the last only on a federated graph:
216
224
  Every one of them exits non-zero on a finding, so the gate is a chain.
217
225
  `verify` compares the committed generated files against what the current schema
218
226
  + queries + registrations would produce, so it belongs in every CI build.
219
- `schema:diff` asks whatever the dump came from — a recorded source url (with
220
- `GRAPHWEAVER_AUTH` for private APIs), or your own schema class when the app
221
- [serves the schema itself](#your-apps-own-schema-in-process) and
227
+ `schema:diff` asks whatever the dump came from — the source url the dump
228
+ recorded (with `GRAPHWEAVER_AUTH` for private APIs), your own schema class when
229
+ the app [serves the schema itself](#your-apps-own-schema-in-process), else the
230
+ client this graph's modules already post to — and
222
231
  `rake graph_weaver:schema:refresh` is the repair either way. On an app with more
223
232
  than one schema, `rake graph_weaver:graphs` lists which graphs are configured,
224
233
  where each generates, and what each registers.
@@ -283,8 +292,8 @@ omits it. Any drift exits non-zero — whether a change matters is yours to judg
283
292
  (`#breaking`, `#compatible`, `#to_h`, `#empty?`).
284
293
 
285
294
  `queries:check` answers the question that matters when the schema *has* moved:
286
- **which of your queries no longer validate, and why.** It re-introspects the
287
- recorded url (without rewriting the dump) and validates every `.graphql` file
295
+ **which of your queries no longer validate, and why.** It re-introspects the url
296
+ the dump records (without rewriting the dump) and validates every `.graphql` file
288
297
  against the schema as it is right now, naming each error's line and column:
289
298
 
290
299
  ```
@@ -294,6 +303,12 @@ app/graphql/queries/person.graphql
294
303
  1 invalid query
295
304
  ```
296
305
 
306
+ A dump that records no url has nothing to re-read, and so does a graph that
307
+ names its own schema: both are checked as they stand on disk. That is `verify`'s
308
+ question rather than this one's, so the passing verdict names the file instead of
309
+ claiming a check against the server ([a dump you already
310
+ have](#a-schema-dump-you-already-have)).
311
+
297
312
  `GraphWeaver.check_queries` returns the same findings as data — a hash of file
298
313
  to `message`/`line`/`column`, empty when everything validates — so you can wire
299
314
  it into a spec, a Slack ping, an issue. Pass it `schema:` a *loaded* schema (not
@@ -331,9 +346,11 @@ app/graphql/queries/products.graphql: Products.sku — selected, never read (Cat
331
346
  Each line names the query file, the selection to go and delete, and the
332
347
  generated prop behind it. It reads the generated structs for the props a query
333
348
  produced, then sweeps your `.rb`, `.rake`, `.builder`, `.erb`, `.slim`, `.haml`
334
- and `.jbuilder` for every name they could be read by `.sku`, `sku:`, `:sku`,
335
- `"sku"`. `PATHS=app,lib` narrows the sweep (a `PATHS=` naming a directory that
336
- isn't there is refused rather than swept as nothing); anything under a directory
349
+ and `.jbuilder` plus Ruby carrying no extension, which is any name under
350
+ `bin/` or `exe/` and a ruby shebang anywhere else for every name they could be
351
+ read by: `.sku`, `sku:`, `:sku`, `"sku"`. `PATHS=app,lib` narrows the sweep (a
352
+ `PATHS=` naming a directory that isn't there is refused rather than swept as
353
+ nothing); anything under a directory
337
354
  named `generated`, plus `vendor`, `node_modules`, `tmp` and `log`, is skipped,
338
355
  as is any file defining a graphql-ruby **type** — a `field :sku` there is your
339
356
  *server* offering a field, not this app reading one back. A module handed whole
@@ -416,6 +433,27 @@ Sets `GraphWeaver.schema_path` to that file rather than writing a second copy,
416
433
  and introspects nothing. A dump has no resolvers, so it can't execute — set
417
434
  `GraphWeaver.client` to whatever serves the API.
418
435
 
436
+ **A dump you brought from elsewhere has no provenance**: it doesn't record the
437
+ server it was introspected from, which is exactly what migrating off
438
+ graphql-client leaves you ([migrating](migrating.md)). `rake
439
+ graph_weaver:schema:refresh` adopts it — with no url on the file it introspects
440
+ the client the graph names, rewrites the dump, and records the source, so every
441
+ later refresh and `schema:diff` re-read the right server. `URL=` names the
442
+ endpoint instead, if you'd rather say it once than configure the client first.
443
+
444
+ Do it early. Until the dump records a source, `queries:check` has nothing to
445
+ re-introspect, so it validates against the committed file — a real check, but
446
+ `verify`'s question rather than this one's — and the verdict says which:
447
+
448
+ ```
449
+ every query validates against db/schema.graphql as committed — not the server (rake graph_weaver:schema:diff asks whether the server moved)
450
+ ```
451
+
452
+ A dump with no recorded url and no client behind it *is* the schema — a
453
+ hand-maintained SDL nothing serves. `schema:refresh` leaves that one alone
454
+ (`records no source url and the graph names no client — left as checked in`) and
455
+ refreshes the rest, rather than taking the whole task down with it.
456
+
419
457
  ## More than one schema
420
458
 
421
459
  The five steps above describe one graph — a schema, its queries, its output. An
@@ -484,8 +522,9 @@ An app that is a pure client of someone else's GraphQL owns no schema class, so
484
522
  every graph's `schema` is a dump. Give each the file you want and a `client`
485
523
  that can fetch it: `rake graph_weaver:schema:refresh` introspects each graph's
486
524
  client into its own dump, recording the url so every later `schema:refresh` and
487
- `schema:diff` re-reads the right server. (`URL=` is for the app that has one
488
- dump and no graphs.)
525
+ `schema:diff` re-reads the right server. A graph with neither a recorded url nor
526
+ a client is left as checked in and the rest still refresh. (`URL=` is for the
527
+ app that has one dump and no graphs.)
489
528
 
490
529
  In specs, `graph:` is how an example says which graph a helper stands in for —
491
530
  `graphql_fake(graph: :poke, "pokemon_v2_pokemon.name" => "pikachu")`. See
@@ -527,6 +566,106 @@ per-struct `extend T::Sig` in generated files is redundant — rubocop's
527
566
  time and skips the `extend`; override with `GraphWeaver.extend_t_sig =
528
567
  true`/`false`.
529
568
 
569
+ ### The types stop where your sigs do
570
+
571
+ That typo is caught where the struct comes back. Hand the struct to one method
572
+ without a sig and it is `T.untyped` from there on — the same typo, one layer in,
573
+ is silent — and a sig on the method that produced it is no help either:
574
+
575
+ ```ruby
576
+ # typed: true
577
+ class PersonDirectory
578
+ extend T::Sig
579
+
580
+ sig { returns(T.nilable(PersonQuery::Result::Person)) }
581
+ def person = PersonQuery.execute!(id: "1").person
582
+ end
583
+
584
+ class Profile
585
+ def initialize(person) # no sig, so @person is untyped
586
+ @person = person
587
+ end
588
+
589
+ def title = @person.nmae # "No errors! Great job."
590
+ end
591
+
592
+ class Page
593
+ def initialize(directory) # nor is the sig above any help here
594
+ @directory = directory
595
+ end
596
+
597
+ def title = @directory.person&.nmae # also clean
598
+ end
599
+ ```
600
+
601
+ Every hop has to be sig'd: the method that produces the struct, any wrapper it
602
+ passes through, and the constructor that stored the collaborator. Two shapes
603
+ cover most of an app.
604
+
605
+ A wrapper that only passes a block through keeps the block's type with
606
+ `type_parameters`:
607
+
608
+ ```ruby
609
+ # a rescue wrapper that keeps the block's type
610
+ sig do
611
+ type_parameters(:T).params(blk: T.proc.returns(T.type_parameter(:T)))
612
+ .returns(T.type_parameter(:T))
613
+ end
614
+ def people(&blk) = yield
615
+ ```
616
+
617
+ A service boundary spells the struct it hands out, and whoever holds that
618
+ service types the ivar:
619
+
620
+ ```ruby
621
+ class Page
622
+ extend T::Sig
623
+
624
+ sig { params(directory: PersonDirectory).void }
625
+ def initialize(directory)
626
+ @directory = directory # the sig is what types this ivar
627
+ end
628
+
629
+ sig { returns(T.nilable(String)) }
630
+ def title = @directory.person&.nmae # now srb tc has it
631
+ end
632
+ ```
633
+
634
+ That is sigs on half a dozen service methods and a couple of constructors before
635
+ the first typo in a layered app is caught. Budget it as its own piece of work
636
+ rather than as part of generating the types — and if it isn't work you're going
637
+ to do, the runtime half still holds: a bad field is a `NoMethodError` the first
638
+ time the line runs.
639
+
640
+ ### Give a struct a short name where you name one
641
+
642
+ A generated struct's constant path spells out the query that produced it, which
643
+ is what makes it stable — and long. Where your own code names one in a sig, alias
644
+ it once, in the class that hands it out:
645
+
646
+ ```ruby
647
+ # before
648
+ sig { params(code: String).returns(T.nilable(Countries::CountryProfileQuery::Result::Country)) }
649
+
650
+ # after
651
+ class Directory
652
+ Country = Countries::CountryProfileQuery::Result::Country
653
+
654
+ sig { params(code: String).returns(T.nilable(Country)) }
655
+ end
656
+ ```
657
+
658
+ Use a plain constant, not `T.type_alias`: the constant works in a sig *and* as a
659
+ value (`Country.from_h`), and in a `# typed: strict` file it needs no `T.let`
660
+ around it. Reach for `T.type_alias` only where a constant can't express the type
661
+ — a union of two queries' structs.
662
+
663
+ That takes the worst sig in one migrated app from 116 characters to 72, and the
664
+ callers get the short name too: a presenter takes a `Directory::Country` and
665
+ stops knowing which `.graphql` file produced it. It is also what makes the sig
666
+ chain above affordable, since a sig usually goes unwritten because the line is
667
+ too long to want to write.
668
+
530
669
  ## Not Rails?
531
670
 
532
671
  There's no generator, but what it writes is short — a few lines wherever your
@@ -541,6 +680,12 @@ GraphWeaver.client = GraphWeaver.new(
541
680
  GraphWeaver.load_generated! # no Railtie to require the generated files
542
681
  ```
543
682
 
683
+ **`load_generated!` goes before your own requires** when anything your app loads
684
+ names a generated constant as it loads — a `STATUS_LABELS` table keyed on
685
+ `GraphQLTypes::ShipmentStatus` raises `uninitialized constant` otherwise, and
686
+ building it lazily to dodge that earns `Dynamic constant references are
687
+ unsupported` from `srb tc`. One line of ordering settles both.
688
+
544
689
  ```sh
545
690
  mkdir -p app/graphql/queries app/graphql/generated
546
691
  rake graph_weaver:schema:refresh URL=https://api.example.com/graphql
data/docs/migrating.md ADDED
@@ -0,0 +1,119 @@
1
+ # Migrating from graphql-client (or a hand-rolled client)
2
+
3
+ **The wire doesn't change.** The same query text goes out and the same JSON comes
4
+ back, so this is a sequence of small commits rather than a cutover: queries move
5
+ across one at a time while the old client keeps serving, and most of the spec
6
+ suite you already have keeps passing untouched.
7
+
8
+ Budget 3–4 weeks of one engineer for an app with ~40 queries and 25k lines of
9
+ code reading them — [what it costs](#what-it-costs) breaks that down. If the app
10
+ doesn't run Sorbet and has fewer than ten queries, don't migrate: the wins that
11
+ survive without `srb tc` are real, but they don't pay for a checked-in code
12
+ generator ([alternatives](alternatives.md#graphlient)).
13
+
14
+ ## From graphql-client, in Rails
15
+
16
+ **Both gems stay in the Gemfile until the last commit.** `rails g
17
+ graph_weaver:install` boots the app to read your configuration, so an initializer
18
+ that still requires graphql-client has to keep resolving — remove the gem first
19
+ and the generator dies on `cannot load such file -- graphql/client`. Running the
20
+ two side by side is the right shape anyway.
21
+
22
+ 1. **Install beside graphql-client**, pointed at the dump you already have:
23
+ `rails g graph_weaver:install db/petstore_schema.json` ([a schema dump you
24
+ already have](getting_started.md#a-schema-dump-you-already-have)). Commit that
25
+ and nothing else.
26
+ 2. **Adopt the dump.** It records no source url, so `schema:diff` and
27
+ `queries:check` have no server to ask. `rake graph_weaver:schema:refresh`
28
+ rewrites it from the client the app points at (or `URL=<your endpoint>` once)
29
+ and records the provenance. Do it now: until you do, `queries:check` is
30
+ re-reading the file it is meant to be checking against, and says so.
31
+ 3. **Register scalars and enums, and regenerate**, before porting any code.
32
+ Registrations are baked into generated source, so one added later reaches
33
+ nothing until the next `rake graph_weaver:generate` ([scalars](scalars.md)).
34
+ 4. **Port one query end to end** — write the `.graphql` file, generate, rewrite
35
+ its call site — and leave the specs alone. They pass untouched. That single
36
+ commit is the proof for every one after it.
37
+ 5. **Port the rest, one commit per query.** Each `Client.parse` constant stays
38
+ where it is until its last caller is gone.
39
+ 6. **Only now port the specs** to `graphql: :fake` ([testing](testing.md)). Keep
40
+ webmock until the last wire-level stub is gone, and keep one spec that really
41
+ serves HTTP.
42
+ 7. **Delete graphql-client.** Last commit.
43
+ 8. **Sigs through your own app are a separate project**, after all of the above —
44
+ [the types stop where your sigs do](getting_started.md#the-types-stop-where-your-sigs-do)
45
+ says what that work is.
46
+
47
+ Keep your own error classes. Translating `GraphWeaver::QueryError`,
48
+ `TransportError` and `ServerError` at one seam leaves a controller, its rescues
49
+ and their specs untouched ([errors](errors.md)). Keep your presenters too, and
50
+ your fixtures until step 6.
51
+
52
+ ### Why app code and specs are separate commits
53
+
54
+ Because the wire is the same, a webmock suite doesn't notice which client sent
55
+ the request. Swap the client under an untouched suite and most of it stays green
56
+ — 18 of 22 stubbed examples in one migrated Rails app, same stubs, same JSON
57
+ fixtures; the four that failed were unit specs feeding a hand-rolled `Struct` to
58
+ a presenter. That is the difference between a reviewable migration and a big-bang
59
+ one.
60
+
61
+ ## From a hand-rolled client
62
+
63
+ Same skeleton, three differences.
64
+
65
+ - **One branch, no seam.** There's no generator to keep bootable and a
66
+ hand-rolled client is sixty lines, so running two of them side by side costs
67
+ more than it saves.
68
+ - **Set the load order first**: `GraphWeaver.load_generated!` goes before your own
69
+ requires ([not Rails?](getting_started.md#not-rails)). Add `rake` to the Gemfile
70
+ while you're there.
71
+ - **Keep the old client's transport specs**, rewritten against a real
72
+ `GraphWeaver.new(url, retries: 2)` with webmock. Retries, timeouts and backoff
73
+ are the one thing fakes can't cover.
74
+
75
+ ## What you delete
76
+
77
+ - **The retry loop.** A hand-rolled one usually retries everything, mutations
78
+ included. `retries: 2` excludes mutations by default, honours `Retry-After`,
79
+ and knows 408 and 429 ([retries](transports.md#retries)) — twenty lines gone and
80
+ a correctness bug gone with them.
81
+ - **Scalar parsers.** `register_scalar("Money", Money)` is one line, and it
82
+ deletes the `"USD 12.50".split` sitting in every place that parsed one. `Date`
83
+ and `DateTime` need no registration at all ([scalars](scalars.md)).
84
+ - **Fragment unwrapping.** graphql-client masks a spread fragment's fields on the
85
+ parent, so every call site reads `Petstore::PetFields.new(data.pet).name` and
86
+ carries the parent alongside for the fields the fragment didn't cover. Generated
87
+ structs inline the fragment, so it's `pet.name`; a fragment that is a whole
88
+ selection becomes one shared Ruby type
89
+ ([hoisting](generated_modules.md#a-shared-fragment-is-one-type)).
90
+ - **JSON fixtures and stub helpers.** `graphql: :fake` fabricates a
91
+ schema-correct response and you pin the fields the example is about. Pins are
92
+ schema vocabulary (`"Pet.species" => "DOG"`), so they survive query refactors,
93
+ and they're spellchecked — a typo raises instead of leaving the example green
94
+ against random data ([testing](testing.md)).
95
+
96
+ ## The one behaviour change to plan for
97
+
98
+ **Enum drift is fatal.** A hand-rolled client hands you the raw string, so a value
99
+ the server added after you shipped falls through to whatever your code does with
100
+ an unknown one — commonly a `humanize`. A generated enum refuses instead: casting
101
+ raises and the whole response is lost, naming the value, the enum and the values
102
+ it knows about.
103
+
104
+ That is the right default for an API you own and a real risk for one you don't,
105
+ because it breaks on a day nobody deployed. Before you port a query over an enum
106
+ someone else can extend, register a fallback for it and unknown values land there
107
+ instead — [enums](scalars.md#enums-map-onto-your-own-tenum).
108
+
109
+ ## What it costs
110
+
111
+ | | how it scales | ~40 queries, 25k lines |
112
+ |---|---|---|
113
+ | `.graphql` files and generating | sublinear — the second query costs a tenth of the first | 2–3 days |
114
+ | call sites | linear, and the bulk of it: every `data["x"]["y"]` becomes `x.y`, and every defensive `nil` guard either disappears or turns out to have been wrong | ~1 day per 3–4k lines of consuming code |
115
+ | specs | linear, and pleasant — the fixture-to-pin conversion is the same edit every time | ~2 days |
116
+ | sigs through your own app | the one that decides whether the migration paid off | weeks; a separate project |
117
+
118
+ Coming off graphql-client, run the new one behind the old for the first two
119
+ weeks. The wire compatibility makes that free.