fix(redis): preserve full key snapshots during local search
Closes #9120
This commit is contained in:
4 files changed
+277
-22
No files matched your search
@@ -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<void>();
|
||||
const releaseBuild = deferred<void>();
|
||||
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<HTMLInputElement>("[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<HTMLButtonElement>("[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 };
|
||||
|
||||
@@ -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<HTMLElement>();
|
||||
@@ -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<string>();
|
||||
@@ -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<number | null>(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<boolean> {
|
||||
if (!hasMore.value || isFetchingAll.value) return false;
|
||||
const requestId = searchRequestId;
|
||||
const fetchAllIsFullKeyspace = searchMode.value === "key" && !isSearchMode.value;
|
||||
const bufferedKeys: RedisKeyInfo[] = [];
|
||||
const bufferedKeyRaws = new Set<string>();
|
||||
const initialFlatKeys = flatKeys.value;
|
||||
@@ -1347,7 +1380,6 @@ async function fetchAll(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
|
||||
+41
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<number>; 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<number>();
|
||||
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<RedisGlobToken, { kind: "class" }>, 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,
|
||||
|
||||
Reference in new issue
Block a user