Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(response-cache): add response-cache-upstash #1404

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
96 changes: 96 additions & 0 deletions packages/plugins/response-cache-upstash/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
## `@envelop/response-cache-upstash`

- Supports redis cache for `@envelop/response-cache` plugin
- Optimized for serveless deployments where stateless HTTP connections are preferred over TCP.

[Check out the GraphQL Response Cache Guide for more information](https://envelop.dev/docs/guides/adding-a-graphql-response-cache)

## Getting Started

```bash
yarn add @envelop/response-cache
yarn add @envelop/response-cache-upstash
```

If you are using a raw nodejs environment prior v18, you need to add a fetch polyfill like `isomorphic-fetch`

```bash
yarn add isomorphic-fetch
```

And import it

```ts
import 'isomorphic-fetch';
```

Platforms like Vercel, Cloudflare and Fastly already provide this and you don't need to do anything.

## Usage Example

This plugin uses Upstash serverless Redis, so you do not have to manage any redis instance yourself and the pricing scales to zero.

- Create a Redis database over at [Upstash](https://console.upstash.com/)
- Copy the `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` from the bottom of the page.
- Create an instance of Upstash Redis
- Create an instance of the Redis Cache and set to the `useResponseCache` plugin options

```ts
import { envelop } from '@envelop/core';
import { useResponseCache } from '@envelop/response-cache';
import { createUpstashCache } from '@envelop/response-cache-upstash";

import { Redis } from '@upstash/redis';

const redis = new Redis({
url: 'UPSTASH_REDIS_REST_URL',
token: 'UPSTASH_REDIS_REST_TOKEN',
});

// or you can set the url and token as environment variable and create redis like this:
const redis = Redis.fromEnv();

const cache = createUpstashCache({ redis });

const getEnveloped = envelop({
plugins: [
// ... other plugins ...
useResponseCache({ cache }),
],
});
```

### Invalidate Cache based on custom logic

```ts
import { envelop } from '@envelop/core';
import { useResponseCache } from '@envelop/response-cache';
import { createUpstashCache } from '@envelop/response-cache-upstash';

import { emitter } from './eventEmitter';

// we create our cache instance, which allows calling all methods on it
const redis = Redis.fromEnv();

const cache = createUpstashCache({ redis });

const getEnveloped = envelop({
plugins: [
// ... other plugins ...
useResponseCache({
ttl: 2000,
// we pass the cache instance to the request.
cache,
}),
],
});

emitter.on('invalidate', resource => {
cache.invalidate([
{
typename: resource.type,
id: resource.id,
},
]);
});
```
49 changes: 49 additions & 0 deletions packages/plugins/response-cache-upstash/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "@envelop/response-cache-upstash",
"version": "0.4.2",
"author": "Andreas Thomas <dev@chronark.com>",
"license": "MIT",
"sideEffects": false,
"repository": {
"type": "git",
"url": "https://github.com/n1ru4l/envelop.git",
"directory": "packages/plugins/response-cache-upstash"
},
"main": "dist/index.js",
"module": "dist/index.mjs",
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
"./*": {
"require": "./dist/*.js",
"import": "./dist/*.mjs"
}
},
"typings": "dist/index.d.ts",
"typescript": {
"definition": "dist/index.d.ts"
},
"scripts": {
"test": "jest",
"prepack": "bob prepack"
},
"devDependencies": {
"bob-the-bundler": "1.6.1",
"isomorphic-fetch": "^3.0.0",
"typescript": "4.4.4"
},
"dependencies": {
"@envelop/response-cache": "^2.3.2",
"@upstash/redis": "^1.18.0"
},
"peerDependencies": {},
"buildOptions": {
"input": "./src/index.ts"
},
"publishConfig": {
"directory": "dist",
"access": "public"
}
}
1 change: 1 addition & 0 deletions packages/plugins/response-cache-upstash/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './upstash-cache';
117 changes: 117 additions & 0 deletions packages/plugins/response-cache-upstash/src/upstash-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Redis } from '@upstash/redis';
import type { Cache } from '@envelop/response-cache';
import 'isomorphic-fetch';

