SurfSense/surfsense_web/lib/query-client/cache-keys.ts
guangyang1206 3504be3413 fix(web): make cacheKeys.*.withQueryParams order-stable (sort entries)
Fixes #1370

Object.values() produces order-dependent cache keys because the order of values depends on the order of keys in the object. This causes the same logical query to produce different cache keys when the parameter object has keys in different orders.

Added stableEntries() helper that:
1. Filters out undefined values
2. Sorts entries by key name
3. Returns flat array of [key, value] pairs

This ensures cache key identity is stable regardless of parameter object key order.

Co-authored-by: guangyang1206 <guangyang1206@users.noreply.github.com>
2026-05-16 12:10:04 +08:00

129 lines
5.3 KiB
TypeScript

import type { GetConnectorsRequest } from "@/contracts/types/connector.types";
import type { GetDocumentsRequest } from "@/contracts/types/document.types";
import type { GetLogsRequest } from "@/contracts/types/log.types";
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
/**
* Convert an object to a stable array of [key, value] pairs sorted by key.
* This ensures cache keys are order-independent (avoiding Object.values order-dependency).
* Filters out undefined values.
*/
function stableEntries(obj: Record<string, unknown> | null | undefined): unknown[] {
if (!obj) return [];
return Object.entries(obj)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
.flat();
}
export const cacheKeys = {
// New chat threads (assistant-ui)
threads: {
list: (searchSpaceId: number) => ["threads", searchSpaceId] as const,
detail: (threadId: number) => ["threads", "detail", threadId] as const,
search: (searchSpaceId: number, query: string) =>
["threads", "search", searchSpaceId, query] as const,
},
documents: {
globalQueryParams: (queries: GetDocumentsRequest["queryParams"]) =>
["documents", ...stableEntries(queries)] as const,
withQueryParams: (queries: GetDocumentsRequest["queryParams"]) =>
["documents-with-queries", ...stableEntries(queries)] as const,
document: (documentId: string) => ["document", documentId] as const,
byChunk: (chunkId: string) => ["documents", "by-chunk", chunkId] as const,
},
logs: {
list: (searchSpaceId?: number | string) => ["logs", "list", searchSpaceId] as const,
detail: (logId: number | string) => ["logs", "detail", logId] as const,
summary: (searchSpaceId?: number | string) => ["logs", "summary", searchSpaceId] as const,
withQueryParams: (queries: GetLogsRequest["queryParams"]) =>
["logs", "with-query-params", ...stableEntries(queries)] as const,
},
newLLMConfigs: {
all: (searchSpaceId: number) => ["new-llm-configs", searchSpaceId] as const,
byId: (configId: number) => ["new-llm-configs", "detail", configId] as const,
preferences: (searchSpaceId: number) => ["llm-preferences", searchSpaceId] as const,
defaultInstructions: () => ["new-llm-configs", "default-instructions"] as const,
global: () => ["new-llm-configs", "global"] as const,
modelList: () => ["models", "catalogue"] as const,
},
imageGenConfigs: {
all: (searchSpaceId: number) => ["image-gen-configs", searchSpaceId] as const,
byId: (configId: number) => ["image-gen-configs", "detail", configId] as const,
global: () => ["image-gen-configs", "global"] as const,
},
visionLLMConfigs: {
all: (searchSpaceId: number) => ["vision-llm-configs", searchSpaceId] as const,
byId: (configId: number) => ["vision-llm-configs", "detail", configId] as const,
global: () => ["vision-llm-configs", "global"] as const,
modelList: () => ["vision-models", "catalogue"] as const,
},
auth: {
user: ["auth", "user"] as const,
},
searchSpaces: {
all: ["search-spaces"] as const,
withQueryParams: (queries: GetSearchSpacesRequest["queryParams"]) =>
["search-spaces", ...stableEntries(queries)] as const,
detail: (searchSpaceId: string) => ["search-spaces", searchSpaceId] as const,
},
user: {
current: () => ["user", "me"] as const,
},
roles: {
all: (searchSpaceId: string) => ["roles", searchSpaceId] as const,
byId: (searchSpaceId: string, roleId: string) => ["roles", searchSpaceId, roleId] as const,
},
permissions: {
all: () => ["permissions"] as const,
},
members: {
all: (searchSpaceId: string) => ["members", searchSpaceId] as const,
myAccess: (searchSpaceId: string) => ["members", "my-access", searchSpaceId] as const,
},
invites: {
all: (searchSpaceId: string) => ["invites", searchSpaceId] as const,
info: (inviteCode: string) => ["invites", "info", inviteCode] as const,
},
agentTools: {
all: () => ["agent-tools"] as const,
},
connectors: {
all: (searchSpaceId: string) => ["connectors", searchSpaceId] as const,
withQueryParams: (queries: GetConnectorsRequest["queryParams"]) =>
["connectors", ...stableEntries(queries)] as const,
byId: (connectorId: string) => ["connector", connectorId] as const,
index: () => ["connector", "index"] as const,
googleDrive: {
folders: (connectorId: number, parentId?: string) =>
["connectors", "google-drive", connectorId, "folders", parentId] as const,
},
},
comments: {
byMessage: (messageId: number) => ["comments", "message", messageId] as const,
},
publicChat: {
byToken: (shareToken: string) => ["public-chat", shareToken] as const,
},
github: {
repoStars: (username: string, repo: string) =>
["github", "repo-stars", username, repo] as const,
},
publicChatSnapshots: {
all: ["public-chat-snapshots"] as const,
bySearchSpace: (searchSpaceId: number) =>
["public-chat-snapshots", "search-space", searchSpaceId] as const,
},
prompts: {
all: () => ["prompts"] as const,
public: () => ["prompts", "public"] as const,
},
notifications: {
search: (searchSpaceId: number | null, search: string, tab: string) =>
["notifications", "search", searchSpaceId, search, tab] as const,
sourceTypes: (searchSpaceId: number | null) =>
["notifications", "source-types", searchSpaceId] as const,
batchUnreadCounts: (searchSpaceId: number | null) =>
["notifications", "unread-counts-batch", searchSpaceId] as const,
},
};