From ffaede31b9f7c3648295af4fdc584738c4b44880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Wed, 5 Aug 2026 09:46:18 +0800 Subject: [PATCH] feat(redis): support score sorting and inline sorted-set editing --- .../RedisValueViewer.streamMonitoring.spec.ts | 50 ++- .../src/components/redis/RedisValueViewer.vue | 241 ++++++++++++--- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../redis/RedisValueViewer.redisJson.spec.ts | 8 + apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 7 +- apps/desktop/src/lib/backend/tauri.ts | 7 +- crates/dbx-core/src/db/redis_driver.rs | 291 +++++++++++++++--- crates/dbx-core/src/redis_ops.rs | 39 ++- crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/redis.rs | 34 ++ src-tauri/src/commands/redis_cmd.rs | 27 ++ src-tauri/src/lib.rs | 1 + 20 files changed, 621 insertions(+), 102 deletions(-) diff --git a/apps/desktop/src/components/redis/RedisValueViewer.streamMonitoring.spec.ts b/apps/desktop/src/components/redis/RedisValueViewer.streamMonitoring.spec.ts index efcf25463..51470c724 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.streamMonitoring.spec.ts +++ b/apps/desktop/src/components/redis/RedisValueViewer.streamMonitoring.spec.ts @@ -37,6 +37,11 @@ vi.mock("@/lib/common/shikiJsonHighlighter", () => ({ createShikiJsonHighlighter: vi.fn().mockResolvedValue(() => ""), })); +vi.mock("@/lib/redis/redisCompression", () => ({ + decompressRedisValue: vi.fn().mockResolvedValue({ ok: false, reason: "corrupt" }), + isGzipMagic: vi.fn().mockReturnValue(false), +})); + vi.mock("vue-virtual-scroller", async () => { const { defineComponent, h } = await import("vue"); const DynamicScroller = defineComponent({ @@ -51,8 +56,13 @@ vi.mock("vue-virtual-scroller", async () => { }, }); const RecycleScroller = defineComponent({ - setup(_, { slots }) { - return () => h("div", slots.default?.()); + props: { items: { type: Array, default: () => [] } }, + setup(props, { slots }) { + return () => + h( + "div", + (props.items as unknown[]).flatMap((item, index) => slots.default?.({ item, index }) ?? []), + ); }, }); @@ -96,8 +106,22 @@ function streamValue(entries = [] as ReturnType[], nextCurso }; } -function blob(raw_base64: string) { - return { raw_base64, encoding: "utf8" as const }; +function blob(raw_base64: string, encoding: "utf8" | "binary" = "utf8") { + return { raw_base64, encoding }; +} + +function zsetValue() { + return { + key_display: "scores", + key_raw: "c2NvcmVz", + ttl: -1, + redis_type: "zset", + data: { + kind: "zset" as const, + items: [{ score: "1", member: blob("AP8=", "binary") }], + total: 1, + }, + }; } function deferred() { @@ -443,3 +467,21 @@ describe("RedisValueViewer stream monitoring", () => { expect(host.querySelector("[data-redis-stream-pending]")?.textContent).toContain("1714470000000-0"); }); }); + +describe("RedisValueViewer ZSet member details", () => { + it("opens the full detail dialog for a binary ZSet member", async () => { + mocks.redisGetValue.mockResolvedValue(zsetValue()); + const host = mountViewer(); + await settle(); + + const viewMember = host.querySelector("[data-redis-zset-view-member]"); + expect(viewMember).not.toBeNull(); + + viewMember!.click(); + await settle(); + + const detail = document.querySelector("[data-redis-member-detail]"); + expect(detail).not.toBeNull(); + expect(detail!.textContent).toContain("Base64"); + }); +}); diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index e5e5d7de8..2e8570b9c 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -4,7 +4,7 @@ import type { CalendarDateTime } from "@internationalized/date"; import { useI18n } from "vue-i18n"; import { onClickOutside } from "@vueuse/core"; import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller"; -import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search, FileArchive } from "@lucide/vue"; +import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search, X, FileArchive } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -156,6 +156,10 @@ const selectedMemberContext = ref(null); const isEditingMember = ref(false); const savingMember = ref(false); const memberEditValue = ref(""); +const editingZsetMemberKey = ref(null); +const savingZsetMember = ref(false); +const zsetInlineMember = ref(""); +const zsetInlineScore = ref(""); const hashTableRef = ref(null); const hashFieldWidth = ref(280); const isResizingHashColumns = ref(false); @@ -358,6 +362,7 @@ function handleDocumentVisibilityChange() { const hashSortBy = ref<"field" | "value" | null>(null); const hashSortDir = ref<"asc" | "desc">("asc"); +const zsetSortDir = ref<"asc" | "desc">("asc"); /** * In-content find (Ctrl+F) for: * - Redis STRING keys @@ -391,6 +396,18 @@ function toggleHashSort(column: "field" | "value") { } } +async function toggleZsetSort() { + if (redisKind.value !== "zset" || loading.value || loadingMore.value || editingZsetMemberKey.value !== null) return; + const previousDirection = zsetSortDir.value; + zsetSortDir.value = previousDirection === "asc" ? "desc" : "asc"; + try { + await load({ notifyParent: false }); + } catch (error) { + zsetSortDir.value = previousDirection; + toast(errorMessage(error), 3000); + } +} + const redisKind = computed(() => data.value?.data.kind ?? "unknown"); const isStringLikeKind = computed(() => redisKind.value === "string"); const stringBlob = computed(() => { @@ -480,14 +497,14 @@ const hasRetainedMemberDraft = computed(() => { } return memberEditValue.value !== original; }); -const hasUnsavedRedisDraft = computed(() => hasRetainedStringDraft.value || redisJsonValueChanged.value || hasRetainedMemberDraft.value); +const hasUnsavedRedisDraft = computed(() => hasRetainedStringDraft.value || redisJsonValueChanged.value || hasRetainedMemberDraft.value || editingZsetMemberKey.value !== null); const hasMore = computed(() => scanCursor.value != null && scanCursor.value > 0); const collectionTotal = computed(() => (data.value ? redisValueCollectionTotal(data.value) : null)); const hashGridStyle = computed(() => ({ gridTemplateColumns: `${hashFieldWidth.value}px minmax(12rem, 1fr) 84px`, })); const zsetGridStyle = computed(() => ({ - gridTemplateColumns: `${zsetScoreWidth.value}px minmax(0, 1fr) 84px`, + gridTemplateColumns: `60px ${zsetScoreWidth.value}px minmax(0, 1fr) 104px`, })); const metadataSizeLabel = computed(() => { const metadata = props.metadata; @@ -548,14 +565,33 @@ const setRows = computed[]>(() => })) : [], ); +function zsetScoreValue(score: string): number { + const normalized = score.toLowerCase(); + if (normalized === "-inf") return -Infinity; + if (normalized === "inf" || normalized === "+inf") return Infinity; + return Number(score); +} + +function isValidZsetScore(score: string): boolean { + const normalized = score.toLowerCase(); + return normalized === "-inf" || normalized === "inf" || normalized === "+inf" || Number.isFinite(Number(score)); +} + +const sortedZsetItems = computed(() => { + if (redisKind.value !== "zset") return []; + const multiplier = zsetSortDir.value === "asc" ? 1 : -1; + return [...(collectionItems.value as RedisZsetItem[])].sort((a, b) => { + const difference = zsetScoreValue(a.score) - zsetScoreValue(b.score); + return Number.isNaN(difference) ? a.score.localeCompare(b.score) * multiplier : difference * multiplier; + }); +}); + const zsetRows = computed[]>(() => - redisKind.value === "zset" - ? (collectionItems.value as RedisZsetItem[]).map((value, index) => ({ - id: collectionRowId(value, index), - index, - value, - })) - : [], + sortedZsetItems.value.map((value, index) => ({ + id: collectionRowId(value, index), + index, + value, + })), ); const usesJsonEditorForMain = computed(() => (isStringLikeKind.value && stringValueView.value === "json" && Boolean(stringValueDetail.value?.json)) || redisKind.value === "json"); @@ -607,7 +643,7 @@ let zsetResizeStartWidth = 0; function shouldPauseAutoValueRefresh(): boolean { const loadedPageSize = data.value ? redisValueCollectionItems(data.value).length : 0; const hasExpandedCollectionPage = collectionItems.value.length > loadedPageSize; - return showMemberDetail.value || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage; + return showMemberDetail.value || editingZsetMemberKey.value !== null || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage; } type PendingDelete = { kind: "key" } | { kind: "hash"; field: string } | { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "zset"; member: string }; @@ -1168,7 +1204,20 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre const requestId = ++loadRequestId; if (!background) loading.value = true; try { - const loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw); + let loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw); + if (loadedValue.data.kind === "zset" && zsetSortDir.value === "desc") { + const sortedPage = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, "zset", 0, 200, undefined, "desc"); + if (sortedPage.kind === "zset") { + loadedValue = { + ...loadedValue, + data: { + ...loadedValue.data, + items: sortedPage.items, + scan_cursor: sortedPage.scan_cursor, + }, + }; + } + } if (requestId !== loadRequestId || (options.shouldApply && !options.shouldApply())) return false; // Redis reports a key that expired between refreshes as a `none` value. @@ -1178,6 +1227,7 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre // the draft and defer even a missing-key update until the user decides // whether to save or discard it. if (background && options.preserveDraft && hasUnsavedRedisDraft.value) return false; + resetZsetInlineEdit(); data.value = null; collectionItems.value = []; scanCursor.value = undefined; @@ -1211,6 +1261,9 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre if (notifyParent) emit("loaded", loadedValue); scanCursor.value = redisValueCollectionScanCursor(loadedValue); collectionItems.value = redisValueCollectionItems(loadedValue); + if (editingZsetMemberKey.value !== null && (loadedValue.data.kind !== "zset" || !loadedValue.data.items.some((item) => zsetInlineEditKey(item) === editingZsetMemberKey.value))) { + resetZsetInlineEdit(); + } replaceStreamEntries(loadedValue); if (loadedValue.data.kind !== "stream") resetStreamMonitoring(); @@ -1260,7 +1313,8 @@ async function loadMore() { const requestId = hashSearchRequestId; loadingMore.value = true; try { - const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, hashFilter); + const sortDirection = keyType === "zset" ? zsetSortDir.value : undefined; + const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, hashFilter, sortDirection); if (keyType === "hash" && requestId !== hashSearchRequestId) return; const newItems = redisCollectionPageItems(result); collectionItems.value = [...collectionItems.value, ...newItems]; @@ -1515,6 +1569,7 @@ function stopResizeHashColumns() { isResizingHashColumns.value = false; window.removeEventListener("pointermove", resizeHashColumns); window.removeEventListener("pointerup", stopResizeHashColumns); + window.removeEventListener("pointercancel", stopResizeHashColumns); } function resizeHashColumns(event: PointerEvent) { @@ -1529,12 +1584,13 @@ function startResizeHashColumns(event: PointerEvent) { hashResizeStartWidth = hashFieldWidth.value; window.addEventListener("pointermove", resizeHashColumns); window.addEventListener("pointerup", stopResizeHashColumns); + window.addEventListener("pointercancel", stopResizeHashColumns); } function clampZsetScoreWidth(width: number) { const containerWidth = zsetTableRef.value?.clientWidth ?? 900; const min = 120; - const max = Math.max(min, containerWidth - 220); + const max = Math.max(min, containerWidth - 300); return Math.min(max, Math.max(min, width)); } @@ -1542,6 +1598,7 @@ function stopResizeZsetColumns() { isResizingZsetColumns.value = false; window.removeEventListener("pointermove", resizeZsetColumns); window.removeEventListener("pointerup", stopResizeZsetColumns); + window.removeEventListener("pointercancel", stopResizeZsetColumns); } function resizeZsetColumns(event: PointerEvent) { @@ -1556,6 +1613,68 @@ function startResizeZsetColumns(event: PointerEvent) { zsetResizeStartWidth = zsetScoreWidth.value; window.addEventListener("pointermove", resizeZsetColumns); window.addEventListener("pointerup", stopResizeZsetColumns); + window.addEventListener("pointercancel", stopResizeZsetColumns); +} + +function zsetInlineEditKey(item: RedisZsetItem): string { + return item.member.raw_base64; +} + +function isEditingZsetRow(item: RedisZsetItem): boolean { + return editingZsetMemberKey.value === zsetInlineEditKey(item); +} + +function startZsetInlineEdit(item: RedisZsetItem) { + const member = redisBlobText(item.member); + if (member == null || savingZsetMember.value) return; + editingZsetMemberKey.value = zsetInlineEditKey(item); + zsetInlineMember.value = member; + zsetInlineScore.value = item.score; +} + +function resetZsetInlineEdit() { + editingZsetMemberKey.value = null; + zsetInlineMember.value = ""; + zsetInlineScore.value = ""; +} + +function cancelZsetInlineEdit() { + if (savingZsetMember.value) return; + resetZsetInlineEdit(); +} + +async function saveZsetInlineEdit(item: RedisZsetItem) { + const originalMember = redisBlobText(item.member); + const scoreText = zsetInlineScore.value.trim(); + if (originalMember == null || savingZsetMember.value) return; + if (!scoreText || !isValidZsetScore(scoreText)) { + toast(t("redis.createScoreInvalid"), 3000); + return; + } + if (!zsetInlineMember.value.trim()) { + toast(t("redis.memberRequired"), 3000); + return; + } + + savingZsetMember.value = true; + try { + try { + const usedAclCompatibility = await api.redisZsetUpdate(props.connectionId, props.db, props.keyRaw, originalMember, item.score, zsetInlineMember.value, scoreText); + if (usedAclCompatibility) toast(t("redis.zsetAclCompatibilityWarning"), 5000); + } catch (error) { + toast(errorMessage(error), 3000); + return; + } + + resetZsetInlineEdit(); + try { + await load(); + } catch (error) { + toast(t("redis.updateAppliedRefreshFailed", { message: errorMessage(error) }), 5000); + } + } finally { + savingZsetMember.value = false; + } } function startEditMember() { @@ -1608,8 +1727,13 @@ async function saveMemberEdit() { nextContext = { kind: "set", member: writeValue, canEdit: true }; } else if (context.kind === "zset") { if (!context.member) return; - await api.redisZrem(props.connectionId, props.db, props.keyRaw, context.member); - await api.redisZadd(props.connectionId, props.db, props.keyRaw, writeValue, Number(context.score)); + try { + const usedAclCompatibility = await api.redisZsetUpdate(props.connectionId, props.db, props.keyRaw, context.member, context.score, writeValue, context.score); + if (usedAclCompatibility) toast(t("redis.zsetAclCompatibilityWarning"), 5000); + } catch (error) { + toast(errorMessage(error), 3000); + return; + } nextContext = { kind: "zset", member: writeValue, score: context.score, canEdit: true }; } const editedValue = writeValue; @@ -2445,17 +2569,14 @@ defineExpose({ focusSearch });
-
- Field - - - -
+
+ +
Value @@ -2511,39 +2632,57 @@ defineExpose({ focusSearch });
-
- Score -
+
#
+
+ +
Member
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 0ac394159..ac3d4e1d2 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3662,6 +3662,8 @@ export default { fieldRequired: "Enter a field name", valueRequired: "Enter a value", memberRequired: "Enter a member", + updateAppliedRefreshFailed: "The change was saved, but the value could not be refreshed: {message}", + zsetAclCompatibilityWarning: "The change was saved using ACL compatibility mode; concurrent score changes could not be checked.", ttlInvalid: "Enter a valid TTL in seconds (-1 for no expiry)", member: "Member", memberDetail: "Member detail", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 2676cad43..21957cd00 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3513,6 +3513,8 @@ export default withEnglishFallback({ fieldRequired: "Ingresa un nombre de campo", valueRequired: "Ingresa un valor", memberRequired: "Ingresa un miembro", + updateAppliedRefreshFailed: "El cambio se guardó, pero no se pudo actualizar el valor: {message}", + zsetAclCompatibilityWarning: "El cambio se guardó en modo de compatibilidad ACL; no se pudieron comprobar cambios simultáneos en la puntuación.", ttlInvalid: "Ingresa un TTL válido en segundos (-1 para sin caducidad)", member: "Miembro", memberDetail: "Detalle del miembro", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 7aa83c927..bd59988bb 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3511,6 +3511,8 @@ export default withEnglishFallback({ fieldRequired: "Inserisci un nome campo", valueRequired: "Inserisci un valore", memberRequired: "Inserisci un membro", + updateAppliedRefreshFailed: "La modifica è stata salvata, ma non è stato possibile aggiornare il valore: {message}", + zsetAclCompatibilityWarning: "La modifica è stata salvata in modalità compatibilità ACL; non è stato possibile verificare modifiche concorrenti al punteggio.", ttlInvalid: "Inserisci un TTL valido in secondi (-1 per nessuna scadenza)", member: "Membro", memberDetail: "Dettaglio membro", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index c01cf461f..55f2f6221 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3543,6 +3543,8 @@ export default withEnglishFallback({ fieldRequired: "フィールド名を入力してください", valueRequired: "値を入力してください", memberRequired: "メンバーを入力してください", + updateAppliedRefreshFailed: "変更は保存されましたが、値を更新できませんでした: {message}", + zsetAclCompatibilityWarning: "ACL 互換モードで保存しました。同時に行われたスコア変更は確認できませんでした。", ttlInvalid: "有効なTTLを秒で入力してください(-1で期限なし)", member: "メンバー", memberDetail: "メンバー詳細", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 679504ef9..c1976ee4e 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3210,6 +3210,8 @@ export default withEnglishFallback({ fieldRequired: "필드 이름을 입력하세요", valueRequired: "값을 입력하세요", memberRequired: "멤버를 입력하세요", + updateAppliedRefreshFailed: "변경 사항은 저장되었지만 값을 새로 고치지 못했습니다: {message}", + zsetAclCompatibilityWarning: "ACL 호환 모드로 저장했습니다. 동시에 변경된 점수는 확인할 수 없습니다.", ttlInvalid: "초 단위의 올바른 TTL을 입력하세요 (만료 없음은 -1)", member: "멤버", memberDetail: "멤버 상세", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1be4ad1f8..50cb93683 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3513,6 +3513,8 @@ export default withEnglishFallback({ fieldRequired: "Insira um nome de campo", valueRequired: "Insira um valor", memberRequired: "Insira um membro", + updateAppliedRefreshFailed: "A alteração foi salva, mas não foi possível atualizar o valor: {message}", + zsetAclCompatibilityWarning: "A alteração foi salva no modo de compatibilidade ACL; não foi possível verificar alterações simultâneas na pontuação.", ttlInvalid: "Insira um TTL válido em segundos (-1 para sem expiração)", member: "Membro", memberDetail: "Detalhe do membro", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index f91f149d1..59b090be0 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3662,6 +3662,8 @@ export default withEnglishFallback({ fieldRequired: "请输入字段名", valueRequired: "请输入值", memberRequired: "请输入成员", + updateAppliedRefreshFailed: "修改已保存,但刷新值失败:{message}", + zsetAclCompatibilityWarning: "修改已通过 ACL 兼容模式保存,无法校验并发的分数变更。", ttlInvalid: "请输入有效的 TTL(秒数,-1 表示永不过期)", member: "成员", memberDetail: "成员详情", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 3f21998f8..b4771b83d 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2981,6 +2981,8 @@ export default withEnglishFallback({ fieldRequired: "請輸入欄位名稱", valueRequired: "請輸入值", memberRequired: "請輸入成員", + updateAppliedRefreshFailed: "變更已儲存,但重新整理值失敗:{message}", + zsetAclCompatibilityWarning: "變更已透過 ACL 相容模式儲存,無法驗證並行的分數變更。", ttlInvalid: "請輸入有效的 TTL(秒數,-1 表示永不過期)", member: "成員", memberDetail: "成員詳情", diff --git a/apps/desktop/src/lib/__tests__/redis/RedisValueViewer.redisJson.spec.ts b/apps/desktop/src/lib/__tests__/redis/RedisValueViewer.redisJson.spec.ts index de7e593ac..5c8759e1e 100644 --- a/apps/desktop/src/lib/__tests__/redis/RedisValueViewer.redisJson.spec.ts +++ b/apps/desktop/src/lib/__tests__/redis/RedisValueViewer.redisJson.spec.ts @@ -260,4 +260,12 @@ describe("native RedisJSON editor", () => { expect(directiveExpression(memberTextarea, "bind", "readonly")).toBe("savingMember"); expect(directiveExpression(refreshButton, "bind", "disabled")).toBe("loading || refreshingValue || hasUnsavedRedisDraft"); }); + + it("passes the loaded ZSet score into both update entry points", () => { + const inlineSave = findFunction("saveZsetInlineEdit").getText(); + const detailSave = findFunction("saveMemberEdit").getText(); + + expect(inlineSave).toContain("originalMember, item.score, zsetInlineMember.value, scoreText"); + expect(detailSave).toContain("context.member, context.score, writeValue, context.score"); + }); }); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index e1aabc038..e00df892a 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -413,6 +413,7 @@ export const redisSetAdd = forward("redisSetAdd"); export const redisSetRemove = forward("redisSetRemove"); export const redisZadd = forward("redisZadd"); export const redisZrem = forward("redisZrem"); +export const redisZsetUpdate = forward("redisZsetUpdate"); export const redisStreamAdd = forward("redisStreamAdd"); export const redisJsonSet = forward("redisJsonSet"); export const redisCheckJsonModule = forward("redisCheckJsonModule"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index be53e4e21..ea50c2841 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -2416,6 +2416,10 @@ export async function redisZrem(connectionId: string, db: number, keyRaw: string return post("/api/redis/zrem", { connectionId, db, keyRaw, member }); } +export async function redisZsetUpdate(connectionId: string, db: number, keyRaw: string, originalMember: string, expectedScore: string, member: string, score: string): Promise { + return post("/api/redis/zset-update", { connectionId, db, keyRaw, originalMember, expectedScore, member, score }); +} + export async function redisStreamAdd(connectionId: string, db: number, keyRaw: string, entryId: string, fields: [string, string][], ttl?: number): Promise { return post("/api/redis/stream-add", { connectionId, @@ -2465,7 +2469,7 @@ export async function redisExecuteCommand(connectionId: string, db: number, comm }); } -export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise { +export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string, sortDirection?: "asc" | "desc"): Promise { return post("/api/redis/load-more", { connectionId, db, @@ -2474,6 +2478,7 @@ export async function redisLoadMore(connectionId: string, db: number, keyRaw: st cursor, count, filter, + sortDirection, }); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index a6201b2a6..817ff07af 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -2231,6 +2231,10 @@ export async function redisZrem(connectionId: string, db: number, keyRaw: string return invoke("redis_zrem", { connectionId, db, keyRaw, member }); } +export async function redisZsetUpdate(connectionId: string, db: number, keyRaw: string, originalMember: string, expectedScore: string, member: string, score: string): Promise { + return invoke("redis_zset_update", { connectionId, db, keyRaw, originalMember, expectedScore, member, score }); +} + export async function redisStreamAdd(connectionId: string, db: number, keyRaw: string, entryId: string, fields: [string, string][], ttl?: number): Promise { return invoke("redis_stream_add", { connectionId, @@ -2275,7 +2279,7 @@ export async function redisExecuteCommand(connectionId: string, db: number, comm }); } -export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string): Promise { +export async function redisLoadMore(connectionId: string, db: number, keyRaw: string, keyType: string, cursor: number, count: number, filter?: string, sortDirection?: "asc" | "desc"): Promise { return invoke("redis_load_more", { connectionId, db, @@ -2284,6 +2288,7 @@ export async function redisLoadMore(connectionId: string, db: number, keyRaw: st cursor, count, filter, + sortDirection, }); } diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index 706357e0e..e8f83c6cb 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -2329,8 +2329,10 @@ where } "zset" => { let len: u64 = redis::cmd("ZCARD").arg(key).query_async(con).await.unwrap_or(0); - let (cursor, items) = zscan_page_raw(con, key, 0, COLLECTION_PAGE_SIZE).await?; - RedisValueData::Zset { items, total: len, scan_cursor: (cursor > 0).then_some(cursor) } + let end = (COLLECTION_PAGE_SIZE as i64) - 1; + let items = zrange_page_raw(con, key, 0, end, false).await?; + let cursor = if len > COLLECTION_PAGE_SIZE as u64 { Some(COLLECTION_PAGE_SIZE as u64) } else { None }; + RedisValueData::Zset { items, total: len, scan_cursor: cursor } } "hash" => { let len: u64 = redis::cmd("HLEN").arg(key).query_async(con).await.unwrap_or(0); @@ -2927,6 +2929,77 @@ where redis::cmd("ZREM").arg(key).arg(member).query_async::<()>(con).await.map_err(|e| e.to_string()) } +pub async fn zset_update( + con: &mut C, + key: &[u8], + original_member: &str, + expected_score: &str, + member: &str, + score: &str, +) -> Result +where + C: ConnectionLike + Send + Sync + Unpin, +{ + // Keep the source check, duplicate check, score write, and optional rename + // in one Redis operation so a failed save cannot delete the original member. + const SCRIPT: &str = r#" + local current_score = redis.call('ZSCORE', KEYS[1], ARGV[1]) + if current_score == false then + return 0 + end + if current_score ~= ARGV[3] then + return -2 + end + if ARGV[1] ~= ARGV[2] and redis.call('ZSCORE', KEYS[1], ARGV[2]) ~= false then + return -1 + end + redis.call('ZADD', KEYS[1], ARGV[4], ARGV[2]) + if ARGV[1] ~= ARGV[2] then + redis.call('ZREM', KEYS[1], ARGV[1]) + end + return 1 + "#; + + let result = redis::cmd("EVAL") + .arg(SCRIPT) + .arg(1) + .arg(key) + .arg(original_member) + .arg(member) + .arg(expected_score) + .arg(score) + .query_async::(con) + .await; + + let result = match result { + Ok(result) => result, + Err(error) if is_zset_update_acl_compatibility_error(&error) => { + return Err( + "Atomic ZSet updates require the Redis EVAL and ZSCORE permissions. Ask an administrator to grant them." + .to_string(), + ); + } + Err(error) => return Err(error.to_string()), + }; + + match result { + 1 => Ok(false), + 0 => Err("The ZSet member no longer exists. Refresh and try again.".to_string()), + -1 => Err("A ZSet member with the new value already exists.".to_string()), + -2 => Err("The ZSet score changed after it was loaded. Refresh and try again.".to_string()), + _ => Err("Unexpected result while updating ZSet member.".to_string()), + } +} + +fn is_zset_update_acl_compatibility_error(error: &redis::RedisError) -> bool { + if error.code() != Some("NOPERM") { + return false; + } + + let detail = error.detail().unwrap_or_default().to_ascii_lowercase(); + detail.contains("eval") || detail.contains("zscore") +} + pub async fn stream_add( con: &mut C, key: &[u8], @@ -3023,6 +3096,7 @@ pub async fn load_more_collection( cursor: u64, count: usize, filter_query: Option<&str>, + sort_direction: Option<&str>, ) -> Result where C: ConnectionLike + Send + Sync + Unpin, @@ -3045,8 +3119,18 @@ where Ok(RedisCollectionPage::Set { items, scan_cursor: (next_cursor > 0).then_some(next_cursor) }) } "zset" => { - let (next_cursor, items) = zscan_page_raw(con, key, cursor, count).await?; - Ok(RedisCollectionPage::Zset { items, scan_cursor: (next_cursor > 0).then_some(next_cursor) }) + let descending = match sort_direction.unwrap_or("asc") { + "asc" => false, + "desc" => true, + direction => return Err(format!("Invalid ZSet sort direction: {direction}")), + }; + let start = cursor as i64; + let end = start + count as i64; + let mut items = zrange_page_raw(con, key, start, end, descending).await?; + let has_more = items.len() > count; + items.truncate(count); + let next = cursor + count as u64; + Ok(RedisCollectionPage::Zset { items, scan_cursor: has_more.then_some(next) }) } "hash" => { let (next_cursor, items) = if let Some(query) = filter_query.filter(|query| !query.is_empty()) { @@ -3137,56 +3221,42 @@ where parse_scan_members(raw) } -async fn zscan_page_raw( +async fn zrange_page_raw( con: &mut C, key: &[u8], - cursor: u64, - count: usize, -) -> Result<(u64, Vec), String> + start: i64, + end: i64, + descending: bool, +) -> Result, String> where C: ConnectionLike + Send + Sync + Unpin, { - let raw: RedisRawValue = redis::cmd("ZSCAN") + let command = if descending { "ZREVRANGE" } else { "ZRANGE" }; + let raw: RedisRawValue = redis::cmd(command) .arg(key) - .arg(cursor) - .arg("COUNT") - .arg(count) + .arg(start) + .arg(end) + .arg("WITHSCORES") .query_async(con) .await .map_err(|e| e.to_string())?; - let (next_cursor, items) = parse_scan_pairs(raw)?; - Ok((next_cursor, items.into_iter().map(|(member, score)| RedisZsetItem { score, member }).collect())) -} -fn parse_scan_pairs(raw: RedisRawValue) -> Result<(u64, Vec<(RedisBlob, String)>), String> { - let RedisRawValue::Array(parts) = raw else { - return Err("Invalid SCAN response".to_string()); + let RedisRawValue::Array(entries) = raw else { + return Err("Invalid ZRANGE response".to_string()); }; - if parts.len() != 2 { - return Err("Invalid SCAN response".to_string()); + if entries.len() % 2 != 0 { + return Err("Invalid ZRANGE response".to_string()); } - let cursor = redis_value_to_string(parts[0].clone()) - .ok_or("Invalid cursor")? - .parse::() - .map_err(|_| "Invalid cursor".to_string())?; - - let RedisRawValue::Array(entries) = &parts[1] else { - return Err("Invalid SCAN entries".to_string()); - }; - - let mut items = Vec::new(); - let mut iter = entries.iter(); - while let Some(a) = iter.next() { - let Some(b) = iter.next() else { break }; - let member = redis_value_to_bytes(a.clone()) + let mut items = Vec::with_capacity(entries.len() / 2); + for pair in entries.chunks_exact(2) { + let member = redis_value_to_bytes(pair[0].clone()) .map(|bytes| redis_blob_from_bytes(&bytes)) - .ok_or_else(|| "Invalid SCAN member payload".to_string())?; - let value = redis_value_to_string(b.clone()).unwrap_or_default(); - items.push((member, value)); + .ok_or_else(|| "Invalid ZRANGE member payload".to_string())?; + let score = redis_value_to_string(pair[1].clone()).ok_or_else(|| "Invalid ZRANGE score".to_string())?; + items.push(RedisZsetItem { score, member }); } - - Ok((cursor, items)) + Ok(items) } fn parse_scan_hash_entries(raw: RedisRawValue) -> Result<(u64, Vec), String> { @@ -3270,7 +3340,7 @@ mod tests { redis_value_to_bytes, standalone_connection_infos, RedisAuthCandidate, RedisBlob, RedisBlobEncoding, RedisClusterSlotRange, RedisCollectionPage, RedisCommandSafety, RedisHashItem, RedisNodeEndpoint, RedisNodeRoute, RedisRawValue, RedisSetItem, RedisStreamConsumer, RedisStreamEntry, RedisStreamField, - RedisStreamGroup, RedisStreamPendingEntry, RedisValue, RedisValueData, + RedisStreamGroup, RedisStreamPendingEntry, RedisValue, RedisValueData, RedisZsetItem, }; use crate::models::connection::ConnectionConfig; use redis::{aio::ConnectionLike, Cmd, ConnectionAddr, Pipeline, RedisFuture}; @@ -3379,6 +3449,17 @@ mod tests { RedisRawValue::Array(vec![bulk(cursor), RedisRawValue::Array(entries)]) } + fn zrange_response(pairs: Vec<(&str, &str)>) -> RedisRawValue { + RedisRawValue::Array(pairs.into_iter().flat_map(|(member, score)| [bulk(member), bulk(score)]).collect()) + } + + fn noperm(command: &str) -> redis::RedisError { + redis::make_extension_error( + "NOPERM".to_string(), + Some(format!("this user has no permissions to run the '{command}' command")), + ) + } + fn text_blob(value: &str) -> RedisBlob { redis_blob_from_bytes(value.as_bytes()) } @@ -4545,7 +4626,8 @@ mod tests { hscan_response("0", vec![("user:2", "Bob")]), ]); - let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 1, Some("user")).await.unwrap(); + let result = + super::load_more_collection(&mut con, b"hash-key", "hash", 0, 1, Some("user"), None).await.unwrap(); let RedisCollectionPage::Hash { items, scan_cursor } = result else { panic!("expected hash collection page"); @@ -4561,7 +4643,8 @@ mod tests { let mut con = FakeRedisConnection::new(vec![hscan_response("0", vec![("status", "Ada Lovelace"), ("name", "Bob")])]); - let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("lovelace")).await.unwrap(); + let result = + super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("lovelace"), None).await.unwrap(); let RedisCollectionPage::Hash { items, scan_cursor } = result else { panic!("expected hash collection page"); @@ -4579,7 +4662,8 @@ mod tests { .collect(); let mut con = FakeRedisConnection::new(responses); - let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("missing")).await.unwrap(); + let result = + super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("missing"), None).await.unwrap(); let RedisCollectionPage::Hash { items, scan_cursor } = result else { panic!("expected hash collection page"); @@ -4589,6 +4673,127 @@ mod tests { assert_eq!(con.command_count("HSCAN"), super::HASH_FILTER_SCAN_MAX_ITERATIONS); } + #[tokio::test] + async fn zset_update_passes_expected_score_to_the_atomic_script() { + let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(1)]); + + let used_acl_compatibility = + super::zset_update(&mut con, b"scores", "alice", "10", "alice", "20").await.unwrap(); + + assert!(!used_acl_compatibility); + assert_eq!(con.command_count("EVAL"), 1); + assert_eq!(con.command_count("ZADD"), 0); + assert!(con.commands[0].contains("\r\n$2\r\n10\r\n")); + assert!(con.commands[0].contains("\r\n$2\r\n20\r\n")); + } + + #[tokio::test] + async fn zset_update_rejects_a_second_writer_with_a_stale_score() { + let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(-2)]); + + let error = super::zset_update(&mut con, b"scores", "alice", "10", "alice", "30").await.unwrap_err(); + + assert!(error.contains("score changed")); + assert_eq!(con.command_count("EVAL"), 1); + assert_eq!(con.commands.len(), 1); + } + + #[tokio::test] + async fn zset_update_rejects_a_second_writer_renaming_a_member_with_a_stale_score() { + let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(-2)]); + + let error = super::zset_update(&mut con, b"scores", "alice", "10", "alicia", "20").await.unwrap_err(); + + assert!(error.contains("score changed")); + assert_eq!(con.command_count("EVAL"), 1); + assert_eq!(con.command_count("ZADD"), 0); + assert_eq!(con.command_count("ZREM"), 0); + assert_eq!(con.commands.len(), 1); + } + + #[tokio::test] + async fn zset_update_rejects_restricted_acl_score_edits_without_writing() { + let mut con = FakeRedisConnection::with_results(vec![Err(noperm("eval"))]); + + let error = super::zset_update(&mut con, b"scores", "alice", "10", "alice", "20").await.unwrap_err(); + + assert!(error.contains("EVAL and ZSCORE permissions")); + assert_eq!(con.command_count("EVAL"), 1); + assert_eq!(con.command_count("ZADD"), 0); + assert_eq!(con.command_count("ZREM"), 0); + assert_eq!(con.commands.len(), 1); + } + + #[tokio::test] + async fn zset_update_rejects_restricted_acl_renames_without_writing() { + let mut con = FakeRedisConnection::with_results(vec![Err(noperm("zscore"))]); + + let error = super::zset_update(&mut con, b"scores", "alice", "10", "alicia", "20").await.unwrap_err(); + + assert!(error.contains("EVAL and ZSCORE permissions")); + assert_eq!(con.command_count("EVAL"), 1); + assert_eq!(con.command_count("ZADD"), 0); + assert_eq!(con.command_count("ZREM"), 0); + assert_eq!(con.commands.len(), 1); + } + + #[tokio::test] + async fn zset_update_does_not_fallback_for_unrelated_acl_errors() { + let mut con = FakeRedisConnection::with_results(vec![Err(noperm("zrem"))]); + + let error = super::zset_update(&mut con, b"scores", "alice", "10", "alicia", "20").await.unwrap_err(); + + assert!(error.contains("NOPERM")); + assert_eq!(con.command_count("EVAL"), 1); + assert_eq!(con.commands.len(), 1); + } + + #[tokio::test] + async fn zset_load_more_uses_ranked_ascending_pages() { + let mut con = + FakeRedisConnection::new(vec![zrange_response(vec![("alice", "1"), ("bob", "2"), ("carol", "3")])]); + + let result = super::load_more_collection(&mut con, b"scores", "zset", 0, 2, None, Some("asc")).await.unwrap(); + + let RedisCollectionPage::Zset { items, scan_cursor } = result else { + panic!("expected zset collection page"); + }; + assert_eq!( + items, + vec![ + RedisZsetItem { score: "1".to_string(), member: text_blob("alice") }, + RedisZsetItem { score: "2".to_string(), member: text_blob("bob") }, + ] + ); + assert_eq!(scan_cursor, Some(2)); + assert_eq!(con.command_count("ZRANGE"), 1); + assert_eq!(con.command_count("ZREVRANGE"), 0); + assert_eq!(con.command_count("ZSCAN"), 0); + } + + #[tokio::test] + async fn zset_load_more_uses_ranked_descending_pages() { + let mut con = + FakeRedisConnection::new(vec![zrange_response(vec![("carol", "3"), ("bob", "2"), ("alice", "1")])]); + + let result = super::load_more_collection(&mut con, b"scores", "zset", 0, 2, None, Some("desc")).await.unwrap(); + + let RedisCollectionPage::Zset { items, scan_cursor } = result else { + panic!("expected zset collection page"); + }; + assert_eq!( + items, + vec![ + RedisZsetItem { score: "3".to_string(), member: text_blob("carol") }, + RedisZsetItem { score: "2".to_string(), member: text_blob("bob") }, + ] + ); + assert_eq!(scan_cursor, Some(2)); + assert_eq!(con.command_count("ZRANGE"), 0); + assert_eq!(con.command_count("ZREVRANGE"), 1); + assert_eq!(con.command_count("ZSCAN"), 0); + } + #[test] fn does_not_treat_utf8_with_backslashes_as_binary() { let raw = RedisRawValue::BulkString(br#"C:\Users\path"#.to_vec()); diff --git a/crates/dbx-core/src/redis_ops.rs b/crates/dbx-core/src/redis_ops.rs index 1c0951a7b..b02ba7670 100644 --- a/crates/dbx-core/src/redis_ops.rs +++ b/crates/dbx-core/src/redis_ops.rs @@ -687,6 +687,38 @@ pub async fn redis_zrem_in_db_core( } } +pub async fn redis_zset_update_in_db_core( + state: &AppState, + connection_id: &str, + db: u32, + key_raw: &str, + original_member: &str, + expected_score: &str, + member: &str, + score: &str, +) -> Result { + ensure_redis_pool(state, connection_id).await?; + let connections = state.connections.read().await; + match connections.get(connection_id).ok_or("Not found")? { + PoolKind::Redis(redis) => { + let key = redis_driver::redis_key_raw_to_bytes(key_raw)?; + match redis { + RedisConnection::Direct(con) => { + let mut con = con.lock().await; + redis_driver::select_db(&mut *con, db).await?; + redis_driver::zset_update(&mut *con, &key, original_member, expected_score, member, score).await + } + RedisConnection::Cluster(cluster) => { + redis_driver::ensure_cluster_db(db)?; + let mut con = redis_driver::cluster_key_connection(cluster, &key).await?; + redis_driver::zset_update(&mut con, &key, original_member, expected_score, member, score).await + } + } + } + _ => Err("Not a Redis connection".to_string()), + } +} + pub async fn redis_stream_add_in_db_core( state: &AppState, connection_id: &str, @@ -941,6 +973,7 @@ pub async fn redis_load_more_in_db_core( cursor: u64, count: usize, filter: Option<&str>, + sort_direction: Option<&str>, ) -> Result { ensure_redis_pool(state, connection_id).await?; let connections = state.connections.read().await; @@ -951,12 +984,14 @@ pub async fn redis_load_more_in_db_core( RedisConnection::Direct(con) => { let mut con = con.lock().await; redis_driver::select_db(&mut *con, db).await?; - redis_driver::load_more_collection(&mut *con, &key, key_type, cursor, count, filter).await + redis_driver::load_more_collection(&mut *con, &key, key_type, cursor, count, filter, sort_direction) + .await } RedisConnection::Cluster(cluster) => { redis_driver::ensure_cluster_db(db)?; let mut con = redis_driver::cluster_key_connection(cluster, &key).await?; - redis_driver::load_more_collection(&mut con, &key, key_type, cursor, count, filter).await + redis_driver::load_more_collection(&mut con, &key, key_type, cursor, count, filter, sort_direction) + .await } } } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 38bf1b5d1..8cff8f1cc 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -512,6 +512,7 @@ async fn main() { .route("/redis/set-add", post(routes::redis::set_add)) .route("/redis/set-remove", post(routes::redis::set_remove)) .route("/redis/zadd", post(routes::redis::zadd)) + .route("/redis/zset-update", post(routes::redis::zset_update)) .route("/redis/stream-add", post(routes::redis::stream_add)) .route("/redis/json-set", post(routes::redis::json_set)) .route("/redis/check-json-module", post(routes::redis::check_json_module)) diff --git a/crates/dbx-web/src/routes/redis.rs b/crates/dbx-web/src/routes/redis.rs index 3c388639f..0417da4d5 100644 --- a/crates/dbx-web/src/routes/redis.rs +++ b/crates/dbx-web/src/routes/redis.rs @@ -115,6 +115,7 @@ pub struct RedisLoadMoreRequest { pub cursor: u64, pub count: usize, pub filter: Option, + pub sort_direction: Option, } #[derive(Deserialize)] @@ -149,6 +150,18 @@ pub struct RedisZaddRequest { pub ttl: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisZsetUpdateRequest { + pub connection_id: String, + pub db: u32, + pub key_raw: String, + pub original_member: String, + pub expected_score: String, + pub member: String, + pub score: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct RedisListRequest { @@ -415,6 +428,7 @@ pub async fn load_more( req.cursor, req.count, req.filter.as_deref(), + req.sort_direction.as_deref(), ) .await .map_err(AppError::from)?; @@ -570,6 +584,26 @@ pub async fn zadd(State(state): State>, Json(req): Json>, + Json(req): Json, +) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "ZADD/ZREM").await?; + let used_acl_compatibility = dbx_core::redis_ops::redis_zset_update_in_db_core( + &state.app, + &req.connection_id, + req.db, + &req.key_raw, + &req.original_member, + &req.expected_score, + &req.member, + &req.score, + ) + .await + .map_err(AppError::from)?; + Ok(Json(used_acl_compatibility)) +} + pub async fn stream_add( State(state): State>, Json(req): Json, diff --git a/src-tauri/src/commands/redis_cmd.rs b/src-tauri/src/commands/redis_cmd.rs index 0b3d2238b..2b59de0a7 100644 --- a/src-tauri/src/commands/redis_cmd.rs +++ b/src-tauri/src/commands/redis_cmd.rs @@ -290,6 +290,31 @@ pub async fn redis_zrem( dbx_core::redis_ops::redis_zrem_in_db_core(&state, &connection_id, db, &key_raw, &member).await } +#[tauri::command] +pub async fn redis_zset_update( + state: State<'_, Arc>, + connection_id: String, + db: u32, + key_raw: String, + original_member: String, + expected_score: String, + member: String, + score: String, +) -> Result { + ensure_connection_writable(&state, &connection_id, "ZADD/ZREM").await?; + dbx_core::redis_ops::redis_zset_update_in_db_core( + &state, + &connection_id, + db, + &key_raw, + &original_member, + &expected_score, + &member, + &score, + ) + .await +} + #[tauri::command] pub async fn redis_stream_add( state: State<'_, Arc>, @@ -407,6 +432,7 @@ pub async fn redis_load_more( cursor: u64, count: usize, filter: Option, + sort_direction: Option, ) -> Result { dbx_core::redis_ops::redis_load_more_in_db_core( &state, @@ -417,6 +443,7 @@ pub async fn redis_load_more( cursor, count, filter.as_deref(), + sort_direction.as_deref(), ) .await } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1519d31b7..aa47dd801 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1706,6 +1706,7 @@ pub fn run() { commands::redis_cmd::redis_set_remove, commands::redis_cmd::redis_zadd, commands::redis_cmd::redis_zrem, + commands::redis_cmd::redis_zset_update, commands::redis_cmd::redis_stream_add, commands::redis_cmd::redis_json_set, commands::redis_cmd::redis_check_json_module,