Repository: urql-graphql/urql
Pull request #3499 (2024-02-02 14:49:27)

feat(core): Support Apollo Federation subscriptions multipart payloads
## Summary

A missing feature for `multipart/mixed` was pointed out where we were missing support for the apollo subscriptions spec which adds a `payload` property


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

Latest commit: c30cd4fafe965b14d787a168ed36697a825603d7

**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/apollo-federation-subscriptions?filename=.changeset/tough-beans-fly.md&value=---%0A%22%40urql%2Fcore%22%3A%20patch%0A---%0A%0Afeat(core)%3A%20support%20apollo%20federation%20subscriptions%0A)



Patch:
diff --git a/.changeset/seven-toys-applaud.md b/.changeset/seven-toys-applaud.md
new file mode 100644
index 00000000..1b5f688e
--- /dev/null
+++ b/.changeset/seven-toys-applaud.md
@@ -0,0 +1,5 @@
+---
+'@urql/svelte': minor
+---
+
+Add back the `reexecute` function
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/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/basics/svelte.md b/docs/basics/svelte.md
index 79dfb4d0..ed71bc6b 100644
--- a/docs/basics/svelte.md
+++ b/docs/basics/svelte.md
@@ -362,9 +362,7 @@ Sometimes we'll need to arbitrarly reexecute a query to check for new data on th
   });
 
   function refresh() {
-    queryStore({
-      client,
-      query,
+    todos.reexecute({
       requestPolicy: 'network-only'
     });
   }
diff --git a/docs/graphcache/cache-updates.md b/docs/graphcache/cache-updates.md
index 16642f98..4e31813b 100644
--- a/docs/graphcache/cache-updates.md
+++ b/docs/graphcache/cache-updates.md
@@ -493,8 +493,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-svelte/src/PokemonList.svelte b/examples/with-svelte/src/PokemonList.svelte
index c02b2f68..c5f0bf9b 100644
--- a/examples/with-svelte/src/PokemonList.svelte
+++ b/examples/with-svelte/src/PokemonList.svelte
@@ -19,6 +19,10 @@
   function nextPage() {
     skip = skip + 10;
   }
+
+  function reexcute() {
+    pokemons.reexecute({ requestPolicy: 'network-only' })
+  }
 </script>
 
 <div>
@@ -34,4 +38,5 @@
     </ul>
   {/if}
   <button on:click={nextPage}>Next Page</button>
+  <button on:click={reexcute}>Reexec</button>
 </div>
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 33ab727f..ca409b72 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -223,6 +223,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.
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 {
diff --git a/packages/svelte-urql/src/queryStore.ts b/packages/svelte-urql/src/queryStore.ts
index 5ec99624..646a9f41 100644
--- a/packages/svelte-urql/src/queryStore.ts
+++ b/packages/svelte-urql/src/queryStore.ts
@@ -119,7 +119,8 @@ export function queryStore<
   Variables extends AnyVariables = AnyVariables,
 >(
   args: QueryArgs<Data, Variables>
-): OperationResultStore<Data, Variables> & Pausable {
+): OperationResultStore<Data, Variables> &
+  Pausable & { reexecute: (context: Partial<OperationContext>) => void } {
   const request = createRequest(args.query, args.variables as Variables);
 
   const context: Partial<OperationContext> = {
@@ -132,6 +133,9 @@ export function queryStore<
     request,
     context
   );
+
+  const operation$ = writable(operation);
+
   const initialState: OperationResultState<Data, Variables> = {
     ...initialResult,
     operation,
@@ -148,21 +152,26 @@ export function queryStore<
             return never as any;
           }
 
-          return concat<Partial<OperationResultState<Data, Variables>>>([
-            fromValue({ fetching: true, stale: false }),
-            pipe(
-              args.client.executeRequestOperation(operation),
-              map(({ stale, data, error, extensions, operation }) => ({
-                fetching: false,
-                stale: !!stale,
-                data,
-                error,
-                operation,
-                extensions,
-              }))
-            ),
-            fromValue({ fetching: false }),
-          ]);
+          return pipe(
+            fromStore(operation$),
+            switchMap(operation => {
+              return concat<Partial<OperationResultState<Data, Variables>>>([
+                fromValue({ fetching: true, stale: false }),
+                pipe(
+                  args.client.executeRequestOperation(operation),
+                  map(({ stale, data, error, extensions, operation }) => ({
+                    fetching: false,
+                    stale: !!stale,
+                    data,
+                    error,
+                    operation,
+                    extensions,
+                  }))
+                ),
+                fromValue({ fetching: false }),
+              ]);
+            })
+          );
         }
       ),
       scan(
@@ -178,10 +187,22 @@ export function queryStore<
     ).unsubscribe;
   });
 
+  const reexecute = (context: Partial<OperationContext>) => {
+    const newContext = { ...context, ...args.context };
+    const operation = args.client.createRequestOperation(
+      'query',
+      request,
+      newContext
+    );
+    isPaused$.set(false);
+    operation$.set(operation);
+  };
+
   return {
     ...derived(result$, (result, set) => {
       set(result);
     }),
     ...createPausable(isPaused$),
+    reexecute,
   };
 }


Test patch:
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,