Skip to content

Commit 2f146f0

Browse files
committed
feat: add KV storage adapters
1 parent 29ad1fc commit 2f146f0

32 files changed

+982
-1
lines changed

.github/workflows/main.yml

+11
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,13 @@ jobs:
202202
# needed because the postgres container does not provide a healthcheck
203203
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
204204

205+
redis:
206+
image: redis:latest
207+
ports:
208+
- 6379:6379 # Expose Redis on port 6379
209+
210+
options: --health-cmd "redis-cli ping" --health-timeout 30s --health-retries 3
211+
205212
steps:
206213
- uses: actions/checkout@v4
207214

@@ -252,6 +259,10 @@ jobs:
252259
echo "POSTGRES_URL=postgresql://postgres:[email protected]:54322/postgres" >> $GITHUB_ENV
253260
if: matrix.database == 'supabase'
254261

262+
- name: Configure Redis
263+
run: |
264+
echo "REDIS_URL=redis://127.0.0.1:6379" >> $GITHUB_ENV
265+
255266
- name: Integration Tests
256267
uses: nick-fields/retry@v3
257268
env:

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"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",
2525
"build:force": "pnpm run build:core:force",
2626
"build:graphql": "turbo build --filter \"@payloadcms/graphql\"",
27+
"build:kv-redis": "turbo build --filter \"@payloadcms/kv-redis\"",
2728
"build:live-preview": "turbo build --filter \"@payloadcms/live-preview\"",
2829
"build:live-preview-react": "turbo build --filter \"@payloadcms/live-preview-react\"",
2930
"build:live-preview-vue": "turbo build --filter \"@payloadcms/live-preview-vue\"",

packages/kv-redis/.prettierignore

+10
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

+15
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

+22
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

+33
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+
prefix: '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.loger.info(data)
33+
```

packages/kv-redis/eslint.config.js

+18
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

+66
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": "Payload storage adapter for uploadthing",
5+
"homepage": "https://payloadcms.com",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/payloadcms/payload.git",
9+
"directory": "packages/storage-uploadthing"
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

+79
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

+14
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+
}

packages/payload/src/config/client.ts

+2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export type ServerOnlyRootProperties = keyof Pick<
2727
| 'graphQL'
2828
| 'hooks'
2929
| 'jobs'
30+
| 'kv'
3031
| 'logger'
3132
| 'onInit'
3233
| 'plugins'
@@ -67,6 +68,7 @@ export const serverOnlyConfigProperties: readonly Partial<ServerOnlyRootProperti
6768
'graphQL',
6869
'jobs',
6970
'logger',
71+
'kv',
7072
// `admin`, `onInit`, `localization`, `collections`, and `globals` are all handled separately
7173
]
7274

packages/payload/src/config/defaults.ts

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { JobsConfig } from '../queues/config/types/index.js'
22
import type { Config } from './types.js'
33

44
import defaultAccess from '../auth/defaultAccess.js'
5+
import { databaseKVAdapter } from '../kv/adapters/DatabaseKVAdapter.js'
56

67
export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
78
admin: {
@@ -54,6 +55,7 @@ export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
5455
deleteJobOnComplete: true,
5556
depth: 0,
5657
} as JobsConfig,
58+
kv: databaseKVAdapter(),
5759
localization: false,
5860
maxDepth: 10,
5961
routes: {

packages/payload/src/config/sanitize.ts

+4
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
192192
configWithDefaults.collections.push(getPreferencesCollection(config as unknown as Config))
193193
configWithDefaults.collections.push(migrationsCollection)
194194

195+
if (configWithDefaults.kv.kvCollection) {
196+
configWithDefaults.collections.push(configWithDefaults.kv.kvCollection)
197+
}
198+
195199
const richTextSanitizationPromises: Array<(config: SanitizedConfig) => Promise<void>> = []
196200
for (let i = 0; i < config.collections.length; i++) {
197201
config.collections[i] = await sanitizeCollection(

packages/payload/src/config/types.ts

+10
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import type { EmailAdapter, SendEmailOptions } from '../email/types.js'
3838
import type { ErrorName } from '../errors/types.js'
3939
import type { GlobalConfig, Globals, SanitizedGlobalConfig } from '../globals/config/types.js'
4040
import type { JobsConfig, Payload, RequestContext, TypedUser } from '../index.js'
41+
import type { KVAdapterResult } from '../kv/index.js'
4142
import type { PayloadRequest, Where } from '../types/index.js'
4243
import type { PayloadLogger } from '../utilities/logger.js'
4344

@@ -974,6 +975,15 @@ export type Config = {
974975
* @experimental There may be frequent breaking changes to this API
975976
*/
976977
jobs?: JobsConfig
978+
/**
979+
* Pass in a KV adapter for use on this project.
980+
* @default `DatabaseKVAdapter` from:
981+
* ```ts
982+
* import { createDatabaseKVAdapter } from 'payload'
983+
* createDatabaseKVAdapter()
984+
* ```
985+
*/
986+
kv?: KVAdapterResult
977987
/**
978988
* Translate your content to different languages/locales.
979989
*

0 commit comments

Comments
 (0)