@forklaunch/infrastructure-redis 1.4.0 → 1.4.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.
@@ -94,11 +94,27 @@ export class RedisTtlCache implements TtlCache {
94
94
  private decryptValue(value: string, compliance?: ComplianceContext): string {
95
95
  if (!compliance || !this.encryptor) return value;
96
96
  if (!isEncrypted(value)) return value;
97
+ // If a value is encrypted but we cannot decrypt it, treat the entry as
98
+ // unreadable rather than returning the ciphertext. Returning the raw
99
+ // bytes lets callers (e.g. cache services that JSON.parse the result)
100
+ // surface garbage as if it were a successful read, masking key/tenant
101
+ // mismatches and corrupting downstream consumers. Throwing forces the
102
+ // caller's catch path (cache miss) to run.
103
+ let decrypted: string | null;
97
104
  try {
98
- return this.encryptor.decrypt(value, compliance.tenantId) ?? value;
99
- } catch {
100
- return value;
105
+ decrypted = this.encryptor.decrypt(value, compliance.tenantId);
106
+ } catch (err) {
107
+ throw new Error(
108
+ `Redis: failed to decrypt value for tenant ${compliance.tenantId}: ${err instanceof Error ? err.message : String(err)}`,
109
+ { cause: err }
110
+ );
111
+ }
112
+ if (decrypted === null) {
113
+ throw new Error(
114
+ `Redis: encrypted value for tenant ${compliance.tenantId} could not be decrypted (null result)`
115
+ );
101
116
  }
117
+ return decrypted;
102
118
  }
103
119
 
104
120
  private parseValue<T>(
@@ -314,7 +330,10 @@ export class RedisTtlCache implements TtlCache {
314
330
  queueName: string,
315
331
  compliance?: ComplianceContext
316
332
  ): Promise<T> {
317
- const value = await this.client.lRange(queueName, 0, 0);
333
+ // Queues use lPush + rPop, so the next item to dequeue lives at the
334
+ // tail of the list, not the head. Reading lRange(0, 0) would return the
335
+ // most-recently-pushed item — the opposite of dequeue order.
336
+ const value = await this.client.lRange(queueName, -1, -1);
318
337
  return this.parseValue<T>(value[0], compliance);
319
338
  }
320
339
 
@@ -323,8 +342,13 @@ export class RedisTtlCache implements TtlCache {
323
342
  pageSize: number,
324
343
  compliance?: ComplianceContext
325
344
  ): Promise<T[]> {
326
- const values = await this.client.lRange(queueName, 0, pageSize - 1);
345
+ // Tail-relative range: the last `pageSize` items, where the very last
346
+ // item is the next to be dequeued. Redis returns them in list order
347
+ // (oldest-tail-end first), so reverse to put next-to-dequeue first.
348
+ const values = await this.client.lRange(queueName, -pageSize, -1);
349
+ if (values.length === 0) return [];
327
350
  return values
351
+ .reverse()
328
352
  .map((value) => this.parseValue<T>(value, compliance))
329
353
  .filter(Boolean);
330
354
  }
package/lib/index.js CHANGED
@@ -66,11 +66,21 @@ var RedisTtlCache = class {
66
66
  decryptValue(value, compliance) {
67
67
  if (!compliance || !this.encryptor) return value;
68
68
  if (!isEncrypted(value)) return value;
69
+ let decrypted;
69
70
  try {
70
- return this.encryptor.decrypt(value, compliance.tenantId) ?? value;
71
- } catch {
72
- return value;
71
+ decrypted = this.encryptor.decrypt(value, compliance.tenantId);
72
+ } catch (err) {
73
+ throw new Error(
74
+ `Redis: failed to decrypt value for tenant ${compliance.tenantId}: ${err instanceof Error ? err.message : String(err)}`,
75
+ { cause: err }
76
+ );
77
+ }
78
+ if (decrypted === null) {
79
+ throw new Error(
80
+ `Redis: encrypted value for tenant ${compliance.tenantId} could not be decrypted (null result)`
81
+ );
73
82
  }
83
+ return decrypted;
74
84
  }
75
85
  parseValue(value, compliance) {
76
86
  if (value == null) {
@@ -220,12 +230,13 @@ var RedisTtlCache = class {
220
230
  return results.map((result) => result === 1);
221
231
  }
222
232
  async peekQueueRecord(queueName, compliance) {
223
- const value = await this.client.lRange(queueName, 0, 0);
233
+ const value = await this.client.lRange(queueName, -1, -1);
224
234
  return this.parseValue(value[0], compliance);
225
235
  }
226
236
  async peekQueueRecords(queueName, pageSize, compliance) {
227
- const values = await this.client.lRange(queueName, 0, pageSize - 1);
228
- return values.map((value) => this.parseValue(value, compliance)).filter(Boolean);
237
+ const values = await this.client.lRange(queueName, -pageSize, -1);
238
+ if (values.length === 0) return [];
239
+ return values.reverse().map((value) => this.parseValue(value, compliance)).filter(Boolean);
229
240
  }
230
241
  async disconnect() {
231
242
  await this.client.quit();
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../index.ts"],"sourcesContent":["import { safeParse, safeStringify } from '@forklaunch/common';\nimport {\n type ComplianceContext,\n TtlCache,\n TtlCacheRecord\n} from '@forklaunch/core/cache';\nimport {\n evaluateTelemetryOptions,\n MetricsDefinition,\n OpenTelemetryCollector,\n TelemetryOptions\n} from '@forklaunch/core/http';\nimport { type FieldEncryptor } from '@forklaunch/core/persistence';\nimport { createClient, RedisClientOptions } from 'redis';\n\n/**\n * Type representing a raw reply from Redis commands.\n * Can be a string, number, Buffer, null, undefined, or array of raw replies.\n */\ntype RedisCommandRawReply =\n | string\n | number\n | Buffer\n | null\n | undefined\n | Array<RedisCommandRawReply>;\n\nconst ENCRYPTED_PREFIXES = ['v1:', 'v2:'] as const;\n\nfunction isEncrypted(value: string): boolean {\n return ENCRYPTED_PREFIXES.some((p) => value.startsWith(p));\n}\n\n/**\n * Options for configuring encryption on the Redis cache.\n * Required — every consumer must explicitly configure encryption.\n */\nexport interface RedisCacheEncryptionOptions {\n /** The FieldEncryptor instance to use for encrypting cache values. */\n encryptor: FieldEncryptor;\n}\n\n/**\n * Class representing a Redis-based TTL (Time-To-Live) cache.\n * Implements the TtlCache interface to provide caching functionality with automatic expiration.\n *\n * Encryption is activated per-operation when a `compliance` context is provided.\n * Without it, values are stored and read as plaintext.\n */\nexport class RedisTtlCache implements TtlCache {\n private client;\n private telemetryOptions;\n private encryptor?: FieldEncryptor;\n\n /**\n * Creates an instance of RedisTtlCache.\n *\n * @param {number} ttlMilliseconds - The default Time-To-Live in milliseconds for cache entries\n * @param {OpenTelemetryCollector<MetricsDefinition>} openTelemetryCollector - Collector for OpenTelemetry metrics\n * @param {RedisClientOptions} options - Configuration options for the Redis client\n * @param {TelemetryOptions} telemetryOptions - Configuration options for telemetry\n * @param {RedisCacheEncryptionOptions} encryption - Encryption configuration\n */\n constructor(\n private ttlMilliseconds: number,\n private openTelemetryCollector: OpenTelemetryCollector<MetricsDefinition>,\n options: RedisClientOptions,\n telemetryOptions: TelemetryOptions,\n encryption: RedisCacheEncryptionOptions\n ) {\n this.telemetryOptions = evaluateTelemetryOptions(telemetryOptions);\n this.client = createClient(options);\n this.encryptor = encryption.encryptor;\n if (this.telemetryOptions.enabled.logging) {\n this.client.on('error', (err) => this.openTelemetryCollector.error(err));\n this.client.connect().catch(this.openTelemetryCollector.error);\n }\n }\n\n // ---------------------------------------------------------------------------\n // Encryption helpers — only active when compliance context is provided\n // ---------------------------------------------------------------------------\n\n private encryptValue(\n serialized: string,\n compliance?: ComplianceContext\n ): string {\n if (!compliance || !this.encryptor) return serialized;\n return (\n this.encryptor.encrypt(serialized, compliance.tenantId) ?? serialized\n );\n }\n\n private decryptValue(value: string, compliance?: ComplianceContext): string {\n if (!compliance || !this.encryptor) return value;\n if (!isEncrypted(value)) return value;\n try {\n return this.encryptor.decrypt(value, compliance.tenantId) ?? value;\n } catch {\n return value;\n }\n }\n\n private parseValue<T>(\n value: RedisCommandRawReply,\n compliance?: ComplianceContext\n ): T {\n if (value == null) {\n return null as T;\n }\n\n if (Array.isArray(value)) {\n return value.map((v) => this.parseValue<T>(v, compliance)) as T;\n }\n\n if (Buffer.isBuffer(value)) {\n return value.toJSON() as T;\n }\n\n switch (typeof value) {\n case 'object':\n case 'string':\n return safeParse(this.decryptValue(String(value), compliance)) as T;\n case 'number':\n return value as T;\n }\n }\n\n // ---------------------------------------------------------------------------\n // TtlCache implementation\n // ---------------------------------------------------------------------------\n\n async putRecord<T>(\n { key, value, ttlMilliseconds = this.ttlMilliseconds }: TtlCacheRecord<T>,\n compliance?: ComplianceContext\n ): Promise<void> {\n if (this.telemetryOptions.enabled.logging) {\n this.openTelemetryCollector.info(`Putting record into cache: ${key}`);\n }\n await this.client.set(\n key,\n this.encryptValue(safeStringify(value), compliance),\n { PX: ttlMilliseconds }\n );\n }\n\n async putBatchRecords<T>(\n cacheRecords: TtlCacheRecord<T>[],\n compliance?: ComplianceContext\n ): Promise<void> {\n const multiCommand = this.client.multi();\n for (const { key, value, ttlMilliseconds } of cacheRecords) {\n multiCommand.set(\n key,\n this.encryptValue(safeStringify(value), compliance),\n { PX: ttlMilliseconds || this.ttlMilliseconds }\n );\n }\n await multiCommand.exec();\n }\n\n async enqueueRecord<T>(\n queueName: string,\n value: T,\n compliance?: ComplianceContext\n ): Promise<void> {\n await this.client.lPush(\n queueName,\n this.encryptValue(safeStringify(value), compliance)\n );\n }\n\n async enqueueBatchRecords<T>(\n queueName: string,\n values: T[],\n compliance?: ComplianceContext\n ): Promise<void> {\n const multiCommand = this.client.multi();\n for (const value of values) {\n multiCommand.lPush(\n queueName,\n this.encryptValue(safeStringify(value), compliance)\n );\n }\n await multiCommand.exec();\n }\n\n async deleteRecord(cacheRecordKey: string): Promise<void> {\n await this.client.del(cacheRecordKey);\n }\n\n async deleteBatchRecords(cacheRecordKeys: string[]): Promise<void> {\n const multiCommand = this.client.multi();\n for (const key of cacheRecordKeys) {\n multiCommand.del(key);\n }\n await multiCommand.exec();\n }\n\n async dequeueRecord<T>(\n queueName: string,\n compliance?: ComplianceContext\n ): Promise<T> {\n const value = await this.client.rPop(queueName);\n if (value === null) {\n throw new Error(`Queue is empty: ${queueName}`);\n }\n return safeParse(this.decryptValue(value, compliance)) as T;\n }\n\n async dequeueBatchRecords<T>(\n queueName: string,\n pageSize: number,\n compliance?: ComplianceContext\n ): Promise<T[]> {\n const multiCommand = this.client.multi();\n for (let i = 0; i < pageSize; i++) {\n multiCommand.rPop(queueName);\n }\n const values = await multiCommand.exec();\n return values\n .map((value) =>\n this.parseValue<T>(value as unknown as RedisCommandRawReply, compliance)\n )\n .filter(Boolean);\n }\n\n async readRecord<T>(\n cacheRecordKey: string,\n compliance?: ComplianceContext\n ): Promise<TtlCacheRecord<T>> {\n const [value, ttl] = await this.client\n .multi()\n .get(cacheRecordKey)\n .ttl(cacheRecordKey)\n .exec();\n if (value === null) {\n throw new Error(`Record not found for key: ${cacheRecordKey}`);\n }\n\n return {\n key: cacheRecordKey,\n value: this.parseValue<T>(\n value as unknown as RedisCommandRawReply,\n compliance\n ),\n ttlMilliseconds:\n this.parseValue<number>(\n ttl as unknown as RedisCommandRawReply,\n compliance\n ) * 1000\n };\n }\n\n async readBatchRecords<T>(\n cacheRecordKeysOrPrefix: string[] | string,\n compliance?: ComplianceContext\n ): Promise<TtlCacheRecord<T>[]> {\n const keys = Array.isArray(cacheRecordKeysOrPrefix)\n ? cacheRecordKeysOrPrefix\n : await this.client.keys(cacheRecordKeysOrPrefix + '*');\n const multiCommand = this.client.multi();\n for (const key of keys) {\n multiCommand.get(key);\n multiCommand.ttl(key);\n }\n const values = await multiCommand.exec();\n return values.reduce<TtlCacheRecord<T>[]>((acc, value, index) => {\n if (index % 2 === 0) {\n const maybeValue = this.parseValue<T>(\n value as unknown as RedisCommandRawReply,\n compliance\n );\n const ttl = this.parseValue<number>(\n values[index + 1] as unknown as RedisCommandRawReply,\n compliance\n );\n if (maybeValue && ttl) {\n acc.push({\n key: keys[index / 2],\n value: maybeValue,\n ttlMilliseconds: ttl * 1000\n });\n }\n }\n return acc;\n }, []);\n }\n\n async listKeys(pattern_prefix: string): Promise<string[]> {\n return this.client.keys(pattern_prefix + '*');\n }\n\n async peekRecord(cacheRecordKey: string): Promise<boolean> {\n const result = await this.client.exists(cacheRecordKey);\n return result === 1;\n }\n\n async peekBatchRecords(\n cacheRecordKeysOrPrefix: string[] | string\n ): Promise<boolean[]> {\n const keys = Array.isArray(cacheRecordKeysOrPrefix)\n ? cacheRecordKeysOrPrefix\n : await this.client.keys(cacheRecordKeysOrPrefix + '*');\n const multiCommand = this.client.multi();\n for (const key of keys) {\n multiCommand.exists(key);\n }\n const results = await multiCommand.exec();\n return results.map((result) => (result as unknown as number) === 1);\n }\n\n async peekQueueRecord<T>(\n queueName: string,\n compliance?: ComplianceContext\n ): Promise<T> {\n const value = await this.client.lRange(queueName, 0, 0);\n return this.parseValue<T>(value[0], compliance);\n }\n\n async peekQueueRecords<T>(\n queueName: string,\n pageSize: number,\n compliance?: ComplianceContext\n ): Promise<T[]> {\n const values = await this.client.lRange(queueName, 0, pageSize - 1);\n return values\n .map((value) => this.parseValue<T>(value, compliance))\n .filter(Boolean);\n }\n\n async disconnect(): Promise<void> {\n await this.client.quit();\n }\n\n getTtlMilliseconds(): number {\n return this.ttlMilliseconds;\n }\n\n getClient(): typeof this.client {\n return this.client;\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../index.ts"],"sourcesContent":["import { safeParse, safeStringify } from '@forklaunch/common';\nimport {\n type ComplianceContext,\n TtlCache,\n TtlCacheRecord\n} from '@forklaunch/core/cache';\nimport {\n evaluateTelemetryOptions,\n MetricsDefinition,\n OpenTelemetryCollector,\n TelemetryOptions\n} from '@forklaunch/core/http';\nimport { type FieldEncryptor } from '@forklaunch/core/persistence';\nimport { createClient, RedisClientOptions } from 'redis';\n\n/**\n * Type representing a raw reply from Redis commands.\n * Can be a string, number, Buffer, null, undefined, or array of raw replies.\n */\ntype RedisCommandRawReply =\n | string\n | number\n | Buffer\n | null\n | undefined\n | Array<RedisCommandRawReply>;\n\nconst ENCRYPTED_PREFIXES = ['v1:', 'v2:'] as const;\n\nfunction isEncrypted(value: string): boolean {\n return ENCRYPTED_PREFIXES.some((p) => value.startsWith(p));\n}\n\n/**\n * Options for configuring encryption on the Redis cache.\n * Required — every consumer must explicitly configure encryption.\n */\nexport interface RedisCacheEncryptionOptions {\n /** The FieldEncryptor instance to use for encrypting cache values. */\n encryptor: FieldEncryptor;\n}\n\n/**\n * Class representing a Redis-based TTL (Time-To-Live) cache.\n * Implements the TtlCache interface to provide caching functionality with automatic expiration.\n *\n * Encryption is activated per-operation when a `compliance` context is provided.\n * Without it, values are stored and read as plaintext.\n */\nexport class RedisTtlCache implements TtlCache {\n private client;\n private telemetryOptions;\n private encryptor?: FieldEncryptor;\n\n /**\n * Creates an instance of RedisTtlCache.\n *\n * @param {number} ttlMilliseconds - The default Time-To-Live in milliseconds for cache entries\n * @param {OpenTelemetryCollector<MetricsDefinition>} openTelemetryCollector - Collector for OpenTelemetry metrics\n * @param {RedisClientOptions} options - Configuration options for the Redis client\n * @param {TelemetryOptions} telemetryOptions - Configuration options for telemetry\n * @param {RedisCacheEncryptionOptions} encryption - Encryption configuration\n */\n constructor(\n private ttlMilliseconds: number,\n private openTelemetryCollector: OpenTelemetryCollector<MetricsDefinition>,\n options: RedisClientOptions,\n telemetryOptions: TelemetryOptions,\n encryption: RedisCacheEncryptionOptions\n ) {\n this.telemetryOptions = evaluateTelemetryOptions(telemetryOptions);\n this.client = createClient(options);\n this.encryptor = encryption.encryptor;\n if (this.telemetryOptions.enabled.logging) {\n this.client.on('error', (err) => this.openTelemetryCollector.error(err));\n this.client.connect().catch(this.openTelemetryCollector.error);\n }\n }\n\n // ---------------------------------------------------------------------------\n // Encryption helpers — only active when compliance context is provided\n // ---------------------------------------------------------------------------\n\n private encryptValue(\n serialized: string,\n compliance?: ComplianceContext\n ): string {\n if (!compliance || !this.encryptor) return serialized;\n return (\n this.encryptor.encrypt(serialized, compliance.tenantId) ?? serialized\n );\n }\n\n private decryptValue(value: string, compliance?: ComplianceContext): string {\n if (!compliance || !this.encryptor) return value;\n if (!isEncrypted(value)) return value;\n // If a value is encrypted but we cannot decrypt it, treat the entry as\n // unreadable rather than returning the ciphertext. Returning the raw\n // bytes lets callers (e.g. cache services that JSON.parse the result)\n // surface garbage as if it were a successful read, masking key/tenant\n // mismatches and corrupting downstream consumers. Throwing forces the\n // caller's catch path (cache miss) to run.\n let decrypted: string | null;\n try {\n decrypted = this.encryptor.decrypt(value, compliance.tenantId);\n } catch (err) {\n throw new Error(\n `Redis: failed to decrypt value for tenant ${compliance.tenantId}: ${err instanceof Error ? err.message : String(err)}`,\n { cause: err }\n );\n }\n if (decrypted === null) {\n throw new Error(\n `Redis: encrypted value for tenant ${compliance.tenantId} could not be decrypted (null result)`\n );\n }\n return decrypted;\n }\n\n private parseValue<T>(\n value: RedisCommandRawReply,\n compliance?: ComplianceContext\n ): T {\n if (value == null) {\n return null as T;\n }\n\n if (Array.isArray(value)) {\n return value.map((v) => this.parseValue<T>(v, compliance)) as T;\n }\n\n if (Buffer.isBuffer(value)) {\n return value.toJSON() as T;\n }\n\n switch (typeof value) {\n case 'object':\n case 'string':\n return safeParse(this.decryptValue(String(value), compliance)) as T;\n case 'number':\n return value as T;\n }\n }\n\n // ---------------------------------------------------------------------------\n // TtlCache implementation\n // ---------------------------------------------------------------------------\n\n async putRecord<T>(\n { key, value, ttlMilliseconds = this.ttlMilliseconds }: TtlCacheRecord<T>,\n compliance?: ComplianceContext\n ): Promise<void> {\n if (this.telemetryOptions.enabled.logging) {\n this.openTelemetryCollector.info(`Putting record into cache: ${key}`);\n }\n await this.client.set(\n key,\n this.encryptValue(safeStringify(value), compliance),\n { PX: ttlMilliseconds }\n );\n }\n\n async putBatchRecords<T>(\n cacheRecords: TtlCacheRecord<T>[],\n compliance?: ComplianceContext\n ): Promise<void> {\n const multiCommand = this.client.multi();\n for (const { key, value, ttlMilliseconds } of cacheRecords) {\n multiCommand.set(\n key,\n this.encryptValue(safeStringify(value), compliance),\n { PX: ttlMilliseconds || this.ttlMilliseconds }\n );\n }\n await multiCommand.exec();\n }\n\n async enqueueRecord<T>(\n queueName: string,\n value: T,\n compliance?: ComplianceContext\n ): Promise<void> {\n await this.client.lPush(\n queueName,\n this.encryptValue(safeStringify(value), compliance)\n );\n }\n\n async enqueueBatchRecords<T>(\n queueName: string,\n values: T[],\n compliance?: ComplianceContext\n ): Promise<void> {\n const multiCommand = this.client.multi();\n for (const value of values) {\n multiCommand.lPush(\n queueName,\n this.encryptValue(safeStringify(value), compliance)\n );\n }\n await multiCommand.exec();\n }\n\n async deleteRecord(cacheRecordKey: string): Promise<void> {\n await this.client.del(cacheRecordKey);\n }\n\n async deleteBatchRecords(cacheRecordKeys: string[]): Promise<void> {\n const multiCommand = this.client.multi();\n for (const key of cacheRecordKeys) {\n multiCommand.del(key);\n }\n await multiCommand.exec();\n }\n\n async dequeueRecord<T>(\n queueName: string,\n compliance?: ComplianceContext\n ): Promise<T> {\n const value = await this.client.rPop(queueName);\n if (value === null) {\n throw new Error(`Queue is empty: ${queueName}`);\n }\n return safeParse(this.decryptValue(value, compliance)) as T;\n }\n\n async dequeueBatchRecords<T>(\n queueName: string,\n pageSize: number,\n compliance?: ComplianceContext\n ): Promise<T[]> {\n const multiCommand = this.client.multi();\n for (let i = 0; i < pageSize; i++) {\n multiCommand.rPop(queueName);\n }\n const values = await multiCommand.exec();\n return values\n .map((value) =>\n this.parseValue<T>(value as unknown as RedisCommandRawReply, compliance)\n )\n .filter(Boolean);\n }\n\n async readRecord<T>(\n cacheRecordKey: string,\n compliance?: ComplianceContext\n ): Promise<TtlCacheRecord<T>> {\n const [value, ttl] = await this.client\n .multi()\n .get(cacheRecordKey)\n .ttl(cacheRecordKey)\n .exec();\n if (value === null) {\n throw new Error(`Record not found for key: ${cacheRecordKey}`);\n }\n\n return {\n key: cacheRecordKey,\n value: this.parseValue<T>(\n value as unknown as RedisCommandRawReply,\n compliance\n ),\n ttlMilliseconds:\n this.parseValue<number>(\n ttl as unknown as RedisCommandRawReply,\n compliance\n ) * 1000\n };\n }\n\n async readBatchRecords<T>(\n cacheRecordKeysOrPrefix: string[] | string,\n compliance?: ComplianceContext\n ): Promise<TtlCacheRecord<T>[]> {\n const keys = Array.isArray(cacheRecordKeysOrPrefix)\n ? cacheRecordKeysOrPrefix\n : await this.client.keys(cacheRecordKeysOrPrefix + '*');\n const multiCommand = this.client.multi();\n for (const key of keys) {\n multiCommand.get(key);\n multiCommand.ttl(key);\n }\n const values = await multiCommand.exec();\n return values.reduce<TtlCacheRecord<T>[]>((acc, value, index) => {\n if (index % 2 === 0) {\n const maybeValue = this.parseValue<T>(\n value as unknown as RedisCommandRawReply,\n compliance\n );\n const ttl = this.parseValue<number>(\n values[index + 1] as unknown as RedisCommandRawReply,\n compliance\n );\n if (maybeValue && ttl) {\n acc.push({\n key: keys[index / 2],\n value: maybeValue,\n ttlMilliseconds: ttl * 1000\n });\n }\n }\n return acc;\n }, []);\n }\n\n async listKeys(pattern_prefix: string): Promise<string[]> {\n return this.client.keys(pattern_prefix + '*');\n }\n\n async peekRecord(cacheRecordKey: string): Promise<boolean> {\n const result = await this.client.exists(cacheRecordKey);\n return result === 1;\n }\n\n async peekBatchRecords(\n cacheRecordKeysOrPrefix: string[] | string\n ): Promise<boolean[]> {\n const keys = Array.isArray(cacheRecordKeysOrPrefix)\n ? cacheRecordKeysOrPrefix\n : await this.client.keys(cacheRecordKeysOrPrefix + '*');\n const multiCommand = this.client.multi();\n for (const key of keys) {\n multiCommand.exists(key);\n }\n const results = await multiCommand.exec();\n return results.map((result) => (result as unknown as number) === 1);\n }\n\n async peekQueueRecord<T>(\n queueName: string,\n compliance?: ComplianceContext\n ): Promise<T> {\n // Queues use lPush + rPop, so the next item to dequeue lives at the\n // tail of the list, not the head. Reading lRange(0, 0) would return the\n // most-recently-pushed item — the opposite of dequeue order.\n const value = await this.client.lRange(queueName, -1, -1);\n return this.parseValue<T>(value[0], compliance);\n }\n\n async peekQueueRecords<T>(\n queueName: string,\n pageSize: number,\n compliance?: ComplianceContext\n ): Promise<T[]> {\n // Tail-relative range: the last `pageSize` items, where the very last\n // item is the next to be dequeued. Redis returns them in list order\n // (oldest-tail-end first), so reverse to put next-to-dequeue first.\n const values = await this.client.lRange(queueName, -pageSize, -1);\n if (values.length === 0) return [];\n return values\n .reverse()\n .map((value) => this.parseValue<T>(value, compliance))\n .filter(Boolean);\n }\n\n async disconnect(): Promise<void> {\n await this.client.quit();\n }\n\n getTtlMilliseconds(): number {\n return this.ttlMilliseconds;\n }\n\n getClient(): typeof this.client {\n return this.client;\n }\n}\n"],"mappings":"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","names":[]}
package/lib/index.mjs CHANGED
@@ -44,11 +44,21 @@ var RedisTtlCache = class {
44
44
  decryptValue(value, compliance) {
45
45
  if (!compliance || !this.encryptor) return value;
46
46
  if (!isEncrypted(value)) return value;
47
+ let decrypted;
47
48
  try {
48
- return this.encryptor.decrypt(value, compliance.tenantId) ?? value;
49
- } catch {
50
- return value;
49
+ decrypted = this.encryptor.decrypt(value, compliance.tenantId);
50
+ } catch (err) {
51
+ throw new Error(
52
+ `Redis: failed to decrypt value for tenant ${compliance.tenantId}: ${err instanceof Error ? err.message : String(err)}`,
53
+ { cause: err }
54
+ );
55
+ }
56
+ if (decrypted === null) {
57
+ throw new Error(
58
+ `Redis: encrypted value for tenant ${compliance.tenantId} could not be decrypted (null result)`
59
+ );
51
60
  }
61
+ return decrypted;
52
62
  }
53
63
  parseValue(value, compliance) {
54
64
  if (value == null) {
@@ -198,12 +208,13 @@ var RedisTtlCache = class {
198
208
  return results.map((result) => result === 1);
199
209
  }
200
210
  async peekQueueRecord(queueName, compliance) {
201
- const value = await this.client.lRange(queueName, 0, 0);
211
+ const value = await this.client.lRange(queueName, -1, -1);
202
212
  return this.parseValue(value[0], compliance);
203
213
  }
204
214
  async peekQueueRecords(queueName, pageSize, compliance) {
205
- const values = await this.client.lRange(queueName, 0, pageSize - 1);
206
- return values.map((value) => this.parseValue(value, compliance)).filter(Boolean);
215
+ const values = await this.client.lRange(queueName, -pageSize, -1);
216
+ if (values.length === 0) return [];
217
+ return values.reverse().map((value) => this.parseValue(value, compliance)).filter(Boolean);
207
218
  }
208
219
  async disconnect() {
209
220
  await this.client.quit();
package/lib/index.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../index.ts"],"sourcesContent":["import { safeParse, safeStringify } from '@forklaunch/common';\nimport {\n type ComplianceContext,\n TtlCache,\n TtlCacheRecord\n} from '@forklaunch/core/cache';\nimport {\n evaluateTelemetryOptions,\n MetricsDefinition,\n OpenTelemetryCollector,\n TelemetryOptions\n} from '@forklaunch/core/http';\nimport { type FieldEncryptor } from '@forklaunch/core/persistence';\nimport { createClient, RedisClientOptions } from 'redis';\n\n/**\n * Type representing a raw reply from Redis commands.\n * Can be a string, number, Buffer, null, undefined, or array of raw replies.\n */\ntype RedisCommandRawReply =\n | string\n | number\n | Buffer\n | null\n | undefined\n | Array<RedisCommandRawReply>;\n\nconst ENCRYPTED_PREFIXES = ['v1:', 'v2:'] as const;\n\nfunction isEncrypted(value: string): boolean {\n return ENCRYPTED_PREFIXES.some((p) => value.startsWith(p));\n}\n\n/**\n * Options for configuring encryption on the Redis cache.\n * Required — every consumer must explicitly configure encryption.\n */\nexport interface RedisCacheEncryptionOptions {\n /** The FieldEncryptor instance to use for encrypting cache values. */\n encryptor: FieldEncryptor;\n}\n\n/**\n * Class representing a Redis-based TTL (Time-To-Live) cache.\n * Implements the TtlCache interface to provide caching functionality with automatic expiration.\n *\n * Encryption is activated per-operation when a `compliance` context is provided.\n * Without it, values are stored and read as plaintext.\n */\nexport class RedisTtlCache implements TtlCache {\n private client;\n private telemetryOptions;\n private encryptor?: FieldEncryptor;\n\n /**\n * Creates an instance of RedisTtlCache.\n *\n * @param {number} ttlMilliseconds - The default Time-To-Live in milliseconds for cache entries\n * @param {OpenTelemetryCollector<MetricsDefinition>} openTelemetryCollector - Collector for OpenTelemetry metrics\n * @param {RedisClientOptions} options - Configuration options for the Redis client\n * @param {TelemetryOptions} telemetryOptions - Configuration options for telemetry\n * @param {RedisCacheEncryptionOptions} encryption - Encryption configuration\n */\n constructor(\n private ttlMilliseconds: number,\n private openTelemetryCollector: OpenTelemetryCollector<MetricsDefinition>,\n options: RedisClientOptions,\n telemetryOptions: TelemetryOptions,\n encryption: RedisCacheEncryptionOptions\n ) {\n this.telemetryOptions = evaluateTelemetryOptions(telemetryOptions);\n this.client = createClient(options);\n this.encryptor = encryption.encryptor;\n if (this.telemetryOptions.enabled.logging) {\n this.client.on('error', (err) => this.openTelemetryCollector.error(err));\n this.client.connect().catch(this.openTelemetryCollector.error);\n }\n }\n\n // ---------------------------------------------------------------------------\n // Encryption helpers — only active when compliance context is provided\n // ---------------------------------------------------------------------------\n\n private encryptValue(\n serialized: string,\n compliance?: ComplianceContext\n ): string {\n if (!compliance || !this.encryptor) return serialized;\n return (\n this.encryptor.encrypt(serialized, compliance.tenantId) ?? serialized\n );\n }\n\n private decryptValue(value: string, compliance?: ComplianceContext): string {\n if (!compliance || !this.encryptor) return value;\n if (!isEncrypted(value)) return value;\n try {\n return this.encryptor.decrypt(value, compliance.tenantId) ?? value;\n } catch {\n return value;\n }\n }\n\n private parseValue<T>(\n value: RedisCommandRawReply,\n compliance?: ComplianceContext\n ): T {\n if (value == null) {\n return null as T;\n }\n\n if (Array.isArray(value)) {\n return value.map((v) => this.parseValue<T>(v, compliance)) as T;\n }\n\n if (Buffer.isBuffer(value)) {\n return value.toJSON() as T;\n }\n\n switch (typeof value) {\n case 'object':\n case 'string':\n return safeParse(this.decryptValue(String(value), compliance)) as T;\n case 'number':\n return value as T;\n }\n }\n\n // ---------------------------------------------------------------------------\n // TtlCache implementation\n // ---------------------------------------------------------------------------\n\n async putRecord<T>(\n { key, value, ttlMilliseconds = this.ttlMilliseconds }: TtlCacheRecord<T>,\n compliance?: ComplianceContext\n ): Promise<void> {\n if (this.telemetryOptions.enabled.logging) {\n this.openTelemetryCollector.info(`Putting record into cache: ${key}`);\n }\n await this.client.set(\n key,\n this.encryptValue(safeStringify(value), compliance),\n { PX: ttlMilliseconds }\n );\n }\n\n async putBatchRecords<T>(\n cacheRecords: TtlCacheRecord<T>[],\n compliance?: ComplianceContext\n ): Promise<void> {\n const multiCommand = this.client.multi();\n for (const { key, value, ttlMilliseconds } of cacheRecords) {\n multiCommand.set(\n key,\n this.encryptValue(safeStringify(value), compliance),\n { PX: ttlMilliseconds || this.ttlMilliseconds }\n );\n }\n await multiCommand.exec();\n }\n\n async enqueueRecord<T>(\n queueName: string,\n value: T,\n compliance?: ComplianceContext\n ): Promise<void> {\n await this.client.lPush(\n queueName,\n this.encryptValue(safeStringify(value), compliance)\n );\n }\n\n async enqueueBatchRecords<T>(\n queueName: string,\n values: T[],\n compliance?: ComplianceContext\n ): Promise<void> {\n const multiCommand = this.client.multi();\n for (const value of values) {\n multiCommand.lPush(\n queueName,\n this.encryptValue(safeStringify(value), compliance)\n );\n }\n await multiCommand.exec();\n }\n\n async deleteRecord(cacheRecordKey: string): Promise<void> {\n await this.client.del(cacheRecordKey);\n }\n\n async deleteBatchRecords(cacheRecordKeys: string[]): Promise<void> {\n const multiCommand = this.client.multi();\n for (const key of cacheRecordKeys) {\n multiCommand.del(key);\n }\n await multiCommand.exec();\n }\n\n async dequeueRecord<T>(\n queueName: string,\n compliance?: ComplianceContext\n ): Promise<T> {\n const value = await this.client.rPop(queueName);\n if (value === null) {\n throw new Error(`Queue is empty: ${queueName}`);\n }\n return safeParse(this.decryptValue(value, compliance)) as T;\n }\n\n async dequeueBatchRecords<T>(\n queueName: string,\n pageSize: number,\n compliance?: ComplianceContext\n ): Promise<T[]> {\n const multiCommand = this.client.multi();\n for (let i = 0; i < pageSize; i++) {\n multiCommand.rPop(queueName);\n }\n const values = await multiCommand.exec();\n return values\n .map((value) =>\n this.parseValue<T>(value as unknown as RedisCommandRawReply, compliance)\n )\n .filter(Boolean);\n }\n\n async readRecord<T>(\n cacheRecordKey: string,\n compliance?: ComplianceContext\n ): Promise<TtlCacheRecord<T>> {\n const [value, ttl] = await this.client\n .multi()\n .get(cacheRecordKey)\n .ttl(cacheRecordKey)\n .exec();\n if (value === null) {\n throw new Error(`Record not found for key: ${cacheRecordKey}`);\n }\n\n return {\n key: cacheRecordKey,\n value: this.parseValue<T>(\n value as unknown as RedisCommandRawReply,\n compliance\n ),\n ttlMilliseconds:\n this.parseValue<number>(\n ttl as unknown as RedisCommandRawReply,\n compliance\n ) * 1000\n };\n }\n\n async readBatchRecords<T>(\n cacheRecordKeysOrPrefix: string[] | string,\n compliance?: ComplianceContext\n ): Promise<TtlCacheRecord<T>[]> {\n const keys = Array.isArray(cacheRecordKeysOrPrefix)\n ? cacheRecordKeysOrPrefix\n : await this.client.keys(cacheRecordKeysOrPrefix + '*');\n const multiCommand = this.client.multi();\n for (const key of keys) {\n multiCommand.get(key);\n multiCommand.ttl(key);\n }\n const values = await multiCommand.exec();\n return values.reduce<TtlCacheRecord<T>[]>((acc, value, index) => {\n if (index % 2 === 0) {\n const maybeValue = this.parseValue<T>(\n value as unknown as RedisCommandRawReply,\n compliance\n );\n const ttl = this.parseValue<number>(\n values[index + 1] as unknown as RedisCommandRawReply,\n compliance\n );\n if (maybeValue && ttl) {\n acc.push({\n key: keys[index / 2],\n value: maybeValue,\n ttlMilliseconds: ttl * 1000\n });\n }\n }\n return acc;\n }, []);\n }\n\n async listKeys(pattern_prefix: string): Promise<string[]> {\n return this.client.keys(pattern_prefix + '*');\n }\n\n async peekRecord(cacheRecordKey: string): Promise<boolean> {\n const result = await this.client.exists(cacheRecordKey);\n return result === 1;\n }\n\n async peekBatchRecords(\n cacheRecordKeysOrPrefix: string[] | string\n ): Promise<boolean[]> {\n const keys = Array.isArray(cacheRecordKeysOrPrefix)\n ? cacheRecordKeysOrPrefix\n : await this.client.keys(cacheRecordKeysOrPrefix + '*');\n const multiCommand = this.client.multi();\n for (const key of keys) {\n multiCommand.exists(key);\n }\n const results = await multiCommand.exec();\n return results.map((result) => (result as unknown as number) === 1);\n }\n\n async peekQueueRecord<T>(\n queueName: string,\n compliance?: ComplianceContext\n ): Promise<T> {\n const value = await this.client.lRange(queueName, 0, 0);\n return this.parseValue<T>(value[0], compliance);\n }\n\n async peekQueueRecords<T>(\n queueName: string,\n pageSize: number,\n compliance?: ComplianceContext\n ): Promise<T[]> {\n const values = await this.client.lRange(queueName, 0, pageSize - 1);\n return values\n .map((value) => this.parseValue<T>(value, compliance))\n .filter(Boolean);\n }\n\n async disconnect(): Promise<void> {\n await this.client.quit();\n }\n\n getTtlMilliseconds(): number {\n return this.ttlMilliseconds;\n }\n\n getClient(): typeof this.client {\n return this.client;\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../index.ts"],"sourcesContent":["import { safeParse, safeStringify } from '@forklaunch/common';\nimport {\n type ComplianceContext,\n TtlCache,\n TtlCacheRecord\n} from '@forklaunch/core/cache';\nimport {\n evaluateTelemetryOptions,\n MetricsDefinition,\n OpenTelemetryCollector,\n TelemetryOptions\n} from '@forklaunch/core/http';\nimport { type FieldEncryptor } from '@forklaunch/core/persistence';\nimport { createClient, RedisClientOptions } from 'redis';\n\n/**\n * Type representing a raw reply from Redis commands.\n * Can be a string, number, Buffer, null, undefined, or array of raw replies.\n */\ntype RedisCommandRawReply =\n | string\n | number\n | Buffer\n | null\n | undefined\n | Array<RedisCommandRawReply>;\n\nconst ENCRYPTED_PREFIXES = ['v1:', 'v2:'] as const;\n\nfunction isEncrypted(value: string): boolean {\n return ENCRYPTED_PREFIXES.some((p) => value.startsWith(p));\n}\n\n/**\n * Options for configuring encryption on the Redis cache.\n * Required — every consumer must explicitly configure encryption.\n */\nexport interface RedisCacheEncryptionOptions {\n /** The FieldEncryptor instance to use for encrypting cache values. */\n encryptor: FieldEncryptor;\n}\n\n/**\n * Class representing a Redis-based TTL (Time-To-Live) cache.\n * Implements the TtlCache interface to provide caching functionality with automatic expiration.\n *\n * Encryption is activated per-operation when a `compliance` context is provided.\n * Without it, values are stored and read as plaintext.\n */\nexport class RedisTtlCache implements TtlCache {\n private client;\n private telemetryOptions;\n private encryptor?: FieldEncryptor;\n\n /**\n * Creates an instance of RedisTtlCache.\n *\n * @param {number} ttlMilliseconds - The default Time-To-Live in milliseconds for cache entries\n * @param {OpenTelemetryCollector<MetricsDefinition>} openTelemetryCollector - Collector for OpenTelemetry metrics\n * @param {RedisClientOptions} options - Configuration options for the Redis client\n * @param {TelemetryOptions} telemetryOptions - Configuration options for telemetry\n * @param {RedisCacheEncryptionOptions} encryption - Encryption configuration\n */\n constructor(\n private ttlMilliseconds: number,\n private openTelemetryCollector: OpenTelemetryCollector<MetricsDefinition>,\n options: RedisClientOptions,\n telemetryOptions: TelemetryOptions,\n encryption: RedisCacheEncryptionOptions\n ) {\n this.telemetryOptions = evaluateTelemetryOptions(telemetryOptions);\n this.client = createClient(options);\n this.encryptor = encryption.encryptor;\n if (this.telemetryOptions.enabled.logging) {\n this.client.on('error', (err) => this.openTelemetryCollector.error(err));\n this.client.connect().catch(this.openTelemetryCollector.error);\n }\n }\n\n // ---------------------------------------------------------------------------\n // Encryption helpers — only active when compliance context is provided\n // ---------------------------------------------------------------------------\n\n private encryptValue(\n serialized: string,\n compliance?: ComplianceContext\n ): string {\n if (!compliance || !this.encryptor) return serialized;\n return (\n this.encryptor.encrypt(serialized, compliance.tenantId) ?? serialized\n );\n }\n\n private decryptValue(value: string, compliance?: ComplianceContext): string {\n if (!compliance || !this.encryptor) return value;\n if (!isEncrypted(value)) return value;\n // If a value is encrypted but we cannot decrypt it, treat the entry as\n // unreadable rather than returning the ciphertext. Returning the raw\n // bytes lets callers (e.g. cache services that JSON.parse the result)\n // surface garbage as if it were a successful read, masking key/tenant\n // mismatches and corrupting downstream consumers. Throwing forces the\n // caller's catch path (cache miss) to run.\n let decrypted: string | null;\n try {\n decrypted = this.encryptor.decrypt(value, compliance.tenantId);\n } catch (err) {\n throw new Error(\n `Redis: failed to decrypt value for tenant ${compliance.tenantId}: ${err instanceof Error ? err.message : String(err)}`,\n { cause: err }\n );\n }\n if (decrypted === null) {\n throw new Error(\n `Redis: encrypted value for tenant ${compliance.tenantId} could not be decrypted (null result)`\n );\n }\n return decrypted;\n }\n\n private parseValue<T>(\n value: RedisCommandRawReply,\n compliance?: ComplianceContext\n ): T {\n if (value == null) {\n return null as T;\n }\n\n if (Array.isArray(value)) {\n return value.map((v) => this.parseValue<T>(v, compliance)) as T;\n }\n\n if (Buffer.isBuffer(value)) {\n return value.toJSON() as T;\n }\n\n switch (typeof value) {\n case 'object':\n case 'string':\n return safeParse(this.decryptValue(String(value), compliance)) as T;\n case 'number':\n return value as T;\n }\n }\n\n // ---------------------------------------------------------------------------\n // TtlCache implementation\n // ---------------------------------------------------------------------------\n\n async putRecord<T>(\n { key, value, ttlMilliseconds = this.ttlMilliseconds }: TtlCacheRecord<T>,\n compliance?: ComplianceContext\n ): Promise<void> {\n if (this.telemetryOptions.enabled.logging) {\n this.openTelemetryCollector.info(`Putting record into cache: ${key}`);\n }\n await this.client.set(\n key,\n this.encryptValue(safeStringify(value), compliance),\n { PX: ttlMilliseconds }\n );\n }\n\n async putBatchRecords<T>(\n cacheRecords: TtlCacheRecord<T>[],\n compliance?: ComplianceContext\n ): Promise<void> {\n const multiCommand = this.client.multi();\n for (const { key, value, ttlMilliseconds } of cacheRecords) {\n multiCommand.set(\n key,\n this.encryptValue(safeStringify(value), compliance),\n { PX: ttlMilliseconds || this.ttlMilliseconds }\n );\n }\n await multiCommand.exec();\n }\n\n async enqueueRecord<T>(\n queueName: string,\n value: T,\n compliance?: ComplianceContext\n ): Promise<void> {\n await this.client.lPush(\n queueName,\n this.encryptValue(safeStringify(value), compliance)\n );\n }\n\n async enqueueBatchRecords<T>(\n queueName: string,\n values: T[],\n compliance?: ComplianceContext\n ): Promise<void> {\n const multiCommand = this.client.multi();\n for (const value of values) {\n multiCommand.lPush(\n queueName,\n this.encryptValue(safeStringify(value), compliance)\n );\n }\n await multiCommand.exec();\n }\n\n async deleteRecord(cacheRecordKey: string): Promise<void> {\n await this.client.del(cacheRecordKey);\n }\n\n async deleteBatchRecords(cacheRecordKeys: string[]): Promise<void> {\n const multiCommand = this.client.multi();\n for (const key of cacheRecordKeys) {\n multiCommand.del(key);\n }\n await multiCommand.exec();\n }\n\n async dequeueRecord<T>(\n queueName: string,\n compliance?: ComplianceContext\n ): Promise<T> {\n const value = await this.client.rPop(queueName);\n if (value === null) {\n throw new Error(`Queue is empty: ${queueName}`);\n }\n return safeParse(this.decryptValue(value, compliance)) as T;\n }\n\n async dequeueBatchRecords<T>(\n queueName: string,\n pageSize: number,\n compliance?: ComplianceContext\n ): Promise<T[]> {\n const multiCommand = this.client.multi();\n for (let i = 0; i < pageSize; i++) {\n multiCommand.rPop(queueName);\n }\n const values = await multiCommand.exec();\n return values\n .map((value) =>\n this.parseValue<T>(value as unknown as RedisCommandRawReply, compliance)\n )\n .filter(Boolean);\n }\n\n async readRecord<T>(\n cacheRecordKey: string,\n compliance?: ComplianceContext\n ): Promise<TtlCacheRecord<T>> {\n const [value, ttl] = await this.client\n .multi()\n .get(cacheRecordKey)\n .ttl(cacheRecordKey)\n .exec();\n if (value === null) {\n throw new Error(`Record not found for key: ${cacheRecordKey}`);\n }\n\n return {\n key: cacheRecordKey,\n value: this.parseValue<T>(\n value as unknown as RedisCommandRawReply,\n compliance\n ),\n ttlMilliseconds:\n this.parseValue<number>(\n ttl as unknown as RedisCommandRawReply,\n compliance\n ) * 1000\n };\n }\n\n async readBatchRecords<T>(\n cacheRecordKeysOrPrefix: string[] | string,\n compliance?: ComplianceContext\n ): Promise<TtlCacheRecord<T>[]> {\n const keys = Array.isArray(cacheRecordKeysOrPrefix)\n ? cacheRecordKeysOrPrefix\n : await this.client.keys(cacheRecordKeysOrPrefix + '*');\n const multiCommand = this.client.multi();\n for (const key of keys) {\n multiCommand.get(key);\n multiCommand.ttl(key);\n }\n const values = await multiCommand.exec();\n return values.reduce<TtlCacheRecord<T>[]>((acc, value, index) => {\n if (index % 2 === 0) {\n const maybeValue = this.parseValue<T>(\n value as unknown as RedisCommandRawReply,\n compliance\n );\n const ttl = this.parseValue<number>(\n values[index + 1] as unknown as RedisCommandRawReply,\n compliance\n );\n if (maybeValue && ttl) {\n acc.push({\n key: keys[index / 2],\n value: maybeValue,\n ttlMilliseconds: ttl * 1000\n });\n }\n }\n return acc;\n }, []);\n }\n\n async listKeys(pattern_prefix: string): Promise<string[]> {\n return this.client.keys(pattern_prefix + '*');\n }\n\n async peekRecord(cacheRecordKey: string): Promise<boolean> {\n const result = await this.client.exists(cacheRecordKey);\n return result === 1;\n }\n\n async peekBatchRecords(\n cacheRecordKeysOrPrefix: string[] | string\n ): Promise<boolean[]> {\n const keys = Array.isArray(cacheRecordKeysOrPrefix)\n ? cacheRecordKeysOrPrefix\n : await this.client.keys(cacheRecordKeysOrPrefix + '*');\n const multiCommand = this.client.multi();\n for (const key of keys) {\n multiCommand.exists(key);\n }\n const results = await multiCommand.exec();\n return results.map((result) => (result as unknown as number) === 1);\n }\n\n async peekQueueRecord<T>(\n queueName: string,\n compliance?: ComplianceContext\n ): Promise<T> {\n // Queues use lPush + rPop, so the next item to dequeue lives at the\n // tail of the list, not the head. Reading lRange(0, 0) would return the\n // most-recently-pushed item — the opposite of dequeue order.\n const value = await this.client.lRange(queueName, -1, -1);\n return this.parseValue<T>(value[0], compliance);\n }\n\n async peekQueueRecords<T>(\n queueName: string,\n pageSize: number,\n compliance?: ComplianceContext\n ): Promise<T[]> {\n // Tail-relative range: the last `pageSize` items, where the very last\n // item is the next to be dequeued. Redis returns them in list order\n // (oldest-tail-end first), so reverse to put next-to-dequeue first.\n const values = await this.client.lRange(queueName, -pageSize, -1);\n if (values.length === 0) return [];\n return values\n .reverse()\n .map((value) => this.parseValue<T>(value, compliance))\n .filter(Boolean);\n }\n\n async disconnect(): Promise<void> {\n await this.client.quit();\n }\n\n getTtlMilliseconds(): number {\n return this.ttlMilliseconds;\n }\n\n getClient(): typeof this.client {\n return this.client;\n }\n}\n"],"mappings":";AAAA,SAAS,WAAW,qBAAqB;AAMzC;AAAA,EACE;AAAA,OAIK;AAEP,SAAS,oBAAwC;AAcjD,IAAM,qBAAqB,CAAC,OAAO,KAAK;AAExC,SAAS,YAAY,OAAwB;AAC3C,SAAO,mBAAmB,KAAK,CAAC,MAAM,MAAM,WAAW,CAAC,CAAC;AAC3D;AAkBO,IAAM,gBAAN,MAAwC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAc7C,YACU,iBACA,wBACR,SACA,kBACA,YACA;AALQ;AACA;AAKR,SAAK,mBAAmB,yBAAyB,gBAAgB;AACjE,SAAK,SAAS,aAAa,OAAO;AAClC,SAAK,YAAY,WAAW;AAC5B,QAAI,KAAK,iBAAiB,QAAQ,SAAS;AACzC,WAAK,OAAO,GAAG,SAAS,CAAC,QAAQ,KAAK,uBAAuB,MAAM,GAAG,CAAC;AACvE,WAAK,OAAO,QAAQ,EAAE,MAAM,KAAK,uBAAuB,KAAK;AAAA,IAC/D;AAAA,EACF;AAAA,EAbU;AAAA,EACA;AAAA,EAfF;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA;AAAA,EA+BA,aACN,YACA,YACQ;AACR,QAAI,CAAC,cAAc,CAAC,KAAK,UAAW,QAAO;AAC3C,WACE,KAAK,UAAU,QAAQ,YAAY,WAAW,QAAQ,KAAK;AAAA,EAE/D;AAAA,EAEQ,aAAa,OAAe,YAAwC;AAC1E,QAAI,CAAC,cAAc,CAAC,KAAK,UAAW,QAAO;AAC3C,QAAI,CAAC,YAAY,KAAK,EAAG,QAAO;AAOhC,QAAI;AACJ,QAAI;AACF,kBAAY,KAAK,UAAU,QAAQ,OAAO,WAAW,QAAQ;AAAA,IAC/D,SAAS,KAAK;AACZ,YAAM,IAAI;AAAA,QACR,6CAA6C,WAAW,QAAQ,KAAK,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC;AAAA,QACrH,EAAE,OAAO,IAAI;AAAA,MACf;AAAA,IACF;AACA,QAAI,cAAc,MAAM;AACtB,YAAM,IAAI;AAAA,QACR,qCAAqC,WAAW,QAAQ;AAAA,MAC1D;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAAA,EAEQ,WACN,OACA,YACG;AACH,QAAI,SAAS,MAAM;AACjB,aAAO;AAAA,IACT;AAEA,QAAI,MAAM,QAAQ,KAAK,GAAG;AACxB,aAAO,MAAM,IAAI,CAAC,MAAM,KAAK,WAAc,GAAG,UAAU,CAAC;AAAA,IAC3D;AAEA,QAAI,OAAO,SAAS,KAAK,GAAG;AAC1B,aAAO,MAAM,OAAO;AAAA,IACtB;AAEA,YAAQ,OAAO,OAAO;AAAA,MACpB,KAAK;AAAA,MACL,KAAK;AACH,eAAO,UAAU,KAAK,aAAa,OAAO,KAAK,GAAG,UAAU,CAAC;AAAA,MAC/D,KAAK;AACH,eAAO;AAAA,IACX;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,UACJ,EAAE,KAAK,OAAO,kBAAkB,KAAK,gBAAgB,GACrD,YACe;AACf,QAAI,KAAK,iBAAiB,QAAQ,SAAS;AACzC,WAAK,uBAAuB,KAAK,8BAA8B,GAAG,EAAE;AAAA,IACtE;AACA,UAAM,KAAK,OAAO;AAAA,MAChB;AAAA,MACA,KAAK,aAAa,cAAc,KAAK,GAAG,UAAU;AAAA,MAClD,EAAE,IAAI,gBAAgB;AAAA,IACxB;AAAA,EACF;AAAA,EAEA,MAAM,gBACJ,cACA,YACe;AACf,UAAM,eAAe,KAAK,OAAO,MAAM;AACvC,eAAW,EAAE,KAAK,OAAO,gBAAgB,KAAK,cAAc;AAC1D,mBAAa;AAAA,QACX;AAAA,QACA,KAAK,aAAa,cAAc,KAAK,GAAG,UAAU;AAAA,QAClD,EAAE,IAAI,mBAAmB,KAAK,gBAAgB;AAAA,MAChD;AAAA,IACF;AACA,UAAM,aAAa,KAAK;AAAA,EAC1B;AAAA,EAEA,MAAM,cACJ,WACA,OACA,YACe;AACf,UAAM,KAAK,OAAO;AAAA,MAChB;AAAA,MACA,KAAK,aAAa,cAAc,KAAK,GAAG,UAAU;AAAA,IACpD;AAAA,EACF;AAAA,EAEA,MAAM,oBACJ,WACA,QACA,YACe;AACf,UAAM,eAAe,KAAK,OAAO,MAAM;AACvC,eAAW,SAAS,QAAQ;AAC1B,mBAAa;AAAA,QACX;AAAA,QACA,KAAK,aAAa,cAAc,KAAK,GAAG,UAAU;AAAA,MACpD;AAAA,IACF;AACA,UAAM,aAAa,KAAK;AAAA,EAC1B;AAAA,EAEA,MAAM,aAAa,gBAAuC;AACxD,UAAM,KAAK,OAAO,IAAI,cAAc;AAAA,EACtC;AAAA,EAEA,MAAM,mBAAmB,iBAA0C;AACjE,UAAM,eAAe,KAAK,OAAO,MAAM;AACvC,eAAW,OAAO,iBAAiB;AACjC,mBAAa,IAAI,GAAG;AAAA,IACtB;AACA,UAAM,aAAa,KAAK;AAAA,EAC1B;AAAA,EAEA,MAAM,cACJ,WACA,YACY;AACZ,UAAM,QAAQ,MAAM,KAAK,OAAO,KAAK,SAAS;AAC9C,QAAI,UAAU,MAAM;AAClB,YAAM,IAAI,MAAM,mBAAmB,SAAS,EAAE;AAAA,IAChD;AACA,WAAO,UAAU,KAAK,aAAa,OAAO,UAAU,CAAC;AAAA,EACvD;AAAA,EAEA,MAAM,oBACJ,WACA,UACA,YACc;AACd,UAAM,eAAe,KAAK,OAAO,MAAM;AACvC,aAAS,IAAI,GAAG,IAAI,UAAU,KAAK;AACjC,mBAAa,KAAK,SAAS;AAAA,IAC7B;AACA,UAAM,SAAS,MAAM,aAAa,KAAK;AACvC,WAAO,OACJ;AAAA,MAAI,CAAC,UACJ,KAAK,WAAc,OAA0C,UAAU;AAAA,IACzE,EACC,OAAO,OAAO;AAAA,EACnB;AAAA,EAEA,MAAM,WACJ,gBACA,YAC4B;AAC5B,UAAM,CAAC,OAAO,GAAG,IAAI,MAAM,KAAK,OAC7B,MAAM,EACN,IAAI,cAAc,EAClB,IAAI,cAAc,EAClB,KAAK;AACR,QAAI,UAAU,MAAM;AAClB,YAAM,IAAI,MAAM,6BAA6B,cAAc,EAAE;AAAA,IAC/D;AAEA,WAAO;AAAA,MACL,KAAK;AAAA,MACL,OAAO,KAAK;AAAA,QACV;AAAA,QACA;AAAA,MACF;AAAA,MACA,iBACE,KAAK;AAAA,QACH;AAAA,QACA;AAAA,MACF,IAAI;AAAA,IACR;AAAA,EACF;AAAA,EAEA,MAAM,iBACJ,yBACA,YAC8B;AAC9B,UAAM,OAAO,MAAM,QAAQ,uBAAuB,IAC9C,0BACA,MAAM,KAAK,OAAO,KAAK,0BAA0B,GAAG;AACxD,UAAM,eAAe,KAAK,OAAO,MAAM;AACvC,eAAW,OAAO,MAAM;AACtB,mBAAa,IAAI,GAAG;AACpB,mBAAa,IAAI,GAAG;AAAA,IACtB;AACA,UAAM,SAAS,MAAM,aAAa,KAAK;AACvC,WAAO,OAAO,OAA4B,CAAC,KAAK,OAAO,UAAU;AAC/D,UAAI,QAAQ,MAAM,GAAG;AACnB,cAAM,aAAa,KAAK;AAAA,UACtB;AAAA,UACA;AAAA,QACF;AACA,cAAM,MAAM,KAAK;AAAA,UACf,OAAO,QAAQ,CAAC;AAAA,UAChB;AAAA,QACF;AACA,YAAI,cAAc,KAAK;AACrB,cAAI,KAAK;AAAA,YACP,KAAK,KAAK,QAAQ,CAAC;AAAA,YACnB,OAAO;AAAA,YACP,iBAAiB,MAAM;AAAA,UACzB,CAAC;AAAA,QACH;AAAA,MACF;AACA,aAAO;AAAA,IACT,GAAG,CAAC,CAAC;AAAA,EACP;AAAA,EAEA,MAAM,SAAS,gBAA2C;AACxD,WAAO,KAAK,OAAO,KAAK,iBAAiB,GAAG;AAAA,EAC9C;AAAA,EAEA,MAAM,WAAW,gBAA0C;AACzD,UAAM,SAAS,MAAM,KAAK,OAAO,OAAO,cAAc;AACtD,WAAO,WAAW;AAAA,EACpB;AAAA,EAEA,MAAM,iBACJ,yBACoB;AACpB,UAAM,OAAO,MAAM,QAAQ,uBAAuB,IAC9C,0BACA,MAAM,KAAK,OAAO,KAAK,0BAA0B,GAAG;AACxD,UAAM,eAAe,KAAK,OAAO,MAAM;AACvC,eAAW,OAAO,MAAM;AACtB,mBAAa,OAAO,GAAG;AAAA,IACzB;AACA,UAAM,UAAU,MAAM,aAAa,KAAK;AACxC,WAAO,QAAQ,IAAI,CAAC,WAAY,WAAiC,CAAC;AAAA,EACpE;AAAA,EAEA,MAAM,gBACJ,WACA,YACY;AAIZ,UAAM,QAAQ,MAAM,KAAK,OAAO,OAAO,WAAW,IAAI,EAAE;AACxD,WAAO,KAAK,WAAc,MAAM,CAAC,GAAG,UAAU;AAAA,EAChD;AAAA,EAEA,MAAM,iBACJ,WACA,UACA,YACc;AAId,UAAM,SAAS,MAAM,KAAK,OAAO,OAAO,WAAW,CAAC,UAAU,EAAE;AAChE,QAAI,OAAO,WAAW,EAAG,QAAO,CAAC;AACjC,WAAO,OACJ,QAAQ,EACR,IAAI,CAAC,UAAU,KAAK,WAAc,OAAO,UAAU,CAAC,EACpD,OAAO,OAAO;AAAA,EACnB;AAAA,EAEA,MAAM,aAA4B;AAChC,UAAM,KAAK,OAAO,KAAK;AAAA,EACzB;AAAA,EAEA,qBAA6B;AAC3B,WAAO,KAAK;AAAA,EACd;AAAA,EAEA,YAAgC;AAC9B,WAAO,KAAK;AAAA,EACd;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forklaunch/infrastructure-redis",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Redis infrastructure for ForkLaunch components.",
5
5
  "homepage": "https://github.com/forklaunch/forklaunch-js#readme",
6
6
  "bugs": {
@@ -29,23 +29,23 @@
29
29
  ],
30
30
  "dependencies": {
31
31
  "redis": "^5.11.0",
32
- "@forklaunch/common": "1.2.14",
33
- "@forklaunch/core": "1.4.0"
32
+ "@forklaunch/common": "1.2.16",
33
+ "@forklaunch/core": "1.5.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@eslint/js": "^10.0.1",
37
37
  "@types/jest": "^30.0.0",
38
- "@typescript/native-preview": "7.0.0-dev.20260331.1",
38
+ "@typescript/native-preview": "7.0.0-dev.20260408.1",
39
39
  "globals": "^17.4.0",
40
40
  "jest": "^30.3.0",
41
41
  "prettier": "^3.8.1",
42
- "testcontainers": "^11.13.0",
43
- "ts-jest": "^29.4.6",
42
+ "testcontainers": "^11.14.0",
43
+ "ts-jest": "^29.4.9",
44
44
  "ts-node": "^10.9.2",
45
45
  "tsup": "^8.5.1",
46
46
  "typedoc": "^0.28.18",
47
47
  "typescript": "^6.0.2",
48
- "typescript-eslint": "^8.58.0"
48
+ "typescript-eslint": "^8.58.1"
49
49
  },
50
50
  "scripts": {
51
51
  "build": "tsgo --noEmit && tsup ./index.ts --format cjs,esm --no-splitting --dts --tsconfig tsconfig.json --out-dir lib --clean --sourcemap && if [ -f eject-infrastructure-package.bash ]; then pnpm package:eject; fi",