Skip to content

Commit ffb9a2e

Browse files
authored
feat: add KV storage adapters (#9913)
Adds KV storage support to Payload Local API with an adapter pattern, includes 3 adapters. We'll use it for the Realtime API. You can access it via `payload.kv`: ```ts interface KVAdapter { /** * Clears all entries in the store. * @returns A promise that resolves once the store is cleared. */ clear(): Promise<void> /** * Deletes a value from the store by its key. * @param key - The key to delete. * @returns A promise that resolves once the key is deleted. */ delete(key: string): Promise<void> /** * Retrieves a value from the store by its key. * @param key - The key to look up. * @returns A promise that resolves to the value, or `null` if not found. */ get(key: string): Promise<KVStoreValue | null> /** * Checks if a key exists in the store. * @param key - The key to check. * @returns A promise that resolves to `true` if the key exists, otherwise `false`. */ has(key: string): Promise<boolean> /** * Retrieves all the keys in the store. * @returns A promise that resolves to an array of keys. */ keys(): Promise<string[]> /** * Sets a value in the store with the given key. * @param key - The key to associate with the value. * @param value - The value to store. * @returns A promise that resolves once the value is stored. */ set(key: string, value: KVStoreValue): Promise<void> } ``` To configure the adapter you can use the `kv` property of the root config. ```ts buildConfig({ kv: adapter() }) ``` #### Database KV adapter (default) No need to configure, as Payload uses it by default. It generates new collection `payload-kv` and uses the current database adapter to access it. The collection is hidden in the admin panel and access locked. If you want to override the generated collection: ```ts import { databaseKVAdapter } from 'payload' buildConfig({ kv: databaseKVAdapter({ kvCollectionOverrides: { slug: 'custom-kv', ...(process.env.DEBUG === 'true' && { admin: { hidden: false }, access: {}, }), }, }), }) ``` #### In Memory KV adapter Simple and very fast storage using memory. Don't use it on Vercel / multiple instances or if you need data persistence. ```ts import { inMemoryKVAdapter } from 'payload' buildConfig({ kv: inMemoryKVAdapter(), }) ``` #### Redis KV Adapter Uses Redis. Probably the best option as it's faster than database, persistent and works with Vercel / multiple instances, but requires additional setup ```sh pnpm add @payloadcms/kv-redis ``` ```ts import { redisKVAdapter } from '@payloadcms/kv-redis' buildConfig({ kv: redisKVAdapter({ keyPrefix: "custom-prefix:", // defaults to 'payload-kv:' redisURL: "redis://127.0.0.1:6379" // defaults to process.env.REDIS_URL (Vercel generates this variable for you if you connect a project to Redis) }), }) ```
1 parent b766ae6 commit ffb9a2e

31 files changed

+977
-2
lines changed

.github/workflows/main.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,13 @@ jobs:
185185
# needed because the postgres container does not provide a healthcheck
186186
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
187187

188+
redis:
189+
image: redis:latest
190+
ports:
191+
- 6379:6379
192+
193+
options: --health-cmd "redis-cli ping" --health-timeout 30s --health-retries 3
194+
188195
steps:
189196
- uses: actions/checkout@v5
190197

@@ -232,6 +239,10 @@ jobs:
232239
echo "POSTGRES_URL=postgresql://postgres:[email protected]:54322/postgres" >> $GITHUB_ENV
233240
if: matrix.database == 'supabase'
234241

242+
- name: Configure Redis
243+
run: |
244+
echo "REDIS_URL=redis://127.0.0.1:6379" >> $GITHUB_ENV
245+
235246
- name: Integration Tests
236247
run: pnpm test:int
237248
env:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"build:essentials:force": "pnpm clean:build && turbo build --filter=\"payload...\" --filter=\"@payloadcms/ui\" --filter=\"@payloadcms/next\" --filter=\"@payloadcms/db-mongodb\" --filter=\"@payloadcms/db-postgres\" --filter=\"@payloadcms/richtext-lexical\" --filter=\"@payloadcms/translations\" --filter=\"@payloadcms/plugin-cloud\" --filter=\"@payloadcms/graphql\" --no-cache --force",
3232
"build:force": "pnpm run build:core:force",
3333
"build:graphql": "turbo build --filter \"@payloadcms/graphql\"",
34+
"build:kv-redis": "turbo build --filter \"@payloadcms/kv-redis\"",
3435
"build:live-preview": "turbo build --filter \"@payloadcms/live-preview\"",
3536
"build:live-preview-react": "turbo build --filter \"@payloadcms/live-preview-react\"",
3637
"build:live-preview-vue": "turbo build --filter \"@payloadcms/live-preview-vue\"",

packages/kv-redis/.prettierignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.tmp
2+
**/.git
3+
**/.hg
4+
**/.pnp.*
5+
**/.svn
6+
**/.yarn/**
7+
**/build
8+
**/dist/**
9+
**/node_modules
10+
**/temp

packages/kv-redis/.swcrc

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"$schema": "https://json.schemastore.org/swcrc",
3+
"sourceMaps": true,
4+
"jsc": {
5+
"target": "esnext",
6+
"parser": {
7+
"syntax": "typescript",
8+
"tsx": true,
9+
"dts": true
10+
}
11+
},
12+
"module": {
13+
"type": "es6"
14+
}
15+
}

