perf(redis): avoid rebuilding key tree on detail load

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-09-04 20:23:27 +08:00
1 parent d9723afceb
commit c9c36a8453
4 files changed
+321 -43

No files matched your search

@@ -25,6 +25,8 @@ const mocks = vi.hoisted(() => ({
redisExecuteCommand: vi.fn(),
saveHistory: vi.fn(),
canBuildRedisFuzzyTree: vi.fn((loadedKeyCount: number) => loadedKeyCount <= 200_000),
createRedisKeyTreeIndex: vi.fn(),
flattenVisibleRedisKeyTree: vi.fn(),
toast: vi.fn(),
updateRedisDbKeyStats: vi.fn(),
listRedisCompletionCommandDocs: vi.fn(),
@@ -33,6 +35,7 @@ const mocks = vi.hoisted(() => ({
infiniteScroll: false,
queryResultMaxRowsEnabled: true,
queryResultMaxRows: 5000,
loadedTtl: -1,
}));
vi.mock("@/lib/backend/api", () => ({
@@ -56,7 +59,14 @@ vi.mock("@/lib/backend/api", () => ({
vi.mock("@/lib/redis/redisKeyTree", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/redis/redisKeyTree")>();
return { ...actual, canBuildRedisFuzzyTree: mocks.canBuildRedisFuzzyTree };
mocks.createRedisKeyTreeIndex.mockImplementation(actual.createRedisKeyTreeIndex);
mocks.flattenVisibleRedisKeyTree.mockImplementation(actual.flattenVisibleRedisKeyTree);
return {
...actual,
canBuildRedisFuzzyTree: mocks.canBuildRedisFuzzyTree,
createRedisKeyTreeIndex: mocks.createRedisKeyTreeIndex,
flattenVisibleRedisKeyTree: mocks.flattenVisibleRedisKeyTree,
};
});
vi.mock("@/stores/connectionStore", () => ({
@@ -287,7 +297,26 @@ vi.mock("@/components/editor/DangerConfirmDialog.vue", async () => {
vi.mock("./RedisValueViewer.vue", async () => {
const { defineComponent, h } = await import("vue");
return { default: defineComponent({ setup: () => () => h("div") }) };
return {
default: defineComponent({
props: { keyDisplay: String, keyRaw: String },
emits: ["loaded"],
setup(props, { emit }) {
return () =>
h("button", {
"data-test-emit-key-loaded": "",
onClick: () =>
emit("loaded", {
key_display: props.keyDisplay,
key_raw: props.keyRaw,
ttl: mocks.loadedTtl,
redis_type: "hash",
data: { kind: "hash", items: [] },
}),
});
},
}),
};
});
vi.mock("./RedisPubSubPanel.vue", async () => {
@@ -379,6 +408,7 @@ function resetApiMocks() {
mocks.infiniteScroll = false;
mocks.queryResultMaxRowsEnabled = true;
mocks.queryResultMaxRows = 5000;
mocks.loadedTtl = -1;
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
mocks.redisGetValue.mockImplementation((_connectionId: string, _db: number, keyRaw: string) => Promise.resolve(redisValue(keyRaw)));
mocks.redisSetString.mockResolvedValue(undefined);
@@ -673,6 +703,80 @@ describe("RedisKeyBrowser scope changes", () => {
});
describe("RedisKeyBrowser TTL list badges and no-expiry filter", () => {
it("refreshes one key's metadata without rebuilding the loaded tree", async () => {
mocks.redisScanKeysBatch.mockResolvedValue({
cursor: 0,
keys: [{ ...redisKeyInfo("string"), ttl: -1 }],
total_keys: 1,
});
mountBrowser();
await settle();
const keyCheckbox = requiredElement<HTMLInputElement>(`[data-redis-leaf="${KEY_RAW}"]`);
const keyRow = keyCheckbox.parentElement?.parentElement?.parentElement;
expect(keyRow).toBeInstanceOf(HTMLElement);
keyRow?.click();
await settle();
const buildsBeforeDetailLoaded = mocks.createRedisKeyTreeIndex.mock.calls.length;
const flattensBeforeDetailLoaded = mocks.flattenVisibleRedisKeyTree.mock.calls.length;
requiredElement<HTMLButtonElement>("[data-test-emit-key-loaded]").click();
await settle();
expect(mocks.createRedisKeyTreeIndex).toHaveBeenCalledTimes(buildsBeforeDetailLoaded);
expect(mocks.flattenVisibleRedisKeyTree).toHaveBeenCalledTimes(flattensBeforeDetailLoaded);
expect(keyRow?.textContent).toContain("hash");
expect(keyRow?.textContent).toContain("redis.noExpiry");
});
it("refreshes no-expiry membership when detail metadata changes TTL", async () => {
mocks.redisScanKeysBatch.mockResolvedValue({
cursor: 0,
keys: [redisKeyInfo("string")],
total_keys: 1,
});
mountBrowser();
await settle();
requiredElement<HTMLInputElement>(`[data-redis-leaf="${KEY_RAW}"]`).closest<HTMLElement>(".group")?.click();
await settle();
requiredElement<HTMLButtonElement>("[data-redis-no-expiry-filter]").click();
await settle();
expect(document.body.textContent).toContain("redis.noExpiryKeysEmpty");
requiredElement<HTMLButtonElement>("[data-test-emit-key-loaded]").click();
await settle();
expect(document.querySelector(`[data-redis-leaf="${KEY_RAW}"]`)).not.toBeNull();
expect(document.body.textContent).toContain("redis.noExpiry");
});
it("starts the local TTL countdown when detail metadata gains an expiry", async () => {
vi.useFakeTimers();
try {
mocks.redisScanKeysBatch.mockResolvedValue({
cursor: 0,
keys: [{ ...redisKeyInfo("string"), ttl: -1 }],
total_keys: 1,
});
mountBrowser();
await settle();
requiredElement<HTMLInputElement>(`[data-redis-leaf="${KEY_RAW}"]`).closest<HTMLElement>(".group")?.click();
await settle();
mocks.loadedTtl = 2;
requiredElement<HTMLButtonElement>("[data-test-emit-key-loaded]").click();
await settle();
expect(document.body.textContent).toContain("redis.ttlSecond");
await vi.advanceTimersByTimeAsync(3000);
await settle();
expect(document.body.textContent).toContain("redis.expired");
} finally {
vi.useRealTimers();
}
});
it("renders TTL badges per row and filters rows to keys without expiry", async () => {
mocks.redisScanKeysBatch.mockResolvedValue({
cursor: 0,
@@ -37,6 +37,8 @@ import {
flattenVisibleRedisKeyTree,
redisKeyNameCopyText,
redisKeyToFlatTreeRow,
updateRedisKeyInfoMetadataByRaw,
updateRedisKeyTreeLeafMetadata,
type RedisKeyTreeGroupNode,
type RedisKeyTreeIndex,
type RedisKeyTreeNode,
@@ -99,6 +101,7 @@ const redisExpiryTransport = {
const flatKeys = shallowRef<RedisKeyInfo[]>([]);
const treeKeys = shallowRef<RedisKeyTreeNode[]>([]);
const flatKeyByRaw = new Map<string, RedisKeyInfo>();
const loading = ref(false);
const loadingMore = ref(false);
const searchPending = ref(false);
@@ -124,6 +127,10 @@ const expandedGroupIds = ref<Set<string>>(new Set());
const checkedKeys = ref<Set<string>>(new Set());
/** Bumped when selection or tree counts change so virtualized rows re-evaluate state. */
const selectionEpoch = ref(0);
/** Bumped when mutable metadata changes; it must never invalidate visibleRows. */
const keyMetadataEpoch = ref(0);
/** Bumped only when a TTL refresh changes membership in the no-expiry projection. */
const noExpiryProjectionEpoch = ref(0);
const selectionAnchorRowId = ref<string | null>(null);
const selectedGroupLeafCounts = shallowRef<Map<string, number>>(new Map());
const deletingKeys = ref(false);
@@ -252,7 +259,11 @@ const lastTotalKeys = ref(0);
// TTL 为 -2 的行(fetch-all 链路未查询 TTL)不会出现在过滤结果里。
const noExpiryOnly = ref(false);
// 过滤后的平铺 key 列表:未开启过滤时与 flatKeys 完全一致,避免额外开销
const filteredFlatKeys = computed(() => (noExpiryOnly.value ? flatKeys.value.filter((key) => key.ttl === -1) : flatKeys.value));
const filteredFlatKeys = computed(() => {
if (!noExpiryOnly.value) return flatKeys.value;
void noExpiryProjectionEpoch.value;
return flatKeys.value.filter((key) => key.ttl === -1);
});
// 过滤后的树:独立重建而不复用 treeIndex,避免污染后续 SCAN 增量合并的全量树基准;
// 分组 id 只由 db+路径决定,与全量树一致,因此展开状态可直接复用
const filteredTreeKeys = computed(() => {
@@ -280,7 +291,10 @@ const keyCountText = computed(() => {
const count = isSearchMode.value && hasMore.value && !isFetchingAll.value ? `${displayedKeyCount.value}+` : displayedKeyCount.value;
return t("redis.keys", { count });
});
const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null);
const selectedKey = computed(() => {
void keyMetadataEpoch.value;
return selectedKeyRaw.value ? (flatKeyByRaw.get(selectedKeyRaw.value) ?? null) : null;
});
const dangerDetails = computed(() => {
if (!pendingDanger.value) return "";
if (pendingDanger.value.kind === "delete-keys") {
@@ -374,6 +388,19 @@ watch(activeCreateKeyTypeHelp, () => {
const visibleRows = computed(() => {
return useFlatKeySearchRows.value ? filteredFlatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(filteredTreeKeys.value, expandedGroupIds.value);
});
function redisRowKeyInfo(node: RedisKeyTreeNode): RedisKeyInfo | null {
void keyMetadataEpoch.value;
return node.kind === "leaf" ? (flatKeyByRaw.get(node.keyRaw) ?? null) : null;
}
function redisRowKeyType(node: RedisKeyTreeNode): string {
return redisRowKeyInfo(node)?.key_type ?? "";
}
function redisRowTtl(node: RedisKeyTreeNode): number {
return redisRowKeyInfo(node)?.ttl ?? -2;
}
// 列表行的 TTL 徽标文案:-1 表示永不过期,展示本地化文案;
// 大于 0 时展示本地倒计时后的剩余时间,倒计时归零展示已过期;其余(-2 未查询)不显示
function redisTtlBadgeText(ttl: number, displayTtl: number): string | null {
@@ -390,6 +417,7 @@ function redisTtlBadgeClass(ttl: number, displayTtl: number): string {
// 记录每个 key 的 TTL 被观测到的时刻(毫秒);本地倒计时 = 观测时的 TTL - 已流逝时间,
// 与右侧详情面板同源(computeTtlCountdownValue),不需要额外的网络请求
const ttlObservedAtByRaw = new Map<string, number>();
const positiveTtlKeyRaws = new Set<string>();
// 驱动列表行 TTL 倒计时的当前时刻,仅在存在需要倒计时的 key 时每秒更新
const listTtlNowMs = ref(Date.now());
let listTtlTimer: ReturnType<typeof setInterval> | null = null;
@@ -399,11 +427,25 @@ function recordKeyTtlObservedAt(key: RedisKeyInfo) {
const ttl = key.ttl ?? -2;
if (ttl > 0) {
ttlObservedAtByRaw.set(key.key_raw, Date.now());
positiveTtlKeyRaws.add(key.key_raw);
} else {
ttlObservedAtByRaw.delete(key.key_raw);
positiveTtlKeyRaws.delete(key.key_raw);
}
}
function appendFlatKeyRecords(keys: readonly RedisKeyInfo[]) {
if (keys.length === 0) return;
for (const key of keys) flatKeyByRaw.set(key.key_raw, key);
flatKeys.value = [...flatKeys.value, ...keys];
}
function replaceFlatKeyRecords(keys: RedisKeyInfo[]) {
flatKeyByRaw.clear();
for (const key of keys) flatKeyByRaw.set(key.key_raw, key);
flatKeys.value = keys;
}
// 列表行展示的 TTL:正 TTL 按观测时刻到当前时刻的流逝本地递减;-1/-2 原样透传
function redisRowDisplayTtl(ttl: number, keyRaw: string): number {
if (ttl <= 0) return ttl;
@@ -413,7 +455,7 @@ function redisRowDisplayTtl(ttl: number, keyRaw: string): number {
// 按需启停倒计时定时器:只在组件激活且存在正 TTL 的 key 时运行,避免空转
function syncListTtlTimer() {
const needed = redisBrowserIsActive && flatKeys.value.some((key) => (key.ttl ?? -2) > 0);
const needed = redisBrowserIsActive && positiveTtlKeyRaws.size > 0;
if (needed && !listTtlTimer) {
listTtlNowMs.value = Date.now();
listTtlTimer = setInterval(() => {
@@ -597,7 +639,7 @@ function rebuildTree(expandAll = false) {
expandedGroupIds.value = nextExpanded;
refreshSelectedGroupLeafCounts();
if (selectedKeyRaw.value && !flatKeys.value.some((key) => key.key_raw === selectedKeyRaw.value)) {
if (selectedKeyRaw.value && !flatKeyByRaw.has(selectedKeyRaw.value)) {
selectedKeyRaw.value = null;
}
}
@@ -704,7 +746,7 @@ function appendScanResult(result: RedisScanResult, options: { updateTree?: boole
if (options.buffer) {
for (const key of newKeys) options.buffer.push(key);
} else if (newKeys.length > 0) {
flatKeys.value = [...flatKeys.value, ...newKeys];
appendFlatKeyRecords(newKeys);
}
const loadedCount = flatKeys.value.length + (options.buffer?.length ?? 0);
scanCursor.value = result.cursor;
@@ -766,8 +808,9 @@ async function loadKeys() {
loading.value = true;
loadedKeyRaws.clear();
ttlObservedAtByRaw.clear();
positiveTtlKeyRaws.clear();
subtreeFilledGroupIds.clear();
flatKeys.value = [];
replaceFlatKeyRecords([]);
treeKeys.value = [];
treeIndex = null;
selectedKeyRaw.value = null;
@@ -918,7 +961,7 @@ async function fetchAll(): Promise<boolean> {
completed = requestId === searchRequestId && !fetchAllStopRequested.value && !hasMore.value;
} finally {
if (requestId === searchRequestId) {
if (bufferedKeys.length > 0) flatKeys.value = [...flatKeys.value, ...bufferedKeys];
appendFlatKeyRecords(bufferedKeys);
if (changed) {
if (useFlatKeySearchRows.value) {
treeKeys.value = [];
@@ -953,7 +996,7 @@ function shouldFillGroupSubtree(): boolean {
function mergeScannedKeys(newKeys: RedisKeyInfo[]) {
if (newKeys.length === 0) return;
for (const key of newKeys) recordKeyTtlObservedAt(key);
flatKeys.value = [...flatKeys.value, ...newKeys];
appendFlatKeyRecords(newKeys);
mergeTree(newKeys);
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
@@ -1014,10 +1057,11 @@ function onRowClick(node: RedisKeyTreeNode, event?: MouseEvent) {
}
function removeKnownKey(keyRaw: string) {
if (!flatKeys.value.some((key) => key.key_raw === keyRaw)) return;
if (!flatKeyByRaw.has(keyRaw)) return;
loadedKeyRaws.delete(keyRaw);
ttlObservedAtByRaw.delete(keyRaw);
flatKeys.value = flatKeys.value.filter((key) => key.key_raw !== keyRaw);
positiveTtlKeyRaws.delete(keyRaw);
replaceFlatKeyRecords(flatKeys.value.filter((key) => key.key_raw !== keyRaw));
if (selectedKeyRaw.value === keyRaw) selectedKeyRaw.value = null;
if (useFlatKeySearchRows.value) {
treeKeys.value = [];
@@ -1043,7 +1087,7 @@ function onKeyRenamed(oldKeyRaw: string, newKeyRaw: string, newKeyDisplay: strin
return;
}
const previous = flatKeys.value.find((key) => key.key_raw === oldKeyRaw);
const previous = flatKeyByRaw.get(oldKeyRaw);
if (!previous) {
void loadKeys();
return;
@@ -1055,7 +1099,8 @@ function onKeyRenamed(oldKeyRaw: string, newKeyRaw: string, newKeyDisplay: strin
const observedAt = ttlObservedAtByRaw.get(oldKeyRaw);
ttlObservedAtByRaw.delete(oldKeyRaw);
if (observedAt !== undefined) ttlObservedAtByRaw.set(newKeyRaw, observedAt);
flatKeys.value = flatKeys.value.map((key) => (key.key_raw === oldKeyRaw ? { ...key, key_raw: newKeyRaw, key_display: newKeyDisplay } : key));
if (positiveTtlKeyRaws.delete(oldKeyRaw)) positiveTtlKeyRaws.add(newKeyRaw);
replaceFlatKeyRecords(flatKeys.value.map((key) => (key.key_raw === oldKeyRaw ? { ...key, key_raw: newKeyRaw, key_display: newKeyDisplay } : key)));
if (selectedKeyRaw.value === oldKeyRaw) selectedKeyRaw.value = newKeyRaw;
if (checkedKeys.value.has(oldKeyRaw)) {
const nextCheckedKeys = new Set(checkedKeys.value);
@@ -1089,19 +1134,15 @@ function onKeyLoaded(value: RedisValue) {
return;
}
const keyInfo = redisValueToKeyInfo(value);
const existingIndex = flatKeys.value.findIndex((key) => key.key_raw === keyInfo.key_raw);
if (existingIndex < 0) return;
const previousTtl = flatKeyByRaw.get(keyInfo.key_raw)?.ttl ?? -2;
if (!updateRedisKeyInfoMetadataByRaw(flatKeyByRaw, keyInfo)) return;
// 详情面板回写了最新的 TTL,同步刷新观测时刻,保证两侧倒计时一致
recordKeyTtlObservedAt(keyInfo);
flatKeys.value = flatKeys.value.map((key, index) => (index === existingIndex ? keyInfo : key));
loadedKeyRaws.add(keyInfo.key_raw);
if (useFlatKeySearchRows.value) {
treeKeys.value = [];
treeIndex = null;
refreshSelectedGroupLeafCounts();
} else {
rebuildTree(false);
}
if (treeIndex) updateRedisKeyTreeLeafMetadata(treeIndex, keyInfo);
if ((previousTtl === -1) !== (keyInfo.ttl === -1)) noExpiryProjectionEpoch.value++;
keyMetadataEpoch.value++;
syncListTtlTimer();
}
function requestBatchDelete() {
@@ -1202,7 +1243,8 @@ function resetLoadedKeys() {
fetchAllLoadedCount.value = 0;
loadedKeyRaws.clear();
ttlObservedAtByRaw.clear();
flatKeys.value = [];
positiveTtlKeyRaws.clear();
replaceFlatKeyRecords([]);
treeKeys.value = [];
treeIndex = null;
selectedKeyRaw.value = null;
@@ -1230,8 +1272,9 @@ async function deleteKeyRaws(keys: string[]) {
for (const key of deleted) {
loadedKeyRaws.delete(key);
ttlObservedAtByRaw.delete(key);
positiveTtlKeyRaws.delete(key);
}
flatKeys.value = flatKeys.value.filter((key) => !deleted.has(key.key_raw));
replaceFlatKeyRecords(flatKeys.value.filter((key) => !deleted.has(key.key_raw)));
if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) {
selectedKeyRaw.value = null;
}
@@ -1456,20 +1499,20 @@ function upsertCreatedKey(value: RedisValue) {
size: redisValueSize(value),
value_preview: redisValuePreview(value),
};
const existingIndex = flatKeys.value.findIndex((key) => key.key_raw === keyInfo.key_raw);
const existing = flatKeyByRaw.get(keyInfo.key_raw);
// 新建 key 携带的 TTL 以当前时刻为观测起点
recordKeyTtlObservedAt(keyInfo);
if (existingIndex >= 0) {
flatKeys.value = flatKeys.value.map((key, index) => (index === existingIndex ? keyInfo : key));
if (existing) {
replaceFlatKeyRecords(flatKeys.value.map((key) => (key.key_raw === keyInfo.key_raw ? keyInfo : key)));
} else {
flatKeys.value = [keyInfo, ...flatKeys.value];
replaceFlatKeyRecords([keyInfo, ...flatKeys.value]);
}
loadedKeyRaws.add(keyInfo.key_raw);
selectedKeyRaw.value = keyInfo.key_raw;
rebuildTree(isSearchMode.value);
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
totalDelta: existingIndex >= 0 ? 0 : 1,
totalDelta: existing ? 0 : 1,
});
}
@@ -2435,14 +2478,14 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</template>
</div>
<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>
<Badge v-if="row.node.kind === 'leaf' && redisRowKeyType(row.node)" variant="outline" class="text-xs px-1.5 py-0" :class="typeColor(redisRowKeyType(row.node))">{{ redisRowKeyType(row.node) }}</Badge>
<!-- TTL 徽标:与类型徽标保持一致的胶囊样式,永不过期为琥珀色、临近过期/已过期为红色警示 -->
<span
v-if="row.node.kind === 'leaf' && redisTtlBadgeText(row.node.ttl, redisRowDisplayTtl(row.node.ttl, row.node.keyRaw))"
v-if="row.node.kind === 'leaf' && redisTtlBadgeText(redisRowTtl(row.node), redisRowDisplayTtl(redisRowTtl(row.node), row.node.keyRaw))"
class="inline-flex shrink-0 items-center whitespace-nowrap rounded border px-1.5 py-0.5 text-[11px] leading-none"
:class="redisTtlBadgeClass(row.node.ttl, redisRowDisplayTtl(row.node.ttl, row.node.keyRaw))"
:title="row.node.ttl === -1 ? t('redis.noExpiry') : t('redis.ttlCountdownTitle')"
>{{ redisTtlBadgeText(row.node.ttl, redisRowDisplayTtl(row.node.ttl, row.node.keyRaw)) }}</span
:class="redisTtlBadgeClass(redisRowTtl(row.node), redisRowDisplayTtl(redisRowTtl(row.node), row.node.keyRaw))"
:title="redisRowTtl(row.node) === -1 ? t('redis.noExpiry') : t('redis.ttlCountdownTitle')"
>{{ redisTtlBadgeText(redisRowTtl(row.node), redisRowDisplayTtl(redisRowTtl(row.node), row.node.keyRaw)) }}</span
>
<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" />
+38 -6
View File
@@ -43,7 +43,7 @@ export interface RedisKeyTreeRow {
export interface RedisKeyTreeIndex {
root: RedisKeyTreeNode[];
groupById: Map<string, RedisKeyTreeGroupNode>;
keyRaws: Set<string>;
leafByKeyRaw: Map<string, RedisKeyTreeLeafNode>;
ancestorGroupIdsByKeyRaw: Map<string, readonly string[]>;
}
@@ -106,7 +106,7 @@ export function createRedisKeyTreeIndex(keys: readonly RedisKeyInfo[], db: numbe
const index: RedisKeyTreeIndex = {
root: [],
groupById: new Map(),
keyRaws: new Set(),
leafByKeyRaw: new Map(),
ancestorGroupIdsByKeyRaw: new Map(),
};
appendRedisKeysToTreeIndex(index, keys, db, separator);
@@ -122,7 +122,7 @@ export function appendRedisKeysToTreeIndex(index: RedisKeyTreeIndex, keys: reado
const addedGroupIds = new Set<string>();
for (const key of keys) {
if (index.keyRaws.has(key.key_raw)) continue;
if (index.leafByKeyRaw.has(key.key_raw)) continue;
const pathSegments = separator ? key.key_display.split(separator) : [key.key_display];
const ancestorGroupIds: string[] = [];
@@ -154,7 +154,7 @@ export function appendRedisKeysToTreeIndex(index: RedisKeyTreeIndex, keys: reado
}
}
currentLevel.push({
const leaf: RedisKeyTreeLeafNode = {
kind: "leaf",
id: buildLeafId(db, key.key_raw),
label: pathSegments[pathSegments.length - 1],
@@ -166,9 +166,10 @@ export function appendRedisKeysToTreeIndex(index: RedisKeyTreeIndex, keys: reado
size: key.size ?? 0,
valuePreview: key.value_preview ?? "",
pathSegments,
});
};
currentLevel.push(leaf);
touchedLevels.add(currentLevel);
index.keyRaws.add(key.key_raw);
index.leafByKeyRaw.set(key.key_raw, leaf);
index.ancestorGroupIdsByKeyRaw.set(key.key_raw, ancestorGroupIds);
}
@@ -176,6 +177,37 @@ export function appendRedisKeysToTreeIndex(index: RedisKeyTreeIndex, keys: reado
return { addedGroupIds };
}
/**
* Refreshes metadata for an existing tree leaf without changing namespace
* identity, hierarchy, ordering, or aggregate counts.
*/
export function updateRedisKeyTreeLeafMetadata(index: RedisKeyTreeIndex, key: RedisKeyInfo): boolean {
const leaf = index.leafByKeyRaw.get(key.key_raw);
if (!leaf) return false;
leaf.keyType = key.key_type ?? "";
leaf.ttl = key.ttl ?? -2;
leaf.size = key.size ?? 0;
leaf.valuePreview = key.value_preview ?? "";
return true;
}
/**
* Refreshes metadata on the canonical flat-list record through its identity
* index. The existing object is retained so large shallow collections do not
* need to be copied for a single-key refresh.
*/
export function updateRedisKeyInfoMetadataByRaw(keyByRaw: ReadonlyMap<string, RedisKeyInfo>, key: RedisKeyInfo): boolean {
const existing = keyByRaw.get(key.key_raw);
if (!existing) return false;
existing.key_type = key.key_type;
existing.ttl = key.ttl;
existing.size = key.size;
existing.value_preview = key.value_preview;
return true;
}
export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeNode[] {
return createRedisKeyTreeIndex(keys, db, separator).root;
}