Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* The Lore Explorer mounts `useScopeTree` AND `useLoreData` together, and both
* need the scope tree. Each used to call `GET /memories/scopes` on its own, so
* every `/lore` load ran the `lorekit_memory_scopes` RPC twice, concurrently.
* Pinned here: the two hooks share ONE request, and `useLoreData`'s `scopes`
* is the very tree `useScopeTree` resolves to.
*
* There is no DOM test environment in this package, so the hooks run as plain
* functions: `useQuery` is swapped for a recorder of the options each hook
* hands it, and those options are then fetched through one real QueryClient —
* the same cache, de-duplication and stale-time logic a mounted page gets.
*/

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, type UseQueryOptions } from '@tanstack/react-query';

const SCOPE_ROWS = [
{ scope: 'repo::mthines/lorekit', count: 3 },
{ scope: 'branch::mthines/lorekit::main', count: 2 },
{ scope: 'global', count: 1 },
];

const api = vi.hoisted(() => ({
listScopesRequest: vi.fn(),
listMemoriesRequest: vi.fn(),
activityRequest: vi.fn(),
}));

const harness = vi.hoisted(() => ({
client: undefined as unknown as QueryClient,
recorded: [] as UseQueryOptions[],
}));

vi.mock('@/lib/api/session-browser', () => ({
browserAccessToken: async () => 'test-token',
}));

vi.mock('@/lib/api/memories', () => ({
...api,
getMemoryByIdRequest: vi.fn(),
getMemoryByRefRequest: vi.fn(),
listFacetsPostRequest: vi.fn(),
pivotPostRequest: vi.fn(),
}));

vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>();
return {
...actual,
useQuery: (options: UseQueryOptions) => {
harness.recorded.push(options);
return { data: undefined };
},
useQueryClient: () => harness.client,
};
});

const { useLoreData, useScopeTree } = await import('./lore');

/** Run a hook the way a mount would: capture its options, then fetch them. */
function mount(hook: () => unknown): Promise<unknown> {
harness.recorded.length = 0;
hook();
const [options] = harness.recorded;
return harness.client.fetchQuery(options as Parameters<QueryClient['fetchQuery']>[0]);
}

beforeEach(() => {
harness.client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
api.listScopesRequest.mockReset().mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ scopes: SCOPE_ROWS }), 10)),
);
api.listMemoriesRequest.mockReset().mockResolvedValue({ entries: [] });
api.activityRequest.mockReset().mockResolvedValue({ buckets: [] });
});

describe('the /lore scope tree is fetched once', () => {
it('shares one GET /memories/scopes between useScopeTree and useLoreData (page mount order)', async () => {
const [tree, lore] = await Promise.all([mount(useScopeTree), mount(useLoreData)]);

expect(api.listScopesRequest).toHaveBeenCalledTimes(1);
expect((lore as { scopes: unknown }).scopes).toEqual(tree);
});

it('shares the request whichever hook mounts first', async () => {
Comment thread
dash0-dev[bot] marked this conversation as resolved.
// Mounting both in one `Promise.all` does not reverse the order: the lore
// fetch awaits the session token before it reaches the scopes query, so
// `useScopeTree` would still start the request. Wait until the lore fetch
// has actually issued it, then mount the scope tree into the in-flight
// request (the mock answers after 10 ms; polling every 1 ms lands inside it).
const lore = mount(useLoreData);
await vi.waitFor(() => expect(api.listScopesRequest).toHaveBeenCalledTimes(1), { interval: 1 });
const tree = await mount(useScopeTree);

expect(api.listScopesRequest).toHaveBeenCalledTimes(1);
expect(((await lore) as { scopes: unknown }).scopes).toEqual(tree);
});

it('hands a joining useScopeTree the shared retry policy when useLoreData starts the fetch', async () => {
// The retry policy belongs to whichever caller STARTS the shared fetch, so
// a no-retry override in the lore fetch would turn one transient scopes
// failure into an error for the scope tree that joined it. Real ~1 s
// backoff: TanStack's default retry delay, kept rather than faked.
api.listScopesRequest.mockImplementationOnce(
() => new Promise((_, reject) => setTimeout(() => reject(new Error('transient')), 10)),
);

const lore = mount(useLoreData);
await vi.waitFor(() => expect(api.listScopesRequest).toHaveBeenCalledTimes(1), { interval: 1 });
const tree = (await mount(useScopeTree)) as { scope: string }[];

expect(api.listScopesRequest).toHaveBeenCalledTimes(2);
expect(tree.map((node) => node.scope)).toEqual(['repo::mthines/lorekit', 'global']);
expect(((await lore) as { scopes: unknown }).scopes).toEqual(tree);
});

it('still fetches the scopes for useLoreData when no scope tree is cached', async () => {
const lore = (await mount(useLoreData)) as { scopes: { scope: string }[] };

expect(api.listScopesRequest).toHaveBeenCalledTimes(1);
expect(lore.scopes.map((node) => node.scope)).toEqual(['repo::mthines/lorekit', 'global']);
});
});
42 changes: 29 additions & 13 deletions packages/web/src/lib/queries/lore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import {
keepPreviousData,
queryOptions,
useQueries,
useQuery,
useInfiniteQuery,
Expand Down Expand Up @@ -313,14 +314,21 @@ export function useFacetCatalog(
});
}