packages/kv-redis/LICENSE.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
MIT License
2+
3+
Copyright (c) 2018-2024 Payload CMS, Inc. <[email protected]>
4+
5+
Permission is hereby granted, free of charge, to any person obtaining
6+
a copy of this software and associated documentation files (the
7+
'Software'), to deal in the Software without restriction, including
8+
without limitation the rights to use, copy, modify, merge, publish,
9+
distribute, sublicense, and/or sell copies of the Software, and to
10+
permit persons to whom the Software is furnished to do so, subject to
11+
the following conditions:
12+
13+
The above copyright notice and this permission notice shall be
14+
included in all copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
17+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
20+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
21+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
22+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

packages/kv-redis/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Redis KV Adapter for Payload (beta)
2+
3+
This package provides a way to use [Redis](https://redis.io) as a KV adapter with Payload.
4+
5+
## Installation
6+
7+
```sh
8+
pnpm add @payloadcms/kv-redis
9+
```
10+
11+
## Usage
12+
13+
```ts
14+
import { redisKVAdapter } from '@payloadcms/kv-redis'
15+
16+
export default buildConfig({
17+
collections: [Media],
18+
kv: redisKVAdapter({
19+
// Redis connection URL. Defaults to process.env.REDIS_URL
20+
redisURL: 'redis://localhost:6379',
21+
// Optional prefix for Redis keys to isolate the store. Defaults to 'payload-kv'
22+
keyPrefix: 'kv-storage',
23+
}),
24+
})
25+
```
26+
27+
Then you can access the KV storage using `payload.kv`:
28+
29+
```ts
30+
await payload.kv.set('key', { value: 1 })
31+
const data = await payload.kv.get('key')
32+
payload.logger.info(data)
33+
```

packages/kv-redis/eslint.config.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
2+
3+
/** @typedef {import('eslint').Linter.Config} Config */
4+
5+
/** @type {Config[]} */
6+
export const index = [
7+
...rootEslintConfig,
8+
{
9+
languageOptions: {
10+
parserOptions: {
11+
...rootParserOptions,
12+
tsconfigRootDir: import.meta.dirname,
13+
},
14+
},
15+
},
16+
]
17+
18+
export default index

packages/kv-redis/package.json

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
{
2+
"name": "@payloadcms/kv-redis",
3+
"version": "3.6.0",
4+
"description": "Redis KV adapter for Payload",
5+
"homepage": "https://payloadcms.com",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/payloadcms/payload.git",
9+
"directory": "packages/kv-redis"
10+
},
11+
"license": "MIT",
12+
"author": "Payload <[email protected]> (https://payloadcms.com)",
13+
"maintainers": [
14+
{
15+
"name": "Payload",
16+
"email": "[email protected]",
17+
"url": "https://payloadcms.com"
18+
}
19+
],
20+
"type": "module",
21+
"exports": {
22+
".": {
23+
"import": "./src/index.ts",
24+
"types": "./src/index.ts",
25+
"default": "./src/index.ts"
26+
}
27+
},
28+
"main": "./src/index.ts",
29+
"types": "./src/index.ts",
30+
"files": [
31+
"dist"
32+
],
33+
"scripts": {
34+
"build": "pnpm build:types && pnpm build:swc",
35+
"build:clean": "find . \\( -type d \\( -name build -o -name dist -o -name .cache \\) -o -type f -name tsconfig.tsbuildinfo \\) -exec rm -rf {} + && pnpm build",
36+
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
37+
"build:types": "tsc --emitDeclarationOnly --outDir dist",
38+
"clean": "rimraf {dist,*.tsbuildinfo}",
39+
"lint": "eslint .",
40+
"lint:fix": "eslint . --fix",
41+
"prepublishOnly": "pnpm clean && pnpm turbo build"
42+
},
43+
"dependencies": {
44+
"ioredis": "^5.4.1"
45+
},
46+
"devDependencies": {
47+
"payload": "workspace:*"
48+
},
49+
"peerDependencies": {
50+
"payload": "workspace:*"
51+
},
52+
"engines": {
53+
"node": "^18.20.2 || >=20.9.0"
54+
},
55+
"publishConfig": {
56+
"exports": {
57+
".": {
58+
"import": "./dist/index.js",
59+
"types": "./dist/index.d.ts",
60+
"default": "./dist/index.js"
61+
}
62+
},
63+
"main": "./dist/index.js",
64+
"types": "./dist/index.d.ts"
65+
}
66+
}

