Repository: urql-graphql/urql
Pull request #3515 (2024-03-02 10:29:17)

feat: Add support for persisted documents via the `documentId` property
## Summary

This PR adds support for persisted documents, as long as:
- An input GraphQL AST contains a `documentId` property
- A GraphQL API accepts the `documentId` parameter as a replacement for the `query` parameter

When we detect the `documentId` property on a document which has no definitions (either an empty array or not defined), we send a `documentId` instead of a `query`.

This is similar to Automatic Persisted Queries (APQs), but those require the `@urql/exchange-persisted` exchange, to facilitate generating persisted queries on the fly (as per the spec) and retrying them.

This is to comply with: https://github.com/graphql/graphql-over-http/pull/264

## Set of changes

- Add `documentId` property to detected GraphQL document types
- Send `documentId` on `FetchBody` when a persisted document is passed


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 33bf2f37b1d03b7f85f307de1f7a0c59798a050f

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name       | Type  |
| ---------- | ----- |
| @urql/core | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/urql-graphql/urql/new/feat/persisted-documents?filename=.changeset/tame-donuts-listen.md&value=---%0A%22%40urql%2Fcore%22%3A%20patch%0A---%0A%0Afeat%3A%20Add%20support%20for%20persisted%20documents%20via%20the%20%60documentId%60%20property%0A)



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/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,