export type BuildRedisEntityId = (typename: string, id: number | string) => string;
export type BuildRedisOperationResultCacheKey = (responseId: string) => string;

export type RedisCacheParameter = {
/**
* Redis instance
* @see Redis https://github.com/upstash/upstash-redis
*/
redis: Redis;
/**
* Customize how the cache entity id is built.
* By default the typename is concatenated with the id e.g. `User:1`
*/
buildRedisEntityId?: BuildRedisEntityId;
/**
* Customize how the cache key that stores the operations associated with the response is built.
* By default `operations` is concatenated with the responseId e.g. `operations:arZm3tCKgGmpu+a5slrpSH9vjSQ=`
*/
buildRedisOperationResultCacheKey?: BuildRedisOperationResultCacheKey;
};

export const createUpstashCache = (params: RedisCacheParameter): Cache => {
const store = params.redis;

const buildRedisEntityId = params?.buildRedisEntityId ?? defaultBuildRedisEntityId;
const buildRedisOperationResultCacheKey =
params?.buildRedisOperationResultCacheKey ?? defaultBuildRedisOperationResultCacheKey;

async function buildEntityInvalidationsKeys(entity: string): Promise<string[]> {
const keysToInvalidate: string[] = [entity];

// find the responseIds for the entity
const responseIds = await store.smembers(entity);
// and add each response to be invalidated since they contained the entity data
responseIds.forEach(responseId => {
keysToInvalidate.push(responseId);
keysToInvalidate.push(buildRedisOperationResultCacheKey(responseId));
});

// if invalidating an entity like Comment, then also invalidate Comment:1, Comment:2, etc
if (!entity.includes(':')) {
const entityKeys = await store.keys(`${entity}:*`);
for (const entityKey of entityKeys) {
// and invalidate any responses in each of those entity keys
const entityResponseIds = await store.smembers(entityKey);
// if invalidating an entity check for associated operations containing that entity
// and invalidate each response since they contained the entity data
entityResponseIds.forEach(responseId => {
keysToInvalidate.push(responseId);
keysToInvalidate.push(buildRedisOperationResultCacheKey(responseId));
});

// then the entityKeys like Comment:1, Comment:2 etc to be invalidated
keysToInvalidate.push(entityKey);
}
}

return keysToInvalidate;
}

return {
async set(responseId, result, collectedEntities, ttl) {
const tx = store.multi();

if (ttl === Infinity) {
tx.set(responseId, result);
} else {
// set the ttl in milliseconds
tx.set(responseId, result, { px: ttl });
}

const responseKey = buildRedisOperationResultCacheKey(responseId);

for (const { typename, id } of collectedEntities) {
// Adds a key for the typename => response
tx.sadd(typename, responseId);
// Adds a key for the operation => typename
tx.sadd(responseKey, typename);

if (id) {
const entityId = buildRedisEntityId(typename, id);
// Adds a key for the typename:id => response
tx.sadd(entityId, responseId);
// Adds a key for the operation => typename:id
tx.sadd(responseKey, entityId);
}
}

await tx.exec();
},
async get(responseId) {
return store.get(responseId);
},
async invalidate(entitiesToRemove) {
const invalidationKeys: string[][] = [];

for (const { typename, id } of entitiesToRemove) {
invalidationKeys.push(
await buildEntityInvalidationsKeys(id != null ? buildRedisEntityId(typename, id) : typename)
);
}

const keys = invalidationKeys.flat();
if (keys.length > 0) {
await store.del(...keys);
}
},
};
};

export const defaultBuildRedisEntityId: BuildRedisEntityId = (typename, id) => `${typename}:${id}`;
export const defaultBuildRedisOperationResultCacheKey: BuildRedisOperationResultCacheKey = responseId =>
`operations:${responseId}`;
Loading