From 9903747381448e11449d4fb5c755116258f0eeea Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 10 Jul 2026 13:50:16 +0800 Subject: [PATCH] fix(redis): preserve large JSON numbers --- .../src/components/redis/RedisJsonTree.vue | 5 +- .../common/__tests__/safeJsonFormat.spec.ts | 14 +- apps/desktop/src/lib/common/safeJsonFormat.ts | 138 ++++++++++++++---- .../src/lib/redis/redisValuePresentation.ts | 5 +- .../app-tests/redisValuePresentation.test.ts | 10 ++ 5 files changed, 137 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/components/redis/RedisJsonTree.vue b/apps/desktop/src/components/redis/RedisJsonTree.vue index 0aceaf7489..d8e4d47997 100644 --- a/apps/desktop/src/components/redis/RedisJsonTree.vue +++ b/apps/desktop/src/components/redis/RedisJsonTree.vue @@ -2,6 +2,7 @@ import { computed, defineComponent, h, ref, type VNodeChild } from "vue"; import { ChevronDown, ChevronRight } from "@lucide/vue"; import { Button } from "@/components/ui/button"; +import { isLosslessJsonNumber } from "@/lib/common/safeJsonFormat"; defineOptions({ name: "RedisJsonTree" }); @@ -38,7 +39,7 @@ const rootNode = computed(() => ({ })); function isContainer(value: unknown): value is Record | unknown[] { - return value !== null && typeof value === "object"; + return value !== null && typeof value === "object" && !isLosslessJsonNumber(value); } function containerKind(value: unknown): "array" | "object" { @@ -74,6 +75,7 @@ function nodeSummary(value: unknown): string { } function scalarClass(value: unknown): string { + if (isLosslessJsonNumber(value)) return "json-tree-number"; if (typeof value === "string") return "json-tree-string"; if (typeof value === "number") return "json-tree-number"; if (typeof value === "boolean") return "json-tree-boolean"; @@ -82,6 +84,7 @@ function scalarClass(value: unknown): string { } function scalarText(value: unknown): string { + if (isLosslessJsonNumber(value)) return value.raw; if (typeof value === "string") return JSON.stringify(value); if (value === null) return "null"; return String(value); diff --git a/apps/desktop/src/lib/common/__tests__/safeJsonFormat.spec.ts b/apps/desktop/src/lib/common/__tests__/safeJsonFormat.spec.ts index 9b78fc867a..c78e9cdc72 100644 --- a/apps/desktop/src/lib/common/__tests__/safeJsonFormat.spec.ts +++ b/apps/desktop/src/lib/common/__tests__/safeJsonFormat.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { safeJsonFormat } from "../safeJsonFormat"; +import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers, safeJsonFormat } from "../safeJsonFormat"; describe("safeJsonFormat", () => { it("preserves large integers exceeding MAX_SAFE_INTEGER", () => { @@ -61,4 +61,16 @@ describe("safeJsonFormat", () => { const result = safeJsonFormat(input, 2); expect(result).toBe('{\n "id": "87712409002717401"\n}'); }); + + it("does not replace large digit sequences inside strings", () => { + const input = '{"text":"value 87712409002717401, unchanged"}'; + expect(safeJsonFormat(input, 2)).toBe('{\n "text": "value 87712409002717401, unchanged"\n}'); + }); + + it("parses large integers as lossless numeric values", () => { + const parsed = parseJsonPreservingLargeNumbers('{"companyId":518400931654815740,"safe":42}') as Record; + expect(isLosslessJsonNumber(parsed.companyId)).toBe(true); + expect(isLosslessJsonNumber(parsed.companyId) ? parsed.companyId.raw : null).toBe("518400931654815740"); + expect(parsed.safe).toBe(42); + }); }); diff --git a/apps/desktop/src/lib/common/safeJsonFormat.ts b/apps/desktop/src/lib/common/safeJsonFormat.ts index d43f1449d1..977c2165e1 100644 --- a/apps/desktop/src/lib/common/safeJsonFormat.ts +++ b/apps/desktop/src/lib/common/safeJsonFormat.ts @@ -1,40 +1,116 @@ +const LOSSLESS_JSON_NUMBER = Symbol("DBX lossless JSON number"); + +export interface LosslessJsonNumber { + readonly [LOSSLESS_JSON_NUMBER]: true; + readonly raw: string; +} + +interface ProtectedJsonNumbers { + text: string; + numbers: Map; +} + +const MAX_SAFE_INTEGER_TEXT = String(Number.MAX_SAFE_INTEGER); + +export function isLosslessJsonNumber(value: unknown): value is LosslessJsonNumber { + return typeof value === "object" && value !== null && (value as Partial)[LOSSLESS_JSON_NUMBER] === true && typeof (value as Partial).raw === "string"; +} + /** - * Parse and re-stringify JSON while preserving integer values that exceed - * Number.MAX_SAFE_INTEGER (2^53 - 1). JavaScript's JSON.parse loses - * precision on such values (e.g. 87712409002717401 → 87712409002717400), - * so we replace large integer literals with string placeholders before - * parsing and restore them after stringifying. + * Parses JSON while retaining numeric literals that JavaScript cannot safely + * represent as numbers. Callers can render these values without adding quotes. + */ +export function parseJsonPreservingLargeNumbers(text: string): unknown { + const protectedJson = protectLargeJsonNumbers(text); + const parsed = JSON.parse(protectedJson.text); + return restoreLosslessNumbers(parsed, protectedJson.numbers); +} + +/** + * Parse and re-stringify JSON while preserving numeric literals whose integer + * parts exceed Number.MAX_SAFE_INTEGER (2^53 - 1). */ export function safeJsonFormat(text: string, indent?: number): string { - const MAX_SAFE_INT = String(Number.MAX_SAFE_INTEGER); - const MIN_SAFE_INT = String(-Number.MAX_SAFE_INTEGER); - const largeInts = new Map(); - const placeholderPrefix = '"__DBX_BIGINT_'; - - // Replace large numeric literals (integers or floats with large integer parts) - // with string placeholders to avoid precision loss. - // Matches: optional minus, 16+ digits, optional decimal part, followed by - // a JSON delimiter (comma, ], }, or end-of-string after whitespace). - const replaced = text.replace(/(-?\d{16,}(?:\.\d+)?)(\s*(?:,|\]|}|$))/g, (_match, digits: string, tail: string) => { - // Extract the integer part (before any decimal point) for comparison - const integerPart = digits.split(".")[0]; - const isNegative = integerPart.startsWith("-"); - const absInteger = isNegative ? integerPart.slice(1) : integerPart; - const threshold = isNegative ? MIN_SAFE_INT.slice(1) : MAX_SAFE_INT; - if (absInteger.length < threshold.length) return _match; - if (absInteger.length === threshold.length && absInteger <= threshold) return _match; - - const key = String(largeInts.size); - largeInts.set(key, digits); - return `${placeholderPrefix}${key}"${tail}`; - }); - - const parsed = JSON.parse(replaced); + const protectedJson = protectLargeJsonNumbers(text); + const parsed = JSON.parse(protectedJson.text); let result = JSON.stringify(parsed, null, indent ?? undefined); - for (const [key, digits] of largeInts) { - result = result.replace(`${placeholderPrefix}${key}"`, digits); + for (const [placeholder, raw] of protectedJson.numbers) { + result = result.replaceAll(JSON.stringify(placeholder), raw); } return result; } + +function protectLargeJsonNumbers(text: string): ProtectedJsonNumbers { + let placeholderPrefix = "__DBX_LOSSLESS_NUMBER_"; + while (text.includes(placeholderPrefix)) placeholderPrefix += "_"; + + const numbers = new Map(); + let output = ""; + let index = 0; + + while (index < text.length) { + const character = text[index]; + if (character === '"') { + const stringEnd = findJsonStringEnd(text, index); + output += text.slice(index, stringEnd); + index = stringEnd; + continue; + } + + const numberMatch = text.slice(index).match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/); + if (numberMatch) { + const raw = numberMatch[0]; + if (hasUnsafeIntegerPart(raw)) { + // A quoted placeholder lets the native parser validate the rest of the JSON. + const placeholder = `${placeholderPrefix}${numbers.size}__`; + numbers.set(placeholder, raw); + output += JSON.stringify(placeholder); + } else { + output += raw; + } + index += raw.length; + continue; + } + + output += character; + index += 1; + } + + return { text: output, numbers }; +} + +function findJsonStringEnd(text: string, start: number): number { + let escaped = false; + for (let index = start + 1; index < text.length; index += 1) { + const character = text[index]; + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === '"') { + return index + 1; + } + } + return text.length; +} + +function hasUnsafeIntegerPart(raw: string): boolean { + const unsigned = raw.startsWith("-") ? raw.slice(1) : raw; + const integerPart = unsigned.split(/[.eE]/, 1)[0]; + const normalized = integerPart.replace(/^0+(?=\d)/, ""); + return normalized.length > MAX_SAFE_INTEGER_TEXT.length || (normalized.length === MAX_SAFE_INTEGER_TEXT.length && normalized > MAX_SAFE_INTEGER_TEXT); +} + +function restoreLosslessNumbers(value: unknown, numbers: Map): unknown { + if (typeof value === "string") { + const raw = numbers.get(value); + return raw === undefined ? value : ({ [LOSSLESS_JSON_NUMBER]: true, raw } satisfies LosslessJsonNumber); + } + if (Array.isArray(value)) return value.map((item) => restoreLosslessNumbers(item, numbers)); + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, restoreLosslessNumbers(item, numbers)])); + } + return value; +} diff --git a/apps/desktop/src/lib/redis/redisValuePresentation.ts b/apps/desktop/src/lib/redis/redisValuePresentation.ts index c7554dcc4e..1d5bfd75e9 100644 --- a/apps/desktop/src/lib/redis/redisValuePresentation.ts +++ b/apps/desktop/src/lib/redis/redisValuePresentation.ts @@ -1,5 +1,6 @@ import type { BinaryHexViewRow } from "@/lib/dataGrid/binaryHexViewer"; import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer"; +import { parseJsonPreservingLargeNumbers, safeJsonFormat } from "@/lib/common/safeJsonFormat"; import type { RedisBlob, RedisCollectionPage, RedisHashItem, RedisListItem, RedisSetItem, RedisValue, RedisZsetItem } from "@/lib/backend/api"; import { parseJavaSerializedDetail, type RedisJavaSerializedDetail } from "@/lib/redis/javaSerialized"; @@ -188,10 +189,10 @@ export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null { if (!trimmed) return null; try { - const parsed = JSON.parse(trimmed); + const parsed = parseJsonPreservingLargeNumbers(trimmed); return { rawText: value, - formattedText: JSON.stringify(parsed, null, 2), + formattedText: safeJsonFormat(trimmed, 2), value: parsed, }; } catch { diff --git a/packages/app-tests/redisValuePresentation.test.ts b/packages/app-tests/redisValuePresentation.test.ts index f04dbec31c..4b065698e7 100644 --- a/packages/app-tests/redisValuePresentation.test.ts +++ b/packages/app-tests/redisValuePresentation.test.ts @@ -1,5 +1,6 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; +import { isLosslessJsonNumber } from "../../apps/desktop/src/lib/common/safeJsonFormat.ts"; import { canRenderRedisValueFormat, canEditRedisMemberDetail, @@ -56,6 +57,15 @@ test("parses Redis JSON details for any valid JSON payload", () => { assert.equal(parseRedisJsonDetail("plain redis value"), null); }); +test("preserves large Redis JSON integers in formatted and tree values", () => { + const detail = parseRedisJsonDetail('{"companyId":518400931654815740,"nested":[-9007199254740992]}'); + + assert.equal(detail?.formattedText, '{\n "companyId": 518400931654815740,\n "nested": [\n -9007199254740992\n ]\n}'); + const value = detail?.value as { companyId?: unknown; nested?: unknown[] }; + assert.equal(isLosslessJsonNumber(value.companyId) ? value.companyId.raw : null, "518400931654815740"); + assert.equal(isLosslessJsonNumber(value.nested?.[0]) ? value.nested[0].raw : null, "-9007199254740992"); +}); + test("string/blob formats stay text-oriented and binary-first where needed", () => { const jsonLikeTextDetail = formatRedisMemberDetail(blobFromText('{"name":"Ada"}')); assert.equal(jsonLikeTextDetail.rawLabel, "ASCII");