/**
* The ONE definition of the scope-tree query, shared by `useScopeTree` and
* `fetchLoreData` so the two cannot drift onto different keys or stale times —
* a drift that would silently bring back the duplicate `GET /memories/scopes`.
*/
const scopeTreeQueryOptions = queryOptions<ScopeNode[], Error>({
queryKey: ['lore-scopes'],
queryFn: ({ signal }) => fetchScopes(signal),
// Scope tree is read-heavy — keep data for 90 s before refetching.
staleTime: 90_000,
retry: retryUnlessSignedOut,
});

export function useScopeTree() {
return useQuery<ScopeNode[]>({
queryKey: ['lore-scopes'],
queryFn: ({ signal }) => fetchScopes(signal),
// Scope tree is read-heavy — keep data for 90 s before refetching.
staleTime: 90_000,
retry: retryUnlessSignedOut,
});
return useQuery(scopeTreeQueryOptions);
}

// ---------------------------------------------------------------------------
Expand All @@ -334,11 +342,20 @@ export function useScopeTree() {
// from `GET /memories/activity`, which buckets per UTC day in Postgres.
// ---------------------------------------------------------------------------

async function fetchLoreData(signal?: AbortSignal): Promise<LoreData> {
async function fetchLoreData(queryClient: QueryClient, signal?: AbortSignal): Promise<LoreData> {
const token = await requireBrowserToken();

const [scopesRes, page, activity] = await Promise.all([
listScopesRequest(token, signal),
const [scopes, page, activity] = await Promise.all([
// Through the shared scope-tree query, not a request of its own: `/lore`
// mounts `useScopeTree` beside this hook, and two independent requests ran
// the `lorekit_memory_scopes` RPC twice, concurrently, on every load.
// `fetchQuery` joins an in-flight fetch and serves a fresh cached tree.
// No `retry` override: the retry policy is captured by whichever caller
// STARTS the shared fetch, so a no-retry override here would hand a
// `useScopeTree` that joins it a fetch that fails on one transient error.
// The cost is accepted: during a sustained scopes outage this query's own
// retry stacks on top, re-running an already-retried, backoff-spaced fetch.
queryClient.fetchQuery(scopeTreeQueryOptions),
listMemoriesRequest(token, { limit: LEGACY_PAGE_SIZE, sort: 'created_at' }, signal),
// `since` is EXPLICIT, not left to the endpoint's default. That default is
// 200 days, sized when the heatmap was a fixed 26 weeks; the desktop grid
Expand All @@ -349,8 +366,6 @@ async function fetchLoreData(signal?: AbortSignal): Promise<LoreData> {
activityRequest(token, { bucket: 'day', since: heatmapSince(new Date().toISOString()) }, signal),
]);

const scopes: ScopeNode[] = buildScopeTree(scopesRes.scopes);

return {
scopes,
lessons: page.entries.map(lessonFromMemoryEntry),
Expand All @@ -359,9 +374,10 @@ async function fetchLoreData(signal?: AbortSignal): Promise<LoreData> {
}

export function useLoreData() {
const queryClient = useQueryClient();
return useQuery<LoreData>({
queryKey: ['lore'],
queryFn: ({ signal }) => fetchLoreData(signal),
queryFn: ({ signal }) => fetchLoreData(queryClient, signal),
// Lore explorer is read-heavy — keep data for 90 s before refetching.
staleTime: 90_000,
retry: retryUnlessSignedOut,
Expand Down
Loading