Repository: urql-graphql/urql
Pull request #3503 (2024-02-06 14:43:56)

docs(graphcache): remove mutative operations
It's confusing that we use mutative operations in our updaters when that can alter the base-layer when we i.e. are working with optimistic layers.


Patch:
diff --git a/.changeset/small-cheetahs-jam.md b/.changeset/small-cheetahs-jam.md
new file mode 100644
index 00000000..3409df63
--- /dev/null
+++ b/.changeset/small-cheetahs-jam.md
@@ -0,0 +1,5 @@
+---
+'@urql/core': minor
+---
+
+Support [Apollo Federation's format](https://www.apollographql.com/docs/router/executing-operations/subscription-multipart-protocol/) for subscription results in `multipart/mixed` responses (result properties essentially are namespaced on a `payload` key)
diff --git a/.changeset/two-bananas-grow.md b/.changeset/two-bananas-grow.md
new file mode 100644
index 00000000..3cf5b516
--- /dev/null
+++ b/.changeset/two-bananas-grow.md
@@ -0,0 +1,5 @@
+---
+'@urql/core': minor
+---
+
+Add support for sending persisted documents. Any `DocumentNode` with no/empty definitions and a `documentId` property is considered a persisted document. When this is detected a `documentId` parameter rather than a `query` string is sent to the GraphQL API, similar to Automatic Persisted Queries (APQs). However, APQs are only supported via `@urql/exchange-persisted`, while support for `documentId` is now built-in.
diff --git a/docs/architecture.md b/docs/architecture.md
index c41fa497..b90d6941 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -35,7 +35,7 @@ Specifically, `urql` simplifies three common aspects of using GraphQL:
 - Abstracting _caching_ and state management internally
 - Providing a central point of _extensibility_ and integration with your API
 
-In the following sections we'll talk about the way that `urql` solves these three problems and how the logic abstracted away internally.
+In the following sections we'll talk about the way that `urql` solves these three problems and how the logic is abstracted away internally.
 
 ## Requests and Operations on the Client
 
diff --git a/docs/graphcache/cache-updates.md b/docs/graphcache/cache-updates.md
index 16642f98..464b5004 100644
--- a/docs/graphcache/cache-updates.md
+++ b/docs/graphcache/cache-updates.md
@@ -260,8 +260,10 @@ cacheExchange({
         `;
 
         cache.updateQuery({ query: TodoList }, data => {
-          data.todos.push(result.createTodo);
-          return data;
+          return {
+            ...data,
+            todos: [...data.todos, result.createTodo],
+          };
         });
       },
     },
@@ -299,8 +301,7 @@ cacheExchange({
       createTodo(result, _args, cache, _info) {
         const todos = cache.resolve('Query', 'todos');
         if (Array.isArray(todos)) {
-          todos.push(result.createTodo);
-          cache.link('Query', 'todos', todos);
+          cache.link('Query', 'todos', [...todos, result.createTodo]);
         }
       },
     },
@@ -493,8 +494,8 @@ invalidate only these fields, which causes all queries using these listing field
 
 If we know what result a mutation may return, why wait for the GraphQL API to fulfill our mutations?
 
-Additionally to the `updates` configuration we may also pass an `optimistic` option to the
-`cacheExchange` which is a factory function using, which we can create a "virtual" result for a
+In addition to the `updates` configuration, we can also pass an `optimistic` option to the
+`cacheExchange`. This option is a factory function that allows us to create a "virtual" result for a
 mutation. This temporary result can be applied immediately to the cache to give our users the
 illusion that mutations were executed immediately, which is a great method to reduce waiting time
 and to make our apps feel snappier.
diff --git a/examples/with-graphcache-updates/src/client.js b/examples/with-graphcache-updates/src/client.js
index ea5cf6ee..152a707f 100644
--- a/examples/with-graphcache-updates/src/client.js
+++ b/examples/with-graphcache-updates/src/client.js
@@ -29,8 +29,13 @@ const cache = cacheExchange({
               variables: { first: lastField.arguments.first },
             },
             data => {
-              data.links.nodes.push(result.createLink.node);
-              return data;
+              return {
+                ...data,
+                links: {
+                  ...data.links,
+                  nodes: [...data.links.nodes, result.createLink.node],
+                },
+              };
             }
           );
         }
diff --git a/packages/core/src/internal/fetchOptions.ts b/packages/core/src/internal/fetchOptions.ts
index ef928946..bede6559 100644
--- a/packages/core/src/internal/fetchOptions.ts
+++ b/packages/core/src/internal/fetchOptions.ts
@@ -10,6 +10,7 @@ import type { AnyVariables, GraphQLRequest, Operation } from '../types';
 /** Abstract definition of the JSON data sent during GraphQL HTTP POST requests. */
 export interface FetchBody {
   query?: string;
+  documentId?: string;
   operationName: string | undefined;
   variables: undefined | Record<string, any>;
   extensions: undefined | Record<string, any>;
@@ -24,16 +25,31 @@ export function makeFetchBody<
   Data = any,
   Variables extends AnyVariables = AnyVariables,
 >(request: Omit<GraphQLRequest<Data, Variables>, 'key'>): FetchBody {
-  const isAPQ =
-    request.extensions &&
-    request.extensions.persistedQuery &&
-    !request.extensions.persistedQuery.miss;
-  return {
-    query: isAPQ ? undefined : stringifyDocument(request.query),
+  const body: FetchBody = {
+    query: undefined,
+    documentId: undefined,
     operationName: getOperationName(request.query),
     variables: request.variables || undefined,
     extensions: request.extensions,
   };
+
+  if (
+    'documentId' in request.query &&
+    request.query.documentId &&
+    // NOTE: We have to check that the document will definitely be sent
+    // as a persisted document to avoid breaking changes
+    (!request.query.definitions || !request.query.definitions.length)
+  ) {
+    body.documentId = request.query.documentId;
+  } else if (
+    !request.extensions ||
+    !request.extensions.persistedQuery ||
+    !!request.extensions.persistedQuery.miss
+  ) {
+    body.query = stringifyDocument(request.query);
+  }
+
+  return body;
 }
 
 /** Creates a URL that will be called for a GraphQL HTTP request.
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 33ab727f..0082d641 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -9,6 +9,11 @@ import type { Subscription, Source } from 'wonka';
 import type { Client } from './client';
 import type { CombinedError } from './utils/error';
 
+/** A GraphQL persisted document will contain `documentId` that replaces its definitions */
+export interface PersistedDocument extends DocumentNode {
+  documentId?: string;
+}
+
 /** A GraphQL `DocumentNode` with attached generics for its result data and variables.
  *
  * @remarks
@@ -223,6 +228,7 @@ export interface ExecutionResult {
    * @see {@link https://github.com/graphql/graphql-wg/blob/main/rfcs/DeferStream.md#payload-format} for the DeferStream spec
    */
   hasNext?: boolean;
+  payload?: Omit<ExecutionResult, 'payload'>;
 }
 
 /** A source of {@link OperationResult | OperationResults}, convertable to a promise, subscribable, or Wonka Source.
@@ -340,7 +346,7 @@ export interface GraphQLRequest<
    * In `urql`, we expect a document to only contain a single operation that is executed rather than
    * multiple ones by convention.
    */
-  query: DocumentNode | TypedDocumentNode<Data, Variables>;
+  query: DocumentNode | PersistedDocument | TypedDocumentNode<Data, Variables>;
   /** Variables used to execute the `query` document.
    *
    * @remarks
diff --git a/packages/core/src/utils/result.ts b/packages/core/src/utils/result.ts
index 386c9b93..001e0057 100644
--- a/packages/core/src/utils/result.ts
+++ b/packages/core/src/utils/result.ts
@@ -92,8 +92,12 @@ export const mergeResultPatch = (
   pending?: ExecutionResult['pending']
 ): OperationResult => {
   let errors = prevResult.error ? prevResult.error.graphQLErrors : [];
-  let hasExtensions = !!prevResult.extensions || !!nextResult.extensions;
-  const extensions = { ...prevResult.extensions, ...nextResult.extensions };
+  let hasExtensions =
+    !!prevResult.extensions || !!(nextResult.payload || nextResult).extensions;
+  const extensions = {
+    ...prevResult.extensions,
+    ...(nextResult.payload || nextResult).extensions,
+  };
 
   let incremental = nextResult.incremental;
 
@@ -146,8 +150,11 @@ export const mergeResultPatch = (
       }
     }
   } else {
-    withData.data = nextResult.data || prevResult.data;
-    errors = (nextResult.errors as any[]) || errors;
+    withData.data = (nextResult.payload || nextResult).data || prevResult.data;
+    errors =
+      (nextResult.errors as any[]) ||
+      (nextResult.payload && nextResult.payload.errors) ||
+      errors;
   }
 
   return {


Test patch:
diff --git a/packages/core/src/internal/fetchOptions.test.ts b/packages/core/src/internal/fetchOptions.test.ts
index e0142bf6..0787101f 100644
--- a/packages/core/src/internal/fetchOptions.test.ts
+++ b/packages/core/src/internal/fetchOptions.test.ts
@@ -1,6 +1,7 @@
 // @vitest-environment jsdom
 
 import { expect, describe, it } from 'vitest';
+import { Kind } from '@0no-co/graphql.web';
 import { makeOperation } from '../utils/operation';
 import { queryOperation, mutationOperation } from '../test-utils';
 import { makeFetchBody, makeFetchURL, makeFetchOptions } from './fetchOptions';
@@ -10,6 +11,7 @@ describe('makeFetchBody', () => {
     const body = makeFetchBody(queryOperation);
     expect(body).toMatchInlineSnapshot(`
       {
+        "documentId": undefined,
         "extensions": undefined,
         "operationName": "getUser",
         "query": "query getUser($name: String) {
@@ -42,6 +44,22 @@ describe('makeFetchBody', () => {
     apqOperation.extensions.persistedQuery!.miss = true;
     expect(makeFetchBody(apqOperation).query).not.toBe(undefined);
   });
+
+  it('omits the query property when query is a persisted document', () => {
+    // A persisted documents is one that carries a `documentId` property and
+    // has no definitions
+    const persistedOperation = makeOperation(queryOperation.kind, {
+      ...queryOperation,
+      query: {
+        kind: Kind.DOCUMENT,
+        definitions: [],
+        documentId: 'TestDocumentId',
+      },
+    });
+
+    expect(makeFetchBody(persistedOperation).query).toBe(undefined);
+    expect(makeFetchBody(persistedOperation).documentId).toBe('TestDocumentId');
+  });
 });
 
 describe('makeFetchURL', () => {
diff --git a/packages/core/src/utils/result.test.ts b/packages/core/src/utils/result.test.ts
index 4d53d2cb..bee7225f 100644
--- a/packages/core/src/utils/result.test.ts
+++ b/packages/core/src/utils/result.test.ts
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest';
 import { OperationResult } from '../types';
 import { queryOperation, subscriptionOperation } from '../test-utils';
 import { makeResult, mergeResultPatch } from './result';
+import { GraphQLError } from '@0no-co/graphql.web';
+import { CombinedError } from './error';
 
 describe('makeResult', () => {
   it('adds extensions and errors correctly', () => {
@@ -180,6 +182,62 @@ describe('mergeResultPatch (defer/stream pre June-2023)', () => {
     expect(merged.hasNext).toBe(true);
   });
 
+  it('should work with the payload property', () => {
+    const prevResult: OperationResult = {
+      operation: subscriptionOperation,
+      data: {
+        __typename: 'Subscription',
+        event: 1,
+      },
+      stale: false,
+      hasNext: true,
+    };
+
+    const merged = mergeResultPatch(prevResult, {
+      payload: {
+        data: {
+          __typename: 'Subscription',
+          event: 2,
+        },
+      },
+    });
+
+    expect(merged.data).not.toBe(prevResult.data);
+    expect(merged.data.event).toBe(2);
+    expect(merged.hasNext).toBe(true);
+  });
+
+  it('should work with the payload property and errors', () => {
+    const prevResult: OperationResult = {
+      operation: subscriptionOperation,
+      data: {
+        __typename: 'Subscription',
+        event: 1,
+      },
+      stale: false,
+      hasNext: true,
+    };
+
+    const merged = mergeResultPatch(prevResult, {
+      payload: {
+        data: {
+          __typename: 'Subscription',
+          event: 2,
+        },
+      },
+      errors: [new GraphQLError('Something went horribly wrong')],
+    });
+
+    expect(merged.data).not.toBe(prevResult.data);
+    expect(merged.data.event).toBe(2);
+    expect(merged.error).toEqual(
+      new CombinedError({
+        graphQLErrors: [new GraphQLError('Something went horribly wrong')],
+      })
+    );
+    expect(merged.hasNext).toBe(true);
+  });
+
   it('should ignore invalid patches', () => {
     const prevResult: OperationResult = {
       operation: queryOperation,