From 15c38da8ea79f8e54c238512f82116b04e6910a3 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 16 Sep 2026 00:47:22 +0800 Subject: [PATCH] fix(redis): preserve full key snapshots during local search Closes #9120 --- .../redis/RedisKeyBrowser.expiry.spec.ts | 60 ++++++++++ .../src/components/redis/RedisKeyBrowser.vue | 89 +++++++++++---- .../src/lib/redis/redisKeyPattern.spec.ts | 42 ++++++- apps/desktop/src/lib/redis/redisKeyPattern.ts | 108 ++++++++++++++++++ 4 files changed, 277 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 146034eca7..3ea2fb8e05 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -2107,6 +2107,66 @@ describe("RedisKeyBrowser KeepAlive scan budget (issue #7779)", () => { }); describe("RedisKeyBrowser interrupted Fetch All", () => { + it("keeps the complete snapshot when a group is opened during preparation", async () => { + const initial = { key_display: "group:first", key_raw: btoa("group:first"), key_type: "string", ttl: -1 }; + const buffered = { key_display: "group:second", key_raw: btoa("group:second"), key_type: "string", ttl: -1 }; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 })); + const buildStarted = deferred(); + const releaseBuild = deferred(); + const buildSnapshot = mocks.buildRedisKeySnapshotCooperatively.getMockImplementation()!; + mocks.buildRedisKeySnapshotCooperatively.mockImplementationOnce(async (...args) => { + buildStarted.resolve(); + await releaseBuild.promise; + return buildSnapshot(...args); + }); + mountBrowser(); + await settle(); + clickButtonWithText("redis.fetchAllKeys"); + await buildStarted.promise; + groupRow("group").click(); + releaseBuild.resolve(); + await settle(); + await vi.waitFor(() => expect(document.body.textContent).not.toContain("redis.stopFetchAll")); + await submitKeySearch("group:second"); + + expect(document.querySelector(`[data-redis-leaf="${buffered.key_raw}"]`)).not.toBeNull(); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2); + expect(document.body.textContent).not.toContain("redis.fetchAllKeys"); + }); + + it("filters a completed Fetch All snapshot while editing a key search", async () => { + const unrelated = { key_display: "session:unrelated", key_raw: "c2Vzc2lvbjp1bnJlbGF0ZWQ=", key_type: "string", ttl: -1 }; + const target = { key_display: "prod:login_fail_count", key_raw: "cHJvZDpsb2dpbl9mYWlsX2NvdW50", key_type: "string", ttl: -1 }; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [unrelated], total_keys: 2 } : { cursor: 0, keys: [target], total_keys: 0 })); + mountBrowser(); + await settle(); + + clickButtonWithText("redis.fetchAllKeys"); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(document.body.textContent).not.toContain("redis.stopFetchAll")); + const scanCalls = mocks.redisScanKeysBatch.mock.calls.length; + + await setInput("[data-redis-search-input]", "prod:login_fail_count"); + + expect(document.body.textContent).toContain(target.key_display); + expect(document.body.textContent).not.toContain(unrelated.key_display); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(scanCalls); + const input = requiredElement("[data-redis-search-input]"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await settle(); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(scanCalls); + expect(document.body.textContent).toContain(target.key_display); + requiredElement("[data-redis-select-all]").click(); + await settle(); + expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain("1"); + expect(document.querySelector("[data-redis-select-all]")).toBeNull(); + + await setInput("[data-redis-search-input]", ""); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(scanCalls); + expect(mocks.scrollerItems[mocks.scrollerItems.length - 1]).toHaveLength(2); + }); + it("publishes Fetch All rows through one stable Array facade and explicitly refreshes its viewport", async () => { const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 }; const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 }; diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 14d70e6fbd..454ffc043b 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -58,7 +58,7 @@ import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts"; import { copyToClipboard } from "@/lib/common/clipboard"; import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle"; import { useToast } from "@/composables/useToast"; -import { redisFuzzySearchScanBudget, redisKeySearchPattern, redisGroupSubtreePattern } from "@/lib/redis/redisKeyPattern"; +import { redisFuzzySearchScanBudget, createRedisKeyPatternMatcher, redisKeySearchPattern, redisGroupSubtreePattern } from "@/lib/redis/redisKeyPattern"; import { filterRedisKeyTemplates, resolveRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates"; import { forgetRedisKeySearchHistory, loadRedisKeySearchHistory, rememberRedisKeySearchHistory, type RedisKeySearchHistoryScope } from "@/lib/redis/redisKeySearchHistory"; import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern"; @@ -148,6 +148,7 @@ const loading = ref(false); const loadingMore = ref(false); const searchPending = ref(false); const isFetchingAll = ref(false); +const fetchAllSnapshotComplete = ref(false); const fetchAllStopRequested = ref(false); const fetchAllLoadedCount = ref(0); const rootRef = ref(); @@ -258,7 +259,6 @@ const AUTO_LOAD_TOTAL_SCAN_ITERATIONS = 50; let autoLoadBudget: ScanIterationBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS }; let redisBrowserIsActive = true; let reloadKeysOnActivation = false; -let fetchAllPreparingSnapshot = false; let redisDbFlushedListenerRegistered = false; let redisInfiniteScrollFrame = 0; let loadedKeyRaws = new Set(); @@ -299,6 +299,8 @@ 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 localKeySearchActive = computed(() => fetchAllSnapshotComplete.value && searchMode.value === "key" && isSearchMode.value); +const localKeyPatternMatcher = computed(() => createRedisKeyPatternMatcher(effectivePattern.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); @@ -310,7 +312,7 @@ const mutatingKeys = computed(() => deletingKeys.value || savingBatchExpiry.valu const selectionBusy = computed(() => mutatingKeys.value || loading.value || loadingMore.value || isFetchingAll.value || searchPending.value); // checkedKeys is always a subset of loaded keys, so size equality is enough. const allLoadedKeysSelected = computed(() => flatKeys.value.length > 0 && checkedKeys.value.size === flatKeys.value.length); -const allKeysSelected = computed(() => (customGrouping.value.enabled ? filteredFlatKeys.value.length > 0 && checkedKeys.value.size === filteredFlatKeys.value.length : allLoadedKeysSelected.value && !hasMore.value)); +const allKeysSelected = computed(() => (customGrouping.value.enabled || localKeySearchActive.value ? filteredFlatKeys.value.length > 0 && checkedKeys.value.size === filteredFlatKeys.value.length : allLoadedKeysSelected.value && !hasMore.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"); @@ -368,18 +370,29 @@ watch([searchPattern, searchMode, fuzzyKeySearch, noExpiryOnly], persistRedisKey const fetchAllFilteredKeyCount = ref(null); // 过滤后的平铺 key 列表:未开启过滤时与 flatKeys 完全一致,避免额外开销 const filteredFlatKeys = computed(() => { - if (!noExpiryOnly.value) return flatKeys.value; + const onlyNoExpiry = noExpiryOnly.value; + const matchesPattern = localKeySearchActive.value ? localKeyPatternMatcher.value : undefined; + if (!onlyNoExpiry && !matchesPattern) return flatKeys.value; void noExpiryProjectionEpoch.value; - return flatKeys.value.filter((key) => key.ttl === -1); + return flatKeys.value.filter((key) => (!onlyNoExpiry || key.ttl === -1) && (!matchesPattern || matchesPattern(key.key_display, key.key_raw))); }); // 过滤后的树:独立重建而不复用 treeIndex,避免污染后续 SCAN 增量合并的全量树基准; // 分组 id 只由 db+路径决定,与全量树一致,因此展开状态可直接复用 const filteredTreeKeys = computed(() => { - if (!noExpiryOnly.value) return treeKeys.value; + if (!noExpiryOnly.value && !localKeySearchActive.value) return treeKeys.value; return buildRedisKeyTree(filteredFlatKeys.value, props.db, redisKeySeparator.value); }); +watch(effectivePattern, () => { + if (!fetchAllSnapshotComplete.value || searchMode.value !== "key") return; + if (localKeySearchActive.value && isFuzzyHierarchyView.value) expandedGroupIds.value = collectExpandedGroupIds(filteredTreeKeys.value); + void nextTick(() => { + redisKeyScroller()?.scrollToItem(0, { align: "start" }); + refreshRedisKeyScroller(); + }); +}); const displayedKeyCount = computed(() => { if (isFetchingAll.value) return fetchAllLoadedCount.value; + if (localKeySearchActive.value) return filteredFlatKeys.value.length; // 过滤时展示匹配数量,便于确认“无过期”key 的规模 if (noExpiryOnly.value) return fetchAllFilteredKeyCount.value ?? filteredFlatKeys.value.length; return flatKeys.value.length; @@ -493,10 +506,28 @@ async function updateCreateKeyTypeHelpOffset() { watch(activeCreateKeyTypeHelp, () => { void updateCreateKeyTypeHelpOffset(); }); +const localFlatSearchRows = computed(() => { + const keys = filteredFlatKeys.value; + const database = props.db; + return markRaw( + new Proxy([] as RedisKeyTreeRow[], { + get(target, property, receiver) { + if (property === "length") return keys.length; + const index = facadeArrayIndex(property); + return index >= 0 ? (keys[index] ? redisKeyToFlatTreeRow(keys[index], database) : undefined) : Reflect.get(target, property, receiver); + }, + has(target, property) { + const index = facadeArrayIndex(property); + return index >= 0 ? index < keys.length : Reflect.has(target, property); + }, + }), + ); +}); const regularVisibleRows = computed(() => { // Prepared snapshots already own their rows. Even a watcher whose callback // ignores them would otherwise synchronously re-map/flatten the keyspace. if (showCustomGrouping.value || fetchAllVisibleRowsActive.value) return []; + if (useFlatKeySearchRows.value && localKeySearchActive.value) return localFlatSearchRows.value; return useFlatKeySearchRows.value ? filteredFlatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(filteredTreeKeys.value, expandedGroupIds.value); }); let fetchAllVisibleRowsSource: readonly RedisKeyTreeRow[] = []; @@ -765,7 +796,7 @@ function toggleNodeCheck(node: RedisKeyTreeNode, event: MouseEvent) { function selectAllLoadedKeys() { if (selectionBusy.value || loadedKeyRaws.size === 0) return; focusKeyPane(); - setKeysChecked(customGrouping.value.enabled ? filteredFlatKeys.value.map((key) => key.key_raw) : loadedKeyRaws, true); + setKeysChecked(customGrouping.value.enabled || localKeySearchActive.value ? filteredFlatKeys.value.map((key) => key.key_raw) : loadedKeyRaws, true); selectionAnchorRowId.value = visibleRows.value[0]?.id ?? null; } @@ -871,7 +902,6 @@ function invalidateScanRequests(resetAutoLoadBudget = true): number { isFetchingAll.value = false; fetchAllStopRequested.value = true; fetchAllLoadedCount.value = 0; - fetchAllPreparingSnapshot = false; searchRequestId++; loadMoreOperationId++; loadingMore.value = false; @@ -881,6 +911,7 @@ function invalidateScanRequests(resetAutoLoadBudget = true): number { } function invalidateFetchAllForStructuralMutation() { + fetchAllSnapshotComplete.value = false; if (isFetchingAll.value || fetchAllPublicationRollback) invalidateScanRequests(); } @@ -1025,6 +1056,7 @@ async function loadKeys() { searchPending.value = false; rememberRedisKeySearchHistory(searchHistoryScope.value, searchPattern.value); const requestId = invalidateScanRequests(); + fetchAllSnapshotComplete.value = false; isFetchingAll.value = false; fetchAllStopRequested.value = false; fetchAllLoadedCount.value = 0; @@ -1288,6 +1320,7 @@ async function publishFetchAllVisibleRows(rows: readonly RedisKeyTreeRow[], requ async function fetchAll(): Promise { if (!hasMore.value || isFetchingAll.value) return false; const requestId = searchRequestId; + const fetchAllIsFullKeyspace = searchMode.value === "key" && !isSearchMode.value; const bufferedKeys: RedisKeyInfo[] = []; const bufferedKeyRaws = new Set(); const initialFlatKeys = flatKeys.value; @@ -1347,7 +1380,6 @@ async function fetchAll(): Promise { snapshotConfig.expandedGroupIds = expandedGroupIds.value; snapshotConfig.noExpiryOnly = noExpiryOnly.value; activateFetchAllVisibleRows(); - fetchAllPreparingSnapshot = true; const snapshot = await buildRedisKeySnapshotCooperatively([initialFlatKeys, bufferedKeys], snapshotConfig, { shouldContinue: () => requestId === searchRequestId && redisBrowserIsActive && !fetchAllStopRequested.value, }); @@ -1364,9 +1396,9 @@ async function fetchAll(): Promise { } } } finally { - fetchAllPreparingSnapshot = false; publicationSucceeded = published; if (published) { + fetchAllSnapshotComplete.value = fetchAllIsFullKeyspace; fetchAllPublicationRollback = null; if (!hasMore.value) refreshExpandedGroupIds.clear(); } @@ -1504,12 +1536,6 @@ function resumePendingGroupSubtrees(requestId = searchRequestId) { } function toggleGroup(groupId: string) { - if (fetchAllPreparingSnapshot) { - invalidateScanRequests(); - isFetchingAll.value = false; - fetchAllStopRequested.value = false; - fetchAllLoadedCount.value = 0; - } deactivateFetchAllVisibleRows(); const next = new Set(expandedGroupIds.value); const expanding = !next.has(groupId); @@ -1733,6 +1759,7 @@ function onRedisRowContextMenu(event: MouseEvent, node: RedisKeyTreeNode, openCo function resetLoadedKeys() { invalidateScanRequests(); + fetchAllSnapshotComplete.value = false; isFetchingAll.value = false; fetchAllStopRequested.value = false; fetchAllLoadedCount.value = 0; @@ -2566,17 +2593,37 @@ function onSearchInput() { }, 400); } +function submitSearch() { + if (!fetchAllSnapshotComplete.value || searchMode.value !== "key") { + void loadKeys(); + return; + } + if (searchTimer) clearTimeout(searchTimer); + searchTimer = null; + searchPending.value = false; + rememberRedisKeySearchHistory(searchHistoryScope.value, searchPattern.value); + invalidateScanRequests(); + loading.value = false; + isFetchingAll.value = false; + fetchAllStopRequested.value = false; + fetchAllLoadedCount.value = 0; + selectedKeyRaw.value = null; + resetCheckedKeys(); + hasMore.value = false; +} + function setSearchMode(mode: RedisSearchMode) { if (searchMode.value === mode) return; searchMode.value = mode; dismissSearchHistoryMenu(); if (mode !== "key") dismissKeyTemplateMenu(); - void loadKeys(); + if (mode === "key") submitSearch(); + else void loadKeys(); } function toggleFuzzyKeySearch() { fuzzyKeySearch.value = !fuzzyKeySearch.value; - if (searchMode.value === "key") void loadKeys(); + if (searchMode.value === "key") submitSearch(); } function toggleNoExpiryOnly() { @@ -2776,7 +2823,7 @@ function onSearchKeydown(event: KeyboardEvent) { selectSearchHistory(searchHistorySelectedIndex.value); } else { dismissSearchHistoryMenu(); - void loadKeys(); + submitSearch(); } return; } @@ -2809,7 +2856,7 @@ function onSearchKeydown(event: KeyboardEvent) { } } if (event.key === "Enter") { - void loadKeys(); + submitSearch(); return; } if (!isCancelSearchShortcut(event)) return; @@ -2817,7 +2864,7 @@ function onSearchKeydown(event: KeyboardEvent) { searchPattern.value = ""; dismissKeyTemplateMenu(); dismissSearchHistoryMenu(); - void loadKeys(); + submitSearch(); } function onRedisDbFlushed(event: Event) { diff --git a/apps/desktop/src/lib/redis/redisKeyPattern.spec.ts b/apps/desktop/src/lib/redis/redisKeyPattern.spec.ts index ce894349b0..5c8ad3ef76 100644 --- a/apps/desktop/src/lib/redis/redisKeyPattern.spec.ts +++ b/apps/desktop/src/lib/redis/redisKeyPattern.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { redisGroupSubtreePattern } from "@/lib/redis/redisKeyPattern"; +import { createRedisKeyPatternMatcher, redisGroupSubtreePattern, redisKeyMatchesPattern } from "@/lib/redis/redisKeyPattern"; describe("redisGroupSubtreePattern", () => { it("builds a prefix pattern for single and nested groups", () => { @@ -18,3 +18,43 @@ describe("redisGroupSubtreePattern", () => { expect(redisGroupSubtreePattern(["back\\slash"])).toBe("back\\\\slash:*"); }); }); + +describe("redisKeyMatchesPattern", () => { + it.each([ + ["[z-a]", "m", true], + ["[a", "a", true], + ["[]", "]", false], + ["?", "中", false], + ["???", "中", true], + ["????", "😀", true], + ["[^x]", "y", true], + ["*中*", "key:中文", true], + ["literal:\\*", "literal:x", false], + ])("matches Redis byte-based glob semantics for %s", (pattern, value, expected) => { + expect(redisKeyMatchesPattern(value, pattern)).toBe(expected); + }); + + it("uses raw bytes when Redis escapes a display key", () => { + const literalSlash = createRedisKeyPatternMatcher("path\\\\name"); + expect(literalSlash("path\\\\name", btoa("path\\name"))).toBe(true); + expect(createRedisKeyPatternMatcher("bin:?")("bin:\\xff", btoa("bin:\xff"))).toBe(true); + expect(createRedisKeyPatternMatcher("bin:?")("bin:\\xff", "invalid!")).toBe(false); + }); + + it("reuses a compiled matcher without carrying state between keys", () => { + const matches = createRedisKeyPatternMatcher("*login*"); + expect(["session:1", "prod:login:1", "session:2", "prod:login:2"].filter((value) => matches(value))).toEqual(["prod:login:1", "prod:login:2"]); + }); + + it("matches Redis glob literals, wildcards, and classes", () => { + expect(redisKeyMatchesPattern("prod:login_fail_count", "prod:login_fail_count")).toBe(true); + expect(redisKeyMatchesPattern("prod:login_fail_count", "prod:*_fail_????t")).toBe(true); + expect(redisKeyMatchesPattern("prod:login_fail_count", "prod:[a-z]*")).toBe(true); + expect(redisKeyMatchesPattern("prod:LOGIN_FAIL_COUNT", "prod:[a-z]*")).toBe(false); + }); + + it("treats escaped glob characters as literals", () => { + expect(redisKeyMatchesPattern("literal:*", "literal:\\*")).toBe(true); + expect(redisKeyMatchesPattern("literal:x", "literal:\\*")).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/redis/redisKeyPattern.ts b/apps/desktop/src/lib/redis/redisKeyPattern.ts index 1923097f0f..9ac4671c5a 100644 --- a/apps/desktop/src/lib/redis/redisKeyPattern.ts +++ b/apps/desktop/src/lib/redis/redisKeyPattern.ts @@ -11,6 +11,114 @@ export function redisKeySearchPattern(value: string, fuzzy: boolean): string { return fuzzy ? `*${escapeRedisGlobText(pattern, fuzzy)}*` : pattern; } +type RedisGlobToken = { kind: "literal"; value: number } | { kind: "star" } | { kind: "any" } | { kind: "class"; negate: boolean; chars: Set; ranges: Array<[number, number]> }; + +const redisPatternEncoder = new TextEncoder(); +const REDIS_NON_ASCII = /[\u0080-\uffff]/; + +function parseRedisGlobClass(pattern: Uint8Array, start: number): { token: RedisGlobToken; next: number } { + let index = start + 1; + let negate = false; + if (pattern[index] === 94) { + negate = true; + index++; + } + const chars = new Set(); + const ranges: Array<[number, number]> = []; + while (index < pattern.length) { + if (pattern[index] === 93) { + return { token: { kind: "class", negate, chars, ranges }, next: index + 1 }; + } + const value = pattern[index]!; + if (value === 92 && index + 1 < pattern.length) { + chars.add(pattern[index + 1]!); + index += 2; + } else if (index + 2 < pattern.length && pattern[index + 1] === 45) { + const startByte = (value << 24) >> 24; + const endByte = (pattern[index + 2]! << 24) >> 24; + ranges.push([Math.min(startByte, endByte), Math.max(startByte, endByte)]); + index += 3; + } else { + chars.add(value); + index++; + } + } + return { token: { kind: "class", negate, chars, ranges }, next: index }; +} + +function redisGlobTokens(patternText: string): RedisGlobToken[] { + const pattern = redisPatternEncoder.encode(patternText); + const tokens: RedisGlobToken[] = []; + for (let index = 0; index < pattern.length; index++) { + const value = pattern[index]!; + if (value === 92 && index + 1 < pattern.length) { + tokens.push({ kind: "literal", value: pattern[++index]! }); + } else if (value === 42) { + if (tokens[tokens.length - 1]?.kind !== "star") tokens.push({ kind: "star" }); + } else if (value === 63) { + tokens.push({ kind: "any" }); + } else if (value === 91) { + const parsed = parseRedisGlobClass(pattern, index); + tokens.push(parsed.token); + index = parsed.next - 1; + } else { + tokens.push({ kind: "literal", value }); + } + } + return tokens; +} + +function redisGlobClassMatches(token: Extract, value: number): boolean { + const signedValue = (value << 24) >> 24; + const matches = token.chars.has(value) || token.ranges.some(([start, end]) => start <= signedValue && signedValue <= end); + return token.negate ? !matches : matches; +} + +export function createRedisKeyPatternMatcher(pattern: string): (value: string, keyRaw?: string) => boolean { + const tokens = redisGlobTokens(pattern); + return (value, keyRaw) => { + let bytes: string | Uint8Array; + if (keyRaw && value.includes("\\")) { + try { + bytes = atob(keyRaw); + } catch { + return false; + } + } else { + bytes = REDIS_NON_ASCII.test(value) ? redisPatternEncoder.encode(value) : value; + } + let valueIndex = 0; + let tokenIndex = 0; + let starTokenIndex = -1; + let starValueIndex = -1; + + while (valueIndex < bytes.length) { + const token = tokens[tokenIndex]; + const byte = typeof bytes === "string" ? bytes.charCodeAt(valueIndex) : bytes[valueIndex]!; + const matches = token?.kind === "literal" ? token.value === byte : token?.kind === "any" ? true : token?.kind === "class" ? redisGlobClassMatches(token, byte) : false; + if (matches) { + tokenIndex++; + valueIndex++; + } else if (token?.kind === "star") { + starTokenIndex = tokenIndex++; + starValueIndex = valueIndex; + } else if (starTokenIndex >= 0) { + tokenIndex = starTokenIndex + 1; + valueIndex = ++starValueIndex; + } else { + return false; + } + } + + while (tokens[tokenIndex]?.kind === "star") tokenIndex++; + return tokenIndex === tokens.length; + }; +} + +export function redisKeyMatchesPattern(value: string, pattern: string): boolean { + return createRedisKeyPatternMatcher(pattern)(value); +} + /** * SCAN MATCH pattern covering exactly the subtree of a tree group: every * segment is glob-escaped so keys containing `*`/`?`/`[`/`]` match literally,