From 258df466e57c87f590b62269f8824664a34a2bdf Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Fri, 12 Jun 2026 15:25:48 +0300 Subject: [PATCH 01/23] feat(api): add array read endpoints (RI-8219) Expose the seven read-only endpoints for the Redis array data type (POST /databases/:id/array/{get-range,scan,get-length,get-count, get-next-index,get-element,get-elements}). Indexes are handled as decimal strings end-to-end (validated via IsArrayIndex) so unsigned 64-bit array indexes survive the wire and controller layer without precision loss. ARGETRANGE enforces a hard 1,000,000-element span guard per the Redis docs; ARSCAN is exempt because it already skips empty slots. Adds 33 unit tests covering happy paths, WrongType, missing keys, and ACL failures. References: RI-8219 --- .../api/src/constants/error-messages.ts | 2 + .../src/modules/browser/__mocks__/array.ts | 91 ++++ .../src/modules/browser/__mocks__/index.ts | 1 + .../modules/browser/array/array.controller.ts | 139 +++++- .../browser/array/array.service.spec.ts | 402 +++++++++++++++++- .../modules/browser/array/array.service.ts | 295 ++++++++++++- .../src/modules/browser/array/constants.ts | 8 + .../array/dto/get.array-count.response.ts | 12 + .../array/dto/get.array-element.dto.ts | 14 + .../array/dto/get.array-element.response.ts | 15 + .../array/dto/get.array-length.response.ts | 13 + .../array/dto/get.array-multi-elements.dto.ts | 18 + .../dto/get.array-multi-elements.response.ts | 17 + .../dto/get.array-next-index.response.ts | 12 + .../browser/array/dto/get.array-range.dto.ts | 24 ++ .../array/dto/get.array-range.response.ts | 17 + .../browser/array/dto/get.array-scan.dto.ts | 38 ++ .../array/dto/get.array-scan.response.ts | 32 ++ .../src/modules/browser/array/dto/index.ts | 11 + .../constants/browser-tool-commands.ts | 6 + 20 files changed, 1152 insertions(+), 15 deletions(-) create mode 100644 redisinsight/api/src/modules/browser/__mocks__/array.ts create mode 100644 redisinsight/api/src/modules/browser/array/constants.ts create mode 100644 redisinsight/api/src/modules/browser/array/dto/get.array-count.response.ts create mode 100644 redisinsight/api/src/modules/browser/array/dto/get.array-element.dto.ts create mode 100644 redisinsight/api/src/modules/browser/array/dto/get.array-element.response.ts create mode 100644 redisinsight/api/src/modules/browser/array/dto/get.array-length.response.ts create mode 100644 redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.dto.ts create mode 100644 redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts create mode 100644 redisinsight/api/src/modules/browser/array/dto/get.array-next-index.response.ts create mode 100644 redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts create mode 100644 redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts create mode 100644 redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts create mode 100644 redisinsight/api/src/modules/browser/array/dto/get.array-scan.response.ts diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index e6a8668621..913ea949c5 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -78,6 +78,8 @@ export default { DATABASE_ALREADY_EXISTS: 'The database already exists.', INCORRECT_CLUSTER_CURSOR_FORMAT: 'Incorrect cluster cursor format.', + ARRAY_RANGE_TOO_LARGE: (max: number) => + `Requested range exceeds the maximum of ${numberWithSpaces(max)} elements per call. Narrow the range and try again.`, REMOVING_MULTIPLE_ELEMENTS_NOT_SUPPORT: () => 'Removing multiple elements is available for Redis databases v. 6.2 or later.', SCAN_PER_KEY_TYPE_NOT_SUPPORT: () => diff --git a/redisinsight/api/src/modules/browser/__mocks__/array.ts b/redisinsight/api/src/modules/browser/__mocks__/array.ts new file mode 100644 index 0000000000..e0019ab13a --- /dev/null +++ b/redisinsight/api/src/modules/browser/__mocks__/array.ts @@ -0,0 +1,91 @@ +import { + GetArrayCountResponse, + GetArrayElementDto, + GetArrayElementResponse, + GetArrayLengthResponse, + GetArrayMultiElementsDto, + GetArrayMultiElementsResponse, + GetArrayNextIndexResponse, + GetArrayRangeDto, + GetArrayRangeResponse, + GetArrayScanDto, + GetArrayScanResponse, +} from 'src/modules/browser/array/dto'; +import { mockKeyDto } from 'src/modules/browser/__mocks__/keys'; + +export const mockArrayIndex = '0'; +export const mockArrayElement1 = Buffer.from('20.1'); +export const mockArrayElement2 = Buffer.from('20.4'); + +// Sparse range fixture: indexes 0,1 populated, 2,3 empty. +export const mockArrayRangeWithGaps: (Buffer | null)[] = [ + mockArrayElement1, + mockArrayElement2, + null, + null, +]; + +export const mockGetArrayRangeDto: GetArrayRangeDto = { + keyName: mockKeyDto.keyName, + start: '0', + end: '3', +}; + +export const mockGetArrayRangeResponse: GetArrayRangeResponse = { + keyName: mockKeyDto.keyName, + elements: mockArrayRangeWithGaps, +}; + +export const mockGetArrayScanDto: GetArrayScanDto = { + keyName: mockKeyDto.keyName, + start: '0', + end: '6', +}; + +export const mockGetArrayScanResponse: GetArrayScanResponse = { + keyName: mockKeyDto.keyName, + elements: [ + { index: '0', value: mockArrayElement1 }, + { index: '1', value: mockArrayElement2 }, + ], +}; + +export const mockGetArrayElementDto: GetArrayElementDto = { + keyName: mockKeyDto.keyName, + index: mockArrayIndex, +}; + +export const mockGetArrayElementResponse: GetArrayElementResponse = { + keyName: mockKeyDto.keyName, + value: mockArrayElement1, +}; + +export const mockGetArrayMultiElementsDto: GetArrayMultiElementsDto = { + keyName: mockKeyDto.keyName, + indexes: ['0', '1', '3'], +}; + +export const mockGetArrayMultiElementsResponse: GetArrayMultiElementsResponse = + { + keyName: mockKeyDto.keyName, + elements: [mockArrayElement1, mockArrayElement2, null], + }; + +export const mockArrayLength = '7'; +export const mockArrayCount = '5'; +export const mockArrayNextIndex = '7'; + +export const mockGetArrayLengthResponse: GetArrayLengthResponse = { + keyName: mockKeyDto.keyName, + length: mockArrayLength, +}; + +export const mockGetArrayCountResponse: GetArrayCountResponse = { + keyName: mockKeyDto.keyName, + count: mockArrayCount, +}; + +export const mockGetArrayNextIndexResponse: GetArrayNextIndexResponse = { + keyName: mockKeyDto.keyName, + index: mockArrayNextIndex, +}; diff --git a/redisinsight/api/src/modules/browser/__mocks__/index.ts b/redisinsight/api/src/modules/browser/__mocks__/index.ts index b17d270834..15bce83948 100644 --- a/redisinsight/api/src/modules/browser/__mocks__/index.ts +++ b/redisinsight/api/src/modules/browser/__mocks__/index.ts @@ -4,3 +4,4 @@ export * from './z-set'; export * from './set'; export * from './list'; export * from './hash'; +export * from './array'; diff --git a/redisinsight/api/src/modules/browser/array/array.controller.ts b/redisinsight/api/src/modules/browser/array/array.controller.ts index 4d6a4890a1..dc8e7c1869 100644 --- a/redisinsight/api/src/modules/browser/array/array.controller.ts +++ b/redisinsight/api/src/modules/browser/array/array.controller.ts @@ -1,20 +1,40 @@ import { Body, Controller, + HttpCode, Post, UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBody, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator'; import { ApiQueryRedisStringEncoding } from 'src/common/decorators'; import { ClientMetadata } from 'src/common/models'; import { BrowserSerializeInterceptor } from 'src/common/interceptors'; import { BrowserClientMetadata } from 'src/modules/browser/decorators/browser-client-metadata.decorator'; import { BrowserBaseController } from 'src/modules/browser/browser.base.controller'; +import { KeyDto } from 'src/modules/browser/keys/dto'; import { ArrayService } from 'src/modules/browser/array/array.service'; -import { CreateArrayWithExpireDto } from 'src/modules/browser/array/dto'; +import { + CreateArrayWithExpireDto, + GetArrayCountResponse, + GetArrayElementDto, + GetArrayElementResponse, + GetArrayLengthResponse, + GetArrayMultiElementsDto, + GetArrayMultiElementsResponse, + GetArrayNextIndexResponse, + GetArrayRangeDto, + GetArrayRangeResponse, + GetArrayScanDto, + GetArrayScanResponse, +} from 'src/modules/browser/array/dto'; @ApiTags('Browser: Array') @UseInterceptors(BrowserSerializeInterceptor) @@ -36,4 +56,119 @@ export class ArrayController extends BrowserBaseController { ): Promise { return this.arrayService.createArray(clientMetadata, dto); } + + // The key name can be very large, so it is better to send it in the request body + @Post('/get-range') + @HttpCode(200) + @ApiOperation({ + description: + 'Read a range of elements from the array stored at key (ARGETRANGE). ' + + 'Empty slots are returned as null; range is inclusive and reversible.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayRangeResponse }) + @ApiQueryRedisStringEncoding() + async getRange( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: GetArrayRangeDto, + ): Promise { + return this.arrayService.getRange(clientMetadata, dto); + } + + @Post('/scan') + @HttpCode(200) + @ApiOperation({ + description: + 'Scan a range of populated elements from the array stored at key (ARSCAN). ' + + 'Empty slots are skipped.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayScanResponse }) + @ApiQueryRedisStringEncoding() + async scan( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: GetArrayScanDto, + ): Promise { + return this.arrayService.scan(clientMetadata, dto); + } + + @Post('/get-length') + @HttpCode(200) + @ApiOperation({ + description: + 'Get the logical length of the array (highest set index + 1, includes gaps) — ARLEN.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayLengthResponse }) + @ApiQueryRedisStringEncoding() + async getLength( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: KeyDto, + ): Promise { + return this.arrayService.getLength(clientMetadata, dto); + } + + @Post('/get-count') + @HttpCode(200) + @ApiOperation({ + description: 'Get the count of populated (non-empty) elements — ARCOUNT.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayCountResponse }) + @ApiQueryRedisStringEncoding() + async getCount( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: KeyDto, + ): Promise { + return this.arrayService.getCount(clientMetadata, dto); + } + + @Post('/get-next-index') + @HttpCode(200) + @ApiOperation({ + description: + 'Get the next index that ARINSERT would use (read-only) — ARNEXT.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayNextIndexResponse }) + @ApiQueryRedisStringEncoding() + async getNextIndex( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: KeyDto, + ): Promise { + return this.arrayService.getNextIndex(clientMetadata, dto); + } + + @Post('/get-element') + @HttpCode(200) + @ApiOperation({ + description: + 'Get the value at a single index, or null for an empty slot / out of range — ARGET.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayElementResponse }) + @ApiQueryRedisStringEncoding() + async getElement( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: GetArrayElementDto, + ): Promise { + return this.arrayService.getElement(clientMetadata, dto); + } + + @Post('/get-elements') + @HttpCode(200) + @ApiOperation({ + description: + 'Get values at multiple indexes in one round-trip — ARMGET. ' + + 'Returns an array of bulk|null, one per requested index, in request order.', + }) + @ApiRedisParams() + @ApiOkResponse({ type: GetArrayMultiElementsResponse }) + @ApiQueryRedisStringEncoding() + async getMultiElements( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: GetArrayMultiElementsDto, + ): Promise { + return this.arrayService.getMultiElements(clientMetadata, dto); + } } diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index 639cb8cc2f..7a608de450 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -1,8 +1,21 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ConflictException, ForbiddenException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; import { when } from 'jest-when'; import { ReplyError } from 'src/models/redis-client'; -import { mockBrowserClientMetadata, mockRedisNoPermError } from 'src/__mocks__'; +import { + mockBrowserClientMetadata, + mockRedisNoPermError, + mockRedisWrongTypeError, +} from 'src/__mocks__'; +import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; +import { mockDatabaseClientFactory } from 'src/__mocks__/databases-client'; +import { mockStandaloneRedisClient } from 'src/__mocks__/redis-client'; +import ERROR_MESSAGES from 'src/constants/error-messages'; import { BrowserToolArrayCommands, BrowserToolKeysCommands, @@ -11,10 +24,26 @@ import { createContiguousArrayDtoFactory, createSparseArrayDtoFactory, } from 'src/modules/browser/array/__tests__/array.factory'; -import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; -import { mockDatabaseClientFactory } from 'src/__mocks__/databases-client'; -import { mockStandaloneRedisClient } from 'src/__mocks__/redis-client'; -import ERROR_MESSAGES from 'src/constants/error-messages'; +import { + mockArrayCount, + mockArrayElement1, + mockArrayLength, + mockArrayNextIndex, + mockArrayRangeWithGaps, + mockGetArrayCountResponse, + mockGetArrayElementDto, + mockGetArrayElementResponse, + mockGetArrayLengthResponse, + mockGetArrayMultiElementsDto, + mockGetArrayMultiElementsResponse, + mockGetArrayNextIndexResponse, + mockGetArrayRangeDto, + mockGetArrayRangeResponse, + mockGetArrayScanDto, + mockGetArrayScanResponse, + mockKeyDto, +} from 'src/modules/browser/__mocks__'; +import { ARRAY_RANGE_MAX_ELEMENTS } from 'src/modules/browser/array/constants'; import { ArrayService } from 'src/modules/browser/array/array.service'; describe('ArrayService', () => { @@ -35,6 +64,10 @@ describe('ArrayService', () => { service = module.get(ArrayService); client.sendCommand = jest.fn().mockResolvedValue(undefined); client.sendPipeline = jest.fn().mockResolvedValue(undefined); + // Key exists by default for read paths; specific tests override. + when(client.sendCommand) + .calledWith([BrowserToolKeysCommands.Exists, mockKeyDto.keyName]) + .mockResolvedValue(1); }); it('should be defined', () => { @@ -139,4 +172,361 @@ describe('ArrayService', () => { expect(client.sendPipeline).not.toHaveBeenCalled(); }); }); + + describe('getRange', () => { + beforeEach(() => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ARGetRange, + mockGetArrayRangeDto.keyName, + mockGetArrayRangeDto.start, + mockGetArrayRangeDto.end, + ]) + .mockResolvedValue(mockArrayRangeWithGaps); + }); + + it('should return range elements (with nulls for gaps)', async () => { + const result = await service.getRange( + mockBrowserClientMetadata, + mockGetArrayRangeDto, + ); + expect(result).toEqual(mockGetArrayRangeResponse); + }); + + it('should reject when key does not exist', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolKeysCommands.Exists, mockKeyDto.keyName]) + .mockResolvedValue(0); + await expect( + service.getRange(mockBrowserClientMetadata, mockGetArrayRangeDto), + ).rejects.toThrow(new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST)); + }); + + it('should reject when range exceeds the 1M cap', async () => { + await expect( + service.getRange(mockBrowserClientMetadata, { + ...mockGetArrayRangeDto, + start: '0', + end: String(ARRAY_RANGE_MAX_ELEMENTS), + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should reject when reversed range exceeds the 1M cap', async () => { + await expect( + service.getRange(mockBrowserClientMetadata, { + ...mockGetArrayRangeDto, + start: String(ARRAY_RANGE_MAX_ELEMENTS), + end: '0', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should rethrow BadRequest on WrongType', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ARGETRANGE', + }; + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ARGetRange, + expect.anything(), + expect.anything(), + expect.anything(), + ]) + .mockRejectedValue(replyError); + await expect( + service.getRange(mockBrowserClientMetadata, mockGetArrayRangeDto), + ).rejects.toThrow(BadRequestException); + }); + + it('should map ACL error to Forbidden', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ARGETRANGE', + }; + mockStandaloneRedisClient.sendCommand.mockRejectedValue(replyError); + await expect( + service.getRange(mockBrowserClientMetadata, mockGetArrayRangeDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('scan', () => { + const flatReply = [ + Buffer.from('0'), + mockArrayElement1, + Buffer.from('1'), + Buffer.from('20.4'), + ]; + + beforeEach(() => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ARScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ]) + .mockResolvedValue(flatReply); + }); + + it('should pair flat reply into structured elements', async () => { + const result = await service.scan( + mockBrowserClientMetadata, + mockGetArrayScanDto, + ); + expect(result).toEqual(mockGetArrayScanResponse); + }); + + it('should append LIMIT when provided', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ARScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + 'LIMIT', + 50, + ]) + .mockResolvedValue([Buffer.from('0'), mockArrayElement1]); + + const result = await service.scan(mockBrowserClientMetadata, { + ...mockGetArrayScanDto, + limit: 50, + }); + expect(result.elements).toHaveLength(1); + }); + + it('should reject when key does not exist', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolKeysCommands.Exists, mockKeyDto.keyName]) + .mockResolvedValue(0); + await expect( + service.scan(mockBrowserClientMetadata, mockGetArrayScanDto), + ).rejects.toThrow(NotFoundException); + }); + + it('should rethrow BadRequest on WrongType', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ARSCAN', + }; + when(mockStandaloneRedisClient.sendCommand) + .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ARScan])) + .mockRejectedValue(replyError); + await expect( + service.scan(mockBrowserClientMetadata, mockGetArrayScanDto), + ).rejects.toThrow(BadRequestException); + }); + + it('should map ACL error to Forbidden', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ARSCAN', + }; + mockStandaloneRedisClient.sendCommand.mockRejectedValue(replyError); + await expect( + service.scan(mockBrowserClientMetadata, mockGetArrayScanDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe.each([ + { + name: 'getLength', + command: BrowserToolArrayCommands.ARLen, + reply: 7, + expected: mockGetArrayLengthResponse, + stringValue: mockArrayLength, + call: (svc: ArrayService) => + svc.getLength(mockBrowserClientMetadata, mockKeyDto), + }, + { + name: 'getCount', + command: BrowserToolArrayCommands.ARCount, + reply: 5, + expected: mockGetArrayCountResponse, + stringValue: mockArrayCount, + call: (svc: ArrayService) => + svc.getCount(mockBrowserClientMetadata, mockKeyDto), + }, + { + name: 'getNextIndex', + command: BrowserToolArrayCommands.ARNext, + reply: 7, + expected: mockGetArrayNextIndexResponse, + stringValue: mockArrayNextIndex, + call: (svc: ArrayService) => + svc.getNextIndex(mockBrowserClientMetadata, mockKeyDto), + }, + ])('$name', ({ command, reply, expected, call }) => { + beforeEach(() => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([command, mockKeyDto.keyName]) + .mockResolvedValue(reply); + }); + + it('should return the value as a string', async () => { + const result = await call(service); + expect(result).toEqual(expected); + }); + + it('should reject when key does not exist', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolKeysCommands.Exists, mockKeyDto.keyName]) + .mockResolvedValue(0); + await expect(call(service)).rejects.toThrow(NotFoundException); + }); + + it('should rethrow BadRequest on WrongType', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: command.toUpperCase(), + }; + when(mockStandaloneRedisClient.sendCommand) + .calledWith([command, mockKeyDto.keyName]) + .mockRejectedValue(replyError); + await expect(call(service)).rejects.toThrow(BadRequestException); + }); + + it('should map ACL error to Forbidden', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: command.toUpperCase(), + }; + mockStandaloneRedisClient.sendCommand.mockRejectedValue(replyError); + await expect(call(service)).rejects.toThrow(ForbiddenException); + }); + }); + + describe('getElement', () => { + beforeEach(() => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ARGet, + mockGetArrayElementDto.keyName, + mockGetArrayElementDto.index, + ]) + .mockResolvedValue(mockArrayElement1); + }); + + it('should return the element value', async () => { + const result = await service.getElement( + mockBrowserClientMetadata, + mockGetArrayElementDto, + ); + expect(result).toEqual(mockGetArrayElementResponse); + }); + + it('should return null for an empty slot', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ARGet, + mockGetArrayElementDto.keyName, + mockGetArrayElementDto.index, + ]) + .mockResolvedValue(null); + const result = await service.getElement( + mockBrowserClientMetadata, + mockGetArrayElementDto, + ); + expect(result.value).toBeNull(); + }); + + it('should reject when key does not exist', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolKeysCommands.Exists, mockKeyDto.keyName]) + .mockResolvedValue(0); + await expect( + service.getElement(mockBrowserClientMetadata, mockGetArrayElementDto), + ).rejects.toThrow(NotFoundException); + }); + + it('should rethrow BadRequest on WrongType', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ARGET', + }; + when(mockStandaloneRedisClient.sendCommand) + .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ARGet])) + .mockRejectedValue(replyError); + await expect( + service.getElement(mockBrowserClientMetadata, mockGetArrayElementDto), + ).rejects.toThrow(BadRequestException); + }); + + it('should map ACL error to Forbidden', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ARGET', + }; + mockStandaloneRedisClient.sendCommand.mockRejectedValue(replyError); + await expect( + service.getElement(mockBrowserClientMetadata, mockGetArrayElementDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('getMultiElements', () => { + beforeEach(() => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ARMGet, + mockGetArrayMultiElementsDto.keyName, + ...mockGetArrayMultiElementsDto.indexes, + ]) + .mockResolvedValue([mockArrayElement1, Buffer.from('20.4'), null]); + }); + + it('should return values aligned with requested indexes', async () => { + const result = await service.getMultiElements( + mockBrowserClientMetadata, + mockGetArrayMultiElementsDto, + ); + expect(result).toEqual(mockGetArrayMultiElementsResponse); + }); + + it('should reject when key does not exist', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolKeysCommands.Exists, mockKeyDto.keyName]) + .mockResolvedValue(0); + await expect( + service.getMultiElements( + mockBrowserClientMetadata, + mockGetArrayMultiElementsDto, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('should rethrow BadRequest on WrongType', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ARMGET', + }; + when(mockStandaloneRedisClient.sendCommand) + .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ARMGet])) + .mockRejectedValue(replyError); + await expect( + service.getMultiElements( + mockBrowserClientMetadata, + mockGetArrayMultiElementsDto, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('should map ACL error to Forbidden', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ARMGET', + }; + mockStandaloneRedisClient.sendCommand.mockRejectedValue(replyError); + await expect( + service.getMultiElements( + mockBrowserClientMetadata, + mockGetArrayMultiElementsDto, + ), + ).rejects.toThrow(ForbiddenException); + }); + }); }); diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index fc9f829932..076b3bc62d 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -1,21 +1,52 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { RedisErrorCodes } from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; import { catchAclError, catchMultiTransactionError } from 'src/utils'; import { ClientMetadata } from 'src/common/models'; -import { - ArrayCreationMode, - CreateArrayWithExpireDto, -} from 'src/modules/browser/array/dto'; +import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; import { BrowserToolArrayCommands, BrowserToolKeysCommands, } from 'src/modules/browser/constants/browser-tool-commands'; -import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; import { RedisClient, RedisClientCommand, RedisClientCommandReply, } from 'src/modules/redis/client'; -import { checkIfKeyExists } from 'src/modules/browser/utils'; +import { + checkIfKeyExists, + checkIfKeyNotExists, +} from 'src/modules/browser/utils'; +import { KeyDto } from 'src/modules/browser/keys/dto'; +import { ARRAY_RANGE_MAX_ELEMENTS } from 'src/modules/browser/array/constants'; +import { + ArrayCreationMode, + ArrayElement, + CreateArrayWithExpireDto, + GetArrayCountResponse, + GetArrayElementDto, + GetArrayElementResponse, + GetArrayLengthResponse, + GetArrayMultiElementsDto, + GetArrayMultiElementsResponse, + GetArrayNextIndexResponse, + GetArrayRangeDto, + GetArrayRangeResponse, + GetArrayScanDto, + GetArrayScanResponse, +} from 'src/modules/browser/array/dto'; + +// Integer/bulk replies for indexes and counts may arrive as Buffer, string, +// number, or bigint depending on the client mode. Normalize to a decimal +// string so the unsigned 64-bit contract is preserved on the wire. +const toIndexString = (value: unknown): string => { + if (typeof value === 'string') return value; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'number') return String(value); + if (Buffer.isBuffer(value)) return value.toString('utf8'); + return String(value); +}; @Injectable() export class ArrayService { @@ -52,6 +83,56 @@ export class ArrayService { } } + // Inputs are validated as canonical decimal strings ≤ 2^64-1, so BigInt() + // is safe. Ranges are reversible (start > end). + private assertRangeWithinCap(start: string, end: string): void { + const startBig = BigInt(start); + const endBig = BigInt(end); + const span = + startBig > endBig + ? startBig - endBig + BigInt(1) + : endBig - startBig + BigInt(1); + + if (span > BigInt(ARRAY_RANGE_MAX_ELEMENTS)) { + throw new BadRequestException( + ERROR_MESSAGES.ARRAY_RANGE_TOO_LARGE(ARRAY_RANGE_MAX_ELEMENTS), + ); + } + } + + public async getRange( + clientMetadata: ClientMetadata, + dto: GetArrayRangeDto, + ): Promise { + try { + this.logger.debug('Getting array range.', clientMetadata); + const { keyName, start, end } = dto; + + this.assertRangeWithinCap(start, end); + + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const elements = (await client.sendCommand([ + BrowserToolArrayCommands.ARGetRange, + keyName, + start, + end, + ])) as (Buffer | string | null)[]; + + this.logger.debug('Succeed to get array range.', clientMetadata); + return plainToInstance(GetArrayRangeResponse, { keyName, elements }); + } catch (error) { + this.logger.error('Failed to get array range.', error, clientMetadata); + if (error instanceof BadRequestException) throw error; + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + public async createSimpleArray( client: RedisClient, dto: CreateArrayWithExpireDto, @@ -92,4 +173,204 @@ export class ArrayService { ...elements.flatMap(({ index, value }) => [index, value]), ]; } + + public async scan( + clientMetadata: ClientMetadata, + dto: GetArrayScanDto, + ): Promise { + try { + this.logger.debug('Scanning array range.', clientMetadata); + const { keyName, start, end, limit } = dto; + + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const baseArgs = [ + BrowserToolArrayCommands.ARScan as string, + keyName, + start, + end, + ] as const; + const flat = (await client.sendCommand( + limit !== undefined ? [...baseArgs, 'LIMIT', limit] : [...baseArgs], + )) as (Buffer | string)[]; + + // Server returns a flat [index, value, index, value, ...] reply; + // pair them into structured elements for the UI. + const elements: ArrayElement[] = []; + for (let i = 0; i < flat.length; i += 2) { + elements.push({ + index: toIndexString(flat[i]), + value: flat[i + 1], + }); + } + + this.logger.debug('Succeed to scan array range.', clientMetadata); + return plainToInstance(GetArrayScanResponse, { keyName, elements }); + } catch (error) { + this.logger.error('Failed to scan array range.', error, clientMetadata); + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async getLength( + clientMetadata: ClientMetadata, + dto: KeyDto, + ): Promise { + try { + this.logger.debug('Getting array length.', clientMetadata); + const { keyName } = dto; + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const reply = await client.sendCommand([ + BrowserToolArrayCommands.ARLen, + keyName, + ]); + + this.logger.debug('Succeed to get array length.', clientMetadata); + return plainToInstance(GetArrayLengthResponse, { + keyName, + length: toIndexString(reply), + }); + } catch (error) { + this.logger.error('Failed to get array length.', error, clientMetadata); + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async getCount( + clientMetadata: ClientMetadata, + dto: KeyDto, + ): Promise { + try { + this.logger.debug('Getting array count.', clientMetadata); + const { keyName } = dto; + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const reply = await client.sendCommand([ + BrowserToolArrayCommands.ARCount, + keyName, + ]); + + this.logger.debug('Succeed to get array count.', clientMetadata); + return plainToInstance(GetArrayCountResponse, { + keyName, + count: toIndexString(reply), + }); + } catch (error) { + this.logger.error('Failed to get array count.', error, clientMetadata); + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async getNextIndex( + clientMetadata: ClientMetadata, + dto: KeyDto, + ): Promise { + try { + this.logger.debug('Getting array next index.', clientMetadata); + const { keyName } = dto; + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const reply = await client.sendCommand([ + BrowserToolArrayCommands.ARNext, + keyName, + ]); + + this.logger.debug('Succeed to get array next index.', clientMetadata); + return plainToInstance(GetArrayNextIndexResponse, { + keyName, + index: toIndexString(reply), + }); + } catch (error) { + this.logger.error( + 'Failed to get array next index.', + error, + clientMetadata, + ); + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async getElement( + clientMetadata: ClientMetadata, + dto: GetArrayElementDto, + ): Promise { + try { + this.logger.debug('Getting array element.', clientMetadata); + const { keyName, index } = dto; + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const value = (await client.sendCommand([ + BrowserToolArrayCommands.ARGet, + keyName, + index, + ])) as Buffer | string | null; + + this.logger.debug('Succeed to get array element.', clientMetadata); + return plainToInstance(GetArrayElementResponse, { keyName, value }); + } catch (error) { + this.logger.error('Failed to get array element.', error, clientMetadata); + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async getMultiElements( + clientMetadata: ClientMetadata, + dto: GetArrayMultiElementsDto, + ): Promise { + try { + this.logger.debug('Getting array multi elements.', clientMetadata); + const { keyName, indexes } = dto; + const client = + await this.databaseClientFactory.getOrCreateClient(clientMetadata); + await checkIfKeyNotExists(keyName, client); + + const elements = (await client.sendCommand([ + BrowserToolArrayCommands.ARMGet, + keyName, + ...indexes, + ])) as (Buffer | string | null)[]; + + this.logger.debug('Succeed to get array multi elements.', clientMetadata); + return plainToInstance(GetArrayMultiElementsResponse, { + keyName, + elements, + }); + } catch (error) { + this.logger.error( + 'Failed to get array multi elements.', + error, + clientMetadata, + ); + if (error?.message?.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } } diff --git a/redisinsight/api/src/modules/browser/array/constants.ts b/redisinsight/api/src/modules/browser/array/constants.ts new file mode 100644 index 0000000000..823f224ced --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/constants.ts @@ -0,0 +1,8 @@ +// Server-enforced hard cap on ARGETRANGE: 1,000,000 elements per call. +// Source: https://redis.io/docs/latest/develop/data-types/arrays/#limits — +// "ARGETRANGE enforces a hard limit of 1,000,000 elements per call to guard +// against accidentally large range reads." We pre-flight the same check so +// callers get a clear 400 instead of a generic server error. +// Note: this limit applies only to ARGETRANGE. ARSCAN has no analogous cap +// because it skips empty slots — a wide range over a sparse array is cheap. +export const ARRAY_RANGE_MAX_ELEMENTS = 1_000_000; diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-count.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-count.response.ts new file mode 100644 index 0000000000..b7c9315643 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-count.response.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; + +export class GetArrayCountResponse extends KeyResponse { + @ApiProperty({ + description: + 'Count of populated (non-empty) elements. Unsigned 64-bit integer as string.', + type: String, + example: '5', + }) + count: string; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-element.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-element.dto.ts new file mode 100644 index 0000000000..2396b5d82d --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-element.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyDto } from 'src/modules/browser/keys/dto'; +import { IsArrayIndex } from 'src/common/decorators'; + +export class GetArrayElementDto extends KeyDto { + @ApiProperty({ + description: + 'Index of the element to read. Unsigned 64-bit integer as string.', + type: String, + example: '42', + }) + @IsArrayIndex() + index: string; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-element.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-element.response.ts new file mode 100644 index 0000000000..b0ee18d229 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-element.response.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; +import { RedisStringType } from 'src/common/decorators'; +import { RedisString } from 'src/common/constants'; + +export class GetArrayElementResponse extends KeyResponse { + @ApiProperty({ + description: + 'Value stored at the requested index, or null if the slot is empty.', + type: String, + nullable: true, + }) + @RedisStringType() + value: RedisString | null; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-length.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-length.response.ts new file mode 100644 index 0000000000..9c4dcb07eb --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-length.response.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; + +export class GetArrayLengthResponse extends KeyResponse { + @ApiProperty({ + description: + 'Logical length of the array (highest set index + 1, includes gaps). ' + + 'Unsigned 64-bit integer as string.', + type: String, + example: '7', + }) + length: string; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.dto.ts new file mode 100644 index 0000000000..61e9edd566 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyDto } from 'src/modules/browser/keys/dto'; +import { IsArrayIndex } from 'src/common/decorators'; +import { ArrayMinSize, IsArray } from 'class-validator'; + +export class GetArrayMultiElementsDto extends KeyDto { + @ApiProperty({ + description: + 'Indexes to read. Each index is an unsigned 64-bit integer as string.', + type: String, + isArray: true, + example: ['0', '5', '42'], + }) + @IsArray() + @ArrayMinSize(1) + @IsArrayIndex({ each: true }) + indexes: string[]; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts new file mode 100644 index 0000000000..294afe1a02 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; +import { RedisStringType } from 'src/common/decorators'; +import { RedisString } from 'src/common/constants'; + +export class GetArrayMultiElementsResponse extends KeyResponse { + @ApiProperty({ + description: + 'Values for each requested index, in request order. ' + + 'Empty slots are returned as null.', + type: 'array', + items: { oneOf: [{ type: 'string' }, { type: 'null' }] }, + nullable: true, + }) + @RedisStringType({ each: true }) + elements: (RedisString | null)[]; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-next-index.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-next-index.response.ts new file mode 100644 index 0000000000..350461c4fe --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-next-index.response.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; + +export class GetArrayNextIndexResponse extends KeyResponse { + @ApiProperty({ + description: + 'Next index that ARINSERT would use. Unsigned 64-bit integer as string.', + type: String, + example: '7', + }) + index: string; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts new file mode 100644 index 0000000000..376d3e5632 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyDto } from 'src/modules/browser/keys/dto'; +import { IsArrayIndex } from 'src/common/decorators'; + +export class GetArrayRangeDto extends KeyDto { + @ApiProperty({ + description: + 'Start index of the range (inclusive). Unsigned 64-bit integer as string.', + type: String, + example: '0', + }) + @IsArrayIndex() + start: string; + + @ApiProperty({ + description: + 'End index of the range (inclusive). Unsigned 64-bit integer as string. ' + + 'If start > end, the range is returned reversed.', + type: String, + example: '99', + }) + @IsArrayIndex() + end: string; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts new file mode 100644 index 0000000000..390345bd2d --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; +import { RedisStringType } from 'src/common/decorators'; +import { RedisString } from 'src/common/constants'; + +export class GetArrayRangeResponse extends KeyResponse { + @ApiProperty({ + description: + 'Values for each index in the requested range, in order. ' + + 'Empty slots are returned as null.', + type: 'array', + items: { oneOf: [{ type: 'string' }, { type: 'null' }] }, + nullable: true, + }) + @RedisStringType({ each: true }) + elements: (RedisString | null)[]; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts new file mode 100644 index 0000000000..87746e8314 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts @@ -0,0 +1,38 @@ +import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger'; +import { KeyDto } from 'src/modules/browser/keys/dto'; +import { IsArrayIndex } from 'src/common/decorators'; +import { IsInt, IsOptional, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class GetArrayScanDto extends KeyDto { + @ApiProperty({ + description: + 'Start index of the range (inclusive). Unsigned 64-bit integer as string.', + type: String, + example: '0', + }) + @IsArrayIndex() + start: string; + + @ApiProperty({ + description: + 'End index of the range (inclusive). Unsigned 64-bit integer as string.', + type: String, + example: '99', + }) + @IsArrayIndex() + end: string; + + @ApiPropertyOptional({ + description: + 'Maximum number of populated elements to return. ' + + 'Maps to the ARSCAN LIMIT option.', + type: Number, + minimum: 1, + }) + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + limit?: number; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-scan.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.response.ts new file mode 100644 index 0000000000..5f3117dc8c --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.response.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KeyResponse } from 'src/modules/browser/keys/dto'; +import { RedisStringType } from 'src/common/decorators'; +import { RedisString } from 'src/common/constants'; +import { Type } from 'class-transformer'; + +export class ArrayElement { + @ApiProperty({ + description: + 'Index of the populated element. Unsigned 64-bit integer as string.', + type: String, + }) + index: string; + + @ApiProperty({ + description: 'Value stored at this index.', + type: String, + }) + @RedisStringType() + value: RedisString; +} + +export class GetArrayScanResponse extends KeyResponse { + @ApiProperty({ + description: + 'Populated elements within the requested range. Empty slots are skipped.', + type: () => ArrayElement, + isArray: true, + }) + @Type(() => ArrayElement) + elements: ArrayElement[]; +} diff --git a/redisinsight/api/src/modules/browser/array/dto/index.ts b/redisinsight/api/src/modules/browser/array/dto/index.ts index c04de7a539..f7a7b7092b 100644 --- a/redisinsight/api/src/modules/browser/array/dto/index.ts +++ b/redisinsight/api/src/modules/browser/array/dto/index.ts @@ -1 +1,12 @@ export * from './create.array-with-expire.dto'; +export * from './get.array-range.dto'; +export * from './get.array-range.response'; +export * from './get.array-scan.dto'; +export * from './get.array-scan.response'; +export * from './get.array-element.dto'; +export * from './get.array-element.response'; +export * from './get.array-multi-elements.dto'; +export * from './get.array-multi-elements.response'; +export * from './get.array-length.response'; +export * from './get.array-count.response'; +export * from './get.array-next-index.response'; diff --git a/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts b/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts index 5848ac3ff1..1849fc72d8 100644 --- a/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts +++ b/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts @@ -118,6 +118,12 @@ export enum BrowserToolArrayCommands { ARGet = 'arget', ArSet = 'arset', ArMSet = 'armset', + ARMGet = 'armget', + ARLen = 'arlen', + ARCount = 'arcount', + ARGetRange = 'argetrange', + ARScan = 'arscan', + ARNext = 'arnext', } export type BrowserToolCommands = From e97009b4140f59f826bcbbc0dc39d1f129fa4f8a Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Fri, 12 Jun 2026 15:26:00 +0300 Subject: [PATCH 02/23] docs(api): add Array bruno presets and seed fixture (RI-8219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Bruno collection folder for the new Array endpoints with one preset per route plus: - Seed Sample Data — uploads fixtures/readings.txt via the existing POST /bulk-actions/import endpoint, seeding the sparse 'readings' array (indexes 0, 1, 5) that the happy-path presets target. - Get Element (empty slot) — documents the gap-preserving semantics (200 OK with value: null on an unset index, not 404). Requires Redis 8.8+ on the target database for the AR* commands. References: RI-8219 --- .../bruno/RedisInsight/Array/Get Count.bru | 45 +++++++++++ .../Array/Get Element (empty slot).bru | 52 +++++++++++++ .../bruno/RedisInsight/Array/Get Element.bru | 53 +++++++++++++ .../bruno/RedisInsight/Array/Get Elements.bru | 53 +++++++++++++ .../bruno/RedisInsight/Array/Get Length.bru | 45 +++++++++++ .../RedisInsight/Array/Get Next Index.bru | 45 +++++++++++ .../bruno/RedisInsight/Array/Get Range.bru | 56 ++++++++++++++ .../api/bruno/RedisInsight/Array/Scan.bru | 63 ++++++++++++++++ .../RedisInsight/Array/Seed Sample Data.bru | 75 +++++++++++++++++++ .../RedisInsight/Array/fixtures/readings.txt | 4 + .../api/bruno/RedisInsight/Array/folder.bru | 1 - 11 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 redisinsight/api/bruno/RedisInsight/Array/Get Count.bru create mode 100644 redisinsight/api/bruno/RedisInsight/Array/Get Element (empty slot).bru create mode 100644 redisinsight/api/bruno/RedisInsight/Array/Get Element.bru create mode 100644 redisinsight/api/bruno/RedisInsight/Array/Get Elements.bru create mode 100644 redisinsight/api/bruno/RedisInsight/Array/Get Length.bru create mode 100644 redisinsight/api/bruno/RedisInsight/Array/Get Next Index.bru create mode 100644 redisinsight/api/bruno/RedisInsight/Array/Get Range.bru create mode 100644 redisinsight/api/bruno/RedisInsight/Array/Scan.bru create mode 100644 redisinsight/api/bruno/RedisInsight/Array/Seed Sample Data.bru create mode 100644 redisinsight/api/bruno/RedisInsight/Array/fixtures/readings.txt diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Count.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Count.bru new file mode 100644 index 0000000000..4c60af5647 --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Count.bru @@ -0,0 +1,45 @@ +meta { + name: Get Count + type: http + seq: 4 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-count + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings" + } +} + +docs { + # ARCOUNT + + Returns the number of populated (non-empty) elements in the array. + Unsigned 64-bit integer returned as a numeric string. + + ## Response + + ``` + { + "keyName": "readings", + "count": "5" + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | Key holds a non-array type. | + | `403` | User has no permission for `ARCOUNT`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Element (empty slot).bru b/redisinsight/api/bruno/RedisInsight/Array/Get Element (empty slot).bru new file mode 100644 index 0000000000..4047ee936b --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Element (empty slot).bru @@ -0,0 +1,52 @@ +meta { + name: Get Element (empty slot) + type: http + seq: 8 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-element + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings", + "index": "3" + } +} + +docs { + # ARGET — empty slot + + Same endpoint as **Get Element**, pre-filled to query an **unset index** + on the seeded `readings` key. With the `Seed Sample Data` fixture + populating indexes `0`, `1`, `5`, asking for index `3` exercises the + gap-preserving semantics of Redis arrays. + + ## Why this preset exists + + A common reviewer assumption is "missing index → 404". For arrays it + isn't: the key exists and the array's logical length still spans the + requested index, so the API returns `200 OK` with a `null` `value`. + Only a missing **key** returns 404. + + ## Response + + ``` + { + "keyName": "readings", + "value": null + } + ``` + + ## Compare with + + - **Get Element** (`index: "1"`) → `"value": "20.4"` (slot is set). + - **Get Element** (`keyName: "no-such-key"`) → `404 Not Found` (key missing). +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Element.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Element.bru new file mode 100644 index 0000000000..71baa2db9a --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Element.bru @@ -0,0 +1,53 @@ +meta { + name: Get Element + type: http + seq: 6 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-element + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings", + "index": "1" + } +} + +docs { + # ARGET + + Get the value at a single index. Returns `null` for an empty slot or an + index past the array length. + + ## Request body + + | Field | Type | Notes | + |-----------|--------|--------------------------------------------------------| + | `keyName` | string | The Array key name. | + | `index` | string | Unsigned 64-bit integer as string. | + + ## Response + + ``` + { + "keyName": "readings", + "value": "20.4" + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | Key holds a non-array type. | + | `403` | User has no permission for `ARGET`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Elements.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Elements.bru new file mode 100644 index 0000000000..1421a19dee --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Elements.bru @@ -0,0 +1,53 @@ +meta { + name: Get Elements + type: http + seq: 7 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-elements + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings", + "indexes": ["0", "3", "5"] + } +} + +docs { + # ARMGET + + Get values at multiple indexes in one round-trip. Returns an array of + `value | null`, one entry per requested index, in request order. + + ## Request body + + | Field | Type | Notes | + |-----------|----------|------------------------------------------------------| + | `keyName` | string | The Array key name. | + | `indexes` | string[] | One or more indexes; each unsigned 64-bit as string. | + + ## Response + + ``` + { + "keyName": "readings", + "elements": ["20.1", null, "21.4"] + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | Any index is not a valid unsigned 64-bit integer string, or the key holds a non-array type. | + | `403` | User has no permission for `ARMGET`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Length.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Length.bru new file mode 100644 index 0000000000..8c6e1c9c8f --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Length.bru @@ -0,0 +1,45 @@ +meta { + name: Get Length + type: http + seq: 3 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-length + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings" + } +} + +docs { + # ARLEN + + Returns the logical length of the array — highest set index + 1 (includes + gaps). Unsigned 64-bit integer returned as a numeric string. + + ## Response + + ``` + { + "keyName": "readings", + "length": "7" + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | Key holds a non-array type. | + | `403` | User has no permission for `ARLEN`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Next Index.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Next Index.bru new file mode 100644 index 0000000000..838347edba --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Next Index.bru @@ -0,0 +1,45 @@ +meta { + name: Get Next Index + type: http + seq: 5 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-next-index + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings" + } +} + +docs { + # ARNEXT + + Returns the next index that `ARINSERT` would use. Read-only. + Unsigned 64-bit integer returned as a numeric string. + + ## Response + + ``` + { + "keyName": "readings", + "index": "7" + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | Key holds a non-array type. | + | `403` | User has no permission for `ARNEXT`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Range.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Range.bru new file mode 100644 index 0000000000..6d6eb225d0 --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Range.bru @@ -0,0 +1,56 @@ +meta { + name: Get Range + type: http + seq: 1 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/get-range + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings", + "start": "0", + "end": "6" + } +} + +docs { + # ARGETRANGE + + Read an inclusive range of elements from an Array key. Empty slots are + returned as `null`. The range is reversible (`start` > `end` returns the + range reversed). + + ## Request body + + | Field | Type | Notes | + |-----------|--------|----------------------------------------------------------| + | `keyName` | string | The Array key name. | + | `start` | string | Inclusive start index. Unsigned 64-bit integer as string.| + | `end` | string | Inclusive end index. Unsigned 64-bit integer as string. | + + ## Response + + ``` + { + "keyName": "readings", + "elements": ["20.1", "20.4", "20.9", null, null, "21.4", "21.9"] + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | Range exceeds 1,000,000 elements per call, or the key holds a non-array type. | + | `403` | User has no permission for `ARGETRANGE`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Scan.bru b/redisinsight/api/bruno/RedisInsight/Array/Scan.bru new file mode 100644 index 0000000000..61a789d2a7 --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Scan.bru @@ -0,0 +1,63 @@ +meta { + name: Scan + type: http + seq: 2 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/array/scan + body: json + auth: inherit +} + +body:json { + { + "keyName": "readings", + "start": "0", + "end": "6", + "limit": 100 + } +} + +docs { + # ARSCAN + + Read a range of populated elements from an Array key, skipping empty slots. + Cheaper than `Get Range` for sparse arrays. + + ## Request body + + | Field | Type | Notes | + |-----------|------------------|----------------------------------------------------------| + | `keyName` | string | The Array key name. | + | `start` | string | Inclusive start index. Unsigned 64-bit integer as string.| + | `end` | string | Inclusive end index. Unsigned 64-bit integer as string. | + | `limit` | number, optional | Maps to ARSCAN `LIMIT`. | + + ## Response + + ``` + { + "keyName": "readings", + "elements": [ + { "index": "0", "value": "20.1" }, + { "index": "1", "value": "20.4" }, + { "index": "2", "value": "20.9" }, + { "index": "5", "value": "21.4" }, + { "index": "6", "value": "21.9" } + ] + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | Key holds a non-array type. | + | `403` | User has no permission for `ARSCAN`. | + | `404` | Key does not exist. | +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/Seed Sample Data.bru b/redisinsight/api/bruno/RedisInsight/Array/Seed Sample Data.bru new file mode 100644 index 0000000000..f0105a4a33 --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/Seed Sample Data.bru @@ -0,0 +1,75 @@ +meta { + name: Seed Sample Data + type: http + seq: 0 +} + +post { + url: {{API_URL}}/databases/{{DB_INSTANCE_ID}}/bulk-actions/import + body: multipartForm + auth: inherit +} + +body:multipart-form { + file: @file(Array/fixtures/readings.txt) +} + +docs { + # Seed Sample Data (Array) + + Loads the `readings` sample array used by the rest of the Array presets + by reusing the existing **bulk-actions import** pipeline (`POST + /bulk-actions/import`) — the same generic loader used for `.txt` files + of raw Redis CLI commands. + + ## How it works + + - The fixture lives at `Array/fixtures/readings.txt`. Bruno resolves + `@file()` paths relative to the **collection root** + (`redisinsight/api/bruno/RedisInsight/`), not the `.bru` file's + directory. + - Each line is one Redis CLI command, parsed via `splitCliCommandLine` + and dispatched through a Redis pipeline by the server. + - The fixture first `DEL`s the key, then `ARSET`s a sparse layout + (indexes 0, 1, 5) — matching the example responses in the other + Array presets. + + Requires Redis 8.8+ on the target database (for the `ARSET` command). + + ## Response + + Returns `200 OK` with a `BulkActionOverview`, e.g.: + + ```json + { + "summary": { + "processed": 4, + "succeed": 4, + "failed": 0, + "errors": [], + "keys": [] + }, + "status": "completed" + } + ``` + + ## Errors + + | Status | When | + |--------|------| + | `400` | Fixture is malformed or unreadable. | + | `500` | Generic import failure. | + + Per-command failures (e.g. `ARSET` on a pre-8.8 server) do **not** fail + the whole request — they're reported in `summary.failed`. + + ## Follow-up + + Once seeded, run the other presets in the Array folder: + **Get Length** → **Get Count** → **Get Next Index** → **Get Range** → + **Scan** → **Get Element** → **Get Elements**. +} + +settings { + encodeUrl: true +} diff --git a/redisinsight/api/bruno/RedisInsight/Array/fixtures/readings.txt b/redisinsight/api/bruno/RedisInsight/Array/fixtures/readings.txt new file mode 100644 index 0000000000..1160790215 --- /dev/null +++ b/redisinsight/api/bruno/RedisInsight/Array/fixtures/readings.txt @@ -0,0 +1,4 @@ +DEL readings +ARSET readings 0 "20.1" +ARSET readings 1 "20.4" +ARSET readings 5 "99.9" diff --git a/redisinsight/api/bruno/RedisInsight/Array/folder.bru b/redisinsight/api/bruno/RedisInsight/Array/folder.bru index bc1a22235a..3a409c6c5f 100644 --- a/redisinsight/api/bruno/RedisInsight/Array/folder.bru +++ b/redisinsight/api/bruno/RedisInsight/Array/folder.bru @@ -1,4 +1,3 @@ meta { name: Array } - From 8ba1b361f21d8546d1cdb7602ac16b900c4abdf1 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Fri, 12 Jun 2026 19:32:57 +0300 Subject: [PATCH 03/23] fix(api): harden array read paths for null/undefined edges (RI-8219) - redis-string-to-buffer transformer: pass null/undefined through rather than throwing, matching the ASCII/UTF8 sibling transformers. - ARSCAN pairing: accept both flat [idx, val, ...] and nested [[idx, val], ...] reply shapes; drop pairs with null/undefined halves so the populated-only contract is honored end-to-end. --- .../redis-string-to-buffer.transformer.ts | 8 ++++ .../browser/array/array.service.spec.ts | 46 +++++++++++++++++++ .../modules/browser/array/array.service.ts | 31 ++++++++++--- 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.ts b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.ts index 98ee2fff89..e0620a91c3 100644 --- a/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.ts +++ b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.ts @@ -7,6 +7,14 @@ const SingleRedisStringToBuffer = ({ value }) => { return value; } + // Preserve null/undefined so callers can express "absent" entries (e.g. + // ARGETRANGE returns null for empty slots in a gap-preserving response). + // Mirrors the behaviour of the sibling ASCII / UTF8 transformers, which + // also short-circuit on non-Buffer values rather than throwing. + if (value === null || value === undefined) { + return value; + } + return Buffer.from(value); }; diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index 7a608de450..abe12b5109 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -298,6 +298,52 @@ describe('ArrayService', () => { expect(result.elements).toHaveLength(1); }); + it('should also accept the nested [[index, value], ...] reply shape', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ARScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ]) + .mockResolvedValue([ + [Buffer.from('0'), mockArrayElement1], + [Buffer.from('1'), Buffer.from('20.4')], + ] as unknown as (Buffer | string)[]); + + const result = await service.scan( + mockBrowserClientMetadata, + mockGetArrayScanDto, + ); + expect(result.elements).toHaveLength(2); + expect(result.elements[0].index).toBe('0'); + expect(result.elements[1].index).toBe('1'); + }); + + it('should drop pairs whose value or index is null/undefined', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ARScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ]) + .mockResolvedValue([ + Buffer.from('0'), + mockArrayElement1, + Buffer.from('1'), + null, + Buffer.from('2'), + ] as (Buffer | string | null)[]); + + const result = await service.scan( + mockBrowserClientMetadata, + mockGetArrayScanDto, + ); + expect(result.elements).toHaveLength(1); + expect(result.elements[0].index).toBe('0'); + }); + it('should reject when key does not exist', async () => { when(mockStandaloneRedisClient.sendCommand) .calledWith([BrowserToolKeysCommands.Exists, mockKeyDto.keyName]) diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 076b3bc62d..1c2385583c 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -192,17 +192,34 @@ export class ArrayService { start, end, ] as const; - const flat = (await client.sendCommand( + const reply = (await client.sendCommand( limit !== undefined ? [...baseArgs, 'LIMIT', limit] : [...baseArgs], - )) as (Buffer | string)[]; + )) as unknown[]; + + // ARSCAN's wire shape varies by client: some clients surface a flat + // [index, value, index, value, ...] reply, others group it into + // [[index, value], [index, value], ...]. Detect by sniffing the first + // element and normalize both into pairs. Malformed pairs (missing + // half) are dropped to honor the "populated-only" contract — + // JSON.stringify would otherwise drop an undefined value and reach + // the client as `{ index }` with no value. + const pairs: Array<[unknown, unknown]> = []; + if (Array.isArray(reply[0])) { + for (const entry of reply as unknown[][]) { + if (entry?.length >= 2) pairs.push([entry[0], entry[1]]); + } + } else { + for (let i = 0; i < reply.length; i += 2) { + pairs.push([reply[i], reply[i + 1]]); + } + } - // Server returns a flat [index, value, index, value, ...] reply; - // pair them into structured elements for the UI. const elements: ArrayElement[] = []; - for (let i = 0; i < flat.length; i += 2) { + for (const [rawIndex, value] of pairs) { + if (rawIndex == null || value == null) continue; elements.push({ - index: toIndexString(flat[i]), - value: flat[i + 1], + index: toIndexString(rawIndex), + value: value as Buffer | string, }); } From 62a94ff48809715ba2540a3e9ccc1419e6f4768e Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Fri, 12 Jun 2026 19:33:15 +0300 Subject: [PATCH 04/23] feat(api): expose array length and count via key-info (RI-8219) Adds ArrayKeyInfoStrategy that pipelines TTL + ARLEN + ARCOUNT + MEMORY USAGE so the array length and populated count appear in the key details header (mirroring vector-set's vectorDim/quantType). Registers the ar* commands as ioredis built-ins so they can be issued via sendPipeline (sendCommand already handled them). - GetKeyInfoResponse: + count?: number - ArrayKeyInfoStrategy: pipelined fetch with size-skip when length exceeds MAX_KEY_SIZE (mirrors list/vector-set patterns) - KeyInfoProvider / KeysModule: register the new strategy - ioredis client: addBuiltinCommand for arget/armget/arlen/arcount/ argetrange/arscan/arnext --- .../keys/dto/get.keys-info.response.ts | 7 + .../keys/key-info/key-info.provider.spec.ts | 3 + .../keys/key-info/key-info.provider.ts | 4 + .../array.key-info.strategy.spec.ts | 123 ++++++++++++++++++ .../strategies/array.key-info.strategy.ts | 63 +++++++++ .../src/modules/browser/keys/keys.module.ts | 2 + .../redis/client/ioredis/ioredis.client.ts | 11 +- 7 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts diff --git a/redisinsight/api/src/modules/browser/keys/dto/get.keys-info.response.ts b/redisinsight/api/src/modules/browser/keys/dto/get.keys-info.response.ts index e0592c3b8a..bdd045d566 100644 --- a/redisinsight/api/src/modules/browser/keys/dto/get.keys-info.response.ts +++ b/redisinsight/api/src/modules/browser/keys/dto/get.keys-info.response.ts @@ -45,4 +45,11 @@ export class GetKeyInfoResponse { description: 'The vector dimensions for vector set keys.', }) vectorDim?: number; + + @ApiPropertyOptional({ + type: Number, + description: + 'The populated element count for array keys (excludes empty slots).', + }) + count?: number; } diff --git a/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.spec.ts b/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.spec.ts index ffe324936f..fa1cc7f860 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.spec.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { KeyInfoProvider } from 'src/modules/browser/keys/key-info/key-info.provider'; import { RedisDataType } from 'src/modules/browser/keys/dto'; import { UnsupportedKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/unsupported.key-info.strategy'; +import { ArrayKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/array.key-info.strategy'; import { GraphKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/graph.key-info.strategy'; import { HashKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/hash.key-info.strategy'; import { ListKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/list.key-info.strategy'; @@ -20,6 +21,7 @@ describe('KeyInfoProvider', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ KeyInfoProvider, + ArrayKeyInfoStrategy, GraphKeyInfoStrategy, HashKeyInfoStrategy, ListKeyInfoStrategy, @@ -43,6 +45,7 @@ describe('KeyInfoProvider', () => { input: 'unknown' as RedisDataType, strategy: UnsupportedKeyInfoStrategy, }, + { input: RedisDataType.Array, strategy: ArrayKeyInfoStrategy }, { input: RedisDataType.Graph, strategy: GraphKeyInfoStrategy }, { input: RedisDataType.Hash, strategy: HashKeyInfoStrategy }, { input: RedisDataType.List, strategy: ListKeyInfoStrategy }, diff --git a/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.ts b/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.ts index 5579067dbd..2171443a6f 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/key-info.provider.ts @@ -1,3 +1,4 @@ +import { ArrayKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/array.key-info.strategy'; import { GraphKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/graph.key-info.strategy'; import { HashKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/hash.key-info.strategy'; import { ListKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/list.key-info.strategy'; @@ -16,6 +17,7 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class KeyInfoProvider { constructor( + private readonly arrayKeyInfoStrategy: ArrayKeyInfoStrategy, private readonly graphKeyInfoStrategy: GraphKeyInfoStrategy, private readonly hashKeyInfoStrategy: HashKeyInfoStrategy, private readonly listKeyInfoStrategy: ListKeyInfoStrategy, @@ -31,6 +33,8 @@ export class KeyInfoProvider { getStrategy(type?: string): KeyInfoStrategy { switch (type) { + case RedisDataType.Array: + return this.arrayKeyInfoStrategy; case RedisDataType.Graph: return this.graphKeyInfoStrategy; case RedisDataType.Hash: diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts new file mode 100644 index 0000000000..8e0fcd23b3 --- /dev/null +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts @@ -0,0 +1,123 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { mockStandaloneRedisClient } from 'src/__mocks__'; +import { + BrowserToolArrayCommands, + BrowserToolKeysCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { + GetKeyInfoResponse, + RedisDataType, +} from 'src/modules/browser/keys/dto'; +import { ArrayKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/array.key-info.strategy'; +import { MAX_KEY_SIZE } from 'src/modules/browser/keys/key-info/constants'; + +const getKeyInfoResponse: GetKeyInfoResponse = { + name: 'testArray', + type: 'array', + ttl: -1, + size: 50, + length: 10, + count: 7, +}; + +describe('ArrayKeyInfoStrategy', () => { + let strategy: ArrayKeyInfoStrategy; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ArrayKeyInfoStrategy], + }).compile(); + + strategy = module.get(ArrayKeyInfoStrategy); + }); + + describe('getInfo', () => { + const key = getKeyInfoResponse.name; + const { ttl, length, size, count } = getKeyInfoResponse; + + describe('when includeSize is true', () => { + it('should return ttl, length, count, and size in single pipeline', async () => { + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ARLen, key], + [BrowserToolArrayCommands.ARCount, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValueOnce([ + [null, ttl], + [null, length], + [null, count], + [null, size], + ]); + + const result = await strategy.getInfo( + mockStandaloneRedisClient, + key, + RedisDataType.Array, + true, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + }); + + describe('when includeSize is false', () => { + it('should skip MEMORY USAGE when length exceeds MAX_KEY_SIZE', async () => { + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ARLen, key], + [BrowserToolArrayCommands.ARCount, key], + ]) + .mockResolvedValueOnce([ + [null, ttl], + [null, MAX_KEY_SIZE + 1], + [null, count], + ]); + + const result = await strategy.getInfo( + mockStandaloneRedisClient, + key, + RedisDataType.Array, + false, + ); + + expect(result).toEqual({ + ...getKeyInfoResponse, + length: MAX_KEY_SIZE + 1, + size: -1, + }); + }); + + it('should issue MEMORY USAGE separately when length is small', async () => { + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ARLen, key], + [BrowserToolArrayCommands.ARCount, key], + ]) + .mockResolvedValueOnce([ + [null, ttl], + [null, length], + [null, count], + ]); + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValueOnce([[null, size]]); + + const result = await strategy.getInfo( + mockStandaloneRedisClient, + key, + RedisDataType.Array, + false, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts new file mode 100644 index 0000000000..27c6f21239 --- /dev/null +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts @@ -0,0 +1,63 @@ +import { + GetKeyInfoResponse, + RedisDataType, +} from 'src/modules/browser/keys/dto'; +import { + BrowserToolArrayCommands, + BrowserToolKeysCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { RedisString } from 'src/common/constants'; +import { KeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/key-info.strategy'; +import { RedisClient } from 'src/modules/redis/client'; +import { MAX_KEY_SIZE } from 'src/modules/browser/keys/key-info/constants'; + +/** + * Key-info strategy for the Array data type. Returns the standard + * TTL / size / length triple plus a `count` field carrying the number of + * populated slots (ARCOUNT). Length reflects total addressable slots + * (ARLEN, including gaps); count reflects only the populated ones — the + * two diverge for sparse arrays and the View tab surfaces both. + */ +export class ArrayKeyInfoStrategy extends KeyInfoStrategy { + public async getInfo( + client: RedisClient, + key: RedisString, + type: string, + includeSize: boolean, + ): Promise { + this.logger.debug(`Getting ${RedisDataType.Array} type info.`); + + if (includeSize !== false) { + const [ + [, ttl = null], + [, length = null], + [, count = null], + [, size = null], + ] = (await client.sendPipeline([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ARLen, key], + [BrowserToolArrayCommands.ARCount, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ])) as [any, any][]; + + return { name: key, type, ttl, size, length, count }; + } + + const [[, ttl = null], [, length = null], [, count = null]] = + (await client.sendPipeline([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ARLen, key], + [BrowserToolArrayCommands.ARCount, key], + ])) as [any, any][]; + + let size = -1; + if (length < MAX_KEY_SIZE) { + const sizeData = (await client.sendPipeline([ + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ])) as [any, number][]; + size = sizeData && sizeData[0] && sizeData[0][1]; + } + + return { name: key, type, ttl, size, length, count }; + } +} diff --git a/redisinsight/api/src/modules/browser/keys/keys.module.ts b/redisinsight/api/src/modules/browser/keys/keys.module.ts index 11c2e5f781..b09d244c80 100644 --- a/redisinsight/api/src/modules/browser/keys/keys.module.ts +++ b/redisinsight/api/src/modules/browser/keys/keys.module.ts @@ -6,6 +6,7 @@ import { ClusterScannerStrategy } from 'src/modules/browser/keys/scanner/strateg import { Scanner } from 'src/modules/browser/keys/scanner/scanner'; import { KeysService } from 'src/modules/browser/keys/keys.service'; import { KeyInfoProvider } from 'src/modules/browser/keys/key-info/key-info.provider'; +import { ArrayKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/array.key-info.strategy'; import { GraphKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/graph.key-info.strategy'; import { HashKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/hash.key-info.strategy'; import { ListKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/list.key-info.strategy'; @@ -42,6 +43,7 @@ export class KeysModule { StandaloneScannerStrategy, ClusterScannerStrategy, // key info strategies + ArrayKeyInfoStrategy, GraphKeyInfoStrategy, HashKeyInfoStrategy, ListKeyInfoStrategy, diff --git a/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts b/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts index 207fe3e708..eea454b33a 100644 --- a/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts +++ b/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts @@ -46,9 +46,18 @@ export abstract class IoredisClient extends RedisClient { client.addBuiltinCommand(BrowserToolVectorSetCommands.VSetAttr); client.addBuiltinCommand(BrowserToolVectorSetCommands.VRem); client.addBuiltinCommand(BrowserToolVectorSetCommands.VSim); - // Array commands + // Array commands — registered so they can be issued via the pipeline + // path (which maps commands to prototype methods); sendCommand works + // without registration but sendPipeline does not. client.addBuiltinCommand(BrowserToolArrayCommands.ArSet); client.addBuiltinCommand(BrowserToolArrayCommands.ArMSet); + client.addBuiltinCommand(BrowserToolArrayCommands.ARGet); + client.addBuiltinCommand(BrowserToolArrayCommands.ARMGet); + client.addBuiltinCommand(BrowserToolArrayCommands.ARLen); + client.addBuiltinCommand(BrowserToolArrayCommands.ARCount); + client.addBuiltinCommand(BrowserToolArrayCommands.ARGetRange); + client.addBuiltinCommand(BrowserToolArrayCommands.ARScan); + client.addBuiltinCommand(BrowserToolArrayCommands.ARNext); } static prepareCommandOptions(options: IRedisClientCommandOptions): any { From 9aa03adec87ac15c1a596c4e118b5b9037e2503f Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 15 Jun 2026 13:34:49 +0300 Subject: [PATCH 05/23] use the proper casing --- .../browser/array/array.service.spec.ts | 30 +++++++++---------- .../modules/browser/array/array.service.ts | 14 ++++----- .../constants/browser-tool-commands.ts | 14 ++++----- .../array.key-info.strategy.spec.ts | 12 ++++---- .../strategies/array.key-info.strategy.ts | 8 ++--- .../redis/client/ioredis/ioredis.client.ts | 14 ++++----- .../modules/redis/client/redis.client.spec.ts | 2 +- .../src/modules/redis/client/redis.client.ts | 2 +- 8 files changed, 48 insertions(+), 48 deletions(-) diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index abe12b5109..b2f6613cfb 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -177,7 +177,7 @@ describe('ArrayService', () => { beforeEach(() => { when(mockStandaloneRedisClient.sendCommand) .calledWith([ - BrowserToolArrayCommands.ARGetRange, + BrowserToolArrayCommands.ArGetRange, mockGetArrayRangeDto.keyName, mockGetArrayRangeDto.start, mockGetArrayRangeDto.end, @@ -229,7 +229,7 @@ describe('ArrayService', () => { }; when(mockStandaloneRedisClient.sendCommand) .calledWith([ - BrowserToolArrayCommands.ARGetRange, + BrowserToolArrayCommands.ArGetRange, expect.anything(), expect.anything(), expect.anything(), @@ -263,7 +263,7 @@ describe('ArrayService', () => { beforeEach(() => { when(mockStandaloneRedisClient.sendCommand) .calledWith([ - BrowserToolArrayCommands.ARScan, + BrowserToolArrayCommands.ArScan, mockGetArrayScanDto.keyName, mockGetArrayScanDto.start, mockGetArrayScanDto.end, @@ -282,7 +282,7 @@ describe('ArrayService', () => { it('should append LIMIT when provided', async () => { when(mockStandaloneRedisClient.sendCommand) .calledWith([ - BrowserToolArrayCommands.ARScan, + BrowserToolArrayCommands.ArScan, mockGetArrayScanDto.keyName, mockGetArrayScanDto.start, mockGetArrayScanDto.end, @@ -301,7 +301,7 @@ describe('ArrayService', () => { it('should also accept the nested [[index, value], ...] reply shape', async () => { when(mockStandaloneRedisClient.sendCommand) .calledWith([ - BrowserToolArrayCommands.ARScan, + BrowserToolArrayCommands.ArScan, mockGetArrayScanDto.keyName, mockGetArrayScanDto.start, mockGetArrayScanDto.end, @@ -323,7 +323,7 @@ describe('ArrayService', () => { it('should drop pairs whose value or index is null/undefined', async () => { when(mockStandaloneRedisClient.sendCommand) .calledWith([ - BrowserToolArrayCommands.ARScan, + BrowserToolArrayCommands.ArScan, mockGetArrayScanDto.keyName, mockGetArrayScanDto.start, mockGetArrayScanDto.end, @@ -359,7 +359,7 @@ describe('ArrayService', () => { command: 'ARSCAN', }; when(mockStandaloneRedisClient.sendCommand) - .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ARScan])) + .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ArScan])) .mockRejectedValue(replyError); await expect( service.scan(mockBrowserClientMetadata, mockGetArrayScanDto), @@ -381,7 +381,7 @@ describe('ArrayService', () => { describe.each([ { name: 'getLength', - command: BrowserToolArrayCommands.ARLen, + command: BrowserToolArrayCommands.ArLen, reply: 7, expected: mockGetArrayLengthResponse, stringValue: mockArrayLength, @@ -390,7 +390,7 @@ describe('ArrayService', () => { }, { name: 'getCount', - command: BrowserToolArrayCommands.ARCount, + command: BrowserToolArrayCommands.ArCount, reply: 5, expected: mockGetArrayCountResponse, stringValue: mockArrayCount, @@ -399,7 +399,7 @@ describe('ArrayService', () => { }, { name: 'getNextIndex', - command: BrowserToolArrayCommands.ARNext, + command: BrowserToolArrayCommands.ArNext, reply: 7, expected: mockGetArrayNextIndexResponse, stringValue: mockArrayNextIndex, @@ -450,7 +450,7 @@ describe('ArrayService', () => { beforeEach(() => { when(mockStandaloneRedisClient.sendCommand) .calledWith([ - BrowserToolArrayCommands.ARGet, + BrowserToolArrayCommands.ArGet, mockGetArrayElementDto.keyName, mockGetArrayElementDto.index, ]) @@ -468,7 +468,7 @@ describe('ArrayService', () => { it('should return null for an empty slot', async () => { when(mockStandaloneRedisClient.sendCommand) .calledWith([ - BrowserToolArrayCommands.ARGet, + BrowserToolArrayCommands.ArGet, mockGetArrayElementDto.keyName, mockGetArrayElementDto.index, ]) @@ -495,7 +495,7 @@ describe('ArrayService', () => { command: 'ARGET', }; when(mockStandaloneRedisClient.sendCommand) - .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ARGet])) + .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ArGet])) .mockRejectedValue(replyError); await expect( service.getElement(mockBrowserClientMetadata, mockGetArrayElementDto), @@ -518,7 +518,7 @@ describe('ArrayService', () => { beforeEach(() => { when(mockStandaloneRedisClient.sendCommand) .calledWith([ - BrowserToolArrayCommands.ARMGet, + BrowserToolArrayCommands.ArMGet, mockGetArrayMultiElementsDto.keyName, ...mockGetArrayMultiElementsDto.indexes, ]) @@ -551,7 +551,7 @@ describe('ArrayService', () => { command: 'ARMGET', }; when(mockStandaloneRedisClient.sendCommand) - .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ARMGet])) + .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ArMGet])) .mockRejectedValue(replyError); await expect( service.getMultiElements( diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 1c2385583c..00211818da 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -115,7 +115,7 @@ export class ArrayService { await checkIfKeyNotExists(keyName, client); const elements = (await client.sendCommand([ - BrowserToolArrayCommands.ARGetRange, + BrowserToolArrayCommands.ArGetRange, keyName, start, end, @@ -187,7 +187,7 @@ export class ArrayService { await checkIfKeyNotExists(keyName, client); const baseArgs = [ - BrowserToolArrayCommands.ARScan as string, + BrowserToolArrayCommands.ArScan as string, keyName, start, end, @@ -246,7 +246,7 @@ export class ArrayService { await checkIfKeyNotExists(keyName, client); const reply = await client.sendCommand([ - BrowserToolArrayCommands.ARLen, + BrowserToolArrayCommands.ArLen, keyName, ]); @@ -276,7 +276,7 @@ export class ArrayService { await checkIfKeyNotExists(keyName, client); const reply = await client.sendCommand([ - BrowserToolArrayCommands.ARCount, + BrowserToolArrayCommands.ArCount, keyName, ]); @@ -306,7 +306,7 @@ export class ArrayService { await checkIfKeyNotExists(keyName, client); const reply = await client.sendCommand([ - BrowserToolArrayCommands.ARNext, + BrowserToolArrayCommands.ArNext, keyName, ]); @@ -340,7 +340,7 @@ export class ArrayService { await checkIfKeyNotExists(keyName, client); const value = (await client.sendCommand([ - BrowserToolArrayCommands.ARGet, + BrowserToolArrayCommands.ArGet, keyName, index, ])) as Buffer | string | null; @@ -368,7 +368,7 @@ export class ArrayService { await checkIfKeyNotExists(keyName, client); const elements = (await client.sendCommand([ - BrowserToolArrayCommands.ARMGet, + BrowserToolArrayCommands.ArMGet, keyName, ...indexes, ])) as (Buffer | string | null)[]; diff --git a/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts b/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts index 1849fc72d8..5352907612 100644 --- a/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts +++ b/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts @@ -115,15 +115,15 @@ export enum BrowserToolVectorSetCommands { } export enum BrowserToolArrayCommands { - ARGet = 'arget', ArSet = 'arset', ArMSet = 'armset', - ARMGet = 'armget', - ARLen = 'arlen', - ARCount = 'arcount', - ARGetRange = 'argetrange', - ARScan = 'arscan', - ARNext = 'arnext', + ArGet = 'arget', + ArMGet = 'armget', + ArLen = 'arlen', + ArCount = 'arcount', + ArGetRange = 'argetrange', + ArScan = 'arscan', + ArNext = 'arnext', } export type BrowserToolCommands = diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts index 8e0fcd23b3..75aa254977 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts @@ -41,8 +41,8 @@ describe('ArrayKeyInfoStrategy', () => { when(mockStandaloneRedisClient.sendPipeline) .calledWith([ [BrowserToolKeysCommands.Ttl, key], - [BrowserToolArrayCommands.ARLen, key], - [BrowserToolArrayCommands.ARCount, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], ]) .mockResolvedValueOnce([ @@ -68,8 +68,8 @@ describe('ArrayKeyInfoStrategy', () => { when(mockStandaloneRedisClient.sendPipeline) .calledWith([ [BrowserToolKeysCommands.Ttl, key], - [BrowserToolArrayCommands.ARLen, key], - [BrowserToolArrayCommands.ARCount, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], ]) .mockResolvedValueOnce([ [null, ttl], @@ -95,8 +95,8 @@ describe('ArrayKeyInfoStrategy', () => { when(mockStandaloneRedisClient.sendPipeline) .calledWith([ [BrowserToolKeysCommands.Ttl, key], - [BrowserToolArrayCommands.ARLen, key], - [BrowserToolArrayCommands.ARCount, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], ]) .mockResolvedValueOnce([ [null, ttl], diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts index 27c6f21239..8c1e5774c0 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts @@ -35,8 +35,8 @@ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { [, size = null], ] = (await client.sendPipeline([ [BrowserToolKeysCommands.Ttl, key], - [BrowserToolArrayCommands.ARLen, key], - [BrowserToolArrayCommands.ARCount, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], ])) as [any, any][]; @@ -46,8 +46,8 @@ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { const [[, ttl = null], [, length = null], [, count = null]] = (await client.sendPipeline([ [BrowserToolKeysCommands.Ttl, key], - [BrowserToolArrayCommands.ARLen, key], - [BrowserToolArrayCommands.ARCount, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], ])) as [any, any][]; let size = -1; diff --git a/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts b/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts index eea454b33a..c8221c2cee 100644 --- a/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts +++ b/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts @@ -51,13 +51,13 @@ export abstract class IoredisClient extends RedisClient { // without registration but sendPipeline does not. client.addBuiltinCommand(BrowserToolArrayCommands.ArSet); client.addBuiltinCommand(BrowserToolArrayCommands.ArMSet); - client.addBuiltinCommand(BrowserToolArrayCommands.ARGet); - client.addBuiltinCommand(BrowserToolArrayCommands.ARMGet); - client.addBuiltinCommand(BrowserToolArrayCommands.ARLen); - client.addBuiltinCommand(BrowserToolArrayCommands.ARCount); - client.addBuiltinCommand(BrowserToolArrayCommands.ARGetRange); - client.addBuiltinCommand(BrowserToolArrayCommands.ARScan); - client.addBuiltinCommand(BrowserToolArrayCommands.ARNext); + client.addBuiltinCommand(BrowserToolArrayCommands.ArGet); + client.addBuiltinCommand(BrowserToolArrayCommands.ArMGet); + client.addBuiltinCommand(BrowserToolArrayCommands.ArLen); + client.addBuiltinCommand(BrowserToolArrayCommands.ArCount); + client.addBuiltinCommand(BrowserToolArrayCommands.ArGetRange); + client.addBuiltinCommand(BrowserToolArrayCommands.ArScan); + client.addBuiltinCommand(BrowserToolArrayCommands.ArNext); } static prepareCommandOptions(options: IRedisClientCommandOptions): any { diff --git a/redisinsight/api/src/modules/redis/client/redis.client.spec.ts b/redisinsight/api/src/modules/redis/client/redis.client.spec.ts index 9014b38dd2..7201219de8 100644 --- a/redisinsight/api/src/modules/redis/client/redis.client.spec.ts +++ b/redisinsight/api/src/modules/redis/client/redis.client.spec.ts @@ -33,7 +33,7 @@ describe('RedisClient', () => { expect(result).toBe(true); expect(client.call).toHaveBeenCalledWith( - ['command', 'info', BrowserToolArrayCommands.ARGet], + ['command', 'info', BrowserToolArrayCommands.ArGet], { replyEncoding: 'utf8' }, ); }); diff --git a/redisinsight/api/src/modules/redis/client/redis.client.ts b/redisinsight/api/src/modules/redis/client/redis.client.ts index e45abf3d29..32fcdc07f6 100644 --- a/redisinsight/api/src/modules/redis/client/redis.client.ts +++ b/redisinsight/api/src/modules/redis/client/redis.client.ts @@ -208,7 +208,7 @@ export abstract class RedisClient extends EventEmitter2 { try { const reply = (await this.call( - ['command', 'info', BrowserToolArrayCommands.ARGet], + ['command', 'info', BrowserToolArrayCommands.ArGet], { replyEncoding: 'utf8' }, )) as RedisClientCommandReply[]; From f51748fe11115fcc0f9afc58cab9741f48ba4659 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 15 Jun 2026 14:51:08 +0300 Subject: [PATCH 06/23] add limit for ARSCAN --- .../browser/array/array.service.spec.ts | 20 +++++++++++++++++++ .../modules/browser/array/array.service.ts | 7 +++++++ .../src/modules/browser/array/constants.ts | 9 ++++++--- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index b2f6613cfb..147419c1af 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -353,6 +353,26 @@ describe('ArrayService', () => { ).rejects.toThrow(NotFoundException); }); + it('should reject when range exceeds the 1M cap', async () => { + await expect( + service.scan(mockBrowserClientMetadata, { + ...mockGetArrayScanDto, + start: '0', + end: String(ARRAY_RANGE_MAX_ELEMENTS), + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should reject when reversed range exceeds the 1M cap', async () => { + await expect( + service.scan(mockBrowserClientMetadata, { + ...mockGetArrayScanDto, + start: String(ARRAY_RANGE_MAX_ELEMENTS), + end: '0', + }), + ).rejects.toThrow(BadRequestException); + }); + it('should rethrow BadRequest on WrongType', async () => { const replyError: ReplyError = { ...mockRedisWrongTypeError, diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 00211818da..7f4317d6b4 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -182,6 +182,12 @@ export class ArrayService { this.logger.debug('Scanning array range.', clientMetadata); const { keyName, start, end, limit } = dto; + // ARSCAN skips empty slots in the response but still walks the index + // range server-side (O(|end-start|+1)). Apply the same span cap as + // ARGETRANGE so an unbounded range cannot tie up Redis even when LIMIT + // is omitted; LIMIT remains a complementary result-set cap. + this.assertRangeWithinCap(start, end); + const client = await this.databaseClientFactory.getOrCreateClient(clientMetadata); await checkIfKeyNotExists(keyName, client); @@ -227,6 +233,7 @@ export class ArrayService { return plainToInstance(GetArrayScanResponse, { keyName, elements }); } catch (error) { this.logger.error('Failed to scan array range.', error, clientMetadata); + if (error instanceof BadRequestException) throw error; if (error?.message?.includes(RedisErrorCodes.WrongType)) { throw new BadRequestException(error.message); } diff --git a/redisinsight/api/src/modules/browser/array/constants.ts b/redisinsight/api/src/modules/browser/array/constants.ts index 823f224ced..14cdb8ee15 100644 --- a/redisinsight/api/src/modules/browser/array/constants.ts +++ b/redisinsight/api/src/modules/browser/array/constants.ts @@ -1,8 +1,11 @@ -// Server-enforced hard cap on ARGETRANGE: 1,000,000 elements per call. +// Server-enforced hard cap on index-range reads: 1,000,000 elements per call. // Source: https://redis.io/docs/latest/develop/data-types/arrays/#limits — // "ARGETRANGE enforces a hard limit of 1,000,000 elements per call to guard // against accidentally large range reads." We pre-flight the same check so // callers get a clear 400 instead of a generic server error. -// Note: this limit applies only to ARGETRANGE. ARSCAN has no analogous cap -// because it skips empty slots — a wide range over a sparse array is cheap. +// The same cap is applied to ARSCAN: although ARSCAN's response only +// contains populated slots, the server still walks the index range +// (O(|end-start|+1) per the Redis docs), so an unbounded range without a +// LIMIT can still tie up Redis. LIMIT remains a complementary result-set +// cap, not a substitute for the range cap. export const ARRAY_RANGE_MAX_ELEMENTS = 1_000_000; From 5d84753523cde874c2c4f6eb5d95296117fdef05 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 15 Jun 2026 15:36:32 +0300 Subject: [PATCH 07/23] fix lint --- .../api/src/modules/browser/array/array.controller.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/redisinsight/api/src/modules/browser/array/array.controller.ts b/redisinsight/api/src/modules/browser/array/array.controller.ts index dc8e7c1869..b73bb763cc 100644 --- a/redisinsight/api/src/modules/browser/array/array.controller.ts +++ b/redisinsight/api/src/modules/browser/array/array.controller.ts @@ -7,12 +7,7 @@ import { UsePipes, ValidationPipe, } from '@nestjs/common'; -import { - ApiBody, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator'; import { ApiQueryRedisStringEncoding } from 'src/common/decorators'; import { ClientMetadata } from 'src/common/models'; From 05e33632242b9bdc64575f4ac9744062113b4677 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 15 Jun 2026 15:42:31 +0300 Subject: [PATCH 08/23] add tests for the transformer --- .../redis-string-to-ascii.transformer.spec.ts | 53 ++++++++++++++++ ...redis-string-to-buffer.transformer.spec.ts | 62 +++++++++++++++++++ .../redis-string-to-utf8.transformer.spec.ts | 53 ++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 redisinsight/api/src/common/transformers/redis-string/redis-string-to-ascii.transformer.spec.ts create mode 100644 redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.spec.ts create mode 100644 redisinsight/api/src/common/transformers/redis-string/redis-string-to-utf8.transformer.spec.ts diff --git a/redisinsight/api/src/common/transformers/redis-string/redis-string-to-ascii.transformer.spec.ts b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-ascii.transformer.spec.ts new file mode 100644 index 0000000000..f1a0873fa6 --- /dev/null +++ b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-ascii.transformer.spec.ts @@ -0,0 +1,53 @@ +import { instanceToPlain } from 'class-transformer'; +import { RedisStringToASCIITransformer } from './redis-string-to-ascii.transformer'; + +class ScalarDto { + @RedisStringToASCIITransformer() + value: unknown; +} + +class ArrayDto { + @RedisStringToASCIITransformer({ each: true }) + values: unknown[]; +} + +const toPlain = (Cls: new () => T, payload: Partial) => + instanceToPlain(Object.assign(new Cls(), payload)); + +describe('RedisStringToASCIITransformer', () => { + describe('scalar', () => { + it('converts Buffer values to an ASCII-safe string', () => { + expect(toPlain(ScalarDto, { value: Buffer.from('hello') })).toEqual({ + value: 'hello', + }); + }); + + it('returns non-Buffer scalars unchanged', () => { + expect(toPlain(ScalarDto, { value: 'hello' })).toEqual({ + value: 'hello', + }); + }); + + it('passes null through without throwing', () => { + expect(toPlain(ScalarDto, { value: null })).toEqual({ value: null }); + }); + + it('passes undefined through without throwing', () => { + expect(() => toPlain(ScalarDto, { value: undefined })).not.toThrow(); + }); + }); + + describe('each: true', () => { + it('returns an empty array unchanged', () => { + expect(toPlain(ArrayDto, { values: [] })).toEqual({ values: [] }); + }); + + it('preserves null/undefined slots alongside converted entries', () => { + const input = [Buffer.from('a'), null, undefined, 'b']; + + expect(toPlain(ArrayDto, { values: input })).toEqual({ + values: ['a', null, undefined, 'b'], + }); + }); + }); +}); diff --git a/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.spec.ts b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.spec.ts new file mode 100644 index 0000000000..29315b4a72 --- /dev/null +++ b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-buffer.transformer.spec.ts @@ -0,0 +1,62 @@ +import { instanceToPlain } from 'class-transformer'; +import { RedisStringToBufferTransformer } from './redis-string-to-buffer.transformer'; + +class ScalarDto { + @RedisStringToBufferTransformer() + value: unknown; +} + +class ArrayDto { + @RedisStringToBufferTransformer({ each: true }) + values: unknown[]; +} + +const toPlain = (Cls: new () => T, payload: Partial) => + instanceToPlain(Object.assign(new Cls(), payload)); + +describe('RedisStringToBufferTransformer', () => { + describe('scalar', () => { + it('returns Buffer values unchanged', () => { + const buf = Buffer.from('hello'); + + expect(toPlain(ScalarDto, { value: buf })).toEqual({ value: buf }); + }); + + it('converts strings via Buffer.from', () => { + expect(toPlain(ScalarDto, { value: 'hello' })).toEqual({ + value: Buffer.from('hello'), + }); + }); + + it('passes null through without throwing', () => { + expect(toPlain(ScalarDto, { value: null })).toEqual({ value: null }); + }); + + it('passes undefined through without throwing', () => { + // Undefined is dropped during plain serialization; the assertion is that + // the call itself does not throw (pre-fix it raised TypeError). + expect(() => toPlain(ScalarDto, { value: undefined })).not.toThrow(); + }); + }); + + describe('each: true', () => { + it('returns an empty array unchanged', () => { + expect(toPlain(ArrayDto, { values: [] })).toEqual({ values: [] }); + }); + + it('converts each entry independently and preserves null/undefined slots', () => { + const buf = Buffer.from('keep'); + const input = [buf, 'text', null, undefined]; + + expect(toPlain(ArrayDto, { values: input })).toEqual({ + values: [buf, Buffer.from('text'), null, undefined], + }); + }); + + it('does not throw on an array of only null/undefined entries', () => { + expect(() => + toPlain(ArrayDto, { values: [null, undefined, null] }), + ).not.toThrow(); + }); + }); +}); diff --git a/redisinsight/api/src/common/transformers/redis-string/redis-string-to-utf8.transformer.spec.ts b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-utf8.transformer.spec.ts new file mode 100644 index 0000000000..7d303d6f12 --- /dev/null +++ b/redisinsight/api/src/common/transformers/redis-string/redis-string-to-utf8.transformer.spec.ts @@ -0,0 +1,53 @@ +import { instanceToPlain } from 'class-transformer'; +import { RedisStringToUTF8Transformer } from './redis-string-to-utf8.transformer'; + +class ScalarDto { + @RedisStringToUTF8Transformer() + value: unknown; +} + +class ArrayDto { + @RedisStringToUTF8Transformer({ each: true }) + values: unknown[]; +} + +const toPlain = (Cls: new () => T, payload: Partial) => + instanceToPlain(Object.assign(new Cls(), payload)); + +describe('RedisStringToUTF8Transformer', () => { + describe('scalar', () => { + it('decodes Buffer values as UTF-8', () => { + expect(toPlain(ScalarDto, { value: Buffer.from('名字') })).toEqual({ + value: '名字', + }); + }); + + it('returns non-Buffer scalars unchanged', () => { + expect(toPlain(ScalarDto, { value: 'hello' })).toEqual({ + value: 'hello', + }); + }); + + it('passes null through without throwing', () => { + expect(toPlain(ScalarDto, { value: null })).toEqual({ value: null }); + }); + + it('passes undefined through without throwing', () => { + expect(() => toPlain(ScalarDto, { value: undefined })).not.toThrow(); + }); + }); + + describe('each: true', () => { + it('returns an empty array unchanged', () => { + expect(toPlain(ArrayDto, { values: [] })).toEqual({ values: [] }); + }); + + it('preserves null/undefined slots alongside decoded entries', () => { + const input = [Buffer.from('a'), null, undefined, 'b']; + + expect(toPlain(ArrayDto, { values: input })).toEqual({ + values: ['a', null, undefined, 'b'], + }); + }); + }); +}); From 3cf97b588b661d57c37873404af93cfbb77d5e9d Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 15 Jun 2026 15:46:11 +0300 Subject: [PATCH 09/23] address PR comments --- .../modules/browser/array/array.service.spec.ts | 15 +++++++++++++++ .../src/modules/browser/array/array.service.ts | 6 +++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index 147419c1af..ee8e959677 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -298,6 +298,21 @@ describe('ArrayService', () => { expect(result.elements).toHaveLength(1); }); + it('should treat an explicit null limit the same as omitted', async () => { + const result = await service.scan(mockBrowserClientMetadata, { + ...mockGetArrayScanDto, + limit: null as unknown as number, + }); + + expect(mockStandaloneRedisClient.sendCommand).toHaveBeenCalledWith([ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ]); + expect(result).toEqual(mockGetArrayScanResponse); + }); + it('should also accept the nested [[index, value], ...] reply shape', async () => { when(mockStandaloneRedisClient.sendCommand) .calledWith([ diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 7f4317d6b4..98c0f7d3e1 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -198,8 +198,12 @@ export class ArrayService { start, end, ] as const; + // Treat an explicit JSON `null` the same as an omitted limit. @IsOptional() + // skips downstream validators for null, so the DTO accepts it; forwarding + // `LIMIT null` to Redis would otherwise surface as a 500. + const hasLimit = typeof limit === 'number'; const reply = (await client.sendCommand( - limit !== undefined ? [...baseArgs, 'LIMIT', limit] : [...baseArgs], + hasLimit ? [...baseArgs, 'LIMIT', limit] : [...baseArgs], )) as unknown[]; // ARSCAN's wire shape varies by client: some clients surface a flat From f65e26d12f55dd138e7a988897c4f81d2d54bba3 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 15 Jun 2026 18:03:04 +0300 Subject: [PATCH 10/23] address PR comments --- .../browser/array/array.service.spec.ts | 15 +++++++ .../modules/browser/array/array.service.ts | 11 ++++-- .../dto/get.array-next-index.response.ts | 6 ++- .../array.key-info.strategy.spec.ts | 39 +++++++++++++++++-- .../strategies/array.key-info.strategy.ts | 6 ++- 5 files changed, 66 insertions(+), 11 deletions(-) diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index ee8e959677..dbc477efd8 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -481,6 +481,21 @@ describe('ArrayService', () => { }); }); + describe('getNextIndex (exhausted)', () => { + it('should surface null index when ARNEXT returns nil', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolArrayCommands.ArNext, mockKeyDto.keyName]) + .mockResolvedValue(null); + + const result = await service.getNextIndex( + mockBrowserClientMetadata, + mockKeyDto, + ); + + expect(result).toEqual({ keyName: mockKeyDto.keyName, index: null }); + }); + }); + describe('getElement', () => { beforeEach(() => { when(mockStandaloneRedisClient.sendCommand) diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 98c0f7d3e1..137cc25c12 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -321,11 +321,14 @@ export class ArrayService { keyName, ]); + // ARNEXT returns nil when the insertion cursor is exhausted. Surface + // that as `null` so clients can distinguish absence from a real index; + // folding it through toIndexString would produce the string "null". + const index = + reply === null || reply === undefined ? null : toIndexString(reply); + this.logger.debug('Succeed to get array next index.', clientMetadata); - return plainToInstance(GetArrayNextIndexResponse, { - keyName, - index: toIndexString(reply), - }); + return plainToInstance(GetArrayNextIndexResponse, { keyName, index }); } catch (error) { this.logger.error( 'Failed to get array next index.', diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-next-index.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-next-index.response.ts index 350461c4fe..6bb29f0ebc 100644 --- a/redisinsight/api/src/modules/browser/array/dto/get.array-next-index.response.ts +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-next-index.response.ts @@ -4,9 +4,11 @@ import { KeyResponse } from 'src/modules/browser/keys/dto'; export class GetArrayNextIndexResponse extends KeyResponse { @ApiProperty({ description: - 'Next index that ARINSERT would use. Unsigned 64-bit integer as string.', + 'Next index that ARINSERT would use. Unsigned 64-bit integer as string. ' + + 'Null when the array is exhausted and no further insertion is possible.', type: String, example: '7', + nullable: true, }) - index: string; + index: string | null; } diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts index 75aa254977..ed2e0123f9 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts @@ -64,7 +64,7 @@ describe('ArrayKeyInfoStrategy', () => { }); describe('when includeSize is false', () => { - it('should skip MEMORY USAGE when length exceeds MAX_KEY_SIZE', async () => { + it('should skip MEMORY USAGE when count exceeds MAX_KEY_SIZE', async () => { when(mockStandaloneRedisClient.sendPipeline) .calledWith([ [BrowserToolKeysCommands.Ttl, key], @@ -73,8 +73,8 @@ describe('ArrayKeyInfoStrategy', () => { ]) .mockResolvedValueOnce([ [null, ttl], + [null, length], [null, MAX_KEY_SIZE + 1], - [null, count], ]); const result = await strategy.getInfo( @@ -86,12 +86,43 @@ describe('ArrayKeyInfoStrategy', () => { expect(result).toEqual({ ...getKeyInfoResponse, - length: MAX_KEY_SIZE + 1, + count: MAX_KEY_SIZE + 1, size: -1, }); }); - it('should issue MEMORY USAGE separately when length is small', async () => { + it('should still issue MEMORY USAGE for sparse arrays where length is huge but count is small', async () => { + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], + ]) + .mockResolvedValueOnce([ + [null, ttl], + [null, MAX_KEY_SIZE * 10], + [null, count], + ]); + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValueOnce([[null, size]]); + + const result = await strategy.getInfo( + mockStandaloneRedisClient, + key, + RedisDataType.Array, + false, + ); + + expect(result).toEqual({ + ...getKeyInfoResponse, + length: MAX_KEY_SIZE * 10, + }); + }); + + it('should issue MEMORY USAGE separately when count is small', async () => { when(mockStandaloneRedisClient.sendPipeline) .calledWith([ [BrowserToolKeysCommands.Ttl, key], diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts index 8c1e5774c0..597db7797e 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts @@ -50,8 +50,12 @@ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { [BrowserToolArrayCommands.ArCount, key], ])) as [any, any][]; + // Sparse arrays can have huge `length` (ARLEN, total addressable slots) + // while `count` (ARCOUNT, populated slots) stays small. MEMORY USAGE cost + // scales with stored data, so gate on `count` — otherwise a sparse key + // with few populated slots would report `size: -1` despite being cheap. let size = -1; - if (length < MAX_KEY_SIZE) { + if (count < MAX_KEY_SIZE) { const sizeData = (await client.sendPipeline([ [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], ])) as [any, number][]; From 9186271634ef1db3af49ac44fae838ef4e5860bd Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 15 Jun 2026 18:15:37 +0300 Subject: [PATCH 11/23] feat(api): reject reversed array ranges (RI-8219) ARGETRANGE / ARSCAN require start <= end. Match that contract at the API boundary: validate the order in the service and return 400 if reversed, instead of silently normalizing. Aligns with how every other Redis range command behaves and removes ambiguity about how a reversed reply would be ordered or paginated. --- redisinsight/api/.tscheck.rec.json | 4 +-- .../api/src/constants/error-messages.ts | 1 + .../browser/array/array.service.spec.ts | 8 +++--- .../modules/browser/array/array.service.ts | 26 +++++++++++-------- .../browser/array/dto/get.array-range.dto.ts | 2 +- .../browser/array/dto/get.array-scan.dto.ts | 3 ++- 6 files changed, 24 insertions(+), 20 deletions(-) diff --git a/redisinsight/api/.tscheck.rec.json b/redisinsight/api/.tscheck.rec.json index b1c1e81d07..b8b2f329bb 100644 --- a/redisinsight/api/.tscheck.rec.json +++ b/redisinsight/api/.tscheck.rec.json @@ -370,6 +370,7 @@ "TS2322": 2 }, "src/modules/browser/utils/clusterCursor.ts": { + "TS2488": 1, "TS7005": 1, "TS7034": 1 }, @@ -1206,9 +1207,6 @@ "src/modules/redis-sentinel/redis-sentinel.analytics.ts": { "TS18048": 1 }, - "src/modules/redis/client/ioredis/cluster.ioredis.client.ts": { - "TS2488": 1 - }, "src/modules/redis/client/ioredis/ioredis.client.ts": { "TS2345": 2, "TS2532": 1 diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index 913ea949c5..fabfbfb3da 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -80,6 +80,7 @@ export default { INCORRECT_CLUSTER_CURSOR_FORMAT: 'Incorrect cluster cursor format.', ARRAY_RANGE_TOO_LARGE: (max: number) => `Requested range exceeds the maximum of ${numberWithSpaces(max)} elements per call. Narrow the range and try again.`, + ARRAY_RANGE_REVERSED: 'Start index must be less than or equal to end index.', REMOVING_MULTIPLE_ELEMENTS_NOT_SUPPORT: () => 'Removing multiple elements is available for Redis databases v. 6.2 or later.', SCAN_PER_KEY_TYPE_NOT_SUPPORT: () => diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index dbc477efd8..6be664c3fe 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -212,11 +212,11 @@ describe('ArrayService', () => { ).rejects.toThrow(BadRequestException); }); - it('should reject when reversed range exceeds the 1M cap', async () => { + it('should reject reversed ranges (start > end)', async () => { await expect( service.getRange(mockBrowserClientMetadata, { ...mockGetArrayRangeDto, - start: String(ARRAY_RANGE_MAX_ELEMENTS), + start: '5', end: '0', }), ).rejects.toThrow(BadRequestException); @@ -378,11 +378,11 @@ describe('ArrayService', () => { ).rejects.toThrow(BadRequestException); }); - it('should reject when reversed range exceeds the 1M cap', async () => { + it('should reject reversed ranges (start > end)', async () => { await expect( service.scan(mockBrowserClientMetadata, { ...mockGetArrayScanDto, - start: String(ARRAY_RANGE_MAX_ELEMENTS), + start: '5', end: '0', }), ).rejects.toThrow(BadRequestException); diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 137cc25c12..8cde5f257e 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -84,15 +84,18 @@ export class ArrayService { } // Inputs are validated as canonical decimal strings ≤ 2^64-1, so BigInt() - // is safe. Ranges are reversible (start > end). - private assertRangeWithinCap(start: string, end: string): void { + // is safe. The contract is start ≤ end — matches every Redis range + // command and avoids any ambiguity about how a reversed reply would be + // ordered or paginated. + private assertValidRange(start: string, end: string): void { const startBig = BigInt(start); const endBig = BigInt(end); - const span = - startBig > endBig - ? startBig - endBig + BigInt(1) - : endBig - startBig + BigInt(1); + if (startBig > endBig) { + throw new BadRequestException(ERROR_MESSAGES.ARRAY_RANGE_REVERSED); + } + + const span = endBig - startBig + BigInt(1); if (span > BigInt(ARRAY_RANGE_MAX_ELEMENTS)) { throw new BadRequestException( ERROR_MESSAGES.ARRAY_RANGE_TOO_LARGE(ARRAY_RANGE_MAX_ELEMENTS), @@ -108,7 +111,7 @@ export class ArrayService { this.logger.debug('Getting array range.', clientMetadata); const { keyName, start, end } = dto; - this.assertRangeWithinCap(start, end); + this.assertValidRange(start, end); const client = await this.databaseClientFactory.getOrCreateClient(clientMetadata); @@ -183,10 +186,11 @@ export class ArrayService { const { keyName, start, end, limit } = dto; // ARSCAN skips empty slots in the response but still walks the index - // range server-side (O(|end-start|+1)). Apply the same span cap as - // ARGETRANGE so an unbounded range cannot tie up Redis even when LIMIT - // is omitted; LIMIT remains a complementary result-set cap. - this.assertRangeWithinCap(start, end); + // range server-side (O(|end-start|+1)). Apply the same range checks + // as ARGETRANGE so an unbounded or malformed range cannot tie up + // Redis even when LIMIT is omitted; LIMIT remains a complementary + // result-set cap. + this.assertValidRange(start, end); const client = await this.databaseClientFactory.getOrCreateClient(clientMetadata); diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts index 376d3e5632..8fd8b8b4c7 100644 --- a/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts @@ -15,7 +15,7 @@ export class GetArrayRangeDto extends KeyDto { @ApiProperty({ description: 'End index of the range (inclusive). Unsigned 64-bit integer as string. ' + - 'If start > end, the range is returned reversed.', + 'Must be greater than or equal to start.', type: String, example: '99', }) diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts index 87746e8314..429be1776a 100644 --- a/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts @@ -16,7 +16,8 @@ export class GetArrayScanDto extends KeyDto { @ApiProperty({ description: - 'End index of the range (inclusive). Unsigned 64-bit integer as string.', + 'End index of the range (inclusive). Unsigned 64-bit integer as string. ' + + 'Must be greater than or equal to start.', type: String, example: '99', }) From 74bb22a61a27b3a15fa8857e6f8c152aefcd01db Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 16 Jun 2026 09:56:57 +0300 Subject: [PATCH 12/23] address comments --- redisinsight/api/.tscheck.rec.json | 4 +- .../modules/browser/array/array.service.ts | 12 +--- .../api/src/modules/browser/array/utils.ts | 10 ++++ .../keys/dto/get.keys-info.response.ts | 11 ++-- .../array.key-info.strategy.spec.ts | 58 +++++++++++++++---- .../strategies/array.key-info.strategy.ts | 31 ++++++++-- 6 files changed, 93 insertions(+), 33 deletions(-) create mode 100644 redisinsight/api/src/modules/browser/array/utils.ts diff --git a/redisinsight/api/.tscheck.rec.json b/redisinsight/api/.tscheck.rec.json index b8b2f329bb..b1c1e81d07 100644 --- a/redisinsight/api/.tscheck.rec.json +++ b/redisinsight/api/.tscheck.rec.json @@ -370,7 +370,6 @@ "TS2322": 2 }, "src/modules/browser/utils/clusterCursor.ts": { - "TS2488": 1, "TS7005": 1, "TS7034": 1 }, @@ -1207,6 +1206,9 @@ "src/modules/redis-sentinel/redis-sentinel.analytics.ts": { "TS18048": 1 }, + "src/modules/redis/client/ioredis/cluster.ioredis.client.ts": { + "TS2488": 1 + }, "src/modules/redis/client/ioredis/ioredis.client.ts": { "TS2345": 2, "TS2532": 1 diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 8cde5f257e..42706f600c 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -20,6 +20,7 @@ import { } from 'src/modules/browser/utils'; import { KeyDto } from 'src/modules/browser/keys/dto'; import { ARRAY_RANGE_MAX_ELEMENTS } from 'src/modules/browser/array/constants'; +import { toIndexString } from 'src/modules/browser/array/utils'; import { ArrayCreationMode, ArrayElement, @@ -37,17 +38,6 @@ import { GetArrayScanResponse, } from 'src/modules/browser/array/dto'; -// Integer/bulk replies for indexes and counts may arrive as Buffer, string, -// number, or bigint depending on the client mode. Normalize to a decimal -// string so the unsigned 64-bit contract is preserved on the wire. -const toIndexString = (value: unknown): string => { - if (typeof value === 'string') return value; - if (typeof value === 'bigint') return value.toString(); - if (typeof value === 'number') return String(value); - if (Buffer.isBuffer(value)) return value.toString('utf8'); - return String(value); -}; - @Injectable() export class ArrayService { private logger = new Logger('ArrayService'); diff --git a/redisinsight/api/src/modules/browser/array/utils.ts b/redisinsight/api/src/modules/browser/array/utils.ts new file mode 100644 index 0000000000..739747c30d --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/utils.ts @@ -0,0 +1,10 @@ +// Integer/bulk replies for indexes and counts may arrive as Buffer, string, +// number, or bigint depending on the client mode. Normalize to a decimal +// string so the unsigned 64-bit contract is preserved on the wire. +export const toIndexString = (value: unknown): string => { + if (typeof value === 'string') return value; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'number') return String(value); + if (Buffer.isBuffer(value)) return value.toString('utf8'); + return String(value); +}; diff --git a/redisinsight/api/src/modules/browser/keys/dto/get.keys-info.response.ts b/redisinsight/api/src/modules/browser/keys/dto/get.keys-info.response.ts index bdd045d566..1020fdbab9 100644 --- a/redisinsight/api/src/modules/browser/keys/dto/get.keys-info.response.ts +++ b/redisinsight/api/src/modules/browser/keys/dto/get.keys-info.response.ts @@ -29,9 +29,11 @@ export class GetKeyInfoResponse { @ApiPropertyOptional({ type: Number, - description: 'The length of the value stored in a key.', + description: + 'The length of the value stored in a key.' + + ' For array keys, returned as an unsigned 64-bit integer decimal string.', }) - length?: number; + length?: number | string; @ApiPropertyOptional({ type: String, @@ -49,7 +51,8 @@ export class GetKeyInfoResponse { @ApiPropertyOptional({ type: Number, description: - 'The populated element count for array keys (excludes empty slots).', + 'The populated element count for array keys (excludes empty slots).' + + ' For array keys, returned as an unsigned 64-bit integer decimal string.', }) - count?: number; + count?: number | string; } diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts index ed2e0123f9..dd11e77ef5 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts @@ -17,8 +17,8 @@ const getKeyInfoResponse: GetKeyInfoResponse = { type: 'array', ttl: -1, size: 50, - length: 10, - count: 7, + length: '10', + count: '7', }; describe('ArrayKeyInfoStrategy', () => { @@ -34,7 +34,9 @@ describe('ArrayKeyInfoStrategy', () => { describe('getInfo', () => { const key = getKeyInfoResponse.name; - const { ttl, length, size, count } = getKeyInfoResponse; + const { ttl, size } = getKeyInfoResponse; + const rawLength = 10; + const rawCount = 7; describe('when includeSize is true', () => { it('should return ttl, length, count, and size in single pipeline', async () => { @@ -47,8 +49,8 @@ describe('ArrayKeyInfoStrategy', () => { ]) .mockResolvedValueOnce([ [null, ttl], - [null, length], - [null, count], + [null, rawLength], + [null, rawCount], [null, size], ]); @@ -61,6 +63,40 @@ describe('ArrayKeyInfoStrategy', () => { expect(result).toEqual(getKeyInfoResponse); }); + + it('should preserve u64 length and count as decimal strings', async () => { + // ARLEN can exceed Number.MAX_SAFE_INTEGER for sparse arrays; the + // strategy must surface the value as a string so precision is kept + // when the client passes back bigint or string replies. + const hugeLength = BigInt('18446744073709551610'); + const hugeCount = '18446744073709551500'; + when(mockStandaloneRedisClient.sendPipeline) + .calledWith([ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolArrayCommands.ArLen, key], + [BrowserToolArrayCommands.ArCount, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValueOnce([ + [null, ttl], + [null, hugeLength], + [null, hugeCount], + [null, size], + ]); + + const result = await strategy.getInfo( + mockStandaloneRedisClient, + key, + RedisDataType.Array, + true, + ); + + expect(result).toEqual({ + ...getKeyInfoResponse, + length: '18446744073709551610', + count: '18446744073709551500', + }); + }); }); describe('when includeSize is false', () => { @@ -73,7 +109,7 @@ describe('ArrayKeyInfoStrategy', () => { ]) .mockResolvedValueOnce([ [null, ttl], - [null, length], + [null, rawLength], [null, MAX_KEY_SIZE + 1], ]); @@ -86,7 +122,7 @@ describe('ArrayKeyInfoStrategy', () => { expect(result).toEqual({ ...getKeyInfoResponse, - count: MAX_KEY_SIZE + 1, + count: String(MAX_KEY_SIZE + 1), size: -1, }); }); @@ -101,7 +137,7 @@ describe('ArrayKeyInfoStrategy', () => { .mockResolvedValueOnce([ [null, ttl], [null, MAX_KEY_SIZE * 10], - [null, count], + [null, rawCount], ]); when(mockStandaloneRedisClient.sendPipeline) .calledWith([ @@ -118,7 +154,7 @@ describe('ArrayKeyInfoStrategy', () => { expect(result).toEqual({ ...getKeyInfoResponse, - length: MAX_KEY_SIZE * 10, + length: String(MAX_KEY_SIZE * 10), }); }); @@ -131,8 +167,8 @@ describe('ArrayKeyInfoStrategy', () => { ]) .mockResolvedValueOnce([ [null, ttl], - [null, length], - [null, count], + [null, rawLength], + [null, rawCount], ]); when(mockStandaloneRedisClient.sendPipeline) .calledWith([ diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts index 597db7797e..9c0a8bcd72 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts @@ -10,6 +10,7 @@ import { RedisString } from 'src/common/constants'; import { KeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/key-info.strategy'; import { RedisClient } from 'src/modules/redis/client'; import { MAX_KEY_SIZE } from 'src/modules/browser/keys/key-info/constants'; +import { toIndexString } from 'src/modules/browser/array/utils'; /** * Key-info strategy for the Array data type. Returns the standard @@ -17,6 +18,10 @@ import { MAX_KEY_SIZE } from 'src/modules/browser/keys/key-info/constants'; * populated slots (ARCOUNT). Length reflects total addressable slots * (ARLEN, including gaps); count reflects only the populated ones — the * two diverge for sparse arrays and the View tab surfaces both. + * + * `length` and `count` are normalized to decimal strings to match the + * unsigned 64-bit contract used by the array read endpoints (ARLEN can + * exceed Number.MAX_SAFE_INTEGER for sparse arrays). */ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { public async getInfo( @@ -30,8 +35,8 @@ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { if (includeSize !== false) { const [ [, ttl = null], - [, length = null], - [, count = null], + [, rawLength = null], + [, rawCount = null], [, size = null], ] = (await client.sendPipeline([ [BrowserToolKeysCommands.Ttl, key], @@ -40,10 +45,17 @@ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], ])) as [any, any][]; - return { name: key, type, ttl, size, length, count }; + return { + name: key, + type, + ttl, + size, + length: rawLength == null ? undefined : toIndexString(rawLength), + count: rawCount == null ? undefined : toIndexString(rawCount), + }; } - const [[, ttl = null], [, length = null], [, count = null]] = + const [[, ttl = null], [, rawLength = null], [, rawCount = null]] = (await client.sendPipeline([ [BrowserToolKeysCommands.Ttl, key], [BrowserToolArrayCommands.ArLen, key], @@ -55,13 +67,20 @@ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { // scales with stored data, so gate on `count` — otherwise a sparse key // with few populated slots would report `size: -1` despite being cheap. let size = -1; - if (count < MAX_KEY_SIZE) { + if (rawCount != null && Number(rawCount) < MAX_KEY_SIZE) { const sizeData = (await client.sendPipeline([ [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], ])) as [any, number][]; size = sizeData && sizeData[0] && sizeData[0][1]; } - return { name: key, type, ttl, size, length, count }; + return { + name: key, + type, + ttl, + size, + length: rawLength == null ? undefined : toIndexString(rawLength), + count: rawCount == null ? undefined : toIndexString(rawCount), + }; } } From ca359b8aefa65fb81d2ec6626e732742928c0da6 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 16 Jun 2026 10:06:49 +0300 Subject: [PATCH 13/23] remove any reversible stuff --- .../api/bruno/RedisInsight/Array/Get Range.bru | 15 +++++++-------- .../api/bruno/RedisInsight/Array/Scan.bru | 17 +++++++++-------- .../modules/browser/array/array.controller.ts | 6 ++++-- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Range.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Range.bru index 6d6eb225d0..91eafa52c4 100644 --- a/redisinsight/api/bruno/RedisInsight/Array/Get Range.bru +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Range.bru @@ -22,16 +22,15 @@ docs { # ARGETRANGE Read an inclusive range of elements from an Array key. Empty slots are - returned as `null`. The range is reversible (`start` > `end` returns the - range reversed). + returned as `null`. `start` must be less than or equal to `end`. ## Request body - | Field | Type | Notes | - |-----------|--------|----------------------------------------------------------| - | `keyName` | string | The Array key name. | - | `start` | string | Inclusive start index. Unsigned 64-bit integer as string.| - | `end` | string | Inclusive end index. Unsigned 64-bit integer as string. | + | Field | Type | Notes | + |-----------|--------|-----------------------------------------------------------| + | `keyName` | string | The Array key name. | + | `start` | string | Inclusive start index. Unsigned 64-bit integer as string. | + | `end` | string | Inclusive end index. Unsigned 64-bit integer as string. Must be ≥ `start`. | ## Response @@ -46,7 +45,7 @@ docs { | Status | When | |--------|------| - | `400` | Range exceeds 1,000,000 elements per call, or the key holds a non-array type. | + | `400` | `start` is greater than `end`, range exceeds 1,000,000 elements per call, or the key holds a non-array type. | | `403` | User has no permission for `ARGETRANGE`. | | `404` | Key does not exist. | } diff --git a/redisinsight/api/bruno/RedisInsight/Array/Scan.bru b/redisinsight/api/bruno/RedisInsight/Array/Scan.bru index 61a789d2a7..ed5f80c396 100644 --- a/redisinsight/api/bruno/RedisInsight/Array/Scan.bru +++ b/redisinsight/api/bruno/RedisInsight/Array/Scan.bru @@ -23,16 +23,17 @@ docs { # ARSCAN Read a range of populated elements from an Array key, skipping empty slots. - Cheaper than `Get Range` for sparse arrays. + Cheaper than `Get Range` for sparse arrays. `start` must be less than or + equal to `end`. ## Request body - | Field | Type | Notes | - |-----------|------------------|----------------------------------------------------------| - | `keyName` | string | The Array key name. | - | `start` | string | Inclusive start index. Unsigned 64-bit integer as string.| - | `end` | string | Inclusive end index. Unsigned 64-bit integer as string. | - | `limit` | number, optional | Maps to ARSCAN `LIMIT`. | + | Field | Type | Notes | + |-----------|------------------|-----------------------------------------------------------| + | `keyName` | string | The Array key name. | + | `start` | string | Inclusive start index. Unsigned 64-bit integer as string. | + | `end` | string | Inclusive end index. Unsigned 64-bit integer as string. Must be ≥ `start`. | + | `limit` | number, optional | Maps to ARSCAN `LIMIT`. | ## Response @@ -53,7 +54,7 @@ docs { | Status | When | |--------|------| - | `400` | Key holds a non-array type. | + | `400` | `start` is greater than `end`, range exceeds 1,000,000 elements per call, or the key holds a non-array type. | | `403` | User has no permission for `ARSCAN`. | | `404` | Key does not exist. | } diff --git a/redisinsight/api/src/modules/browser/array/array.controller.ts b/redisinsight/api/src/modules/browser/array/array.controller.ts index b73bb763cc..784efba99e 100644 --- a/redisinsight/api/src/modules/browser/array/array.controller.ts +++ b/redisinsight/api/src/modules/browser/array/array.controller.ts @@ -58,7 +58,8 @@ export class ArrayController extends BrowserBaseController { @ApiOperation({ description: 'Read a range of elements from the array stored at key (ARGETRANGE). ' + - 'Empty slots are returned as null; range is inclusive and reversible.', + 'Empty slots are returned as null. The range is inclusive and ' + + 'requires start ≤ end; a reversed range is rejected with 400.', }) @ApiRedisParams() @ApiOkResponse({ type: GetArrayRangeResponse }) @@ -75,7 +76,8 @@ export class ArrayController extends BrowserBaseController { @ApiOperation({ description: 'Scan a range of populated elements from the array stored at key (ARSCAN). ' + - 'Empty slots are skipped.', + 'Empty slots are skipped. The range is inclusive and requires ' + + 'start ≤ end; a reversed range is rejected with 400.', }) @ApiRedisParams() @ApiOkResponse({ type: GetArrayScanResponse }) From 7ccd26c98e47c3c59d22e4fc1ea961cdb9d8f63e Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 16 Jun 2026 10:38:31 +0300 Subject: [PATCH 14/23] refactor(api): dedicated key-info response for array keys (RI-8219) --- .../keys/dto/get.array-key-info.response.ts | 57 +++++++++++++++++++ .../keys/dto/get.keys-info.response.ts | 14 +---- .../api/src/modules/browser/keys/dto/index.ts | 1 + .../array.key-info.strategy.spec.ts | 4 +- .../strategies/array.key-info.strategy.ts | 27 ++++----- .../key-info/strategies/key-info.strategy.ts | 9 ++- .../modules/browser/keys/keys.controller.ts | 20 ++++++- .../src/modules/browser/keys/keys.service.ts | 11 +++- 8 files changed, 110 insertions(+), 33 deletions(-) create mode 100644 redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts diff --git a/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts b/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts new file mode 100644 index 0000000000..6baa958ebf --- /dev/null +++ b/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts @@ -0,0 +1,57 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { RedisStringType, ApiRedisString } from 'src/common/decorators'; +import { RedisString } from 'src/common/constants'; + +/** + * Key-info response for the Array data type. + * + * `length` (ARLEN) and `count` (ARCOUNT) are returned as decimal strings + * because an Array's index space is unsigned 64-bit and can exceed + * Number.MAX_SAFE_INTEGER for sparse keys. Consumers must narrow on the + * `array` key type before reading these fields and avoid silent numeric + * coercion that would round large values. + */ +export class GetArrayKeyInfoResponse { + @ApiRedisString() + @RedisStringType() + name: RedisString; + + @ApiPropertyOptional({ + type: String, + description: 'Always "array" for this response shape.', + }) + type?: string; + + @ApiPropertyOptional({ + type: Number, + description: + 'The remaining time to live of a key.' + + ' If the property has value of -1, then the key has no expiration time (no limit).', + }) + ttl?: number; + + @ApiPropertyOptional({ + type: Number, + description: + 'The number of bytes that the array key and its value require to be stored in RAM.', + }) + size?: number; + + @ApiProperty({ + type: String, + description: + 'Logical length of the array (highest set index + 1, includes gaps).' + + ' Unsigned 64-bit integer as a decimal string.', + example: '7', + }) + length: string; + + @ApiProperty({ + type: String, + description: + 'Populated element count (excludes empty slots, ARCOUNT).' + + ' Unsigned 64-bit integer as a decimal string.', + example: '5', + }) + count: string; +} diff --git a/redisinsight/api/src/modules/browser/keys/dto/get.keys-info.response.ts b/redisinsight/api/src/modules/browser/keys/dto/get.keys-info.response.ts index 1020fdbab9..e0592c3b8a 100644 --- a/redisinsight/api/src/modules/browser/keys/dto/get.keys-info.response.ts +++ b/redisinsight/api/src/modules/browser/keys/dto/get.keys-info.response.ts @@ -29,11 +29,9 @@ export class GetKeyInfoResponse { @ApiPropertyOptional({ type: Number, - description: - 'The length of the value stored in a key.' + - ' For array keys, returned as an unsigned 64-bit integer decimal string.', + description: 'The length of the value stored in a key.', }) - length?: number | string; + length?: number; @ApiPropertyOptional({ type: String, @@ -47,12 +45,4 @@ export class GetKeyInfoResponse { description: 'The vector dimensions for vector set keys.', }) vectorDim?: number; - - @ApiPropertyOptional({ - type: Number, - description: - 'The populated element count for array keys (excludes empty slots).' + - ' For array keys, returned as an unsigned 64-bit integer decimal string.', - }) - count?: number | string; } diff --git a/redisinsight/api/src/modules/browser/keys/dto/index.ts b/redisinsight/api/src/modules/browser/keys/dto/index.ts index f501b184ef..b317f191f5 100644 --- a/redisinsight/api/src/modules/browser/keys/dto/index.ts +++ b/redisinsight/api/src/modules/browser/keys/dto/index.ts @@ -3,6 +3,7 @@ export * from './delete.keys.response'; export * from './get.keys.dto'; export * from './get.keys-info.dto'; export * from './get.keys-info.response'; +export * from './get.array-key-info.response'; export * from './get.keys-with-details.response'; export * from './key.dto'; export * from './key-with-expire.dto'; diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts index dd11e77ef5..2634df5195 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.spec.ts @@ -6,13 +6,13 @@ import { BrowserToolKeysCommands, } from 'src/modules/browser/constants/browser-tool-commands'; import { - GetKeyInfoResponse, + GetArrayKeyInfoResponse, RedisDataType, } from 'src/modules/browser/keys/dto'; import { ArrayKeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/array.key-info.strategy'; import { MAX_KEY_SIZE } from 'src/modules/browser/keys/key-info/constants'; -const getKeyInfoResponse: GetKeyInfoResponse = { +const getKeyInfoResponse: GetArrayKeyInfoResponse = { name: 'testArray', type: 'array', ttl: -1, diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts index 9c0a8bcd72..19797d088b 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts @@ -1,5 +1,5 @@ import { - GetKeyInfoResponse, + GetArrayKeyInfoResponse, RedisDataType, } from 'src/modules/browser/keys/dto'; import { @@ -14,14 +14,15 @@ import { toIndexString } from 'src/modules/browser/array/utils'; /** * Key-info strategy for the Array data type. Returns the standard - * TTL / size / length triple plus a `count` field carrying the number of - * populated slots (ARCOUNT). Length reflects total addressable slots - * (ARLEN, including gaps); count reflects only the populated ones — the - * two diverge for sparse arrays and the View tab surfaces both. + * TTL / size triple plus `length` (ARLEN, includes gaps) and `count` + * (ARCOUNT, populated slots) — the two diverge for sparse arrays and + * the View tab surfaces both. * - * `length` and `count` are normalized to decimal strings to match the - * unsigned 64-bit contract used by the array read endpoints (ARLEN can - * exceed Number.MAX_SAFE_INTEGER for sparse arrays). + * Returns a dedicated `GetArrayKeyInfoResponse` so that `length` and + * `count` are typed as decimal strings end-to-end. The Array index + * space is unsigned 64-bit and can exceed Number.MAX_SAFE_INTEGER for + * sparse keys; the shared `GetKeyInfoResponse.length: number` used by + * other key types would silently lose precision. */ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { public async getInfo( @@ -29,7 +30,7 @@ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { key: RedisString, type: string, includeSize: boolean, - ): Promise { + ): Promise { this.logger.debug(`Getting ${RedisDataType.Array} type info.`); if (includeSize !== false) { @@ -50,8 +51,8 @@ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { type, ttl, size, - length: rawLength == null ? undefined : toIndexString(rawLength), - count: rawCount == null ? undefined : toIndexString(rawCount), + length: toIndexString(rawLength), + count: toIndexString(rawCount), }; } @@ -79,8 +80,8 @@ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { type, ttl, size, - length: rawLength == null ? undefined : toIndexString(rawLength), - count: rawCount == null ? undefined : toIndexString(rawCount), + length: toIndexString(rawLength), + count: toIndexString(rawCount), }; } } diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/key-info.strategy.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/key-info.strategy.ts index 7a98e1485e..574a4b9d67 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/strategies/key-info.strategy.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/key-info.strategy.ts @@ -1,8 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; -import { GetKeyInfoResponse } from 'src/modules/browser/keys/dto'; +import { + GetArrayKeyInfoResponse, + GetKeyInfoResponse, +} from 'src/modules/browser/keys/dto'; import { RedisString } from 'src/common/constants'; import { RedisClient } from 'src/modules/redis/client'; +export type KeyInfoResponse = GetKeyInfoResponse | GetArrayKeyInfoResponse; + @Injectable() export abstract class KeyInfoStrategy { protected readonly logger = new Logger(this.constructor.name); @@ -12,5 +17,5 @@ export abstract class KeyInfoStrategy { key: RedisString, type: string, includeSize: boolean, - ): Promise; + ): Promise; } diff --git a/redisinsight/api/src/modules/browser/keys/keys.controller.ts b/redisinsight/api/src/modules/browser/keys/keys.controller.ts index 2939c6ddac..172ac73e5e 100644 --- a/redisinsight/api/src/modules/browser/keys/keys.controller.ts +++ b/redisinsight/api/src/modules/browser/keys/keys.controller.ts @@ -9,7 +9,14 @@ import { UsePipes, ValidationPipe, } from '@nestjs/common'; -import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBody, + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, + getSchemaPath, +} from '@nestjs/swagger'; import { KeysService } from 'src/modules/browser/keys/keys.service'; import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator'; import { BrowserClientMetadata } from 'src/modules/browser/decorators/browser-client-metadata.decorator'; @@ -18,6 +25,7 @@ import { ClientMetadata } from 'src/common/models'; import { DeleteKeysDto, DeleteKeysResponse, + GetArrayKeyInfoResponse, GetKeyInfoDto, GetKeysDto, GetKeysWithDetailsResponse, @@ -80,15 +88,21 @@ export class KeysController { @ApiOperation({ description: 'Get key info' }) @ApiRedisParams() @ApiBody({ type: GetKeyInfoDto }) + @ApiExtraModels(GetKeyInfoResponse, GetArrayKeyInfoResponse) @ApiOkResponse({ description: 'Keys info', - type: GetKeyInfoResponse, + schema: { + oneOf: [ + { $ref: getSchemaPath(GetKeyInfoResponse) }, + { $ref: getSchemaPath(GetArrayKeyInfoResponse) }, + ], + }, }) @ApiQueryRedisStringEncoding() async getKeyInfo( @BrowserClientMetadata() clientMetadata: ClientMetadata, @Body() dto: GetKeyInfoDto, - ): Promise { + ): Promise { return await this.keysService.getKeyInfo( clientMetadata, dto.keyName, diff --git a/redisinsight/api/src/modules/browser/keys/keys.service.ts b/redisinsight/api/src/modules/browser/keys/keys.service.ts index 7286e1ec5a..5d0e7a4f23 100644 --- a/redisinsight/api/src/modules/browser/keys/keys.service.ts +++ b/redisinsight/api/src/modules/browser/keys/keys.service.ts @@ -13,6 +13,7 @@ import { catchAclError } from 'src/utils'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { DeleteKeysResponse, + GetArrayKeyInfoResponse, GetKeyInfoResponse, GetKeysDto, GetKeysInfoDto, @@ -145,7 +146,7 @@ export class KeysService { clientMetadata: ClientMetadata, key: RedisString, includeSize: boolean = false, - ): Promise { + ): Promise { try { this.logger.debug('Getting key info.', clientMetadata); const client = @@ -183,6 +184,14 @@ export class KeysService { result, ); + // Array keys carry u64 `length` / `count` as decimal strings — they + // have a dedicated response class so the wire format and the + // generated client both preserve precision. Split the + // plainToInstance call so each branch is monomorphic and + // class-transformer keeps the array-only string fields. + if (type === RedisDataType.Array) { + return plainToInstance(GetArrayKeyInfoResponse, result); + } return plainToInstance(GetKeyInfoResponse, result); } catch (error) { this.logger.error('Failed to get key info.', error, clientMetadata); From 42ddc13ebe3ad534b0bdcb52ae93ec0bf7589177 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 16 Jun 2026 10:55:01 +0300 Subject: [PATCH 15/23] fix(api): nil-safe index normalization and ARMGET indexes cap (RI-8219) Addresses two review issues on the array read endpoints: - toIndexString silently turned nil replies into the literal strings "null" / "undefined". Only getNextIndex guarded the helper; getLength, getCount, and ArrayKeyInfoStrategy fed nil straight through. Fixed at the helper: toIndexString now returns string | null (passing nil through as null), and a new toRequiredIndexString throws on nil for callers where the upstream key/type check guarantees a value. - GetArrayMultiElementsDto accepted an unbounded indexes array. ARMGET is O(N) per the Redis docs, so a large list with the app's 512MB request body limit could tie up Redis. Capped at the same 1,000,000 per-call limit ARGETRANGE/ARSCAN use, with the same 400 error path. Bruno Get Elements doc updated to reflect the new cap. New unit tests cover the nil pass-through (the exact regression codex flagged) and the strict variant's throw-on-nil contract. --- .../bruno/RedisInsight/Array/Get Elements.bru | 4 +- .../modules/browser/array/array.service.ts | 25 ++++++----- .../array/dto/get.array-multi-elements.dto.ts | 9 +++- .../src/modules/browser/array/utils.spec.ts | 43 +++++++++++++++++++ .../api/src/modules/browser/array/utils.ts | 20 ++++++++- .../strategies/array.key-info.strategy.ts | 10 ++--- 6 files changed, 90 insertions(+), 21 deletions(-) create mode 100644 redisinsight/api/src/modules/browser/array/utils.spec.ts diff --git a/redisinsight/api/bruno/RedisInsight/Array/Get Elements.bru b/redisinsight/api/bruno/RedisInsight/Array/Get Elements.bru index 1421a19dee..6a0560d746 100644 --- a/redisinsight/api/bruno/RedisInsight/Array/Get Elements.bru +++ b/redisinsight/api/bruno/RedisInsight/Array/Get Elements.bru @@ -28,7 +28,7 @@ docs { | Field | Type | Notes | |-----------|----------|------------------------------------------------------| | `keyName` | string | The Array key name. | - | `indexes` | string[] | One or more indexes; each unsigned 64-bit as string. | + | `indexes` | string[] | 1 to 1,000,000 indexes; each unsigned 64-bit as string. | ## Response @@ -43,7 +43,7 @@ docs { | Status | When | |--------|------| - | `400` | Any index is not a valid unsigned 64-bit integer string, or the key holds a non-array type. | + | `400` | `indexes` is empty or exceeds 1,000,000 entries, any index is not a valid unsigned 64-bit integer string, or the key holds a non-array type. | | `403` | User has no permission for `ARMGET`. | | `404` | Key does not exist. | } diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 42706f600c..3fc4e374ff 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -20,7 +20,10 @@ import { } from 'src/modules/browser/utils'; import { KeyDto } from 'src/modules/browser/keys/dto'; import { ARRAY_RANGE_MAX_ELEMENTS } from 'src/modules/browser/array/constants'; -import { toIndexString } from 'src/modules/browser/array/utils'; +import { + toIndexString, + toRequiredIndexString, +} from 'src/modules/browser/array/utils'; import { ArrayCreationMode, ArrayElement, @@ -222,7 +225,7 @@ export class ArrayService { for (const [rawIndex, value] of pairs) { if (rawIndex == null || value == null) continue; elements.push({ - index: toIndexString(rawIndex), + index: toRequiredIndexString(rawIndex), value: value as Buffer | string, }); } @@ -258,7 +261,7 @@ export class ArrayService { this.logger.debug('Succeed to get array length.', clientMetadata); return plainToInstance(GetArrayLengthResponse, { keyName, - length: toIndexString(reply), + length: toRequiredIndexString(reply), }); } catch (error) { this.logger.error('Failed to get array length.', error, clientMetadata); @@ -288,7 +291,7 @@ export class ArrayService { this.logger.debug('Succeed to get array count.', clientMetadata); return plainToInstance(GetArrayCountResponse, { keyName, - count: toIndexString(reply), + count: toRequiredIndexString(reply), }); } catch (error) { this.logger.error('Failed to get array count.', error, clientMetadata); @@ -315,14 +318,14 @@ export class ArrayService { keyName, ]); - // ARNEXT returns nil when the insertion cursor is exhausted. Surface - // that as `null` so clients can distinguish absence from a real index; - // folding it through toIndexString would produce the string "null". - const index = - reply === null || reply === undefined ? null : toIndexString(reply); - + // ARNEXT returns nil when the insertion cursor is exhausted; toIndexString + // passes that through as null so clients can distinguish absence from a + // real index. this.logger.debug('Succeed to get array next index.', clientMetadata); - return plainToInstance(GetArrayNextIndexResponse, { keyName, index }); + return plainToInstance(GetArrayNextIndexResponse, { + keyName, + index: toIndexString(reply), + }); } catch (error) { this.logger.error( 'Failed to get array next index.', diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.dto.ts index 61e9edd566..4fd263874d 100644 --- a/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.dto.ts +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.dto.ts @@ -1,18 +1,23 @@ import { ApiProperty } from '@nestjs/swagger'; import { KeyDto } from 'src/modules/browser/keys/dto'; import { IsArrayIndex } from 'src/common/decorators'; -import { ArrayMinSize, IsArray } from 'class-validator'; +import { ArrayMaxSize, ArrayMinSize, IsArray } from 'class-validator'; +import { ARRAY_RANGE_MAX_ELEMENTS } from 'src/modules/browser/array/constants'; export class GetArrayMultiElementsDto extends KeyDto { @ApiProperty({ description: - 'Indexes to read. Each index is an unsigned 64-bit integer as string.', + 'Indexes to read. Each index is an unsigned 64-bit integer as string. ' + + `At most ${ARRAY_RANGE_MAX_ELEMENTS.toLocaleString('en-US')} indexes ` + + 'per call — ARMGET is O(N) in the number of indexes, so the same ' + + 'per-call cap as ARGETRANGE/ARSCAN applies.', type: String, isArray: true, example: ['0', '5', '42'], }) @IsArray() @ArrayMinSize(1) + @ArrayMaxSize(ARRAY_RANGE_MAX_ELEMENTS) @IsArrayIndex({ each: true }) indexes: string[]; } diff --git a/redisinsight/api/src/modules/browser/array/utils.spec.ts b/redisinsight/api/src/modules/browser/array/utils.spec.ts new file mode 100644 index 0000000000..1c6d928793 --- /dev/null +++ b/redisinsight/api/src/modules/browser/array/utils.spec.ts @@ -0,0 +1,43 @@ +import { + toIndexString, + toRequiredIndexString, +} from 'src/modules/browser/array/utils'; + +describe('toIndexString', () => { + it('preserves a decimal string as-is', () => { + expect(toIndexString('18446744073709551610')).toBe('18446744073709551610'); + }); + + it('stringifies bigint without precision loss', () => { + expect(toIndexString(BigInt('18446744073709551610'))).toBe( + '18446744073709551610', + ); + }); + + it('stringifies number replies', () => { + expect(toIndexString(42)).toBe('42'); + }); + + it('decodes Buffer replies as utf8', () => { + expect(toIndexString(Buffer.from('42'))).toBe('42'); + }); + + it('returns null for nil replies instead of the string "null"', () => { + expect(toIndexString(null)).toBeNull(); + expect(toIndexString(undefined)).toBeNull(); + }); +}); + +describe('toRequiredIndexString', () => { + it('returns a decimal string for non-nil replies', () => { + expect(toRequiredIndexString(7)).toBe('7'); + expect(toRequiredIndexString('7')).toBe('7'); + }); + + it('throws for nil replies', () => { + expect(() => toRequiredIndexString(null)).toThrow( + 'Unexpected nil reply where a value was required.', + ); + expect(() => toRequiredIndexString(undefined)).toThrow(); + }); +}); diff --git a/redisinsight/api/src/modules/browser/array/utils.ts b/redisinsight/api/src/modules/browser/array/utils.ts index 739747c30d..5b2ad85488 100644 --- a/redisinsight/api/src/modules/browser/array/utils.ts +++ b/redisinsight/api/src/modules/browser/array/utils.ts @@ -1,10 +1,28 @@ // Integer/bulk replies for indexes and counts may arrive as Buffer, string, // number, or bigint depending on the client mode. Normalize to a decimal // string so the unsigned 64-bit contract is preserved on the wire. -export const toIndexString = (value: unknown): string => { +// +// Returns `null` for nil replies so callers can distinguish absence from a +// real value (e.g. ARNEXT returns nil when the insertion cursor is exhausted). +// Without this guard `String(null)` / `String(undefined)` would emit the +// literal strings "null" / "undefined" and corrupt downstream JSON. +export const toIndexString = (value: unknown): string | null => { + if (value === null || value === undefined) return null; if (typeof value === 'string') return value; if (typeof value === 'bigint') return value.toString(); if (typeof value === 'number') return String(value); if (Buffer.isBuffer(value)) return value.toString('utf8'); return String(value); }; + +// Strict variant for callers where the upstream key/type check guarantees +// Redis cannot return nil (e.g. ARLEN / ARCOUNT / ARMGET element lookup +// on a verified array key). Throws on nil to surface unexpected states +// rather than emit a corrupt response. +export const toRequiredIndexString = (value: unknown): string => { + const result = toIndexString(value); + if (result === null) { + throw new Error('Unexpected nil reply where a value was required.'); + } + return result; +}; diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts index 19797d088b..8091b9ee6a 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts @@ -10,7 +10,7 @@ import { RedisString } from 'src/common/constants'; import { KeyInfoStrategy } from 'src/modules/browser/keys/key-info/strategies/key-info.strategy'; import { RedisClient } from 'src/modules/redis/client'; import { MAX_KEY_SIZE } from 'src/modules/browser/keys/key-info/constants'; -import { toIndexString } from 'src/modules/browser/array/utils'; +import { toRequiredIndexString } from 'src/modules/browser/array/utils'; /** * Key-info strategy for the Array data type. Returns the standard @@ -51,8 +51,8 @@ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { type, ttl, size, - length: toIndexString(rawLength), - count: toIndexString(rawCount), + length: toRequiredIndexString(rawLength), + count: toRequiredIndexString(rawCount), }; } @@ -80,8 +80,8 @@ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { type, ttl, size, - length: toIndexString(rawLength), - count: toIndexString(rawCount), + length: toRequiredIndexString(rawLength), + count: toRequiredIndexString(rawCount), }; } } From 0409c614bb8ed567b8327364c6fdc80a1b09a5d3 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 16 Jun 2026 12:43:30 +0300 Subject: [PATCH 16/23] fix(api): correct OAS schema for array element nullability (RI-8219) --- .../browser/array/dto/get.array-multi-elements.response.ts | 7 +++++-- .../modules/browser/array/dto/get.array-range.response.ts | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts index 294afe1a02..f8c24e432d 100644 --- a/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts @@ -9,8 +9,11 @@ export class GetArrayMultiElementsResponse extends KeyResponse { 'Values for each requested index, in request order. ' + 'Empty slots are returned as null.', type: 'array', - items: { oneOf: [{ type: 'string' }, { type: 'null' }] }, - nullable: true, + // OAS 3.0: `type: 'null'` is not a valid schema type and `nullable: true` + // on the array would mark the array itself as nullable. Putting + // `nullable: true` on the item schema is the OAS 3.0 way to express + // `(string | null)[]` so the generated client types items correctly. + items: { type: 'string', nullable: true }, }) @RedisStringType({ each: true }) elements: (RedisString | null)[]; diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts index 390345bd2d..3fbe2f141b 100644 --- a/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts @@ -9,8 +9,11 @@ export class GetArrayRangeResponse extends KeyResponse { 'Values for each index in the requested range, in order. ' + 'Empty slots are returned as null.', type: 'array', - items: { oneOf: [{ type: 'string' }, { type: 'null' }] }, - nullable: true, + // OAS 3.0: `type: 'null'` is not a valid schema type and `nullable: true` + // on the array would mark the array itself as nullable. Putting + // `nullable: true` on the item schema is the OAS 3.0 way to express + // `(string | null)[]` so the generated client types items correctly. + items: { type: 'string', nullable: true }, }) @RedisStringType({ each: true }) elements: (RedisString | null)[]; From 036f805ce3abe7ecbdb3c06c4d869f0a3c31bab0 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 16 Jun 2026 22:52:47 +0300 Subject: [PATCH 17/23] address pr comments --- .../Array/Create Array (Contiguous).bru | 4 +- .../Array/Create Array (Sparse).bru | 4 +- .../array-index/array-index.decorator.spec.ts | 7 ++-- .../common/utils/array-index.helper.spec.ts | 9 +++-- .../src/common/utils/array-index.helper.ts | 17 +++++---- .../browser/array/array.service.spec.ts | 22 ----------- .../modules/browser/array/array.service.ts | 32 +++++----------- .../keys/dto/get.array-key-info.response.ts | 37 ++++--------------- .../redis/client/ioredis/ioredis.client.ts | 4 +- redisinsight/ui/src/utils/arrayIndex.ts | 18 +++++---- .../ui/src/utils/tests/arrayIndex.spec.ts | 9 +++-- 11 files changed, 56 insertions(+), 107 deletions(-) diff --git a/redisinsight/api/bruno/RedisInsight/Array/Create Array (Contiguous).bru b/redisinsight/api/bruno/RedisInsight/Array/Create Array (Contiguous).bru index ed1804081d..a7084cf18b 100644 --- a/redisinsight/api/bruno/RedisInsight/Array/Create Array (Contiguous).bru +++ b/redisinsight/api/bruno/RedisInsight/Array/Create Array (Contiguous).bru @@ -35,7 +35,7 @@ docs { |-------|------|-------------| | `keyName` | string | The key name for the new Array | | `mode` | string | Must be `contiguous` for this request shape | - | `startIndex` | string | Start index as a numeric string (`0` to `18446744073709551615`) | + | `startIndex` | string | Start index as a numeric string (`0` to `18446744073709551614`) | | `values` | string[] | Value(s) to write starting at `startIndex` | | `expire` | number (optional) | TTL in seconds | @@ -47,7 +47,7 @@ docs { | Status | Condition | |--------|-----------| - | `400` | Validation failed, e.g. a non-canonical index: `startIndex must be an integer string between 0 and 18446744073709551615` | + | `400` | Validation failed, e.g. a non-canonical index: `startIndex must be an integer string between 0 and 18446744073709551614` | | `403` | User has no permissions | | `409` | Key with this name already exists | } diff --git a/redisinsight/api/bruno/RedisInsight/Array/Create Array (Sparse).bru b/redisinsight/api/bruno/RedisInsight/Array/Create Array (Sparse).bru index 1a67fc62a4..5365e929f1 100644 --- a/redisinsight/api/bruno/RedisInsight/Array/Create Array (Sparse).bru +++ b/redisinsight/api/bruno/RedisInsight/Array/Create Array (Sparse).bru @@ -38,7 +38,7 @@ docs { | `keyName` | string | The key name for the new Array | | `mode` | string | Must be `sparse` for this request shape | | `elements` | array | Index/value pairs to write | - | `elements[].index` | string | Element index as a numeric string (`0` to `18446744073709551615`) | + | `elements[].index` | string | Element index as a numeric string (`0` to `18446744073709551614`) | | `elements[].value` | string | Element value | | `expire` | number (optional) | TTL in seconds | @@ -50,7 +50,7 @@ docs { | Status | Condition | |--------|-----------| - | `400` | Validation failed, e.g. a non-canonical index: `elements.0.index must be an integer string between 0 and 18446744073709551615` | + | `400` | Validation failed, e.g. a non-canonical index: `elements.0.index must be an integer string between 0 and 18446744073709551614` | | `403` | User has no permissions | | `409` | Key with this name already exists | } diff --git a/redisinsight/api/src/common/decorators/array-index/array-index.decorator.spec.ts b/redisinsight/api/src/common/decorators/array-index/array-index.decorator.spec.ts index e455646831..da523c7099 100644 --- a/redisinsight/api/src/common/decorators/array-index/array-index.decorator.spec.ts +++ b/redisinsight/api/src/common/decorators/array-index/array-index.decorator.spec.ts @@ -18,7 +18,7 @@ const validateSingle = async (index: any) => { }; describe('IsArrayIndex', () => { - it.each(['0', '42', '18446744073709551615'])( + it.each(['0', '42', '18446744073709551614'])( 'should pass for valid index %p', async (input) => { expect(await validateSingle(input)).toHaveLength(0); @@ -30,6 +30,7 @@ describe('IsArrayIndex', () => { '1.5', 'abc', '', + '18446744073709551615', '18446744073709551616', 42, undefined, @@ -45,7 +46,7 @@ describe('IsArrayIndex', () => { // Consumers may match on this exact format — changing it is breaking. expect(errors[0].constraints).toEqual({ ArrayIndexValidator: - 'index must be an integer string between 0 and 18446744073709551615', + 'index must be an integer string between 0 and 18446744073709551614', }); }); @@ -58,7 +59,7 @@ describe('IsArrayIndex', () => { it('should pass with { each: true } when all elements are valid', async () => { const dto = new MultiIndexDto(); - dto.indexes = ['1', '2', '18446744073709551615']; + dto.indexes = ['1', '2', '18446744073709551614']; expect(await validate(dto)).toHaveLength(0); }); diff --git a/redisinsight/api/src/common/utils/array-index.helper.spec.ts b/redisinsight/api/src/common/utils/array-index.helper.spec.ts index 88b4e322c4..244f49b2d2 100644 --- a/redisinsight/api/src/common/utils/array-index.helper.spec.ts +++ b/redisinsight/api/src/common/utils/array-index.helper.spec.ts @@ -5,8 +5,8 @@ import { } from 'src/common/utils'; describe('array-index.helper', () => { - it('should expose max unsigned 64-bit value', () => { - expect(ARRAY_INDEX_MAX).toEqual(BigInt('18446744073709551615')); + it('should expose the max valid Redis array index (2^64 - 2)', () => { + expect(ARRAY_INDEX_MAX).toEqual(BigInt('18446744073709551614')); }); describe('parseArrayIndex', () => { @@ -15,8 +15,9 @@ describe('array-index.helper', () => { { input: '7', expected: '7' }, { input: '007', expected: '7' }, // leading zeros normalized { input: ' 42 ', expected: '42' }, // outer whitespace trimmed - { input: '18446744073709551615', expected: '18446744073709551615' }, // max u64 - { input: '18446744073709551616', expected: null }, // max + 1 + { input: '18446744073709551614', expected: '18446744073709551614' }, // max valid (2^64 - 2) + { input: '18446744073709551615', expected: null }, // 2^64 - 1 — reserved + { input: '18446744073709551616', expected: null }, // 2^64 { input: '184467440737095516150', expected: null }, // 21 digits — length guard { input: '00000000000000000000042', expected: null }, // >20 chars — guard trumps normalization { input: '-1', expected: null }, diff --git a/redisinsight/api/src/common/utils/array-index.helper.ts b/redisinsight/api/src/common/utils/array-index.helper.ts index f1a680d780..a4afebd323 100644 --- a/redisinsight/api/src/common/utils/array-index.helper.ts +++ b/redisinsight/api/src/common/utils/array-index.helper.ts @@ -1,14 +1,17 @@ /** - * Redis array indexes are unsigned 64-bit integers (0 … 2^64−1) and exceed - * Number.MAX_SAFE_INTEGER, so they travel as numeric strings end-to-end — - * never parseInt/Number, no JS-side arithmetic on indexes. + * Redis array indexes are unsigned 64-bit integers in the range + * [0, 2^64−1) (max valid is 2^64−2) and exceed Number.MAX_SAFE_INTEGER, + * so they travel as numeric strings end-to-end — never parseInt/Number, + * no JS-side arithmetic on indexes. * * Mirrored in redisinsight/ui/src/utils/arrayIndex.ts — keep semantics and * tests in sync. */ -// 2^64 - 1; BigInt() call (not a literal) — this tsconfig targets es2019, -// where BigInt literals are a syntax error (TS2737). -export const ARRAY_INDEX_MAX = BigInt('18446744073709551615'); +// 2^64 - 2 — Redis accepts indexes in the half-open range [0, 2^64-1), +// so the max valid index is 2^64-2 (2^64-1 is reserved). BigInt() call +// form (not a literal) — this tsconfig targets es2019, where BigInt +// literals are a syntax error (TS2737). +export const ARRAY_INDEX_MAX = BigInt('18446744073709551614'); const ARRAY_INDEX_REGEX = /^\d+$/; @@ -19,7 +22,7 @@ const ARRAY_INDEX_MAX_LENGTH = 20; /** * Returns the canonical decimal string for a valid index ("007" → "7"), * or null for anything else (empty or whitespace-only, negative, - * fractional, exponent, hex, > 2^64−1, non-string input). + * fractional, exponent, hex, > 2^64−2, non-string input). */ export const parseArrayIndex = (input: unknown): string | null => { if (typeof input !== 'string') { diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index 6be664c3fe..147aa9e8f4 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -313,28 +313,6 @@ describe('ArrayService', () => { expect(result).toEqual(mockGetArrayScanResponse); }); - it('should also accept the nested [[index, value], ...] reply shape', async () => { - when(mockStandaloneRedisClient.sendCommand) - .calledWith([ - BrowserToolArrayCommands.ArScan, - mockGetArrayScanDto.keyName, - mockGetArrayScanDto.start, - mockGetArrayScanDto.end, - ]) - .mockResolvedValue([ - [Buffer.from('0'), mockArrayElement1], - [Buffer.from('1'), Buffer.from('20.4')], - ] as unknown as (Buffer | string)[]); - - const result = await service.scan( - mockBrowserClientMetadata, - mockGetArrayScanDto, - ); - expect(result.elements).toHaveLength(2); - expect(result.elements[0].index).toBe('0'); - expect(result.elements[1].index).toBe('1'); - }); - it('should drop pairs whose value or index is null/undefined', async () => { when(mockStandaloneRedisClient.sendCommand) .calledWith([ diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 3fc4e374ff..96f4a09d3b 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -76,10 +76,8 @@ export class ArrayService { } } - // Inputs are validated as canonical decimal strings ≤ 2^64-1, so BigInt() - // is safe. The contract is start ≤ end — matches every Redis range - // command and avoids any ambiguity about how a reversed reply would be - // ordered or paginated. + // Inputs are validated as canonical decimal strings ≤ 2^64-2, so BigInt() + // is safe. ARGETRANGE / ARSCAN require start ≤ end. private assertValidRange(start: string, end: string): void { const startBig = BigInt(start); const endBig = BigInt(end); @@ -203,26 +201,14 @@ export class ArrayService { hasLimit ? [...baseArgs, 'LIMIT', limit] : [...baseArgs], )) as unknown[]; - // ARSCAN's wire shape varies by client: some clients surface a flat - // [index, value, index, value, ...] reply, others group it into - // [[index, value], [index, value], ...]. Detect by sniffing the first - // element and normalize both into pairs. Malformed pairs (missing - // half) are dropped to honor the "populated-only" contract — - // JSON.stringify would otherwise drop an undefined value and reach - // the client as `{ index }` with no value. - const pairs: Array<[unknown, unknown]> = []; - if (Array.isArray(reply[0])) { - for (const entry of reply as unknown[][]) { - if (entry?.length >= 2) pairs.push([entry[0], entry[1]]); - } - } else { - for (let i = 0; i < reply.length; i += 2) { - pairs.push([reply[i], reply[i + 1]]); - } - } - + // ARSCAN returns a flat [index, value, index, value, ...] reply. + // Skip pairs with a nil index or value to honor the "populated-only" + // contract — JSON.stringify would otherwise drop an undefined value + // and reach the client as `{ index }` with no value. const elements: ArrayElement[] = []; - for (const [rawIndex, value] of pairs) { + for (let i = 0; i < reply.length; i += 2) { + const rawIndex = reply[i]; + const value = reply[i + 1]; if (rawIndex == null || value == null) continue; elements.push({ index: toRequiredIndexString(rawIndex), diff --git a/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts b/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts index 6baa958ebf..7cd121d9df 100644 --- a/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts +++ b/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts @@ -1,6 +1,5 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { RedisStringType, ApiRedisString } from 'src/common/decorators'; -import { RedisString } from 'src/common/constants'; +import { ApiProperty, PickType } from '@nestjs/swagger'; +import { GetKeyInfoResponse } from 'src/modules/browser/keys/dto/get.keys-info.response'; /** * Key-info response for the Array data type. @@ -11,32 +10,12 @@ import { RedisString } from 'src/common/constants'; * `array` key type before reading these fields and avoid silent numeric * coercion that would round large values. */ -export class GetArrayKeyInfoResponse { - @ApiRedisString() - @RedisStringType() - name: RedisString; - - @ApiPropertyOptional({ - type: String, - description: 'Always "array" for this response shape.', - }) - type?: string; - - @ApiPropertyOptional({ - type: Number, - description: - 'The remaining time to live of a key.' + - ' If the property has value of -1, then the key has no expiration time (no limit).', - }) - ttl?: number; - - @ApiPropertyOptional({ - type: Number, - description: - 'The number of bytes that the array key and its value require to be stored in RAM.', - }) - size?: number; - +export class GetArrayKeyInfoResponse extends PickType(GetKeyInfoResponse, [ + 'name', + 'type', + 'ttl', + 'size', +] as const) { @ApiProperty({ type: String, description: diff --git a/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts b/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts index c8221c2cee..1454f2a4a4 100644 --- a/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts +++ b/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts @@ -46,9 +46,7 @@ export abstract class IoredisClient extends RedisClient { client.addBuiltinCommand(BrowserToolVectorSetCommands.VSetAttr); client.addBuiltinCommand(BrowserToolVectorSetCommands.VRem); client.addBuiltinCommand(BrowserToolVectorSetCommands.VSim); - // Array commands — registered so they can be issued via the pipeline - // path (which maps commands to prototype methods); sendCommand works - // without registration but sendPipeline does not. + // Array commands — must be registered for sendPipeline. client.addBuiltinCommand(BrowserToolArrayCommands.ArSet); client.addBuiltinCommand(BrowserToolArrayCommands.ArMSet); client.addBuiltinCommand(BrowserToolArrayCommands.ArGet); diff --git a/redisinsight/ui/src/utils/arrayIndex.ts b/redisinsight/ui/src/utils/arrayIndex.ts index e027875140..b0141fbfbd 100644 --- a/redisinsight/ui/src/utils/arrayIndex.ts +++ b/redisinsight/ui/src/utils/arrayIndex.ts @@ -1,15 +1,17 @@ /** - * Redis array indexes are unsigned 64-bit integers (0 … 2^64−1) and exceed - * Number.MAX_SAFE_INTEGER, so they stay numeric strings end-to-end — - * never parseInt/Number, no JS-side arithmetic on indexes; Redux stores - * them as strings. + * Redis array indexes are unsigned 64-bit integers in the range + * [0, 2^64−1) (max valid is 2^64−2) and exceed Number.MAX_SAFE_INTEGER, + * so they stay numeric strings end-to-end — never parseInt/Number, + * no JS-side arithmetic on indexes; Redux stores them as strings. * * Mirrored in redisinsight/api/src/common/utils/array-index.helper.ts — * keep semantics and tests in sync. */ -// 2^64 - 1; BigInt() call form (not a literal) for parity with the API -// mirror, whose tsconfig targets es2019 (where BigInt literals are TS2737). -export const ARRAY_INDEX_MAX = BigInt('18446744073709551615') +// 2^64 - 2 — Redis accepts indexes in the half-open range [0, 2^64-1), +// so the max valid index is 2^64-2 (2^64-1 is reserved). BigInt() call +// form (not a literal) for parity with the API mirror, whose tsconfig +// targets es2019 (where BigInt literals are TS2737). +export const ARRAY_INDEX_MAX = BigInt('18446744073709551614') const ARRAY_INDEX_REGEX = /^\d+$/ @@ -20,7 +22,7 @@ const ARRAY_INDEX_MAX_LENGTH = 20 /** * Returns the canonical decimal string for a valid index ("007" → "7"), * or null for anything else (empty or whitespace-only, negative, - * fractional, exponent, hex, > 2^64−1, non-string input). + * fractional, exponent, hex, > 2^64−2, non-string input). */ export const parseArrayIndex = (input: unknown): string | null => { if (typeof input !== 'string') { diff --git a/redisinsight/ui/src/utils/tests/arrayIndex.spec.ts b/redisinsight/ui/src/utils/tests/arrayIndex.spec.ts index 757f6bed85..70fbcfa2f7 100644 --- a/redisinsight/ui/src/utils/tests/arrayIndex.spec.ts +++ b/redisinsight/ui/src/utils/tests/arrayIndex.spec.ts @@ -5,8 +5,8 @@ import { } from 'uiSrc/utils' describe('arrayIndex', () => { - it('should expose max unsigned 64-bit value', () => { - expect(ARRAY_INDEX_MAX).toEqual(BigInt('18446744073709551615')) + it('should expose the max valid Redis array index (2^64 - 2)', () => { + expect(ARRAY_INDEX_MAX).toEqual(BigInt('18446744073709551614')) }) describe('parseArrayIndex', () => { @@ -15,8 +15,9 @@ describe('arrayIndex', () => { { input: '7', expected: '7' }, { input: '007', expected: '7' }, // leading zeros normalized { input: ' 42 ', expected: '42' }, // outer whitespace trimmed - { input: '18446744073709551615', expected: '18446744073709551615' }, // max u64 - { input: '18446744073709551616', expected: null }, // max + 1 + { input: '18446744073709551614', expected: '18446744073709551614' }, // max valid (2^64 - 2) + { input: '18446744073709551615', expected: null }, // 2^64 - 1 — reserved + { input: '18446744073709551616', expected: null }, // 2^64 { input: '184467440737095516150', expected: null }, // 21 digits — length guard { input: '00000000000000000000042', expected: null }, // >20 chars — guard trumps normalization { input: '-1', expected: null }, From a72df11aaa6f2ab2f285679e4a6ffdfc5615c5a8 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 16 Jun 2026 22:56:34 +0300 Subject: [PATCH 18/23] reduce comments --- .../src/common/utils/array-index.helper.ts | 18 +++++----------- .../modules/browser/array/array.service.ts | 21 ++----------------- .../src/modules/browser/array/constants.ts | 13 +++--------- .../api/src/modules/browser/array/utils.ts | 15 ++++--------- .../keys/dto/get.array-key-info.response.ts | 10 +++------ .../strategies/array.key-info.strategy.ts | 15 ++++++------- redisinsight/ui/src/utils/arrayIndex.ts | 19 ++++++----------- 7 files changed, 29 insertions(+), 82 deletions(-) diff --git a/redisinsight/api/src/common/utils/array-index.helper.ts b/redisinsight/api/src/common/utils/array-index.helper.ts index a4afebd323..adcadf697a 100644 --- a/redisinsight/api/src/common/utils/array-index.helper.ts +++ b/redisinsight/api/src/common/utils/array-index.helper.ts @@ -1,22 +1,14 @@ /** - * Redis array indexes are unsigned 64-bit integers in the range - * [0, 2^64−1) (max valid is 2^64−2) and exceed Number.MAX_SAFE_INTEGER, - * so they travel as numeric strings end-to-end — never parseInt/Number, - * no JS-side arithmetic on indexes. - * - * Mirrored in redisinsight/ui/src/utils/arrayIndex.ts — keep semantics and - * tests in sync. + * Redis array indexes travel as numeric strings end-to-end (u64 exceeds + * Number.MAX_SAFE_INTEGER) — never parseInt/Number. + * Mirrored in redisinsight/ui/src/utils/arrayIndex.ts — keep in sync. */ -// 2^64 - 2 — Redis accepts indexes in the half-open range [0, 2^64-1), -// so the max valid index is 2^64-2 (2^64-1 is reserved). BigInt() call -// form (not a literal) — this tsconfig targets es2019, where BigInt -// literals are a syntax error (TS2737). +// Max valid Redis array index — half-open [0, 2^64−1), so 2^64−2. export const ARRAY_INDEX_MAX = BigInt('18446744073709551614'); const ARRAY_INDEX_REGEX = /^\d+$/; -// Max u64 is 20 digits; longer all-digit inputs can't be valid and a length -// guard keeps BigInt() from parsing arbitrarily large request payloads. +// 20-digit guard so BigInt() can't parse arbitrarily long payloads. const ARRAY_INDEX_MAX_LENGTH = 20; /** diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 96f4a09d3b..f6d4d0bde2 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -76,8 +76,6 @@ export class ArrayService { } } - // Inputs are validated as canonical decimal strings ≤ 2^64-2, so BigInt() - // is safe. ARGETRANGE / ARSCAN require start ≤ end. private assertValidRange(start: string, end: string): void { const startBig = BigInt(start); const endBig = BigInt(end); @@ -156,11 +154,9 @@ export class ArrayService { const { keyName, mode, startIndex = '0', values = [], elements = [] } = dto; if (mode === ArrayCreationMode.Contiguous) { - // ARSET key startIndex value [value ...] — contiguous run from startIndex return [BrowserToolArrayCommands.ArSet, keyName, startIndex, ...values]; } - // ARMSET key index value [index value ...] — sparse index/value pairs return [ BrowserToolArrayCommands.ArMSet, keyName, @@ -176,11 +172,6 @@ export class ArrayService { this.logger.debug('Scanning array range.', clientMetadata); const { keyName, start, end, limit } = dto; - // ARSCAN skips empty slots in the response but still walks the index - // range server-side (O(|end-start|+1)). Apply the same range checks - // as ARGETRANGE so an unbounded or malformed range cannot tie up - // Redis even when LIMIT is omitted; LIMIT remains a complementary - // result-set cap. this.assertValidRange(start, end); const client = @@ -193,18 +184,13 @@ export class ArrayService { start, end, ] as const; - // Treat an explicit JSON `null` the same as an omitted limit. @IsOptional() - // skips downstream validators for null, so the DTO accepts it; forwarding - // `LIMIT null` to Redis would otherwise surface as a 500. + // typeof 'number' so an explicit JSON null is treated as omitted. const hasLimit = typeof limit === 'number'; const reply = (await client.sendCommand( hasLimit ? [...baseArgs, 'LIMIT', limit] : [...baseArgs], )) as unknown[]; - // ARSCAN returns a flat [index, value, index, value, ...] reply. - // Skip pairs with a nil index or value to honor the "populated-only" - // contract — JSON.stringify would otherwise drop an undefined value - // and reach the client as `{ index }` with no value. + // Skip pairs with a nil index or value (populated-only contract). const elements: ArrayElement[] = []; for (let i = 0; i < reply.length; i += 2) { const rawIndex = reply[i]; @@ -304,9 +290,6 @@ export class ArrayService { keyName, ]); - // ARNEXT returns nil when the insertion cursor is exhausted; toIndexString - // passes that through as null so clients can distinguish absence from a - // real index. this.logger.debug('Succeed to get array next index.', clientMetadata); return plainToInstance(GetArrayNextIndexResponse, { keyName, diff --git a/redisinsight/api/src/modules/browser/array/constants.ts b/redisinsight/api/src/modules/browser/array/constants.ts index 14cdb8ee15..c2eb2598ca 100644 --- a/redisinsight/api/src/modules/browser/array/constants.ts +++ b/redisinsight/api/src/modules/browser/array/constants.ts @@ -1,11 +1,4 @@ -// Server-enforced hard cap on index-range reads: 1,000,000 elements per call. -// Source: https://redis.io/docs/latest/develop/data-types/arrays/#limits — -// "ARGETRANGE enforces a hard limit of 1,000,000 elements per call to guard -// against accidentally large range reads." We pre-flight the same check so -// callers get a clear 400 instead of a generic server error. -// The same cap is applied to ARSCAN: although ARSCAN's response only -// contains populated slots, the server still walks the index range -// (O(|end-start|+1) per the Redis docs), so an unbounded range without a -// LIMIT can still tie up Redis. LIMIT remains a complementary result-set -// cap, not a substitute for the range cap. +// Hard cap on the |end - start| + 1 span for ARGETRANGE and ARSCAN, mirrored +// from the server-side ARGETRANGE limit so callers get a clear 400. +// https://redis.io/docs/latest/develop/data-types/arrays/#limits export const ARRAY_RANGE_MAX_ELEMENTS = 1_000_000; diff --git a/redisinsight/api/src/modules/browser/array/utils.ts b/redisinsight/api/src/modules/browser/array/utils.ts index 5b2ad85488..2593c1df15 100644 --- a/redisinsight/api/src/modules/browser/array/utils.ts +++ b/redisinsight/api/src/modules/browser/array/utils.ts @@ -1,11 +1,6 @@ -// Integer/bulk replies for indexes and counts may arrive as Buffer, string, -// number, or bigint depending on the client mode. Normalize to a decimal -// string so the unsigned 64-bit contract is preserved on the wire. -// -// Returns `null` for nil replies so callers can distinguish absence from a -// real value (e.g. ARNEXT returns nil when the insertion cursor is exhausted). -// Without this guard `String(null)` / `String(undefined)` would emit the -// literal strings "null" / "undefined" and corrupt downstream JSON. +// Normalize an integer/bulk reply to a decimal string so u64 indexes survive +// the wire. Returns null for nil replies (e.g. ARNEXT when the cursor is +// exhausted) so callers can distinguish absence from a real value. export const toIndexString = (value: unknown): string | null => { if (value === null || value === undefined) return null; if (typeof value === 'string') return value; @@ -16,9 +11,7 @@ export const toIndexString = (value: unknown): string | null => { }; // Strict variant for callers where the upstream key/type check guarantees -// Redis cannot return nil (e.g. ARLEN / ARCOUNT / ARMGET element lookup -// on a verified array key). Throws on nil to surface unexpected states -// rather than emit a corrupt response. +// Redis cannot return nil (ARLEN / ARCOUNT / ARSCAN element index). export const toRequiredIndexString = (value: unknown): string => { const result = toIndexString(value); if (result === null) { diff --git a/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts b/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts index 7cd121d9df..f45de64259 100644 --- a/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts +++ b/redisinsight/api/src/modules/browser/keys/dto/get.array-key-info.response.ts @@ -2,13 +2,9 @@ import { ApiProperty, PickType } from '@nestjs/swagger'; import { GetKeyInfoResponse } from 'src/modules/browser/keys/dto/get.keys-info.response'; /** - * Key-info response for the Array data type. - * - * `length` (ARLEN) and `count` (ARCOUNT) are returned as decimal strings - * because an Array's index space is unsigned 64-bit and can exceed - * Number.MAX_SAFE_INTEGER for sparse keys. Consumers must narrow on the - * `array` key type before reading these fields and avoid silent numeric - * coercion that would round large values. + * Key-info response for the Array data type. `length` (ARLEN) and `count` + * (ARCOUNT) are decimal strings because the u64 index space exceeds + * Number.MAX_SAFE_INTEGER. */ export class GetArrayKeyInfoResponse extends PickType(GetKeyInfoResponse, [ 'name', diff --git a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts index 8091b9ee6a..dbd5a152cc 100644 --- a/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts +++ b/redisinsight/api/src/modules/browser/keys/key-info/strategies/array.key-info.strategy.ts @@ -13,16 +13,13 @@ import { MAX_KEY_SIZE } from 'src/modules/browser/keys/key-info/constants'; import { toRequiredIndexString } from 'src/modules/browser/array/utils'; /** - * Key-info strategy for the Array data type. Returns the standard - * TTL / size triple plus `length` (ARLEN, includes gaps) and `count` - * (ARCOUNT, populated slots) — the two diverge for sparse arrays and - * the View tab surfaces both. + * Key-info strategy for the Array data type. Returns TTL / size plus + * `length` (ARLEN, includes gaps) and `count` (ARCOUNT, populated only) — + * the two diverge for sparse arrays. * - * Returns a dedicated `GetArrayKeyInfoResponse` so that `length` and - * `count` are typed as decimal strings end-to-end. The Array index - * space is unsigned 64-bit and can exceed Number.MAX_SAFE_INTEGER for - * sparse keys; the shared `GetKeyInfoResponse.length: number` used by - * other key types would silently lose precision. + * Uses a dedicated `GetArrayKeyInfoResponse` so `length` / `count` stay + * decimal strings; the shared `GetKeyInfoResponse.length: number` would + * silently lose precision for u64 indexes. */ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { public async getInfo( diff --git a/redisinsight/ui/src/utils/arrayIndex.ts b/redisinsight/ui/src/utils/arrayIndex.ts index b0141fbfbd..57cbd65137 100644 --- a/redisinsight/ui/src/utils/arrayIndex.ts +++ b/redisinsight/ui/src/utils/arrayIndex.ts @@ -1,22 +1,15 @@ /** - * Redis array indexes are unsigned 64-bit integers in the range - * [0, 2^64−1) (max valid is 2^64−2) and exceed Number.MAX_SAFE_INTEGER, - * so they stay numeric strings end-to-end — never parseInt/Number, - * no JS-side arithmetic on indexes; Redux stores them as strings. - * - * Mirrored in redisinsight/api/src/common/utils/array-index.helper.ts — - * keep semantics and tests in sync. + * Redis array indexes travel as numeric strings end-to-end (u64 exceeds + * Number.MAX_SAFE_INTEGER) — never parseInt/Number; Redux stores them as + * strings. Mirrored in redisinsight/api/src/common/utils/array-index.helper.ts + * — keep in sync. */ -// 2^64 - 2 — Redis accepts indexes in the half-open range [0, 2^64-1), -// so the max valid index is 2^64-2 (2^64-1 is reserved). BigInt() call -// form (not a literal) for parity with the API mirror, whose tsconfig -// targets es2019 (where BigInt literals are TS2737). +// Max valid Redis array index — half-open [0, 2^64−1), so 2^64−2. export const ARRAY_INDEX_MAX = BigInt('18446744073709551614') const ARRAY_INDEX_REGEX = /^\d+$/ -// Max u64 is 20 digits; longer all-digit inputs can't be valid and a length -// guard keeps BigInt() from parsing arbitrarily large request payloads. +// 20-digit guard so BigInt() can't parse arbitrarily long payloads. const ARRAY_INDEX_MAX_LENGTH = 20 /** From 2ccfdf31ba73635e4036660aaa5d1faca3c50bde Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Wed, 17 Jun 2026 07:41:25 +0300 Subject: [PATCH 19/23] fix(api): allow reversed ranges for ARSCAN (RI-8219) ARSCAN's per-command page documents start > end as reverse iteration, not invalid input. Split the range guard so getRange still rejects reversed ranges (ARGETRANGE doesn't support them) but scan only enforces the abs-span cap and forwards start/end unchanged. --- .../modules/browser/array/array.controller.ts | 4 +-- .../browser/array/array.service.spec.ts | 30 ++++++++++++++----- .../modules/browser/array/array.service.ts | 22 +++++++++----- .../browser/array/dto/get.array-scan.dto.ts | 2 +- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/redisinsight/api/src/modules/browser/array/array.controller.ts b/redisinsight/api/src/modules/browser/array/array.controller.ts index 784efba99e..edea131d47 100644 --- a/redisinsight/api/src/modules/browser/array/array.controller.ts +++ b/redisinsight/api/src/modules/browser/array/array.controller.ts @@ -76,8 +76,8 @@ export class ArrayController extends BrowserBaseController { @ApiOperation({ description: 'Scan a range of populated elements from the array stored at key (ARSCAN). ' + - 'Empty slots are skipped. The range is inclusive and requires ' + - 'start ≤ end; a reversed range is rejected with 400.', + 'Empty slots are skipped. The range is inclusive; passing start > end ' + + 'returns pairs in reverse index order.', }) @ApiRedisParams() @ApiOkResponse({ type: GetArrayScanResponse }) diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index 147aa9e8f4..c50175b452 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -356,14 +356,28 @@ describe('ArrayService', () => { ).rejects.toThrow(BadRequestException); }); - it('should reject reversed ranges (start > end)', async () => { - await expect( - service.scan(mockBrowserClientMetadata, { - ...mockGetArrayScanDto, - start: '5', - end: '0', - }), - ).rejects.toThrow(BadRequestException); + it('should forward reversed ranges (start > end) to Redis as-is', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + '5', + '0', + ]) + .mockResolvedValue(flatReply); + + await service.scan(mockBrowserClientMetadata, { + ...mockGetArrayScanDto, + start: '5', + end: '0', + }); + + expect(mockStandaloneRedisClient.sendCommand).toHaveBeenCalledWith([ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + '5', + '0', + ]); }); it('should rethrow BadRequest on WrongType', async () => { diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index f6d4d0bde2..7eb372e128 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -76,15 +76,12 @@ export class ArrayService { } } - private assertValidRange(start: string, end: string): void { + private assertSpanWithinCap(start: string, end: string): void { const startBig = BigInt(start); const endBig = BigInt(end); + const span = + (startBig > endBig ? startBig - endBig : endBig - startBig) + BigInt(1); - if (startBig > endBig) { - throw new BadRequestException(ERROR_MESSAGES.ARRAY_RANGE_REVERSED); - } - - const span = endBig - startBig + BigInt(1); if (span > BigInt(ARRAY_RANGE_MAX_ELEMENTS)) { throw new BadRequestException( ERROR_MESSAGES.ARRAY_RANGE_TOO_LARGE(ARRAY_RANGE_MAX_ELEMENTS), @@ -92,6 +89,17 @@ export class ArrayService { } } + // ARGETRANGE does not support reversed ranges (per its command docs), so we + // pre-flight start ≤ end here. ARSCAN does support reversal and uses only + // the span cap. + private assertValidRange(start: string, end: string): void { + this.assertSpanWithinCap(start, end); + + if (BigInt(start) > BigInt(end)) { + throw new BadRequestException(ERROR_MESSAGES.ARRAY_RANGE_REVERSED); + } + } + public async getRange( clientMetadata: ClientMetadata, dto: GetArrayRangeDto, @@ -172,7 +180,7 @@ export class ArrayService { this.logger.debug('Scanning array range.', clientMetadata); const { keyName, start, end, limit } = dto; - this.assertValidRange(start, end); + this.assertSpanWithinCap(start, end); const client = await this.databaseClientFactory.getOrCreateClient(clientMetadata); diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts index 429be1776a..6beaaf248a 100644 --- a/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts @@ -17,7 +17,7 @@ export class GetArrayScanDto extends KeyDto { @ApiProperty({ description: 'End index of the range (inclusive). Unsigned 64-bit integer as string. ' + - 'Must be greater than or equal to start.', + 'When start > end, pairs are returned in reverse index order.', type: String, example: '99', }) From 79b21819e3bd07644887f9264a88c38359415e9b Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Wed, 17 Jun 2026 09:20:26 +0300 Subject: [PATCH 20/23] fix(api): allow reversed ranges for ARGETRANGE (RI-8219) Empirical probe against Redis 8.8.0 confirms ARGETRANGE returns elements in reverse index order when start > end (matching ARSCAN and the /data-types/arrays page). Drop the pre-flight reversal rejection so both endpoints share a single span-cap validator and forward start/end to Redis unchanged. --- .../api/src/constants/error-messages.ts | 1 - .../modules/browser/array/array.controller.ts | 4 +-- .../browser/array/array.service.spec.ts | 30 ++++++++++++++----- .../modules/browser/array/array.service.ts | 15 ++-------- .../browser/array/dto/get.array-range.dto.ts | 2 +- 5 files changed, 27 insertions(+), 25 deletions(-) diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index fabfbfb3da..913ea949c5 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -80,7 +80,6 @@ export default { INCORRECT_CLUSTER_CURSOR_FORMAT: 'Incorrect cluster cursor format.', ARRAY_RANGE_TOO_LARGE: (max: number) => `Requested range exceeds the maximum of ${numberWithSpaces(max)} elements per call. Narrow the range and try again.`, - ARRAY_RANGE_REVERSED: 'Start index must be less than or equal to end index.', REMOVING_MULTIPLE_ELEMENTS_NOT_SUPPORT: () => 'Removing multiple elements is available for Redis databases v. 6.2 or later.', SCAN_PER_KEY_TYPE_NOT_SUPPORT: () => diff --git a/redisinsight/api/src/modules/browser/array/array.controller.ts b/redisinsight/api/src/modules/browser/array/array.controller.ts index edea131d47..a425b24e85 100644 --- a/redisinsight/api/src/modules/browser/array/array.controller.ts +++ b/redisinsight/api/src/modules/browser/array/array.controller.ts @@ -58,8 +58,8 @@ export class ArrayController extends BrowserBaseController { @ApiOperation({ description: 'Read a range of elements from the array stored at key (ARGETRANGE). ' + - 'Empty slots are returned as null. The range is inclusive and ' + - 'requires start ≤ end; a reversed range is rejected with 400.', + 'Empty slots are returned as null. The range is inclusive; passing ' + + 'start > end returns elements in reverse index order.', }) @ApiRedisParams() @ApiOkResponse({ type: GetArrayRangeResponse }) diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index c50175b452..dc3f167494 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -212,14 +212,28 @@ describe('ArrayService', () => { ).rejects.toThrow(BadRequestException); }); - it('should reject reversed ranges (start > end)', async () => { - await expect( - service.getRange(mockBrowserClientMetadata, { - ...mockGetArrayRangeDto, - start: '5', - end: '0', - }), - ).rejects.toThrow(BadRequestException); + it('should forward reversed ranges (start > end) to Redis as-is', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArGetRange, + mockGetArrayRangeDto.keyName, + '5', + '0', + ]) + .mockResolvedValue(mockArrayRangeWithGaps); + + await service.getRange(mockBrowserClientMetadata, { + ...mockGetArrayRangeDto, + start: '5', + end: '0', + }); + + expect(mockStandaloneRedisClient.sendCommand).toHaveBeenCalledWith([ + BrowserToolArrayCommands.ArGetRange, + mockGetArrayRangeDto.keyName, + '5', + '0', + ]); }); it('should rethrow BadRequest on WrongType', async () => { diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 7eb372e128..6471dd1b4d 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -76,7 +76,7 @@ export class ArrayService { } } - private assertSpanWithinCap(start: string, end: string): void { + private assertValidRange(start: string, end: string): void { const startBig = BigInt(start); const endBig = BigInt(end); const span = @@ -89,17 +89,6 @@ export class ArrayService { } } - // ARGETRANGE does not support reversed ranges (per its command docs), so we - // pre-flight start ≤ end here. ARSCAN does support reversal and uses only - // the span cap. - private assertValidRange(start: string, end: string): void { - this.assertSpanWithinCap(start, end); - - if (BigInt(start) > BigInt(end)) { - throw new BadRequestException(ERROR_MESSAGES.ARRAY_RANGE_REVERSED); - } - } - public async getRange( clientMetadata: ClientMetadata, dto: GetArrayRangeDto, @@ -180,7 +169,7 @@ export class ArrayService { this.logger.debug('Scanning array range.', clientMetadata); const { keyName, start, end, limit } = dto; - this.assertSpanWithinCap(start, end); + this.assertValidRange(start, end); const client = await this.databaseClientFactory.getOrCreateClient(clientMetadata); diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts index 8fd8b8b4c7..e46dbf1b9d 100644 --- a/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-range.dto.ts @@ -15,7 +15,7 @@ export class GetArrayRangeDto extends KeyDto { @ApiProperty({ description: 'End index of the range (inclusive). Unsigned 64-bit integer as string. ' + - 'Must be greater than or equal to start.', + 'If end < start, elements are returned in reverse index order.', type: String, example: '99', }) From 2ff59390ddc3425a123fc14b612ff8fc94902cd8 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Wed, 17 Jun 2026 10:15:46 +0300 Subject: [PATCH 21/23] address issues --- .../browser/array/dto/get.array-element.response.ts | 3 ++- .../array/dto/get.array-multi-elements.response.ts | 10 +++++----- .../browser/array/dto/get.array-range.response.ts | 10 +++++----- .../browser/array/dto/get.array-scan.response.ts | 7 ++----- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-element.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-element.response.ts index b0ee18d229..2f7fff5457 100644 --- a/redisinsight/api/src/modules/browser/array/dto/get.array-element.response.ts +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-element.response.ts @@ -1,13 +1,14 @@ import { ApiProperty } from '@nestjs/swagger'; import { KeyResponse } from 'src/modules/browser/keys/dto'; import { RedisStringType } from 'src/common/decorators'; +import { REDIS_STRING_SCHEMA } from 'src/common/decorators/redis-string-schema.decorator'; import { RedisString } from 'src/common/constants'; export class GetArrayElementResponse extends KeyResponse { @ApiProperty({ description: 'Value stored at the requested index, or null if the slot is empty.', - type: String, + ...REDIS_STRING_SCHEMA, nullable: true, }) @RedisStringType() diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts index f8c24e432d..b93f142e6b 100644 --- a/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-multi-elements.response.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { KeyResponse } from 'src/modules/browser/keys/dto'; import { RedisStringType } from 'src/common/decorators'; +import { REDIS_STRING_SCHEMA } from 'src/common/decorators/redis-string-schema.decorator'; import { RedisString } from 'src/common/constants'; export class GetArrayMultiElementsResponse extends KeyResponse { @@ -9,11 +10,10 @@ export class GetArrayMultiElementsResponse extends KeyResponse { 'Values for each requested index, in request order. ' + 'Empty slots are returned as null.', type: 'array', - // OAS 3.0: `type: 'null'` is not a valid schema type and `nullable: true` - // on the array would mark the array itself as nullable. Putting - // `nullable: true` on the item schema is the OAS 3.0 way to express - // `(string | null)[]` so the generated client types items correctly. - items: { type: 'string', nullable: true }, + // Each item mirrors the project-wide RedisString schema (string | Buffer + // object under ?encoding=buffer); `nullable: true` on the item is the + // OAS 3.0 way to express `(string | null)[]`. + items: { oneOf: REDIS_STRING_SCHEMA.oneOf, nullable: true }, }) @RedisStringType({ each: true }) elements: (RedisString | null)[]; diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts index 3fbe2f141b..5cb57154d0 100644 --- a/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-range.response.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { KeyResponse } from 'src/modules/browser/keys/dto'; import { RedisStringType } from 'src/common/decorators'; +import { REDIS_STRING_SCHEMA } from 'src/common/decorators/redis-string-schema.decorator'; import { RedisString } from 'src/common/constants'; export class GetArrayRangeResponse extends KeyResponse { @@ -9,11 +10,10 @@ export class GetArrayRangeResponse extends KeyResponse { 'Values for each index in the requested range, in order. ' + 'Empty slots are returned as null.', type: 'array', - // OAS 3.0: `type: 'null'` is not a valid schema type and `nullable: true` - // on the array would mark the array itself as nullable. Putting - // `nullable: true` on the item schema is the OAS 3.0 way to express - // `(string | null)[]` so the generated client types items correctly. - items: { type: 'string', nullable: true }, + // Each item mirrors the project-wide RedisString schema (string | Buffer + // object under ?encoding=buffer); `nullable: true` on the item is the + // OAS 3.0 way to express `(string | null)[]`. + items: { oneOf: REDIS_STRING_SCHEMA.oneOf, nullable: true }, }) @RedisStringType({ each: true }) elements: (RedisString | null)[]; diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-scan.response.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.response.ts index 5f3117dc8c..30dccd8529 100644 --- a/redisinsight/api/src/modules/browser/array/dto/get.array-scan.response.ts +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.response.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { KeyResponse } from 'src/modules/browser/keys/dto'; -import { RedisStringType } from 'src/common/decorators'; +import { ApiRedisString, RedisStringType } from 'src/common/decorators'; import { RedisString } from 'src/common/constants'; import { Type } from 'class-transformer'; @@ -12,10 +12,7 @@ export class ArrayElement { }) index: string; - @ApiProperty({ - description: 'Value stored at this index.', - type: String, - }) + @ApiRedisString('Value stored at this index.') @RedisStringType() value: RedisString; } From fd4df8dbda8d34aef90904d42a2c47a831f60014 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Wed, 17 Jun 2026 12:35:33 +0300 Subject: [PATCH 22/23] fix(api): handle nested ARSCAN reply shape (RI-8219) Redis 8.8 returns ARSCAN results as [[index, value], ...] nested entries, while some earlier builds surface a flat [index, value, ...] reply. Detect the shape by sniffing the first element and normalize both into the same populated-only contract. --- .../browser/array/array.service.spec.ts | 40 +++++++++++++++++++ .../modules/browser/array/array.service.ts | 35 +++++++++++----- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index dc3f167494..f86cb547be 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -293,6 +293,46 @@ describe('ArrayService', () => { expect(result).toEqual(mockGetArrayScanResponse); }); + it('should pair nested [[index, value], ...] reply (Redis 8.8 shape)', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ]) + .mockResolvedValue([ + [Buffer.from('0'), mockArrayElement1], + [Buffer.from('1'), Buffer.from('20.4')], + ]); + const result = await service.scan( + mockBrowserClientMetadata, + mockGetArrayScanDto, + ); + expect(result).toEqual(mockGetArrayScanResponse); + }); + + it('should drop nested entries with a nil half', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ]) + .mockResolvedValue([ + [Buffer.from('0'), mockArrayElement1], + [Buffer.from('1'), null], + [Buffer.from('2')], + ]); + const result = await service.scan( + mockBrowserClientMetadata, + mockGetArrayScanDto, + ); + expect(result.elements).toHaveLength(1); + expect(result.elements[0].index).toBe('0'); + }); + it('should append LIMIT when provided', async () => { when(mockStandaloneRedisClient.sendCommand) .calledWith([ diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 6471dd1b4d..71d8ccae95 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -187,16 +187,33 @@ export class ArrayService { hasLimit ? [...baseArgs, 'LIMIT', limit] : [...baseArgs], )) as unknown[]; - // Skip pairs with a nil index or value (populated-only contract). + // ARSCAN wire shape varies by Redis version / client: Redis 8.8 + // returns nested [[index, value], ...] entries, while some earlier + // builds surface a flat [index, value, index, value, ...] reply. + // Detect by sniffing the first element and normalize both. Pairs + // with a nil half are dropped (populated-only contract). const elements: ArrayElement[] = []; - for (let i = 0; i < reply.length; i += 2) { - const rawIndex = reply[i]; - const value = reply[i + 1]; - if (rawIndex == null || value == null) continue; - elements.push({ - index: toRequiredIndexString(rawIndex), - value: value as Buffer | string, - }); + if (Array.isArray(reply[0])) { + for (const entry of reply as unknown[][]) { + if (!entry || entry.length < 2) continue; + const rawIndex = entry[0]; + const value = entry[1]; + if (rawIndex == null || value == null) continue; + elements.push({ + index: toRequiredIndexString(rawIndex), + value: value as Buffer | string, + }); + } + } else { + for (let i = 0; i < reply.length; i += 2) { + const rawIndex = reply[i]; + const value = reply[i + 1]; + if (rawIndex == null || value == null) continue; + elements.push({ + index: toRequiredIndexString(rawIndex), + value: value as Buffer | string, + }); + } } this.logger.debug('Succeed to scan array range.', clientMetadata); From d3cdb3f24a74bdb51c6ac703a135a18759c6ef21 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Wed, 17 Jun 2026 12:53:29 +0300 Subject: [PATCH 23/23] address comments --- .../modules/browser/array/array.service.spec.ts | 14 +++++--------- .../api/src/modules/browser/array/array.service.ts | 6 ++++-- .../api/src/modules/browser/array/constants.ts | 6 ++++-- .../browser/array/dto/get.array-scan.dto.ts | 8 ++++++-- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index f86cb547be..026b4f93de 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -400,15 +400,11 @@ describe('ArrayService', () => { ).rejects.toThrow(NotFoundException); }); - it('should reject when range exceeds the 1M cap', async () => { - await expect( - service.scan(mockBrowserClientMetadata, { - ...mockGetArrayScanDto, - start: '0', - end: String(ARRAY_RANGE_MAX_ELEMENTS), - }), - ).rejects.toThrow(BadRequestException); - }); + // No span cap on scan: ARSCAN skips empty slots server-side and the + // sparse-array use case routinely spans far more indexes than it + // returns. The DTO caps `limit` at ARRAY_RANGE_MAX_ELEMENTS to keep + // result-set size bounded — exercised via DTO validation tests, not + // here (the service trusts the DTO). it('should forward reversed ranges (start > end) to Redis as-is', async () => { when(mockStandaloneRedisClient.sendCommand) diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 71d8ccae95..ec8ec0e001 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -169,8 +169,10 @@ export class ArrayService { this.logger.debug('Scanning array range.', clientMetadata); const { keyName, start, end, limit } = dto; - this.assertValidRange(start, end); - + // No |end - start| span cap here (unlike ARGETRANGE): ARSCAN skips + // empty slots server-side and the sparse-array use case routinely + // spans far more indexes than it returns. LIMIT (capped on the DTO) + // is the natural backpressure on result-set size. const client = await this.databaseClientFactory.getOrCreateClient(clientMetadata); await checkIfKeyNotExists(keyName, client); diff --git a/redisinsight/api/src/modules/browser/array/constants.ts b/redisinsight/api/src/modules/browser/array/constants.ts index c2eb2598ca..112b3cce99 100644 --- a/redisinsight/api/src/modules/browser/array/constants.ts +++ b/redisinsight/api/src/modules/browser/array/constants.ts @@ -1,4 +1,6 @@ -// Hard cap on the |end - start| + 1 span for ARGETRANGE and ARSCAN, mirrored -// from the server-side ARGETRANGE limit so callers get a clear 400. +// Hard cap on result-set size shared by ARGETRANGE (applied as |end - start| +// + 1 span, since the reply is dense), ARMGET (applied as @ArrayMaxSize on +// the indexes list), and ARSCAN (applied as @Max on the optional LIMIT). +// Mirrored from the server-side ARGETRANGE limit so callers get a clear 400. // https://redis.io/docs/latest/develop/data-types/arrays/#limits export const ARRAY_RANGE_MAX_ELEMENTS = 1_000_000; diff --git a/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts index 6beaaf248a..39acde8be2 100644 --- a/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts +++ b/redisinsight/api/src/modules/browser/array/dto/get.array-scan.dto.ts @@ -1,8 +1,9 @@ import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger'; import { KeyDto } from 'src/modules/browser/keys/dto'; import { IsArrayIndex } from 'src/common/decorators'; -import { IsInt, IsOptional, Min } from 'class-validator'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; import { Type } from 'class-transformer'; +import { ARRAY_RANGE_MAX_ELEMENTS } from 'src/modules/browser/array/constants'; export class GetArrayScanDto extends KeyDto { @ApiProperty({ @@ -26,14 +27,17 @@ export class GetArrayScanDto extends KeyDto { @ApiPropertyOptional({ description: - 'Maximum number of populated elements to return. ' + + 'Maximum number of populated elements to return (1..' + + `${ARRAY_RANGE_MAX_ELEMENTS.toLocaleString('en-US')}). ` + 'Maps to the ARSCAN LIMIT option.', type: Number, minimum: 1, + maximum: ARRAY_RANGE_MAX_ELEMENTS, }) @IsOptional() @IsInt() @Min(1) + @Max(ARRAY_RANGE_MAX_ELEMENTS) @Type(() => Number) limit?: number; }