From 92e3f344cc7f7b5c0773cb07e5b01acc09fa45f3 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 15 Apr 2026 15:54:42 +0300 Subject: [PATCH 01/28] test(resp2-resp3): expand coverage and consolidate spec cleanup - Add RESP2 vs RESP3 structural assertions across command specs. - Consolidate duplicate, renamed, and flaky test cases in one pass. - Keep this commit spec-only to isolate test intent. --- .../bloom/lib/commands/bloom/EXISTS.spec.ts | 9 +++ .../bloom/lib/commands/bloom/INFO.spec.ts | 21 +++++++ .../bloom/lib/commands/bloom/MEXISTS.spec.ts | 11 ++++ .../commands/count-min-sketch/INFO.spec.ts | 2 +- .../bloom/lib/commands/cuckoo/ADDNX.spec.ts | 8 +++ .../bloom/lib/commands/cuckoo/DEL.spec.ts | 9 +++ .../bloom/lib/commands/cuckoo/EXISTS.spec.ts | 9 +++ .../bloom/lib/commands/cuckoo/INFO.spec.ts | 28 ++++++++++ .../lib/commands/cuckoo/INSERTNX.spec.ts | 2 +- .../lib/commands/t-digest/BYRANK.spec.ts | 15 +++++ .../lib/commands/t-digest/BYREVRANK.spec.ts | 15 +++++ .../bloom/lib/commands/t-digest/CDF.spec.ts | 12 ++++ .../bloom/lib/commands/t-digest/INFO.spec.ts | 37 ++++++++++++ .../bloom/lib/commands/t-digest/MAX.spec.ts | 8 +++ .../bloom/lib/commands/t-digest/MIN.spec.ts | 10 ++++ .../lib/commands/t-digest/QUANTILE.spec.ts | 17 ++++++ .../commands/t-digest/TRIMMED_MEAN.spec.ts | 11 ++++ .../bloom/lib/commands/top-k/INFO.spec.ts | 17 ++++++ packages/client/lib/RESP/decoder.spec.ts | 10 ++-- packages/client/lib/client/index.spec.ts | 56 +++++++++++++------ .../client/lib/client/legacy-mode.spec.ts | 6 +- .../client/lib/cluster/cluster-slots.spec.ts | 9 ++- .../client/lib/commands/ACL_GETUSER.spec.ts | 30 ++++++++++ .../client/lib/commands/BGREWRITEAOF.spec.ts | 13 ++++- packages/client/lib/commands/DUMP.spec.ts | 11 ++++ .../lib/commands/FUNCTION_STATS.spec.ts | 25 +++++++++ packages/client/lib/commands/GEOHASH.spec.ts | 18 ++++++ .../GEORADIUSBYMEMBER_RO_WITH.spec.ts | 6 +- .../commands/GEORADIUSBYMEMBER_WITH.spec.ts | 6 +- .../lib/commands/GEORADIUS_RO_WITH.spec.ts | 23 +++++++- .../lib/commands/GEORADIUS_WITH.spec.ts | 6 +- .../lib/commands/GEOSEARCH_WITH.spec.ts | 6 +- packages/client/lib/commands/GET.spec.ts | 11 ++++ packages/client/lib/commands/HDEL.spec.ts | 16 ++++++ packages/client/lib/commands/HELLO.spec.ts | 2 +- packages/client/lib/commands/HGETALL.spec.ts | 4 +- packages/client/lib/commands/HKEYS.spec.ts | 14 +++++ packages/client/lib/commands/HLEN.spec.ts | 11 ++++ .../client/lib/commands/HOTKEYS_GET.spec.ts | 1 - packages/client/lib/commands/HVALS.spec.ts | 19 +++++++ packages/client/lib/commands/INFO.spec.ts | 19 +++++++ .../lib/commands/LATENCY_HISTOGRAM.spec.ts | 26 +++++++++ .../client/lib/commands/LATENCY_RESET.spec.ts | 21 +++++-- packages/client/lib/commands/LCS.spec.ts | 15 +++++ .../client/lib/commands/MEMORY_STATS.spec.ts | 3 +- .../client/lib/commands/MODULE_LIST.spec.ts | 28 ++++++++++ packages/client/lib/commands/PFCOUNT.spec.ts | 12 ++++ .../client/lib/commands/PUBSUB_NUMSUB.spec.ts | 4 +- .../lib/commands/PUBSUB_SHARDNUMSUB.spec.ts | 2 +- .../client/lib/commands/RANDOMKEY.spec.ts | 9 +++ packages/client/lib/commands/SCARD.spec.ts | 11 ++++ packages/client/lib/commands/SDIFF.spec.ts | 13 +++++ packages/client/lib/commands/SINTER.spec.ts | 12 ++++ .../client/lib/commands/SINTERSTORE.spec.ts | 16 ++++++ packages/client/lib/commands/SMEMBERS.spec.ts | 11 ++++ packages/client/lib/commands/SREM.spec.ts | 11 ++++ packages/client/lib/commands/SUNION.spec.ts | 12 ++++ .../client/lib/commands/SUNIONSTORE.spec.ts | 13 +++++ packages/client/lib/commands/VADD.spec.ts | 4 +- packages/client/lib/commands/VEMB.spec.ts | 14 ----- packages/client/lib/commands/VINFO.spec.ts | 49 +++++++--------- packages/client/lib/commands/VLINKS.spec.ts | 15 ----- packages/client/lib/commands/VSETATTR.spec.ts | 20 ------- .../client/lib/commands/XAUTOCLAIM.spec.ts | 2 +- packages/client/lib/commands/XCLAIM.spec.ts | 6 +- .../client/lib/commands/XINFO_STREAM.spec.ts | 2 +- packages/client/lib/commands/XRANGE.spec.ts | 4 +- packages/client/lib/commands/XREAD.spec.ts | 30 ++-------- .../client/lib/commands/XREADGROUP.spec.ts | 6 +- .../client/lib/commands/XREVRANGE.spec.ts | 4 +- packages/client/lib/commands/ZMSCORE.spec.ts | 14 +++++ .../lib/commands/ZREMRANGEBYLEX.spec.ts | 17 ++++++ .../lib/commands/ZREMRANGEBYRANK.spec.ts | 15 +++++ .../lib/commands/ZREMRANGEBYSCORE.spec.ts | 15 +++++ packages/client/lib/commands/ZSCORE.spec.ts | 11 ++++ .../lib/commands/generic-transformers.spec.ts | 12 ++-- packages/client/lib/sentinel/index.spec.ts | 9 ++- packages/json/lib/commands/GET.spec.ts | 18 ++++++ packages/json/lib/commands/TYPE.spec.ts | 21 +++++++ .../search/lib/commands/AGGREGATE.spec.ts | 31 +++++++++- .../lib/commands/AGGREGATE_WITHCURSOR.spec.ts | 16 ++++++ .../search/lib/commands/CONFIG_GET.spec.ts | 2 +- packages/search/lib/commands/HYBRID.spec.ts | 31 ++++++++++ packages/search/lib/commands/INFO.spec.ts | 8 +-- .../lib/commands/PROFILE_AGGREGATE.spec.ts | 3 +- .../lib/commands/PROFILE_SEARCH.spec.ts | 19 +++++++ packages/search/lib/commands/SEARCH.spec.ts | 24 +++++++- .../lib/commands/SEARCH_NOCONTENT.spec.ts | 19 +++++++ packages/search/lib/commands/SYNDUMP.spec.ts | 21 +++++++ packages/search/lib/commands/TAGVALS.spec.ts | 23 ++++++++ packages/search/lib/commands/_LIST.spec.ts | 16 ++++++ .../time-series/lib/commands/INFO.spec.ts | 1 + .../lib/commands/INFO_DEBUG.spec.ts | 23 ++++++++ .../time-series/lib/commands/MGET.spec.ts | 2 +- .../lib/commands/MGET_SELECTED_LABELS.spec.ts | 6 +- .../lib/commands/MGET_WITHLABELS.spec.ts | 29 +++++++++- .../time-series/lib/commands/MRANGE.spec.ts | 31 +++++++++- .../lib/commands/MRANGE_GROUPBY.spec.ts | 28 +++++++++- .../commands/MRANGE_SELECTED_LABELS.spec.ts | 45 ++++++++++++++- .../MRANGE_SELECTED_LABELS_GROUPBY.spec.ts | 33 ++++++++++- .../lib/commands/MRANGE_WITHLABELS.spec.ts | 28 +++++++++- .../MRANGE_WITHLABELS_GROUPBY.spec.ts | 4 +- .../lib/commands/MREVRANGE.spec.ts | 31 +++++++++- .../lib/commands/MREVRANGE_GROUPBY.spec.ts | 23 +++++++- .../MREVRANGE_SELECTED_LABELS.spec.ts | 31 +++++++++- .../MREVRANGE_SELECTED_LABELS_GROUPBY.spec.ts | 30 +++++++++- .../lib/commands/MREVRANGE_WITHLABELS.spec.ts | 28 +++++++++- .../MREVRANGE_WITHLABELS_GROUPBY.spec.ts | 4 +- 108 files changed, 1431 insertions(+), 229 deletions(-) diff --git a/packages/bloom/lib/commands/bloom/EXISTS.spec.ts b/packages/bloom/lib/commands/bloom/EXISTS.spec.ts index 4d2cc70074a..d8f8f926455 100644 --- a/packages/bloom/lib/commands/bloom/EXISTS.spec.ts +++ b/packages/bloom/lib/commands/bloom/EXISTS.spec.ts @@ -17,4 +17,13 @@ describe('BF.EXISTS', () => { false ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.bf.exists with existing item', async client => { + await client.bf.add('key', 'item'); + + assert.strictEqual( + await client.bf.exists('key', 'item'), + true + ); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/bloom/INFO.spec.ts b/packages/bloom/lib/commands/bloom/INFO.spec.ts index 0dbe5cb1f43..9d2f00014f0 100644 --- a/packages/bloom/lib/commands/bloom/INFO.spec.ts +++ b/packages/bloom/lib/commands/bloom/INFO.spec.ts @@ -24,4 +24,25 @@ describe('BF.INFO', () => { assert.equal(typeof reply['Number of items inserted'], 'number'); assert.equal(typeof reply['Expansion rate'], 'number'); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.bf.info - structural shape assertion', async client => { + await client.bf.reserve('key', 0.01, 100); + const reply = await client.bf.info('key'); + + // Assert the exact RESP2 response structure (object with specific keys) + // This would break if RESP3 returns a different shape + assert.ok(reply !== null && typeof reply === 'object'); + assert.ok(!Array.isArray(reply)); + assert.ok(!(reply instanceof Map)); + assert.ok('Capacity' in reply); + assert.ok('Size' in reply); + assert.ok('Number of filters' in reply); + assert.ok('Number of items inserted' in reply); + assert.ok('Expansion rate' in reply); + assert.equal(reply['Capacity'], 100); + assert.equal(typeof reply['Size'], 'number'); + assert.equal(typeof reply['Number of filters'], 'number'); + assert.equal(typeof reply['Number of items inserted'], 'number'); + assert.equal(typeof reply['Expansion rate'], 'number'); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/bloom/MEXISTS.spec.ts b/packages/bloom/lib/commands/bloom/MEXISTS.spec.ts index 60c09b00f17..ba7b2ec3921 100644 --- a/packages/bloom/lib/commands/bloom/MEXISTS.spec.ts +++ b/packages/bloom/lib/commands/bloom/MEXISTS.spec.ts @@ -17,4 +17,15 @@ describe('BF.MEXISTS', () => { [false, false] ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.bf.mExists with existing items', async client => { + const key = 'mExistsKey'; + await client.bf.add(key, 'item1'); + await client.bf.add(key, 'item2'); + + assert.deepEqual( + await client.bf.mExists(key, ['item1', 'item2', 'item3']), + [true, true, false] + ); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/count-min-sketch/INFO.spec.ts b/packages/bloom/lib/commands/count-min-sketch/INFO.spec.ts index cbc8065016a..53727c7f628 100644 --- a/packages/bloom/lib/commands/count-min-sketch/INFO.spec.ts +++ b/packages/bloom/lib/commands/count-min-sketch/INFO.spec.ts @@ -19,7 +19,7 @@ describe('CMS.INFO', () => { client.cms.info('key') ]); - const expected = Object.create(null); + const expected = {}; expected['width'] = width; expected['depth'] = depth; expected['count'] = 0; diff --git a/packages/bloom/lib/commands/cuckoo/ADDNX.spec.ts b/packages/bloom/lib/commands/cuckoo/ADDNX.spec.ts index c142733ce40..417fa6d4b7f 100644 --- a/packages/bloom/lib/commands/cuckoo/ADDNX.spec.ts +++ b/packages/bloom/lib/commands/cuckoo/ADDNX.spec.ts @@ -17,4 +17,12 @@ describe('CF.ADDNX', () => { true ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.cf.addNX returns false when item already exists', async client => { + await client.cf.addNX('key', 'item'); + assert.equal( + await client.cf.addNX('key', 'item'), + false + ); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/cuckoo/DEL.spec.ts b/packages/bloom/lib/commands/cuckoo/DEL.spec.ts index 41ed653bfc9..fc3f51292c5 100644 --- a/packages/bloom/lib/commands/cuckoo/DEL.spec.ts +++ b/packages/bloom/lib/commands/cuckoo/DEL.spec.ts @@ -19,4 +19,13 @@ describe('CF.DEL', () => { assert.equal(reply, false); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.cf.del with existing item', async client => { + await client.cf.reserve('key', 4); + await client.cf.add('key', 'item'); + + const reply = await client.cf.del('key', 'item'); + + assert.equal(reply, true); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/cuckoo/EXISTS.spec.ts b/packages/bloom/lib/commands/cuckoo/EXISTS.spec.ts index f77a9d69eff..8c2ec0a1c75 100644 --- a/packages/bloom/lib/commands/cuckoo/EXISTS.spec.ts +++ b/packages/bloom/lib/commands/cuckoo/EXISTS.spec.ts @@ -17,4 +17,13 @@ describe('CF.EXISTS', () => { false ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.cf.exists with existing item', async client => { + await client.cf.reserve('key', 100); + await client.cf.add('key', 'item'); + assert.equal( + await client.cf.exists('key', 'item'), + true + ); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/cuckoo/INFO.spec.ts b/packages/bloom/lib/commands/cuckoo/INFO.spec.ts index c5503ed113b..a4d4cf5f96b 100644 --- a/packages/bloom/lib/commands/cuckoo/INFO.spec.ts +++ b/packages/bloom/lib/commands/cuckoo/INFO.spec.ts @@ -27,4 +27,32 @@ describe('CF.INFO', () => { assert.equal(typeof reply['Expansion rate'], 'number'); assert.equal(typeof reply['Max iterations'], 'number'); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.cf.info returns object structure', async client => { + await client.cf.reserve('key', 4); + const reply = await client.cf.info('key'); + + // Structural assertion: response must be a plain object (not an array) + assert.ok(!Array.isArray(reply), 'reply should not be an array'); + assert.equal(typeof reply, 'object'); + + // Assert exact structure with all expected keys + const expectedKeys = [ + 'Size', + 'Number of buckets', + 'Number of filters', + 'Number of items inserted', + 'Number of items deleted', + 'Bucket size', + 'Expansion rate', + 'Max iterations' + ]; + + assert.deepEqual(Object.keys(reply).sort(), expectedKeys.sort()); + + // Assert all values are numbers + for (const key of expectedKeys) { + assert.equal(typeof reply[key], 'number', `${key} should be a number`); + } + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/cuckoo/INSERTNX.spec.ts b/packages/bloom/lib/commands/cuckoo/INSERTNX.spec.ts index 648d9be7ac8..7094226809f 100644 --- a/packages/bloom/lib/commands/cuckoo/INSERTNX.spec.ts +++ b/packages/bloom/lib/commands/cuckoo/INSERTNX.spec.ts @@ -17,7 +17,7 @@ describe('CF.INSERTNX', () => { testUtils.testWithClient('client.cf.insertnx', async client => { assert.deepEqual( await client.cf.insertNX('key', 'item'), - [true] + [1] ); }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/t-digest/BYRANK.spec.ts b/packages/bloom/lib/commands/t-digest/BYRANK.spec.ts index 81a2c75dff5..e9b533f0b2b 100644 --- a/packages/bloom/lib/commands/t-digest/BYRANK.spec.ts +++ b/packages/bloom/lib/commands/t-digest/BYRANK.spec.ts @@ -19,4 +19,19 @@ describe('TDIGEST.BYRANK', () => { assert.deepEqual(reply, [NaN]); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.tDigest.byRank with data', async client => { + await client.tDigest.create('key'); + await client.tDigest.add('key', [1, 2, 3, 4, 5]); + + const reply = await client.tDigest.byRank('key', [0, 2, 4]); + + assert.ok(Array.isArray(reply)); + assert.equal(reply.length, 3); + assert.equal(typeof reply[0], 'number'); + assert.equal(typeof reply[1], 'number'); + assert.equal(typeof reply[2], 'number'); + assert.ok(reply[0] <= reply[1]); + assert.ok(reply[1] <= reply[2]); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/t-digest/BYREVRANK.spec.ts b/packages/bloom/lib/commands/t-digest/BYREVRANK.spec.ts index c8f794bef57..31f05d14139 100644 --- a/packages/bloom/lib/commands/t-digest/BYREVRANK.spec.ts +++ b/packages/bloom/lib/commands/t-digest/BYREVRANK.spec.ts @@ -19,4 +19,19 @@ describe('TDIGEST.BYREVRANK', () => { assert.deepEqual(reply, [NaN]); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.tDigest.byRevRank with data', async client => { + await client.tDigest.create('key'); + await client.tDigest.add('key', [1, 2, 3, 4, 5]); + + const reply = await client.tDigest.byRevRank('key', [0, 2, 4]); + + assert.ok(Array.isArray(reply)); + assert.equal(reply.length, 3); + assert.equal(typeof reply[0], 'number'); + assert.equal(typeof reply[1], 'number'); + assert.equal(typeof reply[2], 'number'); + assert.ok(reply[0] >= reply[1]); + assert.ok(reply[1] >= reply[2]); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/t-digest/CDF.spec.ts b/packages/bloom/lib/commands/t-digest/CDF.spec.ts index 2689bf2fc9a..bfd5ff081ec 100644 --- a/packages/bloom/lib/commands/t-digest/CDF.spec.ts +++ b/packages/bloom/lib/commands/t-digest/CDF.spec.ts @@ -19,4 +19,16 @@ describe('TDIGEST.CDF', () => { assert.deepEqual(reply, [NaN]); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.tDigest.cdf with data', async client => { + await client.tDigest.create('key'); + await client.tDigest.add('key', [1, 2, 3, 4, 5]); + + const reply = await client.tDigest.cdf('key', [2, 4]); + + assert.ok(Array.isArray(reply)); + assert.equal(reply.length, 2); + assert.equal(typeof reply[0], 'number'); + assert.equal(typeof reply[1], 'number'); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/t-digest/INFO.spec.ts b/packages/bloom/lib/commands/t-digest/INFO.spec.ts index d5b8b3e13ed..febf3842cb6 100644 --- a/packages/bloom/lib/commands/t-digest/INFO.spec.ts +++ b/packages/bloom/lib/commands/t-digest/INFO.spec.ts @@ -28,4 +28,41 @@ describe('TDIGEST.INFO', () => { assert(typeof reply['Total compressions'], 'number'); assert(typeof reply['Memory usage'], 'number'); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.tDigest.info structural response shape', async client => { + await client.tDigest.create('key', { COMPRESSION: 100 }); + await client.tDigest.add('key', [1, 2, 3]); + + const reply = await client.tDigest.info('key'); + + // Assert exact structure to catch RESP2 (Array) vs RESP3 (Map) differences + assert.ok(reply !== null && typeof reply === 'object'); + assert.ok(!Array.isArray(reply)); // Should be object, not array + assert.deepEqual(Object.keys(reply).sort(), [ + 'Capacity', + 'Compression', + 'Memory usage', + 'Merged nodes', + 'Merged weight', + 'Observations', + 'Total compressions', + 'Unmerged nodes', + 'Unmerged weight' + ].sort()); + + // Verify all values are numbers + assert.strictEqual(typeof reply['Compression'], 'number'); + assert.strictEqual(typeof reply['Capacity'], 'number'); + assert.strictEqual(typeof reply['Merged nodes'], 'number'); + assert.strictEqual(typeof reply['Unmerged nodes'], 'number'); + assert.strictEqual(typeof reply['Merged weight'], 'number'); + assert.strictEqual(typeof reply['Unmerged weight'], 'number'); + assert.strictEqual(typeof reply['Observations'], 'number'); + assert.strictEqual(typeof reply['Total compressions'], 'number'); + assert.strictEqual(typeof reply['Memory usage'], 'number'); + + // Verify expected values based on setup + assert.strictEqual(reply['Compression'], 100); + assert.strictEqual(reply['Observations'], 3); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/t-digest/MAX.spec.ts b/packages/bloom/lib/commands/t-digest/MAX.spec.ts index 920c9d11391..3367d7596c6 100644 --- a/packages/bloom/lib/commands/t-digest/MAX.spec.ts +++ b/packages/bloom/lib/commands/t-digest/MAX.spec.ts @@ -19,4 +19,12 @@ describe('TDIGEST.MAX', () => { assert.deepEqual(reply, NaN); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.tDigest.max with data', async client => { + await client.tDigest.create('key'); + await client.tDigest.add('key', [1, 2, 3, 4, 5]); + const reply = await client.tDigest.max('key'); + + assert.equal(reply, 5); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/t-digest/MIN.spec.ts b/packages/bloom/lib/commands/t-digest/MIN.spec.ts index 278248ea465..429761bae5f 100644 --- a/packages/bloom/lib/commands/t-digest/MIN.spec.ts +++ b/packages/bloom/lib/commands/t-digest/MIN.spec.ts @@ -19,4 +19,14 @@ describe('TDIGEST.MIN', () => { assert.equal(reply, NaN); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.tDigest.min with data', async client => { + await client.tDigest.create('key'); + await client.tDigest.add('key', [1, 2, 3, 4, 5]); + + const reply = await client.tDigest.min('key'); + + assert.equal(typeof reply, 'number'); + assert.equal(reply, 1); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/t-digest/QUANTILE.spec.ts b/packages/bloom/lib/commands/t-digest/QUANTILE.spec.ts index ac7249d12d9..9738a983a8b 100644 --- a/packages/bloom/lib/commands/t-digest/QUANTILE.spec.ts +++ b/packages/bloom/lib/commands/t-digest/QUANTILE.spec.ts @@ -22,4 +22,21 @@ describe('TDIGEST.QUANTILE', () => { [NaN] ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.tDigest.quantile with values', async client => { + await client.tDigest.create('key'); + await client.tDigest.add('key', [1, 2, 3, 4, 5]); + + const reply = await client.tDigest.quantile('key', [0, 0.5, 1]); + + assert.ok(Array.isArray(reply)); + assert.equal(reply.length, 3); + assert.equal(typeof reply[0], 'number'); + assert.equal(typeof reply[1], 'number'); + assert.equal(typeof reply[2], 'number'); + // Verify approximate quantile values + assert.ok(reply[0] >= 1 && reply[0] <= 1.5); // min + assert.ok(reply[1] >= 2.5 && reply[1] <= 3.5); // median + assert.ok(reply[2] >= 4.5 && reply[2] <= 5); // max + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/t-digest/TRIMMED_MEAN.spec.ts b/packages/bloom/lib/commands/t-digest/TRIMMED_MEAN.spec.ts index 8e83c736476..3f0d7867fcc 100644 --- a/packages/bloom/lib/commands/t-digest/TRIMMED_MEAN.spec.ts +++ b/packages/bloom/lib/commands/t-digest/TRIMMED_MEAN.spec.ts @@ -19,4 +19,15 @@ describe('TDIGEST.TRIMMED_MEAN', () => { assert.equal(reply, NaN); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.tDigest.trimmedMean with data', async client => { + await client.tDigest.create('key'); + await client.tDigest.add('key', [1, 2, 3, 4, 5]); + + const reply = await client.tDigest.trimmedMean('key', 0.1, 0.9); + + assert.equal(typeof reply, 'number'); + assert.ok(!isNaN(reply)); + assert.ok(reply > 0 && reply < 10); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/bloom/lib/commands/top-k/INFO.spec.ts b/packages/bloom/lib/commands/top-k/INFO.spec.ts index 2efbf0bdbef..8b255cf8ac1 100644 --- a/packages/bloom/lib/commands/top-k/INFO.spec.ts +++ b/packages/bloom/lib/commands/top-k/INFO.spec.ts @@ -24,4 +24,21 @@ describe('TOPK INFO', () => { assert.equal(typeof reply.depth, 'number'); assert.equal(typeof reply.decay, 'number'); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.topK.info - structural assertion', async client => { + await client.topK.reserve('key', 5); + const reply = await client.topK.info('key'); + + // Structural assertion to ensure RESP2 array-to-object transformation + assert.ok(reply !== null && typeof reply === 'object' && !Array.isArray(reply)); + assert.ok('k' in reply && typeof reply.k === 'number'); + assert.ok('width' in reply && typeof reply.width === 'number'); + assert.ok('depth' in reply && typeof reply.depth === 'number'); + assert.ok('decay' in reply && typeof reply.decay === 'number'); + + // Verify the structure matches the expected object shape + const expectedKeys = ['k', 'width', 'depth', 'decay']; + const actualKeys = Object.keys(reply).sort(); + assert.deepStrictEqual(actualKeys, expectedKeys.sort()); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/RESP/decoder.spec.ts b/packages/client/lib/RESP/decoder.spec.ts index 43b08e35662..ba1bdd13d3e 100644 --- a/packages/client/lib/RESP/decoder.spec.ts +++ b/packages/client/lib/RESP/decoder.spec.ts @@ -75,13 +75,13 @@ describe('RESP Decoder', () => { toWrite: Buffer.from('_\r\n'), replies: [null] }); - + describe('Boolean', () => { test('true', { toWrite: Buffer.from('#t\r\n'), replies: [true] }); - + test('false', { toWrite: Buffer.from('#f\r\n'), replies: [false] @@ -347,7 +347,7 @@ describe('RESP Decoder', () => { new BlobError(''), [], [], - Object.create(null) + {} ]] }); @@ -380,12 +380,12 @@ describe('RESP Decoder', () => { describe('Map', () => { test('{}', { toWrite: Buffer.from('%0\r\n'), - replies: [Object.create(null)] + replies: [{}] }); test("{ '0'..'9': }", { toWrite: Buffer.from(`%10\r\n+0\r\n+0\r\n+1\r\n+1\r\n+2\r\n+2\r\n+3\r\n+3\r\n+4\r\n+4\r\n+5\r\n+5\r\n+6\r\n+6\r\n+7\r\n+7\r\n+8\r\n+8\r\n+9\r\n+9\r\n`), - replies: [Object.create(null, { + replies: [Object.defineProperties({}, { 0: { value: '0', enumerable: true }, 1: { value: '1', enumerable: true }, 2: { value: '2', enumerable: true }, diff --git a/packages/client/lib/client/index.spec.ts b/packages/client/lib/client/index.spec.ts index b5f89941a19..eec9409485b 100644 --- a/packages/client/lib/client/index.spec.ts +++ b/packages/client/lib/client/index.spec.ts @@ -43,12 +43,11 @@ describe('Client', () => { ); }); - it('should throw error when clientSideCache is enabled with RESP undefined', () => { - assert.throws( - () => new RedisClient({ + it('should not throw when clientSideCache is enabled with RESP undefined', () => { + assert.doesNotThrow(() => + new RedisClient({ clientSideCache: clientSideCacheConfig, - }), - new Error('Client Side Caching is only supported with RESP3') + }) ); }); @@ -1327,6 +1326,14 @@ describe('Client', () => { it("should reconnect after multiple connection drops during handshake", async () => { const { log, client, teardown } = await setup({}, 2); await client.connect(); + + // Some environments emit duplicate consecutive `error` events per dropped + // socket during handshake. Normalize those duplicates before asserting + // the reconnect sequence. + const normalized = log.filter((event, index) => { + return !(event === "error" && log[index - 1] === "error"); + }); + assert.deepEqual( [ "connect", @@ -1338,7 +1345,7 @@ describe('Client', () => { "connect", "ready", ], - log, + normalized, ); teardown(); }); @@ -1381,17 +1388,34 @@ describe('Client', () => { return log; } - // Create a TCP server that accepts connections but immediately drops them times - // This simulates what happens when Docker container is stopped: - // - TCP connection succeeds (OS accepts it) - // - But socket is immediately destroyed, causing ECONNRESET during handshake - function setupMockServer(dropImmediately: number) { - const server = net.createServer(async (socket) => { - if (dropImmediately > 0) { - dropImmediately--; - socket.destroy(); + function countRespCommands(chunk: Buffer): number { + let commands = 0; + + for (let i = 0; i < chunk.length; i++) { + if (chunk[i] === 42 && (i === 0 || chunk[i - 1] === 10)) { + commands++; } - socket.write("+OK\r\n+OK\r\n"); + } + + return commands; + } + + // Create a TCP server that accepts connections but immediately drops them times. + // For accepted connections, reply with one `+OK` per incoming RESP command. + function setupMockServer(dropImmediately: number) { + const server = net.createServer((socket) => { + socket.on("data", (chunk: Buffer) => { + if (dropImmediately > 0) { + dropImmediately--; + socket.destroy(); + return; + } + + const commands = countRespCommands(chunk); + if (commands > 0) { + socket.write("+OK\r\n".repeat(commands)); + } + }); }); return server; } diff --git a/packages/client/lib/client/legacy-mode.spec.ts b/packages/client/lib/client/legacy-mode.spec.ts index 306ea7f3353..4a6bfbe7c83 100644 --- a/packages/client/lib/client/legacy-mode.spec.ts +++ b/packages/client/lib/client/legacy-mode.spec.ts @@ -33,12 +33,12 @@ describe('Legacy Mode', () => { }); }); - describe('hGetAll (TRANSFORM_LEGACY_REPLY)', () => { + describe('hGetAll (TRANSFORM_LEGACY_REPLY)', () => { testWithLegacyClient('resolve', async client => { await promisify(client.hSet).call(client, 'key', 'field', 'value'); assert.deepEqual( await promisify(client.hGetAll).call(client, 'key'), - Object.create(null, { + Object.defineProperties({}, { field: { value: 'value', configurable: true, @@ -93,7 +93,7 @@ describe('Legacy Mode', () => { ['PONG', 'PONG'] ); }); - + testWithLegacyClient('reject', async client => { const multi = client.multi().sendCommand('ERROR'); await assert.rejects( diff --git a/packages/client/lib/cluster/cluster-slots.spec.ts b/packages/client/lib/cluster/cluster-slots.spec.ts index 8585b9a6780..9d03dfc723a 100644 --- a/packages/client/lib/cluster/cluster-slots.spec.ts +++ b/packages/client/lib/cluster/cluster-slots.spec.ts @@ -23,13 +23,12 @@ describe('RedisClusterSlots', () => { ); }); - it('should throw error when clientSideCache is enabled with RESP undefined', () => { - assert.throws( - () => new RedisClusterSlots({ + it('should not throw when clientSideCache is enabled with RESP undefined', () => { + assert.doesNotThrow(() => + new RedisClusterSlots({ rootNodes, clientSideCache: clientSideCacheConfig, - }, mockEmit), - new Error('Client Side Caching is only supported with RESP3') + }, mockEmit) ); }); diff --git a/packages/client/lib/commands/ACL_GETUSER.spec.ts b/packages/client/lib/commands/ACL_GETUSER.spec.ts index 83776a3473a..d80cb892bb4 100644 --- a/packages/client/lib/commands/ACL_GETUSER.spec.ts +++ b/packages/client/lib/commands/ACL_GETUSER.spec.ts @@ -32,4 +32,34 @@ describe('ACL GETUSER', () => { } } }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.aclGetUser with structural assertion', async client => { + const reply = await client.aclGetUser('default'); + + // Structurally assert the complete response shape to catch RESP2→RESP3 differences + // The response must be an object (not an array) with specific fields + assert.equal(typeof reply, 'object'); + assert.ok(reply !== null); + assert.ok(!Array.isArray(reply)); // Must be object, not array + + // Deep structural assertion: verify all expected keys exist and have correct types + assert.ok('flags' in reply); + assert.ok('passwords' in reply); + assert.ok('commands' in reply); + assert.ok('keys' in reply); + assert.ok('channels' in reply); + + assert.ok(Array.isArray(reply.flags)); + assert.ok(Array.isArray(reply.passwords)); + assert.equal(typeof reply.commands, 'string'); + + // Verify the structure matches expected object shape, not a flat array + const expectedKeys = testUtils.isVersionGreaterThan([7]) + ? ['channels', 'commands', 'flags', 'keys', 'passwords', 'selectors'] + : testUtils.isVersionGreaterThan([6, 2]) + ? ['channels', 'commands', 'flags', 'keys', 'passwords'] + : ['commands', 'flags', 'keys', 'passwords']; + + assert.deepEqual(Object.keys(reply).sort(), expectedKeys.sort()); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/BGREWRITEAOF.spec.ts b/packages/client/lib/commands/BGREWRITEAOF.spec.ts index f58ec9a5762..42173966970 100644 --- a/packages/client/lib/commands/BGREWRITEAOF.spec.ts +++ b/packages/client/lib/commands/BGREWRITEAOF.spec.ts @@ -12,9 +12,16 @@ describe('BGREWRITEAOF', () => { }); testUtils.testWithClient('client.bgRewriteAof', async client => { - assert.equal( - typeof await client.bgRewriteAof(), - 'string' + const reply = await client.bgRewriteAof(); + // Structural assertion to pin RESP2 response shape + assert.equal(typeof reply, 'string'); + assert.ok(reply.length > 0); + // Verify response contains expected content patterns + assert.ok( + reply.includes('rewrite') || + reply.includes('Background') || + reply.includes('started') || + reply.includes('scheduled') ); }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/DUMP.spec.ts b/packages/client/lib/commands/DUMP.spec.ts index 76fb2ec7c18..0a3915d4dc8 100644 --- a/packages/client/lib/commands/DUMP.spec.ts +++ b/packages/client/lib/commands/DUMP.spec.ts @@ -20,4 +20,15 @@ describe('DUMP', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('client.dump with data', async client => { + await client.set('dumpKey', 'value'); + const reply = await client.dump('dumpKey'); + assert.ok(reply !== null); + assert.ok(Buffer.isBuffer(reply) || typeof reply === 'string'); + assert.ok(reply.length > 0); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/FUNCTION_STATS.spec.ts b/packages/client/lib/commands/FUNCTION_STATS.spec.ts index a3c5e00fe72..f251533edfe 100644 --- a/packages/client/lib/commands/FUNCTION_STATS.spec.ts +++ b/packages/client/lib/commands/FUNCTION_STATS.spec.ts @@ -2,6 +2,7 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; import FUNCTION_STATS from './FUNCTION_STATS'; import { parseArgs } from './generic-transformers'; +import { loadMathFunction, MATH_FUNCTION } from './FUNCTION_LOAD.spec'; describe('FUNCTION STATS', () => { testUtils.isVersionGreaterThanHook([7]); @@ -23,4 +24,28 @@ describe('FUNCTION STATS', () => { assert.equal(typeof functions_count, 'number'); } }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('structural assertion with loaded function', async client => { + await loadMathFunction(client); + const stats = await client.functionStats(); + + // Structural assertion to catch RESP2 array vs RESP3 map differences + assert.equal(stats.running_script, null); + assert.ok(stats.engines); + assert.equal(typeof stats.engines, 'object'); + assert.ok(!Array.isArray(stats.engines)); + + // At least one engine (LUA) should exist with the loaded function + const luaEngine = stats.engines['LUA']; + assert.ok(luaEngine); + + // Deep structural check - ensures shape is {libraries_count: number, functions_count: number} + assert.equal(Object.keys(luaEngine).length, 2); + assert.ok('libraries_count' in luaEngine); + assert.ok('functions_count' in luaEngine); + assert.equal(typeof luaEngine.libraries_count, 'number'); + assert.equal(typeof luaEngine.functions_count, 'number'); + assert.ok(luaEngine.libraries_count >= 1); + assert.ok(luaEngine.functions_count >= 1); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/GEOHASH.spec.ts b/packages/client/lib/commands/GEOHASH.spec.ts index ad26dff8434..eb7970665a0 100644 --- a/packages/client/lib/commands/GEOHASH.spec.ts +++ b/packages/client/lib/commands/GEOHASH.spec.ts @@ -29,4 +29,22 @@ describe('GEOHASH', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('geoHash with real geospatial data', async client => { + await client.geoAdd('geo-key', { + longitude: 13.361389, + latitude: 38.115556, + member: 'Palermo' + }); + + const reply = await client.geoHash('geo-key', 'Palermo'); + + assert.ok(Array.isArray(reply)); + assert.equal(reply.length, 1); + assert.equal(typeof reply[0], 'string'); + assert.ok(reply[0]!.length > 0); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/GEORADIUSBYMEMBER_RO_WITH.spec.ts b/packages/client/lib/commands/GEORADIUSBYMEMBER_RO_WITH.spec.ts index 52b31b03594..d8c09721365 100644 --- a/packages/client/lib/commands/GEORADIUSBYMEMBER_RO_WITH.spec.ts +++ b/packages/client/lib/commands/GEORADIUSBYMEMBER_RO_WITH.spec.ts @@ -34,10 +34,10 @@ describe('GEORADIUSBYMEMBER_RO WITH', () => { assert.equal(reply.length, 1); assert.equal(reply[0].member, 'member'); - assert.equal(typeof reply[0].distance, 'string'); + assert.equal(typeof reply[0].distance, 'number'); assert.equal(typeof reply[0].hash, 'number'); - assert.equal(typeof reply[0].coordinates?.longitude, 'string'); - assert.equal(typeof reply[0].coordinates?.latitude, 'string'); + assert.equal(typeof reply[0].coordinates?.longitude, 'number'); + assert.equal(typeof reply[0].coordinates?.latitude, 'number'); }, { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN diff --git a/packages/client/lib/commands/GEORADIUSBYMEMBER_WITH.spec.ts b/packages/client/lib/commands/GEORADIUSBYMEMBER_WITH.spec.ts index 9d634d60656..3e543656d6f 100644 --- a/packages/client/lib/commands/GEORADIUSBYMEMBER_WITH.spec.ts +++ b/packages/client/lib/commands/GEORADIUSBYMEMBER_WITH.spec.ts @@ -34,10 +34,10 @@ describe('GEORADIUSBYMEMBER WITH', () => { assert.equal(reply.length, 1); assert.equal(reply[0].member, 'member'); - assert.equal(typeof reply[0].distance, 'string'); + assert.equal(typeof reply[0].distance, 'number'); assert.equal(typeof reply[0].hash, 'number'); - assert.equal(typeof reply[0].coordinates!.longitude, 'string'); - assert.equal(typeof reply[0].coordinates!.latitude, 'string'); + assert.equal(typeof reply[0].coordinates!.longitude, 'number'); + assert.equal(typeof reply[0].coordinates!.latitude, 'number'); }, { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN diff --git a/packages/client/lib/commands/GEORADIUS_RO_WITH.spec.ts b/packages/client/lib/commands/GEORADIUS_RO_WITH.spec.ts index 01d79954b64..6bea27c6256 100644 --- a/packages/client/lib/commands/GEORADIUS_RO_WITH.spec.ts +++ b/packages/client/lib/commands/GEORADIUS_RO_WITH.spec.ts @@ -19,6 +19,23 @@ describe('GEORADIUS_RO WITH', () => { ); }); + it('transformReply should parse RESP2 floating-point strings', () => { + const reply = GEORADIUS_RO_WITH.transformReply([ + ['member', '0.5', 1, ['1.23', '4.56']] + ] as any, [ + GEO_REPLY_WITH.DISTANCE, + GEO_REPLY_WITH.HASH, + GEO_REPLY_WITH.COORDINATES + ]); + + assert.equal(reply.length, 1); + assert.equal(reply[0].member, 'member'); + assert.equal(reply[0].distance, 0.5); + assert.equal(reply[0].hash, 1); + assert.equal(reply[0].coordinates!.longitude, 1.23); + assert.equal(reply[0].coordinates!.latitude, 4.56); + }); + testUtils.testAll('geoRadiusRoWith', async client => { const [, reply] = await Promise.all([ client.geoAdd('key', { @@ -38,10 +55,10 @@ describe('GEORADIUS_RO WITH', () => { assert.equal(reply.length, 1); assert.equal(reply[0].member, 'member'); - assert.equal(typeof reply[0].distance, 'string'); + assert.equal(typeof reply[0].distance, 'number'); assert.equal(typeof reply[0].hash, 'number'); - assert.equal(typeof reply[0].coordinates!.longitude, 'string'); - assert.equal(typeof reply[0].coordinates!.latitude, 'string'); + assert.equal(typeof reply[0].coordinates!.longitude, 'number'); + assert.equal(typeof reply[0].coordinates!.latitude, 'number'); }, { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN diff --git a/packages/client/lib/commands/GEORADIUS_WITH.spec.ts b/packages/client/lib/commands/GEORADIUS_WITH.spec.ts index f514c9be96f..c95451750ad 100644 --- a/packages/client/lib/commands/GEORADIUS_WITH.spec.ts +++ b/packages/client/lib/commands/GEORADIUS_WITH.spec.ts @@ -38,10 +38,10 @@ describe('GEORADIUS WITH', () => { assert.equal(reply.length, 1); assert.equal(reply[0].member, 'member'); - assert.equal(typeof reply[0].distance, 'string'); + assert.equal(typeof reply[0].distance, 'number'); assert.equal(typeof reply[0].hash, 'number'); - assert.equal(typeof reply[0].coordinates?.longitude, 'string'); - assert.equal(typeof reply[0].coordinates?.latitude, 'string'); + assert.equal(typeof reply[0].coordinates?.longitude, 'number'); + assert.equal(typeof reply[0].coordinates?.latitude, 'number'); }, { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN diff --git a/packages/client/lib/commands/GEOSEARCH_WITH.spec.ts b/packages/client/lib/commands/GEOSEARCH_WITH.spec.ts index 973e5d5827f..75b3f79d746 100644 --- a/packages/client/lib/commands/GEOSEARCH_WITH.spec.ts +++ b/packages/client/lib/commands/GEOSEARCH_WITH.spec.ts @@ -39,10 +39,10 @@ describe('GEOSEARCH WITH', () => { assert.equal(reply.length, 1); assert.equal(reply[0].member, 'member'); - assert.equal(typeof reply[0].distance, 'string'); + assert.equal(typeof reply[0].distance, 'number'); assert.equal(typeof reply[0].hash, 'number'); - assert.equal(typeof reply[0].coordinates!.longitude, 'string'); - assert.equal(typeof reply[0].coordinates!.latitude, 'string'); + assert.equal(typeof reply[0].coordinates!.longitude, 'number'); + assert.equal(typeof reply[0].coordinates!.latitude, 'number'); }, { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN diff --git a/packages/client/lib/commands/GET.spec.ts b/packages/client/lib/commands/GET.spec.ts index 3e630d03e0b..7d3b40fcc74 100644 --- a/packages/client/lib/commands/GET.spec.ts +++ b/packages/client/lib/commands/GET.spec.ts @@ -20,4 +20,15 @@ describe('GET', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('get with value', async client => { + await client.set('key', 'value'); + assert.deepEqual( + await client.get('key'), + 'value' + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/HDEL.spec.ts b/packages/client/lib/commands/HDEL.spec.ts index 767d916e147..4653c60e1bd 100644 --- a/packages/client/lib/commands/HDEL.spec.ts +++ b/packages/client/lib/commands/HDEL.spec.ts @@ -29,4 +29,20 @@ describe('HDEL', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('hDel with existing fields', async client => { + await client.hSet('key', { + field1: 'value1', + field2: 'value2', + field3: 'value3' + }); + + assert.equal( + await client.hDel('key', ['field1', 'field2']), + 2 + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/HELLO.spec.ts b/packages/client/lib/commands/HELLO.spec.ts index 5d11be344c1..a6c2f35a0d3 100644 --- a/packages/client/lib/commands/HELLO.spec.ts +++ b/packages/client/lib/commands/HELLO.spec.ts @@ -60,7 +60,7 @@ describe('HELLO', () => { const reply = await client.hello(); assert.equal(reply.server, 'redis'); assert.equal(typeof reply.version, 'string'); - assert.equal(reply.proto, 2); + assert.equal(reply.proto, client.options.RESP ?? 3); assert.equal(typeof reply.id, 'number'); assert.equal(reply.mode, 'standalone'); assert.equal(reply.role, 'master'); diff --git a/packages/client/lib/commands/HGETALL.spec.ts b/packages/client/lib/commands/HGETALL.spec.ts index 93d122bae07..d63d59b04b2 100644 --- a/packages/client/lib/commands/HGETALL.spec.ts +++ b/packages/client/lib/commands/HGETALL.spec.ts @@ -6,7 +6,7 @@ describe('HGETALL', () => { testUtils.testAll('hGetAll empty', async client => { assert.deepEqual( await client.hGetAll('key'), - Object.create(null) + {} ); }, { client: GLOBAL.SERVERS.OPEN, @@ -20,7 +20,7 @@ describe('HGETALL', () => { ]); assert.deepEqual( reply, - Object.create(null, { + Object.defineProperties({}, { field: { value: 'value', enumerable: true diff --git a/packages/client/lib/commands/HKEYS.spec.ts b/packages/client/lib/commands/HKEYS.spec.ts index 58445696d20..8204972087c 100644 --- a/packages/client/lib/commands/HKEYS.spec.ts +++ b/packages/client/lib/commands/HKEYS.spec.ts @@ -20,4 +20,18 @@ describe('HKEYS', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testWithClient('hKeys with data', async client => { + await client.hSet('hash', { + field1: 'value1', + field2: 'value2', + field3: 'value3' + }); + + const keys = await client.hKeys('hash'); + assert.deepEqual( + keys.sort(), + ['field1', 'field2', 'field3'] + ); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/HLEN.spec.ts b/packages/client/lib/commands/HLEN.spec.ts index 640e461ad07..53fe9169d3a 100644 --- a/packages/client/lib/commands/HLEN.spec.ts +++ b/packages/client/lib/commands/HLEN.spec.ts @@ -20,4 +20,15 @@ describe('HLEN', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('hLen with fields', async client => { + await client.hSet('key', { field1: 'value1', field2: 'value2', field3: 'value3' }); + assert.strictEqual( + await client.hLen('key'), + 3 + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/HOTKEYS_GET.spec.ts b/packages/client/lib/commands/HOTKEYS_GET.spec.ts index 0803c8d00a3..eae25ab75e1 100644 --- a/packages/client/lib/commands/HOTKEYS_GET.spec.ts +++ b/packages/client/lib/commands/HOTKEYS_GET.spec.ts @@ -179,4 +179,3 @@ describe('HOTKEYS GET', () => { minimumDockerVersion: [8, 6] }); }); - diff --git a/packages/client/lib/commands/HVALS.spec.ts b/packages/client/lib/commands/HVALS.spec.ts index 89cbb52861c..a4cd9f26d3d 100644 --- a/packages/client/lib/commands/HVALS.spec.ts +++ b/packages/client/lib/commands/HVALS.spec.ts @@ -20,4 +20,23 @@ describe('HVALS', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('hVals with data', async client => { + await client.hSet('key', { + field1: 'value1', + field2: 'value2', + field3: 'value3' + }); + + const values = await client.hVals('key'); + assert.ok(Array.isArray(values)); + assert.equal(values.length, 3); + assert.deepEqual( + values.sort(), + ['value1', 'value2', 'value3'] + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/INFO.spec.ts b/packages/client/lib/commands/INFO.spec.ts index 7ee8a95c137..75571672d7a 100644 --- a/packages/client/lib/commands/INFO.spec.ts +++ b/packages/client/lib/commands/INFO.spec.ts @@ -26,4 +26,23 @@ describe('INFO', () => { 'string' ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.info structural shape', async client => { + const reply = await client.info(); + + // RESP2 returns a bulk string with specific format + assert.equal(typeof reply, 'string'); + + // Must contain section headers starting with '#' + assert.ok(reply.includes('# Server') || reply.includes('# CPU'), + 'INFO response should contain section headers starting with #'); + + // Must contain field:value pairs + assert.ok(/\w+:\w+/.test(reply), + 'INFO response should contain field:value pairs'); + + // Should contain line breaks (fields are line-separated) + assert.ok(reply.includes('\r\n') || reply.includes('\n'), + 'INFO response should contain line breaks'); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/LATENCY_HISTOGRAM.spec.ts b/packages/client/lib/commands/LATENCY_HISTOGRAM.spec.ts index 225410e0288..28bb76b77b8 100644 --- a/packages/client/lib/commands/LATENCY_HISTOGRAM.spec.ts +++ b/packages/client/lib/commands/LATENCY_HISTOGRAM.spec.ts @@ -49,6 +49,32 @@ describe("LATENCY HISTOGRAM", () => { }, GLOBAL.SERVERS.OPEN, ); + + testUtils.testWithClient( + "structural validation of response shape", + async (client) => { + await client.configResetStat(); + await client.set("test-key", "test-value"); + const histogram = await client.latencyHistogram("set"); + + // Assert the response is a plain object (RESP2), not a Map (RESP3) + assert.equal(typeof histogram, "object"); + assert.ok(histogram !== null); + assert.ok(!Array.isArray(histogram)); + assert.ok(!(histogram instanceof Map)); + + // Assert the structure of each command entry + assert.ok("set" in histogram); + assert.equal(typeof histogram.set, "object"); + assert.equal(typeof histogram.set.calls, "number"); + assert.ok(histogram.set.calls > 0); + assert.equal(typeof histogram.set.histogram_usec, "object"); + assert.ok(histogram.set.histogram_usec !== null); + assert.ok(!Array.isArray(histogram.set.histogram_usec)); + assert.ok(!(histogram.set.histogram_usec instanceof Map)); + }, + GLOBAL.SERVERS.OPEN, + ); }); describe("RESP 3", () => { diff --git a/packages/client/lib/commands/LATENCY_RESET.spec.ts b/packages/client/lib/commands/LATENCY_RESET.spec.ts index 030d0d78e0a..da13e270e75 100644 --- a/packages/client/lib/commands/LATENCY_RESET.spec.ts +++ b/packages/client/lib/commands/LATENCY_RESET.spec.ts @@ -50,8 +50,12 @@ describe('LATENCY RESET', function () { const latestLatencyBeforeReset = await client.latencyLatest(); assert.ok(latestLatencyBeforeReset.length > 0, 'Expected latency events to be recorded before first reset.'); - assert.equal(latestLatencyBeforeReset[0][0], 'command', 'Expected "command" event to be recorded.'); - assert.ok(Number(latestLatencyBeforeReset[0][2]) >= 100, 'Expected latest latency for "command" to be at least 100ms.'); + const commandEventBeforeReset = latestLatencyBeforeReset.find(event => event[0] === LATENCY_EVENTS.COMMAND); + assert.ok( + commandEventBeforeReset, + `Expected "command" event to be recorded. Got events: ${latestLatencyBeforeReset.map(event => event[0]).join(', ')}` + ); + assert.ok(Number(commandEventBeforeReset[2]) >= 100, 'Expected latest latency for "command" to be at least 100ms.'); const replyAll = await client.latencyReset(); @@ -75,7 +79,10 @@ describe('LATENCY RESET', function () { const latestLatencyAfterSpecificReset = await client.latencyLatest(); - assert.deepEqual(latestLatencyAfterSpecificReset, [], 'Expected no latency events after specific reset of "command".'); + assert.ok( + latestLatencyAfterSpecificReset.every(event => event[0] !== LATENCY_EVENTS.COMMAND), + `Expected no "${LATENCY_EVENTS.COMMAND}" event after specific reset. Got events: ${latestLatencyAfterSpecificReset.map(event => event[0]).join(', ')}` + ); await client.sendCommand(['DEBUG', 'SLEEP', '0.02']); @@ -90,7 +97,13 @@ describe('LATENCY RESET', function () { assert.ok(replyMultiple >= 0); const latestLatencyAfterMultipleReset = await client.latencyLatest(); - assert.deepEqual(latestLatencyAfterMultipleReset, [], 'Expected no latency events after multiple specified resets.'); + assert.ok( + latestLatencyAfterMultipleReset.every(event => ( + event[0] !== LATENCY_EVENTS.COMMAND && + event[0] !== LATENCY_EVENTS.FORK + )), + `Expected no "${LATENCY_EVENTS.COMMAND}" or "${LATENCY_EVENTS.FORK}" events after reset. Got events: ${latestLatencyAfterMultipleReset.map(event => event[0]).join(', ')}` + ); }, { diff --git a/packages/client/lib/commands/LCS.spec.ts b/packages/client/lib/commands/LCS.spec.ts index aedbb1b34e3..2b9697a3886 100644 --- a/packages/client/lib/commands/LCS.spec.ts +++ b/packages/client/lib/commands/LCS.spec.ts @@ -22,4 +22,19 @@ describe('LCS', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('lcs with actual common substring', async client => { + await Promise.all([ + client.set('{tag}key1', 'ohmytext'), + client.set('{tag}key2', 'mynewtext') + ]); + + const result = await client.lcs('{tag}key1', '{tag}key2'); + + assert.equal(typeof result, 'string'); + assert.equal(result, 'mytext'); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/MEMORY_STATS.spec.ts b/packages/client/lib/commands/MEMORY_STATS.spec.ts index 6aad05116af..3a63f413027 100644 --- a/packages/client/lib/commands/MEMORY_STATS.spec.ts +++ b/packages/client/lib/commands/MEMORY_STATS.spec.ts @@ -38,10 +38,11 @@ describe('MEMORY STATS', () => { assert.equal(typeof memoryStats['rss-overhead.bytes'], 'number'); assert.equal(typeof memoryStats['fragmentation'], 'number', 'fragmentation'); assert.equal(typeof memoryStats['fragmentation.bytes'], 'number'); - + if (testUtils.isVersionGreaterThan([7])) { assert.equal(typeof memoryStats['cluster.links'], 'number'); assert.equal(typeof memoryStats['functions.caches'], 'number'); } }, GLOBAL.SERVERS.OPEN); + }); diff --git a/packages/client/lib/commands/MODULE_LIST.spec.ts b/packages/client/lib/commands/MODULE_LIST.spec.ts index 0aab973cf21..a22d013e7f3 100644 --- a/packages/client/lib/commands/MODULE_LIST.spec.ts +++ b/packages/client/lib/commands/MODULE_LIST.spec.ts @@ -1,4 +1,5 @@ import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; import MODULE_LIST from './MODULE_LIST'; import { parseArgs } from './generic-transformers'; @@ -9,4 +10,31 @@ describe('MODULE LIST', () => { ['MODULE', 'LIST'] ); }); + + testUtils.testWithClient('client.moduleList', async client => { + const reply = await client.moduleList(); + assert.ok(Array.isArray(reply)); + for (const module of reply) { + assert.equal(typeof module.name, 'string'); + assert.equal(typeof module.ver, 'number'); + } + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.moduleList - structural assertion', async client => { + const reply = await client.moduleList(); + // Strong structural assertion: reply must be an array of objects with exact shape + assert.ok(Array.isArray(reply)); + for (const module of reply) { + // Assert the exact structure: must be a plain object with 'name' and 'ver' properties + assert.ok(typeof module === 'object' && module !== null); + assert.ok('name' in module && 'ver' in module); + assert.equal(typeof module.name, 'string'); + assert.equal(typeof module.ver, 'number'); + // Ensure it's a plain object (not a Map or other structure) + assert.ok(!('entries' in module && typeof module.entries === 'function')); + // Check that the object has exactly these two keys + const keys = Object.keys(module).sort(); + assert.deepStrictEqual(keys, ['name', 'ver']); + } + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/PFCOUNT.spec.ts b/packages/client/lib/commands/PFCOUNT.spec.ts index aec2ebecf0b..29d5df54d63 100644 --- a/packages/client/lib/commands/PFCOUNT.spec.ts +++ b/packages/client/lib/commands/PFCOUNT.spec.ts @@ -29,4 +29,16 @@ describe('PFCOUNT', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('pfCount with data', async client => { + await client.pfAdd('key', ['a', 'b', 'c']); + const count = await client.pfCount('key'); + // Structural assertion: must be a primitive number, not an object/array/map + assert.equal(typeof count, 'number'); + assert.ok(Number.isInteger(count)); + assert.ok(count >= 3); // HyperLogLog approximation, should be at least 3 + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/PUBSUB_NUMSUB.spec.ts b/packages/client/lib/commands/PUBSUB_NUMSUB.spec.ts index 4965c43fc6a..bf93c4e3334 100644 --- a/packages/client/lib/commands/PUBSUB_NUMSUB.spec.ts +++ b/packages/client/lib/commands/PUBSUB_NUMSUB.spec.ts @@ -30,7 +30,7 @@ describe('PUBSUB NUMSUB', () => { testUtils.testWithClient('client.pubSubNumSub resp2', async client => { assert.deepEqual( await client.pubSubNumSub(), - Object.create(null) + {} ); const res = await client.PUBSUB_NUMSUB(["test", "test2"]); @@ -47,7 +47,7 @@ describe('PUBSUB NUMSUB', () => { testUtils.testWithClient('client.pubSubNumSub resp3', async client => { assert.deepEqual( await client.pubSubNumSub(), - Object.create(null) + {} ); const res = await client.PUBSUB_NUMSUB(["test", "test2"]); diff --git a/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.spec.ts b/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.spec.ts index e335941897d..05b3ca6233b 100644 --- a/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.spec.ts +++ b/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.spec.ts @@ -32,7 +32,7 @@ describe('PUBSUB SHARDNUMSUB', () => { testUtils.testWithClient('client.pubSubShardNumSub', async client => { assert.deepEqual( await client.pubSubShardNumSub(['foo', 'bar']), - Object.create(null, { + Object.defineProperties({}, { foo: { value: 0, configurable: true, diff --git a/packages/client/lib/commands/RANDOMKEY.spec.ts b/packages/client/lib/commands/RANDOMKEY.spec.ts index f86617a3b75..22f2d9ba076 100644 --- a/packages/client/lib/commands/RANDOMKEY.spec.ts +++ b/packages/client/lib/commands/RANDOMKEY.spec.ts @@ -20,4 +20,13 @@ describe('RANDOMKEY', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testWithClient('randomKey with keys in database', async client => { + await client.set('key1', 'value1'); + await client.set('key2', 'value2'); + + const reply = await client.randomKey(); + assert.equal(typeof reply, 'string'); + assert.ok(['key1', 'key2'].includes(reply!)); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/SCARD.spec.ts b/packages/client/lib/commands/SCARD.spec.ts index 53434583832..9c205195230 100644 --- a/packages/client/lib/commands/SCARD.spec.ts +++ b/packages/client/lib/commands/SCARD.spec.ts @@ -20,4 +20,15 @@ describe('SCARD', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('sCard with set members', async client => { + await client.sAdd('key', ['member1', 'member2', 'member3']); + assert.equal( + await client.sCard('key'), + 3 + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/SDIFF.spec.ts b/packages/client/lib/commands/SDIFF.spec.ts index a943a80688d..b2e65738e75 100644 --- a/packages/client/lib/commands/SDIFF.spec.ts +++ b/packages/client/lib/commands/SDIFF.spec.ts @@ -29,4 +29,17 @@ describe('SDIFF', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testWithClient('sDiff with data', async client => { + await client.sAdd('sdiff-r3-1', ['a', 'b', 'c', 'd']); + await client.sAdd('sdiff-r3-2', ['c']); + await client.sAdd('sdiff-r3-3', ['a', 'c', 'e']); + + const result = await client.sDiff(['sdiff-r3-1', 'sdiff-r3-2', 'sdiff-r3-3']); + + // RESP3 returns a Set reply; verify it contains the expected members + assert.ok(Array.isArray(result)); + const sorted = [...result].sort(); + assert.deepEqual(sorted, ['b', 'd']); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/SINTER.spec.ts b/packages/client/lib/commands/SINTER.spec.ts index 6ca7b959ca7..bf8679b3d07 100644 --- a/packages/client/lib/commands/SINTER.spec.ts +++ b/packages/client/lib/commands/SINTER.spec.ts @@ -29,4 +29,16 @@ describe('SINTER', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testWithClient('sInter with data', async client => { + await client.sAdd('sinter-r3-1', ['a', 'b', 'c']); + await client.sAdd('sinter-r3-2', ['b', 'c', 'd']); + + const result = await client.sInter(['sinter-r3-1', 'sinter-r3-2']); + + // RESP3 returns a Set reply; verify it contains the expected members + assert.ok(Array.isArray(result)); + const sorted = [...result].sort(); + assert.deepEqual(sorted, ['b', 'c']); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/SINTERSTORE.spec.ts b/packages/client/lib/commands/SINTERSTORE.spec.ts index 83302a5c829..7b688627ccc 100644 --- a/packages/client/lib/commands/SINTERSTORE.spec.ts +++ b/packages/client/lib/commands/SINTERSTORE.spec.ts @@ -29,4 +29,20 @@ describe('SINTERSTORE', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('sInterStore with multiple sets', async client => { + await Promise.all([ + client.sAdd('{tag}key1', ['a', 'b', 'c']), + client.sAdd('{tag}key2', ['b', 'c', 'd']), + client.sAdd('{tag}key3', ['c', 'd', 'e']) + ]); + + const reply = await client.sInterStore('{tag}destination', ['{tag}key1', '{tag}key2', '{tag}key3']); + + assert.equal(typeof reply, 'number'); + assert.equal(reply, 1); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/SMEMBERS.spec.ts b/packages/client/lib/commands/SMEMBERS.spec.ts index 6e2582e5abc..12d8708aa83 100644 --- a/packages/client/lib/commands/SMEMBERS.spec.ts +++ b/packages/client/lib/commands/SMEMBERS.spec.ts @@ -20,4 +20,15 @@ describe('SMEMBERS', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testWithClient('sMembers with data', async client => { + await client.sAdd('smembers-r3', ['a', 'b', 'c']); + + const result = await client.sMembers('smembers-r3'); + + // RESP3 returns a Set reply; verify it contains the expected members + assert.ok(Array.isArray(result)); + const sorted = [...result].sort(); + assert.deepEqual(sorted, ['a', 'b', 'c']); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/SREM.spec.ts b/packages/client/lib/commands/SREM.spec.ts index 6def4178fc8..148be0f7f52 100644 --- a/packages/client/lib/commands/SREM.spec.ts +++ b/packages/client/lib/commands/SREM.spec.ts @@ -29,4 +29,15 @@ describe('SREM', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('sRem with existing members', async client => { + await client.sAdd('key', ['member1', 'member2', 'member3']); + assert.equal( + await client.sRem('key', ['member1', 'member2']), + 2 + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/SUNION.spec.ts b/packages/client/lib/commands/SUNION.spec.ts index a4389d4236e..54d1d454c01 100644 --- a/packages/client/lib/commands/SUNION.spec.ts +++ b/packages/client/lib/commands/SUNION.spec.ts @@ -29,4 +29,16 @@ describe('SUNION', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testWithClient('sUnion with data', async client => { + await client.sAdd('sunion-r3-1', ['a', 'b', 'c', 'd']); + await client.sAdd('sunion-r3-2', ['c', 'e']); + + const result = await client.sUnion(['sunion-r3-1', 'sunion-r3-2']); + + // RESP3 returns a Set reply; verify it contains the expected members + assert.ok(Array.isArray(result)); + const sorted = [...result].sort(); + assert.deepEqual(sorted, ['a', 'b', 'c', 'd', 'e']); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/SUNIONSTORE.spec.ts b/packages/client/lib/commands/SUNIONSTORE.spec.ts index 8f3db2cacd7..54a266e35a3 100644 --- a/packages/client/lib/commands/SUNIONSTORE.spec.ts +++ b/packages/client/lib/commands/SUNIONSTORE.spec.ts @@ -29,4 +29,17 @@ describe('SUNIONSTORE', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('sUnionStore with data', async client => { + await client.sAdd('{tag}set1', ['a', 'b', 'c']); + await client.sAdd('{tag}set2', ['c', 'd', 'e']); + + const reply = await client.sUnionStore('{tag}destination', ['{tag}set1', '{tag}set2']); + + assert.strictEqual(typeof reply, 'number'); + assert.strictEqual(reply, 5); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/VADD.spec.ts b/packages/client/lib/commands/VADD.spec.ts index e064beab498..7d1073d8705 100644 --- a/packages/client/lib/commands/VADD.spec.ts +++ b/packages/client/lib/commands/VADD.spec.ts @@ -67,13 +67,13 @@ describe('VADD', () => { }); testUtils.testAll('vAdd', async client => { - assert.equal( + assert.strictEqual( await client.vAdd('key', [1.0, 2.0, 3.0], 'element'), true ); // same element should not be added again - assert.equal( + assert.strictEqual( await client.vAdd('key', [1, 2 , 3], 'element'), false ); diff --git a/packages/client/lib/commands/VEMB.spec.ts b/packages/client/lib/commands/VEMB.spec.ts index ed9515ebddf..ce5bb0c0a94 100644 --- a/packages/client/lib/commands/VEMB.spec.ts +++ b/packages/client/lib/commands/VEMB.spec.ts @@ -25,18 +25,4 @@ describe('VEMB', () => { 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/VINFO.spec.ts b/packages/client/lib/commands/VINFO.spec.ts index 074598644ff..668dcd7931e 100644 --- a/packages/client/lib/commands/VINFO.spec.ts +++ b/packages/client/lib/commands/VINFO.spec.ts @@ -18,41 +18,30 @@ describe('VINFO', () => { const result = await client.vInfo('key'); assert.ok(typeof result === 'object' && result !== null); + assert.equal(Array.isArray(result), false); + assert.equal(result instanceof Map, false); - 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] } - }); + const expectedKeys = [ + 'quant-type', + 'hnsw-m', + 'vector-dim', + 'projection-input-dim', + 'size', + 'max-level', + 'attributes-count', + 'vset-uid', + 'hnsw-max-node-uid' + ]; - 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.deepEqual( + Object.keys(result).sort(), + expectedKeys.sort() + ); 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] + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 0] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 0] } }); }); diff --git a/packages/client/lib/commands/VLINKS.spec.ts b/packages/client/lib/commands/VLINKS.spec.ts index e788f9f9a98..15b8893e9bd 100644 --- a/packages/client/lib/commands/VLINKS.spec.ts +++ b/packages/client/lib/commands/VLINKS.spec.ts @@ -24,19 +24,4 @@ describe('VLINKS', () => { 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/VSETATTR.spec.ts b/packages/client/lib/commands/VSETATTR.spec.ts index 303006d4081..cd9f76e06ea 100644 --- a/packages/client/lib/commands/VSETATTR.spec.ts +++ b/packages/client/lib/commands/VSETATTR.spec.ts @@ -35,24 +35,4 @@ describe('VSETATTR', () => { 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/XAUTOCLAIM.spec.ts b/packages/client/lib/commands/XAUTOCLAIM.spec.ts index 58b09a63e78..1874851767e 100644 --- a/packages/client/lib/commands/XAUTOCLAIM.spec.ts +++ b/packages/client/lib/commands/XAUTOCLAIM.spec.ts @@ -25,7 +25,7 @@ describe('XAUTOCLAIM', () => { }); testUtils.testAll('xAutoClaim', async client => { - const message = Object.create(null, { + const message = Object.defineProperties({}, { field: { value: 'value', enumerable: true diff --git a/packages/client/lib/commands/XCLAIM.spec.ts b/packages/client/lib/commands/XCLAIM.spec.ts index 90768509225..61dfb4c73f2 100644 --- a/packages/client/lib/commands/XCLAIM.spec.ts +++ b/packages/client/lib/commands/XCLAIM.spec.ts @@ -27,7 +27,7 @@ describe('XCLAIM', () => { ['XCLAIM', 'key', 'group', 'consumer', '1', '0-0', 'IDLE', '1'] ); }); - + describe('with TIME', () => { it('number', () => { assert.deepEqual( @@ -37,7 +37,7 @@ describe('XCLAIM', () => { ['XCLAIM', 'key', 'group', 'consumer', '1', '0-0', 'TIME', '1'] ); }); - + it('Date', () => { const d = new Date(); assert.deepEqual( @@ -91,7 +91,7 @@ describe('XCLAIM', () => { }); testUtils.testAll('xClaim', async client => { - const message = Object.create(null, { + const message = Object.defineProperties({}, { field: { value: 'value', enumerable: true diff --git a/packages/client/lib/commands/XINFO_STREAM.spec.ts b/packages/client/lib/commands/XINFO_STREAM.spec.ts index 55ed8a07bea..f0385b51a69 100644 --- a/packages/client/lib/commands/XINFO_STREAM.spec.ts +++ b/packages/client/lib/commands/XINFO_STREAM.spec.ts @@ -54,7 +54,7 @@ describe('XINFO STREAM', () => { client.xInfoStream('key') ]); - const expected = Object.assign(Object.create(null), { + const expected = Object.assign({}, { length: 0, 'radix-tree-keys': 0, 'radix-tree-nodes': 1, diff --git a/packages/client/lib/commands/XRANGE.spec.ts b/packages/client/lib/commands/XRANGE.spec.ts index b111a97aff1..10cd858ab3f 100644 --- a/packages/client/lib/commands/XRANGE.spec.ts +++ b/packages/client/lib/commands/XRANGE.spec.ts @@ -23,7 +23,7 @@ describe('XRANGE', () => { }); testUtils.testAll('xRange', async client => { - const message = Object.create(null, { + const message = Object.defineProperties({}, { field: { value: 'value', enumerable: true @@ -34,7 +34,7 @@ describe('XRANGE', () => { client.xAdd('key', '*', message), client.xRange('key', '-', '+') ]); - + assert.deepEqual(reply, [{ id, message diff --git a/packages/client/lib/commands/XREAD.spec.ts b/packages/client/lib/commands/XREAD.spec.ts index 0edcfe43117..82eb473ab3a 100644 --- a/packages/client/lib/commands/XREAD.spec.ts +++ b/packages/client/lib/commands/XREAD.spec.ts @@ -92,7 +92,7 @@ describe('XREAD', () => { }); testUtils.testAll('client.xRead', async client => { - const message = { field: 'value' }, + const message = { field: 'value' }, [id, reply] = await Promise.all([ client.xAdd('key', '*', message), client.xRead({ @@ -102,10 +102,10 @@ describe('XREAD', () => { ]) // FUTURE resp3 compatible - const obj = Object.assign(Object.create(null), { + const obj = Object.assign({}, { 'key': [{ id: id, - message: Object.create(null, { + message: Object.defineProperties({}, { field: { value: 'value', configurable: true, @@ -120,7 +120,7 @@ describe('XREAD', () => { name: 'key', messages: [{ id: id, - message: Object.assign(Object.create(null), { + message: Object.assign({}, { field: 'value' }) }] @@ -132,24 +132,7 @@ describe('XREAD', () => { 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 => { + testUtils.testWithClient('client.xRead should not throw with resp3', async client => { assert.doesNotThrow( () => client.xRead({ key: 'key', @@ -159,8 +142,7 @@ describe('XREAD', () => { }, { ...GLOBAL.SERVERS.OPEN, clientOptions: { - RESP: 3, - unstableResp3: true + RESP: 3 } }); diff --git a/packages/client/lib/commands/XREADGROUP.spec.ts b/packages/client/lib/commands/XREADGROUP.spec.ts index 39c7c70d678..5d5704fcc74 100644 --- a/packages/client/lib/commands/XREADGROUP.spec.ts +++ b/packages/client/lib/commands/XREADGROUP.spec.ts @@ -153,10 +153,10 @@ describe('XREADGROUP', () => { // FUTURE resp3 compatible - const obj = Object.assign(Object.create(null), { + const obj = Object.assign({}, { 'key': [{ id: id, - message: Object.create(null, { + message: Object.defineProperties({}, { field: { value: 'value', configurable: true, @@ -171,7 +171,7 @@ describe('XREADGROUP', () => { name: 'key', messages: [{ id: id, - message: Object.assign(Object.create(null), { + message: Object.assign({}, { field: 'value' }) }] diff --git a/packages/client/lib/commands/XREVRANGE.spec.ts b/packages/client/lib/commands/XREVRANGE.spec.ts index 9872dc5e9e0..10d2e6dae57 100644 --- a/packages/client/lib/commands/XREVRANGE.spec.ts +++ b/packages/client/lib/commands/XREVRANGE.spec.ts @@ -23,7 +23,7 @@ describe('XREVRANGE', () => { }); testUtils.testAll('xRevRange', async client => { - const message = Object.create(null, { + const message = Object.defineProperties({}, { field: { value: 'value', enumerable: true @@ -34,7 +34,7 @@ describe('XREVRANGE', () => { client.xAdd('key', '*', message), client.xRange('key', '-', '+') ]); - + assert.deepEqual(reply, [{ id, message diff --git a/packages/client/lib/commands/ZMSCORE.spec.ts b/packages/client/lib/commands/ZMSCORE.spec.ts index 6c6d2946e00..9bc24eeddc7 100644 --- a/packages/client/lib/commands/ZMSCORE.spec.ts +++ b/packages/client/lib/commands/ZMSCORE.spec.ts @@ -31,4 +31,18 @@ describe('ZMSCORE', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('zmScore - existing members', async client => { + await client.zAdd('key', [ + { value: 'a', score: 1.5 }, + { value: 'b', score: 2.5 } + ]); + assert.deepEqual( + await client.zmScore('key', ['a', 'b', 'c']), + [1.5, 2.5, null] + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/ZREMRANGEBYLEX.spec.ts b/packages/client/lib/commands/ZREMRANGEBYLEX.spec.ts index b141b7679ee..bc432985ebb 100644 --- a/packages/client/lib/commands/ZREMRANGEBYLEX.spec.ts +++ b/packages/client/lib/commands/ZREMRANGEBYLEX.spec.ts @@ -20,4 +20,21 @@ describe('ZREMRANGEBYLEX', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('zRemRangeByLex with members', async client => { + await client.zAdd('key', [ + { score: 0, value: 'a' }, + { score: 0, value: 'b' }, + { score: 0, value: 'c' }, + { score: 0, value: 'd' } + ]); + + assert.equal( + await client.zRemRangeByLex('key', '[b', '[c'), + 2 + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/ZREMRANGEBYRANK.spec.ts b/packages/client/lib/commands/ZREMRANGEBYRANK.spec.ts index 19f54466c20..44e4935d621 100644 --- a/packages/client/lib/commands/ZREMRANGEBYRANK.spec.ts +++ b/packages/client/lib/commands/ZREMRANGEBYRANK.spec.ts @@ -20,4 +20,19 @@ describe('ZREMRANGEBYRANK', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('zRemRangeByRank with members', async client => { + await client.zAdd('key', [ + { score: 1, value: 'a' }, + { score: 2, value: 'b' }, + { score: 3, value: 'c' } + ]); + assert.equal( + await client.zRemRangeByRank('key', 0, 1), + 2 + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/ZREMRANGEBYSCORE.spec.ts b/packages/client/lib/commands/ZREMRANGEBYSCORE.spec.ts index 856692ef8f5..53e84371ab4 100644 --- a/packages/client/lib/commands/ZREMRANGEBYSCORE.spec.ts +++ b/packages/client/lib/commands/ZREMRANGEBYSCORE.spec.ts @@ -20,4 +20,19 @@ describe('ZREMRANGEBYSCORE', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('zRemRangeByScore with members', async client => { + await client.zAdd('key', [ + { score: 1, value: 'one' }, + { score: 2, value: 'two' }, + { score: 3, value: 'three' } + ]); + + const reply = await client.zRemRangeByScore('key', 1, 2); + assert.equal(typeof reply, 'number'); + assert.equal(reply, 2); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/ZSCORE.spec.ts b/packages/client/lib/commands/ZSCORE.spec.ts index 4229ab7aac0..7200b2fec8b 100644 --- a/packages/client/lib/commands/ZSCORE.spec.ts +++ b/packages/client/lib/commands/ZSCORE.spec.ts @@ -20,4 +20,15 @@ describe('ZSCORE', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('zScore with existing member', async client => { + await client.zAdd('key', { score: 1.5, value: 'member' }); + assert.equal( + await client.zScore('key', 'member'), + 1.5 + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/generic-transformers.spec.ts b/packages/client/lib/commands/generic-transformers.spec.ts index 879c6ec86f8..422dcdbb25c 100644 --- a/packages/client/lib/commands/generic-transformers.spec.ts +++ b/packages/client/lib/commands/generic-transformers.spec.ts @@ -256,7 +256,7 @@ describe('Generic Transformers', () => { it('transformTuplesReply', () => { assert.deepEqual( transformTuplesReply(['key1', 'value1', 'key2', 'value2']), - Object.create(null, { + Object.create({}, { key1: { value: 'value1', configurable: true, @@ -276,7 +276,7 @@ describe('Generic Transformers', () => { transformStreamMessagesReply([['0-0', ['0key', '0value']], ['1-0', ['1key', '1value']]]), [{ id: '0-0', - message: Object.create(null, { + message: Object.create({}, { '0key': { value: '0value', configurable: true, @@ -285,7 +285,7 @@ describe('Generic Transformers', () => { }) }, { id: '1-0', - message: Object.create(null, { + message: Object.create({}, { '1key': { value: '1value', configurable: true, @@ -311,7 +311,7 @@ describe('Generic Transformers', () => { name: 'stream1', messages: [{ id: '0-1', - message: Object.create(null, { + message: Object.create({}, { '11key': { value: '11value', configurable: true, @@ -320,7 +320,7 @@ describe('Generic Transformers', () => { }) }, { id: '1-1', - message: Object.create(null, { + message: Object.create({}, { '12key': { value: '12value', configurable: true, @@ -332,7 +332,7 @@ describe('Generic Transformers', () => { name: 'stream2', messages: [{ id: '0-2', - message: Object.create(null, { + message: Object.create({}, { '2key1': { value: '2value1', configurable: true, diff --git a/packages/client/lib/sentinel/index.spec.ts b/packages/client/lib/sentinel/index.spec.ts index d85ae4a8545..272e12f1adc 100644 --- a/packages/client/lib/sentinel/index.spec.ts +++ b/packages/client/lib/sentinel/index.spec.ts @@ -51,13 +51,12 @@ describe('RedisSentinel', () => { ); }); - it('should throw error when clientSideCache is enabled with RESP undefined', () => { - assert.throws( - () => RedisSentinel.create({ + it('should not throw when clientSideCache is enabled with RESP undefined', () => { + assert.doesNotThrow(() => + RedisSentinel.create({ ...options, clientSideCache: clientSideCacheConfig, - }), - new Error('Client Side Caching is only supported with RESP3') + }) ); }); diff --git a/packages/json/lib/commands/GET.spec.ts b/packages/json/lib/commands/GET.spec.ts index 6b4f44871cb..0674fa5271d 100644 --- a/packages/json/lib/commands/GET.spec.ts +++ b/packages/json/lib/commands/GET.spec.ts @@ -41,4 +41,22 @@ describe('JSON.GET', () => { assert.deepEqual(res, { name: 'Alice', age: 32, }) }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.json.get with path', async client => { + await client.json.set('json:path:test', '$', { + user: { name: 'Bob', age: 25 }, + count: 42 + }); + + // Test JSONPath syntax ($ prefix) - returns array + const jsonPathResult = await client.json.get('json:path:test', { path: '$.user' }); + assert.ok(Array.isArray(jsonPathResult), 'JSONPath $ syntax returns array'); + assert.equal(jsonPathResult.length, 1); + assert.deepEqual(jsonPathResult[0], { name: 'Bob', age: 25 }); + + // Test legacy path syntax (. prefix) - returns value directly (not array) + const legacyPathResult = await client.json.get('json:path:test', { path: '.user' }); + assert.ok(!Array.isArray(legacyPathResult), 'Legacy . syntax should not return array'); + assert.deepEqual(legacyPathResult, { name: 'Bob', age: 25 }); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/json/lib/commands/TYPE.spec.ts b/packages/json/lib/commands/TYPE.spec.ts index 1b6ad109816..ffffb8f8ab9 100644 --- a/packages/json/lib/commands/TYPE.spec.ts +++ b/packages/json/lib/commands/TYPE.spec.ts @@ -28,4 +28,25 @@ describe('JSON.TYPE', () => { null ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.json.type with $-based path', async client => { + await client.json.set('key', '$', { + string: 'value', + number: 42, + array: [1, 2, 3], + object: { nested: true } + }); + + const reply = await client.json.type('key', { path: '$' }); + assert.deepEqual(reply, ['object']); + + const stringType = await client.json.type('key', { path: '$.string' }); + assert.deepEqual(stringType, ['string']); + + const numberType = await client.json.type('key', { path: '$.number' }); + assert.deepEqual(numberType, ['integer']); + + const arrayType = await client.json.type('key', { path: '$.array' }); + assert.deepEqual(arrayType, ['array']); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/search/lib/commands/AGGREGATE.spec.ts b/packages/search/lib/commands/AGGREGATE.spec.ts index 45be9b2a6cb..e033962444b 100644 --- a/packages/search/lib/commands/AGGREGATE.spec.ts +++ b/packages/search/lib/commands/AGGREGATE.spec.ts @@ -509,7 +509,7 @@ describe('AGGREGATE', () => { { total: 1, results: [ - Object.create(null, { + Object.defineProperties({}, { sum: { value: '3', configurable: true, @@ -525,4 +525,33 @@ describe('AGGREGATE', () => { } ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ft.aggregate with data', async client => { + await client.ft.create('index', { + field: 'NUMERIC' + }); + await client.hSet('1', 'field', '1'); + await client.hSet('2', 'field', '2'); + + const reply = await client.ft.aggregate('index', '*', { + STEPS: [{ + type: 'GROUPBY', + REDUCE: [{ + type: 'SUM', + property: '@field', + AS: 'sum' + }, { + type: 'AVG', + property: '@field', + AS: 'avg' + }] + }] + }); + + // RESP3 returns a Map reply with structured fields instead of a flat Array + assert.ok(reply !== null && typeof reply === 'object'); + assert.ok('results' in reply); + assert.ok(Array.isArray(reply.results)); + assert.ok(reply.results.length > 0); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/search/lib/commands/AGGREGATE_WITHCURSOR.spec.ts b/packages/search/lib/commands/AGGREGATE_WITHCURSOR.spec.ts index 0e89346c49f..80718b3d845 100644 --- a/packages/search/lib/commands/AGGREGATE_WITHCURSOR.spec.ts +++ b/packages/search/lib/commands/AGGREGATE_WITHCURSOR.spec.ts @@ -46,4 +46,20 @@ describe('AGGREGATE WITHCURSOR', () => { } ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ft.aggregateWithCursor with data', async client => { + await client.ft.create('index', { + field: 'NUMERIC' + }); + + const reply = await client.ft.aggregateWithCursor('index', '*'); + + // Transformed reply has { total, results, cursor } + assert.equal(typeof reply.total, 'number'); + assert.ok(Array.isArray(reply.results)); + assert.equal(typeof reply.cursor, 'number'); + assert.equal(reply.total, 0); + assert.deepEqual(reply.results, []); + assert.equal(reply.cursor, 0); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/search/lib/commands/CONFIG_GET.spec.ts b/packages/search/lib/commands/CONFIG_GET.spec.ts index 598a2a9ac41..e6bc3126e29 100644 --- a/packages/search/lib/commands/CONFIG_GET.spec.ts +++ b/packages/search/lib/commands/CONFIG_GET.spec.ts @@ -14,7 +14,7 @@ describe('FT.CONFIG GET', () => { testUtils.testWithClient('client.ft.configGet', async client => { assert.deepEqual( await client.ft.configGet('TIMEOUT'), - Object.create(null, { + Object.defineProperties({}, { TIMEOUT: { value: '500', configurable: true, diff --git a/packages/search/lib/commands/HYBRID.spec.ts b/packages/search/lib/commands/HYBRID.spec.ts index 81a80d7d688..2b68216ef74 100644 --- a/packages/search/lib/commands/HYBRID.spec.ts +++ b/packages/search/lib/commands/HYBRID.spec.ts @@ -1874,5 +1874,36 @@ describe("FT.HYBRID", () => { }, GLOBAL.SERVERS.OPEN, ); + + testUtils.testWithClientIfVersionWithinRange( + [[8, 6], "LATEST"], + "hybrid search with structured response", + async (client) => { + const indexName = "idx_structured_basic"; + await createHybridSearchIndex(client, indexName); + await addDataForHybridSearch(client, 5); + + const result = await client.ft.hybrid(indexName, { + SEARCH: { query: "@color:{red}" }, + VSIM: { + field: "@embedding", + vector: "$vec", + }, + LOAD: ["@description", "@color", "@price"], + LIMIT: { offset: 0, count: 3 }, + TIMEOUT: 10000, + PARAMS: { + vec: createVectorBuffer([1, 2, 7, 6]), + }, + }); + + // Transformed reply has { results, warnings, executionTime } + assert.ok(Array.isArray(result.results), "results should be an array"); + assert.ok(result.results.length <= 3, "results should respect LIMIT"); + assert.ok(Array.isArray(result.warnings), "warnings should be an array"); + assert.ok(typeof result.executionTime === "number", "executionTime should be a number"); + }, + GLOBAL.SERVERS.OPEN, + ); }); }); diff --git a/packages/search/lib/commands/INFO.spec.ts b/packages/search/lib/commands/INFO.spec.ts index b52e99ab9b0..3059b22ebff 100644 --- a/packages/search/lib/commands/INFO.spec.ts +++ b/packages/search/lib/commands/INFO.spec.ts @@ -34,7 +34,7 @@ describe('INFO', () => { { index_name: 'index', index_options: [], - index_definition: Object.create(null, { + index_definition: Object.defineProperties({}, { default_score: { value: '1', configurable: true, @@ -51,7 +51,7 @@ describe('INFO', () => { enumerable: true } }), - attributes: [Object.create(null, { + attributes: [Object.defineProperties({}, { identifier: { value: 'field', configurable: true, @@ -130,7 +130,7 @@ describe('INFO', () => { { index_name: 'index', index_options: [], - index_definition: Object.create(null, { + index_definition: Object.defineProperties({}, { default_score: { value: '1', configurable: true, @@ -147,7 +147,7 @@ describe('INFO', () => { enumerable: true } }), - attributes: [Object.create(null, { + attributes: [Object.defineProperties({}, { identifier: { value: 'field', configurable: true, diff --git a/packages/search/lib/commands/PROFILE_AGGREGATE.spec.ts b/packages/search/lib/commands/PROFILE_AGGREGATE.spec.ts index 82783fbaba9..39a6f48a4b0 100644 --- a/packages/search/lib/commands/PROFILE_AGGREGATE.spec.ts +++ b/packages/search/lib/commands/PROFILE_AGGREGATE.spec.ts @@ -113,6 +113,7 @@ describe('PROFILE AGGREGATE', () => { // assert.equal(res.Results.total_results, 2); const normalizedRes = normalizeObject(res); - assert.ok(normalizedRes.Profile.Shards); + assert.ok(Array.isArray(normalizedRes.profile)); + assert.equal(normalizedRes.profile[0], 'Shards'); }, GLOBAL.SERVERS.OPEN_3); }); diff --git a/packages/search/lib/commands/PROFILE_SEARCH.spec.ts b/packages/search/lib/commands/PROFILE_SEARCH.spec.ts index 419b879d00a..958bd4704f8 100644 --- a/packages/search/lib/commands/PROFILE_SEARCH.spec.ts +++ b/packages/search/lib/commands/PROFILE_SEARCH.spec.ts @@ -92,4 +92,23 @@ describe('PROFILE SEARCH', () => { }, GLOBAL.SERVERS.OPEN); + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'client.ft.profileSearch returns structured response', async client => { + await Promise.all([ + client.ft.create('index', { + field: SCHEMA_FIELD_TYPE.NUMERIC + }), + client.hSet('1', 'field', '1') + ]); + + const res = await client.ft.profileSearch('index', '*'); + + // Transformed reply has { results, profile } + assert.ok(typeof res === 'object' && res !== null); + assert.ok(!Array.isArray(res)); + + const keys = Object.keys(res as Record); + assert.ok(keys.includes('results'), `Expected 'results' key in response, got keys: ${keys}`); + assert.ok(keys.includes('profile'), `Expected 'profile' key in response, got keys: ${keys}`); + }, GLOBAL.SERVERS.OPEN); + }); diff --git a/packages/search/lib/commands/SEARCH.spec.ts b/packages/search/lib/commands/SEARCH.spec.ts index 97e1a9a9885..758ac2a1b2b 100644 --- a/packages/search/lib/commands/SEARCH.spec.ts +++ b/packages/search/lib/commands/SEARCH.spec.ts @@ -289,7 +289,7 @@ describe('FT.SEARCH', () => { total: 1, documents: [{ id: '1', - value: Object.create(null, { + value: Object.defineProperties({}, { field: { value: '1', configurable: true, @@ -318,15 +318,33 @@ describe('FT.SEARCH', () => { total: 2, documents: [{ id: '1', - value: Object.create(null) + value: {} }, { id: '2', - value: Object.create(null) + value: {} }] } ); }, GLOBAL.SERVERS.OPEN); + testUtils.testWithClient('with data', async client => { + await Promise.all([ + client.ft.create('index', { + field: 'TEXT' + }), + client.hSet('1', 'field', '1') + ]); + + const reply = await client.ft.search('index', '*'); + + // Transformed reply has { total, documents } + assert.ok(reply !== null && typeof reply === 'object'); + assert.equal(typeof reply.total, 'number'); + assert.equal(reply.total, 1); + assert.ok(Array.isArray(reply.documents)); + assert.equal(reply.documents.length, 1); + }, GLOBAL.SERVERS.OPEN); + testUtils.testWithClient('properly parse content/nocontent scenarios', async client => { const indexName = 'foo'; diff --git a/packages/search/lib/commands/SEARCH_NOCONTENT.spec.ts b/packages/search/lib/commands/SEARCH_NOCONTENT.spec.ts index cd37409b5bb..1e0ed3d10a2 100644 --- a/packages/search/lib/commands/SEARCH_NOCONTENT.spec.ts +++ b/packages/search/lib/commands/SEARCH_NOCONTENT.spec.ts @@ -32,5 +32,24 @@ describe('FT.SEARCH NOCONTENT', () => { } ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('returns structured reply', async client => { + await Promise.all([ + client.ft.create('index', { + field: 'TEXT' + }), + client.hSet('1', 'field', 'field1'), + client.hSet('2', 'field', 'field2') + ]); + + const reply = await client.ft.searchNoContent('index', '*'); + + // Transformed reply has { total, documents } + assert.ok(reply !== null && typeof reply === 'object'); + assert.equal(typeof reply.total, 'number'); + assert.equal(reply.total, 2); + assert.ok(Array.isArray(reply.documents)); + assert.equal(reply.documents.length, 2); + }, GLOBAL.SERVERS.OPEN); }); }); diff --git a/packages/search/lib/commands/SYNDUMP.spec.ts b/packages/search/lib/commands/SYNDUMP.spec.ts index 88bf50cfb54..0323ec67db5 100644 --- a/packages/search/lib/commands/SYNDUMP.spec.ts +++ b/packages/search/lib/commands/SYNDUMP.spec.ts @@ -22,4 +22,25 @@ describe('FT.SYNDUMP', () => { assert.deepEqual(reply, {}); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ft.synDump with data', async client => { + await client.ft.create('index', { + field: SCHEMA_FIELD_TYPE.TEXT + }); + + await client.ft.synUpdate('index', 'group1', ['hello', 'hi']); + + const reply = await client.ft.synDump('index'); + + // RESP2 returns a flat array that transformReply converts to an object + // Each key should map to an array of synonym group IDs (as Buffer[]) + assert.ok(reply !== null && typeof reply === 'object'); + assert.ok('hello' in reply); + assert.ok('hi' in reply); + assert.ok(Array.isArray(reply.hello)); + assert.ok(Array.isArray(reply.hi)); + assert.ok(reply.hello.length > 0); + assert.ok(reply.hi.length > 0); + }, GLOBAL.SERVERS.OPEN); + }); diff --git a/packages/search/lib/commands/TAGVALS.spec.ts b/packages/search/lib/commands/TAGVALS.spec.ts index f0d83c9f7ad..9766fe7de73 100644 --- a/packages/search/lib/commands/TAGVALS.spec.ts +++ b/packages/search/lib/commands/TAGVALS.spec.ts @@ -22,4 +22,27 @@ describe('FT.TAGVALS', () => { assert.deepEqual(reply, []); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ft.tagVals with data', async client => { + await client.ft.create('index', { + tags: { + type: SCHEMA_FIELD_TYPE.TAG, + SEPARATOR: ',' + } + }); + + await Promise.all([ + client.hSet('doc:1', 'tags', 'alpha,beta'), + client.hSet('doc:2', 'tags', 'beta,gamma'), + client.hSet('doc:3', 'tags', 'alpha,delta') + ]); + + const reply = await client.ft.tagVals('index', 'tags'); + + // RESP2 returns an Array; RESP3 returns a Set + assert.ok(Array.isArray(reply)); + assert.equal(reply.length, 4); + const sorted = reply.slice().sort(); + assert.deepEqual(sorted, ['alpha', 'beta', 'delta', 'gamma']); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/search/lib/commands/_LIST.spec.ts b/packages/search/lib/commands/_LIST.spec.ts index dfe32f2e29d..ddbad81f122 100644 --- a/packages/search/lib/commands/_LIST.spec.ts +++ b/packages/search/lib/commands/_LIST.spec.ts @@ -17,4 +17,20 @@ describe('_LIST', () => { [] ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ft._list with indexes', async client => { + const indexName = 'test-index'; + await client.ft.create(indexName, { + field: { + type: 'TEXT' + } + }); + + const reply = await client.ft._list(); + + // Assert RESP2 structure: Array of strings + assert.ok(Array.isArray(reply), 'reply should be an array'); + assert.ok(reply.includes(indexName), `reply should include ${indexName}`); + assert.equal(typeof reply[0], 'string', 'array elements should be strings'); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/time-series/lib/commands/INFO.spec.ts b/packages/time-series/lib/commands/INFO.spec.ts index 994cb281915..f6d37644e5f 100644 --- a/packages/time-series/lib/commands/INFO.spec.ts +++ b/packages/time-series/lib/commands/INFO.spec.ts @@ -26,6 +26,7 @@ describe('TS.INFO', () => { assertInfo(await client.ts.info('key') as any); }, GLOBAL.SERVERS.OPEN); + }); export function assertInfo(info: InfoReply): void { diff --git a/packages/time-series/lib/commands/INFO_DEBUG.spec.ts b/packages/time-series/lib/commands/INFO_DEBUG.spec.ts index ff9d6aa3c72..26c61035640 100644 --- a/packages/time-series/lib/commands/INFO_DEBUG.spec.ts +++ b/packages/time-series/lib/commands/INFO_DEBUG.spec.ts @@ -38,4 +38,27 @@ describe('TS.INFO_DEBUG', () => { assert.equal(typeof chunk.bytesPerSample, 'string'); } }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ts.infoDebug with data', async client => { + await Promise.all([ + client.ts.create('key', { + LABELS: { id: '1' }, + DUPLICATE_POLICY: TIME_SERIES_DUPLICATE_POLICIES.LAST + }), + client.ts.create('key2'), + client.ts.createRule('key', 'key2', TIME_SERIES_AGGREGATION_TYPE.COUNT, 5), + client.ts.add('key', 1, 10) + ]); + + const infoDebug = await client.ts.infoDebug('key') as any; + // RESP3 returns a Map, verify key fields exist with correct types + assert.equal(typeof infoDebug.totalSamples, 'number'); + assert.equal(typeof infoDebug.memoryUsage, 'number'); + assert.equal(typeof infoDebug.firstTimestamp, 'number'); + assert.equal(typeof infoDebug.lastTimestamp, 'number'); + assert.equal(typeof infoDebug.retentionTime, 'number'); + assert.equal(typeof infoDebug.chunkCount, 'number'); + assert.equal(typeof infoDebug.keySelfName, 'string'); + assert.ok(infoDebug.Chunks !== undefined || infoDebug.chunks !== undefined); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/time-series/lib/commands/MGET.spec.ts b/packages/time-series/lib/commands/MGET.spec.ts index ba2e571be49..57fb61bef04 100644 --- a/packages/time-series/lib/commands/MGET.spec.ts +++ b/packages/time-series/lib/commands/MGET.spec.ts @@ -30,7 +30,7 @@ describe('TS.MGET', () => { client.ts.mGet('label=value') ]); - assert.deepStrictEqual(reply, Object.create(null, { + assert.deepStrictEqual(reply, Object.defineProperties({}, { key: { configurable: true, enumerable: true, diff --git a/packages/time-series/lib/commands/MGET_SELECTED_LABELS.spec.ts b/packages/time-series/lib/commands/MGET_SELECTED_LABELS.spec.ts index d79c463fc7d..ded9d1be9dc 100644 --- a/packages/time-series/lib/commands/MGET_SELECTED_LABELS.spec.ts +++ b/packages/time-series/lib/commands/MGET_SELECTED_LABELS.spec.ts @@ -18,13 +18,13 @@ describe('TS.MGET_SELECTED_LABELS', () => { }), client.ts.mGetSelectedLabels('label=value', ['label', 'NX']) ]); - - assert.deepStrictEqual(reply, Object.create(null, { + + assert.deepStrictEqual(reply, Object.defineProperties({}, { key: { configurable: true, enumerable: true, value: { - labels: Object.create(null, { + labels: Object.defineProperties({}, { label: { configurable: true, enumerable: true, diff --git a/packages/time-series/lib/commands/MGET_WITHLABELS.spec.ts b/packages/time-series/lib/commands/MGET_WITHLABELS.spec.ts index 33fc5308444..0c3738e27a2 100644 --- a/packages/time-series/lib/commands/MGET_WITHLABELS.spec.ts +++ b/packages/time-series/lib/commands/MGET_WITHLABELS.spec.ts @@ -18,13 +18,13 @@ describe('TS.MGET_WITHLABELS', () => { }), client.ts.mGetWithLabels('label=value') ]); - - assert.deepStrictEqual(reply, Object.create(null, { + + assert.deepStrictEqual(reply, Object.defineProperties({}, { key: { configurable: true, enumerable: true, value: { - labels: Object.create(null, { + labels: Object.defineProperties({}, { label: { configurable: true, enumerable: true, @@ -39,4 +39,27 @@ describe('TS.MGET_WITHLABELS', () => { } })); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ts.mGetWithLabels with data', async client => { + const [, reply] = await Promise.all([ + client.ts.add('key', 0, 0, { + LABELS: { label: 'value' } + }), + client.ts.mGetWithLabels('label=value') + ]); + + // RESP3 returns Map instead of Array at top level and for labels + assert.ok(typeof reply === 'object' && !Array.isArray(reply)); + assert.ok('key' in reply); + + const entry = reply['key']; + // Labels should be a Map/object, not an array of tuples + assert.ok(typeof entry.labels === 'object' && !Array.isArray(entry.labels)); + assert.equal(entry.labels['label'], 'value'); + + // Sample value should be a number (Double in RESP3) not a string + assert.equal(typeof entry.sample.value, 'number'); + assert.equal(entry.sample.value, 0); + assert.equal(entry.sample.timestamp, 0); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/time-series/lib/commands/MRANGE.spec.ts b/packages/time-series/lib/commands/MRANGE.spec.ts index 94c8e72983a..893d3b02303 100644 --- a/packages/time-series/lib/commands/MRANGE.spec.ts +++ b/packages/time-series/lib/commands/MRANGE.spec.ts @@ -48,7 +48,36 @@ describe('TS.MRANGE', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { + key: { + configurable: true, + enumerable: true, + value: [{ + timestamp: 0, + value: 0 + }] + } + }) + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ts.mRange with data', async client => { + const [, reply] = await Promise.all([ + client.ts.add('key', 0, 0, { + LABELS: { + label: 'value' + } + }), + client.ts.mRange('-', '+', 'label=value', { + COUNT: 1 + }) + ]); + + // RESP3 returns Map reply (converted to object) with Double values instead of + // RESP2's Array reply with Simple string values + assert.deepStrictEqual( + reply, + Object.defineProperties({}, { key: { configurable: true, enumerable: true, diff --git a/packages/time-series/lib/commands/MRANGE_GROUPBY.spec.ts b/packages/time-series/lib/commands/MRANGE_GROUPBY.spec.ts index f8171750064..54ecaa8333f 100644 --- a/packages/time-series/lib/commands/MRANGE_GROUPBY.spec.ts +++ b/packages/time-series/lib/commands/MRANGE_GROUPBY.spec.ts @@ -78,11 +78,11 @@ describe('TS.MRANGE_GROUPBY', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { 'label=value': { configurable: true, enumerable: true, - value: { + value: { samples: [{ timestamp: 0, value: 0 @@ -110,6 +110,30 @@ describe('TS.MRANGE_GROUPBY', () => { minimumDockerVersion: [8, 6] }); + testUtils.testWithClient('client.ts.mRangeGroupBy with data', async client => { + const [, reply] = await Promise.all([ + client.ts.add('key', 0, 0, { + LABELS: { label: 'value' } + }), + client.ts.mRangeGroupBy('-', '+', 'label=value', { + REDUCE: TIME_SERIES_REDUCERS.AVG, + label: 'label' + }) + ]); + + // Transformed reply is an object keyed by group + assert.ok(typeof reply === 'object' && !Array.isArray(reply)); + assert.ok('label=value' in reply); + + const entry = reply['label=value']; + + // Sample values should be numbers + assert.equal(entry.samples.length, 1); + assert.equal(typeof entry.samples[0].value, 'number'); + assert.equal(entry.samples[0].timestamp, 0); + assert.equal(entry.samples[0].value, 0); + }, GLOBAL.SERVERS.OPEN); + testUtils.testWithClient('client.ts.mRangeGroupBy with COUNTALL', async client => { await client.ts.add('key-countall', 0, 1, { LABELS: { label: 'countall' } diff --git a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.spec.ts b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.spec.ts index 92680dea375..e76944ff271 100644 --- a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.spec.ts +++ b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.spec.ts @@ -44,12 +44,53 @@ describe('TS.MRANGE_SELECTED_LABELS', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { key: { configurable: true, enumerable: true, value: { - labels: Object.create(null, { + labels: Object.defineProperties({}, { + label: { + configurable: true, + enumerable: true, + value: 'value' + }, + NX: { + configurable: true, + enumerable: true, + value: null + } + }), + samples: [{ + timestamp: 0, + value: 0 + }] + } + } + }) + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ts.mRangeSelectedLabels with data', async client => { + const [, reply] = await Promise.all([ + client.ts.add('key', 0, 0, { + LABELS: { label: 'value' } + }), + client.ts.mRangeSelectedLabels('-', '+', ['label', 'NX'], 'label=value', { + COUNT: 1 + }) + ]); + + // RESP3 returns Map reply (converted to object) with Double values instead of + // RESP2's Array reply with Simple string values, and labels as Map instead of Array of pairs + assert.deepStrictEqual( + reply, + Object.defineProperties({}, { + key: { + configurable: true, + enumerable: true, + value: { + labels: Object.defineProperties({}, { label: { configurable: true, enumerable: true, diff --git a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_GROUPBY.spec.ts b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_GROUPBY.spec.ts index 4e5b2b47094..6fd797d7b9e 100644 --- a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_GROUPBY.spec.ts +++ b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_GROUPBY.spec.ts @@ -52,12 +52,12 @@ describe('TS.MRANGE_SELECTED_LABELS_GROUPBY', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { 'label=value': { configurable: true, enumerable: true, value: { - labels: Object.create(null, { + labels: Object.defineProperties({}, { label: { configurable: true, enumerable: true, @@ -78,4 +78,33 @@ describe('TS.MRANGE_SELECTED_LABELS_GROUPBY', () => { }) ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ts.mRangeSelectedLabelsGroupBy with data', async client => { + const [, reply] = await Promise.all([ + client.ts.add('key', 0, 0, { + LABELS: { label: 'value' } + }), + client.ts.mRangeSelectedLabelsGroupBy('-', '+', ['label', 'NX'], 'label=value', { + REDUCE: TIME_SERIES_REDUCERS.AVG, + label: 'label' + }) + ]); + + // Transformed reply is an object keyed by group + assert.ok(typeof reply === 'object' && !Array.isArray(reply)); + assert.ok('label=value' in reply); + + const entry = reply['label=value']; + + // Labels should be an object + assert.ok(typeof entry.labels === 'object' && !Array.isArray(entry.labels)); + assert.equal(entry.labels['label'], 'value'); + assert.equal(entry.labels['NX'], null); + + // Sample values should be numbers + assert.equal(entry.samples.length, 1); + assert.equal(typeof entry.samples[0].value, 'number'); + assert.equal(entry.samples[0].value, 0); + assert.equal(entry.samples[0].timestamp, 0); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/time-series/lib/commands/MRANGE_WITHLABELS.spec.ts b/packages/time-series/lib/commands/MRANGE_WITHLABELS.spec.ts index eab2e1fadbe..8fd68cbd6e8 100644 --- a/packages/time-series/lib/commands/MRANGE_WITHLABELS.spec.ts +++ b/packages/time-series/lib/commands/MRANGE_WITHLABELS.spec.ts @@ -45,12 +45,12 @@ describe('TS.MRANGE_WITHLABELS', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { key: { configurable: true, enumerable: true, value: { - labels: Object.create(null, { + labels: Object.defineProperties({}, { label: { configurable: true, enumerable: true, @@ -66,4 +66,28 @@ describe('TS.MRANGE_WITHLABELS', () => { }) ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ts.mRangeWithLabels with data', async client => { + const [, reply] = await Promise.all([ + client.ts.add('key', 0, 0, { + LABELS: { label: 'value' } + }), + client.ts.mRangeWithLabels('-', '+', 'label=value') + ]); + + // RESP3 returns Map instead of Array at top level and for labels + assert.ok(typeof reply === 'object' && !Array.isArray(reply)); + assert.ok('key' in reply); + + const entry = reply['key']; + // Labels should be a Map/object, not an array of tuples + assert.ok(typeof entry.labels === 'object' && !Array.isArray(entry.labels)); + assert.equal(entry.labels['label'], 'value'); + + // Sample values should be numbers (Double in RESP3) not strings + assert.equal(entry.samples.length, 1); + assert.equal(typeof entry.samples[0].value, 'number'); + assert.equal(entry.samples[0].value, 0); + assert.equal(entry.samples[0].timestamp, 0); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/time-series/lib/commands/MRANGE_WITHLABELS_GROUPBY.spec.ts b/packages/time-series/lib/commands/MRANGE_WITHLABELS_GROUPBY.spec.ts index 4a8b8fe707f..86beae26612 100644 --- a/packages/time-series/lib/commands/MRANGE_WITHLABELS_GROUPBY.spec.ts +++ b/packages/time-series/lib/commands/MRANGE_WITHLABELS_GROUPBY.spec.ts @@ -53,12 +53,12 @@ describe('TS.MRANGE_WITHLABELS_GROUPBY', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { 'label=value': { configurable: true, enumerable: true, value: { - labels: Object.create(null, { + labels: Object.defineProperties({}, { label: { configurable: true, enumerable: true, diff --git a/packages/time-series/lib/commands/MREVRANGE.spec.ts b/packages/time-series/lib/commands/MREVRANGE.spec.ts index 09051103f8b..174621118e9 100644 --- a/packages/time-series/lib/commands/MREVRANGE.spec.ts +++ b/packages/time-series/lib/commands/MREVRANGE.spec.ts @@ -48,7 +48,36 @@ describe('TS.MREVRANGE', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { + key: { + configurable: true, + enumerable: true, + value: [{ + timestamp: 0, + value: 0 + }] + } + }) + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ts.mRevRange with data', async client => { + const [, reply] = await Promise.all([ + client.ts.add('key', 0, 0, { + LABELS: { + label: 'value' + } + }), + client.ts.mRevRange('-', '+', 'label=value', { + COUNT: 1 + }) + ]); + + // RESP3 returns Map reply (converted to object) with Double values instead of + // RESP2's Array reply with Simple string values + assert.deepStrictEqual( + reply, + Object.defineProperties({}, { key: { configurable: true, enumerable: true, diff --git a/packages/time-series/lib/commands/MREVRANGE_GROUPBY.spec.ts b/packages/time-series/lib/commands/MREVRANGE_GROUPBY.spec.ts index d32d675ad0a..8ddc488ff3a 100644 --- a/packages/time-series/lib/commands/MREVRANGE_GROUPBY.spec.ts +++ b/packages/time-series/lib/commands/MREVRANGE_GROUPBY.spec.ts @@ -51,11 +51,11 @@ describe('TS.MREVRANGE_GROUPBY', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { 'label=value': { configurable: true, enumerable: true, - value: { + value: { samples: [{ timestamp: 0, value: 0 @@ -65,4 +65,23 @@ describe('TS.MREVRANGE_GROUPBY', () => { }) ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ts.mRevRangeGroupBy with data', async client => { + const [, reply] = await Promise.all([ + client.ts.add('key', 0, 0, { + LABELS: { label: 'value' } + }), + client.ts.mRevRangeGroupBy('-', '+', 'label=value', { + REDUCE: TIME_SERIES_REDUCERS.AVG, + label: 'label' + }) + ]); + + // Transformed reply is an object keyed by group + assert.ok(reply['label=value'], 'expected group key in reply'); + assert.deepStrictEqual(reply['label=value'].samples, [{ + timestamp: 0, + value: 0 + }]); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS.spec.ts b/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS.spec.ts index f68e34727c2..2366e7613ba 100644 --- a/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS.spec.ts +++ b/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS.spec.ts @@ -44,12 +44,12 @@ describe('TS.MREVRANGE_SELECTED_LABELS', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { key: { configurable: true, enumerable: true, value: { - labels: Object.create(null, { + labels: Object.defineProperties({}, { label: { configurable: true, enumerable: true, @@ -66,9 +66,34 @@ describe('TS.MREVRANGE_SELECTED_LABELS', () => { value: 0 }] } - + } }) ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ts.mRevRangeSelectedLabels with data', async client => { + const [, reply] = await Promise.all([ + client.ts.add('key', 0, 0, { + LABELS: { label: 'value' } + }), + client.ts.mRevRangeSelectedLabels('-', '+', ['label'], 'label=value', { + COUNT: 1 + }) + ]); + + // RESP3 returns Map instead of Array at top level and for labels + assert.ok(typeof reply === 'object' && !Array.isArray(reply)); + assert.ok('key' in reply); + + const entry = reply['key']; + // Labels should be a Map/object, not an array of tuples + assert.ok(typeof entry.labels === 'object' && !Array.isArray(entry.labels)); + + // Sample values should be numbers (Double in RESP3) not strings + assert.equal(entry.samples.length, 1); + assert.equal(typeof entry.samples[0].value, 'number'); + assert.equal(entry.samples[0].value, 0); + assert.equal(entry.samples[0].timestamp, 0); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_GROUPBY.spec.ts b/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_GROUPBY.spec.ts index 444bb2f3d24..be71327a845 100644 --- a/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_GROUPBY.spec.ts +++ b/packages/time-series/lib/commands/MREVRANGE_SELECTED_LABELS_GROUPBY.spec.ts @@ -52,12 +52,12 @@ describe('TS.MREVRANGE_SELECTED_LABELS_GROUPBY', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { 'label=value': { configurable: true, enumerable: true, value: { - labels: Object.create(null, { + labels: Object.defineProperties({}, { label: { configurable: true, enumerable: true, @@ -78,4 +78,30 @@ describe('TS.MREVRANGE_SELECTED_LABELS_GROUPBY', () => { }) ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ts.mRevRangeSelectedLabelsGroupBy with data', async client => { + const [, reply] = await Promise.all([ + client.ts.add('key', 0, 0, { + LABELS: { label: 'value' } + }), + client.ts.mRevRangeSelectedLabelsGroupBy('-', '+', ['label', 'NX'], 'label=value', { + REDUCE: TIME_SERIES_REDUCERS.AVG, + label: 'label' + }) + ]); + + // Transformed reply is an object keyed by group + assert.ok(typeof reply === 'object' && !Array.isArray(reply)); + assert.ok('label=value' in reply); + + const entry = reply['label=value']; + // Labels should be an object + assert.ok(typeof entry.labels === 'object'); + + // Sample values should be numbers + assert.equal(entry.samples.length, 1); + assert.equal(typeof entry.samples[0].value, 'number'); + assert.equal(entry.samples[0].timestamp, 0); + assert.equal(entry.samples[0].value, 0); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/time-series/lib/commands/MREVRANGE_WITHLABELS.spec.ts b/packages/time-series/lib/commands/MREVRANGE_WITHLABELS.spec.ts index da43a715f2e..a36c6d3da1f 100644 --- a/packages/time-series/lib/commands/MREVRANGE_WITHLABELS.spec.ts +++ b/packages/time-series/lib/commands/MREVRANGE_WITHLABELS.spec.ts @@ -45,12 +45,12 @@ describe('TS.MREVRANGE_WITHLABELS', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { key: { configurable: true, enumerable: true, value: { - labels: Object.create(null, { + labels: Object.defineProperties({}, { label: { configurable: true, enumerable: true, @@ -66,4 +66,28 @@ describe('TS.MREVRANGE_WITHLABELS', () => { }) ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ts.mRevRangeWithLabels with data', async client => { + const [, reply] = await Promise.all([ + client.ts.add('key', 0, 0, { + LABELS: { label: 'value' } + }), + client.ts.mRevRangeWithLabels('-', '+', 'label=value') + ]); + + // RESP3 returns Map reply (converted to object) instead of Array reply + assert.ok(typeof reply === 'object' && !Array.isArray(reply)); + assert.ok('key' in reply); + + const entry = reply['key']; + // Labels should be a Map/object (RESP3) not an array of tuples (RESP2) + assert.ok(typeof entry.labels === 'object' && !Array.isArray(entry.labels)); + assert.equal(entry.labels['label'], 'value'); + + // Sample values should be numbers (Double in RESP3) not strings (Simple string in RESP2) + assert.equal(entry.samples.length, 1); + assert.equal(typeof entry.samples[0].value, 'number'); + assert.equal(entry.samples[0].value, 0); + assert.equal(entry.samples[0].timestamp, 0); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/time-series/lib/commands/MREVRANGE_WITHLABELS_GROUPBY.spec.ts b/packages/time-series/lib/commands/MREVRANGE_WITHLABELS_GROUPBY.spec.ts index f4e6df9f0c6..691cad15cfc 100644 --- a/packages/time-series/lib/commands/MREVRANGE_WITHLABELS_GROUPBY.spec.ts +++ b/packages/time-series/lib/commands/MREVRANGE_WITHLABELS_GROUPBY.spec.ts @@ -53,12 +53,12 @@ describe('TS.MREVRANGE_WITHLABELS_GROUPBY', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { 'label=value': { configurable: true, enumerable: true, value: { - labels: Object.create(null, { + labels: Object.defineProperties({}, { label: { configurable: true, enumerable: true, From 261f28f7147153c4b195b55b53a19ced148205c3 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 7 Apr 2026 16:56:24 +0300 Subject: [PATCH 02/28] feat(client): default connections to RESP3 - Switch default RESP behavior to RESP3 across client entry points. - Align cluster and sentinel command paths with RESP3 defaults. - Keep compatibility normalization and module fixes for later commits. --- packages/bloom/lib/test-utils.ts | 1 + packages/client/lib/RESP/types.ts | 12 ++------ .../client/enterprise-maintenance-manager.ts | 15 +++++----- packages/client/lib/client/index.ts | 24 ++++++++------- packages/client/lib/client/legacy-mode.ts | 2 +- packages/client/lib/client/multi-command.ts | 2 +- packages/client/lib/client/pool.ts | 14 ++------- packages/client/lib/cluster/cluster-slots.ts | 2 +- packages/client/lib/cluster/index.ts | 6 ++-- packages/client/lib/cluster/multi-command.ts | 2 +- packages/client/lib/commander.ts | 23 ++++---------- packages/client/lib/commands/HOTKEYS_GET.ts | 3 +- packages/client/lib/commands/XREAD.ts | 3 +- packages/client/lib/sentinel/index.ts | 10 +++---- .../client/lib/sentinel/multi-commands.ts | 2 +- packages/client/lib/sentinel/test-util.ts | 6 ++-- packages/client/lib/sentinel/types.ts | 4 +-- packages/client/lib/test-utils.ts | 5 +++- packages/json/lib/test-utils.ts | 1 + packages/redis/index.ts | 6 ++-- packages/search/lib/commands/AGGREGATE.ts | 1 - .../lib/commands/AGGREGATE_WITHCURSOR.ts | 1 - packages/search/lib/commands/CURSOR_READ.ts | 1 - packages/search/lib/commands/HYBRID.ts | 1 - packages/search/lib/commands/INFO.ts | 1 - .../search/lib/commands/PROFILE_AGGREGATE.ts | 1 - .../search/lib/commands/PROFILE_SEARCH.ts | 1 - packages/search/lib/commands/SEARCH.ts | 1 - .../search/lib/commands/SEARCH_NOCONTENT.ts | 1 - packages/search/lib/commands/SPELLCHECK.ts | 1 - packages/search/lib/test-utils.ts | 2 +- packages/test-utils/lib/index.ts | 30 ++++++++++++++----- packages/time-series/lib/commands/INFO.ts | 1 - .../time-series/lib/commands/INFO_DEBUG.ts | 1 - packages/time-series/lib/test-utils.ts | 1 + 35 files changed, 83 insertions(+), 105 deletions(-) diff --git a/packages/bloom/lib/test-utils.ts b/packages/bloom/lib/test-utils.ts index f05be8f77ed..a2a88afcc38 100644 --- a/packages/bloom/lib/test-utils.ts +++ b/packages/bloom/lib/test-utils.ts @@ -13,6 +13,7 @@ export const GLOBAL = { OPEN: { serverArguments: [], clientOptions: { + RESP: 3 as const, modules: RedisBloomModules } } diff --git a/packages/client/lib/RESP/types.ts b/packages/client/lib/RESP/types.ts index d1d26e6ccbf..ae24c677ea1 100644 --- a/packages/client/lib/RESP/types.ts +++ b/packages/client/lib/RESP/types.ts @@ -292,7 +292,6 @@ export type Command = { parseCommand(this: void, parser: CommandParser, ...args: Array): void; TRANSFORM_LEGACY_REPLY?: boolean; transformReply: TransformReply | Record; - unstableResp3?: boolean; }; export type RedisCommands = Record; @@ -321,18 +320,11 @@ export interface CommanderConfig< scripts?: S; /** * Specifies the Redis Serialization Protocol version to use. - * RESP2 is the default (value 2), while RESP3 (value 3) provides + * RESP3 is the default (value 3), while RESP2 (value 2) remains available for compatibility. + * RESP3 provides * additional data types and features introduced in Redis 6.0. */ RESP?: RESP; - /** - * When set to true, enables commands that have unstable RESP3 implementations. - * When using RESP3 protocol, commands marked as having unstable RESP3 support - * will throw an error unless this flag is explicitly set to true. - * This primarily affects modules like Redis Search where response formats - * in RESP3 mode may change in future versions. - */ - unstableResp3?: boolean; } type Resp2Array = ( diff --git a/packages/client/lib/client/enterprise-maintenance-manager.ts b/packages/client/lib/client/enterprise-maintenance-manager.ts index df58f1be292..3b3ae9fe8f9 100644 --- a/packages/client/lib/client/enterprise-maintenance-manager.ts +++ b/packages/client/lib/client/enterprise-maintenance-manager.ts @@ -81,7 +81,7 @@ export default class EnterpriseMaintenanceManager { static setupDefaultMaintOptions(options: RedisClientOptions) { if (options.maintNotifications === undefined) { options.maintNotifications = - options?.RESP === 3 ? "auto" : "disabled"; + (options?.RESP ?? 3) === 3 ? "auto" : "disabled"; } if (options.maintEndpointType === undefined) { options.maintEndpointType = "auto"; @@ -123,14 +123,13 @@ export default class EnterpriseMaintenanceManager { errorHandler: (error: Error) => { dbgMaintenance("handshake failed:", error); - publish(CHANNELS.ERROR, () => ({ - error, - origin: 'client', - internal: true, - clientId, - })); - if (options.maintNotifications === "enabled") { + publish(CHANNELS.ERROR, () => ({ + error, + origin: 'client', + internal: true, + clientId, + })); throw error; } diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index cb20201f03d..530e69cc241 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -235,7 +235,7 @@ export type RedisClientExtensions< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {} > = ( WithCommands & @@ -248,7 +248,7 @@ export type RedisClientType< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {} > = ( RedisClient & @@ -327,7 +327,7 @@ export default class RedisClient< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2 + RESP extends RespVersions = 3 >(config?: CommanderConfig) { @@ -360,7 +360,7 @@ export default class RedisClient< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {} >(this: void, options?: RedisClientOptions) { return RedisClient.factory(options)(options); @@ -693,16 +693,17 @@ export default class RedisClient< } #validateOptions(options?: RedisClientOptions) { - if (options?.clientSideCache && options?.RESP !== 3) { + const resp = options?.RESP ?? 3; + if (options?.clientSideCache && resp !== 3) { throw new Error('Client Side Caching is only supported with RESP3'); } - if (options?.emitInvalidate && options?.RESP !== 3) { + if (options?.emitInvalidate && resp !== 3) { throw new Error('emitInvalidate is only supported with RESP3'); } if (options?.clientSideCache && options?.emitInvalidate) { throw new Error('emitInvalidate is not supported (or necessary) when clientSideCache is enabled'); } - if (options?.maintNotifications && options?.maintNotifications !== 'disabled' && options?.RESP !== 3) { + if (options?.maintNotifications && options?.maintNotifications !== 'disabled' && resp !== 3) { throw new Error('Graceful Maintenance is only supported with RESP3'); } } @@ -746,7 +747,7 @@ export default class RedisClient< #initiateQueue(clientId: string): RedisCommandsQueue { return new RedisCommandsQueue( - this.#options.RESP ?? 2, + this.#options.RESP ?? 3, this.#options.commandsQueueMaxLength, (channel, listeners) => this.emit('sharded-channel-moved', channel, listeners), clientId @@ -758,7 +759,7 @@ export default class RedisClient< */ private reAuthenticate = async (credentials: BasicAuth) => { // Re-authentication is not supported on RESP2 with PubSub active - if (!(this.isPubSubActive && !this.#options.RESP)) { + if (!(this.isPubSubActive && (this.#options.RESP ?? 3) === 2)) { await this.sendCommand( parseArgs(COMMANDS.AUTH, { username: credentials.username, @@ -808,8 +809,9 @@ export default class RedisClient< > { const commands = []; const cp = this.#options.credentialsProvider; + const resp = this.#options.RESP ?? 3; - if (this.#options.RESP) { + if (resp !== 2) { const hello: HelloOptions = {}; if (cp && cp.type === 'async-credentials-provider') { @@ -839,7 +841,7 @@ export default class RedisClient< hello.SETNAME = this.#options.name; } - commands.push({ cmd: parseArgs(HELLO, this.#options.RESP, hello) }); + commands.push({ cmd: parseArgs(HELLO, resp, hello) }); } else { if (cp && cp.type === 'async-credentials-provider') { const credentials = await cp.credentials(); diff --git a/packages/client/lib/client/legacy-mode.ts b/packages/client/lib/client/legacy-mode.ts index 03e7cf4efe1..3bdef010637 100644 --- a/packages/client/lib/client/legacy-mode.ts +++ b/packages/client/lib/client/legacy-mode.ts @@ -81,7 +81,7 @@ export class RedisLegacyClient { ) { this.#client = client; - const RESP = client.options?.RESP ?? 2; + const RESP = client.options?.RESP ?? 3; for (const [name, command] of Object.entries(COMMANDS)) { // TODO: as any? (this as any)[name] = RedisLegacyClient.#createCommand( diff --git a/packages/client/lib/client/multi-command.ts b/packages/client/lib/client/multi-command.ts index 2c0d8a2acd1..57a4e9494a8 100644 --- a/packages/client/lib/client/multi-command.ts +++ b/packages/client/lib/client/multi-command.ts @@ -176,7 +176,7 @@ export default class RedisClientMultiCommand { M extends RedisModules = Record, F extends RedisFunctions = Record, S extends RedisScripts = Record, - RESP extends RespVersions = 2 + RESP extends RespVersions = 3 >(config?: CommanderConfig) { return attachConfig({ BaseClass: RedisClientMultiCommand, diff --git a/packages/client/lib/client/pool.ts b/packages/client/lib/client/pool.ts index 65f9875dc92..9dd4bb1bd2e 100644 --- a/packages/client/lib/client/pool.ts +++ b/packages/client/lib/client/pool.ts @@ -70,16 +70,6 @@ export interface RedisPoolOptions { * ``` */ clientSideCache?: PooledClientSideCacheProvider | ClientSideCacheConfig; - /** - * Enable experimental support for RESP3 module commands. - * - * When enabled, allows the use of module commands that have been adapted - * for the RESP3 protocol. This is an unstable feature and may change in - * future versions. - * - * @default false - */ - unstableResp3Modules?: boolean; } export type PoolTask< @@ -103,7 +93,7 @@ export type RedisClientPoolType< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {} > = ( RedisClientPool & @@ -121,7 +111,7 @@ export class RedisClientPool< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {} > extends EventEmitter { static #createCommand(command: Command, resp: RespVersions) { diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index 98a42420ae8..f4ed96acb14 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -130,7 +130,7 @@ export default class RedisClusterSlots< } #validateOptions(options?: RedisClusterOptions) { - if (options?.clientSideCache && options?.RESP !== 3) { + if (options?.clientSideCache && (options?.RESP ?? 3) !== 3) { throw new Error('Client Side Caching is only supported with RESP3'); } } diff --git a/packages/client/lib/cluster/index.ts b/packages/client/lib/cluster/index.ts index 1c994d03314..a8f40cccb45 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -132,7 +132,7 @@ export type RedisClusterType< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {}, // POLICIES extends CommandPolicies = {} > = ( @@ -235,7 +235,7 @@ export default class RedisCluster< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {}, // POLICIES extends CommandPolicies = {} >(config?: ClusterCommander) { @@ -266,7 +266,7 @@ export default class RedisCluster< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {}, // POLICIES extends CommandPolicies = {} >(options?: RedisClusterOptions) { diff --git a/packages/client/lib/cluster/multi-command.ts b/packages/client/lib/cluster/multi-command.ts index 8cceca5ec37..329559985e4 100644 --- a/packages/client/lib/cluster/multi-command.ts +++ b/packages/client/lib/cluster/multi-command.ts @@ -179,7 +179,7 @@ export default class RedisClusterMultiCommand { M extends RedisModules = Record, F extends RedisFunctions = Record, S extends RedisScripts = Record, - RESP extends RespVersions = 2 + RESP extends RespVersions = 3 >(config?: CommanderConfig) { return attachConfig({ BaseClass: RedisClusterMultiCommand, diff --git a/packages/client/lib/commander.ts b/packages/client/lib/commander.ts index 628b29972c6..e65ff6ee1dc 100644 --- a/packages/client/lib/commander.ts +++ b/packages/client/lib/commander.ts @@ -15,11 +15,6 @@ interface AttachConfigOptions< config?: CommanderConfig; } -/* FIXME: better error message / link */ -function throwResp3SearchModuleUnstableError() { - throw new Error('Some RESP3 results for Redis Query Engine responses may change. Refer to the readme for guidance'); -} - export function attachConfig< M extends RedisModules, F extends RedisFunctions, @@ -34,26 +29,18 @@ export function attachConfig< createScriptCommand, config }: AttachConfigOptions) { - const RESP = config?.RESP ?? 2, + const RESP = config?.RESP ?? 3, Class: any = class extends BaseClass {}; for (const [name, command] of Object.entries(commands)) { - if (config?.RESP == 3 && command.unstableResp3 && !config.unstableResp3) { - Class.prototype[name] = throwResp3SearchModuleUnstableError; - } else { - Class.prototype[name] = createCommand(command, RESP) - } + Class.prototype[name] = createCommand(command, RESP); } if (config?.modules) { for (const [moduleName, module] of Object.entries(config.modules)) { - const fns = Object.create(null); + const fns: Record) => any> = {}; for (const [name, command] of Object.entries(module)) { - if (config.RESP == 3 && command.unstableResp3 && !config.unstableResp3) { - fns[name] = throwResp3SearchModuleUnstableError; - } else { - fns[name] = createModuleCommand(command, RESP); - } + fns[name] = createModuleCommand(command, RESP); } attachNamespace(Class.prototype, moduleName, fns); @@ -62,7 +49,7 @@ export function attachConfig< if (config?.functions) { for (const [library, commands] of Object.entries(config.functions)) { - const fns = Object.create(null); + const fns: Record) => any> = {}; for (const [name, command] of Object.entries(commands)) { fns[name] = createFunctionCommand(name, command, RESP); } diff --git a/packages/client/lib/commands/HOTKEYS_GET.ts b/packages/client/lib/commands/HOTKEYS_GET.ts index 354e0ae2fbb..febc49b4eb1 100644 --- a/packages/client/lib/commands/HOTKEYS_GET.ts +++ b/packages/client/lib/commands/HOTKEYS_GET.ts @@ -170,6 +170,5 @@ export default { return transformHotkeysGetReply(reply); }, 3: undefined as unknown as () => ReplyUnion - }, - unstableResp3: true + } } as const satisfies Command; diff --git a/packages/client/lib/commands/XREAD.ts b/packages/client/lib/commands/XREAD.ts index 60c4dce0216..78616906193 100644 --- a/packages/client/lib/commands/XREAD.ts +++ b/packages/client/lib/commands/XREAD.ts @@ -69,6 +69,5 @@ export default { transformReply: { 2: transformStreamsMessagesReplyResp2, 3: undefined as unknown as () => ReplyUnion - }, - unstableResp3: true, + } } as const satisfies Command; diff --git a/packages/client/lib/sentinel/index.ts b/packages/client/lib/sentinel/index.ts index ccb16cc0f8b..761f7135c8b 100644 --- a/packages/client/lib/sentinel/index.ts +++ b/packages/client/lib/sentinel/index.ts @@ -79,7 +79,7 @@ export class RedisSentinelClient< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {} >(config?: SentinelCommander) { const SentinelClient = attachConfig({ @@ -108,7 +108,7 @@ export class RedisSentinelClient< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {} >( options: RedisSentinelOptions, @@ -350,7 +350,7 @@ export default class RedisSentinel< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {} >(config?: SentinelCommander) { const Sentinel = attachConfig({ @@ -375,7 +375,7 @@ export default class RedisSentinel< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {} >(options: RedisSentinelOptions) { return RedisSentinel.factory(options)(options); @@ -716,7 +716,7 @@ export class RedisSentinelInternal< } #validateOptions(options?: RedisSentinelOptions) { - if (options?.clientSideCache && options?.RESP !== 3) { + if (options?.clientSideCache && (options?.RESP ?? 3) !== 3) { throw new Error('Client Side Caching is only supported with RESP3'); } } diff --git a/packages/client/lib/sentinel/multi-commands.ts b/packages/client/lib/sentinel/multi-commands.ts index d65264aa80f..be642b22675 100644 --- a/packages/client/lib/sentinel/multi-commands.ts +++ b/packages/client/lib/sentinel/multi-commands.ts @@ -167,7 +167,7 @@ export default class RedisSentinelMultiCommand { M extends RedisModules = Record, F extends RedisFunctions = Record, S extends RedisScripts = Record, - RESP extends RespVersions = 2 + RESP extends RespVersions = 3 >(config?: CommanderConfig) { return attachConfig({ BaseClass: RedisSentinelMultiCommand, diff --git a/packages/client/lib/sentinel/test-util.ts b/packages/client/lib/sentinel/test-util.ts index 951a00a38a1..69463e07d82 100644 --- a/packages/client/lib/sentinel/test-util.ts +++ b/packages/client/lib/sentinel/test-util.ts @@ -147,7 +147,7 @@ export interface SentinelController { restartNode(id: string): Promise; stopSentinel(id: string): Promise; restartSentinel(id: string): Promise; - getSentinelClient(opts?: Partial>): RedisSentinelType; + getSentinelClient(opts?: Partial>): RedisSentinelType; } export class SentinelFramework extends DockerBase { @@ -193,8 +193,10 @@ export class SentinelFramework extends DockerBase { throw new Error("cannot specify sentinel db name here"); } + const { RESP = 3, ...sentinelOptions } = opts ?? {}; const options: RedisSentinelOptions = { - ...opts, + ...sentinelOptions, + RESP, name: this.config.sentinelName, sentinelRootNodes: this.#sentinelList.map((sentinel) => { return { host: '127.0.0.1', port: sentinel.port } }), passthroughClientErrorEvents: errors diff --git a/packages/client/lib/sentinel/types.ts b/packages/client/lib/sentinel/types.ts index d6c9f5011cb..71fc5ae4f41 100644 --- a/packages/client/lib/sentinel/types.ts +++ b/packages/client/lib/sentinel/types.ts @@ -170,7 +170,7 @@ export type RedisSentinelClientType< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {}, > = ( RedisSentinelClient & @@ -184,7 +184,7 @@ export type RedisSentinelType< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {}, // POLICIES extends CommandPolicies = {} > = ( diff --git a/packages/client/lib/test-utils.ts b/packages/client/lib/test-utils.ts index 8d045bd6ca8..c41b95ee438 100644 --- a/packages/client/lib/test-utils.ts +++ b/packages/client/lib/test-utils.ts @@ -86,7 +86,10 @@ export const MATH_FUNCTION = { export const GLOBAL = { SERVERS: { OPEN: { - serverArguments: [...DEBUG_MODE_ARGS] + serverArguments: [...DEBUG_MODE_ARGS], + clientOptions: { + RESP: 3 as const + } }, PASSWORD: { serverArguments: ['--requirepass', 'password', ...DEBUG_MODE_ARGS], diff --git a/packages/json/lib/test-utils.ts b/packages/json/lib/test-utils.ts index 23d7ce2e5c1..43a8c4247cf 100644 --- a/packages/json/lib/test-utils.ts +++ b/packages/json/lib/test-utils.ts @@ -13,6 +13,7 @@ export const GLOBAL = { OPEN: { serverArguments: [], clientOptions: { + RESP: 3 as const, modules: { json: RedisJSON } diff --git a/packages/redis/index.ts b/packages/redis/index.ts index c84069015bb..981f7075a23 100644 --- a/packages/redis/index.ts +++ b/packages/redis/index.ts @@ -41,7 +41,7 @@ export type RedisClientType< M extends RedisModules = RedisDefaultModules, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {} > = GenericRedisClientType; @@ -92,7 +92,7 @@ export type RedisClusterType< M extends RedisModules = RedisDefaultModules, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {} > = genericRedisClusterType; @@ -118,7 +118,7 @@ export type RedisSentinelType< M extends RedisModules = RedisDefaultModules, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2, + RESP extends RespVersions = 3, TYPE_MAPPING extends TypeMapping = {} > = genericRedisSentinelType; diff --git a/packages/search/lib/commands/AGGREGATE.ts b/packages/search/lib/commands/AGGREGATE.ts index ad58415c45c..08d9adeab44 100644 --- a/packages/search/lib/commands/AGGREGATE.ts +++ b/packages/search/lib/commands/AGGREGATE.ts @@ -165,7 +165,6 @@ export default { }, 3: undefined as unknown as () => ReplyUnion }, - unstableResp3: true } as const satisfies Command; export function parseAggregateOptions(parser: CommandParser, options?: FtAggregateOptions) { diff --git a/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts b/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts index 8dfca7169ef..120b0bec030 100644 --- a/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts +++ b/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts @@ -40,5 +40,4 @@ export default { }, 3: undefined as unknown as () => ReplyUnion }, - unstableResp3: true } as const satisfies Command; diff --git a/packages/search/lib/commands/CURSOR_READ.ts b/packages/search/lib/commands/CURSOR_READ.ts index e64070122d1..6df0c56f8d8 100644 --- a/packages/search/lib/commands/CURSOR_READ.ts +++ b/packages/search/lib/commands/CURSOR_READ.ts @@ -17,5 +17,4 @@ export default { } }, transformReply: AGGREGATE_WITHCURSOR.transformReply, - unstableResp3: true } as const satisfies Command; diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 3bd47342b1d..081b55a7480 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -407,7 +407,6 @@ export default { }, 3: undefined as unknown as () => ReplyUnion, }, - unstableResp3: true, } as const satisfies Command; export interface HybridSearchResult { diff --git a/packages/search/lib/commands/INFO.ts b/packages/search/lib/commands/INFO.ts index 6552ad7fa11..de666708996 100644 --- a/packages/search/lib/commands/INFO.ts +++ b/packages/search/lib/commands/INFO.ts @@ -14,7 +14,6 @@ export default { 2: transformV2Reply, 3: undefined as unknown as () => ReplyUnion }, - unstableResp3: true } as const satisfies Command; export interface InfoReply { diff --git a/packages/search/lib/commands/PROFILE_AGGREGATE.ts b/packages/search/lib/commands/PROFILE_AGGREGATE.ts index 94bb6984afa..a3dd46795d9 100644 --- a/packages/search/lib/commands/PROFILE_AGGREGATE.ts +++ b/packages/search/lib/commands/PROFILE_AGGREGATE.ts @@ -31,5 +31,4 @@ export default { }, 3: (reply: ReplyUnion): ReplyUnion => reply }, - unstableResp3: true } as const satisfies Command; diff --git a/packages/search/lib/commands/PROFILE_SEARCH.ts b/packages/search/lib/commands/PROFILE_SEARCH.ts index b13dbebe996..b72a71f94e4 100644 --- a/packages/search/lib/commands/PROFILE_SEARCH.ts +++ b/packages/search/lib/commands/PROFILE_SEARCH.ts @@ -47,5 +47,4 @@ export default { }, 3: (reply: ReplyUnion): ReplyUnion => reply }, - unstableResp3: true } as const satisfies Command; diff --git a/packages/search/lib/commands/SEARCH.ts b/packages/search/lib/commands/SEARCH.ts index e4299f3cbf0..ec2e85839d4 100644 --- a/packages/search/lib/commands/SEARCH.ts +++ b/packages/search/lib/commands/SEARCH.ts @@ -187,7 +187,6 @@ export default { }, 3: undefined as unknown as () => ReplyUnion }, - unstableResp3: true } as const satisfies Command; export type SearchRawReply = Array; diff --git a/packages/search/lib/commands/SEARCH_NOCONTENT.ts b/packages/search/lib/commands/SEARCH_NOCONTENT.ts index a3f7ab2939e..5049154a77d 100644 --- a/packages/search/lib/commands/SEARCH_NOCONTENT.ts +++ b/packages/search/lib/commands/SEARCH_NOCONTENT.ts @@ -17,7 +17,6 @@ export default { }, 3: undefined as unknown as () => ReplyUnion }, - unstableResp3: true } as const satisfies Command; export interface SearchNoContentReply { diff --git a/packages/search/lib/commands/SPELLCHECK.ts b/packages/search/lib/commands/SPELLCHECK.ts index 3b909cdca32..1b55e13c53f 100644 --- a/packages/search/lib/commands/SPELLCHECK.ts +++ b/packages/search/lib/commands/SPELLCHECK.ts @@ -51,7 +51,6 @@ export default { }, 3: undefined as unknown as () => ReplyUnion, }, - unstableResp3: true } as const satisfies Command; function parseTerms(parser: CommandParser, { mode, dictionary }: Terms) { diff --git a/packages/search/lib/test-utils.ts b/packages/search/lib/test-utils.ts index 28374d67552..ef987c61ef5 100644 --- a/packages/search/lib/test-utils.ts +++ b/packages/search/lib/test-utils.ts @@ -14,6 +14,7 @@ export const GLOBAL = { OPEN: { serverArguments: [], clientOptions: { + RESP: 3 as const, modules: { ft: RediSearch } @@ -23,7 +24,6 @@ export const GLOBAL = { serverArguments: [], clientOptions: { RESP: 3 as RespVersions, - unstableResp3:true, modules: { ft: RediSearch } diff --git a/packages/test-utils/lib/index.ts b/packages/test-utils/lib/index.ts index c1888d0e68d..fea86942853 100644 --- a/packages/test-utils/lib/index.ts +++ b/packages/test-utils/lib/index.ts @@ -531,6 +531,8 @@ export default class TestUtils { it(title, async function () { if (!spawnPromise) return this.skip(); const { apiPort } = await spawnPromise; + const RESP = (options.clusterConfiguration?.RESP ?? 3) as RESP; + const { RESP: _RESP, ...clusterConfiguration } = options.clusterConfiguration ?? {}; const proxyFI = new ProxiedFaultInjectorClientForCluster( @@ -552,8 +554,9 @@ export default class TestUtils { port: n.port, }, })), - ...options.clusterConfiguration, - }); + RESP, + ...clusterConfiguration, + }) as RedisClusterType; if (options.disableClusterSetup) { return fn(cluster, faultInjectorClient); @@ -647,11 +650,13 @@ export default class TestUtils { host: "127.0.0.1", port: promise.port })); + const { RESP = 3, ...sentinelOptions } = options?.sentinelOptions ?? {}; const sentinel = createSentinel({ name: 'mymaster', sentinelRootNodes: rootNodes, + RESP, nodeClientOptions: { commandOptions: options.clientOptions?.commandOptions, password: password || undefined, @@ -665,7 +670,7 @@ export default class TestUtils { functions: options?.functions || {}, masterPoolSize: options?.masterPoolSize || undefined, reserveClient: options?.reserveClient || false, - ...options?.sentinelOptions + ...sentinelOptions }) as RedisSentinelType; if (options.disableClientSetup) { @@ -822,6 +827,12 @@ export default class TestUtils { it(title, async function () { if (options.skipTest) return this.skip(); if (!dockersPromise) return this.skip(); + const RESP = (options.clusterConfiguration?.RESP ?? 3) as RESP; + const { + RESP: _RESP, + minimizeConnections = false, + ...clusterConfiguration + } = options.clusterConfiguration ?? {}; const dockers = await dockersPromise, cluster = createCluster({ @@ -830,9 +841,10 @@ export default class TestUtils { port } })), - minimizeConnections: options.clusterConfiguration?.minimizeConnections ?? true, - ...options.clusterConfiguration - }); + RESP, + minimizeConnections, + ...clusterConfiguration + }) as RedisClusterType; if(options.disableClusterSetup) { return fn(cluster); @@ -973,7 +985,8 @@ export default class TestUtils { this.timeout(options.testTimeout); } - const { defaults, ...rest } = options.clusterConfiguration ?? {}; + const RESP = (options.clusterConfiguration?.RESP ?? 3) as RESP; + const { defaults, RESP: _RESP, ...rest } = options.clusterConfiguration ?? {}; // Wait for database to be fully ready before connecting await new Promise(resolve => setTimeout(resolve, 1000)); @@ -987,13 +1000,14 @@ export default class TestUtils { }, }, ], + RESP, defaults: { password: dbConfig.password, username: dbConfig.username, ...defaults, }, ...rest, - }); + }) as RedisClusterType; await cluster.connect(); diff --git a/packages/time-series/lib/commands/INFO.ts b/packages/time-series/lib/commands/INFO.ts index 65e70c4aaed..b49395ebdb7 100644 --- a/packages/time-series/lib/commands/INFO.ts +++ b/packages/time-series/lib/commands/INFO.ts @@ -125,5 +125,4 @@ export default { }, 3: undefined as unknown as () => ReplyUnion }, - unstableResp3: true } as const satisfies Command; diff --git a/packages/time-series/lib/commands/INFO_DEBUG.ts b/packages/time-series/lib/commands/INFO_DEBUG.ts index 058e549b161..e390d0c53d5 100644 --- a/packages/time-series/lib/commands/INFO_DEBUG.ts +++ b/packages/time-series/lib/commands/INFO_DEBUG.ts @@ -73,5 +73,4 @@ export default { }, 3: undefined as unknown as () => ReplyUnion }, - unstableResp3: true } as const satisfies Command; diff --git a/packages/time-series/lib/test-utils.ts b/packages/time-series/lib/test-utils.ts index d1e0c6684d3..d3e74de20ca 100644 --- a/packages/time-series/lib/test-utils.ts +++ b/packages/time-series/lib/test-utils.ts @@ -13,6 +13,7 @@ export const GLOBAL = { OPEN: { serverArguments: [], clientOptions: { + RESP: 3 as const, modules: { ts: TimeSeries } From 8c222bd3964fa512283b600c84ac88620c4498b7 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 7 Apr 2026 16:56:36 +0300 Subject: [PATCH 03/28] fix(compat): normalize RESP2/RESP3 reply transforms - Normalize cross-module reply-shape handling for RESP2 and RESP3. - Apply shared parser and transformer updates for stable compatibility. - Leave targeted module bugfixes isolated for the next commit. --- docs/v5.md | 35 --- packages/bloom/lib/commands/bloom/helpers.ts | 4 +- packages/client/lib/RESP/decoder.ts | 2 +- packages/client/lib/client/commands-queue.ts | 5 +- .../client/lib/commands/FUNCTION_STATS.ts | 2 +- packages/client/lib/commands/HOTKEYS_GET.ts | 101 ++++-- packages/client/lib/commands/MODULE_LIST.ts | 61 +++- packages/client/lib/commands/PUBSUB_NUMSUB.ts | 2 +- .../client/lib/commands/PUBSUB_SHARDNUMSUB.ts | 14 +- packages/client/lib/commands/VINFO.ts | 9 +- .../client/lib/commands/VLINKS_WITHSCORES.ts | 2 +- packages/client/lib/commands/XREAD.ts | 42 ++- packages/client/lib/commands/XREADGROUP.ts | 42 ++- .../lib/commands/generic-transformers.ts | 8 +- packages/search/lib/commands/AGGREGATE.ts | 82 +++-- .../lib/commands/AGGREGATE_WITHCURSOR.ts | 20 +- packages/search/lib/commands/CONFIG_GET.ts | 11 +- packages/search/lib/commands/HYBRID.ts | 91 +++--- packages/search/lib/commands/SEARCH.ts | 94 +++--- .../search/lib/commands/SEARCH_NOCONTENT.ts | 14 +- packages/search/lib/commands/SPELLCHECK.ts | 53 +++- .../search/lib/commands/reply-transformers.ts | 188 ++++++++++++ packages/test-utils/lib/test-utils.ts | 2 +- packages/time-series/lib/commands/INFO.ts | 288 +++++++++++++++--- .../time-series/lib/commands/INFO_DEBUG.ts | 74 ++++- .../lib/commands/MRANGE_WITHLABELS.ts | 6 +- packages/time-series/lib/commands/helpers.ts | 12 +- 27 files changed, 1000 insertions(+), 264 deletions(-) create mode 100644 packages/search/lib/commands/reply-transformers.ts diff --git a/docs/v5.md b/docs/v5.md index 15ef67c14ee..ce540041e50 100644 --- a/docs/v5.md +++ b/docs/v5.md @@ -40,41 +40,6 @@ This replaces the previous approach of using `commandOptions({ returnBuffers: tr RESP3 uses a different mechanism for handling Pub/Sub messages. Instead of modifying the `onReply` handler as in RESP2, RESP3 provides a dedicated `onPush` handler. When using RESP3, the client automatically uses this more efficient push notification system. -## Known Limitations - -### Unstable Commands - -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({ - RESP: 3, - unstableResp3: true -}); -``` - -The following commands have unstable RESP3 implementations: - -1. **Stream Commands**: - - `XREAD` and `XREADGROUP` - The response format differs between RESP2 and RESP3 - -2. **Search Commands (RediSearch)**: - - `FT.AGGREGATE` - - `FT.AGGREGATE_WITHCURSOR` - - `FT.CURSOR_READ` - - `FT.INFO` - - `FT.PROFILE_AGGREGATE` - - `FT.PROFILE_SEARCH` - - `FT.SEARCH` - - `FT.SEARCH_NOCONTENT` - - `FT.SPELLCHECK` - -3. **Time Series Commands**: - - `TS.INFO` - - `TS.INFO_DEBUG` - -If you need to use these commands with RESP3, be aware that the response format might change in future versions. - # Sentinel Support [Sentinel](./sentinel.md) diff --git a/packages/bloom/lib/commands/bloom/helpers.ts b/packages/bloom/lib/commands/bloom/helpers.ts index f5b39c71aa8..54a257e2ce3 100644 --- a/packages/bloom/lib/commands/bloom/helpers.ts +++ b/packages/bloom/lib/commands/bloom/helpers.ts @@ -17,7 +17,7 @@ export function transformInfoV2Reply(reply: Array, typeMapping?: TypeMap return ret as unknown as T; } default: { - const ret = Object.create(null); + const ret: Record = {}; for (let i = 0; i < reply.length; i += 2) { ret[reply[i].toString()] = reply[i + 1]; @@ -26,4 +26,4 @@ export function transformInfoV2Reply(reply: Array, typeMapping?: TypeMap return ret as unknown as T; } } -} \ No newline at end of file +} diff --git a/packages/client/lib/RESP/decoder.ts b/packages/client/lib/RESP/decoder.ts index 3bdcae66a4a..369dcec6baa 100644 --- a/packages/client/lib/RESP/decoder.ts +++ b/packages/client/lib/RESP/decoder.ts @@ -924,7 +924,7 @@ export class Decoder { default: return this.#decodeMapAsObject( - Object.create(null), + {}, length, typeMapping, chunk diff --git a/packages/client/lib/client/commands-queue.ts b/packages/client/lib/client/commands-queue.ts index 42fd89082df..54610df9920 100644 --- a/packages/client/lib/client/commands-queue.ts +++ b/packages/client/lib/client/commands-queue.ts @@ -189,7 +189,10 @@ export default class RedisCommandsQueue { } #getTypeMapping() { - return this.#waitingForReply.head!.value.typeMapping ?? {}; + const head = this.#waitingForReply.head; + if (!head) return PUSH_TYPE_MAPPING; + + return head.value.typeMapping ?? {}; } #initiateDecoder() { diff --git a/packages/client/lib/commands/FUNCTION_STATS.ts b/packages/client/lib/commands/FUNCTION_STATS.ts index 908be5476e0..9e418e80860 100644 --- a/packages/client/lib/commands/FUNCTION_STATS.ts +++ b/packages/client/lib/commands/FUNCTION_STATS.ts @@ -56,7 +56,7 @@ function transformEngines(reply: Resp2Reply) { const engines: Record = Object.create(null); + }> = {}; for (let i = 0; i < unwraped.length; i++) { const name = unwraped[i] as BlobStringReply, stats = unwraped[++i] as Resp2Reply, diff --git a/packages/client/lib/commands/HOTKEYS_GET.ts b/packages/client/lib/commands/HOTKEYS_GET.ts index febc49b4eb1..8f1fa5ae68b 100644 --- a/packages/client/lib/commands/HOTKEYS_GET.ts +++ b/packages/client/lib/commands/HOTKEYS_GET.ts @@ -1,5 +1,5 @@ import { CommandParser } from '../client/parser'; -import { Command, ReplyUnion, UnwrapReply, ArrayReply, BlobStringReply, NumberReply } from '../RESP/types'; +import { Command } from '../RESP/types'; /** * Hotkey entry with key name and metric value @@ -43,20 +43,52 @@ export interface HotkeysGetReply { byNetBytes?: Array; } -type HotkeysGetRawReply = ArrayReply>>; +function mapLikeEntries(value: any): Array<[string, any]> { + if (value instanceof Map) { + return Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]); + } + + if (Array.isArray(value)) { + if ( + value.length === 1 && + (Array.isArray(value[0]) || value[0] instanceof Map || (typeof value[0] === 'object' && value[0] !== null)) + ) { + return mapLikeEntries(value[0]); + } + + if (value.every(item => Array.isArray(item) && item.length >= 2)) { + return value.map(item => [item[0].toString(), item[1]]); + } + + const entries: Array<[string, any]> = []; + for (let i = 0; i < value.length - 1; i += 2) { + entries.push([value[i].toString(), value[i + 1]]); + } + return entries; + } + + if (value !== null && typeof value === 'object') { + return Object.entries(value); + } + + return []; +} + +function mapLikeValues(value: any): Array { + if (Array.isArray(value)) return value; + if (value instanceof Map) return [...value.values()]; + if (value !== null && typeof value === 'object') return Object.values(value); + return []; +} /** * Parse the hotkeys array into HotkeyEntry objects */ -function parseHotkeysList(arr: Array): Array { - const result: Array = []; - for (let i = 0; i < arr.length; i += 2) { - result.push({ - key: arr[i].toString(), - value: Number(arr[i + 1]) - }); - } - return result; +function parseHotkeysList(arr: unknown): Array { + return mapLikeEntries(arr).map(([key, value]) => ({ + key, + value: Number(value) + })); } /** @@ -64,9 +96,24 @@ function parseHotkeysList(arr: Array): Array>): Array { - return arr.map(range => { - const unwrapped = range as unknown as Array; +function parseSlotRanges(arr: unknown): Array { + return mapLikeValues(arr).map(range => { + let unwrapped: Array; + + if (Array.isArray(range)) { + unwrapped = range as Array; + } else if (range instanceof Map) { + unwrapped = [...range.values()].map(value => Number(value)); + } else if (range !== null && typeof range === 'object') { + const objectRange = range as Record; + const start = Number(objectRange.start ?? objectRange[0]); + const end = Number(objectRange.end ?? objectRange[1] ?? start); + unwrapped = [start, end]; + } else { + const slot = Number(range); + unwrapped = [slot, slot]; + } + if (unwrapped.length === 1) { // Single slot - start and end are the same return { @@ -85,15 +132,11 @@ function parseSlotRanges(arr: Array>): Array /** * Transform the raw reply into a structured object */ -function transformHotkeysGetReply(reply: UnwrapReply): HotkeysGetReply { - const result: Partial = {}; - - // The reply is wrapped in an extra array, so we need to access reply[0] - const data = reply[0] as unknown as Array>; +function transformHotkeysGetReply(reply: unknown | null): HotkeysGetReply | null { + if (reply === null) return null; - for (let i = 0; i < data.length; i += 2) { - const key = data[i].toString(); - const value = data[i + 1]; + const result: Partial = {}; + for (const [key, value] of mapLikeEntries(reply)) { switch (key) { case 'tracking-active': @@ -103,7 +146,7 @@ function transformHotkeysGetReply(reply: UnwrapReply): Hotke result.sampleRatio = Number(value); break; case 'selected-slots': - result.selectedSlots = parseSlotRanges(value as unknown as Array>); + result.selectedSlots = parseSlotRanges(value); break; case 'sampled-commands-selected-slots-us': result.sampledCommandsSelectedSlotsUs = Number(value); @@ -139,10 +182,10 @@ function transformHotkeysGetReply(reply: UnwrapReply): Hotke result.totalNetBytes = Number(value); break; case 'by-cpu-time-us': - result.byCpuTimeUs = parseHotkeysList(value as unknown as Array); + result.byCpuTimeUs = parseHotkeysList(value); break; case 'by-net-bytes': - result.byNetBytes = parseHotkeysList(value as unknown as Array); + result.byNetBytes = parseHotkeysList(value); break; } } @@ -164,11 +207,5 @@ export default { parseCommand(parser: CommandParser) { parser.push('HOTKEYS', 'GET'); }, - transformReply: { - 2: (reply: UnwrapReply | null): HotkeysGetReply | null => { - if (reply === null) return null; - return transformHotkeysGetReply(reply); - }, - 3: undefined as unknown as () => ReplyUnion - } + transformReply: transformHotkeysGetReply } as const satisfies Command; diff --git a/packages/client/lib/commands/MODULE_LIST.ts b/packages/client/lib/commands/MODULE_LIST.ts index 85203138f57..2ed2c918e39 100644 --- a/packages/client/lib/commands/MODULE_LIST.ts +++ b/packages/client/lib/commands/MODULE_LIST.ts @@ -6,6 +6,55 @@ export type ModuleListReply = ArrayReply, NumberReply], ]>>; +function transformModuleReply(moduleReply: any) { + if (Array.isArray(moduleReply)) { + let name: BlobStringReply | undefined; + let ver: NumberReply | undefined; + + for (let i = 0; i < moduleReply.length; i += 2) { + const key = moduleReply[i]?.toString(); + if (key === 'name') { + name = moduleReply[i + 1]; + } else if (key === 'ver') { + ver = moduleReply[i + 1]; + } + } + + return { + name: name as BlobStringReply, + ver: ver as NumberReply + }; + } + + if (moduleReply instanceof Map) { + let name: BlobStringReply | undefined; + let ver: NumberReply | undefined; + + for (const [key, value] of moduleReply.entries()) { + const normalizedKey = key?.toString(); + if (normalizedKey === 'name') { + name = value; + } else if (normalizedKey === 'ver') { + ver = value; + } + } + + return { + name: name as BlobStringReply, + ver: ver as NumberReply + }; + } + + return { + name: moduleReply.name, + ver: moduleReply.ver + }; +} + +function transformModuleListReply(reply: Array) { + return reply.map(moduleReply => transformModuleReply(moduleReply)); +} + export default { NOT_KEYED_COMMAND: true, IS_READ_ONLY: true, @@ -13,15 +62,7 @@ export default { parser.push('MODULE', 'LIST'); }, transformReply: { - 2: (reply: UnwrapReply>) => { - return reply.map(module => { - const unwrapped = module as unknown as UnwrapReply; - return { - name: unwrapped[1], - ver: unwrapped[3] - }; - }); - }, - 3: undefined as unknown as () => ModuleListReply + 2: transformModuleListReply as unknown as (reply: UnwrapReply>) => ModuleListReply, + 3: transformModuleListReply as unknown as () => ModuleListReply } } as const satisfies Command; diff --git a/packages/client/lib/commands/PUBSUB_NUMSUB.ts b/packages/client/lib/commands/PUBSUB_NUMSUB.ts index 27ae7dc75ab..f6a2c27f4a8 100644 --- a/packages/client/lib/commands/PUBSUB_NUMSUB.ts +++ b/packages/client/lib/commands/PUBSUB_NUMSUB.ts @@ -19,7 +19,7 @@ export default { * @returns Record mapping channel names to their subscriber counts */ transformReply(rawReply: UnwrapReply>) { - const reply = Object.create(null); + const reply: Record = {}; let i = 0; while (i < rawReply.length) { reply[rawReply[i++].toString()] = Number(rawReply[i++]); diff --git a/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts b/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts index 041f8b9a262..f05822787c0 100644 --- a/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts +++ b/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts @@ -4,6 +4,13 @@ import { RedisVariadicArgument } from './generic-transformers'; export default { IS_READ_ONLY: true, + /** + * Constructs the PUBSUB SHARDNUMSUB command + * + * @param parser - The command parser + * @param channels - Optional shard channel names to get subscription count for + * @see https://redis.io/commands/pubsub-shardnumsub/ + */ parseCommand(parser: CommandParser, channels?: RedisVariadicArgument) { parser.push('PUBSUB', 'SHARDNUMSUB'); @@ -13,18 +20,17 @@ export default { }, /** * Transforms the PUBSUB SHARDNUMSUB reply into a record of shard channel name to subscriber count - * + * * @param reply - The raw reply from Redis * @returns Record mapping shard channel names to their subscriber counts */ transformReply(reply: UnwrapReply>) { - const transformedReply: Record = Object.create(null); + const transformedReply: Record = {}; for (let i = 0; i < reply.length; i += 2) { transformedReply[(reply[i] as BlobStringReply).toString()] = reply[i + 1] as NumberReply; } - + return transformedReply; } } as const satisfies Command; - diff --git a/packages/client/lib/commands/VINFO.ts b/packages/client/lib/commands/VINFO.ts index 3072b20d143..07f49c6b617 100644 --- a/packages/client/lib/commands/VINFO.ts +++ b/packages/client/lib/commands/VINFO.ts @@ -12,13 +12,20 @@ export type VInfoReplyMap = TuplesToMapReply<[ 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); + const ret: Record = {}; for (let i = 0; i < reply.length; i += 2) { ret[reply[i].toString()] = reply[i + 1]; diff --git a/packages/client/lib/commands/VLINKS_WITHSCORES.ts b/packages/client/lib/commands/VLINKS_WITHSCORES.ts index 238e4425db7..48e7c5b4660 100644 --- a/packages/client/lib/commands/VLINKS_WITHSCORES.ts +++ b/packages/client/lib/commands/VLINKS_WITHSCORES.ts @@ -7,7 +7,7 @@ function transformVLinksWithScoresReply(reply: Array>): A const layers: Array> = []; for (const layer of reply) { - const obj: Record = Object.create(null); + const obj: Record = {}; // Each layer contains alternating element names and scores for (let i = 0; i < layer.length; i += 2) { diff --git a/packages/client/lib/commands/XREAD.ts b/packages/client/lib/commands/XREAD.ts index 78616906193..993a3b3e595 100644 --- a/packages/client/lib/commands/XREAD.ts +++ b/packages/client/lib/commands/XREAD.ts @@ -1,6 +1,6 @@ import { CommandParser } from '../client/parser'; import { Command, RedisArgument, ReplyUnion } from '../RESP/types'; -import { transformStreamsMessagesReplyResp2 } from './generic-transformers'; +import { transformStreamsMessagesReplyResp2, transformStreamsMessagesReplyResp3 } from './generic-transformers'; /** * Structure representing a stream to read from @@ -48,6 +48,44 @@ export interface XReadOptions { BLOCK?: number; } +function transformStreamsMessagesReplyResp3Compat(reply: ReplyUnion) { + const transformed = transformStreamsMessagesReplyResp3(reply as any); + if (transformed === null) return null; + + const compat = []; + + if (transformed instanceof Map) { + for (const [name, messages] of transformed.entries()) { + compat.push({ + name, + messages + }); + } + + return compat; + } + + if (Array.isArray(transformed)) { + for (let i = 0; i < transformed.length; i += 2) { + compat.push({ + name: transformed[i], + messages: transformed[i + 1] + }); + } + + return compat; + } + + for (const [name, messages] of Object.entries(transformed)) { + compat.push({ + name, + messages + }); + } + + return compat; +} + export default { IS_READ_ONLY: true, parseCommand(parser: CommandParser, streams: XReadStreams, options?: XReadOptions) { @@ -68,6 +106,6 @@ export default { */ transformReply: { 2: transformStreamsMessagesReplyResp2, - 3: undefined as unknown as () => ReplyUnion + 3: transformStreamsMessagesReplyResp3Compat } } as const satisfies Command; diff --git a/packages/client/lib/commands/XREADGROUP.ts b/packages/client/lib/commands/XREADGROUP.ts index 46e9d5e71fa..e8e2c4879de 100644 --- a/packages/client/lib/commands/XREADGROUP.ts +++ b/packages/client/lib/commands/XREADGROUP.ts @@ -1,7 +1,7 @@ import { CommandParser } from '../client/parser'; import { Command, RedisArgument, ReplyUnion } from '../RESP/types'; import { XReadStreams, pushXReadStreams } from './XREAD'; -import { transformStreamsMessagesReplyResp2 } from './generic-transformers'; +import { transformStreamsMessagesReplyResp2, transformStreamsMessagesReplyResp3 } from './generic-transformers'; /** * Options for the XREADGROUP command @@ -18,6 +18,44 @@ export interface XReadGroupOptions { CLAIM?: number; } +function transformStreamsMessagesReplyResp3Compat(reply: ReplyUnion) { + const transformed = transformStreamsMessagesReplyResp3(reply as any); + if (transformed === null) return null; + + const compat = []; + + if (transformed instanceof Map) { + for (const [name, messages] of transformed.entries()) { + compat.push({ + name, + messages + }); + } + + return compat; + } + + if (Array.isArray(transformed)) { + for (let i = 0; i < transformed.length; i += 2) { + compat.push({ + name: transformed[i], + messages: transformed[i + 1] + }); + } + + return compat; + } + + for (const [name, messages] of Object.entries(transformed)) { + compat.push({ + name, + messages + }); + } + + return compat; +} + export default { IS_READ_ONLY: true, parseCommand( @@ -52,6 +90,6 @@ export default { */ transformReply: { 2: transformStreamsMessagesReplyResp2, - 3: undefined as unknown as () => ReplyUnion + 3: transformStreamsMessagesReplyResp3Compat }, } as const satisfies Command; diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index bc74cf5739f..94407036a99 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -115,7 +115,7 @@ export function transformTuplesToMap( reply: UnwrapReply>, func: (elem: any) => T, ) { - const message = Object.create(null); + const message: Record = {}; for (let i = 0; i < reply.length; i+= 2) { message[reply[i].toString()] = func(reply[i + 1]); @@ -153,7 +153,7 @@ export function transformTuplesReply( return ret as unknown as MapReply;; } default: { - const ret: Record = Object.create(null); + const ret: Record = {}; for (let i = 0; i < inferred.length; i += 2) { ret[inferred[i].toString()] = inferred[i + 1] as any; @@ -603,7 +603,7 @@ export function transformStreamsMessagesReplyResp2( return ret as unknown as MapReply; } default: { - const ret: Record = Object.create(null); + const ret: Record = {}; for (let i=0; i < reply.length; i++) { const stream = reply[i] as unknown as UnwrapReply; @@ -663,7 +663,7 @@ export function transformStreamsMessagesReplyResp3(reply: UnwrapReply } else { - const ret = Object.create(null); + const ret: Record = {}; for (const [name, rawMessages] of Object.entries(reply)) { ret[name] = transformStreamMessagesReply(rawMessages); } diff --git a/packages/search/lib/commands/AGGREGATE.ts b/packages/search/lib/commands/AGGREGATE.ts index 08d9adeab44..09e87d9c78d 100644 --- a/packages/search/lib/commands/AGGREGATE.ts +++ b/packages/search/lib/commands/AGGREGATE.ts @@ -4,6 +4,8 @@ import { RediSearchProperty } from './CREATE'; import { FtSearchParams, parseParamsArgument } from './SEARCH'; import { transformTuplesReply } from '@redis/client/dist/lib/commands/generic-transformers'; import { DEFAULT_DIALECT } from '../dialect/default'; +import { getMapValue, mapLikeToFlatArray, mapLikeToObject, mapLikeValues, parseAggregateResultRow } from './reply-transformers'; +import { RESP_TYPES } from '@redis/client/dist/lib/RESP/decoder'; type LoadField = RediSearchProperty | { identifier: RediSearchProperty; @@ -138,6 +140,67 @@ export interface AggregateReply { results: Array>; }; +function transformAggregateReplyResp2( + rawReply: AggregateRawReply, + preserve?: any, + typeMapping?: TypeMapping +): AggregateReply { + const results: Array> = []; + for (let i = 1; i < rawReply.length; i++) { + results.push( + transformTuplesReply(rawReply[i] as ArrayReply, preserve, typeMapping) + ); + } + + return { + // https://redis.io/docs/latest/commands/ft.aggregate/#return + // FT.AGGREGATE returns an array reply where each row is an array reply and represents a single aggregate result. + // The integer reply at position 1 does not represent a valid value. + total: Number(rawReply[0]), + results + }; +} + +function transformAggregateReplyResp3( + rawReply: ReplyUnion, + preserve?: any, + typeMapping?: TypeMapping +): AggregateReply { + if (Array.isArray(rawReply)) { + return transformAggregateReplyResp2(rawReply as unknown as AggregateRawReply, preserve, typeMapping); + } + + const reply = mapLikeToObject(rawReply); + const total = Number(getMapValue(reply, ['total_results', 'total']) ?? 0); + const rawResults = mapLikeValues(getMapValue(reply, ['results']) ?? []); + + const results: Array> = []; + const mapType = typeMapping ? typeMapping[RESP_TYPES.MAP] : undefined; + + for (const rawResult of rawResults) { + const normalized = parseAggregateResultRow(rawResult); + + switch (mapType) { + case Array: { + results.push(mapLikeToFlatArray(normalized) as unknown as MapReply); + break; + } + case Map: { + results.push(new Map(Object.entries(normalized)) as unknown as MapReply); + break; + } + default: { + results.push(normalized as unknown as MapReply); + } + } + } + + return { + total, + results + }; +} + export default { NOT_KEYED_COMMAND: true, IS_READ_ONLY: false, @@ -147,23 +210,8 @@ export default { return parseAggregateOptions(parser, options); }, transformReply: { - 2: (rawReply: AggregateRawReply, preserve?: unknown, typeMapping?: TypeMapping): AggregateReply => { - const results: Array> = []; - for (let i = 1; i < rawReply.length; i++) { - results.push( - transformTuplesReply(rawReply[i] as ArrayReply, preserve, typeMapping) - ); - } - - return { - // https://redis.io/docs/latest/commands/ft.aggregate/#return - // FT.AGGREGATE returns an array reply where each row is an array reply and represents a single aggregate result. - // The integer reply at position 1 does not represent a valid value. - total: Number(rawReply[0]), - results - }; - }, - 3: undefined as unknown as () => ReplyUnion + 2: transformAggregateReplyResp2, + 3: transformAggregateReplyResp3 }, } as const satisfies Command; diff --git a/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts b/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts index 120b0bec030..02de8bfb56f 100644 --- a/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts +++ b/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts @@ -1,6 +1,7 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, Command, ReplyUnion, NumberReply } from '@redis/client/dist/lib/RESP/types'; import AGGREGATE, { AggregateRawReply, AggregateReply, FtAggregateOptions } from './AGGREGATE'; +import { getMapValue, mapLikeToObject } from './reply-transformers'; export interface FtAggregateWithCursorOptions extends FtAggregateOptions { COUNT?: number; @@ -17,6 +18,23 @@ export interface AggregateWithCursorReply extends AggregateReply { cursor: NumberReply; } +function transformAggregateWithCursorReplyResp3(reply: ReplyUnion): AggregateWithCursorReply { + if (Array.isArray(reply)) { + return { + ...(AGGREGATE.transformReply[3](reply[0] as ReplyUnion) as AggregateReply), + cursor: reply[1] as NumberReply + }; + } + + const mappedReply = mapLikeToObject(reply); + const rawResult = getMapValue(mappedReply, ['results', 'result']) ?? mappedReply; + + return { + ...(AGGREGATE.transformReply[3](rawResult as ReplyUnion) as AggregateReply), + cursor: (getMapValue(mappedReply, ['cursor']) ?? 0) as NumberReply + }; +} + export default { IS_READ_ONLY: AGGREGATE.IS_READ_ONLY, parseCommand(parser: CommandParser, index: RedisArgument, query: RedisArgument, options?: FtAggregateWithCursorOptions) { @@ -38,6 +56,6 @@ export default { cursor: reply[1] }; }, - 3: undefined as unknown as () => ReplyUnion + 3: transformAggregateWithCursorReplyResp3 }, } as const satisfies Command; diff --git a/packages/search/lib/commands/CONFIG_GET.ts b/packages/search/lib/commands/CONFIG_GET.ts index ae7a9e0c78d..9d20c6d4a41 100644 --- a/packages/search/lib/commands/CONFIG_GET.ts +++ b/packages/search/lib/commands/CONFIG_GET.ts @@ -1,5 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { ArrayReply, TuplesReply, BlobStringReply, NullReply, UnwrapReply, Command } from '@redis/client/dist/lib/RESP/types'; +import { mapLikeEntries, toCompatObject } from './reply-transformers'; export default { NOT_KEYED_COMMAND: true, @@ -8,12 +9,12 @@ export default { parser.push('FT.CONFIG', 'GET', option); }, transformReply(reply: UnwrapReply>>) { - const transformedReply: Record = Object.create(null); - for (const item of reply) { - const [key, value] = item as unknown as UnwrapReply; - transformedReply[key.toString()] = value; + const transformedReply: Record = {}; + + for (const [key, value] of mapLikeEntries(reply)) { + transformedReply[key] = value; } - return transformedReply; + return toCompatObject(transformedReply); } } as const satisfies Command; diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 081b55a7480..988c76b320d 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -2,7 +2,6 @@ import { CommandParser } from "@redis/client/dist/lib/client/parser"; import { RedisArgument, Command, - ReplyUnion, } from "@redis/client/dist/lib/RESP/types"; import { RedisVariadicArgument, @@ -10,6 +9,12 @@ import { } from "@redis/client/dist/lib/commands/generic-transformers"; import { parseParamsArgument } from "./SEARCH"; import { GroupByReducers, parseGroupByReducer } from "./AGGREGATE"; +import { + getMapValue, + mapLikeToObject, + mapLikeValues, + parseDocumentValue, +} from "./reply-transformers"; /** * Text search expression configuration for hybrid search. @@ -405,7 +410,9 @@ export default { 2: (reply: unknown): HybridSearchResult => { return transformHybridSearchResults(reply); }, - 3: undefined as unknown as () => ReplyUnion, + 3: (reply: any): HybridSearchResult => { + return transformHybridSearchResults(reply); + }, }, } as const satisfies Command; @@ -417,36 +424,54 @@ export interface HybridSearchResult { results: Record[]; } -function transformHybridSearchResults(reply: unknown): HybridSearchResult { - // FT.HYBRID returns a map-like structure as flat array: - // ['total_results', N, 'results', [...], 'warnings', [...], 'execution_time', 'X.XXX'] +function transformHybridSearchResults(reply: any): HybridSearchResult { const replyMap = parseReplyMap(reply); - const totalResults = (replyMap["total_results"] ?? 0) as number; - const rawResults = (replyMap["results"] ?? []) as Array; - const warnings = (replyMap["warnings"] ?? []) as string[]; - const rawExecutionTime = replyMap["execution_time"]; - const executionTime = rawExecutionTime - ? Number.parseFloat(rawExecutionTime as string) - : 0; + const totalResults = Number( + getMapValue(replyMap, ["total_results", "totalResults"]) ?? 0, + ); + + const rawResults = mapLikeValues(getMapValue(replyMap, ["results"]) ?? []); + const warnings = mapLikeValues( + getMapValue(replyMap, ["warnings", "warning"]) ?? [], + ); + + const executionTimeValue = getMapValue(replyMap, [ + "execution_time", + "executionTime", + ]); + const executionTime = + executionTimeValue === undefined ? 0 : Number(executionTimeValue); const results: HybridSearchResult['results'] = []; for (const result of rawResults) { - // Each result is a flat key-value array like FT.AGGREGATE: ['field1', 'value1', 'field2', 'value2', ...] const resultMap = parseReplyMap(result); + const doc: Record = {}; + const id = getMapValue(resultMap, ["id"]); + + if (id !== undefined) { + doc.id = id.toString(); + } - const doc: Record = Object.create(null); + Object.assign(doc, parseDocumentValue(getMapValue(resultMap, ["values"]))); + Object.assign( + doc, + parseDocumentValue( + getMapValue(resultMap, ["extra_attributes", "extraAttributes"]), + ), + ); - // Add all other fields from the result for (const [key, value] of Object.entries(resultMap)) { - if (key === "$") { - // JSON document - parse and merge - try { - Object.assign(doc, JSON.parse(value as string)); - } catch { - doc[key] = value; - } - } else { + if ( + key === "id" || + key === "values" || + key.toLowerCase() === "extra_attributes" || + key === "extraAttributes" + ) { + continue; + } + + if (!Object.hasOwn(doc, key)) { doc[key] = value; } } @@ -457,25 +482,11 @@ function transformHybridSearchResults(reply: unknown): HybridSearchResult { return { totalResults, executionTime, - warnings, + warnings: warnings.map(warning => warning.toString()), results, }; } -function parseReplyMap(reply: unknown): Record { - const map: Record = {}; - - if (!Array.isArray(reply)) { - return map; - } - - for (let i = 0; i < reply.length; i += 2) { - const key = reply[i]; - const value = reply[i + 1]; - if (typeof key === "string") { - map[key] = value; - } - } - - return map; +function parseReplyMap(reply: any): Record { + return mapLikeToObject(reply); } diff --git a/packages/search/lib/commands/SEARCH.ts b/packages/search/lib/commands/SEARCH.ts index ec2e85839d4..13ffcf8abcd 100644 --- a/packages/search/lib/commands/SEARCH.ts +++ b/packages/search/lib/commands/SEARCH.ts @@ -3,6 +3,7 @@ import { RedisArgument, Command, ReplyUnion } from '@redis/client/dist/lib/RESP/ import { RedisVariadicArgument, parseOptionalVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; import { RediSearchLanguage } from './CREATE'; import { DEFAULT_DIALECT } from '../dialect/default'; +import { getMapValue, mapLikeToObject, mapLikeValues, parseDocumentValue, parseSearchResultRow } from './reply-transformers'; export type FtSearchParams = Record; @@ -158,6 +159,51 @@ export function parseSearchOptions(parser: CommandParser, options?: FtSearchOpti } } +function transformSearchReplyResp2(reply: SearchRawReply): SearchReply { + // if reply[2] is array, then we have content/documents. Otherwise, only ids + const withoutDocuments = reply.length > 2 && !Array.isArray(reply[2]); + + const documents = []; + let i = 1; + while (i < reply.length) { + documents.push({ + id: reply[i++], + value: withoutDocuments ? {} : documentValue(reply[i++]) + }); + } + + return { + total: reply[0], + documents + }; +} + +function transformSearchReplyResp3(rawReply: ReplyUnion): SearchReply { + if (Array.isArray(rawReply)) { + return transformSearchReplyResp2(rawReply as SearchRawReply); + } + + const reply = mapLikeToObject(rawReply); + const total = Number(getMapValue(reply, ['total_results', 'total']) ?? 0); + + const results = mapLikeValues( + getMapValue(reply, ['results', 'documents']) ?? [] + ); + + const documents = results.map(result => { + const { id, value } = parseSearchResultRow(result); + return { + id: id?.toString?.() ?? id, + value + }; + }); + + return { + total, + documents + }; +} + export default { NOT_KEYED_COMMAND: true, IS_READ_ONLY: true, @@ -167,25 +213,8 @@ export default { parseSearchOptions(parser, options); }, transformReply: { - 2: (reply: SearchRawReply): SearchReply => { - // if reply[2] is array, then we have content/documents. Otherwise, only ids - const withoutDocuments = reply.length > 2 && !Array.isArray(reply[2]); - - const documents: SearchReply['documents'] = []; - let i = 1; - while (i < reply.length) { - documents.push({ - id: reply[i++] as string, - value: withoutDocuments ? Object.create(null) : documentValue(reply[i++]) - }); - } - - return { - total: reply[0] as number, - documents - }; - }, - 3: undefined as unknown as () => ReplyUnion + 2: transformSearchReplyResp2, + 3: transformSearchReplyResp3 }, } as const satisfies Command; @@ -203,29 +232,6 @@ export interface SearchReply { }>; } -function documentValue(tuples: unknown) { - const message: SearchDocumentValue = Object.create(null); - - if(!tuples) { - return message; - } - - const rawTuples = tuples as Array; - let i = 0; - while (i < rawTuples.length) { - const key = rawTuples[i++] as string, - value = rawTuples[i++] as SearchDocumentValue[string]; - if (key === '$') { // might be a JSON reply - try { - Object.assign(message, JSON.parse(value as string)); - continue; - } catch { - // set as a regular property if not a valid JSON - } - } - - message[key] = value; - } - - return message; +function documentValue(tuples: any) { + return parseDocumentValue(tuples); } diff --git a/packages/search/lib/commands/SEARCH_NOCONTENT.ts b/packages/search/lib/commands/SEARCH_NOCONTENT.ts index 5049154a77d..88927207664 100644 --- a/packages/search/lib/commands/SEARCH_NOCONTENT.ts +++ b/packages/search/lib/commands/SEARCH_NOCONTENT.ts @@ -15,7 +15,19 @@ export default { documents: reply.slice(1) as Array } }, - 3: undefined as unknown as () => ReplyUnion + 3: (reply: ReplyUnion): SearchNoContentReply => { + const transformed = SEARCH.transformReply[3](reply) as { + total: number; + documents: Array<{ + id: string; + }>; + }; + + return { + total: transformed.total, + documents: transformed.documents.map(document => document.id) + }; + } }, } as const satisfies Command; diff --git a/packages/search/lib/commands/SPELLCHECK.ts b/packages/search/lib/commands/SPELLCHECK.ts index 1b55e13c53f..42e88fe538c 100644 --- a/packages/search/lib/commands/SPELLCHECK.ts +++ b/packages/search/lib/commands/SPELLCHECK.ts @@ -1,6 +1,7 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, Command, ReplyUnion } from '@redis/client/dist/lib/RESP/types'; import { DEFAULT_DIALECT } from '../dialect/default'; +import { getMapValue, mapLikeEntries, mapLikeValues } from './reply-transformers'; export interface Terms { mode: 'INCLUDE' | 'EXCLUDE'; @@ -13,6 +14,56 @@ export interface FtSpellCheckOptions { DIALECT?: number; } +function transformSpellCheckReplyResp3(rawReply: ReplyUnion): SpellCheckReply { + const transformed: SpellCheckReply = []; + const results = getMapValue(rawReply, ['results', 'Results']) ?? rawReply; + + for (const [term, rawSuggestions] of mapLikeEntries(results)) { + const suggestions: Array<{ + score: number; + suggestion: string; + }> = []; + + for (const rawSuggestion of mapLikeValues(rawSuggestions)) { + if (Array.isArray(rawSuggestion) && rawSuggestion.length >= 2) { + const first = rawSuggestion[0]; + const second = rawSuggestion[1]; + + const numericFirst = Number(first); + if (!Number.isNaN(numericFirst)) { + suggestions.push({ + score: numericFirst, + suggestion: second.toString() + }); + } else { + suggestions.push({ + score: Number(second), + suggestion: first.toString() + }); + } + + continue; + } + + const entries = mapLikeEntries(rawSuggestion); + if (entries.length === 0) continue; + + const [suggestion, score] = entries[0]; + suggestions.push({ + score: Number(score), + suggestion + }); + } + + transformed.push({ + term, + suggestions + }); + } + + return transformed; +} + export default { NOT_KEYED_COMMAND: true, IS_READ_ONLY: true, @@ -49,7 +100,7 @@ export default { })) })); }, - 3: undefined as unknown as () => ReplyUnion, + 3: transformSpellCheckReplyResp3, }, } as const satisfies Command; diff --git a/packages/search/lib/commands/reply-transformers.ts b/packages/search/lib/commands/reply-transformers.ts new file mode 100644 index 00000000000..f884e6d9ca3 --- /dev/null +++ b/packages/search/lib/commands/reply-transformers.ts @@ -0,0 +1,188 @@ +function isPlainObject(value: unknown): value is Record { + return value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Map); +} + +export function mapLikeEntries(value: unknown): Array<[string, any]> { + if (value instanceof Map) { + return Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]); + } + + if (Array.isArray(value)) { + if ( + value.length === 1 && + (Array.isArray(value[0]) || value[0] instanceof Map || isPlainObject(value[0])) + ) { + return mapLikeEntries(value[0]); + } + + if (value.every(item => Array.isArray(item) && item.length >= 2)) { + return value.map(item => [item[0].toString(), item[1]]); + } + + const entries: Array<[string, any]> = []; + for (let i = 0; i < value.length - 1; i += 2) { + entries.push([value[i].toString(), value[i + 1]]); + } + return entries; + } + + if (isPlainObject(value)) { + return Object.entries(value); + } + + return []; +} + +export function toCompatObject(value: Record): Record { + const descriptors: PropertyDescriptorMap = {}; + + for (const [key, entryValue] of Object.entries(value)) { + descriptors[key] = { + value: entryValue, + configurable: true, + enumerable: true + }; + } + + return Object.defineProperties({}, descriptors); +} + +export function mapLikeToObject(value: unknown): Record { + const object: Record = {}; + for (const [key, entryValue] of mapLikeEntries(value)) { + object[key] = entryValue; + } + return object; +} + +export function mapLikeToFlatArray(value: unknown): Array { + const flat: Array = []; + for (const [key, entryValue] of mapLikeEntries(value)) { + flat.push(key, entryValue); + } + return flat; +} + +export function mapLikeValues(value: unknown): Array { + if (Array.isArray(value)) return value; + if (value instanceof Map) return [...value.values()]; + if (isPlainObject(value)) return Object.values(value); + return []; +} + +export function getMapValue(value: unknown, keys: Array): any { + const object = mapLikeToObject(value); + + for (const key of keys) { + if (Object.hasOwn(object, key)) { + return object[key]; + } + } + + const lowerCaseKeyToOriginal = new Map(); + for (const key of Object.keys(object)) { + const lowerCaseKey = key.toLowerCase(); + if (!lowerCaseKeyToOriginal.has(lowerCaseKey)) { + lowerCaseKeyToOriginal.set(lowerCaseKey, key); + } + } + + for (const key of keys) { + const original = lowerCaseKeyToOriginal.get(key.toLowerCase()); + if (original !== undefined) { + return object[original]; + } + } + + return undefined; +} + +function assignDocumentField(target: Record, key: string, value: any): void { + if (key === '$') { + const json = value?.toString?.() ?? value; + if (typeof json === 'string') { + try { + Object.assign(target, JSON.parse(json)); + return; + } catch { + // Fallback to setting the raw value below. + } + } + } + + target[key] = value; +} + +export function parseDocumentValue(value: unknown): Record { + const document: Record = {}; + + for (const [key, entryValue] of mapLikeEntries(value)) { + assignDocumentField(document, key, entryValue); + } + + return document; +} + +function normalizeProfileValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(normalizeProfileValue); + } + + if (value instanceof Map || isPlainObject(value)) { + const normalized: Array = []; + for (const [key, entryValue] of mapLikeEntries(value)) { + normalized.push(key, normalizeProfileValue(entryValue)); + } + return normalized; + } + + return value; +} + +export function normalizeProfileReply(profile: unknown): unknown { + return normalizeProfileValue(profile); +} + +export function parseSearchResultRow(rawRow: unknown): { + id: any; + value: Record; +} { + const row = mapLikeToObject(rawRow); + + const value: Record = {}; + Object.assign(value, parseDocumentValue(getMapValue(row, ['values']))); + Object.assign(value, parseDocumentValue(getMapValue(row, ['extra_attributes', 'extraAttributes']))); + + return { + id: getMapValue(row, ['id', 'doc_id']), + value: toCompatObject(value) + }; +} + +export function parseAggregateResultRow(rawRow: unknown): Record { + const row = mapLikeToObject(rawRow); + + const result: Record = {}; + Object.assign(result, parseDocumentValue(getMapValue(row, ['values']))); + Object.assign(result, parseDocumentValue(getMapValue(row, ['extra_attributes', 'extraAttributes']))); + + for (const [key, value] of Object.entries(row)) { + if ( + key === 'id' || + key === 'values' || + key.toLowerCase() === 'extra_attributes' || + key === 'extraAttributes' + ) { + continue; + } + + if (!Object.hasOwn(result, key)) { + result[key] = value; + } + } + + return toCompatObject(result); +} diff --git a/packages/test-utils/lib/test-utils.ts b/packages/test-utils/lib/test-utils.ts index 69e9834023f..a82fed21601 100644 --- a/packages/test-utils/lib/test-utils.ts +++ b/packages/test-utils/lib/test-utils.ts @@ -19,7 +19,7 @@ export const GLOBAL = { OPEN_RESP_3: { serverArguments: [...DEBUG_MODE_ARGS], clientOptions: { - RESP: 3, + RESP: 3 as const, } }, } diff --git a/packages/time-series/lib/commands/INFO.ts b/packages/time-series/lib/commands/INFO.ts index b49395ebdb7..526ef058c15 100644 --- a/packages/time-series/lib/commands/INFO.ts +++ b/packages/time-series/lib/commands/INFO.ts @@ -1,7 +1,7 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { ArrayReply, BlobStringReply, Command, DoubleReply, NumberReply, ReplyUnion, SimpleStringReply, TypeMapping } from "@redis/client/dist/lib/RESP/types"; import { TimeSeriesDuplicatePolicies } from "./helpers"; -import { TimeSeriesAggregationType } from "./CREATERULE"; +import { TIME_SERIES_AGGREGATION_TYPE, TimeSeriesAggregationType } from "./CREATERULE"; import { transformDoubleReply } from '@redis/client/dist/lib/commands/generic-transformers'; export type InfoRawReplyTypes = SimpleStringReply | @@ -71,6 +71,244 @@ export interface InfoReply { ignoreMaxValDiff: DoubleReply; } +function mapLikeEntries(value: unknown): Array<[string, any]> { + if (value instanceof Map) { + return Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]); + } + + if (Array.isArray(value)) { + if (value.every(item => Array.isArray(item) && item.length >= 2)) { + return value.map(item => [item[0].toString(), item[1]]); + } + + const entries: Array<[string, any]> = []; + for (let i = 0; i < value.length - 1; i += 2) { + entries.push([value[i].toString(), value[i + 1]]); + } + return entries; + } + + if (value !== null && typeof value === 'object') { + return Object.entries(value); + } + + return []; +} + +function mapLikeValues(value: unknown): Array { + if (Array.isArray(value)) return value; + if (value instanceof Map) return [...value.values()]; + if (value !== null && typeof value === 'object') return Object.values(value); + return []; +} + +function mapLikeToObject(value: unknown): Record { + const object: Record = {}; + for (const [key, entryValue] of mapLikeEntries(value)) { + object[key] = entryValue; + } + return object; +} + +function getMapValue(value: unknown, keys: Array): any { + const object = mapLikeToObject(value); + + for (const key of keys) { + if (Object.hasOwn(object, key)) { + return object[key]; + } + } + + const lowerCaseKeyToOriginal = new Map(); + for (const key of Object.keys(object)) { + const lowerCaseKey = key.toLowerCase(); + if (!lowerCaseKeyToOriginal.has(lowerCaseKey)) { + lowerCaseKeyToOriginal.set(lowerCaseKey, key); + } + } + + for (const key of keys) { + const original = lowerCaseKeyToOriginal.get(key.toLowerCase()); + if (original !== undefined) { + return object[original]; + } + } + + return undefined; +} + +function normalizeInfoLabels(labels: unknown): Array<[name: BlobStringReply, value: BlobStringReply]> { + if (Array.isArray(labels)) { + if (labels.every(item => Array.isArray(item) && item.length >= 2)) { + return labels.map(item => [item[0], item[1]]); + } + + const normalized = labels + .map(label => { + const object = mapLikeToObject(label); + return [ + getMapValue(object, ['name', 'label']), + getMapValue(object, ['value']) + ] as [BlobStringReply, BlobStringReply]; + }) + .filter(([name]) => name !== undefined); + + if (normalized.length > 0) { + return normalized; + } + } + + return mapLikeEntries(labels).map(([name, value]) => [name as unknown as BlobStringReply, value as BlobStringReply]); +} + +function normalizeInfoRules(rules: unknown): Array<[key: BlobStringReply, timeBucket: NumberReply, aggregationType: TimeSeriesAggregationType]> { + const normalized: Array<[key: BlobStringReply, timeBucket: NumberReply, aggregationType: TimeSeriesAggregationType]> = []; + + const aggregationTypes = new Set( + Object.values(TIME_SERIES_AGGREGATION_TYPE).map(type => type.toUpperCase()) + ); + + const parseRuleTuple = (rule: Array): [BlobStringReply, NumberReply, TimeSeriesAggregationType] => { + const stringCandidates = rule.filter(value => typeof value === 'string' || value instanceof Buffer); + const numberCandidates = rule.filter(value => typeof value === 'number'); + + const aggregationCandidate = stringCandidates.find(value => { + return aggregationTypes.has(value.toString().toUpperCase()); + }); + + const keyCandidate = stringCandidates.find(value => value !== aggregationCandidate); + + return [ + (keyCandidate ?? rule[0]) as BlobStringReply, + (numberCandidates[0] ?? Number(rule[1])) as NumberReply, + (aggregationCandidate ?? rule[2]) as TimeSeriesAggregationType + ]; + }; + + if (!Array.isArray(rules)) { + for (const [key, value] of mapLikeEntries(rules)) { + if (Array.isArray(value)) { + const timeBucket = value.find(item => typeof item === 'number') ?? Number(value[0]); + const aggregationType = value.find(item => { + return (typeof item === 'string' || item instanceof Buffer) && + aggregationTypes.has(item.toString().toUpperCase()); + }) ?? value[1]; + + normalized.push([ + key as unknown as BlobStringReply, + timeBucket as NumberReply, + aggregationType as TimeSeriesAggregationType + ]); + continue; + } + + const object = mapLikeToObject(value); + const timeBucket = getMapValue(object, ['timeBucket', 'time_bucket']) as NumberReply; + const aggregationType = getMapValue(object, ['aggregationType', 'aggregation_type']) as TimeSeriesAggregationType; + + normalized.push([ + key as unknown as BlobStringReply, + timeBucket, + aggregationType + ]); + } + + return normalized; + } + + if (Array.isArray(rules) && rules.every(rule => Array.isArray(rule) && rule.length >= 3)) { + return rules.map(rule => parseRuleTuple(rule)); + } + + for (const rule of mapLikeValues(rules)) { + if (Array.isArray(rule)) { + normalized.push(parseRuleTuple(rule)); + continue; + } + + const object = mapLikeToObject(rule); + const key = getMapValue(object, ['key']); + const timeBucket = getMapValue(object, ['timeBucket', 'time_bucket']); + const aggregationType = getMapValue(object, ['aggregationType', 'aggregation_type']); + normalized.push(parseRuleTuple([key, timeBucket, aggregationType])); + } + + return normalized; +} + +function normalizeInfoRawReply(reply: ReplyUnion): InfoRawReply { + if (Array.isArray(reply)) { + return reply as unknown as InfoRawReply; + } + + const normalized: Array = []; + for (const [key, value] of mapLikeEntries(reply)) { + switch (key) { + case 'labels': + normalized.push(key, normalizeInfoLabels(value)); + break; + case 'rules': + normalized.push(key, normalizeInfoRules(value)); + break; + default: + normalized.push(key, value); + break; + } + } + + return normalized as InfoRawReply; +} + +function transformInfoReplyResp2(reply: InfoRawReply, _: unknown, typeMapping?: TypeMapping): InfoReply { + const ret = {} as any; + + for (let i = 0; i < reply.length; i += 2) { + const key = (reply[i] as any).toString(); + + switch (key) { + case 'totalSamples': + case 'memoryUsage': + case 'firstTimestamp': + case 'lastTimestamp': + case 'retentionTime': + case 'chunkCount': + case 'chunkSize': + case 'chunkType': + case 'duplicatePolicy': + case 'sourceKey': + case 'ignoreMaxTimeDiff': + ret[key] = reply[i + 1]; + break; + case 'labels': + ret[key] = (reply[i + 1] as Array<[name: BlobStringReply, value: BlobStringReply]>).map( + ([name, value]) => ({ + name, + value + }) + ); + break; + case 'rules': + ret[key] = (reply[i + 1] as Array<[key: BlobStringReply, timeBucket: NumberReply, aggregationType: TimeSeriesAggregationType]>).map( + ([key, timeBucket, aggregationType]) => ({ + key, + timeBucket, + aggregationType + }) + ); + break; + case 'ignoreMaxValDiff': + ret[key] = transformDoubleReply[2](reply[i + 1] as unknown as BlobStringReply, undefined, typeMapping); + break; + } + } + + return ret; +} + +function transformInfoReplyResp3(reply: ReplyUnion, preserve?: unknown, typeMapping?: TypeMapping): InfoReply { + return transformInfoReplyResp2(normalizeInfoRawReply(reply), preserve, typeMapping); +} + export default { IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: string) { @@ -78,51 +316,7 @@ export default { parser.pushKey(key); }, transformReply: { - 2: (reply: InfoRawReply, _, typeMapping?: TypeMapping): InfoReply => { - const ret: Record = {}; - - for (let i=0; i < reply.length; i += 2) { - const key = (reply[i] as { toString(): string }).toString(); - - switch (key) { - case 'totalSamples': - case 'memoryUsage': - case 'firstTimestamp': - case 'lastTimestamp': - case 'retentionTime': - case 'chunkCount': - case 'chunkSize': - case 'chunkType': - case 'duplicatePolicy': - case 'sourceKey': - case 'ignoreMaxTimeDiff': - ret[key] = reply[i+1]; - break; - case 'labels': - ret[key] = (reply[i+1] as Array<[name: BlobStringReply, value: BlobStringReply]>).map( - ([name, value]) => ({ - name, - value - }) - ); - break; - case 'rules': - ret[key] = (reply[i+1] as Array<[key: BlobStringReply, timeBucket: NumberReply, aggregationType: TimeSeriesAggregationType]>).map( - ([key, timeBucket, aggregationType]) => ({ - key, - timeBucket, - aggregationType - }) - ); - break; - case 'ignoreMaxValDiff': - ret[key] = transformDoubleReply[2](reply[27] as unknown as BlobStringReply, undefined, typeMapping); - break; - } - } - - return ret as unknown as InfoReply; - }, - 3: undefined as unknown as () => ReplyUnion + 2: transformInfoReplyResp2, + 3: transformInfoReplyResp3 }, } as const satisfies Command; diff --git a/packages/time-series/lib/commands/INFO_DEBUG.ts b/packages/time-series/lib/commands/INFO_DEBUG.ts index e390d0c53d5..fecfafd3e36 100644 --- a/packages/time-series/lib/commands/INFO_DEBUG.ts +++ b/packages/time-series/lib/commands/INFO_DEBUG.ts @@ -36,6 +36,68 @@ export interface InfoDebugReply extends InfoReply { }>; } +function mapLikeToObject(value: unknown): Record { + if (value instanceof Map) { + return Object.fromEntries( + Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]) + ); + } + + if (Array.isArray(value)) { + const object: Record = {}; + for (let i = 0; i < value.length - 1; i += 2) { + object[value[i].toString()] = value[i + 1]; + } + return object; + } + + if (value !== null && typeof value === 'object') { + return value as Record; + } + + return {}; +} + +function mapLikeValues(value: unknown): Array { + if (Array.isArray(value)) return value; + if (value instanceof Map) return [...value.values()]; + if (value !== null && typeof value === 'object') return Object.values(value); + return []; +} + +function normalizeChunks(chunks: unknown): InfoDebugReply['chunks'] { + return mapLikeValues(chunks).map(chunk => { + if (Array.isArray(chunk)) { + if (chunk.length >= 10 && chunk[0] === 'startTimestamp') { + return { + startTimestamp: chunk[1], + endTimestamp: chunk[3], + samples: chunk[5], + size: chunk[7], + bytesPerSample: chunk[9].toString() + }; + } + + return { + startTimestamp: chunk[0], + endTimestamp: chunk[1], + samples: chunk[2], + size: chunk[3], + bytesPerSample: chunk[4].toString() + }; + } + + const object = mapLikeToObject(chunk); + return { + startTimestamp: object.startTimestamp ?? object.start_timestamp, + endTimestamp: object.endTimestamp ?? object.end_timestamp, + samples: object.samples, + size: object.size, + bytesPerSample: (object.bytesPerSample ?? object.bytes_per_sample).toString() + }; + }); +} + export default { IS_READ_ONLY: INFO.IS_READ_ONLY, parseCommand(parser: CommandParser, key: string) { @@ -71,6 +133,16 @@ export default { return ret as unknown as InfoDebugReply; }, - 3: undefined as unknown as () => ReplyUnion + 3: (reply: ReplyUnion, preserve?: unknown, typeMapping?: TypeMapping): InfoDebugReply => { + const ret = INFO.transformReply[3](reply, preserve, typeMapping) as InfoDebugReply; + const mappedReply = mapLikeToObject(reply); + + ret.keySelfName = mappedReply.keySelfName ?? mappedReply.key_self_name; + + const chunks = mappedReply.Chunks ?? mappedReply.chunks; + ret.chunks = normalizeChunks(chunks); + + return ret; + } }, } as const satisfies Command; diff --git a/packages/time-series/lib/commands/MRANGE_WITHLABELS.ts b/packages/time-series/lib/commands/MRANGE_WITHLABELS.ts index 7f543909b51..925b7c4fb73 100644 --- a/packages/time-series/lib/commands/MRANGE_WITHLABELS.ts +++ b/packages/time-series/lib/commands/MRANGE_WITHLABELS.ts @@ -44,9 +44,9 @@ export function createTransformMRangeWithLabelsArguments(command: RedisArgument) toTimestamp, options ); - + parser.push('WITHLABELS'); - + parseFilterArgument(parser, filter); }; } @@ -60,7 +60,7 @@ export default { return resp2MapToValue(reply, ([_key, labels, samples]) => { const unwrappedLabels = labels as unknown as UnwrapReply; // TODO: use Map type mapping for labels - const labelsObject: Record = Object.create(null); + const labelsObject: Record = {}; for (const tuple of unwrappedLabels) { const [key, value] = tuple as unknown as UnwrapReply; const unwrappedKey = key as unknown as UnwrapReply; diff --git a/packages/time-series/lib/commands/helpers.ts b/packages/time-series/lib/commands/helpers.ts index db15a8dbdc4..e43db1787cd 100644 --- a/packages/time-series/lib/commands/helpers.ts +++ b/packages/time-series/lib/commands/helpers.ts @@ -173,7 +173,7 @@ export function resp2MapToValue< return reply as never; } default: { - const ret: Record = Object.create(null); + const ret: Record = {}; for (const wrappedTuple of reply) { const tuple = wrappedTuple as unknown as UnwrapReply; const key = tuple[0] as unknown as UnwrapReply; @@ -181,7 +181,7 @@ export function resp2MapToValue< } return ret as never; } - } + } } export function resp3MapToValue< @@ -191,7 +191,7 @@ export function resp3MapToValue< wrappedReply: MapReply, parseFunc: (rawValue: UnwrapReply) => TRANSFORMED ): MapReply { - const reply = wrappedReply as unknown as UnwrapReply; + const reply = wrappedReply as unknown as UnwrapReply; if (reply instanceof Array) { for (let i = 1; i < reply.length; i += 2) { (reply[i] as unknown as TRANSFORMED) = parseFunc(reply[i] as unknown as UnwrapReply); @@ -246,7 +246,7 @@ export function transformRESP2Labels( case Object: default: - const labelsObject: Record = Object.create(null); + const labelsObject: Record = {}; for (const tuple of unwrappedLabels) { const [key, value] = tuple as unknown as UnwrapReply; const unwrappedKey = key as unknown as UnwrapReply; @@ -280,7 +280,7 @@ export function transformRESP2LabelsWithSources( case Object: default: - const labelsObject: Record = Object.create(null); + const labelsObject: Record = {}; for (let i = 0; i < to; i++) { const [key, value] = unwrappedLabels[i] as unknown as UnwrapReply; const unwrappedKey = key as unknown as UnwrapReply; @@ -304,7 +304,7 @@ export function transformRESP2LabelsWithSources( function transformRESP2Sources(sourcesRaw: BlobStringReply) { // if a label contains "," this function will produce incorrcet results.. // there is not much we can do about it, and we assume most users won't be using "," in their labels.. - + const unwrappedSources = sourcesRaw as unknown as UnwrapReply; if (typeof unwrappedSources === 'string') { return unwrappedSources.split(','); From e5dcaaea3f374be432487a8c96fd7aae2d5c98f1 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 7 Apr 2026 16:56:47 +0300 Subject: [PATCH 04/28] fix(modules): address targeted RESP compatibility regressions - Fix GEO float reply handling across geosearch-compatible paths. - Fix Bloom CF.INSERTNX status handling and Search PROFILE parsing edge cases. - Fix TimeSeries MRANGE selected-label/groupby compatibility behavior. --- .../bloom/lib/commands/cuckoo/INSERTNX.ts | 4 +- .../client/lib/commands/GEOSEARCH_WITH.ts | 30 +++++--- .../search/lib/commands/PROFILE_AGGREGATE.ts | 17 ++++- .../search/lib/commands/PROFILE_SEARCH.ts | 70 ++++++++++++++++++- .../lib/commands/MRANGE_GROUPBY.ts | 3 +- .../lib/commands/MRANGE_SELECTED_LABELS.ts | 2 +- .../MRANGE_SELECTED_LABELS_GROUPBY.ts | 5 +- 7 files changed, 110 insertions(+), 21 deletions(-) diff --git a/packages/bloom/lib/commands/cuckoo/INSERTNX.ts b/packages/bloom/lib/commands/cuckoo/INSERTNX.ts index bf99db6c3f7..81b1f88a422 100644 --- a/packages/bloom/lib/commands/cuckoo/INSERTNX.ts +++ b/packages/bloom/lib/commands/cuckoo/INSERTNX.ts @@ -1,4 +1,4 @@ -import { Command } from '@redis/client/dist/lib/RESP/types'; +import { ArrayReply, Command, NumberReply } from '@redis/client/dist/lib/RESP/types'; import INSERT, { parseCfInsertArguments } from './INSERT'; /** @@ -16,5 +16,5 @@ export default { args[0].push('CF.INSERTNX'); parseCfInsertArguments(...args); }, - transformReply: INSERT.transformReply + transformReply: undefined as unknown as () => ArrayReply> } as const satisfies Command; diff --git a/packages/client/lib/commands/GEOSEARCH_WITH.ts b/packages/client/lib/commands/GEOSEARCH_WITH.ts index 37610a2758d..60cdf1a1418 100644 --- a/packages/client/lib/commands/GEOSEARCH_WITH.ts +++ b/packages/client/lib/commands/GEOSEARCH_WITH.ts @@ -1,6 +1,7 @@ import { CommandParser } from '../client/parser'; -import { RedisArgument, BlobStringReply, NumberReply, DoubleReply, Command } from '../RESP/types'; +import { RedisArgument, ArrayReply, TuplesReply, BlobStringReply, NumberReply, DoubleReply, UnwrapReply, Command, TypeMapping } from '../RESP/types'; import GEOSEARCH, { GeoSearchBy, GeoSearchFrom, GeoSearchOptions } from './GEOSEARCH'; +import { transformDoubleReply } from './generic-transformers'; export const GEO_REPLY_WITH = { DISTANCE: 'WITHDIST', @@ -12,7 +13,7 @@ export type GeoReplyWith = typeof GEO_REPLY_WITH[keyof typeof GEO_REPLY_WITH]; export interface GeoReplyWithMember { member: BlobStringReply; - distance?: BlobStringReply; + distance?: DoubleReply; hash?: NumberReply; coordinates?: { longitude: DoubleReply; @@ -40,33 +41,42 @@ export default { parser.preserve = replyWith; }, transformReply( - reply: Array, - replyWith: Array + reply: UnwrapReply]>>>, + replyWith: Array, + typeMapping?: TypeMapping ) { const replyWithSet = new Set(replyWith); let index = 0; const distanceIndex = replyWithSet.has(GEO_REPLY_WITH.DISTANCE) && ++index, hashIndex = replyWithSet.has(GEO_REPLY_WITH.HASH) && ++index, coordinatesIndex = replyWithSet.has(GEO_REPLY_WITH.COORDINATES) && ++index; - + + const parseDouble = (value: unknown) => { + return ( + typeof value === 'number' ? + value as unknown as DoubleReply : + transformDoubleReply[2](value as BlobStringReply, undefined, typeMapping) + ); + }; + return reply.map(raw => { const item: GeoReplyWithMember = { member: raw[0] }; if (distanceIndex) { - item.distance = raw[distanceIndex] as BlobStringReply; + item.distance = parseDouble(raw[distanceIndex]); } - + if (hashIndex) { item.hash = raw[hashIndex] as NumberReply; } - + if (coordinatesIndex) { const [longitude, latitude] = raw[coordinatesIndex] as [DoubleReply, DoubleReply]; item.coordinates = { - longitude, - latitude + longitude: parseDouble(longitude), + latitude: parseDouble(latitude) }; } diff --git a/packages/search/lib/commands/PROFILE_AGGREGATE.ts b/packages/search/lib/commands/PROFILE_AGGREGATE.ts index a3dd46795d9..86cb9215291 100644 --- a/packages/search/lib/commands/PROFILE_AGGREGATE.ts +++ b/packages/search/lib/commands/PROFILE_AGGREGATE.ts @@ -1,7 +1,13 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { Command, ReplyUnion, UnwrapReply } from '@redis/client/dist/lib/RESP/types'; import AGGREGATE, { AggregateRawReply, FtAggregateOptions, parseAggregateOptions } from './AGGREGATE'; -import { ProfileOptions, ProfileRawReplyResp2, ProfileReplyResp2, } from './PROFILE_SEARCH'; +import { + ProfileOptions, + ProfileRawReplyResp2, + ProfileReplyResp2, + extractProfileResultsReply, + transformProfileReply +} from './PROFILE_SEARCH'; export default { NOT_KEYED_COMMAND: true, @@ -29,6 +35,13 @@ export default { profile: reply[1] } }, - 3: (reply: ReplyUnion): ReplyUnion => reply + 3: (reply: ReplyUnion): ProfileReplyResp2 => { + return { + results: AGGREGATE.transformReply[3]( + extractProfileResultsReply(reply) + ), + profile: transformProfileReply(reply) + }; + } }, } as const satisfies Command; diff --git a/packages/search/lib/commands/PROFILE_SEARCH.ts b/packages/search/lib/commands/PROFILE_SEARCH.ts index b72a71f94e4..8b7c934e67d 100644 --- a/packages/search/lib/commands/PROFILE_SEARCH.ts +++ b/packages/search/lib/commands/PROFILE_SEARCH.ts @@ -2,6 +2,7 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { ArrayReply, Command, RedisArgument, ReplyUnion, TuplesReply, UnwrapReply } from '@redis/client/dist/lib/RESP/types'; import { AggregateReply } from './AGGREGATE'; import SEARCH, { FtSearchOptions, SearchRawReply, SearchReply, parseSearchOptions } from './SEARCH'; +import { getMapValue, mapLikeEntries, mapLikeToObject, normalizeProfileReply } from './reply-transformers'; export type ProfileRawReplyResp2 = TuplesReply<[ T, @@ -19,6 +20,73 @@ export interface ProfileOptions { LIMITED?: true; } +export function extractProfileResultsReply(reply: ReplyUnion): ReplyUnion { + const replyObject = mapLikeToObject(reply); + + // Redis 8+ wraps results under `Results`. + if (Object.hasOwn(replyObject, 'Results')) { + return replyObject['Results'] as ReplyUnion; + } + + // Redis 7.4 RESP3 returns search/aggregate payload directly at top-level. + if ( + (Object.hasOwn(replyObject, 'total_results') || Object.hasOwn(replyObject, 'total')) && + Object.hasOwn(replyObject, 'results') + ) { + return reply; + } + + if (Object.hasOwn(replyObject, 'results')) { + return replyObject['results'] as ReplyUnion; + } + + return (getMapValue(replyObject, ['results']) ?? reply) as ReplyUnion; +} + +function normalizeLegacyProfileReply(profile: ReplyUnion): ReplyUnion { + return mapLikeEntries(profile).map(([key, value]) => { + // Redis 7.4 often wraps iterator profiles as a single-element array containing an object. + // Tests expect the inner object normalized directly as a flat key/value list. + if (Array.isArray(value) && value.length === 1) { + const first = value[0]; + if (Object.keys(mapLikeToObject(first)).length > 0) { + return [key, normalizeProfileReply(first)]; + } + } + + return [key, normalizeProfileReply(value)]; + }) as unknown as ReplyUnion; +} + +export function transformProfileReply(reply: ReplyUnion): ReplyUnion { + const replyObject = mapLikeToObject(reply); + const profile = ( + Object.hasOwn(replyObject, 'Profile') ? + replyObject['Profile'] : + Object.hasOwn(replyObject, 'profile') ? + replyObject['profile'] : + getMapValue(replyObject, ['Profile', 'profile']) + ) as ReplyUnion; + + const profileObject = mapLikeToObject(profile); + + // Redis 7.2 - 7.4 profile payload is a plain map keyed by timing labels. + if (Object.hasOwn(profileObject, 'Total profile time')) { + return normalizeLegacyProfileReply(profile); + } + + return normalizeProfileReply(profile) as ReplyUnion; +} + +function transformProfileSearchReplyResp3(reply: ReplyUnion): ProfileReplyResp2 { + return { + results: SEARCH.transformReply[3]( + extractProfileResultsReply(reply) + ), + profile: transformProfileReply(reply) + }; +} + export default { NOT_KEYED_COMMAND: true, IS_READ_ONLY: true, @@ -45,6 +113,6 @@ export default { profile: reply[1] }; }, - 3: (reply: ReplyUnion): ReplyUnion => reply + 3: transformProfileSearchReplyResp3 }, } as const satisfies Command; diff --git a/packages/time-series/lib/commands/MRANGE_GROUPBY.ts b/packages/time-series/lib/commands/MRANGE_GROUPBY.ts index 7ef974b7a12..5c4d41c850c 100644 --- a/packages/time-series/lib/commands/MRANGE_GROUPBY.ts +++ b/packages/time-series/lib/commands/MRANGE_GROUPBY.ts @@ -113,9 +113,8 @@ export default { }, typeMapping); }, 3(reply: TsMRangeGroupByRawReply3) { - return resp3MapToValue(reply, ([_labels, _metadata1, metadata2, samples]) => { + return resp3MapToValue(reply, ([_labels, _metadata1, _metadata2, samples]) => { return { - sources: extractResp3MRangeSources(metadata2), samples: transformSamplesReply[3](samples) }; }); diff --git a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts index 01ab8e6fcf4..7cc05d11ab6 100644 --- a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts +++ b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts @@ -65,7 +65,7 @@ export default { }, typeMapping); }, 3(reply: TsMRangeSelectedLabelsRawReply3) { - return resp3MapToValue(reply, ([_key, labels, samples]) => { + return resp3MapToValue(reply, ([labels, _metadata, samples]) => { return { labels, samples: transformSamplesReply[3](samples) diff --git a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_GROUPBY.ts b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_GROUPBY.ts index 198d441301b..38e3a4a60d3 100644 --- a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_GROUPBY.ts +++ b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_GROUPBY.ts @@ -3,7 +3,7 @@ import { Command, ArrayReply, BlobStringReply, MapReply, TuplesReply, RedisArgum import { RedisVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; import { parseSelectedLabelsArguments, resp3MapToValue, SampleRawReply, Timestamp, transformSamplesReply } from './helpers'; import { TsRangeOptions, parseRangeArguments } from './RANGE'; -import { extractResp3MRangeSources, parseGroupByArguments, TsMRangeGroupBy, TsMRangeGroupByRawMetadataReply3 } from './MRANGE_GROUPBY'; +import { parseGroupByArguments, TsMRangeGroupBy, TsMRangeGroupByRawMetadataReply3 } from './MRANGE_GROUPBY'; import { parseFilterArgument } from './MGET'; import MRANGE_SELECTED_LABELS from './MRANGE_SELECTED_LABELS'; @@ -55,10 +55,9 @@ export default { transformReply: { 2: MRANGE_SELECTED_LABELS.transformReply[2], 3(reply: TsMRangeWithLabelsGroupByRawReply3) { - return resp3MapToValue(reply, ([labels, _metadata, metadata2, samples]) => { + return resp3MapToValue(reply, ([labels, _metadata, _metadata2, samples]) => { return { labels, - sources: extractResp3MRangeSources(metadata2), samples: transformSamplesReply[3](samples) }; }); From 5b18a00d3de3c26780d39926cf643c2d71207a57 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 15 Apr 2026 21:16:36 +0300 Subject: [PATCH 05/28] tests: fix some wrong assertions generic-transformers.spec.ts was newly uncommented and needed some assertion updates --- .../lib/commands/generic-transformers.spec.ts | 111 ++++++++++-------- .../lib/commands/generic-transformers.ts | 2 +- packages/search/lib/commands/INFO.spec.ts | 62 +++++++++- packages/search/lib/commands/INFO.ts | 14 +-- 4 files changed, 133 insertions(+), 56 deletions(-) diff --git a/packages/client/lib/commands/generic-transformers.spec.ts b/packages/client/lib/commands/generic-transformers.spec.ts index 422dcdbb25c..fcb0effe6f5 100644 --- a/packages/client/lib/commands/generic-transformers.spec.ts +++ b/packages/client/lib/commands/generic-transformers.spec.ts @@ -4,8 +4,8 @@ import { pushScanArguments } from './SCAN'; import { parseGeoSearchArguments, parseGeoSearchOptions } from './GEOSEARCH'; import GEOSEARCH_WITH, { GEO_REPLY_WITH } from './GEOSEARCH_WITH'; import { - transformBooleanReply as transformBooleanReplyTransformer, - transformBooleanArrayReply as transformBooleanArrayReplyTransformer, + transformBooleanReply, + transformBooleanArrayReply, transformDoubleReply, transformNullableDoubleReply, transformDoubleArgument, @@ -24,17 +24,16 @@ import { transformCommandReply, CommandFlags, CommandCategories, - parseSlotRangesArguments + parseSlotRangesArguments, + Stringable, + StreamMessageRawReply, + StreamsMessagesRawReply2 } from './generic-transformers'; +import { ArrayReply, BlobStringReply, DoubleReply, NullReply, NumberReply, TuplesReply, UnwrapReply } from '../RESP/types'; -const transformBooleanReply = transformBooleanReplyTransformer[2]; -const transformBooleanArrayReply = transformBooleanArrayReplyTransformer[2]; -const transformNumberInfinityReply = transformDoubleReply[2]; -const transformNumberInfinityNullReply = transformNullableDoubleReply[2]; const transformNumberInfinityArgument = transformDoubleArgument; const transformStringNumberInfinityArgument = transformStringDoubleArgument; const transformStreamsMessagesReply = transformStreamsMessagesReplyResp2; -const transformSortedSetWithScoresReply = transformSortedSetReply[2]; const GeoReplyWith = GEO_REPLY_WITH; const transformGeoMembersWithReply = GEOSEARCH_WITH.transformReply; @@ -107,32 +106,35 @@ function pushSlotRangesArguments( describe('Generic Transformers', () => { describe('transformBooleanReply', () => { + assert.equal(transformBooleanReply[3], undefined); it('0', () => { assert.equal( - transformBooleanReply(0), + transformBooleanReply[2](0 as unknown as NumberReply<0|1>), false ); }); it('1', () => { assert.equal( - transformBooleanReply(1), + transformBooleanReply[2](1 as unknown as NumberReply<0|1>), true ); }); + }); describe('transformBooleanArrayReply', () => { + assert.equal(transformBooleanArrayReply[3], undefined); it('empty array', () => { assert.deepEqual( - transformBooleanArrayReply([]), + transformBooleanArrayReply[2]([] as unknown as ArrayReply>), [] ); }); it('0, 1', () => { assert.deepEqual( - transformBooleanArrayReply([0, 1]), + transformBooleanArrayReply[2]([0, 1] as unknown as ArrayReply>), [false, true] ); }); @@ -141,14 +143,14 @@ describe('Generic Transformers', () => { describe('pushScanArguments', () => { it('cusror only', () => { assert.deepEqual( - pushScanArguments([], 0), + pushScanArguments([], '0'), ['0'] ); }); it('with MATCH', () => { assert.deepEqual( - pushScanArguments([], 0, { + pushScanArguments([], '0', { MATCH: 'pattern' }), ['0', 'MATCH', 'pattern'] @@ -157,7 +159,7 @@ describe('Generic Transformers', () => { it('with COUNT', () => { assert.deepEqual( - pushScanArguments([], 0, { + pushScanArguments([], '0', { COUNT: 1 }), ['0', 'COUNT', '1'] @@ -166,7 +168,7 @@ describe('Generic Transformers', () => { it('with MATCH & COUNT', () => { assert.deepEqual( - pushScanArguments([], 0, { + pushScanArguments([], '0', { MATCH: 'pattern', COUNT: 1 }), @@ -175,40 +177,42 @@ describe('Generic Transformers', () => { }); }); - describe('transformNumberInfinityReply', () => { + describe('transformDoubleReply', () => { + assert.equal(transformDoubleReply[3], undefined); it('0.5', () => { assert.equal( - transformNumberInfinityReply('0.5'), + transformDoubleReply[2]('0.5' as unknown as BlobStringReply), 0.5 ); }); it('+inf', () => { assert.equal( - transformNumberInfinityReply('+inf'), + transformDoubleReply[2]('+inf' as unknown as BlobStringReply), Infinity ); }); it('-inf', () => { assert.equal( - transformNumberInfinityReply('-inf'), + transformDoubleReply[2]('-inf' as unknown as BlobStringReply), -Infinity ); }); }); describe('transformNumberInfinityNullReply', () => { + assert.equal(transformNullableDoubleReply[3], undefined); it('null', () => { assert.equal( - transformNumberInfinityNullReply(null), + transformNullableDoubleReply[2](null as unknown as NullReply), null ); }); it('1', () => { assert.equal( - transformNumberInfinityNullReply('1'), + transformNullableDoubleReply[2]('1' as unknown as BlobStringReply), 1 ); }); @@ -255,7 +259,7 @@ describe('Generic Transformers', () => { it('transformTuplesReply', () => { assert.deepEqual( - transformTuplesReply(['key1', 'value1', 'key2', 'value2']), + transformTuplesReply(['key1', 'value1', 'key2', 'value2'] as unknown as ArrayReply), Object.create({}, { key1: { value: 'value1', @@ -273,7 +277,7 @@ describe('Generic Transformers', () => { it('transformStreamMessagesReply', () => { assert.deepEqual( - transformStreamMessagesReply([['0-0', ['0key', '0value']], ['1-0', ['1key', '1value']]]), + transformStreamMessagesReply([['0-0', ['0key', '0value']], ['1-0', ['1key', '1value']]] as unknown as ArrayReply), [{ id: '0-0', message: Object.create({}, { @@ -306,7 +310,7 @@ describe('Generic Transformers', () => { it('with messages', () => { assert.deepEqual( - transformStreamsMessagesReply([['stream1', [['0-1', ['11key', '11value']], ['1-1', ['12key', '12value']]]], ['stream2', [['0-2', ['2key1', '2value1', '2key2', '2value2']]]]]), + transformStreamsMessagesReply([['stream1', [['0-1', ['11key', '11value']], ['1-1', ['12key', '12value']]]], ['stream2', [['0-2', ['2key1', '2value1', '2key2', '2value2']]]]] as unknown as UnwrapReply), [{ name: 'stream1', messages: [{ @@ -350,9 +354,22 @@ describe('Generic Transformers', () => { }); }); - it('transformSortedSetWithScoresReply', () => { + it('transformSortedSetReply', () => { + assert.deepEqual( + transformSortedSetReply[2](['member1', '0.5', 'member2', '+inf', 'member3', '-inf'] as unknown as ArrayReply), + [{ + value: 'member1', + score: 0.5 + }, { + value: 'member2', + score: Infinity + }, { + value: 'member3', + score: -Infinity + }] + ); assert.deepEqual( - transformSortedSetWithScoresReply(['member1', '0.5', 'member2', '+inf', 'member3', '-inf']), + transformSortedSetReply[3]([['member1', 0.5], ['member2', Infinity], ['member3', -Infinity]] as unknown as ArrayReply>), [{ value: 'member1', score: 0.5 @@ -455,18 +472,18 @@ describe('Generic Transformers', () => { [ '1', '2' - ], + ] as unknown as TuplesReply<[BlobStringReply, ...Array]>, [ '3', '4' - ] + ] as unknown as TuplesReply<[BlobStringReply, ...Array]> ], [GeoReplyWith.DISTANCE]), [{ member: '1', - distance: '2' + distance: 2 }, { member: '3', - distance: '4' + distance: 4 }] ); }); @@ -477,11 +494,11 @@ describe('Generic Transformers', () => { [ '1', 2 - ], + ] as unknown as TuplesReply<[BlobStringReply, ...Array]>, [ '3', 4 - ] + ] as unknown as TuplesReply<[BlobStringReply, ...Array]> ], [GeoReplyWith.HASH]), [{ member: '1', @@ -502,26 +519,26 @@ describe('Generic Transformers', () => { '2', '3' ] - ], + ] as unknown as TuplesReply<[BlobStringReply, ...Array]>, [ '4', [ '5', '6' ] - ] + ] as unknown as TuplesReply<[BlobStringReply, ...Array]> ], [GeoReplyWith.COORDINATES]), [{ member: '1', coordinates: { - longitude: '2', - latitude: '3' + longitude: 2, + latitude: 3 } }, { member: '4', coordinates: { - longitude: '5', - latitude: '6' + longitude: 5, + latitude: 6 } }] ); @@ -538,7 +555,7 @@ describe('Generic Transformers', () => { '4', '5' ] - ], + ] as unknown as TuplesReply<[BlobStringReply, ...Array]>, [ '6', '7', @@ -547,23 +564,23 @@ describe('Generic Transformers', () => { '9', '10' ] - ] + ] as unknown as TuplesReply<[BlobStringReply, ...Array]> ], [GeoReplyWith.DISTANCE, GeoReplyWith.HASH, GeoReplyWith.COORDINATES]), [{ member: '1', - distance: '2', + distance: 2, hash: 3, coordinates: { - longitude: '4', - latitude: '5' + longitude: 4, + latitude: 5 } }, { member: '6', - distance: '7', + distance: 7, hash: 8, coordinates: { - longitude: '9', - latitude: '10' + longitude: 9, + latitude: 10 } }] ); diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index 94407036a99..cf6daecbe1a 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -560,7 +560,7 @@ export function transformStreamMessagesReply( } type StreamMessagesRawReply = TuplesReply<[name: BlobStringReply, ArrayReply]>; -type StreamsMessagesRawReply2 = ArrayReply; +export type StreamsMessagesRawReply2 = ArrayReply; export function transformStreamsMessagesReplyResp2( reply: UnwrapReply, diff --git a/packages/search/lib/commands/INFO.spec.ts b/packages/search/lib/commands/INFO.spec.ts index 3059b22ebff..22015588621 100644 --- a/packages/search/lib/commands/INFO.spec.ts +++ b/packages/search/lib/commands/INFO.spec.ts @@ -13,13 +13,73 @@ describe('INFO', () => { }); testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'client.ft.info', async client => { - await client.ft.create('index', { field: SCHEMA_FIELD_TYPE.TEXT }); + const ret = await client.ft.info('index'); + assert.ok(ret !== null && typeof ret === 'object'); assert.equal(ret.index_name, 'index'); + assert.ok(Array.isArray(ret.index_options)); + assert.ok(ret.index_definition !== null && typeof ret.index_definition === 'object'); + assert.equal(ret.index_definition.key_type, 'HASH'); + assert.ok(Array.isArray(ret.index_definition.prefixes)); + assert.equal(Number(ret.index_definition.default_score), 1); + + assert.ok(Array.isArray(ret.attributes)); + assert.equal(ret.attributes.length, 1); + assert.ok(ret.attributes[0] !== null && typeof ret.attributes[0] === 'object'); + assert.equal(ret.attributes[0].identifier, 'field'); + assert.equal(ret.attributes[0].attribute, 'field'); + assert.equal(ret.attributes[0].type, 'TEXT'); + assert.equal(Number(ret.attributes[0].WEIGHT), 1); + + assert.equal(typeof ret.num_docs, 'number'); + assert.equal(typeof ret.max_doc_id, 'number'); + assert.equal(typeof ret.num_terms, 'number'); + assert.equal(typeof ret.num_records, 'number'); + assert.equal(typeof ret.inverted_sz_mb, 'number'); + assert.equal(typeof ret.vector_index_sz_mb, 'number'); + assert.equal(typeof ret.total_inverted_index_blocks, 'number'); + assert.equal(typeof ret.offset_vectors_sz_mb, 'number'); + assert.equal(typeof ret.doc_table_size_mb, 'number'); + assert.equal(typeof ret.sortable_values_size_mb, 'number'); + assert.equal(typeof ret.key_table_size_mb, 'number'); + assert.equal(typeof ret.records_per_doc_avg, 'number'); + assert.equal(typeof ret.bytes_per_record_avg, 'number'); + assert.equal(typeof ret.cleaning, 'number'); + assert.equal(typeof ret.offsets_per_term_avg, 'number'); + assert.equal(typeof ret.offset_bits_per_record_avg, 'number'); + assert.equal(typeof ret.geoshapes_sz_mb, 'number'); + assert.equal(typeof ret.hash_indexing_failures, 'number'); + assert.equal(typeof ret.indexing, 'number'); + assert.equal(typeof ret.percent_indexed, 'number'); + assert.equal(typeof ret.number_of_uses, 'number'); + assert.equal(typeof ret.tag_overhead_sz_mb, 'number'); + assert.equal(typeof ret.text_overhead_sz_mb, 'number'); + assert.equal(typeof ret.total_index_memory_sz_mb, 'number'); + assert.equal(typeof ret.total_indexing_time, 'number'); + + assert.ok(ret.gc_stats !== null && typeof ret.gc_stats === 'object'); + assert.equal(typeof ret.gc_stats.bytes_collected, 'number'); + assert.equal(typeof ret.gc_stats.total_ms_run, 'number'); + assert.equal(typeof ret.gc_stats.total_cycles, 'number'); + assert.equal(typeof ret.gc_stats.average_cycle_time_ms, 'number'); + assert.equal(typeof ret.gc_stats.last_run_time_ms, 'number'); + assert.equal(typeof ret.gc_stats.gc_numeric_trees_missed, 'number'); + assert.equal(typeof ret.gc_stats.gc_blocks_denied, 'number'); + + assert.ok(ret.cursor_stats !== null && typeof ret.cursor_stats === 'object'); + assert.equal(typeof ret.cursor_stats.global_idle, 'number'); + assert.equal(typeof ret.cursor_stats.global_total, 'number'); + assert.equal(typeof ret.cursor_stats.index_capacity, 'number'); + assert.equal(typeof ret.cursor_stats.index_total, 'number'); + + if (ret.stopwords_list !== undefined) { + assert.ok(Array.isArray(ret.stopwords_list)); + } + }, GLOBAL.SERVERS.OPEN); testUtils.testWithClientIfVersionWithinRange([[7, 4, 2], [7, 4, 2]], 'client.ft.info', async client => { diff --git a/packages/search/lib/commands/INFO.ts b/packages/search/lib/commands/INFO.ts index de666708996..4b0f30964d0 100644 --- a/packages/search/lib/commands/INFO.ts +++ b/packages/search/lib/commands/INFO.ts @@ -1,6 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument } from "@redis/client"; -import { ArrayReply, BlobStringReply, Command, DoubleReply, MapReply, NullReply, NumberReply, ReplyUnion, SimpleStringReply, TypeMapping } from "@redis/client/dist/lib/RESP/types"; +import { ArrayReply, BlobStringReply, Command, DoubleReply, MapReply, NullReply, NumberReply, SimpleStringReply, TypeMapping } from "@redis/client/dist/lib/RESP/types"; import { createTransformTuplesReplyFunc, transformDoubleReply } from "@redis/client/dist/lib/commands/generic-transformers"; import { TuplesReply } from '@redis/client/dist/lib/RESP/types'; @@ -12,7 +12,7 @@ export default { }, transformReply: { 2: transformV2Reply, - 3: undefined as unknown as () => ReplyUnion + 3: undefined as unknown as () => InfoReply }, } as const satisfies Command; @@ -83,7 +83,7 @@ function transformV2Reply(reply: Array, preserve?: unknown, typeMapping case 'hash_indexing_failures': case 'indexing': case 'number_of_uses': - case 'cleaning': + case 'cleaning': case 'stopwords_list': ret[key] = reply[i+1] as never; break; @@ -102,8 +102,8 @@ function transformV2Reply(reply: Array, preserve?: unknown, typeMapping case 'offsets_per_term_avg': case 'offset_bits_per_record_avg': case 'total_indexing_time': - case 'percent_indexed': - ret[key] = transformDoubleReply[2](reply[i+1] as BlobStringReply, undefined, typeMapping) as DoubleReply; + case 'percent_indexed': + ret[key] = transformDoubleReply[2](reply[i+1], undefined, typeMapping) as DoubleReply; break; case 'index_definition': ret[key] = myTransformFunc(reply[i+1] as ArrayReply); @@ -131,7 +131,7 @@ function transformV2Reply(reply: Array, preserve?: unknown, typeMapping break; } } - + ret[key] = innerRet; break; } @@ -156,7 +156,7 @@ function transformV2Reply(reply: Array, preserve?: unknown, typeMapping ret[key] = innerRet; break; } - } + } } return ret; From 2ede56ce0163d1ad4c0d34106424a90ceb95c924 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 16 Apr 2026 13:21:54 +0300 Subject: [PATCH 06/28] docs: add migration doc --- docs/v5-to-v6.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/v5-to-v6.md diff --git a/docs/v5-to-v6.md b/docs/v5-to-v6.md new file mode 100644 index 00000000000..4de613d7985 --- /dev/null +++ b/docs/v5-to-v6.md @@ -0,0 +1,76 @@ +# v5 to v6 migration guide + +## RESP3 is now the default protocol + +In v5, Node-Redis defaulted to `RESP: 2` unless you explicitly configured `RESP: 3`. +In v6, the default is now `RESP: 3`. + +RESP3 introduces a bunch of “on the wire” formats that replace RESP2 workarounds. +Node-Redis already maps most of the RESP2 workarounds to the proper javascript type, but there are some commands that were missed. +Those are now aligned to return the proper types. For more details on protocol type mapping, see [RESP type mapping](./RESP.md). + + +## Default behavior changes (v5 default -> v6 default) + + - `GEOSEARCH_WITH`, `GEORADIUS_WITH`, `GEORADIUS_RO_WITH`, `GEORADIUSBYMEMBER_WITH`, `GEORADIUSBYMEMBER_RO_WITH` - `distance`, `coordinates.longitude`, and `coordinates.latitude` are now `number` (previously `string`). + - `CF.INSERTNX` changed from `Array` to `Array`. + + +## Stabilized APIs +In v5, some command transforms were unstable under RESP3. In v6, those commands are stabilized and normalized: +These stabilization changes are RESP3-only: RESP2 transforms are unchanged. +They are breaking only for clients using RESP3 (including v5 users who explicitly opted into RESP3, and v6 users on the new default RESP3). + +| Package | Command | Return type change | Notes | +|---|---|---|---| +| `@redis/client` | `HOTKEYS GET` | `ReplyUnion -> HotkeysGetReply \| null` | RESP3 reply now normalized to stable structured output. | +| `@redis/client` | `XREAD` | `ReplyUnion -> StreamsMessagesReply \| null` | RESP3 reply is normalized to v4/v5-compatible stream list shape. | +| `@redis/client` | `XREADGROUP` | `ReplyUnion -> StreamsMessagesReply \| null` | RESP3 reply is normalized to v4/v5-compatible stream list shape. | +| `@redis/search` | `FT.AGGREGATE` | `ReplyUnion -> AggregateReply` | RESP3 map/array variants normalized to aggregate reply shape. | +| `@redis/search` | `FT.AGGREGATE WITHCURSOR` | `ReplyUnion -> AggregateWithCursorReply` | Cursor + results are normalized for RESP3. | +| `@redis/search` | `FT.CURSOR READ` | `ReplyUnion -> AggregateWithCursorReply` | RESP3 cursor-read map/array wrapper variants are normalized to a stable `{ total, results, cursor }` reply shape. | +| `@redis/search` | `FT.SEARCH` | `ReplyUnion -> SearchReply` | RESP3 map-like payload normalized to `{ total, documents }`. | +| `@redis/search` | `FT.SEARCH NOCONTENT` | `ReplyUnion -> SearchNoContentReply` | RESP3 normalized through `FT.SEARCH` then projected to ids. | +| `@redis/search` | `FT.SPELLCHECK` | `ReplyUnion -> SpellCheckReply` | RESP3 result/suggestion map variants normalized. | +| `@redis/search` | `FT.HYBRID` | `ReplyUnion -> HybridSearchResult` | RESP3 map-like payload normalized to hybrid result object. | +| `@redis/search` | `FT.INFO` | `ReplyUnion -> InfoReply` | RESP3 map-like payload normalized to stable info object shape. | +| `@redis/search` | `FT.PROFILE SEARCH` | `ReplyUnion -> ProfileReplyResp2` | RESP3 profile/results wrappers normalized (Redis 7.4/8 layouts). | +| `@redis/search` | `FT.PROFILE AGGREGATE` | `ReplyUnion -> ProfileReplyResp2` | RESP3 profile/results wrappers normalized (Redis 7.4/8 layouts). | +| `@redis/time-series` | `TS.INFO` | `ReplyUnion -> InfoReply` | RESP3 map/array variants normalized to `InfoReply`. | +| `@redis/time-series` | `TS.INFO DEBUG` | `ReplyUnion -> InfoDebugReply` | RESP3 `keySelfName`/`chunks` payload normalized. | +| `@redis/time-series` | `TS.MRANGE GROUPBY` | `{ sources: Array; samples: Array<{ timestamp: number; value: number }> } -> { samples: Array<{ timestamp: number; value: number }> }` | `sources` removed from RESP3 grouped reply. | +| `@redis/time-series` | `TS.MREVRANGE GROUPBY` | `{ sources: Array; samples: Array<{ timestamp: number; value: number }> } -> { samples: Array<{ timestamp: number; value: number }> }` | In RESP3 grouped reverse-range replies, `sources` is removed and output now includes only `{ samples }`. | +| `@redis/time-series` | `TS.MRANGE SELECTED_LABELS GROUPBY` | `{ labels: Record; sources: Array; samples: Array<{ timestamp: number; value: number }> } -> { labels: Record; samples: Array<{ timestamp: number; value: number }> }` | `sources` removed from RESP3 selected-labels grouped reply. | +| `@redis/time-series` | `TS.MREVRANGE SELECTED_LABELS GROUPBY` | `{ labels: Record; sources: Array; samples: Array<{ timestamp: number; value: number }> } -> { labels: Record; samples: Array<{ timestamp: number; value: number }> }` | In RESP3 selected-labels grouped reverse-range replies, `sources` is removed and output now includes `{ labels, samples }`. | + +## Object Prototype Normalization +In v6, object-like replies are normalized to plain objects (`{}` / `Object.defineProperties({}, ...)`) instead of null-prototype objects (`Object.create(null)`). + +Compatibility impact: this can be technically breaking for code/tests that assert a `null` prototype (for example `Object.getPrototypeOf(reply) === null` or deep-equality against `Object.create(null)`), but for most users key access/iteration/serialization behavior remains the same. + +Commands affected: + +- `@redis/client`: `CONFIG GET`, `FUNCTION STATS`, `HGETALL`, `LATENCY HISTOGRAM` (`histogram_usec`), `PUBSUB NUMSUB`, `PUBSUB SHARDNUMSUB`, `VINFO`, `VLINKS WITHSCORES`, `XINFO STREAM` (entry message objects), `XREAD`/`XREADGROUP` (message objects) +- `@redis/search`: `FT.AGGREGATE`, `FT.AGGREGATE WITHCURSOR`, `FT.CURSOR READ`, `FT.CONFIG GET`, `FT.HYBRID`, `FT.INFO`, `FT.SEARCH`, `FT.PROFILE SEARCH`, `FT.PROFILE AGGREGATE` +- `@redis/time-series`: `TS.MGET`, `TS.MGET WITHLABELS`, `TS.MGET SELECTED_LABELS`, `TS.MRANGE`, `TS.MREVRANGE`, `TS.MRANGE GROUPBY`, `TS.MREVRANGE GROUPBY`, `TS.MRANGE WITHLABELS`, `TS.MREVRANGE WITHLABELS`, `TS.MRANGE WITHLABELS GROUPBY`, `TS.MREVRANGE WITHLABELS GROUPBY`, `TS.MRANGE SELECTED_LABELS`, `TS.MREVRANGE SELECTED_LABELS`, `TS.MRANGE SELECTED_LABELS GROUPBY`, `TS.MREVRANGE SELECTED_LABELS GROUPBY` +- `@redis/bloom`: `BF.INFO`, `CF.INFO`, `CMS.INFO`, `TOPK.INFO`, `TDIGEST.INFO` + +Additionally, RESP3 map decoding now creates plain objects by default, so commands that expose raw RESP3 maps as JS objects inherit the same prototype change. + + + +## If you need to preserve v5 default behavior while migrating, pin RESP2 explicitly: + +```javascript +// Single node +const client = createClient({ RESP: 2 }); + +// Cluster +const cluster = createCluster({ RESP: 2, ...}); + +// Sentinel +const sentinel = createSentinel({ RESP: 2, ... }); + +// Pool +const pool = createClientPool({ RESP: 2 }); +``` From 3c84ae3223d5869bbc62063e3d88b4638170a8cf Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 16 Apr 2026 17:30:44 +0300 Subject: [PATCH 07/28] test: stabilize generic-transformers spec across Node versions --- .../lib/commands/generic-transformers.spec.ts | 72 ++++++------------- 1 file changed, 20 insertions(+), 52 deletions(-) diff --git a/packages/client/lib/commands/generic-transformers.spec.ts b/packages/client/lib/commands/generic-transformers.spec.ts index fcb0effe6f5..63123005ee0 100644 --- a/packages/client/lib/commands/generic-transformers.spec.ts +++ b/packages/client/lib/commands/generic-transformers.spec.ts @@ -260,18 +260,10 @@ describe('Generic Transformers', () => { it('transformTuplesReply', () => { assert.deepEqual( transformTuplesReply(['key1', 'value1', 'key2', 'value2'] as unknown as ArrayReply), - Object.create({}, { - key1: { - value: 'value1', - configurable: true, - enumerable: true - }, - key2: { - value: 'value2', - configurable: true, - enumerable: true - } - }) + { + key1: 'value1', + key2: 'value2' + } ); }); @@ -280,22 +272,14 @@ describe('Generic Transformers', () => { transformStreamMessagesReply([['0-0', ['0key', '0value']], ['1-0', ['1key', '1value']]] as unknown as ArrayReply), [{ id: '0-0', - message: Object.create({}, { - '0key': { - value: '0value', - configurable: true, - enumerable: true - } - }) + message: { + '0key': '0value' + } }, { id: '1-0', - message: Object.create({}, { - '1key': { - value: '1value', - configurable: true, - enumerable: true - } - }) + message: { + '1key': '1value' + } }] ); }); @@ -315,39 +299,23 @@ describe('Generic Transformers', () => { name: 'stream1', messages: [{ id: '0-1', - message: Object.create({}, { - '11key': { - value: '11value', - configurable: true, - enumerable: true - } - }) + message: { + '11key': '11value' + } }, { id: '1-1', - message: Object.create({}, { - '12key': { - value: '12value', - configurable: true, - enumerable: true - } - }) + message: { + '12key': '12value' + } }] }, { name: 'stream2', messages: [{ id: '0-2', - message: Object.create({}, { - '2key1': { - value: '2value1', - configurable: true, - enumerable: true - }, - '2key2': { - value: '2value2', - configurable: true, - enumerable: true - } - }) + message: { + '2key1': '2value1', + '2key2': '2value2' + } }] }] ); From cccfe57f81d9917c7075a56474d9afea99d7c604 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 13 May 2026 13:47:26 +0300 Subject: [PATCH 08/28] fix merge and lint issues --- eslint.config.mjs | 3 +- packages/bloom/lib/commands/bloom/helpers.ts | 15 ++--- packages/client/lib/RESP/decoder.ts | 9 ++- packages/client/lib/RESP/types.ts | 13 +++++ packages/client/lib/client/commands-queue.ts | 9 ++- .../client/enterprise-maintenance-manager.ts | 8 ++- packages/client/lib/client/legacy-mode.ts | 2 + packages/client/lib/client/multi-command.ts | 3 + packages/client/lib/client/pool.ts | 9 ++- packages/client/lib/cluster/multi-command.ts | 1 + packages/client/lib/commander.ts | 9 +++ .../lib/commands/FUNCTION_STATS.spec.ts | 2 +- .../lib/commands/GEORADIUS_RO_WITH.spec.ts | 2 +- .../client/lib/commands/GEOSEARCH_WITH.ts | 16 +++-- packages/client/lib/commands/HOTKEYS_GET.ts | 10 ++-- packages/client/lib/commands/MODULE_LIST.ts | 9 +-- packages/client/lib/commands/PUBSUB_NUMSUB.ts | 4 +- packages/client/lib/commands/VINFO.ts | 2 +- packages/client/lib/commands/VSETATTR.spec.ts | 4 +- packages/client/lib/commands/XREAD.spec.ts | 2 +- packages/client/lib/commands/XREAD.ts | 2 +- .../client/lib/commands/XREADGROUP.spec.ts | 4 +- packages/client/lib/commands/XREADGROUP.ts | 2 +- .../lib/commands/generic-transformers.spec.ts | 16 ++--- .../lib/commands/generic-transformers.ts | 18 +++++- packages/client/lib/sentinel/index.spec.ts | 58 +++++++++---------- .../client/lib/sentinel/multi-commands.ts | 1 + packages/client/lib/sentinel/types.ts | 10 ++-- packages/search/lib/commands/AGGREGATE.ts | 2 + packages/search/lib/commands/CONFIG_GET.ts | 2 +- packages/search/lib/commands/HYBRID.spec.ts | 14 +++-- packages/search/lib/commands/HYBRID.ts | 14 ++--- packages/search/lib/commands/INFO.spec.ts | 2 +- packages/search/lib/commands/INFO.ts | 2 +- packages/search/lib/commands/SEARCH.ts | 16 ++--- .../search/lib/commands/reply-transformers.ts | 40 ++++++------- packages/test-utils/lib/index.ts | 11 ++-- .../time-series/lib/commands/INFO.spec.ts | 2 +- packages/time-series/lib/commands/INFO.ts | 28 ++++----- .../lib/commands/INFO_DEBUG.spec.ts | 4 +- .../time-series/lib/commands/INFO_DEBUG.ts | 12 ++-- .../lib/commands/MRANGE_MULTIAGGR.spec.ts | 2 +- .../MRANGE_SELECTED_LABELS_MULTIAGGR.spec.ts | 4 +- .../MRANGE_WITHLABELS_MULTIAGGR.spec.ts | 4 +- .../commands/MRANGE_WITHLABELS_MULTIAGGR.ts | 2 +- .../lib/commands/MREVRANGE_MULTIAGGR.spec.ts | 2 +- packages/time-series/lib/commands/helpers.ts | 13 +++-- 47 files changed, 244 insertions(+), 175 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 427812633bc..99149dab959 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -39,7 +39,8 @@ export default [ varsIgnorePattern: '^_' }], '@typescript-eslint/no-empty-object-type': ['error', { - allowObjectTypes: 'always' + allowObjectTypes: 'always', + allowInterfaces: 'with-single-extends' }] } }, diff --git a/packages/bloom/lib/commands/bloom/helpers.ts b/packages/bloom/lib/commands/bloom/helpers.ts index 54a257e2ce3..ad545671c9c 100644 --- a/packages/bloom/lib/commands/bloom/helpers.ts +++ b/packages/bloom/lib/commands/bloom/helpers.ts @@ -1,6 +1,7 @@ import { RESP_TYPES, TypeMapping } from "@redis/client"; -export function transformInfoV2Reply(reply: Array, typeMapping?: TypeMapping): T { +export function transformInfoV2Reply(reply: Array, typeMapping?: TypeMapping): T { + const entries = reply as Array<{ toString(): string }>; const mapType = typeMapping ? typeMapping[RESP_TYPES.MAP] : undefined; switch (mapType) { @@ -8,19 +9,19 @@ export function transformInfoV2Reply(reply: Array, typeMapping?: TypeMap return reply as unknown as T; } case Map: { - const ret = new Map(); + const ret = new Map(); - for (let i = 0; i < reply.length; i += 2) { - ret.set(reply[i].toString(), reply[i + 1]); + for (let i = 0; i < entries.length; i += 2) { + ret.set(entries[i].toString(), entries[i + 1]); } return ret as unknown as T; } default: { - const ret: Record = {}; + const ret: Record = {}; - for (let i = 0; i < reply.length; i += 2) { - ret[reply[i].toString()] = reply[i + 1]; + for (let i = 0; i < entries.length; i += 2) { + ret[entries[i].toString()] = entries[i + 1]; } return ret as unknown as T; diff --git a/packages/client/lib/RESP/decoder.ts b/packages/client/lib/RESP/decoder.ts index 369dcec6baa..df1d1bb01e0 100644 --- a/packages/client/lib/RESP/decoder.ts +++ b/packages/client/lib/RESP/decoder.ts @@ -1,4 +1,5 @@ -// @ts-nocheck +/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any */ +// @ts-nocheck -- decoder uses untyped continuation callbacks; full typing tracked separately import { VerbatimString } from './verbatim-string'; import { SimpleError, BlobError, ErrorReply } from '../errors'; import { TypeMapping } from './types'; @@ -380,12 +381,13 @@ export class Decoder { this.#decodeDoubleDecimal.bind(this, isNegative, 0, integer); case ASCII.E: - case ASCII.e: + case ASCII.e: { this.#cursor = cursor + 1; // skip E/e const i = isNegative ? -integer : integer; return this.#cursor < chunk.length ? this.#decodeDoubleExponent(i, chunk) : this.#decodeDoubleExponent.bind(this, i); + } case ASCII['\r']: this.#cursor = cursor + 2; // skip \r\n @@ -415,12 +417,13 @@ export class Decoder { const byte = chunk[cursor]; switch (byte) { case ASCII.E: - case ASCII.e: + case ASCII.e: { this.#cursor = cursor + 1; // skip E/e const d = isNegative ? -double : double; return this.#cursor === chunk.length ? this.#decodeDoubleExponent.bind(this, d) : this.#decodeDoubleExponent(d, chunk); + } case ASCII['\r']: this.#cursor = cursor + 2; // skip \r\n diff --git a/packages/client/lib/RESP/types.ts b/packages/client/lib/RESP/types.ts index ae24c677ea1..c77a3617006 100644 --- a/packages/client/lib/RESP/types.ts +++ b/packages/client/lib/RESP/types.ts @@ -108,6 +108,7 @@ export interface ArrayReply extends RespType< RESP_TYPES['ARRAY'], Array, never, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TYPE_MAPPING variance marker Array > { } @@ -115,6 +116,7 @@ export interface TuplesReply]> extends RespType< RESP_TYPES['ARRAY'], T, never, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TYPE_MAPPING variance marker Array > { } @@ -122,6 +124,7 @@ export interface SetReply extends RespType< RESP_TYPES['SET'], Array, Set, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TYPE_MAPPING variance marker Array | Set > { } @@ -129,6 +132,7 @@ export interface MapReply extends RespType< RESP_TYPES['MAP'], { [key: string]: V }, Map | Array, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TYPE_MAPPING variance marker Map | Array > { } @@ -176,11 +180,13 @@ export type ReplyUnion = ( MapReply ); +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- constructor/function signature variance export type MappedType = ((...args: any) => T) | (new (...args: any) => T); type InferTypeMapping = T extends RespType ? FLAG_TYPES : never; export type TypeMapping = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RespType generic variance markers [P in RespTypes]?: MappedType>>>; }; @@ -199,6 +205,7 @@ type UnwrapConstructor = T extends BooleanConstructor ? boolean : T extends BigIntConstructor ? bigint : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- RespType variance markers export type UnwrapReply> = REPLY['DEFAULT' | 'TYPES']; export type ReplyWithTypeMapping< @@ -218,6 +225,7 @@ export type ReplyWithTypeMapping< REPLY extends Map ? Map, ReplyWithTypeMapping> : // `Date | Buffer | Error` are supersets of `Record`, so they need to be checked first REPLY extends Date | Buffer | Error ? REPLY : + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- structural variance check REPLY extends Record ? { [P in keyof REPLY]: ReplyWithTypeMapping; } : @@ -226,6 +234,7 @@ export type ReplyWithTypeMapping< ) ); +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- public transform contract accepts/returns arbitrary shapes export type TransformReply = (this: void, reply: any, preserve?: any, typeMapping?: TypeMapping) => any; // TODO; export type RedisArgument = string | Buffer; @@ -289,6 +298,7 @@ export type Command = { IS_FORWARD_COMMAND?: boolean; NOT_KEYED_COMMAND?: true; // POLICIES?: CommandPolicies; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- arbitrary arg list per command parseCommand(this: void, parser: CommandParser, ...args: Array): void; TRANSFORM_LEGACY_REPLY?: boolean; transformReply: TransformReply | Record; @@ -348,6 +358,7 @@ export type Resp2Reply = ( > : RESP_TYPE extends RESP_TYPES['MAP'] ? RespType< RESP_TYPES['ARRAY'], + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Array filter for union extraction Resp2Array>> > : RESP3REPLY : @@ -361,8 +372,10 @@ export type CommandReply< RESP extends RespVersions > = ( // if transformReply is a function, use its return type + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- structural function inference COMMAND['transformReply'] extends (...args: any) => infer T ? T : // if transformReply[RESP] is a function, use its return type + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- structural function inference COMMAND['transformReply'] extends Record infer T> ? T : // otherwise use the generic reply type ReplyUnion diff --git a/packages/client/lib/client/commands-queue.ts b/packages/client/lib/client/commands-queue.ts index 54610df9920..af909351382 100644 --- a/packages/client/lib/client/commands-queue.ts +++ b/packages/client/lib/client/commands-queue.ts @@ -27,7 +27,7 @@ export interface CommandOptions { } export interface CommandToWrite extends CommandWaitingForReply { - args: ReadonlyArray; + args: ReadonlyArray | undefined; chainId: symbol | undefined; abort: { signal: AbortSignal; @@ -63,6 +63,7 @@ const RESP2_PUSH_TYPE_MAPPING = { // important in order for the queue to be able to pass the // notification to another handler if the current one did not // succeed. +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- heterogeneous push payload type PushHandler = (pushItems: Array) => boolean; export default class RedisCommandsQueue { @@ -163,6 +164,7 @@ export default class RedisCommandsQueue { this.#waitingForReply.shift()!.reject(err); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- heterogeneous push payload #onPush(push: Array) { // TODO: type if (this.#pubSub.handleMessageReply(push)) return true; @@ -265,6 +267,7 @@ export default class RedisCommandsQueue { } return new Promise((resolve, reject) => { + // eslint-disable-next-line prefer-const -- assigned after closures that reference it are constructed let node: DoublyLinkedNode; const value: CommandToWrite = { args, @@ -551,7 +554,7 @@ export default class RedisCommandsQueue { while (toSend) { let encoded: ReadonlyArray; try { - encoded = encodeCommand(toSend.args); + encoded = encodeCommand(toSend.args!); } catch (err) { toSend.reject(err); toSend = this.#toWrite.shift(); @@ -559,7 +562,7 @@ export default class RedisCommandsQueue { } // TODO reuse `toSend` or create new object? - (toSend as any).args = undefined; + toSend.args = undefined; if (toSend.abort) { RedisCommandsQueue.#removeAbortListener(toSend); toSend.abort = undefined; diff --git a/packages/client/lib/client/enterprise-maintenance-manager.ts b/packages/client/lib/client/enterprise-maintenance-manager.ts index 3b3ae9fe8f9..7fc4cfae879 100644 --- a/packages/client/lib/client/enterprise-maintenance-manager.ts +++ b/packages/client/lib/client/enterprise-maintenance-manager.ts @@ -9,6 +9,7 @@ import diagnostics_channel from "node:diagnostics_channel"; import { RedisArgument } from "../RESP/types"; import { publish, CHANNELS } from "./tracing"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance markers for RedisClient generics type RedisType = RedisClient; export const SMIGRATED_EVENT = "__SMIGRATED"; @@ -52,10 +53,10 @@ const PN = { export type DiagnosticsEvent = { type: string; timestamp: number; - data?: Object; + data?: object; }; -export const dbgMaintenance = (...args: any[]) => { +export const dbgMaintenance = (...args: unknown[]) => { if (!process.env.REDIS_DEBUG_MAINTENANCE) return; return console.log(new Date().toISOString().slice(11, 23), "[MNT]", ...args); }; @@ -149,6 +150,7 @@ export default class EnterpriseMaintenanceManager { this.#commandsQueue.addPushHandler(this.#onPush); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- heterogeneous push payload #onPush = (push: Array): boolean => { dbgMaintenance("ONPUSH:", push.map(String)); @@ -338,6 +340,7 @@ export default class EnterpriseMaintenanceManager { this.#client._maintenanceUpdate(update); }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- heterogeneous push payload #onSMigrated = (push: any[]) => { const smigratedEvent = EnterpriseMaintenanceManager.parseSMigratedPush(push); dbgMaintenance(`emit smigratedEvent`, smigratedEvent); @@ -368,6 +371,7 @@ export default class EnterpriseMaintenanceManager { * - Each destination contains the complete list of slots that moved from that source to that destination * - Note: The same destination address CAN appear under different sources (e.g., node X receives slots from both A and B) */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- heterogeneous push payload static parseSMigratedPush(push: any[]): SMigratedEvent { const map = new Map(); diff --git a/packages/client/lib/client/legacy-mode.ts b/packages/client/lib/client/legacy-mode.ts index 3bdef010637..61ccd937664 100644 --- a/packages/client/lib/client/legacy-mode.ts +++ b/packages/client/lib/client/legacy-mode.ts @@ -84,6 +84,7 @@ export class RedisLegacyClient { const RESP = client.options?.RESP ?? 3; for (const [name, command] of Object.entries(COMMANDS)) { // TODO: as any? + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic command attachment (this as any)[name] = RedisLegacyClient.#createCommand( name, command, @@ -136,6 +137,7 @@ class LegacyMultiCommand { for (const [name, command] of Object.entries(COMMANDS)) { // TODO: as any? + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic command attachment (Multi as any).prototype[name] = LegacyMultiCommand.#createCommand( name, command, diff --git a/packages/client/lib/client/multi-command.ts b/packages/client/lib/client/multi-command.ts index 57a4e9494a8..aa446a7db31 100644 --- a/packages/client/lib/client/multi-command.ts +++ b/packages/client/lib/client/multi-command.ts @@ -71,6 +71,7 @@ type WithScripts< }; type InternalRedisClientMultiCommandType< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance marker for reply tuple REPLIES extends Array, M extends RedisModules, F extends RedisFunctions, @@ -86,10 +87,12 @@ type InternalRedisClientMultiCommandType< ); type TypedOrAny = + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- non-typed branch falls through to any [Flag] extends [MULTI_MODE['TYPED']] ? T : any; export type RedisClientMultiCommandType< isTyped extends MultiMode, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance marker for reply tuple REPLIES extends Array, M extends RedisModules, F extends RedisFunctions, diff --git a/packages/client/lib/client/pool.ts b/packages/client/lib/client/pool.ts index 9dd4bb1bd2e..3b8783c6328 100644 --- a/packages/client/lib/client/pool.ts +++ b/packages/client/lib/client/pool.ts @@ -103,6 +103,7 @@ export type RedisClientPoolType< WithScripts ); +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance markers for pool generics type ProxyPool = RedisClientPoolType; type NamespaceProxyPool = { _self: ProxyPool }; @@ -161,6 +162,7 @@ export class RedisClientPool< }; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic cache, keys/values vary per call site static #SingleEntryCache = new SingleEntryCache(); static create< @@ -445,7 +447,7 @@ export class RedisClientPool< const task = this._self.#tasksQueue.push({ timeout, - // @ts-ignore + // @ts-expect-error -- resolve generic variance resolve, reject, fn, @@ -461,7 +463,7 @@ export class RedisClientPool< const node = this._self.#clientsInUse.push(client); publish(CHANNELS.POOL_CONNECTION_WAIT, () => ({ clientId: client._clientId, waitStartTimestamp })); - // @ts-ignore + // @ts-expect-error -- resolve generic variance this._self.#executeTask(node, resolve, reject, fn); }); } @@ -534,6 +536,7 @@ export class RedisClientPool< MULTI() { type Multi = new (...args: ConstructorParameters) => RedisClientMultiCommandType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- access to dynamic Multi class return new ((this as any).Multi as Multi)( (commands, selectedDB) => this.execute(client => client._executeMulti(commands, selectedDB)), commands => this.execute(client => client._executePipeline(commands)), @@ -570,7 +573,7 @@ export class RedisClientPool< this._self.#idleClients.reset(); this._self.#clientsInUse.reset(); - } catch (err) { + } catch { } finally { this._self.#drainResolve = undefined; diff --git a/packages/client/lib/cluster/multi-command.ts b/packages/client/lib/cluster/multi-command.ts index 329559985e4..558151588d9 100644 --- a/packages/client/lib/cluster/multi-command.ts +++ b/packages/client/lib/cluster/multi-command.ts @@ -71,6 +71,7 @@ type WithScripts< }; export type RedisClusterMultiCommandType< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance marker for reply tuple REPLIES extends Array, M extends RedisModules, F extends RedisFunctions, diff --git a/packages/client/lib/commander.ts b/packages/client/lib/commander.ts index e65ff6ee1dc..22c29e0cc18 100644 --- a/packages/client/lib/commander.ts +++ b/packages/client/lib/commander.ts @@ -6,11 +6,16 @@ interface AttachConfigOptions< S extends RedisScripts, RESP extends RespVersions > { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- factory contract: arbitrary constructor BaseClass: new (...args: any) => any; commands: RedisCommands; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- factory contract: arbitrary command function createCommand(command: Command, resp: RespVersions): (...args: any) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- factory contract: arbitrary command function createModuleCommand(command: Command, resp: RespVersions): (...args: any) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- factory contract: arbitrary command function createFunctionCommand(name: string, fn: RedisFunction, resp: RespVersions): (...args: any) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- factory contract: arbitrary command function createScriptCommand(script: RedisScript, resp: RespVersions): (...args: any) => any; config?: CommanderConfig; } @@ -30,6 +35,7 @@ export function attachConfig< config }: AttachConfigOptions) { const RESP = config?.RESP ?? 3, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic prototype patching Class: any = class extends BaseClass {}; for (const [name, command] of Object.entries(commands)) { @@ -38,6 +44,7 @@ export function attachConfig< if (config?.modules) { for (const [moduleName, module] of Object.entries(config.modules)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic command namespace const fns: Record) => any> = {}; for (const [name, command] of Object.entries(module)) { fns[name] = createModuleCommand(command, RESP); @@ -49,6 +56,7 @@ export function attachConfig< if (config?.functions) { for (const [library, commands] of Object.entries(config.functions)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic command namespace const fns: Record) => any> = {}; for (const [name, command] of Object.entries(commands)) { fns[name] = createFunctionCommand(name, command, RESP); @@ -67,6 +75,7 @@ export function attachConfig< return Class; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic prototype patching helper function attachNamespace(prototype: any, name: PropertyKey, fns: any) { Object.defineProperty(prototype, name, { get() { diff --git a/packages/client/lib/commands/FUNCTION_STATS.spec.ts b/packages/client/lib/commands/FUNCTION_STATS.spec.ts index f251533edfe..6b851267f82 100644 --- a/packages/client/lib/commands/FUNCTION_STATS.spec.ts +++ b/packages/client/lib/commands/FUNCTION_STATS.spec.ts @@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; import FUNCTION_STATS from './FUNCTION_STATS'; import { parseArgs } from './generic-transformers'; -import { loadMathFunction, MATH_FUNCTION } from './FUNCTION_LOAD.spec'; +import { loadMathFunction } from './FUNCTION_LOAD.spec'; describe('FUNCTION STATS', () => { testUtils.isVersionGreaterThanHook([7]); diff --git a/packages/client/lib/commands/GEORADIUS_RO_WITH.spec.ts b/packages/client/lib/commands/GEORADIUS_RO_WITH.spec.ts index 6bea27c6256..32b6cfc3965 100644 --- a/packages/client/lib/commands/GEORADIUS_RO_WITH.spec.ts +++ b/packages/client/lib/commands/GEORADIUS_RO_WITH.spec.ts @@ -22,7 +22,7 @@ describe('GEORADIUS_RO WITH', () => { it('transformReply should parse RESP2 floating-point strings', () => { const reply = GEORADIUS_RO_WITH.transformReply([ ['member', '0.5', 1, ['1.23', '4.56']] - ] as any, [ + ] as never, [ GEO_REPLY_WITH.DISTANCE, GEO_REPLY_WITH.HASH, GEO_REPLY_WITH.COORDINATES diff --git a/packages/client/lib/commands/GEOSEARCH_WITH.ts b/packages/client/lib/commands/GEOSEARCH_WITH.ts index 60cdf1a1418..de6ffd23f4d 100644 --- a/packages/client/lib/commands/GEOSEARCH_WITH.ts +++ b/packages/client/lib/commands/GEOSEARCH_WITH.ts @@ -21,11 +21,6 @@ export interface GeoReplyWithMember { }; } -type GeoSearchWithRawMember = [ - BlobStringReply, - ...(BlobStringReply | NumberReply | [DoubleReply, DoubleReply])[] -]; - export default { IS_READ_ONLY: GEOSEARCH.IS_READ_ONLY, parseCommand( @@ -41,6 +36,7 @@ export default { parser.preserve = replyWith; }, transformReply( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- heterogeneous tuple variance marker reply: UnwrapReply]>>>, replyWith: Array, typeMapping?: TypeMapping @@ -60,20 +56,22 @@ export default { }; return reply.map(raw => { + const unwrapped = raw as unknown as UnwrapReply; + const item: GeoReplyWithMember = { - member: raw[0] + member: unwrapped[0] }; if (distanceIndex) { - item.distance = parseDouble(raw[distanceIndex]); + item.distance = parseDouble(unwrapped[distanceIndex]); } if (hashIndex) { - item.hash = raw[hashIndex] as NumberReply; + item.hash = unwrapped[hashIndex] as NumberReply; } if (coordinatesIndex) { - const [longitude, latitude] = raw[coordinatesIndex] as [DoubleReply, DoubleReply]; + const [longitude, latitude] = unwrapped[coordinatesIndex] as [DoubleReply, DoubleReply]; item.coordinates = { longitude: parseDouble(longitude), latitude: parseDouble(latitude) diff --git a/packages/client/lib/commands/HOTKEYS_GET.ts b/packages/client/lib/commands/HOTKEYS_GET.ts index 8f1fa5ae68b..5292c06d5af 100644 --- a/packages/client/lib/commands/HOTKEYS_GET.ts +++ b/packages/client/lib/commands/HOTKEYS_GET.ts @@ -43,7 +43,7 @@ export interface HotkeysGetReply { byNetBytes?: Array; } -function mapLikeEntries(value: any): Array<[string, any]> { +function mapLikeEntries(value: unknown): Array<[string, unknown]> { if (value instanceof Map) { return Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]); } @@ -60,7 +60,7 @@ function mapLikeEntries(value: any): Array<[string, any]> { return value.map(item => [item[0].toString(), item[1]]); } - const entries: Array<[string, any]> = []; + const entries: Array<[string, unknown]> = []; for (let i = 0; i < value.length - 1; i += 2) { entries.push([value[i].toString(), value[i + 1]]); } @@ -68,16 +68,16 @@ function mapLikeEntries(value: any): Array<[string, any]> { } if (value !== null && typeof value === 'object') { - return Object.entries(value); + return Object.entries(value as Record); } return []; } -function mapLikeValues(value: any): Array { +function mapLikeValues(value: unknown): Array { if (Array.isArray(value)) return value; if (value instanceof Map) return [...value.values()]; - if (value !== null && typeof value === 'object') return Object.values(value); + if (value !== null && typeof value === 'object') return Object.values(value as Record); return []; } diff --git a/packages/client/lib/commands/MODULE_LIST.ts b/packages/client/lib/commands/MODULE_LIST.ts index 2ed2c918e39..ada3925f2a7 100644 --- a/packages/client/lib/commands/MODULE_LIST.ts +++ b/packages/client/lib/commands/MODULE_LIST.ts @@ -6,7 +6,7 @@ export type ModuleListReply = ArrayReply, NumberReply], ]>>; -function transformModuleReply(moduleReply: any) { +function transformModuleReply(moduleReply: unknown) { if (Array.isArray(moduleReply)) { let name: BlobStringReply | undefined; let ver: NumberReply | undefined; @@ -45,13 +45,14 @@ function transformModuleReply(moduleReply: any) { }; } + const objectReply = moduleReply as { name: BlobStringReply; ver: NumberReply }; return { - name: moduleReply.name, - ver: moduleReply.ver + name: objectReply.name, + ver: objectReply.ver }; } -function transformModuleListReply(reply: Array) { +function transformModuleListReply(reply: Array) { return reply.map(moduleReply => transformModuleReply(moduleReply)); } diff --git a/packages/client/lib/commands/PUBSUB_NUMSUB.ts b/packages/client/lib/commands/PUBSUB_NUMSUB.ts index f6a2c27f4a8..845f587a834 100644 --- a/packages/client/lib/commands/PUBSUB_NUMSUB.ts +++ b/packages/client/lib/commands/PUBSUB_NUMSUB.ts @@ -19,12 +19,12 @@ export default { * @returns Record mapping channel names to their subscriber counts */ transformReply(rawReply: UnwrapReply>) { - const reply: Record = {}; + const reply: Record = {}; let i = 0; while (i < rawReply.length) { reply[rawReply[i++].toString()] = Number(rawReply[i++]); } - return reply as Record; + return reply as unknown as Record; } } as const satisfies Command; diff --git a/packages/client/lib/commands/VINFO.ts b/packages/client/lib/commands/VINFO.ts index 07f49c6b617..1b719d338f1 100644 --- a/packages/client/lib/commands/VINFO.ts +++ b/packages/client/lib/commands/VINFO.ts @@ -25,7 +25,7 @@ export default { }, transformReply: { 2: (reply: UnwrapReply>): VInfoReplyMap => { - const ret: Record = {}; + const ret: Record = {}; for (let i = 0; i < reply.length; i += 2) { ret[reply[i].toString()] = reply[i + 1]; diff --git a/packages/client/lib/commands/VSETATTR.spec.ts b/packages/client/lib/commands/VSETATTR.spec.ts index cd9f76e06ea..71bede57329 100644 --- a/packages/client/lib/commands/VSETATTR.spec.ts +++ b/packages/client/lib/commands/VSETATTR.spec.ts @@ -7,7 +7,7 @@ describe('VSETATTR', () => { describe('parseCommand', () => { it('with object', () => { const parser = new BasicCommandParser(); - VSETATTR.parseCommand(parser, 'key', 'element', { name: 'test', value: 42 }), + VSETATTR.parseCommand(parser, 'key', 'element', { name: 'test', value: 42 }); assert.deepEqual( parser.redisArgs, ['VSETATTR', 'key', 'element', '{"name":"test","value":42}'] @@ -16,7 +16,7 @@ describe('VSETATTR', () => { it('with string', () => { const parser = new BasicCommandParser(); - VSETATTR.parseCommand(parser, 'key', 'element', '{"name":"test"}'), + VSETATTR.parseCommand(parser, 'key', 'element', '{"name":"test"}'); assert.deepEqual( parser.redisArgs, ['VSETATTR', 'key', 'element', '{"name":"test"}'] diff --git a/packages/client/lib/commands/XREAD.spec.ts b/packages/client/lib/commands/XREAD.spec.ts index 82eb473ab3a..963989a2359 100644 --- a/packages/client/lib/commands/XREAD.spec.ts +++ b/packages/client/lib/commands/XREAD.spec.ts @@ -102,7 +102,7 @@ describe('XREAD', () => { ]) // FUTURE resp3 compatible - const obj = Object.assign({}, { + const _obj = Object.assign({}, { 'key': [{ id: id, message: Object.defineProperties({}, { diff --git a/packages/client/lib/commands/XREAD.ts b/packages/client/lib/commands/XREAD.ts index 993a3b3e595..71e2e17eee1 100644 --- a/packages/client/lib/commands/XREAD.ts +++ b/packages/client/lib/commands/XREAD.ts @@ -49,7 +49,7 @@ export interface XReadOptions { } function transformStreamsMessagesReplyResp3Compat(reply: ReplyUnion) { - const transformed = transformStreamsMessagesReplyResp3(reply as any); + const transformed = transformStreamsMessagesReplyResp3(reply as unknown as Parameters[0]); if (transformed === null) return null; const compat = []; diff --git a/packages/client/lib/commands/XREADGROUP.spec.ts b/packages/client/lib/commands/XREADGROUP.spec.ts index 5d5704fcc74..89ba4b05321 100644 --- a/packages/client/lib/commands/XREADGROUP.spec.ts +++ b/packages/client/lib/commands/XREADGROUP.spec.ts @@ -153,7 +153,7 @@ describe('XREADGROUP', () => { // FUTURE resp3 compatible - const obj = Object.assign({}, { + const _obj = Object.assign({}, { 'key': [{ id: id, message: Object.defineProperties({}, { @@ -184,7 +184,7 @@ describe('XREADGROUP', () => { }); testUtils.testAll('xReadGroup - without CLAIM should not include delivery fields', async client => { - const [, id] = await Promise.all([ + const [, _id] = await Promise.all([ client.xGroupCreate('key', 'group', '$', { MKSTREAM: true }), diff --git a/packages/client/lib/commands/XREADGROUP.ts b/packages/client/lib/commands/XREADGROUP.ts index e8e2c4879de..6058c84d069 100644 --- a/packages/client/lib/commands/XREADGROUP.ts +++ b/packages/client/lib/commands/XREADGROUP.ts @@ -19,7 +19,7 @@ export interface XReadGroupOptions { } function transformStreamsMessagesReplyResp3Compat(reply: ReplyUnion) { - const transformed = transformStreamsMessagesReplyResp3(reply as any); + const transformed = transformStreamsMessagesReplyResp3(reply as unknown as Parameters[0]); if (transformed === null) return null; const compat = []; diff --git a/packages/client/lib/commands/generic-transformers.spec.ts b/packages/client/lib/commands/generic-transformers.spec.ts index 63123005ee0..328140c3422 100644 --- a/packages/client/lib/commands/generic-transformers.spec.ts +++ b/packages/client/lib/commands/generic-transformers.spec.ts @@ -440,11 +440,11 @@ describe('Generic Transformers', () => { [ '1', '2' - ] as unknown as TuplesReply<[BlobStringReply, ...Array]>, + ] as unknown as TuplesReply<[BlobStringReply, ...Array]>, [ '3', '4' - ] as unknown as TuplesReply<[BlobStringReply, ...Array]> + ] as unknown as TuplesReply<[BlobStringReply, ...Array]> ], [GeoReplyWith.DISTANCE]), [{ member: '1', @@ -462,11 +462,11 @@ describe('Generic Transformers', () => { [ '1', 2 - ] as unknown as TuplesReply<[BlobStringReply, ...Array]>, + ] as unknown as TuplesReply<[BlobStringReply, ...Array]>, [ '3', 4 - ] as unknown as TuplesReply<[BlobStringReply, ...Array]> + ] as unknown as TuplesReply<[BlobStringReply, ...Array]> ], [GeoReplyWith.HASH]), [{ member: '1', @@ -487,14 +487,14 @@ describe('Generic Transformers', () => { '2', '3' ] - ] as unknown as TuplesReply<[BlobStringReply, ...Array]>, + ] as unknown as TuplesReply<[BlobStringReply, ...Array]>, [ '4', [ '5', '6' ] - ] as unknown as TuplesReply<[BlobStringReply, ...Array]> + ] as unknown as TuplesReply<[BlobStringReply, ...Array]> ], [GeoReplyWith.COORDINATES]), [{ member: '1', @@ -523,7 +523,7 @@ describe('Generic Transformers', () => { '4', '5' ] - ] as unknown as TuplesReply<[BlobStringReply, ...Array]>, + ] as unknown as TuplesReply<[BlobStringReply, ...Array]>, [ '6', '7', @@ -532,7 +532,7 @@ describe('Generic Transformers', () => { '9', '10' ] - ] as unknown as TuplesReply<[BlobStringReply, ...Array]> + ] as unknown as TuplesReply<[BlobStringReply, ...Array]> ], [GeoReplyWith.DISTANCE, GeoReplyWith.HASH, GeoReplyWith.COORDINATES]), [{ member: '1', diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index cf6daecbe1a..7a43e13df14 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -44,6 +44,7 @@ export function transformStringDoubleArgument(num: RedisArgument | number): Redi } export const transformDoubleReply = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract 2: (reply: BlobStringReply, preserve?: any, typeMapping?: TypeMapping): DoubleReply => { const double = typeMapping ? typeMapping[RESP_TYPES.DOUBLE] : undefined; @@ -79,6 +80,7 @@ export const transformDoubleReply = { 3: undefined as unknown as () => DoubleReply }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract export function createTransformDoubleReplyResp2Func(preserve?: any, typeMapping?: TypeMapping) { return (reply: BlobStringReply) => { return transformDoubleReply[2](reply, preserve, typeMapping); @@ -86,12 +88,14 @@ export function createTransformDoubleReplyResp2Func(preserve?: any, typeMapping? } export const transformDoubleArrayReply = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract 2: (reply: Array, preserve?: any, typeMapping?: TypeMapping) => { return reply.map(createTransformDoubleReplyResp2Func(preserve, typeMapping)); }, 3: undefined as unknown as () => ArrayReply } +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract export function createTransformNullableDoubleReplyResp2Func(preserve?: any, typeMapping?: TypeMapping) { return (reply: BlobStringReply | NullReply) => { return transformNullableDoubleReply[2](reply, preserve, typeMapping); @@ -99,6 +103,7 @@ export function createTransformNullableDoubleReplyResp2Func(preserve?: any, type } export const transformNullableDoubleReply = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract 2: (reply: BlobStringReply | NullReply, preserve?: any, typeMapping?: TypeMapping) => { if (reply === null) return null; @@ -112,7 +117,9 @@ export interface Stringable { } export function transformTuplesToMap( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic over arbitrary tuple element types reply: UnwrapReply>, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic over arbitrary tuple element types func: (elem: any) => T, ) { const message: Record = {}; @@ -124,6 +131,7 @@ export function transformTuplesToMap( return message; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract export function createTransformTuplesReplyFunc(preserve?: any, typeMapping?: TypeMapping) { return (reply: ArrayReply) => { return transformTuplesReply(reply, preserve, typeMapping); @@ -132,6 +140,7 @@ export function createTransformTuplesReplyFunc(preserve?: export function transformTuplesReply( reply: ArrayReply, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract preserve?: any, typeMapping?: TypeMapping ): MapReply { @@ -147,7 +156,7 @@ export function transformTuplesReply( const ret = new Map; for (let i = 0; i < inferred.length; i += 2) { - ret.set(inferred[i].toString(), inferred[i + 1] as any); + ret.set(inferred[i].toString(), inferred[i + 1] as unknown as BlobStringReply); } return ret as unknown as MapReply;; @@ -156,7 +165,7 @@ export function transformTuplesReply( const ret: Record = {}; for (let i = 0; i < inferred.length; i += 2) { - ret[inferred[i].toString()] = inferred[i + 1] as any; + ret[inferred[i].toString()] = inferred[i + 1] as unknown as BlobStringReply; } return ret as unknown as MapReply;; @@ -172,6 +181,7 @@ export interface SortedSetMember { export type SortedSetSide = 'MIN' | 'MAX'; export const transformSortedSetReply = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract 2: (reply: ArrayReply, preserve?: any, typeMapping?: TypeMapping) => { const inferred = reply as unknown as UnwrapReply, members = []; @@ -499,11 +509,12 @@ function isPlainKeys(keys: Array | Array): keys is return isPlainKey(keys[0]); } -export type Tail = T extends [infer Head, ...infer Tail] ? Tail : never; +export type Tail = T extends [unknown, ...infer Tail] ? Tail : never; /** * @deprecated */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- variadic command args of mixed types export function parseArgs(command: Command, ...args: Array): CommandArguments { const parser = new BasicCommandParser(); command.parseCommand!(parser, ...args); @@ -564,6 +575,7 @@ export type StreamsMessagesRawReply2 = ArrayReply; export function transformStreamsMessagesReplyResp2( reply: UnwrapReply, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract preserve?: any, typeMapping?: TypeMapping ): StreamsMessagesReply | NullReply { diff --git a/packages/client/lib/sentinel/index.spec.ts b/packages/client/lib/sentinel/index.spec.ts index 272e12f1adc..fcaf05ad120 100644 --- a/packages/client/lib/sentinel/index.spec.ts +++ b/packages/client/lib/sentinel/index.spec.ts @@ -6,7 +6,7 @@ import { WatchError } from "../errors"; import { RedisSentinelConfig, SentinelFramework } from "./test-util"; import { RedisSentinelEvent, RedisSentinelType, RedisSentinelClientType, RedisNode } from "./types"; import RedisSentinel from "./index"; -import { RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping, NumberReply } from '../RESP/types'; +import { RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping } from '../RESP/types'; import { promisify } from 'node:util'; import { exec } from 'node:child_process'; import { BasicPooledClientSideCache } from '../client/cache' @@ -20,14 +20,14 @@ describe('RedisSentinel', () => { name: 'mymaster', sentinelRootNodes: [{ host: 'localhost', port: 26379 }] }); - assert.equal((sentinel as any).hotkeysStart, undefined); - assert.equal((sentinel as any).hotkeysStop, undefined); - assert.equal((sentinel as any).hotkeysGet, undefined); - assert.equal((sentinel as any).hotkeysReset, undefined); - assert.equal((sentinel as any).HOTKEYS_START, undefined); - assert.equal((sentinel as any).HOTKEYS_STOP, undefined); - assert.equal((sentinel as any).HOTKEYS_GET, undefined); - assert.equal((sentinel as any).HOTKEYS_RESET, undefined); + assert.equal((sentinel as unknown as Record).hotkeysStart, undefined); + assert.equal((sentinel as unknown as Record).hotkeysStop, undefined); + assert.equal((sentinel as unknown as Record).hotkeysGet, undefined); + assert.equal((sentinel as unknown as Record).hotkeysReset, undefined); + assert.equal((sentinel as unknown as Record).HOTKEYS_START, undefined); + assert.equal((sentinel as unknown as Record).HOTKEYS_STOP, undefined); + assert.equal((sentinel as unknown as Record).HOTKEYS_GET, undefined); + assert.equal((sentinel as unknown as Record).HOTKEYS_RESET, undefined); }); describe('initialization', () => { @@ -179,7 +179,7 @@ describe('RedisSentinel', () => { testUtils.testWithClientSentinel('use', async sentinel => { await sentinel.use( - async (client: any ) => { + async client => { await assert.doesNotReject(client.get('x')); } ); @@ -250,7 +250,7 @@ describe('RedisSentinel', () => { let tester = false; await sentinel.sSubscribe('test', () => { tester = true; - pubSubResolve && pubSubResolve(1); + if (pubSubResolve) pubSubResolve(1); }) await sentinel.sPublish('test', 'hello world'); @@ -284,8 +284,8 @@ describe(`test with scripts`, () => { }, GLOBAL.SENTINEL.WITH_SCRIPT); testUtils.testWithClientSentinel('use with script', async sentinel => { - const reply = await sentinel.use( - async (client: any) => { + await sentinel.use( + async client => { assert.equal(await client.set('key', '2'), 'OK'); assert.equal(await client.get('key'), '2'); return client.square('key') @@ -324,7 +324,7 @@ describe(`test with functions`, () => { ); const reply = await sentinel.use( - async (client: any) => { + async client => { await client.set('key', '2'); return client.math.square('key'); } @@ -347,7 +347,7 @@ describe(`test with modules`, () => { testUtils.testWithClientSentinel('use with module', async sentinel => { const reply = await sentinel.use( - async (client: any) => { + async client => { return client.bf.add('key', 'item'); } ); @@ -370,7 +370,7 @@ describe(`test with replica pool size 1`, () => { assert.equal(await sentinel.get("x"), '456'); matched = true; break; - } catch (err) { + } catch { await setTimeout(1000); } } @@ -436,7 +436,7 @@ describe(`test with masterPoolSize 2`, () => { }, GLOBAL.SENTINEL.WITH_MASTER_POOL_SIZE_2); testUtils.testWithClientSentinel('use - watch - clean', async sentinel => { - let promise = sentinel.use(async (client) => { + const promise = sentinel.use(async (client) => { await client.set("x", 1); await client.watch("x"); return client.multi().get("x").exec(); @@ -446,7 +446,7 @@ describe(`test with masterPoolSize 2`, () => { }, GLOBAL.SENTINEL.WITH_MASTER_POOL_SIZE_2); testUtils.testWithClientSentinel('use - watch - dirty', async sentinel => { - let promise = sentinel.use(async (client) => { + const promise = sentinel.use(async (client) => { await client.set('x', 1); await client.watch('x'); await sentinel!.set('x', 2); @@ -493,10 +493,9 @@ async function steadyState(frame: SentinelFramework) { } } } - let nodeResolve, nodeReject; - const nodePromise = new Promise((res, rej) => { + let nodeResolve; + const nodePromise = new Promise(res => { nodeResolve = res; - nodeReject = rej; }) const seenNodes = new Set(); let sentinel: RedisSentinelType | undefined; @@ -510,7 +509,7 @@ async function steadyState(frame: SentinelFramework) { nodeResolve(); } } - }).on('error', err => { }); + }).on('error', () => { }); sentinel.setTracer(tracer); await sentinel.connect(); await nodePromise; @@ -526,7 +525,7 @@ async function steadyState(frame: SentinelFramework) { describe('legacy tests', () => { const config: RedisSentinelConfig = { sentinelName: "test", numberOfNodes: 3, password: undefined }; const frame = new SentinelFramework(config); - let tracer = new Array(); + const tracer = new Array(); let stopMeasuringBlocking = false; let longestDelta = 0; let longestTestDelta = 0; @@ -587,13 +586,13 @@ describe('legacy tests', () => { console.log(`sentinel sentinels:\n${JSON.stringify(results[0], undefined, '\t')}`); console.log(`sentinel master:\n${JSON.stringify(results[1], undefined, '\t')}`); console.log(`sentinel replicas:\n${JSON.stringify(results[2], undefined, '\t')}`); - const { stdout, stderr } = await execAsync("docker ps -a"); + const { stdout } = await execAsync("docker ps -a"); console.log(`docker stdout:\n${stdout}`); const ids = frame.getAllDockerIds(); console.log("docker logs"); for (const [id, port] of ids) { console.log(`${id}/${port}\n`); - const { stdout, stderr } = await execAsync(`docker logs ${id}`, {maxBuffer: 8192 * 8192 * 4}); + const { stdout } = await execAsync(`docker logs ${id}`, {maxBuffer: 8192 * 8192 * 4}); console.log(stdout); } } @@ -1025,10 +1024,11 @@ describe('legacy tests', () => { try { await timedGet(); pollResults.push({ phase, status: 'success' }); - } catch (err: any) { + } catch (err) { + const message = (err as { message?: string })?.message; pollResults.push({ phase, - status: err?.message === '1s Timeout' ? 'timeout' : 'error' + status: message === '1s Timeout' ? 'timeout' : 'error' }); } await setTimeout(3000); @@ -1090,7 +1090,7 @@ describe('legacy tests', () => { sentinel = frame.getSentinelClient({ replicaPoolSize: 1 }); sentinel.setTracer(tracer); - sentinel.on('error', err => { }); + sentinel.on('error', () => { }); await sentinel.connect(); tracer.push("connected"); @@ -1147,7 +1147,7 @@ describe('legacy tests', () => { sentinel = frame.getSentinelClient({ scanInterval: 2000, replicaPoolSize: 1 }); sentinel.setTracer(tracer); // need to handle errors, as the spawning a new docker node can cause existing connections to time out - sentinel.on('error', err => { }); + sentinel.on('error', () => { }); await sentinel.connect(); tracer.push("connected"); diff --git a/packages/client/lib/sentinel/multi-commands.ts b/packages/client/lib/sentinel/multi-commands.ts index be642b22675..72aaf195409 100644 --- a/packages/client/lib/sentinel/multi-commands.ts +++ b/packages/client/lib/sentinel/multi-commands.ts @@ -73,6 +73,7 @@ type WithScripts< }; export type RedisSentinelMultiCommandType< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance marker for reply tuple REPLIES extends Array, M extends RedisModules, F extends RedisFunctions, diff --git a/packages/client/lib/sentinel/types.ts b/packages/client/lib/sentinel/types.ts index 71fc5ae4f41..afb378817e0 100644 --- a/packages/client/lib/sentinel/types.ts +++ b/packages/client/lib/sentinel/types.ts @@ -199,15 +199,17 @@ export interface SentinelCommandOptions< TYPE_MAPPING extends TypeMapping = TypeMapping > extends CommandOptions {} +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance markers for sentinel generics export type ProxySentinel = RedisSentinel; +// 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 NodeInfo = { - ip: any, - port: any, - flags: any, + ip: string, + port: string, + flags: string, }; export type RedisSentinelEvent = NodeChangeEvent | SizeChangeEvent; @@ -219,7 +221,7 @@ export type NodeChangeEvent = { export type SizeChangeEvent = { type: "SENTINE_LIST_CHANGE"; - size: Number; + size: number; } export type ClientErrorEvent = { diff --git a/packages/search/lib/commands/AGGREGATE.ts b/packages/search/lib/commands/AGGREGATE.ts index 09e87d9c78d..690e533fad6 100644 --- a/packages/search/lib/commands/AGGREGATE.ts +++ b/packages/search/lib/commands/AGGREGATE.ts @@ -142,6 +142,7 @@ export interface AggregateReply { function transformAggregateReplyResp2( rawReply: AggregateRawReply, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract preserve?: any, typeMapping?: TypeMapping ): AggregateReply { @@ -163,6 +164,7 @@ function transformAggregateReplyResp2( function transformAggregateReplyResp3( rawReply: ReplyUnion, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract preserve?: any, typeMapping?: TypeMapping ): AggregateReply { diff --git a/packages/search/lib/commands/CONFIG_GET.ts b/packages/search/lib/commands/CONFIG_GET.ts index 9d20c6d4a41..2272438c702 100644 --- a/packages/search/lib/commands/CONFIG_GET.ts +++ b/packages/search/lib/commands/CONFIG_GET.ts @@ -12,7 +12,7 @@ export default { const transformedReply: Record = {}; for (const [key, value] of mapLikeEntries(reply)) { - transformedReply[key] = value; + transformedReply[key] = value as BlobStringReply | NullReply; } return toCompatObject(transformedReply); diff --git a/packages/search/lib/commands/HYBRID.spec.ts b/packages/search/lib/commands/HYBRID.spec.ts index 2b68216ef74..752d2a74136 100644 --- a/packages/search/lib/commands/HYBRID.spec.ts +++ b/packages/search/lib/commands/HYBRID.spec.ts @@ -45,6 +45,7 @@ const FT_HYBRID_ITEMS = [ * Helper to create the index for hybrid search tests */ const createHybridSearchIndex = async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test helper accepts loosely-typed clients client: any, indexName: string, dim = 4, @@ -83,6 +84,7 @@ const createHybridSearchIndex = async ( * Helper to add data to the index for hybrid search tests */ const addDataForHybridSearch = async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test helper accepts loosely-typed clients client: any, itemsSets = 1, options: { @@ -108,14 +110,14 @@ const addDataForHybridSearch = async ( : () => generateRandomVector(actualDim); items = [ - { vector: generateDataFunc() as any, description: "red shoes" }, + { vector: generateDataFunc() as never, description: "red shoes" }, { - vector: generateDataFunc() as any, + vector: generateDataFunc() as never, description: "green shoes with red laces", }, - { vector: generateDataFunc() as any, description: "red dress" }, - { vector: generateDataFunc() as any, description: "orange dress" }, - { vector: generateDataFunc() as any, description: "black shoes" }, + { vector: generateDataFunc() as never, description: "red dress" }, + { vector: generateDataFunc() as never, description: "orange dress" }, + { vector: generateDataFunc() as never, description: "black shoes" }, ]; } else { items = FT_HYBRID_ITEMS; @@ -127,7 +129,7 @@ const addDataForHybridSearch = async ( allItems.push(...items); } - const promises: Promise[] = []; + const promises: Promise[] = []; for (let i = 0; i < allItems.length; i++) { const { vector, description } = allItems[i]; const embeddingData = diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 988c76b320d..7cd9a889954 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -410,7 +410,7 @@ export default { 2: (reply: unknown): HybridSearchResult => { return transformHybridSearchResults(reply); }, - 3: (reply: any): HybridSearchResult => { + 3: (reply: unknown): HybridSearchResult => { return transformHybridSearchResults(reply); }, }, @@ -424,7 +424,7 @@ export interface HybridSearchResult { results: Record[]; } -function transformHybridSearchResults(reply: any): HybridSearchResult { +function transformHybridSearchResults(reply: unknown): HybridSearchResult { const replyMap = parseReplyMap(reply); const totalResults = Number( @@ -446,11 +446,11 @@ function transformHybridSearchResults(reply: any): HybridSearchResult { const results: HybridSearchResult['results'] = []; for (const result of rawResults) { const resultMap = parseReplyMap(result); - const doc: Record = {}; + const doc: Record = {}; const id = getMapValue(resultMap, ["id"]); - if (id !== undefined) { - doc.id = id.toString(); + if (id != null) { + doc.id = (id as { toString(): string }).toString(); } Object.assign(doc, parseDocumentValue(getMapValue(resultMap, ["values"]))); @@ -482,11 +482,11 @@ function transformHybridSearchResults(reply: any): HybridSearchResult { return { totalResults, executionTime, - warnings: warnings.map(warning => warning.toString()), + warnings: warnings.map(warning => (warning as { toString(): string }).toString()), results, }; } -function parseReplyMap(reply: any): Record { +function parseReplyMap(reply: unknown): Record { return mapLikeToObject(reply); } diff --git a/packages/search/lib/commands/INFO.spec.ts b/packages/search/lib/commands/INFO.spec.ts index 22015588621..6b4579c077d 100644 --- a/packages/search/lib/commands/INFO.spec.ts +++ b/packages/search/lib/commands/INFO.spec.ts @@ -1,6 +1,6 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; -import INFO, { InfoReply } from './INFO'; +import INFO from './INFO'; import { SCHEMA_FIELD_TYPE } from './CREATE'; import { parseArgs } from '@redis/client/lib/commands/generic-transformers'; diff --git a/packages/search/lib/commands/INFO.ts b/packages/search/lib/commands/INFO.ts index 4b0f30964d0..4e16c071898 100644 --- a/packages/search/lib/commands/INFO.ts +++ b/packages/search/lib/commands/INFO.ts @@ -103,7 +103,7 @@ function transformV2Reply(reply: Array, preserve?: unknown, typeMapping case 'offset_bits_per_record_avg': case 'total_indexing_time': case 'percent_indexed': - ret[key] = transformDoubleReply[2](reply[i+1], undefined, typeMapping) as DoubleReply; + ret[key] = transformDoubleReply[2](reply[i+1] as BlobStringReply, undefined, typeMapping) as DoubleReply; break; case 'index_definition': ret[key] = myTransformFunc(reply[i+1] as ArrayReply); diff --git a/packages/search/lib/commands/SEARCH.ts b/packages/search/lib/commands/SEARCH.ts index 13ffcf8abcd..9d29d90d0fd 100644 --- a/packages/search/lib/commands/SEARCH.ts +++ b/packages/search/lib/commands/SEARCH.ts @@ -163,17 +163,17 @@ function transformSearchReplyResp2(reply: SearchRawReply): SearchReply { // if reply[2] is array, then we have content/documents. Otherwise, only ids const withoutDocuments = reply.length > 2 && !Array.isArray(reply[2]); - const documents = []; + const documents: SearchReply['documents'] = []; let i = 1; while (i < reply.length) { documents.push({ - id: reply[i++], - value: withoutDocuments ? {} : documentValue(reply[i++]) + id: reply[i++] as string, + value: (withoutDocuments ? {} : documentValue(reply[i++])) as SearchDocumentValue }); } return { - total: reply[0], + total: reply[0] as number, documents }; } @@ -190,11 +190,11 @@ function transformSearchReplyResp3(rawReply: ReplyUnion): SearchReply { getMapValue(reply, ['results', 'documents']) ?? [] ); - const documents = results.map(result => { + const documents: SearchReply['documents'] = results.map(result => { const { id, value } = parseSearchResultRow(result); return { - id: id?.toString?.() ?? id, - value + id: String((id as { toString?(): string })?.toString?.() ?? id ?? ''), + value: value as SearchDocumentValue }; }); @@ -232,6 +232,6 @@ export interface SearchReply { }>; } -function documentValue(tuples: any) { +function documentValue(tuples: unknown) { return parseDocumentValue(tuples); } diff --git a/packages/search/lib/commands/reply-transformers.ts b/packages/search/lib/commands/reply-transformers.ts index f884e6d9ca3..fa4f1db3f33 100644 --- a/packages/search/lib/commands/reply-transformers.ts +++ b/packages/search/lib/commands/reply-transformers.ts @@ -1,11 +1,11 @@ -function isPlainObject(value: unknown): value is Record { +function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Map); } -export function mapLikeEntries(value: unknown): Array<[string, any]> { +export function mapLikeEntries(value: unknown): Array<[string, unknown]> { if (value instanceof Map) { return Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]); } @@ -22,7 +22,7 @@ export function mapLikeEntries(value: unknown): Array<[string, any]> { return value.map(item => [item[0].toString(), item[1]]); } - const entries: Array<[string, any]> = []; + const entries: Array<[string, unknown]> = []; for (let i = 0; i < value.length - 1; i += 2) { entries.push([value[i].toString(), value[i + 1]]); } @@ -36,7 +36,7 @@ export function mapLikeEntries(value: unknown): Array<[string, any]> { return []; } -export function toCompatObject(value: Record): Record { +export function toCompatObject(value: Record): Record { const descriptors: PropertyDescriptorMap = {}; for (const [key, entryValue] of Object.entries(value)) { @@ -50,30 +50,30 @@ export function toCompatObject(value: Record): Record return Object.defineProperties({}, descriptors); } -export function mapLikeToObject(value: unknown): Record { - const object: Record = {}; +export function mapLikeToObject(value: unknown): Record { + const object: Record = {}; for (const [key, entryValue] of mapLikeEntries(value)) { object[key] = entryValue; } return object; } -export function mapLikeToFlatArray(value: unknown): Array { - const flat: Array = []; +export function mapLikeToFlatArray(value: unknown): Array { + const flat: Array = []; for (const [key, entryValue] of mapLikeEntries(value)) { flat.push(key, entryValue); } return flat; } -export function mapLikeValues(value: unknown): Array { +export function mapLikeValues(value: unknown): Array { if (Array.isArray(value)) return value; if (value instanceof Map) return [...value.values()]; if (isPlainObject(value)) return Object.values(value); return []; } -export function getMapValue(value: unknown, keys: Array): any { +export function getMapValue(value: unknown, keys: Array): unknown { const object = mapLikeToObject(value); for (const key of keys) { @@ -100,9 +100,9 @@ export function getMapValue(value: unknown, keys: Array): any { return undefined; } -function assignDocumentField(target: Record, key: string, value: any): void { +function assignDocumentField(target: Record, key: string, value: unknown): void { if (key === '$') { - const json = value?.toString?.() ?? value; + const json = (value as { toString?: () => string })?.toString?.() ?? value; if (typeof json === 'string') { try { Object.assign(target, JSON.parse(json)); @@ -116,8 +116,8 @@ function assignDocumentField(target: Record, key: string, value: an target[key] = value; } -export function parseDocumentValue(value: unknown): Record { - const document: Record = {}; +export function parseDocumentValue(value: unknown): Record { + const document: Record = {}; for (const [key, entryValue] of mapLikeEntries(value)) { assignDocumentField(document, key, entryValue); @@ -132,7 +132,7 @@ function normalizeProfileValue(value: unknown): unknown { } if (value instanceof Map || isPlainObject(value)) { - const normalized: Array = []; + const normalized: Array = []; for (const [key, entryValue] of mapLikeEntries(value)) { normalized.push(key, normalizeProfileValue(entryValue)); } @@ -147,12 +147,12 @@ export function normalizeProfileReply(profile: unknown): unknown { } export function parseSearchResultRow(rawRow: unknown): { - id: any; - value: Record; + id: unknown; + value: Record; } { const row = mapLikeToObject(rawRow); - const value: Record = {}; + const value: Record = {}; Object.assign(value, parseDocumentValue(getMapValue(row, ['values']))); Object.assign(value, parseDocumentValue(getMapValue(row, ['extra_attributes', 'extraAttributes']))); @@ -162,10 +162,10 @@ export function parseSearchResultRow(rawRow: unknown): { }; } -export function parseAggregateResultRow(rawRow: unknown): Record { +export function parseAggregateResultRow(rawRow: unknown): Record { const row = mapLikeToObject(rawRow); - const result: Record = {}; + const result: Record = {}; Object.assign(result, parseDocumentValue(getMapValue(row, ['values']))); Object.assign(result, parseDocumentValue(getMapValue(row, ['extra_attributes', 'extraAttributes']))); diff --git a/packages/test-utils/lib/index.ts b/packages/test-utils/lib/index.ts index fea86942853..df242321661 100644 --- a/packages/test-utils/lib/index.ts +++ b/packages/test-utils/lib/index.ts @@ -201,13 +201,13 @@ export default class TestUtils { // Match complete version number patterns - const versionMatch = version.match(/(^|\-)\d+(\.\d+)*($|\-)/); + const versionMatch = version.match(/(^|-)\d+(\.\d+)*($|-)/); if (!versionMatch) { throw new TypeError(`${version} is not a valid redis version`); } // Extract just the numbers and dots between first and last dash (or start/end) - const versionNumbers = versionMatch[0].replace(/^\-|\-$/g, ''); + const versionNumbers = versionMatch[0].replace(/^-|-$/g, ''); return versionNumbers.split('.').map(x => { const value = Number(x); @@ -576,7 +576,9 @@ export default class TestUtils { testWithProxiedClient( title: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance markers for client generics fn: (proxiedClient: RedisClientType, proxy: RedisProxy) => unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance markers for client generics options: ClientTestOptions ) { @@ -586,9 +588,9 @@ export default class TestUtils { const proxy = new RedisProxy({ listenHost: '127.0.0.1', listenPort: freePort, - //@ts-ignore + // @ts-expect-error -- proxy tests target TCP-only socket options targetPort: socketOptions.port, - //@ts-ignore + // @ts-expect-error -- proxy tests target TCP-only socket options targetHost: socketOptions.host ?? '127.0.0.1', enableLogging: true }); @@ -912,6 +914,7 @@ export default class TestUtils { }; if (clientOptions.clientOptions) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- destructure generic test fixtures across M/F/S const { modules, functions, scripts, ...clientDefaults } = clientOptions.clientOptions as any; if (modules) { diff --git a/packages/time-series/lib/commands/INFO.spec.ts b/packages/time-series/lib/commands/INFO.spec.ts index f6d37644e5f..1287565a99f 100644 --- a/packages/time-series/lib/commands/INFO.spec.ts +++ b/packages/time-series/lib/commands/INFO.spec.ts @@ -24,7 +24,7 @@ describe('TS.INFO', () => { client.ts.add('key', 1, 10) ]); - assertInfo(await client.ts.info('key') as any); + assertInfo(await client.ts.info('key') as never); }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/time-series/lib/commands/INFO.ts b/packages/time-series/lib/commands/INFO.ts index 526ef058c15..2eb41375d41 100644 --- a/packages/time-series/lib/commands/INFO.ts +++ b/packages/time-series/lib/commands/INFO.ts @@ -71,7 +71,7 @@ export interface InfoReply { ignoreMaxValDiff: DoubleReply; } -function mapLikeEntries(value: unknown): Array<[string, any]> { +function mapLikeEntries(value: unknown): Array<[string, unknown]> { if (value instanceof Map) { return Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]); } @@ -81,7 +81,7 @@ function mapLikeEntries(value: unknown): Array<[string, any]> { return value.map(item => [item[0].toString(), item[1]]); } - const entries: Array<[string, any]> = []; + const entries: Array<[string, unknown]> = []; for (let i = 0; i < value.length - 1; i += 2) { entries.push([value[i].toString(), value[i + 1]]); } @@ -95,22 +95,22 @@ function mapLikeEntries(value: unknown): Array<[string, any]> { return []; } -function mapLikeValues(value: unknown): Array { +function mapLikeValues(value: unknown): Array { if (Array.isArray(value)) return value; if (value instanceof Map) return [...value.values()]; if (value !== null && typeof value === 'object') return Object.values(value); return []; } -function mapLikeToObject(value: unknown): Record { - const object: Record = {}; +function mapLikeToObject(value: unknown): Record { + const object: Record = {}; for (const [key, entryValue] of mapLikeEntries(value)) { object[key] = entryValue; } return object; } -function getMapValue(value: unknown, keys: Array): any { +function getMapValue(value: unknown, keys: Array): unknown { const object = mapLikeToObject(value); for (const key of keys) { @@ -168,9 +168,9 @@ function normalizeInfoRules(rules: unknown): Array<[key: BlobStringReply, timeBu Object.values(TIME_SERIES_AGGREGATION_TYPE).map(type => type.toUpperCase()) ); - const parseRuleTuple = (rule: Array): [BlobStringReply, NumberReply, TimeSeriesAggregationType] => { - const stringCandidates = rule.filter(value => typeof value === 'string' || value instanceof Buffer); - const numberCandidates = rule.filter(value => typeof value === 'number'); + const parseRuleTuple = (rule: Array): [BlobStringReply, NumberReply, TimeSeriesAggregationType] => { + const stringCandidates = rule.filter((value): value is string | Buffer => typeof value === 'string' || value instanceof Buffer); + const numberCandidates = rule.filter((value): value is number => typeof value === 'number'); const aggregationCandidate = stringCandidates.find(value => { return aggregationTypes.has(value.toString().toUpperCase()); @@ -180,7 +180,7 @@ function normalizeInfoRules(rules: unknown): Array<[key: BlobStringReply, timeBu return [ (keyCandidate ?? rule[0]) as BlobStringReply, - (numberCandidates[0] ?? Number(rule[1])) as NumberReply, + (numberCandidates[0] ?? Number(rule[1])) as unknown as NumberReply, (aggregationCandidate ?? rule[2]) as TimeSeriesAggregationType ]; }; @@ -241,7 +241,7 @@ function normalizeInfoRawReply(reply: ReplyUnion): InfoRawReply { return reply as unknown as InfoRawReply; } - const normalized: Array = []; + const normalized: Array = []; for (const [key, value] of mapLikeEntries(reply)) { switch (key) { case 'labels': @@ -260,10 +260,10 @@ function normalizeInfoRawReply(reply: ReplyUnion): InfoRawReply { } function transformInfoReplyResp2(reply: InfoRawReply, _: unknown, typeMapping?: TypeMapping): InfoReply { - const ret = {} as any; + const ret: Record = {}; for (let i = 0; i < reply.length; i += 2) { - const key = (reply[i] as any).toString(); + const key = (reply[i] as { toString(): string }).toString(); switch (key) { case 'totalSamples': @@ -302,7 +302,7 @@ function transformInfoReplyResp2(reply: InfoRawReply, _: unknown, typeMapping?: } } - return ret; + return ret as unknown as InfoReply; } function transformInfoReplyResp3(reply: ReplyUnion, preserve?: unknown, typeMapping?: TypeMapping): InfoReply { diff --git a/packages/time-series/lib/commands/INFO_DEBUG.spec.ts b/packages/time-series/lib/commands/INFO_DEBUG.spec.ts index 26c61035640..c21414d3a0a 100644 --- a/packages/time-series/lib/commands/INFO_DEBUG.spec.ts +++ b/packages/time-series/lib/commands/INFO_DEBUG.spec.ts @@ -26,7 +26,7 @@ describe('TS.INFO_DEBUG', () => { ]); const infoDebug = await client.ts.infoDebug('key'); - assertInfo(infoDebug as any); + assertInfo(infoDebug as never); assert.equal(typeof infoDebug.keySelfName, 'string'); assert.ok(Array.isArray(infoDebug.chunks)); for (const chunk of infoDebug.chunks) { @@ -50,7 +50,7 @@ describe('TS.INFO_DEBUG', () => { client.ts.add('key', 1, 10) ]); - const infoDebug = await client.ts.infoDebug('key') as any; + const infoDebug = await client.ts.infoDebug('key') as never; // RESP3 returns a Map, verify key fields exist with correct types assert.equal(typeof infoDebug.totalSamples, 'number'); assert.equal(typeof infoDebug.memoryUsage, 'number'); diff --git a/packages/time-series/lib/commands/INFO_DEBUG.ts b/packages/time-series/lib/commands/INFO_DEBUG.ts index fecfafd3e36..558e5917e15 100644 --- a/packages/time-series/lib/commands/INFO_DEBUG.ts +++ b/packages/time-series/lib/commands/INFO_DEBUG.ts @@ -36,7 +36,7 @@ export interface InfoDebugReply extends InfoReply { }>; } -function mapLikeToObject(value: unknown): Record { +function mapLikeToObject(value: unknown): Record { if (value instanceof Map) { return Object.fromEntries( Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]) @@ -44,7 +44,7 @@ function mapLikeToObject(value: unknown): Record { } if (Array.isArray(value)) { - const object: Record = {}; + const object: Record = {}; for (let i = 0; i < value.length - 1; i += 2) { object[value[i].toString()] = value[i + 1]; } @@ -52,13 +52,13 @@ function mapLikeToObject(value: unknown): Record { } if (value !== null && typeof value === 'object') { - return value as Record; + return value as Record; } return {}; } -function mapLikeValues(value: unknown): Array { +function mapLikeValues(value: unknown): Array { if (Array.isArray(value)) return value; if (value instanceof Map) return [...value.values()]; if (value !== null && typeof value === 'object') return Object.values(value); @@ -93,7 +93,7 @@ function normalizeChunks(chunks: unknown): InfoDebugReply['chunks'] { endTimestamp: object.endTimestamp ?? object.end_timestamp, samples: object.samples, size: object.size, - bytesPerSample: (object.bytesPerSample ?? object.bytes_per_sample).toString() + bytesPerSample: (object.bytesPerSample ?? object.bytes_per_sample as { toString(): string }).toString() }; }); } @@ -137,7 +137,7 @@ export default { const ret = INFO.transformReply[3](reply, preserve, typeMapping) as InfoDebugReply; const mappedReply = mapLikeToObject(reply); - ret.keySelfName = mappedReply.keySelfName ?? mappedReply.key_self_name; + ret.keySelfName = (mappedReply.keySelfName ?? mappedReply.key_self_name) as BlobStringReply; const chunks = mappedReply.Chunks ?? mappedReply.chunks; ret.chunks = normalizeChunks(chunks); diff --git a/packages/time-series/lib/commands/MRANGE_MULTIAGGR.spec.ts b/packages/time-series/lib/commands/MRANGE_MULTIAGGR.spec.ts index 1580bb98a57..87713dabfcb 100644 --- a/packages/time-series/lib/commands/MRANGE_MULTIAGGR.spec.ts +++ b/packages/time-series/lib/commands/MRANGE_MULTIAGGR.spec.ts @@ -58,7 +58,7 @@ describe('TS.MRANGE_MULTIAGGR', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { 'mrange-multi': { configurable: true, enumerable: true, diff --git a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_MULTIAGGR.spec.ts b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_MULTIAGGR.spec.ts index f1515ca0734..b77a91c152a 100644 --- a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_MULTIAGGR.spec.ts +++ b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_MULTIAGGR.spec.ts @@ -55,12 +55,12 @@ describe('TS.MRANGE_SELECTED_LABELS_MULTIAGGR', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { 'mrange-selectedlabels-multi': { configurable: true, enumerable: true, value: { - labels: Object.create(null, { + labels: Object.defineProperties({}, { label: { configurable: true, enumerable: true, diff --git a/packages/time-series/lib/commands/MRANGE_WITHLABELS_MULTIAGGR.spec.ts b/packages/time-series/lib/commands/MRANGE_WITHLABELS_MULTIAGGR.spec.ts index 4d5eb8b364f..89e7175b92f 100644 --- a/packages/time-series/lib/commands/MRANGE_WITHLABELS_MULTIAGGR.spec.ts +++ b/packages/time-series/lib/commands/MRANGE_WITHLABELS_MULTIAGGR.spec.ts @@ -57,12 +57,12 @@ describe('TS.MRANGE_WITHLABELS_MULTIAGGR', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { 'mrange-withlabels-multi': { configurable: true, enumerable: true, value: { - labels: Object.create(null, { + labels: Object.defineProperties({}, { label: { configurable: true, enumerable: true, diff --git a/packages/time-series/lib/commands/MRANGE_WITHLABELS_MULTIAGGR.ts b/packages/time-series/lib/commands/MRANGE_WITHLABELS_MULTIAGGR.ts index e5cc87d6eed..0192e9c97c8 100644 --- a/packages/time-series/lib/commands/MRANGE_WITHLABELS_MULTIAGGR.ts +++ b/packages/time-series/lib/commands/MRANGE_WITHLABELS_MULTIAGGR.ts @@ -66,7 +66,7 @@ export default { return resp2MapToValue(reply, ([_key, labels, samples]) => { const unwrappedLabels = labels as unknown as UnwrapReply; // TODO: use Map type mapping for labels - const labelsObject: Record = Object.create(null); + const labelsObject: Record = {}; for (const tuple of unwrappedLabels) { const [key, value] = tuple as unknown as UnwrapReply; const unwrappedKey = key as unknown as UnwrapReply; diff --git a/packages/time-series/lib/commands/MREVRANGE_MULTIAGGR.spec.ts b/packages/time-series/lib/commands/MREVRANGE_MULTIAGGR.spec.ts index aa08dd35e57..3c31adc7106 100644 --- a/packages/time-series/lib/commands/MREVRANGE_MULTIAGGR.spec.ts +++ b/packages/time-series/lib/commands/MREVRANGE_MULTIAGGR.spec.ts @@ -58,7 +58,7 @@ describe('TS.MREVRANGE_MULTIAGGR', () => { assert.deepStrictEqual( reply, - Object.create(null, { + Object.defineProperties({}, { 'mrevrange-multiaggr': { configurable: true, enumerable: true, diff --git a/packages/time-series/lib/commands/helpers.ts b/packages/time-series/lib/commands/helpers.ts index e43db1787cd..e5b090d86cb 100644 --- a/packages/time-series/lib/commands/helpers.ts +++ b/packages/time-series/lib/commands/helpers.ts @@ -185,6 +185,7 @@ export function resp2MapToValue< } export function resp3MapToValue< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic over arbitrary RespType RAW_VALUE extends RespType, // TODO: simplify types TRANSFORMED >( @@ -232,7 +233,7 @@ export function transformRESP2Labels( ): MapReply { const unwrappedLabels = labels as unknown as UnwrapReply; switch (typeMapping?.[RESP_TYPES.MAP]) { - case Map: + case Map: { const map = new Map(); for (const tuple of unwrappedLabels) { const [key, value] = tuple as unknown as UnwrapReply; @@ -240,12 +241,13 @@ export function transformRESP2Labels( map.set(unwrappedKey.toString(), value); } return map as never; + } case Array: return unwrappedLabels.flat() as never; case Object: - default: + default: { const labelsObject: Record = {}; for (const tuple of unwrappedLabels) { const [key, value] = tuple as unknown as UnwrapReply; @@ -253,6 +255,7 @@ export function transformRESP2Labels( labelsObject[unwrappedKey.toString()] = value; } return labelsObject as never; + } } } @@ -264,7 +267,7 @@ export function transformRESP2LabelsWithSources( const to = unwrappedLabels.length - 2; // ignore __reducer__ and __source__ let transformedLabels: MapReply; switch (typeMapping?.[RESP_TYPES.MAP]) { - case Map: + case Map: { const map = new Map(); for (let i = 0; i < to; i++) { const [key, value] = unwrappedLabels[i] as unknown as UnwrapReply; @@ -273,13 +276,14 @@ export function transformRESP2LabelsWithSources( } transformedLabels = map as never; break; + } case Array: transformedLabels = unwrappedLabels.slice(0, to).flat() as never; break; case Object: - default: + default: { const labelsObject: Record = {}; for (let i = 0; i < to; i++) { const [key, value] = unwrappedLabels[i] as unknown as UnwrapReply; @@ -288,6 +292,7 @@ export function transformRESP2LabelsWithSources( } transformedLabels = labelsObject as never; break; + } } const sourcesTuple = unwrappedLabels[unwrappedLabels.length - 1]; From c905730b30a8164ac0a9c55d8bb1c3cb40e05637 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 14 May 2026 12:32:06 +0300 Subject: [PATCH 09/28] address PR comments - refactor(streams): share RESP3 compat transformer across XREAD/XREADGROUP - test-utils: restore pre-resp3 testWithCluster minimizeConnections default - fix(search): handle FT.AGGREGATE RESP3 reply under typeMapping[MAP]=Array - test(search): cover FT.HYBRID JSON-doc reply for RESP2 and RESP3 --- packages/client/lib/commands/XREAD.ts | 42 +------ packages/client/lib/commands/XREADGROUP.ts | 42 +------ .../lib/commands/generic-transformers.ts | 40 +++++- .../search/lib/commands/AGGREGATE.spec.ts | 48 ++++++++ packages/search/lib/commands/AGGREGATE.ts | 4 - packages/search/lib/commands/HYBRID.spec.ts | 114 ++++++++++++++++++ packages/test-utils/lib/index.ts | 8 +- 7 files changed, 207 insertions(+), 91 deletions(-) diff --git a/packages/client/lib/commands/XREAD.ts b/packages/client/lib/commands/XREAD.ts index 71e2e17eee1..19a6945fd5e 100644 --- a/packages/client/lib/commands/XREAD.ts +++ b/packages/client/lib/commands/XREAD.ts @@ -1,6 +1,6 @@ import { CommandParser } from '../client/parser'; -import { Command, RedisArgument, ReplyUnion } from '../RESP/types'; -import { transformStreamsMessagesReplyResp2, transformStreamsMessagesReplyResp3 } from './generic-transformers'; +import { Command, RedisArgument } from '../RESP/types'; +import { transformStreamsMessagesReplyResp2, transformStreamsMessagesReplyResp3Compat } from './generic-transformers'; /** * Structure representing a stream to read from @@ -48,44 +48,6 @@ export interface XReadOptions { BLOCK?: number; } -function transformStreamsMessagesReplyResp3Compat(reply: ReplyUnion) { - const transformed = transformStreamsMessagesReplyResp3(reply as unknown as Parameters[0]); - if (transformed === null) return null; - - const compat = []; - - if (transformed instanceof Map) { - for (const [name, messages] of transformed.entries()) { - compat.push({ - name, - messages - }); - } - - return compat; - } - - if (Array.isArray(transformed)) { - for (let i = 0; i < transformed.length; i += 2) { - compat.push({ - name: transformed[i], - messages: transformed[i + 1] - }); - } - - return compat; - } - - for (const [name, messages] of Object.entries(transformed)) { - compat.push({ - name, - messages - }); - } - - return compat; -} - export default { IS_READ_ONLY: true, parseCommand(parser: CommandParser, streams: XReadStreams, options?: XReadOptions) { diff --git a/packages/client/lib/commands/XREADGROUP.ts b/packages/client/lib/commands/XREADGROUP.ts index 6058c84d069..7ff9b9c6460 100644 --- a/packages/client/lib/commands/XREADGROUP.ts +++ b/packages/client/lib/commands/XREADGROUP.ts @@ -1,7 +1,7 @@ import { CommandParser } from '../client/parser'; -import { Command, RedisArgument, ReplyUnion } from '../RESP/types'; +import { Command, RedisArgument } from '../RESP/types'; import { XReadStreams, pushXReadStreams } from './XREAD'; -import { transformStreamsMessagesReplyResp2, transformStreamsMessagesReplyResp3 } from './generic-transformers'; +import { transformStreamsMessagesReplyResp2, transformStreamsMessagesReplyResp3Compat } from './generic-transformers'; /** * Options for the XREADGROUP command @@ -18,44 +18,6 @@ export interface XReadGroupOptions { CLAIM?: number; } -function transformStreamsMessagesReplyResp3Compat(reply: ReplyUnion) { - const transformed = transformStreamsMessagesReplyResp3(reply as unknown as Parameters[0]); - if (transformed === null) return null; - - const compat = []; - - if (transformed instanceof Map) { - for (const [name, messages] of transformed.entries()) { - compat.push({ - name, - messages - }); - } - - return compat; - } - - if (Array.isArray(transformed)) { - for (let i = 0; i < transformed.length; i += 2) { - compat.push({ - name: transformed[i], - messages: transformed[i + 1] - }); - } - - return compat; - } - - for (const [name, messages] of Object.entries(transformed)) { - compat.push({ - name, - messages - }); - } - - return compat; -} - export default { IS_READ_ONLY: true, parseCommand( diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index 7a43e13df14..a1436b17a50 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -1,6 +1,6 @@ import { BasicCommandParser, CommandParser } from '../client/parser'; import { RESP_TYPES } from '../RESP/decoder'; -import { UnwrapReply, ArrayReply, BlobStringReply, BooleanReply, CommandArguments, DoubleReply, NullReply, NumberReply, RedisArgument, TuplesReply, MapReply, TypeMapping, Command } from '../RESP/types'; +import { UnwrapReply, ArrayReply, BlobStringReply, BooleanReply, CommandArguments, DoubleReply, NullReply, NumberReply, RedisArgument, ReplyUnion, TuplesReply, MapReply, TypeMapping, Command } from '../RESP/types'; export function isNullReply(reply: unknown): reply is NullReply { return reply === null; @@ -684,6 +684,44 @@ export function transformStreamsMessagesReplyResp3(reply: UnwrapReply[0]); + if (transformed === null) return null; + + const compat = []; + + if (transformed instanceof Map) { + for (const [name, messages] of transformed.entries()) { + compat.push({ + name, + messages + }); + } + + return compat; + } + + if (Array.isArray(transformed)) { + for (let i = 0; i < transformed.length; i += 2) { + compat.push({ + name: transformed[i], + messages: transformed[i + 1] + }); + } + + return compat; + } + + for (const [name, messages] of Object.entries(transformed)) { + compat.push({ + name, + messages + }); + } + + return compat; +} + export type RedisJSON = null | boolean | number | string | Date | Array | { [key: string]: RedisJSON; [key: number]: RedisJSON; diff --git a/packages/search/lib/commands/AGGREGATE.spec.ts b/packages/search/lib/commands/AGGREGATE.spec.ts index e033962444b..f1ce5e07b07 100644 --- a/packages/search/lib/commands/AGGREGATE.spec.ts +++ b/packages/search/lib/commands/AGGREGATE.spec.ts @@ -2,6 +2,7 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; import AGGREGATE from './AGGREGATE'; import { parseArgs } from '@redis/client/lib/commands/generic-transformers'; +import { RESP_TYPES } from '@redis/client'; import { DEFAULT_DIALECT } from '../dialect/default'; describe('AGGREGATE', () => { @@ -554,4 +555,51 @@ describe('AGGREGATE', () => { assert.ok(Array.isArray(reply.results)); assert.ok(reply.results.length > 0); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.ft.aggregate with RESP_TYPES.MAP: Array typeMapping', async client => { + await client.ft.create('index', { + field: 'NUMERIC' + }); + await client.hSet('1', 'field', '1'); + await client.hSet('2', 'field', '2'); + + const reply = await client.ft.aggregate('index', '*', { + STEPS: [{ + type: 'GROUPBY', + REDUCE: [{ + type: 'SUM', + property: '@field', + AS: 'sum' + }, { + type: 'AVG', + property: '@field', + AS: 'avg' + }] + }] + }); + + assert.strictEqual(reply.total, 1); + assert.ok(Array.isArray(reply.results)); + assert.strictEqual(reply.results.length, 1); + + const firstResult = reply.results[0] as unknown as Array; + assert.ok(Array.isArray(firstResult), 'each result row should be a flat [k,v,...] array under MAP=Array'); + + const obj: Record = {}; + for (let i = 0; i < firstResult.length; i += 2) { + obj[String(firstResult[i])] = firstResult[i + 1]; + } + assert.strictEqual(String(obj.sum), '3'); + assert.strictEqual(String(obj.avg), '1.5'); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + ...GLOBAL.SERVERS.OPEN.clientOptions, + commandOptions: { + typeMapping: { + [RESP_TYPES.MAP]: Array + } + } + } + }); }); diff --git a/packages/search/lib/commands/AGGREGATE.ts b/packages/search/lib/commands/AGGREGATE.ts index 690e533fad6..3a9774d50f2 100644 --- a/packages/search/lib/commands/AGGREGATE.ts +++ b/packages/search/lib/commands/AGGREGATE.ts @@ -168,10 +168,6 @@ function transformAggregateReplyResp3( preserve?: any, typeMapping?: TypeMapping ): AggregateReply { - if (Array.isArray(rawReply)) { - return transformAggregateReplyResp2(rawReply as unknown as AggregateRawReply, preserve, typeMapping); - } - const reply = mapLikeToObject(rawReply); const total = Number(getMapValue(reply, ['total_results', 'total']) ?? 0); const rawResults = mapLikeValues(getMapValue(reply, ['results']) ?? []); diff --git a/packages/search/lib/commands/HYBRID.spec.ts b/packages/search/lib/commands/HYBRID.spec.ts index 752d2a74136..0ff56588095 100644 --- a/packages/search/lib/commands/HYBRID.spec.ts +++ b/packages/search/lib/commands/HYBRID.spec.ts @@ -930,6 +930,120 @@ describe("FT.HYBRID", () => { }); }); + describe("transformReply", () => { + const jsonPayload = '{"description":"red shoes","color":"red","price":15}'; + + it("RESP2: parses $ inside extra_attributes (JSON-on schema)", () => { + const reply = [ + "total_results", 1, + "results", [ + [ + "id", "item:0", + "extra_attributes", ["$", jsonPayload], + "values", [] + ] + ], + "warnings", [], + "execution_time", "0.5" + ]; + + const result = HYBRID.transformReply[2](reply); + + assert.strictEqual(result.totalResults, 1); + assert.strictEqual(result.executionTime, 0.5); + assert.deepStrictEqual(result.warnings, []); + assert.strictEqual(result.results.length, 1); + assert.deepStrictEqual(result.results[0], { + id: "item:0", + description: "red shoes", + color: "red", + price: 15 + }); + }); + + it("RESP3 (object form): parses $ inside extra_attributes", () => { + const reply = { + total_results: 1, + results: [ + { + id: "item:0", + extra_attributes: { $: jsonPayload }, + values: [] + } + ], + warnings: [], + execution_time: 0.5 + }; + + const result = HYBRID.transformReply[3](reply); + + assert.strictEqual(result.totalResults, 1); + assert.strictEqual(result.executionTime, 0.5); + assert.deepStrictEqual(result.warnings, []); + assert.strictEqual(result.results.length, 1); + assert.deepStrictEqual(result.results[0], { + id: "item:0", + description: "red shoes", + color: "red", + price: 15 + }); + }); + + it("RESP3 (Map form): parses $ inside extra_attributes", () => { + const reply = new Map([ + ["total_results", 1], + [ + "results", + [ + new Map([ + ["id", "item:0"], + [ + "extra_attributes", + new Map([["$", jsonPayload]]) + ], + ["values", []] + ]) + ] + ], + ["warnings", []], + ["execution_time", 0.5] + ]); + + const result = HYBRID.transformReply[3](reply); + + assert.strictEqual(result.totalResults, 1); + assert.strictEqual(result.executionTime, 0.5); + assert.deepStrictEqual(result.warnings, []); + assert.strictEqual(result.results.length, 1); + assert.deepStrictEqual(result.results[0], { + id: "item:0", + description: "red shoes", + color: "red", + price: 15 + }); + }); + + it("RESP2: malformed $ payload falls back to raw value", () => { + const reply = [ + "total_results", 1, + "results", [ + [ + "id", "item:0", + "extra_attributes", ["$", "not json"] + ] + ], + "warnings", [], + "execution_time", "0" + ]; + + const result = HYBRID.transformReply[2](reply); + + assert.strictEqual(result.results.length, 1); + assert.strictEqual(result.results[0].id, "item:0"); + assert.strictEqual(result.results[0].$, "not json"); + }); + }); + describe("client.ft.create", () => { testUtils.testWithClientIfVersionWithinRange( [[8, 6], "LATEST"], diff --git a/packages/test-utils/lib/index.ts b/packages/test-utils/lib/index.ts index df242321661..e97260a0fa7 100644 --- a/packages/test-utils/lib/index.ts +++ b/packages/test-utils/lib/index.ts @@ -830,11 +830,7 @@ export default class TestUtils { if (options.skipTest) return this.skip(); if (!dockersPromise) return this.skip(); const RESP = (options.clusterConfiguration?.RESP ?? 3) as RESP; - const { - RESP: _RESP, - minimizeConnections = false, - ...clusterConfiguration - } = options.clusterConfiguration ?? {}; + const { RESP: _RESP, ...clusterConfiguration } = options.clusterConfiguration ?? {}; const dockers = await dockersPromise, cluster = createCluster({ @@ -844,7 +840,7 @@ export default class TestUtils { } })), RESP, - minimizeConnections, + minimizeConnections: options.clusterConfiguration?.minimizeConnections ?? true, ...clusterConfiguration }) as RedisClusterType; From 9b6696e18baa6d1b848d70240f65e8e013722243 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 14 May 2026 12:45:58 +0300 Subject: [PATCH 10/28] refactor(client): introduce DEFAULT_RESP for runtime fallbacks Replace hardcoded `?? 3` defaults with a named DEFAULT_RESP constant exported from @redis/client. Covers all runtime fallback sites in client/, sentinel/, cluster/, commander, legacy-mode, and the test-utils helpers. TS generic defaults, JSDoc examples, and explicit per-test RESP pins are intentionally left as literals. --- packages/client/index.ts | 1 + packages/client/lib/RESP/types.ts | 2 ++ .../lib/client/enterprise-maintenance-manager.ts | 4 ++-- packages/client/lib/client/index.ts | 10 +++++----- packages/client/lib/client/legacy-mode.ts | 4 ++-- packages/client/lib/cluster/cluster-slots.ts | 4 ++-- packages/client/lib/commander.ts | 4 ++-- packages/client/lib/sentinel/index.ts | 4 ++-- packages/client/lib/sentinel/test-util.ts | 4 ++-- packages/test-utils/lib/index.ts | 11 ++++++----- 10 files changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/client/index.ts b/packages/client/index.ts index ffaf08d1992..c75fd8aa922 100644 --- a/packages/client/index.ts +++ b/packages/client/index.ts @@ -6,6 +6,7 @@ export { RedisScripts, RespVersions, TypeMapping, + DEFAULT_RESP, } from './lib/RESP/types'; export { RESP_TYPES } from './lib/RESP/decoder'; export { VerbatimString } from './lib/RESP/verbatim-string'; diff --git a/packages/client/lib/RESP/types.ts b/packages/client/lib/RESP/types.ts index c77a3617006..225f89c7e0b 100644 --- a/packages/client/lib/RESP/types.ts +++ b/packages/client/lib/RESP/types.ts @@ -367,6 +367,8 @@ export type Resp2Reply = ( export type RespVersions = 2 | 3; +export const DEFAULT_RESP: RespVersions = 3; + export type CommandReply< COMMAND extends Command, RESP extends RespVersions diff --git a/packages/client/lib/client/enterprise-maintenance-manager.ts b/packages/client/lib/client/enterprise-maintenance-manager.ts index 7fc4cfae879..ddeb964f722 100644 --- a/packages/client/lib/client/enterprise-maintenance-manager.ts +++ b/packages/client/lib/client/enterprise-maintenance-manager.ts @@ -6,7 +6,7 @@ import assert from "node:assert"; import { setTimeout } from "node:timers/promises"; import { RedisTcpSocketOptions } from "./socket"; import diagnostics_channel from "node:diagnostics_channel"; -import { RedisArgument } from "../RESP/types"; +import { RedisArgument, DEFAULT_RESP } from "../RESP/types"; import { publish, CHANNELS } from "./tracing"; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance markers for RedisClient generics @@ -82,7 +82,7 @@ export default class EnterpriseMaintenanceManager { static setupDefaultMaintOptions(options: RedisClientOptions) { if (options.maintNotifications === undefined) { options.maintNotifications = - (options?.RESP ?? 3) === 3 ? "auto" : "disabled"; + (options?.RESP ?? DEFAULT_RESP) === 3 ? "auto" : "disabled"; } if (options.maintEndpointType === undefined) { options.maintEndpointType = "auto"; diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index 530e69cc241..c87efaf894f 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -8,7 +8,7 @@ import { ClientClosedError, ClientOfflineError, DisconnectsClientError, WatchErr import { URL } from 'node:url'; import { TcpSocketConnectOpts } from 'node:net'; import { PUBSUB_TYPE, PubSubType, PubSubListener, PubSubTypeListeners, ChannelListeners } from './pub-sub'; -import { Command, CommandSignature, TypeMapping, CommanderConfig, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions, RedisArgument, ReplyWithTypeMapping, SimpleStringReply, TransformReply, CommandArguments } from '../RESP/types'; +import { Command, CommandSignature, TypeMapping, CommanderConfig, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions, RedisArgument, ReplyWithTypeMapping, SimpleStringReply, TransformReply, CommandArguments, DEFAULT_RESP } from '../RESP/types'; import RedisClientMultiCommand, { RedisClientMultiCommandType } from './multi-command'; import { MULTI_MODE, MultiMode, RedisMultiQueuedCommand } from '../multi-command'; import HELLO, { HelloOptions } from '../commands/HELLO'; @@ -693,7 +693,7 @@ export default class RedisClient< } #validateOptions(options?: RedisClientOptions) { - const resp = options?.RESP ?? 3; + const resp = options?.RESP ?? DEFAULT_RESP; if (options?.clientSideCache && resp !== 3) { throw new Error('Client Side Caching is only supported with RESP3'); } @@ -747,7 +747,7 @@ export default class RedisClient< #initiateQueue(clientId: string): RedisCommandsQueue { return new RedisCommandsQueue( - this.#options.RESP ?? 3, + this.#options.RESP ?? DEFAULT_RESP, this.#options.commandsQueueMaxLength, (channel, listeners) => this.emit('sharded-channel-moved', channel, listeners), clientId @@ -759,7 +759,7 @@ export default class RedisClient< */ private reAuthenticate = async (credentials: BasicAuth) => { // Re-authentication is not supported on RESP2 with PubSub active - if (!(this.isPubSubActive && (this.#options.RESP ?? 3) === 2)) { + if (!(this.isPubSubActive && (this.#options.RESP ?? DEFAULT_RESP) === 2)) { await this.sendCommand( parseArgs(COMMANDS.AUTH, { username: credentials.username, @@ -809,7 +809,7 @@ export default class RedisClient< > { const commands = []; const cp = this.#options.credentialsProvider; - const resp = this.#options.RESP ?? 3; + const resp = this.#options.RESP ?? DEFAULT_RESP; if (resp !== 2) { const hello: HelloOptions = {}; diff --git a/packages/client/lib/client/legacy-mode.ts b/packages/client/lib/client/legacy-mode.ts index 61ccd937664..8ed05ef6789 100644 --- a/packages/client/lib/client/legacy-mode.ts +++ b/packages/client/lib/client/legacy-mode.ts @@ -1,4 +1,4 @@ -import { RedisModules, RedisFunctions, RedisScripts, RespVersions, Command, CommandArguments, ReplyUnion } from '../RESP/types'; +import { RedisModules, RedisFunctions, RedisScripts, RespVersions, Command, CommandArguments, ReplyUnion, DEFAULT_RESP } from '../RESP/types'; import { RedisClientType } from '.'; import { getTransformReply } from '../commander'; import { ErrorReply } from '../errors'; @@ -81,7 +81,7 @@ export class RedisLegacyClient { ) { this.#client = client; - const RESP = client.options?.RESP ?? 3; + const RESP = client.options?.RESP ?? DEFAULT_RESP; for (const [name, command] of Object.entries(COMMANDS)) { // TODO: as any? // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic command attachment diff --git a/packages/client/lib/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index f4ed96acb14..907a6b378c0 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -3,7 +3,7 @@ import { RootNodesUnavailableError } from '../errors'; import RedisClient, { RedisClientOptions, RedisClientType } from '../client'; import { EventEmitter } from 'node:stream'; import { ChannelListeners, PUBSUB_TYPE, PubSubListeners, PubSubTypeListeners } from '../client/pub-sub'; -import { RedisArgument, RedisFunctions, RedisModules, RedisScripts, RespVersions, TypeMapping } from '../RESP/types'; +import { RedisArgument, RedisFunctions, RedisModules, RedisScripts, RespVersions, TypeMapping, DEFAULT_RESP } from '../RESP/types'; import calculateSlot from 'cluster-key-slot'; import { RedisSocketOptions } from '../client/socket'; import { BasicPooledClientSideCache, PooledClientSideCacheProvider } from '../client/cache'; @@ -130,7 +130,7 @@ export default class RedisClusterSlots< } #validateOptions(options?: RedisClusterOptions) { - if (options?.clientSideCache && (options?.RESP ?? 3) !== 3) { + if (options?.clientSideCache && (options?.RESP ?? DEFAULT_RESP) !== 3) { throw new Error('Client Side Caching is only supported with RESP3'); } } diff --git a/packages/client/lib/commander.ts b/packages/client/lib/commander.ts index 22c29e0cc18..3370a0f3cec 100644 --- a/packages/client/lib/commander.ts +++ b/packages/client/lib/commander.ts @@ -1,4 +1,4 @@ -import { Command, CommanderConfig, RedisArgument, RedisCommands, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, RespVersions, TransformReply } from './RESP/types'; +import { Command, CommanderConfig, RedisArgument, RedisCommands, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, RespVersions, TransformReply, DEFAULT_RESP } from './RESP/types'; interface AttachConfigOptions< M extends RedisModules, @@ -34,7 +34,7 @@ export function attachConfig< createScriptCommand, config }: AttachConfigOptions) { - const RESP = config?.RESP ?? 3, + const RESP = config?.RESP ?? DEFAULT_RESP, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic prototype patching Class: any = class extends BaseClass {}; diff --git a/packages/client/lib/sentinel/index.ts b/packages/client/lib/sentinel/index.ts index 761f7135c8b..f886ab575a9 100644 --- a/packages/client/lib/sentinel/index.ts +++ b/packages/client/lib/sentinel/index.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events'; -import { CommandArguments, RedisFunctions, RedisModules, RedisScripts, ReplyUnion, RespVersions, TypeMapping } from '../RESP/types'; +import { CommandArguments, RedisFunctions, RedisModules, RedisScripts, ReplyUnion, RespVersions, TypeMapping, DEFAULT_RESP } from '../RESP/types'; import RedisClient, { RedisClientOptions, RedisClientType } from '../client'; import { CommandOptions } from '../client/commands-queue'; import { attachConfig } from '../commander'; @@ -716,7 +716,7 @@ export class RedisSentinelInternal< } #validateOptions(options?: RedisSentinelOptions) { - if (options?.clientSideCache && (options?.RESP ?? 3) !== 3) { + if (options?.clientSideCache && (options?.RESP ?? DEFAULT_RESP) !== 3) { throw new Error('Client Side Caching is only supported with RESP3'); } } diff --git a/packages/client/lib/sentinel/test-util.ts b/packages/client/lib/sentinel/test-util.ts index 69463e07d82..5328cf2abc8 100644 --- a/packages/client/lib/sentinel/test-util.ts +++ b/packages/client/lib/sentinel/test-util.ts @@ -6,7 +6,7 @@ import { exec } from 'node:child_process'; import { RedisSentinelOptions, RedisSentinelType } from './types'; import RedisClient from '../client'; import RedisSentinel from '.'; -import { RedisFunctions, RedisModules, RedisScripts, RespVersions, TypeMapping } from '../RESP/types'; +import { RedisFunctions, RedisModules, RedisScripts, RespVersions, TypeMapping, DEFAULT_RESP } from '../RESP/types'; const execAsync = promisify(exec); import RedisSentinelModule from './module' import TestUtils from '@redis/test-utils'; @@ -193,7 +193,7 @@ export class SentinelFramework extends DockerBase { throw new Error("cannot specify sentinel db name here"); } - const { RESP = 3, ...sentinelOptions } = opts ?? {}; + const { RESP = DEFAULT_RESP, ...sentinelOptions } = opts ?? {}; const options: RedisSentinelOptions = { ...sentinelOptions, RESP, diff --git a/packages/test-utils/lib/index.ts b/packages/test-utils/lib/index.ts index e97260a0fa7..db52269810a 100644 --- a/packages/test-utils/lib/index.ts +++ b/packages/test-utils/lib/index.ts @@ -16,7 +16,8 @@ import { createClientPool, createCluster, RedisClusterOptions, - RedisClusterType + RedisClusterType, + DEFAULT_RESP } from '@redis/client/index'; import { RedisNode } from '@redis/client/lib/sentinel/types' import { spawnRedisServer, spawnRedisCluster, spawnRedisSentinel, RedisServerDockerOptions, RedisServerDocker, spawnSentinelNode, spawnRedisServerDocker, spawnTlsRedisServer, TlsConfig, spawnProxiedRedisServer } from './dockers'; @@ -531,7 +532,7 @@ export default class TestUtils { it(title, async function () { if (!spawnPromise) return this.skip(); const { apiPort } = await spawnPromise; - const RESP = (options.clusterConfiguration?.RESP ?? 3) as RESP; + const RESP = (options.clusterConfiguration?.RESP ?? DEFAULT_RESP) as RESP; const { RESP: _RESP, ...clusterConfiguration } = options.clusterConfiguration ?? {}; @@ -652,7 +653,7 @@ export default class TestUtils { host: "127.0.0.1", port: promise.port })); - const { RESP = 3, ...sentinelOptions } = options?.sentinelOptions ?? {}; + const { RESP = DEFAULT_RESP, ...sentinelOptions } = options?.sentinelOptions ?? {}; const sentinel = createSentinel({ @@ -829,7 +830,7 @@ export default class TestUtils { it(title, async function () { if (options.skipTest) return this.skip(); if (!dockersPromise) return this.skip(); - const RESP = (options.clusterConfiguration?.RESP ?? 3) as RESP; + const RESP = (options.clusterConfiguration?.RESP ?? DEFAULT_RESP) as RESP; const { RESP: _RESP, ...clusterConfiguration } = options.clusterConfiguration ?? {}; const dockers = await dockersPromise, @@ -984,7 +985,7 @@ export default class TestUtils { this.timeout(options.testTimeout); } - const RESP = (options.clusterConfiguration?.RESP ?? 3) as RESP; + const RESP = (options.clusterConfiguration?.RESP ?? DEFAULT_RESP) as RESP; const { defaults, RESP: _RESP, ...rest } = options.clusterConfiguration ?? {}; // Wait for database to be fully ready before connecting From 91084aa0a93a73196ed79c7362e8fa848b722132 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 14 May 2026 13:19:15 +0300 Subject: [PATCH 11/28] fix(client): forward typeMapping through stream message transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transformStreamsMessagesReplyResp3Compat now matches the TransformReply contract and propagates typeMapping into the inner Resp3 transform. transformStreamsMessagesReplyResp3 accepts and forwards typeMapping to transformStreamMessagesReply in every branch, and the V4-compat path in transformStreamsMessagesReplyResp2 does the same — so XREAD/XREADGROUP message bodies now honor RESP_TYPES.MAP like XRANGE does. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/commands/generic-transformers.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index a1436b17a50..2401d6ea680 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -638,7 +638,7 @@ export function transformStreamsMessagesReplyResp2( ret.push({ name: stream[0], - messages: transformStreamMessagesReply(stream[1]) + messages: transformStreamMessagesReply(stream[1], typeMapping) }); } @@ -649,7 +649,10 @@ export function transformStreamsMessagesReplyResp2( type StreamsMessagesRawReply3 = MapReply>; -export function transformStreamsMessagesReplyResp3(reply: UnwrapReply): MapReply | NullReply { +export function transformStreamsMessagesReplyResp3( + reply: UnwrapReply, + typeMapping?: TypeMapping +): MapReply | NullReply { if (reply === null) return null as unknown as NullReply; if (reply instanceof Map) { @@ -658,7 +661,7 @@ export function transformStreamsMessagesReplyResp3(reply: UnwrapReply; - ret.set(name.toString(), transformStreamMessagesReply(rawMessages)); + ret.set(name.toString(), transformStreamMessagesReply(rawMessages, typeMapping)); } return ret as unknown as MapReply @@ -670,22 +673,30 @@ export function transformStreamsMessagesReplyResp3(reply: UnwrapReply; ret.push(name); - ret.push(transformStreamMessagesReply(rawMessages)); + ret.push(transformStreamMessagesReply(rawMessages, typeMapping)); } return ret as unknown as MapReply } else { const ret: Record = {}; for (const [name, rawMessages] of Object.entries(reply)) { - ret[name] = transformStreamMessagesReply(rawMessages); + ret[name] = transformStreamMessagesReply(rawMessages, typeMapping); } return ret as unknown as MapReply } } -export function transformStreamsMessagesReplyResp3Compat(reply: ReplyUnion) { - const transformed = transformStreamsMessagesReplyResp3(reply as unknown as Parameters[0]); +export function transformStreamsMessagesReplyResp3Compat( + reply: ReplyUnion, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract + preserve?: any, + typeMapping?: TypeMapping +) { + const transformed = transformStreamsMessagesReplyResp3( + reply as unknown as Parameters[0], + typeMapping + ); if (transformed === null) return null; const compat = []; From 9b47f14ba90203007db4b754619ea85ff2ff22e8 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 15 May 2026 17:44:08 +0300 Subject: [PATCH 12/28] fix(search): forward typeMapping through composed RESP3 transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FT.SEARCH, FT.SEARCH NOCONTENT, FT.PROFILE SEARCH/AGGREGATE, FT.AGGREGATE WITHCURSOR and FT.HYBRID wrap inner transforms but their signatures previously took only (reply), silently dropping typeMapping on the way through. Extend each transformReply to the full TransformReply contract and forward preserve/typeMapping into the inner SEARCH/AGGREGATE transforms — the same shape as 25b36fb for streams — so RESP_TYPES.MAP (and other typeMappings) now propagate end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/commands/AGGREGATE_WITHCURSOR.ts | 22 ++++++++++++++----- packages/search/lib/commands/HYBRID.ts | 17 ++++++++++++-- .../search/lib/commands/PROFILE_AGGREGATE.ts | 22 ++++++++++++++----- .../search/lib/commands/PROFILE_SEARCH.ts | 22 ++++++++++++++----- packages/search/lib/commands/SEARCH.ts | 19 ++++++++++++---- .../search/lib/commands/SEARCH_NOCONTENT.ts | 13 +++++++---- 6 files changed, 89 insertions(+), 26 deletions(-) diff --git a/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts b/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts index 02de8bfb56f..e8c85c6f16b 100644 --- a/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts +++ b/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts @@ -1,5 +1,5 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; -import { RedisArgument, Command, ReplyUnion, NumberReply } from '@redis/client/dist/lib/RESP/types'; +import { RedisArgument, Command, ReplyUnion, NumberReply, TypeMapping } from '@redis/client/dist/lib/RESP/types'; import AGGREGATE, { AggregateRawReply, AggregateReply, FtAggregateOptions } from './AGGREGATE'; import { getMapValue, mapLikeToObject } from './reply-transformers'; @@ -18,10 +18,15 @@ export interface AggregateWithCursorReply extends AggregateReply { cursor: NumberReply; } -function transformAggregateWithCursorReplyResp3(reply: ReplyUnion): AggregateWithCursorReply { +function transformAggregateWithCursorReplyResp3( + reply: ReplyUnion, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract + preserve?: any, + typeMapping?: TypeMapping +): AggregateWithCursorReply { if (Array.isArray(reply)) { return { - ...(AGGREGATE.transformReply[3](reply[0] as ReplyUnion) as AggregateReply), + ...(AGGREGATE.transformReply[3](reply[0] as ReplyUnion, preserve, typeMapping) as AggregateReply), cursor: reply[1] as NumberReply }; } @@ -30,7 +35,7 @@ function transformAggregateWithCursorReplyResp3(reply: ReplyUnion): AggregateWit const rawResult = getMapValue(mappedReply, ['results', 'result']) ?? mappedReply; return { - ...(AGGREGATE.transformReply[3](rawResult as ReplyUnion) as AggregateReply), + ...(AGGREGATE.transformReply[3](rawResult as ReplyUnion, preserve, typeMapping) as AggregateReply), cursor: (getMapValue(mappedReply, ['cursor']) ?? 0) as NumberReply }; } @@ -50,9 +55,14 @@ export default { } }, transformReply: { - 2: (reply: AggregateWithCursorRawReply): AggregateWithCursorReply => { + 2: ( + reply: AggregateWithCursorRawReply, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract + preserve?: any, + typeMapping?: TypeMapping + ): AggregateWithCursorReply => { return { - ...AGGREGATE.transformReply[2](reply[0]), + ...AGGREGATE.transformReply[2](reply[0], preserve, typeMapping), cursor: reply[1] }; }, diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 7cd9a889954..4cfe2bab099 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -2,6 +2,7 @@ import { CommandParser } from "@redis/client/dist/lib/client/parser"; import { RedisArgument, Command, + TypeMapping, } from "@redis/client/dist/lib/RESP/types"; import { RedisVariadicArgument, @@ -407,10 +408,22 @@ export default { parseHybridOptions(parser, options); }, transformReply: { - 2: (reply: unknown): HybridSearchResult => { + 2: ( + reply: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract + _preserve?: any, + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- matches TransformReply contract + _typeMapping?: TypeMapping + ): HybridSearchResult => { return transformHybridSearchResults(reply); }, - 3: (reply: unknown): HybridSearchResult => { + 3: ( + reply: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract + _preserve?: any, + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- matches TransformReply contract + _typeMapping?: TypeMapping + ): HybridSearchResult => { return transformHybridSearchResults(reply); }, }, diff --git a/packages/search/lib/commands/PROFILE_AGGREGATE.ts b/packages/search/lib/commands/PROFILE_AGGREGATE.ts index 86cb9215291..9646df2b6e9 100644 --- a/packages/search/lib/commands/PROFILE_AGGREGATE.ts +++ b/packages/search/lib/commands/PROFILE_AGGREGATE.ts @@ -1,5 +1,5 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; -import { Command, ReplyUnion, UnwrapReply } from '@redis/client/dist/lib/RESP/types'; +import { Command, ReplyUnion, TypeMapping, UnwrapReply } from '@redis/client/dist/lib/RESP/types'; import AGGREGATE, { AggregateRawReply, FtAggregateOptions, parseAggregateOptions } from './AGGREGATE'; import { ProfileOptions, @@ -29,16 +29,28 @@ export default { parseAggregateOptions(parser, options) }, transformReply: { - 2: (reply: UnwrapReply>): ProfileReplyResp2 => { + 2: ( + reply: UnwrapReply>, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract + preserve?: any, + typeMapping?: TypeMapping + ): ProfileReplyResp2 => { return { - results: AGGREGATE.transformReply[2](reply[0]), + results: AGGREGATE.transformReply[2](reply[0], preserve, typeMapping), profile: reply[1] } }, - 3: (reply: ReplyUnion): ProfileReplyResp2 => { + 3: ( + reply: ReplyUnion, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract + preserve?: any, + typeMapping?: TypeMapping + ): ProfileReplyResp2 => { return { results: AGGREGATE.transformReply[3]( - extractProfileResultsReply(reply) + extractProfileResultsReply(reply), + preserve, + typeMapping ), profile: transformProfileReply(reply) }; diff --git a/packages/search/lib/commands/PROFILE_SEARCH.ts b/packages/search/lib/commands/PROFILE_SEARCH.ts index 8b7c934e67d..3fcb5ec3968 100644 --- a/packages/search/lib/commands/PROFILE_SEARCH.ts +++ b/packages/search/lib/commands/PROFILE_SEARCH.ts @@ -1,5 +1,5 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; -import { ArrayReply, Command, RedisArgument, ReplyUnion, TuplesReply, UnwrapReply } from '@redis/client/dist/lib/RESP/types'; +import { ArrayReply, Command, RedisArgument, ReplyUnion, TuplesReply, TypeMapping, UnwrapReply } from '@redis/client/dist/lib/RESP/types'; import { AggregateReply } from './AGGREGATE'; import SEARCH, { FtSearchOptions, SearchRawReply, SearchReply, parseSearchOptions } from './SEARCH'; import { getMapValue, mapLikeEntries, mapLikeToObject, normalizeProfileReply } from './reply-transformers'; @@ -78,10 +78,17 @@ export function transformProfileReply(reply: ReplyUnion): ReplyUnion { return normalizeProfileReply(profile) as ReplyUnion; } -function transformProfileSearchReplyResp3(reply: ReplyUnion): ProfileReplyResp2 { +function transformProfileSearchReplyResp3( + reply: ReplyUnion, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract + preserve?: any, + typeMapping?: TypeMapping +): ProfileReplyResp2 { return { results: SEARCH.transformReply[3]( - extractProfileResultsReply(reply) + extractProfileResultsReply(reply), + preserve, + typeMapping ), profile: transformProfileReply(reply) }; @@ -107,9 +114,14 @@ export default { parseSearchOptions(parser, options); }, transformReply: { - 2: (reply: UnwrapReply): ProfileReplyResp2 => { + 2: ( + reply: UnwrapReply, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract + preserve?: any, + typeMapping?: TypeMapping + ): ProfileReplyResp2 => { return { - results: SEARCH.transformReply[2](reply[0]), + results: SEARCH.transformReply[2](reply[0], preserve, typeMapping), profile: reply[1] }; }, diff --git a/packages/search/lib/commands/SEARCH.ts b/packages/search/lib/commands/SEARCH.ts index 9d29d90d0fd..e08afe24fc6 100644 --- a/packages/search/lib/commands/SEARCH.ts +++ b/packages/search/lib/commands/SEARCH.ts @@ -1,5 +1,5 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; -import { RedisArgument, Command, ReplyUnion } from '@redis/client/dist/lib/RESP/types'; +import { RedisArgument, Command, ReplyUnion, TypeMapping } from '@redis/client/dist/lib/RESP/types'; import { RedisVariadicArgument, parseOptionalVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; import { RediSearchLanguage } from './CREATE'; import { DEFAULT_DIALECT } from '../dialect/default'; @@ -159,7 +159,13 @@ export function parseSearchOptions(parser: CommandParser, options?: FtSearchOpti } } -function transformSearchReplyResp2(reply: SearchRawReply): SearchReply { +function transformSearchReplyResp2( + reply: SearchRawReply, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract + _preserve?: any, + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- matches TransformReply contract + _typeMapping?: TypeMapping +): SearchReply { // if reply[2] is array, then we have content/documents. Otherwise, only ids const withoutDocuments = reply.length > 2 && !Array.isArray(reply[2]); @@ -178,9 +184,14 @@ function transformSearchReplyResp2(reply: SearchRawReply): SearchReply { }; } -function transformSearchReplyResp3(rawReply: ReplyUnion): SearchReply { +function transformSearchReplyResp3( + rawReply: ReplyUnion, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract + preserve?: any, + typeMapping?: TypeMapping +): SearchReply { if (Array.isArray(rawReply)) { - return transformSearchReplyResp2(rawReply as SearchRawReply); + return transformSearchReplyResp2(rawReply as SearchRawReply, preserve, typeMapping); } const reply = mapLikeToObject(rawReply); diff --git a/packages/search/lib/commands/SEARCH_NOCONTENT.ts b/packages/search/lib/commands/SEARCH_NOCONTENT.ts index 88927207664..635bf3b0530 100644 --- a/packages/search/lib/commands/SEARCH_NOCONTENT.ts +++ b/packages/search/lib/commands/SEARCH_NOCONTENT.ts @@ -1,11 +1,11 @@ -import { Command, ReplyUnion } from '@redis/client/dist/lib/RESP/types'; +import { Command, ReplyUnion, TypeMapping } from '@redis/client/dist/lib/RESP/types'; import SEARCH, { SearchRawReply } from './SEARCH'; export default { NOT_KEYED_COMMAND: SEARCH.NOT_KEYED_COMMAND, IS_READ_ONLY: SEARCH.IS_READ_ONLY, parseCommand(...args: Parameters) { - SEARCH.parseCommand(...args); + SEARCH.parseCommand(...args); args[0].push('NOCONTENT'); }, transformReply: { @@ -15,8 +15,13 @@ export default { documents: reply.slice(1) as Array } }, - 3: (reply: ReplyUnion): SearchNoContentReply => { - const transformed = SEARCH.transformReply[3](reply) as { + 3: ( + reply: ReplyUnion, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract + preserve?: any, + typeMapping?: TypeMapping + ): SearchNoContentReply => { + const transformed = SEARCH.transformReply[3](reply, preserve, typeMapping) as { total: number; documents: Array<{ id: string; From d8150107062f889c4603277ffd3c269dc3e2942b Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 15 May 2026 17:44:16 +0300 Subject: [PATCH 13/28] fix(search): make toCompatObject properties writable Object.defineProperties without writable: true defaults to writable: false, making search/aggregate result rows silently immutable for callers that mutate them in-place. Add writable: true so the compat shape matches a plain object literal. Drop the matching Object.defineProperties shape from MRANGE_SELECTED_LABELS.spec.ts since the production code doesn't pin that descriptor and the assertion only cares about structural equality. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../search/lib/commands/reply-transformers.ts | 3 +- .../commands/MRANGE_SELECTED_LABELS.spec.ts | 78 ++++++------------- 2 files changed, 26 insertions(+), 55 deletions(-) diff --git a/packages/search/lib/commands/reply-transformers.ts b/packages/search/lib/commands/reply-transformers.ts index fa4f1db3f33..f818600b808 100644 --- a/packages/search/lib/commands/reply-transformers.ts +++ b/packages/search/lib/commands/reply-transformers.ts @@ -43,7 +43,8 @@ export function toCompatObject(value: Record): Record { }) ]); - assert.deepStrictEqual( - reply, - Object.defineProperties({}, { - key: { - configurable: true, - enumerable: true, - value: { - labels: Object.defineProperties({}, { - label: { - configurable: true, - enumerable: true, - value: 'value' - }, - NX: { - configurable: true, - enumerable: true, - value: null - } - }), - samples: [{ - timestamp: 0, - value: 0 - }] - } - } - }) - ); + assert.deepStrictEqual(reply, { + key: { + labels: { + label: 'value', + NX: null + }, + samples: [{ + timestamp: 0, + value: 0 + }] + } + }); }, GLOBAL.SERVERS.OPEN); testUtils.testWithClient('client.ts.mRangeSelectedLabels with data', async client => { @@ -83,32 +68,17 @@ describe('TS.MRANGE_SELECTED_LABELS', () => { // RESP3 returns Map reply (converted to object) with Double values instead of // RESP2's Array reply with Simple string values, and labels as Map instead of Array of pairs - assert.deepStrictEqual( - reply, - Object.defineProperties({}, { - key: { - configurable: true, - enumerable: true, - value: { - labels: Object.defineProperties({}, { - label: { - configurable: true, - enumerable: true, - value: 'value' - }, - NX: { - configurable: true, - enumerable: true, - value: null - } - }), - samples: [{ - timestamp: 0, - value: 0 - }] - } - } - }) - ); + assert.deepStrictEqual(reply, { + key: { + labels: { + label: 'value', + NX: null + }, + samples: [{ + timestamp: 0, + value: 0 + }] + } + }); }, GLOBAL.SERVERS.OPEN); }); From 0e915389acfc9deaa60c276eb23b7f835085929f Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 15 May 2026 17:44:32 +0300 Subject: [PATCH 14/28] fix(client): apply DOUBLE typeMapping in GEOSEARCH_WITH on RESP3 parseDouble short-circuited on typeof value === 'number' before consulting typeMapping, so RESP3 callers asking for RESP_TYPES.DOUBLE: String got raw numbers while RESP2 callers got the string form. Honor the DOUBLE mapping on the number path too so both protocols return the same shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/lib/commands/GEOSEARCH_WITH.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/client/lib/commands/GEOSEARCH_WITH.ts b/packages/client/lib/commands/GEOSEARCH_WITH.ts index de6ffd23f4d..2cf7132f7ae 100644 --- a/packages/client/lib/commands/GEOSEARCH_WITH.ts +++ b/packages/client/lib/commands/GEOSEARCH_WITH.ts @@ -1,5 +1,6 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, ArrayReply, TuplesReply, BlobStringReply, NumberReply, DoubleReply, UnwrapReply, Command, TypeMapping } from '../RESP/types'; +import { RESP_TYPES } from '../RESP/decoder'; import GEOSEARCH, { GeoSearchBy, GeoSearchFrom, GeoSearchOptions } from './GEOSEARCH'; import { transformDoubleReply } from './generic-transformers'; @@ -47,12 +48,18 @@ export default { hashIndex = replyWithSet.has(GEO_REPLY_WITH.HASH) && ++index, coordinatesIndex = replyWithSet.has(GEO_REPLY_WITH.COORDINATES) && ++index; + const doubleMapping = typeMapping ? typeMapping[RESP_TYPES.DOUBLE] : undefined; + const parseDouble = (value: unknown) => { - return ( - typeof value === 'number' ? - value as unknown as DoubleReply : - transformDoubleReply[2](value as BlobStringReply, undefined, typeMapping) - ); + if (typeof value !== 'number') { + return transformDoubleReply[2](value as BlobStringReply, undefined, typeMapping); + } + + if (doubleMapping === String) { + return value.toString() as unknown as DoubleReply; + } + + return value as unknown as DoubleReply; }; return reply.map(raw => { From a4f03906e98515a7c6db86a9e6e2ef6c76d35103 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 15 May 2026 18:15:02 +0300 Subject: [PATCH 15/28] fix(client): harden HOTKEYS GET reply parsing - Tighten the flat-array auto-unwrap to plain-objects only, so Buffers, typed arrays, and Dates no longer trip the single-element heuristic and short-circuit into mapLikeEntries. - Guard map/tuple keys against null/undefined before calling toString(). - Surface non-finite slot values (NaN from unexpected wire shapes) by throwing a TypeError with the offending payload instead of silently emitting NaN-bearing SlotRange objects. - Document that this command returns a fixed-schema DTO and does not honor RESP_TYPES.MAP / other typeMapping entries. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/lib/commands/HOTKEYS_GET.ts | 72 ++++++++++++++------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/packages/client/lib/commands/HOTKEYS_GET.ts b/packages/client/lib/commands/HOTKEYS_GET.ts index 5292c06d5af..ac09bcdcad1 100644 --- a/packages/client/lib/commands/HOTKEYS_GET.ts +++ b/packages/client/lib/commands/HOTKEYS_GET.ts @@ -43,32 +43,46 @@ export interface HotkeysGetReply { byNetBytes?: Array; } +function isPlainObject(value: unknown): value is Record { + return value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Map) && + !ArrayBuffer.isView(value) && + Object.prototype.toString.call(value) === '[object Object]'; +} + +function keyToString(key: unknown): string { + if (key === null || key === undefined) return ''; + return (key as { toString(): string }).toString(); +} + function mapLikeEntries(value: unknown): Array<[string, unknown]> { if (value instanceof Map) { - return Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]); + return Array.from(value.entries(), ([key, entryValue]) => [keyToString(key), entryValue]); } if (Array.isArray(value)) { if ( value.length === 1 && - (Array.isArray(value[0]) || value[0] instanceof Map || (typeof value[0] === 'object' && value[0] !== null)) + (Array.isArray(value[0]) || value[0] instanceof Map || isPlainObject(value[0])) ) { return mapLikeEntries(value[0]); } if (value.every(item => Array.isArray(item) && item.length >= 2)) { - return value.map(item => [item[0].toString(), item[1]]); + return value.map(item => [keyToString(item[0]), item[1]]); } const entries: Array<[string, unknown]> = []; for (let i = 0; i < value.length - 1; i += 2) { - entries.push([value[i].toString(), value[i + 1]]); + entries.push([keyToString(value[i]), value[i + 1]]); } return entries; } - if (value !== null && typeof value === 'object') { - return Object.entries(value as Record); + if (isPlainObject(value)) { + return Object.entries(value); } return []; @@ -77,10 +91,20 @@ function mapLikeEntries(value: unknown): Array<[string, unknown]> { function mapLikeValues(value: unknown): Array { if (Array.isArray(value)) return value; if (value instanceof Map) return [...value.values()]; - if (value !== null && typeof value === 'object') return Object.values(value as Record); + if (isPlainObject(value)) return Object.values(value); return []; } +function toSlotNumber(value: unknown): number { + const slot = Number(value); + if (!Number.isFinite(slot)) { + throw new TypeError( + `HOTKEYS GET: expected slot to be a finite number, got ${JSON.stringify(value)}` + ); + } + return slot; +} + /** * Parse the hotkeys array into HotkeyEntry objects */ @@ -98,33 +122,29 @@ function parseHotkeysList(arr: unknown): Array { */ function parseSlotRanges(arr: unknown): Array { return mapLikeValues(arr).map(range => { - let unwrapped: Array; + let unwrapped: Array; if (Array.isArray(range)) { - unwrapped = range as Array; + unwrapped = range; } else if (range instanceof Map) { - unwrapped = [...range.values()].map(value => Number(value)); - } else if (range !== null && typeof range === 'object') { - const objectRange = range as Record; - const start = Number(objectRange.start ?? objectRange[0]); - const end = Number(objectRange.end ?? objectRange[1] ?? start); + unwrapped = [...range.values()]; + } else if (isPlainObject(range)) { + const start = range.start ?? range[0]; + const end = range.end ?? range[1] ?? start; unwrapped = [start, end]; } else { - const slot = Number(range); - unwrapped = [slot, slot]; + const slot = toSlotNumber(range); + return { start: slot, end: slot }; } if (unwrapped.length === 1) { - // Single slot - start and end are the same - return { - start: Number(unwrapped[0]), - end: Number(unwrapped[0]) - }; + const slot = toSlotNumber(unwrapped[0]); + return { start: slot, end: slot }; } - // Slot range + return { - start: Number(unwrapped[0]), - end: Number(unwrapped[1]) + start: toSlotNumber(unwrapped[0]), + end: toSlotNumber(unwrapped[1]) }; }); } @@ -200,6 +220,10 @@ function transformHotkeysGetReply(reply: unknown | null): HotkeysGetReply | null * - ACTIVE -> returns data (does not stop) * - STOPPED -> returns data * - EMPTY -> returns null + * + * Note: this transform always returns a structured `HotkeysGetReply` DTO and + * does not honor `typeMapping` (e.g. `RESP_TYPES.MAP: Map`/`Array`). The + * server-side payload is treated as a fixed schema, not a generic map. */ export default { NOT_KEYED_COMMAND: true, From aef23991a7c2bae60e870317061968d082e0a68e Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 15 May 2026 18:15:10 +0300 Subject: [PATCH 16/28] fix(client): MODULE LIST preserves unknown keys and avoids undefined casts The previous transform only captured `name`/`ver` and cast missing values to BlobStringReply/NumberReply, producing malformed entries when the server returned modules without those fields. It also discarded extra metadata (`path`, `args`, ...) that Redis exposes today. Switch to a key-preserving transform over Array/Map/object replies and relax the spec's exact-keys assertion accordingly. Type still advertises the canonical name/ver shape, but the returned objects are open to extra properties. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client/lib/commands/MODULE_LIST.spec.ts | 10 +-- packages/client/lib/commands/MODULE_LIST.ts | 61 ++++++++----------- 2 files changed, 28 insertions(+), 43 deletions(-) diff --git a/packages/client/lib/commands/MODULE_LIST.spec.ts b/packages/client/lib/commands/MODULE_LIST.spec.ts index a22d013e7f3..da05e5e5fbe 100644 --- a/packages/client/lib/commands/MODULE_LIST.spec.ts +++ b/packages/client/lib/commands/MODULE_LIST.spec.ts @@ -22,19 +22,15 @@ describe('MODULE LIST', () => { testUtils.testWithClient('client.moduleList - structural assertion', async client => { const reply = await client.moduleList(); - // Strong structural assertion: reply must be an array of objects with exact shape + // Reply must be an array of plain objects with at least name/ver, and may + // expose additional fields the server sends (path, args, ...). assert.ok(Array.isArray(reply)); for (const module of reply) { - // Assert the exact structure: must be a plain object with 'name' and 'ver' properties assert.ok(typeof module === 'object' && module !== null); assert.ok('name' in module && 'ver' in module); assert.equal(typeof module.name, 'string'); assert.equal(typeof module.ver, 'number'); - // Ensure it's a plain object (not a Map or other structure) - assert.ok(!('entries' in module && typeof module.entries === 'function')); - // Check that the object has exactly these two keys - const keys = Object.keys(module).sort(); - assert.deepStrictEqual(keys, ['name', 'ver']); + assert.ok(!(module instanceof Map)); } }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/MODULE_LIST.ts b/packages/client/lib/commands/MODULE_LIST.ts index ada3925f2a7..791c1a53e54 100644 --- a/packages/client/lib/commands/MODULE_LIST.ts +++ b/packages/client/lib/commands/MODULE_LIST.ts @@ -6,50 +6,39 @@ export type ModuleListReply = ArrayReply, NumberReply], ]>>; -function transformModuleReply(moduleReply: unknown) { +function moduleEntries(moduleReply: unknown): Array<[string, unknown]> { if (Array.isArray(moduleReply)) { - let name: BlobStringReply | undefined; - let ver: NumberReply | undefined; - - for (let i = 0; i < moduleReply.length; i += 2) { - const key = moduleReply[i]?.toString(); - if (key === 'name') { - name = moduleReply[i + 1]; - } else if (key === 'ver') { - ver = moduleReply[i + 1]; - } + const entries: Array<[string, unknown]> = []; + for (let i = 0; i + 1 < moduleReply.length; i += 2) { + const key = moduleReply[i]; + if (key === null || key === undefined) continue; + entries.push([(key as { toString(): string }).toString(), moduleReply[i + 1]]); } - - return { - name: name as BlobStringReply, - ver: ver as NumberReply - }; + return entries; } if (moduleReply instanceof Map) { - let name: BlobStringReply | undefined; - let ver: NumberReply | undefined; - - for (const [key, value] of moduleReply.entries()) { - const normalizedKey = key?.toString(); - if (normalizedKey === 'name') { - name = value; - } else if (normalizedKey === 'ver') { - ver = value; - } - } + return Array.from(moduleReply.entries(), ([key, value]) => { + const k = key === null || key === undefined + ? '' + : (key as { toString(): string }).toString(); + return [k, value]; + }); + } - return { - name: name as BlobStringReply, - ver: ver as NumberReply - }; + if (moduleReply !== null && typeof moduleReply === 'object') { + return Object.entries(moduleReply as Record); } - const objectReply = moduleReply as { name: BlobStringReply; ver: NumberReply }; - return { - name: objectReply.name, - ver: objectReply.ver - }; + return []; +} + +function transformModuleReply(moduleReply: unknown) { + const result: Record = {}; + for (const [key, value] of moduleEntries(moduleReply)) { + result[key] = value; + } + return result as { name: BlobStringReply; ver: NumberReply } & Record; } function transformModuleListReply(reply: Array) { From f013889130e6fffcc360f8c507e3b66d55edb0c3 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 15 May 2026 18:15:18 +0300 Subject: [PATCH 17/28] types(time-series): replace `never` metadata slot in MRANGE SELECTED_LABELS RESP3 tuple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The middle tuple cell isn't actually `never` — Redis 7.4 leaves it empty while Redis 8 may populate it with aggregation/reducer metadata. Type it as `unknown` so the tuple shape stays honest with what the wire returns. The transform already discards this cell, so behavior is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts index 7cc05d11ab6..353d23b5b7e 100644 --- a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts +++ b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts @@ -20,7 +20,9 @@ export type TsMRangeSelectedLabelsRawReply3 = MapReply< BlobStringReply, TuplesReply<[ labels: MapReply, - metadata: never, // ?! + // Redis 7.4 leaves this slot empty; Redis 8 may populate it with + // aggregation/reducer metadata. We don't consume it, so accept anything. + metadata: unknown, samples: ArrayReply ]> >; From 6bf1b88790c577aee2b34025f2fe4c40a89c264c Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 15 May 2026 18:15:27 +0300 Subject: [PATCH 18/28] fix(search): avoid "[object Object]" warnings from FT.HYBRID The previous `warnings.map(w => w.toString())` collapsed non-string warning payloads (Map/Array/plain object) into the useless "[object Object]" string. Route warnings through a small helper that keeps strings/Buffers as-is, treats null/undefined as empty, and JSON-serializes everything else so the caller can read what the server actually sent. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/search/lib/commands/HYBRID.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 4cfe2bab099..1612715d599 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -495,7 +495,7 @@ function transformHybridSearchResults(reply: unknown): HybridSearchResult { return { totalResults, executionTime, - warnings: warnings.map(warning => (warning as { toString(): string }).toString()), + warnings: warnings.map(toWarningString), results, }; } @@ -503,3 +503,16 @@ function transformHybridSearchResults(reply: unknown): HybridSearchResult { function parseReplyMap(reply: unknown): Record { return mapLikeToObject(reply); } + +function toWarningString(warning: unknown): string { + if (typeof warning === 'string') return warning; + if (warning instanceof Buffer) return warning.toString(); + if (warning === null || warning === undefined) return ''; + // Anything else (Map/Array/plain object) would collapse to "[object Object]" + // under a naive toString — JSON-serialize instead so the caller can read it. + try { + return JSON.stringify(warning); + } catch { + return String(warning); + } +} From 9c8648c4c02f4e9b26019b914e3fae457b9754d7 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Fri, 15 May 2026 18:15:36 +0300 Subject: [PATCH 19/28] fix(client): normalize stream name in Resp3Compat Array branch and document shape - transformStreamsMessagesReplyResp3Compat's Array branch was packing the raw BlobStringReply as `name` without normalizing through toString(); callers reading the compat shape would get a Buffer when decoding is byte-oriented. Stringify the name in that branch to match the Map/Object branches. - Add a JSDoc explaining that the Compat suffix intentionally returns a flat Array<{ name, messages }> regardless of RESP_TYPES.MAP, and that `typeMapping` is still forwarded to the inner message-body transform. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client/lib/commands/generic-transformers.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index 2401d6ea680..badc580c125 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -687,6 +687,16 @@ export function transformStreamsMessagesReplyResp3( } } +/** + * v4/v5-compatible XREAD/XREADGROUP shape: a flat + * `Array<{ name, messages }>` regardless of `typeMapping[RESP_TYPES.MAP]`. + * + * The outer container is intentionally normalized; `typeMapping` is still + * forwarded to the inner message-body transform so individual stream messages + * honor `RESP_TYPES.MAP` (e.g. `XRANGE`-style body shape). If you want the + * outer container itself as a `Map`/`Array`/`Object`, use the non-compat + * `transformStreamsMessagesReplyResp3` directly. + */ export function transformStreamsMessagesReplyResp3Compat( reply: ReplyUnion, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract @@ -714,8 +724,9 @@ export function transformStreamsMessagesReplyResp3Compat( if (Array.isArray(transformed)) { for (let i = 0; i < transformed.length; i += 2) { + const rawName = transformed[i] as unknown as UnwrapReply; compat.push({ - name: transformed[i], + name: rawName?.toString?.() ?? rawName, messages: transformed[i + 1] }); } From d49fa6f86466b8d19de2c4b123b00b5ee36f0f75 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 18 May 2026 11:32:58 +0300 Subject: [PATCH 20/28] docs(migration): call out legacy callback mode now defaulting to RESP3 Legacy callback consumers inherit the v6 RESP3 default, so reply shapes can differ from v5 for commands whose transforms diverge between protocol versions. Document the workaround: pin RESP: 2 on the parent client before calling .legacy(). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v5-to-v6.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/v5-to-v6.md b/docs/v5-to-v6.md index 4de613d7985..7d3bf3d225c 100644 --- a/docs/v5-to-v6.md +++ b/docs/v5-to-v6.md @@ -58,6 +58,14 @@ Commands affected: Additionally, RESP3 map decoding now creates plain objects by default, so commands that expose raw RESP3 maps as JS objects inherit the same prototype change. +## Legacy (callback) mode now uses RESP3 + +`createClient().legacy()` reads the parent client's RESP version. With the v6 default of RESP3, legacy callback consumers will see RESP3-shaped replies for any command whose transforms differ between protocol versions (for example, doubles arriving as `number` instead of `string`, or hash-like replies arriving as `Map`s). To keep the v5 callback reply shapes, pin `RESP: 2` on the parent client: + +```javascript +const legacy = createClient({ RESP: 2 }).legacy(); +``` + ## If you need to preserve v5 default behavior while migrating, pin RESP2 explicitly: From 75604f10105d37eac0389f8c841035e8cebafb52 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 18 May 2026 11:34:54 +0300 Subject: [PATCH 21/28] docs(migration): add XRANGE/XREVRANGE/XCLAIM/XAUTOCLAIM to prototype list These four stream commands route their message bodies through transformStreamMessageReply -> transformTuplesReply, the same plain-object normalization path already documented for XREAD/XREADGROUP. List them explicitly so readers can identify affected reply shapes without tracing the transform graph. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v5-to-v6.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/v5-to-v6.md b/docs/v5-to-v6.md index 7d3bf3d225c..fc98f613aa5 100644 --- a/docs/v5-to-v6.md +++ b/docs/v5-to-v6.md @@ -50,7 +50,7 @@ Compatibility impact: this can be technically breaking for code/tests that asser Commands affected: -- `@redis/client`: `CONFIG GET`, `FUNCTION STATS`, `HGETALL`, `LATENCY HISTOGRAM` (`histogram_usec`), `PUBSUB NUMSUB`, `PUBSUB SHARDNUMSUB`, `VINFO`, `VLINKS WITHSCORES`, `XINFO STREAM` (entry message objects), `XREAD`/`XREADGROUP` (message objects) +- `@redis/client`: `CONFIG GET`, `FUNCTION STATS`, `HGETALL`, `LATENCY HISTOGRAM` (`histogram_usec`), `PUBSUB NUMSUB`, `PUBSUB SHARDNUMSUB`, `VINFO`, `VLINKS WITHSCORES`, `XINFO STREAM` (entry message objects), `XREAD`/`XREADGROUP` (message objects), `XRANGE`/`XREVRANGE` (message objects), `XCLAIM`/`XAUTOCLAIM` (message objects) - `@redis/search`: `FT.AGGREGATE`, `FT.AGGREGATE WITHCURSOR`, `FT.CURSOR READ`, `FT.CONFIG GET`, `FT.HYBRID`, `FT.INFO`, `FT.SEARCH`, `FT.PROFILE SEARCH`, `FT.PROFILE AGGREGATE` - `@redis/time-series`: `TS.MGET`, `TS.MGET WITHLABELS`, `TS.MGET SELECTED_LABELS`, `TS.MRANGE`, `TS.MREVRANGE`, `TS.MRANGE GROUPBY`, `TS.MREVRANGE GROUPBY`, `TS.MRANGE WITHLABELS`, `TS.MREVRANGE WITHLABELS`, `TS.MRANGE WITHLABELS GROUPBY`, `TS.MREVRANGE WITHLABELS GROUPBY`, `TS.MRANGE SELECTED_LABELS`, `TS.MREVRANGE SELECTED_LABELS`, `TS.MRANGE SELECTED_LABELS GROUPBY`, `TS.MREVRANGE SELECTED_LABELS GROUPBY` - `@redis/bloom`: `BF.INFO`, `CF.INFO`, `CMS.INFO`, `TOPK.INFO`, `TDIGEST.INFO` From 856f101e5be03690eb1ab2b418e5fda64a3a41a4 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 18 May 2026 11:40:03 +0300 Subject: [PATCH 22/28] docs(migration): note removal of unstableResp3/unstableResp3Modules Both options were v5 opt-in gates for in-development RESP3 transforms. They were deleted in the v6 RESP3-default switch because the transforms they gated are now stable. Document the removal so v5 upgraders aren't surprised by the unknown-property TS error. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v5-to-v6.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/v5-to-v6.md b/docs/v5-to-v6.md index fc98f613aa5..f7025e98f8d 100644 --- a/docs/v5-to-v6.md +++ b/docs/v5-to-v6.md @@ -58,6 +58,19 @@ Commands affected: Additionally, RESP3 map decoding now creates plain objects by default, so commands that expose raw RESP3 maps as JS objects inherit the same prototype change. +## `unstableResp3` and `unstableResp3Modules` are removed + +Both options were v5 gates for opt-in access to in-development RESP3 transforms. In v6 those transforms are stable (see the [Stabilized APIs](#stabilized-apis) table above) and the gates have been deleted. If your v5 code passed either option, remove the property — TypeScript will surface it as an unknown-property error on the options literal: + +```javascript +// v5 +const client = createClient({ RESP: 3, unstableResp3: true }); + +// v6 +const client = createClient({ RESP: 3 }); +``` + + ## Legacy (callback) mode now uses RESP3 `createClient().legacy()` reads the parent client's RESP version. With the v6 default of RESP3, legacy callback consumers will see RESP3-shaped replies for any command whose transforms differ between protocol versions (for example, doubles arriving as `number` instead of `string`, or hash-like replies arriving as `Map`s). To keep the v5 callback reply shapes, pin `RESP: 2` on the parent client: From b88f72a5893882581f352411521712e56f3a137d Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 18 May 2026 11:56:04 +0300 Subject: [PATCH 23/28] docs(migration): document maintNotifications default flip to "auto" on RESP3 --- docs/v5-to-v6.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/v5-to-v6.md b/docs/v5-to-v6.md index f7025e98f8d..37015058bbb 100644 --- a/docs/v5-to-v6.md +++ b/docs/v5-to-v6.md @@ -71,6 +71,23 @@ const client = createClient({ RESP: 3 }); ``` +## `maintNotifications` default is now derived from `RESP` + +In v5, `maintNotifications` defaulted to `"disabled"` regardless of protocol. In v6, the default is derived from the negotiated RESP version: `"auto"` when `RESP: 3` (the new default), and `"disabled"` when `RESP: 2`. Because v6 also flips the protocol default to RESP3, clients that don't set `maintNotifications` explicitly now opt into Enterprise maintenance push notifications, the relaxed socket/command timeouts, and endpoint-type negotiation — behavior v5 never enabled by default. + +`"auto"` is the functional on-switch for maintenance handling; it is not a no-op. The client subscribes to maintenance push frames (moving/migrating/failing-over) and adjusts socket/command timeouts during maintenance windows. On non-Enterprise servers this is generally harmless, but it does change topology-change behavior versus v5. + +To keep the v5 default, set `maintNotifications: "disabled"` explicitly (or pin `RESP: 2`): + +```javascript +// Keep v5 maintenance semantics on RESP3 +const client = createClient({ maintNotifications: "disabled" }); + +// Or pin RESP2 and inherit the disabled default +const client = createClient({ RESP: 2 }); +``` + + ## Legacy (callback) mode now uses RESP3 `createClient().legacy()` reads the parent client's RESP version. With the v6 default of RESP3, legacy callback consumers will see RESP3-shaped replies for any command whose transforms differ between protocol versions (for example, doubles arriving as `number` instead of `string`, or hash-like replies arriving as `Map`s). To keep the v5 callback reply shapes, pin `RESP: 2` on the parent client: From a4955213ebde7aca5bac394d229e5d3ad7553183 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 18 May 2026 11:57:00 +0300 Subject: [PATCH 24/28] types(client): narrow DEFAULT_RESP from RespVersions to literal 3 The previous annotation `: RespVersions = 3` widened the constant's inferred type to `2 | 3`, losing the literal information at every call site that branches on the value. Using `as const satisfies RespVersions` keeps the literal `3` type while still validating membership in `RespVersions`, so consumers that compare against `3` retain narrower inference and the membership invariant is still checked at compile time. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/lib/RESP/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/lib/RESP/types.ts b/packages/client/lib/RESP/types.ts index 225f89c7e0b..e50dc382309 100644 --- a/packages/client/lib/RESP/types.ts +++ b/packages/client/lib/RESP/types.ts @@ -367,7 +367,7 @@ export type Resp2Reply = ( export type RespVersions = 2 | 3; -export const DEFAULT_RESP: RespVersions = 3; +export const DEFAULT_RESP = 3 as const satisfies RespVersions; export type CommandReply< COMMAND extends Command, From 08577e321d6c882aa3eeb3df730d1f4f98b029bb Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 18 May 2026 11:58:03 +0300 Subject: [PATCH 25/28] test(client): use DEFAULT_RESP in HELLO spec instead of hardcoded 3 If the default RESP version ever changes again, this assertion would silently diverge from the runtime default. Source the fallback from the exported constant so the spec stays in sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/lib/commands/HELLO.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/lib/commands/HELLO.spec.ts b/packages/client/lib/commands/HELLO.spec.ts index a6c2f35a0d3..817165e9920 100644 --- a/packages/client/lib/commands/HELLO.spec.ts +++ b/packages/client/lib/commands/HELLO.spec.ts @@ -2,6 +2,7 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; import HELLO from './HELLO'; import { parseArgs } from './generic-transformers'; +import { DEFAULT_RESP } from '../RESP/types'; describe('HELLO', () => { testUtils.isVersionGreaterThanHook([6]); @@ -60,7 +61,7 @@ describe('HELLO', () => { const reply = await client.hello(); assert.equal(reply.server, 'redis'); assert.equal(typeof reply.version, 'string'); - assert.equal(reply.proto, client.options.RESP ?? 3); + assert.equal(reply.proto, client.options.RESP ?? DEFAULT_RESP); assert.equal(typeof reply.id, 'number'); assert.equal(reply.mode, 'standalone'); assert.equal(reply.role, 'master'); From deb1d0fbd3f22756aba6382f141a897ac7e2c540 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 18 May 2026 12:06:02 +0300 Subject: [PATCH 26/28] refactor(client): centralize map-like reply primitives in reply-utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isPlainObject / mapLikeEntries / mapLikeValues / mapLikeToObject / mapLikeToFlatArray / getMapValue were independently re-implemented in @redis/client (HOTKEYS_GET), @redis/search (reply-transformers), and @redis/time-series (INFO, INFO_DEBUG) with subtly different shapes — some lacked null-safe key stringification, some over-accepted Buffer/ typed-array values as plain objects, and INFO_DEBUG carried a fourth slightly divergent variant. Consolidate into a single module in @redis/client and have all consumers import from it. The unified versions adopt the strictest behavior already validated by prior fixes: isPlainObject rejects Buffer/typed-arrays, mapLikeEntries auto-unwraps single-element wrappers only when the inner value is Array/Map/plain-object, and keyToString short-circuits null/undefined keys. search/reply-transformers re-exports the shared symbols so existing search command imports remain unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/client/lib/commands/HOTKEYS_GET.ts | 53 +-------- packages/client/lib/commands/reply-utils.ts | 107 ++++++++++++++++++ .../search/lib/commands/reply-transformers.ts | 104 +++-------------- packages/time-series/lib/commands/INFO.ts | 72 +----------- .../time-series/lib/commands/INFO_DEBUG.ts | 30 +---- 5 files changed, 132 insertions(+), 234 deletions(-) create mode 100644 packages/client/lib/commands/reply-utils.ts diff --git a/packages/client/lib/commands/HOTKEYS_GET.ts b/packages/client/lib/commands/HOTKEYS_GET.ts index ac09bcdcad1..dcc5b5ca59c 100644 --- a/packages/client/lib/commands/HOTKEYS_GET.ts +++ b/packages/client/lib/commands/HOTKEYS_GET.ts @@ -1,5 +1,6 @@ import { CommandParser } from '../client/parser'; import { Command } from '../RESP/types'; +import { isPlainObject, mapLikeEntries, mapLikeValues } from './reply-utils'; /** * Hotkey entry with key name and metric value @@ -43,58 +44,6 @@ export interface HotkeysGetReply { byNetBytes?: Array; } -function isPlainObject(value: unknown): value is Record { - return value !== null && - typeof value === 'object' && - !Array.isArray(value) && - !(value instanceof Map) && - !ArrayBuffer.isView(value) && - Object.prototype.toString.call(value) === '[object Object]'; -} - -function keyToString(key: unknown): string { - if (key === null || key === undefined) return ''; - return (key as { toString(): string }).toString(); -} - -function mapLikeEntries(value: unknown): Array<[string, unknown]> { - if (value instanceof Map) { - return Array.from(value.entries(), ([key, entryValue]) => [keyToString(key), entryValue]); - } - - if (Array.isArray(value)) { - if ( - value.length === 1 && - (Array.isArray(value[0]) || value[0] instanceof Map || isPlainObject(value[0])) - ) { - return mapLikeEntries(value[0]); - } - - if (value.every(item => Array.isArray(item) && item.length >= 2)) { - return value.map(item => [keyToString(item[0]), item[1]]); - } - - const entries: Array<[string, unknown]> = []; - for (let i = 0; i < value.length - 1; i += 2) { - entries.push([keyToString(value[i]), value[i + 1]]); - } - return entries; - } - - if (isPlainObject(value)) { - return Object.entries(value); - } - - return []; -} - -function mapLikeValues(value: unknown): Array { - if (Array.isArray(value)) return value; - if (value instanceof Map) return [...value.values()]; - if (isPlainObject(value)) return Object.values(value); - return []; -} - function toSlotNumber(value: unknown): number { const slot = Number(value); if (!Number.isFinite(slot)) { diff --git a/packages/client/lib/commands/reply-utils.ts b/packages/client/lib/commands/reply-utils.ts new file mode 100644 index 00000000000..8a94df48e1c --- /dev/null +++ b/packages/client/lib/commands/reply-utils.ts @@ -0,0 +1,107 @@ +/** + * Shared primitives for parsing "map-like" replies that can arrive in any of + * four shapes depending on protocol version and `typeMapping`: + * + * - JS `Map` (RESP3 + `typeMapping[RESP_TYPES.MAP] = Map`) + * - Plain object (RESP3 default) + * - Flat key/value `Array` (RESP2, or `typeMapping[RESP_TYPES.MAP] = Array`) + * - Tuple `Array` of `[key, value]` pairs (older RESP2 wire shapes) + * + * Helpers here normalize over all four so transforms don't have to branch + * on protocol version. + */ + +export function isPlainObject(value: unknown): value is Record { + return value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Map) && + !ArrayBuffer.isView(value) && + Object.prototype.toString.call(value) === '[object Object]'; +} + +export function keyToString(key: unknown): string { + if (key === null || key === undefined) return ''; + return (key as { toString(): string }).toString(); +} + +export function mapLikeEntries(value: unknown): Array<[string, unknown]> { + if (value instanceof Map) { + return Array.from(value.entries(), ([key, entryValue]) => [keyToString(key), entryValue]); + } + + if (Array.isArray(value)) { + if ( + value.length === 1 && + (Array.isArray(value[0]) || value[0] instanceof Map || isPlainObject(value[0])) + ) { + return mapLikeEntries(value[0]); + } + + if (value.every(item => Array.isArray(item) && item.length >= 2)) { + return value.map(item => [keyToString(item[0]), item[1]]); + } + + const entries: Array<[string, unknown]> = []; + for (let i = 0; i < value.length - 1; i += 2) { + entries.push([keyToString(value[i]), value[i + 1]]); + } + return entries; + } + + if (isPlainObject(value)) { + return Object.entries(value); + } + + return []; +} + +export function mapLikeValues(value: unknown): Array { + if (Array.isArray(value)) return value; + if (value instanceof Map) return [...value.values()]; + if (isPlainObject(value)) return Object.values(value); + return []; +} + +export function mapLikeToObject(value: unknown): Record { + const object: Record = {}; + for (const [key, entryValue] of mapLikeEntries(value)) { + object[key] = entryValue; + } + return object; +} + +export function mapLikeToFlatArray(value: unknown): Array { + const flat: Array = []; + for (const [key, entryValue] of mapLikeEntries(value)) { + flat.push(key, entryValue); + } + return flat; +} + +export function getMapValue(value: unknown, keys: Array): unknown { + const object = mapLikeToObject(value); + + for (const key of keys) { + if (Object.hasOwn(object, key)) { + return object[key]; + } + } + + const lowerCaseKeyToOriginal = new Map(); + for (const key of Object.keys(object)) { + const lowerCaseKey = key.toLowerCase(); + if (!lowerCaseKeyToOriginal.has(lowerCaseKey)) { + lowerCaseKeyToOriginal.set(lowerCaseKey, key); + } + } + + for (const key of keys) { + const original = lowerCaseKeyToOriginal.get(key.toLowerCase()); + if (original !== undefined) { + return object[original]; + } + } + + return undefined; +} diff --git a/packages/search/lib/commands/reply-transformers.ts b/packages/search/lib/commands/reply-transformers.ts index f818600b808..a43f051bb31 100644 --- a/packages/search/lib/commands/reply-transformers.ts +++ b/packages/search/lib/commands/reply-transformers.ts @@ -1,40 +1,20 @@ -function isPlainObject(value: unknown): value is Record { - return value !== null && - typeof value === 'object' && - !Array.isArray(value) && - !(value instanceof Map); -} - -export function mapLikeEntries(value: unknown): Array<[string, unknown]> { - if (value instanceof Map) { - return Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]); - } - - if (Array.isArray(value)) { - if ( - value.length === 1 && - (Array.isArray(value[0]) || value[0] instanceof Map || isPlainObject(value[0])) - ) { - return mapLikeEntries(value[0]); - } - - if (value.every(item => Array.isArray(item) && item.length >= 2)) { - return value.map(item => [item[0].toString(), item[1]]); - } - - const entries: Array<[string, unknown]> = []; - for (let i = 0; i < value.length - 1; i += 2) { - entries.push([value[i].toString(), value[i + 1]]); - } - return entries; - } - - if (isPlainObject(value)) { - return Object.entries(value); - } - - return []; -} +import { + isPlainObject, + mapLikeEntries, + mapLikeValues, + mapLikeToObject, + mapLikeToFlatArray, + getMapValue +} from '@redis/client/dist/lib/commands/reply-utils'; + +export { + isPlainObject, + mapLikeEntries, + mapLikeValues, + mapLikeToObject, + mapLikeToFlatArray, + getMapValue +}; export function toCompatObject(value: Record): Record { const descriptors: PropertyDescriptorMap = {}; @@ -51,56 +31,6 @@ export function toCompatObject(value: Record): Record { - const object: Record = {}; - for (const [key, entryValue] of mapLikeEntries(value)) { - object[key] = entryValue; - } - return object; -} - -export function mapLikeToFlatArray(value: unknown): Array { - const flat: Array = []; - for (const [key, entryValue] of mapLikeEntries(value)) { - flat.push(key, entryValue); - } - return flat; -} - -export function mapLikeValues(value: unknown): Array { - if (Array.isArray(value)) return value; - if (value instanceof Map) return [...value.values()]; - if (isPlainObject(value)) return Object.values(value); - return []; -} - -export function getMapValue(value: unknown, keys: Array): unknown { - const object = mapLikeToObject(value); - - for (const key of keys) { - if (Object.hasOwn(object, key)) { - return object[key]; - } - } - - const lowerCaseKeyToOriginal = new Map(); - for (const key of Object.keys(object)) { - const lowerCaseKey = key.toLowerCase(); - if (!lowerCaseKeyToOriginal.has(lowerCaseKey)) { - lowerCaseKeyToOriginal.set(lowerCaseKey, key); - } - } - - for (const key of keys) { - const original = lowerCaseKeyToOriginal.get(key.toLowerCase()); - if (original !== undefined) { - return object[original]; - } - } - - return undefined; -} - function assignDocumentField(target: Record, key: string, value: unknown): void { if (key === '$') { const json = (value as { toString?: () => string })?.toString?.() ?? value; diff --git a/packages/time-series/lib/commands/INFO.ts b/packages/time-series/lib/commands/INFO.ts index 2eb41375d41..07279168d9e 100644 --- a/packages/time-series/lib/commands/INFO.ts +++ b/packages/time-series/lib/commands/INFO.ts @@ -3,6 +3,12 @@ import { ArrayReply, BlobStringReply, Command, DoubleReply, NumberReply, ReplyUn import { TimeSeriesDuplicatePolicies } from "./helpers"; import { TIME_SERIES_AGGREGATION_TYPE, TimeSeriesAggregationType } from "./CREATERULE"; import { transformDoubleReply } from '@redis/client/dist/lib/commands/generic-transformers'; +import { + getMapValue, + mapLikeEntries, + mapLikeToObject, + mapLikeValues +} from '@redis/client/dist/lib/commands/reply-utils'; export type InfoRawReplyTypes = SimpleStringReply | NumberReply | @@ -71,72 +77,6 @@ export interface InfoReply { ignoreMaxValDiff: DoubleReply; } -function mapLikeEntries(value: unknown): Array<[string, unknown]> { - if (value instanceof Map) { - return Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]); - } - - if (Array.isArray(value)) { - if (value.every(item => Array.isArray(item) && item.length >= 2)) { - return value.map(item => [item[0].toString(), item[1]]); - } - - const entries: Array<[string, unknown]> = []; - for (let i = 0; i < value.length - 1; i += 2) { - entries.push([value[i].toString(), value[i + 1]]); - } - return entries; - } - - if (value !== null && typeof value === 'object') { - return Object.entries(value); - } - - return []; -} - -function mapLikeValues(value: unknown): Array { - if (Array.isArray(value)) return value; - if (value instanceof Map) return [...value.values()]; - if (value !== null && typeof value === 'object') return Object.values(value); - return []; -} - -function mapLikeToObject(value: unknown): Record { - const object: Record = {}; - for (const [key, entryValue] of mapLikeEntries(value)) { - object[key] = entryValue; - } - return object; -} - -function getMapValue(value: unknown, keys: Array): unknown { - const object = mapLikeToObject(value); - - for (const key of keys) { - if (Object.hasOwn(object, key)) { - return object[key]; - } - } - - const lowerCaseKeyToOriginal = new Map(); - for (const key of Object.keys(object)) { - const lowerCaseKey = key.toLowerCase(); - if (!lowerCaseKeyToOriginal.has(lowerCaseKey)) { - lowerCaseKeyToOriginal.set(lowerCaseKey, key); - } - } - - for (const key of keys) { - const original = lowerCaseKeyToOriginal.get(key.toLowerCase()); - if (original !== undefined) { - return object[original]; - } - } - - return undefined; -} - function normalizeInfoLabels(labels: unknown): Array<[name: BlobStringReply, value: BlobStringReply]> { if (Array.isArray(labels)) { if (labels.every(item => Array.isArray(item) && item.length >= 2)) { diff --git a/packages/time-series/lib/commands/INFO_DEBUG.ts b/packages/time-series/lib/commands/INFO_DEBUG.ts index 558e5917e15..13344976a38 100644 --- a/packages/time-series/lib/commands/INFO_DEBUG.ts +++ b/packages/time-series/lib/commands/INFO_DEBUG.ts @@ -1,5 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { BlobStringReply, Command, NumberReply, SimpleStringReply, TypeMapping, ReplyUnion } from "@redis/client/dist/lib/RESP/types"; +import { mapLikeToObject, mapLikeValues } from '@redis/client/dist/lib/commands/reply-utils'; import INFO, { InfoRawReply, InfoRawReplyTypes, InfoReply } from "./INFO"; type chunkType = Array<[ @@ -36,35 +37,6 @@ export interface InfoDebugReply extends InfoReply { }>; } -function mapLikeToObject(value: unknown): Record { - if (value instanceof Map) { - return Object.fromEntries( - Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]) - ); - } - - if (Array.isArray(value)) { - const object: Record = {}; - for (let i = 0; i < value.length - 1; i += 2) { - object[value[i].toString()] = value[i + 1]; - } - return object; - } - - if (value !== null && typeof value === 'object') { - return value as Record; - } - - return {}; -} - -function mapLikeValues(value: unknown): Array { - if (Array.isArray(value)) return value; - if (value instanceof Map) return [...value.values()]; - if (value !== null && typeof value === 'object') return Object.values(value); - return []; -} - function normalizeChunks(chunks: unknown): InfoDebugReply['chunks'] { return mapLikeValues(chunks).map(chunk => { if (Array.isArray(chunk)) { From 51aa7e2bb0e76522a8076b5b530729b8287c20bc Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 18 May 2026 14:27:00 +0300 Subject: [PATCH 27/28] test(client): add unit tests for map-like reply primitives --- .../client/lib/commands/reply-utils.spec.ts | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 packages/client/lib/commands/reply-utils.spec.ts diff --git a/packages/client/lib/commands/reply-utils.spec.ts b/packages/client/lib/commands/reply-utils.spec.ts new file mode 100644 index 00000000000..c5c6757afa9 --- /dev/null +++ b/packages/client/lib/commands/reply-utils.spec.ts @@ -0,0 +1,289 @@ +import { strict as assert } from 'node:assert'; +import { + isPlainObject, + keyToString, + mapLikeEntries, + mapLikeValues, + mapLikeToObject, + mapLikeToFlatArray, + getMapValue +} from './reply-utils'; + +describe('reply-utils', () => { + describe('isPlainObject', () => { + it('accepts plain object literals', () => { + assert.equal(isPlainObject({}), true); + assert.equal(isPlainObject({ a: 1 }), true); + }); + + it('accepts Object.create(null)', () => { + assert.equal(isPlainObject(Object.create(null)), true); + }); + + it('rejects arrays, Maps, Buffers, Dates, null, primitives', () => { + assert.equal(isPlainObject([]), false); + assert.equal(isPlainObject(new Map()), false); + assert.equal(isPlainObject(Buffer.from('x')), false); + assert.equal(isPlainObject(new Uint8Array(1)), false); + assert.equal(isPlainObject(new Date()), false); + assert.equal(isPlainObject(null), false); + assert.equal(isPlainObject(undefined), false); + assert.equal(isPlainObject('s'), false); + assert.equal(isPlainObject(1), false); + }); + }); + + describe('keyToString', () => { + it('coerces null and undefined to empty string', () => { + assert.equal(keyToString(null), ''); + assert.equal(keyToString(undefined), ''); + }); + + it('stringifies Buffers, numbers, and strings', () => { + assert.equal(keyToString(Buffer.from('foo')), 'foo'); + assert.equal(keyToString(42), '42'); + assert.equal(keyToString('bar'), 'bar'); + }); + }); + + describe('mapLikeEntries', () => { + describe('empty inputs', () => { + it('returns [] for an empty Map', () => { + assert.deepEqual(mapLikeEntries(new Map()), []); + }); + + it('returns [] for an empty plain object', () => { + assert.deepEqual(mapLikeEntries({}), []); + }); + + it('returns [] for an empty array', () => { + assert.deepEqual(mapLikeEntries([]), []); + }); + + it('returns [] for non-map-like inputs', () => { + assert.deepEqual(mapLikeEntries(null), []); + assert.deepEqual(mapLikeEntries(undefined), []); + assert.deepEqual(mapLikeEntries(42), []); + assert.deepEqual(mapLikeEntries('foo'), []); + }); + }); + + describe('single-key inputs', () => { + it('reads a single entry from a Map', () => { + assert.deepEqual( + mapLikeEntries(new Map([['k', 'v']])), + [['k', 'v']] + ); + }); + + it('reads a single entry from a plain object', () => { + assert.deepEqual(mapLikeEntries({ k: 'v' }), [['k', 'v']]); + }); + + it('reads a single entry from a flat key/value array', () => { + assert.deepEqual(mapLikeEntries(['k', 'v']), [['k', 'v']]); + }); + + it('reads a single entry from a tuple array', () => { + assert.deepEqual(mapLikeEntries([['k', 'v']]), [['k', 'v']]); + }); + + it('stringifies non-string keys consistently across shapes', () => { + const m = new Map([[Buffer.from('k'), 1]]); + assert.deepEqual(mapLikeEntries(m), [['k', 1]]); + assert.deepEqual(mapLikeEntries([Buffer.from('k'), 1]), [['k', 1]]); + assert.deepEqual(mapLikeEntries([[Buffer.from('k'), 1]]), [['k', 1]]); + }); + + it('coerces null/undefined keys to empty string instead of throwing', () => { + assert.deepEqual(mapLikeEntries([null, 1]), [['', 1]]); + assert.deepEqual(mapLikeEntries([[null, 1]]), [['', 1]]); + assert.deepEqual(mapLikeEntries(new Map([[null, 1]])), [['', 1]]); + }); + }); + + describe('multi-entry shapes', () => { + it('reads a Map with multiple entries', () => { + const result = mapLikeEntries(new Map([['a', 1], ['b', 2]])); + assert.deepEqual(result, [['a', 1], ['b', 2]]); + }); + + it('reads a plain object with multiple entries', () => { + assert.deepEqual( + mapLikeEntries({ a: 1, b: 2 }), + [['a', 1], ['b', 2]] + ); + }); + + it('reads a flat key/value array with multiple entries', () => { + assert.deepEqual( + mapLikeEntries(['a', 1, 'b', 2]), + [['a', 1], ['b', 2]] + ); + }); + + it('reads a tuple array with multiple entries', () => { + assert.deepEqual( + mapLikeEntries([['a', 1], ['b', 2]]), + [['a', 1], ['b', 2]] + ); + }); + + it('drops a trailing unpaired element in a flat array', () => { + assert.deepEqual( + mapLikeEntries(['a', 1, 'b']), + [['a', 1]] + ); + }); + + it('keeps only the first two slots of tuples longer than two', () => { + assert.deepEqual( + mapLikeEntries([['a', 1, 'extra'], ['b', 2, 'extra']]), + [['a', 1], ['b', 2]] + ); + }); + }); + + describe('single-element array unwrapping', () => { + it('unwraps [Map]', () => { + assert.deepEqual( + mapLikeEntries([new Map([['k', 'v']])]), + [['k', 'v']] + ); + }); + + it('unwraps [plainObject]', () => { + assert.deepEqual(mapLikeEntries([{ k: 'v' }]), [['k', 'v']]); + }); + + it('unwraps a tuple-array wrapped in an array', () => { + assert.deepEqual( + mapLikeEntries([[['k', 'v']]]), + [['k', 'v']] + ); + }); + + it('does NOT unwrap [primitive] — falls back to flat-array branch', () => { + // ['only'] is a 1-element flat array: loop runs 0 times → [] + assert.deepEqual(mapLikeEntries(['only']), []); + }); + }); + + describe('mixed / heuristic edges', () => { + it('a flat array containing only 2+ length arrays is read as tuple list, not flat', () => { + // value.every(item => Array.isArray && length >= 2) — tuple branch wins + const result = mapLikeEntries([['a', 1], ['b', 2]]); + assert.deepEqual(result, [['a', 1], ['b', 2]]); + }); + + it('a mixed array (some tuples, some scalars) falls back to flat key/value pairing', () => { + // every() fails because 'b' is not an array → flat branch + assert.deepEqual( + mapLikeEntries([['a', 1], 'b', 2]), + [['a,1', 'b'] /* Array#toString joins with comma */] + ); + }); + + it('tuple values can themselves be Maps/objects (passthrough, no recursion)', () => { + const inner = new Map([['x', 1]]); + assert.deepEqual( + mapLikeEntries([['outer', inner]]), + [['outer', inner]] + ); + }); + }); + }); + + describe('mapLikeValues', () => { + it('returns [] for empty inputs', () => { + assert.deepEqual(mapLikeValues(new Map()), []); + assert.deepEqual(mapLikeValues({}), []); + assert.deepEqual(mapLikeValues([]), []); + }); + + it('returns [] for non-map-like inputs', () => { + assert.deepEqual(mapLikeValues(null), []); + assert.deepEqual(mapLikeValues(undefined), []); + assert.deepEqual(mapLikeValues(42), []); + }); + + it('returns array contents as-is (does NOT pair key/value)', () => { + // Note: mapLikeValues differs from mapLikeEntries — it does not flatten pairs. + assert.deepEqual(mapLikeValues(['a', 1, 'b', 2]), ['a', 1, 'b', 2]); + }); + + it('returns Map values in insertion order', () => { + assert.deepEqual( + mapLikeValues(new Map([['a', 1], ['b', 2]])), + [1, 2] + ); + }); + + it('returns plain-object values', () => { + assert.deepEqual(mapLikeValues({ a: 1, b: 2 }), [1, 2]); + }); + }); + + describe('mapLikeToObject', () => { + it('returns {} for empty inputs', () => { + assert.deepEqual(mapLikeToObject(new Map()), {}); + assert.deepEqual(mapLikeToObject({}), {}); + assert.deepEqual(mapLikeToObject([]), {}); + }); + + it('normalizes all four shapes to the same object', () => { + const expected = { a: 1, b: 2 }; + assert.deepEqual(mapLikeToObject(new Map([['a', 1], ['b', 2]])), expected); + assert.deepEqual(mapLikeToObject({ a: 1, b: 2 }), expected); + assert.deepEqual(mapLikeToObject(['a', 1, 'b', 2]), expected); + assert.deepEqual(mapLikeToObject([['a', 1], ['b', 2]]), expected); + }); + }); + + describe('mapLikeToFlatArray', () => { + it('returns [] for empty inputs', () => { + assert.deepEqual(mapLikeToFlatArray(new Map()), []); + assert.deepEqual(mapLikeToFlatArray({}), []); + assert.deepEqual(mapLikeToFlatArray([]), []); + }); + + it('normalizes all four shapes to the same flat key/value list', () => { + const expected = ['a', 1, 'b', 2]; + assert.deepEqual(mapLikeToFlatArray(new Map([['a', 1], ['b', 2]])), expected); + assert.deepEqual(mapLikeToFlatArray({ a: 1, b: 2 }), expected); + assert.deepEqual(mapLikeToFlatArray(['a', 1, 'b', 2]), expected); + assert.deepEqual(mapLikeToFlatArray([['a', 1], ['b', 2]]), expected); + }); + }); + + describe('getMapValue', () => { + it('returns undefined for empty inputs', () => { + assert.equal(getMapValue({}, ['a']), undefined); + assert.equal(getMapValue(new Map(), ['a']), undefined); + assert.equal(getMapValue([], ['a']), undefined); + }); + + it('returns undefined when no requested key matches', () => { + assert.equal(getMapValue({ a: 1 }, ['b', 'c']), undefined); + }); + + it('prefers exact-case match over case-insensitive match', () => { + // Both 'Total' and 'total' exist; exact 'total' wins. + assert.equal(getMapValue({ Total: 1, total: 2 }, ['total']), 2); + }); + + it('falls back to case-insensitive match', () => { + assert.equal(getMapValue({ Total: 1 }, ['total']), 1); + }); + + it('returns the value of the first key in the lookup list that matches', () => { + assert.equal(getMapValue({ a: 1, b: 2 }, ['b', 'a']), 2); + }); + + it('works across all four shapes', () => { + assert.equal(getMapValue(new Map([['a', 1]]), ['a']), 1); + assert.equal(getMapValue(['a', 1], ['a']), 1); + assert.equal(getMapValue([['a', 1]], ['a']), 1); + }); + }); +}); From 5110f79dcefa1d3514dcf4674a85e44c1ab811ea Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 18 May 2026 14:40:03 +0300 Subject: [PATCH 28/28] style(search): drop unused eslint-disable for no-unused-vars The `_typeMapping` underscore prefix already exempts the param from `@typescript-eslint/no-unused-vars`, so the disable directive triggered "unused eslint-disable" warnings under --max-warnings 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/search/lib/commands/HYBRID.ts | 2 -- packages/search/lib/commands/SEARCH.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index 1612715d599..6bef1dfaa4d 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -412,7 +412,6 @@ export default { reply: unknown, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract _preserve?: any, - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- matches TransformReply contract _typeMapping?: TypeMapping ): HybridSearchResult => { return transformHybridSearchResults(reply); @@ -421,7 +420,6 @@ export default { reply: unknown, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract _preserve?: any, - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- matches TransformReply contract _typeMapping?: TypeMapping ): HybridSearchResult => { return transformHybridSearchResults(reply); diff --git a/packages/search/lib/commands/SEARCH.ts b/packages/search/lib/commands/SEARCH.ts index e08afe24fc6..8f8da9d9bcb 100644 --- a/packages/search/lib/commands/SEARCH.ts +++ b/packages/search/lib/commands/SEARCH.ts @@ -163,7 +163,6 @@ function transformSearchReplyResp2( reply: SearchRawReply, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches TransformReply contract _preserve?: any, - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- matches TransformReply contract _typeMapping?: TypeMapping ): SearchReply { // if reply[2] is array, then we have content/documents. Otherwise, only ids