fix(web): scope daily brief cache by account

This commit is contained in:
GautamSharma99 2026-07-13 03:54:58 +05:30
parent 2cebe81512
commit fc5df55b8c
4 changed files with 148 additions and 8 deletions

View file

@ -67,6 +67,11 @@ import {
} from "@/lib/search-params"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
import { getToolDocumentSpace } from "@/lib/plugin-space"
import {
getSpaceHighlightsCacheKey,
LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME,
SPACE_HIGHLIGHTS_CACHE_NAME,
} from "@/lib/space-highlights-cache"
import { getBackendUrl } from "@/lib/url-helpers"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
@ -112,7 +117,7 @@ function ViewErrorFallback() {
export function AppExperience() {
const isMobile = useIsMobile()
const { user, session, isSessionPending, org } = useAuth()
const { user, session, isSessionPending, isRestoring, org } = useAuth()
const { selectedProject, selectedProjects, setSelectedProject } = useProject()
const selectedProjectTag = selectedProjects[0]
@ -316,27 +321,75 @@ export function AppExperience() {
generatedAt: string
}
const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1"
const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours
const highlightsAccountScope =
user?.id && org?.id ? JSON.stringify([user.id, org.id]) : null
const previousHighlightsAccountScope = useRef<string | null | undefined>(
undefined,
)
useEffect(() => {
if (isSessionPending || isRestoring) return
const previousScope = previousHighlightsAccountScope.current
previousHighlightsAccountScope.current = highlightsAccountScope
if (previousScope === undefined) {
try {
void caches.delete(LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME)
} catch {}
return
}
if (previousScope === highlightsAccountScope) return
queryClient.removeQueries({
queryKey: ["space-highlights"],
predicate: (query) =>
query.queryKey[1] !== user?.id || query.queryKey[2] !== org?.id,
})
try {
void caches.delete(SPACE_HIGHLIGHTS_CACHE_NAME)
} catch {}
}, [
highlightsAccountScope,
isRestoring,
isSessionPending,
org?.id,
queryClient,
user?.id,
])
const handleResetHighlights = useCallback(async () => {
toast.success("Refreshing daily brief…")
try {
await caches.delete(HIGHLIGHTS_CACHE_NAME)
await caches.delete(SPACE_HIGHLIGHTS_CACHE_NAME)
} catch {}
setHighlightsForceAt(Date.now())
}, [])
const { data: highlightsData, isLoading: isLoadingHighlights } =
useQuery<SpaceHighlightsResponse>({
queryKey: ["space-highlights", selectedProject, highlightsForceAt],
queryKey: [
"space-highlights",
user?.id,
org?.id,
selectedProject,
highlightsForceAt,
],
queryFn: async (): Promise<SpaceHighlightsResponse> => {
if (!user?.id || !org?.id) {
throw new Error("User and organization are required")
}
const spaceId = selectedProject || "sm_project_default"
const forceRefresh = highlightsForceAt > 0
const cacheKey = `${backendUrl}/v3/space-highlights?spaceId=${spaceId}`
const cacheKey = getSpaceHighlightsCacheKey({
backendUrl,
spaceId,
userId: user.id,
organizationId: org.id,
})
if (!forceRefresh) {
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
const cache = await caches.open(SPACE_HIGHLIGHTS_CACHE_NAME)
const cached = await cache.match(cacheKey)
if (cached) {
const age =
@ -369,7 +422,7 @@ export function AppExperience() {
// Update browser cache with fresh data (works for both normal and forced refresh)
try {
const freshCache = await caches.open(HIGHLIGHTS_CACHE_NAME)
const freshCache = await caches.open(SPACE_HIGHLIGHTS_CACHE_NAME)
const cacheResponse = new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
@ -387,6 +440,7 @@ export function AppExperience() {
},
staleTime: HIGHLIGHTS_MAX_AGE,
refetchOnWindowFocus: false,
enabled: !!user?.id && !!org?.id,
})
const { data: memoryOfDay = null } = useQuery<MemoryOfDay | null>({

View file

@ -3,6 +3,10 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { $fetch } from "@lib/api"
import {
LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME,
SPACE_HIGHLIGHTS_CACHE_NAME,
} from "@/lib/space-highlights-cache"
export function useResetOrganization() {
const queryClient = useQueryClient()
@ -30,7 +34,10 @@ export function useResetOrganization() {
queryClient.invalidateQueries()
// Clear the daily brief Cache API entry so stale highlights don't survive the reset
try {
await caches.delete("space-highlights-v1")
await Promise.all([
caches.delete(SPACE_HIGHLIGHTS_CACHE_NAME),
caches.delete(LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME),
])
} catch {
// Cache API not available in all environments
}

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from "bun:test"
import {
getSpaceHighlightsCacheKey,
LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME,
SPACE_HIGHLIGHTS_CACHE_NAME,
} from "./space-highlights-cache"
describe("space highlights cache", () => {
it("does not reuse the legacy account-agnostic cache", () => {
expect(SPACE_HIGHLIGHTS_CACHE_NAME).toBe("space-highlights-v2")
expect(SPACE_HIGHLIGHTS_CACHE_NAME).not.toBe(
LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME,
)
})
it("scopes cache keys by user, organization, and space", () => {
const cacheKey = getSpaceHighlightsCacheKey({
backendUrl: "https://api.supermemory.ai",
spaceId: "sm_project_default",
userId: "user-1",
organizationId: "org-1",
})
expect(cacheKey).toBe(
"https://api.supermemory.ai/v3/space-highlights?spaceId=sm_project_default&userId=user-1&organizationId=org-1",
)
})
it("produces different keys for different accounts sharing a project tag", () => {
const firstAccount = getSpaceHighlightsCacheKey({
backendUrl: "https://api.supermemory.ai",
spaceId: "sm_project_default",
userId: "user-1",
organizationId: "org-1",
})
const secondAccount = getSpaceHighlightsCacheKey({
backendUrl: "https://api.supermemory.ai",
spaceId: "sm_project_default",
userId: "user-2",
organizationId: "org-2",
})
expect(firstAccount).not.toBe(secondAccount)
})
it("safely encodes identifiers used in cache keys", () => {
const cacheKey = getSpaceHighlightsCacheKey({
backendUrl: "http://localhost:8787/",
spaceId: "project&shared=true",
userId: "user?admin=true",
organizationId: "org/name",
})
const url = new URL(cacheKey)
expect(url.searchParams.get("spaceId")).toBe("project&shared=true")
expect(url.searchParams.get("userId")).toBe("user?admin=true")
expect(url.searchParams.get("organizationId")).toBe("org/name")
})
})

View file

@ -0,0 +1,20 @@
export const SPACE_HIGHLIGHTS_CACHE_NAME = "space-highlights-v2"
export const LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME = "space-highlights-v1"
export function getSpaceHighlightsCacheKey({
backendUrl,
spaceId,
userId,
organizationId,
}: {
backendUrl: string
spaceId: string
userId: string
organizationId: string
}): string {
const url = new URL(`${backendUrl.replace(/\/$/, "")}/v3/space-highlights`)
url.searchParams.set("spaceId", spaceId)
url.searchParams.set("userId", userId)
url.searchParams.set("organizationId", organizationId)
return url.toString()
}