Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions gaps/GAP-54/DRAFT.md
Original file line number Diff line number Diff line change
@@ -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: <field>)`
is specified, then `strong_id__` returns the same value as `<Type>.<field>`.

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: "<field>")` is specified, `strong_id__` returns the same
value as `<Type>.<field>`.

## @strong on Object types

For Object types, `@strong(field_name: "<field>")` is non-nullable: the referenced field
must exist and be typed `<field>: ID @semanticNonNull` or `<field>: ID!`.

## @strong on Interface types

For Interface types, `@strong(field_name: "<field>")` is optional. If provided, the referenced field
must exist and be typed `<field>: ID @semanticNonNull` or `<field>: ID!`.

- All types implementing an `@strong` interface must themselves be `@strong`.
- All types implementing an interface with `@strong(field_name: "<field>")` set must
provide the same value `@strong(field_name: "<field>")` 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.
156 changes: 156 additions & 0 deletions gaps/GAP-54/README.md
Original file line number Diff line number Diff line change
@@ -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 `<field_name>` 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.
16 changes: 16 additions & 0 deletions gaps/GAP-54/metadata.yml
Original file line number Diff line number Diff line change
@@ -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