From 46e5efff65f2cddbd93dc5be6aae27689404e1ee Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Sat, 27 Jun 2026 12:17:59 +0300 Subject: [PATCH 1/4] Add proxy inheritance for clients --- packages/client/lib/client/index.spec.ts | 30 +++++++++++++++++ packages/client/lib/client/index.ts | 22 +++++++------ packages/client/lib/client/pool.spec.ts | 39 +++++++++++++++++++++++ packages/client/lib/client/pool.ts | 20 ++++++++---- packages/client/lib/cluster/index.spec.ts | 37 +++++++++++++++++++++ packages/client/lib/cluster/index.ts | 25 +++++++++------ packages/client/lib/commander.ts | 8 ++++- packages/client/lib/sentinel/types.ts | 16 +++++++--- packages/client/lib/sentinel/utils.ts | 16 +++++----- 9 files changed, 175 insertions(+), 38 deletions(-) diff --git a/packages/client/lib/client/index.spec.ts b/packages/client/lib/client/index.spec.ts index 9af9b9a3bef..44050576f3a 100644 --- a/packages/client/lib/client/index.spec.ts +++ b/packages/client/lib/client/index.spec.ts @@ -864,6 +864,36 @@ describe('Client', () => { } } }); + + testUtils.testWithClient('proxies respect RedisClient command options', async client => { + + const TIMEOUT = 1234; + (client as any)._commandOptions = { timeout: TIMEOUT }; + + const bufferProxy = client.withCommandOptions({ + typeMapping: { [RESP_TYPES.BLOB_STRING]: Buffer } + }); + + const stringReply = await client.module.echo('hi'); + const bufferReply = await bufferProxy.module.echo('hi'); + + + assert.ok((bufferReply as unknown) instanceof Buffer, 'Proxy failed to return Buffer.'); + assert.strictEqual(typeof stringReply, 'string', 'Original client was corrupted.'); + assert.equal(bufferReply.toString(), stringReply); + + const proxyOptions = (bufferProxy.module as any)._commandOptions; + assert.equal(proxyOptions.timeout, TIMEOUT, 'Inherited options (timeout) were lost in the proxy chain.') + + assert.ok(!Object.prototype.hasOwnProperty.call(proxyOptions, 'timeout'), 'Timeout should be inherited, not copied.'); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + modules: { + module + } + } + }) testUtils.testWithClient('duplicate should reuse command options', async client => { const duplicate = client.duplicate(); diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index bac6efa0770..46d333a6b29 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -272,7 +272,9 @@ export type RedisClientType< type ProxyClient = RedisClient; -type NamespaceProxyClient = { _self: ProxyClient }; +type NamespaceProxyClient = { + _self: ProxyClient + _commandOptions?:CommandOptions }; export interface ScanIteratorOptions { cursor?: RedisArgument; @@ -305,7 +307,7 @@ export default class RedisClient< const parser = new BasicCommandParser(); command.parseCommand(parser, ...args); - return this._self._executeCommand(command, parser, this._self._commandOptions, transformReply); + return this._self._executeCommand(command, parser, this._commandOptions, transformReply); }; } @@ -318,7 +320,7 @@ export default class RedisClient< parser.push(...prefix); fn.parseCommand(parser, ...args); - return this._self._executeCommand(fn, parser, this._self._commandOptions, transformReply); + return this._self._executeCommand(fn, parser, this._commandOptions, transformReply); }; } @@ -1038,7 +1040,9 @@ export default class RedisClient< TYPE_MAPPING extends TypeMapping >(options: OPTIONS) { const proxy = Object.create(this._self); - proxy._commandOptions = { ...this._commandOptions, ...options }; + proxy._commandOptions = Object.assign( + Object.create(this._commandOptions ?? null),options + ); return proxy as RedisClientType< M, F, @@ -1056,7 +1060,8 @@ export default class RedisClient< value: V ) { const proxy = Object.create(this._self); - proxy._commandOptions = { ...this._commandOptions, [key]: value }; + proxy._commandOptions = Object.assign(Object.create( + this._commandOptions ?? null),{ [key]: value }); return proxy as RedisClientType< M, F, @@ -1261,10 +1266,9 @@ export default class RedisClient< } // Merge global options with provided options - const opts = { - ...this._commandOptions, - ...options, - }; + const opts = options? + Object.assign(Object.create(this._commandOptions ?? null), + options): this._commandOptions; const promise = this._self.#queue.addCommand(args, opts); this._self.#scheduleWrite(); diff --git a/packages/client/lib/client/pool.spec.ts b/packages/client/lib/client/pool.spec.ts index fa19504785d..6de47177910 100644 --- a/packages/client/lib/client/pool.spec.ts +++ b/packages/client/lib/client/pool.spec.ts @@ -201,6 +201,45 @@ describe('RedisClientPool', () => { await task1Promise; }, GLOBAL.SERVERS.OPEN); + testUtils.testWithClientPool(' proxy inheritance completes', async pool => { + const TIMEOUT = 1234; + + (pool as any)._commandOptions = { timeout: TIMEOUT }; + + const bufferProxy = pool.withCommandOptions({ + typeMapping: { + [RESP_TYPES.BLOB_STRING]: Buffer + } + }); + + const stringReply = await pool.sendCommand(['ECHO', 'hello']); + assert.equal(typeof stringReply, 'string', 'Base pool should return a string'); + + const bufferReply = await bufferProxy.sendCommand(['ECHO', 'hello']); + assert.ok(bufferReply instanceof Buffer, 'Proxy should return a Buffer'); + assert.equal(bufferReply.toString(), 'hello'); + + const proxyOptions = (bufferProxy as any)._commandOptions; + + assert.equal( + proxyOptions.timeout, + TIMEOUT, + 'Proxy should inherit timeout from base pool' + ); + + assert.equal( + Object.prototype.hasOwnProperty.call(proxyOptions, 'timeout'), + false, + 'Timeout should be inherited via prototype chain, not copied' + ); + + assert.equal( + Object.prototype.hasOwnProperty.call(proxyOptions, 'typeMapping'), + true, + 'TypeMapping should be a direct property of the proxy options' + ); + }, GLOBAL.SERVERS.OPEN); + testUtils.testWithClientPool('execute rejects when pool is closed', async pool => { await pool.close(); diff --git a/packages/client/lib/client/pool.ts b/packages/client/lib/client/pool.ts index 0602187ee33..0c5d525ba86 100644 --- a/packages/client/lib/client/pool.ts +++ b/packages/client/lib/client/pool.ts @@ -106,7 +106,9 @@ export type RedisClientPoolType< // eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance markers for pool generics type ProxyPool = RedisClientPoolType; -type NamespaceProxyPool = { _self: ProxyPool }; +type NamespaceProxyPool = { + _self: ProxyPool + _commandOptions?: CommandOptions }; export class RedisClientPool< M extends RedisModules = {}, @@ -122,7 +124,7 @@ export class RedisClientPool< const parser = new BasicCommandParser(); command.parseCommand(parser, ...args); - return this.execute(client => client._executeCommand(command, parser, this._commandOptions, transformReply)) + return this._self.execute(client => client._executeCommand(command, parser, this._commandOptions, transformReply)) }; } @@ -133,7 +135,7 @@ export class RedisClientPool< const parser = new BasicCommandParser(); command.parseCommand(parser, ...args); - return this._self.execute(client => client._executeCommand(command, parser, this._self._commandOptions, transformReply)) + return this._self.execute(client => client._executeCommand(command, parser, this._commandOptions, transformReply)) }; } @@ -146,7 +148,7 @@ export class RedisClientPool< parser.push(...prefix); fn.parseCommand(parser, ...args); - return this._self.execute(client => client._executeCommand(fn, parser, this._self._commandOptions, transformReply)) }; + return this._self.execute(client => client._executeCommand(fn, parser, this._commandOptions, transformReply)) }; } static #createScriptCommand(script: RedisScript, resp: RespVersions) { @@ -158,7 +160,7 @@ export class RedisClientPool< parser.pushVariadic(prefix); script.parseCommand(parser, ...args); - return this.execute(client => client._executeScript(script, parser, this._commandOptions, transformReply)) + return this._self.execute(client => client._executeScript(script, parser, this._commandOptions, transformReply)) }; } @@ -336,7 +338,10 @@ export class RedisClientPool< TYPE_MAPPING extends TypeMapping >(options: OPTIONS) { const proxy = Object.create(this._self); - proxy._commandOptions = options; + proxy._commandOptions = Object.assign( + Object.create(this._commandOptions ?? null), + options + ); return proxy as RedisClientPoolType< M, F, @@ -358,7 +363,8 @@ export class RedisClientPool< value: V ) { const proxy = Object.create(this._self); - proxy._commandOptions = { ...this._commandOptions, [key]: value }; + proxy._commandOptions = Object.assign( Object.create( + this._commandOptions ?? null), {[key]: value }); return proxy as RedisClientPoolType< M, F, diff --git a/packages/client/lib/cluster/index.spec.ts b/packages/client/lib/cluster/index.spec.ts index 0360d7d66cb..401602529cc 100644 --- a/packages/client/lib/cluster/index.spec.ts +++ b/packages/client/lib/cluster/index.spec.ts @@ -284,6 +284,43 @@ describe('Cluster', () => { minimizeConnections: true } }); + + testUtils.testWithCluster('proxy inheritance completes',async cluster => { + const CUSTOM_MAPPING = { + [RESP_TYPES.BLOB_STRING]: Buffer + }; + + (cluster as any)._commandOptions = { + timeout: 5000 + }; + + const proxy = cluster.withTypeMapping(CUSTOM_MAPPING); + const proxyOptions = (proxy as any)._commandOptions; + + assert.equal(proxyOptions.timeout, 5000); + + assert.deepEqual( + proxyOptions.typeMapping, + CUSTOM_MAPPING + ); + + assert.equal( + Object.prototype.hasOwnProperty.call(proxyOptions, 'timeout'), + false + ); + + assert.equal( + Object.prototype.hasOwnProperty.call(proxyOptions, 'typeMapping'), + true + ); + + const reply = await proxy.echo('hello'); + + assert.ok(reply instanceof Buffer); + assert.deepEqual(reply, Buffer.from('hello')); + }, + GLOBAL.CLUSTERS.OPEN +); }); describe('PubSub', () => { diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 8035b0a7f3f..4239f51b00c 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -151,7 +151,9 @@ export type ClusterCommandOptions< type ProxyCluster = RedisCluster; -type NamespaceProxyCluster = { _self: ProxyCluster }; +type NamespaceProxyCluster = { + _self: ProxyCluster + _commandOptions?:ClusterCommandOptions }; export default class RedisCluster< M extends RedisModules, @@ -187,7 +189,7 @@ export default class RedisCluster< return this._self._execute( parser.firstKey, command.IS_READ_ONLY, - this._self._commandOptions, + this._commandOptions, (client, opts) => client._executeCommand(command, parser, opts, transformReply) ); }; @@ -205,7 +207,7 @@ export default class RedisCluster< return this._self._execute( parser.firstKey, fn.IS_READ_ONLY, - this._self._commandOptions, + this._commandOptions, (client, opts) => client._executeCommand(fn, parser, opts, transformReply) ); }; @@ -377,7 +379,11 @@ export default class RedisCluster< // POLICIES extends CommandPolicies >(options: OPTIONS) { const proxy = Object.create(this); - proxy._commandOptions = options; + proxy._commandOptions = Object.assign( + Object.create(this._commandOptions ?? null), + options + ); + return proxy as RedisClusterType< M, F, @@ -396,7 +402,8 @@ export default class RedisCluster< value: V ) { const proxy = Object.create(this); - proxy._commandOptions = { ...this._commandOptions, [key]: value }; + proxy._commandOptions = Object.assign( Object.create( + this._commandOptions ?? null),{ [key]: value }); return proxy as RedisClusterType< M, F, @@ -531,10 +538,10 @@ export default class RedisCluster< ): Promise { // Merge global options with local options - const opts = { - ...this._commandOptions, - ...options - } + const opts = options ? + Object.assign(Object.create(this._commandOptions ?? null), + options):(this._commandOptions ?? {}); + return this._self._execute( firstKey, isReadonly, diff --git a/packages/client/lib/commander.ts b/packages/client/lib/commander.ts index b5ab085edaa..ea68edaf3b4 100644 --- a/packages/client/lib/commander.ts +++ b/packages/client/lib/commander.ts @@ -98,7 +98,13 @@ function attachNamespace(prototype: any, name: PropertyKey, fns: any) { if (value === undefined) { value = Object.create(fns); value._self = this; - perReceiver.set(name, value); + + Object.defineProperty(value,'_commandOptions',{ + get(){return this._self._commandOptions ?? null}, + enumerable:true, + configurable:false + }) + perReceiver.set(name,value); } return value; } diff --git a/packages/client/lib/sentinel/types.ts b/packages/client/lib/sentinel/types.ts index 1831ba3f790..10ce9e9170f 100644 --- a/packages/client/lib/sentinel/types.ts +++ b/packages/client/lib/sentinel/types.ts @@ -214,11 +214,19 @@ export interface SentinelCommandOptions< > extends CommandOptions {} // eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance markers for sentinel generics -export type ProxySentinel = RedisSentinel; +export type ProxySentinel = RedisSentinel&{ + _commandOptions?:SentinelCommandOptions; +}; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance markers for sentinel generics -export type ProxySentinelClient = RedisSentinelClient; -export type NamespaceProxySentinel = { _self: ProxySentinel }; -export type NamespaceProxySentinelClient = { _self: ProxySentinelClient }; +export type ProxySentinelClient = RedisSentinelClient&{ + _commandOptions?:SentinelCommandOptions; +}; +export type NamespaceProxySentinel = { + _self: ProxySentinel + _commandOptions?: CommandOptions }; +export type NamespaceProxySentinelClient = { + _self: ProxySentinelClient + _commandOptions?: CommandOptions}; export type NodeInfo = { ip: string, diff --git a/packages/client/lib/sentinel/utils.ts b/packages/client/lib/sentinel/utils.ts index ab475919398..dc8398c557b 100644 --- a/packages/client/lib/sentinel/utils.ts +++ b/packages/client/lib/sentinel/utils.ts @@ -70,9 +70,9 @@ export function createCommand(com const parser = new BasicCommandParser(); command.parseCommand(parser, ...args); - return this._self._execute( + return (this as any)._execute( command.IS_READ_ONLY, - client => client._executeCommand(command, parser, this.commandOptions, transformReply) + (client:any) => client._executeCommand(command, parser, (this as any)._commandOptions, transformReply) ); }; } @@ -86,9 +86,9 @@ export function createFunctionCommand client._executeCommand(fn, parser, this._self.commandOptions, transformReply) + (client:any) => client._executeCommand(fn, parser, this._commandOptions, transformReply) ); } }; @@ -100,9 +100,9 @@ export function createModuleCommand client._executeCommand(command, parser, this._self.commandOptions, transformReply) + (client:any) => client._executeCommand(command, parser, this._commandOptions, transformReply) ); } }; @@ -116,9 +116,9 @@ export function createScriptCommand client._executeScript(script, parser, this.commandOptions, transformReply) + (client:any) => client._executeScript(script, parser, (this as any)._commandOptions, transformReply) ); }; } From 183c970e0f5798bfbb828ed285c42fdcd3a7539f Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Sat, 27 Jun 2026 12:38:44 +0300 Subject: [PATCH 2/4] Add proxy for sendCommand --- packages/client/lib/client/pool.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/client/lib/client/pool.ts b/packages/client/lib/client/pool.ts index 0c5d525ba86..250004ace49 100644 --- a/packages/client/lib/client/pool.ts +++ b/packages/client/lib/client/pool.ts @@ -540,7 +540,9 @@ export class RedisClientPool< args: Array, options?: CommandOptions ) { - const mergedOptions = { ...this._commandOptions, ...options }; + const mergedOptions = options + ? Object.assign(Object.create(this._commandOptions ?? null), options) + : this._commandOptions; return this.execute(client => client.sendCommand(args, mergedOptions)); } From c2224212f29f7169a1932740651f76ff4603a885 Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Sat, 27 Jun 2026 14:02:27 +0300 Subject: [PATCH 3/4] Preserve inheritance in duplicated clients --- packages/client/lib/client/index.spec.ts | 16 +++++++++++++ packages/client/lib/client/index.ts | 15 +++++++++++- packages/client/lib/client/pool.spec.ts | 30 ++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/client/lib/client/index.spec.ts b/packages/client/lib/client/index.spec.ts index 44050576f3a..f82313a9d88 100644 --- a/packages/client/lib/client/index.spec.ts +++ b/packages/client/lib/client/index.spec.ts @@ -886,6 +886,22 @@ describe('Client', () => { assert.equal(proxyOptions.timeout, TIMEOUT, 'Inherited options (timeout) were lost in the proxy chain.') assert.ok(!Object.prototype.hasOwnProperty.call(proxyOptions, 'timeout'), 'Timeout should be inherited, not copied.'); + + const duplicate = bufferProxy.duplicate(); + + const duplicateOptions = (duplicate as any)._commandOptions; + + assert.equal( + duplicateOptions.timeout, + TIMEOUT, + 'duplicate() lost inherited timeout.' + ); + + assert.deepEqual( + duplicateOptions.typeMapping, + { [RESP_TYPES.BLOB_STRING]: Buffer }, + 'duplicate() lost typeMapping.' +); }, { ...GLOBAL.SERVERS.OPEN, clientOptions: { diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index 46d333a6b29..6d799210d8f 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -337,6 +337,19 @@ export default class RedisClient< } } + static #flattenCommandOptions(options?: CommandOptions) { + if (!options) return options; + + const flattened = {}; + const chain = []; + + for (let current = options; current; current = Object.getPrototypeOf(current)) { + chain.unshift(current); + } + + return Object.assign(flattened, ...chain); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any static #SingleEntryCache = new SingleEntryCache() @@ -1120,7 +1133,7 @@ export default class RedisClient< >(overrides?: Partial>) { return new (Object.getPrototypeOf(this).constructor)({ ...this._self.#options, - commandOptions: this._commandOptions, + commandOptions: RedisClient.#flattenCommandOptions(this._commandOptions), ...overrides }) as RedisClientType<_M, _F, _S, _RESP, _TYPE_MAPPING>; } diff --git a/packages/client/lib/client/pool.spec.ts b/packages/client/lib/client/pool.spec.ts index 6de47177910..6ede45f98e8 100644 --- a/packages/client/lib/client/pool.spec.ts +++ b/packages/client/lib/client/pool.spec.ts @@ -240,6 +240,36 @@ describe('RedisClientPool', () => { ); }, GLOBAL.SERVERS.OPEN); + testUtils.testWithClientPool('nested proxy inheritance',async pool=>{ + const TIMEOUT = 1234; + + const timeoutProxy = pool.withCommandOptions({ + timeout: TIMEOUT + }); + + const chainedProxy = timeoutProxy.withTypeMapping({ + [RESP_TYPES.BLOB_STRING]: Buffer + }); + + const chainedReply = await chainedProxy.sendCommand(['ECHO', 'hello']); + + assert.ok(chainedReply instanceof Buffer); + assert.equal(chainedReply.toString(), 'hello'); + + const chainedOptions = (chainedProxy as any)._commandOptions; + + assert.equal(chainedOptions.timeout, TIMEOUT); + assert.equal( + Object.prototype.hasOwnProperty.call(chainedOptions, 'timeout'), + false +); + +assert.equal( + Object.prototype.hasOwnProperty.call(chainedOptions, 'typeMapping'), + true +); + },GLOBAL.SERVERS.OPEN) + testUtils.testWithClientPool('execute rejects when pool is closed', async pool => { await pool.close(); From c2de21ebde4ba122db05a343b70efb2068df04d8 Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Sat, 27 Jun 2026 17:12:32 +0300 Subject: [PATCH 4/4] Nested cluster prototype inheritance --- packages/client/lib/cluster/index.spec.ts | 112 +++++++++++++++------- packages/client/lib/cluster/index.ts | 27 +++++- 2 files changed, 99 insertions(+), 40 deletions(-) diff --git a/packages/client/lib/cluster/index.spec.ts b/packages/client/lib/cluster/index.spec.ts index 401602529cc..036b2d2cc83 100644 --- a/packages/client/lib/cluster/index.spec.ts +++ b/packages/client/lib/cluster/index.spec.ts @@ -285,42 +285,82 @@ describe('Cluster', () => { } }); - testUtils.testWithCluster('proxy inheritance completes',async cluster => { - const CUSTOM_MAPPING = { - [RESP_TYPES.BLOB_STRING]: Buffer - }; - - (cluster as any)._commandOptions = { - timeout: 5000 - }; - - const proxy = cluster.withTypeMapping(CUSTOM_MAPPING); - const proxyOptions = (proxy as any)._commandOptions; - - assert.equal(proxyOptions.timeout, 5000); - - assert.deepEqual( - proxyOptions.typeMapping, - CUSTOM_MAPPING - ); - - assert.equal( - Object.prototype.hasOwnProperty.call(proxyOptions, 'timeout'), - false - ); - - assert.equal( - Object.prototype.hasOwnProperty.call(proxyOptions, 'typeMapping'), - true - ); - - const reply = await proxy.echo('hello'); - - assert.ok(reply instanceof Buffer); - assert.deepEqual(reply, Buffer.from('hello')); - }, - GLOBAL.CLUSTERS.OPEN -); + testUtils.testWithCluster('proxy inheritance completes cluster routing chains', async cluster => { + const CUSTOM_MAPPING = { + [RESP_TYPES.BLOB_STRING]: Buffer + }; + + // 1. Set global base option + (cluster as any)._commandOptions = { + timeout: 5000, + asap: false + }; + + // 2. Chain Proxy Layer 1: Add type mapping + const proxy1 = cluster.withTypeMapping(CUSTOM_MAPPING); + + // 3. Chain Proxy Layer 2: Override an intermediate property + // AND add a localized abort signal or different option. + // This creates a 3-tier deep prototype chain. + const controller = new AbortController(); + const proxy2 = proxy1.withCommandOptions({ + asap: true, + signal: controller.signal + }); + + const proxyOptions = (proxy2 as any)._commandOptions; + + // --- Structural Assertions --- + assert.equal(proxyOptions.timeout, 5000, 'Root level options (timeout) were lost in the chain'); + assert.equal(proxyOptions.asap, true, 'Intermediate overrides (asap) failed to apply'); + assert.deepEqual(proxyOptions.typeMapping, CUSTOM_MAPPING, 'Nested object mappings were lost'); + + assert.equal(Object.prototype.hasOwnProperty.call(proxyOptions, 'signal'), true, 'Signal should belong to layer 2'); + assert.equal(Object.prototype.hasOwnProperty.call(proxyOptions, 'asap'), true, 'Asap override should belong to layer 2'); + assert.equal(Object.prototype.hasOwnProperty.call(proxyOptions, 'typeMapping'), false, 'TypeMapping should be inherited from layer 1'); + assert.equal(Object.prototype.hasOwnProperty.call(proxyOptions, 'timeout'), false, 'Timeout should be inherited from the base cluster root'); + + // ------------------------------------------------------------------ + // Execution diagnostics + // ------------------------------------------------------------------ + + // Base cluster should NOT use the custom type mapping. + const baseReply = await cluster.echo('hello'); + assert.equal( + typeof baseReply, + 'string', + 'Base cluster unexpectedly inherited custom typeMapping' + ); + + // Layer 1 owns the typeMapping. + + const proxy1Reply = await proxy1.echo('hello'); + assert.ok( + proxy1Reply instanceof Buffer, + 'Layer 1 execution stripped typeMapping' + ); + assert.deepEqual( + proxy1Reply, + Buffer.from('hello'), + 'Layer 1 payload was mutated' + ); + + // Layer 2 inherits typeMapping through the prototype chain. + const proxy2Reply = await proxy2.echo('hello'); + assert.ok( + (proxy2Reply as any) instanceof Buffer, + 'Cluster execution stripped typeMapping from deep proxy chain' + ); + assert.deepEqual( + proxy2Reply, + Buffer.from('hello'), + 'Data payload was mutated during cluster execution proxying' + ); + + controller.abort(); + }, + GLOBAL.CLUSTERS.OPEN + ); }); describe('PubSub', () => { diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 4239f51b00c..94d9b713fde 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -230,6 +230,18 @@ export default class RedisCluster< ); }; } + static #flattenCommandOptions(options?: CommandOptions) { + if (!options) return options; + + const flattened = {}; + const chain = []; + + for (let current = options; current; current = Object.getPrototypeOf(current)) { + chain.unshift(current); + } + + return Object.assign(flattened, ...chain); +} // eslint-disable-next-line @typescript-eslint/no-explicit-any -- cache stores dynamically generated cluster subclasses static #SingleEntryCache = new SingleEntryCache(); @@ -363,7 +375,7 @@ export default class RedisCluster< >(overrides?: Partial>) { return new (Object.getPrototypeOf(this).constructor)({ ...this._self._options, - commandOptions: this._commandOptions, + commandOptions: RedisCluster.#flattenCommandOptions(this._commandOptions), ...overrides }) as RedisClusterType<_M, _F, _S, _RESP, _TYPE_MAPPING>; } @@ -434,7 +446,9 @@ export default class RedisCluster< ) { return async (client: RedisClientType, options?: ClusterCommandOptions) => { const chainId = Symbol("asking chain"); - const opts = options ? {...options} : {}; + const opts = Object.assign( + Object.create(options ?? null), + {}); opts.chainId = chainId; @@ -464,8 +478,13 @@ export default class RedisCluster< while (true) { try { - const opts: ClusterCommandOptions = { ...options, slotNumber }; - return await myFn(client, opts); + const opts: ClusterCommandOptions = Object.assign( + Object.create(options ?? null), + { slotNumber } + ); + const executionClient = Object.create(client); + executionClient._commandOptions = opts; + return await myFn(executionClient, opts); } catch (_err) { const err = _err as Error; myFn = fn;