-
Notifications
You must be signed in to change notification settings - Fork 7
GAP-55 @fetchable Identity #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b46ddbb
3ce7424
b368fff
d4d1b0e
0686ae9
a79fe74
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| # Identity: @fetchable | ||
|
|
||
| > [!NOTE] | ||
| > This is one of a pair of companion proposals. `@fetchable` builds on **Identity: | ||
| > @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. | ||
|
|
||
| ``` | ||
| 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-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: "<field>")` | ||
| does not have to match `@strong(field_name: "<field>")`. | ||
|
|
||
| This is an alternative form of fetchability to the | ||
| [Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm). | ||
| `@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. | ||
| - 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 `<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` echoes the requested `id` (equal to the resolved `node`'s | ||
| `<field_name>` value when present). | ||
|
|
||
| **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__<Type>` 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 `<Type>`: | ||
|
|
||
| - a single-item query field, `Query.fetch__<Type>(id: ID!): <Type>`, exists. | ||
| - a multi-item query field, | ||
| `Query.multifetch__<Type>(ids: [ID!]!): [<Type>MultifetchEdge!]!`, exists. | ||
| - `type <Type>MultifetchEdge { node: <Type>, node_id: ID! }` exists. | ||
|
|
||
| `field_name` is always required. It names the field whose value can be passed to | ||
| the generated `Query.fetch__<Type>(id:)` and `Query.multifetch__<Type>(ids:)` | ||
| fields to (re-)fetch the object with the exact same _identity_. | ||
|
|
||
| `multifetch__<Type>(ids: $ids)` must return exactly one `<Type>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 `<Type>`, 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: "<field>")` is required: the referenced | ||
| field must exist and be typed `<field>: ID @semanticNonNull` or `<field>: 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm a bit confused by this section. If Here is an example that seems like it would be valid according to this section: # "field_name" is required, so it needs to be provided with
# a value
interface Story @fetchable(field_name: "id") {
id: ID!
}
# field_name can be different than its interface
type PhotoStory implements Story @fetchable(field_name: "cache_id") {
# id required by the interface
id: ID!
cache_id: ID!
}Am I understanding this correctly? If so, what is the value in having different This ties into the question for generated root fields. If root fields are only generated for object types, why is Clarification on my misunderstand would be helpful 🙂 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just realized this is included in the README. Should it be mentioned here as well?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
not just object types |
||
|
|
||
| ## New Implementation Recommendations | ||
|
|
||
| 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: | ||
|
|
||
| - Prefer a single field for both `@strong(field_name:)` and | ||
| `@fetchable(field_name:)` unless a separate fetch token is genuinely required. | ||
| - Ensure `Query.fetch__<Type>` and `Query.multifetch__<Type>` are generated | ||
| consistently for every `@fetchable` type, rather than hand-authored. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,184 @@ | ||
| # GAP-55: Identity: @fetchable | ||
|
|
||
| > [!NOTE] | ||
| > This proposal has a companion: **Identity: @strong** | ||
| > ([GAP-54](../GAP-54/README.md)), 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. | ||
|
|
||
| `@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. | ||
|
|
||
| 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. | ||
|
|
||
| ## 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__<Type>(id:)` and | ||
| `Query.multifetch__<Type>(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 | ||
|
|
||
| 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/). | ||
| - 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. | ||
|
|
||
| ## Challenges and drawbacks | ||
|
|
||
| `@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__<Type>`, `multifetch__<Type>`, and `<Type>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__<Type>` returns a wrapper edge | ||
| type (`{ node, node_id }`) rather than a plain `[<Type>]`, 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How would a client know to use this field since it only knows about concrete types? (e.g.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The client knows it is requesting an interface: the This may require manually knowing if there is no compiler to automatically thread these through. We always have a compiler step for producing queries for any client with a store, so this is not a problem for us. Alternatively, you could use the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also just logistically: we recommend against using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the "Apollo Client" way of thinking is so engrained in me which is why I have a hard time thinking about how these fit together, especially the lack of schema knowledge or compiler to help on that end of things. Thanks for the clarification! |
||
| 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`. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| id: 55 | ||
| 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/55" | ||
| related: | ||
| - 49 | ||
| - 54 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For an
interfacethat is@fetchable, what are its generated fields? Are the root fields generated for only the concrete types, or for the interface as well?Using your example above for
Story, isfetch__Story(id:)available or justfetch__PhotoStory(id:)?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just realized this is in the README. Should it be included here as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably!
But yes: each type (Object or Interface) that is
@fetchablegets its own Query fields.This is important: you may need the identity for interface fetchability to encode the underlying type, to make resolving across a cloud of types efficient, whereas the default fetchable field for a given object could be its standard
@strongfield.