Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rich-kiwis-stand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@envelop/response-cache': minor
---

Add `extras` function to `BuildResponseCacheKeyFunction` to get computed scope
64 changes: 64 additions & 0 deletions packages/plugins/response-cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -863,3 +863,67 @@ mutation SetNameMutation {
}
}
```

#### Get scope of the query

Useful for building a cache with more flexibility (e.g. generate a key that is shared across all
sessions when `PUBLIC`).

```ts
import jsonStableStringify from 'fast-json-stable-stringify'
import { execute, parse, subscribe, validate } from 'graphql'
import { envelop } from '@envelop/core'
import { hashSHA256, useResponseCache } from '@envelop/response-cache'

const schema = buildSchema(/* GraphQL */ `
${cacheControlDirective}
type PrivateProfile @cacheControl(scope: PRIVATE) {
# ...
}

type Profile {
privateData: String @cacheControl(scope: PRIVATE)
}
`)

const getEnveloped = envelop({
parse,
validate,
execute,
subscribe,
plugins: [
// ... other plugins ...
useResponseCache({
ttl: 2000,
session: request => getSessionId(request),
buildResponseCacheKey: ({
sessionId,
documentString,
operationName,
variableValues,
extras
}) =>
hashSHA256(
[
// Use it to put a unique key for every session when `PUBLIC`
extras(schema).scope === 'PUBLIC' ? 'PUBLIC' : sessionId,
documentString,
operationName ?? '',
jsonStableStringify(variableValues ?? {})
].join('|')
),
scopePerSchemaCoordinate: {
// Set scope for an entire query
'Query.getProfile': 'PRIVATE',
// Set scope for an entire type
PrivateProfile: 'PRIVATE',
// Set scope for a single field
'Profile.privateData': 'PRIVATE'
}
})
]
})
```

> Note: The use of this callback will increase the ram usage since it memoizes the scope for each
> query in a weak map.
156 changes: 156 additions & 0 deletions packages/plugins/response-cache/src/get-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {
FieldNode,
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLOutputType,
GraphQLSchema,
Kind,
parse,
SelectionNode,
visit,
} from 'graphql';
import { LRUCache } from 'lru-cache';
import { isPrivate, type CacheControlDirective } from './plugin';

/** Parse the selected query fields */
function parseSelections(selections: readonly SelectionNode[] = [], record: Record<string, any>) {
for (const selection of selections) {
if (selection.kind === Kind.FIELD) {
record[selection.name.value] = {};
parseSelections(selection.selectionSet?.selections, record[selection.name.value]);
}
}
}

/** Iterate over record and parse its fields with schema type */
function parseRecordWithSchemaType(
type: GraphQLOutputType,
record: Record<string, any>,
prefix?: string,
): Set<string> {
let fields = new Set<string>();
if (type instanceof GraphQLNonNull || type instanceof GraphQLList) {
fields = new Set([...fields, ...parseRecordWithSchemaType(type.ofType, record, prefix)]);
}

if (type instanceof GraphQLObjectType) {
const newPrefixes = [...(prefix ?? []), type.name];
fields.add(newPrefixes.join('.'));

const typeFields = type.getFields();
for (const key of Object.keys(record)) {
const field = typeFields[key];
if (!field) {
continue;
}

fields.add([...newPrefixes, field.name].join('.'));
if (Object.keys(record[key]).length > 0) {
fields = new Set([...fields, ...parseRecordWithSchemaType(field.type, record[key])]);
}
}
}

return fields;
}

function getSchemaCoordinatesFromQuery(schema: GraphQLSchema, query: string): Set<string> {
const ast = parse(query);
let fields = new Set<string>();

// Launch the field visitor
visit(ast, {
// Parse the fields of the root of query
Field: node => {
const record: Record<string, any> = {};
const queryFields = schema.getQueryType()?.getFields()[node.name.value];

if (queryFields) {
record[node.name.value] = {};
parseSelections(node.selectionSet?.selections, record[node.name.value]);

fields.add(`Query.${node.name.value}`);
fields = new Set([
...fields,
...parseRecordWithSchemaType(queryFields.type, record[node.name.value]),
]);
}
},
// And each fragment
FragmentDefinition: fragment => {
const type = fragment.typeCondition.name.value;
fields = new Set([
...fields,
...(
fragment.selectionSet.selections.filter(({ kind }) => kind === Kind.FIELD) as FieldNode[]
).map(({ name: { value } }) => `${type}.${value}`),
]);
},
});

return fields;
}

export type Scope = {
scope: NonNullable<CacheControlDirective['scope']>;
metadata?: { privateProperty?: string; hitCache?: boolean };
};

const scopeCachePerSchema = new WeakMap<GraphQLSchema, LRUCache<string, Scope>>();

export type GetScopeFromQueryOptions = {
includeExtensionMetadata?: boolean;
sizePerSchema?: number;
};

export const getScopeFromQuery = (
schema: GraphQLSchema,
query: string,
options?: GetScopeFromQueryOptions,
): Scope => {
if (!scopeCachePerSchema.has(schema)) {
scopeCachePerSchema.set(
schema,
new LRUCache({
max: options?.sizePerSchema ?? 1000,
}),
);
}

const cache = scopeCachePerSchema.get(schema);
const cachedScope = cache?.get(query);

if (cachedScope)
return {
...cachedScope,
...(options?.includeExtensionMetadata
? { metadata: { ...cachedScope.metadata, hitCache: true } }
: {}),
};

function getScope() {
const schemaCoordinates = getSchemaCoordinatesFromQuery(schema, query);

for (const coordinate of schemaCoordinates) {
if (isPrivate(coordinate)) {
return {
scope: 'PRIVATE' as const,
...(options?.includeExtensionMetadata
? { metadata: { privateProperty: coordinate } }
: {}),
};
}
}

return {
scope: 'PUBLIC' as const,
...(options?.includeExtensionMetadata ? { metadata: {} } : {}),
};
}

const scope = getScope();
cache?.set(query, scope);

return scope;
};
1 change: 1 addition & 0 deletions packages/plugins/response-cache/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './in-memory-cache.js';
export * from './plugin.js';
export * from './cache.js';
export * from './hash-sha256.js';
export * from './get-scope.js';
50 changes: 34 additions & 16 deletions packages/plugins/response-cache/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import jsonStableStringify from 'fast-json-stable-stringify';
import stringify from 'fast-json-stable-stringify';
import {
ASTVisitor,
DocumentNode,
ExecutionArgs,
getOperationAST,
GraphQLDirective,
GraphQLSchema,
GraphQLType,
isListType,
isNonNullType,
Expand Down Expand Up @@ -35,6 +36,7 @@ import {
} from '@graphql-tools/utils';
import { handleMaybePromise, MaybePromise } from '@whatwg-node/promise-helpers';
import type { Cache, CacheEntityRecord } from './cache.js';
import { getScopeFromQuery, GetScopeFromQueryOptions, Scope } from './get-scope.js';
import { hashSHA256 } from './hash-sha256.js';
import { createInMemoryCache } from './in-memory-cache.js';

Expand All @@ -52,6 +54,8 @@ export type BuildResponseCacheKeyFunction = (params: {
sessionId: Maybe<string>;
/** GraphQL Context */
context: ExecutionArgs['contextValue'];
/** Extras of the query (won't be computed if not requested) */
extras: (schema: GraphQLSchema) => Scope;
}) => MaybePromise<string>;

export type GetDocumentStringFunction = (executionArgs: ExecutionArgs) => string;
Expand Down Expand Up @@ -171,7 +175,7 @@ export const defaultBuildResponseCacheKey = (params: {
[
params.documentString,
params.operationName ?? '',
jsonStableStringify(params.variableValues ?? {}),
stringify(params.variableValues ?? {}),
params.sessionId ?? '',
].join('|'),
);
Expand Down Expand Up @@ -295,20 +299,35 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument(
return [visit(document, visitWithTypeInfo(typeInfo, visitor)), ttl];
});

type CacheControlDirective = {
export type CacheControlDirective = {
maxAge?: number;
scope?: 'PUBLIC' | 'PRIVATE';
};

let schema: GraphQLSchema;
let ttlPerSchemaCoordinate: Record<string, CacheControlDirective['maxAge']> = {};
let scopePerSchemaCoordinate: Record<string, CacheControlDirective['scope']> = {};

export function isPrivate(typeName: string, data?: Record<string, unknown>): boolean {
if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') {
return true;
}
return data
? Object.keys(data).some(
fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE',
)
: false;
}

export function useResponseCache<PluginContext extends Record<string, any> = {}>({
cache = createInMemoryCache(),
ttl: globalTtl = Infinity,
session,
enabled,
ignoredTypes = [],
ttlPerType,
ttlPerSchemaCoordinate = {},
scopePerSchemaCoordinate = {},
ttlPerSchemaCoordinate: localTtlPerSchemaCoordinate = {},
scopePerSchemaCoordinate: localScopePerSchemaCoordinate = {},
idFields = ['id'],
invalidateViaMutation = true,
buildResponseCacheKey = defaultBuildResponseCacheKey,
Expand All @@ -326,7 +345,7 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
enabled = enabled ? memoize1(enabled) : enabled;

// never cache Introspections
ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...ttlPerSchemaCoordinate };
ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...localTtlPerSchemaCoordinate };
if (ttlPerType) {
// eslint-disable-next-line no-console
console.warn(
Expand All @@ -341,17 +360,8 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
queries: { invalidateViaMutation, ttlPerSchemaCoordinate },
mutations: { invalidateViaMutation }, // remove ttlPerSchemaCoordinate for mutations to skip TTL calculation
};
scopePerSchemaCoordinate = { ...localScopePerSchemaCoordinate };
const idFieldByTypeName = new Map<string, string>();
let schema: any;

function isPrivate(typeName: string, data: Record<string, unknown>): boolean {
if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') {
return true;
}
return Object.keys(data).some(
fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE',
);
}

return {
onSchemaChange({ schema: newSchema }) {
Expand Down Expand Up @@ -564,6 +574,14 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
operationName: onExecuteParams.args.operationName,
sessionId,
context: onExecuteParams.args.contextValue,
extras: (
schema: GraphQLSchema,
options?: Omit<GetScopeFromQueryOptions, 'includeExtensionMetadata'>,
) =>
getScopeFromQuery(schema, onExecuteParams.args.document.loc.source.body, {
...options,
includeExtensionMetadata,
}),
}),
cacheKey => {
const cacheInstance = cacheFactory(onExecuteParams.args.contextValue);
Expand Down
Loading