Skip to content

Commit dc5a4e0

Browse files
committed
fix(compat): normalize RESP2/RESP3 reply transforms
- Normalize cross-module reply-shape handling for RESP2 and RESP3. - Apply shared parser and transformer updates for stable compatibility. - Leave targeted module bugfixes isolated for the next commit.
1 parent 08b6dac commit dc5a4e0

27 files changed

+987
-263
lines changed

docs/v5.md

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -40,41 +40,6 @@ This replaces the previous approach of using `commandOptions({ returnBuffers: tr
4040

4141
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.
4242

43-
## Known Limitations
44-
45-
### Unstable Commands
46-
47-
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:
48-
49-
```javascript
50-
const client = createClient({
51-
RESP: 3,
52-
unstableResp3: true
53-
});
54-
```
55-
56-
The following commands have unstable RESP3 implementations:
57-
58-
1. **Stream Commands**:
59-
- `XREAD` and `XREADGROUP` - The response format differs between RESP2 and RESP3
60-
61-
2. **Search Commands (RediSearch)**:
62-
- `FT.AGGREGATE`
63-
- `FT.AGGREGATE_WITHCURSOR`
64-
- `FT.CURSOR_READ`
65-
- `FT.INFO`
66-
- `FT.PROFILE_AGGREGATE`
67-
- `FT.PROFILE_SEARCH`
68-
- `FT.SEARCH`
69-
- `FT.SEARCH_NOCONTENT`
70-
- `FT.SPELLCHECK`
71-
72-
3. **Time Series Commands**:
73-
- `TS.INFO`
74-
- `TS.INFO_DEBUG`
75-
76-
If you need to use these commands with RESP3, be aware that the response format might change in future versions.
77-
7843
# Sentinel Support
7944

8045
[Sentinel](./sentinel.md)

packages/bloom/lib/commands/bloom/helpers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function transformInfoV2Reply<T>(reply: Array<any>, typeMapping?: TypeMap
1717
return ret as unknown as T;
1818
}
1919
default: {
20-
const ret = Object.create(null);
20+
const ret: Record<string, any> = {};
2121

2222
for (let i = 0; i < reply.length; i += 2) {
2323
ret[reply[i].toString()] = reply[i + 1];
@@ -26,4 +26,4 @@ export function transformInfoV2Reply<T>(reply: Array<any>, typeMapping?: TypeMap
2626
return ret as unknown as T;
2727
}
2828
}
29-
}
29+
}

packages/client/lib/RESP/decoder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -924,7 +924,7 @@ export class Decoder {
924924

925925
default:
926926
return this.#decodeMapAsObject(
927-
Object.create(null),
927+
{},
928928
length,
929929
typeMapping,
930930
chunk

packages/client/lib/client/commands-queue.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,10 @@ export default class RedisCommandsQueue {
189189
}
190190

191191
#getTypeMapping() {
192-
return this.#waitingForReply.head!.value.typeMapping ?? {};
192+
const head = this.#waitingForReply.head;
193+
if (!head) return PUSH_TYPE_MAPPING;
194+
195+
return head.value.typeMapping ?? {};
193196
}
194197

195198
#initiateDecoder() {

packages/client/lib/commands/FUNCTION_STATS.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ function transformEngines(reply: Resp2Reply<Engines>) {
6060
const engines: Record<string, {
6161
libraries_count: NumberReply;
6262
functions_count: NumberReply;
63-
}> = Object.create(null);
63+
}> = {};
6464
for (let i = 0; i < unwraped.length; i++) {
6565
const name = unwraped[i] as BlobStringReply,
6666
stats = unwraped[++i] as Resp2Reply<Engine>,

packages/client/lib/commands/HOTKEYS_GET.ts

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CommandParser } from '../client/parser';
2-
import { Command, ReplyUnion, UnwrapReply, ArrayReply, BlobStringReply, NumberReply } from '../RESP/types';
2+
import { Command } from '../RESP/types';
33

44
/**
55
* Hotkey entry with key name and metric value
@@ -43,30 +43,77 @@ export interface HotkeysGetReply {
4343
byNetBytes?: Array<HotkeyEntry>;
4444
}
4545

46-
type HotkeysGetRawReply = ArrayReply<ArrayReply<BlobStringReply | NumberReply | ArrayReply<BlobStringReply | NumberReply>>>;
46+
function mapLikeEntries(value: any): Array<[string, any]> {
47+
if (value instanceof Map) {
48+
return Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]);
49+
}
50+
51+
if (Array.isArray(value)) {
52+
if (
53+
value.length === 1 &&
54+
(Array.isArray(value[0]) || value[0] instanceof Map || (typeof value[0] === 'object' && value[0] !== null))
55+
) {
56+
return mapLikeEntries(value[0]);
57+
}
58+
59+
if (value.every(item => Array.isArray(item) && item.length >= 2)) {
60+
return value.map(item => [item[0].toString(), item[1]]);
61+
}
62+
63+
const entries: Array<[string, any]> = [];
64+
for (let i = 0; i < value.length - 1; i += 2) {
65+
entries.push([value[i].toString(), value[i + 1]]);
66+
}
67+
return entries;
68+
}
69+
70+
if (value !== null && typeof value === 'object') {
71+
return Object.entries(value);
72+
}
73+
74+
return [];
75+
}
76+
77+
function mapLikeValues(value: any): Array<any> {
78+
if (Array.isArray(value)) return value;
79+
if (value instanceof Map) return [...value.values()];
80+
if (value !== null && typeof value === 'object') return Object.values(value);
81+
return [];
82+
}
4783

4884
/**
4985
* Parse the hotkeys array into HotkeyEntry objects
5086
*/
51-
function parseHotkeysList(arr: Array<BlobStringReply | NumberReply>): Array<HotkeyEntry> {
52-
const result: Array<HotkeyEntry> = [];
53-
for (let i = 0; i < arr.length; i += 2) {
54-
result.push({
55-
key: arr[i].toString(),
56-
value: Number(arr[i + 1])
57-
});
58-
}
59-
return result;
87+
function parseHotkeysList(arr: unknown): Array<HotkeyEntry> {
88+
return mapLikeEntries(arr).map(([key, value]) => ({
89+
key,
90+
value: Number(value)
91+
}));
6092
}
6193

6294
/**
6395
* Parse slot ranges from the server response.
6496
* Single slots are represented as arrays with one element: [slot]
6597
* Slot ranges are represented as arrays with two elements: [start, end]
6698
*/
67-
function parseSlotRanges(arr: Array<ArrayReply<NumberReply>>): Array<SlotRange> {
68-
return arr.map(range => {
69-
const unwrapped = range as unknown as Array<number>;
99+
function parseSlotRanges(arr: unknown): Array<SlotRange> {
100+
return mapLikeValues(arr).map(range => {
101+
let unwrapped: Array<number>;
102+
103+
if (Array.isArray(range)) {
104+
unwrapped = range as Array<number>;
105+
} else if (range instanceof Map) {
106+
unwrapped = [...range.values()].map(value => Number(value));
107+
} else if (range !== null && typeof range === 'object') {
108+
const objectRange = range as Record<string, unknown>;
109+
const start = Number(objectRange.start ?? objectRange[0]);
110+
const end = Number(objectRange.end ?? objectRange[1] ?? start);
111+
unwrapped = [start, end];
112+
} else {
113+
const slot = Number(range);
114+
unwrapped = [slot, slot];
115+
}
116+
70117
if (unwrapped.length === 1) {
71118
// Single slot - start and end are the same
72119
return {
@@ -85,15 +132,11 @@ function parseSlotRanges(arr: Array<ArrayReply<NumberReply>>): Array<SlotRange>
85132
/**
86133
* Transform the raw reply into a structured object
87134
*/
88-
function transformHotkeysGetReply(reply: UnwrapReply<HotkeysGetRawReply>): HotkeysGetReply {
89-
const result: Partial<HotkeysGetReply> = {};
90-
91-
// The reply is wrapped in an extra array, so we need to access reply[0]
92-
const data = reply[0] as unknown as Array<BlobStringReply | NumberReply | ArrayReply<BlobStringReply | NumberReply>>;
135+
function transformHotkeysGetReply(reply: unknown | null): HotkeysGetReply | null {
136+
if (reply === null) return null;
93137

94-
for (let i = 0; i < data.length; i += 2) {
95-
const key = data[i].toString();
96-
const value = data[i + 1];
138+
const result: Partial<HotkeysGetReply> = {};
139+
for (const [key, value] of mapLikeEntries(reply)) {
97140

98141
switch (key) {
99142
case 'tracking-active':
@@ -103,7 +146,7 @@ function transformHotkeysGetReply(reply: UnwrapReply<HotkeysGetRawReply>): Hotke
103146
result.sampleRatio = Number(value);
104147
break;
105148
case 'selected-slots':
106-
result.selectedSlots = parseSlotRanges(value as unknown as Array<ArrayReply<NumberReply>>);
149+
result.selectedSlots = parseSlotRanges(value);
107150
break;
108151
case 'sampled-commands-selected-slots-us':
109152
result.sampledCommandsSelectedSlotsUs = Number(value);
@@ -139,10 +182,10 @@ function transformHotkeysGetReply(reply: UnwrapReply<HotkeysGetRawReply>): Hotke
139182
result.totalNetBytes = Number(value);
140183
break;
141184
case 'by-cpu-time-us':
142-
result.byCpuTimeUs = parseHotkeysList(value as unknown as Array<BlobStringReply | NumberReply>);
185+
result.byCpuTimeUs = parseHotkeysList(value);
143186
break;
144187
case 'by-net-bytes':
145-
result.byNetBytes = parseHotkeysList(value as unknown as Array<BlobStringReply | NumberReply>);
188+
result.byNetBytes = parseHotkeysList(value);
146189
break;
147190
}
148191
}
@@ -170,11 +213,5 @@ export default {
170213
parseCommand(parser: CommandParser) {
171214
parser.push('HOTKEYS', 'GET');
172215
},
173-
transformReply: {
174-
2: (reply: UnwrapReply<HotkeysGetRawReply> | null): HotkeysGetReply | null => {
175-
if (reply === null) return null;
176-
return transformHotkeysGetReply(reply);
177-
},
178-
3: undefined as unknown as () => ReplyUnion
179-
}
216+
transformReply: transformHotkeysGetReply
180217
} as const satisfies Command;

packages/client/lib/commands/MODULE_LIST.ts

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,55 @@ export type ModuleListReply = ArrayReply<TuplesToMapReply<[
66
[BlobStringReply<'ver'>, NumberReply],
77
]>>;
88

9+
function transformModuleReply(moduleReply: any) {
10+
if (Array.isArray(moduleReply)) {
11+
let name: BlobStringReply | undefined;
12+
let ver: NumberReply | undefined;
13+
14+
for (let i = 0; i < moduleReply.length; i += 2) {
15+
const key = moduleReply[i]?.toString();
16+
if (key === 'name') {
17+
name = moduleReply[i + 1];
18+
} else if (key === 'ver') {
19+
ver = moduleReply[i + 1];
20+
}
21+
}
22+
23+
return {
24+
name: name as BlobStringReply,
25+
ver: ver as NumberReply
26+
};
27+
}
28+
29+
if (moduleReply instanceof Map) {
30+
let name: BlobStringReply | undefined;
31+
let ver: NumberReply | undefined;
32+
33+
for (const [key, value] of moduleReply.entries()) {
34+
const normalizedKey = key?.toString();
35+
if (normalizedKey === 'name') {
36+
name = value;
37+
} else if (normalizedKey === 'ver') {
38+
ver = value;
39+
}
40+
}
41+
42+
return {
43+
name: name as BlobStringReply,
44+
ver: ver as NumberReply
45+
};
46+
}
47+
48+
return {
49+
name: moduleReply.name,
50+
ver: moduleReply.ver
51+
};
52+
}
53+
54+
function transformModuleListReply(reply: Array<any>) {
55+
return reply.map(moduleReply => transformModuleReply(moduleReply));
56+
}
57+
958
export default {
1059
NOT_KEYED_COMMAND: true,
1160
IS_READ_ONLY: true,
@@ -19,15 +68,7 @@ export default {
1968
parser.push('MODULE', 'LIST');
2069
},
2170
transformReply: {
22-
2: (reply: UnwrapReply<Resp2Reply<ModuleListReply>>) => {
23-
return reply.map(module => {
24-
const unwrapped = module as unknown as UnwrapReply<typeof module>;
25-
return {
26-
name: unwrapped[1],
27-
ver: unwrapped[3]
28-
};
29-
});
30-
},
31-
3: undefined as unknown as () => ModuleListReply
71+
2: transformModuleListReply as unknown as (reply: UnwrapReply<Resp2Reply<ModuleListReply>>) => ModuleListReply,
72+
3: transformModuleListReply as unknown as () => ModuleListReply
3273
}
3374
} as const satisfies Command;

packages/client/lib/commands/PUBSUB_NUMSUB.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export default {
2626
* @returns Record mapping channel names to their subscriber counts
2727
*/
2828
transformReply(rawReply: UnwrapReply<ArrayReply<BlobStringReply | NumberReply>>) {
29-
const reply = Object.create(null);
29+
const reply: Record<string, any> = {};
3030
let i = 0;
3131
while (i < rawReply.length) {
3232
reply[rawReply[i++].toString()] = Number(rawReply[i++]);

packages/client/lib/commands/PUBSUB_SHARDNUMSUB.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export default {
66
IS_READ_ONLY: true,
77
/**
88
* Constructs the PUBSUB SHARDNUMSUB command
9-
*
9+
*
1010
* @param parser - The command parser
1111
* @param channels - Optional shard channel names to get subscription count for
1212
* @see https://redis.io/commands/pubsub-shardnumsub/
@@ -20,18 +20,17 @@ export default {
2020
},
2121
/**
2222
* Transforms the PUBSUB SHARDNUMSUB reply into a record of shard channel name to subscriber count
23-
*
23+
*
2424
* @param reply - The raw reply from Redis
2525
* @returns Record mapping shard channel names to their subscriber counts
2626
*/
2727
transformReply(reply: UnwrapReply<ArrayReply<BlobStringReply | NumberReply>>) {
28-
const transformedReply: Record<string, NumberReply> = Object.create(null);
28+
const transformedReply: Record<string, NumberReply> = {};
2929

3030
for (let i = 0; i < reply.length; i += 2) {
3131
transformedReply[(reply[i] as BlobStringReply).toString()] = reply[i + 1] as NumberReply;
3232
}
33-
33+
3434
return transformedReply;
3535
}
3636
} as const satisfies Command;
37-

packages/client/lib/commands/VINFO.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export default {
1414
IS_READ_ONLY: true,
1515
/**
1616
* Retrieve metadata and internal details about a vector set, including size, dimensions, quantization type, and graph structure
17-
*
17+
*
1818
* @param parser - The command parser
1919
* @param key - The key of the vector set
2020
* @see https://redis.io/commands/vinfo/
@@ -25,7 +25,7 @@ export default {
2525
},
2626
transformReply: {
2727
2: (reply: UnwrapReply<Resp2Reply<VInfoReplyMap>>): VInfoReplyMap => {
28-
const ret = Object.create(null);
28+
const ret: Record<string, any> = {};
2929

3030
for (let i = 0; i < reply.length; i += 2) {
3131
ret[reply[i].toString()] = reply[i + 1];

0 commit comments

Comments
 (0)