From 6d442a447c859adb4540e3431dbe58edf8fc6b07 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 23:53:28 +0000 Subject: [PATCH 1/4] redis - fix: Client swap leak, connect errors, and createKeyv types Remove listeners from a replaced Redis client (matching Valkey), reset PXAT detection on client swap, and rethrow connection errors from getMany and clear so throwOnConnectError is consistent. Clear the connection-timeout timer after a successful connect, strip namespace prefixes only from the start of a key, and share a KeyvRedisConnect type so createKeyv accepts cluster and sentinel options. Co-authored-by: Jared Wray --- storage/redis/src/create.ts | 53 ++------ storage/redis/src/index.ts | 169 ++++++++++++++----------- storage/redis/src/types.ts | 14 ++ storage/redis/test/create-keyv.test.ts | 9 ++ storage/redis/test/events.test.ts | 27 ++++ storage/redis/test/get.test.ts | 35 ++++- storage/redis/test/main.test.ts | 21 ++- storage/redis/test/namespace.test.ts | 7 + 8 files changed, 220 insertions(+), 115 deletions(-) diff --git a/storage/redis/src/create.ts b/storage/redis/src/create.ts index f358a42e9..4c8a0292a 100644 --- a/storage/redis/src/create.ts +++ b/storage/redis/src/create.ts @@ -1,54 +1,27 @@ -import type { RedisClientOptions, RedisClientType } from "@redis/client"; +import type { RedisClientType } from "@redis/client"; import { Keyv, type KeyvAny } from "keyv"; import KeyvRedis from "./index.js"; -import type { KeyvRedisOptions } from "./types.js"; +import type { KeyvRedisConnect, KeyvRedisOptions } from "./types.js"; /** - * Will create a Keyv instance with the Redis adapter. This will also set the namespace and disable the Keyv - * key prefix to avoid double prefixing of keys. - * @param {string | RedisClientOptions | RedisClientType} [connect] - How to connect to the Redis server. If string pass in the url, if object pass in the options, if RedisClient pass in the client. If nothing is passed in, it will default to 'redis://localhost:6379'. + * Create a Keyv instance with the Redis adapter. Namespace is applied on both Keyv and the + * adapter so keys are prefixed once (`namespace::key` with the default separator). + * @param {KeyvRedisConnect} [connect] - How to connect to the Redis server. If string pass in the url, if object pass in the options, if RedisClient pass in the client. If nothing is passed in, it will default to 'redis://localhost:6379'. * @param {KeyvRedisOptions} [options] - Options for the adapter such as namespace, keyPrefixSeparator, and clearBatchSize. * @returns {Keyv} - Keyv instance with the Redis adapter */ -export function createKeyv( - connect?: string | RedisClientOptions | RedisClientType, - options?: KeyvRedisOptions, -): Keyv { +export function createKeyv(connect?: KeyvRedisConnect, options?: KeyvRedisOptions): Keyv { connect ??= "redis://localhost:6379"; const adapter = new KeyvRedis(connect, options); + const keyv = new Keyv({ + store: adapter, + namespace: adapter.namespace, + }); - if (options?.namespace) { - adapter.namespace = options.namespace; - const keyv = new Keyv(adapter, { - namespace: options?.namespace, - }); - - if (options?.throwOnConnectError) { - // Set the throwOnError in Keyv so it throws - keyv.throwOnErrors = true; - } - - if (options?.throwOnErrors) { - // Set the throwOnError in Keyv so it throws - keyv.throwOnErrors = true; - } - - return keyv; - } - - const keyv = new Keyv(adapter); - - if (options?.throwOnConnectError) { - // Set the throwOnError in Keyv so it throws - keyv.throwOnErrors = true; - } - - if (options?.throwOnErrors) { - // Set the throwOnError in Keyv so it throws + if (options?.throwOnConnectError || options?.throwOnErrors) { keyv.throwOnErrors = true; } - keyv.namespace = undefined; // Ensure no namespace is set return keyv; } @@ -56,12 +29,12 @@ export function createKeyv( * Will create a non-blocking Keyv instance with the Redis adapter. This does everything `createKeyv` does but also * disables throwing errors, removes the offline queue, and disables the reconnect strategy so that when used as a * secondary cache (such as with cacheable) it does not block the primary cache on connection errors or timeouts. - * @param {string | RedisClientOptions | RedisClientType} [connect] - How to connect to the Redis server. If string pass in the url, if object pass in the options, if RedisClient pass in the client. If nothing is passed in, it will default to 'redis://localhost:6379'. + * @param {KeyvRedisConnect} [connect] - How to connect to the Redis server. If string pass in the url, if object pass in the options, if RedisClient pass in the client. If nothing is passed in, it will default to 'redis://localhost:6379'. * @param {KeyvRedisOptions} [options] - Options for the adapter such as namespace, keyPrefixSeparator, and clearBatchSize. * @returns {Keyv} - non-blocking Keyv instance with the Redis adapter */ export function createKeyvNonBlocking( - connect?: string | RedisClientOptions | RedisClientType, + connect?: KeyvRedisConnect, options?: KeyvRedisOptions, ): Keyv { const keyv = createKeyv(connect, options); diff --git a/storage/redis/src/index.ts b/storage/redis/src/index.ts index a6aee7fa9..15e598a50 100644 --- a/storage/redis/src/index.ts +++ b/storage/redis/src/index.ts @@ -24,6 +24,7 @@ import { } from "keyv"; import { defaultReconnectStrategy, + type KeyvRedisConnect, type KeyvRedisEntry, type KeyvRedisOptions, type KeyvRedisPropertyOptions, @@ -36,6 +37,7 @@ import { export { defaultReconnectStrategy, + type KeyvRedisConnect, type KeyvRedisEntry, type KeyvRedisOptions, type KeyvRedisPropertyOptions, @@ -102,20 +104,40 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte */ private _eventsWiredClient: RedisClientConnectionType | undefined; + /** + * Stable `error` listener so it can be removed when the underlying client is replaced. + */ + private readonly _errorHandler = (error: Error) => { + this.emit("error", error); + }; + + /** + * Stable `connect` listener so it can be removed when the underlying client is replaced. + */ + private readonly _connectHandler = () => { + this.emit("connect", this._client); + }; + + /** + * Stable `disconnect` listener so it can be removed when the underlying client is replaced. + */ + private readonly _disconnectHandler = () => { + this.emit("disconnect", this._client); + }; + + /** + * Stable `reconnecting` listener so it can be removed when the underlying client is replaced. + */ + private readonly _reconnectingHandler = (reconnectInfo: unknown) => { + this.emit("reconnecting", reconnectInfo); + }; + /** * KeyvRedis constructor. - * @param {string | RedisClientOptions | RedisClientType} [connect] How to connect to the Redis server. If string pass in the url, if object pass in the options, if RedisClient pass in the client. + * @param {KeyvRedisConnect} [connect] How to connect to the Redis server. If string pass in the url, if object pass in the options, if RedisClient pass in the client. * @param {KeyvRedisOptions} [options] Options for the adapter such as namespace, keyPrefixSeparator, and clearBatchSize. */ - constructor( - connect?: - | string - | RedisClientOptions - | RedisClusterOptions - | RedisSentinelOptions - | RedisClientConnectionType, - options?: KeyvRedisOptions, - ) { + constructor(connect?: KeyvRedisConnect, options?: KeyvRedisOptions) { super({ throwOnEmptyListeners: false }); // Build the socket reconnect strategy @@ -164,11 +186,13 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Set the Redis client, cluster, or sentinel connection. This will re-wire the event listeners. + * Set the Redis client, cluster, or sentinel connection. This will re-wire the event listeners + * and reset PXAT capability detection so the new server is introspected on the next expiring write. * @param {RedisClientConnectionType} value - The Redis client connection to use. */ public set client(value: RedisClientConnectionType) { this._client = value; + this._pxatSupported = undefined; this.initClient(); } @@ -342,10 +366,7 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte if (this._connectionTimeout === undefined) { await this._client.connect(); } else { - await Promise.race([ - this._client.connect(), - this.createTimeoutPromise(this._connectionTimeout), - ]); + await this.raceWithTimeout(this._client.connect(), this._connectionTimeout); } } catch (error) { this.emit("error", error); @@ -521,15 +542,7 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte return results; } catch (error) { this.emit("error", error); - // Re-throw connection errors if throwOnConnectError is true - /* v8 ignore next -- @preserve */ - if ( - this._throwOnConnectError && - (error as Error).message === RedisErrorMessages.RedisClientNotConnectedThrown - ) { - throw error; - } - if (this._throwOnErrors) { + if (this.shouldRethrow(error)) { throw error; } @@ -603,15 +616,7 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } } catch (error) { this.emit("error", error); - // Re-throw connection errors if throwOnConnectError is true - /* v8 ignore next -- @preserve */ - if ( - this._throwOnConnectError && - (error as Error).message === RedisErrorMessages.RedisClientNotConnectedThrown - ) { - throw error; - } - if (this._throwOnErrors) { + if (this.shouldRethrow(error)) { throw error; } @@ -661,10 +666,9 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte const values = await this.mget(keys); return values; - /* c8 ignore next 5 */ } catch (error) { this.emit("error", error); - if (this._throwOnErrors) { + if (this.shouldRethrow(error)) { throw error; } @@ -750,14 +754,7 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte return prefixedKeys.map((key) => resultMap.get(key) ?? false); } catch (error) { this.emit("error", error); - // Re-throw connection errors if throwOnConnectError is true - if ( - this._throwOnConnectError && - (error as Error).message === RedisErrorMessages.RedisClientNotConnectedThrown - ) { - throw error; - } - if (this._throwOnErrors) { + if (this.shouldRethrow(error)) { throw error; } @@ -799,7 +796,10 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte */ public getKeyWithoutPrefix(key: string, namespace?: string): string { if (namespace) { - return key.replace(`${namespace}${this._keyPrefixSeparator}`, ""); + const prefix = `${namespace}${this._keyPrefixSeparator}`; + if (key.startsWith(prefix)) { + return key.slice(prefix.length); + } } return key; @@ -858,6 +858,7 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte do { const result = await client.scan(cursor, { MATCH: match, + COUNT: this._clearBatchSize, TYPE: "string", }); cursor = result.cursor.toString(); @@ -928,8 +929,10 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte }), ); } catch (error) { - /* v8 ignore next -- @preserve */ this.emit("error", error); + if (this.shouldRethrow(error)) { + throw error; + } } } @@ -1090,48 +1093,68 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte /** * Wire up the client events (error, connect, disconnect, reconnecting) to be re-emitted on this instance. - * Listeners are only attached once per client instance to avoid duplicates. + * Listeners are only attached once per client instance to avoid duplicates. When the client is replaced, + * listeners are removed from the previous client so events from a discarded connection are not re-emitted. */ private initClient(): void { - // Only wire up listeners once per client instance so that repeated calls - // (for example on reconnect via getClient) do not accumulate duplicate listeners. if (this._eventsWiredClient === this._client) { return; } - this._eventsWiredClient = this._client; - - this._client.on("error", (error) => { - this.emit("error", error); - }); + if (this._eventsWiredClient) { + this._eventsWiredClient.removeListener("error", this._errorHandler); + this._eventsWiredClient.removeListener("connect", this._connectHandler); + this._eventsWiredClient.removeListener("disconnect", this._disconnectHandler); + this._eventsWiredClient.removeListener("reconnecting", this._reconnectingHandler); + } - this._client.on("connect", () => { - this.emit("connect", this._client); - }); + this._eventsWiredClient = this._client; + this._client.on("error", this._errorHandler); + this._client.on("connect", this._connectHandler); /* v8 ignore next -- @preserve */ - this._client.on("disconnect", () => { - this.emit("disconnect", this._client); - }); - + this._client.on("disconnect", this._disconnectHandler); /* v8 ignore next -- @preserve */ - this._client.on("reconnecting", (reconnectInfo) => { - this.emit("reconnecting", reconnectInfo); - }); + this._client.on("reconnecting", this._reconnectingHandler); } /** - * Create a promise that rejects after the provided timeout. Used to race against the connection. - * @param {number} timeoutMs - the timeout in milliseconds before the promise rejects - * @returns {Promise} - a promise that always rejects once the timeout elapses + * Whether an operation error should be re-thrown after being emitted. Connection failures + * honor `throwOnConnectError`; all other failures honor `throwOnErrors`. */ - private async createTimeoutPromise(timeoutMs: number): Promise { - return new Promise((_, reject) => - setTimeout(() => { - /* v8 ignore next 3 -- @preserve */ - reject(new Error(`Redis timed out after ${timeoutMs}ms`)); - }, timeoutMs), - ); + private shouldRethrow(error: unknown): boolean { + if ( + this._throwOnConnectError && + error instanceof Error && + error.message === RedisErrorMessages.RedisClientNotConnectedThrown + ) { + return true; + } + + return this._throwOnErrors; + } + + /** + * Race a promise against a timeout, always clearing the timer so a successful connect + * does not leave a dangling rejection. + * @param {Promise} promise - the promise to race + * @param {number} timeoutMs - the timeout in milliseconds before the race rejects + * @returns {Promise} - the original promise result, or a timeout rejection + */ + private async raceWithTimeout(promise: Promise, timeoutMs: number): Promise { + let timeoutId: ReturnType | undefined; + try { + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Redis timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + return await Promise.race([promise, timeout]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } } } diff --git a/storage/redis/src/types.ts b/storage/redis/src/types.ts index 6d387d224..57d8dd8db 100644 --- a/storage/redis/src/types.ts +++ b/storage/redis/src/types.ts @@ -1,9 +1,12 @@ import type { + RedisClientOptions, RedisClientType, + RedisClusterOptions, RedisClusterType, RedisFunctions, RedisModules, RedisScripts, + RedisSentinelOptions, RedisSentinelType, RespVersions, TypeMapping, @@ -120,3 +123,14 @@ export type RedisClientConnectionType = | RedisConnectionClientType | RedisConnectionClusterType | RedisConnectionSentinelType; + +/** + * Accepted first argument to the KeyvRedis constructor and `createKeyv`: a URI string, + * client/cluster/sentinel options object, or an already-created connection. + */ +export type KeyvRedisConnect = + | string + | RedisClientOptions + | RedisClusterOptions + | RedisSentinelOptions + | RedisClientConnectionType; diff --git a/storage/redis/test/create-keyv.test.ts b/storage/redis/test/create-keyv.test.ts index c873833ba..dc3ff2c10 100644 --- a/storage/redis/test/create-keyv.test.ts +++ b/storage/redis/test/create-keyv.test.ts @@ -34,6 +34,15 @@ describe("createKeyv", () => { expect(keyv.store).toBeInstanceOf(KeyvRedis); expect(keyv.namespace).toBe(namespace); expect(keyv.store.namespace).toBe(namespace); + expect(keyv.throwOnErrors).toBe(true); + }); + + test("should create a cluster-backed Keyv instance from cluster options", () => { + const keyv = createKeyv({ + rootNodes: [{ url: "redis://localhost:7001" }], + }); + expect(keyv.store).toBeInstanceOf(KeyvRedis); + expect(keyv.store.isCluster()).toBe(true); }); }); diff --git a/storage/redis/test/events.test.ts b/storage/redis/test/events.test.ts index 5c61b5695..40ff53747 100644 --- a/storage/redis/test/events.test.ts +++ b/storage/redis/test/events.test.ts @@ -66,6 +66,33 @@ describe("events", () => { expect(keyvRedis.client.listenerCount("connect")).toBe(1); }); + test("should remove listeners from the previous client when replaced", () => { + const keyvRedis = new KeyvRedis(redisUri); + const oldClient = keyvRedis.client; + const newClient = createClient({ url: redisUri }) as RedisClientType; + + expect(oldClient.listenerCount("error")).toBe(1); + expect(oldClient.listenerCount("connect")).toBe(1); + + keyvRedis.client = newClient; + + expect(oldClient.listenerCount("error")).toBe(0); + expect(oldClient.listenerCount("connect")).toBe(0); + expect(oldClient.listenerCount("disconnect")).toBe(0); + expect(oldClient.listenerCount("reconnecting")).toBe(0); + expect(newClient.listenerCount("error")).toBe(1); + expect(newClient.listenerCount("connect")).toBe(1); + }); + + test("should reset PXAT detection when the client is replaced", () => { + const keyvRedis = new KeyvRedis(redisUri); + (keyvRedis as unknown as { _pxatSupported: boolean })._pxatSupported = false; + + keyvRedis.client = createClient({ url: redisUri }) as RedisClientType; + + expect((keyvRedis as unknown as { _pxatSupported?: boolean })._pxatSupported).toBeUndefined(); + }); + test("should emit an error event when clearBatchSize is set to an invalid value", () => { const keyvRedis = new KeyvRedis(redisUri); let received = ""; diff --git a/storage/redis/test/get.test.ts b/storage/redis/test/get.test.ts index 892449989..79df01d71 100644 --- a/storage/redis/test/get.test.ts +++ b/storage/redis/test/get.test.ts @@ -2,9 +2,10 @@ import process from "node:process"; import { faker } from "@faker-js/faker"; import { delay } from "@keyv/test-suite"; import { describe, expect, test, vi } from "vitest"; -import KeyvRedis from "../src/index.js"; +import KeyvRedis, { RedisErrorMessages } from "../src/index.js"; const redisUri = process.env.REDIS_URI ?? "redis://localhost:6379"; +const redisBadUri = process.env.REDIS_BAD_URI ?? "redis://localhost:6378"; describe("get", () => { test("should get many values", async () => { @@ -160,4 +161,36 @@ describe("get", () => { expect(values).toEqual([]); await keyvRedis.disconnect(); }); + + test("should throw on getMany connection error when throwOnConnectError is true", async () => { + const keyvRedis = new KeyvRedis(redisBadUri, { + throwOnConnectError: true, + connectionTimeout: 500, + }); + keyvRedis.on("error", () => {}); + + let didError = false; + try { + await keyvRedis.getMany([faker.string.alphanumeric(10), faker.string.alphanumeric(10)]); + } catch (error) { + didError = true; + expect((error as Error).message).toBe(RedisErrorMessages.RedisClientNotConnectedThrown); + } + + expect(didError).toBe(true); + }); + + test("should not throw on getMany connection error when throwOnConnectError is false", async () => { + const keyvRedis = new KeyvRedis(redisBadUri, { + throwOnConnectError: false, + connectionTimeout: 500, + }); + keyvRedis.on("error", () => {}); + + const result = await keyvRedis.getMany([ + faker.string.alphanumeric(10), + faker.string.alphanumeric(10), + ]); + expect(result).toEqual([undefined, undefined]); + }); }); diff --git a/storage/redis/test/main.test.ts b/storage/redis/test/main.test.ts index 106fa5c0a..016cdeffc 100644 --- a/storage/redis/test/main.test.ts +++ b/storage/redis/test/main.test.ts @@ -2,7 +2,7 @@ import process from "node:process"; import { faker } from "@faker-js/faker"; import { createClient, type RedisClientType } from "@redis/client"; import { beforeEach, describe, expect, test } from "vitest"; -import KeyvRedis, { createKeyv } from "../src/index.js"; +import KeyvRedis, { createKeyv, RedisErrorMessages } from "../src/index.js"; const redisUri = process.env.REDIS_URI ?? "redis://localhost:6379"; @@ -220,4 +220,23 @@ describe("KeyvRedis Methods", () => { await keyvRedis.clear(); await keyvRedis.disconnect(); }); + + test("should throw on clear connection error when throwOnConnectError is true", async () => { + const redisBadUri = process.env.REDIS_BAD_URI ?? "redis://localhost:6378"; + const keyvRedis = new KeyvRedis(redisBadUri, { + throwOnConnectError: true, + connectionTimeout: 500, + }); + keyvRedis.on("error", () => {}); + + let didError = false; + try { + await keyvRedis.clear(); + } catch (error) { + didError = true; + expect((error as Error).message).toBe(RedisErrorMessages.RedisClientNotConnectedThrown); + } + + expect(didError).toBe(true); + }); }); diff --git a/storage/redis/test/namespace.test.ts b/storage/redis/test/namespace.test.ts index 1135fdd4b..20cb3c336 100644 --- a/storage/redis/test/namespace.test.ts +++ b/storage/redis/test/namespace.test.ts @@ -18,6 +18,13 @@ describe("Namespace", () => { const testKey = faker.string.uuid(); const key = keyvRedis.createKeyPrefix(testKey, "ns2"); expect(key).toBe(`ns2::${testKey}`); + expect(keyvRedis.getKeyWithoutPrefix(key, "ns2")).toBe(testKey); + }); + + test("getKeyWithoutPrefix only strips a leading namespace prefix", () => { + const keyvRedis = new KeyvRedis(); + const key = "ns1::hello::ns1::world"; + expect(keyvRedis.getKeyWithoutPrefix(key, "ns1")).toBe("hello::ns1::world"); }); test("if no namespace on key prefix and no default namespace", async () => { From 6f9536cd26022dab84539b709987157c4b9a6170 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 23:53:31 +0000 Subject: [PATCH 2/4] redis - docs: Align README with the v6 expires and namespace contract Document v5-to-v6 migration, absolute expires on set/setMany, the default :: namespace separator, and createClient({ url }) instead of a URI string. Co-authored-by: Jared Wray --- storage/redis/README.md | 46 ++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/storage/redis/README.md b/storage/redis/README.md index a61dd33fe..075360e0c 100644 --- a/storage/redis/README.md +++ b/storage/redis/README.md @@ -25,6 +25,7 @@ Redis storage adapter for [Keyv](https://github.com/jaredwray/keyv). # Table of Contents * [Usage](#usage) +* [Migrating from v5 to v6](#migrating-from-v5-to-v6) * [Migrating from v4 to v5](#migrating-from-v4-to-v5) * [Using the createKeyv function](#using-the-createkeyv-function) * [Using the createKeyvNonBlocking function](#using-the-createkeyvnonblocking-function) @@ -103,11 +104,20 @@ Or you can create a new Redis instance and pass it in with `KeyvOptions` such as import Keyv from 'keyv'; import KeyvRedis, { createClient } from '@keyv/redis'; -const redis = createClient('redis://user:pass@localhost:6379'); +const redis = createClient({ url: 'redis://user:pass@localhost:6379' }); const keyvRedis = new KeyvRedis(redis); const keyv = new Keyv({ store: keyvRedis}); ``` +# Migrating from v5 to v6 + +`@keyv/redis` v6 tracks Keyv's shared version. The adapter already implemented the v6 storage contract (`capabilities.expires`); these are the redis-specific things to know: + +* **`@redis/client` is now v6.** `createClient`, `createCluster`, and `createSentinel` are still exported from this package. `createClient` takes a `RedisClientOptions` object (`{ url: 'redis://...' }`), not a URI string. +* **Adapters receive absolute `expires`, not relative `ttl`.** When you call `keyv.set(key, value, 1000)`, Keyv converts that millisecond ttl to a Unix-ms deadline and passes it to the adapter. Direct adapter calls should pass `expires` (`Date.now() + ttl`), not a relative ttl. +* **Keyv no longer prefixes keys.** Namespacing lives on the adapter (`namespace` + `keyPrefixSeparator`, default `::`). You do not need `useKeyPrefix: false` — that option was removed from Keyv. +* **`createKeyv` accepts cluster and sentinel options** the same way the `KeyvRedis` constructor does. + # Migrating from v4 to v5 The major change from v4 to v5 is that we are now using v5 of the `@redis/client` library which has a new API. This means that some methods have changed but it should be a drop-in replacement for most use cases. @@ -194,7 +204,7 @@ keyv.store.namespace = 'my-namespace'; # Using the `createKeyv` function -The `createKeyv` function is a convenience function that creates a new `Keyv` instance with the `@keyv/redis` store. It automatically sets the `useKeyPrefix` option to `false`. Here is an example of how to use it: +The `createKeyv` function is a convenience function that creates a new `Keyv` instance with the `@keyv/redis` store. It applies `namespace` on both Keyv and the adapter so keys are prefixed once. Here is an example of how to use it: ```js import { createKeyv } from '@keyv/redis'; @@ -233,9 +243,9 @@ You can set a namespace for your keys. This is useful if you want to manage your import Keyv from 'keyv'; import KeyvRedis, { createClient } from '@keyv/redis'; -const redis = createClient('redis://user:pass@localhost:6379'); +const redis = createClient({ url: 'redis://user:pass@localhost:6379' }); const keyvRedis = new KeyvRedis(redis); -const keyv = new Keyv({ store: keyvRedis, namespace: 'my-namespace', useKeyPrefix: false }); +const keyv = new Keyv({ store: keyvRedis, namespace: 'my-namespace' }); ``` To make this easier, you can use the `createKeyv` function which will automatically set the `namespace` option to the `KeyvRedis` instance: @@ -245,7 +255,7 @@ import { createKeyv } from '@keyv/redis'; const keyv = createKeyv('redis://user:pass@localhost:6379', { namespace: 'my-namespace' }); ``` -This will prefix all keys with `my-namespace:` and will also set `useKeyPrefix` to `false`. This is done to avoid double prefixing of keys as we transition out of the legacy behavior in Keyv. You can also set the namespace after the fact: +This will prefix all keys with `my-namespace::` (the default `keyPrefixSeparator` is `::`). You can also set the namespace after the fact: ```js keyv.namespace = 'my-namespace'; @@ -255,22 +265,24 @@ NOTE: If you plan to do many clears or deletes, it is recommended to read the [P # Fixing Double Prefixing of Keys -If you are using `Keyv` with `@keyv/redis` as the storage adapter, you may notice that keys are being prefixed twice. This is because `Keyv` has a default prefixing behavior that is applied to all keys. To fix this, you can set the `useKeyPrefix` option to `false` when creating the `Keyv` instance: +In v6, Keyv does not prefix keys. The Redis adapter owns namespacing, so this: ```js import Keyv from 'keyv'; import KeyvRedis from '@keyv/redis'; -const keyv = new Keyv(new KeyvRedis('redis://user:pass@localhost:6379'), { useKeyPrefix: false }); +const keyv = new Keyv(new KeyvRedis('redis://user:pass@localhost:6379'), { namespace: 'my-namespace' }); ``` -To make this easier, you can use the `createKeyv` function which will automatically set the `useKeyPrefix` option to `false`: +stores keys as `my-namespace::key`. `createKeyv` does the same wiring for you: ```js import { createKeyv } from '@keyv/redis'; -const keyv = createKeyv('redis://user:pass@localhost:6379'); +const keyv = createKeyv('redis://user:pass@localhost:6379', { namespace: 'my-namespace' }); ``` +If you are upgrading from v5, drop `useKeyPrefix` — it no longer exists. Do not also set Redis client's `keyPrefix`; use the adapter `namespace` instead so `SCAN`/`clear`/`iterator` stay in sync. + ## Using Generic Types When initializing `KeyvRedis`, you can specify the type of the values you are storing and you can also specify types when calling methods: @@ -280,12 +292,12 @@ import Keyv from 'keyv'; import KeyvRedis, { createClient } from '@keyv/redis'; -type User { +type User = { id: number name: string } -const redis = createClient('redis://user:pass@localhost:6379'); +const redis = createClient({ url: 'redis://user:pass@localhost:6379' }); const keyvRedis = new KeyvRedis(redis); const keyv = new Keyv({ store: keyvRedis }); @@ -322,7 +334,7 @@ Keyv hands this adapter an **absolute** expiry — a Unix timestamp in milliseco * On the **first** expiring write, the adapter runs `INFO server` once, parses `redis_version`, and caches whether the server is 6.2+. Every later write reuses that cached answer, so detection costs one `INFO` round-trip per connection, not per write. * **6.2 or newer** → the write uses `PXAT: expires` (absolute). -* **Older than 6.2** → the write uses `PX: max(0, expires - Date.now())` (relative, computed at write time). An expiry already in the past becomes `PX: 0`. +* **Older than 6.2** → the write uses `PX: max(1, expires - Date.now())` (relative, computed at write time). An expiry already in the past becomes `PX: 1` because Redis rejects `SET ... PX 0`. * If the version **cannot be determined** — for example a cluster where `INFO` isn't directly available, or a transient error reading it — the adapter assumes `PXAT` is supported, since such deployments are overwhelmingly modern. The cached result means it won't keep retrying `INFO` on every write. The same detection and fallback apply to `setMany`, including the per-hash-slot grouping used in cluster mode. No configuration is required; you always call `keyv.set(key, value, ttl)` with a relative millisecond `ttl` (or rely on the `ttl` option) and the adapter chooses the correct Redis option for your server. @@ -338,7 +350,7 @@ If you are deleting or clearing a large number of keys you can disable this by s ```js const keyv = new Keyv(new KeyvRedis('redis://user:pass@localhost:6379', { useUnlink: false })); // Or -keyv.useUnlink = false; +keyv.store.useUnlink = false; ``` # Gracefully Handling Errors and Timeouts @@ -513,8 +525,8 @@ const keyv = new Keyv({ store: new KeyvRedis(tlsOptions) }); ## Methods * **constructor([connection], [options])** - Create a new `KeyvRedis` instance. See [Keyv Redis Options](#keyv-redis-options). * **getClient()** - Get the connected Redis client. Connects first if the client is not already connected. -* **set(key, value, [ttl])** - Set a key. `ttl` is in milliseconds. Returns `boolean`. -* **setMany(entries)** - Set multiple keys using `KeyvEntry` objects (`{ key: string, value: Value, ttl?: number }`) via `MULTI/EXEC` transactions. Returns `boolean[]` with per-entry success tracking by inspecting each command's result. In cluster mode, entries are grouped by hash slot with results mapped back to the original order. +* **set(key, value, [expires])** - Set a key. `expires` is an absolute Unix timestamp in milliseconds. Returns `boolean`. When used through Keyv, pass a relative millisecond `ttl` to `keyv.set` — Keyv converts it to `expires` for you. +* **setMany(entries)** - Set multiple keys using `KeyvStorageEntry` objects (`{ key: string, value: Value, expires?: number }`) via `MULTI/EXEC` transactions. Returns `boolean[]` with per-entry success tracking by inspecting each command's result. In cluster mode, entries are grouped by hash slot with results mapped back to the original order. * **get(key)** - Get a key. Returns the value or `undefined` if the key does not exist. * **getMany(keys)** - Get multiple keys. Returns an array of values where each entry is the value or `undefined` if the key does not exist. * **has(key)** - Check if a key exists. Returns `boolean`. @@ -548,7 +560,7 @@ redisClient.on('reconnecting', () => { console.log('Redis client reconnecting'); }); -redisClient.on('end', () => { +redisClient.on('disconnect', () => { console.log('Redis client disconnected'); }); ``` @@ -739,4 +751,4 @@ You can learn more about caching in NestJS in the [official documentation](https # License -[MIT © Jared Wray](LISCENCE) +[MIT © Jared Wray](LICENSE) From 97ec3a09afc69dc24fc04a8c10e319960831692c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 00:05:21 +0000 Subject: [PATCH 3/4] redis - refactor: Align class layout, jsDocs, and README API Order public properties under the constructor, keep private methods last, map Redis null to undefined, and document the Hookified event contract in the README. Co-authored-by: Jared Wray --- storage/redis/README.md | 126 +++++++------ storage/redis/src/create.ts | 32 ++-- storage/redis/src/index.ts | 343 ++++++++++++++++++++---------------- storage/redis/src/types.ts | 34 ++-- 4 files changed, 310 insertions(+), 225 deletions(-) diff --git a/storage/redis/README.md b/storage/redis/README.md index 075360e0c..ae2da8119 100644 --- a/storage/redis/README.md +++ b/storage/redis/README.md @@ -42,7 +42,7 @@ Redis storage adapter for [Keyv](https://github.com/jaredwray/keyv). * [TLS Support](#tls-support) * [Keyv Redis Options](#keyv-redis-options) * [API](#api) -* [Using Custom Redis Client Events](#using-custom-redis-client-events) +* [Events](#events) * [Migrating from v3 to v4](#migrating-from-v3-to-v4) * [About Redis Sets and its Support in v4](#about-redis-sets-and-its-support-in-v4) * [Using with NestJS](#using-with-nestjs) @@ -148,28 +148,27 @@ export type KeyvRedisOptions = { /** * Whether to allow clearing all keys when no namespace is set. * If set to true and no namespace is set, iterate() will return all keys. - * Defaults to `false`. + * @default false */ noNamespaceAffectsAll?: boolean; /** - * This is used to throw an error if the client is not connected when trying to connect. By default, this is - * set to true so that it throws an error when trying to connect to the Redis server fails. + * Throw an error if the client is not connected when trying to connect. By default this is + * `true` so a failed Redis connection throws. + * @default true */ throwOnConnectError?: boolean; /** - * This is used to throw an error if at any point there is a failure. Use this if you want to - * ensure that all operations are successful and you want to handle errors. By default, this is - * set to false so that it does not throw an error on every operation and instead emits an error event - * and returns no-op responses. + * Throw an error if any operation fails. When `false`, failures emit an `error` event + * and return no-op responses (`undefined` for gets, `false` for writes/deletes). * @default false */ throwOnErrors?: boolean; /** - * Timeout in milliseconds for the connection. Default is undefined, which uses the default timeout of the Redis client. - * If set, it will throw an error if the connection does not succeed within the specified time. + * Timeout in milliseconds for the connection. When undefined, the Redis client default is used. + * If set, connection that does not succeed within this time throws. * @default undefined */ connectionTimeout?: number; @@ -202,6 +201,8 @@ const keyv = createKeyv('redis://user:pass@localhost:6379'); keyv.store.namespace = 'my-namespace'; ``` +See [API](#api) for the full property and method list. Missing keys and swallowed errors return `undefined` (never `null`). Events are emitted through [Hookified](https://hookified.org) — see [Events](#events). + # Using the `createKeyv` function The `createKeyv` function is a convenience function that creates a new `Keyv` instance with the `@keyv/redis` store. It applies `namespace` on both Keyv and the adapter so keys are prefixed once. Here is an example of how to use it: @@ -511,61 +512,88 @@ const keyv = new Keyv({ store: new KeyvRedis(tlsOptions) }); # API +`KeyvRedis` extends [Hookified](https://hookified.org) (`on`, `once`, `emit`, hooks) and implements the Keyv storage adapter contract. Missing keys and swallowed errors return `undefined` (never `null`). + ## Properties -* **client** - The Redis client, cluster, or sentinel connection instance. -* **namespace** - The namespace to use for the keys. If `undefined` no namespace prefixing is applied. Default is `undefined`. -* **keyPrefixSeparator** - The separator to use between the namespace and key. It can be set to a blank string. Default is `::`. -* **clearBatchSize** - The number of keys to delete in a single batch. Has to be greater than 0. Default is `1000`. -* **useUnlink** - Use the `UNLINK` command for deleting keys instead of `DEL`. Default is `true`. -* **noNamespaceAffectsAll** - Whether to allow clearing and iterating all keys when no namespace is set. Default is `false`. -* **throwOnConnectError** - Whether to throw an error if the client fails to connect. Default is `true`. -* **throwOnErrors** - Whether to throw an error if any operation fails instead of emitting an error event and returning a no-op response. Default is `false`. -* **connectionTimeout** - The connection timeout in milliseconds. Default is `undefined` which uses the Redis client default. +* **capabilities** - Adapter capability descriptor. `capabilities.expires` is `true` (absolute Unix-ms `expires` on `set` / `setMany`). +* **client** - The Redis client, cluster, or sentinel connection. Assigning a new connection re-wires Hookified event listeners and resets PXAT detection. Type: `RedisClientConnectionType`. +* **namespace** - Namespace used to prefix keys. `undefined` means no prefixing. Default: `undefined`. +* **keyPrefixSeparator** - Separator between namespace and key. May be `""`. Default: `"::"`. +* **clearBatchSize** - `SCAN` / delete batch size. Must be greater than `0` or an `error` event is emitted. Default: `1000`. +* **useUnlink** - Use `UNLINK` instead of `DEL`. Default: `true`. +* **noNamespaceAffectsAll** - When no namespace is set, `clear()` / `iterator()` affect all keys (including namespaced ones). Default: `false`. +* **throwOnConnectError** - Throw when connect fails. Default: `true`. +* **throwOnErrors** - Throw on operation failures instead of emitting `error` and returning a no-op. Default: `false`. +* **connectionTimeout** - Connect timeout in milliseconds. `undefined` uses the Redis client default. ## Methods -* **constructor([connection], [options])** - Create a new `KeyvRedis` instance. See [Keyv Redis Options](#keyv-redis-options). -* **getClient()** - Get the connected Redis client. Connects first if the client is not already connected. -* **set(key, value, [expires])** - Set a key. `expires` is an absolute Unix timestamp in milliseconds. Returns `boolean`. When used through Keyv, pass a relative millisecond `ttl` to `keyv.set` — Keyv converts it to `expires` for you. -* **setMany(entries)** - Set multiple keys using `KeyvStorageEntry` objects (`{ key: string, value: Value, expires?: number }`) via `MULTI/EXEC` transactions. Returns `boolean[]` with per-entry success tracking by inspecting each command's result. In cluster mode, entries are grouped by hash slot with results mapped back to the original order. -* **get(key)** - Get a key. Returns the value or `undefined` if the key does not exist. -* **getMany(keys)** - Get multiple keys. Returns an array of values where each entry is the value or `undefined` if the key does not exist. -* **has(key)** - Check if a key exists. Returns `boolean`. -* **hasMany(keys)** - Check if multiple keys exist. Returns `boolean[]`. -* **delete(key)** - Delete a key. Returns `boolean`. -* **deleteMany(keys)** - Delete multiple keys. Returns `boolean[]`. -* **clear()** - Clear all keys in the namespace. If the namespace is not set it will clear all keys that are not prefixed with a namespace unless `noNamespaceAffectsAll` is set to `true`. -* **disconnect([force])** - Disconnect from the Redis server using the `Quit` command. If you set `force` to `true` it will force the disconnect. -* **iterator()** - Create a new async iterator for the keys and values. The iterator uses the namespace configured on the instance and does not take a namespace parameter. If no namespace is set it will iterate over all keys that are not prefixed with a namespace unless `noNamespaceAffectsAll` is set to `true`. -* **createKeyPrefix(key, [namespace])** - Helper that returns the key with the namespace prefix applied such as `namespace::key`. -* **getKeyWithoutPrefix(key, [namespace])** - Helper that returns the key with the namespace prefix removed. -* **isCluster()** - Returns `true` if the client is a Redis cluster. -* **isSentinel()** - Returns `true` if the client is a Redis sentinel. -* **getMasterNodes()** - Get the master node clients in the cluster. If the client is not a cluster it returns the single client. - -# Using Custom Redis Client Events - -Keyv by default supports the `error` event across all storage adapters. If you want to listen to other events you can do so by accessing the `client` property of the `KeyvRedis` instance. Here is an example of how to do that: +* **constructor([connect], [options])** - `connect` is a URI string, client/cluster/sentinel options (`KeyvRedisConnect`), or an existing connection. See [Keyv Redis Options](#keyv-redis-options). +* **getClient()** - Return the connected client, connecting first if needed. Returns `Promise`. +* **set(key, value, [expires])** - Set a key. `expires` is an absolute Unix timestamp in milliseconds. Returns `Promise`. Through Keyv, pass a relative millisecond `ttl` to `keyv.set` — Keyv converts it to `expires`. +* **setMany(entries)** - Set `KeyvStorageEntry` objects (`{ key, value, expires? }`) via `MULTI/EXEC`. Returns `Promise` (per-entry success). Cluster mode groups by hash slot. +* **get(key)** - Get a key. Returns the value or `undefined` if missing (Redis `null` is mapped to `undefined`). +* **getMany(keys)** - Get multiple keys. Each missing entry is `undefined`. +* **has(key)** - Returns `Promise`. +* **hasMany(keys)** - Returns `Promise`. +* **delete(key)** - Returns `Promise`. +* **deleteMany(keys)** - Returns `Promise`. +* **clear()** - Clear keys in the namespace. With no namespace, clears un-prefixed keys unless `noNamespaceAffectsAll` is `true` (`FLUSHDB`). +* **iterator()** - Async generator of `[key, value]` pairs. Uses the instance namespace. Missing values are `undefined`. +* **disconnect([force])** - Graceful `close()` (`QUIT`) when `force` is false; `destroy()` when `true`. +* **createKeyPrefix(key, [namespace])** - Returns `namespace::key` (or `key` when namespace is omitted). +* **getKeyWithoutPrefix(key, [namespace])** - Strips a leading namespace prefix. +* **isCluster()** - `true` if the connection is a Redis cluster. +* **isSentinel()** - `true` if the connection is a Redis sentinel. +* **getMasterNodes()** - Cluster master node clients, or `[client]` when standalone. + +## Helpers +* **createKeyv([connect], [options])** - Keyv instance with this adapter. Applies `namespace` on both Keyv and the store. `connect` accepts the same `KeyvRedisConnect` types as the constructor (including cluster/sentinel). Defaults to `"redis://localhost:6379"` when `connect` is omitted. +* **createKeyvNonBlocking([connect], [options])** - Same as `createKeyv`, then disables throws, the offline queue, and reconnect for secondary-cache use. +* **defaultReconnectStrategy(attempts)** - Default socket reconnect delay when a URI string is passed to the constructor. Exponential backoff capped at 2s, plus up to ±50ms of jitter. Returns a delay in milliseconds. + +# Events + +`KeyvRedis` extends Hookified and re-emits these events from the Redis client onto the adapter: + +| Event | Payload | When | +| --- | --- | --- | +| `error` | `Error` (or a string for invalid `clearBatchSize`) | Client errors, connect failures, operation failures | +| `connect` | the Redis connection | The client connects | +| `disconnect` | the Redis connection | The client disconnects | +| `reconnecting` | reconnect info from `@redis/client` | The client is reconnecting | ```js import {createKeyv} from '@keyv/redis'; const keyv = createKeyv('redis://user:pass@localhost:6379'); -const redisClient = keyv.store.client; +const store = keyv.store; + +store.on('error', (error) => { + console.error('adapter error', error); +}); -redisClient.on('connect', () => { - console.log('Redis client connected'); +store.on('connect', () => { + console.log('Redis connected'); }); -redisClient.on('reconnecting', () => { - console.log('Redis client reconnecting'); +store.once('disconnect', () => { + console.log('Redis disconnected'); }); -redisClient.on('disconnect', () => { - console.log('Redis client disconnected'); +store.on('reconnecting', () => { + console.log('Redis reconnecting'); +}); +``` + +`error`, `connect`, `disconnect`, and `reconnecting` are re-emitted on the adapter. Other Redis-client-only events (for example `ready`) can still be listened on `store.client`: + +```js +store.client.on('ready', () => { + console.log('Redis client ready'); }); ``` -Here are some of the events you can listen to: https://www.npmjs.com/package/redis#events +Client events: https://www.npmjs.com/package/redis#events # Migrating from v3 to v4 diff --git a/storage/redis/src/create.ts b/storage/redis/src/create.ts index 4c8a0292a..b63c99977 100644 --- a/storage/redis/src/create.ts +++ b/storage/redis/src/create.ts @@ -4,11 +4,18 @@ import KeyvRedis from "./index.js"; import type { KeyvRedisConnect, KeyvRedisOptions } from "./types.js"; /** - * Create a Keyv instance with the Redis adapter. Namespace is applied on both Keyv and the - * adapter so keys are prefixed once (`namespace::key` with the default separator). - * @param {KeyvRedisConnect} [connect] - How to connect to the Redis server. If string pass in the url, if object pass in the options, if RedisClient pass in the client. If nothing is passed in, it will default to 'redis://localhost:6379'. - * @param {KeyvRedisOptions} [options] - Options for the adapter such as namespace, keyPrefixSeparator, and clearBatchSize. - * @returns {Keyv} - Keyv instance with the Redis adapter + * Create a Keyv instance backed by {@link KeyvRedis}. Namespace is applied on both + * Keyv and the adapter so keys are prefixed once (`namespace::key` with the default separator). + * + * @param {KeyvRedisConnect} [connect] - URI, client/cluster/sentinel options, or an existing + * connection. Defaults to `"redis://localhost:6379"`. + * @param {KeyvRedisOptions} [options] - Adapter options such as `namespace`, `keyPrefixSeparator`, + * `clearBatchSize`, `throwOnErrors`, and `connectionTimeout`. + * @returns {Keyv} A Keyv instance using KeyvRedis as the store. + * @example + * ```ts + * const keyv = createKeyv("redis://localhost:6379", { namespace: "cache" }); + * ``` */ export function createKeyv(connect?: KeyvRedisConnect, options?: KeyvRedisOptions): Keyv { connect ??= "redis://localhost:6379"; @@ -26,12 +33,15 @@ export function createKeyv(connect?: KeyvRedisConnect, options?: KeyvRedisOption } /** - * Will create a non-blocking Keyv instance with the Redis adapter. This does everything `createKeyv` does but also - * disables throwing errors, removes the offline queue, and disables the reconnect strategy so that when used as a - * secondary cache (such as with cacheable) it does not block the primary cache on connection errors or timeouts. - * @param {KeyvRedisConnect} [connect] - How to connect to the Redis server. If string pass in the url, if object pass in the options, if RedisClient pass in the client. If nothing is passed in, it will default to 'redis://localhost:6379'. - * @param {KeyvRedisOptions} [options] - Options for the adapter such as namespace, keyPrefixSeparator, and clearBatchSize. - * @returns {Keyv} - non-blocking Keyv instance with the Redis adapter + * Create a non-blocking Keyv instance with the Redis adapter. Same as {@link createKeyv}, then + * disables throwing, the Redis offline queue, and reconnect so a secondary cache (for example + * cacheable) does not block the primary cache on connection errors or timeouts. + * + * @param {KeyvRedisConnect} [connect] - URI, client/cluster/sentinel options, or an existing + * connection. Defaults to `"redis://localhost:6379"`. + * @param {KeyvRedisOptions} [options] - Adapter options. `throwOnConnectError` and `throwOnErrors` + * are forced off on the returned instance. + * @returns {Keyv} A non-blocking Keyv instance using KeyvRedis as the store. */ export function createKeyvNonBlocking( connect?: KeyvRedisConnect, diff --git a/storage/redis/src/index.ts b/storage/redis/src/index.ts index 15e598a50..f48f7a51e 100644 --- a/storage/redis/src/index.ts +++ b/storage/redis/src/index.ts @@ -48,12 +48,24 @@ export { RedisErrorMessages, }; +/** + * Redis storage adapter for Keyv. Supports standalone, cluster, and sentinel + * connections via `@redis/client`. Extends [Hookified](https://hookified.org) and + * re-emits `error`, `connect`, `disconnect`, and `reconnecting` from the underlying + * client. Implements {@link KeyvStorageAdapter} with namespacing, absolute `expires` + * (`PXAT` / `PX` fallback), batch operations, and async iteration. + * + * @example + * ```ts + * import KeyvRedis from "@keyv/redis"; + * import Keyv from "keyv"; + * + * const store = new KeyvRedis("redis://localhost:6379", { namespace: "cache" }); + * const keyv = new Keyv({ store }); + * store.on("error", (error) => console.error(error)); + * ``` + */ export default class KeyvRedis extends Hookified implements KeyvStorageAdapter { - /** Declares the v6 absolute-`expires` storage contract via `capabilities.expires`. */ - public get capabilities() { - return keyvStorageCapability(this); - } - /** * The underlying Redis client, cluster, or sentinel connection used for all storage operations. */ @@ -98,6 +110,11 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte * @default undefined */ private _connectionTimeout: number | undefined; + /** + * Whether the connected server supports the absolute-expiry `PXAT` option (Redis 6.2+). + * Detected lazily on first expiring write and cached. `undefined` until detected. + */ + private _pxatSupported?: boolean; /** * Tracks the client instance whose events have already been wired up so that * repeated initialization does not attach duplicate listeners. @@ -133,9 +150,18 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte }; /** - * KeyvRedis constructor. - * @param {KeyvRedisConnect} [connect] How to connect to the Redis server. If string pass in the url, if object pass in the options, if RedisClient pass in the client. - * @param {KeyvRedisOptions} [options] Options for the adapter such as namespace, keyPrefixSeparator, and clearBatchSize. + * Creates a new KeyvRedis adapter. + * + * Accepts a Redis URI string, client/cluster/sentinel options, or an existing + * connection. When a URI string is provided, a client is created with + * {@link defaultReconnectStrategy}. + * + * @param {KeyvRedisConnect} [connect] - URI (`"redis://localhost:6379"`), + * Redis client/cluster/sentinel options, or an existing connection. Defaults to + * a localhost client with {@link defaultReconnectStrategy}. + * @param {KeyvRedisOptions} [options] - Adapter options such as `namespace`, + * `keyPrefixSeparator`, `clearBatchSize`, `useUnlink`, `throwOnErrors`, and + * `connectionTimeout`. */ constructor(connect?: KeyvRedisConnect, options?: KeyvRedisOptions) { super({ throwOnEmptyListeners: false }); @@ -177,9 +203,17 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte this.initClient(); } + /** + * Declares the v6 absolute-`expires` storage contract via `capabilities.expires`. + * @returns {ReturnType} The adapter capability descriptor with `expires: true`. + */ + public get capabilities() { + return keyvStorageCapability(this); + } + /** * Get the Redis client, cluster, or sentinel connection. - * @returns {RedisClientConnectionType} The current Redis client connection. + * @returns {RedisClientConnectionType} The current Redis client, cluster, or sentinel connection. */ public get client(): RedisClientConnectionType { return this._client; @@ -352,10 +386,11 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Get the connected Redis client. If the client is not already connected it will connect first, respecting - * the connectionTimeout. If the connection fails it will emit an error event and, when throwOnConnectError - * is true, throw an error. - * @returns {Promise} The connected Redis client. + * Get the connected Redis client. Connects first if the client is not already + * connected, respecting `connectionTimeout`. On failure, emits `error` and, when + * `throwOnConnectError` is true, throws {@link RedisErrorMessages.RedisClientNotConnectedThrown}. + * @returns {Promise} The connected Redis client, cluster, or sentinel. + * @throws {Error} When connect fails and `throwOnConnectError` is true. */ public async getClient(): Promise { if (this._client.isOpen) { @@ -383,72 +418,12 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte return this._client; } - /** - * Whether the connected server supports the absolute-expiry `PXAT` option (Redis 6.2+). - * Detected lazily on first expiring write and cached. `undefined` until detected. - */ - private _pxatSupported?: boolean; - - /** - * Lazily detect whether the server supports `SET ... PXAT` (Redis 6.2+), caching the result. - * Falls back to assuming support (PXAT) when the version cannot be determined (e.g. clusters - * where `INFO` isn't directly available, or a transient error) — those deployments are modern. - * @param client - the Redis client/cluster/sentinel connection to introspect - * @returns {Promise} true if PXAT may be used, false to fall back to relative PX - */ - private async supportsPxat(client: RedisClientConnectionType): Promise { - if (this._pxatSupported !== undefined) { - return this._pxatSupported; - } - - try { - const info = String(await (client as RedisClientType).info("server")); - const match = /redis_version:(\d+)\.(\d+)/.exec(info); - if (match) { - const major = Number(match[1]); - const minor = Number(match[2]); - this._pxatSupported = major > 6 || (major === 6 && minor >= 2); - } else { - this._pxatSupported = true; - } - } catch { - this._pxatSupported = true; - } - - return this._pxatSupported; - } - - /** - * Build the `SET` expiry option for an absolute `expires`. Prefers the skew-immune absolute - * `PXAT`; falls back to a relative `PX` (remaining ms) for servers older than 6.2. - * @param expires - absolute expiry as Unix ms since epoch - * @param usePxat - whether the server supports PXAT (from {@link supportsPxat}) - */ - private expiryOptions(expires: number, usePxat: boolean): { PXAT: number } | { PX: number } { - // Redis rejects `SET ... PX 0` ("invalid expire time"), so floor the relative fallback at - // 1ms for an already-elapsed deadline — the key is written and reaped almost immediately, - // matching how an absolute PXAT in the past behaves (and the memcache exptime floor). - return usePxat ? { PXAT: expires } : { PX: Math.max(1, expires - Date.now()) }; - } - - /** - * Resolve the `SET` expiry option for a single write, detecting PXAT support as needed. - * @param client - the Redis client/cluster/sentinel connection - * @param expires - absolute expiry as Unix ms since epoch - */ - private async buildExpiryOptions( - client: RedisClientConnectionType, - expires: number, - ): Promise<{ PXAT: number } | { PX: number }> { - return this.expiryOptions(expires, await this.supportsPxat(client)); - } - /** * Set a key value pair in the store. Expiry is an absolute Unix timestamp in milliseconds. - * @param {string} key - the key to set - * @param {string} value - the value to set - * @param {number} [expires] - absolute expiry as Unix ms since epoch, or undefined for no expiry - * @returns {Promise} - true if the value was set, false if an error occurred and throwOnErrors is false + * @param {string} key - The key to set. + * @param {string} value - The value to set. + * @param {number} [expires] - Absolute expiry as Unix ms since epoch, or `undefined` for no expiry. + * @returns {Promise} `true` if the value was set, `false` if an error occurred and `throwOnErrors` is false. */ public async set(key: string, value: string, expires?: number): Promise { const client = await this.getClient(); @@ -475,11 +450,12 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Will set many key value pairs in the store. Expiry is an absolute Unix timestamp in milliseconds. This will be done as a single transaction. - * @param {KeyvStorageEntry[]} entries - the key value pairs to set with optional absolute expires - * @returns {Promise} - array of booleans indicating whether each entry was successfully set + * Set many key-value pairs in a single `MULTI/EXEC` transaction (or one transaction + * per hash slot in cluster mode). + * @param {KeyvStorageEntry[]} entries - Entries with `key`, `value`, and optional absolute `expires`. + * @returns {Promise} Per-entry success flags, or all `false` when an error is swallowed. */ - public async setMany(entries: KeyvStorageEntry[]): Promise { + public async setMany(entries: KeyvStorageEntry[]): Promise { try { const results = new Array(entries.length).fill(false); @@ -552,8 +528,8 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte /** * Check if a key exists in the store. - * @param {string} key - the key to check - * @returns {Promise} - true if the key exists, false if not + * @param {string} key - The key to check. + * @returns {Promise} `true` if the key exists, `false` if it does not or an error was swallowed. */ public async has(key: string): Promise { const client = await this.getClient(); @@ -574,9 +550,9 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Check if many keys exist in the store. This will be done as a single transaction. - * @param {Array} keys - the keys to check - * @returns {Promise>} - array of booleans for each key if it exists + * Check if many keys exist in the store in a single transaction (or per hash slot in cluster mode). + * @param {string[]} keys - The keys to check. + * @returns {Promise} Per-key existence flags. */ public async hasMany(keys: string[]): Promise { try { @@ -625,9 +601,9 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Get a value from the store. If the key does not exist, it will return undefined. - * @param {string} key - the key to get - * @returns {Promise} - the value or undefined if the key does not exist + * Get a value from the store. Redis `null` replies are mapped to `undefined`. + * @param {string} key - The key to get. + * @returns {Promise} The stored value, or `undefined` if the key does not exist or an error was swallowed. */ public async get(key: string): Promise { const client = await this.getClient(); @@ -636,11 +612,7 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte key = this.createKeyPrefix(key, this._namespace); const value = await client.get(key); - if (value === null) { - return undefined; - } - - return value as U; + return value === null ? undefined : (value as U); } catch (error) { this.emit("error", error); if (this._throwOnErrors) { @@ -652,9 +624,9 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Get many values from the store. If a key does not exist, it will return undefined. - * @param {Array} keys - the keys to get - * @returns {Promise>} - array of values or undefined if the key does not exist + * Get many values from the store. Missing keys are `undefined`, never `null`. + * @param {string[]} keys - The keys to get. + * @returns {Promise>} Values in the same order as `keys`. */ public async getMany(keys: string[]): Promise> { if (keys.length === 0) { @@ -677,9 +649,9 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Delete a key from the store. - * @param {string} key - the key to delete - * @returns {Promise} - true if the key was deleted, false if not + * Delete a key from the store. Uses `UNLINK` when `useUnlink` is true, otherwise `DEL`. + * @param {string} key - The key to delete. + * @returns {Promise} `true` if the key was deleted, `false` otherwise. */ public async delete(key: string): Promise { const client = await this.getClient(); @@ -701,9 +673,9 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Delete many keys from the store. This will be done as a single transaction. - * @param {Array} keys - the keys to delete - * @returns {Promise} - array of booleans indicating whether each key was successfully deleted + * Delete many keys from the store in a single transaction (or per hash slot in cluster mode). + * @param {string[]} keys - The keys to delete. + * @returns {Promise} Per-key deletion flags. */ public async deleteMany(keys: string[]): Promise { const resultMap = new Map(); @@ -763,9 +735,10 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Disconnect from the Redis server. - * @returns {Promise} - * @param {boolean} [force] - it will send a quit command if false, otherwise it will send a disconnect command to forcefully disconnect. + * Disconnect from the Redis server. Sends `QUIT` (`close`) when `force` is false, + * or forcefully destroys the socket when `force` is true. + * @param {boolean} [force] - When `true`, destroy the connection instead of a graceful close. + * @returns {Promise} Resolves when the client is closed, or immediately if it was not open. * @see {@link https://github.com/redis/node-redis/tree/master/packages/redis#disconnecting} */ public async disconnect(force?: boolean): Promise { @@ -775,10 +748,10 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Helper function to create a key with a namespace. - * @param {string} key - the key to prefix - * @param {string} namespace - the namespace to prefix the key with - * @returns {string} - the key with the namespace such as 'namespace::key' + * Prefix a key with the namespace and {@link keyPrefixSeparator}. + * @param {string} key - The key to prefix. + * @param {string} [namespace] - The namespace to prefix the key with. + * @returns {string} `namespace::key` when a namespace is set, otherwise the original key. */ public createKeyPrefix(key: string, namespace?: string): string { if (namespace) { @@ -789,10 +762,10 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Helper function to get a key without the namespace. - * @param {string} key - the key to remove the namespace from - * @param {string} namespace - the namespace to remove from the key - * @returns {string} - the key without the namespace such as 'key' + * Strip a leading namespace prefix from a key. + * @param {string} key - The key to remove the namespace from. + * @param {string} [namespace] - The namespace to remove from the start of the key. + * @returns {string} The key without the namespace prefix, or the original key if it was not prefixed. */ public getKeyWithoutPrefix(key: string, namespace?: string): string { if (namespace) { @@ -806,25 +779,24 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Is the client a cluster. - * @returns {boolean} - true if the client is a cluster, false if not + * Whether the current connection is a Redis cluster. + * @returns {boolean} `true` if the client is a cluster, `false` otherwise. */ public isCluster(): boolean { return this.isClientCluster(this._client); } /** - * Is the client a sentinel. - * @returns {boolean} - true if the client is a sentinel, false if not + * Whether the current connection is a Redis sentinel. + * @returns {boolean} `true` if the client is a sentinel, `false` otherwise. */ public isSentinel(): boolean { return this.isClientSentinel(this._client); } /** - * Get the master nodes in the cluster. If not a cluster, it will return the single client. - * - * @returns {Promise} - array of master nodes + * Get the master node clients in the cluster. If the client is not a cluster, returns the single client. + * @returns {Promise} Master node clients, or a one-element array with the standalone client. */ public async getMasterNodes(): Promise { if (this.isCluster()) { @@ -843,10 +815,9 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Get an async iterator for the keys and values in the store. The namespace is not passed in and instead - * uses the namespace configured on the instance. It will only iterate over keys with the current namespace. - * If no namespace is set it will iterate over keys with no namespace prefix unless noNamespaceAffectsAll is true. - * @returns {AsyncGenerator<[string, U | undefined], void, unknown>} - async iterator with key value pairs + * Async iterator over keys and values. Uses the instance namespace. With no namespace, + * iterates un-prefixed keys unless `noNamespaceAffectsAll` is true. + * @returns {AsyncGenerator<[string, U | undefined], void, unknown>} Yields `[key, value]` pairs. Missing values are `undefined`. */ public async *iterator(): AsyncGenerator<[string, U | undefined], void, unknown> { // When instance is not a cluster, it will only have one client @@ -881,11 +852,10 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Clear all keys in the store. - * IMPORTANT: this can cause performance issues if there are a large number of keys in the store and worse with clusters. Use with caution as not recommended for production. - * If a namespace is not set it will clear all keys with no prefix. - * If a namespace is set it will clear all keys with that namespace. - * @returns {Promise} + * Clear keys in the current namespace. With no namespace, clears un-prefixed keys unless + * `noNamespaceAffectsAll` is true (then `FLUSHDB`). Uses `SCAN` in batches of `clearBatchSize`. + * Can be expensive on large keyspaces and clusters — not recommended in production. + * @returns {Promise} Resolves when the matching keys have been removed. */ public async clear(): Promise { try { @@ -937,8 +907,66 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Get many keys. If the instance is a cluster, it will do multiple MGET calls - * by separating the keys by slot to solve the CROSS-SLOT restriction. + * Lazily detect whether the server supports `SET ... PXAT` (Redis 6.2+), caching the result. + * Falls back to assuming support (PXAT) when the version cannot be determined (e.g. clusters + * where `INFO` isn't directly available, or a transient error) — those deployments are modern. + * @param {RedisClientConnectionType} client - The Redis client, cluster, or sentinel connection to introspect. + * @returns {Promise} `true` if PXAT may be used, `false` to fall back to relative PX. + */ + private async supportsPxat(client: RedisClientConnectionType): Promise { + if (this._pxatSupported !== undefined) { + return this._pxatSupported; + } + + try { + const info = String(await (client as RedisClientType).info("server")); + const match = /redis_version:(\d+)\.(\d+)/.exec(info); + if (match) { + const major = Number(match[1]); + const minor = Number(match[2]); + this._pxatSupported = major > 6 || (major === 6 && minor >= 2); + } else { + this._pxatSupported = true; + } + } catch { + this._pxatSupported = true; + } + + return this._pxatSupported; + } + + /** + * Build the `SET` expiry option for an absolute `expires`. Prefers the skew-immune absolute + * `PXAT`; falls back to a relative `PX` (remaining ms) for servers older than 6.2. + * @param {number} expires - Absolute expiry as Unix ms since epoch. + * @param {boolean} usePxat - Whether the server supports PXAT (from {@link supportsPxat}). + * @returns {{PXAT: number} | {PX: number}} The Redis `SET` expiry option. + */ + private expiryOptions(expires: number, usePxat: boolean): { PXAT: number } | { PX: number } { + // Redis rejects `SET ... PX 0` ("invalid expire time"), so floor the relative fallback at + // 1ms for an already-elapsed deadline — the key is written and reaped almost immediately, + // matching how an absolute PXAT in the past behaves (and the memcache exptime floor). + return usePxat ? { PXAT: expires } : { PX: Math.max(1, expires - Date.now()) }; + } + + /** + * Resolve the `SET` expiry option for a single write, detecting PXAT support as needed. + * @param {RedisClientConnectionType} client - The Redis client, cluster, or sentinel connection. + * @param {number} expires - Absolute expiry as Unix ms since epoch. + * @returns {Promise<{PXAT: number} | {PX: number}>} The Redis `SET` expiry option. + */ + private async buildExpiryOptions( + client: RedisClientConnectionType, + expires: number, + ): Promise<{ PXAT: number } | { PX: number }> { + return this.expiryOptions(expires, await this.supportsPxat(client)); + } + + /** + * Get many keys. In cluster mode, issues one `MGET` per hash slot to avoid CROSSSLOT errors. + * Redis `null` replies are mapped to `undefined`. + * @param {string[]} keys - Prefixed keys to fetch. + * @returns {Promise>} Values in the same order as `keys`. */ private async mget(keys: string[]): Promise> { const valueMap = new Map(); @@ -969,8 +997,9 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Clear all keys in the store with a specific namespace. If the instance is a cluster, it will clear all keys - * by separating the keys by slot to solve the CROSS-SLOT restriction. + * Delete the given keys, grouping by hash slot in cluster mode to avoid CROSSSLOT errors. + * @param {string[]} keys - Prefixed keys to delete. + * @returns {Promise} Resolves when all slot groups have been deleted. */ private async clearWithClusterSupport(keys: string[]): Promise { /* v8 ignore next -- @preserve */ @@ -988,7 +1017,9 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Returns the master node client for a given slot or the instance's client if it's not a cluster. + * Return the master node client for a hash slot, or the instance client when not clustered. + * @param {number} slot - Redis cluster hash slot. + * @returns {Promise} The node client that owns `slot`. */ private async getSlotMaster(slot: number): Promise { const connection = await this.getClient(); @@ -1009,12 +1040,11 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Group keys by their slot. - * - * @param {string[]} keys - the keys to group - * @returns {Map} - map of slot to keys + * Group keys by their Redis cluster hash slot. Non-cluster clients use a single slot `0` group. + * @param {string[]} keys - The keys to group. + * @returns {Map} Map of slot number to keys in that slot. */ - private getSlotMap(keys: string[]) { + private getSlotMap(keys: string[]): Map { const slotMap = new Map(); if (this.isCluster()) { for (const key of keys) { @@ -1032,26 +1062,27 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Check if the provided client is a cluster client. - * @param {RedisClientConnectionType} client - the client to check - * @returns {boolean} - true if the client is a cluster client, false if not + * Whether the provided client is a cluster connection. + * @param {RedisClientConnectionType} client - The client to check. + * @returns {boolean} `true` if `client` exposes cluster `slots`. */ private isClientCluster(client: RedisClientConnectionType): boolean { return (client as KeyvAny).slots !== undefined; } /** - * Check if the provided client is a sentinel client. - * @param {RedisClientConnectionType} client - the client to check - * @returns {boolean} - true if the client is a sentinel client, false if not + * Whether the provided client is a sentinel connection. + * @param {RedisClientConnectionType} client - The client to check. + * @returns {boolean} `true` if `client` exposes `getSentinelNode`. */ private isClientSentinel(client: RedisClientConnectionType): boolean { return (client as KeyvAny).getSentinelNode !== undefined; } /** - * Apply the provided options to the instance. Only defined options are applied. - * @param {KeyvRedisOptions} [options] - the options to apply + * Apply defined adapter options to this instance. + * @param {KeyvRedisOptions} [options] - Options to apply. Omitted or undefined fields are left unchanged. + * @returns {void} */ private setOptions(options?: KeyvRedisOptions): void { if (!options) { @@ -1092,9 +1123,10 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte } /** - * Wire up the client events (error, connect, disconnect, reconnecting) to be re-emitted on this instance. - * Listeners are only attached once per client instance to avoid duplicates. When the client is replaced, - * listeners are removed from the previous client so events from a discarded connection are not re-emitted. + * Re-emit client `error`, `connect`, `disconnect`, and `reconnecting` events on this adapter + * via Hookified. Listeners are attached once per client instance. Replacing `client` removes + * listeners from the previous connection. + * @returns {void} */ private initClient(): void { if (this._eventsWiredClient === this._client) { @@ -1121,6 +1153,8 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte /** * Whether an operation error should be re-thrown after being emitted. Connection failures * honor `throwOnConnectError`; all other failures honor `throwOnErrors`. + * @param {unknown} error - The caught error. + * @returns {boolean} `true` when the caller should rethrow. */ private shouldRethrow(error: unknown): boolean { if ( @@ -1137,9 +1171,10 @@ export default class KeyvRedis extends Hookified implements KeyvStorageAdapte /** * Race a promise against a timeout, always clearing the timer so a successful connect * does not leave a dangling rejection. - * @param {Promise} promise - the promise to race - * @param {number} timeoutMs - the timeout in milliseconds before the race rejects - * @returns {Promise} - the original promise result, or a timeout rejection + * @template T + * @param {Promise} promise - The promise to race. + * @param {number} timeoutMs - Timeout in milliseconds before the race rejects. + * @returns {Promise} The original promise result, or a timeout rejection. */ private async raceWithTimeout(promise: Promise, timeoutMs: number): Promise { let timeoutId: ReturnType | undefined; diff --git a/storage/redis/src/types.ts b/storage/redis/src/types.ts index 57d8dd8db..491f48b79 100644 --- a/storage/redis/src/types.ts +++ b/storage/redis/src/types.ts @@ -37,28 +37,27 @@ export type KeyvRedisOptions = { /** * Whether to allow clearing all keys when no namespace is set. * If set to true and no namespace is set, iterate() will return all keys. - * Defaults to `false`. + * @default false */ noNamespaceAffectsAll?: boolean; /** - * This is used to throw an error if the client is not connected when trying to connect. By default, this is - * set to true so that it throws an error when trying to connect to the Redis server fails. + * Throw an error if the client is not connected when trying to connect. By default this is + * `true` so a failed Redis connection throws. + * @default true */ throwOnConnectError?: boolean; /** - * This is used to throw an error if at any point there is a failure. Use this if you want to - * ensure that all operations are successful and you want to handle errors. By default, this is - * set to false so that it does not throw an error on every operation and instead emits an error event - * and returns no-op responses. + * Throw an error if any operation fails. When `false`, failures emit an `error` event + * and return no-op responses (`undefined` for gets, `false` for writes/deletes). * @default false */ throwOnErrors?: boolean; /** - * Timeout in milliseconds for the connection. Default is undefined, which uses the default timeout of the Redis client. - * If set, it will throw an error if the connection does not succeed within the specified time. + * Timeout in milliseconds for the connection. When undefined, the Redis client default is used. + * If set, connection that does not succeed within this time throws. * @default undefined */ connectionTimeout?: number; @@ -93,6 +92,12 @@ export enum RedisErrorMessages { RedisClientNotConnectedThrown = "Redis client is not connected or has failed to connect. This is thrown because throwOnConnectError is set to true.", } +/** + * Default socket reconnect strategy used when a URI string is passed to the constructor. + * Exponential backoff capped at 2s, plus up to ±50ms of jitter. + * @param {number} attempts - The current reconnection attempt count (0-based). + * @returns {number | Error} Delay in milliseconds before the next attempt. + */ export const defaultReconnectStrategy = (attempts: number): number | Error => { // Exponential backoff base: double each time, capped at 2s. // Parentheses make it clear we do (2 ** attempts) first, then * 100 @@ -125,8 +130,15 @@ export type RedisClientConnectionType = | RedisConnectionSentinelType; /** - * Accepted first argument to the KeyvRedis constructor and `createKeyv`: a URI string, - * client/cluster/sentinel options object, or an already-created connection. + * First argument to the {@link KeyvRedis} constructor and {@link createKeyv}: a URI string, + * `@redis/client` client/cluster/sentinel options, or an already-created connection. + * + * @example + * ```ts + * new KeyvRedis("redis://localhost:6379"); + * new KeyvRedis({ url: "redis://localhost:6379" }); + * new KeyvRedis({ rootNodes: [{ url: "redis://localhost:7001" }] }); + * ``` */ export type KeyvRedisConnect = | string From a73a43adee52811e4dfe2d4be4f365e093a353c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 00:05:24 +0000 Subject: [PATCH 4/4] redis - test: Uniform faker tests with should descriptions Use test() throughout, faker for keys and values, cover Hookified event wiring, and assert missing gets return undefined rather than null. Co-authored-by: Jared Wray --- storage/redis/test/cluster.test.ts | 20 ++++---- storage/redis/test/create-keyv.test.ts | 14 ++++-- storage/redis/test/delete.test.ts | 11 +---- storage/redis/test/events.test.ts | 66 ++++++++++++++++++++++---- storage/redis/test/get.test.ts | 54 ++++++++++++++------- storage/redis/test/has.test.ts | 52 ++++++-------------- storage/redis/test/iterator.test.ts | 30 ++---------- storage/redis/test/main.test.ts | 20 ++++++-- storage/redis/test/namespace.test.ts | 10 ++-- storage/redis/test/sentinel.test.ts | 10 ++-- storage/redis/test/set.test.ts | 12 +---- storage/redis/test/suite.test.ts | 8 ++-- 12 files changed, 164 insertions(+), 143 deletions(-) diff --git a/storage/redis/test/cluster.test.ts b/storage/redis/test/cluster.test.ts index 9c336ac3e..5ee87fb30 100644 --- a/storage/redis/test/cluster.test.ts +++ b/storage/redis/test/cluster.test.ts @@ -112,9 +112,7 @@ describe("KeyvRedis Cluster", () => { let errorThrown = false; try { await keyvRedis.clear(); - } catch (error) { - console.log(error); - expect(error).toBeDefined(); + } catch { errorThrown = true; } @@ -270,9 +268,7 @@ describe("KeyvRedis Cluster", () => { keys.push(key); values.push(value); } - } catch (error) { - console.log(error); - expect(error).toBeDefined(); + } catch { errorThrown = true; } @@ -376,7 +372,7 @@ describe("KeyvRedis Cluster", () => { expect(values).toContain(val3); }); - test("should only iterate over keys with no namespace if name is undefined set and noNamespaceAffectsAll is false", async () => { + test("should only iterate un-prefixed keys when namespace is undefined and noNamespaceAffectsAll is false", async () => { const cluster = createCluster(defaultClusterOptions); const keyvRedis = new KeyvRedis(cluster); keyvRedis.noNamespaceAffectsAll = false; @@ -415,7 +411,7 @@ describe("KeyvRedis Cluster", () => { }); describe("KeyvRedis Batch Operations", () => { - test("setMany should work with cluster mode without CROSSSLOT errors", async () => { + test("should set many keys in cluster mode without CROSSSLOT errors", async () => { const cluster = createCluster(defaultClusterOptions); const keyvRedis = new KeyvRedis(cluster); @@ -435,7 +431,7 @@ describe("KeyvRedis Cluster", () => { await keyvRedis.disconnect(); }); - test("hasMany should work with cluster mode without CROSSSLOT errors", async () => { + test("should check many keys in cluster mode without CROSSSLOT errors", async () => { const cluster = createCluster(defaultClusterOptions); const keyvRedis = new KeyvRedis(cluster); @@ -457,7 +453,7 @@ describe("KeyvRedis Cluster", () => { await keyvRedis.disconnect(); }); - test("deleteMany should work with cluster mode without CROSSSLOT errors", async () => { + test("should delete many keys in cluster mode without CROSSSLOT errors", async () => { const cluster = createCluster(defaultClusterOptions); const keyvRedis = new KeyvRedis(cluster); @@ -482,7 +478,7 @@ describe("KeyvRedis Cluster", () => { await keyvRedis.disconnect(); }); - test("setMany with expires should work with cluster mode", async () => { + test("should set many keys with expires in cluster mode", async () => { const cluster = createCluster(defaultClusterOptions); const keyvRedis = new KeyvRedis(cluster); @@ -503,7 +499,7 @@ describe("KeyvRedis Cluster", () => { await keyvRedis.disconnect(); }); - test("deleteMany with useUnlink false should work with cluster mode", async () => { + test("should delete many keys in cluster mode when useUnlink is false", async () => { const cluster = createCluster(defaultClusterOptions); const keyvRedis = new KeyvRedis(cluster, { useUnlink: false }); diff --git a/storage/redis/test/create-keyv.test.ts b/storage/redis/test/create-keyv.test.ts index dc3ff2c10..248701288 100644 --- a/storage/redis/test/create-keyv.test.ts +++ b/storage/redis/test/create-keyv.test.ts @@ -6,7 +6,7 @@ import KeyvRedis, { createKeyv, createKeyvNonBlocking } from "../src/index.js"; const redisUri = process.env.REDIS_URI ?? "redis://localhost:6379"; describe("createKeyv", () => { - test("should create Keyv instance with default options", async () => { + test("should create a Keyv instance with default options", () => { const keyv = createKeyv(redisUri); expect(keyv).toBeDefined(); expect(keyv.store).toBeInstanceOf(KeyvRedis); @@ -14,7 +14,13 @@ describe("createKeyv", () => { expect(keyv.store.namespace).toBeUndefined(); }); - test("should create Keyv instance with custom namespace", async () => { + test("should default to the localhost Redis URI when connect is omitted", () => { + const keyv = createKeyv(); + expect(keyv.store).toBeInstanceOf(KeyvRedis); + expect(keyv.namespace).toBeUndefined(); + }); + + test("should create a Keyv instance with a custom namespace", async () => { const namespace = faker.string.alphanumeric(10); const keyv = createKeyv(redisUri, { namespace }); expect(keyv).toBeDefined(); @@ -23,7 +29,7 @@ describe("createKeyv", () => { expect(keyv.store.namespace).toBe(namespace); }); - test("should create Keyv instance with custom namespace and errors enabled", async () => { + test("should create a Keyv instance with a custom namespace and errors enabled", async () => { const namespace = faker.string.alphanumeric(10); const keyv = createKeyv(redisUri, { namespace, @@ -47,7 +53,7 @@ describe("createKeyv", () => { }); describe("createKeyvNonBlocking", () => { - test("should create Keyv instance with default options", async () => { + test("should create a Keyv instance with default options", async () => { const keyv = createKeyvNonBlocking(redisUri); expect(keyv).toBeDefined(); expect(keyv.throwOnErrors).toBe(false); diff --git a/storage/redis/test/delete.test.ts b/storage/redis/test/delete.test.ts index 47139e090..4374cc214 100644 --- a/storage/redis/test/delete.test.ts +++ b/storage/redis/test/delete.test.ts @@ -167,14 +167,7 @@ describe("delete", () => { vi.spyOn(keyvRedis.client, "multi").mockRestore(); }); - test("should return false on delete if key does not exist", async () => { - const keyvRedis = new KeyvRedis(); - const deleted = await keyvRedis.delete(faker.string.uuid()); - expect(deleted).toBe(false); - await keyvRedis.disconnect(); - }); - - test("should be able to delete many with namespace", async () => { + test("should delete many keys", async () => { const keyvRedis = new KeyvRedis(); const key1 = faker.string.uuid(); const key2 = faker.string.uuid(); @@ -197,7 +190,7 @@ describe("delete", () => { await keyvRedis.disconnect(); }); - test("should be able to delete many with namespace with useUnlink false", async () => { + test("should delete many keys when useUnlink is false", async () => { const keyvRedis = new KeyvRedis(); keyvRedis.useUnlink = false; const key1 = faker.string.uuid(); diff --git a/storage/redis/test/events.test.ts b/storage/redis/test/events.test.ts index 40ff53747..afeef3774 100644 --- a/storage/redis/test/events.test.ts +++ b/storage/redis/test/events.test.ts @@ -6,13 +6,26 @@ import KeyvRedis, { createClient, type RedisClientType } from "../src/index.js"; const redisUri = process.env.REDIS_URI ?? "redis://localhost:6379"; describe("events", () => { - test("should expose hookified event methods", () => { + test("should expose the Hookified event methods", () => { const keyvRedis = new KeyvRedis(redisUri); expect(typeof keyvRedis.on).toBe("function"); expect(typeof keyvRedis.once).toBe("function"); expect(typeof keyvRedis.emit).toBe("function"); }); + test("should deliver an event only once when once() is used", () => { + const keyvRedis = new KeyvRedis(redisUri); + let count = 0; + keyvRedis.once("error", () => { + count += 1; + }); + + keyvRedis.emit("error", new Error(faker.lorem.sentence())); + keyvRedis.emit("error", new Error(faker.lorem.sentence())); + + expect(count).toBe(1); + }); + test("should re-emit the client error event on the adapter", async () => { const keyvRedis = new KeyvRedis(redisUri); const error = new Error(faker.lorem.sentence()); @@ -27,26 +40,51 @@ describe("events", () => { await keyvRedis.disconnect(); }); - test("should emit a connect event when the client connects", async () => { + test("should re-emit the client connect event on the adapter", async () => { const keyvRedis = new KeyvRedis(redisUri); - let connected = false; - keyvRedis.on("connect", () => { - connected = true; + let received: unknown; + keyvRedis.on("connect", (client) => { + received = client; }); await keyvRedis.getClient(); - expect(connected).toBe(true); + expect(received).toBe(keyvRedis.client); + await keyvRedis.disconnect(); + }); + + test("should re-emit the client reconnecting event on the adapter", async () => { + const keyvRedis = new KeyvRedis(redisUri); + let received: unknown; + keyvRedis.on("reconnecting", (info) => { + received = info; + }); + + const reconnectInfo = { attempt: 1 }; + keyvRedis.client.emit("reconnecting", reconnectInfo); + + expect(received).toBe(reconnectInfo); + await keyvRedis.disconnect(); + }); + + test("should re-emit the client disconnect event on the adapter", async () => { + const keyvRedis = new KeyvRedis(redisUri); + let received: unknown; + keyvRedis.on("disconnect", (client) => { + received = client; + }); + + keyvRedis.client.emit("disconnect"); + + expect(received).toBe(keyvRedis.client); await keyvRedis.disconnect(); }); test("should not attach duplicate listeners when connecting", async () => { const keyvRedis = new KeyvRedis(redisUri); - // The constructor wires up a single listener per event. expect(keyvRedis.client.listenerCount("error")).toBe(1); expect(keyvRedis.client.listenerCount("connect")).toBe(1); - // Connecting (and re-initializing the client) must not add duplicates. await keyvRedis.getClient(); await keyvRedis.getClient(); @@ -55,6 +93,18 @@ describe("events", () => { await keyvRedis.disconnect(); }); + test("should not attach duplicate listeners when the same client is reused", () => { + const client = createClient({ url: redisUri }) as RedisClientType; + const keyvRedis = new KeyvRedis(client); + const errorCount = client.listenerCount("error"); + const connectCount = client.listenerCount("connect"); + + keyvRedis.client = client; + + expect(client.listenerCount("error")).toBe(errorCount); + expect(client.listenerCount("connect")).toBe(connectCount); + }); + test("should re-wire listeners when the client is replaced", () => { const keyvRedis = new KeyvRedis(redisUri); const newClient = createClient({ url: redisUri }) as RedisClientType; diff --git a/storage/redis/test/get.test.ts b/storage/redis/test/get.test.ts index 79df01d71..a66596b7e 100644 --- a/storage/redis/test/get.test.ts +++ b/storage/redis/test/get.test.ts @@ -8,6 +8,28 @@ const redisUri = process.env.REDIS_URI ?? "redis://localhost:6379"; const redisBadUri = process.env.REDIS_BAD_URI ?? "redis://localhost:6378"; describe("get", () => { + test("should get a value", async () => { + const keyvRedis = new KeyvRedis(redisUri); + const data = { + key: faker.string.alphanumeric(10), + value: faker.lorem.sentence(), + }; + + await keyvRedis.set(data.key, data.value); + + const result = await keyvRedis.get(data.key); + expect(result).toBe(data.value); + await keyvRedis.disconnect(); + }); + + test("should return undefined, not null, for a missing key", async () => { + const keyvRedis = new KeyvRedis(redisUri); + const result = await keyvRedis.get(faker.string.alphanumeric(10)); + expect(result).toBeUndefined(); + expect(result).not.toBeNull(); + await keyvRedis.disconnect(); + }); + test("should get many values", async () => { const keyvRedis = new KeyvRedis(redisUri); const data = { @@ -21,11 +43,11 @@ describe("get", () => { await keyvRedis.set(data.key2, data.value2); const results = await keyvRedis.getMany([data.key1, data.key2]); - expect(results).toEqual([data.value1, data.value2]); + await keyvRedis.disconnect(); }); - test("should return undefined for keys that do not exist", async () => { + test("should return undefined, not null, for missing keys in getMany", async () => { const keyvRedis = new KeyvRedis(redisUri); const data = { key1: faker.string.alphanumeric(10), @@ -33,17 +55,20 @@ describe("get", () => { }; const results = await keyvRedis.getMany([data.key1, data.key2]); - expect(results).toEqual([undefined, undefined]); + expect(results[0]).not.toBeNull(); + expect(results[1]).not.toBeNull(); + await keyvRedis.disconnect(); }); - test("should handle empty array input", async () => { + test("should return an empty array when getMany is called with no keys", async () => { const keyvRedis = new KeyvRedis(redisUri); const results = await keyvRedis.getMany([]); expect(results).toEqual([]); + await keyvRedis.disconnect(); }); - test("should throw an error on client error", async () => { + test("should throw an error on client error when throwOnErrors is true", async () => { const keyvRedis = new KeyvRedis(redisUri, { throwOnErrors: true }); const data = { @@ -66,7 +91,7 @@ describe("get", () => { vi.spyOn(keyvRedis.client, "get").mockRestore(); }); - test("should not throw an error on client error", async () => { + test("should not throw an error on client error when throwOnErrors is false", async () => { const keyvRedis = new KeyvRedis(redisUri, { throwOnErrors: false }); const data = { @@ -87,10 +112,11 @@ describe("get", () => { expect(didError).toBe(false); expect(result).toBeUndefined(); + expect(result).not.toBeNull(); vi.spyOn(keyvRedis.client, "get").mockRestore(); }); - test("should throw an error on getMany client error", async () => { + test("should throw an error on getMany client error when throwOnErrors is true", async () => { const keyvRedis = new KeyvRedis(redisUri, { throwOnErrors: true }); const data = { @@ -113,7 +139,7 @@ describe("get", () => { vi.spyOn(keyvRedis.client, "mGet").mockRestore(); }); - test("should not throw an error on getMany client error", async () => { + test("should not throw an error on getMany client error when throwOnErrors is false", async () => { const keyvRedis = new KeyvRedis(redisUri, { throwOnErrors: false }); const data = { @@ -134,10 +160,11 @@ describe("get", () => { expect(didError).toBe(false); expect(result).toEqual([undefined, undefined]); + expect(result[0]).not.toBeNull(); vi.spyOn(keyvRedis.client, "mGet").mockRestore(); }); - test("should be able to get many keys", async () => { + test("should get many keys including an expired entry", async () => { const keyvRedis = new KeyvRedis(); const key1 = faker.string.uuid(); const key2 = faker.string.uuid(); @@ -152,13 +179,7 @@ describe("get", () => { await delay(300); const values = await keyvRedis.getMany([key1, key2, key3]); expect(values).toEqual([val1, val2, undefined]); - await keyvRedis.disconnect(); - }); - - test("should be able to call getMany with an empty array", async () => { - const keyvRedis = new KeyvRedis(); - const values = await keyvRedis.getMany([]); - expect(values).toEqual([]); + expect(values[2]).not.toBeNull(); await keyvRedis.disconnect(); }); @@ -192,5 +213,6 @@ describe("get", () => { faker.string.alphanumeric(10), ]); expect(result).toEqual([undefined, undefined]); + expect(result[0]).not.toBeNull(); }); }); diff --git a/storage/redis/test/has.test.ts b/storage/redis/test/has.test.ts index 877547520..a6c1975ac 100644 --- a/storage/redis/test/has.test.ts +++ b/storage/redis/test/has.test.ts @@ -8,29 +8,23 @@ const redisUri = process.env.REDIS_URI ?? "redis://localhost:6379"; const redisBadUri = process.env.REDIS_BAD_URI ?? "redis://localhost:6378"; describe("has", () => { - test("should return true for existing keys", async () => { + test("should return true for an existing key", async () => { const keyvRedis = new KeyvRedis(redisUri); - const data = { - key: faker.string.alphanumeric(10), - value: faker.lorem.sentence(), - }; + const key = faker.string.alphanumeric(10); + const value = faker.lorem.sentence(); - await keyvRedis.set(data.key, data.value); - - const result = await keyvRedis.has(data.key); + await keyvRedis.set(key, value); - expect(result).toBe(true); + expect(await keyvRedis.has(key)).toBe(true); + await keyvRedis.disconnect(); }); - test("should return false for non-existing keys", async () => { + test("should return false for a missing key", async () => { const keyvRedis = new KeyvRedis(redisUri); - const data = { - key: faker.string.alphanumeric(10), - }; - - const result = await keyvRedis.has(data.key); + const key = faker.string.alphanumeric(10); - expect(result).toBe(false); + expect(await keyvRedis.has(key)).toBe(false); + await keyvRedis.disconnect(); }); test("should throw on connection error", async () => { @@ -87,7 +81,7 @@ describe("has", () => { vi.spyOn(keyvRedis.client, "exists").mockRestore(); }); - test("should throw an error when throwErrors is true and an error occurs", async () => { + test("should throw on has when throwOnErrors is true", async () => { const keyvRedis = new KeyvRedis(redisUri, { throwOnErrors: true }); const data = { @@ -110,7 +104,7 @@ describe("has", () => { vi.spyOn(keyvRedis.client, "exists").mockRestore(); }); - test("should not throw an error on hasMany when throwErrors is false", async () => { + test("should not throw on hasMany when throwOnErrors is false", async () => { const keyvRedis = new KeyvRedis(redisUri, { throwOnErrors: false }); const data = { @@ -133,7 +127,7 @@ describe("has", () => { vi.spyOn(keyvRedis.client, "multi").mockRestore(); }); - test("should throw an error on hasMany when throwErrors is true", async () => { + test("should throw on hasMany when throwOnErrors is true", async () => { const keyvRedis = new KeyvRedis(redisUri, { throwOnErrors: true }); const data = { @@ -156,7 +150,7 @@ describe("has", () => { vi.spyOn(keyvRedis.client, "multi").mockRestore(); }); - test("should be able to has many keys", async () => { + test("should return existence flags for many keys including an expired entry", async () => { const keyvRedis = new KeyvRedis(); const key1 = faker.string.uuid(); const key2 = faker.string.uuid(); @@ -174,22 +168,4 @@ describe("has", () => { expect(exists).toEqual([true, true, false]); await keyvRedis.disconnect(); }); - - test("should return true on has if key exists", async () => { - const keyvRedis = new KeyvRedis(); - const key = faker.string.uuid(); - const value = faker.lorem.word(); - await keyvRedis.set(key, value); - const exists = await keyvRedis.has(key); - expect(exists).toBe(true); - await keyvRedis.disconnect(); - }); - - test("should return false on has if key does not exist", async () => { - const keyvRedis = new KeyvRedis(); - const key = faker.string.uuid(); - const exists = await keyvRedis.has(key); - expect(exists).toBe(false); - await keyvRedis.disconnect(); - }); }); diff --git a/storage/redis/test/iterator.test.ts b/storage/redis/test/iterator.test.ts index 565fdc090..c3bb3c094 100644 --- a/storage/redis/test/iterator.test.ts +++ b/storage/redis/test/iterator.test.ts @@ -1,9 +1,9 @@ import { faker } from "@faker-js/faker"; import type { RedisClientType } from "@redis/client"; import { beforeEach, describe, expect, test } from "vitest"; -import KeyvRedis, { createKeyv } from "../src/index.js"; +import KeyvRedis from "../src/index.js"; -describe("iterators", () => { +describe("iterator", () => { beforeEach(async () => { const keyvRedis = new KeyvRedis(); const client = (await keyvRedis.getClient()) as RedisClientType; @@ -71,7 +71,7 @@ describe("iterators", () => { await keyvRedis.disconnect(); }); - test("should be able to iterate over all keys if namespace is undefined and noNamespaceAffectsAll is true", async () => { + test("should iterate all keys when namespace is undefined and noNamespaceAffectsAll is true", async () => { const keyvRedis = new KeyvRedis(); keyvRedis.noNamespaceAffectsAll = true; @@ -104,7 +104,7 @@ describe("iterators", () => { expect(values).toContain(val3); }); - test("should only iterate over keys with no namespace if name is undefined set and noNamespaceAffectsAll is false", async () => { + test("should only iterate un-prefixed keys when noNamespaceAffectsAll is false", async () => { const keyvRedis = new KeyvRedis(); keyvRedis.noNamespaceAffectsAll = false; @@ -139,26 +139,4 @@ describe("iterators", () => { expect(values).not.toContain(val1); expect(values).not.toContain(val2); }); - - test("should be able to pass undefined on connect to get localhost", async () => { - const keyv = createKeyv(); - const keyvRedis = keyv.store as KeyvRedis; - expect((keyvRedis.client as RedisClientType).options?.url).toBe("redis://localhost:6379"); - }); - - test("should go to the RedisClientOptions if passed in", async () => { - const reconnectStrategy = (times: number) => Math.min(times * 50, 2000); - - const keyvRedis = new KeyvRedis({ - socket: { - host: "localhost", - port: 6379, - reconnectStrategy, - }, - }); - - expect((keyvRedis.client as RedisClientType).options?.socket?.reconnectStrategy).toBe( - reconnectStrategy, - ); - }); }); diff --git a/storage/redis/test/main.test.ts b/storage/redis/test/main.test.ts index 016cdeffc..1e128aad6 100644 --- a/storage/redis/test/main.test.ts +++ b/storage/redis/test/main.test.ts @@ -58,6 +58,20 @@ describe("KeyvRedis", () => { expect(keyvRedis.client).toBe(client); }); + test("should apply RedisClientOptions including reconnectStrategy", () => { + const reconnectStrategy = (times: number) => Math.min(times * 50, 2000); + const keyvRedis = new KeyvRedis({ + socket: { + host: "localhost", + port: 6379, + reconnectStrategy, + }, + }); + expect((keyvRedis.client as RedisClientType).options?.socket?.reconnectStrategy).toBe( + reconnectStrategy, + ); + }); + test("should be able to pass in a client to constructor", () => { const client = createClient() as RedisClientType; const keyvRedis = new KeyvRedis(client); @@ -106,7 +120,7 @@ describe("KeyvRedis", () => { expect(keyvRedis.useUnlink).toBe(false); }); - test("keyPrefixSeparator should be able to set to blank string", () => { + test("should allow keyPrefixSeparator to be set to a blank string", () => { const keyvRedis = new KeyvRedis("redis://localhost:6379", { keyPrefixSeparator: "", }); @@ -117,7 +131,7 @@ describe("KeyvRedis", () => { expect(keyvRedis.keyPrefixSeparator).toBe(""); }); - test("clearBatchSize should not set if 0 or less than", () => { + test("should not set clearBatchSize when the value is 0 or less", () => { const keyvRedis = new KeyvRedis("redis://localhost:6379", { clearBatchSize: 0, }); @@ -149,7 +163,7 @@ describe("KeyvRedis", () => { expect(keyvRedis.useUnlink).toBe(true); }); - test("client options should contain the url", () => { + test("should store the url on the Redis client options", () => { const uri = "redis://foo:6379"; const keyvRedis = new KeyvRedis(uri); expect((keyvRedis.client as RedisClientType).options?.url).toBe(uri); diff --git a/storage/redis/test/namespace.test.ts b/storage/redis/test/namespace.test.ts index 20cb3c336..2bc093617 100644 --- a/storage/redis/test/namespace.test.ts +++ b/storage/redis/test/namespace.test.ts @@ -12,7 +12,7 @@ describe("Namespace", () => { await keyvRedis.disconnect(); }); - test("if there is a namespace on key prefix", async () => { + test("should prefix a key with the given namespace", async () => { const keyvRedis = new KeyvRedis(); keyvRedis.namespace = "ns1"; const testKey = faker.string.uuid(); @@ -21,13 +21,13 @@ describe("Namespace", () => { expect(keyvRedis.getKeyWithoutPrefix(key, "ns2")).toBe(testKey); }); - test("getKeyWithoutPrefix only strips a leading namespace prefix", () => { + test("should only strip a leading namespace prefix", () => { const keyvRedis = new KeyvRedis(); const key = "ns1::hello::ns1::world"; expect(keyvRedis.getKeyWithoutPrefix(key, "ns1")).toBe("hello::ns1::world"); }); - test("if no namespace on key prefix and no default namespace", async () => { + test("should return the key unchanged when no namespace is set", async () => { const keyvRedis = new KeyvRedis(); keyvRedis.namespace = undefined; const testKey = faker.string.uuid(); @@ -70,7 +70,7 @@ describe("Namespace", () => { await keyvRedis.disconnect(); }); - test("should clear with no namespace but not the namespace ones", async () => { + test("should clear un-prefixed keys and leave namespaced keys", async () => { const keyvRedis = new KeyvRedis(); const client = (await keyvRedis.getClient()) as RedisClientType; await client.flushDb(); @@ -178,7 +178,7 @@ describe("Namespace", () => { await keyvRedis.disconnect(); }); - test("should be able to has many keys with namespace", async () => { + test("should check many namespaced keys including an expired entry", async () => { const keyvRedis = new KeyvRedis("redis://localhost:6379", { namespace: "ns-many2", }); diff --git a/storage/redis/test/sentinel.test.ts b/storage/redis/test/sentinel.test.ts index c13456c17..1a20e2c1e 100644 --- a/storage/redis/test/sentinel.test.ts +++ b/storage/redis/test/sentinel.test.ts @@ -91,9 +91,7 @@ describe("KeyvRedis Sentinel", () => { let errorThrown = false; try { await keyvRedis.clear(); - } catch (error) { - console.log(error); - expect(error).toBeDefined(); + } catch { errorThrown = true; } @@ -247,9 +245,7 @@ describe("KeyvRedis Sentinel", () => { keys.push(key); values.push(value); } - } catch (error) { - console.log(error); - expect(error).toBeDefined(); + } catch { errorThrown = true; } @@ -352,7 +348,7 @@ describe("KeyvRedis Sentinel", () => { expect(values).toContain(val3); }); - test("should only iterate over keys with no namespace if name is undefined set and noNamespaceAffectsAll is false", async () => { + test("should only iterate un-prefixed keys when namespace is undefined and noNamespaceAffectsAll is false", async () => { const sentinel = createSentinel(defaultSentinelOptions); const keyvRedis = new KeyvRedis(sentinel); keyvRedis.noNamespaceAffectsAll = false; diff --git a/storage/redis/test/set.test.ts b/storage/redis/test/set.test.ts index f299d3b99..ee6da64fb 100644 --- a/storage/redis/test/set.test.ts +++ b/storage/redis/test/set.test.ts @@ -245,17 +245,7 @@ describe("set", () => { vi.spyOn(keyvRedis.client, "multi").mockRestore(); }); - test("should be able to set an expires", async () => { - const keyvRedis = new KeyvRedis(); - const key = faker.string.uuid(); - await keyvRedis.set(key, faker.lorem.word(), Date.now() + 100); - await delay(300); - const value = await keyvRedis.get(key); - expect(value).toBeUndefined(); - await keyvRedis.disconnect(); - }); - - test("should be able to set many keys", async () => { + test("should set many keys including an expired entry", async () => { const keyvRedis = new KeyvRedis(); const key1 = faker.string.uuid(); const key2 = faker.string.uuid(); diff --git a/storage/redis/test/suite.test.ts b/storage/redis/test/suite.test.ts index b77b31a6a..b7e57cf2e 100644 --- a/storage/redis/test/suite.test.ts +++ b/storage/redis/test/suite.test.ts @@ -1,6 +1,6 @@ import { keyvIteratorTests, keyvTestSuite, storageTestSuite } from "@keyv/test-suite"; import { Keyv } from "keyv"; -import { afterAll, it } from "vitest"; +import { afterAll, test } from "vitest"; import KeyvRedis, { type RedisClientType } from "../src/index.js"; const redisUrl = "redis://localhost:6379/5"; @@ -12,6 +12,6 @@ afterAll(async () => { await store().disconnect(); }); -keyvTestSuite(it, Keyv, store); -keyvIteratorTests(it, Keyv, store); -storageTestSuite(it, store); +keyvTestSuite(test, Keyv, store); +keyvIteratorTests(test, Keyv, store); +storageTestSuite(test, store);