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 }); +``` 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/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/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/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/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/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/bloom/lib/test-utils.ts b/packages/bloom/lib/test-utils.ts index e6f5aeffad9..e7137df82a4 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/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/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/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/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/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.spec.ts b/packages/client/lib/client/index.spec.ts index 128499851e1..f11a228fb14 100644 --- a/packages/client/lib/client/index.spec.ts +++ b/packages/client/lib/client/index.spec.ts @@ -42,12 +42,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') + }) ); }); @@ -1142,6 +1141,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", @@ -1153,7 +1160,7 @@ describe('Client', () => { "connect", "ready", ], - log, + normalized, ); teardown(); }); @@ -1196,17 +1203,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/index.ts b/packages/client/lib/client/index.ts index c20c75830e0..ea9dcad37b9 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 & @@ -326,7 +326,7 @@ export default class RedisClient< M extends RedisModules = {}, F extends RedisFunctions = {}, S extends RedisScripts = {}, - RESP extends RespVersions = 2 + RESP extends RespVersions = 3 >(config?: CommanderConfig) { @@ -359,7 +359,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); @@ -628,16 +628,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'); } } @@ -681,7 +682,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 @@ -693,7 +694,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, @@ -743,8 +744,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') { @@ -774,7 +776,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.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/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.spec.ts b/packages/client/lib/cluster/cluster-slots.spec.ts index 86e4ecc06aa..b7de9e3166c 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/cluster/cluster-slots.ts b/packages/client/lib/cluster/cluster-slots.ts index c043dd7dc53..f6771c37c02 100644 --- a/packages/client/lib/cluster/cluster-slots.ts +++ b/packages/client/lib/cluster/cluster-slots.ts @@ -127,7 +127,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 fbdebad16a7..33bfe58fa58 100644 --- a/packages/client/lib/cluster/index.ts +++ b/packages/client/lib/cluster/index.ts @@ -118,7 +118,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 = {} > = ( @@ -222,7 +222,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) { @@ -253,7 +253,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/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/FUNCTION_STATS.ts b/packages/client/lib/commands/FUNCTION_STATS.ts index 77eccf916bd..f2e061ffc00 100644 --- a/packages/client/lib/commands/FUNCTION_STATS.ts +++ b/packages/client/lib/commands/FUNCTION_STATS.ts @@ -60,7 +60,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/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/GEOSEARCH_WITH.ts b/packages/client/lib/commands/GEOSEARCH_WITH.ts index dca125a816e..1bae3286e1f 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, ArrayReply, TuplesReply, BlobStringReply, NumberReply, DoubleReply, UnwrapReply, 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; @@ -45,14 +46,23 @@ export default { }, transformReply( reply: UnwrapReply]>>>, - replyWith: Array + 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 unwrapped = raw as unknown as UnwrapReply; @@ -61,18 +71,18 @@ export default { }; if (distanceIndex) { - item.distance = unwrapped[distanceIndex]; + item.distance = parseDouble(unwrapped[distanceIndex]); } - + if (hashIndex) { item.hash = unwrapped[hashIndex]; } - + if (coordinatesIndex) { const [longitude, latitude] = unwrapped[coordinatesIndex]; item.coordinates = { - longitude, - latitude + longitude: parseDouble(longitude), + latitude: parseDouble(latitude) }; } 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/HOTKEYS_GET.ts b/packages/client/lib/commands/HOTKEYS_GET.ts index b8be1307ec6..902f5732726 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; } } @@ -170,12 +213,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 - }, - unstableResp3: true + transformReply: transformHotkeysGetReply } as const satisfies Command; 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/MODULE_LIST.ts b/packages/client/lib/commands/MODULE_LIST.ts index 8183c419a66..012296593a1 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, @@ -19,15 +68,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/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_NUMSUB.ts b/packages/client/lib/commands/PUBSUB_NUMSUB.ts index 980679ff2ec..2ff405f25cb 100644 --- a/packages/client/lib/commands/PUBSUB_NUMSUB.ts +++ b/packages/client/lib/commands/PUBSUB_NUMSUB.ts @@ -26,7 +26,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.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/PUBSUB_SHARDNUMSUB.ts b/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts index 9d54a113d78..f05822787c0 100644 --- a/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts +++ b/packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts @@ -6,7 +6,7 @@ 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/ @@ -20,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/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/VINFO.ts b/packages/client/lib/commands/VINFO.ts index 4e0d68d7cb0..07f49c6b617 100644 --- a/packages/client/lib/commands/VINFO.ts +++ b/packages/client/lib/commands/VINFO.ts @@ -14,7 +14,7 @@ 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/ @@ -25,7 +25,7 @@ export default { }, 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.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/VLINKS_WITHSCORES.ts b/packages/client/lib/commands/VLINKS_WITHSCORES.ts index 10ebe160fcd..1c27acf479f 100644 --- a/packages/client/lib/commands/VLINKS_WITHSCORES.ts +++ b/packages/client/lib/commands/VLINKS_WITHSCORES.ts @@ -7,7 +7,7 @@ function transformVLinksWithScoresReply(reply: any): 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/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/XREAD.ts b/packages/client/lib/commands/XREAD.ts index 3a636598394..623de715cb5 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, /** @@ -77,7 +115,6 @@ export default { */ transformReply: { 2: transformStreamsMessagesReplyResp2, - 3: undefined as unknown as () => ReplyUnion - }, - unstableResp3: true, + 3: transformStreamsMessagesReplyResp3Compat + } } as const satisfies Command; 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/XREADGROUP.ts b/packages/client/lib/commands/XREADGROUP.ts index d177c2e4864..5319a8da2fc 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, /** @@ -63,6 +101,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/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..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,8 +259,8 @@ describe('Generic Transformers', () => { it('transformTuplesReply', () => { assert.deepEqual( - transformTuplesReply(['key1', 'value1', 'key2', 'value2']), - Object.create(null, { + transformTuplesReply(['key1', 'value1', 'key2', 'value2'] as unknown as ArrayReply), + Object.create({}, { key1: { value: 'value1', configurable: true, @@ -273,10 +277,10 @@ 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(null, { + message: Object.create({}, { '0key': { value: '0value', configurable: true, @@ -285,7 +289,7 @@ describe('Generic Transformers', () => { }) }, { id: '1-0', - message: Object.create(null, { + message: Object.create({}, { '1key': { value: '1value', configurable: true, @@ -306,12 +310,12 @@ 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: [{ id: '0-1', - message: Object.create(null, { + message: Object.create({}, { '11key': { value: '11value', configurable: true, @@ -320,7 +324,7 @@ describe('Generic Transformers', () => { }) }, { id: '1-1', - message: Object.create(null, { + message: Object.create({}, { '12key': { value: '12value', configurable: true, @@ -332,7 +336,7 @@ describe('Generic Transformers', () => { name: 'stream2', messages: [{ id: '0-2', - message: Object.create(null, { + message: Object.create({}, { '2key1': { value: '2value1', configurable: true, @@ -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 bc74cf5739f..cf6daecbe1a 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; @@ -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, @@ -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/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/client/lib/sentinel/index.ts b/packages/client/lib/sentinel/index.ts index 0f8eb2cc0fe..22e5956dd81 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, @@ -349,7 +349,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({ @@ -374,7 +374,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); @@ -701,7 +701,7 @@ 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 319e879e86d..a840567cac3 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<{}, {}, {}, 2, {}>; + getSentinelClient(opts?: Partial>): RedisSentinelType<{}, {}, {}, RespVersions, {}>; } 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 f611ee8d9cb..5fe7eeb9d43 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], @@ -97,7 +100,7 @@ export const GLOBAL = { OPEN_RESP_3: { serverArguments: [...DEBUG_MODE_ARGS], clientOptions: { - RESP: 3, + RESP: 3 as const, } }, ASYNC_BASIC_AUTH: { 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/json/lib/test-utils.ts b/packages/json/lib/test-utils.ts index 3bfc5ce6425..0cbb5180ed9 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 f4341bbf48f..3ae8010500c 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; @@ -84,7 +84,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; @@ -110,7 +110,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.spec.ts b/packages/search/lib/commands/AGGREGATE.spec.ts index 3fc6b77722f..e3c0d8c5487 100644 --- a/packages/search/lib/commands/AGGREGATE.spec.ts +++ b/packages/search/lib/commands/AGGREGATE.spec.ts @@ -501,7 +501,7 @@ describe('AGGREGATE', () => { { total: 1, results: [ - Object.create(null, { + Object.defineProperties({}, { sum: { value: '3', configurable: true, @@ -517,4 +517,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.ts b/packages/search/lib/commands/AGGREGATE.ts index ea3e3aa18ff..b2fc8c30a32 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, @@ -159,25 +222,9 @@ export default { return parseAggregateOptions(parser, options); }, transformReply: { - 2: (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 - }; - }, - 3: undefined as unknown as () => ReplyUnion + 2: transformAggregateReplyResp2, + 3: transformAggregateReplyResp3 }, - unstableResp3: true } as const satisfies Command; export function parseAggregateOptions(parser: CommandParser , options?: FtAggregateOptions) { 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/AGGREGATE_WITHCURSOR.ts b/packages/search/lib/commands/AGGREGATE_WITHCURSOR.ts index e1b0e42f9fe..af2451862e2 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, /** @@ -48,7 +66,6 @@ export default { cursor: reply[1] }; }, - 3: undefined as unknown as () => ReplyUnion + 3: transformAggregateWithCursorReplyResp3 }, - unstableResp3: true } as const satisfies Command; 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/CONFIG_GET.ts b/packages/search/lib/commands/CONFIG_GET.ts index 8073805c533..35290e6ddea 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, @@ -13,12 +14,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/CURSOR_READ.ts b/packages/search/lib/commands/CURSOR_READ.ts index 50ee5eafbd6..59c75e1cbc7 100644 --- a/packages/search/lib/commands/CURSOR_READ.ts +++ b/packages/search/lib/commands/CURSOR_READ.ts @@ -25,5 +25,4 @@ export default { } }, transformReply: AGGREGATE_WITHCURSOR.transformReply, - unstableResp3: true } as const satisfies Command; 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/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index f2eddccd8cf..658b0c42193 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. @@ -422,9 +427,10 @@ export default { 2: (reply: any): HybridSearchResult => { return transformHybridSearchResults(reply); }, - 3: undefined as unknown as () => ReplyUnion, + 3: (reply: any): HybridSearchResult => { + return transformHybridSearchResults(reply); + }, }, - unstableResp3: true, } as const satisfies Command; export interface HybridSearchResult { @@ -435,34 +441,53 @@ export interface HybridSearchResult { } function transformHybridSearchResults(reply: any): HybridSearchResult { - // FT.HYBRID returns a map-like structure as flat array: - // ['total_results', N, 'results', [...], 'warnings', [...], 'execution_time', 'X.XXX'] const replyMap = parseReplyMap(reply); - const totalResults = replyMap["total_results"] ?? 0; - const rawResults = replyMap["results"] ?? []; - const warnings = replyMap["warnings"] ?? []; - const executionTime = replyMap["execution_time"] - ? Number.parseFloat(replyMap["execution_time"]) - : 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: Record[] = []; 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 = 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; } } @@ -473,25 +498,11 @@ function transformHybridSearchResults(reply: any): HybridSearchResult { return { totalResults, executionTime, - warnings, + warnings: warnings.map(warning => warning.toString()), results, }; } function parseReplyMap(reply: any): 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; + return mapLikeToObject(reply); } diff --git a/packages/search/lib/commands/INFO.spec.ts b/packages/search/lib/commands/INFO.spec.ts index b52e99ab9b0..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 => { @@ -34,7 +94,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 +111,7 @@ describe('INFO', () => { enumerable: true } }), - attributes: [Object.create(null, { + attributes: [Object.defineProperties({}, { identifier: { value: 'field', configurable: true, @@ -130,7 +190,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 +207,7 @@ describe('INFO', () => { enumerable: true } }), - attributes: [Object.create(null, { + attributes: [Object.defineProperties({}, { identifier: { value: 'field', configurable: true, diff --git a/packages/search/lib/commands/INFO.ts b/packages/search/lib/commands/INFO.ts index 03cf21edfd8..afc672e3c02 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'; @@ -17,9 +17,8 @@ export default { }, transformReply: { 2: transformV2Reply, - 3: undefined as unknown as () => ReplyUnion + 3: undefined as unknown as () => InfoReply }, - unstableResp3: true } as const satisfies Command; export interface InfoReply { @@ -89,7 +88,7 @@ function transformV2Reply(reply: Array, preserve?: any, typeMapping?: TypeM case 'hash_indexing_failures': case 'indexing': case 'number_of_uses': - case 'cleaning': + case 'cleaning': case 'stopwords_list': ret[key] = reply[i+1]; break; @@ -108,7 +107,7 @@ function transformV2Reply(reply: Array, preserve?: any, typeMapping?: TypeM case 'offsets_per_term_avg': case 'offset_bits_per_record_avg': case 'total_indexing_time': - case 'percent_indexed': + case 'percent_indexed': ret[key] = transformDoubleReply[2](reply[i+1], undefined, typeMapping) as DoubleReply; break; case 'index_definition': @@ -137,7 +136,7 @@ function transformV2Reply(reply: Array, preserve?: any, typeMapping?: TypeM break; } } - + ret[key] = innerRet; break; } @@ -162,7 +161,7 @@ function transformV2Reply(reply: Array, preserve?: any, typeMapping?: TypeM ret[key] = innerRet; break; } - } + } } return ret; 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_AGGREGATE.ts b/packages/search/lib/commands/PROFILE_AGGREGATE.ts index 99aca95a698..04b7e0575ba 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, @@ -38,7 +44,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) + }; + } }, - unstableResp3: true } as const satisfies Command; 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/PROFILE_SEARCH.ts b/packages/search/lib/commands/PROFILE_SEARCH.ts index cdbb12fcdd8..1b89a5caf67 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, @@ -54,7 +122,6 @@ export default { profile: reply[1] }; }, - 3: (reply: ReplyUnion): ReplyUnion => reply + 3: transformProfileSearchReplyResp3 }, - unstableResp3: true } as const satisfies Command; 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.ts b/packages/search/lib/commands/SEARCH.ts index 03779a446cc..a440ea9634e 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, @@ -182,27 +228,9 @@ 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 = []; - let i = 1; - while (i < reply.length) { - documents.push({ - id: reply[i++], - value: withoutDocuments ? Object.create(null) : documentValue(reply[i++]) - }); - } - - return { - total: reply[0], - documents - }; - }, - 3: undefined as unknown as () => ReplyUnion + 2: transformSearchReplyResp2, + 3: transformSearchReplyResp3 }, - unstableResp3: true } as const satisfies Command; export type SearchRawReply = Array; @@ -220,27 +248,5 @@ export interface SearchReply { } function documentValue(tuples: any) { - const message = Object.create(null); - - if(!tuples) { - return message; - } - - let i = 0; - while (i < tuples.length) { - const key = tuples[i++], - value = tuples[i++]; - if (key === '$') { // might be a JSON reply - try { - Object.assign(message, JSON.parse(value)); - continue; - } catch { - // set as a regular property if not a valid JSON - } - } - - message[key] = value; - } - - return message; + return parseDocumentValue(tuples); } 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/SEARCH_NOCONTENT.ts b/packages/search/lib/commands/SEARCH_NOCONTENT.ts index 2fcfd2b4166..5623c189392 100644 --- a/packages/search/lib/commands/SEARCH_NOCONTENT.ts +++ b/packages/search/lib/commands/SEARCH_NOCONTENT.ts @@ -23,12 +23,23 @@ export default { documents: reply.slice(1) } }, - 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) + }; + } }, - unstableResp3: true } as const satisfies Command; export interface SearchNoContentReply { total: number; documents: Array; -}; \ No newline at end of file +}; diff --git a/packages/search/lib/commands/SPELLCHECK.ts b/packages/search/lib/commands/SPELLCHECK.ts index d6d84b19543..8f8243fabb9 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, @@ -59,9 +110,8 @@ export default { })) })); }, - 3: undefined as unknown as () => ReplyUnion, + 3: transformSpellCheckReplyResp3, }, - unstableResp3: true } as const satisfies Command; function parseTerms(parser: CommandParser, { mode, dictionary }: Terms) { 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/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/search/lib/test-utils.ts b/packages/search/lib/test-utils.ts index 20b25fe6c98..daeea4a7516 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/test-utils/lib/test-utils.ts b/packages/test-utils/lib/test-utils.ts index 34efcd365f1..3e644e08977 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.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.ts b/packages/time-series/lib/commands/INFO.ts index 2e908a9d32d..252de761614 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, /** @@ -83,52 +321,7 @@ export default { parser.pushKey(key); }, transformReply: { - 2: (reply: InfoRawReply, _, 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[27] as unknown as BlobStringReply, undefined, typeMapping); - break; - } - } - - return ret; - }, - 3: undefined as unknown as () => ReplyUnion + 2: transformInfoReplyResp2, + 3: transformInfoReplyResp3 }, - unstableResp3: true } as const satisfies Command; 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/INFO_DEBUG.ts b/packages/time-series/lib/commands/INFO_DEBUG.ts index bbdee4924ff..72e21f8cdd4 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, /** @@ -76,7 +138,16 @@ export default { return ret; }, - 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; + } }, - unstableResp3: true } as const satisfies Command; 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_GROUPBY.ts b/packages/time-series/lib/commands/MRANGE_GROUPBY.ts index 204ac560454..f6be9bb119a 100644 --- a/packages/time-series/lib/commands/MRANGE_GROUPBY.ts +++ b/packages/time-series/lib/commands/MRANGE_GROUPBY.ts @@ -122,9 +122,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.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.ts b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts index c9b737fd290..0d2842f61c5 100644 --- a/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts +++ b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS.ts @@ -74,7 +74,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.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_SELECTED_LABELS_GROUPBY.ts b/packages/time-series/lib/commands/MRANGE_SELECTED_LABELS_GROUPBY.ts index d2f94b82bb3..bbc9fc7ee19 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'; @@ -65,10 +65,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) }; }); 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.ts b/packages/time-series/lib/commands/MRANGE_WITHLABELS.ts index 01a3634cf4c..5003b20dae1 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); }; } @@ -68,7 +68,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/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, diff --git a/packages/time-series/lib/commands/helpers.ts b/packages/time-series/lib/commands/helpers.ts index 3e277d0747d..12a379cc268 100644 --- a/packages/time-series/lib/commands/helpers.ts +++ b/packages/time-series/lib/commands/helpers.ts @@ -138,7 +138,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; @@ -146,7 +146,7 @@ export function resp2MapToValue< } return ret as never; } - } + } } export function resp3MapToValue< @@ -156,7 +156,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); @@ -211,7 +211,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; @@ -245,7 +245,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; @@ -269,7 +269,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(','); @@ -303,4 +303,4 @@ function transformRESP2Sources(sourcesRaw: BlobStringReply) { } return sourcesArray; -} \ No newline at end of file +} diff --git a/packages/time-series/lib/test-utils.ts b/packages/time-series/lib/test-utils.ts index 375c2351e6d..055d605a728 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 }