Sitelet https://github.com/redis/node-redis/pull/3295/commits/91631bb2e4cbc4e55c9f1671a7ac6f7b459c3a18
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(wrappers): propagate proxy command options through lease, clone, …
…and chaining

`withCommandOptions(...)` / `withTypeMapping(...)` proxies were silently
dropping their overrides in three related ways:

- Sentinel `use()`, `acquire()`, and `duplicate()` handed the leased or
  duplicated client `this._self.#commandOptions` (the constructor base)
  instead of the proxy's effective options.
- `_commandOptionsProxy` in client/pool/cluster built `_commandOptions`
  via `Object.create(this._commandOptions ?? null)`, leaving earlier keys
  on the prototype where the dispatch-time spread skipped them. Sentinel
  had a similar shape — layering over `#commandOptions` instead of the
  current effective options, which dropped prior proxy overrides on the
  second call. Switched all four to flat `{ ...effective, [key]: value }`.
- Pool's helper was `#commandOptionsProxy` (true JS private), forcing
  every caller to use `this._self.#commandOptionsProxy(...)` — which
  discarded the proxy's overrides because `_self` resolves to the
  original. Demoted to `private _commandOptionsProxy` so it inherits
  via the prototype chain and is callable on proxies.

Added regression tests across sentinel, client, pool, and cluster: unit
tests for chained-override preservation and `duplicate()` propagation;
integration tests for `use()`/`acquire()` and behavioral dispatch of
`withTypeMapping`/`withCommandOptions` through both raw `sendCommand`
and typed commands. Cleaned up `as any` HOTKEYS casts in the cluster
spec while it was a changed file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  • Loading branch information
nkaradzhov and claude committed May 28, 2026
commit 91631bb2e4cbc4e55c9f1671a7ac6f7b459c3a18
48 changes: 48 additions & 0 deletions packages/client/lib/client/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ export const SQUARE_SCRIPT = defineScript({
});

