File size: 5,658 Bytes
aec3094
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
/**
 * 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 <T>(p: Promise<T>) => {
	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<RedisStore>;

export interface RedisStore extends Store {
	readonly isCacheable: (value: unknown) => boolean;
	get client(): Redis | Cluster;
	hget<T>(key: string, field: string): Promise<T | undefined>;
	hgetall<T>(key: string): Promise<Record<string, T> | undefined>;
	hset(key: string, fieldValueRecord: Record<string, unknown>): Promise<void>;
	hkeys(key: string): Promise<string[]>;
	hvals<T>(key: string): Promise<T[]>;
	hexists(key: string, field: string): Promise<boolean>;
	hdel(key: string, field: string): Promise<number>;
	expire(key: string, ttlSeconds: number): Promise<void>;
}

function builder(
	redisCache: Redis | Cluster,
	reset: () => Promise<void>,
	keys: (pattern: string) => Promise<string[]>,
	options?: Config,
) {
	const isCacheable = options?.isCacheable ?? ((value) => value !== undefined && value !== null);
	const getVal = (value: unknown) => JSON.stringify(value) || '"undefined"';

	return {
		async get<T>(key: string) {
			const val = await redisCache.get(key);
			if (val === undefined || val === null) return undefined;
			else return jsonParse<T>(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<T>(key: string, field: string) {
			const val = await redisCache.hget(key, field);
			if (val === undefined || val === null) return undefined;
			else return jsonParse<T>(val);
		},
		async hgetall<T>(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<string, T>;
			}
		},
		async hset(key: string, fieldValueRecord: Record<string, unknown>) {
			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<T>(key: string): Promise<T[]> {
			const values = await redisCache.hvals(key);
			return values.map((value) => jsonParse<T>(value));
		},
		async hexists(key: string, field: string): Promise<boolean> {
			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);
}