Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions docs/v5-to-v6.md
Original file line number Diff line number Diff line change
@@ -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<boolean>` to `Array<number>`.


## 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<string>; 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<string>; 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<string, string \| null>; sources: Array<string>; samples: Array<{ timestamp: number; value: number }> } -> { labels: Record<string, string \| null>; samples: Array<{ timestamp: number; value: number }> }` | `sources` removed from RESP3 selected-labels grouped reply. |
| `@redis/time-series` | `TS.MREVRANGE SELECTED_LABELS GROUPBY` | `{ labels: Record<string, string \| null>; sources: Array<string>; samples: Array<{ timestamp: number; value: number }> } -> { labels: Record<string, string \| null>; 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 });
```
35 changes: 0 additions & 35 deletions docs/v5.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions packages/bloom/lib/commands/bloom/EXISTS.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
21 changes: 21 additions & 0 deletions packages/bloom/lib/commands/bloom/INFO.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
11 changes: 11 additions & 0 deletions packages/bloom/lib/commands/bloom/MEXISTS.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
4 changes: 2 additions & 2 deletions packages/bloom/lib/commands/bloom/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function transformInfoV2Reply<T>(reply: Array<any>, typeMapping?: TypeMap
return ret as unknown as T;
}
default: {
const ret = Object.create(null);
const ret: Record<string, any> = {};

for (let i = 0; i < reply.length; i += 2) {
ret[reply[i].toString()] = reply[i + 1];
Expand All @@ -26,4 +26,4 @@ export function transformInfoV2Reply<T>(reply: Array<any>, typeMapping?: TypeMap
return ret as unknown as T;
}
}
}
}
2 changes: 1 addition & 1 deletion packages/bloom/lib/commands/count-min-sketch/INFO.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions packages/bloom/lib/commands/cuckoo/ADDNX.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
9 changes: 9 additions & 0 deletions packages/bloom/lib/commands/cuckoo/DEL.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
9 changes: 9 additions & 0 deletions packages/bloom/lib/commands/cuckoo/EXISTS.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
28 changes: 28 additions & 0 deletions packages/bloom/lib/commands/cuckoo/INFO.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
2 changes: 1 addition & 1 deletion packages/bloom/lib/commands/cuckoo/INSERTNX.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
4 changes: 2 additions & 2 deletions packages/bloom/lib/commands/cuckoo/INSERTNX.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -16,5 +16,5 @@ export default {
args[0].push('CF.INSERTNX');
parseCfInsertArguments(...args);
},
transformReply: INSERT.transformReply
transformReply: undefined as unknown as () => ArrayReply<NumberReply<-1 | 0 | 1>>
} as const satisfies Command;
15 changes: 15 additions & 0 deletions packages/bloom/lib/commands/t-digest/BYRANK.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
15 changes: 15 additions & 0 deletions packages/bloom/lib/commands/t-digest/BYREVRANK.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
12 changes: 12 additions & 0 deletions packages/bloom/lib/commands/t-digest/CDF.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
37 changes: 37 additions & 0 deletions packages/bloom/lib/commands/t-digest/INFO.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading
Loading