describe('Client', () => {
it('chained withCommandOptions(...).withTypeMapping(...) preserves earlier overrides at dispatch', () => {
// Regression: `_commandOptionsProxy` used to layer `_commandOptions` via
// `Object.create(this._commandOptions ?? null)`, which left earlier keys
// (e.g. `asap`) on the prototype. At dispatch, `{...this._commandOptions, ...}`
// only iterates *own* enumerable properties, so those inherited keys
// silently disappeared in the spread.
const client = RedisClient.create({});
const proxy = client
.withCommandOptions({ asap: true })
.withTypeMapping({ [RESP_TYPES.SIMPLE_STRING]: Buffer });
type WithOptions = { _commandOptions?: { asap?: boolean; typeMapping?: unknown } };
const ownKeys = { ...(proxy as unknown as WithOptions)._commandOptions };
assert.equal(ownKeys.asap, true);
assert.deepEqual(ownKeys.typeMapping, { [RESP_TYPES.SIMPLE_STRING]: Buffer });
});

it('module/function namespaces resolve to the receiver, not the original', () => {
// Regression: `attachNamespace` cached the namespace as an own property
// on the receiver, leaking via the prototype chain into any
Expand Down Expand Up @@ -1340,6 +1356,38 @@ describe('Client', () => {
}, GLOBAL.SERVERS.OPEN);
});

describe('withCommandOptions / withTypeMapping dispatch', () => {
testUtils.testWithClient('withTypeMapping override reaches raw sendCommand', async client => {
// Regression for `client/index.ts:1253` (`this._self._commandOptions` →
// `this._commandOptions`): without this fix, the proxy's `withTypeMapping`
// override was silently ignored at `sendCommand` dispatch.
const typed = client.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});
const resp = await typed.sendCommand(['PING']);
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClient('withTypeMapping override reaches typed commands', async client => {
const typed = client.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});
const resp = await typed.ping();
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClient('withCommandOptions full override reaches typed commands', async client => {
// The `withCommandOptions` (full replace) path went through the same
// proxy-dispatch fix; covered separately from `withTypeMapping` because
// the two helpers store overrides differently on the proxy.
const proxy = client.withCommandOptions({
typeMapping: { [RESP_TYPES.SIMPLE_STRING]: Buffer }
});
const resp = await proxy.ping();
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.SERVERS.OPEN);
});

describe("socket errors during handshake", () => {

it("should successfully connect when server accepts connection immediately", async () => {
Expand Down
3 changes: 1 addition & 2 deletions packages/client/lib/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1043,8 +1043,7 @@ export default class RedisClient<
value: V
) {
const proxy = Object.create(this._self);
proxy._commandOptions = Object.create(this._commandOptions ?? null);
proxy._commandOptions[key] = value;
proxy._commandOptions = { ...this._commandOptions, [key]: value };
return proxy as RedisClientType<
M,
F,
Expand Down
55 changes: 55 additions & 0 deletions packages/client/lib/client/pool.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import { RESP_TYPES } from '../RESP/decoder';
import { RedisClientPool } from './pool';

describe('RedisClientPool', () => {
it('chained withCommandOptions(...).withTypeMapping(...) preserves earlier overrides at dispatch', () => {
// Regression: pool's `_commandOptionsProxy` had two related bugs.
// First, it built `_commandOptions` via `Object.create(...)`, leaving earlier
// keys on the prototype where the dispatch-time spread silently dropped them.
// Second, `withTypeMapping`/`withAbortSignal`/`asap` called the helper via
// `this._self.#commandOptionsProxy(...)`, so even the prototype chain was
// discarded — the helper saw the original pool's `_commandOptions`, not the
// prior proxy's.
const pool = RedisClientPool.create({});
const proxy = pool
.withCommandOptions({ asap: true })
.withTypeMapping({ [RESP_TYPES.SIMPLE_STRING]: Buffer });
type WithOptions = { _commandOptions?: { asap?: boolean; typeMapping?: unknown } };
const ownKeys = { ...(proxy as unknown as WithOptions)._commandOptions };
assert.equal(ownKeys.asap, true);
assert.deepEqual(ownKeys.typeMapping, { [RESP_TYPES.SIMPLE_STRING]: Buffer });
});

it('initializes _commandOptions from clientOptions.commandOptions', () => {
// Regression: when constructor commandOptions weren't propagated to the pool's own
// _commandOptions, the typeMapping equality check in client._executeCommand
Expand Down Expand Up @@ -33,6 +52,42 @@ describe('RedisClientPool', () => {
);
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClientPool('withTypeMapping override reaches raw sendCommand', async pool => {
// Regression for `pool.ts:534-535`: pool.sendCommand now merges its own
// `_commandOptions` (which a `withCommandOptions` proxy overrides) before
// dispatching to the leased client.
const typed = pool.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});
const resp = await typed.sendCommand(['PING']);
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClientPool('withTypeMapping override reaches typed commands', async pool => {
const typed = pool.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});
const resp = await typed.ping();
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClientPool('constructor commandOptions reach sendCommand without an explicit proxy', async pool => {
// The stated motivation for storing `_commandOptions` on the pool at
// construction was that the typeMapping needs to reach dispatch — the
// earlier internal-shape test only proved the property is stored.
const resp = await pool.sendCommand(['PING']);
assert.deepEqual(resp, Buffer.from('PONG'));
}, {
...GLOBAL.SERVERS.OPEN,
clientOptions: {
commandOptions: {
typeMapping: {
[RESP_TYPES.SIMPLE_STRING]: Buffer
}
}
}
});

testUtils.testWithClientPool('multi sendCommand', async pool => {
assert.deepEqual(
await pool.multi()
Expand Down
15 changes: 9 additions & 6 deletions packages/client/lib/client/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,16 +346,19 @@ export class RedisClientPool<
>;
}

#commandOptionsProxy<
// Plain (not `#`) method so it can be invoked on prototype-derived proxies
// returned by `withCommandOptions(...)` — JS private (`#`) methods aren't
// accessible through the prototype chain, which would force the helper to
// be called via `this._self`, discarding any prior proxy overrides.
private _commandOptionsProxy<
K extends keyof CommandOptions,
V extends CommandOptions[K]
>(
key: K,
value: V
) {
const proxy = Object.create(this._self);
proxy._commandOptions = Object.create(this._commandOptions ?? null);
proxy._commandOptions[key] = value;
proxy._commandOptions = { ...this._commandOptions, [key]: value };
return proxy as RedisClientPoolType<
M,
F,
Expand All @@ -369,22 +372,22 @@ export class RedisClientPool<
* Override the `typeMapping` command option
*/
withTypeMapping<TYPE_MAPPING extends TypeMapping>(typeMapping: TYPE_MAPPING) {
return this._self.#commandOptionsProxy('typeMapping', typeMapping);
return this._commandOptionsProxy('typeMapping', typeMapping);
}

/**
* Override the `abortSignal` command option
*/
withAbortSignal(abortSignal: AbortSignal) {
return this._self.#commandOptionsProxy('abortSignal', abortSignal);
return this._commandOptionsProxy('abortSignal', abortSignal);
}

/**
* Override the `asap` command option to `true`
* TODO: remove?
*/
asap() {
return this._self.#commandOptionsProxy('asap', true);
return this._commandOptionsProxy('asap', true);
}

async connect() {
Expand Down
51 changes: 42 additions & 9 deletions packages/client/lib/cluster/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,33 @@ import { SQUARE_SCRIPT } from '../client/index.spec';
import { RootNodesUnavailableError } from '../errors';
import { spy } from 'sinon';
import RedisClient from '../client';
import { RESP_TYPES } from '../RESP/decoder';

describe('Cluster', () => {
it('chained withCommandOptions(...).withTypeMapping(...) preserves earlier overrides at dispatch', () => {
// Regression: cluster's `_commandOptionsProxy` used to layer via `Object.create`,
// leaving earlier keys on the prototype where the dispatch-time spread dropped them.
const cluster = RedisCluster.create({ rootNodes: [] });
const proxy = cluster
.withCommandOptions({ asap: true })
.withTypeMapping({ [RESP_TYPES.SIMPLE_STRING]: Buffer });
type WithOptions = { _commandOptions?: { asap?: boolean; typeMapping?: unknown } };
const ownKeys = { ...(proxy as unknown as WithOptions)._commandOptions };
assert.equal(ownKeys.asap, true);
assert.deepEqual(ownKeys.typeMapping, { [RESP_TYPES.SIMPLE_STRING]: Buffer });
});

it('should not have HOTKEYS commands (requires session affinity)', () => {
// HOTKEYS commands require session affinity and are only available on standalone clients
const cluster = RedisCluster.create({ rootNodes: [] });
assert.equal((cluster as any).hotkeysStart, undefined);
assert.equal((cluster as any).hotkeysStop, undefined);
assert.equal((cluster as any).hotkeysGet, undefined);
assert.equal((cluster as any).hotkeysReset, undefined);
assert.equal((cluster as any).HOTKEYS_START, undefined);
assert.equal((cluster as any).HOTKEYS_STOP, undefined);
assert.equal((cluster as any).HOTKEYS_GET, undefined);
assert.equal((cluster as any).HOTKEYS_RESET, undefined);
const cluster = RedisCluster.create({ rootNodes: [] }) as unknown as Record<string, unknown>;
assert.equal(cluster.hotkeysStart, undefined);
assert.equal(cluster.hotkeysStop, undefined);
assert.equal(cluster.hotkeysGet, undefined);
assert.equal(cluster.hotkeysReset, undefined);
assert.equal(cluster.HOTKEYS_START, undefined);
assert.equal(cluster.HOTKEYS_STOP, undefined);
assert.equal(cluster.HOTKEYS_GET, undefined);
assert.equal(cluster.HOTKEYS_RESET, undefined);
});

testUtils.testWithCluster('sendCommand', async cluster => {
Expand All @@ -27,6 +41,25 @@ describe('Cluster', () => {
);
}, GLOBAL.CLUSTERS.OPEN);

testUtils.testWithCluster('withTypeMapping override reaches raw sendCommand', async cluster => {
// Regression for `cluster/index.ts:538` (`this._self._commandOptions` →
// `this._commandOptions`): without this fix, `withTypeMapping`/`withCommandOptions`
// proxies were silently ignored at cluster dispatch.
const typed = cluster.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});
const resp = await typed.sendCommand(undefined, true, ['PING']);
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.CLUSTERS.OPEN);

testUtils.testWithCluster('withTypeMapping override reaches typed commands', async cluster => {
const typed = cluster.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});
const resp = await typed.ping();
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.CLUSTERS.OPEN);

testUtils.testWithCluster('isOpen', async cluster => {
assert.equal(cluster.isOpen, true);
await cluster.destroy();
Expand Down
3 changes: 1 addition & 2 deletions packages/client/lib/cluster/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,7 @@ export default class RedisCluster<
value: V
) {
const proxy = Object.create(this);
proxy._commandOptions = Object.create(this._commandOptions ?? null);
proxy._commandOptions[key] = value;
proxy._commandOptions = { ...this._commandOptions, [key]: value };
return proxy as RedisClusterType<
M,
F,
Expand Down
101 changes: 101 additions & 0 deletions packages/client/lib/sentinel/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,51 @@ describe('RedisSentinel', () => {
assert.equal(proxy.commandOptions?.timeout, overrideTimeout);
});

it('chained withCommandOptions(...).withTypeMapping(...) preserves earlier overrides', () => {
// Regression: `_commandOptionsProxy` used to layer over `this._self.#commandOptions`
// (the constructor base) instead of `this.commandOptions` (the effective options),
// so any prior `withCommandOptions` override was silently dropped on the second call.
const sentinel = RedisSentinel.create({
name: 'mymaster',
sentinelRootNodes: [{ host: 'localhost', port: 26379 }]
});
const overrideTypeMapping = { [RESP_TYPES.SIMPLE_STRING]: Buffer };
const proxy = sentinel
.withCommandOptions({ asap: true })
.withTypeMapping(overrideTypeMapping);
assert.equal(proxy.commandOptions?.asap, true);
assert.equal(proxy.commandOptions?.typeMapping, overrideTypeMapping);
});

it('chained withTypeMapping(...).withTypeMapping(...) keeps the latest override', () => {
// Sanity: `_commandOptionsProxy` builds from the prior effective options, so a
// later `withTypeMapping` should still win for the same key.
const initial = { [RESP_TYPES.SIMPLE_STRING]: Buffer };
const sentinel = RedisSentinel.create({
name: 'mymaster',
sentinelRootNodes: [{ host: 'localhost', port: 26379 }],
commandOptions: { typeMapping: initial }
});
const second = { [RESP_TYPES.SIMPLE_STRING]: String };
const proxy = sentinel
.withTypeMapping({ [RESP_TYPES.SIMPLE_STRING]: Buffer })
.withTypeMapping(second);
assert.equal(proxy.commandOptions?.typeMapping, second);
});

it('duplicate() on a withCommandOptions proxy carries the override into the new sentinel', () => {
// Regression: `duplicate()` used to read `this._self.#commandOptions` directly,
// so any proxy override created via `withCommandOptions(...)` was dropped.
const overrideTimeout = 99999;
const sentinel = RedisSentinel.create({
name: 'mymaster',
sentinelRootNodes: [{ host: 'localhost', port: 26379 }]
});
const proxy = sentinel.withCommandOptions({ timeout: overrideTimeout });
const duplicated = proxy.duplicate();
assert.equal(duplicated.commandOptions?.timeout, overrideTimeout);
});

it('should not have HOTKEYS commands (requires session affinity)', () => {
// HOTKEYS commands require session affinity and are only available on standalone clients
const sentinel = RedisSentinel.create({
Expand Down Expand Up @@ -200,6 +245,62 @@ describe('RedisSentinel', () => {
assert.deepEqual(resp, Buffer.from('PONG'));
}, testOptions);

testUtils.testWithClientSentinel('withTypeMapping override flows through use() to the leased client', async sentinel => {
// Regression: `use()` used to pass `this._self.#commandOptions` (constructor base)
// to `RedisSentinelClient.create`, so any `withTypeMapping`/`withCommandOptions`
// proxy override was dropped before the leased client ever saw it.
const typeMapped = sentinel.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});

await typeMapped.use(async client => {
const resp = await client.ping();
assert.deepEqual(resp, Buffer.from('PONG'));
});
}, testOptions);

testUtils.testWithClientSentinel('withTypeMapping override flows through acquire() to the leased client', async sentinel => {
// Regression: same as above, but for `acquire()` which returns the leased
// client to the caller instead of passing it to a callback.
const typeMapped = sentinel.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});

const client = await typeMapped.acquire();
try {
const resp = await client.ping();
assert.deepEqual(resp, Buffer.from('PONG'));
} finally {
client.release();
}
}, testOptions);

testUtils.testWithClientSentinel('RedisSentinelClient.withTypeMapping override reaches dispatch', async sentinel => {
// T2 / parity: every other proxy-options regression test hits top-level
// `RedisSentinel`. The same getter/merge/dispatch fixes live on
// `RedisSentinelClient` and need direct coverage.
await sentinel.use(async client => {
const typeMapped = client.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});
const resp = await typeMapped.ping();
assert.deepEqual(resp, Buffer.from('PONG'));
});
}, testOptions);

testUtils.testWithClientSentinel('RedisSentinelClient chained withCommandOptions(...).withTypeMapping(...) preserves earlier overrides', async sentinel => {
// B2 parity: the leased client's `_commandOptionsProxy` had the same
// chained-override bug as the top-level sentinel.
await sentinel.use(async client => {
const proxy = client
.withCommandOptions({ asap: true })
.withTypeMapping({ [RESP_TYPES.SIMPLE_STRING]: Buffer });
assert.equal(proxy.commandOptions?.asap, true);
const resp = await proxy.ping();
assert.deepEqual(resp, Buffer.from('PONG'));
});
}, testOptions);

testUtils.testWithClientSentinel('many readers', async sentinel => {
await sentinel.set("x", 1);
for (let i = 0; i < 10; i++) {
Expand Down
Loading
Loading