--- name: implement-command description: Add a new Redis command (or command variant) to node-redis end-to-end — the `.ts` Command file, its registration with JSDoc in the package `commands/index.ts`, and a co-located `.spec.ts` with arg + behavior tests. Use when asked to implement, add, or wire up a Redis command in the client or a module package (json/search/bloom/time-series). --- # Implement a node-redis Command ## Overview A command in node-redis is a single `Command` object exported from `packages//lib/commands/.ts`. It declares how to serialize arguments onto the wire (`parseCommand`) and how to map the RESP reply to a JS value (`transformReply`). It becomes callable on clients only after it is registered in the package's `commands/index.ts`. Both the raw name and a camelCase alias are exposed (`HSET` and `hSet`). This skill is for `@redis/client` core commands and for module packages (`@redis/json`, `@redis/search`, `@redis/bloom`, `@redis/time-series`). It does not cover RESP codec changes or new client transports. Before writing, **read 2-3 existing commands with a similar shape** (simple key read, key + options, variadic, RESP2/3-divergent reply) and mirror them. Consult [redis.io/commands](https://redis.io/commands) for argument order and reply type, but treat existing `parseCommand`/`transformReply` files as the source of truth for repo conventions. ## File layout | Package | Command dir | Import paths | Wire name prefix | | --- | --- | --- | --- | | `client` | `packages/client/lib/commands/` | `../client/parser`, `../RESP/types`, `./generic-transformers` | none (`GET`) | | `json` | `packages/json/lib/commands/` | `@redis/client/dist/lib/...` | `JSON.` (`JSON.ARRAPPEND`) | | `search` | `packages/search/lib/commands/` | `@redis/client/dist/lib/...` | `FT.` | | `bloom` | `packages/bloom/lib/commands//` | `@redis/client/dist/lib/...` | e.g. `BF.`, `CF.`, `TOPK.` | | `time-series` | `packages/time-series/lib/commands/` | `@redis/client/dist/lib/...` | `TS.` | **Naming**: file name = raw wire name with subcommand `_` separators (`ACL_CAT`, `CONFIG_GET`, `CLUSTER_FORGET`). A distinct reply variant gets its own file (`HRANDFIELD_COUNT_WITHVALUES`). Module commands drop the dotted prefix from the file name (`ARRAPPEND.ts` → wire `JSON.ARRAPPEND`). ## Step 0 — Gather inputs (ask the user first) New commands are often implemented **before they are publicly released**, so redis.io may not document them yet and a default `redis:latest` may not have them. Before writing any code, ask the user for three things: 1. **The command spec.** Ask for the redis/redis JSON spec file — one per command under [`src/commands/.json`](https://github.com/redis/redis/tree/unstable/src/commands) (subcommands use `-`, e.g. `client-info.json`, `acl-cat.json`). If the command is unreleased, ask the user to paste the spec from their branch. As a fallback on a live server: `redis-cli --json COMMAND DOCS ` and `COMMAND INFO `. 2. **A running Redis instance that has the command.** This is the single most useful input — ask for it explicitly and up front. Ask for connection details (host/port, TLS, auth, module loaded). Use it to explore real behavior and confirm the implementation matches the spec — **do not rely on the spec alone**. Probe every argument branch and diff the real reply against your `transformReply`. A quick `redis-cli` session or a throwaway probe script (`packages/client` is already wired for `tsx`) is enough; never commit the probe. 3. **The first server version that ships the command.** Ask which Redis (or module) version introduced it — the spec's `since` field is the answer when present; otherwise ask the user directly. You need this to (a) write `@since` in the JSDoc (Step 2) and (b) gate the behavior tests with `minimumDockerVersion` (Step 3) so they don't run — and fail — on older servers in CI. If the user cannot provide a spec, derive arguments/reply from [redis.io/commands](https://redis.io/commands) but flag that it is unverified. If they cannot provide a live instance, implement from the spec but state that runtime behavior was not confirmed. If the introducing version is unknown, say so and leave `@since`/`minimumDockerVersion` out rather than guessing. ### Reading the spec JSON → mapping to a Command The redis/redis spec drives every part of the `Command` object. Example (`getex.json`, trimmed): ```json { "GETEX": { "since": "6.2.0", "arity": -2, "command_flags": ["WRITE", "FAST"], "key_specs": [{ "begin_search": { "index": { "pos": 1 } }, "flags": ["RW", "UPDATE"] }], "arguments": [ { "name": "key", "type": "key", "key_spec_index": 0 }, { "name": "expiration", "type": "oneof", "optional": true, "arguments": [ { "name": "seconds", "type": "integer", "token": "EX" }, { "name": "persist", "type": "pure-token", "token": "PERSIST" } ]} ], "reply_schema": { "oneOf": [ { "type": "string" }, { "type": "null" } ] } } } ``` | Spec field | Drives | | --- | --- | | `command_flags` contains `READONLY` (and **not** `WRITE`) | `IS_READ_ONLY: true`. `WRITE` → omit it. Pure read with no side effects → also `CACHEABLE: true`. For keyed commands the generated metadata table already implies these defaults, so module packages omit the flags; keyless reads must set `IS_READ_ONLY: true` explicitly (Step 4). | | `key_specs` empty / no `key`-type args | `NOT_KEYED_COMMAND: true`. | | `arguments[].type: "key"` | `parser.pushKey(...)` (one per key, in spec order). | | `type: "pure-token"` + `token` | a literal flag pushed only when its option is set (`parser.push('PERSIST')`). | | `token` + value type (`integer`/`string`/...) | push the token then the stringified value (`parser.push('EX', seconds.toString())`). | | `type: "oneof"` | mutually exclusive branch → `if/else if` in `parseCommand`; model as a union/`options` field. | | `optional: true` | goes in the `options` object (exported `interface`); guard with `if (options?.x)`. | | `multiple: true` | variadic → `parser.pushVariadic*`. | | `arity` | sanity-check arg count in `parseArgs` tests. | | `reply_schema` (JSON Schema) | the `transformReply` return type. `oneOf [string, null]` → `BlobStringReply \| NullReply`; `integer` → `NumberReply`; `array` → `ArrayReply<...>`; a map differing by RESP version → keyed `transformReply: { 2, 3 }`. | | `since` | the introducing server version → `@since` in the registry JSDoc (Step 2) **and** `minimumDockerVersion: [major, minor]` on the behavior tests (Step 3). | After implementing, run the command against the live instance and diff the real reply against `reply_schema` and your `transformReply` output. ## Step 1 — Write `.ts` Minimal pass-through command (`packages/client/lib/commands/GET.ts`): ```typescript import { CommandParser } from '../client/parser'; import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types'; export default { CACHEABLE: true, IS_READ_ONLY: true, parseCommand(parser: CommandParser, key: RedisArgument) { parser.push('GET'); parser.pushKey(key); }, transformReply: undefined as unknown as () => BlobStringReply | NullReply } as const satisfies Command; ``` `as const satisfies Command` is mandatory — it preserves the literal arg types for the public API while type-checking the shape. ### Command flags (all optional) - `IS_READ_ONLY: true` — read command; routable to replicas. Set for reads, omit/`false` for writes. - `CACHEABLE: true` — eligible for client-side caching. Only for pure reads with no side effects. - `NOT_KEYED_COMMAND: true` — command takes no key (server/connection level, e.g. `PING`, `CONFIG_GET`). - `IS_FORWARD_COMMAND` — internal; do not set on new commands. ### `parseCommand` — serialize args via `CommandParser` First arg is always `parser`. Push the wire name first, then args in order. Use the parser helpers — **do not** hand-build arrays: - `push(...args)` — raw args (the command token, flags, stringified numbers). - `pushKey(key)` — a key. Registers it for cluster slot routing. Use for **every** key, never `push` a key. - `pushKeys(keys)` / `pushKeysLength(keys)` — multiple keys; the `Length` variant prefixes the count. - `pushVariadic(vals)` — a `RedisVariadicArgument` (one value or array) as flat args. - `pushVariadicWithLength(vals)` — same, prefixed with the count (e.g. `FIELDS ...`). - `pushVariadicNumber(vals)` — number or array of numbers, stringified. Numbers are not auto-stringified by `push` — call `.toString()`. Optional trailing args go in an `options` object; export its `interface` (see `SET.ts`'s `SetOptions`). Encode keyword flags conditionally: ```typescript parseCommand(parser: CommandParser, key: RedisArgument, value: RedisArgument, options?: SetOptions) { parser.push('SET'); parser.pushKey(key); parser.push(value); if (options?.condition) parser.push(options.condition); // 'NX' | 'XX' } ``` ### `transformReply` — map RESP reply to JS - **Pass-through** (reply already the right shape): `transformReply: undefined as unknown as () => `. - **Function**: `(reply: ) => `. Use `UnwrapReply<...>` to read the raw RESP container. - **RESP-version keyed**: `{ 2: (reply) => ..., 3: (reply) => ... }` when RESP2 and RESP3 shapes differ (e.g. flat array vs map/tuple). See `HRANDFIELD_COUNT_WITHVALUES.ts`. #### Unify RESP2 onto the RESP3 shape When the server returns different shapes per protocol, the library exposes **one return type to callers**: the **RESP3 shape is the source of truth**, and the RESP2 reply is transformed to look like it. So the keyed form is almost always: - `3:` — **pass-through** (`undefined as unknown as () => `), because RESP3 already has the target shape (map, tuple, big-number, double, ...). - `2:` — a **function** that reshapes the flat/legacy RESP2 reply into that same ``. Type its input `UnwrapReply>` so the raw RESP2 container is visible while the output type still matches RESP3. Canonical example — `HELLO.ts` turns the RESP2 flat array (`[k, v, k, v, ...]`) into the RESP3 map, while RESP3 passes through: ```typescript transformReply: { 2: (reply: UnwrapReply>) => ({ server: reply[1], version: reply[3], proto: reply[5], /* ... */ }), 3: undefined as unknown as () => HelloReply } ``` Reuse shared transformers where one exists (`HGETALL.ts` uses `transformTuplesReply` for `2:`, map pass-through for `3:`). Only when RESP3 still needs reshaping does `3:` get its own function too. Verify the actual per-protocol shapes against the live instance (Step 0) — connect once with `RESP: 2` and once with `RESP: 3` and diff. Reply types live in `RESP/types`: `BlobStringReply`, `SimpleStringReply<'OK'>`, `NumberReply`, `DoubleReply`, `NullReply`, `BooleanReply`, `ArrayReply`, `TuplesReply<[...]>`, `MapReply`, `UnwrapReply`. **RESP3 is the default.** No separate RESP3 test is needed for a new command; the default test setup already exercises RESP3. ### Type-mapping precision caveats - A `BLOB_STRING` reply cannot be remapped to `Number` via type mapping; only RESP3 `DOUBLE`/`BIG_NUMBER` are precision-risky. - If a `NumberReply` can exceed `Number.MAX_SAFE_INTEGER` (2^53-1), add a `@remarks` line to the JSDoc (Step 2) telling users to do `client.withTypeMapping({ [RESP_TYPES.NUMBER]: String })`. See the `ARGREP` entries in the client index for the exact wording. ### Module package commands Import from the published client subpath, prefix the wire name, and reuse shared transformers (`packages/json/lib/commands/ARRAPPEND.ts`): ```typescript import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, NumberReply, Command } from '@redis/client/dist/lib/RESP/types'; export default { IS_READ_ONLY: false, parseCommand(parser, key, path, value) { parser.push('JSON.ARRAPPEND'); parser.pushKey(key); parser.push(path, /* transform */ value); }, transformReply: undefined as unknown as () => NumberReply } as const satisfies Command; ``` ## Step 2 — Register in `commands/index.ts` `import` the command, then add it to the default-export map **twice**: the raw name (shorthand) and a camelCase alias. **Every entry MUST have a JSDoc block directly above it** — `npm run check:command-jsdoc` fails on any registry entry without an attached JSDoc comment (no blank-line gap allowed). ```typescript import GET from './GET'; // ... export default { /** * Returns the value of a key, or null if the key does not exist * @param key - Key to read * @since 1.0.0 */ GET, /** * Returns the value of a key, or null if the key does not exist * @param key - Key to read * @since 1.0.0 */ get: GET, } satisfies RedisCommands; ``` Keep both JSDoc blocks (raw + alias) in sync. Document every `parseCommand` param after `parser` with `@param`. Add `@since ` with the introducing server version from Step 0 (the spec's `since`); omit it only if that version is unknown. Add `@remarks` for the precision caveat above when relevant. For module packages the registry files are `packages//lib/commands/index.ts` (bloom: per-family `...//index.ts`). ## Step 3 — Write `.spec.ts` (co-located) Two layers: arg serialization (no server) + behavior (real server, server + cluster topologies). Mirror `GET.spec.ts`: ```typescript import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; import { parseArgs } from './generic-transformers'; import GET from './GET'; describe('GET', () => { it('transformArguments', () => { assert.deepEqual(parseArgs(GET, 'key'), ['GET', 'key']); }); testUtils.testAll('get', async client => { assert.equal(await client.get('key'), null); }, { client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 8] }, cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 8] } }); }); ``` - `parseArgs(COMMAND, ...args)` asserts the exact wire array — cover each option/flag branch and variadic shapes. Arg tests need no server, so **never gate them** by version. - `testUtils.testAll(name, fn, { client, cluster })` runs the same body against a standalone server and a cluster. Use it so cluster key routing (`pushKey`) is exercised. Drop `cluster` only when the command is genuinely cluster-incompatible. - **Gate every behavior test by the introducing version** (Step 0). Spread `minimumDockerVersion: [major, minor]` into **both** the `client` and `cluster` options (as above). CI runs multiple server versions; without the gate the test runs on older servers that lack the command and fails. `[8, 8]` = "8.8 and newer". Apply the same to `testWithClient`/`testWithCluster` by spreading it into their single options object. Omit only if the version is genuinely unknown. - Pick the right `GLOBAL.SERVERS.*` / `GLOBAL.CLUSTERS.*` setup (see `test-utils.ts`); `OPEN` is the default. - **Docker is required** — test-utils starts real Redis containers. ## Step 4 — Regenerate static command metadata Cluster/sentinel routing (replica-safety, keyedness, CSC eligibility) is resolved from a generated table: `packages/client/lib/command-metadata/command-metadata-data.ts`. The file is auto-generated — **never edit it manually**. The table lives in `@redis/client`, but the `COMMAND` dump includes module commands (`ft`, `json`, `bf`, `ts`, ...), so a new command in **any** package needs a regenerate: ```bash npm run generate:metadata --workspace=packages/client -- redis://localhost:6379 ``` The script rebuilds the **entire** table from a single live server's `COMMAND` reply and overwrites the file — entries the server doesn't report are silently dropped. Run it only against a server with **all** bundled modules loaded and a current core command set (e.g. the CI image `redislabs/client-libs-test` or a full Redis 8.8+ build); reuse the Step 0 instance only if it meets that bar — a server with just the new command's module would wipe every other module's metadata. The script then applies the curation in `packages/client/scripts/command-metadata-overrides.ts`: hand-curated excludes (internal, deprecated and cluster-admin commands) plus per-command routing overrides. After regenerating, verify the new command has an entry with the expected flags, **and** check `git diff` on `command-metadata-data.ts`: it must contain only the intended additions/changes. Deletions of other modules' or core entries mean the source server was incomplete — revert and rerun against a full build. How the table and the command object interact (override-first — see `lib/command-metadata/predicates.ts`): - The table sets **defaults only**, for both replica routing (cluster and sentinel) and client-side caching eligibility. A keyed entry without the `write` flag is already replica-safe, so a keyed read command with a correct table entry needs no `IS_READ_ONLY` on the command object — bloom and json omit it everywhere. - `IS_READ_ONLY`/`CACHEABLE` on the command object **win over the table**. Set them as deliberate corrections, not to restate the table. The main case is keyless reads, which default to master routing: `PING`/`INFO` in the client, and module reads whose args are not keys — search and time-series set `IS_READ_ONLY: true` on exactly their keyless commands (`FT.SEARCH` takes an index name, `TS.MGET`/`TS.MRANGE` take filters, ...). - Table-shape fixes (wrong, missing or excluded entries, routing policies) belong in `command-metadata-overrides.ts`; value intent (`IS_READ_ONLY`, `CACHEABLE`) belongs in the command definition, never in the overrides file. ## Step 5 — Build, verify, lint ```bash npm run build # tsc --build (project references) npm run check:command-jsdoc # registry JSDoc gate npm run test-single -- packages//lib/commands/.spec.ts npm run lint # changed files ``` If the build fails on stale `dist/` from project references: ```bash find packages -type d -name "dist" -exec rm -rf {} + && npm run build ``` For module packages, build the client first (or whole repo) — they import from `@redis/client/dist`. ## Completion checklist - [ ] Asked the user for spec, a live instance with the command, and the introducing server version (Step 0); probed real behavior against the live instance. - [ ] `.ts` created with `parseCommand` + `transformReply`, `as const satisfies Command`. - [ ] Flags set correctly (`IS_READ_ONLY` for reads, `CACHEABLE` only for side-effect-free reads, `NOT_KEYED_COMMAND` if no key; omit flags the metadata table already implies — Step 4). - [ ] Every key uses `pushKey`/`pushKeys`; numbers stringified; options behind an exported `interface`. - [ ] RESP2/3 divergence handled via keyed `transformReply`: RESP3 is the target shape (usually `3:` pass-through), RESP2 transformed to match it; both shapes verified against the live instance. - [ ] Registered in `commands/index.ts`: import + raw entry + camelCase alias, **each with JSDoc** (`@param` per arg; `@since` for the introducing version; `@remarks` for >2^53 precision). - [ ] Static command metadata regenerated (`npm run generate:metadata`) against a server with **all** bundled modules; the new command's entry verified and the diff contains no dropped entries (Step 4). - [ ] `.spec.ts`: `parseArgs` covers all branches; `testUtils.testAll` covers server + cluster; behavior tests gated with `minimumDockerVersion` on both `client` and `cluster`. - [ ] `npm run build`, `npm run check:command-jsdoc`, the spec, and `npm run lint` all pass. - [ ] Commit message uses Conventional Commits; no company-internal refs. ```