@envelop/response-cache 8.2.0-alpha-20251013081815-d6f74fceb1a32fd336b2664f90f87872e1bbf2fe → 8.2.0-alpha-20251217212728-de569f3d7fe45e330eca177b69ee1c89fd5dc498
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/cjs/plugin.js +143 -89
- package/esm/plugin.js +145 -91
- package/package.json +1 -1
- package/typings/plugin.d.cts +26 -3
- package/typings/plugin.d.ts +26 -3
package/README.md
CHANGED
|
@@ -863,3 +863,67 @@ mutation SetNameMutation {
|
|
|
863
863
|
}
|
|
864
864
|
}
|
|
865
865
|
```
|
|
866
|
+
|
|
867
|
+
#### Get scope of the query
|
|
868
|
+
|
|
869
|
+
Useful for building a cache with more flexibility (e.g. generate a key that is shared across all
|
|
870
|
+
sessions when `PUBLIC`).
|
|
871
|
+
|
|
872
|
+
```ts
|
|
873
|
+
import jsonStableStringify from 'fast-json-stable-stringify'
|
|
874
|
+
import { execute, parse, subscribe, validate } from 'graphql'
|
|
875
|
+
import { envelop } from '@envelop/core'
|
|
876
|
+
import { hashSHA256, useResponseCache } from '@envelop/response-cache'
|
|
877
|
+
|
|
878
|
+
const schema = buildSchema(/* GraphQL */ `
|
|
879
|
+
${cacheControlDirective}
|
|
880
|
+
type PrivateProfile @cacheControl(scope: PRIVATE) {
|
|
881
|
+
# ...
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
type Profile {
|
|
885
|
+
privateData: String @cacheControl(scope: PRIVATE)
|
|
886
|
+
}
|
|
887
|
+
`)
|
|
888
|
+
|
|
889
|
+
const getEnveloped = envelop({
|
|
890
|
+
parse,
|
|
891
|
+
validate,
|
|
892
|
+
execute,
|
|
893
|
+
subscribe,
|
|
894
|
+
plugins: [
|
|
895
|
+
// ... other plugins ...
|
|
896
|
+
useResponseCache({
|
|
897
|
+
ttl: 2000,
|
|
898
|
+
session: request => getSessionId(request),
|
|
899
|
+
buildResponseCacheKey: ({
|
|
900
|
+
sessionId,
|
|
901
|
+
documentString,
|
|
902
|
+
operationName,
|
|
903
|
+
variableValues,
|
|
904
|
+
extras
|
|
905
|
+
}) =>
|
|
906
|
+
hashSHA256(
|
|
907
|
+
[
|
|
908
|
+
// Use it to put a unique key for every session when `PUBLIC`
|
|
909
|
+
extras(schema).scope === 'PUBLIC' ? 'PUBLIC' : sessionId,
|
|
910
|
+
documentString,
|
|
911
|
+
operationName ?? '',
|
|
912
|
+
jsonStableStringify(variableValues ?? {})
|
|
913
|
+
].join('|')
|
|
914
|
+
),
|
|
915
|
+
scopePerSchemaCoordinate: {
|
|
916
|
+
// Set scope for an entire query
|
|
917
|
+
'Query.getProfile': 'PRIVATE',
|
|
918
|
+
// Set scope for an entire type
|
|
919
|
+
PrivateProfile: 'PRIVATE',
|
|
920
|
+
// Set scope for a single field
|
|
921
|
+
'Profile.privateData': 'PRIVATE'
|
|
922
|
+
}
|
|
923
|
+
})
|
|
924
|
+
]
|
|
925
|
+
})
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
> Note: The use of this callback will increase the ram usage since it memoizes the scope for each
|
|
929
|
+
> query in a weak map.
|
package/cjs/plugin.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.resultWithMetadata = resultWithMetadata;
|
|
|
7
7
|
const tslib_1 = require("tslib");
|
|
8
8
|
const fast_json_stable_stringify_1 = tslib_1.__importDefault(require("fast-json-stable-stringify"));
|
|
9
9
|
const graphql_1 = require("graphql");
|
|
10
|
+
const lru_cache_1 = require("lru-cache");
|
|
10
11
|
const core_1 = require("@envelop/core");
|
|
11
12
|
const utils_1 = require("@graphql-tools/utils");
|
|
12
13
|
const promise_helpers_1 = require("@whatwg-node/promise-helpers");
|
|
@@ -96,41 +97,62 @@ const getDocumentWithMetadataAndTTL = (0, utils_1.memoize4)(function addTypeName
|
|
|
96
97
|
};
|
|
97
98
|
return [(0, graphql_1.visit)(document, (0, graphql_1.visitWithTypeInfo)(typeInfo, visitor)), ttl];
|
|
98
99
|
});
|
|
99
|
-
|
|
100
|
+
const DOCUMENTS_SCOPE_MAX = 1000;
|
|
101
|
+
const DOCUMENTS_SCOPE_TTL = 3600000;
|
|
102
|
+
function useResponseCache({ cache = (0, in_memory_cache_js_1.createInMemoryCache)(), ttl: globalTtl = Infinity, session, enabled, ignoredTypes = [], ttlPerType, idFields = ['id'], invalidateViaMutation = true, ignoreSessionIdForPublicScope = false, buildResponseCacheKey = exports.defaultBuildResponseCacheKey, getDocumentString = defaultGetDocumentString, shouldCacheResult = exports.defaultShouldCacheResult, onTtl, includeExtensionMetadata = typeof process !== 'undefined'
|
|
100
103
|
? // eslint-disable-next-line dot-notation
|
|
101
104
|
process.env['NODE_ENV'] === 'development' || !!process.env['DEBUG']
|
|
102
|
-
: false, }) {
|
|
105
|
+
: false, ...options }) {
|
|
103
106
|
const cacheFactory = typeof cache === 'function' ? (0, utils_1.memoize1)(cache) : () => cache;
|
|
104
107
|
const ignoredTypesMap = new Set(ignoredTypes);
|
|
105
|
-
const typePerSchemaCoordinateMap = new Map();
|
|
106
108
|
enabled = enabled ? (0, utils_1.memoize1)(enabled) : enabled;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
const configPerSchemaCoordinate = {
|
|
110
|
+
// never cache Introspections
|
|
111
|
+
ttl: { 'Query.__schema': 0, ...options.ttlPerSchemaCoordinate },
|
|
112
|
+
scope: { ...options.scopePerSchemaCoordinate },
|
|
113
|
+
};
|
|
109
114
|
if (ttlPerType) {
|
|
110
115
|
// eslint-disable-next-line no-console
|
|
111
116
|
console.warn('[useResponseCache] `ttlForType` is deprecated. To migrate, merge it with `ttlForSchemaCoordinate` option');
|
|
112
117
|
for (const [typeName, ttl] of Object.entries(ttlPerType)) {
|
|
113
|
-
|
|
118
|
+
configPerSchemaCoordinate.ttl[typeName] = ttl;
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
121
|
+
const makeSchemaConfig = function makeSchemaConfig(schema) {
|
|
122
|
+
const ttl = { ...configPerSchemaCoordinate.ttl };
|
|
123
|
+
const scope = { ...configPerSchemaCoordinate.scope };
|
|
124
|
+
return {
|
|
125
|
+
schema,
|
|
126
|
+
perSchemaCoordinate: { ttl, scope, type: new Map() },
|
|
127
|
+
idFieldByTypeName: new Map(),
|
|
128
|
+
publicDocuments: new lru_cache_1.LRUCache({
|
|
129
|
+
max: DOCUMENTS_SCOPE_MAX,
|
|
130
|
+
ttl: DOCUMENTS_SCOPE_TTL,
|
|
131
|
+
}),
|
|
132
|
+
documentMetadataOptions: {
|
|
133
|
+
// Do not override mutations metadata to keep a stable reference for memoization
|
|
134
|
+
mutations: { invalidateViaMutation },
|
|
135
|
+
queries: { invalidateViaMutation, ttlPerSchemaCoordinate: ttl },
|
|
136
|
+
},
|
|
137
|
+
isPrivate(typeName, data) {
|
|
138
|
+
if (scope[typeName] === 'PRIVATE') {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
return data
|
|
142
|
+
? Object.keys(data).some(fieldName => scope[`${typeName}.${fieldName}`] === 'PRIVATE')
|
|
143
|
+
: false;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
119
146
|
};
|
|
120
|
-
const
|
|
121
|
-
let schema;
|
|
122
|
-
function isPrivate(typeName, data) {
|
|
123
|
-
if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') {
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
return Object.keys(data).some(fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE');
|
|
127
|
-
}
|
|
147
|
+
const schemaConfigs = new WeakMap();
|
|
128
148
|
return {
|
|
129
|
-
onSchemaChange({ schema
|
|
130
|
-
if (schema
|
|
149
|
+
onSchemaChange({ schema }) {
|
|
150
|
+
if (schemaConfigs.has(schema)) {
|
|
131
151
|
return;
|
|
132
152
|
}
|
|
133
|
-
|
|
153
|
+
const config = makeSchemaConfig(schema);
|
|
154
|
+
schemaConfigs.set(schema, config);
|
|
155
|
+
// Reset all configs, to avoid keeping stale field configuration
|
|
134
156
|
const directive = schema.getDirective('cacheControl');
|
|
135
157
|
(0, utils_1.mapSchema)(schema, {
|
|
136
158
|
...(directive && {
|
|
@@ -138,10 +160,10 @@ function useResponseCache({ cache = (0, in_memory_cache_js_1.createInMemoryCache
|
|
|
138
160
|
const cacheControlAnnotations = (0, utils_1.getDirective)(schema, type, 'cacheControl');
|
|
139
161
|
cacheControlAnnotations?.forEach(cacheControl => {
|
|
140
162
|
if (cacheControl.maxAge != null) {
|
|
141
|
-
|
|
163
|
+
config.perSchemaCoordinate.ttl[type.name] = cacheControl.maxAge * 1000;
|
|
142
164
|
}
|
|
143
165
|
if (cacheControl.scope) {
|
|
144
|
-
|
|
166
|
+
config.perSchemaCoordinate.scope[type.name] = cacheControl.scope;
|
|
145
167
|
}
|
|
146
168
|
});
|
|
147
169
|
return type;
|
|
@@ -150,18 +172,18 @@ function useResponseCache({ cache = (0, in_memory_cache_js_1.createInMemoryCache
|
|
|
150
172
|
[utils_1.MapperKind.FIELD]: (fieldConfig, fieldName, typeName) => {
|
|
151
173
|
const schemaCoordinates = `${typeName}.${fieldName}`;
|
|
152
174
|
const resultTypeNames = unwrapTypenames(fieldConfig.type);
|
|
153
|
-
|
|
154
|
-
if (idFields.includes(fieldName) && !idFieldByTypeName.has(typeName)) {
|
|
155
|
-
idFieldByTypeName.set(typeName, fieldName);
|
|
175
|
+
config.perSchemaCoordinate.type.set(schemaCoordinates, resultTypeNames);
|
|
176
|
+
if (idFields.includes(fieldName) && !config.idFieldByTypeName.has(typeName)) {
|
|
177
|
+
config.idFieldByTypeName.set(typeName, fieldName);
|
|
156
178
|
}
|
|
157
179
|
if (directive) {
|
|
158
180
|
const cacheControlAnnotations = (0, utils_1.getDirective)(schema, fieldConfig, 'cacheControl');
|
|
159
181
|
cacheControlAnnotations?.forEach(cacheControl => {
|
|
160
182
|
if (cacheControl.maxAge != null) {
|
|
161
|
-
|
|
183
|
+
config.perSchemaCoordinate.ttl[schemaCoordinates] = cacheControl.maxAge * 1000;
|
|
162
184
|
}
|
|
163
185
|
if (cacheControl.scope) {
|
|
164
|
-
|
|
186
|
+
config.perSchemaCoordinate.scope[schemaCoordinates] = cacheControl.scope;
|
|
165
187
|
}
|
|
166
188
|
});
|
|
167
189
|
}
|
|
@@ -173,11 +195,25 @@ function useResponseCache({ cache = (0, in_memory_cache_js_1.createInMemoryCache
|
|
|
173
195
|
if (enabled && !enabled(onExecuteParams.args.contextValue)) {
|
|
174
196
|
return;
|
|
175
197
|
}
|
|
198
|
+
const { schema } = onExecuteParams.args;
|
|
199
|
+
if (!schemaConfigs.has(schema)) {
|
|
200
|
+
// eslint-disable-next-line no-console
|
|
201
|
+
console.error('[response-cache] Unknown schema, operation ignored');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const config = schemaConfigs.get(schema);
|
|
176
205
|
const identifier = new Map();
|
|
177
206
|
const types = new Set();
|
|
178
207
|
let currentTtl;
|
|
208
|
+
let isPrivate = false;
|
|
179
209
|
let skip = false;
|
|
180
|
-
const
|
|
210
|
+
const documentString = getDocumentString(onExecuteParams.args);
|
|
211
|
+
// Verify if we already know this document is public or not. If it is public, we should not
|
|
212
|
+
// take the session ID into account. If not, we keep the default behavior of letting user
|
|
213
|
+
// decide if a session id should be used to build the key
|
|
214
|
+
const sessionId = ignoreSessionIdForPublicScope && config.publicDocuments.get(documentString)
|
|
215
|
+
? undefined
|
|
216
|
+
: session(onExecuteParams.args.contextValue);
|
|
181
217
|
function setExecutor({ execute, onExecuteDone, }) {
|
|
182
218
|
let executed = false;
|
|
183
219
|
onExecuteParams.setExecuteFn(args => {
|
|
@@ -198,21 +234,21 @@ function useResponseCache({ cache = (0, in_memory_cache_js_1.createInMemoryCache
|
|
|
198
234
|
if (skip) {
|
|
199
235
|
return;
|
|
200
236
|
}
|
|
201
|
-
|
|
202
|
-
|
|
237
|
+
isPrivate ||= config.isPrivate(entity.typename, data);
|
|
238
|
+
if (ignoredTypesMap.has(entity.typename) || (!sessionId && isPrivate)) {
|
|
203
239
|
skip = true;
|
|
204
240
|
return;
|
|
205
241
|
}
|
|
206
242
|
// in case the entity has no id, we attempt to extract it from the data
|
|
207
243
|
if (!entity.id) {
|
|
208
|
-
const idField = idFieldByTypeName.get(entity.typename);
|
|
244
|
+
const idField = config.idFieldByTypeName.get(entity.typename);
|
|
209
245
|
if (idField) {
|
|
210
246
|
entity.id = data[idField];
|
|
211
247
|
}
|
|
212
248
|
}
|
|
213
249
|
types.add(entity.typename);
|
|
214
|
-
if (entity.typename in
|
|
215
|
-
const maybeTtl =
|
|
250
|
+
if (entity.typename in config.perSchemaCoordinate.ttl) {
|
|
251
|
+
const maybeTtl = config.perSchemaCoordinate.ttl[entity.typename];
|
|
216
252
|
currentTtl = calculateTtl(maybeTtl, currentTtl);
|
|
217
253
|
}
|
|
218
254
|
if (entity.id != null) {
|
|
@@ -221,10 +257,10 @@ function useResponseCache({ cache = (0, in_memory_cache_js_1.createInMemoryCache
|
|
|
221
257
|
for (const fieldName in data) {
|
|
222
258
|
const fieldData = data[fieldName];
|
|
223
259
|
if (fieldData == null || (Array.isArray(fieldData) && fieldData.length === 0)) {
|
|
224
|
-
const inferredTypes =
|
|
260
|
+
const inferredTypes = config.perSchemaCoordinate.type.get(`${entity.typename}.${fieldName}`);
|
|
225
261
|
inferredTypes?.forEach(inferredType => {
|
|
226
|
-
if (inferredType in
|
|
227
|
-
const maybeTtl =
|
|
262
|
+
if (inferredType in config.perSchemaCoordinate.ttl) {
|
|
263
|
+
const maybeTtl = config.perSchemaCoordinate.ttl[inferredType];
|
|
228
264
|
currentTtl = calculateTtl(maybeTtl, currentTtl);
|
|
229
265
|
}
|
|
230
266
|
identifier.set(inferredType, { typename: inferredType });
|
|
@@ -233,9 +269,11 @@ function useResponseCache({ cache = (0, in_memory_cache_js_1.createInMemoryCache
|
|
|
233
269
|
}
|
|
234
270
|
}
|
|
235
271
|
function invalidateCache(result, setResult) {
|
|
272
|
+
let changed = false;
|
|
236
273
|
if (result.data) {
|
|
237
|
-
|
|
238
|
-
|
|
274
|
+
result = { ...result };
|
|
275
|
+
result.data = removeMetadataFieldsFromResult(result.data, onEntity);
|
|
276
|
+
changed = true;
|
|
239
277
|
}
|
|
240
278
|
const cacheInstance = cacheFactory(onExecuteParams.args.contextValue);
|
|
241
279
|
if (cacheInstance == null) {
|
|
@@ -251,13 +289,16 @@ function useResponseCache({ cache = (0, in_memory_cache_js_1.createInMemoryCache
|
|
|
251
289
|
}));
|
|
252
290
|
}
|
|
253
291
|
}
|
|
292
|
+
if (changed) {
|
|
293
|
+
setResult(result);
|
|
294
|
+
}
|
|
254
295
|
}
|
|
255
296
|
if (invalidateViaMutation !== false) {
|
|
256
297
|
const operationAST = (0, graphql_1.getOperationAST)(onExecuteParams.args.document, onExecuteParams.args.operationName);
|
|
257
298
|
if (operationAST?.operation === 'mutation') {
|
|
258
299
|
return setExecutor({
|
|
259
300
|
execute(args) {
|
|
260
|
-
const [document] = getDocumentWithMetadataAndTTL(args.document, documentMetadataOptions.mutations, args.schema, idFieldByTypeName);
|
|
301
|
+
const [document] = getDocumentWithMetadataAndTTL(args.document, config.documentMetadataOptions.mutations, args.schema, config.idFieldByTypeName);
|
|
261
302
|
return onExecuteParams.executeFn({ ...args, document });
|
|
262
303
|
},
|
|
263
304
|
onExecuteDone({ result, setResult }) {
|
|
@@ -270,30 +311,36 @@ function useResponseCache({ cache = (0, in_memory_cache_js_1.createInMemoryCache
|
|
|
270
311
|
}
|
|
271
312
|
}
|
|
272
313
|
return (0, promise_helpers_1.handleMaybePromise)(() => buildResponseCacheKey({
|
|
273
|
-
|
|
314
|
+
sessionId,
|
|
315
|
+
documentString,
|
|
274
316
|
variableValues: onExecuteParams.args.variableValues,
|
|
275
317
|
operationName: onExecuteParams.args.operationName,
|
|
276
|
-
sessionId,
|
|
277
318
|
context: onExecuteParams.args.contextValue,
|
|
278
319
|
}), cacheKey => {
|
|
279
320
|
const cacheInstance = cacheFactory(onExecuteParams.args.contextValue);
|
|
280
321
|
if (cacheInstance == null) {
|
|
281
322
|
// eslint-disable-next-line no-console
|
|
282
323
|
console.warn('[useResponseCache] Cache instance is not available for the context. Skipping cache lookup.');
|
|
324
|
+
return;
|
|
283
325
|
}
|
|
284
|
-
|
|
285
|
-
if (
|
|
286
|
-
|
|
287
|
-
execute: () => includeExtensionMetadata
|
|
288
|
-
? resultWithMetadata(cachedResponse, { hit: true })
|
|
289
|
-
: cachedResponse,
|
|
290
|
-
});
|
|
326
|
+
function maybeCacheResult(result, setResult) {
|
|
327
|
+
if (result.data) {
|
|
328
|
+
result.data = removeMetadataFieldsFromResult(result.data, onEntity);
|
|
291
329
|
}
|
|
292
|
-
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
330
|
+
return (0, promise_helpers_1.handleMaybePromise)(() => {
|
|
331
|
+
if (!skip && ignoreSessionIdForPublicScope && !isPrivate && sessionId) {
|
|
332
|
+
config.publicDocuments.set(documentString, true);
|
|
333
|
+
return buildResponseCacheKey({
|
|
334
|
+
// Build a public key for this document
|
|
335
|
+
sessionId: undefined,
|
|
336
|
+
documentString,
|
|
337
|
+
variableValues: onExecuteParams.args.variableValues,
|
|
338
|
+
operationName: onExecuteParams.args.operationName,
|
|
339
|
+
context: onExecuteParams.args.contextValue,
|
|
340
|
+
});
|
|
296
341
|
}
|
|
342
|
+
return cacheKey;
|
|
343
|
+
}, cacheKey => {
|
|
297
344
|
// we only use the global ttl if no currentTtl has been determined.
|
|
298
345
|
let finalTtl = currentTtl ?? globalTtl;
|
|
299
346
|
if (onTtl) {
|
|
@@ -312,10 +359,19 @@ function useResponseCache({ cache = (0, in_memory_cache_js_1.createInMemoryCache
|
|
|
312
359
|
if (includeExtensionMetadata) {
|
|
313
360
|
setResult(resultWithMetadata(result, { hit: false, didCache: true, ttl: finalTtl }));
|
|
314
361
|
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
return (0, promise_helpers_1.handleMaybePromise)(() => cacheInstance.get(cacheKey), cachedResponse => {
|
|
365
|
+
if (cachedResponse != null) {
|
|
366
|
+
return setExecutor({
|
|
367
|
+
execute: () => includeExtensionMetadata
|
|
368
|
+
? resultWithMetadata(cachedResponse, { hit: true })
|
|
369
|
+
: cachedResponse,
|
|
370
|
+
});
|
|
315
371
|
}
|
|
316
372
|
return setExecutor({
|
|
317
373
|
execute(args) {
|
|
318
|
-
const [document, ttl] = getDocumentWithMetadataAndTTL(args.document, documentMetadataOptions.queries, schema, idFieldByTypeName);
|
|
374
|
+
const [document, ttl] = getDocumentWithMetadataAndTTL(args.document, config.documentMetadataOptions.queries, schema, config.idFieldByTypeName);
|
|
319
375
|
currentTtl = ttl;
|
|
320
376
|
return onExecuteParams.executeFn({ ...args, document });
|
|
321
377
|
},
|
|
@@ -340,7 +396,7 @@ function handleAsyncIterableResult(handler) {
|
|
|
340
396
|
onNext(payload) {
|
|
341
397
|
// This is the first result with the initial data payload sent to the client. We use it as the base result
|
|
342
398
|
if (payload.result.data) {
|
|
343
|
-
result.data =
|
|
399
|
+
result.data = payload.result.data;
|
|
344
400
|
}
|
|
345
401
|
if (payload.result.errors) {
|
|
346
402
|
result.errors = payload.result.errors;
|
|
@@ -352,10 +408,7 @@ function handleAsyncIterableResult(handler) {
|
|
|
352
408
|
const { incremental, hasNext } = payload.result;
|
|
353
409
|
if (incremental) {
|
|
354
410
|
for (const patch of incremental) {
|
|
355
|
-
(0, utils_1.mergeIncrementalResult)({
|
|
356
|
-
executionResult: result,
|
|
357
|
-
incrementalResult: structuredClone(patch),
|
|
358
|
-
});
|
|
411
|
+
(0, utils_1.mergeIncrementalResult)({ executionResult: result, incrementalResult: patch });
|
|
359
412
|
}
|
|
360
413
|
}
|
|
361
414
|
if (!hasNext) {
|
|
@@ -363,22 +416,30 @@ function handleAsyncIterableResult(handler) {
|
|
|
363
416
|
handler(result, payload.setResult);
|
|
364
417
|
}
|
|
365
418
|
}
|
|
366
|
-
|
|
367
|
-
|
|
419
|
+
const newResult = { ...payload.result };
|
|
420
|
+
// Handle initial/single result
|
|
421
|
+
if (newResult.data) {
|
|
422
|
+
newResult.data = removeMetadataFieldsFromResult(newResult.data);
|
|
368
423
|
}
|
|
369
424
|
// Handle Incremental results
|
|
370
|
-
if ('hasNext' in
|
|
371
|
-
|
|
425
|
+
if ('hasNext' in newResult && newResult.incremental) {
|
|
426
|
+
newResult.incremental = newResult.incremental.map(value => {
|
|
372
427
|
if ('items' in value && value.items) {
|
|
373
|
-
|
|
428
|
+
return {
|
|
429
|
+
...value,
|
|
430
|
+
items: removeMetadataFieldsFromResult(value.items),
|
|
431
|
+
};
|
|
374
432
|
}
|
|
375
433
|
if ('data' in value && value.data) {
|
|
376
|
-
|
|
434
|
+
return {
|
|
435
|
+
...value,
|
|
436
|
+
data: removeMetadataFieldsFromResult(value.data),
|
|
437
|
+
};
|
|
377
438
|
}
|
|
378
439
|
return value;
|
|
379
440
|
});
|
|
380
441
|
}
|
|
381
|
-
|
|
442
|
+
payload.setResult(newResult);
|
|
382
443
|
},
|
|
383
444
|
};
|
|
384
445
|
}
|
|
@@ -423,45 +484,38 @@ exports.cacheControlDirective = `
|
|
|
423
484
|
|
|
424
485
|
directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT
|
|
425
486
|
`;
|
|
426
|
-
function
|
|
487
|
+
function removeMetadataFieldsFromResult(data, onEntity) {
|
|
427
488
|
if (Array.isArray(data)) {
|
|
428
|
-
|
|
429
|
-
collectEntities(record, onEntity);
|
|
430
|
-
}
|
|
431
|
-
return;
|
|
489
|
+
return data.map(record => removeMetadataFieldsFromResult(record, onEntity));
|
|
432
490
|
}
|
|
433
491
|
if (typeof data !== 'object' || data == null) {
|
|
434
|
-
return;
|
|
492
|
+
return data;
|
|
435
493
|
}
|
|
436
494
|
const dataPrototype = Object.getPrototypeOf(data);
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
// As a workaround, we can just check that there is no parent prototype, which should be
|
|
440
|
-
// the case only when it's the Object prototype
|
|
441
|
-
// Rollback once migrated to Bun or vitest
|
|
442
|
-
//
|
|
443
|
-
// if (dataPrototype != null && dataPrototype !== Object.prototype) {
|
|
444
|
-
if (dataPrototype != null && Object.getPrototypeOf(dataPrototype) !== null) {
|
|
445
|
-
// It is not a plain object, like a Date, don't inspect further
|
|
446
|
-
return;
|
|
495
|
+
if (dataPrototype != null && dataPrototype !== Object.prototype) {
|
|
496
|
+
return data;
|
|
447
497
|
}
|
|
498
|
+
// clone the data to avoid mutation
|
|
499
|
+
data = { ...data };
|
|
448
500
|
const typename = data.__responseCacheTypeName ?? data.__typename;
|
|
449
501
|
if (typeof typename === 'string') {
|
|
450
502
|
const entity = { typename };
|
|
503
|
+
delete data.__responseCacheTypeName;
|
|
451
504
|
if (data.__responseCacheId &&
|
|
452
505
|
(typeof data.__responseCacheId === 'string' || typeof data.__responseCacheId === 'number')) {
|
|
453
506
|
entity.id = data.__responseCacheId;
|
|
507
|
+
delete data.__responseCacheId;
|
|
454
508
|
}
|
|
455
509
|
onEntity?.(entity, data);
|
|
456
510
|
}
|
|
457
511
|
for (const key in data) {
|
|
458
|
-
|
|
512
|
+
const value = data[key];
|
|
513
|
+
if (Array.isArray(value)) {
|
|
514
|
+
data[key] = removeMetadataFieldsFromResult(value, onEntity);
|
|
515
|
+
}
|
|
516
|
+
if (value !== null && typeof value === 'object') {
|
|
517
|
+
data[key] = removeMetadataFieldsFromResult(value, onEntity);
|
|
518
|
+
}
|
|
459
519
|
}
|
|
520
|
+
return data;
|
|
460
521
|
}
|
|
461
|
-
function setStringifyWithoutMetadata(result) {
|
|
462
|
-
result.stringify = stringifyWithoutMetadata;
|
|
463
|
-
return result;
|
|
464
|
-
}
|
|
465
|
-
const stringifyWithoutMetadata = result => {
|
|
466
|
-
return JSON.stringify(result, (key, value) => key === '__responseCacheId' || key === '__responseCacheTypeName' ? undefined : value);
|
|
467
|
-
};
|
package/esm/plugin.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import stringify from 'fast-json-stable-stringify';
|
|
2
2
|
import { getOperationAST, isListType, isNonNullType, isUnionType, Kind, print, TypeInfo, visit, visitWithTypeInfo, } from 'graphql';
|
|
3
|
+
import { LRUCache } from 'lru-cache';
|
|
3
4
|
import { getDocumentString, isAsyncIterable, } from '@envelop/core';
|
|
4
5
|
import { getDirective, MapperKind, mapSchema, memoize1, memoize4, mergeIncrementalResult, } from '@graphql-tools/utils';
|
|
5
6
|
import { handleMaybePromise } from '@whatwg-node/promise-helpers';
|
|
@@ -12,7 +13,7 @@ import { createInMemoryCache } from './in-memory-cache.js';
|
|
|
12
13
|
export const defaultBuildResponseCacheKey = (params) => hashSHA256([
|
|
13
14
|
params.documentString,
|
|
14
15
|
params.operationName ?? '',
|
|
15
|
-
|
|
16
|
+
stringify(params.variableValues ?? {}),
|
|
16
17
|
params.sessionId ?? '',
|
|
17
18
|
].join('|'));
|
|
18
19
|
/**
|
|
@@ -87,41 +88,62 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(do
|
|
|
87
88
|
};
|
|
88
89
|
return [visit(document, visitWithTypeInfo(typeInfo, visitor)), ttl];
|
|
89
90
|
});
|
|
90
|
-
|
|
91
|
+
const DOCUMENTS_SCOPE_MAX = 1000;
|
|
92
|
+
const DOCUMENTS_SCOPE_TTL = 3600000;
|
|
93
|
+
export function useResponseCache({ cache = createInMemoryCache(), ttl: globalTtl = Infinity, session, enabled, ignoredTypes = [], ttlPerType, idFields = ['id'], invalidateViaMutation = true, ignoreSessionIdForPublicScope = false, buildResponseCacheKey = defaultBuildResponseCacheKey, getDocumentString = defaultGetDocumentString, shouldCacheResult = defaultShouldCacheResult, onTtl, includeExtensionMetadata = typeof process !== 'undefined'
|
|
91
94
|
? // eslint-disable-next-line dot-notation
|
|
92
95
|
process.env['NODE_ENV'] === 'development' || !!process.env['DEBUG']
|
|
93
|
-
: false, }) {
|
|
96
|
+
: false, ...options }) {
|
|
94
97
|
const cacheFactory = typeof cache === 'function' ? memoize1(cache) : () => cache;
|
|
95
98
|
const ignoredTypesMap = new Set(ignoredTypes);
|
|
96
|
-
const typePerSchemaCoordinateMap = new Map();
|
|
97
99
|
enabled = enabled ? memoize1(enabled) : enabled;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
const configPerSchemaCoordinate = {
|
|
101
|
+
// never cache Introspections
|
|
102
|
+
ttl: { 'Query.__schema': 0, ...options.ttlPerSchemaCoordinate },
|
|
103
|
+
scope: { ...options.scopePerSchemaCoordinate },
|
|
104
|
+
};
|
|
100
105
|
if (ttlPerType) {
|
|
101
106
|
// eslint-disable-next-line no-console
|
|
102
107
|
console.warn('[useResponseCache] `ttlForType` is deprecated. To migrate, merge it with `ttlForSchemaCoordinate` option');
|
|
103
108
|
for (const [typeName, ttl] of Object.entries(ttlPerType)) {
|
|
104
|
-
|
|
109
|
+
configPerSchemaCoordinate.ttl[typeName] = ttl;
|
|
105
110
|
}
|
|
106
111
|
}
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
const makeSchemaConfig = function makeSchemaConfig(schema) {
|
|
113
|
+
const ttl = { ...configPerSchemaCoordinate.ttl };
|
|
114
|
+
const scope = { ...configPerSchemaCoordinate.scope };
|
|
115
|
+
return {
|
|
116
|
+
schema,
|
|
117
|
+
perSchemaCoordinate: { ttl, scope, type: new Map() },
|
|
118
|
+
idFieldByTypeName: new Map(),
|
|
119
|
+
publicDocuments: new LRUCache({
|
|
120
|
+
max: DOCUMENTS_SCOPE_MAX,
|
|
121
|
+
ttl: DOCUMENTS_SCOPE_TTL,
|
|
122
|
+
}),
|
|
123
|
+
documentMetadataOptions: {
|
|
124
|
+
// Do not override mutations metadata to keep a stable reference for memoization
|
|
125
|
+
mutations: { invalidateViaMutation },
|
|
126
|
+
queries: { invalidateViaMutation, ttlPerSchemaCoordinate: ttl },
|
|
127
|
+
},
|
|
128
|
+
isPrivate(typeName, data) {
|
|
129
|
+
if (scope[typeName] === 'PRIVATE') {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
return data
|
|
133
|
+
? Object.keys(data).some(fieldName => scope[`${typeName}.${fieldName}`] === 'PRIVATE')
|
|
134
|
+
: false;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
110
137
|
};
|
|
111
|
-
const
|
|
112
|
-
let schema;
|
|
113
|
-
function isPrivate(typeName, data) {
|
|
114
|
-
if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') {
|
|
115
|
-
return true;
|
|
116
|
-
}
|
|
117
|
-
return Object.keys(data).some(fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE');
|
|
118
|
-
}
|
|
138
|
+
const schemaConfigs = new WeakMap();
|
|
119
139
|
return {
|
|
120
|
-
onSchemaChange({ schema
|
|
121
|
-
if (schema
|
|
140
|
+
onSchemaChange({ schema }) {
|
|
141
|
+
if (schemaConfigs.has(schema)) {
|
|
122
142
|
return;
|
|
123
143
|
}
|
|
124
|
-
|
|
144
|
+
const config = makeSchemaConfig(schema);
|
|
145
|
+
schemaConfigs.set(schema, config);
|
|
146
|
+
// Reset all configs, to avoid keeping stale field configuration
|
|
125
147
|
const directive = schema.getDirective('cacheControl');
|
|
126
148
|
mapSchema(schema, {
|
|
127
149
|
...(directive && {
|
|
@@ -129,10 +151,10 @@ export function useResponseCache({ cache = createInMemoryCache(), ttl: globalTtl
|
|
|
129
151
|
const cacheControlAnnotations = getDirective(schema, type, 'cacheControl');
|
|
130
152
|
cacheControlAnnotations?.forEach(cacheControl => {
|
|
131
153
|
if (cacheControl.maxAge != null) {
|
|
132
|
-
|
|
154
|
+
config.perSchemaCoordinate.ttl[type.name] = cacheControl.maxAge * 1000;
|
|
133
155
|
}
|
|
134
156
|
if (cacheControl.scope) {
|
|
135
|
-
|
|
157
|
+
config.perSchemaCoordinate.scope[type.name] = cacheControl.scope;
|
|
136
158
|
}
|
|
137
159
|
});
|
|
138
160
|
return type;
|
|
@@ -141,18 +163,18 @@ export function useResponseCache({ cache = createInMemoryCache(), ttl: globalTtl
|
|
|
141
163
|
[MapperKind.FIELD]: (fieldConfig, fieldName, typeName) => {
|
|
142
164
|
const schemaCoordinates = `${typeName}.${fieldName}`;
|
|
143
165
|
const resultTypeNames = unwrapTypenames(fieldConfig.type);
|
|
144
|
-
|
|
145
|
-
if (idFields.includes(fieldName) && !idFieldByTypeName.has(typeName)) {
|
|
146
|
-
idFieldByTypeName.set(typeName, fieldName);
|
|
166
|
+
config.perSchemaCoordinate.type.set(schemaCoordinates, resultTypeNames);
|
|
167
|
+
if (idFields.includes(fieldName) && !config.idFieldByTypeName.has(typeName)) {
|
|
168
|
+
config.idFieldByTypeName.set(typeName, fieldName);
|
|
147
169
|
}
|
|
148
170
|
if (directive) {
|
|
149
171
|
const cacheControlAnnotations = getDirective(schema, fieldConfig, 'cacheControl');
|
|
150
172
|
cacheControlAnnotations?.forEach(cacheControl => {
|
|
151
173
|
if (cacheControl.maxAge != null) {
|
|
152
|
-
|
|
174
|
+
config.perSchemaCoordinate.ttl[schemaCoordinates] = cacheControl.maxAge * 1000;
|
|
153
175
|
}
|
|
154
176
|
if (cacheControl.scope) {
|
|
155
|
-
|
|
177
|
+
config.perSchemaCoordinate.scope[schemaCoordinates] = cacheControl.scope;
|
|
156
178
|
}
|
|
157
179
|
});
|
|
158
180
|
}
|
|
@@ -164,11 +186,25 @@ export function useResponseCache({ cache = createInMemoryCache(), ttl: globalTtl
|
|
|
164
186
|
if (enabled && !enabled(onExecuteParams.args.contextValue)) {
|
|
165
187
|
return;
|
|
166
188
|
}
|
|
189
|
+
const { schema } = onExecuteParams.args;
|
|
190
|
+
if (!schemaConfigs.has(schema)) {
|
|
191
|
+
// eslint-disable-next-line no-console
|
|
192
|
+
console.error('[response-cache] Unknown schema, operation ignored');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const config = schemaConfigs.get(schema);
|
|
167
196
|
const identifier = new Map();
|
|
168
197
|
const types = new Set();
|
|
169
198
|
let currentTtl;
|
|
199
|
+
let isPrivate = false;
|
|
170
200
|
let skip = false;
|
|
171
|
-
const
|
|
201
|
+
const documentString = getDocumentString(onExecuteParams.args);
|
|
202
|
+
// Verify if we already know this document is public or not. If it is public, we should not
|
|
203
|
+
// take the session ID into account. If not, we keep the default behavior of letting user
|
|
204
|
+
// decide if a session id should be used to build the key
|
|
205
|
+
const sessionId = ignoreSessionIdForPublicScope && config.publicDocuments.get(documentString)
|
|
206
|
+
? undefined
|
|
207
|
+
: session(onExecuteParams.args.contextValue);
|
|
172
208
|
function setExecutor({ execute, onExecuteDone, }) {
|
|
173
209
|
let executed = false;
|
|
174
210
|
onExecuteParams.setExecuteFn(args => {
|
|
@@ -189,21 +225,21 @@ export function useResponseCache({ cache = createInMemoryCache(), ttl: globalTtl
|
|
|
189
225
|
if (skip) {
|
|
190
226
|
return;
|
|
191
227
|
}
|
|
192
|
-
|
|
193
|
-
|
|
228
|
+
isPrivate ||= config.isPrivate(entity.typename, data);
|
|
229
|
+
if (ignoredTypesMap.has(entity.typename) || (!sessionId && isPrivate)) {
|
|
194
230
|
skip = true;
|
|
195
231
|
return;
|
|
196
232
|
}
|
|
197
233
|
// in case the entity has no id, we attempt to extract it from the data
|
|
198
234
|
if (!entity.id) {
|
|
199
|
-
const idField = idFieldByTypeName.get(entity.typename);
|
|
235
|
+
const idField = config.idFieldByTypeName.get(entity.typename);
|
|
200
236
|
if (idField) {
|
|
201
237
|
entity.id = data[idField];
|
|
202
238
|
}
|
|
203
239
|
}
|
|
204
240
|
types.add(entity.typename);
|
|
205
|
-
if (entity.typename in
|
|
206
|
-
const maybeTtl =
|
|
241
|
+
if (entity.typename in config.perSchemaCoordinate.ttl) {
|
|
242
|
+
const maybeTtl = config.perSchemaCoordinate.ttl[entity.typename];
|
|
207
243
|
currentTtl = calculateTtl(maybeTtl, currentTtl);
|
|
208
244
|
}
|
|
209
245
|
if (entity.id != null) {
|
|
@@ -212,10 +248,10 @@ export function useResponseCache({ cache = createInMemoryCache(), ttl: globalTtl
|
|
|
212
248
|
for (const fieldName in data) {
|
|
213
249
|
const fieldData = data[fieldName];
|
|
214
250
|
if (fieldData == null || (Array.isArray(fieldData) && fieldData.length === 0)) {
|
|
215
|
-
const inferredTypes =
|
|
251
|
+
const inferredTypes = config.perSchemaCoordinate.type.get(`${entity.typename}.${fieldName}`);
|
|
216
252
|
inferredTypes?.forEach(inferredType => {
|
|
217
|
-
if (inferredType in
|
|
218
|
-
const maybeTtl =
|
|
253
|
+
if (inferredType in config.perSchemaCoordinate.ttl) {
|
|
254
|
+
const maybeTtl = config.perSchemaCoordinate.ttl[inferredType];
|
|
219
255
|
currentTtl = calculateTtl(maybeTtl, currentTtl);
|
|
220
256
|
}
|
|
221
257
|
identifier.set(inferredType, { typename: inferredType });
|
|
@@ -224,9 +260,11 @@ export function useResponseCache({ cache = createInMemoryCache(), ttl: globalTtl
|
|
|
224
260
|
}
|
|
225
261
|
}
|
|
226
262
|
function invalidateCache(result, setResult) {
|
|
263
|
+
let changed = false;
|
|
227
264
|
if (result.data) {
|
|
228
|
-
|
|
229
|
-
|
|
265
|
+
result = { ...result };
|
|
266
|
+
result.data = removeMetadataFieldsFromResult(result.data, onEntity);
|
|
267
|
+
changed = true;
|
|
230
268
|
}
|
|
231
269
|
const cacheInstance = cacheFactory(onExecuteParams.args.contextValue);
|
|
232
270
|
if (cacheInstance == null) {
|
|
@@ -242,13 +280,16 @@ export function useResponseCache({ cache = createInMemoryCache(), ttl: globalTtl
|
|
|
242
280
|
}));
|
|
243
281
|
}
|
|
244
282
|
}
|
|
283
|
+
if (changed) {
|
|
284
|
+
setResult(result);
|
|
285
|
+
}
|
|
245
286
|
}
|
|
246
287
|
if (invalidateViaMutation !== false) {
|
|
247
288
|
const operationAST = getOperationAST(onExecuteParams.args.document, onExecuteParams.args.operationName);
|
|
248
289
|
if (operationAST?.operation === 'mutation') {
|
|
249
290
|
return setExecutor({
|
|
250
291
|
execute(args) {
|
|
251
|
-
const [document] = getDocumentWithMetadataAndTTL(args.document, documentMetadataOptions.mutations, args.schema, idFieldByTypeName);
|
|
292
|
+
const [document] = getDocumentWithMetadataAndTTL(args.document, config.documentMetadataOptions.mutations, args.schema, config.idFieldByTypeName);
|
|
252
293
|
return onExecuteParams.executeFn({ ...args, document });
|
|
253
294
|
},
|
|
254
295
|
onExecuteDone({ result, setResult }) {
|
|
@@ -261,30 +302,36 @@ export function useResponseCache({ cache = createInMemoryCache(), ttl: globalTtl
|
|
|
261
302
|
}
|
|
262
303
|
}
|
|
263
304
|
return handleMaybePromise(() => buildResponseCacheKey({
|
|
264
|
-
|
|
305
|
+
sessionId,
|
|
306
|
+
documentString,
|
|
265
307
|
variableValues: onExecuteParams.args.variableValues,
|
|
266
308
|
operationName: onExecuteParams.args.operationName,
|
|
267
|
-
sessionId,
|
|
268
309
|
context: onExecuteParams.args.contextValue,
|
|
269
310
|
}), cacheKey => {
|
|
270
311
|
const cacheInstance = cacheFactory(onExecuteParams.args.contextValue);
|
|
271
312
|
if (cacheInstance == null) {
|
|
272
313
|
// eslint-disable-next-line no-console
|
|
273
314
|
console.warn('[useResponseCache] Cache instance is not available for the context. Skipping cache lookup.');
|
|
315
|
+
return;
|
|
274
316
|
}
|
|
275
|
-
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
execute: () => includeExtensionMetadata
|
|
279
|
-
? resultWithMetadata(cachedResponse, { hit: true })
|
|
280
|
-
: cachedResponse,
|
|
281
|
-
});
|
|
317
|
+
function maybeCacheResult(result, setResult) {
|
|
318
|
+
if (result.data) {
|
|
319
|
+
result.data = removeMetadataFieldsFromResult(result.data, onEntity);
|
|
282
320
|
}
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
|
|
321
|
+
return handleMaybePromise(() => {
|
|
322
|
+
if (!skip && ignoreSessionIdForPublicScope && !isPrivate && sessionId) {
|
|
323
|
+
config.publicDocuments.set(documentString, true);
|
|
324
|
+
return buildResponseCacheKey({
|
|
325
|
+
// Build a public key for this document
|
|
326
|
+
sessionId: undefined,
|
|
327
|
+
documentString,
|
|
328
|
+
variableValues: onExecuteParams.args.variableValues,
|
|
329
|
+
operationName: onExecuteParams.args.operationName,
|
|
330
|
+
context: onExecuteParams.args.contextValue,
|
|
331
|
+
});
|
|
287
332
|
}
|
|
333
|
+
return cacheKey;
|
|
334
|
+
}, cacheKey => {
|
|
288
335
|
// we only use the global ttl if no currentTtl has been determined.
|
|
289
336
|
let finalTtl = currentTtl ?? globalTtl;
|
|
290
337
|
if (onTtl) {
|
|
@@ -303,10 +350,19 @@ export function useResponseCache({ cache = createInMemoryCache(), ttl: globalTtl
|
|
|
303
350
|
if (includeExtensionMetadata) {
|
|
304
351
|
setResult(resultWithMetadata(result, { hit: false, didCache: true, ttl: finalTtl }));
|
|
305
352
|
}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return handleMaybePromise(() => cacheInstance.get(cacheKey), cachedResponse => {
|
|
356
|
+
if (cachedResponse != null) {
|
|
357
|
+
return setExecutor({
|
|
358
|
+
execute: () => includeExtensionMetadata
|
|
359
|
+
? resultWithMetadata(cachedResponse, { hit: true })
|
|
360
|
+
: cachedResponse,
|
|
361
|
+
});
|
|
306
362
|
}
|
|
307
363
|
return setExecutor({
|
|
308
364
|
execute(args) {
|
|
309
|
-
const [document, ttl] = getDocumentWithMetadataAndTTL(args.document, documentMetadataOptions.queries, schema, idFieldByTypeName);
|
|
365
|
+
const [document, ttl] = getDocumentWithMetadataAndTTL(args.document, config.documentMetadataOptions.queries, schema, config.idFieldByTypeName);
|
|
310
366
|
currentTtl = ttl;
|
|
311
367
|
return onExecuteParams.executeFn({ ...args, document });
|
|
312
368
|
},
|
|
@@ -331,7 +387,7 @@ function handleAsyncIterableResult(handler) {
|
|
|
331
387
|
onNext(payload) {
|
|
332
388
|
// This is the first result with the initial data payload sent to the client. We use it as the base result
|
|
333
389
|
if (payload.result.data) {
|
|
334
|
-
result.data =
|
|
390
|
+
result.data = payload.result.data;
|
|
335
391
|
}
|
|
336
392
|
if (payload.result.errors) {
|
|
337
393
|
result.errors = payload.result.errors;
|
|
@@ -343,10 +399,7 @@ function handleAsyncIterableResult(handler) {
|
|
|
343
399
|
const { incremental, hasNext } = payload.result;
|
|
344
400
|
if (incremental) {
|
|
345
401
|
for (const patch of incremental) {
|
|
346
|
-
mergeIncrementalResult({
|
|
347
|
-
executionResult: result,
|
|
348
|
-
incrementalResult: structuredClone(patch),
|
|
349
|
-
});
|
|
402
|
+
mergeIncrementalResult({ executionResult: result, incrementalResult: patch });
|
|
350
403
|
}
|
|
351
404
|
}
|
|
352
405
|
if (!hasNext) {
|
|
@@ -354,22 +407,30 @@ function handleAsyncIterableResult(handler) {
|
|
|
354
407
|
handler(result, payload.setResult);
|
|
355
408
|
}
|
|
356
409
|
}
|
|
357
|
-
|
|
358
|
-
|
|
410
|
+
const newResult = { ...payload.result };
|
|
411
|
+
// Handle initial/single result
|
|
412
|
+
if (newResult.data) {
|
|
413
|
+
newResult.data = removeMetadataFieldsFromResult(newResult.data);
|
|
359
414
|
}
|
|
360
415
|
// Handle Incremental results
|
|
361
|
-
if ('hasNext' in
|
|
362
|
-
|
|
416
|
+
if ('hasNext' in newResult && newResult.incremental) {
|
|
417
|
+
newResult.incremental = newResult.incremental.map(value => {
|
|
363
418
|
if ('items' in value && value.items) {
|
|
364
|
-
|
|
419
|
+
return {
|
|
420
|
+
...value,
|
|
421
|
+
items: removeMetadataFieldsFromResult(value.items),
|
|
422
|
+
};
|
|
365
423
|
}
|
|
366
424
|
if ('data' in value && value.data) {
|
|
367
|
-
|
|
425
|
+
return {
|
|
426
|
+
...value,
|
|
427
|
+
data: removeMetadataFieldsFromResult(value.data),
|
|
428
|
+
};
|
|
368
429
|
}
|
|
369
430
|
return value;
|
|
370
431
|
});
|
|
371
432
|
}
|
|
372
|
-
|
|
433
|
+
payload.setResult(newResult);
|
|
373
434
|
},
|
|
374
435
|
};
|
|
375
436
|
}
|
|
@@ -414,45 +475,38 @@ export const cacheControlDirective = /* GraphQL */ `
|
|
|
414
475
|
|
|
415
476
|
directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT
|
|
416
477
|
`;
|
|
417
|
-
function
|
|
478
|
+
function removeMetadataFieldsFromResult(data, onEntity) {
|
|
418
479
|
if (Array.isArray(data)) {
|
|
419
|
-
|
|
420
|
-
collectEntities(record, onEntity);
|
|
421
|
-
}
|
|
422
|
-
return;
|
|
480
|
+
return data.map(record => removeMetadataFieldsFromResult(record, onEntity));
|
|
423
481
|
}
|
|
424
482
|
if (typeof data !== 'object' || data == null) {
|
|
425
|
-
return;
|
|
483
|
+
return data;
|
|
426
484
|
}
|
|
427
485
|
const dataPrototype = Object.getPrototypeOf(data);
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
// As a workaround, we can just check that there is no parent prototype, which should be
|
|
431
|
-
// the case only when it's the Object prototype
|
|
432
|
-
// Rollback once migrated to Bun or vitest
|
|
433
|
-
//
|
|
434
|
-
// if (dataPrototype != null && dataPrototype !== Object.prototype) {
|
|
435
|
-
if (dataPrototype != null && Object.getPrototypeOf(dataPrototype) !== null) {
|
|
436
|
-
// It is not a plain object, like a Date, don't inspect further
|
|
437
|
-
return;
|
|
486
|
+
if (dataPrototype != null && dataPrototype !== Object.prototype) {
|
|
487
|
+
return data;
|
|
438
488
|
}
|
|
489
|
+
// clone the data to avoid mutation
|
|
490
|
+
data = { ...data };
|
|
439
491
|
const typename = data.__responseCacheTypeName ?? data.__typename;
|
|
440
492
|
if (typeof typename === 'string') {
|
|
441
493
|
const entity = { typename };
|
|
494
|
+
delete data.__responseCacheTypeName;
|
|
442
495
|
if (data.__responseCacheId &&
|
|
443
496
|
(typeof data.__responseCacheId === 'string' || typeof data.__responseCacheId === 'number')) {
|
|
444
497
|
entity.id = data.__responseCacheId;
|
|
498
|
+
delete data.__responseCacheId;
|
|
445
499
|
}
|
|
446
500
|
onEntity?.(entity, data);
|
|
447
501
|
}
|
|
448
502
|
for (const key in data) {
|
|
449
|
-
|
|
503
|
+
const value = data[key];
|
|
504
|
+
if (Array.isArray(value)) {
|
|
505
|
+
data[key] = removeMetadataFieldsFromResult(value, onEntity);
|
|
506
|
+
}
|
|
507
|
+
if (value !== null && typeof value === 'object') {
|
|
508
|
+
data[key] = removeMetadataFieldsFromResult(value, onEntity);
|
|
509
|
+
}
|
|
450
510
|
}
|
|
511
|
+
return data;
|
|
451
512
|
}
|
|
452
|
-
function setStringifyWithoutMetadata(result) {
|
|
453
|
-
result.stringify = stringifyWithoutMetadata;
|
|
454
|
-
return result;
|
|
455
|
-
}
|
|
456
|
-
const stringifyWithoutMetadata = result => {
|
|
457
|
-
return JSON.stringify(result, (key, value) => key === '__responseCacheId' || key === '__responseCacheTypeName' ? undefined : value);
|
|
458
|
-
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@envelop/response-cache",
|
|
3
|
-
"version": "8.2.0-alpha-
|
|
3
|
+
"version": "8.2.0-alpha-20251217212728-de569f3d7fe45e330eca177b69ee1c89fd5dc498",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"peerDependencies": {
|
|
6
6
|
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
package/typings/plugin.d.cts
CHANGED
|
@@ -22,6 +22,8 @@ export type ShouldCacheResultFunction = (params: {
|
|
|
22
22
|
cacheKey: string;
|
|
23
23
|
result: ExecutionResult;
|
|
24
24
|
}) => boolean;
|
|
25
|
+
export type TTLPerSchemaCoordinate = Record<string, number | undefined>;
|
|
26
|
+
export type ScopePerSchemaCoordinate = Record<string, 'PRIVATE' | 'PUBLIC' | undefined>;
|
|
25
27
|
export type UseResponseCacheParameter<PluginContext extends Record<string, any> = {}> = {
|
|
26
28
|
cache?: Cache | ((ctx: Record<string, any>) => Cache);
|
|
27
29
|
/**
|
|
@@ -40,8 +42,25 @@ export type UseResponseCacheParameter<PluginContext extends Record<string, any>
|
|
|
40
42
|
* In the unusual case where you actually want to cache introspection query operations,
|
|
41
43
|
* you need to provide the value `{ 'Query.__schema': undefined }`.
|
|
42
44
|
*/
|
|
43
|
-
ttlPerSchemaCoordinate?:
|
|
44
|
-
|
|
45
|
+
ttlPerSchemaCoordinate?: TTLPerSchemaCoordinate;
|
|
46
|
+
/**
|
|
47
|
+
* Define the scope (PUBLIC or PRIVATE) by schema coordinate.
|
|
48
|
+
* The default scope for all types and fields is PUBLIC
|
|
49
|
+
*
|
|
50
|
+
* If an operation contains a PRIVATE type or field, the result will be cached only if a session
|
|
51
|
+
* id is found for this request.
|
|
52
|
+
*
|
|
53
|
+
* Note: To share cache of responses with a PUBLIC scope between all users, enable `ignoreSessionIdForPublicScope`
|
|
54
|
+
*/
|
|
55
|
+
scopePerSchemaCoordinate?: ScopePerSchemaCoordinate;
|
|
56
|
+
/**
|
|
57
|
+
* If enabled, a response with a PUBLIC scope will be cached with an operation key ignoring the
|
|
58
|
+
* session ID. This allows to improve cache hit further, but scope should be carefully defined
|
|
59
|
+
* to avoid any private data.
|
|
60
|
+
*
|
|
61
|
+
* @default false.
|
|
62
|
+
*/
|
|
63
|
+
ignoreSessionIdForPublicScope?: boolean;
|
|
45
64
|
/**
|
|
46
65
|
* Allows to cache responses based on the resolved session id.
|
|
47
66
|
* Return a unique value for each session.
|
|
@@ -151,6 +170,10 @@ export type ResponseCacheExtensions = {
|
|
|
151
170
|
export type ResponseCacheExecutionResult = ExecutionResult<ObjMap<unknown>, {
|
|
152
171
|
responseCache?: ResponseCacheExtensions;
|
|
153
172
|
}>;
|
|
154
|
-
export
|
|
173
|
+
export type CacheControlDirective = {
|
|
174
|
+
maxAge?: number;
|
|
175
|
+
scope?: 'PUBLIC' | 'PRIVATE';
|
|
176
|
+
};
|
|
177
|
+
export declare function useResponseCache<PluginContext extends Record<string, any> = {}>({ cache, ttl: globalTtl, session, enabled, ignoredTypes, ttlPerType, idFields, invalidateViaMutation, ignoreSessionIdForPublicScope, buildResponseCacheKey, getDocumentString, shouldCacheResult, onTtl, includeExtensionMetadata, ...options }: UseResponseCacheParameter<PluginContext>): Plugin<PluginContext>;
|
|
155
178
|
export declare function resultWithMetadata(result: ExecutionResult, metadata: ResponseCacheExtensions): ResponseCacheExecutionResult;
|
|
156
179
|
export declare const cacheControlDirective = "\n enum CacheControlScope {\n PUBLIC\n PRIVATE\n }\n\n directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT\n";
|
package/typings/plugin.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export type ShouldCacheResultFunction = (params: {
|
|
|
22
22
|
cacheKey: string;
|
|
23
23
|
result: ExecutionResult;
|
|
24
24
|
}) => boolean;
|
|
25
|
+
export type TTLPerSchemaCoordinate = Record<string, number | undefined>;
|
|
26
|
+
export type ScopePerSchemaCoordinate = Record<string, 'PRIVATE' | 'PUBLIC' | undefined>;
|
|
25
27
|
export type UseResponseCacheParameter<PluginContext extends Record<string, any> = {}> = {
|
|
26
28
|
cache?: Cache | ((ctx: Record<string, any>) => Cache);
|
|
27
29
|
/**
|
|
@@ -40,8 +42,25 @@ export type UseResponseCacheParameter<PluginContext extends Record<string, any>
|
|
|
40
42
|
* In the unusual case where you actually want to cache introspection query operations,
|
|
41
43
|
* you need to provide the value `{ 'Query.__schema': undefined }`.
|
|
42
44
|
*/
|
|
43
|
-
ttlPerSchemaCoordinate?:
|
|
44
|
-
|
|
45
|
+
ttlPerSchemaCoordinate?: TTLPerSchemaCoordinate;
|
|
46
|
+
/**
|
|
47
|
+
* Define the scope (PUBLIC or PRIVATE) by schema coordinate.
|
|
48
|
+
* The default scope for all types and fields is PUBLIC
|
|
49
|
+
*
|
|
50
|
+
* If an operation contains a PRIVATE type or field, the result will be cached only if a session
|
|
51
|
+
* id is found for this request.
|
|
52
|
+
*
|
|
53
|
+
* Note: To share cache of responses with a PUBLIC scope between all users, enable `ignoreSessionIdForPublicScope`
|
|
54
|
+
*/
|
|
55
|
+
scopePerSchemaCoordinate?: ScopePerSchemaCoordinate;
|
|
56
|
+
/**
|
|
57
|
+
* If enabled, a response with a PUBLIC scope will be cached with an operation key ignoring the
|
|
58
|
+
* session ID. This allows to improve cache hit further, but scope should be carefully defined
|
|
59
|
+
* to avoid any private data.
|
|
60
|
+
*
|
|
61
|
+
* @default false.
|
|
62
|
+
*/
|
|
63
|
+
ignoreSessionIdForPublicScope?: boolean;
|
|
45
64
|
/**
|
|
46
65
|
* Allows to cache responses based on the resolved session id.
|
|
47
66
|
* Return a unique value for each session.
|
|
@@ -151,6 +170,10 @@ export type ResponseCacheExtensions = {
|
|
|
151
170
|
export type ResponseCacheExecutionResult = ExecutionResult<ObjMap<unknown>, {
|
|
152
171
|
responseCache?: ResponseCacheExtensions;
|
|
153
172
|
}>;
|
|
154
|
-
export
|
|
173
|
+
export type CacheControlDirective = {
|
|
174
|
+
maxAge?: number;
|
|
175
|
+
scope?: 'PUBLIC' | 'PRIVATE';
|
|
176
|
+
};
|
|
177
|
+
export declare function useResponseCache<PluginContext extends Record<string, any> = {}>({ cache, ttl: globalTtl, session, enabled, ignoredTypes, ttlPerType, idFields, invalidateViaMutation, ignoreSessionIdForPublicScope, buildResponseCacheKey, getDocumentString, shouldCacheResult, onTtl, includeExtensionMetadata, ...options }: UseResponseCacheParameter<PluginContext>): Plugin<PluginContext>;
|
|
155
178
|
export declare function resultWithMetadata(result: ExecutionResult, metadata: ResponseCacheExtensions): ResponseCacheExecutionResult;
|
|
156
179
|
export declare const cacheControlDirective = "\n enum CacheControlScope {\n PUBLIC\n PRIVATE\n }\n\n directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT\n";
|