From 52ce442ec6e999030dfa319aa0eff1e7200880c3 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 14 Jul 2026 15:17:54 +0300 Subject: [PATCH 1/5] feat(client): add LMOVEM and BLMOVEM commands Add LMOVEM and its blocking variant BLMOVEM to move multiple elements between two lists in a single command, closing the gap where only one element could be moved (LMOVE/BLMOVE) while the pop family already supported many. Options are modeled as a discriminated union so COUNT and EXACTLY are mutually exclusive at the type level, each with an optional OBO|BULK ordering. Reply is always an array of moved elements (destination order) or null when nothing moved. Single-slot multi-key: specs use hash-tag collocated keys for cluster. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/lib/commands/BLMOVEM.spec.ts | 59 ++++++++++++ packages/client/lib/commands/BLMOVEM.ts | 34 +++++++ packages/client/lib/commands/LMOVEM.spec.ts | 94 ++++++++++++++++++++ packages/client/lib/commands/LMOVEM.ts | 62 +++++++++++++ packages/client/lib/commands/index.ts | 42 +++++++++ 5 files changed, 291 insertions(+) create mode 100644 packages/client/lib/commands/BLMOVEM.spec.ts create mode 100644 packages/client/lib/commands/BLMOVEM.ts create mode 100644 packages/client/lib/commands/LMOVEM.spec.ts create mode 100644 packages/client/lib/commands/LMOVEM.ts diff --git a/packages/client/lib/commands/BLMOVEM.spec.ts b/packages/client/lib/commands/BLMOVEM.spec.ts new file mode 100644 index 00000000000..3c4d0c8e3c8 --- /dev/null +++ b/packages/client/lib/commands/BLMOVEM.spec.ts @@ -0,0 +1,59 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL, BLOCKING_MIN_VALUE } from '../test-utils'; +import BLMOVEM from './BLMOVEM'; +import { parseArgs } from './generic-transformers'; + +describe('BLMOVEM', () => { + describe('transformArguments', () => { + it('simple', () => { + assert.deepEqual( + parseArgs(BLMOVEM, 'source', 'destination', 'LEFT', 'RIGHT', 0), + ['BLMOVEM', 'source', 'destination', 'LEFT', 'RIGHT', '0'] + ); + }); + + it('with COUNT and ORDER', () => { + assert.deepEqual( + parseArgs(BLMOVEM, 'source', 'destination', 'LEFT', 'LEFT', 0, { + COUNT: 3, + ORDER: 'OBO' + }), + ['BLMOVEM', 'source', 'destination', 'LEFT', 'LEFT', '0', 'COUNT', '3', 'OBO'] + ); + }); + + it('with EXACTLY and ORDER', () => { + assert.deepEqual( + parseArgs(BLMOVEM, 'source', 'destination', 'LEFT', 'RIGHT', 0, { + EXACTLY: 2, + ORDER: 'BULK' + }), + ['BLMOVEM', 'source', 'destination', 'LEFT', 'RIGHT', '0', 'EXACTLY', '2', 'BULK'] + ); + }); + }); + + testUtils.testAll('blMoveM - null on timeout', async client => { + assert.equal( + await client.blMoveM('{tag}source', '{tag}destination', 'LEFT', 'RIGHT', BLOCKING_MIN_VALUE), + null + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testAll('blMoveM - with elements', async client => { + const [, reply] = await Promise.all([ + client.rPush('{tag}source', ['1', '2', '3']), + client.blMoveM('{tag}source', '{tag}destination', 'LEFT', 'LEFT', BLOCKING_MIN_VALUE, { + COUNT: 2, + ORDER: 'BULK' + }) + ]); + assert.deepEqual(reply, ['1', '2']); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); +}); diff --git a/packages/client/lib/commands/BLMOVEM.ts b/packages/client/lib/commands/BLMOVEM.ts new file mode 100644 index 00000000000..1caff5db88d --- /dev/null +++ b/packages/client/lib/commands/BLMOVEM.ts @@ -0,0 +1,34 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, Command } from '../RESP/types'; +import { ListSide } from './generic-transformers'; +import LMOVEM, { LMoveMOptions } from './LMOVEM'; + +export default { + IS_READ_ONLY: false, + parseCommand( + parser: CommandParser, + source: RedisArgument, + destination: RedisArgument, + sourceSide: ListSide, + destinationSide: ListSide, + timeout: number, + options?: LMoveMOptions + ) { + parser.push('BLMOVEM'); + parser.pushKeys([source, destination]); + parser.push(sourceSide, destinationSide, timeout.toString()); + + if (options) { + if ('EXACTLY' in options) { + parser.push('EXACTLY', options.EXACTLY.toString()); + } else { + parser.push('COUNT', options.COUNT.toString()); + } + + if (options.ORDER !== undefined) { + parser.push(options.ORDER); + } + } + }, + transformReply: LMOVEM.transformReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/LMOVEM.spec.ts b/packages/client/lib/commands/LMOVEM.spec.ts new file mode 100644 index 00000000000..92e3ce54a8b --- /dev/null +++ b/packages/client/lib/commands/LMOVEM.spec.ts @@ -0,0 +1,94 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import LMOVEM from './LMOVEM'; +import { parseArgs } from './generic-transformers'; + +describe('LMOVEM', () => { + describe('transformArguments', () => { + it('simple', () => { + assert.deepEqual( + parseArgs(LMOVEM, 'source', 'destination', 'LEFT', 'RIGHT'), + ['LMOVEM', 'source', 'destination', 'LEFT', 'RIGHT'] + ); + }); + + it('with COUNT', () => { + assert.deepEqual( + parseArgs(LMOVEM, 'source', 'destination', 'LEFT', 'RIGHT', { + COUNT: 3 + }), + ['LMOVEM', 'source', 'destination', 'LEFT', 'RIGHT', 'COUNT', '3'] + ); + }); + + it('with COUNT and ORDER', () => { + assert.deepEqual( + parseArgs(LMOVEM, 'source', 'destination', 'LEFT', 'LEFT', { + COUNT: 3, + ORDER: 'OBO' + }), + ['LMOVEM', 'source', 'destination', 'LEFT', 'LEFT', 'COUNT', '3', 'OBO'] + ); + }); + + it('with EXACTLY and ORDER', () => { + assert.deepEqual( + parseArgs(LMOVEM, 'source', 'destination', 'LEFT', 'RIGHT', { + EXACTLY: 2, + ORDER: 'BULK' + }), + ['LMOVEM', 'source', 'destination', 'LEFT', 'RIGHT', 'EXACTLY', '2', 'BULK'] + ); + }); + }); + + testUtils.testAll('lMoveM - null when source empty', async client => { + assert.equal( + await client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'RIGHT'), + null + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testAll('lMoveM - COUNT with OBO ordering', async client => { + await client.rPush('{tag}source', ['1', '2', '3', '4', '5']); + assert.deepEqual( + await client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'LEFT', { + COUNT: 3, + ORDER: 'OBO' + }), + ['3', '2', '1'] + ); + assert.deepEqual( + await client.lRange('{tag}destination', 0, -1), + ['3', '2', '1'] + ); + assert.deepEqual( + await client.lRange('{tag}source', 0, -1), + ['4', '5'] + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testAll('lMoveM - COUNT with BULK ordering', async client => { + await client.rPush('{tag}source', ['1', '2', '3', '4', '5']); + assert.deepEqual( + await client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'LEFT', { + COUNT: 3, + ORDER: 'BULK' + }), + ['1', '2', '3'] + ); + assert.deepEqual( + await client.lRange('{tag}destination', 0, -1), + ['1', '2', '3'] + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); +}); diff --git a/packages/client/lib/commands/LMOVEM.ts b/packages/client/lib/commands/LMOVEM.ts new file mode 100644 index 00000000000..1d1629cd2bb --- /dev/null +++ b/packages/client/lib/commands/LMOVEM.ts @@ -0,0 +1,62 @@ +import { CommandParser } from '../client/parser'; +import { RedisArgument, ArrayReply, BlobStringReply, NullReply, Command } from '../RESP/types'; +import { ListSide } from './generic-transformers'; + +/** + * Ordering of the moved elements at the destination. + * + * - `OBO` (one-by-one): push each element as it is popped, reversing the + * moved block's relative order (stack semantics). + * - `BULK`: preserve the moved block's original relative order (queue semantics). + */ +export type LMoveMOrder = 'OBO' | 'BULK'; + +export type LMoveMOptions = { + /** Move up to `COUNT` elements (fewer if the source has fewer). */ + COUNT: number; + ORDER?: LMoveMOrder; +} | { + /** Move exactly `EXACTLY` elements; if the source has fewer, move nothing. */ + EXACTLY: number; + ORDER?: LMoveMOrder; +}; + +export function parseLMoveMArguments( + parser: CommandParser, + source: RedisArgument, + destination: RedisArgument, + sourceSide: ListSide, + destinationSide: ListSide, + options?: LMoveMOptions +) { + parser.pushKeys([source, destination]); + parser.push(sourceSide, destinationSide); + + if (options) { + if ('EXACTLY' in options) { + parser.push('EXACTLY', options.EXACTLY.toString()); + } else { + parser.push('COUNT', options.COUNT.toString()); + } + + if (options.ORDER !== undefined) { + parser.push(options.ORDER); + } + } +} + +export default { + IS_READ_ONLY: false, + parseCommand( + parser: CommandParser, + source: RedisArgument, + destination: RedisArgument, + sourceSide: ListSide, + destinationSide: ListSide, + options?: LMoveMOptions + ) { + parser.push('LMOVEM'); + parseLMoveMArguments(parser, source, destination, sourceSide, destinationSide, options); + }, + transformReply: undefined as unknown as () => ArrayReply | NullReply +} as const satisfies Command; diff --git a/packages/client/lib/commands/index.ts b/packages/client/lib/commands/index.ts index 3ae4f4485ff..aa0253a7a13 100644 --- a/packages/client/lib/commands/index.ts +++ b/packages/client/lib/commands/index.ts @@ -42,6 +42,7 @@ import BITFIELD from './BITFIELD'; import BITOP from './BITOP'; import BITPOS from './BITPOS'; import BLMOVE from './BLMOVE'; +import BLMOVEM from './BLMOVEM'; import BLMPOP from './BLMPOP'; import BLPOP from './BLPOP'; import BRPOP from './BRPOP'; @@ -208,6 +209,7 @@ import LINDEX from './LINDEX'; import LINSERT from './LINSERT'; import LLEN from './LLEN'; import LMOVE from './LMOVE'; +import LMOVEM from './LMOVEM'; import LMPOP from './LMPOP'; import LOLWUT from './LOLWUT'; import LPOP_COUNT from './LPOP_COUNT'; @@ -951,6 +953,26 @@ export default { * @param timeout - Timeout in seconds, 0 to block indefinitely */ blMove: BLMOVE, + /** + * Moves multiple elements from one list to another, blocking until enough elements are available + * @param source - Key of the source list + * @param destination - Key of the destination list + * @param sourceSide - Side of source list to pop from (LEFT or RIGHT) + * @param destinationSide - Side of destination list to push to (LEFT or RIGHT) + * @param timeout - Timeout in seconds, 0 to block indefinitely + * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + */ + BLMOVEM, + /** + * Moves multiple elements from one list to another, blocking until enough elements are available + * @param source - Key of the source list + * @param destination - Key of the destination list + * @param sourceSide - Side of source list to pop from (LEFT or RIGHT) + * @param destinationSide - Side of destination list to push to (LEFT or RIGHT) + * @param timeout - Timeout in seconds, 0 to block indefinitely + * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + */ + blMoveM: BLMOVEM, /** * Pops elements from multiple lists; blocks until elements are available * @param timeout - Timeout in seconds, 0 to block indefinitely @@ -3037,6 +3059,26 @@ export default { * @see https://redis.io/commands/lmove/ */ lMove: LMOVE, + /** + * Moves multiple elements from one list to another + * + * @param source - The source list key + * @param destination - The destination list key + * @param sourceSide - The side to pop from (LEFT or RIGHT) + * @param destinationSide - The side to push to (LEFT or RIGHT) + * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + */ + LMOVEM, + /** + * Moves multiple elements from one list to another + * + * @param source - The source list key + * @param destination - The destination list key + * @param sourceSide - The side to pop from (LEFT or RIGHT) + * @param destinationSide - The side to push to (LEFT or RIGHT) + * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + */ + lMoveM: LMOVEM, /** * Constructs the LMPOP command * From cc911e2b3c0a86e9d8e7bb4820e13c38aec65d0e Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 14 Jul 2026 15:22:12 +0300 Subject: [PATCH 2/5] test(client): gate LMOVEM/BLMOVEM specs to 8.10 and annotate @since Add isVersionGreaterThanHook([8, 10]) so behavior tests run only on Redis 8.10+, and add @since 8.10 to the LMOVEM/BLMOVEM registry JSDoc. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/lib/commands/BLMOVEM.spec.ts | 2 ++ packages/client/lib/commands/LMOVEM.spec.ts | 2 ++ packages/client/lib/commands/index.ts | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/packages/client/lib/commands/BLMOVEM.spec.ts b/packages/client/lib/commands/BLMOVEM.spec.ts index 3c4d0c8e3c8..f6ddfbcaa08 100644 --- a/packages/client/lib/commands/BLMOVEM.spec.ts +++ b/packages/client/lib/commands/BLMOVEM.spec.ts @@ -4,6 +4,8 @@ import BLMOVEM from './BLMOVEM'; import { parseArgs } from './generic-transformers'; describe('BLMOVEM', () => { + testUtils.isVersionGreaterThanHook([8, 10]); + describe('transformArguments', () => { it('simple', () => { assert.deepEqual( diff --git a/packages/client/lib/commands/LMOVEM.spec.ts b/packages/client/lib/commands/LMOVEM.spec.ts index 92e3ce54a8b..64b82034501 100644 --- a/packages/client/lib/commands/LMOVEM.spec.ts +++ b/packages/client/lib/commands/LMOVEM.spec.ts @@ -4,6 +4,8 @@ import LMOVEM from './LMOVEM'; import { parseArgs } from './generic-transformers'; describe('LMOVEM', () => { + testUtils.isVersionGreaterThanHook([8, 10]); + describe('transformArguments', () => { it('simple', () => { assert.deepEqual( diff --git a/packages/client/lib/commands/index.ts b/packages/client/lib/commands/index.ts index aa0253a7a13..937db0a9fc1 100644 --- a/packages/client/lib/commands/index.ts +++ b/packages/client/lib/commands/index.ts @@ -961,6 +961,7 @@ export default { * @param destinationSide - Side of destination list to push to (LEFT or RIGHT) * @param timeout - Timeout in seconds, 0 to block indefinitely * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + * @since 8.10 */ BLMOVEM, /** @@ -971,6 +972,7 @@ export default { * @param destinationSide - Side of destination list to push to (LEFT or RIGHT) * @param timeout - Timeout in seconds, 0 to block indefinitely * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + * @since 8.10 */ blMoveM: BLMOVEM, /** @@ -3067,6 +3069,7 @@ export default { * @param sourceSide - The side to pop from (LEFT or RIGHT) * @param destinationSide - The side to push to (LEFT or RIGHT) * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + * @since 8.10 */ LMOVEM, /** @@ -3077,6 +3080,7 @@ export default { * @param sourceSide - The side to pop from (LEFT or RIGHT) * @param destinationSide - The side to push to (LEFT or RIGHT) * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + * @since 8.10 */ lMoveM: LMOVEM, /** From 6bcaf3eae139437d443dd798eeac70245f181e49 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 14 Jul 2026 15:26:37 +0300 Subject: [PATCH 3/5] refactor(client): inline LMOVEM arg building Drop the single-caller parseLMoveMArguments helper; BLMOVEM cannot reuse it (timeout sits mid-args), so it was dead abstraction. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/lib/commands/LMOVEM.ts | 39 +++++++++----------------- 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/packages/client/lib/commands/LMOVEM.ts b/packages/client/lib/commands/LMOVEM.ts index 1d1629cd2bb..c21d73b384f 100644 --- a/packages/client/lib/commands/LMOVEM.ts +++ b/packages/client/lib/commands/LMOVEM.ts @@ -21,30 +21,6 @@ export type LMoveMOptions = { ORDER?: LMoveMOrder; }; -export function parseLMoveMArguments( - parser: CommandParser, - source: RedisArgument, - destination: RedisArgument, - sourceSide: ListSide, - destinationSide: ListSide, - options?: LMoveMOptions -) { - parser.pushKeys([source, destination]); - parser.push(sourceSide, destinationSide); - - if (options) { - if ('EXACTLY' in options) { - parser.push('EXACTLY', options.EXACTLY.toString()); - } else { - parser.push('COUNT', options.COUNT.toString()); - } - - if (options.ORDER !== undefined) { - parser.push(options.ORDER); - } - } -} - export default { IS_READ_ONLY: false, parseCommand( @@ -56,7 +32,20 @@ export default { options?: LMoveMOptions ) { parser.push('LMOVEM'); - parseLMoveMArguments(parser, source, destination, sourceSide, destinationSide, options); + parser.pushKeys([source, destination]); + parser.push(sourceSide, destinationSide); + + if (options) { + if ('EXACTLY' in options) { + parser.push('EXACTLY', options.EXACTLY.toString()); + } else { + parser.push('COUNT', options.COUNT.toString()); + } + + if (options.ORDER !== undefined) { + parser.push(options.ORDER); + } + } }, transformReply: undefined as unknown as () => ArrayReply | NullReply } as const satisfies Command; From 29581f76f9765acb6eb282d4b77334d1ef35c2f7 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 14 Jul 2026 16:23:13 +0300 Subject: [PATCH 4/5] refactor(client): dedupe LMOVEM/BLMOVEM options, fix docs, add tests - Extract parseLMoveMOptions helper shared by LMOVEM and BLMOVEM - Correct BLMOVEM JSDoc blocking-semantics wording (COUNT unblocks at 1) - Add @see redis.io links to LMOVEM/BLMOVEM entries - Cover no-options single-element reply, COUNT clamping, EXACTLY-too-few Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/lib/commands/BLMOVEM.ts | 15 +------ packages/client/lib/commands/LMOVEM.spec.ts | 48 +++++++++++++++++++++ packages/client/lib/commands/LMOVEM.ts | 32 ++++++++------ packages/client/lib/commands/index.ts | 8 +++- 4 files changed, 76 insertions(+), 27 deletions(-) diff --git a/packages/client/lib/commands/BLMOVEM.ts b/packages/client/lib/commands/BLMOVEM.ts index 1caff5db88d..fc6cf5173e1 100644 --- a/packages/client/lib/commands/BLMOVEM.ts +++ b/packages/client/lib/commands/BLMOVEM.ts @@ -1,7 +1,7 @@ import { CommandParser } from '../client/parser'; import { RedisArgument, Command } from '../RESP/types'; import { ListSide } from './generic-transformers'; -import LMOVEM, { LMoveMOptions } from './LMOVEM'; +import LMOVEM, { LMoveMOptions, parseLMoveMOptions } from './LMOVEM'; export default { IS_READ_ONLY: false, @@ -17,18 +17,7 @@ export default { parser.push('BLMOVEM'); parser.pushKeys([source, destination]); parser.push(sourceSide, destinationSide, timeout.toString()); - - if (options) { - if ('EXACTLY' in options) { - parser.push('EXACTLY', options.EXACTLY.toString()); - } else { - parser.push('COUNT', options.COUNT.toString()); - } - - if (options.ORDER !== undefined) { - parser.push(options.ORDER); - } - } + parseLMoveMOptions(parser, options); }, transformReply: LMOVEM.transformReply } as const satisfies Command; diff --git a/packages/client/lib/commands/LMOVEM.spec.ts b/packages/client/lib/commands/LMOVEM.spec.ts index 64b82034501..eb907cad331 100644 --- a/packages/client/lib/commands/LMOVEM.spec.ts +++ b/packages/client/lib/commands/LMOVEM.spec.ts @@ -93,4 +93,52 @@ describe('LMOVEM', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('lMoveM - no options moves a single element as an array', async client => { + await client.rPush('{tag}source', ['1', '2', '3']); + assert.deepEqual( + await client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'RIGHT'), + ['1'] + ); + assert.deepEqual( + await client.lRange('{tag}source', 0, -1), + ['2', '3'] + ); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testAll('lMoveM - COUNT clamps to available elements', async client => { + await client.rPush('{tag}source', ['1', '2', '3']); + assert.deepEqual( + await client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'LEFT', { + COUNT: 10, + ORDER: 'BULK' + }), + ['1', '2', '3'] + ); + assert.equal(await client.exists('{tag}source'), 0); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); + + testUtils.testAll('lMoveM - EXACTLY with too few elements moves nothing', async client => { + await client.rPush('{tag}source', ['1', '2']); + await assert.rejects( + client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'RIGHT', { + EXACTLY: 3, + ORDER: 'BULK' + }) + ); + assert.deepEqual( + await client.lRange('{tag}source', 0, -1), + ['1', '2'] + ); + assert.equal(await client.exists('{tag}destination'), 0); + }, { + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN + }); }); diff --git a/packages/client/lib/commands/LMOVEM.ts b/packages/client/lib/commands/LMOVEM.ts index c21d73b384f..0b3e837c940 100644 --- a/packages/client/lib/commands/LMOVEM.ts +++ b/packages/client/lib/commands/LMOVEM.ts @@ -21,6 +21,25 @@ export type LMoveMOptions = { ORDER?: LMoveMOrder; }; +/** + * Pushes the trailing `[ count [OBO|BULK]]` arguments shared by + * LMOVEM and BLMOVEM. Kept separate from the command bodies because BLMOVEM's + * `timeout` sits between the sides and these options, so only the tail is shared. + */ +export function parseLMoveMOptions(parser: CommandParser, options?: LMoveMOptions) { + if (options) { + if ('EXACTLY' in options) { + parser.push('EXACTLY', options.EXACTLY.toString()); + } else { + parser.push('COUNT', options.COUNT.toString()); + } + + if (options.ORDER !== undefined) { + parser.push(options.ORDER); + } + } +} + export default { IS_READ_ONLY: false, parseCommand( @@ -34,18 +53,7 @@ export default { parser.push('LMOVEM'); parser.pushKeys([source, destination]); parser.push(sourceSide, destinationSide); - - if (options) { - if ('EXACTLY' in options) { - parser.push('EXACTLY', options.EXACTLY.toString()); - } else { - parser.push('COUNT', options.COUNT.toString()); - } - - if (options.ORDER !== undefined) { - parser.push(options.ORDER); - } - } + parseLMoveMOptions(parser, options); }, transformReply: undefined as unknown as () => ArrayReply | NullReply } as const satisfies Command; diff --git a/packages/client/lib/commands/index.ts b/packages/client/lib/commands/index.ts index 937db0a9fc1..65b98a70f61 100644 --- a/packages/client/lib/commands/index.ts +++ b/packages/client/lib/commands/index.ts @@ -954,24 +954,26 @@ export default { */ blMove: BLMOVE, /** - * Moves multiple elements from one list to another, blocking until enough elements are available + * Moves multiple elements from one list to another; or blocks until the source has elements to move * @param source - Key of the source list * @param destination - Key of the destination list * @param sourceSide - Side of source list to pop from (LEFT or RIGHT) * @param destinationSide - Side of destination list to push to (LEFT or RIGHT) * @param timeout - Timeout in seconds, 0 to block indefinitely * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + * @see https://redis.io/commands/blmovem/ * @since 8.10 */ BLMOVEM, /** - * Moves multiple elements from one list to another, blocking until enough elements are available + * Moves multiple elements from one list to another; or blocks until the source has elements to move * @param source - Key of the source list * @param destination - Key of the destination list * @param sourceSide - Side of source list to pop from (LEFT or RIGHT) * @param destinationSide - Side of destination list to push to (LEFT or RIGHT) * @param timeout - Timeout in seconds, 0 to block indefinitely * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + * @see https://redis.io/commands/blmovem/ * @since 8.10 */ blMoveM: BLMOVEM, @@ -3069,6 +3071,7 @@ export default { * @param sourceSide - The side to pop from (LEFT or RIGHT) * @param destinationSide - The side to push to (LEFT or RIGHT) * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + * @see https://redis.io/commands/lmovem/ * @since 8.10 */ LMOVEM, @@ -3080,6 +3083,7 @@ export default { * @param sourceSide - The side to pop from (LEFT or RIGHT) * @param destinationSide - The side to push to (LEFT or RIGHT) * @param options - Optional COUNT/EXACTLY count and OBO/BULK ordering + * @see https://redis.io/commands/lmovem/ * @since 8.10 */ lMoveM: LMOVEM, From 05594baa44392c52c7903c48ab14ad98336e816d Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 22 Jul 2026 12:27:57 +0300 Subject: [PATCH 5/5] fix(client): LMOVEM EXACTLY shortfall returns null, not an error The spec expected LMOVEM with EXACTLY greater than the source list length to reject with a server error, but Redis 8.10 replies with null and moves nothing. Assert a null reply instead and document the null reply in the EXACTLY option JSDoc. Co-Authored-By: Claude Fable 5 --- packages/client/lib/commands/LMOVEM.spec.ts | 7 ++++--- packages/client/lib/commands/LMOVEM.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/client/lib/commands/LMOVEM.spec.ts b/packages/client/lib/commands/LMOVEM.spec.ts index eb907cad331..f426b047766 100644 --- a/packages/client/lib/commands/LMOVEM.spec.ts +++ b/packages/client/lib/commands/LMOVEM.spec.ts @@ -126,11 +126,12 @@ describe('LMOVEM', () => { testUtils.testAll('lMoveM - EXACTLY with too few elements moves nothing', async client => { await client.rPush('{tag}source', ['1', '2']); - await assert.rejects( - client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'RIGHT', { + assert.equal( + await client.lMoveM('{tag}source', '{tag}destination', 'LEFT', 'RIGHT', { EXACTLY: 3, ORDER: 'BULK' - }) + }), + null ); assert.deepEqual( await client.lRange('{tag}source', 0, -1), diff --git a/packages/client/lib/commands/LMOVEM.ts b/packages/client/lib/commands/LMOVEM.ts index 0b3e837c940..cd7ee895472 100644 --- a/packages/client/lib/commands/LMOVEM.ts +++ b/packages/client/lib/commands/LMOVEM.ts @@ -16,7 +16,7 @@ export type LMoveMOptions = { COUNT: number; ORDER?: LMoveMOrder; } | { - /** Move exactly `EXACTLY` elements; if the source has fewer, move nothing. */ + /** Move exactly `EXACTLY` elements; if the source has fewer, move nothing and reply with null. */ EXACTLY: number; ORDER?: LMoveMOrder; };