From b46ddbb1c2bb39b51ae94e4ccbacf82ece0f0e13 Mon Sep 17 00:00:00 2001 From: Matt Mahoney Date: Tue, 9 Jun 2026 17:50:59 -0400 Subject: [PATCH 1/6] Draft Identity GAP: @fetchable (GAP-00) --- gaps/GAP-00/DRAFT.md | 182 +++++++++++++++++++++++++++++++++++++++ gaps/GAP-00/README.md | 67 ++++++++++++++ gaps/GAP-00/metadata.yml | 15 ++++ 3 files changed, 264 insertions(+) create mode 100644 gaps/GAP-00/DRAFT.md create mode 100644 gaps/GAP-00/README.md create mode 100644 gaps/GAP-00/metadata.yml diff --git a/gaps/GAP-00/DRAFT.md b/gaps/GAP-00/DRAFT.md new file mode 100644 index 0000000..d266a53 --- /dev/null +++ b/gaps/GAP-00/DRAFT.md @@ -0,0 +1,182 @@ +# Identity: @fetchable + +> [!NOTE] +> This is one of a pair of companion proposals. `@fetchable` builds on **Identity: +> @strong** ([GAP-0](../GAP-0/README.md) — placeholder number), which marks types +> as having an identity. The two are designed to be read together but may be +> adopted independently. + +``` +directive @fetchable(field_name: String!) on OBJECT | INTERFACE +``` + +## Introduction + +:: This document specifies the `@fetchable` schema directive, which declares that +a type can be independently (re-)fetched from a type-specific root field, given a +field value that identifies it. + +A `@fetchable` type can be retrieved on its own — without replaying the operation +that originally produced it — through generated root fields keyed on a +type-unique identifier. This enables refetching, cache eviction, and +optimistic-update tooling to re-resolve an object in isolation. + +`@fetchable` builds on the companion +[`@strong`](../GAP-0/README.md) directive: a type can only be fetched by an +identifier if it has one, so every `@fetchable` type must also be `@strong`. The +two need not, however, use the same field — `@fetchable(field_name: "")` +does not have to match `@strong(field_name: "")`. + +This is an alternative form of fetchability to the +[Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm). +In particular, `@fetchable` is particularly useful when: + +- You want to improve performance by avoiding costly `node(id: $id)` root field + resolution in favor of type-specific root fields. +- You want to improve performance by separating a field for large tokens required + for fetchability from a small field for hashed identity. +- You have already created an expensive or non-identifying `id` field that you + cannot migrate away from. + +For a `@fetchable` type ``, the schema is guaranteed to contain a +single-item root field, a multi-item root field, and an edge type: + +```graphql +type Query { + fetch__PhotoStory(id: ID!): PhotoStory + multifetch__PhotoStory(ids: [ID!]!): [PhotoStoryMultiFetchEdge!]! +} + +type PhotoStoryMultiFetchEdge { + node: PhotoStory + node_id: ID +} +``` + +where `node_id` is the `.` value. + +**Example** + +```graphql +# `Story` is guaranteed to have an identity, but that identity may be expressed +# by a different field across implementations. +interface Story @strong { + text: String +} + +# `EphemeralStory` has an identity (so it can be merged in a normalized cache) +# but is not, on its own, fetchable. +type EphemeralStory implements Story @strong(field_name: "cache_id") { + cache_id: ID! + text: String +} + +# `PhotoStory` is fetchable: it can be re-resolved on its own via +# `Query.fetch__PhotoStory(id:)`. +type PhotoStory @strong(field_name: "id") @fetchable(field_name: "id") { + id: ID! + text: String + photo_url: String +} +``` + +**Use Cases** + +- Refetching, cache eviction, and optimistic-update tooling may re-resolve a + `@fetchable` object on its own, rather than re-running the original operation. +- Batch loaders may use `multifetch__` to resolve many objects of a type in + a single round trip. +- Code generators may emit refetch queries only for the types that declare + fetchability. + +With the above example, having read a `PhotoStory`'s `id` in an earlier response, +we can re-resolve just that story without replaying the original query: + +```graphql +query RefetchPhotoStory($id: ID!) { + fetch__PhotoStory(id: $id) { + id + photo_url + } +} +``` + +To re-resolve many stories at once, `multifetch__PhotoStory` returns one edge per +requested id, in the same order and with the same length as the input list. Each +edge's `node_id` echoes the requested id (and equals `node`'s +`@fetchable(field_name:)` value when resolved), so results can be correlated back +to the requested ids even when a `node` is {null}: + +```graphql +query RefetchPhotoStories($ids: [ID!]!) { + multifetch__PhotoStory(ids: $ids) { + node_id + node { + photo_url + } + } +} +``` + +## Relationship to @strong + +When using `@strong` every `@fetchable` type must also be `@strong`. The `@fetchable(field_name:)` +need not match the `@strong(field_name:)`: identity (used for merging objects in +a normalized cache) and fetchability (used for re-resolution) may be backed by +different fields. This is useful, for example, when a small hashed field is used +for identity while a separate, larger token field is required to fetch the +object. + +## Generated root fields + +For each `@fetchable` type ``: + +- a single-item query field, `Query.fetch__(id: ID!): `, exists. +- a multi-item query field, + `Query.multifetch__(ids: [ID!]!): [MultiFetchEdge!]!`, exists. +- `type MultiFetchEdge { node: , node_id: ID }` exists. + +`field_name` is always required. It names the field whose value can be passed to +the generated `Query.fetch__(id:)` and `Query.multifetch__(ids:)` +fields to (re-)fetch the object with the exact same _identity_. + +`multifetch__(ids: $ids)` must return exactly one `MultiFetchEdge` +per input `id`, in the same order as the input `ids`: the result list always has +the same length as the input list. For each edge: + +- `node` is the resolved ``, or {null} if the corresponding `id` cannot be + resolved. +- `node_id` is the `id` from the input list — the original requested key, + preserved even when `node` is {null}. When `node` is non-null, `node_id` equals + `node`'s `@fetchable(field_name:)` value. + +The nullable `node` within a non-null edge is precisely what lets a caller +correlate every requested `id` to its result — by position and by `node_id` — +even for ids that did not resolve. + +## @fetchable on Object types + +For Object types, `@fetchable(field_name: "")` is required: the referenced +field must exist and be typed `: ID @semanticNonNull` or `: ID!`. + +## @fetchable on Interface types + +`field_name` is required on an `@fetchable` interface, just as it is on an +`@fetchable` object. All types implementing an `@fetchable` interface must +themselves be `@fetchable`, but the interface and its implementations each +specify their own `field_name` and _need not_ share the same value. + +## New Implementation Recommendations + +This specification _matches behavior in existing implementations_: there are +adjacent, semi-duplicated APIs with regards to `__token` and +`@fetchable(field_name:)`. As this specification is a result of iterative, +in-production implementations, it describes what _is_ rather than what _ought to +be_. + +If you're creating a new GraphQL implementation, you should, if possible: + +- Prefer a single field for both `@strong(field_name:)` and + `@fetchable(field_name:)` unless a separate fetch token is genuinely required. +- Ensure `Query.fetch__` and `Query.multifetch__` are generated + consistently for every `@fetchable` type, rather than hand-authored. diff --git a/gaps/GAP-00/README.md b/gaps/GAP-00/README.md new file mode 100644 index 0000000..3a02214 --- /dev/null +++ b/gaps/GAP-00/README.md @@ -0,0 +1,67 @@ +# GAP-00: Identity: @fetchable + +> [!NOTE] +> This is a scaffold. `GAP-00` is a placeholder until the introducing PR is filed +> (see [CONTRIBUTING.md](../../CONTRIBUTING.md)); it will be renamed to the PR +> number. Sections marked _TODO_ still need to be written. +> +> This proposal has a companion: **Identity: @strong** +> ([GAP-0](../GAP-0/README.md) — placeholder number), which `@fetchable` builds +> on. + +## Overview + +This proposal defines the **`@fetchable`** schema directive, which declares that +a type can be independently (re-)fetched from a type-specific root field given a +field value that identifies it. + +For a `@fetchable` type `Type`, the schema guarantees generated root fields: + +- `Query.fetch__Type(id: ID!): Type` — single-item fetch +- `Query.multifetch__Type(ids: [ID!]!): [TypeMultiFetchEdge!]!` — batch fetch +- `type TypeMultiFetchEdge { node: Type, node_id: ID }` + +`@fetchable` builds on the companion **`@strong`** directive (see +[Identity: @strong](../GAP-0/README.md)): every `@fetchable` type must also be +`@strong`, though the two directives need not reference the same field — identity +and fetchability may be backed by different fields. + +## Motivation + + + +_TODO._ + +## Relationship to prior art + +This is an alternative form of fetchability to the +[Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm) +(the `Node` interface and `node(id:)` root field). + +Related discussions and prior art: + +- [Global Object Identification](https://relay.dev/graphql/objectidentification.htm). +- [Apollo Federation entities and `@key`](https://www.apollographql.com/docs/federation/entities/). +- [GAP-33 — Set Extensions for Type System Documents](../GAP-33/README.md). +- Companion proposal: [Identity: @strong](../GAP-0/README.md). + +## Status + +**Proposal.** Initial draft; not yet sponsored. + +## Challenges and drawbacks + + + +_TODO._ diff --git a/gaps/GAP-00/metadata.yml b/gaps/GAP-00/metadata.yml new file mode 100644 index 0000000..88268fe --- /dev/null +++ b/gaps/GAP-00/metadata.yml @@ -0,0 +1,15 @@ +id: 0 +title: "Identity: @fetchable" +summary: > + A schema directive declaring that a type can be independently (re-)fetched via + generated type-specific root fields (`fetch__Type`, `multifetch__Type`). Builds + on the companion `@strong` directive — every `@fetchable` type must be `@strong`. +status: proposal +authors: + - name: "Matt Mahoney" + email: "mahoney.mattj@gmail.com" + githubUsername: "@mjmahone" +sponsor: "@TBD" +discussion: "https://github.com/graphql/gaps/pull/0" +related: + - 33 From 3ce7424f89aa8592ab4ab914ca2920b2bd7ddd85 Mon Sep 17 00:00:00 2001 From: Matt Mahoney Date: Tue, 9 Jun 2026 17:57:49 -0400 Subject: [PATCH 2/6] Renumber to GAP-55 and update companion references to GAP-54 --- gaps/{GAP-00 => GAP-55}/DRAFT.md | 4 ++-- gaps/{GAP-00 => GAP-55}/README.md | 13 +++++-------- gaps/{GAP-00 => GAP-55}/metadata.yml | 5 +++-- 3 files changed, 10 insertions(+), 12 deletions(-) rename gaps/{GAP-00 => GAP-55}/DRAFT.md (97%) rename gaps/{GAP-00 => GAP-55}/README.md (81%) rename gaps/{GAP-00 => GAP-55}/metadata.yml (86%) diff --git a/gaps/GAP-00/DRAFT.md b/gaps/GAP-55/DRAFT.md similarity index 97% rename from gaps/GAP-00/DRAFT.md rename to gaps/GAP-55/DRAFT.md index d266a53..2c9ff4b 100644 --- a/gaps/GAP-00/DRAFT.md +++ b/gaps/GAP-55/DRAFT.md @@ -2,7 +2,7 @@ > [!NOTE] > This is one of a pair of companion proposals. `@fetchable` builds on **Identity: -> @strong** ([GAP-0](../GAP-0/README.md) — placeholder number), which marks types +> @strong** ([GAP-54](../GAP-54/README.md)), which marks types > as having an identity. The two are designed to be read together but may be > adopted independently. @@ -22,7 +22,7 @@ type-unique identifier. This enables refetching, cache eviction, and optimistic-update tooling to re-resolve an object in isolation. `@fetchable` builds on the companion -[`@strong`](../GAP-0/README.md) directive: a type can only be fetched by an +[`@strong`](../GAP-54/README.md) directive: a type can only be fetched by an identifier if it has one, so every `@fetchable` type must also be `@strong`. The two need not, however, use the same field — `@fetchable(field_name: "")` does not have to match `@strong(field_name: "")`. diff --git a/gaps/GAP-00/README.md b/gaps/GAP-55/README.md similarity index 81% rename from gaps/GAP-00/README.md rename to gaps/GAP-55/README.md index 3a02214..f525df5 100644 --- a/gaps/GAP-00/README.md +++ b/gaps/GAP-55/README.md @@ -1,13 +1,10 @@ -# GAP-00: Identity: @fetchable +# GAP-55: Identity: @fetchable > [!NOTE] -> This is a scaffold. `GAP-00` is a placeholder until the introducing PR is filed -> (see [CONTRIBUTING.md](../../CONTRIBUTING.md)); it will be renamed to the PR -> number. Sections marked _TODO_ still need to be written. +> This is a scaffold. Sections marked _TODO_ still need to be written. > > This proposal has a companion: **Identity: @strong** -> ([GAP-0](../GAP-0/README.md) — placeholder number), which `@fetchable` builds -> on. +> ([GAP-54](../GAP-54/README.md)), which `@fetchable` builds on. ## Overview @@ -22,7 +19,7 @@ For a `@fetchable` type `Type`, the schema guarantees generated root fields: - `type TypeMultiFetchEdge { node: Type, node_id: ID }` `@fetchable` builds on the companion **`@strong`** directive (see -[Identity: @strong](../GAP-0/README.md)): every `@fetchable` type must also be +[Identity: @strong](../GAP-54/README.md)): every `@fetchable` type must also be `@strong`, though the two directives need not reference the same field — identity and fetchability may be backed by different fields. @@ -48,7 +45,7 @@ Related discussions and prior art: - [Global Object Identification](https://relay.dev/graphql/objectidentification.htm). - [Apollo Federation entities and `@key`](https://www.apollographql.com/docs/federation/entities/). - [GAP-33 — Set Extensions for Type System Documents](../GAP-33/README.md). -- Companion proposal: [Identity: @strong](../GAP-0/README.md). +- Companion proposal: [Identity: @strong](../GAP-54/README.md). ## Status diff --git a/gaps/GAP-00/metadata.yml b/gaps/GAP-55/metadata.yml similarity index 86% rename from gaps/GAP-00/metadata.yml rename to gaps/GAP-55/metadata.yml index 88268fe..7eb5b7f 100644 --- a/gaps/GAP-00/metadata.yml +++ b/gaps/GAP-55/metadata.yml @@ -1,4 +1,4 @@ -id: 0 +id: 55 title: "Identity: @fetchable" summary: > A schema directive declaring that a type can be independently (re-)fetched via @@ -10,6 +10,7 @@ authors: email: "mahoney.mattj@gmail.com" githubUsername: "@mjmahone" sponsor: "@TBD" -discussion: "https://github.com/graphql/gaps/pull/0" +discussion: "https://github.com/graphql/gaps/pull/55" related: - 33 + - 54 From b368fffb4764aad91f6d94188470c59e9f080447 Mon Sep 17 00:00:00 2001 From: Matt Mahoney Date: Tue, 9 Jun 2026 18:02:47 -0400 Subject: [PATCH 3/6] Remove unrelated GAP-33 from related/prior art --- gaps/GAP-55/README.md | 1 - gaps/GAP-55/metadata.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/gaps/GAP-55/README.md b/gaps/GAP-55/README.md index f525df5..0f97fe2 100644 --- a/gaps/GAP-55/README.md +++ b/gaps/GAP-55/README.md @@ -44,7 +44,6 @@ Related discussions and prior art: - [Global Object Identification](https://relay.dev/graphql/objectidentification.htm). - [Apollo Federation entities and `@key`](https://www.apollographql.com/docs/federation/entities/). -- [GAP-33 — Set Extensions for Type System Documents](../GAP-33/README.md). - Companion proposal: [Identity: @strong](../GAP-54/README.md). ## Status diff --git a/gaps/GAP-55/metadata.yml b/gaps/GAP-55/metadata.yml index 7eb5b7f..2ba5b81 100644 --- a/gaps/GAP-55/metadata.yml +++ b/gaps/GAP-55/metadata.yml @@ -12,5 +12,4 @@ authors: sponsor: "@TBD" discussion: "https://github.com/graphql/gaps/pull/55" related: - - 33 - 54 From d4d1b0e3699a5999ff1d67d3d2fc137d66e382d2 Mon Sep 17 00:00:00 2001 From: Matt Mahoney Date: Tue, 9 Jun 2026 18:13:12 -0400 Subject: [PATCH 4/6] Remove __token / @fetchable(field_name:) semi-duplicated APIs note --- gaps/GAP-55/DRAFT.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/gaps/GAP-55/DRAFT.md b/gaps/GAP-55/DRAFT.md index 2c9ff4b..d75562d 100644 --- a/gaps/GAP-55/DRAFT.md +++ b/gaps/GAP-55/DRAFT.md @@ -168,11 +168,9 @@ specify their own `field_name` and _need not_ share the same value. ## New Implementation Recommendations -This specification _matches behavior in existing implementations_: there are -adjacent, semi-duplicated APIs with regards to `__token` and -`@fetchable(field_name:)`. As this specification is a result of iterative, -in-production implementations, it describes what _is_ rather than what _ought to -be_. +This specification _matches behavior in existing implementations_: as this +specification is a result of iterative, in-production implementations, it +describes what _is_ rather than what _ought to be_. If you're creating a new GraphQL implementation, you should, if possible: From 0686ae99c80184ff0cc345682f2de2458e0ff6bf Mon Sep 17 00:00:00 2001 From: Matt Mahoney Date: Wed, 10 Jun 2026 15:52:28 -0400 Subject: [PATCH 5/6] Flesh out @fetchable Motivation/Challenges; node_id non-null; add GAP-49 to related --- gaps/GAP-55/DRAFT.md | 17 +++-- gaps/GAP-55/README.md | 156 ++++++++++++++++++++++++++++++++------- gaps/GAP-55/metadata.yml | 1 + 3 files changed, 141 insertions(+), 33 deletions(-) diff --git a/gaps/GAP-55/DRAFT.md b/gaps/GAP-55/DRAFT.md index d75562d..1201e94 100644 --- a/gaps/GAP-55/DRAFT.md +++ b/gaps/GAP-55/DRAFT.md @@ -29,7 +29,7 @@ does not have to match `@strong(field_name: "")`. This is an alternative form of fetchability to the [Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm). -In particular, `@fetchable` is particularly useful when: +`@fetchable` may be useful when: - You want to improve performance by avoiding costly `node(id: $id)` root field resolution in favor of type-specific root fields. @@ -44,16 +44,17 @@ single-item root field, a multi-item root field, and an edge type: ```graphql type Query { fetch__PhotoStory(id: ID!): PhotoStory - multifetch__PhotoStory(ids: [ID!]!): [PhotoStoryMultiFetchEdge!]! + multifetch__PhotoStory(ids: [ID!]!): [PhotoStoryMultifetchEdge!]! } -type PhotoStoryMultiFetchEdge { +type PhotoStoryMultifetchEdge { node: PhotoStory - node_id: ID + node_id: ID! } ``` -where `node_id` is the `.` value. +where `node_id` echoes the requested `id` (equal to the resolved `node`'s +`` value when present). **Example** @@ -133,14 +134,14 @@ For each `@fetchable` type ``: - a single-item query field, `Query.fetch__(id: ID!): `, exists. - a multi-item query field, - `Query.multifetch__(ids: [ID!]!): [MultiFetchEdge!]!`, exists. -- `type MultiFetchEdge { node: , node_id: ID }` exists. + `Query.multifetch__(ids: [ID!]!): [MultifetchEdge!]!`, exists. +- `type MultifetchEdge { node: , node_id: ID! }` exists. `field_name` is always required. It names the field whose value can be passed to the generated `Query.fetch__(id:)` and `Query.multifetch__(ids:)` fields to (re-)fetch the object with the exact same _identity_. -`multifetch__(ids: $ids)` must return exactly one `MultiFetchEdge` +`multifetch__(ids: $ids)` must return exactly one `MultifetchEdge` per input `id`, in the same order as the input `ids`: the result list always has the same length as the input list. For each edge: diff --git a/gaps/GAP-55/README.md b/gaps/GAP-55/README.md index 0f97fe2..b7d43d7 100644 --- a/gaps/GAP-55/README.md +++ b/gaps/GAP-55/README.md @@ -1,8 +1,6 @@ # GAP-55: Identity: @fetchable > [!NOTE] -> This is a scaffold. Sections marked _TODO_ still need to be written. -> > This proposal has a companion: **Identity: @strong** > ([GAP-54](../GAP-54/README.md)), which `@fetchable` builds on. @@ -12,27 +10,81 @@ This proposal defines the **`@fetchable`** schema directive, which declares that a type can be independently (re-)fetched from a type-specific root field given a field value that identifies it. -For a `@fetchable` type `Type`, the schema guarantees generated root fields: - -- `Query.fetch__Type(id: ID!): Type` — single-item fetch -- `Query.multifetch__Type(ids: [ID!]!): [TypeMultiFetchEdge!]!` — batch fetch -- `type TypeMultiFetchEdge { node: Type, node_id: ID }` - `@fetchable` builds on the companion **`@strong`** directive (see [Identity: @strong](../GAP-54/README.md)): every `@fetchable` type must also be `@strong`, though the two directives need not reference the same field — identity and fetchability may be backed by different fields. -## Motivation +For a `@fetchable` type `Type`, the schema guarantees generated root fields: - +- `Query.fetch__Type(id: ID!): Type` — single-item fetch +- `Query.multifetch__Type(ids: [ID!]!): [TypeMultifetchEdge!]!` — batch fetch +- `type TypeMultifetchEdge { node: Type, node_id: ID! }` + +Given the schema: +``` +type User @strong(field_name: "id") @fetchable(field_name: "key") { + id: ID! + key: ID! +} + +type Query { + fetch__User(id: ID!): User + multifetch__User(ids: [ID!]!): [UserMultifetchEdge!]! +} + +type UserMultifetchEdge { + node: User + node_id: ID! +} +``` + +and a `User.key` value, `@fetchable` guarantees that the following query will always produce the same response when provided the same `$key`: + +``` +query FetchUser($key: ID!) { + fetch__User(id: $key) { + id + key + } +} +``` +While `User.id` may be different from `User.key`, both values must stay consistent across requests. -_TODO._ +## Motivation + +This proposal documents how `@fetchable` is already defined and used, in +production. `@fetchable` exists to let a client re-resolve a single object on its +own — for refetching, cache eviction, pagination repair, and optimistic-update +rollback — without replaying the operation that originally produced it. + +`@fetchable` builds directly on [`@strong`](../GAP-54/README.md), and the two +divide a single concern that [Global Object Identification](https://relay.dev/graphql/objectidentification.htm) +conflates. Identity (`@strong`) tells a client *whether two values are the same +entity*; fetchability (`@fetchable`) tells it *how to retrieve that entity +again*. The Global Object Identification spec couples both into one `id` field +resolved through a single `node(id:)` root field, which carries three costs +`@fetchable` avoids: + +- **Service resolution cost**: A single polymorphic `node(id:)` must dispatch across + every `Node` type, which is often slower and harder to optimize than a + type-specific root field. `@fetchable` generates `Query.fetch__(id:)` and + `Query.multifetch__(ids:)`, so each type is fetched through a direct, + individually optimizable path. +- **Token size**: Because `node(id:)` keys on the same `id` used for identity, an + object that needs a large retrieval token must carry that token *as* its + identity, bloating every cache key. `@fetchable` lets identity and retrieval be + backed by *different* fields — exactly the `id` (identity) versus `key` + (retrieval) split shown above: a small hashed field can serve as the `@strong` + identity while a separate, larger token field serves as the `@fetchable` key. +- **Client type resolution**: `Node` is an abstract type, and requires explicit type-discrimination like `... on ActualType { ... }` + to actually use the value. This risks empty non-null responses when `node` resolves + to a different type. It also makes schema evolution more challenging: migrating a type from an Object to an Interface + is difficult when clients rely on a `__typename` field to determine whether the spread underneath `node` is fulfilled. + +Because `@fetchable` is per-type and explicit, code generators can emit refetch +queries only for the types that actually declare fetchability, rather than +assuming every `Node` is independently retrievable. ## Relationship to prior art @@ -52,12 +104,66 @@ Related discussions and prior art: ## Challenges and drawbacks - - -_TODO._ +`@fetchable` inherits the adoption caveats of [`@strong`](../GAP-54/README.md), +and adds a few of its own. + +**Generated field naming and collisions**: `@fetchable` reserves the +`fetch__`, `multifetch__`, and `MultifetchEdge` names. The +embedded `__` is the convention that keeps these from colliding with +user-authored fields (which must not contain `__`), but the prefix is not +configurable in this spec, and an implementation must still guard against +collisions between generated names and any other generated or hand-authored +schema element. + +**Two identity-like fields to keep in sync**: allowing +`@fetchable(field_name:)` to differ from `@strong(field_name:)` means a type can have +two related-but-distinct fields to represent identity. +Prefer a single field for both unless a separate fetch token is genuinely +required. + +**The `MultifetchEdge` wrapper**: `multifetch__` returns a wrapper edge +type (`{ node, node_id }`) rather than a plain `[]`, purely so an +unresolved `id` can be correlated back to its request by position and by +`node_id`. This is extra schema surface, and the nullability of `node` versus +`node_id` is subtle: `node` may be {null} for an id that does not resolve, while +`node_id` always echoes the requested id. Implementations that support nullable +list items could express this without the wrapper, but the wrapper is required if +your client cannot include null list items: you don't want to compact out null edges +and end up with differently-sized input and output lists. + +**Fetchable interfaces with divergent `field_name`s**: an `@fetchable` interface +and each of its implementations may declare *different* `field_name`s. It's important +not to assume we can use the *concrete type's* fetchable field with the *interface*'s root fetch field. + +For instance with: +``` +interface Animal @fetchable(field_name: "animal_id") { + name: String + animal_id: ID! +} + +type Dog implements Animal @fetchable(field_name: "id") { + name: String + id: ID! + animal_id: ID! +} + +type Query { + fetch__Animal(id: ID!): Animal + fetch__Dog(id: ID!): Dog + random_animal: Animal! +} +``` +If we try to get a Dog by passing the `animal_id` from `Query.random_animal.animal_id` in an operation like: +``` +query FetchDog($id: ID!) { + fetch__Dog(id: $id) { + name + id + } +} +``` +we cannot guarantee the `Query.fetch__Dog` field can resolve based on the `animal_id`. + +Instead, we must use `Query.fetch__Animal` when passing an `Animal.animal_id` or `Dog.animal_id`, +but we can specifically request a `Dog` response with `Query.fetch__Dog` via a `Dog.id`. diff --git a/gaps/GAP-55/metadata.yml b/gaps/GAP-55/metadata.yml index 2ba5b81..b26c336 100644 --- a/gaps/GAP-55/metadata.yml +++ b/gaps/GAP-55/metadata.yml @@ -12,4 +12,5 @@ authors: sponsor: "@TBD" discussion: "https://github.com/graphql/gaps/pull/55" related: + - 49 - 54 From a79fe74041e034c6af6e975516e084eb9e47e0cf Mon Sep 17 00:00:00 2001 From: Matt Mahoney Date: Wed, 10 Jun 2026 16:10:35 -0400 Subject: [PATCH 6/6] Add Relay reference-implementation links to @fetchable prior art --- gaps/GAP-55/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/gaps/GAP-55/README.md b/gaps/GAP-55/README.md index b7d43d7..ced4b93 100644 --- a/gaps/GAP-55/README.md +++ b/gaps/GAP-55/README.md @@ -98,6 +98,21 @@ Related discussions and prior art: - [Apollo Federation entities and `@key`](https://www.apollographql.com/docs/federation/entities/). - Companion proposal: [Identity: @strong](../GAP-54/README.md). +**Reference implementation.** Relay already carries (partial, partly +undocumented) support for `@fetchable`: + +- Relay's directive docs reference it under + [`@refetchable(..., preferFetchable:)`](https://relay.dev/docs/api-reference/graphql/graphql-directives/): + the flag makes the compiler "prefer generating `fetch_MyType(): MyType` + queries … useful for schemas that have adopted the `@strong` and `@fetchable` + server annotations". +- The `@fetchable(field_name:)` directive is defined in the Relay compiler — + [`compiler/crates/schema/src/flatbuffer.rs`](https://github.com/facebook/relay/blob/main/compiler/crates/schema/src/flatbuffer.rs). +- Relay generates per-type fetch queries from `@fetchable` via + [`fetchable_query_generator.rs`](https://github.com/facebook/relay/blob/main/compiler/crates/relay-transforms/src/refetchable_fragment/fetchable_query_generator.rs). +- `@fetchable` on interfaces is shown in the + [Relay 15 release notes](https://relay.dev/blog/2023/03/30/relay-15/). + ## Status **Proposal.** Initial draft; not yet sponsored.