fix(redis): bound large string previews to avoid crashes
This commit is contained in:
14 files changed
+210
-29
No files matched your search
@@ -94,6 +94,18 @@ function stringValue(rawBase64 = "dmFsdWU=", ttl = 60) {
|
||||
};
|
||||
}
|
||||
|
||||
function largeStringValue() {
|
||||
return {
|
||||
...stringValue("cHJldmlldw=="),
|
||||
data: {
|
||||
kind: "string" as const,
|
||||
content: { raw_base64: "cHJldmlldw==", encoding: "utf8" as const },
|
||||
total_bytes: 45 * 1024 * 1024,
|
||||
truncated: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function listValue(ttl = 60) {
|
||||
return {
|
||||
key_display: "key",
|
||||
@@ -137,6 +149,8 @@ const testI18nMessages = {
|
||||
ttlHour: "{count}h",
|
||||
ttlMinute: "{count}m",
|
||||
ttlSecond: "{count}s",
|
||||
largeStringPreviewHint: "Only {loaded} of {total} is loaded. Preview is read-only.",
|
||||
largeStringPreviewActionUnavailable: "Unavailable for large-value previews",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -250,6 +264,20 @@ async function setStringDraft(value: string) {
|
||||
}
|
||||
|
||||
describe("RedisValueViewer expiry saving", () => {
|
||||
it("renders a large String as a bounded read-only preview", async () => {
|
||||
mocks.redisGetValue.mockResolvedValueOnce(largeStringValue());
|
||||
|
||||
mountViewer(vi.fn());
|
||||
await settle();
|
||||
|
||||
expect(document.querySelector("textarea")).toBeNull();
|
||||
expect(document.querySelector<HTMLElement>("[data-redis-large-string-preview]")?.textContent).toContain("Only 7 B of 45.0 MB is loaded");
|
||||
expect(document.querySelector<HTMLButtonElement>("[aria-label='grid.copyValue']")?.disabled).toBe(true);
|
||||
expect(document.querySelector<HTMLButtonElement>("[aria-label='redis.copyInsertStatement']")?.disabled).toBe(true);
|
||||
const gzip = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "GZip");
|
||||
expect(gzip?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("opens member UTF-8 editing from blank space without hijacking text double-clicks", async () => {
|
||||
mocks.redisGetValue.mockResolvedValueOnce(listValue());
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ import { decodeJsonUnicodeEscapes, formatJsonSource, mapDisplayToRaw } from "@/l
|
||||
import { unixSecondsToCalendarDateTime } from "@/components/ui/date-time-picker/dateTimePicker";
|
||||
import { applyRedisExpiryPolicy, type RedisExpiryMode, redisExpiryModeForTtl, validateRedisExpiry } from "@/lib/redis/redisExpiry";
|
||||
import { redisKeyRawToText, redisKeyTextToDisplay, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession";
|
||||
import { formatBytes } from "@/lib/database/serverMetrics";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
@@ -439,6 +440,7 @@ const stringBlob = computed<RedisBlob | null>(() => {
|
||||
if (!value) return null;
|
||||
return value.data.kind === "string" ? value.data.content : null;
|
||||
});
|
||||
const isStringValueTruncated = computed(() => data.value?.data.kind === "string" && Boolean(data.value.data.truncated));
|
||||
const stringValueDetail = computed(() => (stringBlob.value ? formatRedisMemberDetail(stringBlob.value, { allowJsonText: true }) : null));
|
||||
const selectedMemberDetail = computed(() => formatRedisMemberDetail(selectedMemberRaw.value, { allowJsonText: true }));
|
||||
|
||||
@@ -569,7 +571,7 @@ const memberCopyText = computed(() => memberDecodedText.value ?? detailTextForFo
|
||||
const redisJsonAppearance = computed(() => (isDark.value ? "dark" : "light"));
|
||||
const isBinaryStringValue = computed(() => Boolean(stringValueDetail.value?.binary));
|
||||
const selectedMemberCanEdit = computed(() => selectedMemberContext.value?.canEdit ?? false);
|
||||
const canEditCurrentStringFormat = computed(() => Boolean(stringValueDetail.value?.editable) && (stringValueView.value === "utf8" || stringValueView.value === "json"));
|
||||
const canEditCurrentStringFormat = computed(() => !isStringValueTruncated.value && Boolean(stringValueDetail.value?.editable) && (stringValueView.value === "utf8" || stringValueView.value === "json"));
|
||||
const showStringEditActions = computed(() => canEditCurrentStringFormat.value);
|
||||
const originalStringEditValue = computed(() => (stringBlob.value ? rawRedisValueText(stringBlob.value) : ""));
|
||||
const stringJsonRawBaseline = ref("");
|
||||
@@ -632,15 +634,26 @@ const zsetGridStyle = computed(() => ({
|
||||
gridTemplateColumns: `60px ${zsetScoreWidth.value}px minmax(0, 1fr) 104px`,
|
||||
}));
|
||||
const metadataSizeLabel = computed(() => {
|
||||
if (data.value?.data.kind === "string" && data.value.data.total_bytes != null) {
|
||||
return formatBytes(data.value.data.total_bytes);
|
||||
}
|
||||
const metadata = props.metadata;
|
||||
const size = metadata?.size ?? 0;
|
||||
if (!metadata || size <= 0) return "";
|
||||
if (metadata.key_type === "string") {
|
||||
if (size >= 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${size} B`;
|
||||
return formatBytes(size);
|
||||
}
|
||||
return String(size);
|
||||
});
|
||||
const largeStringPreviewHint = computed(() => {
|
||||
const value = data.value;
|
||||
const loaded = stringValueDetail.value?.byteCount ?? 0;
|
||||
if (!value || value.data.kind !== "string" || !value.data.truncated) return "";
|
||||
if (value.data.total_bytes != null) {
|
||||
return t("redis.largeStringPreviewHint", { loaded: formatBytes(loaded), total: formatBytes(value.data.total_bytes) });
|
||||
}
|
||||
return t("redis.largeStringPreviewHintUnknown", { loaded: formatBytes(loaded) });
|
||||
});
|
||||
const streamRows = computed<RedisStreamRow[]>(() => {
|
||||
if (redisKind.value !== "stream") return [];
|
||||
return streamEntries.value.map((entry, index) => ({
|
||||
@@ -1305,6 +1318,7 @@ function setStringValueFormat(format: RedisValueFormat) {
|
||||
}
|
||||
|
||||
function setStringValueCodec(codec: RedisValueCodec) {
|
||||
if (isStringValueTruncated.value && codec !== "none") return;
|
||||
stringValueCodec.value = codec;
|
||||
rememberRedisValueCodec(codec);
|
||||
}
|
||||
@@ -1538,6 +1552,7 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
|
||||
}
|
||||
|
||||
if (loadedValue.data.kind === "string") {
|
||||
if (loadedValue.data.truncated) stringValueCodec.value = "none";
|
||||
const detail = formatRedisMemberDetail(loadedValue.data.content, { allowJsonText: true });
|
||||
stringValueView.value = preferredRedisValueFormat(loadedValue.data.content, readPreferredRedisValueFormat(), { allowJsonText: true });
|
||||
stringJsonRawBaseline.value = detail.json?.formattedText ?? "";
|
||||
@@ -1591,7 +1606,7 @@ async function loadMore() {
|
||||
}
|
||||
|
||||
async function saveString() {
|
||||
if (!data.value || !stringBlob.value || isBinaryStringValue.value || !stringValueChanged.value || savingString.value) return;
|
||||
if (!data.value || !stringBlob.value || isStringValueTruncated.value || isBinaryStringValue.value || !stringValueChanged.value || savingString.value) return;
|
||||
|
||||
let value = editValue.value;
|
||||
// Compact whenever this draft is/was JSON-edited, even if the user switched tabs before Save.
|
||||
@@ -1695,6 +1710,10 @@ async function renameKey() {
|
||||
|
||||
async function copyValue() {
|
||||
if (!data.value) return;
|
||||
if (isStringValueTruncated.value) {
|
||||
toast(t("redis.largeStringPreviewActionUnavailable"), 3000);
|
||||
return;
|
||||
}
|
||||
// Copy the decoded text while a codec is decoding the string value.
|
||||
if (stringValueCodec.value !== "none" && stringDecodedText.value != null) {
|
||||
await copyText(stringDecodedText.value);
|
||||
@@ -1805,6 +1824,10 @@ function generateInsertStatements(): string | null {
|
||||
}
|
||||
|
||||
async function copyInsertStatement() {
|
||||
if (isStringValueTruncated.value) {
|
||||
toast(t("redis.largeStringPreviewActionUnavailable"), 3000);
|
||||
return;
|
||||
}
|
||||
const stmt = generateInsertStatements();
|
||||
if (!stmt) {
|
||||
toast(t("redis.copyInsertStatementBinary"), 3000);
|
||||
@@ -2734,8 +2757,10 @@ defineExpose({ focusSearch });
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('grid.copyValue')" :aria-label="t('grid.copyValue')" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('redis.copyInsertStatement')" :aria-label="t('redis.copyInsertStatement')" @click="copyInsertStatement"><ClipboardCopy class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :disabled="isStringValueTruncated" :title="isStringValueTruncated ? t('redis.largeStringPreviewActionUnavailable') : t('grid.copyValue')" :aria-label="t('grid.copyValue')" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :disabled="isStringValueTruncated" :title="isStringValueTruncated ? t('redis.largeStringPreviewActionUnavailable') : t('redis.copyInsertStatement')" :aria-label="t('redis.copyInsertStatement')" @click="copyInsertStatement"
|
||||
><ClipboardCopy class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('redis.renameKey')" :aria-label="t('redis.renameKey')" @click="openRenameKeyDialog"><Pencil class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 text-destructive" @click="requestDeleteKey"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -2774,11 +2799,21 @@ defineExpose({ focusSearch });
|
||||
<div class="flex h-9 items-center gap-2 border-b px-4 text-xs shrink-0">
|
||||
<span class="shrink-0 text-muted-foreground">{{ t("redis.codecRowLabel") }}</span>
|
||||
<div class="flex max-w-full overflow-x-auto rounded-md border bg-muted/20 p-0.5">
|
||||
<Button v-for="codec in REDIS_VALUE_CODEC_ORDER" :key="codec" variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': stringValueCodec === codec }" @click="setStringValueCodec(codec)">
|
||||
<Button
|
||||
v-for="codec in REDIS_VALUE_CODEC_ORDER"
|
||||
:key="codec"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 shrink-0 rounded-[5px] px-2 text-xs"
|
||||
:class="{ 'bg-background shadow-sm': stringValueCodec === codec }"
|
||||
:disabled="isStringValueTruncated && codec !== 'none'"
|
||||
:title="isStringValueTruncated && codec !== 'none' ? t('redis.largeStringPreviewActionUnavailable') : undefined"
|
||||
@click="setStringValueCodec(codec)"
|
||||
>
|
||||
{{ redisCodecLabel(codec) }}
|
||||
</Button>
|
||||
</div>
|
||||
<FileArchive v-if="stringGzipBadge && stringValueCodec === 'none'" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')" />
|
||||
<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">
|
||||
<WrapText class="h-3.5 w-3.5" />
|
||||
@@ -2882,7 +2917,11 @@ defineExpose({ focusSearch });
|
||||
/>
|
||||
<pre v-else-if="canHighlightStringSurface" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6" :class="detailTextClass(stringValueView)" v-html="contentSearchHighlightedHtml" />
|
||||
<pre v-else class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-4 text-sm leading-6" :class="detailTextClass(stringValueView)">{{ detailTextForFormat(stringValueDetail, stringValueView) }}</pre>
|
||||
<div v-if="isBinaryStringValue" class="px-4 py-2 border-t text-xs text-muted-foreground shrink-0">
|
||||
<div v-if="isStringValueTruncated" data-redis-large-string-preview class="flex shrink-0 items-center gap-2 border-t bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
|
||||
<Eye class="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{{ largeStringPreviewHint }}</span>
|
||||
</div>
|
||||
<div v-else-if="isBinaryStringValue" class="px-4 py-2 border-t text-xs text-muted-foreground shrink-0">
|
||||
{{ t("redis.binaryStringReadonlyHint") }}
|
||||
</div>
|
||||
<div v-if="showStringEditActions" class="px-4 py-2 border-t flex justify-end gap-2 shrink-0">
|
||||
|
||||
@@ -4773,6 +4773,9 @@ export default {
|
||||
columnSize: "Size",
|
||||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "Binary string values are shown as escaped text in read-only mode; editing raw bytes is not supported.",
|
||||
largeStringPreviewHint: "This value is large. Only the first {loaded} of {total} is loaded, and the preview is read-only.",
|
||||
largeStringPreviewHintUnknown: "This value is large. Only the first {loaded} is loaded, and the preview is read-only.",
|
||||
largeStringPreviewActionUnavailable: "Large-value previews cannot be copied, decoded, or used to generate insert statements",
|
||||
selectedKeys: "Selected keys",
|
||||
selectAllLoaded: "Select all",
|
||||
selectAllLoadedTitle: "Select all keys (Ctrl/Cmd+A)",
|
||||
|
||||
@@ -4590,6 +4590,9 @@ export default withEnglishFallback({
|
||||
columnSize: "Tamaño",
|
||||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "Los valores de cadena binaria se muestran como texto escapado en modo de solo lectura; la edición de bytes sin procesar no está disponible.",
|
||||
largeStringPreviewHint: "Este valor es grande. Solo se cargaron los primeros {loaded} de {total}; la vista previa es de solo lectura.",
|
||||
largeStringPreviewHintUnknown: "Este valor es grande. Solo se cargaron los primeros {loaded}; la vista previa es de solo lectura.",
|
||||
largeStringPreviewActionUnavailable: "Las vistas previas de valores grandes no se pueden copiar, decodificar ni usar para generar sentencias de inserción",
|
||||
selectedKeys: "Claves seleccionadas",
|
||||
selectAllLoaded: "Seleccionar todo",
|
||||
selectAllLoadedTitle: "Seleccionar todas las claves (Ctrl/Cmd+A)",
|
||||
|
||||
@@ -4588,6 +4588,9 @@ export default withEnglishFallback({
|
||||
columnSize: "Dimensione",
|
||||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "I valori delle stringhe binarie sono mostrati come testo con escape in modalità sola lettura; la modifica dei byte grezzi non è supportata.",
|
||||
largeStringPreviewHint: "Questo valore è grande. Sono stati caricati solo i primi {loaded} di {total}; l'anteprima è di sola lettura.",
|
||||
largeStringPreviewHintUnknown: "Questo valore è grande. Sono stati caricati solo i primi {loaded}; l'anteprima è di sola lettura.",
|
||||
largeStringPreviewActionUnavailable: "Le anteprime di valori grandi non possono essere copiate, decodificate o usate per generare istruzioni di inserimento",
|
||||
selectedKeys: "Chiavi selezionate",
|
||||
selectAllLoaded: "Seleziona tutto",
|
||||
selectAllLoadedTitle: "Seleziona tutte le chiavi (Ctrl/Cmd+A)",
|
||||
|
||||
@@ -4617,6 +4617,9 @@ export default withEnglishFallback({
|
||||
columnSize: "サイズ",
|
||||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "バイナリ文字列値は読み取り専用モードでエスケープテキストとして表示されます。生バイトの編集はサポートされていません。",
|
||||
largeStringPreviewHint: "値が大きいため、{total} のうち先頭 {loaded} のみを読み込みました。プレビューは読み取り専用です。",
|
||||
largeStringPreviewHintUnknown: "値が大きいため、先頭 {loaded} のみを読み込みました。プレビューは読み取り専用です。",
|
||||
largeStringPreviewActionUnavailable: "大きな値のプレビューは、コピー、デコード、または INSERT 文の生成には使用できません",
|
||||
selectedKeys: "選択中のキー",
|
||||
selectAllLoaded: "すべて選択",
|
||||
selectAllLoadedTitle: "すべてのキーを選択(Ctrl/Cmd+A)",
|
||||
|
||||
@@ -4205,6 +4205,9 @@ export default withEnglishFallback({
|
||||
columnSize: "크기",
|
||||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "바이너리 문자열 값은 읽기 전용 모드에서 이스케이프된 텍스트로 표시되며, 원시 바이트 편집은 지원되지 않습니다.",
|
||||
largeStringPreviewHint: "값이 너무 커서 전체 {total} 중 앞부분 {loaded}만 불러왔습니다. 미리 보기는 읽기 전용입니다.",
|
||||
largeStringPreviewHintUnknown: "값이 너무 커서 앞부분 {loaded}만 불러왔습니다. 미리 보기는 읽기 전용입니다.",
|
||||
largeStringPreviewActionUnavailable: "큰 값 미리 보기에서는 복사, 디코딩 또는 INSERT 문 생성을 사용할 수 없습니다",
|
||||
selectedKeys: "선택한 키",
|
||||
selectAllLoaded: "전체 선택",
|
||||
selectAllLoadedTitle: "모든 키 선택 (Ctrl/Cmd+A)",
|
||||
|
||||
@@ -4590,6 +4590,9 @@ export default withEnglishFallback({
|
||||
columnSize: "Tamanho",
|
||||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "Valores de string binária são exibidos como texto escapado no modo somente leitura; a edição de bytes brutos não é suportada.",
|
||||
largeStringPreviewHint: "Este valor é grande. Apenas os primeiros {loaded} de {total} foram carregados; a visualização é somente leitura.",
|
||||
largeStringPreviewHintUnknown: "Este valor é grande. Apenas os primeiros {loaded} foram carregados; a visualização é somente leitura.",
|
||||
largeStringPreviewActionUnavailable: "Visualizações de valores grandes não podem ser copiadas, decodificadas ou usadas para gerar instruções de inserção",
|
||||
selectedKeys: "Chaves selecionadas",
|
||||
selectAllLoaded: "Selecionar tudo",
|
||||
selectAllLoadedTitle: "Selecionar todas as chaves (Ctrl/Cmd+A)",
|
||||
|
||||
@@ -4757,6 +4757,9 @@ export default withEnglishFallback({
|
||||
columnSize: "大小",
|
||||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "二进制字符串按转义文本只读展示;当前不支持直接编辑原始字节值。",
|
||||
largeStringPreviewHint: "值过大,仅加载前 {loaded}(共 {total})。预览为只读。",
|
||||
largeStringPreviewHintUnknown: "值过大,仅加载前 {loaded}。预览为只读。",
|
||||
largeStringPreviewActionUnavailable: "大值预览不支持复制、解码或生成插入语句",
|
||||
selectedKeys: "已选择的 key",
|
||||
selectAllLoaded: "全选",
|
||||
selectAllLoadedTitle: "全选 key(Ctrl/Cmd+A)",
|
||||
|
||||
@@ -3913,6 +3913,9 @@ export default withEnglishFallback({
|
||||
columnSize: "大小",
|
||||
columnTTL: "TTL",
|
||||
binaryStringReadonlyHint: "二進位字串值會以逸出文字唯讀顯示;不支援編輯原始位元組。",
|
||||
largeStringPreviewHint: "值過大,僅載入前 {loaded}(共 {total})。預覽為唯讀。",
|
||||
largeStringPreviewHintUnknown: "值過大,僅載入前 {loaded}。預覽為唯讀。",
|
||||
largeStringPreviewActionUnavailable: "大值預覽不支援複製、解碼或產生插入語句",
|
||||
selectedKeys: "已選擇的 key",
|
||||
selectAllLoaded: "全選",
|
||||
selectAllLoadedTitle: "全選 key(Ctrl/Cmd+A)",
|
||||
|
||||
@@ -180,6 +180,23 @@ describe("redisValuePresentation", () => {
|
||||
expect(redisValueSize(value)).toBe(1);
|
||||
});
|
||||
|
||||
it("uses the original byte count for a truncated Redis String preview", () => {
|
||||
const value = {
|
||||
key_display: "bf:ali_health_monitor",
|
||||
key_raw: "YmY6YWxpX2hlYWx0aF9tb25pdG9y",
|
||||
ttl: -1,
|
||||
redis_type: "string",
|
||||
data: {
|
||||
kind: "string" as const,
|
||||
content: { raw_base64: "cHJldmlldw==", encoding: "utf8" as const },
|
||||
total_bytes: 45 * 1024 * 1024,
|
||||
truncated: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(redisValueSize(value)).toBe(45 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("labels raw text views by encoding instead of generic raw text", () => {
|
||||
expect(formatRedisMemberDetail("plain-text").rawLabel).toBe("ASCII");
|
||||
expect(
|
||||
|
||||
@@ -2542,7 +2542,7 @@ export interface RedisStreamPendingPage {
|
||||
}
|
||||
|
||||
export type RedisValueData =
|
||||
| { kind: "string"; content: RedisBlob }
|
||||
| { kind: "string"; content: RedisBlob; total_bytes?: number; truncated?: boolean }
|
||||
| { kind: "json"; value: string }
|
||||
| {
|
||||
kind: "list";
|
||||
|
||||
@@ -446,7 +446,7 @@ export function redisValueCollectionScanCursor(value: RedisValue): number | unde
|
||||
export function redisValueSize(value: RedisValue): number {
|
||||
switch (value.data.kind) {
|
||||
case "string":
|
||||
return decodeRedisBlob(value.data.content).byteLength;
|
||||
return value.data.total_bytes ?? decodeRedisBlob(value.data.content).byteLength;
|
||||
case "json":
|
||||
return new TextEncoder().encode(redisJsonValueText(value.data)).byteLength;
|
||||
case "list":
|
||||
|
||||
Reference in new issue
Block a user