feat(redis): add fuzzy key tree search

This commit is contained in:
onenewcode 2026-07-27 14:40:31 +08:00 committed by GitHub
parent 589fbb02c9
commit 4c57520a12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 901 additions and 172 deletions

View File

@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { CalendarDateTime, resetLocalTimeZone, setLocalTimeZone } from "@internationalized/date";
import { createApp, nextTick, type ComponentPublicInstance } from "vue";
import { createApp, defineComponent, h, KeepAlive, nextTick, ref, type ComponentPublicInstance } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { calendarDateTimeToUnixSeconds } from "@/components/ui/date-time-picker/dateTimePicker";
@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
redisCheckJsonModule: vi.fn(),
redisDeleteKey: vi.fn(),
redisDeleteKeys: vi.fn(),
canBuildRedisFuzzyTree: vi.fn((loadedKeyCount: number) => loadedKeyCount <= 200_000),
toast: vi.fn(),
updateRedisDbKeyStats: vi.fn(),
}));
@ -42,6 +43,11 @@ vi.mock("@/lib/backend/api", () => ({
redisDeleteKeys: mocks.redisDeleteKeys,
}));
vi.mock("@/lib/redis/redisKeyTree", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/redis/redisKeyTree")>();
return { ...actual, canBuildRedisFuzzyTree: mocks.canBuildRedisFuzzyTree };
});
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: vi.fn().mockResolvedValue(undefined),
@ -228,7 +234,30 @@ vi.mock("@/components/ui/CustomContextMenu.vue", async () => {
vi.mock("@/components/editor/DangerConfirmDialog.vue", async () => {
const { defineComponent, h } = await import("vue");
return { default: defineComponent({ setup: () => () => h("div") }) };
return {
default: defineComponent({
props: { open: Boolean, loading: Boolean, details: String },
emits: ["confirm"],
setup(props, { emit }) {
return () =>
props.open
? h("div", { "data-test-danger-dialog": "" }, [
h("div", { "data-test-danger-details": "" }, props.details),
h(
"button",
{
type: "button",
disabled: props.loading,
"data-test-danger-confirm": "",
onClick: () => emit("confirm"),
},
"Confirm",
),
])
: null;
},
}),
};
});
vi.mock("./RedisValueViewer.vue", async () => {
@ -248,7 +277,23 @@ vi.mock("./RedisSlowlogPanel.vue", async () => {
vi.mock("vue-virtual-scroller", async () => {
const { defineComponent, h } = await import("vue");
return { RecycleScroller: defineComponent({ setup: () => () => h("div") }) };
return {
RecycleScroller: defineComponent({
inheritAttrs: false,
props: { items: { type: Array, default: () => [] } },
setup(props, { attrs, slots }) {
// Mirror the real scroller: interaction tests should render a viewport,
// not every row in a deliberately large result set.
const visibleItemCount = 50;
return () =>
h(
"div",
attrs,
props.items.slice(0, visibleItemCount).map((item) => slots.default?.({ item })),
);
},
}),
};
});
vi.mock("splitpanes", async () => {
@ -284,6 +329,16 @@ function redisKeyInfo(keyType = "json") {
return { key_display: KEY_NAME, key_raw: KEY_RAW, key_type: keyType, ttl: 90, size: 7, value_preview: "{}" };
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((next, fail) => {
resolve = next;
reject = fail;
});
return { promise, resolve, reject };
}
function resetApiMocks() {
vi.clearAllMocks();
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
@ -300,6 +355,7 @@ function resetApiMocks() {
mocks.redisCheckJsonModule.mockResolvedValue(true);
mocks.redisDeleteKey.mockResolvedValue(undefined);
mocks.redisDeleteKeys.mockResolvedValue(0);
mocks.canBuildRedisFuzzyTree.mockImplementation((loadedKeyCount: number) => loadedKeyCount <= 200_000);
}
function mountBrowser() {
@ -311,6 +367,36 @@ function mountBrowser() {
mountedApps.push({ unmount: () => app.unmount(), host });
}
function mountKeptAliveBrowser() {
const active = ref(true);
const host = document.createElement("div");
document.body.append(host);
const app = createApp(
defineComponent({
setup() {
return () =>
h(KeepAlive, null, {
default: () => (active.value ? h(RedisKeyBrowser, { connectionId: "connection", db: 0, blockDangerousRedisCommands: false }) : null),
});
},
}),
);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
mountedApps.push({ unmount: () => app.unmount(), host });
return {
async deactivate() {
active.value = false;
await settle();
},
async activate() {
active.value = true;
await settle();
},
};
}
async function settle() {
await nextTick();
await Promise.resolve();
@ -338,6 +424,22 @@ async function setInput(selector: string, value: string) {
await settle();
}
async function submitKeySearch(value: string) {
const input = requiredElement<HTMLInputElement>("[data-redis-search-input]");
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
}
function groupCheckbox(label: string): HTMLInputElement {
const labelElement = Array.from(document.querySelectorAll<HTMLElement>(".dbx-editor-font-family")).find((element) => element.textContent === label);
expect(labelElement, label).toBeDefined();
const checkbox = labelElement?.closest(".group")?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox, label).toBeDefined();
return checkbox!;
}
async function select(value: string) {
const item = requiredElement<HTMLButtonElement>(`[data-test-select-value="${value}"]`);
const root = item.closest<TestSelectRoot>("[data-test-select-root]");
@ -522,3 +624,289 @@ describe("RedisKeyBrowser expiry creation", () => {
expect(mocks.toast).toHaveBeenCalledWith("TTL command failed", 5000);
});
});
describe("RedisKeyBrowser fuzzy key hierarchy", () => {
it("keeps NUL-containing fuzzy groups isolated when selecting keys to delete", async () => {
const firstKeyRaw = "cmF3LWZpcnN0";
const secondKeyRaw = "cmF3LXNlY29uZA==";
const keys = [
{ key_display: `a\0b:c:x`, key_raw: firstKeyRaw, key_type: "string", ttl: -1 },
{ key_display: `a:b\0c:y`, key_raw: secondKeyRaw, key_type: "string", ttl: -1 },
];
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length });
mocks.redisDeleteKeys.mockResolvedValue(1);
mountBrowser();
await settle();
await submitKeySearch("a");
clickButtonWithText("redis.fuzzyMatch");
await settle();
const firstGroupCheckbox = groupCheckbox(`a\0b`);
firstGroupCheckbox.checked = true;
firstGroupCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await settle();
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "1");
expect(deleteButton).toBeDefined();
deleteButton!.click();
await settle();
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
await settle();
expect(mocks.redisDeleteKeys).toHaveBeenCalledTimes(1);
expect(mocks.redisDeleteKeys).toHaveBeenCalledWith("connection", 0, [firstKeyRaw]);
expect(mocks.redisDeleteKeys.mock.calls[0]?.[2]).not.toContain(secondKeyRaw);
});
it("keeps regular key searches flat, then selects and deletes a loaded fuzzy branch", async () => {
const keys = [
{ key_display: "user:profile:email", key_raw: "cmF3LWVtYWls", key_type: "string", ttl: -1 },
{ key_display: "user:profile:name", key_raw: "cmF3LW5hbWU=", key_type: "string", ttl: -1 },
{ key_display: "user:settings", key_raw: "cmF3LXNldHRpbmdz", key_type: "hash", ttl: -1 },
];
// The first page is intentionally incomplete: branch selection must only
// submit the currently loaded raw keys, never widen into a new SCAN.
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 7, keys, total_keys: 20 });
mocks.redisDeleteKeys.mockResolvedValue(keys.length);
mountBrowser();
await settle();
await submitKeySearch("user");
// Regular glob search keeps the pre-existing flat, virtualized result path.
expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(keys.length);
clickButtonWithText("redis.fuzzyMatch");
await settle();
// Fuzzy search restores the namespace hierarchy and exposes group selection.
expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(keys.length + 2);
const userCheckbox = groupCheckbox("user");
userCheckbox.checked = true;
userCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await settle();
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(keys.length));
expect(deleteButton).toBeDefined();
deleteButton!.click();
await settle();
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
await settle();
expect(mocks.redisDeleteKeys).toHaveBeenCalledTimes(1);
const [connectionId, db, deletedKeyRaws] = mocks.redisDeleteKeys.mock.calls[0] ?? [];
expect(connectionId).toBe("connection");
expect(db).toBe(0);
expect(new Set(deletedKeyRaws)).toEqual(new Set(keys.map((key) => key.key_raw)));
expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(0);
});
it("falls back to flat rows at the fuzzy tree limit while retaining loaded-result delete wording", async () => {
const keys = [
{ key_display: "user:profile:email", key_raw: "cmF3LWVtYWls", key_type: "string", ttl: -1 },
{ key_display: "user:profile:name", key_raw: "cmF3LW5hbWU=", key_type: "string", ttl: -1 },
];
mocks.canBuildRedisFuzzyTree.mockReturnValue(false);
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length });
mocks.redisDeleteKeys.mockResolvedValue(1);
mountBrowser();
await settle();
await submitKeySearch("user");
clickButtonWithText("redis.fuzzyMatch");
await settle();
// The group controls disappear when the view falls back to virtualized rows.
expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(keys.length);
expect(document.body.textContent).toContain("redis.fuzzyTreeLimit");
requiredElement<HTMLInputElement>('input[type="checkbox"]').click();
await settle();
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "1");
expect(deleteButton).toBeDefined();
deleteButton!.click();
await settle();
expect(requiredElement<HTMLElement>("[data-test-danger-details]").textContent).toContain("redis.deleteLoadedSearchKeysDetails");
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
await settle();
expect(mocks.redisDeleteKeys).toHaveBeenCalledWith("connection", 0, [keys[0]!.key_raw]);
});
it("keeps a selected fuzzy group partial when a later SCAN page adds matching keys", async () => {
const firstPageKeys = [{ key_display: "user:one", key_raw: "dXNlci1vbmU=", key_type: "string", ttl: -1 }];
const laterPageKeys = [{ key_display: "user:two", key_raw: "dXNlci10d28=", key_type: "string", ttl: -1 }];
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 7, keys: firstPageKeys, total_keys: 2 } : { cursor: 0, keys: laterPageKeys, total_keys: 0 }));
mocks.redisDeleteKeys.mockResolvedValue(1);
mountBrowser();
await settle();
await submitKeySearch("user");
clickButtonWithText("redis.fuzzyMatch");
await settle();
const userCheckbox = groupCheckbox("user");
userCheckbox.checked = true;
userCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await settle();
clickButtonWithText("redis.loadMoreKeys");
await settle();
const updatedUserCheckbox = groupCheckbox("user");
expect(updatedUserCheckbox.checked).toBe(false);
expect(updatedUserCheckbox.indeterminate).toBe(true);
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "1");
expect(deleteButton).toBeDefined();
deleteButton!.click();
await settle();
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
await settle();
expect(mocks.redisDeleteKeys).toHaveBeenCalledWith("connection", 0, [firstPageKeys[0]!.key_raw]);
});
it("sends a large selected fuzzy group in bounded delete batches", async () => {
const keys = Array.from({ length: 1_001 }, (_, index) => ({
key_display: `batch:${String(index).padStart(4, "0")}`,
key_raw: `cmF3LWJhdGNoLS${index}`,
key_type: "string",
ttl: -1,
}));
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length });
mocks.redisDeleteKeys.mockImplementation(async (_connectionId: string, _db: number, keyRaws: string[]) => keyRaws.length);
mountBrowser();
await settle();
await submitKeySearch("batch");
clickButtonWithText("redis.fuzzyMatch");
await settle();
const batchCheckbox = groupCheckbox("batch");
batchCheckbox.checked = true;
batchCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await settle();
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(keys.length));
expect(deleteButton).toBeDefined();
deleteButton!.click();
await settle();
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
await settle();
await settle();
expect(mocks.redisDeleteKeys.mock.calls.map((call) => call[2]?.length)).toEqual([1_000, 1]);
expect(new Set(mocks.redisDeleteKeys.mock.calls.flatMap((call) => call[2] ?? []))).toEqual(new Set(keys.map((key) => key.key_raw)));
});
it("reloads the result set when a later delete batch fails after an earlier batch succeeds", async () => {
const keys = Array.from({ length: 1_001 }, (_, index) => ({
key_display: `batch:${String(index).padStart(4, "0")}`,
key_raw: `cmF3LWJhdGNoLS${index}`,
key_type: "string",
ttl: -1,
}));
const freshKeys = [{ key_display: "fresh:remaining", key_raw: "ZnJlc2gtcmVtYWluaW5n", key_type: "string", ttl: -1 }];
let returnFreshResults = false;
mocks.redisScanKeysBatch.mockImplementation(() => Promise.resolve(returnFreshResults ? { cursor: 0, keys: freshKeys, total_keys: 1 } : { cursor: 0, keys, total_keys: keys.length }));
mocks.redisDeleteKeys.mockResolvedValueOnce(1_000).mockImplementationOnce(async () => {
returnFreshResults = true;
throw new Error("second batch failed");
});
mountBrowser();
await settle();
await submitKeySearch("batch");
clickButtonWithText("redis.fuzzyMatch");
await settle();
const batchCheckbox = groupCheckbox("batch");
batchCheckbox.checked = true;
batchCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await settle();
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(keys.length));
expect(deleteButton).toBeDefined();
deleteButton!.click();
await settle();
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
await settle();
await settle();
expect(mocks.redisDeleteKeys.mock.calls.map((call) => call[2]?.length)).toEqual([1_000, 1]);
expect(mocks.toast).toHaveBeenCalledWith("second batch failed", 5000);
expect(document.body.textContent).toContain("fresh");
expect(document.body.textContent).not.toContain("0000");
});
it("reloads after a later delete batch fails while the browser is deactivated", async () => {
const keys = Array.from({ length: 1_001 }, (_, index) => ({
key_display: `batch:${String(index).padStart(4, "0")}`,
key_raw: `cmF3LWJhdGNoLS${index}`,
key_type: "string",
ttl: -1,
}));
const freshKeys = [{ key_display: "fresh:remaining", key_raw: "ZnJlc2gtcmVtYWluaW5n", key_type: "string", ttl: -1 }];
const laterDelete = deferred<number>();
let returnFreshResults = false;
mocks.redisScanKeysBatch.mockImplementation(() => Promise.resolve(returnFreshResults ? { cursor: 0, keys: freshKeys, total_keys: 1 } : { cursor: 0, keys, total_keys: keys.length }));
mocks.redisDeleteKeys.mockResolvedValueOnce(1_000).mockImplementationOnce(() => laterDelete.promise);
const browser = mountKeptAliveBrowser();
await settle();
await submitKeySearch("batch");
clickButtonWithText("redis.fuzzyMatch");
await settle();
const batchCheckbox = groupCheckbox("batch");
batchCheckbox.checked = true;
batchCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await settle();
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(keys.length));
expect(deleteButton).toBeDefined();
deleteButton!.click();
await settle();
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
await settle();
expect(mocks.redisDeleteKeys.mock.calls.map((call) => call[2]?.length)).toEqual([1_000, 1]);
await browser.deactivate();
returnFreshResults = true;
laterDelete.reject(new Error("second batch failed while inactive"));
await settle();
const scanCountBeforeActivation = mocks.redisScanKeysBatch.mock.calls.length;
await browser.activate();
await settle();
expect(mocks.toast).toHaveBeenCalledWith("second batch failed while inactive", 5000);
expect(mocks.redisScanKeysBatch.mock.calls.length).toBeGreaterThan(scanCountBeforeActivation);
expect(document.body.textContent).toContain("fresh");
expect(document.body.textContent).not.toContain("0000");
});
});
describe("RedisKeyBrowser interrupted Fetch All", () => {
it("reloads instead of advancing past an uncommitted buffered page after reactivation", async () => {
const bufferedPage = deferred<{ cursor: number; keys: Array<{ key_display: string; key_raw: string; key_type: string; ttl: number }>; total_keys: number }>();
let returnFreshPage = false;
let freshPageRequests = 0;
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => {
if (cursor === 1) return bufferedPage.promise;
if (returnFreshPage) freshPageRequests++;
return Promise.resolve(returnFreshPage ? { cursor: 0, keys: [{ key_display: "fresh:key", key_raw: "ZnJlc2gta2V5", key_type: "string", ttl: -1 }], total_keys: 2 } : { cursor: 1, keys: [{ key_display: "initial:key", key_raw: "aW5pdGlhbC1rZXk=", key_type: "string", ttl: -1 }], total_keys: 2 });
});
const browser = mountKeptAliveBrowser();
await settle();
clickButtonWithText("redis.fetchAllKeys");
await settle();
await browser.deactivate();
returnFreshPage = true;
bufferedPage.resolve({ cursor: 0, keys: [{ key_display: "buffered:key", key_raw: "YnVmZmVyZWQta2V5", key_type: "string", ttl: -1 }], total_keys: 0 });
await settle();
await browser.activate();
await settle();
expect(document.body.textContent).toContain("fresh");
expect(document.body.textContent).not.toContain("buffered");
expect(freshPageRequests).toBeGreaterThan(0);
});
});