packages/kv-redis/src/index.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { KVAdapter, KVAdapterResult, KVStoreValue } from 'payload'
2+
3+
import { Redis } from 'ioredis'
4+
5+
export class RedisKVAdapter implements KVAdapter {
6+
redisClient: Redis
7+
8+
constructor(
9+
readonly keyPrefix: string,
10+
redisURL: string,
11+
) {
12+
this.redisClient = new Redis(redisURL)
13+
}
14+
15+
async clear(): Promise<void> {
16+
const keys = await this.redisClient.keys(`${this.keyPrefix}*`)
17+
18+
if (keys.length > 0) {
19+
await this.redisClient.del(keys)
20+
}
21+
}
22+
23+
async delete(key: string): Promise<void> {
24+
await this.redisClient.del(`${this.keyPrefix}${key}`)
25+
}
26+
27+
async get(key: string): Promise<KVStoreValue | null> {
28+
const data = await this.redisClient.get(`${this.keyPrefix}${key}`)
29+
30+
if (data === null) {
31+
return data
32+
}
33+
34+
return JSON.parse(data)
35+
}
36+
37+
async has(key: string): Promise<boolean> {
38+
const exists = await this.redisClient.exists(`${this.keyPrefix}${key}`)
39+
return exists === 1
40+
}
41+
42+
async keys(): Promise<string[]> {
43+
const prefixedKeys = await this.redisClient.keys(`${this.keyPrefix}*`)
44+
45+
if (this.keyPrefix) {
46+
return prefixedKeys.map((key) => key.replace(this.keyPrefix, ''))
47+
}
48+
49+
return prefixedKeys
50+
}
51+
52+
async set(key: string, data: KVStoreValue): Promise<void> {
53+
await this.redisClient.set(`${this.keyPrefix}${key}`, JSON.stringify(data))
54+
}
55+
}
56+
57+
export type RedisKVAdapterOptions = {
58+
/**
59+
* Optional prefix for Redis keys to isolate the store
60+
*
61+
* @default 'payload-kv:'
62+
*/
63+
keyPrefix?: string
64+
/** Redis connection URL (e.g., 'redis://localhost:6379'). Defaults to process.env.REDIS_URL */
65+
redisURL?: string
66+
}
67+
68+
export const redisKVAdapter = (options: RedisKVAdapterOptions = {}): KVAdapterResult => {
69+
const keyPrefix = options.keyPrefix ?? 'payload-kv:'
70+
const redisURL = options.redisURL ?? process.env.REDIS_URL
71+
72+
if (!redisURL) {
73+
throw new Error('redisURL or REDIS_URL env variable is required')
74+
}
75+
76+
return {
77+
init: () => new RedisKVAdapter(keyPrefix, redisURL),
78+
}
79+
}

packages/kv-redis/tsconfig.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"composite": true, // Make sure typescript knows that this module depends on their references
5+
"noEmit": false /* Do not emit outputs. */,
6+
"emitDeclarationOnly": true,
7+
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
8+
"rootDir": "./src" /* Specify the root folder within your source files. */,
9+
"strict": true
10+
},
11+
"exclude": ["dist", "node_modules"],
12+
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"],
13+
"references": [{ "path": "../payload" }]
14+
}

0 commit comments

Comments
 (0)