From c4de9afa4297e4f0369d5e0ce92868eb66d312cb Mon Sep 17 00:00:00 2001 From: Kilyan Fossey Date: Tue, 2 Apr 2024 17:55:19 +0200 Subject: [PATCH 1/6] feat(response-cache): add extras property to get scope and its metadata from buildResponseCacheKey --- .changeset/sour-cars-hang.md | 5 + packages/plugins/response-cache/README.md | 52 +++++ .../plugins/response-cache/src/get-scope.ts | 115 +++++++++++ packages/plugins/response-cache/src/index.ts | 1 + packages/plugins/response-cache/src/plugin.ts | 64 ++++-- .../test/response-cache.spec.ts | 184 +++++++++++++++++- 6 files changed, 407 insertions(+), 14 deletions(-) create mode 100644 .changeset/sour-cars-hang.md create mode 100644 packages/plugins/response-cache/src/get-scope.ts diff --git a/.changeset/sour-cars-hang.md b/.changeset/sour-cars-hang.md new file mode 100644 index 0000000000..b3191c495d --- /dev/null +++ b/.changeset/sour-cars-hang.md @@ -0,0 +1,5 @@ +--- +'@envelop/response-cache': minor +--- + +Added `getScope` callback in `buildResponseCacheKey` params diff --git a/packages/plugins/response-cache/README.md b/packages/plugins/response-cache/README.md index fa2ee43715..8bdc7f3248 100644 --- a/packages/plugins/response-cache/README.md +++ b/packages/plugins/response-cache/README.md @@ -863,3 +863,55 @@ mutation SetNameMutation { } } ``` + +#### Get scope of the query + +Useful for building a cache 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 getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, + plugins: [ + // ... other plugins ... + useResponseCache({ + ttl: 2000, + session: request => getSessionId(request), + buildResponseCacheKey: ({ + getScope, + sessionId, + documentString, + operationName, + variableValues + }) => + // Use `getScope()` to put a unique key for every session when `PUBLIC` + hashSHA256( + [ + getScope() === '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. diff --git a/packages/plugins/response-cache/src/get-scope.ts b/packages/plugins/response-cache/src/get-scope.ts new file mode 100644 index 0000000000..dd20621a12 --- /dev/null +++ b/packages/plugins/response-cache/src/get-scope.ts @@ -0,0 +1,115 @@ +import { + FieldNode, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLOutputType, + GraphQLSchema, + Kind, + parse, + SelectionNode, + visit, +} from 'graphql'; +import { memoize1 } from '@graphql-tools/utils'; +import { isPrivate, type CacheControlDirective } from './plugin'; + +/** Parse the selected query fields */ +function parseSelections(selections: readonly SelectionNode[] = [], record: Record) { + 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, + prefix?: string, +): Set { + let fields = new Set(); + 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 { + const ast = parse(query); + let fields = new Set(); + + // Launch the field visitor + visit(ast, { + // Parse the fields of the root of query + Field: node => { + const record: Record = {}; + 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 const getScopeFromQuery = ( + schema: GraphQLSchema, + query: string, +): { + scope: NonNullable; + metadata: { privateProperty?: string }; +} => { + const fn = memoize1(({ query }: { query: string }) => { + const schemaCoordinates = getSchemaCoordinatesFromQuery(schema, query); + + for (const coordinate of schemaCoordinates) { + if (isPrivate(coordinate)) { + return { scope: 'PRIVATE' as const, metadata: { privateProperty: coordinate } }; + } + } + + return { scope: 'PUBLIC' as const, metadata: {} }; + }); + + return fn({ query }); +}; diff --git a/packages/plugins/response-cache/src/index.ts b/packages/plugins/response-cache/src/index.ts index 2d6d23a362..69663dd532 100644 --- a/packages/plugins/response-cache/src/index.ts +++ b/packages/plugins/response-cache/src/index.ts @@ -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'; diff --git a/packages/plugins/response-cache/src/plugin.ts b/packages/plugins/response-cache/src/plugin.ts index 679c4eaeb3..f9d2cbe989 100644 --- a/packages/plugins/response-cache/src/plugin.ts +++ b/packages/plugins/response-cache/src/plugin.ts @@ -5,6 +5,7 @@ import { ExecutionArgs, getOperationAST, GraphQLDirective, + GraphQLSchema, GraphQLType, isListType, isNonNullType, @@ -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 } from './get-scope.js'; import { hashSHA256 } from './hash-sha256.js'; import { createInMemoryCache } from './in-memory-cache.js'; @@ -52,6 +54,13 @@ export type BuildResponseCacheKeyFunction = (params: { sessionId: Maybe; /** GraphQL Context */ context: ExecutionArgs['contextValue']; + /** Extras of the query (won't be computed if not requested) */ + extras: { + scope: NonNullable; + metadata: { + privateProperty?: string; + }; + }; }) => MaybePromise; export type GetDocumentStringFunction = (executionArgs: ExecutionArgs) => string; @@ -295,11 +304,29 @@ const getDocumentWithMetadataAndTTL = memoize4(function addTypeNameToDocument( return [visit(document, visitWithTypeInfo(typeInfo, visitor)), ttl]; }); -type CacheControlDirective = { +export type CacheControlDirective = { maxAge?: number; scope?: 'PUBLIC' | 'PRIVATE'; }; +export let schema: GraphQLSchema; +let ttlPerSchemaCoordinate: Record = {}; +let scopePerSchemaCoordinate: Record = {}; + +export function isPrivate( + typeName: string, + data?: Record>, +): boolean { + if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') { + return true; + } + return data + ? Object.keys(data).some( + fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE', + ) + : false; +} + export function useResponseCache = {}>({ cache = createInMemoryCache(), ttl: globalTtl = Infinity, @@ -307,8 +334,8 @@ export function useResponseCache = {}> enabled, ignoredTypes = [], ttlPerType, - ttlPerSchemaCoordinate = {}, - scopePerSchemaCoordinate = {}, + ttlPerSchemaCoordinate: localTtlPerSchemaCoordinate = {}, + scopePerSchemaCoordinate: localScopePerSchemaCoordinate = {}, idFields = ['id'], invalidateViaMutation = true, buildResponseCacheKey = defaultBuildResponseCacheKey, @@ -326,7 +353,7 @@ export function useResponseCache = {}> 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( @@ -341,8 +368,8 @@ export function useResponseCache = {}> queries: { invalidateViaMutation, ttlPerSchemaCoordinate }, mutations: { invalidateViaMutation }, // remove ttlPerSchemaCoordinate for mutations to skip TTL calculation }; + scopePerSchemaCoordinate = { ...localScopePerSchemaCoordinate }; const idFieldByTypeName = new Map(); - let schema: any; function isPrivate(typeName: string, data: Record): boolean { if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') { @@ -558,13 +585,26 @@ export function useResponseCache = {}> return handleMaybePromise( () => - buildResponseCacheKey({ - documentString: getDocumentString(onExecuteParams.args), - variableValues: onExecuteParams.args.variableValues, - operationName: onExecuteParams.args.operationName, - sessionId, - context: onExecuteParams.args.contextValue, - }), + buildResponseCacheKey( + new Proxy( + { + documentString: getDocumentString(onExecuteParams.args), + variableValues: onExecuteParams.args.variableValues, + operationName: onExecuteParams.args.operationName, + sessionId, + context: onExecuteParams.args.contextValue, + extras: undefined as any, + }, + { + get: (obj, prop) => { + if (prop === 'extras') { + return getScopeFromQuery(schema, onExecuteParams.args.document.loc.source.body); + } + return obj[prop as keyof typeof obj]; + }, + }, + ), + ), cacheKey => { const cacheInstance = cacheFactory(onExecuteParams.args.contextValue); if (cacheInstance == null) { diff --git a/packages/plugins/response-cache/test/response-cache.spec.ts b/packages/plugins/response-cache/test/response-cache.spec.ts index 14d2e6984d..40b1e6de7f 100644 --- a/packages/plugins/response-cache/test/response-cache.spec.ts +++ b/packages/plugins/response-cache/test/response-cache.spec.ts @@ -3285,7 +3285,7 @@ describe('useResponseCache', () => { expect(spy).toHaveBeenCalledTimes(2); }); - it('should not cache response with a type with a PRIVATE scope for request without session using @cachControl directive', async () => { + it('should not cache response with a type with a PRIVATE scope for request without session using @cacheControl directive', async () => { jest.useFakeTimers(); const spy = jest.fn(() => [ { @@ -3445,7 +3445,7 @@ describe('useResponseCache', () => { expect(spy).toHaveBeenCalledTimes(2); }); - it('should not cache response with a field with PRIVATE scope for request without session using @cachControl directive', async () => { + it('should not cache response with a field with PRIVATE scope for request without session using @cacheControl directive', async () => { jest.useFakeTimers(); const spy = jest.fn(() => [ { @@ -3524,6 +3524,186 @@ describe('useResponseCache', () => { expect(spy).toHaveBeenCalledTimes(2); }); + ['query', 'field', 'subfield'].forEach(type => { + it(`should return PRIVATE scope in buildResponseCacheKey when putting @cacheControl scope on ${type}`, async () => { + jest.useFakeTimers(); + const spy = jest.fn(() => [ + { + id: 1, + name: 'User 1', + comments: [ + { + id: 1, + text: 'Comment 1 of User 1', + }, + ], + }, + { + id: 2, + name: 'User 2', + comments: [ + { + id: 2, + text: 'Comment 2 of User 2', + }, + ], + }, + ]); + + const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + ${cacheControlDirective} + type Query { + users: [User!]! ${type === 'query' ? '@cacheControl(scope: PRIVATE)' : ''} + } + + type User ${type === 'field' ? '@cacheControl(scope: PRIVATE)' : ''} { + id: ID! + name: String! ${type === 'subfield' ? '@cacheControl(scope: PRIVATE)' : ''} + comments: [Comment!]! + recentComment: Comment + } + + type Comment { + id: ID! + text: String! + } + `, + resolvers: { + Query: { + users: spy, + }, + }, + }); + + function getPrivateProperty() { + if (type === 'query') return 'Query.users'; + if (type === 'field') return 'User'; + return 'User.name'; + } + + const testInstance = createTestkit( + [ + useResponseCache({ + session: () => null, + buildResponseCacheKey: ({ extras: { scope, metadata }, ...rest }) => { + expect(scope).toEqual('PRIVATE'); + expect(metadata?.privateProperty).toEqual(getPrivateProperty()); + return defaultBuildResponseCacheKey(rest); + }, + ttl: 200, + }), + ], + schema, + ); + + const query = /* GraphQL */ ` + query test { + users { + id + name + comments { + id + text + } + } + } + `; + + await testInstance.execute(query); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + it('should return PRIVATE scope in buildResponseCacheKey even when requesting property from a fragment', async () => { + jest.useFakeTimers(); + const spy = jest.fn(() => [ + { + id: 1, + name: 'User 1', + comments: [ + { + id: 1, + text: 'Comment 1 of User 1', + }, + ], + }, + { + id: 2, + name: 'User 2', + comments: [ + { + id: 2, + text: 'Comment 2 of User 2', + }, + ], + }, + ]); + + const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + ${cacheControlDirective} + type Query { + users: [User!]! + } + + type User { + id: ID! + name: String! @cacheControl(scope: PRIVATE) + comments: [Comment!]! + recentComment: Comment + } + + type Comment { + id: ID! + text: String! + } + `, + resolvers: { + Query: { + users: spy, + }, + }, + }); + + const testInstance = createTestkit( + [ + useResponseCache({ + session: () => null, + buildResponseCacheKey: ({ extras: { scope, metadata }, ...rest }) => { + expect(scope).toEqual('PRIVATE'); + expect(metadata?.privateProperty).toEqual('User.name'); + return defaultBuildResponseCacheKey(rest); + }, + ttl: 200, + }), + ], + schema, + ); + + const query = /* GraphQL */ ` + query test { + users { + ...user + } + } + + fragment user on User { + id + name + comments { + id + text + } + } + `; + + await testInstance.execute(query); + + expect(spy).toHaveBeenCalledTimes(1); + }); + it('should cache correctly for session with ttl being a valid number', async () => { jest.useFakeTimers(); const spy = jest.fn(() => [ From d4ab39bce27bc444255a754d0063cb051009b01d Mon Sep 17 00:00:00 2001 From: Kilyan Fossey Date: Thu, 30 Oct 2025 11:28:33 +0100 Subject: [PATCH 2/6] feat(response-cache): extras as a function with schema as a param and better scope memoization --- .../plugins/response-cache/src/get-scope.ts | 38 +++++++++--- packages/plugins/response-cache/src/plugin.ts | 58 +++++-------------- .../test/response-cache.spec.ts | 16 +++-- 3 files changed, 57 insertions(+), 55 deletions(-) diff --git a/packages/plugins/response-cache/src/get-scope.ts b/packages/plugins/response-cache/src/get-scope.ts index dd20621a12..1d5af2bd2c 100644 --- a/packages/plugins/response-cache/src/get-scope.ts +++ b/packages/plugins/response-cache/src/get-scope.ts @@ -10,7 +10,7 @@ import { SelectionNode, visit, } from 'graphql'; -import { memoize1 } from '@graphql-tools/utils'; +import { LRUCache } from 'lru-cache'; import { isPrivate, type CacheControlDirective } from './plugin'; /** Parse the selected query fields */ @@ -92,14 +92,33 @@ function getSchemaCoordinatesFromQuery(schema: GraphQLSchema, query: string): Se return fields; } +export type Scope = { + scope: NonNullable; + metadata: { privateProperty?: string; hitCache?: boolean }; +}; + +const scopeCachePerSchema = new WeakMap>(); + export const getScopeFromQuery = ( schema: GraphQLSchema, query: string, -): { - scope: NonNullable; - metadata: { privateProperty?: string }; -} => { - const fn = memoize1(({ query }: { query: string }) => { + options?: { sizePerSchema?: number }, +): 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, metadata: { ...cachedScope.metadata, hitCache: true } }; + + function getScope() { const schemaCoordinates = getSchemaCoordinatesFromQuery(schema, query); for (const coordinate of schemaCoordinates) { @@ -109,7 +128,10 @@ export const getScopeFromQuery = ( } return { scope: 'PUBLIC' as const, metadata: {} }; - }); + } + + const scope = getScope(); + cache?.set(query, scope); - return fn({ query }); + return scope; }; diff --git a/packages/plugins/response-cache/src/plugin.ts b/packages/plugins/response-cache/src/plugin.ts index f9d2cbe989..e11aee1613 100644 --- a/packages/plugins/response-cache/src/plugin.ts +++ b/packages/plugins/response-cache/src/plugin.ts @@ -1,4 +1,4 @@ -import jsonStableStringify from 'fast-json-stable-stringify'; +import stringify from 'fast-json-stable-stringify'; import { ASTVisitor, DocumentNode, @@ -36,7 +36,7 @@ import { } from '@graphql-tools/utils'; import { handleMaybePromise, MaybePromise } from '@whatwg-node/promise-helpers'; import type { Cache, CacheEntityRecord } from './cache.js'; -import { getScopeFromQuery } from './get-scope.js'; +import { getScopeFromQuery, Scope } from './get-scope.js'; import { hashSHA256 } from './hash-sha256.js'; import { createInMemoryCache } from './in-memory-cache.js'; @@ -55,12 +55,7 @@ export type BuildResponseCacheKeyFunction = (params: { /** GraphQL Context */ context: ExecutionArgs['contextValue']; /** Extras of the query (won't be computed if not requested) */ - extras: { - scope: NonNullable; - metadata: { - privateProperty?: string; - }; - }; + extras: (schema: GraphQLSchema) => Scope; }) => MaybePromise; export type GetDocumentStringFunction = (executionArgs: ExecutionArgs) => string; @@ -180,7 +175,7 @@ export const defaultBuildResponseCacheKey = (params: { [ params.documentString, params.operationName ?? '', - jsonStableStringify(params.variableValues ?? {}), + stringify(params.variableValues ?? {}), params.sessionId ?? '', ].join('|'), ); @@ -309,14 +304,11 @@ export type CacheControlDirective = { scope?: 'PUBLIC' | 'PRIVATE'; }; -export let schema: GraphQLSchema; +let schema: GraphQLSchema; let ttlPerSchemaCoordinate: Record = {}; let scopePerSchemaCoordinate: Record = {}; -export function isPrivate( - typeName: string, - data?: Record>, -): boolean { +export function isPrivate(typeName: string, data?: Record): boolean { if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') { return true; } @@ -371,15 +363,6 @@ export function useResponseCache = {}> scopePerSchemaCoordinate = { ...localScopePerSchemaCoordinate }; const idFieldByTypeName = new Map(); - function isPrivate(typeName: string, data: Record): boolean { - if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') { - return true; - } - return Object.keys(data).some( - fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE', - ); - } - return { onSchemaChange({ schema: newSchema }) { if (schema === newSchema) { @@ -585,26 +568,15 @@ export function useResponseCache = {}> return handleMaybePromise( () => - buildResponseCacheKey( - new Proxy( - { - documentString: getDocumentString(onExecuteParams.args), - variableValues: onExecuteParams.args.variableValues, - operationName: onExecuteParams.args.operationName, - sessionId, - context: onExecuteParams.args.contextValue, - extras: undefined as any, - }, - { - get: (obj, prop) => { - if (prop === 'extras') { - return getScopeFromQuery(schema, onExecuteParams.args.document.loc.source.body); - } - return obj[prop as keyof typeof obj]; - }, - }, - ), - ), + buildResponseCacheKey({ + documentString: getDocumentString(onExecuteParams.args), + variableValues: onExecuteParams.args.variableValues, + operationName: onExecuteParams.args.operationName, + sessionId, + context: onExecuteParams.args.contextValue, + extras: (schema: GraphQLSchema) => + getScopeFromQuery(schema, onExecuteParams.args.document.loc.source.body), + }), cacheKey => { const cacheInstance = cacheFactory(onExecuteParams.args.contextValue); if (cacheInstance == null) { diff --git a/packages/plugins/response-cache/test/response-cache.spec.ts b/packages/plugins/response-cache/test/response-cache.spec.ts index 40b1e6de7f..94e4f942ae 100644 --- a/packages/plugins/response-cache/test/response-cache.spec.ts +++ b/packages/plugins/response-cache/test/response-cache.spec.ts @@ -3586,7 +3586,8 @@ describe('useResponseCache', () => { [ useResponseCache({ session: () => null, - buildResponseCacheKey: ({ extras: { scope, metadata }, ...rest }) => { + buildResponseCacheKey: ({ extras, ...rest }) => { + const { scope, metadata } = extras(schema); expect(scope).toEqual('PRIVATE'); expect(metadata?.privateProperty).toEqual(getPrivateProperty()); return defaultBuildResponseCacheKey(rest); @@ -3667,13 +3668,17 @@ describe('useResponseCache', () => { }, }); + let multipleCalls = false; + const testInstance = createTestkit( [ useResponseCache({ session: () => null, - buildResponseCacheKey: ({ extras: { scope, metadata }, ...rest }) => { + buildResponseCacheKey: ({ extras, ...rest }) => { + const { scope, metadata } = extras(schema); expect(scope).toEqual('PRIVATE'); - expect(metadata?.privateProperty).toEqual('User.name'); + expect(metadata.privateProperty).toEqual('User.name'); + expect(metadata.hitCache).toEqual(multipleCalls ? true : undefined); return defaultBuildResponseCacheKey(rest); }, ttl: 200, @@ -3700,8 +3705,11 @@ describe('useResponseCache', () => { `; await testInstance.execute(query); - expect(spy).toHaveBeenCalledTimes(1); + + multipleCalls = true; + await testInstance.execute(query); + expect(spy).toHaveBeenCalledTimes(2); }); it('should cache correctly for session with ttl being a valid number', async () => { From 839204e106129425eb1a65bdd27f8c651a068644 Mon Sep 17 00:00:00 2001 From: Kilyan Fossey Date: Wed, 19 Nov 2025 12:24:10 +0100 Subject: [PATCH 3/6] feat(response-cache): extras metadata uses includeExtensionMetadata and sizePerSchema is accessible --- .changeset/rich-kiwis-stand.md | 5 ++++ .changeset/sour-cars-hang.md | 5 ---- packages/plugins/response-cache/README.md | 22 ++++++++++---- .../plugins/response-cache/src/get-scope.ts | 29 +++++++++++++++---- packages/plugins/response-cache/src/plugin.ts | 12 ++++++-- .../test/response-cache.spec.ts | 6 ++-- 6 files changed, 59 insertions(+), 20 deletions(-) create mode 100644 .changeset/rich-kiwis-stand.md delete mode 100644 .changeset/sour-cars-hang.md diff --git a/.changeset/rich-kiwis-stand.md b/.changeset/rich-kiwis-stand.md new file mode 100644 index 0000000000..240e8ba43d --- /dev/null +++ b/.changeset/rich-kiwis-stand.md @@ -0,0 +1,5 @@ +--- +'@envelop/response-cache': minor +--- + +Add `extras` function to `BuildResponseCacheKeyFunction` to get computed scope diff --git a/.changeset/sour-cars-hang.md b/.changeset/sour-cars-hang.md deleted file mode 100644 index b3191c495d..0000000000 --- a/.changeset/sour-cars-hang.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@envelop/response-cache': minor ---- - -Added `getScope` callback in `buildResponseCacheKey` params diff --git a/packages/plugins/response-cache/README.md b/packages/plugins/response-cache/README.md index 8bdc7f3248..2eed254098 100644 --- a/packages/plugins/response-cache/README.md +++ b/packages/plugins/response-cache/README.md @@ -866,7 +866,8 @@ mutation SetNameMutation { #### Get scope of the query -Useful for building a cache key that is shared across all sessions when `PUBLIC`. +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' @@ -874,6 +875,17 @@ 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, @@ -885,16 +897,16 @@ const getEnveloped = envelop({ ttl: 2000, session: request => getSessionId(request), buildResponseCacheKey: ({ - getScope, sessionId, documentString, operationName, - variableValues + variableValues, + extras }) => - // Use `getScope()` to put a unique key for every session when `PUBLIC` hashSHA256( [ - getScope() === 'PUBLIC' ? 'PUBLIC' : sessionId, + // Use it to put a unique key for every session when `PUBLIC` + extras(schema).scope === 'PUBLIC' ? 'PUBLIC' : sessionId, documentString, operationName ?? '', jsonStableStringify(variableValues ?? {}) diff --git a/packages/plugins/response-cache/src/get-scope.ts b/packages/plugins/response-cache/src/get-scope.ts index 1d5af2bd2c..d5c2ba8083 100644 --- a/packages/plugins/response-cache/src/get-scope.ts +++ b/packages/plugins/response-cache/src/get-scope.ts @@ -94,15 +94,20 @@ function getSchemaCoordinatesFromQuery(schema: GraphQLSchema, query: string): Se export type Scope = { scope: NonNullable; - metadata: { privateProperty?: string; hitCache?: boolean }; + metadata?: { privateProperty?: string; hitCache?: boolean }; }; const scopeCachePerSchema = new WeakMap>(); +export type GetScopeFromQueryOptions = { + includeExtensionMetadata?: boolean; + sizePerSchema?: number; +}; + export const getScopeFromQuery = ( schema: GraphQLSchema, query: string, - options?: { sizePerSchema?: number }, + options?: GetScopeFromQueryOptions, ): Scope => { if (!scopeCachePerSchema.has(schema)) { scopeCachePerSchema.set( @@ -116,18 +121,32 @@ export const getScopeFromQuery = ( const cache = scopeCachePerSchema.get(schema); const cachedScope = cache?.get(query); - if (cachedScope) return { ...cachedScope, metadata: { ...cachedScope.metadata, hitCache: true } }; + 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, metadata: { privateProperty: coordinate } }; + return { + scope: 'PRIVATE' as const, + ...(options?.includeExtensionMetadata + ? { metadata: { privateProperty: coordinate } } + : {}), + }; } } - return { scope: 'PUBLIC' as const, metadata: {} }; + return { + scope: 'PUBLIC' as const, + ...(options?.includeExtensionMetadata ? { metadata: {} } : {}), + }; } const scope = getScope(); diff --git a/packages/plugins/response-cache/src/plugin.ts b/packages/plugins/response-cache/src/plugin.ts index e11aee1613..ffe064ffcf 100644 --- a/packages/plugins/response-cache/src/plugin.ts +++ b/packages/plugins/response-cache/src/plugin.ts @@ -36,7 +36,7 @@ import { } from '@graphql-tools/utils'; import { handleMaybePromise, MaybePromise } from '@whatwg-node/promise-helpers'; import type { Cache, CacheEntityRecord } from './cache.js'; -import { getScopeFromQuery, Scope } from './get-scope.js'; +import { getScopeFromQuery, GetScopeFromQueryOptions, Scope } from './get-scope.js'; import { hashSHA256 } from './hash-sha256.js'; import { createInMemoryCache } from './in-memory-cache.js'; @@ -574,8 +574,14 @@ export function useResponseCache = {}> operationName: onExecuteParams.args.operationName, sessionId, context: onExecuteParams.args.contextValue, - extras: (schema: GraphQLSchema) => - getScopeFromQuery(schema, onExecuteParams.args.document.loc.source.body), + extras: ( + schema: GraphQLSchema, + options?: Omit, + ) => + getScopeFromQuery(schema, onExecuteParams.args.document.loc.source.body, { + ...options, + includeExtensionMetadata, + }), }), cacheKey => { const cacheInstance = cacheFactory(onExecuteParams.args.contextValue); diff --git a/packages/plugins/response-cache/test/response-cache.spec.ts b/packages/plugins/response-cache/test/response-cache.spec.ts index 94e4f942ae..5efc81a50b 100644 --- a/packages/plugins/response-cache/test/response-cache.spec.ts +++ b/packages/plugins/response-cache/test/response-cache.spec.ts @@ -3586,6 +3586,7 @@ describe('useResponseCache', () => { [ useResponseCache({ session: () => null, + includeExtensionMetadata: true, buildResponseCacheKey: ({ extras, ...rest }) => { const { scope, metadata } = extras(schema); expect(scope).toEqual('PRIVATE'); @@ -3674,11 +3675,12 @@ describe('useResponseCache', () => { [ useResponseCache({ session: () => null, + includeExtensionMetadata: true, buildResponseCacheKey: ({ extras, ...rest }) => { const { scope, metadata } = extras(schema); expect(scope).toEqual('PRIVATE'); - expect(metadata.privateProperty).toEqual('User.name'); - expect(metadata.hitCache).toEqual(multipleCalls ? true : undefined); + expect(metadata?.privateProperty).toEqual('User.name'); + expect(metadata?.hitCache).toEqual(multipleCalls ? true : undefined); return defaultBuildResponseCacheKey(rest); }, ttl: 200, From 94fa3e051f319ab995341c9538b35d7b0dbf9177 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Wed, 17 Dec 2025 22:12:51 +0100 Subject: [PATCH 4/6] fix schema change issues + refactor for auto-ignore session id --- .../plugins/response-cache/src/get-scope.ts | 156 ----------- packages/plugins/response-cache/src/plugin.ts | 242 ++++++++++++------ .../test/response-cache.spec.ts | 132 +--------- 3 files changed, 172 insertions(+), 358 deletions(-) delete mode 100644 packages/plugins/response-cache/src/get-scope.ts diff --git a/packages/plugins/response-cache/src/get-scope.ts b/packages/plugins/response-cache/src/get-scope.ts deleted file mode 100644 index d5c2ba8083..0000000000 --- a/packages/plugins/response-cache/src/get-scope.ts +++ /dev/null @@ -1,156 +0,0 @@ -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) { - 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, - prefix?: string, -): Set { - let fields = new Set(); - 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 { - const ast = parse(query); - let fields = new Set(); - - // Launch the field visitor - visit(ast, { - // Parse the fields of the root of query - Field: node => { - const record: Record = {}; - 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; - metadata?: { privateProperty?: string; hitCache?: boolean }; -}; - -const scopeCachePerSchema = new WeakMap>(); - -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; -}; diff --git a/packages/plugins/response-cache/src/plugin.ts b/packages/plugins/response-cache/src/plugin.ts index ffe064ffcf..58e9e0f345 100644 --- a/packages/plugins/response-cache/src/plugin.ts +++ b/packages/plugins/response-cache/src/plugin.ts @@ -16,6 +16,7 @@ import { visit, visitWithTypeInfo, } from 'graphql'; +import { LRUCache } from 'lru-cache'; import { ExecutionResult, getDocumentString, @@ -36,7 +37,6 @@ 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'; @@ -54,8 +54,6 @@ export type BuildResponseCacheKeyFunction = (params: { sessionId: Maybe; /** GraphQL Context */ context: ExecutionArgs['contextValue']; - /** Extras of the query (won't be computed if not requested) */ - extras: (schema: GraphQLSchema) => Scope; }) => MaybePromise; export type GetDocumentStringFunction = (executionArgs: ExecutionArgs) => string; @@ -65,6 +63,9 @@ export type ShouldCacheResultFunction = (params: { result: ExecutionResult; }) => boolean; +export type TTLPerSchemaCoordinate = Record; +export type ScopePerSchemaCoordinate = Record; + export type UseResponseCacheParameter = {}> = { cache?: Cache | ((ctx: Record) => Cache); /** @@ -83,8 +84,25 @@ export type UseResponseCacheParameter * In the unusual case where you actually want to cache introspection query operations, * you need to provide the value `{ 'Query.__schema': undefined }`. */ - ttlPerSchemaCoordinate?: Record; - scopePerSchemaCoordinate?: Record; + ttlPerSchemaCoordinate?: TTLPerSchemaCoordinate; + /** + * Define the scope (PUBLIC or PRIVATE) by schema coordinate. + * The default scope for all types and fields is PUBLIC + * + * If an operation contains a PRIVATE type or field, the result will be cached only if a session + * id is found for this request. + * + * Note: To share cache of responses with a PUBLIC scope between all users, enable `ignoreSessionIdForPublicScope` + */ + scopePerSchemaCoordinate?: ScopePerSchemaCoordinate; + /** + * If enabled, a response with a PUBLIC scope will be cached with an operation key ignoring the + * session ID. This allows to improve cache hit further, but scope should be carefully defined + * to avoid any private data. + * + * @default false. + */ + ignoreSessionIdForPublicScope?: boolean; /** * Allows to cache responses based on the resolved session id. * Return a unique value for each session. @@ -304,20 +322,24 @@ export type CacheControlDirective = { scope?: 'PUBLIC' | 'PRIVATE'; }; -let schema: GraphQLSchema; -let ttlPerSchemaCoordinate: Record = {}; -let scopePerSchemaCoordinate: Record = {}; +type SchemaConfig = { + schema: GraphQLSchema | undefined; + idFieldByTypeName: Map; + perSchemaCoordinate: { + type: Map; + scope: ScopePerSchemaCoordinate; + ttl: TTLPerSchemaCoordinate; + }; + publicDocuments: LRUCache; + documentMetadataOptions: Record< + 'queries' | 'mutations', + { ttlPerSchemaCoordinate?: TTLPerSchemaCoordinate; invalidateViaMutation: boolean } + >; + isPrivate(typeName: string, data?: Record): boolean; +}; -export function isPrivate(typeName: string, data?: Record): boolean { - if (scopePerSchemaCoordinate[typeName] === 'PRIVATE') { - return true; - } - return data - ? Object.keys(data).some( - fieldName => scopePerSchemaCoordinate[`${typeName}.${fieldName}`] === 'PRIVATE', - ) - : false; -} +const DOCUMENTS_SCOPE_MAX = 1000; +const DOCUMENTS_SCOPE_TTL = 3600000; export function useResponseCache = {}>({ cache = createInMemoryCache(), @@ -326,10 +348,9 @@ export function useResponseCache = {}> enabled, ignoredTypes = [], ttlPerType, - ttlPerSchemaCoordinate: localTtlPerSchemaCoordinate = {}, - scopePerSchemaCoordinate: localScopePerSchemaCoordinate = {}, idFields = ['id'], invalidateViaMutation = true, + ignoreSessionIdForPublicScope = false, buildResponseCacheKey = defaultBuildResponseCacheKey, getDocumentString = defaultGetDocumentString, shouldCacheResult = defaultShouldCacheResult, @@ -338,37 +359,67 @@ export function useResponseCache = {}> ? // eslint-disable-next-line dot-notation process.env['NODE_ENV'] === 'development' || !!process.env['DEBUG'] : false, + ...options }: UseResponseCacheParameter): Plugin { const cacheFactory = typeof cache === 'function' ? memoize1(cache) : () => cache; const ignoredTypesMap = new Set(ignoredTypes); - const typePerSchemaCoordinateMap = new Map(); enabled = enabled ? memoize1(enabled) : enabled; - // never cache Introspections - ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...localTtlPerSchemaCoordinate }; + const configPerSchemaCoordinate = { + // never cache Introspections + ttl: { 'Query.__schema': 0, ...options.ttlPerSchemaCoordinate } as TTLPerSchemaCoordinate, + scope: { ...options.scopePerSchemaCoordinate } as ScopePerSchemaCoordinate, + }; + if (ttlPerType) { // eslint-disable-next-line no-console console.warn( '[useResponseCache] `ttlForType` is deprecated. To migrate, merge it with `ttlForSchemaCoordinate` option', ); for (const [typeName, ttl] of Object.entries(ttlPerType)) { - ttlPerSchemaCoordinate[typeName] = ttl; + configPerSchemaCoordinate.ttl[typeName] = ttl; } } - const documentMetadataOptions = { - queries: { invalidateViaMutation, ttlPerSchemaCoordinate }, - mutations: { invalidateViaMutation }, // remove ttlPerSchemaCoordinate for mutations to skip TTL calculation + const makeSchemaConfig = function makeSchemaConfig(schema?: GraphQLSchema): SchemaConfig { + const ttl = { ...configPerSchemaCoordinate.ttl }; + const scope = { ...configPerSchemaCoordinate.scope }; + return { + schema, + perSchemaCoordinate: { ttl, scope, type: new Map() }, + idFieldByTypeName: new Map(), + publicDocuments: new LRUCache({ + max: DOCUMENTS_SCOPE_MAX, + ttl: DOCUMENTS_SCOPE_TTL, + }), + documentMetadataOptions: { + // Do not override mutations metadata to keep a stable reference for memoization + mutations: { invalidateViaMutation }, + queries: { invalidateViaMutation, ttlPerSchemaCoordinate: ttl }, + }, + isPrivate(typeName: string, data?: Record): boolean { + if (scope[typeName] === 'PRIVATE') { + return true; + } + return data + ? Object.keys(data).some(fieldName => scope[`${typeName}.${fieldName}`] === 'PRIVATE') + : false; + }, + }; }; - scopePerSchemaCoordinate = { ...localScopePerSchemaCoordinate }; - const idFieldByTypeName = new Map(); + + const schemaConfigs = new WeakMap(); return { - onSchemaChange({ schema: newSchema }) { - if (schema === newSchema) { + onSchemaChange({ schema }) { + if (schemaConfigs.has(schema)) { return; } - schema = newSchema; + + const config = makeSchemaConfig(schema); + schemaConfigs.set(schema, config); + + // Reset all configs, to avoid keeping stale field configuration const directive = schema.getDirective('cacheControl') as unknown as | GraphQLDirective @@ -384,10 +435,10 @@ export function useResponseCache = {}> ) as unknown as CacheControlDirective[] | undefined; cacheControlAnnotations?.forEach(cacheControl => { if (cacheControl.maxAge != null) { - ttlPerSchemaCoordinate[type.name] = cacheControl.maxAge * 1000; + config.perSchemaCoordinate.ttl[type.name] = cacheControl.maxAge * 1000; } if (cacheControl.scope) { - scopePerSchemaCoordinate[type.name] = cacheControl.scope; + config.perSchemaCoordinate.scope[type.name] = cacheControl.scope; } }); return type; @@ -396,10 +447,10 @@ export function useResponseCache = {}> [MapperKind.FIELD]: (fieldConfig, fieldName, typeName) => { const schemaCoordinates = `${typeName}.${fieldName}`; const resultTypeNames = unwrapTypenames(fieldConfig.type); - typePerSchemaCoordinateMap.set(schemaCoordinates, resultTypeNames); + config.perSchemaCoordinate.type.set(schemaCoordinates, resultTypeNames); - if (idFields.includes(fieldName) && !idFieldByTypeName.has(typeName)) { - idFieldByTypeName.set(typeName, fieldName); + if (idFields.includes(fieldName) && !config.idFieldByTypeName.has(typeName)) { + config.idFieldByTypeName.set(typeName, fieldName); } if (directive) { @@ -410,10 +461,10 @@ export function useResponseCache = {}> ) as unknown as CacheControlDirective[] | undefined; cacheControlAnnotations?.forEach(cacheControl => { if (cacheControl.maxAge != null) { - ttlPerSchemaCoordinate[schemaCoordinates] = cacheControl.maxAge * 1000; + config.perSchemaCoordinate.ttl[schemaCoordinates] = cacheControl.maxAge * 1000; } if (cacheControl.scope) { - scopePerSchemaCoordinate[schemaCoordinates] = cacheControl.scope; + config.perSchemaCoordinate.scope[schemaCoordinates] = cacheControl.scope; } }); } @@ -425,12 +476,29 @@ export function useResponseCache = {}> if (enabled && !enabled(onExecuteParams.args.contextValue)) { return; } + + const { schema } = onExecuteParams.args; + if (!schemaConfigs.has(schema)) { + // eslint-disable-next-line no-console + console.error('[response-cache] Unknown schema, operation ignored'); + return; + } + const config = schemaConfigs.get(schema)!; + const identifier = new Map(); const types = new Set(); let currentTtl: number | undefined; + let isPrivate = false; let skip = false; - const sessionId = session(onExecuteParams.args.contextValue); + const documentString = getDocumentString(onExecuteParams.args); + // Verify if we already know this document is public or not. If it is public, we should not + // take the session ID into account. If not, we keep the default behavior of letting user + // decide if a session id should be used to build the key + const sessionId = + ignoreSessionIdForPublicScope && config.publicDocuments.get(documentString) + ? undefined + : session(onExecuteParams.args.contextValue); function setExecutor({ execute, @@ -462,25 +530,23 @@ export function useResponseCache = {}> return; } - if ( - ignoredTypesMap.has(entity.typename) || - (!sessionId && isPrivate(entity.typename, data)) - ) { + isPrivate ||= config.isPrivate(entity.typename, data); + if (ignoredTypesMap.has(entity.typename) || (!sessionId && isPrivate)) { skip = true; return; } // in case the entity has no id, we attempt to extract it from the data if (!entity.id) { - const idField = idFieldByTypeName.get(entity.typename); + const idField = config.idFieldByTypeName.get(entity.typename); if (idField) { entity.id = data[idField] as string | number | undefined; } } types.add(entity.typename); - if (entity.typename in ttlPerSchemaCoordinate) { - const maybeTtl = ttlPerSchemaCoordinate[entity.typename] as unknown; + if (entity.typename in config.perSchemaCoordinate.ttl) { + const maybeTtl = config.perSchemaCoordinate.ttl[entity.typename] as unknown; currentTtl = calculateTtl(maybeTtl, currentTtl); } if (entity.id != null) { @@ -489,10 +555,12 @@ export function useResponseCache = {}> for (const fieldName in data) { const fieldData = data[fieldName]; if (fieldData == null || (Array.isArray(fieldData) && fieldData.length === 0)) { - const inferredTypes = typePerSchemaCoordinateMap.get(`${entity.typename}.${fieldName}`); + const inferredTypes = config.perSchemaCoordinate.type.get( + `${entity.typename}.${fieldName}`, + ); inferredTypes?.forEach(inferredType => { - if (inferredType in ttlPerSchemaCoordinate) { - const maybeTtl = ttlPerSchemaCoordinate[inferredType] as unknown; + if (inferredType in config.perSchemaCoordinate.ttl) { + const maybeTtl = config.perSchemaCoordinate.ttl[inferredType] as unknown; currentTtl = calculateTtl(maybeTtl, currentTtl); } identifier.set(inferredType, { typename: inferredType }); @@ -549,9 +617,9 @@ export function useResponseCache = {}> execute(args) { const [document] = getDocumentWithMetadataAndTTL( args.document, - documentMetadataOptions.mutations, + config.documentMetadataOptions.mutations, args.schema, - idFieldByTypeName, + config.idFieldByTypeName, ); return onExecuteParams.executeFn({ ...args, document }); }, @@ -569,19 +637,11 @@ export function useResponseCache = {}> return handleMaybePromise( () => buildResponseCacheKey({ - documentString: getDocumentString(onExecuteParams.args), + sessionId, + documentString, variableValues: onExecuteParams.args.variableValues, operationName: onExecuteParams.args.operationName, - sessionId, context: onExecuteParams.args.contextValue, - extras: ( - schema: GraphQLSchema, - options?: Omit, - ) => - getScopeFromQuery(schema, onExecuteParams.args.document.loc.source.body, { - ...options, - includeExtensionMetadata, - }), }), cacheKey => { const cacheInstance = cacheFactory(onExecuteParams.args.contextValue); @@ -590,28 +650,34 @@ export function useResponseCache = {}> console.warn( '[useResponseCache] Cache instance is not available for the context. Skipping cache lookup.', ); + return; } - return handleMaybePromise( - () => cacheInstance.get(cacheKey), - cachedResponse => { - if (cachedResponse != null) { - return setExecutor({ - execute: () => - includeExtensionMetadata - ? resultWithMetadata(cachedResponse, { hit: true }) - : cachedResponse, - }); - } + function maybeCacheResult( + result: ExecutionResult, + setResult: (newResult: ExecutionResult) => void, + ) { + if (result.data) { + result.data = removeMetadataFieldsFromResult(result.data, onEntity); + } - function maybeCacheResult( - result: ExecutionResult, - setResult: (newResult: ExecutionResult) => void, - ) { - if (result.data) { - result.data = removeMetadataFieldsFromResult(result.data, onEntity); + return handleMaybePromise( + () => { + if (!skip && ignoreSessionIdForPublicScope && !isPrivate && sessionId) { + config.publicDocuments.set(documentString, true); + return buildResponseCacheKey({ + // Build a public key for this document + sessionId: undefined, + documentString, + variableValues: onExecuteParams.args.variableValues, + operationName: onExecuteParams.args.operationName, + context: onExecuteParams.args.contextValue, + }); } + return cacheKey; + }, + cacheKey => { // we only use the global ttl if no currentTtl has been determined. let finalTtl = currentTtl ?? globalTtl; if (onTtl) { @@ -634,15 +700,29 @@ export function useResponseCache = {}> resultWithMetadata(result, { hit: false, didCache: true, ttl: finalTtl }), ); } + }, + ); + } + + return handleMaybePromise( + () => cacheInstance.get(cacheKey), + cachedResponse => { + if (cachedResponse != null) { + return setExecutor({ + execute: () => + includeExtensionMetadata + ? resultWithMetadata(cachedResponse, { hit: true }) + : cachedResponse, + }); } return setExecutor({ execute(args) { const [document, ttl] = getDocumentWithMetadataAndTTL( args.document, - documentMetadataOptions.queries, + config.documentMetadataOptions.queries, schema, - idFieldByTypeName, + config.idFieldByTypeName, ); currentTtl = ttl; return onExecuteParams.executeFn({ ...args, document }); diff --git a/packages/plugins/response-cache/test/response-cache.spec.ts b/packages/plugins/response-cache/test/response-cache.spec.ts index 5efc81a50b..9d3a4bf894 100644 --- a/packages/plugins/response-cache/test/response-cache.spec.ts +++ b/packages/plugins/response-cache/test/response-cache.spec.ts @@ -3524,101 +3524,7 @@ describe('useResponseCache', () => { expect(spy).toHaveBeenCalledTimes(2); }); - ['query', 'field', 'subfield'].forEach(type => { - it(`should return PRIVATE scope in buildResponseCacheKey when putting @cacheControl scope on ${type}`, async () => { - jest.useFakeTimers(); - const spy = jest.fn(() => [ - { - id: 1, - name: 'User 1', - comments: [ - { - id: 1, - text: 'Comment 1 of User 1', - }, - ], - }, - { - id: 2, - name: 'User 2', - comments: [ - { - id: 2, - text: 'Comment 2 of User 2', - }, - ], - }, - ]); - - const schema = makeExecutableSchema({ - typeDefs: /* GraphQL */ ` - ${cacheControlDirective} - type Query { - users: [User!]! ${type === 'query' ? '@cacheControl(scope: PRIVATE)' : ''} - } - - type User ${type === 'field' ? '@cacheControl(scope: PRIVATE)' : ''} { - id: ID! - name: String! ${type === 'subfield' ? '@cacheControl(scope: PRIVATE)' : ''} - comments: [Comment!]! - recentComment: Comment - } - - type Comment { - id: ID! - text: String! - } - `, - resolvers: { - Query: { - users: spy, - }, - }, - }); - - function getPrivateProperty() { - if (type === 'query') return 'Query.users'; - if (type === 'field') return 'User'; - return 'User.name'; - } - - const testInstance = createTestkit( - [ - useResponseCache({ - session: () => null, - includeExtensionMetadata: true, - buildResponseCacheKey: ({ extras, ...rest }) => { - const { scope, metadata } = extras(schema); - expect(scope).toEqual('PRIVATE'); - expect(metadata?.privateProperty).toEqual(getPrivateProperty()); - return defaultBuildResponseCacheKey(rest); - }, - ttl: 200, - }), - ], - schema, - ); - - const query = /* GraphQL */ ` - query test { - users { - id - name - comments { - id - text - } - } - } - `; - - await testInstance.execute(query); - - expect(spy).toHaveBeenCalledTimes(1); - }); - }); - - it('should return PRIVATE scope in buildResponseCacheKey even when requesting property from a fragment', async () => { + it.only('should ignore session id for responses with public key', async () => { jest.useFakeTimers(); const spy = jest.fn(() => [ { @@ -3652,7 +3558,7 @@ describe('useResponseCache', () => { type User { id: ID! - name: String! @cacheControl(scope: PRIVATE) + name: String! comments: [Comment!]! recentComment: Comment } @@ -3669,21 +3575,12 @@ describe('useResponseCache', () => { }, }); - let multipleCalls = false; - + let i = 0; const testInstance = createTestkit( [ useResponseCache({ - session: () => null, - includeExtensionMetadata: true, - buildResponseCacheKey: ({ extras, ...rest }) => { - const { scope, metadata } = extras(schema); - expect(scope).toEqual('PRIVATE'); - expect(metadata?.privateProperty).toEqual('User.name'); - expect(metadata?.hitCache).toEqual(multipleCalls ? true : undefined); - return defaultBuildResponseCacheKey(rest); - }, - ttl: 200, + ignoreSessionIdForPublicScope: true, + session: () => 'session id' + i++, }), ], schema, @@ -3692,26 +3589,19 @@ describe('useResponseCache', () => { const query = /* GraphQL */ ` query test { users { - ...user - } - } - - fragment user on User { - id - name - comments { id - text + name + comments { + id + text + } } } `; await testInstance.execute(query); - expect(spy).toHaveBeenCalledTimes(1); - - multipleCalls = true; await testInstance.execute(query); - expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledTimes(1); }); it('should cache correctly for session with ttl being a valid number', async () => { From de569f3d7fe45e330eca177b69ee1c89fd5dc498 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Wed, 17 Dec 2025 22:26:33 +0100 Subject: [PATCH 5/6] remove export --- packages/plugins/response-cache/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/plugins/response-cache/src/index.ts b/packages/plugins/response-cache/src/index.ts index 69663dd532..2d6d23a362 100644 --- a/packages/plugins/response-cache/src/index.ts +++ b/packages/plugins/response-cache/src/index.ts @@ -2,4 +2,3 @@ export * from './in-memory-cache.js'; export * from './plugin.js'; export * from './cache.js'; export * from './hash-sha256.js'; -export * from './get-scope.js'; From dbbaed96c3f02c68f29ff432a033e4a12fcbce24 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Wed, 17 Dec 2025 22:41:36 +0100 Subject: [PATCH 6/6] fix --- packages/plugins/response-cache/src/plugin.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/plugins/response-cache/src/plugin.ts b/packages/plugins/response-cache/src/plugin.ts index 58e9e0f345..5060341330 100644 --- a/packages/plugins/response-cache/src/plugin.ts +++ b/packages/plugins/response-cache/src/plugin.ts @@ -416,11 +416,10 @@ export function useResponseCache = {}> return; } + // Reset all configs, to avoid keeping stale field configuration const config = makeSchemaConfig(schema); schemaConfigs.set(schema, config); - // Reset all configs, to avoid keeping stale field configuration - const directive = schema.getDirective('cacheControl') as unknown as | GraphQLDirective | undefined;