From d053eec21a548c8bc4b8c0c14199aefb280db3f4 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Fri, 5 Dec 2025 16:49:57 +0000 Subject: [PATCH] feat: Redis caching integration --- package.json | 1 + pnpm-lock.yaml | 83 ++++++++++++++++++++++++--- src/hooks/invalidateCache.ts | 50 ++++++++++++++++ src/lib/cache-keys.ts | 33 +++++++++++ src/lib/redis.ts | 108 +++++++++++++++++++++++++++++++++++ 5 files changed, 267 insertions(+), 8 deletions(-) create mode 100644 src/hooks/invalidateCache.ts create mode 100644 src/lib/cache-keys.ts create mode 100644 src/lib/redis.ts diff --git a/package.json b/package.json index 2c256c2..ed109f3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 237ea31..88eb3fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/hooks/invalidateCache.ts b/src/hooks/invalidateCache.ts new file mode 100644 index 0000000..dbe5c16 --- /dev/null +++ b/src/hooks/invalidateCache.ts @@ -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 +} diff --git a/src/lib/cache-keys.ts b/src/lib/cache-keys.ts new file mode 100644 index 0000000..665c348 --- /dev/null +++ b/src/lib/cache-keys.ts @@ -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 +} diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..d0a5243 --- /dev/null +++ b/src/lib/redis.ts @@ -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(key: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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( + key: string, + fn: () => Promise, + ttlSeconds = 300 + ): Promise { + const cached = await this.get(key) + if (cached !== null) { + return cached + } + + const result = await fn() + await this.set(key, result, ttlSeconds) + return result + }, +} + +export default getRedis