@prismakit/redis 2.1.1 → 2.2.2

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 CHANGED
@@ -43,6 +43,7 @@ module.exports = __toCommonJS(index_exports);
43
43
 
44
44
  // src/redis-cache-adapter.ts
45
45
  var import_ioredis = __toESM(require("ioredis"), 1);
46
+ var import_zlib = require("zlib");
46
47
 
47
48
  // src/redis-json.ts
48
49
  var BIGINT_TAG = "__bigint";
@@ -70,18 +71,33 @@ function redisJsonParse(raw) {
70
71
 
71
72
  // src/redis-cache-adapter.ts
72
73
  var INDEX_TTL_BUFFER = 60;
74
+ var INVALIDATE_BY_INDEX_LUA = `
75
+ local members = redis.call('SMEMBERS', KEYS[1])
76
+ for i, member in ipairs(members) do
77
+ redis.call('DEL', member)
78
+ end
79
+ redis.call('DEL', KEYS[1])
80
+ return #members
81
+ `;
82
+ var COMPRESSED_PREFIX = "gz:";
73
83
  var RedisCacheAdapter = class {
74
84
  client;
75
85
  prefix;
76
86
  ready = false;
87
+ compression;
88
+ compressionThreshold;
77
89
  constructor(options = {}) {
78
90
  const {
79
91
  url,
80
92
  host = "localhost",
81
93
  port = 6379,
82
- prefix = "prismakit"
94
+ prefix = "prismakit",
95
+ compression = "none",
96
+ compressionThresholdBytes = 1024
83
97
  } = options;
84
98
  this.prefix = prefix;
99
+ this.compression = compression;
100
+ this.compressionThreshold = compressionThresholdBytes;
85
101
  this.client = url ? new import_ioredis.default(url, { lazyConnect: true }) : new import_ioredis.default({ host, port, lazyConnect: true });
86
102
  this.client.on("ready", () => {
87
103
  this.ready = true;
@@ -118,14 +134,30 @@ var RedisCacheAdapter = class {
118
134
  getPrefix() {
119
135
  return this.prefix;
120
136
  }
137
+ encode(value) {
138
+ const json = redisJsonStringify(value);
139
+ if (this.compression === "gzip" && Buffer.byteLength(json, "utf8") >= this.compressionThreshold) {
140
+ const compressed = (0, import_zlib.gzipSync)(Buffer.from(json, "utf8")).toString("base64");
141
+ return COMPRESSED_PREFIX + compressed;
142
+ }
143
+ return json;
144
+ }
145
+ decode(raw) {
146
+ if (raw.startsWith(COMPRESSED_PREFIX)) {
147
+ const buf = Buffer.from(raw.slice(COMPRESSED_PREFIX.length), "base64");
148
+ const json = (0, import_zlib.gunzipSync)(buf).toString("utf8");
149
+ return redisJsonParse(json);
150
+ }
151
+ return redisJsonParse(raw);
152
+ }
121
153
  // --- low-level ops (may throw) ---
122
154
  async get(key) {
123
155
  const raw = await this.client.get(key);
124
156
  if (raw === null) return null;
125
- return redisJsonParse(raw);
157
+ return this.decode(raw);
126
158
  }
127
159
  async set(key, value, ttlSeconds) {
128
- await this.client.set(key, redisJsonStringify(value), "EX", ttlSeconds);
160
+ await this.client.set(key, this.encode(value), "EX", ttlSeconds);
129
161
  }
130
162
  async del(...keys) {
131
163
  if (keys.length === 0) return;
@@ -143,22 +175,21 @@ var RedisCacheAdapter = class {
143
175
  return result === "OK";
144
176
  }
145
177
  /**
146
- * Atomically SET a cache key + SADD into an index SET + EXPIRE the index.
178
+ * Atomically SET a cache key + SADD into an index SET + EXPIRE the index
179
+ * via a single Redis pipeline.
147
180
  */
148
181
  async setWithIndex(key, value, ttlSeconds, indexKey) {
149
182
  const pipeline = this.client.pipeline();
150
- pipeline.set(key, redisJsonStringify(value), "EX", ttlSeconds);
183
+ pipeline.set(key, this.encode(value), "EX", ttlSeconds);
151
184
  pipeline.sadd(indexKey, key);
152
185
  pipeline.expire(indexKey, ttlSeconds + INDEX_TTL_BUFFER);
153
186
  await pipeline.exec();
154
187
  }
188
+ /**
189
+ * Atomic invalidation via Lua (SMEMBERS + DEL members + DEL index).
190
+ */
155
191
  async invalidateByIndex(indexKey) {
156
- const keys = await this.smembers(indexKey);
157
- if (keys.length > 0) {
158
- await this.del(...keys, indexKey);
159
- } else {
160
- await this.del(indexKey);
161
- }
192
+ await this.client.eval(INVALIDATE_BY_INDEX_LUA, 1, indexKey);
162
193
  }
163
194
  async saddAndExpire(key, members, ttlSeconds) {
164
195
  if (members.length === 0) return;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/redis-cache-adapter.ts","../src/redis-json.ts"],"sourcesContent":["export {\n RedisCacheAdapter,\n type RedisCacheAdapterOptions,\n} from './redis-cache-adapter';\n\nexport {\n redisJsonParse,\n redisJsonStringify,\n redisJsonReplacer,\n redisJsonReviver,\n} from './redis-json';\n\n/** Re-export cache debug helpers from core — do not duplicate. */\nexport {\n type CacheDebugStatus,\n cacheDebugStorage,\n isCacheDebugEnabled,\n recordCacheDebug,\n} from '@prismakit/core';\n\nexport type { CacheAdapter } from '@prismakit/core';\n","import Redis from 'ioredis';\nimport type { CacheAdapter } from '@prismakit/core';\nimport { redisJsonParse, redisJsonStringify } from './redis-json';\n\nconst INDEX_TTL_BUFFER = 60;\n\nexport type RedisCacheAdapterOptions = {\n url?: string;\n host?: string;\n port?: number;\n prefix?: string;\n};\n\n/**\n * Redis-backed {@link CacheAdapter}. Framework-agnostic — no NestJS / ConfigService.\n */\nexport class RedisCacheAdapter implements CacheAdapter {\n private readonly client: Redis;\n private readonly prefix: string;\n private ready = false;\n\n constructor(options: RedisCacheAdapterOptions = {}) {\n const {\n url,\n host = 'localhost',\n port = 6379,\n prefix = 'prismakit',\n } = options;\n\n this.prefix = prefix;\n this.client = url\n ? new Redis(url, { lazyConnect: true })\n : new Redis({ host, port, lazyConnect: true });\n\n this.client.on('ready', () => {\n this.ready = true;\n });\n this.client.on('error', (err) => {\n this.ready = false;\n console.warn('[RedisCacheAdapter] connection error', err.message);\n });\n this.client.on('close', () => {\n this.ready = false;\n });\n\n void this.connect();\n }\n\n async connect(): Promise<void> {\n if (this.client.status === 'ready' || this.client.status === 'connecting') {\n return;\n }\n try {\n await this.client.connect();\n } catch (err) {\n console.warn(\n '[RedisCacheAdapter] failed to connect',\n (err as Error).message,\n );\n }\n }\n\n async disconnect(): Promise<void> {\n await this.client?.quit();\n this.ready = false;\n }\n\n isReady(): boolean {\n return this.ready;\n }\n\n getPrefix(): string {\n return this.prefix;\n }\n\n // --- low-level ops (may throw) ---\n\n async get<T>(key: string): Promise<T | null> {\n const raw = await this.client.get(key);\n if (raw === null) return null;\n return redisJsonParse<T>(raw);\n }\n\n async set(key: string, value: unknown, ttlSeconds: number): Promise<void> {\n await this.client.set(key, redisJsonStringify(value), 'EX', ttlSeconds);\n }\n\n async del(...keys: string[]): Promise<void> {\n if (keys.length === 0) return;\n await this.client.del(...keys);\n }\n\n async sadd(key: string, ...members: string[]): Promise<void> {\n if (members.length === 0) return;\n await this.client.sadd(key, ...members);\n }\n\n async smembers(key: string): Promise<string[]> {\n return this.client.smembers(key);\n }\n\n async setNx(key: string, ttlSeconds: number): Promise<boolean> {\n const result = await this.client.set(key, '1', 'EX', ttlSeconds, 'NX');\n return result === 'OK';\n }\n\n /**\n * Atomically SET a cache key + SADD into an index SET + EXPIRE the index.\n */\n async setWithIndex(\n key: string,\n value: unknown,\n ttlSeconds: number,\n indexKey: string,\n ): Promise<void> {\n const pipeline = this.client.pipeline();\n pipeline.set(key, redisJsonStringify(value), 'EX', ttlSeconds);\n pipeline.sadd(indexKey, key);\n pipeline.expire(indexKey, ttlSeconds + INDEX_TTL_BUFFER);\n await pipeline.exec();\n }\n\n async invalidateByIndex(indexKey: string): Promise<void> {\n const keys = await this.smembers(indexKey);\n if (keys.length > 0) {\n await this.del(...keys, indexKey);\n } else {\n await this.del(indexKey);\n }\n }\n\n async saddAndExpire(\n key: string,\n members: string[],\n ttlSeconds: number,\n ): Promise<void> {\n if (members.length === 0) return;\n const pipeline = this.client.pipeline();\n pipeline.sadd(key, ...members);\n pipeline.expire(key, ttlSeconds + INDEX_TTL_BUFFER);\n await pipeline.exec();\n }\n\n // --- safe wrappers (never throw — warn + return fallback) ---\n\n async safeGet<T>(key: string): Promise<T | null> {\n try {\n return await this.get<T>(key);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeGet failed for key=${key}`,\n (err as Error).message,\n );\n return null;\n }\n }\n\n async safeSet(\n key: string,\n value: unknown,\n ttlSeconds: number,\n ): Promise<void> {\n try {\n await this.set(key, value, ttlSeconds);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSet failed for key=${key}`,\n (err as Error).message,\n );\n }\n }\n\n async safeDel(...keys: string[]): Promise<void> {\n try {\n await this.del(...keys);\n } catch (err) {\n console.warn(\n '[RedisCacheAdapter] safeDel failed',\n (err as Error).message,\n );\n }\n }\n\n async safeSetNx(key: string, ttlSeconds: number): Promise<boolean> {\n try {\n return await this.setNx(key, ttlSeconds);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSetNx failed for key=${key}`,\n (err as Error).message,\n );\n return false;\n }\n }\n\n async safeSetWithIndex(\n key: string,\n value: unknown,\n ttlSeconds: number,\n indexKey: string,\n ): Promise<void> {\n try {\n await this.setWithIndex(key, value, ttlSeconds, indexKey);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSetWithIndex failed for key=${key}`,\n (err as Error).message,\n );\n }\n }\n\n async safeInvalidateByIndex(indexKey: string): Promise<void> {\n try {\n await this.invalidateByIndex(indexKey);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeInvalidateByIndex failed for idx=${indexKey}`,\n (err as Error).message,\n );\n }\n }\n\n async safeSaddAndExpire(\n key: string,\n members: string[],\n ttlSeconds: number,\n ): Promise<void> {\n try {\n await this.saddAndExpire(key, members, ttlSeconds);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSaddAndExpire failed for key=${key}`,\n (err as Error).message,\n );\n }\n }\n\n async safeSmembers(key: string): Promise<string[]> {\n try {\n return await this.smembers(key);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSmembers failed for key=${key}`,\n (err as Error).message,\n );\n return [];\n }\n }\n}\n","const BIGINT_TAG = '__bigint';\n\n/** JSON.stringify replacer — Prisma `BigInt` fields are not JSON-serializable by default. */\nexport function redisJsonReplacer(_key: string, value: unknown): unknown {\n if (typeof value === 'bigint') {\n return { [BIGINT_TAG]: value.toString() };\n }\n return value;\n}\n\n/** JSON.parse reviver — restore tagged BigInt payloads from {@link redisJsonReplacer}. */\nexport function redisJsonReviver(_key: string, value: unknown): unknown {\n if (\n value !== null &&\n typeof value === 'object' &&\n !Array.isArray(value) &&\n Object.keys(value).length === 1 &&\n BIGINT_TAG in value\n ) {\n const raw = (value as Record<string, unknown>)[BIGINT_TAG];\n if (typeof raw === 'string') {\n return BigInt(raw);\n }\n }\n return value;\n}\n\nexport function redisJsonStringify(value: unknown): string {\n return JSON.stringify(value, redisJsonReplacer);\n}\n\nexport function redisJsonParse<T>(raw: string): T {\n return JSON.parse(raw, redisJsonReviver) as T;\n}\n"],"mappings":"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","names":["Redis"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/redis-cache-adapter.ts","../src/redis-json.ts"],"sourcesContent":["export {\n RedisCacheAdapter,\n type RedisCacheAdapterOptions,\n type RedisCompression,\n} from './redis-cache-adapter';\n\nexport {\n redisJsonParse,\n redisJsonStringify,\n redisJsonReplacer,\n redisJsonReviver,\n} from './redis-json';\n\n/** Re-export cache debug helpers from core — do not duplicate. */\nexport {\n type CacheDebugStatus,\n cacheDebugStorage,\n isCacheDebugEnabled,\n recordCacheDebug,\n} from '@prismakit/core';\n\nexport type { CacheAdapter } from '@prismakit/core';\n","import Redis from 'ioredis';\nimport { gzipSync, gunzipSync } from 'zlib';\nimport type { CacheAdapter } from '@prismakit/core';\nimport { redisJsonParse, redisJsonStringify } from './redis-json';\n\nconst INDEX_TTL_BUFFER = 60;\n\n/** Lua: atomically SMEMBERS index + DEL members + index. */\nconst INVALIDATE_BY_INDEX_LUA = `\nlocal members = redis.call('SMEMBERS', KEYS[1])\nfor i, member in ipairs(members) do\n redis.call('DEL', member)\nend\nredis.call('DEL', KEYS[1])\nreturn #members\n`;\n\nexport type RedisCompression = 'none' | 'gzip';\n\nexport type RedisCacheAdapterOptions = {\n url?: string;\n host?: string;\n port?: number;\n prefix?: string;\n /**\n * Compress payloads larger than `compressionThresholdBytes` (default 1024).\n * Uses gzip (widely available; zstd/lz4 can be added later).\n */\n compression?: RedisCompression;\n /** Minimum payload size (bytes) before compression (default 1024). */\n compressionThresholdBytes?: number;\n};\n\nconst COMPRESSED_PREFIX = 'gz:';\n\n/**\n * Redis-backed {@link CacheAdapter}. Framework-agnostic — no NestJS / ConfigService.\n */\nexport class RedisCacheAdapter implements CacheAdapter {\n private readonly client: Redis;\n private readonly prefix: string;\n private ready = false;\n private readonly compression: RedisCompression;\n private readonly compressionThreshold: number;\n\n constructor(options: RedisCacheAdapterOptions = {}) {\n const {\n url,\n host = 'localhost',\n port = 6379,\n prefix = 'prismakit',\n compression = 'none',\n compressionThresholdBytes = 1024,\n } = options;\n\n this.prefix = prefix;\n this.compression = compression;\n this.compressionThreshold = compressionThresholdBytes;\n this.client = url\n ? new Redis(url, { lazyConnect: true })\n : new Redis({ host, port, lazyConnect: true });\n\n this.client.on('ready', () => {\n this.ready = true;\n });\n this.client.on('error', (err) => {\n this.ready = false;\n console.warn('[RedisCacheAdapter] connection error', err.message);\n });\n this.client.on('close', () => {\n this.ready = false;\n });\n\n void this.connect();\n }\n\n async connect(): Promise<void> {\n if (this.client.status === 'ready' || this.client.status === 'connecting') {\n return;\n }\n try {\n await this.client.connect();\n } catch (err) {\n console.warn(\n '[RedisCacheAdapter] failed to connect',\n (err as Error).message,\n );\n }\n }\n\n async disconnect(): Promise<void> {\n await this.client?.quit();\n this.ready = false;\n }\n\n isReady(): boolean {\n return this.ready;\n }\n\n getPrefix(): string {\n return this.prefix;\n }\n\n private encode(value: unknown): string {\n const json = redisJsonStringify(value);\n if (\n this.compression === 'gzip' &&\n Buffer.byteLength(json, 'utf8') >= this.compressionThreshold\n ) {\n const compressed = gzipSync(Buffer.from(json, 'utf8')).toString('base64');\n return COMPRESSED_PREFIX + compressed;\n }\n return json;\n }\n\n private decode<T>(raw: string): T {\n if (raw.startsWith(COMPRESSED_PREFIX)) {\n const buf = Buffer.from(raw.slice(COMPRESSED_PREFIX.length), 'base64');\n const json = gunzipSync(buf).toString('utf8');\n return redisJsonParse<T>(json);\n }\n return redisJsonParse<T>(raw);\n }\n\n // --- low-level ops (may throw) ---\n\n async get<T>(key: string): Promise<T | null> {\n const raw = await this.client.get(key);\n if (raw === null) return null;\n return this.decode<T>(raw);\n }\n\n async set(key: string, value: unknown, ttlSeconds: number): Promise<void> {\n await this.client.set(key, this.encode(value), 'EX', ttlSeconds);\n }\n\n async del(...keys: string[]): Promise<void> {\n if (keys.length === 0) return;\n await this.client.del(...keys);\n }\n\n async sadd(key: string, ...members: string[]): Promise<void> {\n if (members.length === 0) return;\n await this.client.sadd(key, ...members);\n }\n\n async smembers(key: string): Promise<string[]> {\n return this.client.smembers(key);\n }\n\n async setNx(key: string, ttlSeconds: number): Promise<boolean> {\n const result = await this.client.set(key, '1', 'EX', ttlSeconds, 'NX');\n return result === 'OK';\n }\n\n /**\n * Atomically SET a cache key + SADD into an index SET + EXPIRE the index\n * via a single Redis pipeline.\n */\n async setWithIndex(\n key: string,\n value: unknown,\n ttlSeconds: number,\n indexKey: string,\n ): Promise<void> {\n const pipeline = this.client.pipeline();\n pipeline.set(key, this.encode(value), 'EX', ttlSeconds);\n pipeline.sadd(indexKey, key);\n pipeline.expire(indexKey, ttlSeconds + INDEX_TTL_BUFFER);\n await pipeline.exec();\n }\n\n /**\n * Atomic invalidation via Lua (SMEMBERS + DEL members + DEL index).\n */\n async invalidateByIndex(indexKey: string): Promise<void> {\n await this.client.eval(INVALIDATE_BY_INDEX_LUA, 1, indexKey);\n }\n\n async saddAndExpire(\n key: string,\n members: string[],\n ttlSeconds: number,\n ): Promise<void> {\n if (members.length === 0) return;\n const pipeline = this.client.pipeline();\n pipeline.sadd(key, ...members);\n pipeline.expire(key, ttlSeconds + INDEX_TTL_BUFFER);\n await pipeline.exec();\n }\n\n // --- safe wrappers (never throw — warn + return fallback) ---\n\n async safeGet<T>(key: string): Promise<T | null> {\n try {\n return await this.get<T>(key);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeGet failed for key=${key}`,\n (err as Error).message,\n );\n return null;\n }\n }\n\n async safeSet(\n key: string,\n value: unknown,\n ttlSeconds: number,\n ): Promise<void> {\n try {\n await this.set(key, value, ttlSeconds);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSet failed for key=${key}`,\n (err as Error).message,\n );\n }\n }\n\n async safeDel(...keys: string[]): Promise<void> {\n try {\n await this.del(...keys);\n } catch (err) {\n console.warn(\n '[RedisCacheAdapter] safeDel failed',\n (err as Error).message,\n );\n }\n }\n\n async safeSetNx(key: string, ttlSeconds: number): Promise<boolean> {\n try {\n return await this.setNx(key, ttlSeconds);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSetNx failed for key=${key}`,\n (err as Error).message,\n );\n return false;\n }\n }\n\n async safeSetWithIndex(\n key: string,\n value: unknown,\n ttlSeconds: number,\n indexKey: string,\n ): Promise<void> {\n try {\n await this.setWithIndex(key, value, ttlSeconds, indexKey);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSetWithIndex failed for key=${key}`,\n (err as Error).message,\n );\n }\n }\n\n async safeInvalidateByIndex(indexKey: string): Promise<void> {\n try {\n await this.invalidateByIndex(indexKey);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeInvalidateByIndex failed for idx=${indexKey}`,\n (err as Error).message,\n );\n }\n }\n\n async safeSaddAndExpire(\n key: string,\n members: string[],\n ttlSeconds: number,\n ): Promise<void> {\n try {\n await this.saddAndExpire(key, members, ttlSeconds);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSaddAndExpire failed for key=${key}`,\n (err as Error).message,\n );\n }\n }\n\n async safeSmembers(key: string): Promise<string[]> {\n try {\n return await this.smembers(key);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSmembers failed for key=${key}`,\n (err as Error).message,\n );\n return [];\n }\n }\n}\n","const BIGINT_TAG = '__bigint';\n\n/** JSON.stringify replacer — Prisma `BigInt` fields are not JSON-serializable by default. */\nexport function redisJsonReplacer(_key: string, value: unknown): unknown {\n if (typeof value === 'bigint') {\n return { [BIGINT_TAG]: value.toString() };\n }\n return value;\n}\n\n/** JSON.parse reviver — restore tagged BigInt payloads from {@link redisJsonReplacer}. */\nexport function redisJsonReviver(_key: string, value: unknown): unknown {\n if (\n value !== null &&\n typeof value === 'object' &&\n !Array.isArray(value) &&\n Object.keys(value).length === 1 &&\n BIGINT_TAG in value\n ) {\n const raw = (value as Record<string, unknown>)[BIGINT_TAG];\n if (typeof raw === 'string') {\n return BigInt(raw);\n }\n }\n return value;\n}\n\nexport function redisJsonStringify(value: unknown): string {\n return JSON.stringify(value, redisJsonReplacer);\n}\n\nexport function redisJsonParse<T>(raw: string): T {\n return JSON.parse(raw, redisJsonReviver) as T;\n}\n"],"mappings":"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","names":["Redis"]}
package/dist/index.d.cts CHANGED
@@ -1,11 +1,19 @@
1
1
  import { CacheAdapter } from '@prismakit/core';
2
2
  export { CacheAdapter, CacheDebugStatus, cacheDebugStorage, isCacheDebugEnabled, recordCacheDebug } from '@prismakit/core';
3
3
 
4
+ type RedisCompression = 'none' | 'gzip';
4
5
  type RedisCacheAdapterOptions = {
5
6
  url?: string;
6
7
  host?: string;
7
8
  port?: number;
8
9
  prefix?: string;
10
+ /**
11
+ * Compress payloads larger than `compressionThresholdBytes` (default 1024).
12
+ * Uses gzip (widely available; zstd/lz4 can be added later).
13
+ */
14
+ compression?: RedisCompression;
15
+ /** Minimum payload size (bytes) before compression (default 1024). */
16
+ compressionThresholdBytes?: number;
9
17
  };
10
18
  /**
11
19
  * Redis-backed {@link CacheAdapter}. Framework-agnostic — no NestJS / ConfigService.
@@ -14,11 +22,15 @@ declare class RedisCacheAdapter implements CacheAdapter {
14
22
  private readonly client;
15
23
  private readonly prefix;
16
24
  private ready;
25
+ private readonly compression;
26
+ private readonly compressionThreshold;
17
27
  constructor(options?: RedisCacheAdapterOptions);
18
28
  connect(): Promise<void>;
19
29
  disconnect(): Promise<void>;
20
30
  isReady(): boolean;
21
31
  getPrefix(): string;
32
+ private encode;
33
+ private decode;
22
34
  get<T>(key: string): Promise<T | null>;
23
35
  set(key: string, value: unknown, ttlSeconds: number): Promise<void>;
24
36
  del(...keys: string[]): Promise<void>;
@@ -26,9 +38,13 @@ declare class RedisCacheAdapter implements CacheAdapter {
26
38
  smembers(key: string): Promise<string[]>;
27
39
  setNx(key: string, ttlSeconds: number): Promise<boolean>;
28
40
  /**
29
- * Atomically SET a cache key + SADD into an index SET + EXPIRE the index.
41
+ * Atomically SET a cache key + SADD into an index SET + EXPIRE the index
42
+ * via a single Redis pipeline.
30
43
  */
31
44
  setWithIndex(key: string, value: unknown, ttlSeconds: number, indexKey: string): Promise<void>;
45
+ /**
46
+ * Atomic invalidation via Lua (SMEMBERS + DEL members + DEL index).
47
+ */
32
48
  invalidateByIndex(indexKey: string): Promise<void>;
33
49
  saddAndExpire(key: string, members: string[], ttlSeconds: number): Promise<void>;
34
50
  safeGet<T>(key: string): Promise<T | null>;
@@ -48,4 +64,4 @@ declare function redisJsonReviver(_key: string, value: unknown): unknown;
48
64
  declare function redisJsonStringify(value: unknown): string;
49
65
  declare function redisJsonParse<T>(raw: string): T;
50
66
 
51
- export { RedisCacheAdapter, type RedisCacheAdapterOptions, redisJsonParse, redisJsonReplacer, redisJsonReviver, redisJsonStringify };
67
+ export { RedisCacheAdapter, type RedisCacheAdapterOptions, type RedisCompression, redisJsonParse, redisJsonReplacer, redisJsonReviver, redisJsonStringify };
package/dist/index.d.ts CHANGED
@@ -1,11 +1,19 @@
1
1
  import { CacheAdapter } from '@prismakit/core';
2
2
  export { CacheAdapter, CacheDebugStatus, cacheDebugStorage, isCacheDebugEnabled, recordCacheDebug } from '@prismakit/core';
3
3
 
4
+ type RedisCompression = 'none' | 'gzip';
4
5
  type RedisCacheAdapterOptions = {
5
6
  url?: string;
6
7
  host?: string;
7
8
  port?: number;
8
9
  prefix?: string;
10
+ /**
11
+ * Compress payloads larger than `compressionThresholdBytes` (default 1024).
12
+ * Uses gzip (widely available; zstd/lz4 can be added later).
13
+ */
14
+ compression?: RedisCompression;
15
+ /** Minimum payload size (bytes) before compression (default 1024). */
16
+ compressionThresholdBytes?: number;
9
17
  };
10
18
  /**
11
19
  * Redis-backed {@link CacheAdapter}. Framework-agnostic — no NestJS / ConfigService.
@@ -14,11 +22,15 @@ declare class RedisCacheAdapter implements CacheAdapter {
14
22
  private readonly client;
15
23
  private readonly prefix;
16
24
  private ready;
25
+ private readonly compression;
26
+ private readonly compressionThreshold;
17
27
  constructor(options?: RedisCacheAdapterOptions);
18
28
  connect(): Promise<void>;
19
29
  disconnect(): Promise<void>;
20
30
  isReady(): boolean;
21
31
  getPrefix(): string;
32
+ private encode;
33
+ private decode;
22
34
  get<T>(key: string): Promise<T | null>;
23
35
  set(key: string, value: unknown, ttlSeconds: number): Promise<void>;
24
36
  del(...keys: string[]): Promise<void>;
@@ -26,9 +38,13 @@ declare class RedisCacheAdapter implements CacheAdapter {
26
38
  smembers(key: string): Promise<string[]>;
27
39
  setNx(key: string, ttlSeconds: number): Promise<boolean>;
28
40
  /**
29
- * Atomically SET a cache key + SADD into an index SET + EXPIRE the index.
41
+ * Atomically SET a cache key + SADD into an index SET + EXPIRE the index
42
+ * via a single Redis pipeline.
30
43
  */
31
44
  setWithIndex(key: string, value: unknown, ttlSeconds: number, indexKey: string): Promise<void>;
45
+ /**
46
+ * Atomic invalidation via Lua (SMEMBERS + DEL members + DEL index).
47
+ */
32
48
  invalidateByIndex(indexKey: string): Promise<void>;
33
49
  saddAndExpire(key: string, members: string[], ttlSeconds: number): Promise<void>;
34
50
  safeGet<T>(key: string): Promise<T | null>;
@@ -48,4 +64,4 @@ declare function redisJsonReviver(_key: string, value: unknown): unknown;
48
64
  declare function redisJsonStringify(value: unknown): string;
49
65
  declare function redisJsonParse<T>(raw: string): T;
50
66
 
51
- export { RedisCacheAdapter, type RedisCacheAdapterOptions, redisJsonParse, redisJsonReplacer, redisJsonReviver, redisJsonStringify };
67
+ export { RedisCacheAdapter, type RedisCacheAdapterOptions, type RedisCompression, redisJsonParse, redisJsonReplacer, redisJsonReviver, redisJsonStringify };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/redis-cache-adapter.ts
2
2
  import Redis from "ioredis";
3
+ import { gzipSync, gunzipSync } from "zlib";
3
4
 
4
5
  // src/redis-json.ts
5
6
  var BIGINT_TAG = "__bigint";
@@ -27,18 +28,33 @@ function redisJsonParse(raw) {
27
28
 
28
29
  // src/redis-cache-adapter.ts
29
30
  var INDEX_TTL_BUFFER = 60;
31
+ var INVALIDATE_BY_INDEX_LUA = `
32
+ local members = redis.call('SMEMBERS', KEYS[1])
33
+ for i, member in ipairs(members) do
34
+ redis.call('DEL', member)
35
+ end
36
+ redis.call('DEL', KEYS[1])
37
+ return #members
38
+ `;
39
+ var COMPRESSED_PREFIX = "gz:";
30
40
  var RedisCacheAdapter = class {
31
41
  client;
32
42
  prefix;
33
43
  ready = false;
44
+ compression;
45
+ compressionThreshold;
34
46
  constructor(options = {}) {
35
47
  const {
36
48
  url,
37
49
  host = "localhost",
38
50
  port = 6379,
39
- prefix = "prismakit"
51
+ prefix = "prismakit",
52
+ compression = "none",
53
+ compressionThresholdBytes = 1024
40
54
  } = options;
41
55
  this.prefix = prefix;
56
+ this.compression = compression;
57
+ this.compressionThreshold = compressionThresholdBytes;
42
58
  this.client = url ? new Redis(url, { lazyConnect: true }) : new Redis({ host, port, lazyConnect: true });
43
59
  this.client.on("ready", () => {
44
60
  this.ready = true;
@@ -75,14 +91,30 @@ var RedisCacheAdapter = class {
75
91
  getPrefix() {
76
92
  return this.prefix;
77
93
  }
94
+ encode(value) {
95
+ const json = redisJsonStringify(value);
96
+ if (this.compression === "gzip" && Buffer.byteLength(json, "utf8") >= this.compressionThreshold) {
97
+ const compressed = gzipSync(Buffer.from(json, "utf8")).toString("base64");
98
+ return COMPRESSED_PREFIX + compressed;
99
+ }
100
+ return json;
101
+ }
102
+ decode(raw) {
103
+ if (raw.startsWith(COMPRESSED_PREFIX)) {
104
+ const buf = Buffer.from(raw.slice(COMPRESSED_PREFIX.length), "base64");
105
+ const json = gunzipSync(buf).toString("utf8");
106
+ return redisJsonParse(json);
107
+ }
108
+ return redisJsonParse(raw);
109
+ }
78
110
  // --- low-level ops (may throw) ---
79
111
  async get(key) {
80
112
  const raw = await this.client.get(key);
81
113
  if (raw === null) return null;
82
- return redisJsonParse(raw);
114
+ return this.decode(raw);
83
115
  }
84
116
  async set(key, value, ttlSeconds) {
85
- await this.client.set(key, redisJsonStringify(value), "EX", ttlSeconds);
117
+ await this.client.set(key, this.encode(value), "EX", ttlSeconds);
86
118
  }
87
119
  async del(...keys) {
88
120
  if (keys.length === 0) return;
@@ -100,22 +132,21 @@ var RedisCacheAdapter = class {
100
132
  return result === "OK";
101
133
  }
102
134
  /**
103
- * Atomically SET a cache key + SADD into an index SET + EXPIRE the index.
135
+ * Atomically SET a cache key + SADD into an index SET + EXPIRE the index
136
+ * via a single Redis pipeline.
104
137
  */
105
138
  async setWithIndex(key, value, ttlSeconds, indexKey) {
106
139
  const pipeline = this.client.pipeline();
107
- pipeline.set(key, redisJsonStringify(value), "EX", ttlSeconds);
140
+ pipeline.set(key, this.encode(value), "EX", ttlSeconds);
108
141
  pipeline.sadd(indexKey, key);
109
142
  pipeline.expire(indexKey, ttlSeconds + INDEX_TTL_BUFFER);
110
143
  await pipeline.exec();
111
144
  }
145
+ /**
146
+ * Atomic invalidation via Lua (SMEMBERS + DEL members + DEL index).
147
+ */
112
148
  async invalidateByIndex(indexKey) {
113
- const keys = await this.smembers(indexKey);
114
- if (keys.length > 0) {
115
- await this.del(...keys, indexKey);
116
- } else {
117
- await this.del(indexKey);
118
- }
149
+ await this.client.eval(INVALIDATE_BY_INDEX_LUA, 1, indexKey);
119
150
  }
120
151
  async saddAndExpire(key, members, ttlSeconds) {
121
152
  if (members.length === 0) return;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/redis-cache-adapter.ts","../src/redis-json.ts","../src/index.ts"],"sourcesContent":["import Redis from 'ioredis';\nimport type { CacheAdapter } from '@prismakit/core';\nimport { redisJsonParse, redisJsonStringify } from './redis-json';\n\nconst INDEX_TTL_BUFFER = 60;\n\nexport type RedisCacheAdapterOptions = {\n url?: string;\n host?: string;\n port?: number;\n prefix?: string;\n};\n\n/**\n * Redis-backed {@link CacheAdapter}. Framework-agnostic — no NestJS / ConfigService.\n */\nexport class RedisCacheAdapter implements CacheAdapter {\n private readonly client: Redis;\n private readonly prefix: string;\n private ready = false;\n\n constructor(options: RedisCacheAdapterOptions = {}) {\n const {\n url,\n host = 'localhost',\n port = 6379,\n prefix = 'prismakit',\n } = options;\n\n this.prefix = prefix;\n this.client = url\n ? new Redis(url, { lazyConnect: true })\n : new Redis({ host, port, lazyConnect: true });\n\n this.client.on('ready', () => {\n this.ready = true;\n });\n this.client.on('error', (err) => {\n this.ready = false;\n console.warn('[RedisCacheAdapter] connection error', err.message);\n });\n this.client.on('close', () => {\n this.ready = false;\n });\n\n void this.connect();\n }\n\n async connect(): Promise<void> {\n if (this.client.status === 'ready' || this.client.status === 'connecting') {\n return;\n }\n try {\n await this.client.connect();\n } catch (err) {\n console.warn(\n '[RedisCacheAdapter] failed to connect',\n (err as Error).message,\n );\n }\n }\n\n async disconnect(): Promise<void> {\n await this.client?.quit();\n this.ready = false;\n }\n\n isReady(): boolean {\n return this.ready;\n }\n\n getPrefix(): string {\n return this.prefix;\n }\n\n // --- low-level ops (may throw) ---\n\n async get<T>(key: string): Promise<T | null> {\n const raw = await this.client.get(key);\n if (raw === null) return null;\n return redisJsonParse<T>(raw);\n }\n\n async set(key: string, value: unknown, ttlSeconds: number): Promise<void> {\n await this.client.set(key, redisJsonStringify(value), 'EX', ttlSeconds);\n }\n\n async del(...keys: string[]): Promise<void> {\n if (keys.length === 0) return;\n await this.client.del(...keys);\n }\n\n async sadd(key: string, ...members: string[]): Promise<void> {\n if (members.length === 0) return;\n await this.client.sadd(key, ...members);\n }\n\n async smembers(key: string): Promise<string[]> {\n return this.client.smembers(key);\n }\n\n async setNx(key: string, ttlSeconds: number): Promise<boolean> {\n const result = await this.client.set(key, '1', 'EX', ttlSeconds, 'NX');\n return result === 'OK';\n }\n\n /**\n * Atomically SET a cache key + SADD into an index SET + EXPIRE the index.\n */\n async setWithIndex(\n key: string,\n value: unknown,\n ttlSeconds: number,\n indexKey: string,\n ): Promise<void> {\n const pipeline = this.client.pipeline();\n pipeline.set(key, redisJsonStringify(value), 'EX', ttlSeconds);\n pipeline.sadd(indexKey, key);\n pipeline.expire(indexKey, ttlSeconds + INDEX_TTL_BUFFER);\n await pipeline.exec();\n }\n\n async invalidateByIndex(indexKey: string): Promise<void> {\n const keys = await this.smembers(indexKey);\n if (keys.length > 0) {\n await this.del(...keys, indexKey);\n } else {\n await this.del(indexKey);\n }\n }\n\n async saddAndExpire(\n key: string,\n members: string[],\n ttlSeconds: number,\n ): Promise<void> {\n if (members.length === 0) return;\n const pipeline = this.client.pipeline();\n pipeline.sadd(key, ...members);\n pipeline.expire(key, ttlSeconds + INDEX_TTL_BUFFER);\n await pipeline.exec();\n }\n\n // --- safe wrappers (never throw — warn + return fallback) ---\n\n async safeGet<T>(key: string): Promise<T | null> {\n try {\n return await this.get<T>(key);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeGet failed for key=${key}`,\n (err as Error).message,\n );\n return null;\n }\n }\n\n async safeSet(\n key: string,\n value: unknown,\n ttlSeconds: number,\n ): Promise<void> {\n try {\n await this.set(key, value, ttlSeconds);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSet failed for key=${key}`,\n (err as Error).message,\n );\n }\n }\n\n async safeDel(...keys: string[]): Promise<void> {\n try {\n await this.del(...keys);\n } catch (err) {\n console.warn(\n '[RedisCacheAdapter] safeDel failed',\n (err as Error).message,\n );\n }\n }\n\n async safeSetNx(key: string, ttlSeconds: number): Promise<boolean> {\n try {\n return await this.setNx(key, ttlSeconds);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSetNx failed for key=${key}`,\n (err as Error).message,\n );\n return false;\n }\n }\n\n async safeSetWithIndex(\n key: string,\n value: unknown,\n ttlSeconds: number,\n indexKey: string,\n ): Promise<void> {\n try {\n await this.setWithIndex(key, value, ttlSeconds, indexKey);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSetWithIndex failed for key=${key}`,\n (err as Error).message,\n );\n }\n }\n\n async safeInvalidateByIndex(indexKey: string): Promise<void> {\n try {\n await this.invalidateByIndex(indexKey);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeInvalidateByIndex failed for idx=${indexKey}`,\n (err as Error).message,\n );\n }\n }\n\n async safeSaddAndExpire(\n key: string,\n members: string[],\n ttlSeconds: number,\n ): Promise<void> {\n try {\n await this.saddAndExpire(key, members, ttlSeconds);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSaddAndExpire failed for key=${key}`,\n (err as Error).message,\n );\n }\n }\n\n async safeSmembers(key: string): Promise<string[]> {\n try {\n return await this.smembers(key);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSmembers failed for key=${key}`,\n (err as Error).message,\n );\n return [];\n }\n }\n}\n","const BIGINT_TAG = '__bigint';\n\n/** JSON.stringify replacer — Prisma `BigInt` fields are not JSON-serializable by default. */\nexport function redisJsonReplacer(_key: string, value: unknown): unknown {\n if (typeof value === 'bigint') {\n return { [BIGINT_TAG]: value.toString() };\n }\n return value;\n}\n\n/** JSON.parse reviver — restore tagged BigInt payloads from {@link redisJsonReplacer}. */\nexport function redisJsonReviver(_key: string, value: unknown): unknown {\n if (\n value !== null &&\n typeof value === 'object' &&\n !Array.isArray(value) &&\n Object.keys(value).length === 1 &&\n BIGINT_TAG in value\n ) {\n const raw = (value as Record<string, unknown>)[BIGINT_TAG];\n if (typeof raw === 'string') {\n return BigInt(raw);\n }\n }\n return value;\n}\n\nexport function redisJsonStringify(value: unknown): string {\n return JSON.stringify(value, redisJsonReplacer);\n}\n\nexport function redisJsonParse<T>(raw: string): T {\n return JSON.parse(raw, redisJsonReviver) as T;\n}\n","export {\n RedisCacheAdapter,\n type RedisCacheAdapterOptions,\n} from './redis-cache-adapter';\n\nexport {\n redisJsonParse,\n redisJsonStringify,\n redisJsonReplacer,\n redisJsonReviver,\n} from './redis-json';\n\n/** Re-export cache debug helpers from core — do not duplicate. */\nexport {\n type CacheDebugStatus,\n cacheDebugStorage,\n isCacheDebugEnabled,\n recordCacheDebug,\n} from '@prismakit/core';\n\nexport type { CacheAdapter } from '@prismakit/core';\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/redis-cache-adapter.ts","../src/redis-json.ts","../src/index.ts"],"sourcesContent":["import Redis from 'ioredis';\nimport { gzipSync, gunzipSync } from 'zlib';\nimport type { CacheAdapter } from '@prismakit/core';\nimport { redisJsonParse, redisJsonStringify } from './redis-json';\n\nconst INDEX_TTL_BUFFER = 60;\n\n/** Lua: atomically SMEMBERS index + DEL members + index. */\nconst INVALIDATE_BY_INDEX_LUA = `\nlocal members = redis.call('SMEMBERS', KEYS[1])\nfor i, member in ipairs(members) do\n redis.call('DEL', member)\nend\nredis.call('DEL', KEYS[1])\nreturn #members\n`;\n\nexport type RedisCompression = 'none' | 'gzip';\n\nexport type RedisCacheAdapterOptions = {\n url?: string;\n host?: string;\n port?: number;\n prefix?: string;\n /**\n * Compress payloads larger than `compressionThresholdBytes` (default 1024).\n * Uses gzip (widely available; zstd/lz4 can be added later).\n */\n compression?: RedisCompression;\n /** Minimum payload size (bytes) before compression (default 1024). */\n compressionThresholdBytes?: number;\n};\n\nconst COMPRESSED_PREFIX = 'gz:';\n\n/**\n * Redis-backed {@link CacheAdapter}. Framework-agnostic — no NestJS / ConfigService.\n */\nexport class RedisCacheAdapter implements CacheAdapter {\n private readonly client: Redis;\n private readonly prefix: string;\n private ready = false;\n private readonly compression: RedisCompression;\n private readonly compressionThreshold: number;\n\n constructor(options: RedisCacheAdapterOptions = {}) {\n const {\n url,\n host = 'localhost',\n port = 6379,\n prefix = 'prismakit',\n compression = 'none',\n compressionThresholdBytes = 1024,\n } = options;\n\n this.prefix = prefix;\n this.compression = compression;\n this.compressionThreshold = compressionThresholdBytes;\n this.client = url\n ? new Redis(url, { lazyConnect: true })\n : new Redis({ host, port, lazyConnect: true });\n\n this.client.on('ready', () => {\n this.ready = true;\n });\n this.client.on('error', (err) => {\n this.ready = false;\n console.warn('[RedisCacheAdapter] connection error', err.message);\n });\n this.client.on('close', () => {\n this.ready = false;\n });\n\n void this.connect();\n }\n\n async connect(): Promise<void> {\n if (this.client.status === 'ready' || this.client.status === 'connecting') {\n return;\n }\n try {\n await this.client.connect();\n } catch (err) {\n console.warn(\n '[RedisCacheAdapter] failed to connect',\n (err as Error).message,\n );\n }\n }\n\n async disconnect(): Promise<void> {\n await this.client?.quit();\n this.ready = false;\n }\n\n isReady(): boolean {\n return this.ready;\n }\n\n getPrefix(): string {\n return this.prefix;\n }\n\n private encode(value: unknown): string {\n const json = redisJsonStringify(value);\n if (\n this.compression === 'gzip' &&\n Buffer.byteLength(json, 'utf8') >= this.compressionThreshold\n ) {\n const compressed = gzipSync(Buffer.from(json, 'utf8')).toString('base64');\n return COMPRESSED_PREFIX + compressed;\n }\n return json;\n }\n\n private decode<T>(raw: string): T {\n if (raw.startsWith(COMPRESSED_PREFIX)) {\n const buf = Buffer.from(raw.slice(COMPRESSED_PREFIX.length), 'base64');\n const json = gunzipSync(buf).toString('utf8');\n return redisJsonParse<T>(json);\n }\n return redisJsonParse<T>(raw);\n }\n\n // --- low-level ops (may throw) ---\n\n async get<T>(key: string): Promise<T | null> {\n const raw = await this.client.get(key);\n if (raw === null) return null;\n return this.decode<T>(raw);\n }\n\n async set(key: string, value: unknown, ttlSeconds: number): Promise<void> {\n await this.client.set(key, this.encode(value), 'EX', ttlSeconds);\n }\n\n async del(...keys: string[]): Promise<void> {\n if (keys.length === 0) return;\n await this.client.del(...keys);\n }\n\n async sadd(key: string, ...members: string[]): Promise<void> {\n if (members.length === 0) return;\n await this.client.sadd(key, ...members);\n }\n\n async smembers(key: string): Promise<string[]> {\n return this.client.smembers(key);\n }\n\n async setNx(key: string, ttlSeconds: number): Promise<boolean> {\n const result = await this.client.set(key, '1', 'EX', ttlSeconds, 'NX');\n return result === 'OK';\n }\n\n /**\n * Atomically SET a cache key + SADD into an index SET + EXPIRE the index\n * via a single Redis pipeline.\n */\n async setWithIndex(\n key: string,\n value: unknown,\n ttlSeconds: number,\n indexKey: string,\n ): Promise<void> {\n const pipeline = this.client.pipeline();\n pipeline.set(key, this.encode(value), 'EX', ttlSeconds);\n pipeline.sadd(indexKey, key);\n pipeline.expire(indexKey, ttlSeconds + INDEX_TTL_BUFFER);\n await pipeline.exec();\n }\n\n /**\n * Atomic invalidation via Lua (SMEMBERS + DEL members + DEL index).\n */\n async invalidateByIndex(indexKey: string): Promise<void> {\n await this.client.eval(INVALIDATE_BY_INDEX_LUA, 1, indexKey);\n }\n\n async saddAndExpire(\n key: string,\n members: string[],\n ttlSeconds: number,\n ): Promise<void> {\n if (members.length === 0) return;\n const pipeline = this.client.pipeline();\n pipeline.sadd(key, ...members);\n pipeline.expire(key, ttlSeconds + INDEX_TTL_BUFFER);\n await pipeline.exec();\n }\n\n // --- safe wrappers (never throw — warn + return fallback) ---\n\n async safeGet<T>(key: string): Promise<T | null> {\n try {\n return await this.get<T>(key);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeGet failed for key=${key}`,\n (err as Error).message,\n );\n return null;\n }\n }\n\n async safeSet(\n key: string,\n value: unknown,\n ttlSeconds: number,\n ): Promise<void> {\n try {\n await this.set(key, value, ttlSeconds);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSet failed for key=${key}`,\n (err as Error).message,\n );\n }\n }\n\n async safeDel(...keys: string[]): Promise<void> {\n try {\n await this.del(...keys);\n } catch (err) {\n console.warn(\n '[RedisCacheAdapter] safeDel failed',\n (err as Error).message,\n );\n }\n }\n\n async safeSetNx(key: string, ttlSeconds: number): Promise<boolean> {\n try {\n return await this.setNx(key, ttlSeconds);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSetNx failed for key=${key}`,\n (err as Error).message,\n );\n return false;\n }\n }\n\n async safeSetWithIndex(\n key: string,\n value: unknown,\n ttlSeconds: number,\n indexKey: string,\n ): Promise<void> {\n try {\n await this.setWithIndex(key, value, ttlSeconds, indexKey);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSetWithIndex failed for key=${key}`,\n (err as Error).message,\n );\n }\n }\n\n async safeInvalidateByIndex(indexKey: string): Promise<void> {\n try {\n await this.invalidateByIndex(indexKey);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeInvalidateByIndex failed for idx=${indexKey}`,\n (err as Error).message,\n );\n }\n }\n\n async safeSaddAndExpire(\n key: string,\n members: string[],\n ttlSeconds: number,\n ): Promise<void> {\n try {\n await this.saddAndExpire(key, members, ttlSeconds);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSaddAndExpire failed for key=${key}`,\n (err as Error).message,\n );\n }\n }\n\n async safeSmembers(key: string): Promise<string[]> {\n try {\n return await this.smembers(key);\n } catch (err) {\n console.warn(\n `[RedisCacheAdapter] safeSmembers failed for key=${key}`,\n (err as Error).message,\n );\n return [];\n }\n }\n}\n","const BIGINT_TAG = '__bigint';\n\n/** JSON.stringify replacer — Prisma `BigInt` fields are not JSON-serializable by default. */\nexport function redisJsonReplacer(_key: string, value: unknown): unknown {\n if (typeof value === 'bigint') {\n return { [BIGINT_TAG]: value.toString() };\n }\n return value;\n}\n\n/** JSON.parse reviver — restore tagged BigInt payloads from {@link redisJsonReplacer}. */\nexport function redisJsonReviver(_key: string, value: unknown): unknown {\n if (\n value !== null &&\n typeof value === 'object' &&\n !Array.isArray(value) &&\n Object.keys(value).length === 1 &&\n BIGINT_TAG in value\n ) {\n const raw = (value as Record<string, unknown>)[BIGINT_TAG];\n if (typeof raw === 'string') {\n return BigInt(raw);\n }\n }\n return value;\n}\n\nexport function redisJsonStringify(value: unknown): string {\n return JSON.stringify(value, redisJsonReplacer);\n}\n\nexport function redisJsonParse<T>(raw: string): T {\n return JSON.parse(raw, redisJsonReviver) as T;\n}\n","export {\n RedisCacheAdapter,\n type RedisCacheAdapterOptions,\n type RedisCompression,\n} from './redis-cache-adapter';\n\nexport {\n redisJsonParse,\n redisJsonStringify,\n redisJsonReplacer,\n redisJsonReviver,\n} from './redis-json';\n\n/** Re-export cache debug helpers from core — do not duplicate. */\nexport {\n type CacheDebugStatus,\n cacheDebugStorage,\n isCacheDebugEnabled,\n recordCacheDebug,\n} from '@prismakit/core';\n\nexport type { CacheAdapter } from '@prismakit/core';\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prismakit/redis",
3
- "version": "2.1.1",
3
+ "version": "2.2.2",
4
4
  "description": "Redis CacheAdapter for @prismakit/core",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -19,14 +19,15 @@
19
19
  },
20
20
  "files": [
21
21
  "dist",
22
+ "src",
22
23
  "LICENSE",
23
24
  "README.md"
24
25
  ],
25
26
  "dependencies": {
26
- "@prismakit/core": "2.1.1"
27
+ "@prismakit/core": "2.2.2"
27
28
  },
28
29
  "peerDependencies": {
29
- "@prismakit/core": ">=2.1.1",
30
+ "@prismakit/core": ">=2.2.2",
30
31
  "ioredis": ">=5.0.0"
31
32
  },
32
33
  "devDependencies": {
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ const mockClient = {
4
+ status: 'wait',
5
+ on: vi.fn(),
6
+ connect: vi.fn().mockResolvedValue(undefined),
7
+ quit: vi.fn().mockResolvedValue(undefined),
8
+ get: vi.fn(),
9
+ set: vi.fn(),
10
+ del: vi.fn(),
11
+ sadd: vi.fn(),
12
+ smembers: vi.fn(),
13
+ pipeline: vi.fn(),
14
+ };
15
+
16
+ vi.mock('ioredis', () => ({
17
+ default: vi.fn().mockImplementation(() => mockClient),
18
+ }));
19
+
20
+ describe('RedisCacheAdapter', () => {
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ mockClient.status = 'wait';
24
+ mockClient.on.mockImplementation(() => mockClient);
25
+ });
26
+
27
+ it('connects with lazyConnect and exposes prefix', async () => {
28
+ const { RedisCacheAdapter } = await import('../redis-cache-adapter');
29
+ const adapter = new RedisCacheAdapter({
30
+ host: '127.0.0.1',
31
+ port: 6379,
32
+ prefix: 'test',
33
+ });
34
+
35
+ expect(adapter.getPrefix()).toBe('test');
36
+ expect(mockClient.connect).toHaveBeenCalled();
37
+ await adapter.disconnect();
38
+ expect(mockClient.quit).toHaveBeenCalled();
39
+ });
40
+
41
+ it('safeGet returns null on failure', async () => {
42
+ const { RedisCacheAdapter } = await import('../redis-cache-adapter');
43
+ mockClient.get.mockRejectedValue(new Error('down'));
44
+ const adapter = new RedisCacheAdapter({ prefix: 't' });
45
+ await expect(adapter.safeGet('k')).resolves.toBeNull();
46
+ });
47
+
48
+ it('round-trips BigInt via redisJson helpers', async () => {
49
+ const { redisJsonParse, redisJsonStringify } = await import('../redis-json');
50
+ const payload = { id: '1', qty: 9007199254740993n, nested: { n: 1n } };
51
+ const raw = redisJsonStringify(payload);
52
+ expect(raw).toContain('__bigint');
53
+ const parsed = redisJsonParse<typeof payload>(raw);
54
+ expect(parsed.qty).toBe(9007199254740993n);
55
+ expect(parsed.nested.n).toBe(1n);
56
+ });
57
+
58
+ it('set/get uses BigInt-safe JSON', async () => {
59
+ const { RedisCacheAdapter } = await import('../redis-cache-adapter');
60
+ let stored: string | undefined;
61
+ mockClient.set.mockImplementation((_k: string, v: string) => {
62
+ stored = v;
63
+ return Promise.resolve('OK');
64
+ });
65
+ mockClient.get.mockImplementation(() => Promise.resolve(stored ?? null));
66
+
67
+ const adapter = new RedisCacheAdapter({ prefix: 't' });
68
+ await adapter.set('k', { amount: 42n }, 60);
69
+ expect(stored).toContain('__bigint');
70
+ await expect(adapter.get<{ amount: bigint }>('k')).resolves.toEqual({
71
+ amount: 42n,
72
+ });
73
+ });
74
+ });
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export {
2
+ RedisCacheAdapter,
3
+ type RedisCacheAdapterOptions,
4
+ type RedisCompression,
5
+ } from './redis-cache-adapter';
6
+
7
+ export {
8
+ redisJsonParse,
9
+ redisJsonStringify,
10
+ redisJsonReplacer,
11
+ redisJsonReviver,
12
+ } from './redis-json';
13
+
14
+ /** Re-export cache debug helpers from core — do not duplicate. */
15
+ export {
16
+ type CacheDebugStatus,
17
+ cacheDebugStorage,
18
+ isCacheDebugEnabled,
19
+ recordCacheDebug,
20
+ } from '@prismakit/core';
21
+
22
+ export type { CacheAdapter } from '@prismakit/core';
@@ -0,0 +1,297 @@
1
+ import Redis from 'ioredis';
2
+ import { gzipSync, gunzipSync } from 'zlib';
3
+ import type { CacheAdapter } from '@prismakit/core';
4
+ import { redisJsonParse, redisJsonStringify } from './redis-json';
5
+
6
+ const INDEX_TTL_BUFFER = 60;
7
+
8
+ /** Lua: atomically SMEMBERS index + DEL members + index. */
9
+ const INVALIDATE_BY_INDEX_LUA = `
10
+ local members = redis.call('SMEMBERS', KEYS[1])
11
+ for i, member in ipairs(members) do
12
+ redis.call('DEL', member)
13
+ end
14
+ redis.call('DEL', KEYS[1])
15
+ return #members
16
+ `;
17
+
18
+ export type RedisCompression = 'none' | 'gzip';
19
+
20
+ export type RedisCacheAdapterOptions = {
21
+ url?: string;
22
+ host?: string;
23
+ port?: number;
24
+ prefix?: string;
25
+ /**
26
+ * Compress payloads larger than `compressionThresholdBytes` (default 1024).
27
+ * Uses gzip (widely available; zstd/lz4 can be added later).
28
+ */
29
+ compression?: RedisCompression;
30
+ /** Minimum payload size (bytes) before compression (default 1024). */
31
+ compressionThresholdBytes?: number;
32
+ };
33
+
34
+ const COMPRESSED_PREFIX = 'gz:';
35
+
36
+ /**
37
+ * Redis-backed {@link CacheAdapter}. Framework-agnostic — no NestJS / ConfigService.
38
+ */
39
+ export class RedisCacheAdapter implements CacheAdapter {
40
+ private readonly client: Redis;
41
+ private readonly prefix: string;
42
+ private ready = false;
43
+ private readonly compression: RedisCompression;
44
+ private readonly compressionThreshold: number;
45
+
46
+ constructor(options: RedisCacheAdapterOptions = {}) {
47
+ const {
48
+ url,
49
+ host = 'localhost',
50
+ port = 6379,
51
+ prefix = 'prismakit',
52
+ compression = 'none',
53
+ compressionThresholdBytes = 1024,
54
+ } = options;
55
+
56
+ this.prefix = prefix;
57
+ this.compression = compression;
58
+ this.compressionThreshold = compressionThresholdBytes;
59
+ this.client = url
60
+ ? new Redis(url, { lazyConnect: true })
61
+ : new Redis({ host, port, lazyConnect: true });
62
+
63
+ this.client.on('ready', () => {
64
+ this.ready = true;
65
+ });
66
+ this.client.on('error', (err) => {
67
+ this.ready = false;
68
+ console.warn('[RedisCacheAdapter] connection error', err.message);
69
+ });
70
+ this.client.on('close', () => {
71
+ this.ready = false;
72
+ });
73
+
74
+ void this.connect();
75
+ }
76
+
77
+ async connect(): Promise<void> {
78
+ if (this.client.status === 'ready' || this.client.status === 'connecting') {
79
+ return;
80
+ }
81
+ try {
82
+ await this.client.connect();
83
+ } catch (err) {
84
+ console.warn(
85
+ '[RedisCacheAdapter] failed to connect',
86
+ (err as Error).message,
87
+ );
88
+ }
89
+ }
90
+
91
+ async disconnect(): Promise<void> {
92
+ await this.client?.quit();
93
+ this.ready = false;
94
+ }
95
+
96
+ isReady(): boolean {
97
+ return this.ready;
98
+ }
99
+
100
+ getPrefix(): string {
101
+ return this.prefix;
102
+ }
103
+
104
+ private encode(value: unknown): string {
105
+ const json = redisJsonStringify(value);
106
+ if (
107
+ this.compression === 'gzip' &&
108
+ Buffer.byteLength(json, 'utf8') >= this.compressionThreshold
109
+ ) {
110
+ const compressed = gzipSync(Buffer.from(json, 'utf8')).toString('base64');
111
+ return COMPRESSED_PREFIX + compressed;
112
+ }
113
+ return json;
114
+ }
115
+
116
+ private decode<T>(raw: string): T {
117
+ if (raw.startsWith(COMPRESSED_PREFIX)) {
118
+ const buf = Buffer.from(raw.slice(COMPRESSED_PREFIX.length), 'base64');
119
+ const json = gunzipSync(buf).toString('utf8');
120
+ return redisJsonParse<T>(json);
121
+ }
122
+ return redisJsonParse<T>(raw);
123
+ }
124
+
125
+ // --- low-level ops (may throw) ---
126
+
127
+ async get<T>(key: string): Promise<T | null> {
128
+ const raw = await this.client.get(key);
129
+ if (raw === null) return null;
130
+ return this.decode<T>(raw);
131
+ }
132
+
133
+ async set(key: string, value: unknown, ttlSeconds: number): Promise<void> {
134
+ await this.client.set(key, this.encode(value), 'EX', ttlSeconds);
135
+ }
136
+
137
+ async del(...keys: string[]): Promise<void> {
138
+ if (keys.length === 0) return;
139
+ await this.client.del(...keys);
140
+ }
141
+
142
+ async sadd(key: string, ...members: string[]): Promise<void> {
143
+ if (members.length === 0) return;
144
+ await this.client.sadd(key, ...members);
145
+ }
146
+
147
+ async smembers(key: string): Promise<string[]> {
148
+ return this.client.smembers(key);
149
+ }
150
+
151
+ async setNx(key: string, ttlSeconds: number): Promise<boolean> {
152
+ const result = await this.client.set(key, '1', 'EX', ttlSeconds, 'NX');
153
+ return result === 'OK';
154
+ }
155
+
156
+ /**
157
+ * Atomically SET a cache key + SADD into an index SET + EXPIRE the index
158
+ * via a single Redis pipeline.
159
+ */
160
+ async setWithIndex(
161
+ key: string,
162
+ value: unknown,
163
+ ttlSeconds: number,
164
+ indexKey: string,
165
+ ): Promise<void> {
166
+ const pipeline = this.client.pipeline();
167
+ pipeline.set(key, this.encode(value), 'EX', ttlSeconds);
168
+ pipeline.sadd(indexKey, key);
169
+ pipeline.expire(indexKey, ttlSeconds + INDEX_TTL_BUFFER);
170
+ await pipeline.exec();
171
+ }
172
+
173
+ /**
174
+ * Atomic invalidation via Lua (SMEMBERS + DEL members + DEL index).
175
+ */
176
+ async invalidateByIndex(indexKey: string): Promise<void> {
177
+ await this.client.eval(INVALIDATE_BY_INDEX_LUA, 1, indexKey);
178
+ }
179
+
180
+ async saddAndExpire(
181
+ key: string,
182
+ members: string[],
183
+ ttlSeconds: number,
184
+ ): Promise<void> {
185
+ if (members.length === 0) return;
186
+ const pipeline = this.client.pipeline();
187
+ pipeline.sadd(key, ...members);
188
+ pipeline.expire(key, ttlSeconds + INDEX_TTL_BUFFER);
189
+ await pipeline.exec();
190
+ }
191
+
192
+ // --- safe wrappers (never throw — warn + return fallback) ---
193
+
194
+ async safeGet<T>(key: string): Promise<T | null> {
195
+ try {
196
+ return await this.get<T>(key);
197
+ } catch (err) {
198
+ console.warn(
199
+ `[RedisCacheAdapter] safeGet failed for key=${key}`,
200
+ (err as Error).message,
201
+ );
202
+ return null;
203
+ }
204
+ }
205
+
206
+ async safeSet(
207
+ key: string,
208
+ value: unknown,
209
+ ttlSeconds: number,
210
+ ): Promise<void> {
211
+ try {
212
+ await this.set(key, value, ttlSeconds);
213
+ } catch (err) {
214
+ console.warn(
215
+ `[RedisCacheAdapter] safeSet failed for key=${key}`,
216
+ (err as Error).message,
217
+ );
218
+ }
219
+ }
220
+
221
+ async safeDel(...keys: string[]): Promise<void> {
222
+ try {
223
+ await this.del(...keys);
224
+ } catch (err) {
225
+ console.warn(
226
+ '[RedisCacheAdapter] safeDel failed',
227
+ (err as Error).message,
228
+ );
229
+ }
230
+ }
231
+
232
+ async safeSetNx(key: string, ttlSeconds: number): Promise<boolean> {
233
+ try {
234
+ return await this.setNx(key, ttlSeconds);
235
+ } catch (err) {
236
+ console.warn(
237
+ `[RedisCacheAdapter] safeSetNx failed for key=${key}`,
238
+ (err as Error).message,
239
+ );
240
+ return false;
241
+ }
242
+ }
243
+
244
+ async safeSetWithIndex(
245
+ key: string,
246
+ value: unknown,
247
+ ttlSeconds: number,
248
+ indexKey: string,
249
+ ): Promise<void> {
250
+ try {
251
+ await this.setWithIndex(key, value, ttlSeconds, indexKey);
252
+ } catch (err) {
253
+ console.warn(
254
+ `[RedisCacheAdapter] safeSetWithIndex failed for key=${key}`,
255
+ (err as Error).message,
256
+ );
257
+ }
258
+ }
259
+
260
+ async safeInvalidateByIndex(indexKey: string): Promise<void> {
261
+ try {
262
+ await this.invalidateByIndex(indexKey);
263
+ } catch (err) {
264
+ console.warn(
265
+ `[RedisCacheAdapter] safeInvalidateByIndex failed for idx=${indexKey}`,
266
+ (err as Error).message,
267
+ );
268
+ }
269
+ }
270
+
271
+ async safeSaddAndExpire(
272
+ key: string,
273
+ members: string[],
274
+ ttlSeconds: number,
275
+ ): Promise<void> {
276
+ try {
277
+ await this.saddAndExpire(key, members, ttlSeconds);
278
+ } catch (err) {
279
+ console.warn(
280
+ `[RedisCacheAdapter] safeSaddAndExpire failed for key=${key}`,
281
+ (err as Error).message,
282
+ );
283
+ }
284
+ }
285
+
286
+ async safeSmembers(key: string): Promise<string[]> {
287
+ try {
288
+ return await this.smembers(key);
289
+ } catch (err) {
290
+ console.warn(
291
+ `[RedisCacheAdapter] safeSmembers failed for key=${key}`,
292
+ (err as Error).message,
293
+ );
294
+ return [];
295
+ }
296
+ }
297
+ }
@@ -0,0 +1,34 @@
1
+ const BIGINT_TAG = '__bigint';
2
+
3
+ /** JSON.stringify replacer — Prisma `BigInt` fields are not JSON-serializable by default. */
4
+ export function redisJsonReplacer(_key: string, value: unknown): unknown {
5
+ if (typeof value === 'bigint') {
6
+ return { [BIGINT_TAG]: value.toString() };
7
+ }
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 {
13
+ if (
14
+ value !== null &&
15
+ typeof value === 'object' &&
16
+ !Array.isArray(value) &&
17
+ Object.keys(value).length === 1 &&
18
+ BIGINT_TAG in value
19
+ ) {
20
+ const raw = (value as Record<string, unknown>)[BIGINT_TAG];
21
+ if (typeof raw === 'string') {
22
+ return BigInt(raw);
23
+ }
24
+ }
25
+ return value;
26
+ }
27
+
28
+ export function redisJsonStringify(value: unknown): string {
29
+ return JSON.stringify(value, redisJsonReplacer);
30
+ }
31
+
32
+ export function redisJsonParse<T>(raw: string): T {
33
+ return JSON.parse(raw, redisJsonReviver) as T;
34
+ }