@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.
@@ -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 { redisJsonParse, redisJsonStringify } from './redis-json';
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
- console.warn(
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
- console.warn(
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
- console.warn(
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
- console.warn(
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
- console.warn(
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
- console.warn(
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
- console.warn(
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
- console.warn(
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
- console.warn(
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
- /** JSON.stringify replacer Prisma `BigInt` fields are not JSON-serializable by default. */
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
- return value;
9
- }
10
-
11
- /** JSON.parse reviver — restore tagged BigInt payloads from {@link redisJsonReplacer}. */
12
- export function redisJsonReviver(_key: string, value: unknown): unknown {
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 !== null &&
54
+ value &&
15
55
  typeof value === 'object' &&
16
- !Array.isArray(value) &&
17
- Object.keys(value).length === 1 &&
18
- BIGINT_TAG in value
56
+ (value as { type?: string }).type === 'Buffer' &&
57
+ Array.isArray((value as { data?: unknown }).data)
19
58
  ) {
20
- const raw = (value as Record<string, unknown>)[BIGINT_TAG];
21
- if (typeof raw === 'string') {
22
- return BigInt(raw);
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, redisJsonReplacer);
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>(raw: string): T {
33
- return JSON.parse(raw, redisJsonReviver) as T;
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
  }