Skip to content

Commit 430ee7d

Browse files
ardatanEmrysMyrddingithub-actions[bot]
authored
enhance(response-cache/cfw-kv): simplify cache init (#2238)
Co-authored-by: Valentin Cocaud <v.cocaud@gmail.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 130aeb0 commit 430ee7d

File tree

12 files changed

+270
-212
lines changed

12 files changed

+270
-212
lines changed
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+
Accept a factory function to `cache` that takes the context and returns the cache implementation

.changeset/young-bears-tease.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@envelop/response-cache-cloudflare-kv': minor
3+
---
4+
5+
BREAKING: Now the cache implementation does not require the `ExecutionContext` or `KVNamespace`
6+
instance but only the name of the namespace
7+
8+
```ts
9+
import { createSchema, createYoga, YogaInitialContext } from 'graphql-yoga'
10+
import { useResponseCache } from '@envelop/response-cache'
11+
import { createKvCache } from '@envelop/response-cache-cloudflare-kv'
12+
import { resolvers } from './graphql-schema/resolvers.generated'
13+
import { typeDefs } from './graphql-schema/typeDefs.generated'
14+
15+
export type Env = {
16+
GRAPHQL_RESPONSE_CACHE: KVNamespace
17+
}
18+
19+
const graphqlServer = createYoga<Env & ExecutionContext>({
20+
schema: createSchema({ typeDefs, resolvers }),
21+
plugins: [
22+
useResponseCache({
23+
cache: createKvCache({
24+
KVName: 'GRAPHQL_RESPONSE_CACHE',
25+
keyPrefix: 'graphql' // optional
26+
}),
27+
session: () => null,
28+
includeExtensionMetadata: true,
29+
ttl: 1000 * 10 // 10 seconds
30+
})
31+
]
32+
})
33+
34+
export default {
35+
fetch: graphqlServer
36+
}
37+
```

packages/plugins/response-cache-cloudflare-kv/README.md

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -33,29 +33,23 @@ import { typeDefs } from './graphql-schema/typeDefs.generated'
3333
export type Env = {
3434
GRAPHQL_RESPONSE_CACHE: KVNamespace
3535
}
36-
export type GraphQLContext = YogaInitialContext & Env & ExecutionContext
3736

38-
export default {
39-
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
40-
const kvCache = createKvCache({
41-
KV: env.GRAPHQL_RESPONSE_CACHE,
42-
ctx,
43-
keyPrefix: 'graphql' // optional
44-
})
45-
46-
const graphqlServer = createYoga<GraphQLContext>({
47-
schema: createSchema({ typeDefs, resolvers }),
48-
plugins: [
49-
useResponseCache({
50-
cache: kvCache,
51-
session: () => null,
52-
includeExtensionMetadata: true,
53-
ttl: 1000 * 10 // 10 seconds
54-
})
55-
]
37+
const graphqlServer = createYoga<Env & ExecutionContext>({
38+
schema: createSchema({ typeDefs, resolvers }),
39+
plugins: [
40+
useResponseCache({
41+
cache: createKvCache({
42+
KVName: 'GRAPHQL_RESPONSE_CACHE',
43+
keyPrefix: 'graphql' // optional
44+
}),
45+
session: () => null,
46+
includeExtensionMetadata: true,
47+
ttl: 1000 * 10 // 10 seconds
5648
})
49+
]
50+
})
5751

58-
return graphqlServer.fetch(request, env, ctx)
59-
}
52+
export default {
53+
fetch: graphqlServer
6054
}
6155
```
Lines changed: 76 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
11
import type { ExecutionResult } from 'graphql';
2-
import type { ExecutionContext, KVNamespace } from '@cloudflare/workers-types';
2+
import type { KVNamespace } from '@cloudflare/workers-types';
33
import type { Cache, CacheEntityRecord } from '@envelop/response-cache';
44
import { buildOperationKey } from './cache-key.js';
55
import { invalidate } from './invalidate.js';
66
import { set } from './set.js';
77

8-
export type KvCacheConfig = {
8+
export type KvCacheConfig<TKVNamespaceName extends string> = {
99
/**
10-
* The Cloudflare KV namespace that should be used to store the cache
10+
* The name of the Cloudflare KV namespace that should be used to store the cache
1111
*/
12-
KV: KVNamespace;
13-
/**
14-
* The Cloudflare worker execution context. Used to perform non-blocking actions like cache storage and invalidation.
15-
*/
16-
ctx: ExecutionContext;
12+
KVName: TKVNamespaceName;
1713
/**
1814
* Defines the length of time in milliseconds that a KV result is cached in the global network location it is accessed from.
1915
*
@@ -35,7 +31,14 @@ export type KvCacheConfig = {
3531
* @param config Modify the behavior of the cache as it pertains to Cloudflare KV
3632
* @returns A cache object that can be passed to envelop's `useResponseCache` plugin
3733
*/
38-
export function createKvCache(config: KvCacheConfig): Cache {
34+
export function createKvCache<
35+
TKVNamespaceName extends string,
36+
TServerContext extends {
37+
[TKey in TKVNamespaceName]: KVNamespace;
38+
} & {
39+
waitUntil(promise: Promise<unknown>): void;
40+
},
41+
>(config: KvCacheConfig<TKVNamespaceName>) {
3942
if (config.cacheReadTTL && config.cacheReadTTL < 60000) {
4043
// eslint-disable-next-line no-console
4144
console.warn(
@@ -44,36 +47,71 @@ export function createKvCache(config: KvCacheConfig): Cache {
4447
}
4548
const computedTtlInSeconds = Math.max(Math.floor((config.cacheReadTTL ?? 60000) / 1000), 60); // KV TTL must be at least 60 seconds
4649

47-
const cache: Cache = {
48-
async get(id: string) {
49-
const kvResponse = await config.KV.get(buildOperationKey(id, config.keyPrefix), {
50-
type: 'text',
51-
cacheTtl: computedTtlInSeconds,
52-
});
53-
if (kvResponse) {
54-
return JSON.parse(kvResponse) as ExecutionResult;
55-
}
56-
return undefined;
57-
},
50+
return function KVCacheFactory(ctx: TServerContext): Cache {
51+
return {
52+
get(id: string) {
53+
if (!ctx[config.KVName]) {
54+
// eslint-disable-next-line no-console
55+
console.warn(
56+
`Cloudflare KV namespace ${config.KVName} is not available in the server context, skipping cache read.`,
57+
);
58+
return;
59+
}
60+
const operationKey = buildOperationKey(id, config.keyPrefix);
61+
return ctx[config.KVName].get(operationKey, {
62+
type: 'json',
63+
cacheTtl: computedTtlInSeconds,
64+
});
65+
},
5866

59-
set(
60-
/** id/hash of the operation */
61-
id: string,
62-
/** the result that should be cached */
63-
data: ExecutionResult,
64-
/** array of entity records that were collected during execution */
65-
entities: Iterable<CacheEntityRecord>,
66-
/** how long the operation should be cached (in milliseconds) */
67-
ttl: number,
68-
) {
69-
// Do not block execution of the worker while caching the result
70-
config.ctx.waitUntil(set(id, data, entities, ttl, config));
71-
},
67+
set(
68+
/** id/hash of the operation */
69+
id: string,
70+
/** the result that should be cached */
71+
data: ExecutionResult,
72+
/** array of entity records that were collected during execution */
73+
entities: Iterable<CacheEntityRecord>,
74+
/** how long the operation should be cached (in milliseconds) */
75+
ttl: number,
76+
): void | Promise<void> {
77+
if (!ctx[config.KVName]) {
78+
// eslint-disable-next-line no-console
79+
console.warn(
80+
`Cloudflare KV namespace ${config.KVName} is not available in the server context, skipping cache write.`,
81+
);
82+
return;
83+
}
84+
const setPromise = set(id, data, entities, ttl, ctx[config.KVName], config.keyPrefix);
85+
if (!ctx.waitUntil) {
86+
// eslint-disable-next-line no-console
87+
console.warn(
88+
'The server context does not have a waitUntil method. This means that the cache write will not be non-blocking.',
89+
);
90+
return setPromise;
91+
}
92+
// Do not block execution of the worker while caching the result
93+
ctx.waitUntil(setPromise);
94+
},
7295

73-
invalidate(entities: Iterable<CacheEntityRecord>) {
74-
// Do not block execution of the worker while invalidating the cache
75-
config.ctx.waitUntil(invalidate(entities, config));
76-
},
96+
invalidate(entities: Iterable<CacheEntityRecord>): void | Promise<void> {
97+
if (!ctx[config.KVName]) {
98+
// eslint-disable-next-line no-console
99+
console.warn(
100+
`Cloudflare KV namespace ${config.KVName} is not available in the server context, skipping cache invalidate.`,
101+
);
102+
return;
103+
}
104+
const invalidatePromise = invalidate(entities, ctx[config.KVName], config.keyPrefix);
105+
if (!ctx.waitUntil) {
106+
// eslint-disable-next-line no-console
107+
console.warn(
108+
'The server context does not have a waitUntil method. This means that the cache invalidation will not be non-blocking.',
109+
);
110+
return invalidatePromise;
111+
}
112+
// Do not block execution of the worker while invalidating the cache
113+
ctx.waitUntil(invalidatePromise);
114+
},
115+
};
77116
};
78-
return cache;
79117
}

packages/plugins/response-cache-cloudflare-kv/src/invalidate.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1+
import { KVNamespace } from '@cloudflare/workers-types';
12
import type { CacheEntityRecord } from '@envelop/response-cache';
23
import { buildEntityKey } from './cache-key.js';
3-
import type { KvCacheConfig } from './index.js';
44

55
export async function invalidate(
66
entities: Iterable<CacheEntityRecord>,
7-
config: KvCacheConfig,
7+
KV: KVNamespace,
8+
keyPrefix?: string,
89
): Promise<void> {
910
const kvPromises: Promise<unknown>[] = []; // Collecting all the KV operations so we can await them all at once
1011
const entityInvalidationPromises: Promise<unknown>[] = []; // Parallelize invalidation of each entity
1112

1213
for (const entity of entities) {
13-
entityInvalidationPromises.push(invalidateCacheEntityRecord(entity, kvPromises, config));
14+
entityInvalidationPromises.push(invalidateCacheEntityRecord(entity, kvPromises, KV, keyPrefix));
1415
}
1516
await Promise.allSettled(entityInvalidationPromises);
1617
await Promise.allSettled(kvPromises);
@@ -20,24 +21,25 @@ export async function invalidateCacheEntityRecord(
2021
entity: CacheEntityRecord,
2122
/** Collect all inner promises to batch await all async operations outside the function */
2223
kvPromiseCollection: Promise<unknown>[],
23-
config: KvCacheConfig,
24+
KV: KVNamespace,
25+
keyPrefix?: string,
2426
) {
25-
const entityKey = buildEntityKey(entity.typename, entity.id, config.keyPrefix);
27+
const entityKey = buildEntityKey(entity.typename, entity.id, keyPrefix);
2628

27-
for await (const kvKey of getAllKvKeysForPrefix(entityKey, config)) {
29+
for await (const kvKey of getAllKvKeysForPrefix(entityKey, KV)) {
2830
if (kvKey.metadata?.operationKey) {
29-
kvPromiseCollection.push(config.KV.delete(kvKey.metadata?.operationKey));
30-
kvPromiseCollection.push(config.KV.delete(kvKey.name));
31+
kvPromiseCollection.push(KV.delete(kvKey.metadata?.operationKey));
32+
kvPromiseCollection.push(KV.delete(kvKey.name));
3133
}
3234
}
3335
}
3436

35-
export async function* getAllKvKeysForPrefix(prefix: string, config: KvCacheConfig) {
37+
export async function* getAllKvKeysForPrefix(prefix: string, KV: KVNamespace) {
3638
let keyListComplete = false;
3739
let cursor: string | undefined;
3840

3941
do {
40-
const kvListResponse = await config.KV.list<{ operationKey: string }>({
42+
const kvListResponse = await KV.list<{ operationKey: string }>({
4143
prefix,
4244
cursor,
4345
});

packages/plugins/response-cache-cloudflare-kv/src/set.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ExecutionResult } from 'graphql';
2+
import { KVNamespace } from '@cloudflare/workers-types';
23
import type { CacheEntityRecord } from '@envelop/response-cache';
34
import { buildEntityKey, buildOperationKey } from './cache-key.js';
4-
import type { KvCacheConfig } from './index.js';
55

66
export async function set(
77
/** id/hash of the operation */
@@ -12,15 +12,16 @@ export async function set(
1212
entities: Iterable<CacheEntityRecord>,
1313
/** how long the operation should be cached (in milliseconds) */
1414
ttl: number,
15-
config: KvCacheConfig,
15+
KV: KVNamespace,
16+
keyPrefix?: string,
1617
): Promise<void> {
1718
const ttlInSeconds = Math.max(Math.floor(ttl / 1000), 60); // KV TTL must be at least 60 seconds
18-
const operationKey = buildOperationKey(id, config.keyPrefix);
19+
const operationKey = buildOperationKey(id, keyPrefix);
1920
const operationKeyWithoutPrefix = buildOperationKey(id);
2021
const kvPromises: Promise<unknown>[] = []; // Collecting all the KV operations so we can await them all at once
2122

2223
kvPromises.push(
23-
config.KV.put(operationKey, JSON.stringify(data), {
24+
KV.put(operationKey, JSON.stringify(data), {
2425
expirationTtl: ttlInSeconds,
2526
metadata: { operationKey },
2627
}),
@@ -29,9 +30,9 @@ export async function set(
2930
// Store connections between the entities and the operation key
3031
// E.g if the entities are User:1 and User:2, we need to know that the operation key is connected to both of them
3132
for (const entity of entities) {
32-
const entityKey = buildEntityKey(entity.typename, entity.id, config.keyPrefix);
33+
const entityKey = buildEntityKey(entity.typename, entity.id, keyPrefix);
3334
kvPromises.push(
34-
config.KV.put(`${entityKey}:${operationKeyWithoutPrefix}`, operationKey, {
35+
KV.put(`${entityKey}:${operationKeyWithoutPrefix}`, operationKey, {
3536
expirationTtl: ttlInSeconds,
3637
metadata: { operationKey },
3738
}),

packages/plugins/response-cache-cloudflare-kv/test/index.spec.ts

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,33 @@ type Env = {
1010
};
1111

1212
describe('@envelop/response-cache-cloudflare-kv integration tests', () => {
13-
let env: Env;
14-
let config: KvCacheConfig;
1513
let maxTtl: number;
16-
let executionContext: ExecutionContext;
1714
let cache: Cache;
1815
const dataValue: ExecutionResult<{ key: string }, { extensions: string }> = {
1916
errors: [],
2017
data: { key: 'value' },
2118
extensions: { extensions: 'value' },
2219
};
2320
const dataKey = '1B9502F92EFA53AFF0AC650794AA79891E4B6900';
21+
let KV: KVNamespace;
22+
let executionContext: ExecutionContext;
23+
const keyPrefix = 'vitest';
24+
const KVName = 'GRAPHQL_RESPONSE_CACHE';
2425

2526
beforeEach(() => {
2627
// @ts-expect-error - Unable to get jest-environment-miniflare/globals working the test/build setup
27-
env = getMiniflareBindings<Env>();
28+
const env = getMiniflareBindings<Env>();
2829
// @ts-expect-error - Unable to get jest-environment-miniflare/globals working the test/build setup
2930
executionContext = new ExecutionContext();
30-
config = {
31-
KV: env.GRAPHQL_RESPONSE_CACHE,
32-
ctx: executionContext,
33-
keyPrefix: 'vitest',
34-
};
31+
KV = env[KVName];
3532
maxTtl = 60 * 1000; // 1 minute
36-
cache = createKvCache(config);
33+
cache = createKvCache({
34+
KVName,
35+
keyPrefix,
36+
})({
37+
GRAPHQL_RESPONSE_CACHE: KV,
38+
waitUntil: executionContext.waitUntil.bind(executionContext),
39+
});
3740
});
3841

3942
test('should work with a basic set() and get()', async () => {
@@ -49,15 +52,15 @@ describe('@envelop/response-cache-cloudflare-kv integration tests', () => {
4952
const result = await cache.get(dataKey);
5053
expect(result).toEqual(dataValue);
5154

52-
const operationKey = buildOperationKey(dataKey, config.keyPrefix);
53-
const operationValue = await env.GRAPHQL_RESPONSE_CACHE.get(operationKey, 'text');
55+
const operationKey = buildOperationKey(dataKey, keyPrefix);
56+
const operationValue = await KV.get(operationKey, 'text');
5457
expect(operationValue).toBeTruthy();
5558
expect(JSON.parse(operationValue!)).toEqual(dataValue);
5659
});
5760

5861
test('should return null when calling get() on a non-existent key', async () => {
5962
const result = await cache.get(dataKey);
60-
expect(result).toBeUndefined();
63+
expect(result).toBeFalsy();
6164
});
6265

6366
test('should return null when calling get() on an invalidated key', async () => {
@@ -75,9 +78,9 @@ describe('@envelop/response-cache-cloudflare-kv integration tests', () => {
7578
await getMiniflareWaitUntil(executionContext);
7679

7780
const result = await cache.get(dataKey);
78-
expect(result).toBeUndefined();
81+
expect(result).toBeFalsy();
7982

80-
const allKeys = await config.KV.list();
83+
const allKeys = await KV.list();
8184
expect(allKeys.keys.length).toEqual(0);
8285
});
8386
});

0 commit comments

Comments
 (0)