Skip to content

Commit c4de9af

Browse files
committed
feat(response-cache): add extras property to get scope and its metadata from buildResponseCacheKey
1 parent 08f6e1d commit c4de9af

File tree

6 files changed

+407
-14
lines changed

6 files changed

+407
-14
lines changed

.changeset/sour-cars-hang.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@envelop/response-cache': minor
3+
---
4+
5+
Added `getScope` callback in `buildResponseCacheKey` params

packages/plugins/response-cache/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,3 +863,55 @@ mutation SetNameMutation {
863863
}
864864
}
865865
```
866+
867+
#### Get scope of the query
868+
869+
Useful for building a cache key that is shared across all sessions when `PUBLIC`.
870+
871+
```ts
872+
import jsonStableStringify from 'fast-json-stable-stringify'
873+
import { execute, parse, subscribe, validate } from 'graphql'
874+
import { envelop } from '@envelop/core'
875+
import { hashSHA256, useResponseCache } from '@envelop/response-cache'
876+
877+
const getEnveloped = envelop({
878+
parse,
879+
validate,
880+
execute,
881+
subscribe,
882+
plugins: [
883+
// ... other plugins ...
884+
useResponseCache({
885+
ttl: 2000,
886+
session: request => getSessionId(request),
887+
buildResponseCacheKey: ({
888+
getScope,
889+
sessionId,
890+
documentString,
891+
operationName,
892+
variableValues
893+
}) =>
894+
// Use `getScope()` to put a unique key for every session when `PUBLIC`
895+
hashSHA256(
896+
[
897+
getScope() === 'PUBLIC' ? 'PUBLIC' : sessionId,
898+
documentString,
899+
operationName ?? '',
900+
jsonStableStringify(variableValues ?? {})
901+
].join('|')
902+
),
903+
scopePerSchemaCoordinate: {
904+
// Set scope for an entire query
905+
'Query.getProfile': 'PRIVATE',
906+
// Set scope for an entire type
907+
PrivateProfile: 'PRIVATE',
908+
// Set scope for a single field
909+
'Profile.privateData': 'PRIVATE'
910+
}
911+
})
912+
]
913+
})
914+
```
915+
916+
> Note: The use of this callback will increase the ram usage since it memoizes the scope for each
917+
> query in a weak map.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
FieldNode,
3+
GraphQLList,
4+
GraphQLNonNull,
5+
GraphQLObjectType,
6+
GraphQLOutputType,
7+
GraphQLSchema,
8+
Kind,
9+
parse,
10+
SelectionNode,
11+
visit,
12+
} from 'graphql';
13+
import { memoize1 } from '@graphql-tools/utils';
14+
import { isPrivate, type CacheControlDirective } from './plugin';
15+
16+
/** Parse the selected query fields */
17+
function parseSelections(selections: readonly SelectionNode[] = [], record: Record<string, any>) {
18+
for (const selection of selections) {
19+
if (selection.kind === Kind.FIELD) {
20+
record[selection.name.value] = {};
21+
parseSelections(selection.selectionSet?.selections, record[selection.name.value]);
22+
}
23+
}
24+
}
25+
26+
/** Iterate over record and parse its fields with schema type */
27+
function parseRecordWithSchemaType(
28+
type: GraphQLOutputType,
29+
record: Record<string, any>,
30+
prefix?: string,
31+
): Set<string> {
32+
let fields = new Set<string>();
33+
if (type instanceof GraphQLNonNull || type instanceof GraphQLList) {
34+
fields = new Set([...fields, ...parseRecordWithSchemaType(type.ofType, record, prefix)]);
35+
}
36+
37+
if (type instanceof GraphQLObjectType) {
38+
const newPrefixes = [...(prefix ?? []), type.name];
39+
fields.add(newPrefixes.join('.'));
40+
41+
const typeFields = type.getFields();
42+
for (const key of Object.keys(record)) {
43+
const field = typeFields[key];
44+
if (!field) {
45+
continue;
46+
}
47+
48+
fields.add([...newPrefixes, field.name].join('.'));
49+
if (Object.keys(record[key]).length > 0) {
50+
fields = new Set([...fields, ...parseRecordWithSchemaType(field.type, record[key])]);
51+
}
52+
}
53+
}
54+
55+
return fields;
56+
}
57+
58+
function getSchemaCoordinatesFromQuery(schema: GraphQLSchema, query: string): Set<string> {
59+
const ast = parse(query);
60+
let fields = new Set<string>();
61+
62+
// Launch the field visitor
63+
visit(ast, {
64+
// Parse the fields of the root of query
65+
Field: node => {
66+
const record: Record<string, any> = {};
67+
const queryFields = schema.getQueryType()?.getFields()[node.name.value];
68+
69+
if (queryFields) {
70+
record[node.name.value] = {};
71+
parseSelections(node.selectionSet?.selections, record[node.name.value]);
72+
73+
fields.add(`Query.${node.name.value}`);
74+
fields = new Set([
75+
...fields,
76+
...parseRecordWithSchemaType(queryFields.type, record[node.name.value]),
77+
]);
78+
}
79+
},
80+
// And each fragment
81+
FragmentDefinition: fragment => {
82+
const type = fragment.typeCondition.name.value;
83+
fields = new Set([
84+
...fields,
85+
...(
86+
fragment.selectionSet.selections.filter(({ kind }) => kind === Kind.FIELD) as FieldNode[]
87+
).map(({ name: { value } }) => `${type}.${value}`),
88+
]);
89+
},
90+
});
91+
92+
return fields;
93+
}
94+
95+
export const getScopeFromQuery = (
96+
schema: GraphQLSchema,
97+
query: string,
98+
): {
99+
scope: NonNullable<CacheControlDirective['scope']>;
100+
metadata: { privateProperty?: string };
101+
} => {
102+
const fn = memoize1(({ query }: { query: string }) => {
103+
const schemaCoordinates = getSchemaCoordinatesFromQuery(schema, query);
104+
105+
for (const coordinate of schemaCoordinates) {
106+
if (isPrivate(coordinate)) {
107+
return { scope: 'PRIVATE' as const, metadata: { privateProperty: coordinate } };
108+
}
109+
}
110+
111+
return { scope: 'PUBLIC' as const, metadata: {} };
112+
});
113+
114+
return fn({ query });
115+
};

packages/plugins/response-cache/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './in-memory-cache.js';
22
export * from './plugin.js';
33
export * from './cache.js';
44
export * from './hash-sha256.js';
5+
export * from './get-scope.js';

packages/plugins/response-cache/src/plugin.ts

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ExecutionArgs,
66
getOperationAST,
77
GraphQLDirective,
8+
GraphQLSchema,
89
GraphQLType,
910
isListType,
1011
isNonNullType,
@@ -35,6 +36,7 @@ import {
3536
} from '@graphql-tools/utils';
3637
import { handleMaybePromise, MaybePromise } from '@whatwg-node/promise-helpers';
3738
import type { Cache, CacheEntityRecord } from './cache.js';
39+
import { getScopeFromQuery } from './get-scope.js';
3840
import { hashSHA256 } from './hash-sha256.js';
3941
import { createInMemoryCache } from './in-memory-cache.js';
4042

@@ -52,6 +54,13 @@ export type BuildResponseCacheKeyFunction = (params: {
5254
sessionId: Maybe<string>;
5355
/** GraphQL Context */
5456
context: ExecutionArgs['contextValue'];
57+
/** Extras of the query (won't be computed if not requested) */
58+
extras: {
59+
scope: NonNullable<CacheControlDirective['scope']>;
60+
metadata: {
61+
privateProperty?: string;
62+
};
63+
};
5564
}) => MaybePromise<string>;
5665

5766
export type GetDocumentStringFunction = (executionArgs: ExecutionArgs) => string;
@@ -295,20 +304,38 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(
295304
return [visit(document, visitWithTypeInfo(typeInfo, visitor)), ttl];
296305
});
297306

298-
type CacheControlDirective = {
307+
export type CacheControlDirective = {
299308
maxAge?: number;
300309
scope?: 'PUBLIC' | 'PRIVATE';
301310
};
302311

312+
export let schema: GraphQLSchema;
313+
let ttlPerSchemaCoordinate: Record<string, CacheControlDirective['maxAge']> = {};
314+
let scopePerSchemaCoordinate: Record<string, CacheControlDirective['scope']> = {};
315+
316+
export function isPrivate(
317+
typeName: string,
318+
data?: Record<string, NonNullable<CacheControlDirective['scope']>>,
319+
): boolean {
320+
if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') {
321+
return true;
322+
}
323+
return data
324+
? Object.keys(data).some(
325+
fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE',
326+
)
327+
: false;
328+
}
329+
303330
export function useResponseCache<PluginContext extends Record<string, any> = {}>({
304331
cache = createInMemoryCache(),
305332
ttl: globalTtl = Infinity,
306333
session,
307334
enabled,
308335
ignoredTypes = [],
309336
ttlPerType,
310-
ttlPerSchemaCoordinate = {},
311-
scopePerSchemaCoordinate = {},
337+
ttlPerSchemaCoordinate: localTtlPerSchemaCoordinate = {},
338+
scopePerSchemaCoordinate: localScopePerSchemaCoordinate = {},
312339
idFields = ['id'],
313340
invalidateViaMutation = true,
314341
buildResponseCacheKey = defaultBuildResponseCacheKey,
@@ -326,7 +353,7 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
326353
enabled = enabled ? memoize1(enabled) : enabled;
327354

328355
// never cache Introspections
329-
ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...ttlPerSchemaCoordinate };
356+
ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...localTtlPerSchemaCoordinate };
330357
if (ttlPerType) {
331358
// eslint-disable-next-line no-console
332359
console.warn(
@@ -341,8 +368,8 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
341368
queries: { invalidateViaMutation, ttlPerSchemaCoordinate },
342369
mutations: { invalidateViaMutation }, // remove ttlPerSchemaCoordinate for mutations to skip TTL calculation
343370
};
371+
scopePerSchemaCoordinate = { ...localScopePerSchemaCoordinate };
344372
const idFieldByTypeName = new Map<string, string>();
345-
let schema: any;
346373

347374
function isPrivate(typeName: string, data: Record<string, unknown>): boolean {
348375
if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') {
@@ -558,13 +585,26 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
558585

559586
return handleMaybePromise(
560587
() =>
561-
buildResponseCacheKey({
562-
documentString: getDocumentString(onExecuteParams.args),
563-
variableValues: onExecuteParams.args.variableValues,
564-
operationName: onExecuteParams.args.operationName,
565-
sessionId,
566-
context: onExecuteParams.args.contextValue,
567-
}),
588+
buildResponseCacheKey(
589+
new Proxy(
590+
{
591+
documentString: getDocumentString(onExecuteParams.args),
592+
variableValues: onExecuteParams.args.variableValues,
593+
operationName: onExecuteParams.args.operationName,
594+
sessionId,
595+
context: onExecuteParams.args.contextValue,
596+
extras: undefined as any,
597+
},
598+
{
599+
get: (obj, prop) => {
600+
if (prop === 'extras') {
601+
return getScopeFromQuery(schema, onExecuteParams.args.document.loc.source.body);
602+
}
603+
return obj[prop as keyof typeof obj];
604+
},
605+
},
606+
),
607+
),
568608
cacheKey => {
569609
const cacheInstance = cacheFactory(onExecuteParams.args.contextValue);
570610
if (cacheInstance == null) {

0 commit comments

Comments
 (0)