From f94d60805ac63a19d290fb41b62231cb38a71216 Mon Sep 17 00:00:00 2001 From: Hashim Khan Date: Mon, 27 Jul 2026 01:36:03 +0500 Subject: [PATCH 1/3] feat: add COMMAND DOCS command support Fixes #1934 --- .../client/lib/commands/COMMAND_DOCS.spec.ts | 132 ++++++++++++++++++ packages/client/lib/commands/COMMAND_DOCS.ts | 113 +++++++++++++++ packages/client/lib/commands/index.ts | 13 ++ 3 files changed, 258 insertions(+) create mode 100644 packages/client/lib/commands/COMMAND_DOCS.spec.ts create mode 100644 packages/client/lib/commands/COMMAND_DOCS.ts diff --git a/packages/client/lib/commands/COMMAND_DOCS.spec.ts b/packages/client/lib/commands/COMMAND_DOCS.spec.ts new file mode 100644 index 00000000000..0e33b1fdac1 --- /dev/null +++ b/packages/client/lib/commands/COMMAND_DOCS.spec.ts @@ -0,0 +1,132 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import COMMAND_DOCS from './COMMAND_DOCS'; +import { parseArgs } from './generic-transformers'; + +describe('COMMAND DOCS', () => { + testUtils.isVersionGreaterThanHook([7]); + + describe('transformArguments', () => { + it('simple', () => { + assert.deepEqual( + parseArgs(COMMAND_DOCS), + ['COMMAND', 'DOCS'] + ); + }); + + it('string', () => { + assert.deepEqual( + parseArgs(COMMAND_DOCS, 'GET'), + ['COMMAND', 'DOCS', 'GET'] + ); + }); + + it('array', () => { + assert.deepEqual( + parseArgs(COMMAND_DOCS, ['GET', 'SET']), + ['COMMAND', 'DOCS', 'GET', 'SET'] + ); + }); + }); + + describe('transformReply', () => { + it('RESP2 map with nested arguments', () => { + assert.deepEqual( + COMMAND_DOCS.transformReply[2]([ + 'get', + [ + 'summary', 'Get the value of a key', + 'since', '1.0.0', + 'group', 'string', + 'complexity', 'O(1)', + 'history', [['2.2.3', 'Added MSET']], + 'arguments', [ + [ + 'name', 'key', + 'type', 'key', + 'display_text', 'key', + 'key_spec_index', 0 + ] + ] + ], + 'command', + [ + 'summary', 'Get or set information about commands', + 'since', '2.8.13', + 'group', 'server', + 'complexity', 'O(N)', + 'subcommands', [ + 'command|docs', + [ + 'summary', 'Returns documentary information about commands', + 'since', '7.0.0', + 'group', 'server', + 'complexity', 'O(N)' + ] + ] + ] + ] as never), + { + get: { + summary: 'Get the value of a key', + since: '1.0.0', + group: 'string', + complexity: 'O(1)', + history: [['2.2.3', 'Added MSET']], + arguments: [{ + name: 'key', + type: 'key', + display_text: 'key', + key_spec_index: 0 + }] + }, + command: { + summary: 'Get or set information about commands', + since: '2.8.13', + group: 'server', + complexity: 'O(N)', + subcommands: { + 'command|docs': { + summary: 'Returns documentary information about commands', + since: '7.0.0', + group: 'server', + complexity: 'O(N)' + } + } + } + } + ); + }); + }); + + testUtils.testWithClient('client.commandDocs', async client => { + const docs = await client.commandDocs(); + assert.equal(typeof docs, 'object'); + assert.ok(docs !== null); + assert.ok(!Array.isArray(docs)); + + const get = docs['get']; + assert.ok(get); + assert.equal(typeof get.summary, 'string'); + assert.equal(typeof get.since, 'string'); + assert.equal(typeof get.group, 'string'); + assert.equal(typeof get.complexity, 'string'); + assert.ok(Array.isArray(get.arguments)); + assert.ok(get.arguments!.length > 0); + assert.equal(typeof get.arguments![0].name, 'string'); + assert.equal(typeof get.arguments![0].type, 'string'); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [7] + }); + + testUtils.testWithClient('client.commandDocs with command filter', async client => { + const docs = await client.commandDocs(['GET', 'SET']); + assert.ok('get' in docs); + assert.ok('set' in docs); + assert.equal(Object.keys(docs).length, 2); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [7] + }); +}); diff --git a/packages/client/lib/commands/COMMAND_DOCS.ts b/packages/client/lib/commands/COMMAND_DOCS.ts new file mode 100644 index 00000000000..9efac19603d --- /dev/null +++ b/packages/client/lib/commands/COMMAND_DOCS.ts @@ -0,0 +1,113 @@ +import { CommandParser } from '../client/parser'; +import { ArrayReply, BlobStringReply, Command, MapReply, NumberReply, ReplyUnion, Resp2Reply, TuplesReply, UnwrapReply } from '../RESP/types'; +import { RedisVariadicArgument } from './generic-transformers'; + +export interface CommandDocsArgument { + name?: BlobStringReply; + type?: BlobStringReply; + display_text?: BlobStringReply; + key_spec_index?: NumberReply; + token?: BlobStringReply; + summary?: BlobStringReply; + since?: BlobStringReply; + deprecated_since?: BlobStringReply; + flags?: ArrayReply; + arguments?: ArrayReply; +} + +export interface CommandDoc { + summary?: BlobStringReply; + since?: BlobStringReply; + group?: BlobStringReply; + complexity?: BlobStringReply; + module?: BlobStringReply; + doc_flags?: ArrayReply; + deprecated_since?: BlobStringReply; + replaced_by?: BlobStringReply; + history?: ArrayReply>; + arguments?: ArrayReply; + subcommands?: CommandDocsReply; + reply_schema?: ReplyUnion; +} + +export type CommandDocsReply = MapReply; + +function transformArgument(reply: unknown): CommandDocsArgument { + if (!Array.isArray(reply)) { + return reply as CommandDocsArgument; + } + + const argument: Record = {}; + for (let i = 0; i < reply.length; i += 2) { + const key = (reply[i] as { toString(): string }).toString(); + const value = reply[i + 1]; + + if (key === 'arguments' && Array.isArray(value)) { + argument.arguments = value.map(transformArgument); + } else { + argument[key] = value; + } + } + + return argument as CommandDocsArgument; +} + +function transformDoc(reply: unknown): CommandDoc { + if (!Array.isArray(reply)) { + return reply as CommandDoc; + } + + const doc: Record = {}; + for (let i = 0; i < reply.length; i += 2) { + const key = (reply[i] as { toString(): string }).toString(); + const value = reply[i + 1]; + + switch (key) { + case 'arguments': + doc.arguments = Array.isArray(value) ? value.map(transformArgument) : value; + break; + case 'subcommands': + doc.subcommands = transformDocsMap(value); + break; + case 'reply_schema': + doc.reply_schema = Array.isArray(value) ? transformDoc(value) : value; + break; + default: + doc[key] = value; + } + } + + return doc as CommandDoc; +} + +function transformDocsMap(reply: unknown): CommandDocsReply { + if (!Array.isArray(reply)) { + return reply as CommandDocsReply; + } + + const docs: Record = {}; + for (let i = 0; i < reply.length; i += 2) { + const name = (reply[i] as { toString(): string }).toString(); + docs[name] = transformDoc(reply[i + 1]); + } + + return docs as unknown as CommandDocsReply; +} + +export default { + NOT_KEYED_COMMAND: true, + IS_READ_ONLY: true, + /** + * @see https://redis.io/commands/command-docs/ + */ + parseCommand(parser: CommandParser, commands?: RedisVariadicArgument) { + parser.push('COMMAND', 'DOCS'); + if (commands !== undefined) { + parser.pushVariadic(commands); + } + }, + transformReply: { + 2: (reply: UnwrapReply>) => transformDocsMap(reply), + 3: undefined as unknown as () => CommandDocsReply + } +} as const satisfies Command; diff --git a/packages/client/lib/commands/index.ts b/packages/client/lib/commands/index.ts index 3ae4f4485ff..2a0e33b4969 100644 --- a/packages/client/lib/commands/index.ts +++ b/packages/client/lib/commands/index.ts @@ -90,6 +90,7 @@ import CLUSTER_SET_CONFIG_EPOCH from './CLUSTER_SET-CONFIG-EPOCH'; import CLUSTER_SETSLOT, { CLUSTER_SLOT_STATES } from './CLUSTER_SETSLOT'; import CLUSTER_SLOTS from './CLUSTER_SLOTS'; import COMMAND_COUNT from './COMMAND_COUNT'; +import COMMAND_DOCS from './COMMAND_DOCS'; import COMMAND_GETKEYS from './COMMAND_GETKEYS'; import COMMAND_GETKEYSANDFLAGS from './COMMAND_GETKEYSANDFLAGS'; import COMMAND_INFO from './COMMAND_INFO'; @@ -1449,6 +1450,18 @@ export default { * @param args - Command arguments to analyze */ commandGetKeysAndFlags: COMMAND_GETKEYSANDFLAGS, + /** + * Returns documentary information about one, multiple, or all Redis commands + * @param commands - Optional command name or names to get documentation for + * @since 7.0.0 + */ + COMMAND_DOCS, + /** + * Returns documentary information about one, multiple, or all Redis commands + * @param commands - Optional command name or names to get documentation for + * @since 7.0.0 + */ + commandDocs: COMMAND_DOCS, /** * Returns details about specific Redis commands * @param commands - Array of command names to get information about From fd3f52841d0fb19ade36f5fdd856e4d52723ce27 Mon Sep 17 00:00:00 2001 From: Hashim1999164 Date: Mon, 27 Jul 2026 20:57:40 +0500 Subject: [PATCH 2/3] fix(COMMAND_DOCS): honor MAP typeMapping and type arguments Address review feedback: preserve Map/Array type mappings in the RESP2 transform, and type nested arguments as CommandDocsArgument. --- packages/client/lib/commands/COMMAND_DOCS.ts | 50 ++++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/packages/client/lib/commands/COMMAND_DOCS.ts b/packages/client/lib/commands/COMMAND_DOCS.ts index 9efac19603d..f9ead4a18f2 100644 --- a/packages/client/lib/commands/COMMAND_DOCS.ts +++ b/packages/client/lib/commands/COMMAND_DOCS.ts @@ -1,5 +1,6 @@ import { CommandParser } from '../client/parser'; -import { ArrayReply, BlobStringReply, Command, MapReply, NumberReply, ReplyUnion, Resp2Reply, TuplesReply, UnwrapReply } from '../RESP/types'; +import { ArrayReply, BlobStringReply, Command, MapReply, NumberReply, ReplyUnion, Resp2Reply, TuplesReply, TypeMapping, UnwrapReply } from '../RESP/types'; +import { RESP_TYPES } from '../RESP/decoder'; import { RedisVariadicArgument } from './generic-transformers'; export interface CommandDocsArgument { @@ -12,7 +13,7 @@ export interface CommandDocsArgument { since?: BlobStringReply; deprecated_since?: BlobStringReply; flags?: ArrayReply; - arguments?: ArrayReply; + arguments?: ArrayReply; } export interface CommandDoc { @@ -25,7 +26,7 @@ export interface CommandDoc { deprecated_since?: BlobStringReply; replaced_by?: BlobStringReply; history?: ArrayReply>; - arguments?: ArrayReply; + arguments?: ArrayReply; subcommands?: CommandDocsReply; reply_schema?: ReplyUnion; } @@ -52,7 +53,7 @@ function transformArgument(reply: unknown): CommandDocsArgument { return argument as CommandDocsArgument; } -function transformDoc(reply: unknown): CommandDoc { +function transformDoc(reply: unknown, typeMapping?: TypeMapping): CommandDoc { if (!Array.isArray(reply)) { return reply as CommandDoc; } @@ -67,10 +68,10 @@ function transformDoc(reply: unknown): CommandDoc { doc.arguments = Array.isArray(value) ? value.map(transformArgument) : value; break; case 'subcommands': - doc.subcommands = transformDocsMap(value); + doc.subcommands = transformDocsMap(value, typeMapping); break; case 'reply_schema': - doc.reply_schema = Array.isArray(value) ? transformDoc(value) : value; + doc.reply_schema = Array.isArray(value) ? transformDoc(value, typeMapping) : value; break; default: doc[key] = value; @@ -80,18 +81,38 @@ function transformDoc(reply: unknown): CommandDoc { return doc as CommandDoc; } -function transformDocsMap(reply: unknown): CommandDocsReply { +function transformDocsMap(reply: unknown, typeMapping?: TypeMapping): CommandDocsReply { if (!Array.isArray(reply)) { return reply as CommandDocsReply; } - const docs: Record = {}; - for (let i = 0; i < reply.length; i += 2) { - const name = (reply[i] as { toString(): string }).toString(); - docs[name] = transformDoc(reply[i + 1]); - } + const mapType = typeMapping ? typeMapping[RESP_TYPES.MAP] : undefined; - return docs as unknown as CommandDocsReply; + switch (mapType) { + case Array: { + const ret: unknown[] = []; + for (let i = 0; i < reply.length; i += 2) { + ret.push(reply[i]); + ret.push(transformDoc(reply[i + 1], typeMapping)); + } + return ret as unknown as CommandDocsReply; + } + case Map: { + const ret = new Map(); + for (let i = 0; i < reply.length; i += 2) { + ret.set((reply[i] as { toString(): string }).toString(), transformDoc(reply[i + 1], typeMapping)); + } + return ret as unknown as CommandDocsReply; + } + default: { + const docs: Record = {}; + for (let i = 0; i < reply.length; i += 2) { + const name = (reply[i] as { toString(): string }).toString(); + docs[name] = transformDoc(reply[i + 1], typeMapping); + } + return docs as unknown as CommandDocsReply; + } + } } export default { @@ -107,7 +128,8 @@ export default { } }, transformReply: { - 2: (reply: UnwrapReply>) => transformDocsMap(reply), + 2: (reply: UnwrapReply>, _preserve?: unknown, typeMapping?: TypeMapping) => + transformDocsMap(reply, typeMapping), 3: undefined as unknown as () => CommandDocsReply } } as const satisfies Command; From 12dbaa11fea42c3fb52a46e4f1b4104cea4a64db Mon Sep 17 00:00:00 2001 From: Hashim Khan Date: Wed, 29 Jul 2026 18:57:01 +0500 Subject: [PATCH 3/3] fix(COMMAND_DOCS): map nested RESP2 replies consistently --- .../client/lib/commands/COMMAND_DOCS.spec.ts | 22 ++++ packages/client/lib/commands/COMMAND_DOCS.ts | 101 +++++++----------- 2 files changed, 63 insertions(+), 60 deletions(-) diff --git a/packages/client/lib/commands/COMMAND_DOCS.spec.ts b/packages/client/lib/commands/COMMAND_DOCS.spec.ts index 0e33b1fdac1..c97913214e6 100644 --- a/packages/client/lib/commands/COMMAND_DOCS.spec.ts +++ b/packages/client/lib/commands/COMMAND_DOCS.spec.ts @@ -1,5 +1,6 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; +import { RESP_TYPES } from '../RESP/decoder'; import COMMAND_DOCS from './COMMAND_DOCS'; import { parseArgs } from './generic-transformers'; @@ -120,6 +121,27 @@ describe('COMMAND DOCS', () => { minimumDockerVersion: [7] }); + testUtils.testWithClient('client.commandDocs with RESP2 and Map type mapping', async client => { + const docs = await client + .withTypeMapping({ [RESP_TYPES.MAP]: Map }) + .commandDocs('GET'); + + assert.ok(docs instanceof Map); + const get = docs.get('get'); + assert.ok(get instanceof Map); + + const argumentsReply = get.get('arguments'); + assert.ok(Array.isArray(argumentsReply)); + assert.ok(argumentsReply[0] instanceof Map); + assert.equal(argumentsReply[0].get('name'), 'key'); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 2 + }, + minimumDockerVersion: [7] + }); + testUtils.testWithClient('client.commandDocs with command filter', async client => { const docs = await client.commandDocs(['GET', 'SET']); assert.ok('get' in docs); diff --git a/packages/client/lib/commands/COMMAND_DOCS.ts b/packages/client/lib/commands/COMMAND_DOCS.ts index f9ead4a18f2..cfee18ec406 100644 --- a/packages/client/lib/commands/COMMAND_DOCS.ts +++ b/packages/client/lib/commands/COMMAND_DOCS.ts @@ -33,90 +33,71 @@ export interface CommandDoc { export type CommandDocsReply = MapReply; -function transformArgument(reply: unknown): CommandDocsArgument { +function transformMap( + reply: unknown, + typeMapping: TypeMapping | undefined, + transformValue: (key: string, value: unknown) => unknown +): unknown { if (!Array.isArray(reply)) { - return reply as CommandDocsArgument; + return reply; } - const argument: Record = {}; + const entries: Array<[string, unknown]> = []; for (let i = 0; i < reply.length; i += 2) { const key = (reply[i] as { toString(): string }).toString(); - const value = reply[i + 1]; + entries.push([key, transformValue(key, reply[i + 1])]); + } - if (key === 'arguments' && Array.isArray(value)) { - argument.arguments = value.map(transformArgument); - } else { - argument[key] = value; + switch (typeMapping?.[RESP_TYPES.MAP]) { + case Array: + return entries.flat(); + case Map: + return new Map(entries); + default: { + const object: Record = {}; + for (const [key, value] of entries) { + object[key] = value; + } + return object; } } +} - return argument as CommandDocsArgument; +function transformArgument(reply: unknown, typeMapping?: TypeMapping): CommandDocsArgument { + return transformMap(reply, typeMapping, (key, value) => { + if (key === 'arguments' && Array.isArray(value)) { + return value.map(argument => transformArgument(argument, typeMapping)); + } + return value; + }) as CommandDocsArgument; } function transformDoc(reply: unknown, typeMapping?: TypeMapping): CommandDoc { - if (!Array.isArray(reply)) { - return reply as CommandDoc; - } - - const doc: Record = {}; - for (let i = 0; i < reply.length; i += 2) { - const key = (reply[i] as { toString(): string }).toString(); - const value = reply[i + 1]; - + return transformMap(reply, typeMapping, (key, value) => { switch (key) { case 'arguments': - doc.arguments = Array.isArray(value) ? value.map(transformArgument) : value; - break; + return Array.isArray(value) + ? value.map(argument => transformArgument(argument, typeMapping)) + : value; case 'subcommands': - doc.subcommands = transformDocsMap(value, typeMapping); - break; + return transformDocsMap(value, typeMapping); case 'reply_schema': - doc.reply_schema = Array.isArray(value) ? transformDoc(value, typeMapping) : value; - break; + return value; default: - doc[key] = value; + return value; } - } - - return doc as CommandDoc; + }) as CommandDoc; } function transformDocsMap(reply: unknown, typeMapping?: TypeMapping): CommandDocsReply { - if (!Array.isArray(reply)) { - return reply as CommandDocsReply; - } - - const mapType = typeMapping ? typeMapping[RESP_TYPES.MAP] : undefined; - - switch (mapType) { - case Array: { - const ret: unknown[] = []; - for (let i = 0; i < reply.length; i += 2) { - ret.push(reply[i]); - ret.push(transformDoc(reply[i + 1], typeMapping)); - } - return ret as unknown as CommandDocsReply; - } - case Map: { - const ret = new Map(); - for (let i = 0; i < reply.length; i += 2) { - ret.set((reply[i] as { toString(): string }).toString(), transformDoc(reply[i + 1], typeMapping)); - } - return ret as unknown as CommandDocsReply; - } - default: { - const docs: Record = {}; - for (let i = 0; i < reply.length; i += 2) { - const name = (reply[i] as { toString(): string }).toString(); - docs[name] = transformDoc(reply[i + 1], typeMapping); - } - return docs as unknown as CommandDocsReply; - } - } + return transformMap( + reply, + typeMapping, + (_key, value) => transformDoc(value, typeMapping) + ) as CommandDocsReply; } export default { - NOT_KEYED_COMMAND: true, IS_READ_ONLY: true, /** * @see https://redis.io/commands/command-docs/