1/* eslint-disable @typescript-eslint/no-use-before-define */
2import { filter, map, merge, pipe, tap } from 'wonka';
3
4import type { Client } from '../client';
5import type { Exchange, Operation, OperationResult } from '../types';
6
7import {
8 makeOperation,
9 addMetadata,
10 collectTypenames,
11 formatDocument,
12 makeResult,
13} from '../utils';
14
15type ResultCache = Map<number, OperationResult>;
16type OperationCache = Map<string, Set<number>>;
17
18const shouldSkip = ({ kind }: Operation) =>
19 kind !== 'mutation' && kind !== 'query';
20
21/** Adds unique typenames to query (for invalidating cache entries) */
22export const mapTypeNames = (operation: Operation): Operation => {
23 const query = formatDocument(operation.query);
24 if (query !== operation.query) {
25 const formattedOperation = makeOperation(operation.kind, operation);
26 formattedOperation.query = query;
27 return formattedOperation;
28 } else {
29 return operation;
30 }
31};
32
33/** Default document cache exchange.
34 *
35 * @remarks
36 * The default document cache in `urql` avoids sending the same GraphQL request
37 * multiple times by caching it using the {@link Operation.key}. It will invalidate
38 * query results automatically whenever it sees a mutation responses with matching
39 * `__typename`s in their responses.
40 *
41 * The document cache will get the introspected `__typename` fields by modifying
42 * your GraphQL operation documents using the {@link formatDocument} utility.
43 *
44 * This automatic invalidation strategy can fail if your query or mutation don’t
45 * contain matching typenames, for instance, because the query contained an
46 * empty list.
47 * You can manually add hints for this exchange by specifying a list of
48 * {@link OperationContext.additionalTypenames} for queries and mutations that
49 * should invalidate one another.
50 *
51 * @see {@link https://urql.dev/goto/docs/basics/document-caching} for more information on this cache.
52 */
53export const cacheExchange: Exchange = ({ forward, client, dispatchDebug }) => {
54 const resultCache: ResultCache = new Map();
55 const operationCache: OperationCache = new Map();
56
57 const isOperationCached = (operation: Operation) =>
58 operation.kind === 'query' &&
59 operation.context.requestPolicy !== 'network-only' &&
60 (operation.context.requestPolicy === 'cache-only' ||
61 resultCache.has(operation.key));
62
63 return ops$ => {
64 const cachedOps$ = pipe(
65 ops$,
66 filter(op => !shouldSkip(op) && isOperationCached(op)),
67 map(operation => {
68 const cachedResult = resultCache.get(operation.key);
69
70 dispatchDebug({
71 operation,
72 ...(cachedResult
73 ? {
74 type: 'cacheHit',
75 message: 'The result was successfully retrieved from the cache',
76 }
77 : {
78 type: 'cacheMiss',
79 message: 'The result could not be retrieved from the cache',
80 }),
81 });
82
83 let result: OperationResult =
84 cachedResult ||
85 makeResult(operation, {
86 data: null,
87 });
88
89 result = {
90 ...result,
91 operation: addMetadata(operation, {
92 cacheOutcome: cachedResult ? 'hit' : 'miss',
93 }),
94 };
95
96 if (operation.context.requestPolicy === 'cache-and-network') {
97 result.stale = true;
98 reexecuteOperation(client, operation);
99 }
100
101 return result;
102 })
103 );
104
105 const forwardedOps$ = pipe(
106 merge([
107 pipe(
108 ops$,
109 filter(op => !shouldSkip(op) && !isOperationCached(op)),
110 map(mapTypeNames)
111 ),
112 pipe(
113 ops$,
114 filter(op => shouldSkip(op))
115 ),
116 ]),
117 map(op => addMetadata(op, { cacheOutcome: 'miss' })),
118 filter(
119 op => op.kind !== 'query' || op.context.requestPolicy !== 'cache-only'
120 ),
121 forward,
122 tap(response => {
123 let { operation } = response;
124 if (!operation) return;
125
126 let typenames = operation.context.additionalTypenames || [];
127 // NOTE: For now, we only respect `additionalTypenames` from subscriptions to
128 // avoid unexpected breaking changes
129 // We'd expect live queries or other update mechanisms to be more suitable rather
130 // than using subscriptions as “signals” to reexecute queries. However, if they’re
131 // just used as signals, it’s intuitive to hook them up using `additionalTypenames`
132 if (response.operation.kind !== 'subscription') {
133 typenames = collectTypenames(response.data).concat(typenames);
134 }
135
136 // Invalidates the cache given a mutation's response
137 if (
138 response.operation.kind === 'mutation' ||
139 response.operation.kind === 'subscription'
140 ) {
141 const pendingOperations = new Set<number>();
142
143 dispatchDebug({
144 type: 'cacheInvalidation',
145 message: `The following typenames have been invalidated: ${typenames}`,
146 operation,
147 data: { typenames, response },
148 });
149
150 for (let i = 0; i < typenames.length; i++) {
151 const typeName = typenames[i];
152 let operations = operationCache.get(typeName);
153 if (!operations)
154 operationCache.set(typeName, (operations = new Set()));
155 for (const key of operations.values()) pendingOperations.add(key);
156 operations.clear();
157 }
158
159 for (const key of pendingOperations.values()) {
160 if (resultCache.has(key)) {
161 operation = (resultCache.get(key) as OperationResult).operation;
162 resultCache.delete(key);
163 reexecuteOperation(client, operation);
164 }
165 }
166 } else if (operation.kind === 'query' && response.data) {
167 resultCache.set(operation.key, response);
168 for (let i = 0; i < typenames.length; i++) {
169 const typeName = typenames[i];
170 let operations = operationCache.get(typeName);
171 if (!operations)
172 operationCache.set(typeName, (operations = new Set()));
173 operations.add(operation.key);
174 }
175 }
176 })
177 );
178
179 return merge([cachedOps$, forwardedOps$]);
180 };
181};
182
183/** Reexecutes an `Operation` with the `network-only` request policy.
184 * @internal
185 */
186export const reexecuteOperation = (client: Client, operation: Operation) => {
187 return client.reexecuteOperation(
188 makeOperation(operation.kind, operation, {
189 requestPolicy: 'network-only',
190 })
191 );
192};