feat(redis): auto-format detected JSON values

This commit is contained in:
弈秋
2026-10-03 00:21:59 +08:00
committed by GitHub
parent 06918c06c8
commit 83651c46d6
15 changed files with 343 additions and 13 deletions
@@ -28,14 +28,16 @@ import { copyToClipboard } from "@/lib/common/clipboard";
import { formatTtl } from "@/lib/common/ttlFormat";
import { computeDisplayTtl, computeTtlCountdownTick, computeTtlCountdownValue, computeTtlForExpiryEdit, DEFAULT_REDIS_AUTO_REFRESH_INTERVAL_SECONDS, normalizeRedisAutoRefreshInterval } from "@/lib/redis/redisAutoRefresh";
import {
autoRedisValueFormat,
canRenderRedisValueFormat,
canEditRedisMemberDetail,
decodeRedisBlob,
detectedRedisStructuredCodec,
formatRedisMemberDetail,
getRedisMemberSelectionKey,
isRedisBlob,
isRedisJsonContainerValue,
parseRedisJsonDetail,
preferredRedisValueFormat,
REDIS_VALUE_FORMAT_DISPLAY_ORDER,
redisBlobText,
redisCollectionPageItems,
@@ -57,6 +59,7 @@ import {
REDIS_VALUE_CODEC_ORDER,
type RedisCollectionItem,
type RedisHashRowCopyTarget,
type RedisMemberDetail,
type RedisValueCodec,
type RedisValueFormat,
} from "@/lib/redis/redisValuePresentation";
@@ -193,10 +196,10 @@ const isResizingHashColumns = ref(false);
const zsetTableRef = ref<HTMLElement | null>(null);
const zsetScoreWidth = ref(220);
const isResizingZsetColumns = ref(false);
const stringValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
const stringValueCodec = ref<RedisValueCodec>(readPreferredRedisValueCodec());
const memberValueCodec = ref<RedisValueCodec>(readPreferredRedisValueCodec());
const stringValueView = ref<RedisValueFormat>(readStoredRedisValueFormat() ?? "utf8");
const memberValueView = ref<RedisValueFormat>(readStoredRedisValueFormat() ?? "utf8");
const stringValueCodec = ref<RedisValueCodec>(readStoredRedisValueCodec() ?? "none");
const memberValueCodec = ref<RedisValueCodec>(readStoredRedisValueCodec() ?? "none");
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
const redisJsonUnicodeMode = ref(readRedisJsonUnicodeMode());
const redisJsonDecoded = computed(() => redisJsonUnicodeMode.value === "decoded");
@@ -479,6 +482,40 @@ watch([memberValueCodec, selectedMemberRaw], ([codec]) => {
const stringGzipBadge = computed(() => (stringBlob.value ? isGzipMagic(decodeRedisBlob(stringBlob.value)) : false));
const memberGzipBadge = computed(() => (isRedisBlob(selectedMemberRaw.value) ? isGzipMagic(decodeRedisBlob(selectedMemberRaw.value)) : false));
/**
* Badge label for the detected payload representation (issue #10922): JSON
* containers and deterministic binary formats the user would otherwise see
* as an opaque blob. Plain strings and scalar JSON get no badge.
*/
function redisDetectionBadgeLabel(detail: RedisMemberDetail | null): string | null {
if (!detail) return null;
if (detail.javaSerialized) return t("redis.detectedJavaSerialized");
if (detail.pickle) return t("redis.detectedPickle");
if (detail.msgpack) return t("redis.detectedMsgpack");
if (detail.phpSerialized) return t("redis.detectedPhpSerialized");
if (isRedisJsonContainerValue(detail.json?.value)) return "JSON";
return null;
}
const stringValueDetectionLabel = computed(() => redisDetectionBadgeLabel(stringValueDetail.value));
const memberDetectionLabel = computed(() => redisDetectionBadgeLabel(selectedMemberDetail.value));
/**
* Issue #10922: payloads with a deterministic container format open decoded
* instead of as an opaque blob — but only while the user has not pinned a
* codec; any explicit codec choice (including "none") disables detection.
*/
function applyDetectedStringCodec(detail: RedisMemberDetail) {
if (isStringValueTruncated.value) return;
if (readStoredRedisValueCodec() != null) return;
stringValueCodec.value = detectedRedisStructuredCodec(detail) ?? "none";
}
function applyDetectedMemberCodec(detail: RedisMemberDetail) {
if (readStoredRedisValueCodec() != null) return;
memberValueCodec.value = detectedRedisStructuredCodec(detail) ?? "none";
}
interface RedisDecodedDetail {
formattedText: string;
value: unknown;
@@ -1275,22 +1312,27 @@ function rememberRedisJsonUnicodeMode(mode: RedisJsonUnicodeMode) {
}
}
function readPreferredRedisValueFormat(): RedisValueFormat {
/**
* The persisted format preference, or null when the user never pinned one.
* Null is the auto-detection signal for issue #10922: detected JSON opens in
* the JSON view instead of raw UTF-8. Legacy values ("raw") map like before.
*/
function readStoredRedisValueFormat(): RedisValueFormat | null {
try {
const stored = localStorage.getItem(REDIS_VALUE_FORMAT_STORAGE_KEY);
if (stored === "raw") return "utf8";
return stored === "utf8" || stored === "ascii" || stored === "binary" || stored === "json" || stored === "hex" || stored === "base64" ? stored : "utf8";
return stored === "utf8" || stored === "ascii" || stored === "binary" || stored === "json" || stored === "hex" || stored === "base64" ? stored : null;
} catch {
return "utf8";
return null;
}
}
function readPreferredRedisValueCodec(): RedisValueCodec {
function readStoredRedisValueCodec(): RedisValueCodec | null {
try {
const stored = localStorage.getItem(REDIS_VALUE_CODEC_STORAGE_KEY);
return isRedisValueCodec(stored) ? stored : "none";
return isRedisValueCodec(stored) ? stored : null;
} catch {
return "none";
return null;
}
}
@@ -1634,7 +1676,13 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
const allowJsonText = loadedValue.data.kind === "string";
if (loadedValue.data.truncated) stringValueCodec.value = "none";
const detail = formatRedisMemberDetail(loadedValue.data.content, { allowJsonText });
stringValueView.value = preferredRedisValueFormat(loadedValue.data.content, readPreferredRedisValueFormat(), { allowJsonText });
// Issue #10922: detected JSON opens pretty-printed, detected structured
// codecs open decoded — unless the user pinned an explicit format/codec.
// Bitmap payloads render through the string pipeline but are never JSON
// or a structured codec, so detection only runs for plain strings.
stringValueView.value = autoRedisValueFormat(detail, readStoredRedisValueFormat());
if (allowJsonText) applyDetectedStringCodec(detail);
stringJsonRawBaseline.value = detail.json?.formattedText ?? "";
stringJsonDraftBaseline.value = jsonDraftBaseline(stringJsonRawBaseline.value, redisJsonDecoded.value);
editValue.value = stringValueView.value === "json" && detail.json ? stringJsonDraftBaseline.value : detail.rawText;
@@ -1936,7 +1984,8 @@ function selectMember(title: string, value: unknown, context: RedisMemberContext
selectedMemberKey.value = getRedisMemberSelectionKey(title, value, identity);
selectedMemberContext.value = context;
isEditingMember.value = false;
memberValueView.value = preferredRedisValueFormat(value, readPreferredRedisValueFormat(), { allowJsonText: true });
memberValueView.value = autoRedisValueFormat(detail, readStoredRedisValueFormat());
applyDetectedMemberCodec(detail);
memberJsonRawBaseline.value = detail.json?.formattedText ?? "";
memberJsonDraftBaseline.value = jsonDraftBaseline(memberJsonRawBaseline.value, redisJsonDecoded.value);
memberEditValue.value = memberValueView.value === "json" && detail.json ? memberJsonDraftBaseline.value : detail.rawText;
@@ -2889,6 +2938,7 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
{{ redisCodecLabel(codec) }}
</Button>
</RedisHorizontalScrollbar>
<Badge v-if="stringValueDetectionLabel" variant="outline" class="shrink-0 text-xs text-muted-foreground" :title="t('redis.detectedBadgeTitle')" :aria-label="t('redis.detectedBadgeTitle')">{{ stringValueDetectionLabel }}</Badge>
<FileArchive v-if="!isStringValueTruncated && stringGzipBadge && stringValueCodec === 'none'" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')" />
<span class="flex-1" />
<label v-if="isTextRedisFormat(stringValueView) || activeStructuredStringDetail || isDecompressCodec(stringValueCodec)" class="flex items-center gap-1.5 text-muted-foreground">
@@ -3671,6 +3721,7 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
<Input v-if="isEditingMember && selectedMemberContext?.kind === 'hash'" v-model="memberFieldEditValue" class="h-7 min-w-0 flex-1 text-sm" :placeholder="t('redis.field')" :aria-label="t('redis.field')" @keydown.enter="saveMemberEdit" />
<span v-else class="truncate">{{ selectedMemberTitle ? formatValue(selectedMemberTitle) : t("redis.memberDetail") }}</span>
<Badge variant="outline" class="shrink-0 text-xs">{{ redisFormatLabel(memberValueView, selectedMemberDetail.rawLabel) }}</Badge>
<Badge v-if="memberDetectionLabel" variant="outline" class="shrink-0 text-xs text-muted-foreground" :title="t('redis.detectedBadgeTitle')" :aria-label="t('redis.detectedBadgeTitle')">{{ memberDetectionLabel }}</Badge>
<Badge v-if="memberGzipBadge && memberValueCodec === 'none'" variant="outline" class="shrink-0 text-xs text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')">
<FileArchive class="h-3 w-3 mr-1" />
Gzip
+5
View File
@@ -5635,6 +5635,11 @@ export default withEnglishFallback({
decompressedRetryAsDeflate: "DEFLATE kimi yenidən cəhd et",
decompressedLimitExceeded: "Sıxılması açılmış nəticə 50 MiB həddini aşır",
gzipBadgeTitle: "Gzip ilə sıxılıb — sıxılması açılmış məzmuna baxmaq üçün klikləyin",
detectedBadgeTitle: "Aşkarlanmış məlumat formatı",
detectedJavaSerialized: "Java serializasiyası",
detectedPickle: "Pickle serializasiyası",
detectedMsgpack: "Msgpack serializasiyası",
detectedPhpSerialized: "PHP serializasiyası",
formatJson: "Formatla",
compressJson: "Sıx",
jsonFormatError: "Yanlış JSON formatı",
+5
View File
@@ -6368,6 +6368,11 @@ export default {
decompressedRetryAsDeflate: "Retry as DEFLATE",
decompressedLimitExceeded: "Decompressed result exceeds the 50 MiB limit",
gzipBadgeTitle: "Gzip compressed — click to view decompressed content",
detectedBadgeTitle: "Detected data format",
detectedJavaSerialized: "Java serialized",
detectedPickle: "Pickle serialized",
detectedMsgpack: "Msgpack serialized",
detectedPhpSerialized: "PHP serialized",
formatJson: "Format",
compressJson: "Compress",
jsonFormatError: "Invalid JSON format",
+5
View File
@@ -5863,6 +5863,11 @@ export default withEnglishFallback({
decompressedRetryAsDeflate: "Reintentar como DEFLATE",
decompressedLimitExceeded: "El resultado descomprimido supera el límite de 50 MiB",
gzipBadgeTitle: "Comprimido con Gzip: haga clic para ver el contenido descomprimido",
detectedBadgeTitle: "Formato de datos detectado",
detectedJavaSerialized: "Serializado en Java",
detectedPickle: "Serializado con Pickle",
detectedMsgpack: "Serializado con Msgpack",
detectedPhpSerialized: "Serializado en PHP",
clearHistory: "Borrar historial de comandos",
historyCleared: "Historial de comandos Redis borrado",
blockedCommand: "El comando {command} está bloqueado por seguridad. Desactiva el icono de escudo en la barra de herramientas para permitirlo.",
+5
View File
@@ -5680,6 +5680,11 @@ export default withEnglishFallback({
decompressedRetryAsDeflate: "Riprova come DEFLATE",
decompressedLimitExceeded: "Il risultato decompresso supera il limite di 50 MiB",
gzipBadgeTitle: "Compresso con Gzip: fare clic per visualizzare il contenuto decompresso",
detectedBadgeTitle: "Formato dati rilevato",
detectedJavaSerialized: "Serializzato in Java",
detectedPickle: "Serializzato con Pickle",
detectedMsgpack: "Serializzato con Msgpack",
detectedPhpSerialized: "Serializzato in PHP",
formatJson: "Formatta",
compressJson: "Comprimi",
jsonFormatError: "Formato JSON non valido",
+5
View File
@@ -5708,6 +5708,11 @@ export default withEnglishFallback({
decompressedRetryAsDeflate: "Deflate で再試行",
decompressedLimitExceeded: "解凍結果が 50 MiB の上限を超えています",
gzipBadgeTitle: "Gzip 圧縮 — クリックで解凍内容を表示",
detectedBadgeTitle: "検出されたデータ形式",
detectedJavaSerialized: "Javaシリアライズ",
detectedPickle: "Pickleシリアライズ",
detectedMsgpack: "Msgpackシリアライズ",
detectedPhpSerialized: "PHPシリアライズ",
formatJson: "フォーマット",
compressJson: "圧縮",
jsonFormatError: "不正なJSON形式です",
+5
View File
@@ -5342,6 +5342,11 @@ export default withEnglishFallback({
decompressedRetryAsDeflate: "Deflate로 재시도",
decompressedLimitExceeded: "압축 해제 결과가 50 MiB 제한을 초과합니다",
gzipBadgeTitle: "Gzip 압축 — 클릭하여 압축 해제 내용 보기",
detectedBadgeTitle: "감지된 데이터 형식",
detectedJavaSerialized: "Java 직렬화",
detectedPickle: "Pickle 직렬화",
detectedMsgpack: "Msgpack 직렬화",
detectedPhpSerialized: "PHP 직렬화",
formatJson: "서식",
compressJson: "압축",
jsonFormatError: "잘못된 JSON 형식",
+5
View File
@@ -5682,6 +5682,11 @@ export default withEnglishFallback({
decompressedRetryAsDeflate: "Tentar novamente como DEFLATE",
decompressedLimitExceeded: "O resultado descomprimido excede o limite de 50 MiB",
gzipBadgeTitle: "Comprimido com Gzip — clique para ver o conteúdo descomprimido",
detectedBadgeTitle: "Formato de dados detectado",
detectedJavaSerialized: "Serializado em Java",
detectedPickle: "Serializado com Pickle",
detectedMsgpack: "Serializado com Msgpack",
detectedPhpSerialized: "Serializado em PHP",
formatJson: "Formatar",
compressJson: "Comprimir",
jsonFormatError: "Formato JSON inválido",
+5
View File
@@ -6712,6 +6712,11 @@ export default withEnglishFallback({
decompressedRetryAsDeflate: "Повторить как DEFLATE",
decompressedLimitExceeded: "Распакованный результат превышает лимит 50 MiB",
gzipBadgeTitle: "Сжато Gzip — нажмите, чтобы посмотреть распакованное содержимое",
detectedBadgeTitle: "Обнаруженный формат данных",
detectedJavaSerialized: "Java-сериализация",
detectedPickle: "Pickle-сериализация",
detectedMsgpack: "Msgpack-сериализация",
detectedPhpSerialized: "PHP-сериализация",
formatJson: "Форматировать",
compressJson: "Сжать",
jsonFormatError: "Недопустимый формат JSON",
+5
View File
@@ -5529,6 +5529,11 @@ export default withEnglishFallback({
decompressedRetryAsDeflate: "DEFLATE olarak yeniden dene",
decompressedLimitExceeded: "Sıkıştırması açılan sonuç 50 MiB sınırını aşıyor",
gzipBadgeTitle: "Gzip ile sıkıştırılmış — açılmış içeriği görmek için tıklayın",
detectedBadgeTitle: "Algılanan veri biçimi",
detectedJavaSerialized: "Java serileştirilmiş",
detectedPickle: "Pickle serileştirilmiş",
detectedMsgpack: "Msgpack serileştirilmiş",
detectedPhpSerialized: "PHP serileştirilmiş",
formatJson: "Biçim",
compressJson: "Sıkıştır",
jsonFormatError: "Geçersiz JSON biçimi",
+5
View File
@@ -6341,6 +6341,11 @@ export default withEnglishFallback({
decompressedRetryAsDeflate: "以 Deflate 重试",
decompressedLimitExceeded: "解压结果超过 50 MiB 限制",
gzipBadgeTitle: "Gzip 压缩 — 点击查看解压内容",
detectedBadgeTitle: "检测到的数据格式",
detectedJavaSerialized: "Java 序列化",
detectedPickle: "Pickle 序列化",
detectedMsgpack: "Msgpack 序列化",
detectedPhpSerialized: "PHP 序列化",
formatJson: "格式化",
compressJson: "压缩",
jsonFormatError: "JSON 格式不合法",
+5
View File
@@ -4995,6 +4995,11 @@ export default withEnglishFallback({
decompressedRetryAsDeflate: "以 Deflate 重試",
decompressedLimitExceeded: "解壓結果超過 50 MiB 限制",
gzipBadgeTitle: "Gzip 壓縮 — 點擊檢視解壓內容",
detectedBadgeTitle: "偵測到的資料格式",
detectedJavaSerialized: "Java 序列化",
detectedPickle: "Pickle 序列化",
detectedMsgpack: "Msgpack 序列化",
detectedPhpSerialized: "PHP 序列化",
formatJson: "格式化",
compressJson: "壓縮",
jsonFormatError: "無效的 JSON 格式",
@@ -0,0 +1,124 @@
import { readFileSync } from "node:fs";
import { parse } from "vue/compiler-sfc";
import ts from "typescript";
import { describe, expect, it } from "vitest";
const viewerSource = readFileSync(new URL("../../../components/redis/RedisValueViewer.vue", import.meta.url), "utf8");
const parsedViewer = parse(viewerSource, { filename: "RedisValueViewer.vue" });
/**
* Issue #10922 wiring: detected JSON containers open pretty-printed, detected
* structured codecs open decoded, and both surfaces carry a representation
* badge — unless the user pinned an explicit format or codec.
*/
describe("Redis value detection (issue 10922)", () => {
function findFunction(name: string): ts.FunctionDeclaration {
const script = parsedViewer.descriptor.scriptSetup;
expect(script).toBeDefined();
const source = ts.createSourceFile("RedisValueViewer.vue.ts", script!.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const declaration = source.statements.find((statement): statement is ts.FunctionDeclaration => ts.isFunctionDeclaration(statement) && statement.name?.text === name);
expect(declaration).toBeDefined();
return declaration!;
}
function callsIn(node: ts.Node): ts.CallExpression[] {
const calls: ts.CallExpression[] = [];
const visit = (child: ts.Node) => {
if (ts.isCallExpression(child)) calls.push(child);
ts.forEachChild(child, visit);
};
visit(node);
return calls;
}
function calledName(call: ts.CallExpression): string | undefined {
if (ts.isIdentifier(call.expression)) return call.expression.text;
if (ts.isPropertyAccessExpression(call.expression)) return `${call.expression.expression.getText()}.${call.expression.name.text}`;
return undefined;
}
function assignmentsIn(node: ts.Node): ts.BinaryExpression[] {
const assignments: ts.BinaryExpression[] = [];
const visit = (child: ts.Node) => {
if (ts.isBinaryExpression(child) && child.operatorToken.kind === ts.SyntaxKind.EqualsToken) assignments.push(child);
ts.forEachChild(child, visit);
};
visit(node);
return assignments;
}
function findVariableInitializer(name: string): ts.Expression {
const script = parsedViewer.descriptor.scriptSetup;
expect(script).toBeDefined();
const source = ts.createSourceFile("RedisValueViewer.vue.ts", script!.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
for (const statement of source.statements) {
if (!ts.isVariableStatement(statement)) continue;
const declaration = statement.declarationList.declarations.find((candidate) => ts.isIdentifier(candidate.name) && candidate.name.text === name);
if (declaration?.initializer) return declaration.initializer;
}
throw new Error(`Expected ${name} to have an initializer`);
}
it("opens detected JSON containers pretty-printed and detected codecs decoded on load", () => {
const load = findFunction("load");
const formatAssignments = assignmentsIn(load).filter((assignment) => assignment.left.getText() === "stringValueView.value");
const codecCalls = callsIn(load).filter((call) => calledName(call) === "applyDetectedStringCodec");
expect(formatAssignments).toHaveLength(1);
expect(formatAssignments[0].right.getText()).toBe("autoRedisValueFormat(detail, readStoredRedisValueFormat())");
expect(codecCalls.map((call) => call.arguments.map((argument) => argument.getText()))).toContainEqual(["detail"]);
});
it("applies the same detection to selected member details", () => {
const selectMember = findFunction("selectMember");
const formatAssignments = assignmentsIn(selectMember).filter((assignment) => assignment.left.getText() === "memberValueView.value");
const codecCalls = callsIn(selectMember).filter((call) => calledName(call) === "applyDetectedMemberCodec");
expect(formatAssignments).toHaveLength(1);
expect(formatAssignments[0].right.getText()).toBe("autoRedisValueFormat(detail, readStoredRedisValueFormat())");
expect(codecCalls.map((call) => call.arguments.map((argument) => argument.getText()))).toContainEqual(["detail"]);
});
it("detection yields to a pinned codec and clears itself for undetected payloads", () => {
const applyString = findFunction("applyDetectedStringCodec").getText();
const applyMember = findFunction("applyDetectedMemberCodec").getText();
for (const text of [applyString, applyMember]) {
expect(text).toContain("readStoredRedisValueCodec() != null");
expect(text).toContain('detectedRedisStructuredCodec(detail) ?? "none"');
}
expect(applyString).toContain("isStringValueTruncated.value");
});
it("treats a missing stored preference as the auto-detection signal", () => {
const readFormat = findFunction("readStoredRedisValueFormat").getText();
const readCodec = findFunction("readStoredRedisValueCodec").getText();
// Legacy stored values keep their mapping; anything unpinned returns null
// (auto), never the old "utf8"/"none" defaults.
expect(readFormat).toContain('if (stored === "raw") return "utf8";');
expect(readFormat).toContain(": null;");
expect(readFormat).not.toContain(': "utf8";');
expect(readCodec).toContain("isRedisValueCodec(stored) ? stored : null");
const stringViewInit = findVariableInitializer("stringValueView").getText();
const stringCodecInit = findVariableInitializer("stringValueCodec").getText();
expect(stringViewInit).toContain('readStoredRedisValueFormat() ?? "utf8"');
expect(stringCodecInit).toContain('readStoredRedisValueCodec() ?? "none"');
});
it("labels the detected representation next to the value on both surfaces", () => {
const template = parsedViewer.descriptor.template;
expect(parsedViewer.errors).toEqual([]);
expect(template).toBeDefined();
expect(viewerSource).toContain('v-if="stringValueDetectionLabel"');
expect(viewerSource).toContain('v-if="memberDetectionLabel"');
expect(viewerSource.match(/t\('redis\.detectedBadgeTitle'\)/g)).toHaveLength(4);
expect(findFunction("redisDetectionBadgeLabel").getText()).toContain('t("redis.detectedJavaSerialized")');
expect(findFunction("redisDetectionBadgeLabel").getText()).toContain("isRedisJsonContainerValue(detail.json?.value)");
});
});
@@ -1,10 +1,13 @@
import { describe, expect, it } from "vitest";
import {
autoRedisValueFormat,
canRenderRedisValueFormat,
detectedRedisStructuredCodec,
formatRedisMemberDetail,
formatRedisStringValue,
getRedisMemberSelectionKey,
isRedisJsonContainerValue,
jsonToXmlText,
jsonToYamlText,
normalizeRedisJsonDraft,
@@ -276,6 +279,69 @@ describe("redisValuePresentation", () => {
expect(canRenderRedisValueFormat(formatRedisMemberDetail("plain-text"), "utf8")).toBe(true);
});
it("auto-opens JSON containers pretty-printed when no format was pinned", () => {
const detail = formatRedisMemberDetail('{"role":"reader"}', { allowJsonText: true });
expect(autoRedisValueFormat(detail, null)).toBe("json");
});
it("keeps scalar JSON and plain text on the raw utf8 view", () => {
expect(autoRedisValueFormat(formatRedisMemberDetail("123", { allowJsonText: true }), null)).toBe("utf8");
expect(autoRedisValueFormat(formatRedisMemberDetail("plain-text", { allowJsonText: true }), null)).toBe("utf8");
});
it("lets a pinned format preference win over JSON detection", () => {
const detail = formatRedisMemberDetail('{"role":"reader"}', { allowJsonText: true });
expect(autoRedisValueFormat(detail, "utf8")).toBe("utf8");
expect(autoRedisValueFormat(detail, "json")).toBe("json");
});
it("keeps non-reusable view preferences falling back to the value default", () => {
const detail = formatRedisMemberDetail('{"role":"reader"}', { allowJsonText: true });
expect(autoRedisValueFormat(detail, "hex")).toBe("utf8");
});
it("still honors binary inspection views on non-editable blobs", () => {
const detail = formatRedisMemberDetail({
raw_base64: Buffer.from([0x00, 0x01, 0x02]).toString("base64"),
encoding: "binary",
});
expect(autoRedisValueFormat(detail, "hex")).toBe("hex");
expect(autoRedisValueFormat(detail, null)).toBe("hex");
});
it("maps detected structured payloads to their codec", () => {
const java = formatRedisMemberDetail({ raw_base64: "rO0ABXQACHNvbWV0ZXh0", encoding: "binary" });
expect(detectedRedisStructuredCodec(java)).toBe("javaserialize");
const plain = formatRedisMemberDetail("plain-text", { allowJsonText: true });
expect(detectedRedisStructuredCodec(plain)).toBeNull();
});
it("auto-detects msgpack only when the decoded value is a container", () => {
// {"a":1} encoded as msgpack.
const container = formatRedisMemberDetail({
raw_base64: Buffer.from([0x81, 0xa1, 0x61, 0x01]).toString("base64"),
encoding: "binary",
});
expect(detectedRedisStructuredCodec(container)).toBe("msgpack");
// Single ASCII bytes are valid msgpack fixints, so scalar payloads must
// stay undetected: a counter key of "1" must not render as 49.
for (const scalar of ["1", "0", "Y", "é"]) {
const detail = formatRedisMemberDetail(scalar, { allowJsonText: true });
expect(detectedRedisStructuredCodec(detail)).toBeNull();
}
});
it("treats only objects and arrays as JSON containers", () => {
expect(isRedisJsonContainerValue({ a: 1 })).toBe(true);
expect(isRedisJsonContainerValue([1])).toBe(true);
expect(isRedisJsonContainerValue("123")).toBe(false);
expect(isRedisJsonContainerValue(null)).toBe(false);
expect(isRedisJsonContainerValue(undefined)).toBe(false);
});
it("keeps legacy Pickle payloads out of conservative auto-detection", () => {
const detail = formatRedisMemberDetail({
raw_base64: "KGRwMApWc3RhdHVzCnAxClZTVUNDRVNTCnAyCnNWcmVzdWx0CnAzCihscDQKSTEKYVZoZWxsbwpwNQphTmFJMDEKYUkwMAphcy4=",
@@ -461,6 +461,40 @@ export function preferredRedisValueFormat(value: unknown, preferred?: RedisValue
return detail.defaultFormat;
}
/**
* Issue #10922: a string payload that parses into a JSON object or array opens
* pretty-printed instead of raw, matching RedisInsight. `stored` is the
* persisted format preference; null means the user never pinned a format, so
* detection wins. Scalar JSON ("123", "\"text\"") renders identically in both
* views, so it never triggers the switch.
*/
export function autoRedisValueFormat(detail: RedisMemberDetail, stored: RedisValueFormat | null): RedisValueFormat {
if (stored != null && shouldReuseRedisValueFormatPreference(detail, stored)) return stored;
if (stored == null && isRedisJsonContainerValue(detail.json?.value)) return "json";
return detail.defaultFormat;
}
export function isRedisJsonContainerValue(value: unknown): boolean {
return typeof value === "object" && value !== null;
}
/**
* Codec that `formatRedisMemberDetail` already decoded for this payload, or
* null. Java serialization, Pickle, and PHP serialization have deterministic
* magic prefixes; Protobuf is deliberately excluded because without a schema
* its wire format matches too many byte strings. Msgpack has no magic prefix
* -- any single byte is a valid msgpack scalar -- so its detection
* additionally requires the decoded value to be a container, matching the
* JSON auto-format guard above.
*/
export function detectedRedisStructuredCodec(detail: RedisMemberDetail): RedisValueCodec | null {
if (detail.javaSerialized) return "javaserialize";
if (detail.pickle) return "pickle";
if (detail.msgpack && isRedisJsonContainerValue(detail.msgpack.value)) return "msgpack";
if (detail.phpSerialized) return "phpserialize";
return null;
}
export function canRenderRedisValueFormat(detail: RedisMemberDetail, format: RedisValueFormat): boolean {
return !isJsonDerivedView(format) || Boolean(detail.json);
}