diff --git a/gaps/GAP-54/DRAFT.md b/gaps/GAP-54/DRAFT.md new file mode 100644 index 0000000..86d5aa3 --- /dev/null +++ b/gaps/GAP-54/DRAFT.md @@ -0,0 +1,141 @@ +# Identity: @strong + +> [!NOTE] +> This is one of a pair of companion proposals. **Identity: @fetchable** +> ([GAP-55](../GAP-55/README.md)) builds on `@strong` to +> describe types that can be independently (re-)fetched given their identity. The +> two are designed to be read together but may be adopted independently. + +``` +directive @strong(field_name: String) on OBJECT | INTERFACE +``` + +## Introduction + +:: This document specifies the `@strong` schema directive, which marks a type as +having a _strong identity_: an identifier that is unique across all values of the +same type, such that any two values of the type that share that identity denote +the same entity, wherever they appear in a response. + +GraphQL responses frequently describe the same underlying entity in more than one +place — the same user as the `author` of a post and as a `friend` of the viewer, +for example. Reconciling two response values is often done via convention, for +instance by assuming two values with the same `id` field value are the same. + +`@strong` makes expectations explicit: two values of a `@strong` type that share the same +identity are the same entity, wherever they appear. + +We add a meta-field to _all_ types, `strong_id__: ID`. When a type +is `@strong`, `strong_id__` is `@semanticNonNull`. If `@strong(field_name: )` +is specified, then `strong_id__` returns the same value as `.`. + +This is an alternative form of identity to the +[Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm). +In particular, `@strong` may be useful when: + +- You cannot guarantee globally unique IDs for all objects in the schema. +- You have already created an expensive or non-identifying `id` field that you + cannot migrate away from. +- You need to have transient elements with an identity that cannot be reliably + fetched. +- You need to be able to migrate types from being weak, without a known identity, + to strong, with a specified identity. +- You need to be able to request a _potential_ identity, to ensure abstract types + with mixed strong and weak implementations merge across requests correctly + within a normalized cache. + +The companion `@fetchable` directive (see +[Identity: @fetchable](../GAP-55/README.md)) builds on `@strong` to describe how +a strong identity can be used to (re-)fetch an object from a type-specific root +field. + +**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` has a strong identity backed by its `id` field. +type PhotoStory @strong(field_name: "id") { + id: ID! + text: String + photo_url: String +} +``` + +**Use Cases** + +- Normalized client caches may merge and deduplicate objects of a `@strong` type + by identity, and may store them in a single canonical location. +- Abstract types with mixed strong and weak implementations may request the + `strong_id__` meta-field to merge correctly across requests. +- Code generators may emit identity-aware types (e.g. cache keys) only for the + types that declare identity. + +With the above example, if we want to write a query that guarantees we can de-duplicate +stories across responses, we might have: +```graphql +query UserStories { + user { + strong_id__ + stories { + strong_id__ + + ... on PhotoStory { + id + photo_url + } + } + } +} +``` +Even though the identity field for `PhotoStory` and `EphemeralStory` are different, +because `Story` is `@strong` we know we can create a key-value store of all Story instances using just `strong_id__` as the key. + +## The strong_id\_\_ meta-field + +A `strong_id__: ID` meta-field is available on every type. + +- If a type is `@strong`, `strong_id__` must be either `strong_id__: ID @semanticNonNull` or `strong_id__: ID!`. +- `<__typename> + strong_id__` forms a globally unique value. +- If a type is not `@strong`, `strong_id__` is `ID` and is always {null}. +- If `@strong(field_name: "")` is specified, `strong_id__` returns the same + value as `.`. + +## @strong on Object types + +For Object types, `@strong(field_name: "")` is non-nullable: the referenced field +must exist and be typed `: ID @semanticNonNull` or `: ID!`. + +## @strong on Interface types + +For Interface types, `@strong(field_name: "")` is optional. If provided, the referenced field +must exist and be typed `: ID @semanticNonNull` or `: ID!`. + +- All types implementing an `@strong` interface must themselves be `@strong`. +- All types implementing an interface with `@strong(field_name: "")` set must + provide the same value `@strong(field_name: "")` 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: +- Ensure the `@strong(field_name:)` is guaranteed to be a globally-unique value. +- Otherwise, ensure via automation or validation, that `__typename` is fetched wherever necessary to ensure globally-unique identity values can be created. +- Always fetch `strong_id__` on all abstract selection sets, i.e. underneath Union or Interface fields, in order to ensure every + instance of a given concrete Object can be merged, regardless of whether it's fetched underneath a Concrete or Abstract field. + - A non-`@strong` Interface or Union may include `@strong` Object types, which is why `strong_id__` is available as a nullable meta-field on *all* types. diff --git a/gaps/GAP-54/README.md b/gaps/GAP-54/README.md new file mode 100644 index 0000000..b844be1 --- /dev/null +++ b/gaps/GAP-54/README.md @@ -0,0 +1,156 @@ +# GAP-54: Identity: @strong + +> [!NOTE] +> This is a scaffold. Sections marked _TODO_ still need to be written. +> +> This proposal has a companion: **Identity: @fetchable** +> ([GAP-55](../GAP-55/README.md)), which builds on `@strong`. + +## Overview + +This proposal defines the **`@strong`** schema directive, which marks a type as +having a _strong identity_: an identifier that is unique across all values of the +same type, such that any two values sharing that identity denote the same entity, +wherever they appear in a response. + +It also defines a `strong_id__: ID` meta-field on every type. For `@strong` +types, `strong_id__` is semantically non-null and, combined with `__typename`, +forms a globally unique value. + +`@strong` is the foundation for the companion **`@fetchable`** directive (see +[Identity: @fetchable](../GAP-55/README.md)): a type must be `@strong` before it +can be `@fetchable`. + +## Motivation + +This proposal documents how `@strong` is already defined and used, in production. +`@strong` enabled Meta to solve specific performance and behavioral issues, and at the time +acted as a foundation for more advanced features. + +Specifically, `@strong` enabled production schemas to safely evolve while preserving +long-lived, compiled clients' store behavior, in a way that was not possible with [Global Object Identification](https://relay.dev/graphql/objectidentification.htm). + +The Global Object Identification Spec conflates *identity* with an object's *retrieval token*. + +If an object is ephemeral and not retrievable from the service, there is either no way to retrieve the object via `node(id: $id)`, or the `id` must encode the entirety of the object's data. In practice, +this led to either producing `id` values that were kilobytes in size, or failing to fulfill the +[Global Object Identification Spec](https://relay.dev/graphql/objectidentification.htm) by having types that +implement `Node` but return responses with `"id": null`. + +The key insight for `@strong` was that we needed a single meta-field, `strong_id__: ID`, that can be requested within *any* selection set, which does *not* guarantee we can retrieve the same object from the service. We need to be able to request `strong_id__` on *abstract* (Union and Interface) type selections. Unions and Interfaces may mix strong and weak types, and we want to ensure that strong values are always consistent. + +`strong_id__` allows clients using a consistent, normalized store to: +- Always request a single field, and know whether to treat a given object in the response as "strong" or "weak" based on whether `strong_id__` is null or not. +- If `strong_id__` is always requested, weak objects (say, an Address type), can become `@strong`, and get a consistency "fix" on old clients. +- For objects that have large retrieval keys, storing the retrieval key as the *key in the store* can cause performance degradation on store publish and lookups. + - If the `id` field is incredibly large, we can create a new field, `cache_id`, that is a one-way hash of `id`'s value. + - By migrating from `type HasLargeKey @strong(field_name: "id")` to `type HasLargeKey @strong(field_name: "cache_id")`, we can ship the new more-performant keys to existing clients using `strong_id__`, provided the client does not treat `strong_id__` as an alias for `id`, or vice versa. + +## Relationship to prior art + +This is an alternative form of identity 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: @fetchable](../GAP-55/README.md). + +**Reference implementation.** Relay already carries (partial, partly +undocumented) support for these annotations: + +- Relay's directive docs name both annotations under + [`@refetchable(..., preferFetchable:)`](https://relay.dev/docs/api-reference/graphql/graphql-directives/): + it is "useful for schemas that have adopted the `@strong` and `@fetchable` + server annotations". +- The `strong_id__` meta-field is defined in the Relay compiler — see + `strongid_field_name: "strong_id__"` in + [`compiler/crates/schema/src/in_memory.rs`](https://github.com/facebook/relay/blob/main/compiler/crates/schema/src/in_memory.rs). +- "Strong" objects are recognized as those implementing `Node` — see the + reserved-`id` error in + [`compiler/crates/relay-transforms/src/errors.rs`](https://github.com/facebook/relay/blob/main/compiler/crates/relay-transforms/src/errors.rs). + +## Status + +**Proposal.** Initial draft; not yet sponsored. + +## Challenges and drawbacks + +`@strong` has some thorny issues that require thoughtful adoption. + +**Reader Confusion**: When people see an `id: ID!` field in a selection set, they tend to assume that's the field that will return a retrievable, global identity. `@strong` upends that: it may be true in almost all cases, but if there is any divergence it will typically be for your *most important* cases. If you *can* make `id` the cheap identity field for every object in your schema, you should, whether or not you adopt `@strong`. + +**Non-global identity**: `@strong` does not *require* an object's `strong_id__` be globally unique. +In practice, this means clients need to request another field, such as `__typename`, which is merged +with `strong_id__` to create the object's key for the client normalized store. + +The reason to *not* make `strong_id__` a hash of `__typename` and `field_name:` was specifically so that `strong_id__` and the `field_name:` could +be considered the same value. In retrospect, this is dangerous behavior to rely on, as described below. + +**Using `strong_id__` as the value for `id` lookups is not safe for schema evolution**: +Even if a type is `@strong(field_name: "id")`, while it is extremely tempting for performance reasons, it is not safe to de-duplicate the `id` and `strong_id__` in the selection set: the `field_name` may change over time, and if `id` is used for the purpose of making a follow-up query, `strong_id__` is not guaranteed to match forever into the future. + +If you de-duplicate the `` and `strong_id__`, or use `strong_id__` for client logic, +it becomes a breaking change for your existing clients to change the `field_name:` value. + +**Treating a `"strong_id__": null` as a single shared object**: +It is easy to assume that, whenever you see a `strong_id__` field, that it will have a non-null value. +The `@strong` spec *specifically* allows for `null` values for weak, non-identifiable object types. + +For a query like: +``` +query { + __typename + strong_id__ + node(id: 123) { + __typename + strong_id__ + ... on User { + address { + __typename + strong_id__ + street + } + } + } +} +``` +with a response like: +``` +{ + "data": { + "__typename": "Query", + "strong_id__": "Query", + "node": { + "__typename": "User", + "strong_id__": "123", + "address": { + "__typename": "Address", + "strong_id__": null, + "street": "Hacker Way" + } + } + } +} +``` + +The client consistency store should probably look something like: +``` +{ + "Query:Query": { + "node(id:123)": "User:123" + }, + "User:123": { + "address": { + "street": "Hacker Way" + } + } +} +``` + +As the `Address` type is a weak object, it can't have an identity independent of the parent strong `User`. + +Note in this example, `Query` has an identity: it's extremely convenient to treat every instance of the `Query` type as a single, shared `@strong` type. +Similarly, it may be convenient for the other root types, `Mutation` and `Subscription`, to each have a singular identity value. diff --git a/gaps/GAP-54/metadata.yml b/gaps/GAP-54/metadata.yml new file mode 100644 index 0000000..421ca9b --- /dev/null +++ b/gaps/GAP-54/metadata.yml @@ -0,0 +1,16 @@ +id: 54 +title: "Identity: @strong" +summary: > + A schema directive marking a type as having a strong identity — an identifier + unique across all values of the same type — plus a `strong_id__` meta-field on + every type. Foundation for the companion `@fetchable` directive. +status: proposal +authors: + - name: "Matt Mahoney" + email: "mahoney.mattj@gmail.com" + githubUsername: "@mjmahone" +sponsor: "@TBD" +discussion: "https://github.com/graphql/gaps/pull/54" +related: + - 49 + - 55