diff --git a/redisinsight/api/.tscheck.rec.json b/redisinsight/api/.tscheck.rec.json index 15f2669ce5..10407eb224 100644 --- a/redisinsight/api/.tscheck.rec.json +++ b/redisinsight/api/.tscheck.rec.json @@ -84,15 +84,15 @@ "TS7053": 3 }, "src/common/transformers/redis-reply/strategies/ascii-formatter.strategey.spec.ts": { - "TS7005": 8, - "TS7034": 2 + "TS7005": 1, + "TS7034": 1 }, "src/common/transformers/redis-reply/strategies/ascii-formatter.strategy.ts": { "TS7053": 2 }, "src/common/transformers/redis-reply/strategies/utf8-formatter.strategy.spec.ts": { - "TS7005": 9, - "TS7034": 2 + "TS7005": 1, + "TS7034": 1 }, "src/common/transformers/redis-reply/strategies/utf8-formatter.strategy.ts": { "TS7053": 2 @@ -497,15 +497,15 @@ "TS7053": 3 }, "src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.spec.ts": { - "TS7005": 8, - "TS7034": 2 + "TS7005": 1, + "TS7034": 1 }, "src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.ts": { "TS7053": 2 }, "src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.spec.ts": { - "TS7005": 10, - "TS7034": 2 + "TS7005": 1, + "TS7034": 1 }, "src/modules/cli/services/cli-business/output-formatter/strategies/utf-8-formatter.strategy.ts": { "TS7053": 2 diff --git a/redisinsight/api/patches/ioredis+5.3.2.patch b/redisinsight/api/patches/ioredis+5.3.2.patch new file mode 100644 index 0000000000..9a9f3e40e9 --- /dev/null +++ b/redisinsight/api/patches/ioredis+5.3.2.patch @@ -0,0 +1,107 @@ +diff --git a/node_modules/ioredis/built/Command.d.ts b/node_modules/ioredis/built/Command.d.ts +index ea90aa2..ca28897 100644 +--- a/node_modules/ioredis/built/Command.d.ts ++++ b/node_modules/ioredis/built/Command.d.ts +@@ -6,6 +6,7 @@ interface CommandOptions { + * Set the encoding of the reply, by default buffer will be returned. + */ + replyEncoding?: BufferEncoding | null; ++ integerReply?: "number" | "bigint"; + errorStack?: Error; + keyPrefix?: string; + /** +@@ -75,6 +76,7 @@ export default class Command implements Respondable { + resolve: (result: any) => void; + promise: Promise; + private replyEncoding; ++ integerReply?: "number" | "bigint"; + private errorStack; + private bufferMode; + private callback; +diff --git a/node_modules/ioredis/built/Command.js b/node_modules/ioredis/built/Command.js +index 40cd1a6..aad90af 100644 +--- a/node_modules/ioredis/built/Command.js ++++ b/node_modules/ioredis/built/Command.js +@@ -39,6 +39,7 @@ class Command { + this.isResolved = false; + this.transformed = false; + this.replyEncoding = options.replyEncoding; ++ this.integerReply = options.integerReply; + this.errorStack = options.errorStack; + this.args = args.flat(); + this.callback = callback; +diff --git a/node_modules/ioredis/built/DataHandler.d.ts b/node_modules/ioredis/built/DataHandler.d.ts +index 93e97d9..24fe877 100644 +--- a/node_modules/ioredis/built/DataHandler.d.ts ++++ b/node_modules/ioredis/built/DataHandler.d.ts +@@ -26,7 +26,11 @@ interface ParserOptions { + } + export default class DataHandler { + private redis; ++ private parser; ++ private stringNumbers; + constructor(redis: DataHandledable, parserOptions: ParserOptions); ++ private updateIntegerReplyMode; ++ private convertIntegerReply; + private returnFatalError; + private returnError; + private returnReply; +diff --git a/node_modules/ioredis/built/DataHandler.js b/node_modules/ioredis/built/DataHandler.js +index 1db1848..f985ed5 100644 +--- a/node_modules/ioredis/built/DataHandler.js ++++ b/node_modules/ioredis/built/DataHandler.js +@@ -8,6 +8,7 @@ const debug = (0, utils_1.Debug)("dataHandler"); + class DataHandler { + constructor(redis, parserOptions) { + this.redis = redis; ++ this.stringNumbers = parserOptions.stringNumbers; + const parser = new RedisParser({ + stringNumbers: parserOptions.stringNumbers, + returnBuffers: true, +@@ -21,10 +22,27 @@ class DataHandler { + this.returnReply(reply); + }, + }); ++ this.parser = parser; + redis.stream.on("data", (data) => { ++ this.updateIntegerReplyMode(); + parser.execute(data); + }); + } ++ updateIntegerReplyMode() { ++ var _a, _b; ++ const item = (_a = this.redis.commandQueue) === null || _a === void 0 ? void 0 : _a.peekFront(); ++ const integerReply = (_b = item === null || item === void 0 ? void 0 : item.command) === null || _b === void 0 ? void 0 : _b.integerReply; ++ this.parser.setStringNumbers(this.stringNumbers || integerReply === "bigint"); ++ } ++ convertIntegerReply(reply) { ++ if (typeof reply === "string") { ++ return BigInt(reply); ++ } ++ if (Array.isArray(reply)) { ++ return reply.map((item) => this.convertIntegerReply(item)); ++ } ++ return reply; ++ } + returnFatalError(err) { + err.message += ". Please report this."; + this.redis.recoverFromFatalError(err, err, { offlineQueue: false }); +@@ -34,6 +52,7 @@ class DataHandler { + if (!item) { + return; + } ++ this.updateIntegerReplyMode(); + err.command = { + name: item.command.name, + args: item.command.args, +@@ -51,6 +70,10 @@ class DataHandler { + if (!item) { + return; + } ++ this.updateIntegerReplyMode(); ++ if (item.command.integerReply === "bigint") { ++ reply = this.convertIntegerReply(reply); ++ } + if (Command_1.default.checkFlag("ENTER_SUBSCRIBER_MODE", item.command.name)) { + this.redis.condition.subscriber = new SubscriptionSet_1.default(); + this.redis.condition.subscriber.add(item.command.name, reply[1].toString()); diff --git a/redisinsight/api/src/common/transformers/redis-reply/strategies/ascii-formatter.strategey.spec.ts b/redisinsight/api/src/common/transformers/redis-reply/strategies/ascii-formatter.strategey.spec.ts index 52a565e57b..65cdaad95d 100644 --- a/redisinsight/api/src/common/transformers/redis-reply/strategies/ascii-formatter.strategey.spec.ts +++ b/redisinsight/api/src/common/transformers/redis-reply/strategies/ascii-formatter.strategey.spec.ts @@ -1,7 +1,7 @@ import { ASCIIFormatterStrategy } from './ascii-formatter.strategy'; describe('ASCIIFormatterStrategy', () => { - let strategy; + let strategy: ASCIIFormatterStrategy; beforeEach(async () => { strategy = new ASCIIFormatterStrategy(); }); @@ -28,6 +28,24 @@ describe('ASCIIFormatterStrategy', () => { expect(output).toEqual('string value'); }); + it('should tag a bigint reply as an integer type', () => { + const output = strategy.format(BigInt('9007199254740994')); + + expect(output).toEqual({ type: 'integer', value: '9007199254740994' }); + }); + it('should tag bigint leaves nested in an array', () => { + const input = [ + BigInt('0'), + [BigInt('9007199254740994'), Buffer.from('value')], + ]; + const mockResponse = [ + { type: 'integer', value: '0' }, + [{ type: 'integer', value: '9007199254740994' }, 'value'], + ]; + const output = strategy.format(input); + + expect(output).toEqual(mockResponse); + }); it('should return correct value for empty array', () => { const input = []; diff --git a/redisinsight/api/src/common/transformers/redis-reply/strategies/ascii-formatter.strategy.ts b/redisinsight/api/src/common/transformers/redis-reply/strategies/ascii-formatter.strategy.ts index 4504eb3d97..dc6240ecc0 100644 --- a/redisinsight/api/src/common/transformers/redis-reply/strategies/ascii-formatter.strategy.ts +++ b/redisinsight/api/src/common/transformers/redis-reply/strategies/ascii-formatter.strategy.ts @@ -4,6 +4,10 @@ import { IFormatterStrategy } from '../formatter.interface'; export class ASCIIFormatterStrategy implements IFormatterStrategy { public format(reply: any): any { + if (typeof reply === 'bigint') { + // Tag u64 integers so the UI prints `(integer) N`, not a quoted string. + return { type: 'integer', value: reply.toString() }; + } if (reply instanceof Buffer) { return getASCIISafeStringFromBuffer(reply); } diff --git a/redisinsight/api/src/common/transformers/redis-reply/strategies/utf8-formatter.strategy.spec.ts b/redisinsight/api/src/common/transformers/redis-reply/strategies/utf8-formatter.strategy.spec.ts index 8d86e56c7d..d183c5fb05 100644 --- a/redisinsight/api/src/common/transformers/redis-reply/strategies/utf8-formatter.strategy.spec.ts +++ b/redisinsight/api/src/common/transformers/redis-reply/strategies/utf8-formatter.strategy.spec.ts @@ -1,7 +1,7 @@ import { UTF8FormatterStrategy } from './utf8-formatter.strategy'; describe('UTF8FormatterStrategy', () => { - let strategy; + let strategy: UTF8FormatterStrategy; beforeEach(async () => { strategy = new UTF8FormatterStrategy(); }); @@ -28,6 +28,24 @@ describe('UTF8FormatterStrategy', () => { expect(output).toEqual('string value'); }); + it('should tag a bigint reply as an integer type', () => { + const output = strategy.format(BigInt('9007199254740994')); + + expect(output).toEqual({ type: 'integer', value: '9007199254740994' }); + }); + it('should tag bigint leaves nested in an array', () => { + const input = [ + BigInt('0'), + [BigInt('9007199254740994'), Buffer.from('value')], + ]; + const mockResponse = [ + { type: 'integer', value: '0' }, + [{ type: 'integer', value: '9007199254740994' }, 'value'], + ]; + const output = strategy.format(input); + + expect(output).toEqual(mockResponse); + }); it('should return correct value for empty array', () => { const input = []; diff --git a/redisinsight/api/src/common/transformers/redis-reply/strategies/utf8-formatter.strategy.ts b/redisinsight/api/src/common/transformers/redis-reply/strategies/utf8-formatter.strategy.ts index 016f419d29..9e86ae522a 100644 --- a/redisinsight/api/src/common/transformers/redis-reply/strategies/utf8-formatter.strategy.ts +++ b/redisinsight/api/src/common/transformers/redis-reply/strategies/utf8-formatter.strategy.ts @@ -4,6 +4,10 @@ import { IFormatterStrategy } from '../formatter.interface'; export class UTF8FormatterStrategy implements IFormatterStrategy { public format(reply: any): any { + if (typeof reply === 'bigint') { + // Tag u64 integers so the UI prints `(integer) N`, not a quoted string. + return { type: 'integer', value: reply.toString() }; + } if (reply instanceof Buffer) { return getUTF8FromBuffer(reply); } 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 74ad19bc1e..626452f383 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -289,12 +289,15 @@ describe('ArrayService', () => { beforeEach(() => { when(mockStandaloneRedisClient.sendCommand) - .calledWith([ - BrowserToolArrayCommands.ArScan, - mockGetArrayScanDto.keyName, - mockGetArrayScanDto.start, - mockGetArrayScanDto.end, - ]) + .calledWith( + [ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ], + { integerReply: 'bigint' }, + ) .mockResolvedValue(flatReply); }); @@ -308,12 +311,15 @@ describe('ArrayService', () => { it('should pair nested [[index, value], ...] reply (Redis 8.8 shape)', async () => { when(mockStandaloneRedisClient.sendCommand) - .calledWith([ - BrowserToolArrayCommands.ArScan, - mockGetArrayScanDto.keyName, - mockGetArrayScanDto.start, - mockGetArrayScanDto.end, - ]) + .calledWith( + [ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ], + { integerReply: 'bigint' }, + ) .mockResolvedValue([ [Buffer.from('0'), mockArrayElement1], [Buffer.from('1'), Buffer.from('20.4')], @@ -327,12 +333,15 @@ describe('ArrayService', () => { it('should drop nested entries with a nil half', async () => { when(mockStandaloneRedisClient.sendCommand) - .calledWith([ - BrowserToolArrayCommands.ArScan, - mockGetArrayScanDto.keyName, - mockGetArrayScanDto.start, - mockGetArrayScanDto.end, - ]) + .calledWith( + [ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ], + { integerReply: 'bigint' }, + ) .mockResolvedValue([ [Buffer.from('0'), mockArrayElement1], [Buffer.from('1'), null], @@ -348,14 +357,17 @@ describe('ArrayService', () => { it('should append LIMIT when provided', async () => { when(mockStandaloneRedisClient.sendCommand) - .calledWith([ - BrowserToolArrayCommands.ArScan, - mockGetArrayScanDto.keyName, - mockGetArrayScanDto.start, - mockGetArrayScanDto.end, - 'LIMIT', - 50, - ]) + .calledWith( + [ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + 'LIMIT', + 50, + ], + { integerReply: 'bigint' }, + ) .mockResolvedValue([Buffer.from('0'), mockArrayElement1]); const result = await service.scan(mockBrowserClientMetadata, { @@ -371,23 +383,29 @@ describe('ArrayService', () => { limit: null as unknown as number, }); - expect(mockStandaloneRedisClient.sendCommand).toHaveBeenCalledWith([ - BrowserToolArrayCommands.ArScan, - mockGetArrayScanDto.keyName, - mockGetArrayScanDto.start, - mockGetArrayScanDto.end, - ]); + expect(mockStandaloneRedisClient.sendCommand).toHaveBeenCalledWith( + [ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ], + { integerReply: 'bigint' }, + ); expect(result).toEqual(mockGetArrayScanResponse); }); it('should drop pairs whose value or index is null/undefined', async () => { when(mockStandaloneRedisClient.sendCommand) - .calledWith([ - BrowserToolArrayCommands.ArScan, - mockGetArrayScanDto.keyName, - mockGetArrayScanDto.start, - mockGetArrayScanDto.end, - ]) + .calledWith( + [ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + mockGetArrayScanDto.start, + mockGetArrayScanDto.end, + ], + { integerReply: 'bigint' }, + ) .mockResolvedValue([ Buffer.from('0'), mockArrayElement1, @@ -421,12 +439,15 @@ describe('ArrayService', () => { it('should forward reversed ranges (start > end) to Redis as-is', async () => { when(mockStandaloneRedisClient.sendCommand) - .calledWith([ - BrowserToolArrayCommands.ArScan, - mockGetArrayScanDto.keyName, - '5', - '0', - ]) + .calledWith( + [ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + '5', + '0', + ], + { integerReply: 'bigint' }, + ) .mockResolvedValue(flatReply); await service.scan(mockBrowserClientMetadata, { @@ -435,12 +456,15 @@ describe('ArrayService', () => { end: '0', }); - expect(mockStandaloneRedisClient.sendCommand).toHaveBeenCalledWith([ - BrowserToolArrayCommands.ArScan, - mockGetArrayScanDto.keyName, - '5', - '0', - ]); + expect(mockStandaloneRedisClient.sendCommand).toHaveBeenCalledWith( + [ + BrowserToolArrayCommands.ArScan, + mockGetArrayScanDto.keyName, + '5', + '0', + ], + { integerReply: 'bigint' }, + ); }); it('should rethrow BadRequest on WrongType', async () => { @@ -449,7 +473,10 @@ describe('ArrayService', () => { command: 'ARSCAN', }; when(mockStandaloneRedisClient.sendCommand) - .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ArScan])) + .calledWith( + expect.arrayContaining([BrowserToolArrayCommands.ArScan]), + expect.anything(), + ) .mockRejectedValue(replyError); await expect( service.scan(mockBrowserClientMetadata, mockGetArrayScanDto), @@ -499,7 +526,7 @@ describe('ArrayService', () => { ])('$name', ({ command, reply, expected, call }) => { beforeEach(() => { when(mockStandaloneRedisClient.sendCommand) - .calledWith([command, mockKeyDto.keyName]) + .calledWith([command, mockKeyDto.keyName], { integerReply: 'bigint' }) .mockResolvedValue(reply); }); @@ -521,7 +548,7 @@ describe('ArrayService', () => { command: command.toUpperCase(), }; when(mockStandaloneRedisClient.sendCommand) - .calledWith([command, mockKeyDto.keyName]) + .calledWith([command, mockKeyDto.keyName], { integerReply: 'bigint' }) .mockRejectedValue(replyError); await expect(call(service)).rejects.toThrow(BadRequestException); }); @@ -539,7 +566,9 @@ describe('ArrayService', () => { describe('getNextIndex (exhausted)', () => { it('should surface null index when ARNEXT returns nil', async () => { when(mockStandaloneRedisClient.sendCommand) - .calledWith([BrowserToolArrayCommands.ArNext, mockKeyDto.keyName]) + .calledWith([BrowserToolArrayCommands.ArNext, mockKeyDto.keyName], { + integerReply: 'bigint', + }) .mockResolvedValue(null); const result = await service.getNextIndex( @@ -683,7 +712,10 @@ describe('ArrayService', () => { it('runs ARGREP with WITHVALUES by default and parses index/value pairs', async () => { when(client.sendCommand) - .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ArGrep])) + .calledWith( + expect.arrayContaining([BrowserToolArrayCommands.ArGrep]), + expect.anything(), + ) .mockResolvedValue(mockArraySearchReplyWithValues); const result = await service.search( @@ -692,15 +724,18 @@ describe('ArrayService', () => { ); expect(result).toEqual(mockGetArraySearchResponse); - expect(client.sendCommand).toHaveBeenCalledWith([ - BrowserToolArrayCommands.ArGrep, - mockGetArraySearchDto.keyName, - '-', - '+', - 'MATCH', - '21.4', - 'WITHVALUES', - ]); + expect(client.sendCommand).toHaveBeenCalledWith( + [ + BrowserToolArrayCommands.ArGrep, + mockGetArraySearchDto.keyName, + '-', + '+', + 'MATCH', + '21.4', + 'WITHVALUES', + ], + { integerReply: 'bigint' }, + ); }); it('appends the global connective only with 2+ predicates', async () => { @@ -715,18 +750,21 @@ describe('ArrayService', () => { combinator: ArrayCombinator.Or, }); - expect(client.sendCommand).toHaveBeenCalledWith([ - BrowserToolArrayCommands.ArGrep, - mockGetArraySearchDto.keyName, - '-', - '+', - 'GLOB', - '21.*', - 'EXACT', - '99', - 'OR', - 'WITHVALUES', - ]); + expect(client.sendCommand).toHaveBeenCalledWith( + [ + BrowserToolArrayCommands.ArGrep, + mockGetArraySearchDto.keyName, + '-', + '+', + 'GLOB', + '21.*', + 'EXACT', + '99', + 'OR', + 'WITHVALUES', + ], + { integerReply: 'bigint' }, + ); }); it('sends no connective when omitted so the server applies its default', async () => { @@ -740,22 +778,28 @@ describe('ArrayService', () => { ], }); - expect(client.sendCommand).toHaveBeenCalledWith([ - BrowserToolArrayCommands.ArGrep, - mockGetArraySearchDto.keyName, - '-', - '+', - 'MATCH', - 'a', - 'MATCH', - 'b', - 'WITHVALUES', - ]); + expect(client.sendCommand).toHaveBeenCalledWith( + [ + BrowserToolArrayCommands.ArGrep, + mockGetArraySearchDto.keyName, + '-', + '+', + 'MATCH', + 'a', + 'MATCH', + 'b', + 'WITHVALUES', + ], + { integerReply: 'bigint' }, + ); }); it('parses the nested [[index, value], ...] reply shape (Redis 8.8)', async () => { when(client.sendCommand) - .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ArGrep])) + .calledWith( + expect.arrayContaining([BrowserToolArrayCommands.ArGrep]), + expect.anything(), + ) .mockResolvedValue([ ['5', '21.4'], ['6', '21.9'], @@ -778,15 +822,18 @@ describe('ArrayService', () => { withValues: null as unknown as boolean, }); - expect(client.sendCommand).toHaveBeenCalledWith([ - BrowserToolArrayCommands.ArGrep, - mockGetArraySearchDto.keyName, - '-', - '+', - 'MATCH', - 'x', - 'WITHVALUES', - ]); + expect(client.sendCommand).toHaveBeenCalledWith( + [ + BrowserToolArrayCommands.ArGrep, + mockGetArraySearchDto.keyName, + '-', + '+', + 'MATCH', + 'x', + 'WITHVALUES', + ], + { integerReply: 'bigint' }, + ); }); it('passes range, NOCASE and LIMIT and omits WITHVALUES when withValues=false', async () => { @@ -804,17 +851,20 @@ describe('ArrayService', () => { limit: 50, }); - expect(client.sendCommand).toHaveBeenCalledWith([ - BrowserToolArrayCommands.ArGrep, - mockGetArraySearchDto.keyName, - '10', - '20', - 'MATCH', - 'x', - 'NOCASE', - 'LIMIT', - 50, - ]); + expect(client.sendCommand).toHaveBeenCalledWith( + [ + BrowserToolArrayCommands.ArGrep, + mockGetArraySearchDto.keyName, + '10', + '20', + 'MATCH', + 'x', + 'NOCASE', + 'LIMIT', + 50, + ], + { integerReply: 'bigint' }, + ); expect(result.elements).toEqual([ { index: '5', value: null }, { index: '6', value: null }, @@ -1086,13 +1136,16 @@ describe('ArrayService', () => { ]) .mockResolvedValue(1); when(mockStandaloneRedisClient.sendCommand) - .calledWith([ - BrowserToolArrayCommands.ArOp, - mockAggregateArrayDto.keyName, - mockAggregateArrayDto.start, - mockAggregateArrayDto.end, - mockAggregateArrayDto.operation, - ]) + .calledWith( + [ + BrowserToolArrayCommands.ArOp, + mockAggregateArrayDto.keyName, + mockAggregateArrayDto.start, + mockAggregateArrayDto.end, + mockAggregateArrayDto.operation, + ], + { integerReply: 'bigint' }, + ) .mockResolvedValue(Buffer.from(mockArrayAggregateSumResult)); }); @@ -1111,14 +1164,17 @@ describe('ArrayService', () => { value: '20.4', }; when(mockStandaloneRedisClient.sendCommand) - .calledWith([ - BrowserToolArrayCommands.ArOp, - dto.keyName, - dto.start, - dto.end, - dto.operation, - dto.value, - ]) + .calledWith( + [ + BrowserToolArrayCommands.ArOp, + dto.keyName, + dto.start, + dto.end, + dto.operation, + dto.value, + ], + { integerReply: 'bigint' }, + ) .mockResolvedValue(1); const result = await service.aggregate(mockBrowserClientMetadata, dto); @@ -1131,24 +1187,30 @@ describe('ArrayService', () => { // value intentionally set — should be ignored when operation !== MATCH. value: 'ignored', }); - expect(mockStandaloneRedisClient.sendCommand).toHaveBeenCalledWith([ - BrowserToolArrayCommands.ArOp, - mockAggregateArrayDto.keyName, - mockAggregateArrayDto.start, - mockAggregateArrayDto.end, - mockAggregateArrayDto.operation, - ]); - }); - - it('should normalize integer replies (USED) to a string', async () => { - when(mockStandaloneRedisClient.sendCommand) - .calledWith([ + expect(mockStandaloneRedisClient.sendCommand).toHaveBeenCalledWith( + [ BrowserToolArrayCommands.ArOp, mockAggregateArrayDto.keyName, mockAggregateArrayDto.start, mockAggregateArrayDto.end, - ArrayAggregateOperation.Used, - ]) + mockAggregateArrayDto.operation, + ], + { integerReply: 'bigint' }, + ); + }); + + it('should normalize integer replies (USED) to a string', async () => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith( + [ + BrowserToolArrayCommands.ArOp, + mockAggregateArrayDto.keyName, + mockAggregateArrayDto.start, + mockAggregateArrayDto.end, + ArrayAggregateOperation.Used, + ], + { integerReply: 'bigint' }, + ) .mockResolvedValue(5); const result = await service.aggregate(mockBrowserClientMetadata, { @@ -1162,13 +1224,16 @@ describe('ArrayService', () => { // SUM over an empty/non-numeric range returns nil; the API surfaces // that as `result: null` instead of throwing a 500. when(mockStandaloneRedisClient.sendCommand) - .calledWith([ - BrowserToolArrayCommands.ArOp, - mockAggregateArrayDto.keyName, - mockAggregateArrayDto.start, - mockAggregateArrayDto.end, - mockAggregateArrayDto.operation, - ]) + .calledWith( + [ + BrowserToolArrayCommands.ArOp, + mockAggregateArrayDto.keyName, + mockAggregateArrayDto.start, + mockAggregateArrayDto.end, + mockAggregateArrayDto.operation, + ], + { integerReply: 'bigint' }, + ) .mockResolvedValue(null); const result = await service.aggregate( @@ -1194,14 +1259,17 @@ describe('ArrayService', () => { value, }; when(mockStandaloneRedisClient.sendCommand) - .calledWith([ - BrowserToolArrayCommands.ArOp, - dto.keyName, - dto.start, - dto.end, - dto.operation, - dto.value, - ]) + .calledWith( + [ + BrowserToolArrayCommands.ArOp, + dto.keyName, + dto.start, + dto.end, + dto.operation, + dto.value, + ], + { integerReply: 'bigint' }, + ) .mockResolvedValue(2); const result = await service.aggregate(mockBrowserClientMetadata, dto); @@ -1248,7 +1316,9 @@ describe('ArrayService', () => { command: 'AROP', }; when(mockStandaloneRedisClient.sendCommand) - .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ArOp])) + .calledWith(expect.arrayContaining([BrowserToolArrayCommands.ArOp]), { + integerReply: 'bigint', + }) .mockRejectedValue(replyError); await expect( service.aggregate(mockBrowserClientMetadata, mockAggregateArrayDto), diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 109158aa3d..bf99165bea 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -198,6 +198,7 @@ export class ArrayService { const hasLimit = typeof limit === 'number'; const reply = (await client.sendCommand( hasLimit ? [...baseArgs, 'LIMIT', limit] : [...baseArgs], + { integerReply: 'bigint' }, )) as unknown[]; // ARSCAN wire shape varies by Redis version / client: Redis 8.8 @@ -277,7 +278,9 @@ export class ArrayService { if (withValues) args.push('WITHVALUES'); if (typeof limit === 'number') args.push('LIMIT', limit); - const reply = (await client.sendCommand(args)) as unknown[]; + const reply = (await client.sendCommand(args, { + integerReply: 'bigint', + })) as unknown[]; // WITHVALUES wire shape varies: Redis 8.8 returns nested // [[index, value], ...] entries; some builds surface a flat @@ -338,10 +341,10 @@ export class ArrayService { await this.databaseClientFactory.getOrCreateClient(clientMetadata); await checkIfKeyNotExists(keyName, client); - const reply = await client.sendCommand([ - BrowserToolArrayCommands.ArLen, - keyName, - ]); + const reply = await client.sendCommand( + [BrowserToolArrayCommands.ArLen, keyName], + { integerReply: 'bigint' }, + ); this.logger.debug('Succeed to get array length.', clientMetadata); return plainToInstance(GetArrayLengthResponse, { @@ -368,10 +371,10 @@ export class ArrayService { await this.databaseClientFactory.getOrCreateClient(clientMetadata); await checkIfKeyNotExists(keyName, client); - const reply = await client.sendCommand([ - BrowserToolArrayCommands.ArCount, - keyName, - ]); + const reply = await client.sendCommand( + [BrowserToolArrayCommands.ArCount, keyName], + { integerReply: 'bigint' }, + ); this.logger.debug('Succeed to get array count.', clientMetadata); return plainToInstance(GetArrayCountResponse, { @@ -398,10 +401,10 @@ export class ArrayService { await this.databaseClientFactory.getOrCreateClient(clientMetadata); await checkIfKeyNotExists(keyName, client); - const reply = await client.sendCommand([ - BrowserToolArrayCommands.ArNext, - keyName, - ]); + const reply = await client.sendCommand( + [BrowserToolArrayCommands.ArNext, keyName], + { integerReply: 'bigint' }, + ); this.logger.debug('Succeed to get array next index.', clientMetadata); return plainToInstance(GetArrayNextIndexResponse, { @@ -487,7 +490,9 @@ export class ArrayService { if (operation === ArrayAggregateOperation.Match) { args.push(value as RedisString); } - const reply = await client.sendCommand(args); + // AND/OR/XOR (and the MATCH/USED counts) come back as RESP integers that + // can exceed 2^53; opt into bigint so they reach toIndexString exact. + const reply = await client.sendCommand(args, { integerReply: 'bigint' }); this.logger.debug('Succeed to aggregate array range.', clientMetadata); // AROP returns nil for numeric ops over a range with no numeric values diff --git a/redisinsight/api/src/modules/browser/array/dto/aggregate.array.response.ts b/redisinsight/api/src/modules/browser/array/dto/aggregate.array.response.ts index 3d860bf27a..75f4007ffa 100644 --- a/redisinsight/api/src/modules/browser/array/dto/aggregate.array.response.ts +++ b/redisinsight/api/src/modules/browser/array/dto/aggregate.array.response.ts @@ -8,12 +8,9 @@ export class AggregateArrayResponse extends KeyResponse { 'decimal string; MATCH/USED return an integer count as a string. ' + 'Returns `null` when AROP yields a nil reply (numeric ops over a ' + 'range with no numeric values; bitwise ops over an empty range). ' + - 'Precision: SUM/MIN/MAX preserve full precision (Redis returns a ' + - 'bulk string); MATCH/USED are bounded by the 1,000,000-element span ' + - 'cap and always fit safely. AND/OR/XOR return RESP integers and are ' + - 'parsed by the Node Redis client as JavaScript numbers, so results ' + - 'above Number.MAX_SAFE_INTEGER (2^53 - 1) may be rounded — use the ' + - 'raw AROP command via Workbench when full u64 precision is required.', + 'Full u64 precision is preserved: SUM/MIN/MAX arrive as bulk strings, ' + + 'and AND/OR/XOR/MATCH/USED integer replies are read with the bigint ' + + 'opt-in, so results above Number.MAX_SAFE_INTEGER (2^53 - 1) stay exact.', type: String, nullable: true, example: '104.7', 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 5ced5f6f4a..80177d57ed 100644 --- a/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts +++ b/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts @@ -128,8 +128,26 @@ export enum BrowserToolArrayCommands { ArGrep = 'argrep', ArDel = 'ardel', ArDelRange = 'ardelrange', + ArInsert = 'arinsert', + ArRing = 'arring', + ArInfo = 'arinfo', } +// Array commands whose integer replies are u64 (indexes, counts, or bitwise +// aggregates) and must stay exact above 2^53 — callers request them with +// { integerReply: 'bigint' }. +export const ARRAY_U64_INTEGER_REPLY_COMMANDS = new Set([ + BrowserToolArrayCommands.ArLen, + BrowserToolArrayCommands.ArCount, + BrowserToolArrayCommands.ArNext, + BrowserToolArrayCommands.ArScan, + BrowserToolArrayCommands.ArGrep, + BrowserToolArrayCommands.ArInsert, + BrowserToolArrayCommands.ArRing, + BrowserToolArrayCommands.ArOp, + BrowserToolArrayCommands.ArInfo, +]); + export type BrowserToolCommands = | BrowserToolKeysCommands | BrowserToolStringCommands 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 2634df5195..ebf1969414 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 @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { when } from 'jest-when'; import { mockStandaloneRedisClient } from 'src/__mocks__'; +import { RedisClientCommandReply } from 'src/modules/redis/client'; import { BrowserToolArrayCommands, BrowserToolKeysCommands, @@ -38,21 +39,34 @@ describe('ArrayKeyInfoStrategy', () => { const rawLength = 10; const rawCount = 7; + const mockCounts = ( + lengthReply: RedisClientCommandReply, + countReply: RedisClientCommandReply, + ) => { + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolArrayCommands.ArLen, key], { + integerReply: 'bigint', + }) + .mockResolvedValueOnce(lengthReply); + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolArrayCommands.ArCount, key], { + integerReply: 'bigint', + }) + .mockResolvedValueOnce(countReply); + }; + describe('when includeSize is true', () => { - it('should return ttl, length, count, and size in single pipeline', async () => { + it('should return ttl, length, count, and size', async () => { when(mockStandaloneRedisClient.sendPipeline) .calledWith([ [BrowserToolKeysCommands.Ttl, key], - [BrowserToolArrayCommands.ArLen, key], - [BrowserToolArrayCommands.ArCount, key], [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], ]) .mockResolvedValueOnce([ [null, ttl], - [null, rawLength], - [null, rawCount], [null, size], ]); + mockCounts(rawLength, rawCount); const result = await strategy.getInfo( mockStandaloneRedisClient, @@ -73,16 +87,13 @@ describe('ArrayKeyInfoStrategy', () => { 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], ]); + mockCounts(hugeLength, hugeCount); const result = await strategy.getInfo( mockStandaloneRedisClient, @@ -100,18 +111,14 @@ describe('ArrayKeyInfoStrategy', () => { }); describe('when includeSize is false', () => { - it('should skip MEMORY USAGE when count exceeds MAX_KEY_SIZE', async () => { + const mockTtl = () => when(mockStandaloneRedisClient.sendPipeline) - .calledWith([ - [BrowserToolKeysCommands.Ttl, key], - [BrowserToolArrayCommands.ArLen, key], - [BrowserToolArrayCommands.ArCount, key], - ]) - .mockResolvedValueOnce([ - [null, ttl], - [null, rawLength], - [null, MAX_KEY_SIZE + 1], - ]); + .calledWith([[BrowserToolKeysCommands.Ttl, key]]) + .mockResolvedValueOnce([[null, ttl]]); + + it('should skip MEMORY USAGE when count exceeds MAX_KEY_SIZE', async () => { + mockTtl(); + mockCounts(rawLength, MAX_KEY_SIZE + 1); const result = await strategy.getInfo( mockStandaloneRedisClient, @@ -128,17 +135,8 @@ describe('ArrayKeyInfoStrategy', () => { }); 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, rawCount], - ]); + mockTtl(); + mockCounts(MAX_KEY_SIZE * 10, rawCount); when(mockStandaloneRedisClient.sendPipeline) .calledWith([ [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], @@ -159,17 +157,8 @@ describe('ArrayKeyInfoStrategy', () => { }); it('should issue MEMORY USAGE separately when count is small', async () => { - when(mockStandaloneRedisClient.sendPipeline) - .calledWith([ - [BrowserToolKeysCommands.Ttl, key], - [BrowserToolArrayCommands.ArLen, key], - [BrowserToolArrayCommands.ArCount, key], - ]) - .mockResolvedValueOnce([ - [null, ttl], - [null, rawLength], - [null, rawCount], - ]); + mockTtl(); + mockCounts(rawLength, rawCount); when(mockStandaloneRedisClient.sendPipeline) .calledWith([ [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], 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 dbd5a152cc..ff837399ef 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 @@ -30,18 +30,25 @@ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { ): Promise { this.logger.debug(`Getting ${RedisDataType.Array} type info.`); + // sendPipeline can't pass per-command options, so it would round u64 + // ARLEN / ARCOUNT replies in (2^53, 2^63); read those per-command with the + // bigint opt-in. TTL / MEMORY USAGE stay pipelined to degrade to size: -1. + const readCounts = () => + Promise.all([ + client.sendCommand([BrowserToolArrayCommands.ArLen, key], { + integerReply: 'bigint', + }), + client.sendCommand([BrowserToolArrayCommands.ArCount, key], { + integerReply: 'bigint', + }), + ]); + if (includeSize !== false) { - const [ - [, ttl = null], - [, rawLength = null], - [, rawCount = null], - [, size = null], - ] = (await client.sendPipeline([ + const [[, ttl = null], [, size = null]] = (await client.sendPipeline([ [BrowserToolKeysCommands.Ttl, key], - [BrowserToolArrayCommands.ArLen, key], - [BrowserToolArrayCommands.ArCount, key], [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], ])) as [any, any][]; + const [rawLength, rawCount] = await readCounts(); return { name: key, @@ -53,12 +60,10 @@ export class ArrayKeyInfoStrategy extends KeyInfoStrategy { }; } - const [[, ttl = null], [, rawLength = null], [, rawCount = null]] = - (await client.sendPipeline([ - [BrowserToolKeysCommands.Ttl, key], - [BrowserToolArrayCommands.ArLen, key], - [BrowserToolArrayCommands.ArCount, key], - ])) as [any, any][]; + const [[, ttl = null]] = (await client.sendPipeline([ + [BrowserToolKeysCommands.Ttl, key], + ])) as [any, any][]; + const [rawLength, rawCount] = await readCounts(); // Sparse arrays can have huge `length` (ARLEN, total addressable slots) // while `count` (ARCOUNT, populated slots) stays small. MEMORY USAGE cost diff --git a/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.spec.ts b/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.spec.ts index adc3bcd957..83bbe5866d 100644 --- a/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.spec.ts +++ b/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.spec.ts @@ -516,6 +516,40 @@ describe('CliBusinessService', () => { ); expect(recommendationService.check).toBeCalledTimes(1); }); + + it('requests AROP with exact u64 integer replies', async () => { + const dto: SendCommandDto = { + command: 'AROP key 0 5 OR', + outputFormat: CliOutputFormatterTypes.Raw, + }; + when(standaloneClient.sendCommand) + .calledWith(['AROP', 'key', '0', '5', 'OR'], expect.anything()) + .mockReturnValue('9007199254740993'); + + await service.sendCommand(mockCliClientMetadata, dto); + + expect(standaloneClient.sendCommand).toHaveBeenCalledWith( + ['AROP', 'key', '0', '5', 'OR'], + expect.objectContaining({ integerReply: 'bigint' }), + ); + }); + + it('requests ARINFO with exact u64 integer replies', async () => { + const dto: SendCommandDto = { + command: 'ARINFO key', + outputFormat: CliOutputFormatterTypes.Raw, + }; + when(standaloneClient.sendCommand) + .calledWith(['ARINFO', 'key'], expect.anything()) + .mockReturnValue(['length', '9007199254740993']); + + await service.sendCommand(mockCliClientMetadata, dto); + + expect(standaloneClient.sendCommand).toHaveBeenCalledWith( + ['ARINFO', 'key'], + expect.objectContaining({ integerReply: 'bigint' }), + ); + }); }); describe('sendClusterCommand', () => { diff --git a/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.ts b/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.ts index b6bcc65932..f1993830ca 100644 --- a/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.ts +++ b/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.ts @@ -38,6 +38,7 @@ import { OutputFormatterManager } from './output-formatter/output-formatter-mana import { CliOutputFormatterTypes } from './output-formatter/output-formatter.interface'; import { TextFormatterStrategy } from './output-formatter/strategies/text-formatter.strategy'; import { RawFormatterStrategy } from './output-formatter/strategies/raw-formatter.strategy'; +import { ARRAY_U64_INTEGER_REPLY_COMMANDS } from 'src/modules/browser/constants/browser-tool-commands'; @Injectable() export class CliBusinessService { @@ -223,8 +224,14 @@ export class CliBusinessService { command, ); + const integerReply = ARRAY_U64_INTEGER_REPLY_COMMANDS.has( + command.toLowerCase(), + ) + ? 'bigint' + : undefined; const reply = await client.sendCommand([command, ...args], { replyEncoding, + integerReply, }); this.cliAnalyticsService.sendCommandExecutedEvent( diff --git a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.spec.ts b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.spec.ts index 8295d2853f..d00706337c 100644 --- a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.spec.ts +++ b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.spec.ts @@ -1,7 +1,7 @@ import { RawFormatterStrategy } from './raw-formatter.strategy'; describe('Cli RawFormatterStrategy', () => { - let strategy; + let strategy: RawFormatterStrategy; beforeEach(async () => { strategy = new RawFormatterStrategy(); }); @@ -28,6 +28,24 @@ describe('Cli RawFormatterStrategy', () => { expect(output).toEqual('string value'); }); + it('should tag a bigint reply as an integer type', () => { + const output = strategy.format(BigInt('9007199254740994')); + + expect(output).toEqual({ type: 'integer', value: '9007199254740994' }); + }); + it('should tag bigint leaves nested in an array', () => { + const input = [ + BigInt('0'), + [BigInt('9007199254740994'), Buffer.from('value')], + ]; + const mockResponse = [ + { type: 'integer', value: '0' }, + [{ type: 'integer', value: '9007199254740994' }, 'value'], + ]; + const output = strategy.format(input); + + expect(output).toEqual(mockResponse); + }); it('should return correct value for empty array', () => { const input = []; diff --git a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.ts b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.ts index 998fffc400..86f6bd7259 100644 --- a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.ts +++ b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.ts @@ -4,6 +4,10 @@ import { IOutputFormatterStrategy } from '../output-formatter.interface'; export class RawFormatterStrategy implements IOutputFormatterStrategy { public format(reply: any): any { + if (typeof reply === 'bigint') { + // Tag u64 integers so the UI prints `(integer) N`, not a quoted string. + return { type: 'integer', value: reply.toString() }; + } if (reply instanceof Buffer) { return getASCIISafeStringFromBuffer(reply); } diff --git a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.spec.ts b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.spec.ts index 11c6a51179..d0f01bdf9d 100644 --- a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.spec.ts +++ b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.spec.ts @@ -1,7 +1,7 @@ import { TextFormatterStrategy } from './text-formatter.strategy'; describe('Cli TextFormatterStrategy', () => { - let strategy; + let strategy: TextFormatterStrategy; beforeEach(async () => { strategy = new TextFormatterStrategy(); }); @@ -21,6 +21,22 @@ describe('Cli TextFormatterStrategy', () => { expect(output).toEqual(`(integer) ${input}`); }); + it('should return correct value for bigint', () => { + const input = BigInt('9007199254740993'); + + const output = strategy.format(input); + + expect(output).toEqual(`(integer) ${input}`); + }); + it('should format bigint leaves nested in an array (ARSCAN/ARGREP)', () => { + // u64 indexes come back as BigInt; the recursive leaf must not reach + // JSON.stringify, which throws on BigInt. + const input = [BigInt('9007199254740993'), Buffer.from('value')]; + + const output = strategy.format(input); + + expect(output).toEqual('1) (integer) 9007199254740993\n2) "value"'); + }); it('should return correct value for string', () => { const input = Buffer.from('string value'); diff --git a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.ts b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.ts index 96f38d96c9..4c35d04d61 100644 --- a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.ts +++ b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.ts @@ -8,7 +8,7 @@ export class TextFormatterStrategy implements IOutputFormatterStrategy { let result; if (isNull(reply)) { result = '(nil)'; - } else if (isInteger(reply)) { + } else if (isInteger(reply) || typeof reply === 'bigint') { result = `(integer) ${reply}`; } else if (reply instanceof Buffer) { result = this.formatRedisBufferReply(reply); @@ -37,11 +37,12 @@ export class TextFormatterStrategy implements IOutputFormatterStrategy { }) .join('\n'); } + } else if (reply instanceof Buffer) { + result = this.formatRedisBufferReply(reply); + } else if (typeof reply === 'bigint') { + result = `(integer) ${reply}`; } else { - result = - reply instanceof Buffer - ? this.formatRedisBufferReply(reply) - : JSON.stringify(reply); + result = JSON.stringify(reply); } return result; } diff --git a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/utf-8-formatter.strategy.ts b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/utf-8-formatter.strategy.ts index 9bb58e6133..d3b7db1aec 100644 --- a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/utf-8-formatter.strategy.ts +++ b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/utf-8-formatter.strategy.ts @@ -4,6 +4,9 @@ import { IOutputFormatterStrategy } from '../output-formatter.interface'; export class UTF8FormatterStrategy implements IOutputFormatterStrategy { public format(reply: any): any { + if (typeof reply === 'bigint') { + return reply.toString(); + } if (reply instanceof Buffer) { return getUTF8FromBuffer(reply); } 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 f8c739a87a..c94ef0f62a 100644 --- a/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts +++ b/redisinsight/api/src/modules/redis/client/ioredis/ioredis.client.ts @@ -70,6 +70,7 @@ export abstract class IoredisClient extends RedisClient { return { replyEncoding, + integerReply: options?.integerReply, }; } diff --git a/redisinsight/api/src/modules/redis/client/redis.client.ts b/redisinsight/api/src/modules/redis/client/redis.client.ts index 5e151d25a8..847af95d33 100644 --- a/redisinsight/api/src/modules/redis/client/redis.client.ts +++ b/redisinsight/api/src/modules/redis/client/redis.client.ts @@ -28,6 +28,9 @@ export interface IRedisClientCommandOptions { firstKey?: RedisString; readOnly?: boolean; replyEncoding?: 'utf8' | null; + // Opt a command's integer replies into exact BigInt (instead of the default + // Number) so u64 values above 2^53 — array indexes/counts — aren't rounded. + integerReply?: 'number' | 'bigint'; unknownCommands?: boolean; } @@ -49,6 +52,7 @@ export type RedisClientCommand = [ export type RedisClientCommandReply = | string | number + | bigint | Buffer | null | undefined diff --git a/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.ts b/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.ts index 404017db47..774adbec9c 100644 --- a/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.ts +++ b/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.ts @@ -20,6 +20,7 @@ import { UTF8FormatterStrategy, } from 'src/common/transformers'; import { RedisClient } from 'src/modules/redis/client'; +import { ARRAY_U64_INTEGER_REPLY_COMMANDS } from 'src/modules/browser/constants/browser-tool-commands'; import { getAnalyticsDataFromIndexInfo } from 'src/utils'; import { RunQueryMode } from 'src/modules/workbench/models/command-execution'; import { WorkbenchAnalytics } from 'src/modules/workbench/workbench.analytics'; @@ -87,8 +88,16 @@ export class WorkbenchCommandsExecutor { ? 'utf8' : undefined; + const integerReply = ARRAY_U64_INTEGER_REPLY_COMMANDS.has( + command.toLowerCase(), + ) + ? 'bigint' + : undefined; const response = formatter.format( - await client.sendCommand([command, ...commandArgs], { replyEncoding }), + await client.sendCommand([command, ...commandArgs], { + replyEncoding, + integerReply, + }), ); const result: CommandExecutionResult[] = [ { response, status: CommandExecutionStatus.Success }, diff --git a/redisinsight/api/test/api/array/POST-databases-id-array-aggregate.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array-aggregate.test.ts index 46c44842ed..deb5c2a37d 100644 --- a/redisinsight/api/test/api/array/POST-databases-id-array-aggregate.test.ts +++ b/redisinsight/api/test/api/array/POST-databases-id-array-aggregate.test.ts @@ -443,6 +443,31 @@ describe('POST /databases/:instanceId/array/aggregate', () => { }); }); + it('Should preserve precision for bitwise results above MAX_SAFE_INTEGER', async () => { + const keyName = constants.getRandomString(); + // OR over a single odd u64 above 2^53 returns that exact value. Unlike + // SUM (a bulk string), AND/OR/XOR come back as RESP integers, so without + // the bigint opt-in the reply rounds to the even 9007199254740992 before + // it reaches the response — the odd value proves the integer path stays + // exact end to end. + await rte.client.call('ARSET', keyName, '0', '9007199254740993'); + + await validateApiCall({ + endpoint, + data: { + keyName, + start: '0', + end: '0', + operation: ArrayAggregateOperation.Or, + }, + responseSchema, + checkFn: ({ body }: any) => { + expect(typeof body.result).to.eql('string'); + expect(body.result).to.eql('9007199254740993'); + }, + }); + }); + [ { name: 'Should return BadRequest if key holds a non-array type', diff --git a/redisinsight/api/test/api/array/POST-databases-id-array-get_length.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array-get_length.test.ts index b64016824a..aeb7613bd3 100644 --- a/redisinsight/api/test/api/array/POST-databases-id-array-get_length.test.ts +++ b/redisinsight/api/test/api/array/POST-databases-id-array-get_length.test.ts @@ -108,6 +108,28 @@ describe('POST /databases/:instanceId/array/get-length', () => { }); }); + it('Should preserve u64 precision in the (2^53, 2^63) RESP-integer zone', async () => { + const keyName = constants.getRandomString(); + // Highest index 2^53, so length is 2^53 + 1 — below 2^63 (so Redis sends + // a RESP integer, not a bulk string) and above 2^53 (a JS number would + // round it). This is the zone the per-command bigint path protects; the + // case above (≥ 2^63) arrives as a bulk string and survives without it. + const highIndex = '9007199254740992'; // 2^53 + const expectedLength = '9007199254740993'; // 2^53 + 1 + await rte.client.call('ARMSET', keyName, highIndex, 'x'); + + await validateApiCall({ + endpoint, + data: { keyName }, + responseSchema, + responseBody: { keyName, length: expectedLength }, + checkFn: ({ body }: any) => { + expect(typeof body.length).to.eql('string'); + expect(body.length).to.eql(expectedLength); + }, + }); + }); + [ { name: 'Should return BadRequest if key holds a non-array type', diff --git a/redisinsight/api/test/api/array/POST-databases-id-array-scan.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array-scan.test.ts index 12ed840dfe..534af664fb 100644 --- a/redisinsight/api/test/api/array/POST-databases-id-array-scan.test.ts +++ b/redisinsight/api/test/api/array/POST-databases-id-array-scan.test.ts @@ -181,6 +181,25 @@ describe('POST /databases/:instanceId/array/scan', () => { }); }); + it('Should keep an index exact in the (2^53, 2^63) RESP-integer zone', async () => { + const keyName = constants.getRandomString(); + // 2^53 + 1: below 2^63 so Redis sends it as a RESP integer (not a bulk + // string), and above 2^53 so a JS number would round it to 2^53. Proves + // the per-command bigint path, which the 2^64-2 case above can't. + const gapIndex = '9007199254740993'; + await rte.client.call('ARSET', keyName, gapIndex, 'needle'); + + await validateApiCall({ + endpoint, + data: { keyName, start: gapIndex, end: gapIndex }, + responseSchema, + responseBody: { + keyName, + elements: [{ index: gapIndex, value: 'needle' }], + }, + }); + }); + it('Should accept explicit limit:null as if it were omitted', async () => { const keyName = constants.getRandomString(); await seedSparse(keyName); diff --git a/redisinsight/api/test/api/array/POST-databases-id-array-search.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array-search.test.ts index 8e195ed182..2f5d6ba075 100644 --- a/redisinsight/api/test/api/array/POST-databases-id-array-search.test.ts +++ b/redisinsight/api/test/api/array/POST-databases-id-array-search.test.ts @@ -46,14 +46,10 @@ describe('POST /databases/:id/array/search', () => { }); }); - it('round-trips a large index (up to 2^53) exactly', async () => { - // ARGREP returns indexes as RESP integers, which ioredis rounds to JS - // number at the transport — so the exact-string proof for values > 2^53 - // lands with the shared transport fix (RI-8296). Here we assert the - // largest exactly-representable index round-trips. + it('keeps a > 2^53 index exact end to end', async () => { const keyName = constants.getRandomString(); - const largeIndex = '9007199254740991'; // 2^53 - 1 (Number.MAX_SAFE_INTEGER) - await rte.client.call('ARMSET', keyName, largeIndex, 'needle'); + const bigIndex = '9007199254740993'; // 2^53 + 1 + await rte.client.call('ARMSET', keyName, bigIndex, 'needle'); await validateApiCall({ endpoint, @@ -64,7 +60,7 @@ describe('POST /databases/:id/array/search', () => { statusCode: 200, responseBody: { keyName, - elements: [{ index: largeIndex, value: 'needle' }], + elements: [{ index: bigIndex, value: 'needle' }], }, }); }); diff --git a/redisinsight/api/test/api/array/POST-databases-id-keys-get_info-array.test.ts b/redisinsight/api/test/api/array/POST-databases-id-keys-get_info-array.test.ts index 2c95a0197a..595f6e9ffb 100644 --- a/redisinsight/api/test/api/array/POST-databases-id-keys-get_info-array.test.ts +++ b/redisinsight/api/test/api/array/POST-databases-id-keys-get_info-array.test.ts @@ -43,11 +43,15 @@ describe('POST /databases/:instanceId/keys/get-info (Array)', () => { const denseKey = constants.getRandomString(); const sparseKey = constants.getRandomString(); + const gapKey = constants.getRandomString(); before(async () => { await rte.client.call('ARSET', denseKey, '0', 'a', 'b', 'c'); // Sparse: indexes 0,5 populated → length=6, count=2. await rte.client.call('ARMSET', sparseKey, '0', 'v0', '5', 'v5'); + // Highest index 2^53 → length 2^53 + 1, inside the (2^53, 2^63) zone + // where Redis sends a RESP integer a JS number would round. + await rte.client.call('ARSET', gapKey, '9007199254740992', 'x'); }); [ @@ -73,5 +77,16 @@ describe('POST /databases/:instanceId/keys/get-info (Array)', () => { count: '2', }, }, + { + name: 'Should keep a u64 length exact in the (2^53, 2^63) RESP-integer zone', + data: { keyName: gapKey }, + responseSchema, + responseBody: { + name: gapKey, + type: 'array', + length: '9007199254740993', + count: '1', + }, + }, ].map(mainCheckFn); }); diff --git a/redisinsight/api/test/api/array/array-resp-integer-encoding.test.ts b/redisinsight/api/test/api/array/array-resp-integer-encoding.test.ts new file mode 100644 index 0000000000..2c12267ab7 --- /dev/null +++ b/redisinsight/api/test/api/array/array-resp-integer-encoding.test.ts @@ -0,0 +1,122 @@ +import * as net from 'net'; +import * as tls from 'tls'; +import { describe, it, deps, requirements, expect } from '../deps'; + +const { constants } = deps; +const rte = deps.rte as any; + +// Encode a command as a RESP array so any argument (colons, etc.) is safe. +const encode = (...args: string[]): string => + args.reduce( + (acc, a) => `${acc}$${Buffer.byteLength(a)}\r\n${a}\r\n`, + `*${args.length}\r\n`, + ); + +// Read exactly one RESP reply (enough for +OK / :int / $bulk / errors) and +// resolve with its raw bytes — so we can inspect the leading type byte. +const readReply = (socket: net.Socket): Promise => + new Promise((resolve, reject) => { + let buf = Buffer.alloc(0); + const done = (fn: () => void) => { + socket.removeListener('data', onData); + socket.removeListener('error', reject); + fn(); + }; + const onData = (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + const type = String.fromCharCode(buf[0]); + const headerEnd = buf.indexOf('\r\n'); + if (headerEnd === -1) return; // wait for the first line + + if (type === '+' || type === '-' || type === ':') { + return done(() => resolve(buf)); + } + if (type === '$') { + const len = parseInt(buf.slice(1, headerEnd).toString(), 10); + if (len < 0 || buf.length >= headerEnd + 2 + len + 2) { + return done(() => resolve(buf)); + } + return; // wait for the rest of the bulk payload + } + return done(() => reject(new Error(`Unexpected reply type "${type}"`))); + }; + socket.on('data', onData); + socket.on('error', reject); + }); + +describe('Array u64 integer reply — RESP wire encoding', () => { + // Array commands are Redis 8.8 preview; the raw single-socket probe below + // can't address a cluster, so this runs only on a standalone 8.8 server. + requirements('rte.version>=8.8', '!rte.type=CLUSTER'); + + // 2^53 = 9007199254740992 (highest exactly-representable index); + // length becomes 2^53 + 1 = 9007199254740993 — a u64 inside i64 range, so + // Redis encodes it as a RESP integer (`:`), which ioredis would round. + const gapMaxIndex = '9007199254740992'; + const gapLength = '9007199254740993'; + // 2^63 + 10: a u64 beyond i64 range, so Redis must encode the length as a + // bulk string (`$`) — exact on the wire without any client opt-in. + const aboveI64MaxIndex = '9223372036854775818'; + + it('encodes (2^53, 2^63) lengths as RESP integers and >= 2^63 as bulk strings', async () => { + const connect = (): Promise => + new Promise((resolve, reject) => { + const onReady = () => resolve(socket); + const socket: net.Socket = rte.env.tls + ? tls.connect( + { + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + ca: constants.TEST_REDIS_TLS_CA, + cert: rte.env.tlsAuth + ? constants.TEST_USER_TLS_CERT + : undefined, + key: rte.env.tlsAuth ? constants.TEST_USER_TLS_KEY : undefined, + rejectUnauthorized: false, + }, + onReady, + ) + : net.connect( + { + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + }, + onReady, + ); + socket.once('error', reject); + }); + + const socket = await connect(); + const gapKey = constants.getRandomString(); + const bigKey = constants.getRandomString(); + try { + if (constants.TEST_REDIS_PASSWORD) { + const authArgs = constants.TEST_REDIS_USER + ? [constants.TEST_REDIS_USER, constants.TEST_REDIS_PASSWORD] + : [constants.TEST_REDIS_PASSWORD]; + socket.write(encode('AUTH', ...authArgs)); + await readReply(socket); + } + + socket.write(encode('ARMSET', gapKey, gapMaxIndex, 'x')); + await readReply(socket); + socket.write(encode('ARMSET', bigKey, aboveI64MaxIndex, 'x')); + await readReply(socket); + + socket.write(encode('ARLEN', gapKey)); + const gapReply = await readReply(socket); + socket.write(encode('ARLEN', bigKey)); + const bigReply = await readReply(socket); + + socket.write(encode('DEL', gapKey, bigKey)); + await readReply(socket); + + // The contract this whole feature rests on, asserted from the wire: + expect(String.fromCharCode(gapReply[0])).to.eql(':'); // RESP integer + expect(gapReply.toString().trim()).to.eql(`:${gapLength}`); // exact on the wire + expect(String.fromCharCode(bigReply[0])).to.eql('$'); // RESP bulk string + } finally { + socket.destroy(); + } + }); +}); diff --git a/redisinsight/api/test/test-runs/test.Dockerfile b/redisinsight/api/test/test-runs/test.Dockerfile index 29105577ca..68e8466d5e 100644 --- a/redisinsight/api/test/test-runs/test.Dockerfile +++ b/redisinsight/api/test/test-runs/test.Dockerfile @@ -6,6 +6,9 @@ RUN dbus-uuidgen > /var/lib/dbus/machine-id WORKDIR /usr/src/app COPY package.json yarn.lock ./ +# patch-package (postinstall) reads these, so they must exist before install — +# otherwise patches silently don't apply (e.g. the ioredis bigint parser). +COPY patches ./patches COPY stubs ./stubs COPY scripts ./scripts # Skip API client generation during install: integration tests don't need the diff --git a/redisinsight/ui/src/utils/transformers/cliTextFormatter.spec.ts b/redisinsight/ui/src/utils/transformers/cliTextFormatter.spec.ts new file mode 100644 index 0000000000..2d74c059bd --- /dev/null +++ b/redisinsight/ui/src/utils/transformers/cliTextFormatter.spec.ts @@ -0,0 +1,33 @@ +import formatToText from 'uiSrc/utils/transformers/cliTextFormatter' + +describe('formatToText', () => { + it('renders nil', () => { + expect(formatToText(null)).toEqual('(nil)') + }) + + it('renders a JS integer as (integer)', () => { + expect(formatToText(5)).toEqual('(integer) 5') + }) + + it('renders a tagged u64 integer reply as (integer)', () => { + expect( + formatToText({ type: 'integer', value: '9007199254740994' }), + ).toEqual('(integer) 9007199254740994') + }) + + it('renders tagged integer leaves nested in an array (ARSCAN)', () => { + const reply = [{ type: 'integer', value: '9007199254740994' }, 'valuetest'] + + expect(formatToText(reply)).toEqual( + '1) (integer) 9007199254740994\n2) "valuetest"', + ) + }) + + it('still quotes plain string replies', () => { + expect(formatToText('hello')).toEqual('"hello"') + }) + + it('still flattens a plain object reply', () => { + expect(formatToText({ field: 'value' })).toEqual('1) "field"\n2) "value"') + }) +}) diff --git a/redisinsight/ui/src/utils/transformers/cliTextFormatter.ts b/redisinsight/ui/src/utils/transformers/cliTextFormatter.ts index 78ab2efbb6..cbbfe1b676 100644 --- a/redisinsight/ui/src/utils/transformers/cliTextFormatter.ts +++ b/redisinsight/ui/src/utils/transformers/cliTextFormatter.ts @@ -1,12 +1,24 @@ import { flattenDeep, isArray, isInteger, isNull, isObject } from 'lodash' import { bulkReplyCommands } from 'uiSrc/constants' +// u64 integer replies arrive tagged from the API formatters; render them as +// `(integer) N` rather than a quoted string. +const isIntegerReply = ( + reply: any, +): reply is { type: 'integer'; value: string } => + !!reply && + typeof reply === 'object' && + reply.type === 'integer' && + typeof reply.value === 'string' + const formatToText = (reply: any, command: string = ''): string => { let result if (isNull(reply)) { result = '(nil)' } else if (isInteger(reply)) { result = `(integer) ${reply}` + } else if (isIntegerReply(reply)) { + result = `(integer) ${reply.value}` } else if (isArray(reply)) { result = formatRedisArrayReply(reply) } else if (isObject(reply)) { @@ -40,6 +52,8 @@ const formatRedisArrayReply = (reply: any | any[], level = 0): string => { }) .join('\n') } + } else if (isIntegerReply(reply)) { + result = `(integer) ${reply.value}` } else { result = `"${reply}"` }