diff --git a/gaps/GAP-55/DRAFT.md b/gaps/GAP-55/DRAFT.md new file mode 100644 index 0000000..1201e94 --- /dev/null +++ b/gaps/GAP-55/DRAFT.md @@ -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: "")` +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). +`@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 ``, 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 +`` 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__` 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_: 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-55/README.md b/gaps/GAP-55/README.md new file mode 100644 index 0000000..ced4b93 --- /dev/null +++ b/gaps/GAP-55/README.md @@ -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__(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 + +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__`, `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 new file mode 100644 index 0000000..b26c336 --- /dev/null +++ b/gaps/GAP-55/metadata.yml @@ -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