diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5732f2eda..e7c9d58fe7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + ssh-key: ${{ secrets.RELEASE_KEY }} - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml new file mode 100644 index 0000000000..445af1c818 --- /dev/null +++ b/.github/workflows/stale-issues.yml @@ -0,0 +1,25 @@ +name: "Close stale issues" +on: + schedule: + - cron: "0 0 * * *" + +permissions: {} +jobs: + stale: + permissions: + issues: write # to close stale issues (actions/stale) + pull-requests: write # to close stale PRs (actions/stale) + + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue is marked stale. It will be closed in 30 days if it is not updated.' + stale-pr-message: 'This pull request is marked stale. It will be closed in 30 days if it is not updated.' + days-before-stale: 365 + days-before-close: 30 + stale-issue-label: "Stale" + stale-pr-label: "Stale" + operations-per-run: 10 + remove-stale-when-updated: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 395e025101..89efdb6111 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,7 +22,7 @@ jobs: fail-fast: false matrix: node-version: ["18", "20", "22"] - redis-version: ["rs-7.2.0-v13", "rs-7.4.0-v1", "8.0.1-pre"] + redis-version: ["rs-7.2.0-v13", "rs-7.4.0-v1", "8.0.2", "8.2-M01-pre"] steps: - uses: actions/checkout@v4 with: diff --git a/README.md b/README.md index 38615ee519..ab6b4707e6 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,13 @@ npm install redis | Name | Description | | ---------------------------------------------- | ------------------------------------------------------------------------------------------- | -| [`redis`](./packages/redis) | The client with all the ["redis-stack"](https://github.com/redis-stack/redis-stack) modules | -| [`@redis/client`](./packages/client) | The base clients (i.e `RedisClient`, `RedisCluster`, etc.) | -| [`@redis/bloom`](./packages/bloom) | [Redis Bloom](https://redis.io/docs/data-types/probabilistic/) commands | -| [`@redis/json`](./packages/json) | [Redis JSON](https://redis.io/docs/data-types/json/) commands | -| [`@redis/search`](./packages/search) | [RediSearch](https://redis.io/docs/interact/search-and-query/) commands | -| [`@redis/time-series`](./packages/time-series) | [Redis Time-Series](https://redis.io/docs/data-types/timeseries/) commands | -| [`@redis/entraid`](./packages/entraid) | Secure token-based authentication for Redis clients using Microsoft Entra ID | +| [`redis`](https://github.com/redis/node-redis/tree/master/packages/redis) | The client with all the ["redis-stack"](https://github.com/redis-stack/redis-stack) modules | +| [`@redis/client`](https://github.com/redis/node-redis/tree/master/packages/client) | The base clients (i.e `RedisClient`, `RedisCluster`, etc.) | +| [`@redis/bloom`](https://github.com/redis/node-redis/tree/master/packages/bloom) | [Redis Bloom](https://redis.io/docs/data-types/probabilistic/) commands | +| [`@redis/json`](https://github.com/redis/node-redis/tree/master/packages/json) | [Redis JSON](https://redis.io/docs/data-types/json/) commands | +| [`@redis/search`](https://github.com/redis/node-redis/tree/master/packages/search) | [RediSearch](https://redis.io/docs/interact/search-and-query/) commands | +| [`@redis/time-series`](https://github.com/redis/node-redis/tree/master/packages/time-series) | [Redis Time-Series](https://redis.io/docs/data-types/timeseries/) commands | +| [`@redis/entraid`](https://github.com/redis/node-redis/tree/master/packages/entraid) | Secure token-based authentication for Redis clients using Microsoft Entra ID | > Looking for a high-level library to handle object mapping? > See [redis-om-node](https://github.com/redis/redis-om-node)! @@ -83,7 +83,7 @@ createClient({ ``` You can also use discrete parameters, UNIX sockets, and even TLS to connect. Details can be found in -the [client configuration guide](./docs/client-configuration.md). +the [client configuration guide](https://github.com/redis/node-redis/blob/master/docs/client-configuration.md). To check if the the client is connected and ready to send commands, use `client.isReady` which returns a boolean. `client.isOpen` is also available. This returns `true` when the client's underlying socket is open, and `false` when it @@ -188,7 +188,7 @@ await pool.ping(); ### Pub/Sub -See the [Pub/Sub overview](./docs/pub-sub.md). +See the [Pub/Sub overview](https://github.com/redis/node-redis/blob/master/docs/pub-sub.md). ### Scan Iterator @@ -234,7 +234,6 @@ of sending a `QUIT` command to the server, the client can simply close the netwo ```typescript client.destroy(); ``` - ### Client Side Caching Node Redis v5 adds support for [Client Side Caching](https://redis.io/docs/manual/client-side-caching/), which enables clients to cache query results locally. The Redis server will notify the client when cached results are no longer valid. @@ -251,7 +250,7 @@ const client = createClient({ }); ``` -See the [V5 documentation](./docs/v5.md#client-side-caching) for more details and advanced usage. +See the [V5 documentation](https://github.com/redis/node-redis/blob/master/docs/v5.md#client-side-caching) for more details and advanced usage. ### Auto-Pipelining @@ -275,11 +274,11 @@ await Promise.all([ ### Programmability -See the [Programmability overview](./docs/programmability.md). +See the [Programmability overview](https://github.com/redis/node-redis/blob/master/docs/programmability.md). ### Clustering -Check out the [Clustering Guide](./docs/clustering.md) when using Node Redis to connect to a Redis Cluster. +Check out the [Clustering Guide](https://github.com/redis/node-redis/blob/master/docs/clustering.md) when using Node Redis to connect to a Redis Cluster. ### Events @@ -292,12 +291,12 @@ The Node Redis client class is an Nodejs EventEmitter and it emits an event each | `end` | Connection has been closed (via `.disconnect()`) | _No arguments_ | | `error` | An error has occurred—usually a network issue such as "Socket closed unexpectedly" | `(error: Error)` | | `reconnecting` | Client is trying to reconnect to the server | _No arguments_ | -| `sharded-channel-moved` | See [here](./docs/pub-sub.md#sharded-channel-moved-event) | See [here](./docs/pub-sub.md#sharded-channel-moved-event) | +| `sharded-channel-moved` | See [here](https://github.com/redis/node-redis/blob/master/docs/pub-sub.md#sharded-channel-moved-event) | See [here](https://github.com/redis/node-redis/blob/master/docs/pub-sub.md#sharded-channel-moved-event) | > :warning: You **MUST** listen to `error` events. If a client doesn't have at least one `error` listener registered and > an `error` occurs, that error will be thrown and the Node.js process will exit. See the [ > `EventEmitter` docs](https://nodejs.org/api/events.html#events_error_events) for more details. -> The client will not emit [any other events](./docs/v3-to-v4.md#all-the-removed-events) beyond those listed above. +> The client will not emit [any other events](https://github.com/redis/node-redis/blob/master/docs/v3-to-v4.md#all-the-removed-events) beyond those listed above. ## Supported Redis versions @@ -314,13 +313,13 @@ Node Redis is supported with the following versions of Redis: ## Migration -- [From V3 to V4](docs/v3-to-v4.md) -- [From V4 to V5](docs/v4-to-v5.md) -- [V5](docs/v5.md) +- [From V3 to V4](https://github.com/redis/node-redis/blob/master/docs/v3-to-v4.md) +- [From V4 to V5](https://github.com/redis/node-redis/blob/master/docs/v4-to-v5.md) +- [V5](https://github.com/redis/node-redis/blob/master/docs/v5.md) ## Contributing -If you'd like to contribute, check out the [contributing guide](CONTRIBUTING.md). +If you'd like to contribute, check out the [contributing guide](https://github.com/redis/node-redis/blob/master/CONTRIBUTING.md). Thank you to all the people who already contributed to Node Redis! @@ -328,4 +327,4 @@ Thank you to all the people who already contributed to Node Redis! ## License -This repository is licensed under the "MIT" license. See [LICENSE](LICENSE). +This repository is licensed under the "MIT" license. See [LICENSE](https://github.com/redis/node-redis/blob/master/LICENSE). diff --git a/docs/command-options.md b/docs/command-options.md index b246445ad7..8583eae135 100644 --- a/docs/command-options.md +++ b/docs/command-options.md @@ -37,6 +37,19 @@ try { } ``` + +## Timeout + +This option is similar to the Abort Signal one, but provides an easier way to set timeout for commands. Again, this applies to commands that haven't been written to the socket yet. + +```javascript +const client = createClient({ + commandOptions: { + timeout: 1000 + } +}) +``` + ## ASAP Commands that are executed in the "asap" mode are added to the beginning of the "to sent" queue. diff --git a/docs/v5.md b/docs/v5.md index 1784ae5bd7..15ef67c14e 100644 --- a/docs/v5.md +++ b/docs/v5.md @@ -42,9 +42,9 @@ RESP3 uses a different mechanism for handling Pub/Sub messages. Instead of modif ## Known Limitations -### Unstable Module Commands +### Unstable Commands -Some Redis module commands have unstable RESP3 transformations. These commands will throw an error when used with RESP3 unless you explicitly opt in to using them by setting `unstableResp3: true` in your client configuration: +Some Redis commands have unstable RESP3 transformations. These commands will throw an error when used with RESP3 unless you explicitly opt in to using them by setting `unstableResp3: true` in your client configuration: ```javascript const client = createClient({ diff --git a/package-lock.json b/package-lock.json index 22e3bb8c44..cd844a0de5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7337,7 +7337,7 @@ }, "packages/bloom": { "name": "@redis/bloom", - "version": "5.5.6", + "version": "5.6.0", "license": "MIT", "devDependencies": { "@redis/test-utils": "*" @@ -7346,12 +7346,12 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.5.6" + "@redis/client": "^5.6.0" } }, "packages/client": { "name": "@redis/client", - "version": "5.5.6", + "version": "5.6.0", "license": "MIT", "dependencies": { "cluster-key-slot": "1.1.2" @@ -7423,7 +7423,7 @@ }, "packages/json": { "name": "@redis/json", - "version": "5.5.6", + "version": "5.6.0", "license": "MIT", "devDependencies": { "@redis/test-utils": "*" @@ -7432,39 +7432,39 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.5.6" + "@redis/client": "^5.6.0" } }, "packages/redis": { - "version": "5.1.1", + "version": "5.5.6", "license": "MIT", "dependencies": { - "@redis/bloom": "5.1.1", - "@redis/client": "5.1.1", - "@redis/json": "5.1.1", - "@redis/search": "5.1.1", - "@redis/time-series": "5.1.1" + "@redis/bloom": "5.5.6", + "@redis/client": "5.5.6", + "@redis/json": "5.5.6", + "@redis/search": "5.5.6", + "@redis/time-series": "5.5.6" }, "engines": { "node": ">= 18" } }, "packages/redis/node_modules/@redis/bloom": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.1.1.tgz", - "integrity": "sha512-PnMcvpL7O2DHtnSL5JtyNmraNrdHuJXi3u2isGTUuPgkbAuWQKfZdknq471ySILL+qKtLfVJqzgDFMjYmZzK6Q==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.5.6.tgz", + "integrity": "sha512-bNR3mxkwtfuCxNOzfV8B3R5zA1LiN57EH6zK4jVBIgzMzliNuReZXBFGnXvsi80/SYohajn78YdpYI+XNpqL+A==", "license": "MIT", "engines": { "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.1.1" + "@redis/client": "^5.5.6" } }, "packages/redis/node_modules/@redis/client": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.1.1.tgz", - "integrity": "sha512-vojbBqUdbkD+ylCy3+ZDXLzSmgiYH9pLrv87kF+nDgsRaHKrVVxPV9B4u6EfWRx7XGvQGZqsXVkKFhsEOsG3LA==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.5.6.tgz", + "integrity": "sha512-M3Svdwt6oSfyfQdqEr0L2HOJH2vK7GgCFx1NfAQvpWAT4+ljoT1L5S5cKT3dA9NJrxrOPDkdoTPWJnIrGCOcmw==", "license": "MIT", "dependencies": { "cluster-key-slot": "1.1.2" @@ -7474,20 +7474,20 @@ } }, "packages/redis/node_modules/@redis/json": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.1.1.tgz", - "integrity": "sha512-A5M0dcgxGKq+oE6spIPBcGLDBiwoSPTs2wesVb4x30rXfG6rPtqt1Z7fCMtvTL2kHUNRKgZ78zhD+0+MENZt7g==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.5.6.tgz", + "integrity": "sha512-AIsoe3SsGQagqAmSQHaqxEinm5oCWr7zxPWL90kKaEdLJ+zw8KBznf2i9oK0WUFP5pFssSQUXqnscQKe2amfDQ==", "license": "MIT", "engines": { "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.1.1" + "@redis/client": "^5.5.6" } }, "packages/search": { "name": "@redis/search", - "version": "5.1.1", + "version": "5.5.6", "license": "MIT", "devDependencies": { "@redis/test-utils": "*" @@ -7496,7 +7496,7 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.1.1" + "@redis/client": "^5.5.6" } }, "packages/test-utils": { @@ -7565,7 +7565,7 @@ }, "packages/time-series": { "name": "@redis/time-series", - "version": "5.1.1", + "version": "5.5.6", "license": "MIT", "devDependencies": { "@redis/test-utils": "*" @@ -7574,7 +7574,7 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.1.1" + "@redis/client": "^5.5.6" } } } diff --git a/packages/bloom/lib/test-utils.ts b/packages/bloom/lib/test-utils.ts index 71b423b41e..4396c94f72 100644 --- a/packages/bloom/lib/test-utils.ts +++ b/packages/bloom/lib/test-utils.ts @@ -4,7 +4,7 @@ import RedisBloomModules from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.0-M05-pre' + defaultDockerVersion: '8.2-M01-pre' }); export const GLOBAL = { diff --git a/packages/bloom/package.json b/packages/bloom/package.json index 83dd6f893f..47d1f6978e 100644 --- a/packages/bloom/package.json +++ b/packages/bloom/package.json @@ -1,6 +1,6 @@ { "name": "@redis/bloom", - "version": "5.5.6", + "version": "5.6.0", "license": "MIT", "main": "./dist/lib/index.js", "types": "./dist/lib/index.d.ts", @@ -13,7 +13,7 @@ "release": "release-it" }, "peerDependencies": { - "@redis/client": "^5.5.6" + "@redis/client": "^5.6.0" }, "devDependencies": { "@redis/test-utils": "*" diff --git a/packages/client/lib/client/commands-queue.ts b/packages/client/lib/client/commands-queue.ts index 78c0a01b20..52a07a7e3b 100644 --- a/packages/client/lib/client/commands-queue.ts +++ b/packages/client/lib/client/commands-queue.ts @@ -3,7 +3,7 @@ import encodeCommand from '../RESP/encoder'; import { Decoder, PUSH_TYPE_MAPPING, RESP_TYPES } from '../RESP/decoder'; import { TypeMapping, ReplyUnion, RespVersions, RedisArgument } from '../RESP/types'; import { ChannelListeners, PubSub, PubSubCommand, PubSubListener, PubSubType, PubSubTypeListeners } from './pub-sub'; -import { AbortError, ErrorReply } from '../errors'; +import { AbortError, ErrorReply, TimeoutError } from '../errors'; import { MonitorCallback } from '.'; export interface CommandOptions { @@ -14,6 +14,10 @@ export interface CommandOptions { * Maps between RESP and JavaScript types */ typeMapping?: T; + /** + * Timeout for the command in milliseconds + */ + timeout?: number; } export interface CommandToWrite extends CommandWaitingForReply { @@ -23,6 +27,10 @@ export interface CommandToWrite extends CommandWaitingForReply { signal: AbortSignal; listener: () => unknown; } | undefined; + timeout: { + signal: AbortSignal; + listener: () => unknown; + } | undefined; } interface CommandWaitingForReply { @@ -80,7 +88,7 @@ export default class RedisCommandsQueue { #onPush(push: Array) { // TODO: type if (this.#pubSub.handleMessageReply(push)) return true; - + const isShardedUnsubscribe = PubSub.isShardedUnsubscribe(push); if (isShardedUnsubscribe && !this.#waitingForReply.length) { const channel = push[1].toString(); @@ -153,12 +161,26 @@ export default class RedisCommandsQueue { args, chainId: options?.chainId, abort: undefined, + timeout: undefined, resolve, reject, channelsCounter: undefined, typeMapping: options?.typeMapping }; + const timeout = options?.timeout; + if (timeout) { + const signal = AbortSignal.timeout(timeout); + value.timeout = { + signal, + listener: () => { + this.#toWrite.remove(node); + value.reject(new TimeoutError()); + } + }; + signal.addEventListener('abort', value.timeout.listener, { once: true }); + } + const signal = options?.abortSignal; if (signal) { value.abort = { @@ -181,6 +203,7 @@ export default class RedisCommandsQueue { args: command.args, chainId, abort: undefined, + timeout: undefined, resolve() { command.resolve(); resolve(); @@ -202,7 +225,7 @@ export default class RedisCommandsQueue { this.decoder.onReply = (reply => { if (Array.isArray(reply)) { if (this.#onPush(reply)) return; - + if (PONG.equals(reply[0] as Buffer)) { const { resolve, typeMapping } = this.#waitingForReply.shift()!, buffer = ((reply[1] as Buffer).length === 0 ? reply[0] : reply[1]) as Buffer; @@ -250,7 +273,7 @@ export default class RedisCommandsQueue { if (!this.#pubSub.isActive) { this.#resetDecoderCallbacks(); } - + resolve(); }; } @@ -299,6 +322,7 @@ export default class RedisCommandsQueue { args: ['MONITOR'], chainId: options?.chainId, abort: undefined, + timeout: undefined, // using `resolve` instead of using `.then`/`await` to make sure it'll be called before processing the next reply resolve: () => { // after running `MONITOR` only `MONITOR` and `RESET` replies are expected @@ -317,7 +341,7 @@ export default class RedisCommandsQueue { reject, channelsCounter: undefined, typeMapping - }, options?.asap); + }, options?.asap); }); } @@ -340,11 +364,11 @@ export default class RedisCommandsQueue { this.#resetDecoderCallbacks(); this.#resetFallbackOnReply = undefined; this.#pubSub.reset(); - + this.#waitingForReply.shift()!.resolve(reply); return; } - + this.#resetFallbackOnReply!(reply); }) as Decoder['onReply']; @@ -352,6 +376,7 @@ export default class RedisCommandsQueue { args: ['RESET'], chainId, abort: undefined, + timeout: undefined, resolve, reject, channelsCounter: undefined, @@ -376,16 +401,20 @@ export default class RedisCommandsQueue { continue; } - // TODO reuse `toSend` or create new object? + // TODO reuse `toSend` or create new object? (toSend as any).args = undefined; if (toSend.abort) { RedisCommandsQueue.#removeAbortListener(toSend); toSend.abort = undefined; } + if (toSend.timeout) { + RedisCommandsQueue.#removeTimeoutListener(toSend); + toSend.timeout = undefined; + } this.#chainInExecution = toSend.chainId; toSend.chainId = undefined; this.#waitingForReply.push(toSend); - + yield encoded; toSend = this.#toWrite.shift(); } @@ -402,11 +431,18 @@ export default class RedisCommandsQueue { command.abort!.signal.removeEventListener('abort', command.abort!.listener); } + static #removeTimeoutListener(command: CommandToWrite) { + command.timeout!.signal.removeEventListener('abort', command.timeout!.listener); + } + static #flushToWrite(toBeSent: CommandToWrite, err: Error) { if (toBeSent.abort) { RedisCommandsQueue.#removeAbortListener(toBeSent); } - + if (toBeSent.timeout) { + RedisCommandsQueue.#removeTimeoutListener(toBeSent); + } + toBeSent.reject(err); } diff --git a/packages/client/lib/client/index.spec.ts b/packages/client/lib/client/index.spec.ts index 4f752210db..f04d646706 100644 --- a/packages/client/lib/client/index.spec.ts +++ b/packages/client/lib/client/index.spec.ts @@ -1,9 +1,9 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL, waitTillBeenCalled } from '../test-utils'; import RedisClient, { RedisClientOptions, RedisClientType } from '.'; -import { AbortError, ClientClosedError, ClientOfflineError, ConnectionTimeoutError, DisconnectsClientError, ErrorReply, MultiErrorReply, SocketClosedUnexpectedlyError, WatchError } from '../errors'; +import { AbortError, ClientClosedError, ClientOfflineError, ConnectionTimeoutError, DisconnectsClientError, ErrorReply, MultiErrorReply, SocketClosedUnexpectedlyError, TimeoutError, WatchError } from '../errors'; import { defineScript } from '../lua-script'; -import { spy } from 'sinon'; +import { spy, stub } from 'sinon'; import { once } from 'node:events'; import { MATH_FUNCTION, loadMathFunction } from '../commands/FUNCTION_LOAD.spec'; import { RESP_TYPES } from '../RESP/decoder'; @@ -239,30 +239,84 @@ describe('Client', () => { assert.equal(await client.sendCommand(['PING']), 'PONG'); }, GLOBAL.SERVERS.OPEN); - describe('AbortController', () => { - before(function () { - if (!global.AbortController) { - this.skip(); - } + testUtils.testWithClient('Unactivated AbortController should not abort', async client => { + await client.sendCommand(['PING'], { + abortSignal: new AbortController().signal }); + }, GLOBAL.SERVERS.OPEN); - testUtils.testWithClient('success', async client => { - await client.sendCommand(['PING'], { - abortSignal: new AbortController().signal - }); - }, GLOBAL.SERVERS.OPEN); + testUtils.testWithClient('AbortError', async client => { + await blockSetImmediate(async () => { + await assert.rejects(client.sendCommand(['PING'], { + abortSignal: AbortSignal.timeout(5) + }), AbortError); + }) + }, GLOBAL.SERVERS.OPEN); - testUtils.testWithClient('AbortError', client => { - const controller = new AbortController(); - controller.abort(); + testUtils.testWithClient('Timeout with custom timeout config', async client => { + await blockSetImmediate(async () => { + await assert.rejects(client.sendCommand(['PING'], { + timeout: 5 + }), TimeoutError); + }) + }, GLOBAL.SERVERS.OPEN); - return assert.rejects( - client.sendCommand(['PING'], { - abortSignal: controller.signal - }), - AbortError - ); - }, GLOBAL.SERVERS.OPEN); + testUtils.testWithCluster('Timeout with custom timeout config (cluster)', async cluster => { + await blockSetImmediate(async () => { + await assert.rejects(cluster.sendCommand(undefined, true, ['PING'], { + timeout: 5 + }), TimeoutError); + }) + }, GLOBAL.CLUSTERS.OPEN); + + testUtils.testWithClientSentinel('Timeout with custom timeout config (sentinel)', async sentinel => { + await blockSetImmediate(async () => { + await assert.rejects(sentinel.sendCommand(true, ['PING'], { + timeout: 5 + }), TimeoutError); + }) + }, GLOBAL.CLUSTERS.OPEN); + + testUtils.testWithClient('Timeout with global timeout config', async client => { + await blockSetImmediate(async () => { + await assert.rejects(client.ping(), TimeoutError); + await assert.rejects(client.sendCommand(['PING']), TimeoutError); + }); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + commandOptions: { + timeout: 5 + } + } + }); + + testUtils.testWithCluster('Timeout with global timeout config (cluster)', async cluster => { + await blockSetImmediate(async () => { + await assert.rejects(cluster.HSET('key', 'foo', 'value'), TimeoutError); + await assert.rejects(cluster.sendCommand(undefined, true, ['PING']), TimeoutError); + }); + }, { + ...GLOBAL.CLUSTERS.OPEN, + clusterConfiguration: { + commandOptions: { + timeout: 5 + } + } + }); + + testUtils.testWithClientSentinel('Timeout with global timeout config (sentinel)', async sentinel => { + await blockSetImmediate(async () => { + await assert.rejects(sentinel.HSET('key', 'foo', 'value'), TimeoutError); + await assert.rejects(sentinel.sendCommand(true, ['PING']), TimeoutError); + }); + }, { + ...GLOBAL.SENTINEL.OPEN, + clientOptions: { + commandOptions: { + timeout: 5 + } + } }); testUtils.testWithClient('undefined and null should not break the client', async client => { @@ -900,3 +954,23 @@ describe('Client', () => { }, GLOBAL.SERVERS.OPEN); }); }); + +/** + * Executes the provided function in a context where setImmediate is stubbed to not do anything. + * This blocks setImmediate callbacks from executing + */ +async function blockSetImmediate(fn: () => Promise) { + let setImmediateStub: any; + + try { + setImmediateStub = stub(global, 'setImmediate'); + setImmediateStub.callsFake(() => { + //Dont call the callback, effectively blocking execution + }); + await fn(); + } finally { + if (setImmediateStub) { + setImmediateStub.restore(); + } + } +} diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index a446ad8e75..128dc59967 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -526,7 +526,7 @@ export default class RedisClient< async #handshake(chainId: symbol, asap: boolean) { const promises = []; const commandsWithErrorHandlers = await this.#getHandshakeCommands(); - + if (asap) commandsWithErrorHandlers.reverse() for (const { cmd, errorHandler } of commandsWithErrorHandlers) { @@ -632,7 +632,7 @@ export default class RedisClient< // since they could be connected to an older version that doesn't support them. } }); - + commands.push({ cmd: [ 'CLIENT', @@ -889,7 +889,13 @@ export default class RedisClient< return Promise.reject(new ClientOfflineError()); } - const promise = this._self.#queue.addCommand(args, options); + // Merge global options with provided options + const opts = { + ...this._self._commandOptions, + ...options + } + + const promise = this._self.#queue.addCommand(args, opts); this._self.#scheduleWrite(); return promise; } diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index c2c251810e..6d26ac98c9 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -38,12 +38,12 @@ export interface RedisClusterOptions< // POLICIES extends CommandPolicies = CommandPolicies > extends ClusterCommander { /** - * Should contain details for some of the cluster nodes that the client will use to discover + * Should contain details for some of the cluster nodes that the client will use to discover * the "cluster topology". We recommend including details for at least 3 nodes here. */ rootNodes: Array; /** - * Default values used for every client in the cluster. Use this to specify global values, + * Default values used for every client in the cluster. Use this to specify global values, * for example: ACL credentials, timeouts, TLS configuration etc. */ defaults?: Partial; @@ -68,13 +68,13 @@ export interface RedisClusterOptions< nodeAddressMap?: NodeAddressMap; /** * Client Side Caching configuration for the pool. - * - * Enables Redis Servers and Clients to work together to cache results from commands + * + * Enables Redis Servers and Clients to work together to cache results from commands * sent to a server. The server will notify the client when cached results are no longer valid. * In pooled mode, the cache is shared across all clients in the pool. - * + * * Note: Client Side Caching is only supported with RESP3. - * + * * @example Anonymous cache configuration * ``` * const client = createCluster({ @@ -86,7 +86,7 @@ export interface RedisClusterOptions< * minimum: 5 * }); * ``` - * + * * @example Using a controllable cache * ``` * const cache = new BasicPooledClientSideCache({ @@ -406,7 +406,7 @@ export default class RedisCluster< proxy._commandOptions[key] = value; return proxy as RedisClusterType< M, - F, + F, S, RESP, K extends 'typeMapping' ? V extends TypeMapping ? V : {} : TYPE_MAPPING @@ -489,7 +489,7 @@ export default class RedisCluster< myFn = this._handleAsk(fn); continue; } - + if (err.message.startsWith('MOVED')) { await this._slots.rediscover(client); client = await this._slots.getClient(firstKey, isReadonly); @@ -497,7 +497,7 @@ export default class RedisCluster< } throw err; - } + } } } @@ -508,10 +508,16 @@ export default class RedisCluster< options?: ClusterCommandOptions, // defaultPolicies?: CommandPolicies ): Promise { + + // Merge global options with local options + const opts = { + ...this._self._commandOptions, + ...options + } return this._self._execute( firstKey, isReadonly, - options, + opts, (client, opts) => client.sendCommand(args, opts) ); } diff --git a/packages/client/lib/commander.ts b/packages/client/lib/commander.ts index 6e5a2687cb..cfdf39526c 100644 --- a/packages/client/lib/commander.ts +++ b/packages/client/lib/commander.ts @@ -38,7 +38,11 @@ export function attachConfig< Class: any = class extends BaseClass {}; for (const [name, command] of Object.entries(commands)) { - Class.prototype[name] = createCommand(command, RESP); + if (config?.RESP == 3 && command.unstableResp3 && !config.unstableResp3) { + Class.prototype[name] = throwResp3SearchModuleUnstableError; + } else { + Class.prototype[name] = createCommand(command, RESP); + } } if (config?.modules) { diff --git a/packages/client/lib/commands/BITOP.spec.ts b/packages/client/lib/commands/BITOP.spec.ts index 25fe48fc13..65fe6f8633 100644 --- a/packages/client/lib/commands/BITOP.spec.ts +++ b/packages/client/lib/commands/BITOP.spec.ts @@ -1,6 +1,6 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; -import BITOP from './BITOP'; +import BITOP, { BitOperations } from './BITOP'; import { parseArgs } from './generic-transformers'; describe('BITOP', () => { @@ -20,13 +20,77 @@ describe('BITOP', () => { }); }); - testUtils.testAll('bitOp', async client => { + for (const op of ['AND', 'OR', 'XOR'] as BitOperations[]) { + testUtils.testAll(`bitOp ${op} with non-existing keys`, async client => { + assert.equal( + await client.bitOp(op, '{tag}destKey', ['{tag}key1', '{tag}key2']), + 0 + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testAll(`bitOp ${op} with existing keys`, async client => { + await client.set('{tag}key1', 'value1'); + await client.set('{tag}key2', 'value2'); + + assert.equal( + await client.bitOp(op, '{tag}destKey', ['{tag}key1', '{tag}key2']), + 6 + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + } + + // NOT operation requires only one key + testUtils.testAll('bitOp NOT with non-existing keys', async client => { assert.equal( - await client.bitOp('AND', '{tag}destKey', '{tag}key'), + await client.bitOp('NOT', '{tag}destKey', '{tag}key'), 0 ); }, { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('bitOp NOT with existing keys', async client => { + await client.set('{tag}key', 'value'); + + assert.equal( + await client.bitOp('NOT', '{tag}destKey', '{tag}key'), + 5 + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + // newer operations supported since Redis 8.2 + for (const op of ['DIFF', 'DIFF1', 'ANDOR', 'ONE'] as BitOperations[]) { + testUtils.testAll(`bitOp ${op} with non-existing keys`, async client => { + assert.equal( + await client.bitOp(op, '{tag}destKey', ['{tag}key1', '{tag}key2']), + 0 + ); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 2] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 2] }, + }); + + testUtils.testAll(`bitOp ${op} with existing keys`, async client => { + await client.set('{tag}key1', 'value1'); + await client.set('{tag}key2', 'value2'); + + assert.equal( + await client.bitOp(op, '{tag}destKey', ['{tag}key1', '{tag}key2']), + 6 + ); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 2] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 2] }, + }); + } }); diff --git a/packages/client/lib/commands/BITOP.ts b/packages/client/lib/commands/BITOP.ts index cfce482fcb..da8b97ceda 100644 --- a/packages/client/lib/commands/BITOP.ts +++ b/packages/client/lib/commands/BITOP.ts @@ -2,14 +2,14 @@ import { CommandParser } from '../client/parser'; import { NumberReply, Command, RedisArgument } from '../RESP/types'; import { RedisVariadicArgument } from './generic-transformers'; -export type BitOperations = 'AND' | 'OR' | 'XOR' | 'NOT'; +export type BitOperations = 'AND' | 'OR' | 'XOR' | 'NOT' | 'DIFF' | 'DIFF1' | 'ANDOR' | 'ONE'; export default { IS_READ_ONLY: false, /** * Performs bitwise operations between strings * @param parser - The Redis command parser - * @param operation - Bitwise operation to perform: AND, OR, XOR, NOT + * @param operation - Bitwise operation to perform: AND, OR, XOR, NOT, DIFF, DIFF1, ANDOR, ONE * @param destKey - Destination key to store the result * @param key - Source key(s) to perform operation on */ diff --git a/packages/client/lib/commands/SPOP.spec.ts b/packages/client/lib/commands/SPOP.spec.ts index f435134416..542e1ba3fc 100644 --- a/packages/client/lib/commands/SPOP.spec.ts +++ b/packages/client/lib/commands/SPOP.spec.ts @@ -1,12 +1,14 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; import SPOP from './SPOP'; -import { parseArgs } from './generic-transformers'; +import { BasicCommandParser } from '../client/parser'; describe('SPOP', () => { it('transformArguments', () => { + const parser = new BasicCommandParser(); + SPOP.parseCommand(parser, 'key'); assert.deepEqual( - parseArgs(SPOP, 'key'), + parser.redisArgs, ['SPOP', 'key'] ); }); @@ -16,6 +18,19 @@ describe('SPOP', () => { await client.sPop('key'), null ); + + await client.sAdd('key', 'member'); + + assert.equal( + await client.sPop('key'), + 'member' + ); + + assert.equal( + await client.sPop('key'), + null + ); + }, { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN diff --git a/packages/client/lib/commands/SPOP_COUNT.spec.ts b/packages/client/lib/commands/SPOP_COUNT.spec.ts index 935ff43780..9720101f31 100644 --- a/packages/client/lib/commands/SPOP_COUNT.spec.ts +++ b/packages/client/lib/commands/SPOP_COUNT.spec.ts @@ -1,21 +1,36 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; import SPOP_COUNT from './SPOP_COUNT'; -import { parseArgs } from './generic-transformers'; +import { BasicCommandParser } from '../client/parser'; describe('SPOP_COUNT', () => { it('transformArguments', () => { + const parser = new BasicCommandParser(); + SPOP_COUNT.parseCommand(parser, 'key', 1); assert.deepEqual( - parseArgs(SPOP_COUNT, 'key', 1), + parser.redisArgs, ['SPOP', 'key', '1'] ); }); testUtils.testAll('sPopCount', async client => { + assert.deepEqual( await client.sPopCount('key', 1), [] ); + + await Promise.all([ + client.sAdd('key', 'member'), + client.sAdd('key', 'member2'), + client.sAdd('key', 'member3') + ]) + + assert.deepEqual( + (await client.sPopCount('key', 3)).length, + 3 + ); + }, { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN diff --git a/packages/client/lib/commands/SPOP_COUNT.ts b/packages/client/lib/commands/SPOP_COUNT.ts index 1191f07cff..a285e6f5c4 100644 --- a/packages/client/lib/commands/SPOP_COUNT.ts +++ b/packages/client/lib/commands/SPOP_COUNT.ts @@ -1,5 +1,5 @@ import { CommandParser } from '../client/parser'; -import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; +import { RedisArgument, Command, ArrayReply } from '../RESP/types'; export default { IS_READ_ONLY: false, @@ -16,5 +16,5 @@ export default { parser.pushKey(key); parser.push(count.toString()); }, - transformReply: undefined as unknown as () => BlobStringReply | NullReply + transformReply: undefined as unknown as () => ArrayReply } as const satisfies Command; diff --git a/packages/client/lib/commands/VADD.spec.ts b/packages/client/lib/commands/VADD.spec.ts new file mode 100644 index 0000000000..e064beab49 --- /dev/null +++ b/packages/client/lib/commands/VADD.spec.ts @@ -0,0 +1,121 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VADD from './VADD'; +import { BasicCommandParser } from '../client/parser'; + +describe('VADD', () => { + describe('parseCommand', () => { + it('basic usage', () => { + const parser = new BasicCommandParser(); + VADD.parseCommand(parser, 'key', [1.0, 2.0, 3.0], 'element'); + assert.deepEqual( + parser.redisArgs, + ['VADD', 'key', 'VALUES', '3', '1', '2', '3', 'element'] + ); + }); + + it('with REDUCE option', () => { + const parser = new BasicCommandParser(); + VADD.parseCommand(parser, 'key', [1.0, 2], 'element', { REDUCE: 50 }); + assert.deepEqual( + parser.redisArgs, + ['VADD', 'key', 'REDUCE', '50', 'VALUES', '2', '1', '2', 'element'] + ); + }); + + it('with quantization options', () => { + let parser = new BasicCommandParser(); + VADD.parseCommand(parser, 'key', [1.0, 2.0], 'element', { QUANT: 'Q8' }); + assert.deepEqual( + parser.redisArgs, + ['VADD', 'key', 'VALUES', '2', '1', '2', 'element', 'Q8'] + ); + + parser = new BasicCommandParser(); + VADD.parseCommand(parser, 'key', [1.0, 2.0], 'element', { QUANT: 'BIN' }); + assert.deepEqual( + parser.redisArgs, + ['VADD', 'key', 'VALUES', '2', '1', '2', 'element', 'BIN'] + ); + + parser = new BasicCommandParser(); + VADD.parseCommand(parser, 'key', [1.0, 2.0], 'element', { QUANT: 'NOQUANT' }); + assert.deepEqual( + parser.redisArgs, + ['VADD', 'key', 'VALUES', '2', '1', '2', 'element', 'NOQUANT'] + ); + }); + + it('with all options', () => { + const parser = new BasicCommandParser(); + VADD.parseCommand(parser, 'key', [1.0, 2.0], 'element', { + REDUCE: 50, + CAS: true, + QUANT: 'Q8', + EF: 200, + SETATTR: { name: 'test', value: 42 }, + M: 16 + }); + assert.deepEqual( + parser.redisArgs, + [ + 'VADD', 'key', 'REDUCE', '50', 'VALUES', '2', '1', '2', 'element', + 'CAS', 'Q8', 'EF', '200', 'SETATTR', '{"name":"test","value":42}', 'M', '16' + ] + ); + }); + }); + + testUtils.testAll('vAdd', async client => { + assert.equal( + await client.vAdd('key', [1.0, 2.0, 3.0], 'element'), + true + ); + + // same element should not be added again + assert.equal( + await client.vAdd('key', [1, 2 , 3], 'element'), + false + ); + + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] }, + }); + + testUtils.testWithClient('vAdd with RESP3', async client => { + // Test basic functionality with RESP3 + assert.equal( + await client.vAdd('resp3-key', [1.5, 2.5, 3.5], 'resp3-element'), + true + ); + + // same element should not be added again + assert.equal( + await client.vAdd('resp3-key', [1, 2 , 3], 'resp3-element'), + false + ); + + // Test with options to ensure complex parameters work with RESP3 + assert.equal( + await client.vAdd('resp3-key', [4.0, 5.0, 6.0], 'resp3-element2', { + QUANT: 'Q8', + CAS: true, + SETATTR: { type: 'test', value: 123 } + }), + true + ); + + // Verify the vector set was created correctly + assert.equal( + await client.vCard('resp3-key'), + 2 + ); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); +}); diff --git a/packages/client/lib/commands/VADD.ts b/packages/client/lib/commands/VADD.ts new file mode 100644 index 0000000000..0406bd58d0 --- /dev/null +++ b/packages/client/lib/commands/VADD.ts @@ -0,0 +1,65 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, Command } from '../RESP/types'; +import { transformBooleanReply, transformDoubleArgument } from './generic-transformers'; + +export interface VAddOptions { + REDUCE?: number; + CAS?: boolean; + QUANT?: 'NOQUANT' | 'BIN' | 'Q8', + EF?: number; + SETATTR?: Record; + M?: number; +} + +export default { + /** + * Add a new element into the vector set specified by key + * + * @param parser - The command parser + * @param key - The name of the key that will hold the vector set data + * @param vector - The vector data as array of numbers + * @param element - The name of the element being added to the vector set + * @param options - Optional parameters for vector addition + * @see https://redis.io/commands/vadd/ + */ + parseCommand( + parser: CommandParser, + key: RedisArgument, + vector: Array, + element: RedisArgument, + options?: VAddOptions + ) { + parser.push('VADD'); + parser.pushKey(key); + + if (options?.REDUCE !== undefined) { + parser.push('REDUCE', options.REDUCE.toString()); + } + + parser.push('VALUES', vector.length.toString()); + for (const value of vector) { + parser.push(transformDoubleArgument(value)); + } + + parser.push(element); + + if (options?.CAS) { + parser.push('CAS'); + } + + options?.QUANT && parser.push(options.QUANT); + + if (options?.EF !== undefined) { + parser.push('EF', options.EF.toString()); + } + + if (options?.SETATTR) { + parser.push('SETATTR', JSON.stringify(options.SETATTR)); + } + + if (options?.M !== undefined) { + parser.push('M', options.M.toString()); + } + }, + transformReply: transformBooleanReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/VCARD.spec.ts b/packages/client/lib/commands/VCARD.spec.ts new file mode 100644 index 0000000000..feb9040fcb --- /dev/null +++ b/packages/client/lib/commands/VCARD.spec.ts @@ -0,0 +1,60 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VCARD from './VCARD'; +import { BasicCommandParser } from '../client/parser'; + +describe('VCARD', () => { + it('parseCommand', () => { + const parser = new BasicCommandParser(); + VCARD.parseCommand(parser, 'key') + assert.deepEqual( + parser.redisArgs, + ['VCARD', 'key'] + ); + }); + + testUtils.testAll('vCard', async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('key', [4.0, 5.0, 6.0], 'element2'); + + assert.equal( + await client.vCard('key'), + 2 + ); + + assert.equal(await client.vCard('unknown'), 0); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testWithClient('vCard with RESP3', async client => { + // Test empty vector set + assert.equal( + await client.vCard('resp3-empty-key'), + 0 + ); + + // Add elements and test cardinality + await client.vAdd('resp3-key', [1.0, 2.0], 'elem1'); + assert.equal( + await client.vCard('resp3-key'), + 1 + ); + + await client.vAdd('resp3-key', [3.0, 4.0], 'elem2'); + await client.vAdd('resp3-key', [5.0, 6.0], 'elem3'); + assert.equal( + await client.vCard('resp3-key'), + 3 + ); + + assert.equal(await client.vCard('unknown'), 0); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); +}); diff --git a/packages/client/lib/commands/VCARD.ts b/packages/client/lib/commands/VCARD.ts new file mode 100644 index 0000000000..575abf9b71 --- /dev/null +++ b/packages/client/lib/commands/VCARD.ts @@ -0,0 +1,18 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, NumberReply, Command } from '../RESP/types'; + +export default { + IS_READ_ONLY: true, + /** + * Retrieve the number of elements in a vector set + * + * @param parser - The command parser + * @param key - The key of the vector set + * @see https://redis.io/commands/vcard/ + */ + parseCommand(parser: CommandParser, key: RedisArgument) { + parser.push('VCARD'); + parser.pushKey(key); + }, + transformReply: undefined as unknown as () => NumberReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/VDIM.spec.ts b/packages/client/lib/commands/VDIM.spec.ts new file mode 100644 index 0000000000..db3f5f3bd8 --- /dev/null +++ b/packages/client/lib/commands/VDIM.spec.ts @@ -0,0 +1,43 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VDIM from './VDIM'; +import { BasicCommandParser } from '../client/parser'; + +describe('VDIM', () => { + it('parseCommand', () => { + const parser = new BasicCommandParser(); + VDIM.parseCommand(parser, 'key'); + assert.deepEqual( + parser.redisArgs, + ['VDIM', 'key'] + ); + }); + + testUtils.testAll('vDim', async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element'); + + assert.equal( + await client.vDim('key'), + 3 + ); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testWithClient('vDim with RESP3', async client => { + await client.vAdd('resp3-5d', [1.0, 2.0, 3.0, 4.0, 5.0], 'elem5d'); + + assert.equal( + await client.vDim('resp3-5d'), + 5 + ); + + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); +}); diff --git a/packages/client/lib/commands/VDIM.ts b/packages/client/lib/commands/VDIM.ts new file mode 100644 index 0000000000..f7933e77ea --- /dev/null +++ b/packages/client/lib/commands/VDIM.ts @@ -0,0 +1,18 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, NumberReply, Command } from '../RESP/types'; + +export default { + IS_READ_ONLY: true, + /** + * Retrieve the dimension of the vectors in a vector set + * + * @param parser - The command parser + * @param key - The key of the vector set + * @see https://redis.io/commands/vdim/ + */ + parseCommand(parser: CommandParser, key: RedisArgument) { + parser.push('VDIM'); + parser.pushKey(key); + }, + transformReply: undefined as unknown as () => NumberReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/VEMB.spec.ts b/packages/client/lib/commands/VEMB.spec.ts new file mode 100644 index 0000000000..ed9515ebdd --- /dev/null +++ b/packages/client/lib/commands/VEMB.spec.ts @@ -0,0 +1,42 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VEMB from './VEMB'; +import { BasicCommandParser } from '../client/parser'; + +describe('VEMB', () => { + it('parseCommand', () => { + const parser = new BasicCommandParser(); + VEMB.parseCommand(parser, 'key', 'element'); + assert.deepEqual( + parser.redisArgs, + ['VEMB', 'key', 'element'] + ); + }); + + testUtils.testAll('vEmb', async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element'); + + const result = await client.vEmb('key', 'element'); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 3); + assert.equal(typeof result[0], 'number'); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testWithClient('vEmb with RESP3', async client => { + await client.vAdd('resp3-key', [1.5, 2.5, 3.5, 4.5], 'resp3-element'); + + const result = await client.vEmb('resp3-key', 'resp3-element'); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 4); + assert.equal(typeof result[0], 'number'); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); +}); diff --git a/packages/client/lib/commands/VEMB.ts b/packages/client/lib/commands/VEMB.ts new file mode 100644 index 0000000000..d534c27d65 --- /dev/null +++ b/packages/client/lib/commands/VEMB.ts @@ -0,0 +1,21 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, Command } from '../RESP/types'; +import { transformDoubleArrayReply } from './generic-transformers'; + +export default { + IS_READ_ONLY: true, + /** + * Retrieve the approximate vector associated with a vector set element + * + * @param parser - The command parser + * @param key - The key of the vector set + * @param element - The name of the element to retrieve the vector for + * @see https://redis.io/commands/vemb/ + */ + parseCommand(parser: CommandParser, key: RedisArgument, element: RedisArgument) { + parser.push('VEMB'); + parser.pushKey(key); + parser.push(element); + }, + transformReply: transformDoubleArrayReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/VEMB_RAW.spec.ts b/packages/client/lib/commands/VEMB_RAW.spec.ts new file mode 100644 index 0000000000..33d3af8540 --- /dev/null +++ b/packages/client/lib/commands/VEMB_RAW.spec.ts @@ -0,0 +1,68 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VEMB_RAW from './VEMB_RAW'; +import { BasicCommandParser } from '../client/parser'; + +describe('VEMB_RAW', () => { + it('parseCommand', () => { + const parser = new BasicCommandParser(); + VEMB_RAW.parseCommand(parser, 'key', 'element'); + assert.deepEqual( + parser.redisArgs, + ['VEMB', 'key', 'element', 'RAW'] + ); + }); + + testUtils.testAll('vEmbRaw', async client => { + await client.vAdd('key1', [1.0, 2.0, 3.0], 'element'); + const result1 = await client.vEmbRaw('key1', 'element'); + assert.equal(result1.quantization, 'int8'); + assert.ok(result1.quantizationRange !== undefined); + + await client.vAdd('key2', [1.0, 2.0, 3.0], 'element', { QUANT: 'Q8' }); + const result2 = await client.vEmbRaw('key2', 'element'); + assert.equal(result2.quantization, 'int8'); + assert.ok(result2.quantizationRange !== undefined); + + await client.vAdd('key3', [1.0, 2.0, 3.0], 'element', { QUANT: 'NOQUANT' }); + const result3 = await client.vEmbRaw('key3', 'element'); + assert.equal(result3.quantization, 'f32'); + assert.equal(result3.quantizationRange, undefined); + + await client.vAdd('key4', [1.0, 2.0, 3.0], 'element', { QUANT: 'BIN' }); + const result4 = await client.vEmbRaw('key4', 'element'); + assert.equal(result4.quantization, 'bin'); + assert.equal(result4.quantizationRange, undefined); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testWithClient('vEmbRaw with RESP3', async client => { + await client.vAdd('key1', [1.0, 2.0, 3.0], 'element'); + const result1 = await client.vEmbRaw('key1', 'element'); + assert.equal(result1.quantization, 'int8'); + assert.ok(result1.quantizationRange !== undefined); + + await client.vAdd('key2', [1.0, 2.0, 3.0], 'element', { QUANT: 'Q8' }); + const result2 = await client.vEmbRaw('key2', 'element'); + assert.equal(result2.quantization, 'int8'); + assert.ok(result2.quantizationRange !== undefined); + + await client.vAdd('key3', [1.0, 2.0, 3.0], 'element', { QUANT: 'NOQUANT' }); + const result3 = await client.vEmbRaw('key3', 'element'); + assert.equal(result3.quantization, 'f32'); + assert.equal(result3.quantizationRange, undefined); + + await client.vAdd('key4', [1.0, 2.0, 3.0], 'element', { QUANT: 'BIN' }); + const result4 = await client.vEmbRaw('key4', 'element'); + assert.equal(result4.quantization, 'bin'); + assert.equal(result4.quantizationRange, undefined); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); +}); diff --git a/packages/client/lib/commands/VEMB_RAW.ts b/packages/client/lib/commands/VEMB_RAW.ts new file mode 100644 index 0000000000..b6881d321c --- /dev/null +++ b/packages/client/lib/commands/VEMB_RAW.ts @@ -0,0 +1,57 @@ +import { CommandParser } from '../client/parser'; +import { + RedisArgument, + Command, + BlobStringReply, + SimpleStringReply, + DoubleReply +} from '../RESP/types'; +import { transformDoubleReply } from './generic-transformers'; +import VEMB from './VEMB'; + +type RawVembReply = { + quantization: SimpleStringReply; + raw: BlobStringReply; + l2Norm: DoubleReply; + quantizationRange?: DoubleReply; +}; + +const transformRawVembReply = { + 2: (reply: any[]): RawVembReply => { + return { + quantization: reply[0], + raw: reply[1], + l2Norm: transformDoubleReply[2](reply[2]), + ...(reply[3] !== undefined && { quantizationRange: transformDoubleReply[2](reply[3]) }) + }; + }, + 3: (reply: any[]): RawVembReply => { + return { + quantization: reply[0], + raw: reply[1], + l2Norm: reply[2], + quantizationRange: reply[3] + }; + }, +}; + +export default { + IS_READ_ONLY: true, + /** + * Retrieve the RAW approximate vector associated with a vector set element + * + * @param parser - The command parser + * @param key - The key of the vector set + * @param element - The name of the element to retrieve the vector for + * @see https://redis.io/commands/vemb/ + */ + parseCommand( + parser: CommandParser, + key: RedisArgument, + element: RedisArgument + ) { + VEMB.parseCommand(parser, key, element); + parser.push('RAW'); + }, + transformReply: transformRawVembReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/VGETATTR.spec.ts b/packages/client/lib/commands/VGETATTR.spec.ts new file mode 100644 index 0000000000..d904146c67 --- /dev/null +++ b/packages/client/lib/commands/VGETATTR.spec.ts @@ -0,0 +1,77 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VGETATTR from './VGETATTR'; +import { BasicCommandParser } from '../client/parser'; + +describe('VGETATTR', () => { + it('parseCommand', () => { + const parser = new BasicCommandParser(); + VGETATTR.parseCommand(parser, 'key', 'element'); + assert.deepEqual( + parser.redisArgs, + ['VGETATTR', 'key', 'element'] + ); + }); + + testUtils.testAll('vGetAttr', async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element'); + + const nullResult = await client.vGetAttr('key', 'element'); + assert.equal(nullResult, null); + + await client.vSetAttr('key', 'element', { name: 'test' }); + + const result = await client.vGetAttr('key', 'element'); + + assert.ok(result !== null); + assert.equal(typeof result, 'object') + + assert.deepEqual(result, { + name: 'test' + }) + + + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testWithClient('vGetAttr with RESP3', async client => { + await client.vAdd('resp3-key', [1.0, 2.0], 'resp3-element'); + + // Test null case (no attributes set) + const nullResult = await client.vGetAttr('resp3-key', 'resp3-element'); + + assert.equal(nullResult, null); + + // Set complex attributes and retrieve them + const complexAttrs = { + name: 'test-item', + category: 'electronics', + price: 99.99, + inStock: true, + tags: ['new', 'featured'] + }; + await client.vSetAttr('resp3-key', 'resp3-element', complexAttrs); + + const result = await client.vGetAttr('resp3-key', 'resp3-element'); + + assert.ok(result !== null); + assert.equal(typeof result, 'object') + + assert.deepEqual(result, { + name: 'test-item', + category: 'electronics', + price: 99.99, + inStock: true, + tags: ['new', 'featured'] + }) + + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); +}); diff --git a/packages/client/lib/commands/VGETATTR.ts b/packages/client/lib/commands/VGETATTR.ts new file mode 100644 index 0000000000..05ec8706fb --- /dev/null +++ b/packages/client/lib/commands/VGETATTR.ts @@ -0,0 +1,21 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, Command } from '../RESP/types'; +import { transformRedisJsonNullReply } from './generic-transformers'; + +export default { + IS_READ_ONLY: true, + /** + * Retrieve the attributes of a vector set element + * + * @param parser - The command parser + * @param key - The key of the vector set + * @param element - The name of the element to retrieve attributes for + * @see https://redis.io/commands/vgetattr/ + */ + parseCommand(parser: CommandParser, key: RedisArgument, element: RedisArgument) { + parser.push('VGETATTR'); + parser.pushKey(key); + parser.push(element); + }, + transformReply: transformRedisJsonNullReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/VINFO.spec.ts b/packages/client/lib/commands/VINFO.spec.ts new file mode 100644 index 0000000000..074598644f --- /dev/null +++ b/packages/client/lib/commands/VINFO.spec.ts @@ -0,0 +1,58 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VINFO from './VINFO'; +import { BasicCommandParser } from '../client/parser'; + +describe('VINFO', () => { + it('parseCommand', () => { + const parser = new BasicCommandParser(); + VINFO.parseCommand(parser, 'key'); + assert.deepEqual( + parser.redisArgs, + ['VINFO', 'key'] + ); + }); + + testUtils.testAll('vInfo', async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element'); + + const result = await client.vInfo('key'); + assert.ok(typeof result === 'object' && result !== null); + + assert.equal(result['vector-dim'], 3); + assert.equal(result['size'], 1); + assert.ok('quant-type' in result); + assert.ok('hnsw-m' in result); + assert.ok('projection-input-dim' in result); + assert.ok('max-level' in result); + assert.ok('attributes-count' in result); + assert.ok('vset-uid' in result); + assert.ok('hnsw-max-node-uid' in result); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testWithClient('vInfo with RESP3', async client => { + await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'resp3-element'); + + const result = await client.vInfo('resp3-key'); + assert.ok(typeof result === 'object' && result !== null); + + assert.equal(result['vector-dim'], 3); + assert.equal(result['size'], 1); + assert.ok('quant-type' in result); + assert.ok('hnsw-m' in result); + assert.ok('projection-input-dim' in result); + assert.ok('max-level' in result); + assert.ok('attributes-count' in result); + assert.ok('vset-uid' in result); + assert.ok('hnsw-max-node-uid' in result); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); +}); diff --git a/packages/client/lib/commands/VINFO.ts b/packages/client/lib/commands/VINFO.ts new file mode 100644 index 0000000000..4e0d68d7cb --- /dev/null +++ b/packages/client/lib/commands/VINFO.ts @@ -0,0 +1,38 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, Command, UnwrapReply, Resp2Reply, TuplesToMapReply, SimpleStringReply, NumberReply } from '../RESP/types'; + +export type VInfoReplyMap = TuplesToMapReply<[ + [SimpleStringReply<'quant-type'>, SimpleStringReply], + [SimpleStringReply<'vector-dim'>, NumberReply], + [SimpleStringReply<'size'>, NumberReply], + [SimpleStringReply<'max-level'>, NumberReply], + [SimpleStringReply<'vset-uid'>, NumberReply], + [SimpleStringReply<'hnsw-max-node-uid'>, NumberReply], +]>; + +export default { + IS_READ_ONLY: true, + /** + * Retrieve metadata and internal details about a vector set, including size, dimensions, quantization type, and graph structure + * + * @param parser - The command parser + * @param key - The key of the vector set + * @see https://redis.io/commands/vinfo/ + */ + parseCommand(parser: CommandParser, key: RedisArgument) { + parser.push('VINFO'); + parser.pushKey(key); + }, + transformReply: { + 2: (reply: UnwrapReply>): VInfoReplyMap => { + const ret = Object.create(null); + + for (let i = 0; i < reply.length; i += 2) { + ret[reply[i].toString()] = reply[i + 1]; + } + + return ret as unknown as VInfoReplyMap; + }, + 3: undefined as unknown as () => VInfoReplyMap + } +} as const satisfies Command; diff --git a/packages/client/lib/commands/VLINKS.spec.ts b/packages/client/lib/commands/VLINKS.spec.ts new file mode 100644 index 0000000000..e788f9f9a9 --- /dev/null +++ b/packages/client/lib/commands/VLINKS.spec.ts @@ -0,0 +1,42 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VLINKS from './VLINKS'; +import { BasicCommandParser } from '../client/parser'; + +describe('VLINKS', () => { + it('parseCommand', () => { + const parser = new BasicCommandParser(); + VLINKS.parseCommand(parser, 'key', 'element'); + assert.deepEqual( + parser.redisArgs, + ['VLINKS', 'key', 'element'] + ); + }); + + testUtils.testAll('vLinks', async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('key', [1.1, 2.1, 3.1], 'element2'); + + const result = await client.vLinks('key', 'element1'); + assert.ok(Array.isArray(result)); + assert.ok(result.length) + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testWithClient('vLinks with RESP3', async client => { + await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('resp3-key', [1.1, 2.1, 3.1], 'element2'); + + const result = await client.vLinks('resp3-key', 'element1'); + assert.ok(Array.isArray(result)); + assert.ok(result.length) + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); +}); diff --git a/packages/client/lib/commands/VLINKS.ts b/packages/client/lib/commands/VLINKS.ts new file mode 100644 index 0000000000..9e97fc7de9 --- /dev/null +++ b/packages/client/lib/commands/VLINKS.ts @@ -0,0 +1,20 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; + +export default { + IS_READ_ONLY: true, + /** + * Retrieve the neighbors of a specified element in a vector set; the connections for each layer of the HNSW graph + * + * @param parser - The command parser + * @param key - The key of the vector set + * @param element - The name of the element to retrieve neighbors for + * @see https://redis.io/commands/vlinks/ + */ + parseCommand(parser: CommandParser, key: RedisArgument, element: RedisArgument) { + parser.push('VLINKS'); + parser.pushKey(key); + parser.push(element); + }, + transformReply: undefined as unknown as () => ArrayReply> +} as const satisfies Command; diff --git a/packages/client/lib/commands/VLINKS_WITHSCORES.spec.ts b/packages/client/lib/commands/VLINKS_WITHSCORES.spec.ts new file mode 100644 index 0000000000..db96bd1a8a --- /dev/null +++ b/packages/client/lib/commands/VLINKS_WITHSCORES.spec.ts @@ -0,0 +1,75 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VLINKS_WITHSCORES from './VLINKS_WITHSCORES'; +import { BasicCommandParser } from '../client/parser'; + +describe('VLINKS WITHSCORES', () => { + it('parseCommand', () => { + const parser = new BasicCommandParser(); + VLINKS_WITHSCORES.parseCommand(parser, 'key', 'element'); + assert.deepEqual(parser.redisArgs, [ + 'VLINKS', + 'key', + 'element', + 'WITHSCORES' + ]); + }); + + testUtils.testAll( + 'vLinksWithScores', + async client => { + // Create a vector set with multiple elements to build HNSW graph layers + await client.vAdd('key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('key', [1.1, 2.1, 3.1], 'element2'); + await client.vAdd('key', [1.2, 2.2, 3.2], 'element3'); + await client.vAdd('key', [2.0, 3.0, 4.0], 'element4'); + + const result = await client.vLinksWithScores('key', 'element1'); + + assert.ok(Array.isArray(result)); + + for (const layer of result) { + assert.equal( + typeof layer, + 'object' + ); + } + + assert.ok(result.length >= 1, 'Should have at least layer 0'); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + } + ); + + testUtils.testWithClient( + 'vLinksWithScores with RESP3', + async client => { + await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('resp3-key', [1.1, 2.1, 3.1], 'element2'); + await client.vAdd('resp3-key', [1.2, 2.2, 3.2], 'element3'); + await client.vAdd('resp3-key', [2.0, 3.0, 4.0], 'element4'); + + const result = await client.vLinksWithScores('resp3-key', 'element1'); + + assert.ok(Array.isArray(result)); + + for (const layer of result) { + assert.equal( + typeof layer, + 'object' + ); + } + + assert.ok(result.length >= 1, 'Should have at least layer 0'); + }, + { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + } + ); +}); diff --git a/packages/client/lib/commands/VLINKS_WITHSCORES.ts b/packages/client/lib/commands/VLINKS_WITHSCORES.ts new file mode 100644 index 0000000000..10ebe160fc --- /dev/null +++ b/packages/client/lib/commands/VLINKS_WITHSCORES.ts @@ -0,0 +1,42 @@ +import { BlobStringReply, Command, DoubleReply, MapReply } from '../RESP/types'; +import { transformDoubleReply } from './generic-transformers'; +import VLINKS from './VLINKS'; + + +function transformVLinksWithScoresReply(reply: any): Array> { + const layers: Array> = []; + + for (const layer of reply) { + const obj: Record = Object.create(null); + + // Each layer contains alternating element names and scores + for (let i = 0; i < layer.length; i += 2) { + const element = layer[i]; + const score = transformDoubleReply[2](layer[i + 1]); + obj[element.toString()] = score; + } + + layers.push(obj); + } + + return layers; +} + +export default { + IS_READ_ONLY: VLINKS.IS_READ_ONLY, + /** + * Get the connections for each layer of the HNSW graph with similarity scores + * @param args - Same parameters as the VLINKS command + * @see https://redis.io/commands/vlinks/ + */ + parseCommand(...args: Parameters) { + const parser = args[0]; + + VLINKS.parseCommand(...args); + parser.push('WITHSCORES'); + }, + transformReply: { + 2: transformVLinksWithScoresReply, + 3: undefined as unknown as () => Array> + } +} as const satisfies Command; diff --git a/packages/client/lib/commands/VRANDMEMBER.spec.ts b/packages/client/lib/commands/VRANDMEMBER.spec.ts new file mode 100644 index 0000000000..28c020e356 --- /dev/null +++ b/packages/client/lib/commands/VRANDMEMBER.spec.ts @@ -0,0 +1,201 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VRANDMEMBER from './VRANDMEMBER'; +import { BasicCommandParser } from '../client/parser'; + +describe('VRANDMEMBER', () => { + describe('parseCommand', () => { + it('without count', () => { + const parser = new BasicCommandParser(); + VRANDMEMBER.parseCommand(parser, 'key'); + assert.deepEqual( + parser.redisArgs, + ['VRANDMEMBER', 'key'] + ); + }); + + it('with count', () => { + const parser = new BasicCommandParser(); + VRANDMEMBER.parseCommand(parser, 'key', 2); + assert.deepEqual( + parser.redisArgs, + ['VRANDMEMBER', 'key', '2'] + ); + }); + }); + + describe('RESP2 tests', () => { + testUtils.testAll('vRandMember without count - returns single element as string', async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('key', [4.0, 5.0, 6.0], 'element2'); + await client.vAdd('key', [7.0, 8.0, 9.0], 'element3'); + + const result = await client.vRandMember('key'); + assert.equal(typeof result, 'string'); + assert.ok(['element1', 'element2', 'element3'].includes(result as string)); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testAll('vRandMember with positive count - returns distinct elements', async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('key', [4.0, 5.0, 6.0], 'element2'); + await client.vAdd('key', [7.0, 8.0, 9.0], 'element3'); + + const result = await client.vRandMember('key', 2); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 2); + + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testAll('vRandMember with negative count - allows duplicates', async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('key', [4.0, 5.0, 6.0], 'element2'); + + const result = await client.vRandMember('key', -5); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 5); + + // All elements should be from our set (duplicates allowed) + result.forEach(element => { + assert.ok(['element1', 'element2'].includes(element)); + }); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testAll('vRandMember count exceeds set size - returns entire set', async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('key', [4.0, 5.0, 6.0], 'element2'); + + const result = await client.vRandMember('key', 10); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 2); // Only 2 elements exist + + // Should contain both elements + assert.ok(result.includes('element1')); + assert.ok(result.includes('element2')); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testAll('vRandMember on non-existent key', async client => { + // Without count - should return null + const resultNoCount = await client.vRandMember('nonexistent'); + assert.equal(resultNoCount, null); + + // With count - should return empty array + const resultWithCount = await client.vRandMember('nonexistent', 5); + assert.ok(Array.isArray(resultWithCount)); + assert.equal(resultWithCount.length, 0); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + }); + + describe('RESP3 tests', () => { + testUtils.testWithClient('vRandMember without count - returns single element as string', async client => { + await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('resp3-key', [4.0, 5.0, 6.0], 'element2'); + await client.vAdd('resp3-key', [7.0, 8.0, 9.0], 'element3'); + + const result = await client.vRandMember('resp3-key'); + assert.equal(typeof result, 'string'); + assert.ok(['element1', 'element2', 'element3'].includes(result as string)); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); + + testUtils.testWithClient('vRandMember with positive count - returns distinct elements', async client => { + await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('resp3-key', [4.0, 5.0, 6.0], 'element2'); + await client.vAdd('resp3-key', [7.0, 8.0, 9.0], 'element3'); + + const result = await client.vRandMember('resp3-key', 2); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 2); + + // Should be distinct elements (no duplicates) + const uniqueElements = new Set(result); + assert.equal(uniqueElements.size, 2); + + // All elements should be from our set + result.forEach(element => { + assert.ok(['element1', 'element2', 'element3'].includes(element)); + }); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); + + testUtils.testWithClient('vRandMember with negative count - allows duplicates', async client => { + await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('resp3-key', [4.0, 5.0, 6.0], 'element2'); + + const result = await client.vRandMember('resp3-key', -5); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 5); + + // All elements should be from our set (duplicates allowed) + result.forEach(element => { + assert.ok(['element1', 'element2'].includes(element)); + }); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); + + testUtils.testWithClient('vRandMember count exceeds set size - returns entire set', async client => { + await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('resp3-key', [4.0, 5.0, 6.0], 'element2'); + + const result = await client.vRandMember('resp3-key', 10); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 2); // Only 2 elements exist + + // Should contain both elements + assert.ok(result.includes('element1')); + assert.ok(result.includes('element2')); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); + + testUtils.testWithClient('vRandMember on non-existent key', async client => { + // Without count - should return null + const resultNoCount = await client.vRandMember('resp3-nonexistent'); + assert.equal(resultNoCount, null); + + // With count - should return empty array + const resultWithCount = await client.vRandMember('resp3-nonexistent', 5); + assert.ok(Array.isArray(resultWithCount)); + assert.equal(resultWithCount.length, 0); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); + }); +}); diff --git a/packages/client/lib/commands/VRANDMEMBER.ts b/packages/client/lib/commands/VRANDMEMBER.ts new file mode 100644 index 0000000000..299af33b9f --- /dev/null +++ b/packages/client/lib/commands/VRANDMEMBER.ts @@ -0,0 +1,23 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, BlobStringReply, ArrayReply, Command, NullReply } from '../RESP/types'; + +export default { + IS_READ_ONLY: true, + /** + * Retrieve random elements of a vector set + * + * @param parser - The command parser + * @param key - The key of the vector set + * @param count - Optional number of elements to return + * @see https://redis.io/commands/vrandmember/ + */ + parseCommand(parser: CommandParser, key: RedisArgument, count?: number) { + parser.push('VRANDMEMBER'); + parser.pushKey(key); + + if (count !== undefined) { + parser.push(count.toString()); + } + }, + transformReply: undefined as unknown as () => BlobStringReply | ArrayReply | NullReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/VREM.spec.ts b/packages/client/lib/commands/VREM.spec.ts new file mode 100644 index 0000000000..9e558c991c --- /dev/null +++ b/packages/client/lib/commands/VREM.spec.ts @@ -0,0 +1,63 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VREM from './VREM'; +import { BasicCommandParser } from '../client/parser'; + +describe('VREM', () => { + const parser = new BasicCommandParser(); + VREM.parseCommand(parser, 'key', 'element'); + it('parseCommand', () => { + assert.deepEqual( + parser.redisArgs, + ['VREM', 'key', 'element'] + ); + }); + + testUtils.testAll('vRem', async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element'); + + assert.equal( + await client.vRem('key', 'element'), + true + ); + + assert.equal( + await client.vRem('key', 'element'), + false + ); + + assert.equal( + await client.vCard('key'), + 0 + ); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testWithClient('vRem with RESP3', async client => { + await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'resp3-element'); + + assert.equal( + await client.vRem('resp3-key', 'resp3-element'), + true + ); + + assert.equal( + await client.vRem('resp3-key', 'resp3-element'), + false + ); + + + assert.equal( + await client.vCard('resp3-key'), + 0 + ); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); +}); diff --git a/packages/client/lib/commands/VREM.ts b/packages/client/lib/commands/VREM.ts new file mode 100644 index 0000000000..7eb22b2e2e --- /dev/null +++ b/packages/client/lib/commands/VREM.ts @@ -0,0 +1,20 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, Command } from '../RESP/types'; +import { transformBooleanReply } from './generic-transformers'; + +export default { + /** + * Remove an element from a vector set + * + * @param parser - The command parser + * @param key - The key of the vector set + * @param element - The name of the element to remove from the vector set + * @see https://redis.io/commands/vrem/ + */ + parseCommand(parser: CommandParser, key: RedisArgument, element: RedisArgument) { + parser.push('VREM'); + parser.pushKey(key); + parser.push(element); + }, + transformReply: transformBooleanReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/VSETATTR.spec.ts b/packages/client/lib/commands/VSETATTR.spec.ts new file mode 100644 index 0000000000..303006d408 --- /dev/null +++ b/packages/client/lib/commands/VSETATTR.spec.ts @@ -0,0 +1,58 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VSETATTR from './VSETATTR'; +import { BasicCommandParser } from '../client/parser'; + +describe('VSETATTR', () => { + describe('parseCommand', () => { + it('with object', () => { + const parser = new BasicCommandParser(); + VSETATTR.parseCommand(parser, 'key', 'element', { name: 'test', value: 42 }), + assert.deepEqual( + parser.redisArgs, + ['VSETATTR', 'key', 'element', '{"name":"test","value":42}'] + ); + }); + + it('with string', () => { + const parser = new BasicCommandParser(); + VSETATTR.parseCommand(parser, 'key', 'element', '{"name":"test"}'), + assert.deepEqual( + parser.redisArgs, + ['VSETATTR', 'key', 'element', '{"name":"test"}'] + ); + }); + }); + + testUtils.testAll('vSetAttr', async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element'); + + assert.equal( + await client.vSetAttr('key', 'element', { name: 'test', value: 42 }), + true + ); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testWithClient('vSetAttr with RESP3 - returns boolean', async client => { + await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'resp3-element'); + + const result = await client.vSetAttr('resp3-key', 'resp3-element', { + name: 'test-item', + category: 'electronics', + price: 99.99 + }); + + // RESP3 returns boolean instead of number + assert.equal(typeof result, 'boolean'); + assert.equal(result, true); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); +}); diff --git a/packages/client/lib/commands/VSETATTR.ts b/packages/client/lib/commands/VSETATTR.ts new file mode 100644 index 0000000000..084b8f8008 --- /dev/null +++ b/packages/client/lib/commands/VSETATTR.ts @@ -0,0 +1,32 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, Command } from '../RESP/types'; +import { transformBooleanReply } from './generic-transformers'; + +export default { + /** + * Set or replace attributes on a vector set element + * + * @param parser - The command parser + * @param key - The key of the vector set + * @param element - The name of the element to set attributes for + * @param attributes - The attributes to set (as JSON string or object) + * @see https://redis.io/commands/vsetattr/ + */ + parseCommand( + parser: CommandParser, + key: RedisArgument, + element: RedisArgument, + attributes: RedisArgument | Record + ) { + parser.push('VSETATTR'); + parser.pushKey(key); + parser.push(element); + + if (typeof attributes === 'object' && attributes !== null) { + parser.push(JSON.stringify(attributes)); + } else { + parser.push(attributes); + } + }, + transformReply: transformBooleanReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/VSIM.spec.ts b/packages/client/lib/commands/VSIM.spec.ts new file mode 100644 index 0000000000..b7e10eb6c4 --- /dev/null +++ b/packages/client/lib/commands/VSIM.spec.ts @@ -0,0 +1,85 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VSIM from './VSIM'; +import { BasicCommandParser } from '../client/parser'; + +describe('VSIM', () => { + describe('parseCommand', () => { + it('with vector', () => { + const parser = new BasicCommandParser(); + VSIM.parseCommand(parser, 'key', [1.0, 2.0, 3.0]), + assert.deepEqual( + parser.redisArgs, + ['VSIM', 'key', 'VALUES', '3', '1', '2', '3'] + ); + }); + + it('with element', () => { + const parser = new BasicCommandParser(); + VSIM.parseCommand(parser, 'key', 'element'); + assert.deepEqual( + parser.redisArgs, + ['VSIM', 'key', 'ELE', 'element'] + ); + }); + + it('with options', () => { + const parser = new BasicCommandParser(); + VSIM.parseCommand(parser, 'key', 'element', { + COUNT: 5, + EF: 100, + FILTER: '.price > 20', + 'FILTER-EF': 50, + TRUTH: true, + NOTHREAD: true + }); + assert.deepEqual( + parser.redisArgs, + [ + 'VSIM', 'key', 'ELE', 'element', + 'COUNT', '5', 'EF', '100', 'FILTER', '.price > 20', + 'FILTER-EF', '50', 'TRUTH', 'NOTHREAD' + ] + ); + }); + }); + + testUtils.testAll('vSim', async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('key', [1.1, 2.1, 3.1], 'element2'); + + const result = await client.vSim('key', 'element1'); + assert.ok(Array.isArray(result)); + assert.ok(result.includes('element1')); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + }); + + testUtils.testWithClient('vSim with RESP3', async client => { + await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('resp3-key', [1.1, 2.1, 3.1], 'element2'); + await client.vAdd('resp3-key', [2.0, 3.0, 4.0], 'element3'); + + // Test similarity search with vector + const resultWithVector = await client.vSim('resp3-key', [1.05, 2.05, 3.05]); + assert.ok(Array.isArray(resultWithVector)); + assert.ok(resultWithVector.length > 0); + + // Test similarity search with element + const resultWithElement = await client.vSim('resp3-key', 'element1'); + assert.ok(Array.isArray(resultWithElement)); + assert.ok(resultWithElement.includes('element1')); + + // Test with options + const resultWithOptions = await client.vSim('resp3-key', 'element1', { COUNT: 2 }); + assert.ok(Array.isArray(resultWithOptions)); + assert.ok(resultWithOptions.length <= 2); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + }); +}); diff --git a/packages/client/lib/commands/VSIM.ts b/packages/client/lib/commands/VSIM.ts new file mode 100644 index 0000000000..dc41a54caf --- /dev/null +++ b/packages/client/lib/commands/VSIM.ts @@ -0,0 +1,68 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, ArrayReply, BlobStringReply, Command } from '../RESP/types'; +import { transformDoubleArgument } from './generic-transformers'; + +export interface VSimOptions { + COUNT?: number; + EF?: number; + FILTER?: string; + 'FILTER-EF'?: number; + TRUTH?: boolean; + NOTHREAD?: boolean; +} + +export default { + IS_READ_ONLY: true, + /** + * Retrieve elements similar to a given vector or element with optional filtering + * + * @param parser - The command parser + * @param key - The key of the vector set + * @param query - The query vector (array of numbers) or element name (string) + * @param options - Optional parameters for similarity search + * @see https://redis.io/commands/vsim/ + */ + parseCommand( + parser: CommandParser, + key: RedisArgument, + query: RedisArgument | Array, + options?: VSimOptions + ) { + parser.push('VSIM'); + parser.pushKey(key); + + if (Array.isArray(query)) { + parser.push('VALUES', query.length.toString()); + for (const value of query) { + parser.push(transformDoubleArgument(value)); + } + } else { + parser.push('ELE', query); + } + + if (options?.COUNT !== undefined) { + parser.push('COUNT', options.COUNT.toString()); + } + + if (options?.EF !== undefined) { + parser.push('EF', options.EF.toString()); + } + + if (options?.FILTER) { + parser.push('FILTER', options.FILTER); + } + + if (options?.['FILTER-EF'] !== undefined) { + parser.push('FILTER-EF', options['FILTER-EF'].toString()); + } + + if (options?.TRUTH) { + parser.push('TRUTH'); + } + + if (options?.NOTHREAD) { + parser.push('NOTHREAD'); + } + }, + transformReply: undefined as unknown as () => ArrayReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/VSIM_WITHSCORES.spec.ts b/packages/client/lib/commands/VSIM_WITHSCORES.spec.ts new file mode 100644 index 0000000000..ff9bc41376 --- /dev/null +++ b/packages/client/lib/commands/VSIM_WITHSCORES.spec.ts @@ -0,0 +1,62 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import VSIM_WITHSCORES from './VSIM_WITHSCORES'; +import { BasicCommandParser } from '../client/parser'; + +describe('VSIM WITHSCORES', () => { + it('parseCommand', () => { + const parser = new BasicCommandParser(); + VSIM_WITHSCORES.parseCommand(parser, 'key', 'element') + assert.deepEqual(parser.redisArgs, [ + 'VSIM', + 'key', + 'ELE', + 'element', + 'WITHSCORES' + ]); + }); + + testUtils.testAll( + 'vSimWithScores', + async client => { + await client.vAdd('key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('key', [1.1, 2.1, 3.1], 'element2'); + + const result = await client.vSimWithScores('key', 'element1'); + + assert.ok(typeof result === 'object'); + assert.ok('element1' in result); + assert.ok('element2' in result); + assert.equal(typeof result['element1'], 'number'); + assert.equal(typeof result['element2'], 'number'); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } + } + ); + + testUtils.testWithClient( + 'vSimWithScores with RESP3 - returns Map with scores', + async client => { + await client.vAdd('resp3-key', [1.0, 2.0, 3.0], 'element1'); + await client.vAdd('resp3-key', [1.1, 2.1, 3.1], 'element2'); + await client.vAdd('resp3-key', [2.0, 3.0, 4.0], 'element3'); + + const result = await client.vSimWithScores('resp3-key', 'element1'); + + assert.ok(typeof result === 'object'); + assert.ok('element1' in result); + assert.ok('element2' in result); + assert.equal(typeof result['element1'], 'number'); + assert.equal(typeof result['element2'], 'number'); + }, + { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + }, + minimumDockerVersion: [8, 0] + } + ); +}); diff --git a/packages/client/lib/commands/VSIM_WITHSCORES.ts b/packages/client/lib/commands/VSIM_WITHSCORES.ts new file mode 100644 index 0000000000..fda05be664 --- /dev/null +++ b/packages/client/lib/commands/VSIM_WITHSCORES.ts @@ -0,0 +1,36 @@ +import { + ArrayReply, + BlobStringReply, + Command, + DoubleReply, + MapReply, + UnwrapReply +} from '../RESP/types'; +import { transformDoubleReply } from './generic-transformers'; +import VSIM from './VSIM'; + +export default { + IS_READ_ONLY: VSIM.IS_READ_ONLY, + /** + * Retrieve elements similar to a given vector or element with similarity scores + * @param args - Same parameters as the VSIM command + * @see https://redis.io/commands/vsim/ + */ + parseCommand(...args: Parameters) { + const parser = args[0]; + + VSIM.parseCommand(...args); + parser.push('WITHSCORES'); + }, + transformReply: { + 2: (reply: ArrayReply) => { + const inferred = reply as unknown as UnwrapReply; + const members: Record = {}; + for (let i = 0; i < inferred.length; i += 2) { + members[inferred[i].toString()] = transformDoubleReply[2](inferred[i + 1]); + } + return members; + }, + 3: undefined as unknown as () => MapReply + } +} as const satisfies Command; diff --git a/packages/client/lib/commands/XREAD.spec.ts b/packages/client/lib/commands/XREAD.spec.ts index bb72c96497..0edcfe4311 100644 --- a/packages/client/lib/commands/XREAD.spec.ts +++ b/packages/client/lib/commands/XREAD.spec.ts @@ -131,4 +131,37 @@ describe('XREAD', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testWithClient('client.xRead should throw with resp3 and unstableResp3: false', async client => { + assert.throws( + () => client.xRead({ + key: 'key', + id: '0-0' + }), + { + message: 'Some RESP3 results for Redis Query Engine responses may change. Refer to the readme for guidance' + } + ); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + } + }); + + testUtils.testWithClient('client.xRead should not throw with resp3 and unstableResp3: true', async client => { + assert.doesNotThrow( + () => client.xRead({ + key: 'key', + id: '0-0' + }) + ); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3, + unstableResp3: true + } + }); + }); diff --git a/packages/client/lib/commands/XREADGROUP.spec.ts b/packages/client/lib/commands/XREADGROUP.spec.ts index 085a67bc9b..acc7cc2dea 100644 --- a/packages/client/lib/commands/XREADGROUP.spec.ts +++ b/packages/client/lib/commands/XREADGROUP.spec.ts @@ -155,4 +155,36 @@ describe('XREADGROUP', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testWithClient('client.xReadGroup should throw with resp3 and unstableResp3: false', async client => { + assert.throws( + () => client.xReadGroup('group', 'consumer', { + key: 'key', + id: '>' + }), + { + message: 'Some RESP3 results for Redis Query Engine responses may change. Refer to the readme for guidance' + } + ); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3 + } + }); + + testUtils.testWithClient('client.xReadGroup should not throw with resp3 and unstableResp3: true', async client => { + assert.doesNotThrow( + () => client.xReadGroup('group', 'consumer', { + key: 'key', + id: '>' + }) + ); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3, + unstableResp3: true + } + }); }); diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index 91eab7107a..022339e4bb 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -662,3 +662,21 @@ export function transformStreamsMessagesReplyResp3(reply: UnwrapReply } } + +export type RedisJSON = null | boolean | number | string | Date | Array | { + [key: string]: RedisJSON; + [key: number]: RedisJSON; +}; + +export function transformRedisJsonArgument(json: RedisJSON): string { + return JSON.stringify(json); +} + +export function transformRedisJsonReply(json: BlobStringReply): RedisJSON { + const res = JSON.parse((json as unknown as UnwrapReply).toString()); + return res; +} + +export function transformRedisJsonNullReply(json: NullReply | BlobStringReply): NullReply | RedisJSON { + return isNullReply(json) ? json : transformRedisJsonReply(json); +} diff --git a/packages/client/lib/commands/index.ts b/packages/client/lib/commands/index.ts index 5cd81331a4..87ab8d10b8 100644 --- a/packages/client/lib/commands/index.ts +++ b/packages/client/lib/commands/index.ts @@ -344,6 +344,20 @@ import ZSCORE from './ZSCORE'; import ZUNION_WITHSCORES from './ZUNION_WITHSCORES'; import ZUNION from './ZUNION'; import ZUNIONSTORE from './ZUNIONSTORE'; +import VADD from './VADD'; +import VCARD from './VCARD'; +import VDIM from './VDIM'; +import VEMB from './VEMB'; +import VEMB_RAW from './VEMB_RAW'; +import VGETATTR from './VGETATTR'; +import VINFO from './VINFO'; +import VLINKS from './VLINKS'; +import VLINKS_WITHSCORES from './VLINKS_WITHSCORES'; +import VRANDMEMBER from './VRANDMEMBER'; +import VREM from './VREM'; +import VSETATTR from './VSETATTR'; +import VSIM from './VSIM'; +import VSIM_WITHSCORES from './VSIM_WITHSCORES'; export default { ACL_CAT, @@ -1037,5 +1051,33 @@ export default { ZUNION, zUnion: ZUNION, ZUNIONSTORE, - zUnionStore: ZUNIONSTORE + zUnionStore: ZUNIONSTORE, + VADD, + vAdd: VADD, + VCARD, + vCard: VCARD, + VDIM, + vDim: VDIM, + VEMB, + vEmb: VEMB, + VEMB_RAW, + vEmbRaw: VEMB_RAW, + VGETATTR, + vGetAttr: VGETATTR, + VINFO, + vInfo: VINFO, + VLINKS, + vLinks: VLINKS, + VLINKS_WITHSCORES, + vLinksWithScores: VLINKS_WITHSCORES, + VRANDMEMBER, + vRandMember: VRANDMEMBER, + VREM, + vRem: VREM, + VSETATTR, + vSetAttr: VSETATTR, + VSIM, + vSim: VSIM, + VSIM_WITHSCORES, + vSimWithScores: VSIM_WITHSCORES } as const satisfies RedisCommands; diff --git a/packages/client/lib/sentinel/index.spec.ts b/packages/client/lib/sentinel/index.spec.ts index a9bdc8cc95..ef1702eab1 100644 --- a/packages/client/lib/sentinel/index.spec.ts +++ b/packages/client/lib/sentinel/index.spec.ts @@ -23,7 +23,7 @@ describe('RedisSentinel', () => { { host: 'localhost', port: 26379 } ] }; - + it('should throw error when clientSideCache is enabled with RESP 2', () => { assert.throws( () => RedisSentinel.create({ @@ -46,7 +46,7 @@ describe('RedisSentinel', () => { }); it('should not throw when clientSideCache is enabled with RESP 3', () => { - assert.doesNotThrow(() => + assert.doesNotThrow(() => RedisSentinel.create({ ...options, clientSideCache: clientSideCacheConfig, @@ -54,6 +54,16 @@ describe('RedisSentinel', () => { }) ); }); + + testUtils.testWithClientSentinel('should successfully connect to sentinel', async () => { + }, { + ...GLOBAL.SENTINEL.OPEN, + sentinelOptions: { + RESP: 3, + clientSideCache: { ttl: 0, maxEntries: 0, evictPolicy: 'LRU'}, + }, + }) + }); }); }); @@ -417,7 +427,7 @@ async function steadyState(frame: SentinelFramework) { sentinel.setTracer(tracer); await sentinel.connect(); await nodePromise; - + await sentinel.flushAll(); } finally { if (sentinel !== undefined) { @@ -443,7 +453,7 @@ describe('legacy tests', () => { this.timeout(15000); last = Date.now(); - + function deltaMeasurer() { const delta = Date.now() - last; if (delta > longestDelta) { @@ -508,7 +518,7 @@ describe('legacy tests', () => { } stopMeasuringBlocking = true; - + await frame.cleanup(); }) @@ -1032,6 +1042,3 @@ describe('legacy tests', () => { }) }); }); - - - diff --git a/packages/client/lib/sentinel/index.ts b/packages/client/lib/sentinel/index.ts index ec570e64bf..b3f3bbf0b8 100644 --- a/packages/client/lib/sentinel/index.ts +++ b/packages/client/lib/sentinel/index.ts @@ -35,7 +35,7 @@ export class RedisSentinelClient< /** * Indicates if the client connection is open - * + * * @returns `true` if the client connection is open, `false` otherwise */ @@ -45,7 +45,7 @@ export class RedisSentinelClient< /** * Indicates if the client connection is ready to accept commands - * + * * @returns `true` if the client connection is ready, `false` otherwise */ get isReady() { @@ -54,7 +54,7 @@ export class RedisSentinelClient< /** * Gets the command options configured for this client - * + * * @returns The command options for this client or `undefined` if none were set */ get commandOptions() { @@ -241,10 +241,10 @@ export class RedisSentinelClient< /** * Releases the client lease back to the pool - * + * * After calling this method, the client instance should no longer be used as it * will be returned to the client pool and may be given to other operations. - * + * * @returns A promise that resolves when the client is ready to be reused, or undefined * if the client was immediately ready * @throws Error if the lease has already been released @@ -274,7 +274,7 @@ export default class RedisSentinel< /** * Indicates if the sentinel connection is open - * + * * @returns `true` if the sentinel connection is open, `false` otherwise */ get isOpen() { @@ -283,7 +283,7 @@ export default class RedisSentinel< /** * Indicates if the sentinel connection is ready to accept commands - * + * * @returns `true` if the sentinel connection is ready, `false` otherwise */ get isReady() { @@ -554,15 +554,15 @@ export default class RedisSentinel< /** * Acquires a master client lease for exclusive operations - * + * * Used when multiple commands need to run on an exclusive client (for example, using `WATCH/MULTI/EXEC`). * The returned client must be released after use with the `release()` method. - * + * * @returns A promise that resolves to a Redis client connected to the master node * @example * ```javascript * const clientLease = await sentinel.acquire(); - * + * * try { * await clientLease.watch('key'); * const resp = await clientLease.multi() @@ -625,6 +625,7 @@ class RedisSentinelInternal< readonly #sentinelClientOptions: RedisClientOptions; readonly #scanInterval: number; readonly #passthroughClientErrorEvents: boolean; + readonly #RESP?: RespVersions; #anotherReset = false; @@ -670,9 +671,10 @@ class RedisSentinelInternal< super(); this.#validateOptions(options); - + this.#name = options.name; + this.#RESP = options.RESP; this.#sentinelRootNodes = Array.from(options.sentinelRootNodes); this.#maxCommandRediscovers = options.maxCommandRediscovers ?? 16; this.#masterPoolSize = options.masterPoolSize ?? 1; @@ -716,6 +718,9 @@ class RedisSentinelInternal< #createClient(node: RedisNode, clientOptions: RedisClientOptions, reconnectStrategy?: undefined | false) { return RedisClient.create({ + //first take the globally set RESP + RESP: this.#RESP, + //then take the client options, which can in theory overwrite it ...clientOptions, socket: { ...clientOptions.socket, @@ -728,7 +733,7 @@ class RedisSentinelInternal< /** * Gets a client lease from the master client pool - * + * * @returns A client info object or a promise that resolves to a client info object * when a client becomes available */ @@ -743,10 +748,10 @@ class RedisSentinelInternal< /** * Releases a client lease back to the pool - * + * * If the client was used for a transaction that might have left it in a dirty state, * it will be reset before being returned to the pool. - * + * * @param clientInfo The client info object representing the client to release * @returns A promise that resolves when the client is ready to be reused, or undefined * if the client was immediately ready or no longer exists @@ -786,10 +791,10 @@ class RedisSentinelInternal< async #connect() { let count = 0; - while (true) { + while (true) { this.#trace("starting connect loop"); - count+=1; + count+=1; if (this.#destroy) { this.#trace("in #connect and want to destroy") return; @@ -842,7 +847,7 @@ class RedisSentinelInternal< try { /* - // force testing of READONLY errors + // force testing of READONLY errors if (clientInfo !== undefined) { if (Math.floor(Math.random() * 10) < 1) { console.log("throwing READONLY error"); @@ -856,7 +861,7 @@ class RedisSentinelInternal< throw err; } - /* + /* rediscover and retry if doing a command against a "master" a) READONLY error (topology has changed) but we haven't been notified yet via pubsub b) client is "not ready" (disconnected), which means topology might have changed, but sentinel might not see it yet @@ -1569,4 +1574,4 @@ export class RedisSentinelFactory extends EventEmitter { } }); } -} \ No newline at end of file +} diff --git a/packages/client/lib/sentinel/test-util.ts b/packages/client/lib/sentinel/test-util.ts index 60c1a59689..c8efa47f41 100644 --- a/packages/client/lib/sentinel/test-util.ts +++ b/packages/client/lib/sentinel/test-util.ts @@ -174,7 +174,7 @@ export class SentinelFramework extends DockerBase { this.#testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.0-M05-pre' + defaultDockerVersion: '8.2-M01-pre' }); this.#nodeMap = new Map>>>(); this.#sentinelMap = new Map>>>(); diff --git a/packages/client/lib/sentinel/utils.ts b/packages/client/lib/sentinel/utils.ts index 7e2404c2f7..c124981e25 100644 --- a/packages/client/lib/sentinel/utils.ts +++ b/packages/client/lib/sentinel/utils.ts @@ -6,7 +6,7 @@ import { NamespaceProxySentinel, NamespaceProxySentinelClient, ProxySentinel, Pr /* TODO: should use map interface, would need a transform reply probably? as resp2 is list form, which this depends on */ export function parseNode(node: Record): RedisNode | undefined{ - + if (node.flags.includes("s_down") || node.flags.includes("disconnected") || node.flags.includes("failover_in_progress")) { return undefined; } diff --git a/packages/client/lib/test-utils.ts b/packages/client/lib/test-utils.ts index 19bbafc66e..809ee788e9 100644 --- a/packages/client/lib/test-utils.ts +++ b/packages/client/lib/test-utils.ts @@ -9,7 +9,7 @@ import RedisBloomModules from '@redis/bloom'; const utils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.0-M05-pre' + defaultDockerVersion: '8.2-M01-pre' }); export default utils; diff --git a/packages/client/package.json b/packages/client/package.json index b95d1087d0..ee98d77ca1 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@redis/client", - "version": "5.5.6", + "version": "5.6.0", "license": "MIT", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/entraid/lib/test-utils.ts b/packages/entraid/lib/test-utils.ts index 11ad498f0b..3c561d4ba4 100644 --- a/packages/entraid/lib/test-utils.ts +++ b/packages/entraid/lib/test-utils.ts @@ -6,7 +6,7 @@ import { EntraidCredentialsProvider } from './entraid-credentials-provider'; export const testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.0-M05-pre' + defaultDockerVersion: '8.2-M01-pre' }); const DEBUG_MODE_ARGS = testUtils.isVersionGreaterThan([7]) ? diff --git a/packages/json/lib/commands/ARRAPPEND.ts b/packages/json/lib/commands/ARRAPPEND.ts index d1082baf48..b98d1532b4 100644 --- a/packages/json/lib/commands/ARRAPPEND.ts +++ b/packages/json/lib/commands/ARRAPPEND.ts @@ -1,5 +1,5 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; -import { RedisJSON, transformRedisJsonArgument } from './helpers'; +import { RedisJSON, transformRedisJsonArgument } from '@redis/client/dist/lib/commands/generic-transformers'; import { RedisArgument, NumberReply, ArrayReply, NullReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { diff --git a/packages/json/lib/commands/ARRINDEX.ts b/packages/json/lib/commands/ARRINDEX.ts index 69485f55a6..1437fab4d5 100644 --- a/packages/json/lib/commands/ARRINDEX.ts +++ b/packages/json/lib/commands/ARRINDEX.ts @@ -1,6 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, NumberReply, ArrayReply, NullReply, Command } from '@redis/client/dist/lib/RESP/types'; -import { RedisJSON, transformRedisJsonArgument } from './helpers'; +import { RedisJSON, transformRedisJsonArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export interface JsonArrIndexOptions { range?: { diff --git a/packages/json/lib/commands/ARRINSERT.ts b/packages/json/lib/commands/ARRINSERT.ts index 33fe30a99e..7a5ab94589 100644 --- a/packages/json/lib/commands/ARRINSERT.ts +++ b/packages/json/lib/commands/ARRINSERT.ts @@ -1,6 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, NumberReply, ArrayReply, NullReply, Command } from '@redis/client/dist/lib/RESP/types'; -import { RedisJSON, transformRedisJsonArgument } from './helpers'; +import { RedisJSON, transformRedisJsonArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export default { IS_READ_ONLY: false, diff --git a/packages/json/lib/commands/ARRPOP.ts b/packages/json/lib/commands/ARRPOP.ts index 53d9ed2dc8..88e4da9698 100644 --- a/packages/json/lib/commands/ARRPOP.ts +++ b/packages/json/lib/commands/ARRPOP.ts @@ -1,7 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, ArrayReply, NullReply, BlobStringReply, Command, UnwrapReply } from '@redis/client/dist/lib/RESP/types'; -import { isArrayReply } from '@redis/client/dist/lib/commands/generic-transformers'; -import { transformRedisJsonNullReply } from './helpers'; +import { isArrayReply, transformRedisJsonNullReply } from '@redis/client/dist/lib/commands/generic-transformers'; export interface RedisArrPopOptions { path: RedisArgument; diff --git a/packages/json/lib/commands/GET.ts b/packages/json/lib/commands/GET.ts index e514fefae3..14ec46a53a 100644 --- a/packages/json/lib/commands/GET.ts +++ b/packages/json/lib/commands/GET.ts @@ -1,7 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; -import { RedisVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; -import { transformRedisJsonNullReply } from './helpers'; +import { RedisVariadicArgument, transformRedisJsonNullReply } from '@redis/client/dist/lib/commands/generic-transformers'; export interface JsonGetOptions { path?: RedisVariadicArgument; diff --git a/packages/json/lib/commands/MERGE.ts b/packages/json/lib/commands/MERGE.ts index 72baea1048..1a4b54fc4b 100644 --- a/packages/json/lib/commands/MERGE.ts +++ b/packages/json/lib/commands/MERGE.ts @@ -1,6 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { SimpleStringReply, Command, RedisArgument } from '@redis/client/dist/lib/RESP/types'; -import { RedisJSON, transformRedisJsonArgument } from './helpers'; +import { RedisJSON, transformRedisJsonArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export default { IS_READ_ONLY: false, diff --git a/packages/json/lib/commands/MGET.ts b/packages/json/lib/commands/MGET.ts index 7bb948bc66..01a7783b92 100644 --- a/packages/json/lib/commands/MGET.ts +++ b/packages/json/lib/commands/MGET.ts @@ -1,6 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, UnwrapReply, ArrayReply, NullReply, BlobStringReply, Command } from '@redis/client/dist/lib/RESP/types'; -import { transformRedisJsonNullReply } from './helpers'; +import { transformRedisJsonNullReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { IS_READ_ONLY: true, diff --git a/packages/json/lib/commands/MSET.ts b/packages/json/lib/commands/MSET.ts index 9e5ec1799f..81e8d4c6bd 100644 --- a/packages/json/lib/commands/MSET.ts +++ b/packages/json/lib/commands/MSET.ts @@ -1,6 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, SimpleStringReply, Command } from '@redis/client/dist/lib/RESP/types'; -import { RedisJSON, transformRedisJsonArgument } from './helpers'; +import { RedisJSON, transformRedisJsonArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export interface JsonMSetItem { key: RedisArgument; diff --git a/packages/json/lib/commands/SET.ts b/packages/json/lib/commands/SET.ts index a0df41fa89..9ab680b489 100644 --- a/packages/json/lib/commands/SET.ts +++ b/packages/json/lib/commands/SET.ts @@ -1,6 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, SimpleStringReply, NullReply, Command } from '@redis/client/dist/lib/RESP/types'; -import { RedisJSON, transformRedisJsonArgument } from './helpers'; +import { RedisJSON, transformRedisJsonArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export interface JsonSetOptions { condition?: 'NX' | 'XX'; diff --git a/packages/json/lib/commands/STRAPPEND.ts b/packages/json/lib/commands/STRAPPEND.ts index aa8f3772fb..b3115f684c 100644 --- a/packages/json/lib/commands/STRAPPEND.ts +++ b/packages/json/lib/commands/STRAPPEND.ts @@ -1,6 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, Command, NullReply, NumberReply, ArrayReply } from '@redis/client/dist/lib/RESP/types'; -import { transformRedisJsonArgument } from './helpers'; +import { transformRedisJsonArgument } from '@redis/client/dist/lib/commands/generic-transformers'; export interface JsonStrAppendOptions { path?: RedisArgument; diff --git a/packages/json/lib/commands/helpers.ts b/packages/json/lib/commands/helpers.ts deleted file mode 100644 index 99579ce81c..0000000000 --- a/packages/json/lib/commands/helpers.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { isNullReply } from "@redis/client/dist/lib/commands/generic-transformers"; -import { BlobStringReply, NullReply, UnwrapReply } from "@redis/client/dist/lib/RESP/types"; - -export function transformRedisJsonNullReply(json: NullReply | BlobStringReply): NullReply | RedisJSON { - return isNullReply(json) ? json : transformRedisJsonReply(json); -} - -export type RedisJSON = null | boolean | number | string | Date | Array | { - [key: string]: RedisJSON; - [key: number]: RedisJSON; -}; - -export function transformRedisJsonArgument(json: RedisJSON): string { - return JSON.stringify(json); -} - -export function transformRedisJsonReply(json: BlobStringReply): RedisJSON { - const res = JSON.parse((json as unknown as UnwrapReply).toString()); - return res; -} diff --git a/packages/json/lib/commands/index.ts b/packages/json/lib/commands/index.ts index a9e16bde75..0e29bdd648 100644 --- a/packages/json/lib/commands/index.ts +++ b/packages/json/lib/commands/index.ts @@ -23,7 +23,9 @@ import STRLEN from './STRLEN'; import TOGGLE from './TOGGLE'; import TYPE from './TYPE'; -export * from './helpers'; +// Re-export helper types and functions from client package +export type { RedisJSON } from '@redis/client/dist/lib/commands/generic-transformers'; +export { transformRedisJsonArgument, transformRedisJsonReply, transformRedisJsonNullReply } from '@redis/client/dist/lib/commands/generic-transformers'; export default { ARRAPPEND, diff --git a/packages/json/lib/test-utils.ts b/packages/json/lib/test-utils.ts index 9894b2d039..6b6859d61b 100644 --- a/packages/json/lib/test-utils.ts +++ b/packages/json/lib/test-utils.ts @@ -4,7 +4,7 @@ import RedisJSON from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.0-M05-pre' + defaultDockerVersion: '8.2-M01-pre' }); export const GLOBAL = { diff --git a/packages/json/package.json b/packages/json/package.json index 0d77a3bc6b..edfdaec8ef 100644 --- a/packages/json/package.json +++ b/packages/json/package.json @@ -1,6 +1,6 @@ { "name": "@redis/json", - "version": "5.5.6", + "version": "5.6.0", "license": "MIT", "main": "./dist/lib/index.js", "types": "./dist/lib/index.d.ts", @@ -13,7 +13,7 @@ "release": "release-it" }, "peerDependencies": { - "@redis/client": "^5.5.6" + "@redis/client": "^5.6.0" }, "devDependencies": { "@redis/test-utils": "*" diff --git a/packages/redis/package.json b/packages/redis/package.json index 889898b3c2..bf5ea798e7 100644 --- a/packages/redis/package.json +++ b/packages/redis/package.json @@ -1,7 +1,7 @@ { "name": "redis", "description": "A modern, high performance Redis client", - "version": "5.1.1", + "version": "5.5.6", "license": "MIT", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -13,11 +13,11 @@ "release": "release-it" }, "dependencies": { - "@redis/bloom": "5.1.1", - "@redis/client": "5.1.1", - "@redis/json": "5.1.1", - "@redis/search": "5.1.1", - "@redis/time-series": "5.1.1" + "@redis/bloom": "5.5.6", + "@redis/client": "5.5.6", + "@redis/json": "5.5.6", + "@redis/search": "5.5.6", + "@redis/time-series": "5.5.6" }, "engines": { "node": ">= 18" diff --git a/packages/search/lib/test-utils.ts b/packages/search/lib/test-utils.ts index 7264b1b6b1..a2b9c816da 100644 --- a/packages/search/lib/test-utils.ts +++ b/packages/search/lib/test-utils.ts @@ -5,7 +5,7 @@ import { RespVersions } from '@redis/client'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.0-M05-pre' + defaultDockerVersion: '8.2-M01-pre' }); export const GLOBAL = { diff --git a/packages/search/package.json b/packages/search/package.json index 6f7af2e0a2..30edac8003 100644 --- a/packages/search/package.json +++ b/packages/search/package.json @@ -1,6 +1,6 @@ { "name": "@redis/search", - "version": "5.1.1", + "version": "5.5.6", "license": "MIT", "main": "./dist/lib/index.js", "types": "./dist/lib/index.d.ts", @@ -14,7 +14,7 @@ "release": "release-it" }, "peerDependencies": { - "@redis/client": "^5.1.1" + "@redis/client": "^5.5.6" }, "devDependencies": { "@redis/test-utils": "*" diff --git a/packages/test-utils/lib/index.ts b/packages/test-utils/lib/index.ts index a41f970e0c..aab1c700f5 100644 --- a/packages/test-utils/lib/index.ts +++ b/packages/test-utils/lib/index.ts @@ -179,7 +179,7 @@ export default class TestUtils { this.#VERSION_NUMBERS = numbers; this.#DOCKER_IMAGE = { image: dockerImageName, - version: string, + version: string, mode: "server" }; } @@ -315,7 +315,7 @@ export default class TestUtils { if (passIndex != 0) { password = options.serverArguments[passIndex]; } - + if (this.isVersionGreaterThan(options.minimumDockerVersion)) { const dockerImage = this.#DOCKER_IMAGE; before(function () { @@ -333,17 +333,19 @@ export default class TestUtils { const promises = await dockerPromises; const rootNodes: Array = promises.map(promise => ({ - host: "127.0.0.1", + host: "127.0.0.1", port: promise.port })); + const sentinel = createSentinel({ - name: 'mymaster', - sentinelRootNodes: rootNodes, - nodeClientOptions: { + name: 'mymaster', + sentinelRootNodes: rootNodes, + nodeClientOptions: { + commandOptions: options.clientOptions?.commandOptions, password: password || undefined, }, - sentinelClientOptions: { + sentinelClientOptions: { password: password || undefined, }, replicaPoolSize: options?.replicaPoolSize || 0, @@ -352,6 +354,7 @@ export default class TestUtils { functions: options?.functions || {}, masterPoolSize: options?.masterPoolSize || undefined, reserveClient: options?.reserveClient || false, + ...options?.sentinelOptions }) as RedisSentinelType; if (options.disableClientSetup) { @@ -505,7 +508,7 @@ export default class TestUtils { it(title, async function () { if (!dockersPromise) return this.skip(); - + const dockers = await dockersPromise, cluster = createCluster({ rootNodes: dockers.map(({ port }) => ({ @@ -578,12 +581,12 @@ export default class TestUtils { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), appPrefix)); sentinels.push(await spawnSentinelNode(this.#DOCKER_IMAGE, options.serverArguments, masterPort, sentinelName, tmpDir)) - + if (tmpDir) { fs.rmSync(tmpDir, { recursive: true }); } } - + return sentinels } } diff --git a/packages/time-series/lib/test-utils.ts b/packages/time-series/lib/test-utils.ts index 0f25341e34..8a664ee8df 100644 --- a/packages/time-series/lib/test-utils.ts +++ b/packages/time-series/lib/test-utils.ts @@ -4,7 +4,7 @@ import TimeSeries from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.0-M05-pre' + defaultDockerVersion: '8.2-M01-pre' }); export const GLOBAL = { diff --git a/packages/time-series/package.json b/packages/time-series/package.json index 4d5d8f01be..f90bc8f5f9 100644 --- a/packages/time-series/package.json +++ b/packages/time-series/package.json @@ -1,6 +1,6 @@ { "name": "@redis/time-series", - "version": "5.1.1", + "version": "5.5.6", "license": "MIT", "main": "./dist/lib/index.js", "types": "./dist/lib/index.d.ts", @@ -13,7 +13,7 @@ "release": "release-it" }, "peerDependencies": { - "@redis/client": "^5.1.1" + "@redis/client": "^5.5.6" }, "devDependencies": { "@redis/test-utils": "*"