@prismakit/redis 3.0.2 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +99 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -15
- package/dist/index.d.ts +37 -15
- package/dist/index.js +97 -58
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/redis-cache-adapter.integration.spec.ts +46 -0
- package/src/__tests__/redis-cache-adapter.spec.ts +14 -0
- package/src/index.ts +4 -0
- package/src/redis-cache-adapter.ts +42 -50
- package/src/redis-json.ts +115 -17
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import Redis from 'ioredis';
|
|
2
2
|
import { gzipSync, gunzipSync } from 'zlib';
|
|
3
3
|
import type { CacheAdapter } from '@prismakit/core';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
redisJsonParse,
|
|
6
|
+
redisJsonStringify,
|
|
7
|
+
type DecimalFactory,
|
|
8
|
+
type RedisJsonOptions,
|
|
9
|
+
} from './redis-json';
|
|
5
10
|
|
|
6
11
|
const INDEX_TTL_BUFFER = 60;
|
|
7
12
|
|
|
@@ -29,6 +34,13 @@ export type RedisCacheAdapterOptions = {
|
|
|
29
34
|
compression?: RedisCompression;
|
|
30
35
|
/** Minimum payload size (bytes) before compression (default 1024). */
|
|
31
36
|
compressionThresholdBytes?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Reconstruct Prisma Decimal from tagged cache payloads.
|
|
39
|
+
* @example decimalFactory: (s) => new Prisma.Decimal(s)
|
|
40
|
+
*/
|
|
41
|
+
decimalFactory?: DecimalFactory;
|
|
42
|
+
/** Optional error hook for safe* wrappers (also used by telemetry). */
|
|
43
|
+
onError?: (err: unknown, op?: string) => void;
|
|
32
44
|
};
|
|
33
45
|
|
|
34
46
|
const COMPRESSED_PREFIX = 'gz:';
|
|
@@ -42,6 +54,8 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
42
54
|
private ready = false;
|
|
43
55
|
private readonly compression: RedisCompression;
|
|
44
56
|
private readonly compressionThreshold: number;
|
|
57
|
+
private readonly jsonOptions: RedisJsonOptions;
|
|
58
|
+
onError?: (err: unknown, op?: string) => void;
|
|
45
59
|
|
|
46
60
|
constructor(options: RedisCacheAdapterOptions = {}) {
|
|
47
61
|
const {
|
|
@@ -51,11 +65,15 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
51
65
|
prefix = 'prismakit',
|
|
52
66
|
compression = 'none',
|
|
53
67
|
compressionThresholdBytes = 1024,
|
|
68
|
+
decimalFactory,
|
|
69
|
+
onError,
|
|
54
70
|
} = options;
|
|
55
71
|
|
|
56
72
|
this.prefix = prefix;
|
|
57
73
|
this.compression = compression;
|
|
58
74
|
this.compressionThreshold = compressionThresholdBytes;
|
|
75
|
+
this.jsonOptions = { decimalFactory };
|
|
76
|
+
this.onError = onError;
|
|
59
77
|
this.client = url
|
|
60
78
|
? new Redis(url, { lazyConnect: true })
|
|
61
79
|
: new Redis({ host, port, lazyConnect: true });
|
|
@@ -74,6 +92,18 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
74
92
|
void this.connect();
|
|
75
93
|
}
|
|
76
94
|
|
|
95
|
+
private report(op: string, err: unknown): void {
|
|
96
|
+
console.warn(
|
|
97
|
+
`[RedisCacheAdapter] ${op} failed`,
|
|
98
|
+
(err as Error)?.message ?? err,
|
|
99
|
+
);
|
|
100
|
+
try {
|
|
101
|
+
this.onError?.(err, op);
|
|
102
|
+
} catch {
|
|
103
|
+
/* ignore hook errors */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
77
107
|
async connect(): Promise<void> {
|
|
78
108
|
if (this.client.status === 'ready' || this.client.status === 'connecting') {
|
|
79
109
|
return;
|
|
@@ -81,10 +111,7 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
81
111
|
try {
|
|
82
112
|
await this.client.connect();
|
|
83
113
|
} catch (err) {
|
|
84
|
-
|
|
85
|
-
'[RedisCacheAdapter] failed to connect',
|
|
86
|
-
(err as Error).message,
|
|
87
|
-
);
|
|
114
|
+
this.report('connect', err);
|
|
88
115
|
}
|
|
89
116
|
}
|
|
90
117
|
|
|
@@ -117,13 +144,11 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
117
144
|
if (raw.startsWith(COMPRESSED_PREFIX)) {
|
|
118
145
|
const buf = Buffer.from(raw.slice(COMPRESSED_PREFIX.length), 'base64');
|
|
119
146
|
const json = gunzipSync(buf).toString('utf8');
|
|
120
|
-
return redisJsonParse<T>(json);
|
|
147
|
+
return redisJsonParse<T>(json, this.jsonOptions);
|
|
121
148
|
}
|
|
122
|
-
return redisJsonParse<T>(raw);
|
|
149
|
+
return redisJsonParse<T>(raw, this.jsonOptions);
|
|
123
150
|
}
|
|
124
151
|
|
|
125
|
-
// --- low-level ops (may throw) ---
|
|
126
|
-
|
|
127
152
|
async get<T>(key: string): Promise<T | null> {
|
|
128
153
|
const raw = await this.client.get(key);
|
|
129
154
|
if (raw === null) return null;
|
|
@@ -153,10 +178,6 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
153
178
|
return result === 'OK';
|
|
154
179
|
}
|
|
155
180
|
|
|
156
|
-
/**
|
|
157
|
-
* Atomically SET a cache key + SADD into an index SET + EXPIRE the index
|
|
158
|
-
* via a single Redis pipeline.
|
|
159
|
-
*/
|
|
160
181
|
async setWithIndex(
|
|
161
182
|
key: string,
|
|
162
183
|
value: unknown,
|
|
@@ -170,9 +191,6 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
170
191
|
await pipeline.exec();
|
|
171
192
|
}
|
|
172
193
|
|
|
173
|
-
/**
|
|
174
|
-
* Atomic invalidation via Lua (SMEMBERS + DEL members + DEL index).
|
|
175
|
-
*/
|
|
176
194
|
async invalidateByIndex(indexKey: string): Promise<void> {
|
|
177
195
|
await this.client.eval(INVALIDATE_BY_INDEX_LUA, 1, indexKey);
|
|
178
196
|
}
|
|
@@ -189,16 +207,11 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
189
207
|
await pipeline.exec();
|
|
190
208
|
}
|
|
191
209
|
|
|
192
|
-
// --- safe wrappers (never throw — warn + return fallback) ---
|
|
193
|
-
|
|
194
210
|
async safeGet<T>(key: string): Promise<T | null> {
|
|
195
211
|
try {
|
|
196
212
|
return await this.get<T>(key);
|
|
197
213
|
} catch (err) {
|
|
198
|
-
|
|
199
|
-
`[RedisCacheAdapter] safeGet failed for key=${key}`,
|
|
200
|
-
(err as Error).message,
|
|
201
|
-
);
|
|
214
|
+
this.report(`safeGet key=${key}`, err);
|
|
202
215
|
return null;
|
|
203
216
|
}
|
|
204
217
|
}
|
|
@@ -211,10 +224,7 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
211
224
|
try {
|
|
212
225
|
await this.set(key, value, ttlSeconds);
|
|
213
226
|
} catch (err) {
|
|
214
|
-
|
|
215
|
-
`[RedisCacheAdapter] safeSet failed for key=${key}`,
|
|
216
|
-
(err as Error).message,
|
|
217
|
-
);
|
|
227
|
+
this.report(`safeSet key=${key}`, err);
|
|
218
228
|
}
|
|
219
229
|
}
|
|
220
230
|
|
|
@@ -222,10 +232,7 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
222
232
|
try {
|
|
223
233
|
await this.del(...keys);
|
|
224
234
|
} catch (err) {
|
|
225
|
-
|
|
226
|
-
'[RedisCacheAdapter] safeDel failed',
|
|
227
|
-
(err as Error).message,
|
|
228
|
-
);
|
|
235
|
+
this.report('safeDel', err);
|
|
229
236
|
}
|
|
230
237
|
}
|
|
231
238
|
|
|
@@ -233,10 +240,7 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
233
240
|
try {
|
|
234
241
|
return await this.setNx(key, ttlSeconds);
|
|
235
242
|
} catch (err) {
|
|
236
|
-
|
|
237
|
-
`[RedisCacheAdapter] safeSetNx failed for key=${key}`,
|
|
238
|
-
(err as Error).message,
|
|
239
|
-
);
|
|
243
|
+
this.report(`safeSetNx key=${key}`, err);
|
|
240
244
|
return false;
|
|
241
245
|
}
|
|
242
246
|
}
|
|
@@ -250,10 +254,7 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
250
254
|
try {
|
|
251
255
|
await this.setWithIndex(key, value, ttlSeconds, indexKey);
|
|
252
256
|
} catch (err) {
|
|
253
|
-
|
|
254
|
-
`[RedisCacheAdapter] safeSetWithIndex failed for key=${key}`,
|
|
255
|
-
(err as Error).message,
|
|
256
|
-
);
|
|
257
|
+
this.report(`safeSetWithIndex key=${key}`, err);
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
260
|
|
|
@@ -261,10 +262,7 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
261
262
|
try {
|
|
262
263
|
await this.invalidateByIndex(indexKey);
|
|
263
264
|
} catch (err) {
|
|
264
|
-
|
|
265
|
-
`[RedisCacheAdapter] safeInvalidateByIndex failed for idx=${indexKey}`,
|
|
266
|
-
(err as Error).message,
|
|
267
|
-
);
|
|
265
|
+
this.report(`safeInvalidateByIndex idx=${indexKey}`, err);
|
|
268
266
|
}
|
|
269
267
|
}
|
|
270
268
|
|
|
@@ -276,10 +274,7 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
276
274
|
try {
|
|
277
275
|
await this.saddAndExpire(key, members, ttlSeconds);
|
|
278
276
|
} catch (err) {
|
|
279
|
-
|
|
280
|
-
`[RedisCacheAdapter] safeSaddAndExpire failed for key=${key}`,
|
|
281
|
-
(err as Error).message,
|
|
282
|
-
);
|
|
277
|
+
this.report(`safeSaddAndExpire key=${key}`, err);
|
|
283
278
|
}
|
|
284
279
|
}
|
|
285
280
|
|
|
@@ -287,10 +282,7 @@ export class RedisCacheAdapter implements CacheAdapter {
|
|
|
287
282
|
try {
|
|
288
283
|
return await this.smembers(key);
|
|
289
284
|
} catch (err) {
|
|
290
|
-
|
|
291
|
-
`[RedisCacheAdapter] safeSmembers failed for key=${key}`,
|
|
292
|
-
(err as Error).message,
|
|
293
|
-
);
|
|
285
|
+
this.report(`safeSmembers key=${key}`, err);
|
|
294
286
|
return [];
|
|
295
287
|
}
|
|
296
288
|
}
|
package/src/redis-json.ts
CHANGED
|
@@ -1,34 +1,132 @@
|
|
|
1
1
|
const BIGINT_TAG = '__bigint';
|
|
2
|
+
const DATE_TAG = '__date';
|
|
3
|
+
const BYTES_TAG = '__bytes';
|
|
4
|
+
const DECIMAL_TAG = '__decimal';
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
export type DecimalFactory = (value: string) => unknown;
|
|
7
|
+
|
|
8
|
+
export type RedisJsonOptions = {
|
|
9
|
+
/**
|
|
10
|
+
* Reconstruct Prisma `Decimal` (or equivalent) from the tagged string.
|
|
11
|
+
* Default keeps the precision-preserving string so values are not silently
|
|
12
|
+
* coerced to numbers.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* decimalFactory: (s) => new Prisma.Decimal(s)
|
|
16
|
+
*/
|
|
17
|
+
decimalFactory?: DecimalFactory;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
21
|
+
return (
|
|
22
|
+
value !== null &&
|
|
23
|
+
typeof value === 'object' &&
|
|
24
|
+
!Array.isArray(value) &&
|
|
25
|
+
!(value instanceof Date) &&
|
|
26
|
+
!Buffer.isBuffer(value)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isDecimalLike(value: unknown): value is { toFixed: () => string } {
|
|
31
|
+
return (
|
|
32
|
+
isPlainObject(value) &&
|
|
33
|
+
typeof (value as { constructor?: { name?: string } }).constructor?.name ===
|
|
34
|
+
'string' &&
|
|
35
|
+
((value as { constructor: { name: string } }).constructor.name ===
|
|
36
|
+
'Decimal' ||
|
|
37
|
+
(typeof (value as { toFixed?: unknown }).toFixed === 'function' &&
|
|
38
|
+
typeof (value as { d?: unknown }).d === 'object'))
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** JSON.stringify replacer — Prisma BigInt / Date / Bytes / Decimal. */
|
|
4
43
|
export function redisJsonReplacer(_key: string, value: unknown): unknown {
|
|
5
44
|
if (typeof value === 'bigint') {
|
|
6
45
|
return { [BIGINT_TAG]: value.toString() };
|
|
7
46
|
}
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
47
|
+
if (value instanceof Date) {
|
|
48
|
+
return { [DATE_TAG]: value.toISOString() };
|
|
49
|
+
}
|
|
50
|
+
if (Buffer.isBuffer(value)) {
|
|
51
|
+
return { [BYTES_TAG]: value.toString('base64') };
|
|
52
|
+
}
|
|
13
53
|
if (
|
|
14
|
-
value
|
|
54
|
+
value &&
|
|
15
55
|
typeof value === 'object' &&
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
BIGINT_TAG in value
|
|
56
|
+
(value as { type?: string }).type === 'Buffer' &&
|
|
57
|
+
Array.isArray((value as { data?: unknown }).data)
|
|
19
58
|
) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
59
|
+
return {
|
|
60
|
+
[BYTES_TAG]: Buffer.from(
|
|
61
|
+
(value as { data: number[] }).data,
|
|
62
|
+
).toString('base64'),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (isDecimalLike(value)) {
|
|
66
|
+
return { [DECIMAL_TAG]: value.toFixed() };
|
|
24
67
|
}
|
|
25
68
|
return value;
|
|
26
69
|
}
|
|
27
70
|
|
|
71
|
+
/** JSON.parse reviver — restore tagged payloads from {@link redisJsonReplacer}. */
|
|
72
|
+
export function createRedisJsonReviver(options: RedisJsonOptions = {}) {
|
|
73
|
+
const decimalFactory = options.decimalFactory ?? ((s: string) => s);
|
|
74
|
+
return (_key: string, value: unknown): unknown => {
|
|
75
|
+
if (!isPlainObject(value)) return value;
|
|
76
|
+
const keys = Object.keys(value);
|
|
77
|
+
if (keys.length !== 1) return value;
|
|
78
|
+
|
|
79
|
+
if (BIGINT_TAG in value && typeof value[BIGINT_TAG] === 'string') {
|
|
80
|
+
return BigInt(value[BIGINT_TAG] as string);
|
|
81
|
+
}
|
|
82
|
+
if (DATE_TAG in value && typeof value[DATE_TAG] === 'string') {
|
|
83
|
+
return new Date(value[DATE_TAG] as string);
|
|
84
|
+
}
|
|
85
|
+
if (BYTES_TAG in value && typeof value[BYTES_TAG] === 'string') {
|
|
86
|
+
return Buffer.from(value[BYTES_TAG] as string, 'base64');
|
|
87
|
+
}
|
|
88
|
+
if (DECIMAL_TAG in value && typeof value[DECIMAL_TAG] === 'string') {
|
|
89
|
+
return decimalFactory(value[DECIMAL_TAG] as string);
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @deprecated Prefer {@link createRedisJsonReviver} when a decimalFactory is needed. */
|
|
96
|
+
export function redisJsonReviver(_key: string, value: unknown): unknown {
|
|
97
|
+
return createRedisJsonReviver()(_key, value);
|
|
98
|
+
}
|
|
99
|
+
|
|
28
100
|
export function redisJsonStringify(value: unknown): string {
|
|
29
|
-
return JSON.stringify(value,
|
|
101
|
+
return JSON.stringify(value, function (key, val) {
|
|
102
|
+
const holder = this as Record<string, unknown>;
|
|
103
|
+
const raw = key === '' ? value : holder[key];
|
|
104
|
+
if (raw instanceof Date) {
|
|
105
|
+
return redisJsonReplacer(key, raw);
|
|
106
|
+
}
|
|
107
|
+
return redisJsonReplacer(key, val);
|
|
108
|
+
});
|
|
30
109
|
}
|
|
31
110
|
|
|
32
|
-
export function redisJsonParse<T>(
|
|
33
|
-
|
|
111
|
+
export function redisJsonParse<T>(
|
|
112
|
+
raw: string,
|
|
113
|
+
options?: RedisJsonOptions,
|
|
114
|
+
): T {
|
|
115
|
+
return JSON.parse(raw, createRedisJsonReviver(options)) as T;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Deep clone via structuredClone, falling back to the tagged JSON codec so
|
|
120
|
+
* Date / BigInt / Buffer / Decimal survive (unlike plain JSON.stringify).
|
|
121
|
+
*/
|
|
122
|
+
export function cloneWithCodec<T>(value: T, options?: RedisJsonOptions): T {
|
|
123
|
+
if (value === null || value === undefined) return value;
|
|
124
|
+
if (typeof structuredClone === 'function') {
|
|
125
|
+
try {
|
|
126
|
+
return structuredClone(value);
|
|
127
|
+
} catch {
|
|
128
|
+
// Fall through for non-cloneable values (e.g. Decimal)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return redisJsonParse(redisJsonStringify(value), options);
|
|
34
132
|
}
|