feat(redis): edit JSON values as source with compact saves
This commit is contained in:
1 parent
deb4cbfdfe
commit
ceadf73e4b
11 files changed
+1181
-210
No files matched your search
@@ -0,0 +1,245 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parse } from "vue/compiler-sfc";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatRedisMemberDetail, normalizeRedisJsonDraft } from "@/lib/redis/redisValuePresentation";
|
||||
|
||||
const viewerSource = readFileSync(new URL("../../../components/redis/RedisValueViewer.vue", import.meta.url), "utf8");
|
||||
const parsedViewer = parse(viewerSource, { filename: "RedisValueViewer.vue" });
|
||||
|
||||
/** Owner-review fixture: compact save must not drop the second "role" member. */
|
||||
const DUPLICATE_MEMBER_COMPACT = '{"role":"reader","role":"writer"}';
|
||||
const DUPLICATE_MEMBER_PRETTY = `{
|
||||
"role": "reader",
|
||||
"role": "writer"
|
||||
}`;
|
||||
|
||||
type TemplateElement = {
|
||||
type: number;
|
||||
tag: string;
|
||||
children?: TemplateNode[];
|
||||
props: Array<{
|
||||
type: number;
|
||||
name?: string;
|
||||
arg?: { content?: string };
|
||||
exp?: { content?: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
type TemplateNode = {
|
||||
type: number;
|
||||
children?: TemplateNode[];
|
||||
};
|
||||
|
||||
function directiveExpression(element: TemplateElement, name: string, arg?: string): string | undefined {
|
||||
return element.props.find((prop) => prop.type === 7 && prop.name === name && (arg == null || prop.arg?.content === arg))?.exp?.content;
|
||||
}
|
||||
|
||||
function templateElements(node: TemplateNode): TemplateElement[] {
|
||||
const children = (node.children ?? []).flatMap(templateElements);
|
||||
return node.type === 1 ? [node as TemplateElement, ...children] : children;
|
||||
}
|
||||
|
||||
function findTemplateElement(predicate: (element: TemplateElement) => boolean): TemplateElement {
|
||||
const template = parsedViewer.descriptor.template;
|
||||
expect(parsedViewer.errors).toEqual([]);
|
||||
expect(template).toBeDefined();
|
||||
|
||||
const element = templateElements(template!.ast as unknown as TemplateElement).find(predicate);
|
||||
expect(element).toBeDefined();
|
||||
return element!;
|
||||
}
|
||||
|
||||
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 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`);
|
||||
}
|
||||
|
||||
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 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 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;
|
||||
}
|
||||
|
||||
describe("native RedisJSON editor", () => {
|
||||
it("uses the same foldable source editor as JSON strings and hash fields", () => {
|
||||
const stringEditor = findTemplateElement((element) => element.tag === "RedisJsonEditor" && directiveExpression(element, "if") === "stringValueView === 'json' && stringValueDetail.json");
|
||||
const nativeJsonBranch = findTemplateElement((element) => directiveExpression(element, "else-if") === "redisKind === 'json'");
|
||||
const hashEditor = findTemplateElement((element) => element.tag === "RedisJsonEditor" && directiveExpression(element, "if") === "isEditingHashJson");
|
||||
const nativeJsonEditors = templateElements(nativeJsonBranch).filter((element) => element.tag === "RedisJsonEditor");
|
||||
|
||||
expect(stringEditor.tag).toBe("RedisJsonEditor");
|
||||
expect(hashEditor.tag).toBe("RedisJsonEditor");
|
||||
expect(nativeJsonEditors).toHaveLength(1);
|
||||
|
||||
const [nativeJsonEditor] = nativeJsonEditors;
|
||||
expect(directiveExpression(nativeJsonEditor, "model")).toBe("editValue");
|
||||
expect(directiveExpression(nativeJsonEditor, "bind", "save-disabled")).toBe("savingJson || !redisJsonValueChanged");
|
||||
expect(directiveExpression(nativeJsonEditor, "bind", "read-only")).toBe("savingJson");
|
||||
expect(directiveExpression(nativeJsonEditor, "bind", "word-wrap")).toBe("redisJsonWordWrap");
|
||||
expect(directiveExpression(nativeJsonEditor, "on", "save")).toBe("saveJson");
|
||||
});
|
||||
|
||||
it("does not leave a native RedisJSON tree or textarea rendering branch behind", () => {
|
||||
const nativeJsonBranch = findTemplateElement((element) => directiveExpression(element, "else-if") === "redisKind === 'json'");
|
||||
const nativeJsonTags = templateElements(nativeJsonBranch).map((element) => element.tag);
|
||||
|
||||
expect(nativeJsonTags).not.toContain("JsonTree");
|
||||
expect(nativeJsonTags).not.toContain("textarea");
|
||||
});
|
||||
|
||||
it("validates and compacts native RedisJSON before retaining JSON.SET save semantics", () => {
|
||||
const saveJson = findFunction("saveJson");
|
||||
const calls = callsIn(saveJson);
|
||||
const normalizeCall = calls.find((call) => calledName(call) === "normalizeRedisJsonDraft");
|
||||
const redisJsonSetCall = calls.find((call) => calledName(call) === "api.redisJsonSet");
|
||||
|
||||
expect(normalizeCall?.arguments.map((argument) => argument.getText())).toEqual(["editValue.value"]);
|
||||
expect(redisJsonSetCall?.arguments.map((argument) => argument.getText())).toEqual(["props.connectionId", "props.db", "props.keyRaw", "normalized.compactText"]);
|
||||
});
|
||||
|
||||
it("routes string and hash JSON saves through the same source-preserving normalize helper", () => {
|
||||
const saveString = findFunction("saveString");
|
||||
const saveMemberEdit = findFunction("saveMemberEdit");
|
||||
const saveStringText = saveString.getText();
|
||||
const saveMemberText = saveMemberEdit.getText();
|
||||
const saveStringCalls = callsIn(saveString).map(calledName);
|
||||
const saveMemberCalls = callsIn(saveMemberEdit).map(calledName);
|
||||
|
||||
// String JSON draft (current tab or retained draft format) compact-writes via SET.
|
||||
expect(saveStringText).toContain('stringValueView.value === "json" || stringDraftFormat.value === "json"');
|
||||
expect(saveStringCalls).toContain("normalizeRedisJsonDraft");
|
||||
expect(saveStringText).toContain("value = normalized.compactText");
|
||||
expect(saveStringCalls).toContain("api.redisSetString");
|
||||
expect(saveStringText).toMatch(/redisSetString\([\s\S]*\bvalue\b/);
|
||||
|
||||
// Hash JSON draft (editor open or retained after leaving JSON tab) compact-writes via HSET.
|
||||
expect(saveMemberText).toContain("savingHashJson");
|
||||
expect(saveMemberText).toContain('memberDraftFormat.value === "json"');
|
||||
expect(saveMemberCalls).toContain("normalizeRedisJsonDraft");
|
||||
expect(saveMemberText).toContain("writeValue = normalized.compactText");
|
||||
expect(saveMemberCalls).toContain("api.redisHashSet");
|
||||
expect(saveMemberText).toMatch(/redisHashSet\([\s\S]*\bwriteValue\b/);
|
||||
|
||||
// Editor pretty baseline also uses the source-preserving formatter.
|
||||
expect(findFunction("formatJsonText").getText()).toContain("formatJsonSource");
|
||||
});
|
||||
|
||||
it("string JSON editor open+save keeps duplicate members end-to-end", () => {
|
||||
// Open: string JSON view pretty baseline comes from formatRedisMemberDetail.
|
||||
const opened = formatRedisMemberDetail(DUPLICATE_MEMBER_COMPACT, { allowJsonText: true });
|
||||
expect(opened.json?.formattedText).toBe(DUPLICATE_MEMBER_PRETTY);
|
||||
|
||||
// Save: saveString always compact-writes through normalizeRedisJsonDraft.
|
||||
const saved = normalizeRedisJsonDraft(opened.json!.formattedText);
|
||||
expect(saved).toEqual({ ok: true, compactText: DUPLICATE_MEMBER_COMPACT });
|
||||
|
||||
// Wiring: that compact text is what redisSetString receives.
|
||||
const saveString = findFunction("saveString");
|
||||
expect(saveString.getText()).toContain("value = normalized.compactText");
|
||||
expect(callsIn(saveString).map(calledName)).toEqual(expect.arrayContaining(["normalizeRedisJsonDraft", "api.redisSetString"]));
|
||||
});
|
||||
|
||||
it("hash JSON editor open+save keeps duplicate members end-to-end", () => {
|
||||
// Open: hash field JSON view uses the same detail pretty baseline.
|
||||
const opened = formatRedisMemberDetail(DUPLICATE_MEMBER_COMPACT, { allowJsonText: true });
|
||||
expect(opened.json?.formattedText).toBe(DUPLICATE_MEMBER_PRETTY);
|
||||
|
||||
// Save: saveMemberEdit compact-writes hash JSON drafts through the same helper.
|
||||
const saved = normalizeRedisJsonDraft(opened.json!.formattedText);
|
||||
expect(saved).toEqual({ ok: true, compactText: DUPLICATE_MEMBER_COMPACT });
|
||||
|
||||
// Wiring: hash path only normalizes when savingHashJson, then HSETs writeValue.
|
||||
const saveMemberEdit = findFunction("saveMemberEdit");
|
||||
const text = saveMemberEdit.getText();
|
||||
expect(text).toContain("if (savingHashJson)");
|
||||
expect(text).toContain("writeValue = normalized.compactText");
|
||||
expect(callsIn(saveMemberEdit).map(calledName)).toEqual(expect.arrayContaining(["normalizeRedisJsonDraft", "api.redisHashSet"]));
|
||||
});
|
||||
|
||||
it("keeps the native editor and export paths on the RedisJSON value-text contract", () => {
|
||||
const load = findFunction("load");
|
||||
const discard = findFunction("discardRedisJsonEdit");
|
||||
const exportStatements = findFunction("generateInsertStatements");
|
||||
const valueTextCalls = (node: ts.Node) => callsIn(node).filter((call) => calledName(call) === "redisJsonValueText");
|
||||
|
||||
expect(valueTextCalls(load).map((call) => call.arguments.map((argument) => argument.getText()))).toContainEqual(["loadedValue.data"]);
|
||||
expect(valueTextCalls(exportStatements).map((call) => call.arguments.map((argument) => argument.getText()))).toContainEqual(["data.value.data"]);
|
||||
expect(assignmentsIn(load).some((assignment) => assignment.left.getText() === "redisJsonDraftBaseline.value" && assignment.right.getText() === "jsonDraftForEditor(redisJsonValueText(loadedValue.data))")).toBe(true);
|
||||
expect(assignmentsIn(discard).some((assignment) => assignment.left.getText() === "editValue.value" && assignment.right.getText() === "redisJsonDraftBaseline.value")).toBe(true);
|
||||
|
||||
for (const node of [load, discard, exportStatements]) {
|
||||
expect(callsIn(node).map(calledName)).not.toContain("JSON.stringify");
|
||||
}
|
||||
expect(viewerSource).not.toContain("isRedisJsonDraftDirty");
|
||||
});
|
||||
|
||||
it("keeps retained drafts out of background refresh and exposes word wrap for every JSON editor", () => {
|
||||
const autoRefresh = findFunction("startAutoRefresh");
|
||||
const hashSearch = findFunction("onHashSearch");
|
||||
const viewMember = findFunction("viewMember");
|
||||
const setMemberValueFormat = findFunction("setMemberValueFormat");
|
||||
const unsavedDraft = findVariableInitializer("hasUnsavedRedisDraft");
|
||||
const textFormat = findFunction("isTextRedisFormat");
|
||||
const labels = templateElements(parsedViewer.descriptor.template!.ast as unknown as TemplateElement);
|
||||
const stringTextarea = findTemplateElement((element) => element.tag === "textarea" && directiveExpression(element, "model") === "editValue");
|
||||
const memberTextarea = findTemplateElement((element) => element.tag === "textarea" && directiveExpression(element, "model") === "memberEditValue");
|
||||
const refreshButton = findTemplateElement((element) => element.tag === "Button" && directiveExpression(element, "on", "click") === "load");
|
||||
|
||||
expect(autoRefresh.getText()).toContain("if (hasUnsavedRedisDraft.value) return;");
|
||||
expect(autoRefresh.getText()).toContain("load({ preserveDraft: true })");
|
||||
expect(hashSearch.getText()).toContain("if (!hasRetainedMemberDraft.value) clearSelectedMember();");
|
||||
expect(viewMember.getText()).toContain("hasRetainedMemberDraft.value");
|
||||
// Clean JSON → other format must clear memberDraftFormat so rawText is not compared to the pretty baseline.
|
||||
expect(setMemberValueFormat.getText()).toContain("memberDraftFormat.value = null");
|
||||
expect(setMemberValueFormat.getText()).toContain('memberDraftFormat.value = "utf8"');
|
||||
expect(unsavedDraft.getText()).toContain("hasRetainedStringDraft.value");
|
||||
expect(unsavedDraft.getText()).toContain("hasRetainedMemberDraft.value");
|
||||
expect(textFormat.getText()).toContain('format === "json"');
|
||||
expect(labels.some((element) => element.tag === "label" && directiveExpression(element, "if") === "isTextRedisFormat(stringValueView)")).toBe(true);
|
||||
expect(labels.some((element) => element.tag === "label" && directiveExpression(element, "if") === "isTextRedisFormat(memberValueView)")).toBe(true);
|
||||
expect(directiveExpression(stringTextarea, "bind", "readonly")).toBe("!canEditCurrentStringFormat || savingString");
|
||||
expect(directiveExpression(memberTextarea, "bind", "readonly")).toBe("savingMember");
|
||||
expect(directiveExpression(refreshButton, "bind", "disabled")).toBe("hasUnsavedRedisDraft");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { canRenderRedisValueFormat, formatRedisMemberDetail, formatRedisStringValue, getRedisMemberSelectionKey, preferredRedisValueFormat, redisMemberCopyText, sanitizeRedisDisplayText } from "@/lib/redis/redisValuePresentation";
|
||||
import {
|
||||
canRenderRedisValueFormat,
|
||||
formatRedisMemberDetail,
|
||||
formatRedisStringValue,
|
||||
getRedisMemberSelectionKey,
|
||||
normalizeRedisJsonDraft,
|
||||
preferredRedisValueFormat,
|
||||
redisJsonValueText,
|
||||
redisMemberCopyText,
|
||||
redisValueCopyText,
|
||||
redisValuePreview,
|
||||
redisValueSize,
|
||||
sanitizeRedisDisplayText,
|
||||
} from "@/lib/redis/redisValuePresentation";
|
||||
|
||||
describe("redisValuePresentation", () => {
|
||||
it("strips control bytes from display without mutating raw member text", () => {
|
||||
@@ -34,6 +47,96 @@ describe("redisValuePresentation", () => {
|
||||
expect(formatRedisStringValue("plain-text")).toBe("plain-text");
|
||||
});
|
||||
|
||||
it("normalizes valid JSON drafts into compact Redis values", () => {
|
||||
expect(
|
||||
normalizeRedisJsonDraft(`
|
||||
{
|
||||
"name": "Ada",
|
||||
"items": [1, 2, 3]
|
||||
}
|
||||
`),
|
||||
).toEqual({ ok: true, compactText: '{"name":"Ada","items":[1,2,3]}' });
|
||||
});
|
||||
|
||||
it("returns an invalid result instead of throwing for malformed JSON drafts", () => {
|
||||
expect(normalizeRedisJsonDraft('{"name": }')).toEqual({ ok: false, error: "invalid_json" });
|
||||
});
|
||||
|
||||
it("keeps lossless large and high-precision numbers when normalizing drafts", () => {
|
||||
const compact = '{"id":87712409002717401,"fraction":0.123456789012345678901234,"scientific":1.234567890123456789e20}';
|
||||
const formatted = `{
|
||||
"id": 87712409002717401,
|
||||
"fraction": 0.123456789012345678901234,
|
||||
"scientific": 1.234567890123456789e20
|
||||
}`;
|
||||
|
||||
expect(normalizeRedisJsonDraft(formatted)).toEqual({ ok: true, compactText: compact });
|
||||
});
|
||||
|
||||
// Reviewer fixture: Redis string/hash values are raw text, so open+save must
|
||||
// only strip insignificant whitespace and must keep both "role" members.
|
||||
const DUPLICATE_MEMBER_COMPACT = '{"role":"reader","role":"writer"}';
|
||||
const DUPLICATE_MEMBER_PRETTY = `{
|
||||
"role": "reader",
|
||||
"role": "writer"
|
||||
}`;
|
||||
|
||||
it("string JSON editor open+save keeps duplicate object members", () => {
|
||||
// Open string key JSON view → pretty baseline from raw Redis text.
|
||||
const stringDetail = formatRedisMemberDetail(DUPLICATE_MEMBER_COMPACT, { allowJsonText: true });
|
||||
expect(stringDetail.json).toBeDefined();
|
||||
expect(stringDetail.json?.rawText).toBe(DUPLICATE_MEMBER_COMPACT);
|
||||
expect(stringDetail.json?.formattedText).toBe(DUPLICATE_MEMBER_PRETTY);
|
||||
|
||||
// Save path compact-writes the editor draft (pretty baseline, no user edit).
|
||||
expect(normalizeRedisJsonDraft(stringDetail.json!.formattedText)).toEqual({
|
||||
ok: true,
|
||||
compactText: DUPLICATE_MEMBER_COMPACT,
|
||||
});
|
||||
// Re-saving an already-compact draft must also keep both members.
|
||||
expect(normalizeRedisJsonDraft(DUPLICATE_MEMBER_COMPACT)).toEqual({
|
||||
ok: true,
|
||||
compactText: DUPLICATE_MEMBER_COMPACT,
|
||||
});
|
||||
});
|
||||
|
||||
it("hash field JSON editor open+save keeps duplicate object members", () => {
|
||||
// Hash fields reuse the same presentation/normalize helpers as string keys.
|
||||
const hashFieldDetail = formatRedisMemberDetail(DUPLICATE_MEMBER_COMPACT, { allowJsonText: true });
|
||||
expect(hashFieldDetail.availableFormats).toContain("json");
|
||||
expect(hashFieldDetail.json?.formattedText).toBe(DUPLICATE_MEMBER_PRETTY);
|
||||
|
||||
// Hash saveMemberEdit compact-writes through normalizeRedisJsonDraft.
|
||||
expect(normalizeRedisJsonDraft(hashFieldDetail.json!.formattedText)).toEqual({
|
||||
ok: true,
|
||||
compactText: DUPLICATE_MEMBER_COMPACT,
|
||||
});
|
||||
expect(normalizeRedisJsonDraft(DUPLICATE_MEMBER_PRETTY)).toEqual({
|
||||
ok: true,
|
||||
compactText: DUPLICATE_MEMBER_COMPACT,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps native RedisJSON source text lossless for copy, preview, and size", () => {
|
||||
const rawText = '{"id":2326645729978441729,"fraction":0.123456789012345678901234,"scientific":1.234567890123456789e20}';
|
||||
const value = {
|
||||
key_display: "json:profile",
|
||||
key_raw: "json:profile",
|
||||
ttl: -1,
|
||||
redis_type: "ReJSON-RL",
|
||||
data: { kind: "json" as const, value: rawText },
|
||||
};
|
||||
|
||||
expect(redisJsonValueText(value.data)).toBe(rawText);
|
||||
expect(redisValuePreview(value)).toBe(rawText);
|
||||
expect(redisValueSize(value)).toBe(new TextEncoder().encode(rawText).byteLength);
|
||||
expect(redisValueCopyText(value)).toBe(`{
|
||||
"id": 2326645729978441729,
|
||||
"fraction": 0.123456789012345678901234,
|
||||
"scientific": 1.234567890123456789e20
|
||||
}`);
|
||||
});
|
||||
|
||||
it("labels raw text views by encoding instead of generic raw text", () => {
|
||||
expect(formatRedisMemberDetail("plain-text").rawLabel).toBe("ASCII");
|
||||
expect(
|
||||
|
||||
@@ -1466,7 +1466,7 @@ export interface RedisStreamEntry {
|
||||
|
||||
export type RedisValueData =
|
||||
| { kind: "string"; content: RedisBlob }
|
||||
| { kind: "json"; value: unknown }
|
||||
| { kind: "json"; value: string }
|
||||
| { kind: "list"; items: RedisListItem[]; total: number; scan_cursor?: number }
|
||||
| { kind: "set"; items: RedisSetItem[]; total: number; scan_cursor?: number }
|
||||
| { kind: "hash"; items: RedisHashItem[]; total: number; scan_cursor?: number }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers, safeJsonFormat } from "../safeJsonFormat";
|
||||
import { formatJsonSource, isLosslessJsonNumber, parseJsonPreservingLargeNumbers, safeJsonFormat } from "../safeJsonFormat";
|
||||
|
||||
describe("safeJsonFormat", () => {
|
||||
it("preserves large integers exceeding MAX_SAFE_INTEGER", () => {
|
||||
@@ -86,3 +86,63 @@ describe("safeJsonFormat", () => {
|
||||
expect(safeJsonFormat(input, 2)).toContain("1e999");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatJsonSource", () => {
|
||||
it("minifies by removing only insignificant whitespace", () => {
|
||||
const input = `{
|
||||
"name": "Ada",
|
||||
"items": [1, 2, 3]
|
||||
}`;
|
||||
expect(formatJsonSource(input)).toBe('{"name":"Ada","items":[1,2,3]}');
|
||||
});
|
||||
|
||||
it("pretty-prints with the requested indent while keeping source tokens", () => {
|
||||
expect(formatJsonSource('{"role":"reader","role":"writer"}', 2)).toBe(`{
|
||||
"role": "reader",
|
||||
"role": "writer"
|
||||
}`);
|
||||
});
|
||||
|
||||
it("preserves duplicate object members when minifying", () => {
|
||||
const pretty = `{
|
||||
"role": "reader",
|
||||
"role": "writer"
|
||||
}`;
|
||||
expect(formatJsonSource(pretty)).toBe('{"role":"reader","role":"writer"}');
|
||||
});
|
||||
|
||||
it("preserves key order when duplicate members are mixed with other keys", () => {
|
||||
const input = '{"a":1,"role":"reader","b":2,"role":"writer","c":3}';
|
||||
expect(formatJsonSource(input)).toBe(input);
|
||||
expect(formatJsonSource(input, 2)).toBe(`{
|
||||
"a": 1,
|
||||
"role": "reader",
|
||||
"b": 2,
|
||||
"role": "writer",
|
||||
"c": 3
|
||||
}`);
|
||||
});
|
||||
|
||||
it("preserves large and high-precision number literals", () => {
|
||||
const compact = '{"id":87712409002717401,"fraction":0.123456789012345678901234,"scientific":1.234567890123456789e20}';
|
||||
const pretty = `{
|
||||
"id": 87712409002717401,
|
||||
"fraction": 0.123456789012345678901234,
|
||||
"scientific": 1.234567890123456789e20
|
||||
}`;
|
||||
|
||||
expect(formatJsonSource(pretty)).toBe(compact);
|
||||
expect(formatJsonSource(compact, 2)).toBe(pretty);
|
||||
});
|
||||
|
||||
it("preserves string escape sequences and does not rewrite them", () => {
|
||||
const input = '{"path":"C:\\\\Users\\\\path","quote":"say \\"hi\\"","unicode":"\\u4e2d"}';
|
||||
expect(formatJsonSource(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => formatJsonSource('{"name": }')).toThrow(SyntaxError);
|
||||
expect(() => formatJsonSource('{"a":1,}')).toThrow(SyntaxError);
|
||||
expect(() => formatJsonSource('{"a":1} trailing')).toThrow(SyntaxError);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,9 @@ export function parseJsonPreservingLargeNumbers(text: string): unknown {
|
||||
* Parse and re-stringify JSON while preserving numeric literals whose integer
|
||||
* parts exceed Number.MAX_SAFE_INTEGER (2^53 - 1), plus decimal and exponent
|
||||
* forms that JavaScript may round or turn into Infinity.
|
||||
*
|
||||
* Note: this rebuilds a JS object, so duplicate object members are collapsed.
|
||||
* Prefer {@link formatJsonSource} when the source text itself must stay lossless.
|
||||
*/
|
||||
export function safeJsonFormat(text: string, indent?: number): string {
|
||||
const protectedJson = protectLargeJsonNumbers(text);
|
||||
@@ -43,6 +46,20 @@ export function safeJsonFormat(text: string, indent?: number): string {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JSON and re-emit source tokens while only changing insignificant
|
||||
* whitespace. Unlike {@link safeJsonFormat}, this keeps duplicate object
|
||||
* members, key order, string escapes, and number spellings intact.
|
||||
*/
|
||||
export function formatJsonSource(text: string, indent?: number): string {
|
||||
const scanner = new JsonSourceScanner(text);
|
||||
const writer = new JsonSourceWriter(indent);
|
||||
writeJsonValue(scanner, writer, 0);
|
||||
scanner.skipWhitespace();
|
||||
if (!scanner.eof()) throw new SyntaxError(`Unexpected trailing content in JSON at position ${scanner.position}`);
|
||||
return writer.toString();
|
||||
}
|
||||
|
||||
function protectLargeJsonNumbers(text: string): ProtectedJsonNumbers {
|
||||
let placeholderPrefix = "__DBX_LOSSLESS_NUMBER_";
|
||||
while (text.includes(placeholderPrefix)) placeholderPrefix += "_";
|
||||
@@ -120,3 +137,267 @@ function restoreLosslessNumbers(value: unknown, numbers: Map<string, string>): u
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
class JsonSourceScanner {
|
||||
readonly text: string;
|
||||
position = 0;
|
||||
|
||||
constructor(text: string) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
eof(): boolean {
|
||||
return this.position >= this.text.length;
|
||||
}
|
||||
|
||||
peek(): string | undefined {
|
||||
return this.text[this.position];
|
||||
}
|
||||
|
||||
skipWhitespace() {
|
||||
while (this.position < this.text.length) {
|
||||
const character = this.text[this.position];
|
||||
if (character === " " || character === "\t" || character === "\n" || character === "\r") {
|
||||
this.position += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(character: string) {
|
||||
this.skipWhitespace();
|
||||
if (this.text[this.position] !== character) {
|
||||
throw new SyntaxError(`Expected '${character}' at position ${this.position}`);
|
||||
}
|
||||
this.position += 1;
|
||||
}
|
||||
|
||||
readStringToken(): string {
|
||||
this.skipWhitespace();
|
||||
if (this.text[this.position] !== '"') {
|
||||
throw new SyntaxError(`Expected string at position ${this.position}`);
|
||||
}
|
||||
|
||||
const start = this.position;
|
||||
this.position += 1;
|
||||
let escaped = false;
|
||||
while (this.position < this.text.length) {
|
||||
const character = this.text[this.position];
|
||||
if (escaped) {
|
||||
if (character === "u") {
|
||||
const hex = this.text.slice(this.position + 1, this.position + 5);
|
||||
if (!/^[0-9a-fA-F]{4}$/.test(hex)) {
|
||||
throw new SyntaxError(`Invalid unicode escape at position ${this.position}`);
|
||||
}
|
||||
this.position += 5;
|
||||
} else if (!'"\\/bfnrt'.includes(character)) {
|
||||
throw new SyntaxError(`Invalid escape sequence at position ${this.position}`);
|
||||
} else {
|
||||
this.position += 1;
|
||||
}
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
this.position += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
this.position += 1;
|
||||
return this.text.slice(start, this.position);
|
||||
}
|
||||
// JSON strings cannot contain raw control characters.
|
||||
if (character.charCodeAt(0) < 0x20) {
|
||||
throw new SyntaxError(`Invalid control character in string at position ${this.position}`);
|
||||
}
|
||||
this.position += 1;
|
||||
}
|
||||
throw new SyntaxError(`Unterminated string at position ${start}`);
|
||||
}
|
||||
|
||||
readNumberToken(): string {
|
||||
this.skipWhitespace();
|
||||
const start = this.position;
|
||||
if (this.text[this.position] === "-") this.position += 1;
|
||||
|
||||
if (this.text[this.position] === "0") {
|
||||
this.position += 1;
|
||||
} else if (this.isDigit(this.text[this.position])) {
|
||||
while (this.isDigit(this.text[this.position])) this.position += 1;
|
||||
} else {
|
||||
throw new SyntaxError(`Invalid number at position ${start}`);
|
||||
}
|
||||
|
||||
if (this.text[this.position] === ".") {
|
||||
this.position += 1;
|
||||
if (!this.isDigit(this.text[this.position])) {
|
||||
throw new SyntaxError(`Invalid number at position ${start}`);
|
||||
}
|
||||
while (this.isDigit(this.text[this.position])) this.position += 1;
|
||||
}
|
||||
|
||||
if (this.text[this.position] === "e" || this.text[this.position] === "E") {
|
||||
this.position += 1;
|
||||
if (this.text[this.position] === "+" || this.text[this.position] === "-") this.position += 1;
|
||||
if (!this.isDigit(this.text[this.position])) {
|
||||
throw new SyntaxError(`Invalid number at position ${start}`);
|
||||
}
|
||||
while (this.isDigit(this.text[this.position])) this.position += 1;
|
||||
}
|
||||
|
||||
if (this.position === start || (this.position === start + 1 && this.text[start] === "-")) {
|
||||
throw new SyntaxError(`Invalid number at position ${start}`);
|
||||
}
|
||||
return this.text.slice(start, this.position);
|
||||
}
|
||||
|
||||
readKeywordToken(keyword: "true" | "false" | "null"): string {
|
||||
this.skipWhitespace();
|
||||
if (this.text.slice(this.position, this.position + keyword.length) !== keyword) {
|
||||
throw new SyntaxError(`Expected '${keyword}' at position ${this.position}`);
|
||||
}
|
||||
this.position += keyword.length;
|
||||
return keyword;
|
||||
}
|
||||
|
||||
private isDigit(character: string | undefined): boolean {
|
||||
return character !== undefined && character >= "0" && character <= "9";
|
||||
}
|
||||
}
|
||||
|
||||
class JsonSourceWriter {
|
||||
private readonly chunks: string[] = [];
|
||||
private readonly indentSize: number | undefined;
|
||||
private readonly pretty: boolean;
|
||||
|
||||
constructor(indent?: number) {
|
||||
this.indentSize = indent !== undefined && indent > 0 ? indent : undefined;
|
||||
this.pretty = this.indentSize !== undefined;
|
||||
}
|
||||
|
||||
write(text: string) {
|
||||
this.chunks.push(text);
|
||||
}
|
||||
|
||||
newline(depth: number) {
|
||||
if (!this.pretty || this.indentSize === undefined) return;
|
||||
this.chunks.push("\n" + " ".repeat(this.indentSize * depth));
|
||||
}
|
||||
|
||||
space() {
|
||||
if (this.pretty) this.chunks.push(" ");
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.chunks.join("");
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonValue(scanner: JsonSourceScanner, writer: JsonSourceWriter, depth: number) {
|
||||
scanner.skipWhitespace();
|
||||
const character = scanner.peek();
|
||||
if (character === undefined) throw new SyntaxError("Unexpected end of JSON input");
|
||||
|
||||
if (character === "{") {
|
||||
writeJsonObject(scanner, writer, depth);
|
||||
return;
|
||||
}
|
||||
if (character === "[") {
|
||||
writeJsonArray(scanner, writer, depth);
|
||||
return;
|
||||
}
|
||||
if (character === '"') {
|
||||
writer.write(scanner.readStringToken());
|
||||
return;
|
||||
}
|
||||
if (character === "-" || (character >= "0" && character <= "9")) {
|
||||
writer.write(scanner.readNumberToken());
|
||||
return;
|
||||
}
|
||||
if (character === "t") {
|
||||
writer.write(scanner.readKeywordToken("true"));
|
||||
return;
|
||||
}
|
||||
if (character === "f") {
|
||||
writer.write(scanner.readKeywordToken("false"));
|
||||
return;
|
||||
}
|
||||
if (character === "n") {
|
||||
writer.write(scanner.readKeywordToken("null"));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new SyntaxError(`Unexpected token '${character}' at position ${scanner.position}`);
|
||||
}
|
||||
|
||||
function writeJsonObject(scanner: JsonSourceScanner, writer: JsonSourceWriter, depth: number) {
|
||||
scanner.expect("{");
|
||||
writer.write("{");
|
||||
scanner.skipWhitespace();
|
||||
|
||||
if (scanner.peek() === "}") {
|
||||
scanner.position += 1;
|
||||
writer.write("}");
|
||||
return;
|
||||
}
|
||||
|
||||
let first = true;
|
||||
while (true) {
|
||||
if (!first) {
|
||||
scanner.expect(",");
|
||||
writer.write(",");
|
||||
}
|
||||
first = false;
|
||||
writer.newline(depth + 1);
|
||||
writer.write(scanner.readStringToken());
|
||||
scanner.expect(":");
|
||||
writer.write(":");
|
||||
writer.space();
|
||||
writeJsonValue(scanner, writer, depth + 1);
|
||||
scanner.skipWhitespace();
|
||||
if (scanner.peek() === "}") {
|
||||
scanner.position += 1;
|
||||
writer.newline(depth);
|
||||
writer.write("}");
|
||||
return;
|
||||
}
|
||||
if (scanner.peek() !== ",") {
|
||||
throw new SyntaxError(`Expected ',' or '}' in object at position ${scanner.position}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonArray(scanner: JsonSourceScanner, writer: JsonSourceWriter, depth: number) {
|
||||
scanner.expect("[");
|
||||
writer.write("[");
|
||||
scanner.skipWhitespace();
|
||||
|
||||
if (scanner.peek() === "]") {
|
||||
scanner.position += 1;
|
||||
writer.write("]");
|
||||
return;
|
||||
}
|
||||
|
||||
let first = true;
|
||||
while (true) {
|
||||
if (!first) {
|
||||
scanner.expect(",");
|
||||
writer.write(",");
|
||||
}
|
||||
first = false;
|
||||
writer.newline(depth + 1);
|
||||
writeJsonValue(scanner, writer, depth + 1);
|
||||
scanner.skipWhitespace();
|
||||
if (scanner.peek() === "]") {
|
||||
scanner.position += 1;
|
||||
writer.newline(depth);
|
||||
writer.write("]");
|
||||
return;
|
||||
}
|
||||
if (scanner.peek() !== ",") {
|
||||
throw new SyntaxError(`Expected ',' or ']' in array at position ${scanner.position}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BinaryHexViewRow } from "@/lib/dataGrid/binaryHexViewer";
|
||||
import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer";
|
||||
import { parseJsonPreservingLargeNumbers, safeJsonFormat } from "@/lib/common/safeJsonFormat";
|
||||
import { formatJsonSource, parseJsonPreservingLargeNumbers } 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";
|
||||
|
||||
@@ -34,6 +34,16 @@ export interface RedisJsonDetail {
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export type RedisJsonDraftNormalizationResult =
|
||||
| {
|
||||
ok: true;
|
||||
compactText: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: "invalid_json";
|
||||
};
|
||||
|
||||
export interface RedisMemberDetailOptions {
|
||||
allowJsonText?: boolean;
|
||||
}
|
||||
@@ -155,6 +165,11 @@ export function formatRedisCommandResult(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
/** RedisJSON source text stays out of JavaScript's numeric representation. */
|
||||
export function redisJsonValueText(value: { value: string }): string {
|
||||
return value.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a value is a cluster-aggregated INFO response:
|
||||
* `[[addr, infoText], ...]` where each `infoText` starts with `"# "`
|
||||
@@ -189,10 +204,13 @@ export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
// Pretty-print from source tokens so duplicate members and number spellings
|
||||
// stay intact when Redis string/hash values open in the JSON editor.
|
||||
const formattedText = formatJsonSource(trimmed, 2);
|
||||
const parsed = parseJsonPreservingLargeNumbers(trimmed);
|
||||
return {
|
||||
rawText: value,
|
||||
formattedText: safeJsonFormat(trimmed, 2),
|
||||
formattedText,
|
||||
value: parsed,
|
||||
};
|
||||
} catch {
|
||||
@@ -200,6 +218,19 @@ export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a JSON editor draft and produces the compact text Redis should
|
||||
* store. Source-preserving minification keeps high-precision numbers and
|
||||
* duplicate object members intact.
|
||||
*/
|
||||
export function normalizeRedisJsonDraft(text: string): RedisJsonDraftNormalizationResult {
|
||||
try {
|
||||
return { ok: true, compactText: formatJsonSource(text) };
|
||||
} catch {
|
||||
return { ok: false, error: "invalid_json" };
|
||||
}
|
||||
}
|
||||
|
||||
export function preferredRedisValueFormat(value: unknown, preferred?: RedisValueFormat | null, options: RedisMemberDetailOptions = {}): RedisValueFormat {
|
||||
const detail = formatRedisMemberDetail(value, options);
|
||||
if (preferred && detail.availableFormats.includes(preferred) && shouldReuseRedisValueFormatPreference(detail, preferred)) return preferred;
|
||||
@@ -272,7 +303,7 @@ export function redisValueSize(value: RedisValue): number {
|
||||
case "string":
|
||||
return decodeRedisBlob(value.data.content).byteLength;
|
||||
case "json":
|
||||
return new TextEncoder().encode(JSON.stringify(value.data.value)).byteLength;
|
||||
return new TextEncoder().encode(redisJsonValueText(value.data)).byteLength;
|
||||
case "list":
|
||||
case "set":
|
||||
case "hash":
|
||||
@@ -290,7 +321,7 @@ export function redisValuePreview(value: RedisValue): string {
|
||||
case "string":
|
||||
return previewText(redisBlobRawText(value.data.content));
|
||||
case "json":
|
||||
return previewText(JSON.stringify(value.data.value));
|
||||
return previewText(redisJsonValueText(value.data));
|
||||
case "list": {
|
||||
const first = value.data.items[0];
|
||||
return first ? previewText(redisBlobRawText(first.value)) : "";
|
||||
@@ -322,8 +353,14 @@ export function redisValueCopyText(value: RedisValue, collectionItems: RedisColl
|
||||
switch (value.data.kind) {
|
||||
case "string":
|
||||
return redisBlobRawText(value.data.content);
|
||||
case "json":
|
||||
return JSON.stringify(value.data.value, null, 2);
|
||||
case "json": {
|
||||
const rawText = redisJsonValueText(value.data);
|
||||
try {
|
||||
return formatJsonSource(rawText, 2);
|
||||
} catch {
|
||||
return rawText;
|
||||
}
|
||||
}
|
||||
case "list":
|
||||
return JSON.stringify(
|
||||
(collectionItems as RedisListItem[]).map((item) => redisBlobRawText(item.value)),
|
||||
|
||||
Reference in new issue
Block a user