View File

@ -25,7 +25,19 @@ import * as api from "@/lib/backend/api";
import type { RedisKeyInfo, RedisScanResult, RedisValue, HistoryEntry } from "@/lib/backend/api";
import { uuid } from "@/lib/common/utils";
import { useConnectionStore } from "@/stores/connectionStore";
import { buildRedisKeyTree, collectExpandedGroupIds, collectRedisGroupKeyRaws, flattenVisibleRedisKeyTree, mergeKeysIntoRedisKeyTree, redisKeyNameCopyText, redisKeyToFlatTreeRow, type RedisKeyTreeNode } from "@/lib/redis/redisKeyTree";
import {
appendRedisKeysToTreeIndex,
canBuildRedisFuzzyTree,
collectExpandedGroupIds,
collectRedisGroupKeyRaws,
createRedisKeyTreeIndex,
flattenVisibleRedisKeyTree,
redisKeyNameCopyText,
redisKeyToFlatTreeRow,
type RedisKeyTreeGroupNode,
type RedisKeyTreeIndex,
type RedisKeyTreeNode,
} from "@/lib/redis/redisKeyTree";
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession";
@ -36,7 +48,7 @@ import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle
import { useToast } from "@/composables/useToast";
import { redisKeySearchPattern } from "@/lib/redis/redisKeyPattern";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
import { collectUniqueRedisKeys } from "@/lib/redis/redisKeyBatch";
import { chunkRedisKeyRaws, collectUniqueRedisKeys } from "@/lib/redis/redisKeyBatch";
import { getRedisCreateKeyTypeHelp, redisCreateKeyTypeHelpOptionOnOpen, shouldActivateRedisCreateKeyTypeHelpOnFocus } from "@/lib/redis/redisCreateKeyTypeHelp";
import { optionHelpPanelOffsetTop } from "@/lib/common/optionHelpPanelOffset";
import { applyRedisExpiryPolicy, type RedisExpiryMode, validateRedisExpiry } from "@/lib/redis/redisExpiry";
@ -76,7 +88,7 @@ const redisExpiryTransport = {
};
const flatKeys = shallowRef<RedisKeyInfo[]>([]);
const treeKeys = ref<RedisKeyTreeNode[]>([]);
const treeKeys = shallowRef<RedisKeyTreeNode[]>([]);
const loading = ref(false);
const loadingMore = ref(false);
const searchPending = ref(false);
@ -94,7 +106,9 @@ const hasMore = ref(false);
const scanCursor = ref(0);
const expandedGroupIds = ref<Set<string>>(new Set());
const checkedKeys = ref<Set<string>>(new Set());
const pendingDanger = ref<{ kind: "delete-keys"; title: string; keyRaws: string[] } | { kind: "command"; command: string } | null>(null);
const selectedGroupLeafCounts = shallowRef<Map<string, number>>(new Map());
const deletingKeys = ref(false);
const pendingDanger = ref<{ kind: "delete-keys"; title: string; keyRaws: string[]; loadedSearchResults: boolean } | { kind: "command"; command: string } | null>(null);
const showDangerConfirm = ref(false);
const commandText = ref("");
const commandRunning = ref(false);
@ -128,14 +142,22 @@ const createKeyTypeHelpOffsetTop = ref(0);
let nextEntryId = 0;
let searchRequestId = 0;
let redisBrowserIsActive = true;
let reloadKeysOnActivation = false;
let redisDbFlushedListenerRegistered = false;
const loadedKeyRaws = new Set<string>();
let treeIndex: RedisKeyTreeIndex | null = null;
const valueQuery = computed(() => searchPattern.value.trim());
const isValueSearchMode = computed(() => searchMode.value === "value" || searchMode.value === "all");
const effectivePattern = computed(() => (searchMode.value === "key" ? redisKeySearchPattern(searchPattern.value, fuzzyKeySearch.value) : "*"));
const isSearchMode = computed(() => (searchMode.value === "key" ? effectivePattern.value !== "*" : valueQuery.value !== ""));
const useFlatKeySearchRows = computed(() => searchMode.value === "key" && isSearchMode.value);
// Keep regular glob search on the low-cost flat path. The explicit fuzzy mode
// opts into the namespace hierarchy that users need for group selection.
const isFuzzyKeySearch = computed(() => searchMode.value === "key" && isSearchMode.value && fuzzyKeySearch.value);
const fuzzyTreeLimitReached = computed(() => isFuzzyKeySearch.value && !canBuildRedisFuzzyTree(flatKeys.value.length));
const useFlatKeySearchRows = computed(() => (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || fuzzyTreeLimitReached.value);
const isFuzzyHierarchyView = computed(() => isFuzzyKeySearch.value && !fuzzyTreeLimitReached.value);
const selectionBusy = computed(() => deletingKeys.value || loading.value || loadingMore.value || isFetchingAll.value || searchPending.value);
const searchPlaceholder = computed(() => {
if (searchMode.value === "key") return fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern");
return searchMode.value === "all" ? t("redis.allSearchPlaceholder") : t("redis.valueSearchPlaceholder");
@ -144,7 +166,14 @@ const loadingEmptyText = computed(() => (isValueSearchMode.value && valueQuery.v
const redisKeySeparator = computed(() => connectionStore.getConfig(props.connectionId)?.redis_key_separator ?? ":");
const redisScanPageSize = computed(() => connectionStore.getConfig(props.connectionId)?.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT);
watch(redisKeySeparator, () => {
if (flatKeys.value.length > 0) rebuildTree(false);
if (flatKeys.value.length === 0) return;
if (useFlatKeySearchRows.value) {
treeKeys.value = [];
treeIndex = null;
expandedGroupIds.value = new Set();
return;
}
rebuildTree(false);
});
const lastTotalKeys = ref(0);
const displayedKeyCount = computed(() => (isFetchingAll.value ? fetchAllLoadedCount.value : flatKeys.value.length));
@ -166,7 +195,7 @@ const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw ===
const dangerDetails = computed(() => {
if (!pendingDanger.value) return "";
if (pendingDanger.value.kind === "delete-keys") {
return t("redis.deleteGroupDetails", {
return t(pendingDanger.value.loadedSearchResults ? "redis.deleteLoadedSearchKeysDetails" : "redis.deleteGroupDetails", {
target: pendingDanger.value.title,
count: pendingDanger.value.keyRaws.length,
});
@ -256,13 +285,79 @@ watch(activeCreateKeyTypeHelp, () => {
const visibleRows = computed(() => (useFlatKeySearchRows.value ? flatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(treeKeys.value, expandedGroupIds.value)));
let commandHistoryId = 0;
function countLeaves(node: RedisKeyTreeNode): number {
if (node.kind === "leaf") return 1;
return node.children.reduce((sum, child) => sum + countLeaves(child), 0);
function resetCheckedKeys() {
checkedKeys.value = new Set();
selectedGroupLeafCounts.value = new Map();
}
function refreshSelectedGroupLeafCounts() {
const nextChecked = new Set<string>();
const nextCounts = new Map<string, number>();
for (const keyRaw of checkedKeys.value) {
if (!loadedKeyRaws.has(keyRaw)) continue;
nextChecked.add(keyRaw);
for (const groupId of treeIndex?.ancestorGroupIdsByKeyRaw.get(keyRaw) ?? []) {
nextCounts.set(groupId, (nextCounts.get(groupId) ?? 0) + 1);
}
}
checkedKeys.value = nextChecked;
selectedGroupLeafCounts.value = nextCounts;
}
function setKeysChecked(keyRaws: Iterable<string>, checked: boolean) {
const nextChecked = new Set(checkedKeys.value);
const nextCounts = new Map(selectedGroupLeafCounts.value);
let changed = false;
for (const keyRaw of keyRaws) {
if (!loadedKeyRaws.has(keyRaw)) continue;
const wasChecked = nextChecked.has(keyRaw);
if (wasChecked === checked) continue;
if (checked) nextChecked.add(keyRaw);
else nextChecked.delete(keyRaw);
const delta = checked ? 1 : -1;
for (const groupId of treeIndex?.ancestorGroupIdsByKeyRaw.get(keyRaw) ?? []) {
const nextCount = (nextCounts.get(groupId) ?? 0) + delta;
if (nextCount > 0) nextCounts.set(groupId, nextCount);
else nextCounts.delete(groupId);
}
changed = true;
}
if (!changed) return;
checkedKeys.value = nextChecked;
selectedGroupLeafCounts.value = nextCounts;
}
function setKeyChecked(keyRaw: string, checked: boolean) {
setKeysChecked([keyRaw], checked);
}
function isGroupFullyChecked(group: RedisKeyTreeGroupNode): boolean {
return group.loadedLeafCount > 0 && selectedGroupLeafCounts.value.get(group.id) === group.loadedLeafCount;
}
function isGroupPartiallyChecked(group: RedisKeyTreeGroupNode): boolean {
const selectedCount = selectedGroupLeafCounts.value.get(group.id) ?? 0;
return selectedCount > 0 && selectedCount < group.loadedLeafCount;
}
function setGroupChecked(group: RedisKeyTreeGroupNode, checked: boolean) {
setKeysChecked(collectRedisGroupKeyRaws(group), checked);
}
function rebuildTree(expandAll = false) {
const nextTree = buildRedisKeyTree(flatKeys.value, props.db, redisKeySeparator.value);
if (useFlatKeySearchRows.value) {
treeKeys.value = [];
treeIndex = null;
expandedGroupIds.value = new Set();
refreshSelectedGroupLeafCounts();
return;
}
treeIndex = createRedisKeyTreeIndex(flatKeys.value, props.db, redisKeySeparator.value);
const nextTree = treeIndex.root;
treeKeys.value = nextTree;
const nextExpanded = new Set<string>();
@ -275,6 +370,7 @@ function rebuildTree(expandAll = false) {
}
}
expandedGroupIds.value = nextExpanded;
refreshSelectedGroupLeafCounts();
if (selectedKeyRaw.value && !flatKeys.value.some((key) => key.key_raw === selectedKeyRaw.value)) {
selectedKeyRaw.value = null;
@ -283,18 +379,22 @@ function rebuildTree(expandAll = false) {
function mergeTree(newKeys: RedisKeyInfo[]) {
if (newKeys.length === 0) return;
treeKeys.value = mergeKeysIntoRedisKeyTree(treeKeys.value, newKeys, props.db, redisKeySeparator.value);
if (!treeIndex) {
rebuildTree(isSearchMode.value);
return;
}
const { addedGroupIds } = appendRedisKeysToTreeIndex(treeIndex, newKeys, props.db, redisKeySeparator.value);
// Trigger the shallow ref without proxying the whole namespace hierarchy.
treeKeys.value = [...treeIndex.root];
const availableExpanded = collectExpandedGroupIds(treeKeys.value);
const nextExpanded = new Set<string>();
for (const id of expandedGroupIds.value) {
if (availableExpanded.has(id)) nextExpanded.add(id);
if (treeIndex.groupById.has(id)) nextExpanded.add(id);
}
if (isFuzzyHierarchyView.value) {
for (const id of addedGroupIds) nextExpanded.add(id);
}
expandedGroupIds.value = nextExpanded;
if (selectedKeyRaw.value && !flatKeys.value.some((key) => key.key_raw === selectedKeyRaw.value)) {
selectedKeyRaw.value = null;
}
}
async function fetchScanPage(requestId = searchRequestId): Promise<RedisScanResult> {
@ -362,6 +462,7 @@ function appendScanResult(result: RedisScanResult, options: { updateTree?: boole
if (options.updateTree ?? true) {
if (useFlatKeySearchRows.value) {
treeKeys.value = [];
treeIndex = null;
expandedGroupIds.value = new Set();
} else if (treeKeys.value.length === 0) {
rebuildTree(isSearchMode.value);
@ -405,8 +506,9 @@ async function loadKeys() {
loadedKeyRaws.clear();
flatKeys.value = [];
treeKeys.value = [];
treeIndex = null;
selectedKeyRaw.value = null;
checkedKeys.value = new Set();
resetCheckedKeys();
expandedGroupIds.value = new Set();
scanCursor.value = 0;
lastTotalKeys.value = 0;
@ -463,7 +565,15 @@ async function fetchAll() {
} finally {
if (requestId === searchRequestId) {
if (bufferedKeys.length > 0) flatKeys.value = [...flatKeys.value, ...bufferedKeys];
if (changed && !useFlatKeySearchRows.value) rebuildTree(isSearchMode.value);
if (changed) {
if (useFlatKeySearchRows.value) {
treeKeys.value = [];
treeIndex = null;
expandedGroupIds.value = new Set();
} else {
rebuildTree(isSearchMode.value);
}
}
isFetchingAll.value = false;
fetchAllStopRequested.value = false;
fetchAllLoadedCount.value = 0;
@ -497,10 +607,13 @@ function removeKnownKey(keyRaw: string) {
loadedKeyRaws.delete(keyRaw);
flatKeys.value = flatKeys.value.filter((key) => key.key_raw !== keyRaw);
if (selectedKeyRaw.value === keyRaw) selectedKeyRaw.value = null;
const nextChecked = new Set(checkedKeys.value);
nextChecked.delete(keyRaw);
checkedKeys.value = nextChecked;
rebuildTree(false);
if (useFlatKeySearchRows.value) {
treeKeys.value = [];
treeIndex = null;
refreshSelectedGroupLeafCounts();
} else {
rebuildTree(false);
}
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
totalDelta: -1,
@ -532,29 +645,43 @@ function onKeyLoaded(value: RedisValue) {
if (existingIndex < 0) return;
flatKeys.value = flatKeys.value.map((key, index) => (index === existingIndex ? keyInfo : key));
loadedKeyRaws.add(keyInfo.key_raw);
rebuildTree(false);
if (useFlatKeySearchRows.value) {
treeKeys.value = [];
treeIndex = null;
refreshSelectedGroupLeafCounts();
} else {
rebuildTree(false);
}
}
function toggleCheck(keyRaw: string, event: Event) {
event.stopPropagation();
const next = new Set(checkedKeys.value);
if (next.has(keyRaw)) next.delete(keyRaw);
else next.add(keyRaw);
checkedKeys.value = next;
if (selectionBusy.value) return;
setKeyChecked(keyRaw, !checkedKeys.value.has(keyRaw));
}
function requestBatchDelete() {
if (checkedKeys.value.size === 0) return;
pendingDanger.value = { kind: "delete-keys", title: t("redis.selectedKeys"), keyRaws: [...checkedKeys.value] };
if (checkedKeys.value.size === 0 || selectionBusy.value) return;
pendingDanger.value = {
kind: "delete-keys",
title: t("redis.selectedKeys"),
keyRaws: [...checkedKeys.value],
loadedSearchResults: isFuzzyKeySearch.value,
};
showDangerConfirm.value = true;
}
function requestGroupDelete(node: RedisKeyTreeNode, event: Event) {
event.stopPropagation();
if (node.kind !== "group") return;
if (node.kind !== "group" || selectionBusy.value) return;
const keyRaws = collectRedisGroupKeyRaws(node);
if (keyRaws.length === 0) return;
pendingDanger.value = { kind: "delete-keys", title: node.pathSegments.join(":"), keyRaws };
pendingDanger.value = {
kind: "delete-keys",
title: node.pathSegments.join(redisKeySeparator.value),
keyRaws,
loadedSearchResults: false,
};
showDangerConfirm.value = true;
}
@ -593,27 +720,54 @@ function resetLoadedKeys() {
loadedKeyRaws.clear();
flatKeys.value = [];
treeKeys.value = [];
treeIndex = null;
selectedKeyRaw.value = null;
checkedKeys.value = new Set();
resetCheckedKeys();
expandedGroupIds.value = new Set();
hasMore.value = false;
lastTotalKeys.value = 0;
}
async function deleteKeyRaws(keys: string[]) {
const deletedCount = await api.redisDeleteKeys(props.connectionId, props.db, keys);
const deleted = new Set(keys);
for (const key of deleted) loadedKeyRaws.delete(key);
flatKeys.value = flatKeys.value.filter((k) => !deleted.has(k.key_raw));
if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) {
selectedKeyRaw.value = null;
const uniqueKeys = [...new Set(keys)];
if (uniqueKeys.length === 0 || deletingKeys.value) return;
// Ignore a late SCAN page while an explicit mutation changes this result set.
searchRequestId++;
fetchAllStopRequested.value = true;
deletingKeys.value = true;
try {
let deletedCount = 0;
for (const batch of chunkRedisKeyRaws(uniqueKeys)) {
deletedCount += await api.redisDeleteKeys(props.connectionId, props.db, batch);
}
const deleted = new Set(uniqueKeys);
for (const key of deleted) loadedKeyRaws.delete(key);
flatKeys.value = flatKeys.value.filter((key) => !deleted.has(key.key_raw));
if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) {
selectedKeyRaw.value = null;
}
resetCheckedKeys();
if (useFlatKeySearchRows.value) {
treeKeys.value = [];
treeIndex = null;
} else {
rebuildTree(false);
}
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
totalDelta: -deletedCount,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
toast(message, 5000);
// A Cluster request may have deleted an earlier shard before a later error.
// Reload instead of leaving a potentially stale partial result in the tree.
if (redisBrowserIsActive) await loadKeys();
else reloadKeysOnActivation = true;
} finally {
deletingKeys.value = false;
}
checkedKeys.value = new Set();
rebuildTree(false);
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
totalDelta: -deletedCount,
});
}
function scrollCommandTerminalToEnd() {
@ -1065,13 +1219,15 @@ async function executeCommand() {
async function applyDangerAction() {
const pending = pendingDanger.value;
pendingDanger.value = null;
showDangerConfirm.value = false;
if (!pending) return;
if (pending.kind === "delete-keys") {
await deleteKeyRaws(pending.keyRaws);
pendingDanger.value = null;
showDangerConfirm.value = false;
} else {
pendingDanger.value = null;
showDangerConfirm.value = false;
await runRedisCommand(pending.command);
}
}
@ -1175,6 +1331,10 @@ function unregisterRedisDbFlushedListener() {
}
function pauseRedisBrowserBackgroundWork() {
// Fetch All advances the cursor and duplicate filter before its local buffer
// is committed. Discard that incomplete session so reactivation cannot skip
// keys that were never rendered.
const discardIncompleteFetchAll = isFetchingAll.value;
redisBrowserIsActive = false;
searchRequestId++;
isFetchingAll.value = false;
@ -1185,6 +1345,7 @@ function pauseRedisBrowserBackgroundWork() {
searchPending.value = false;
if (searchTimer) clearTimeout(searchTimer);
searchTimer = null;
if (discardIncompleteFetchAll) resetLoadedKeys();
unregisterRedisDbFlushedListener();
}
@ -1252,6 +1413,8 @@ onMounted(async () => {
onActivated(async () => {
resumeRedisBrowserBackgroundWork();
void autofocusSearchOnce();
const shouldReload = reloadKeysOnActivation;
reloadKeysOnActivation = false;
// Ensure the connection is still alive after reactivation (e.g. tab switch).
// If keys failed to load previously (empty list), retry loading.
try {
@ -1259,7 +1422,7 @@ onActivated(async () => {
} catch (e) {
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
}
if (flatKeys.value.length === 0 && !loading.value) {
if ((shouldReload || flatKeys.value.length === 0) && !loading.value) {
void loadKeys();
}
});
@ -1318,8 +1481,8 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</div>
<div class="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
<span class="min-w-0 max-w-full truncate text-xs text-muted-foreground" :title="keyCountText">{{ keyCountText }}</span>
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 text-xs text-destructive" @click="requestBatchDelete"> <Trash2 class="w-3 h-3 mr-1" />{{ checkedKeys.size }} </Button>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="loadKeys">
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 text-xs text-destructive" :disabled="selectionBusy" @click="requestBatchDelete"> <Trash2 class="w-3 h-3 mr-1" />{{ checkedKeys.size }} </Button>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="deletingKeys" @click="loadKeys">
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
<RefreshCw v-else class="h-3 w-3" />
</Button>
@ -1359,7 +1522,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<div v-if="flatKeys.length === 0 && !loading" class="flex-1 flex flex-col items-center justify-center text-muted-foreground text-xs p-4 text-center">
<template v-if="hasMore">
<span class="mb-3">{{ t("redis.noKeysInScanHint") }}</span>
<Button variant="outline" size="sm" class="h-7 text-xs" :disabled="loadingMore || searchPending" @click="loadMore">
<Button variant="outline" size="sm" class="h-7 text-xs" :disabled="loadingMore || searchPending || deletingKeys" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
@ -1384,15 +1547,33 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
>
<div class="min-w-0 flex flex-1 items-center gap-1 overflow-hidden" :style="{ paddingLeft: `${12 + row.depth * 16}px` }">
<template v-if="row.node.kind === 'group'">
<input
v-if="isFuzzyHierarchyView"
type="checkbox"
class="h-3.5 w-3.5 shrink-0 accent-primary cursor-pointer"
:checked="isGroupFullyChecked(row.node)"
:indeterminate="isGroupPartiallyChecked(row.node)"
:disabled="selectionBusy"
:aria-label="t('redis.selectLoadedGroupKeys', { count: row.node.loadedLeafCount })"
@click.stop
@change="setGroupChecked(row.node, ($event.target as HTMLInputElement).checked)"
/>
<component :is="expandedGroupIds.has(row.node.id) ? ChevronDown : ChevronRight" class="w-3 h-3 shrink-0 text-muted-foreground" />
<component :is="expandedGroupIds.has(row.node.id) ? FolderOpen : FolderClosed" class="w-3 h-3 shrink-0 text-amber-500" />
<span class="dbx-editor-font-family truncate">{{ row.node.label }}</span>
<span class="text-muted-foreground ml-1">({{ countLeaves(row.node) }})</span>
<span class="text-muted-foreground ml-1" :title="isFuzzyHierarchyView ? t('redis.loadedMatchingKeys', { count: row.node.loadedLeafCount }) : undefined">({{ row.node.loadedLeafCount }})</span>
</template>
<template v-else>
<span class="relative flex h-4 w-4 shrink-0 items-center justify-center">
<KeyRound class="h-3.5 w-3.5 text-muted-foreground/70 transition-opacity group-hover:opacity-0" :class="{ 'opacity-0': checkedKeys.has(row.node.keyRaw) }" />
<input type="checkbox" class="absolute h-3.5 w-3.5 accent-primary cursor-pointer opacity-0 group-hover:opacity-100" :class="{ 'opacity-100': checkedKeys.has(row.node.keyRaw) }" :checked="checkedKeys.has(row.node.keyRaw)" @click="toggleCheck(row.node.keyRaw, $event)" />
<input
type="checkbox"
class="absolute h-3.5 w-3.5 accent-primary cursor-pointer opacity-0 group-hover:opacity-100"
:class="{ 'opacity-100': checkedKeys.has(row.node.keyRaw) }"
:checked="checkedKeys.has(row.node.keyRaw)"
:disabled="selectionBusy"
@click="toggleCheck(row.node.keyRaw, $event)"
/>
</span>
<span class="dbx-editor-font-family truncate">{{ row.node.label }}</span>
</template>
@ -1400,7 +1581,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<div class="flex shrink-0 items-center justify-end gap-1">
<Badge v-if="row.node.kind === 'leaf' && row.node.keyType" variant="outline" class="text-xs px-1.5 py-0" :class="typeColor(row.node.keyType)">{{ row.node.keyType }}</Badge>
<Button v-if="row.node.kind === 'group'" variant="ghost" size="icon" class="h-5 w-5 shrink-0 text-destructive opacity-0 group-hover:opacity-100" :title="t('redis.deleteGroup')" @click="requestGroupDelete(row.node, $event)">
<Button v-if="row.node.kind === 'group' && !isFuzzyHierarchyView" variant="ghost" size="icon" class="h-5 w-5 shrink-0 text-destructive opacity-0 group-hover:opacity-100" :title="t('redis.deleteGroup')" :disabled="selectionBusy" @click="requestGroupDelete(row.node, $event)">
<Trash2 class="h-3 w-3" />
</Button>
</div>
@ -1408,12 +1589,15 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</CustomContextMenu>
</template>
</RecycleScroller>
<div v-if="fuzzyTreeLimitReached" class="shrink-0 border-t px-3 py-2 text-center text-xs text-muted-foreground">
{{ t("redis.fuzzyTreeLimit", { count: flatKeys.length }) }}
</div>
<div v-if="hasMore && !isFetchingAll" class="shrink-0 border-t px-2 py-1.5 flex items-center gap-1.5">
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loadingMore || loading || searchPending" @click="loadMore">
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loadingMore || loading || searchPending || deletingKeys" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loading || searchPending || !hasMore" @click="fetchAll">
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loading || searchPending || deletingKeys || !hasMore" @click="fetchAll">
{{ t("redis.fetchAllKeys") }}
</Button>
</div>
@ -1421,7 +1605,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<div class="text-xs text-muted-foreground text-center">
{{ fetchAllProgressText }}
</div>
<Button variant="destructive" size="sm" class="h-7 text-xs w-full" :disabled="fetchAllStopRequested" @click="stopFetchAll">
<Button variant="destructive" size="sm" class="h-7 text-xs w-full" :disabled="fetchAllStopRequested || deletingKeys" @click="stopFetchAll">
{{ t("redis.stopFetchAll") }}
</Button>
</div>
@ -1509,7 +1693,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</Pane>
</Splitpanes>
<DangerConfirmDialog v-model:open="showDangerConfirm" :message="dangerMessage" :details="dangerDetails" :confirm-label="dangerConfirmLabel" @confirm="applyDangerAction" />
<DangerConfirmDialog v-model:open="showDangerConfirm" :message="dangerMessage" :details="dangerDetails" :confirm-label="dangerConfirmLabel" :loading="deletingKeys" :close-on-confirm="pendingDanger?.kind !== 'delete-keys'" @confirm="applyDangerAction" />
<Dialog :open="showCreateKeyDialog" @update:open="onCreateKeyDialogOpenChange">
<DialogContent class="sm:max-w-md" :show-close-button="!creatingKey" :style="editorFontFamilyStyle">

View File

@ -2909,8 +2909,12 @@ export default {
columnTTL: "TTL",
binaryStringReadonlyHint: "Binary string values are shown as escaped text in read-only mode; editing raw bytes is not supported.",
selectedKeys: "Selected keys",
selectLoadedGroupKeys: "Select {count} loaded matching keys",
loadedMatchingKeys: "{count} loaded matching keys",
fuzzyTreeLimit: "{count} fuzzy matches are shown as a flat list to keep the browser responsive. Refine the search to view the hierarchy.",
deleteGroup: "Delete group",
deleteGroupDetails: "{target}\n{count} keys",
deleteLoadedSearchKeysDetails: "{target}\n{count} loaded matching keys",
flushDb: "Clear current DB",
flushDbMessage: "This will delete every key in the current Redis database and cannot be undone. Continue?",
flushDbDetails: "Target: Redis db{db}\nScope: all keys in the current DB",

View File

@ -2770,8 +2770,12 @@ export default withEnglishFallback({
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.",
selectedKeys: "Claves seleccionadas",
selectLoadedGroupKeys: "Seleccionar {count} claves coincidentes cargadas",
loadedMatchingKeys: "{count} claves coincidentes cargadas",
fuzzyTreeLimit: "Se muestran {count} coincidencias difusas como lista plana para mantener el navegador responsivo. Refina la búsqueda para ver la jerarquía.",
deleteGroup: "Eliminar grupo",
deleteGroupDetails: "{target}\n{count} claves",
deleteLoadedSearchKeysDetails: "{target}\n{count} claves coincidentes cargadas",
flushDb: "Limpiar DB actual",
flushDbMessage: "Esto eliminará todas las claves de la base de datos Redis actual y no se puede deshacer. ¿Continuar?",
flushDbDetails: "Destino: Redis db{db}\nAlcance: todas las claves de la DB actual",

View File

@ -2768,8 +2768,12 @@ export default withEnglishFallback({
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.",
selectedKeys: "Chiavi selezionate",
selectLoadedGroupKeys: "Seleziona {count} chiavi corrispondenti caricate",
loadedMatchingKeys: "{count} chiavi corrispondenti caricate",
fuzzyTreeLimit: "{count} corrispondenze fuzzy sono mostrate come elenco piatto per mantenere il browser reattivo. Restringi la ricerca per vedere la gerarchia.",
deleteGroup: "Elimina gruppo",
deleteGroupDetails: "{target}\n{count} chiavi",
deleteLoadedSearchKeysDetails: "{target}\n{count} chiavi corrispondenti caricate",
flushDb: "Pulisci DB corrente",
flushDbMessage: "Questo eliminerà ogni chiave nel database Redis corrente e non può essere annullato. Continuare?",
flushDbDetails: "Destinazione: Redis db{db}\nAmbito: tutte le chiavi nel DB corrente",

View File

@ -2769,8 +2769,12 @@ export default withEnglishFallback({
columnTTL: "TTL",
binaryStringReadonlyHint: "バイナリ文字列値は読み取り専用モードでエスケープテキストとして表示されます。生バイトの編集はサポートされていません。",
selectedKeys: "選択中のキー",
selectLoadedGroupKeys: "読み込み済みの一致キー {count} 件を選択",
loadedMatchingKeys: "読み込み済みの一致キー {count} 件",
fuzzyTreeLimit: "ブラウザーの応答性を保つため、あいまい一致 {count} 件をフラットな一覧で表示しています。階層を表示するには検索条件を絞り込んでください。",
deleteGroup: "グループを削除",
deleteGroupDetails: "{target}\n{count}個のキー",
deleteLoadedSearchKeysDetails: "{target}\n読み込み済みの一致キー {count} 件",
flushDb: "現在のDBをクリア",
flushDbMessage: "現在のRedisデータベース内のすべてのキーが削除されます。この操作は取り消せません。続行しますか",
flushDbDetails: "対象: Redis db{db}\n範囲: 現在のDB内のすべてのキー",

View File

@ -2770,8 +2770,12 @@ export default withEnglishFallback({
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.",
selectedKeys: "Chaves selecionadas",
selectLoadedGroupKeys: "Selecionar {count} chaves correspondentes carregadas",
loadedMatchingKeys: "{count} chaves correspondentes carregadas",
fuzzyTreeLimit: "{count} correspondências difusas são mostradas como uma lista plana para manter o navegador responsivo. Refine a busca para ver a hierarquia.",
deleteGroup: "Excluir grupo",
deleteGroupDetails: "{target}\n{count} chaves",
deleteLoadedSearchKeysDetails: "{target}\n{count} chaves correspondentes carregadas",
flushDb: "Limpar DB atual",
flushDbMessage: "Isto excluirá todas as chaves do banco de dados Redis atual e não pode ser desfeito. Continuar?",
flushDbDetails: "Alvo: Redis db{db}\nEscopo: todas as chaves do DB atual",

View File

@ -2909,8 +2909,12 @@ export default withEnglishFallback({
columnTTL: "TTL",
binaryStringReadonlyHint: "二进制字符串按转义文本只读展示;当前不支持直接编辑原始字节值。",
selectedKeys: "已选择的 key",
selectLoadedGroupKeys: "选择已加载的 {count} 个匹配 key",
loadedMatchingKeys: "已加载的 {count} 个匹配 key",
fuzzyTreeLimit: "已加载 {count} 个模糊匹配 key。为保持浏览器响应当前以扁平列表展示请缩小搜索范围以查看层级。",
deleteGroup: "删除分组",
deleteGroupDetails: "{target}\n{count} 个 key",
deleteLoadedSearchKeysDetails: "{target}\n已加载的 {count} 个匹配 key",
flushDb: "清空当前 DB",
flushDbMessage: "这会删除当前 Redis 数据库中的所有 key且不可撤销。确认继续",
flushDbDetails: "目标: Redis db{db}\n范围: 当前 DB 的所有 key",

View File

@ -2441,8 +2441,12 @@ export default withEnglishFallback({
columnTTL: "TTL",
binaryStringReadonlyHint: "二進位字串值會以逸出文字唯讀顯示;不支援編輯原始位元組。",
selectedKeys: "已選擇的 key",
selectLoadedGroupKeys: "選取已載入的 {count} 個相符 key",
loadedMatchingKeys: "已載入的 {count} 個相符 key",
fuzzyTreeLimit: "已載入 {count} 個模糊相符 key。為保持瀏覽器回應目前以扁平清單顯示請縮小搜尋範圍以查看層級。",
deleteGroup: "刪除群組",
deleteGroupDetails: "{target}\n{count} 個 key",
deleteLoadedSearchKeysDetails: "{target}\n已載入的 {count} 個相符 key",
flushDb: "清空目前 DB",
flushDbMessage: "這會刪除目前 Redis 資料庫中的所有 key且無法復原。確認繼續",
flushDbDetails: "目標: Redis db{db}\n範圍 目前 DB 的所有 key",

View File

@ -1,5 +1,8 @@
import type { RedisKeyInfo } from "@/lib/backend/api";
// Keep desktop IPC and Redis command payloads bounded for large group deletes.
export const REDIS_DELETE_KEY_BATCH_SIZE = 1_000;
export function collectUniqueRedisKeys(keys: RedisKeyInfo[], loadedKeyRaws: Set<string>): RedisKeyInfo[] {
const uniqueKeys: RedisKeyInfo[] = [];
@ -11,3 +14,9 @@ export function collectUniqueRedisKeys(keys: RedisKeyInfo[], loadedKeyRaws: Set<
return uniqueKeys;
}
export function* chunkRedisKeyRaws(keyRaws: readonly string[], batchSize = REDIS_DELETE_KEY_BATCH_SIZE): Generator<string[]> {
for (let start = 0; start < keyRaws.length; start += batchSize) {
yield keyRaws.slice(start, start + batchSize);
}
}

View File

@ -1,5 +1,13 @@
import type { RedisKeyInfo } from "@/lib/backend/api";
// A hierarchy duplicates key metadata and indexes; above this limit keep
// fuzzy results virtualized as flat rows instead of exhausting desktop memory.
export const REDIS_FUZZY_TREE_MAX_KEYS = 200_000;
export function canBuildRedisFuzzyTree(loadedKeyCount: number): boolean {
return loadedKeyCount <= REDIS_FUZZY_TREE_MAX_KEYS;
}
export interface RedisKeyTreeLeafNode {
kind: "leaf";
id: string;
@ -20,6 +28,8 @@ export interface RedisKeyTreeGroupNode {
label: string;
pathSegments: string[];
children: RedisKeyTreeNode[];
// The rendered count must not recursively walk an entire visible subtree.
loadedLeafCount: number;
}
export type RedisKeyTreeNode = RedisKeyTreeLeafNode | RedisKeyTreeGroupNode;
@ -30,6 +40,17 @@ export interface RedisKeyTreeRow {
depth: number;
}
export interface RedisKeyTreeIndex {
root: RedisKeyTreeNode[];
groupById: Map<string, RedisKeyTreeGroupNode>;
keyRaws: Set<string>;
ancestorGroupIdsByKeyRaw: Map<string, readonly string[]>;
}
export interface AppendRedisKeysResult {
addedGroupIds: Set<string>;
}
export function redisKeyNameCopyText(node: RedisKeyTreeNode): string | null {
// keyRaw is base64-encoded for backend roundtrips; copy the user-visible
// Redis key name instead of the internal transport value.
@ -37,7 +58,7 @@ export function redisKeyNameCopyText(node: RedisKeyTreeNode): string | null {
}
function buildGroupId(db: number, pathSegments: string[]): string {
return `group:${db}:${pathSegments.join("\u0000")}`;
return `group:${db}:${JSON.stringify(pathSegments)}`;
}
function buildLeafId(db: number, keyRaw: string): string {
@ -70,35 +91,73 @@ function compareRedisTreeNodes(a: RedisKeyTreeNode, b: RedisKeyTreeNode): number
return a.label.localeCompare(b.label);
}
function sortRedisTreeNodes(nodes: RedisKeyTreeNode[]): RedisKeyTreeNode[] {
return [...nodes].sort(compareRedisTreeNodes).map((node) =>
node.kind === "group"
? {
...node,
children: sortRedisTreeNodes(node.children),
}
: node,
);
function redisKeyInfoFromLeaf(node: RedisKeyTreeLeafNode): RedisKeyInfo {
return {
key_display: node.fullKeyDisplay,
key_raw: node.keyRaw,
key_type: node.keyType,
ttl: node.ttl,
size: node.size,
value_preview: node.valuePreview,
};
}
export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeNode[] {
const root: RedisKeyTreeNode[] = [];
const groupMap = new Map<string, RedisKeyTreeGroupNode>();
export function createRedisKeyTreeIndex(keys: readonly RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeIndex {
const index: RedisKeyTreeIndex = {
root: [],
groupById: new Map(),
keyRaws: new Set(),
ancestorGroupIdsByKeyRaw: new Map(),
};
appendRedisKeysToTreeIndex(index, keys, db, separator);
return index;
}
/**
* Inserts one SCAN batch while sorting only sibling arrays changed by that
* batch. This keeps repeated "load more" merges from re-sorting the full tree.
*/
export function appendRedisKeysToTreeIndex(index: RedisKeyTreeIndex, keys: readonly RedisKeyInfo[], db: number, separator = ":"): AppendRedisKeysResult {
const touchedLevels = new Set<RedisKeyTreeNode[]>();
const addedGroupIds = new Set<string>();
for (const key of keys) {
insertKeyIntoTree(root, groupMap, key, db, separator);
}
if (index.keyRaws.has(key.key_raw)) continue;
return sortRedisTreeNodes(root);
}
const pathSegments = separator ? key.key_display.split(separator) : [key.key_display];
const ancestorGroupIds: string[] = [];
let currentLevel = index.root;
function insertKeyIntoTree(root: RedisKeyTreeNode[], groupMap: Map<string, RedisKeyTreeGroupNode>, key: RedisKeyInfo, db: number, separator: string): void {
const pathSegments = separator ? key.key_display.split(separator) : [key.key_display];
if (pathSegments.length === 1) {
root.push({
if (pathSegments.length > 1) {
const groupSegments: string[] = [];
for (const segment of pathSegments.slice(0, -1)) {
groupSegments.push(segment);
const groupId = buildGroupId(db, groupSegments);
let group = index.groupById.get(groupId);
if (!group) {
group = {
kind: "group",
id: groupId,
label: segment,
pathSegments: [...groupSegments],
children: [],
loadedLeafCount: 0,
};
index.groupById.set(groupId, group);
currentLevel.push(group);
touchedLevels.add(currentLevel);
addedGroupIds.add(groupId);
}
group.loadedLeafCount++;
ancestorGroupIds.push(groupId);
currentLevel = group.children;
}
}
currentLevel.push({
kind: "leaf",
id: buildLeafId(db, key.key_raw),
label: pathSegments[0],
label: pathSegments[pathSegments.length - 1],
fullKeyDisplay: key.key_display,
keyRaw: key.key_raw,
db,
@ -108,113 +167,62 @@ function insertKeyIntoTree(root: RedisKeyTreeNode[], groupMap: Map<string, Redis
valuePreview: key.value_preview ?? "",
pathSegments,
});
return;
touchedLevels.add(currentLevel);
index.keyRaws.add(key.key_raw);
index.ancestorGroupIdsByKeyRaw.set(key.key_raw, ancestorGroupIds);
}
let currentLevel = root;
const groupSegments: string[] = [];
for (const segment of pathSegments.slice(0, -1)) {
groupSegments.push(segment);
const groupId = buildGroupId(db, groupSegments);
let group = groupMap.get(groupId);
if (!group) {
group = {
kind: "group",
id: groupId,
label: segment,
pathSegments: [...groupSegments],
children: [],
};
groupMap.set(groupId, group);
currentLevel.push(group);
}
currentLevel = group.children;
}
currentLevel.push({
kind: "leaf",
id: buildLeafId(db, key.key_raw),
label: pathSegments[pathSegments.length - 1],
fullKeyDisplay: key.key_display,
keyRaw: key.key_raw,
db,
keyType: key.key_type ?? "",
ttl: key.ttl ?? -2,
size: key.size ?? 0,
valuePreview: key.value_preview ?? "",
pathSegments,
});
for (const nodes of touchedLevels) nodes.sort(compareRedisTreeNodes);
return { addedGroupIds };
}
function rebuildGroupMap(tree: RedisKeyTreeNode[]): Map<string, RedisKeyTreeGroupNode> {
const groupMap = new Map<string, RedisKeyTreeGroupNode>();
const walk = (nodes: RedisKeyTreeNode[]) => {
for (const node of nodes) {
if (node.kind === "group") {
groupMap.set(node.id, node);
walk(node.children);
}
}
};
walk(tree);
return groupMap;
export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeNode[] {
return createRedisKeyTreeIndex(keys, db, separator).root;
}
export function mergeKeysIntoRedisKeyTree(existingTree: RedisKeyTreeNode[], newKeys: RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeNode[] {
if (existingTree.length === 0) return buildRedisKeyTree(newKeys, db, separator);
const groupMap = rebuildGroupMap(existingTree);
const existingKeyIds = new Set<string>();
const collectKeys = (nodes: RedisKeyTreeNode[]) => {
for (const node of nodes) {
if (node.kind === "leaf") {
existingKeyIds.add(node.keyRaw);
} else {
collectKeys(node.children);
}
const existingKeys: RedisKeyInfo[] = [];
const stack = [...existingTree];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.kind === "leaf") {
existingKeys.push(redisKeyInfoFromLeaf(node));
} else {
for (const child of node.children) stack.push(child);
}
};
collectKeys(existingTree);
for (const key of newKeys) {
if (existingKeyIds.has(key.key_raw)) continue;
insertKeyIntoTree(existingTree, groupMap, key, db, separator);
}
return sortRedisTreeNodes(existingTree);
const index = createRedisKeyTreeIndex(existingKeys, db, separator);
appendRedisKeysToTreeIndex(index, newKeys, db, separator);
return index.root;
}
export function collectExpandedGroupIds(nodes: RedisKeyTreeNode[]): Set<string> {
const ids = new Set<string>();
const visit = (entries: RedisKeyTreeNode[]) => {
for (const node of entries) {
if (node.kind !== "group") continue;
ids.add(node.id);
visit(node.children);
}
};
visit(nodes);
const stack = [...nodes];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.kind !== "group") continue;
ids.add(node.id);
for (const child of node.children) stack.push(child);
}
return ids;
}
export function collectRedisGroupKeyRaws(group: RedisKeyTreeGroupNode): string[] {
const keyRaws: string[] = [];
const visit = (nodes: RedisKeyTreeNode[]) => {
for (const node of nodes) {
if (node.kind === "leaf") {
keyRaws.push(node.keyRaw);
} else {
visit(node.children);
const stack = [...group.children].reverse();
while (stack.length > 0) {
const node = stack.pop()!;
if (node.kind === "leaf") {
keyRaws.push(node.keyRaw);
} else {
// Push in reverse so the iterative walk keeps the rendered tree order.
for (let index = node.children.length - 1; index >= 0; index--) {
stack.push(node.children[index]);
}
}
};
visit(group.children);
}
return keyRaws;
}

View File

@ -1,6 +1,6 @@
import { test } from "vitest";
import assert from "node:assert/strict";
import { collectUniqueRedisKeys } from "../../apps/desktop/src/lib/redis/redisKeyBatch.ts";
import { chunkRedisKeyRaws, collectUniqueRedisKeys, REDIS_DELETE_KEY_BATCH_SIZE } from "../../apps/desktop/src/lib/redis/redisKeyBatch.ts";
import type { RedisKeyInfo } from "../../apps/desktop/src/lib/backend/api.ts";
function makeKey(key: string): RedisKeyInfo {
@ -40,3 +40,14 @@ test("collectUniqueRedisKeys handles large batches without changing key objects"
assert.equal(keys[0], input[0]);
assert.equal(keys.at(-1), input.at(-1));
});
test("chunkRedisKeyRaws bounds large delete payloads without changing key order", () => {
const keyRaws = Array.from({ length: REDIS_DELETE_KEY_BATCH_SIZE * 2 + 1 }, (_, index) => `key:${index}`);
const batches = [...chunkRedisKeyRaws(keyRaws)];
assert.deepEqual(
batches.map((batch) => batch.length),
[REDIS_DELETE_KEY_BATCH_SIZE, REDIS_DELETE_KEY_BATCH_SIZE, 1],
);
assert.deepEqual(batches.flat(), keyRaws);
});

View File

@ -1,6 +1,20 @@
import { test } from "vitest";
import assert from "node:assert/strict";
import { buildRedisKeyTree, collectRedisGroupKeyRaws, collectExpandedGroupIds, flattenVisibleRedisKeyTree, mergeKeysIntoRedisKeyTree, redisKeyNameCopyText, redisKeyToFlatTreeRow, type RedisKeyTreeNode } from "../../apps/desktop/src/lib/redis/redisKeyTree.ts";
import {
appendRedisKeysToTreeIndex,
buildRedisKeyTree,
canBuildRedisFuzzyTree,
collectRedisGroupKeyRaws,
collectExpandedGroupIds,
createRedisKeyTreeIndex,
flattenVisibleRedisKeyTree,
mergeKeysIntoRedisKeyTree,
redisKeyNameCopyText,
redisKeyToFlatTreeRow,
REDIS_FUZZY_TREE_MAX_KEYS,
type RedisKeyTreeGroupNode,
type RedisKeyTreeNode,
} from "../../apps/desktop/src/lib/redis/redisKeyTree.ts";
import type { RedisKeyInfo } from "../../apps/desktop/src/lib/backend/api.ts";
function makeKey(key_display: string, key_raw: string, key_type = "string", ttl = -1): RedisKeyInfo {
@ -11,6 +25,18 @@ function leafLabels(nodes: RedisKeyTreeNode[]): string[] {
return nodes.filter((node) => node.kind === "leaf").map((node) => node.label);
}
function findGroup(nodes: RedisKeyTreeNode[], label: string): RedisKeyTreeGroupNode {
const group = nodes.find((node) => node.kind === "group" && node.label === label);
assert.ok(group);
assert.equal(group.kind, "group");
return group;
}
test("fuzzy hierarchy stops before the configured key limit is exceeded", () => {
assert.equal(canBuildRedisFuzzyTree(REDIS_FUZZY_TREE_MAX_KEYS), true);
assert.equal(canBuildRedisFuzzyTree(REDIS_FUZZY_TREE_MAX_KEYS + 1), false);
});
test("buildRedisKeyTree groups colon-delimited keys by segment", () => {
const tree = buildRedisKeyTree([makeKey("a:b:c", "k1"), makeKey("a:b:d", "k2"), makeKey("a:e", "k3"), makeKey("x", "k4")], 0);
@ -127,6 +153,81 @@ test("collectRedisGroupKeyRaws returns every leaf key under a group", () => {
assert.deepEqual(collectRedisGroupKeyRaws(userGroup), ["k2", "k1", "k3"]);
});
test("group identity keeps NUL-containing path segments isolated", () => {
const firstKeyRaw = "raw-first";
const secondKeyRaw = "raw-second";
const index = createRedisKeyTreeIndex([makeKey(`a\0b:c:x`, firstKeyRaw), makeKey(`a:b\0c:y`, secondKeyRaw)], 0);
const firstRoot = findGroup(index.root, `a\0b`);
const firstBranch = findGroup(firstRoot.children, "c");
const secondRoot = findGroup(index.root, "a");
const secondBranch = findGroup(secondRoot.children, `b\0c`);
assert.notEqual(firstBranch.id, secondBranch.id);
assert.equal(index.groupById.size, 4);
assert.deepEqual(collectRedisGroupKeyRaws(firstRoot), [firstKeyRaw]);
assert.deepEqual(collectRedisGroupKeyRaws(secondRoot), [secondKeyRaw]);
assert.deepEqual(index.ancestorGroupIdsByKeyRaw.get(firstKeyRaw), [firstRoot.id, firstBranch.id]);
assert.deepEqual(index.ancestorGroupIdsByKeyRaw.get(secondKeyRaw), [secondRoot.id, secondBranch.id]);
});
test("tree index incrementally merges SCAN pages with loaded counts and selection ancestry", () => {
const index = createRedisKeyTreeIndex([makeKey("team:api:v1", "k1"), makeKey("team:web:home", "k2")], 0);
const team = findGroup(index.root, "team");
const api = findGroup(team.children, "api");
const { addedGroupIds } = appendRedisKeysToTreeIndex(
index,
[
makeKey("team:api:v2", "k3"),
makeKey("orders:1", "k4"),
// SCAN may return the same key more than once across pages.
makeKey("team:api:v1", "k1"),
],
0,
);
const orders = findGroup(index.root, "orders");
assert.deepEqual(
index.root.map((node) => node.label),
["orders", "team"],
);
assert.equal(team.loadedLeafCount, 3);
assert.equal(api.loadedLeafCount, 2);
assert.equal(orders.loadedLeafCount, 1);
assert.equal(index.keyRaws.size, 4);
assert.deepEqual(index.ancestorGroupIdsByKeyRaw.get("k3"), [team.id, api.id]);
assert.ok(addedGroupIds.has(orders.id));
assert.ok(!addedGroupIds.has(team.id));
assert.deepEqual(collectRedisGroupKeyRaws(team), ["k1", "k3", "k2"]);
});
test("buildRedisKeyTree honors custom and empty Redis key separators", () => {
const customSeparatorTree = buildRedisKeyTree([makeKey("service/api/v1", "k1"), makeKey("service/web", "k2"), makeKey("literal:colon", "k3")], 0, "/");
const service = findGroup(customSeparatorTree, "service");
const api = findGroup(service.children, "api");
assert.equal(service.loadedLeafCount, 2);
assert.equal(api.loadedLeafCount, 1);
assert.deepEqual(collectRedisGroupKeyRaws(service), ["k1", "k2"]);
assert.deepEqual(
buildRedisKeyTree([makeKey("service/api/v1", "k1"), makeKey("literal:colon", "k2")], 0, "").map((node) => `${node.kind}:${node.label}`),
["leaf:literal:colon", "leaf:service/api/v1"],
);
});
test("tree keeps a namespace leaf alongside its child group", () => {
const tree = buildRedisKeyTree([makeKey("session", "k1"), makeKey("session:active", "k2")], 0);
const sessionGroup = findGroup(tree, "session");
assert.equal(sessionGroup.loadedLeafCount, 1);
assert.deepEqual(
tree.map((node) => `${node.kind}:${node.label}`),
["group:session", "leaf:session"],
);
assert.deepEqual(collectRedisGroupKeyRaws(sessionGroup), ["k2"]);
});
test("mergeKeysIntoRedisKeyTree adds new leaf to existing group", () => {
const tree = buildRedisKeyTree([makeKey("user:profile:name", "k1")], 0);
const merged = mergeKeysIntoRedisKeyTree(tree, [makeKey("user:profile:email", "k2")], 0);