feat: Redis caching integration

This commit is contained in:
Martin Porwoll 2025-12-05 16:49:57 +00:00
parent 0a8e191747
commit d053eec21a
5 changed files with 267 additions and 8 deletions

View file

@ -31,6 +31,7 @@
"cross-env": "^7.0.3",
"dotenv": "16.4.7",
"graphql": "^16.8.1",
"ioredis": "^5.8.2",
"next": "15.4.7",
"node-cron": "^4.2.1",
"payload": "3.65.0",

View file

@ -47,6 +47,9 @@ importers:
graphql:
specifier: ^16.8.1
version: 16.12.0
ioredis:
specifier: ^5.8.2
version: 5.8.2
next:
specifier: 15.4.7
version: 15.4.7(@babel/core@7.28.5)(@playwright/test@1.56.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4)
@ -965,6 +968,9 @@ packages:
cpu: [x64]
os: [win32]
'@ioredis/commands@1.4.0':
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -1882,6 +1888,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -2020,6 +2030,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@ -2596,6 +2610,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
ioredis@5.8.2:
resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==}
engines: {node: '>=12.22.0'}
ipaddr.js@2.2.0:
resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==}
engines: {node: '>= 10'}
@ -2845,6 +2863,12 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@ -3397,6 +3421,14 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@ -3582,6 +3614,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
@ -4740,6 +4775,8 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
'@ioredis/commands@1.4.0': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -5879,6 +5916,8 @@ snapshots:
clsx@2.1.1: {}
cluster-key-slot@1.1.2: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -6007,6 +6046,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
denque@2.1.0: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {}
@ -6244,8 +6285,8 @@ snapshots:
'@typescript-eslint/parser': 8.48.0(eslint@9.39.1)(typescript@5.7.3)
eslint: 9.39.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1)
eslint-plugin-react: 7.37.5(eslint@9.39.1)
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1)
@ -6264,7 +6305,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@ -6275,22 +6316,22 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.48.0(eslint@9.39.1)(typescript@5.7.3)
eslint: 9.39.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1)
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -6301,7 +6342,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.7.3))(eslint@9.39.1))(eslint@9.39.1))(eslint@9.39.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@ -6673,6 +6714,20 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
ioredis@5.8.2:
dependencies:
'@ioredis/commands': 1.4.0
cluster-key-slot: 1.1.2
debug: 4.4.3
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ipaddr.js@2.2.0: {}
is-alphabetical@2.0.1: {}
@ -6932,6 +6987,10 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {}
lodash.merge@4.6.2: {}
lodash@4.17.21: {}
@ -7652,6 +7711,12 @@ snapshots:
real-require@0.2.0: {}
redis-errors@1.2.0: {}
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@ -7927,6 +7992,8 @@ snapshots:
stackback@0.0.2: {}
standard-as-callback@2.1.0: {}
state-local@1.0.7: {}
std-env@3.10.0: {}

View file

@ -0,0 +1,50 @@
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
import { cache } from '@/lib/redis'
// Nach dem Speichern/Ändern Cache invalidieren
export const invalidateCacheOnChange: CollectionAfterChangeHook = async ({
collection,
doc,
operation,
}) => {
const collectionSlug = collection.slug
console.log(`[Cache Invalidation] ${operation} on ${collectionSlug}`)
// Pattern-basierte Invalidierung
switch (collectionSlug) {
case 'pages':
await cache.delPattern('page:*')
await cache.delPattern('pages:*')
break
case 'posts':
await cache.delPattern('post:*')
await cache.delPattern('posts:*')
break
case 'navigation':
await cache.delPattern('nav:*')
break
case 'categories':
await cache.delPattern('categories:*')
break
default:
// Fallback: Collection-spezifisches Pattern
await cache.delPattern(`${collectionSlug}:*`)
}
return doc
}
// Nach dem Löschen Cache invalidieren
export const invalidateCacheOnDelete: CollectionAfterDeleteHook = async ({
collection,
doc,
}) => {
const collectionSlug = collection.slug
console.log(`[Cache Invalidation] delete on ${collectionSlug}`)
await cache.delPattern(`${collectionSlug}:*`)
return doc
}

33
src/lib/cache-keys.ts Normal file
View file

@ -0,0 +1,33 @@
// Zentrale Cache-Key Definitionen
export const CacheKeys = {
// Pages
page: (slug: string, locale: string) => `page:${locale}:${slug}`,
pages: (tenant: string, locale: string) => `pages:${tenant}:${locale}`,
// Posts
post: (slug: string, locale: string) => `post:${locale}:${slug}`,
posts: (tenant: string, locale: string) => `posts:${tenant}:${locale}`,
postsList: (tenant: string, locale: string, page: number) =>
`posts:${tenant}:${locale}:list:${page}`,
// Navigation
navigation: (tenant: string, locale: string) => `nav:${tenant}:${locale}`,
// Categories
categories: (tenant: string, locale: string) => `categories:${tenant}:${locale}`,
// Global Settings
globals: (tenant: string, slug: string) => `global:${tenant}:${slug}`,
// Tenant-spezifisch
tenant: (slug: string) => `tenant:${slug}`,
}
// TTL Werte (in Sekunden)
export const CacheTTL = {
SHORT: 60, // 1 Minute
DEFAULT: 300, // 5 Minuten
MEDIUM: 900, // 15 Minuten
LONG: 3600, // 1 Stunde
VERY_LONG: 86400, // 24 Stunden
}

108
src/lib/redis.ts Normal file
View file

@ -0,0 +1,108 @@
import Redis from 'ioredis'
const getRedisClient = () => {
const host = process.env.REDIS_HOST || 'localhost'
const port = parseInt(process.env.REDIS_PORT || '6379')
if (!process.env.REDIS_HOST) {
console.warn('[Redis] REDIS_HOST nicht gesetzt, verwende localhost')
}
return new Redis({
host,
port,
maxRetriesPerRequest: 3,
retryDelayOnFailover: 100,
lazyConnect: true,
})
}
// Singleton Pattern
let redisClient: Redis | null = null
export const getRedis = (): Redis => {
if (!redisClient) {
redisClient = getRedisClient()
}
return redisClient
}
// Cache Helper Funktionen
export const cache = {
async get<T>(key: string): Promise<T | null> {
try {
const redis = getRedis()
const data = await redis.get(key)
if (data) {
console.log(`[Cache] HIT: ${key}`)
return JSON.parse(data)
}
console.log(`[Cache] MISS: ${key}`)
return null
} catch (error) {
console.error('[Cache] GET Error:', error)
return null
}
},
async set(key: string, value: unknown, ttlSeconds = 300): Promise<void> {
try {
const redis = getRedis()
await redis.setex(key, ttlSeconds, JSON.stringify(value))
console.log(`[Cache] SET: ${key} (TTL: ${ttlSeconds}s)`)
} catch (error) {
console.error('[Cache] SET Error:', error)
}
},
async del(key: string): Promise<void> {
try {
const redis = getRedis()
await redis.del(key)
console.log(`[Cache] DEL: ${key}`)
} catch (error) {
console.error('[Cache] DEL Error:', error)
}
},
async delPattern(pattern: string): Promise<void> {
try {
const redis = getRedis()
const keys = await redis.keys(pattern)
if (keys.length > 0) {
await redis.del(...keys)
console.log(`[Cache] DEL Pattern: ${pattern} (${keys.length} keys)`)
}
} catch (error) {
console.error('[Cache] DEL Pattern Error:', error)
}
},
async flush(): Promise<void> {
try {
const redis = getRedis()
await redis.flushdb()
console.log('[Cache] FLUSH: Database cleared')
} catch (error) {
console.error('[Cache] FLUSH Error:', error)
}
},
// Cache-Wrapper für einfache Verwendung
async wrap<T>(
key: string,
fn: () => Promise<T>,
ttlSeconds = 300
): Promise<T> {
const cached = await this.get<T>(key)
if (cached !== null) {
return cached
}
const result = await fn()
await this.set(key, result, ttlSeconds)
return result
},
}
export default getRedis