/** * Based on https://github.com/node-cache-manager/node-cache-manager-ioredis-yet */ import type { Cache, Store, Config } from 'cache-manager'; import Redis from 'ioredis'; import type { Cluster, ClusterNode, ClusterOptions, RedisOptions } from 'ioredis'; import { jsonParse, UnexpectedError } from 'n8n-workflow'; export class NoCacheableError implements Error { name = 'NoCacheableError'; constructor(public message: string) {} } export const avoidNoCacheable = async (p: Promise) => { try { return await p; } catch (e) { if (!(e instanceof NoCacheableError)) throw e; return undefined; } }; export interface RedisClusterConfig { nodes: ClusterNode[]; options?: ClusterOptions; } export type RedisCache = Cache; export interface RedisStore extends Store { readonly isCacheable: (value: unknown) => boolean; get client(): Redis | Cluster; hget(key: string, field: string): Promise; hgetall(key: string): Promise | undefined>; hset(key: string, fieldValueRecord: Record): Promise; hkeys(key: string): Promise; hvals(key: string): Promise; hexists(key: string, field: string): Promise; hdel(key: string, field: string): Promise; expire(key: string, ttlSeconds: number): Promise; } function builder( redisCache: Redis | Cluster, reset: () => Promise, keys: (pattern: string) => Promise, options?: Config, ) { const isCacheable = options?.isCacheable ?? ((value) => value !== undefined && value !== null); const getVal = (value: unknown) => JSON.stringify(value) || '"undefined"'; return { async get(key: string) { const val = await redisCache.get(key); if (val === undefined || val === null) return undefined; else return jsonParse(val); }, async expire(key: string, ttlSeconds: number) { await redisCache.expire(key, ttlSeconds); }, async set(key, value, ttl) { // eslint-disable-next-line @typescript-eslint/no-throw-literal, @typescript-eslint/restrict-template-expressions if (!isCacheable(value)) throw new NoCacheableError(`"${value}" is not a cacheable value`); const t = ttl ?? options?.ttl; if (t !== undefined && t !== 0) await redisCache.set(key, getVal(value), 'PX', t); else await redisCache.set(key, getVal(value)); }, async mset(args, ttl) { const t = ttl ?? options?.ttl; if (t !== undefined && t !== 0) { const multi = redisCache.multi(); for (const [key, value] of args) { if (!isCacheable(value)) // eslint-disable-next-line @typescript-eslint/no-throw-literal throw new NoCacheableError(`"${getVal(value)}" is not a cacheable value`); multi.set(key, getVal(value), 'PX', t); } await multi.exec(); } else await redisCache.mset( args.flatMap(([key, value]) => { if (!isCacheable(value)) throw new UnexpectedError(`"${getVal(value)}" is not a cacheable value`); return [key, getVal(value)] as [string, string]; }), ); }, mget: async (...args) => await redisCache .mget(args) .then((results) => results.map((result) => result === null || result === undefined ? undefined : jsonParse(result), ), ), async mdel(...args) { await redisCache.del(args); }, async del(key) { await redisCache.del(key); }, ttl: async (key) => await redisCache.pttl(key), keys: async (pattern = '*') => await keys(pattern), reset, isCacheable, get client() { return redisCache; }, // Redis Hash functions async hget(key: string, field: string) { const val = await redisCache.hget(key, field); if (val === undefined || val === null) return undefined; else return jsonParse(val); }, async hgetall(key: string) { const val = await redisCache.hgetall(key); if (val === undefined || val === null) return undefined; else { for (const field in val) { const value = val[field]; val[field] = jsonParse(value); } return val as Record; } }, async hset(key: string, fieldValueRecord: Record) { for (const field in fieldValueRecord) { const value = fieldValueRecord[field]; if (!isCacheable(fieldValueRecord[field])) { // eslint-disable-next-line @typescript-eslint/no-throw-literal, @typescript-eslint/restrict-template-expressions throw new NoCacheableError(`"${value}" is not a cacheable value`); } fieldValueRecord[field] = getVal(value); } await redisCache.hset(key, fieldValueRecord); }, async hkeys(key: string) { return await redisCache.hkeys(key); }, async hvals(key: string): Promise { const values = await redisCache.hvals(key); return values.map((value) => jsonParse(value)); }, async hexists(key: string, field: string): Promise { return (await redisCache.hexists(key, field)) === 1; }, async hdel(key: string, field: string) { return await redisCache.hdel(key, field); }, } as RedisStore; } export function redisStoreUsingClient(redisCache: Redis | Cluster, options?: Config) { const reset = async () => { await redisCache.flushdb(); }; const keys = async (pattern: string) => await redisCache.keys(pattern); return builder(redisCache, reset, keys, options); } export async function redisStore( options?: (RedisOptions | { clusterConfig: RedisClusterConfig }) & Config, ) { options ||= {}; const redisCache = 'clusterConfig' in options ? new Redis.Cluster(options.clusterConfig.nodes, options.clusterConfig.options) : new Redis(options); return redisStoreUsingClient(redisCache, options); }