diff --git a/CLAUDE.md b/CLAUDE.md index 831c11f86..d32c458db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -390,6 +390,7 @@ Each decision's full rationale lives in [`docs/decisions.md`](./docs/decisions.m the headline here is the rule; follow the link for the "why". Short entries carry their rationale inline. **Do not relitigate these.** - **Dashboard is a CLIENT of LoreKit's REST API** — [rationale](./docs/decisions.md#dashboard-is-a-client-of-lorekits-rest-api) +- **Client reads are never server actions** (Next runs actions one at a time and they cannot be aborted) — [rationale](./docs/decisions.md#client-reads-are-never-server-actions) - **MCP server endpoint is a static production URL** — [rationale](./docs/decisions.md#mcp-server-endpoint-is-a-static-production-url) - **Lore Explorer filters through ONE two-level command menu + pills** — [rationale](./docs/decisions.md#lore-explorer-filters-through-one-two-level-command-menu) - **The Explorer's Activity panel has a DISPLAY default (24h), separate from the list's (all time)** — [rationale](./docs/decisions.md#the-explorers-activity-panel-has-a-display-default-separate-from-the-lists) diff --git a/docs/decisions.md b/docs/decisions.md index 505f7c00f..8a60bad57 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -857,3 +857,7 @@ score stays in `lesson-rank.ts` / `lessons-pure.mjs`. The ranking is set-relativ normalises against the most-recurring candidate) and it has to agree exactly with the CLI hook's ordering — an agreement `lesson-rank-parity.spec.ts` can hold two TypeScript-family implementations to and could not hold a plpgsql third copy to at all. + +## Client reads are never server actions + +**A React Query read in `packages/web` must not call a server action.** Next's app router runs server actions one at a time per client (the router's action queue starts the next one only when the previous resolves), and an action cannot take an `AbortSignal`. A read served by one queues behind every other action in flight, cannot be cancelled when its query is superseded, and keeps `useIsFetching()` (and so the TopBar activity bar) non-zero until the queue drains. It was measured, not theorised: on `/lore` the memory badge (`getPlanUsage`), the pending-invites query (`listPendingInvitesForMe`) and the Explorer list (`listMemories`) were all actions. Every load and every window focus (`FocusRefetcher`) ran them strictly in that order, each `POST /lore` starting the millisecond the previous one ended. The list arrived last, and the bar stayed on for seconds after the parallel REST panels had loaded. In the worst case seen, a 50 s Supabase `auth/v1/user` call inside the list action, plus a focus refetch queued behind it, kept the bar on for about 85 s. The fix is transport only: the list calls `POST /memories/list` from the browser (`fetchMemoryPage`, body from the pure `lib/memory-list.ts`), and the two reads with no REST endpoint yet go through GET route handlers (`/api/plan/usage`, `/api/invites/pending`) read with `getAppRoute(path, signal)`. Moving those two onto proper edge endpoints is still the backlog `packages/web/CLAUDE.md` names; the route handlers are the step that removes the queue without a schema/handler/OpenAPI change. Server actions remain right for mutations and for server components, where calling the function is a plain call. Guarded by `lib/queries/parallel-reads.spec.ts`, which mocks the action modules to throw. diff --git a/docs/key-files.md b/docs/key-files.md index 5fb603597..3e617cb5f 100644 --- a/docs/key-files.md +++ b/docs/key-files.md @@ -115,6 +115,9 @@ else. Grouped loosely by subsystem in migration/PR order. | `packages/mcp-core/src/auth/tenant-scope-usage.spec.ts` | Source-scan drift guard: asserts every edge `tools.ts` read handler (`toolRead`/`toolList`/`toolSearch`/`toolListArchived`) routes through `applyTenantScope`, not an inlined `.eq('user_id', ...)` | | `packages/web/src/lib/pagination/` | Pure, audit-decoupled keyset-pagination + filter module: `cursor.ts` (opaque base64url cursor codec, fails closed to `null` on any malformed/forged input — safe because callers still apply their own `user_id` scoping), `keyset.ts` (`clampPageSize`, the `(created_at desc, id desc)` `.or()` predicate, fetch+1 → page assembly), `filters.ts` (`normalizeActions` against a caller-supplied allow-set, LIKE/PostgREST-escaping `substringNeedle`, inclusive-`from`/half-open-`to` `dateRangeBounds`), `apply.ts` (thin supabase-js query-builder shell). Each pure file has a co-located `.spec.ts` (mirrors `aggregations.spec.ts`/`repo-format.spec.ts`); composed by `listAuditLog` in `audit-log.ts`, the only current consumer | | `packages/web/src/lib/queries/audit-log.ts` | `useAuditLog` — `useInfiniteQuery` over the `listAuditLog` server action, keyed by the URL-backed filter state (action set / name / date range); drives the feed's "Load more" | +| `packages/web/src/lib/memory-list.ts` | Pure Lore Explorer list request: `MemoryFilters` → the `POST /memories/list` body (`memoryListBody`: default `created_at` sort, page-size clamp, legacy `tags` folded into a `label` filter, retention thresholds) and the response → `MemoryPage` (`memoryPageFromResponse`). Moved out of the former `listMemories` server action when `useMemories` became a browser request; `memory-list.spec.ts` pins the body | +| `packages/web/src/lib/app-route.ts` | `getAppRoute(path, signal)` — the browser GET for the dashboard's own `app/api/**` route handlers (same-origin cookie auth, `no-store`, throws `AppRouteError` on non-2xx). Used for reads that still need server code but must not be server actions (see `packages/web/CLAUDE.md` → "A client read is never a server action") | +| `packages/web/src/app/api/plan/usage/route.ts`, `packages/web/src/app/api/invites/pending/route.ts` | GET route handlers wrapping `getPlanUsage` / `listPendingInvitesForMe` for `useMemoryTotal` and `usePendingInvitesForMe`. Same contract as the functions (`null` / `[]` when signed out or on failure), `force-dynamic`, `no-store`. `lib/queries/parallel-reads.spec.ts` asserts the hooks read them with React Query's signal and never call the server actions | | `supabase/migrations/00012_audit_log_search.sql` | Enables `pg_trgm` + a GIN trigram index on `audit_log.target` (index-backed `ilike` name search) and a `(user_id, created_at desc, id)` index covering the keyset seek (00010's `(user_id, created_at desc)` index lacks the `id` tiebreaker) | | `supabase/migrations/00017_org_roles_and_author.sql` | Adds `viewer` to the `org_members.role` CHECK; adds `memories.created_by`/`updated_by`; creates `lorekit_org_role` + `lorekit_org_can(user, org, capability)` — the single `SECURITY DEFINER` capability source for org writes/deletes (org-sharing Phase 2; see plan `.agent/claude/org-sharing-phase-2-writes/plan.md`) | | `supabase/migrations/00018_org_limits.sql` | `org_limits` table (mirrors `user_limits`) + `lorekit_get_org_limit`; re-creates `enforce_memory_cap()` with an org branch that runs BEFORE the `user_id IS NULL` service exemption, so an org-owned row (always `user_id NULL`) is never cap-exempt | diff --git a/docs/storybook.md b/docs/storybook.md index 623daec34..b5a595ceb 100644 --- a/docs/storybook.md +++ b/docs/storybook.md @@ -63,10 +63,11 @@ Shipped examples: Client pages (`'use client'`) render as true full-page stories under MSW. Server-component pages cannot — story their largest client subtree, don't refactor an RSC page to client just -to story it. One honest edge: the `/lore` lesson list reads the `listMemories` **server -action**, which is gated on `supabase.auth.getUser()`; with no session in the mocked browser -context it returns an empty page, so the results panel shows its empty state while the rest of -the page populates. Every part driven by a *browser* fetch is populated. +to story it. Every part driven by a *browser* fetch is populated, which on `/lore` now includes +the lesson list (`useMemories` reads `POST /memories/list` from the browser). A read that still +goes through a **server action** cannot be served this way: the action runs with no session in +the mocked context and returns its signed-out shape, so its panel shows an empty state. That is +one more reason reads do not belong in server actions (see `packages/web/CLAUDE.md`). ### Determinism diff --git a/packages/web/CLAUDE.md b/packages/web/CLAUDE.md index fc4195d0c..1a0af9684 100644 --- a/packages/web/CLAUDE.md +++ b/packages/web/CLAUDE.md @@ -84,6 +84,36 @@ or `.rpc(…)` for anything other than **auth**, stop and add an endpoint instea are the migration backlog, not a licence to add a new direct `.from(…)`/`.rpc(…)` read for product data — new surfaces get an endpoint. +## A client read is never a server action (REQUIRED) + +React Query `queryFn`s must not call a server action (`'use server'` module). +Next's app router runs server actions **one at a time per client**, and an +action cannot take an `AbortSignal`. Every read served that way queues behind +whatever action is already in flight, and React Query can never cancel it. +`useIsFetching()`, and with it the TopBar `ActivityIndicator`, stays non-zero +until the whole queue drains. + +- **This happened.** On `/lore` the memory badge, the pending-invites query and + the Explorer's list were all server actions, fired on every load and every + window focus (`FocusRefetcher`). They ran strictly in sequence, with the list + last, so the activity bar stayed lit for seconds after every visible panel had + loaded. In the worst case seen, a slow Supabase auth call kept it on for about + 85 s. +- **What to use instead.** If a REST endpoint exists, call it from the browser + through `src/lib/api/*` with the query's `signal` (`fetchMemoryPage`, + `fetchScopes`). If the read still needs server-side code (a surface on the + migration backlog), wrap it in a GET route handler under `app/api/**` and read + it with `getAppRoute(path, signal)` (`lib/app-route.ts`), as + `/api/plan/usage` and `/api/invites/pending` do. +- **Server actions stay for mutations**, which are one user gesture each, and + for server components, where calling the function is a plain call with no + queue. +- **Known remaining offenders** (not on `/lore`'s refresh path): + `useAuditLog` → `listAuditLog`; `LessonDetailSheet`'s member lookup → + `listMemberIdentities`; `OrganizationManager`'s org-data effect → + `listScopeBindings`, `listMembers`, `listMemberIdentities`, `listInvites`, + `listAvailableScopes`; and `InviteDetailsDialog` → `getInviteOrgDetails`. + ## Conventions specific to this package - **Theme:** dark-only, driven by CSS custom properties in diff --git a/packages/web/src/app/(dashboard)/lore/LorePage.stories.tsx b/packages/web/src/app/(dashboard)/lore/LorePage.stories.tsx index 869d6b038..9b0556901 100644 --- a/packages/web/src/app/(dashboard)/lore/LorePage.stories.tsx +++ b/packages/web/src/app/(dashboard)/lore/LorePage.stories.tsx @@ -17,19 +17,15 @@ import { * mirroring `/insights`) that a server component cannot render in the browser — * so this story mounts its extracted `'use client'` body, `LorePageClient`, * directly, the same convention `/insights` uses for `InsightsPage`. - * `LorePageClient` drives its scope tree and contribution heatmap from - * `useScopeTree` / `useLoreData` — TanStack Query hooks that call the Supabase - * *browser* client (PostgREST over HTTP), which MSW mocks. So the component - * renders end-to-end here, not a hand-assembled subtree. + * `LorePageClient` drives its scope tree, contribution heatmap and lesson list + * from TanStack Query hooks (`useScopeTree`, `useLoreData`, `useMemories`) that + * call the `memories` REST function from the browser, which MSW mocks. So the + * component renders end-to-end here, not a hand-assembled subtree. * - * The lesson list inside `LoreExplorer` reads the `listMemories` **server - * action**. With `next/headers` auto-mocked by `@storybook/nextjs-vite` it does - * execute in the browser, but it is gated on `supabase.auth.getUser()`, which - * short-circuits to no session in the mocked context and returns an empty page — - * so the results panel renders its "No memories in this scope" empty state while - * the scope tree + heatmap populate from the MSW-mocked PostgREST reads. That is - * the honest edge of the mixed rendering model: the component renders end-to-end, - * and every part that is driven by a *browser* fetch is populated. + * The lesson list used to read the `listMemories` server action, which had no + * session in the mocked context and returned an empty page, so this story + * showed "No memories in this scope". The list is a browser request now + * (`POST /memories/list`), so it populates from the same fixtures as the tree. * * Determinism: the clock is frozen to {@link FROZEN_NOW} (heatmap + freshness are * time-relative) and the query client retries off / never refetches. @@ -63,8 +59,7 @@ type Story = StoryObj; /** * Populated Lore Explorer: the scope tree, contribution heatmap, view-mode tabs, - * and filter bars all resolve from the MSW-mocked dataset. The lesson results - * panel shows its empty state (see the auth-gate note above). + * filter bars and the lesson list all resolve from the MSW-mocked dataset. */ export const Default: Story = {}; diff --git a/packages/web/src/app/(dashboard)/lore/__screenshots__/LorePage.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/app/(dashboard)/lore/__screenshots__/LorePage.stories.tsx/Default-1-chromium-linux.png index 73c059086..5359ebf23 100644 Binary files a/packages/web/src/app/(dashboard)/lore/__screenshots__/LorePage.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/app/(dashboard)/lore/__screenshots__/LorePage.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/app/(dashboard)/lore/__screenshots__/LorePage.stories.tsx/Empty-1-chromium-linux.png b/packages/web/src/app/(dashboard)/lore/__screenshots__/LorePage.stories.tsx/Empty-1-chromium-linux.png index 89dccbd23..5ff70e54c 100644 Binary files a/packages/web/src/app/(dashboard)/lore/__screenshots__/LorePage.stories.tsx/Empty-1-chromium-linux.png and b/packages/web/src/app/(dashboard)/lore/__screenshots__/LorePage.stories.tsx/Empty-1-chromium-linux.png differ diff --git a/packages/web/src/app/api/invites/pending/route.ts b/packages/web/src/app/api/invites/pending/route.ts new file mode 100644 index 000000000..3d325428c --- /dev/null +++ b/packages/web/src/app/api/invites/pending/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; +import { listPendingInvitesForMe } from '@/lib/org-invites'; + +/** + * GET /api/invites/pending + * + * Pending org invites addressed to the caller — `listPendingInvitesForMe` over + * HTTP, for the shared `['pending-invites']` query behind the top bar's invites + * badge, the `/lore` banner and the Organization nav badge. Those used to call + * the server action directly. Next's router runs server actions one at a time + * per client, so on `/lore` this read sat in a queue with the plan-usage read + * and the Explorer's list on every load and every window focus. As a route + * handler it is an ordinary request that runs in parallel and the browser can + * abort. + * + * Same contract as the action: `[]` when there is no session or the query + * fails. Never cached: accepting or declining elsewhere must show up on the + * next refetch. + */ +export const dynamic = 'force-dynamic'; + +export async function GET() { + const invites = await listPendingInvitesForMe(); + return NextResponse.json(invites, { headers: { 'Cache-Control': 'no-store' } }); +} diff --git a/packages/web/src/app/api/plan/usage/route.ts b/packages/web/src/app/api/plan/usage/route.ts new file mode 100644 index 000000000..21602c693 --- /dev/null +++ b/packages/web/src/app/api/plan/usage/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import { getPlanUsage } from '@/lib/plan'; + +/** + * GET /api/plan/usage + * + * The caller's plan and active memory count — `getPlanUsage` over HTTP, for the + * top bar's memory badge (`useMemoryTotal`). The badge used to call the server + * action directly. Next's router runs server actions one at a time per client, + * so on `/lore` it led a queue that the pending-invites read and the Explorer's + * list waited behind on every load and every window focus. As a route handler + * it is an ordinary request that runs in parallel and the browser can abort. + * + * Same contract as the action: `null` when there is no session or the RPC + * fails, and the badge degrades to 0. Never cached, because the count moves + * whenever an agent writes. + */ +export const dynamic = 'force-dynamic'; + +export async function GET() { + const usage = await getPlanUsage(); + return NextResponse.json(usage, { headers: { 'Cache-Control': 'no-store' } }); +} diff --git a/packages/web/src/app/api/read-routes.spec.ts b/packages/web/src/app/api/read-routes.spec.ts new file mode 100644 index 000000000..3a3f89513 --- /dev/null +++ b/packages/web/src/app/api/read-routes.spec.ts @@ -0,0 +1,72 @@ +/** + * `GET /api/plan/usage` and `GET /api/invites/pending` are transports, not new + * logic: each wraps an existing server function so the browser can read it as + * an ordinary, parallel, abortable request instead of a queued server action. + * Pinned here: they answer exactly what the function returns (including its + * signed-out / failure shape) and are never cached. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const getPlanUsage = vi.fn(); +const listPendingInvitesForMe = vi.fn(); + +vi.mock('@/lib/plan', () => ({ getPlanUsage: () => getPlanUsage() })); +vi.mock('@/lib/org-invites', () => ({ + listPendingInvitesForMe: () => listPendingInvitesForMe(), +})); + +const planUsageRoute = await import('./plan/usage/route'); +const pendingInvitesRoute = await import('./invites/pending/route'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('GET /api/plan/usage', () => { + it('answers the caller plan usage as JSON, uncached', async () => { + getPlanUsage.mockResolvedValue({ count: 3318, limit: 5000, plan: 'free' }); + + const response = await planUsageRoute.GET(); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + await expect(response.json()).resolves.toEqual({ count: 3318, limit: 5000, plan: 'free' }); + }); + + it('answers null when there is no session or the RPC failed, as the action did', async () => { + getPlanUsage.mockResolvedValue(null); + + const response = await planUsageRoute.GET(); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toBeNull(); + }); + + it('is rendered per request, never statically', () => { + expect(planUsageRoute.dynamic).toBe('force-dynamic'); + }); +}); + +describe('GET /api/invites/pending', () => { + it('answers the invites addressed to the caller as JSON, uncached', async () => { + const invites = [{ id: 'inv-1', org_id: 'org-1', status: 'pending', org: { name: 'Acme', slug: 'acme' } }]; + listPendingInvitesForMe.mockResolvedValue(invites); + + const response = await pendingInvitesRoute.GET(); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + await expect(response.json()).resolves.toEqual(invites); + }); + + it('answers [] when there is no session or the query failed, as the action did', async () => { + listPendingInvitesForMe.mockResolvedValue([]); + + await expect((await pendingInvitesRoute.GET()).json()).resolves.toEqual([]); + }); + + it('is rendered per request, never statically', () => { + expect(pendingInvitesRoute.dynamic).toBe('force-dynamic'); + }); +}); diff --git a/packages/web/src/components/blog/__screenshots__/BlogPostCard.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/blog/__screenshots__/BlogPostCard.stories.tsx/Default-1-chromium-linux.png index f099001c3..1cce14a7d 100644 Binary files a/packages/web/src/components/blog/__screenshots__/BlogPostCard.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/blog/__screenshots__/BlogPostCard.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/blog/__screenshots__/BlogPostCard.stories.tsx/Playground-1-chromium-linux.png b/packages/web/src/components/blog/__screenshots__/BlogPostCard.stories.tsx/Playground-1-chromium-linux.png index e2370d14d..43994b975 100644 Binary files a/packages/web/src/components/blog/__screenshots__/BlogPostCard.stories.tsx/Playground-1-chromium-linux.png and b/packages/web/src/components/blog/__screenshots__/BlogPostCard.stories.tsx/Playground-1-chromium-linux.png differ diff --git a/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Default-1-chromium-linux.png index 6a1ae797f..8387861bc 100644 Binary files a/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Playground-1-chromium-linux.png b/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Playground-1-chromium-linux.png index 56f4ba65b..7b2aca5cd 100644 Binary files a/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Playground-1-chromium-linux.png and b/packages/web/src/components/blog/__screenshots__/LikeButton.stories.tsx/Playground-1-chromium-linux.png differ diff --git a/packages/web/src/components/blog/__screenshots__/TocList.stories.tsx/Playground-1-chromium-linux.png b/packages/web/src/components/blog/__screenshots__/TocList.stories.tsx/Playground-1-chromium-linux.png index 70285de06..8642d8f99 100644 Binary files a/packages/web/src/components/blog/__screenshots__/TocList.stories.tsx/Playground-1-chromium-linux.png and b/packages/web/src/components/blog/__screenshots__/TocList.stories.tsx/Playground-1-chromium-linux.png differ diff --git a/packages/web/src/components/command/__screenshots__/CommandPaletteFab.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/command/__screenshots__/CommandPaletteFab.stories.tsx/Default-1-chromium-linux.png index edc7c6baf..6d3e9c8c3 100644 Binary files a/packages/web/src/components/command/__screenshots__/CommandPaletteFab.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/command/__screenshots__/CommandPaletteFab.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/Default-1-chromium-linux.png index 009cfde94..1934b4cdb 100644 Binary files a/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/No-Agent-Family-Data-1-chromium-linux.png b/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/No-Agent-Family-Data-1-chromium-linux.png index 3f01bb003..dec6850d6 100644 Binary files a/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/No-Agent-Family-Data-1-chromium-linux.png and b/packages/web/src/components/dashboard/__screenshots__/AgentBreakdown.stories.tsx/No-Agent-Family-Data-1-chromium-linux.png differ diff --git a/packages/web/src/components/dashboard/__screenshots__/UsageHealth.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/dashboard/__screenshots__/UsageHealth.stories.tsx/Default-1-chromium-linux.png index 9c37d77c4..e05912073 100644 Binary files a/packages/web/src/components/dashboard/__screenshots__/UsageHealth.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/dashboard/__screenshots__/UsageHealth.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/dashboard/__screenshots__/UsageHealth.stories.tsx/No-Friction-Or-Coverage-1-chromium-linux.png b/packages/web/src/components/dashboard/__screenshots__/UsageHealth.stories.tsx/No-Friction-Or-Coverage-1-chromium-linux.png index 003497534..98c88641b 100644 Binary files a/packages/web/src/components/dashboard/__screenshots__/UsageHealth.stories.tsx/No-Friction-Or-Coverage-1-chromium-linux.png and b/packages/web/src/components/dashboard/__screenshots__/UsageHealth.stories.tsx/No-Friction-Or-Coverage-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/LoreExplorer.tsx b/packages/web/src/components/lore/LoreExplorer.tsx index 2bdb24dad..bfd4c523f 100644 --- a/packages/web/src/components/lore/LoreExplorer.tsx +++ b/packages/web/src/components/lore/LoreExplorer.tsx @@ -10,7 +10,7 @@ * - Default view is "all scopes" (no scope selected). The scope selector's first * chip is "All scopes", which clears the scope filter. * - Filtering (scope / search / date) is server-side, not client-side. - * `useMemories` (`useInfiniteQuery` over `listMemories`) is the data source. + * `useMemories` (`useInfiniteQuery` over `POST /memories/list`) is the data source. * - Pagination: "Load more" button appends the next keyset page, identical to * the audit log feed (`AuditLogFeed.tsx`). * - Scope is a persistent chip row (`ScopeSelector`) at the top of the page, @@ -659,6 +659,7 @@ export function LoreExplorer({ scopes, heatmapData }: LoreExplorerProps) { isError, hasNextPage, isFetchingNextPage, + isFetchNextPageError, fetchNextPage, } = useMemories({ scope: selectedScope, @@ -1156,30 +1157,46 @@ export function LoreExplorer({ scopes, heatmapData }: LoreExplorerProps) { card?.scrollIntoView({ block: 'nearest' }); } + // A failed next page keeps the rows already on screen (see `renderResults`) + // and is reported here instead. The query stays in its error state while a + // retry is in flight, so the message is hidden for the retry's duration. + const loadMoreFailed = isFetchNextPageError && !isFetchingNextPage; + // Shared "Load more" / "all loaded" control — identical for both views so the // pagination affordance never differs between the scope list and the feed. const loadMore = (
{hasNextPage ? ( - + +
) : (

All memories loaded

)} @@ -1254,7 +1271,10 @@ export function LoreExplorer({ scopes, heatmapData }: LoreExplorerProps) { ); } - if (isError) { + // Only a failure with NOTHING loaded replaces the list: a failed "Load more" + // or focus refetch keeps the cached pages in `data`, and wiping them would + // turn one bad request into a blank Explorer. + if (isError && lessons.length === 0) { return (

Failed to load memories. Please refresh.

diff --git a/packages/web/src/components/lore/__screenshots__/DuplicateClusters.stories.tsx/Closed-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/DuplicateClusters.stories.tsx/Closed-1-chromium-linux.png index 3b911dd71..bacfe4d6e 100644 Binary files a/packages/web/src/components/lore/__screenshots__/DuplicateClusters.stories.tsx/Closed-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/DuplicateClusters.stories.tsx/Closed-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/DuplicateClusters.stories.tsx/Open-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/DuplicateClusters.stories.tsx/Open-1-chromium-linux.png index 8c8c53abd..e16021a93 100644 Binary files a/packages/web/src/components/lore/__screenshots__/DuplicateClusters.stories.tsx/Open-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/DuplicateClusters.stories.tsx/Open-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/DuplicateClusters.stories.tsx/Open-Empty-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/DuplicateClusters.stories.tsx/Open-Empty-1-chromium-linux.png index 5b9a69ffd..0ccd9542d 100644 Binary files a/packages/web/src/components/lore/__screenshots__/DuplicateClusters.stories.tsx/Open-Empty-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/DuplicateClusters.stories.tsx/Open-Empty-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Default-1-chromium-linux.png index eceb45220..8239ea905 100644 Binary files a/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Empty-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Empty-1-chromium-linux.png index 853d02000..f93a3b41c 100644 Binary files a/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Empty-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Empty-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Failed-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Failed-1-chromium-linux.png index af2e06290..f1863ae28 100644 Binary files a/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Failed-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Failed-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Saturated-Window-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Saturated-Window-1-chromium-linux.png index 59abffc4f..9ea9d36ed 100644 Binary files a/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Saturated-Window-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/DuplicateClustersSidebar.stories.tsx/Saturated-Window-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/ExplorerInsights.stories.tsx/With-Active-Filters-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/ExplorerInsights.stories.tsx/With-Active-Filters-1-chromium-linux.png index d42fa8a8e..a80cdf6c7 100644 Binary files a/packages/web/src/components/lore/__screenshots__/ExplorerInsights.stories.tsx/With-Active-Filters-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/ExplorerInsights.stories.tsx/With-Active-Filters-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/Default-1-chromium-linux.png index 924f97d9e..a8ca181ea 100644 Binary files a/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/Menu-Open-With-Retention-Rows-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/Menu-Open-With-Retention-Rows-1-chromium-linux.png index 62dad2bc8..32a2fda4b 100644 Binary files a/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/Menu-Open-With-Retention-Rows-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/Menu-Open-With-Retention-Rows-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/Playground-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/Playground-1-chromium-linux.png index cd3016da7..1cd2fec5f 100644 Binary files a/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/Playground-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/Playground-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/With-Retention-Conditions-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/With-Retention-Conditions-1-chromium-linux.png index 69d37931a..58932fe60 100644 Binary files a/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/With-Retention-Conditions-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/FilterMenu.stories.tsx/With-Retention-Conditions-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Default-1-chromium-linux.png index 20fef0769..e78fbaafe 100644 Binary files a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Edit-View-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Edit-View-1-chromium-linux.png index 8590918ba..5afe0a296 100644 Binary files a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Edit-View-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Edit-View-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Full-Provenance-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Full-Provenance-1-chromium-linux.png index a20dd7ee8..1eba02fcb 100644 Binary files a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Full-Provenance-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Full-Provenance-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/No-Provenance-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/No-Provenance-1-chromium-linux.png index 9fe946b46..517d4c64a 100644 Binary files a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/No-Provenance-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/No-Provenance-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Playground-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Playground-1-chromium-linux.png index d9afec4dd..d8821626e 100644 Binary files a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Playground-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Playground-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Recurrence-Below-And-At-Threshold-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Recurrence-Below-And-At-Threshold-1-chromium-linux.png index 056103a3d..f9d0a4f09 100644 Binary files a/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Recurrence-Below-And-At-Threshold-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/LessonDetailSheet.stories.tsx/Recurrence-Below-And-At-Threshold-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/LoreUtilityGrid.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/LoreUtilityGrid.stories.tsx/Default-1-chromium-linux.png index 2c10590d1..7555cf309 100644 Binary files a/packages/web/src/components/lore/__screenshots__/LoreUtilityGrid.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/LoreUtilityGrid.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/LoreUtilityGrid.stories.tsx/Empty-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/LoreUtilityGrid.stories.tsx/Empty-1-chromium-linux.png index 9dffe024e..3cba5100a 100644 Binary files a/packages/web/src/components/lore/__screenshots__/LoreUtilityGrid.stories.tsx/Empty-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/LoreUtilityGrid.stories.tsx/Empty-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Default-1-chromium-linux.png index eaa2b6cbd..fdcb8aa1c 100644 Binary files a/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Expanded-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Expanded-1-chromium-linux.png index 354286ef9..df8669bb3 100644 Binary files a/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Expanded-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Expanded-1-chromium-linux.png differ diff --git a/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Selected-1-chromium-linux.png b/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Selected-1-chromium-linux.png index 93c37e427..8f42a4298 100644 Binary files a/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Selected-1-chromium-linux.png and b/packages/web/src/components/lore/__screenshots__/ScopeSelector.stories.tsx/Selected-1-chromium-linux.png differ diff --git a/packages/web/src/components/settings/__screenshots__/GroomingRuleBuilder.stories.tsx/Dialog-Open-1-chromium-linux.png b/packages/web/src/components/settings/__screenshots__/GroomingRuleBuilder.stories.tsx/Dialog-Open-1-chromium-linux.png index c77646a16..0475deafd 100644 Binary files a/packages/web/src/components/settings/__screenshots__/GroomingRuleBuilder.stories.tsx/Dialog-Open-1-chromium-linux.png and b/packages/web/src/components/settings/__screenshots__/GroomingRuleBuilder.stories.tsx/Dialog-Open-1-chromium-linux.png differ diff --git a/packages/web/src/components/settings/__screenshots__/GroomingRuleBuilder.stories.tsx/List-Empty-1-chromium-linux.png b/packages/web/src/components/settings/__screenshots__/GroomingRuleBuilder.stories.tsx/List-Empty-1-chromium-linux.png index 52509ecbd..6f034efa8 100644 Binary files a/packages/web/src/components/settings/__screenshots__/GroomingRuleBuilder.stories.tsx/List-Empty-1-chromium-linux.png and b/packages/web/src/components/settings/__screenshots__/GroomingRuleBuilder.stories.tsx/List-Empty-1-chromium-linux.png differ diff --git a/packages/web/src/components/settings/__screenshots__/GroomingRuleBuilder.stories.tsx/List-Populated-Narrow-1-chromium-linux.png b/packages/web/src/components/settings/__screenshots__/GroomingRuleBuilder.stories.tsx/List-Populated-Narrow-1-chromium-linux.png index 08b4aeb25..1b3a042ad 100644 Binary files a/packages/web/src/components/settings/__screenshots__/GroomingRuleBuilder.stories.tsx/List-Populated-Narrow-1-chromium-linux.png and b/packages/web/src/components/settings/__screenshots__/GroomingRuleBuilder.stories.tsx/List-Populated-Narrow-1-chromium-linux.png differ diff --git a/packages/web/src/components/settings/__screenshots__/RunsList.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/settings/__screenshots__/RunsList.stories.tsx/Default-1-chromium-linux.png index 3a4ae9b09..8131b6658 100644 Binary files a/packages/web/src/components/settings/__screenshots__/RunsList.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/settings/__screenshots__/RunsList.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/settings/__screenshots__/RunsList.stories.tsx/No-Runs-1-chromium-linux.png b/packages/web/src/components/settings/__screenshots__/RunsList.stories.tsx/No-Runs-1-chromium-linux.png index eba7e87b0..25924884c 100644 Binary files a/packages/web/src/components/settings/__screenshots__/RunsList.stories.tsx/No-Runs-1-chromium-linux.png and b/packages/web/src/components/settings/__screenshots__/RunsList.stories.tsx/No-Runs-1-chromium-linux.png differ diff --git a/packages/web/src/components/ui/__screenshots__/Button.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/ui/__screenshots__/Button.stories.tsx/Default-1-chromium-linux.png index e8d39bb05..4dd4df2b6 100644 Binary files a/packages/web/src/components/ui/__screenshots__/Button.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/ui/__screenshots__/Button.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/ui/__screenshots__/Button.stories.tsx/Playground-1-chromium-linux.png b/packages/web/src/components/ui/__screenshots__/Button.stories.tsx/Playground-1-chromium-linux.png index 20120b95b..b4e8fc4e4 100644 Binary files a/packages/web/src/components/ui/__screenshots__/Button.stories.tsx/Playground-1-chromium-linux.png and b/packages/web/src/components/ui/__screenshots__/Button.stories.tsx/Playground-1-chromium-linux.png differ diff --git a/packages/web/src/components/ui/__screenshots__/EmptyState.stories.tsx/Default-1-chromium-linux.png b/packages/web/src/components/ui/__screenshots__/EmptyState.stories.tsx/Default-1-chromium-linux.png index 22cbdb790..06d517239 100644 Binary files a/packages/web/src/components/ui/__screenshots__/EmptyState.stories.tsx/Default-1-chromium-linux.png and b/packages/web/src/components/ui/__screenshots__/EmptyState.stories.tsx/Default-1-chromium-linux.png differ diff --git a/packages/web/src/components/ui/__screenshots__/EmptyState.stories.tsx/Playground-1-chromium-linux.png b/packages/web/src/components/ui/__screenshots__/EmptyState.stories.tsx/Playground-1-chromium-linux.png index ec2f3fd63..5a4967454 100644 Binary files a/packages/web/src/components/ui/__screenshots__/EmptyState.stories.tsx/Playground-1-chromium-linux.png and b/packages/web/src/components/ui/__screenshots__/EmptyState.stories.tsx/Playground-1-chromium-linux.png differ diff --git a/packages/web/src/lib/app-route.ts b/packages/web/src/lib/app-route.ts new file mode 100644 index 000000000..dae9df9ff --- /dev/null +++ b/packages/web/src/lib/app-route.ts @@ -0,0 +1,40 @@ +/** + * Browser GET of one of the dashboard's own Next route handlers (`app/api/**`), + * read as JSON. + * + * This exists for the reads that still need server-side code: there is no REST + * endpoint for them yet (`packages/web/CLAUDE.md` lists invites and plan usage + * as migration backlog). They used to be called as server actions straight from + * React Query, and Next's app router runs server actions one at a time per + * client. Every read queued behind whichever action was already in flight, and + * none of them could be aborted, so the header's activity bar stayed lit until + * the whole chain had drained. A route handler is an ordinary request: it runs + * alongside the others and takes React Query's AbortSignal. + * + * Same-origin, so the session cookie authenticates it; no token is passed. + * `no-store` because these answer "what is true now": a refetch on window focus + * must not be served from the HTTP cache. + */ + +/** A non-2xx answer from an app route — the query's error, so React Query can retry it. */ +export class AppRouteError extends Error { + constructor( + readonly path: string, + readonly status: number, + ) { + super(`GET ${path} failed with HTTP ${status}`); + this.name = 'AppRouteError'; + } +} + +export async function getAppRoute(path: string, signal?: AbortSignal): Promise { + const response = await fetch(path, { + method: 'GET', + credentials: 'same-origin', + cache: 'no-store', + headers: { Accept: 'application/json' }, + ...(signal ? { signal } : {}), + }); + if (!response.ok) throw new AppRouteError(path, response.status); + return (await response.json()) as T; +} diff --git a/packages/web/src/lib/lore.ts b/packages/web/src/lib/lore.ts index 421628c94..d1de4d813 100644 --- a/packages/web/src/lib/lore.ts +++ b/packages/web/src/lib/lore.ts @@ -2,7 +2,13 @@ /** * Server actions for memory (lore) management. - * Update, archive, restore, purge, and paginated list — all user-scoped. + * Update, archive, restore and purge — all user-scoped MUTATIONS. + * + * Reads do not belong here. Next's app router runs server actions one at a + * time per client, so a read served by one queues behind every other action in + * flight and cannot be aborted. The paginated list used to live in this file as + * `listMemories`; it is now a browser request (`fetchMemoryPage` in + * `queries/lore.ts`, body built by `lib/memory-list.ts`). * * Every one of these goes through LoreKit's own REST API (the `memories` edge * function) rather than querying PostgREST directly. They used to do the @@ -12,31 +18,18 @@ * contract drift, and these had. The dashboard is now a client of the same * documented surface the CLI and every agent use. * - * They stay SERVER actions (rather than moving into the client hooks) so the - * Explorer's data path is unchanged from the components' point of view, and so - * the access token is read from the cookie session rather than handed to the - * browser bundle. + * The mutations stay SERVER actions, reading the access token from the cookie + * session. A mutation is a single user gesture, so the one-at-a-time queue costs + * nothing here. The read hooks in `queries/lore.ts` use the browser session's + * token (`session-browser.ts`), which is the same user JWT. */ import { revalidatePath } from 'next/cache'; -import type { Page } from '@/lib/pagination/keyset'; -import { clampPageSize } from '@/lib/pagination/keyset'; -import { dateRangeBounds, type DateRangeInput } from '@/lib/pagination/filters'; -import type { LessonEntry } from '@/components/lore/LessonCard'; -import { normalizeTags } from '@/lib/tag-filter'; -import { filtersToBody, normalizeFilters, type Filter } from '@/lib/filters'; -import { DEFAULT_EXPLORER_SORT, type ExplorerSort } from '@/lib/explorer-sort'; -import { - retentionConditionsToListBody, - type RetentionConditions, -} from '@/lib/retention-filter'; -import { lessonFromMemoryEntry } from '@/lib/lesson-entry'; import { serverAccessToken } from '@/lib/api/session-server'; import { RestApiError } from '@/lib/api/rest'; import { archiveMemoryRequest, listMemoriesRequest, - listMemoriesPostRequest, purgeMemoriesRequest, restoreMemoryRequest, updateMemoryRequest, @@ -160,142 +153,3 @@ export async function purgeArchivedLessons( return { purged: 0, error: messageFor(err) }; } } - -// --------------------------------------------------------------------------- -// Paginated memory listing -// --------------------------------------------------------------------------- - -export interface MemoryFilters { - /** Filter to a single scope. Omit or pass null to return all scopes. */ - scope?: string | null; - /** Case-insensitive substring match against `key` or `value`. */ - search?: string; - /** Inclusive `from` / exclusive-day-end `to` interval on `created_at`. */ - range?: DateRangeInput | null; - /** - * Labels (`memories.tags`) a row must carry — ALL of them, not any. - * Absent or empty means "no label filter". - * - * @deprecated Superseded by {@link MemoryFilters.filters}. Folded into it - * below (as a `label` filter with the `all` operator) so there is one - * translation to the wire. - */ - tags?: string[]; - /** - * The Explorer's filter bar: one condition per dimension (label / agent / - * trigger / repo / branch / pull request), OR within a dimension and AND - * across them. Translated by the pure `filtersToBody`, which is the - * single place the UI vocabulary meets the wire vocabulary. - */ - filters?: Filter[]; - /** - * The retention-preview trio (`lib/retention-filter.ts`) — the same three - * conditions a saved retention policy matches on, narrowing the list to - * what that policy would catch. Absent/empty means no narrowing. - */ - retentionConditions?: RetentionConditions; - /** - * The list order (`lib/explorer-sort.ts`) — `created_at` (Newest, the - * default) or `opened_count` (Most chosen, migration 00113). A cursor is only - * valid for the order that minted it; the route restarts at page one if the - * two disagree, and the Explorer's query key changes with the sort anyway. - */ - sort?: ExplorerSort; - /** Page size, default 50, hard max 100. */ - pageSize?: number; - /** Opaque keyset cursor from a previous page's `nextCursor`. */ - cursor?: string | null; - /** - * When true, returns only archived memories (archived_at IS NOT NULL). - * When false/absent, returns only active memories (archived_at IS NULL). - */ - showArchived?: boolean; - /** - * Narrow to memories whose TTL runs out within N days — the route's - * `expiring_within_days` (1–365). Absent means no expiry narrowing. - * - * Passed straight through and bounded by the route, not here: an - * out-of-range value is a 400 the caller should see, and re-implementing the - * bound in the dashboard would be a second copy of it to keep in step. - */ - expiringWithinDays?: number; -} - -/** - * `total` — exact count of every memory the request's filters matched, - * ignoring pagination (`MemoryPageResponseSchema`'s optional field, - * `lorekit_memory_list`'s `total_count` column, migration 00094). `Page` - * itself stays total-less: it is shared with `useAuditLog`, which has no - * equivalent aggregate, so this is `MemoryPage`'s own addition rather than a - * field every keyset page now carries. - */ -export type MemoryPage = Page & { total?: number }; - -const DEFAULT_PAGE_SIZE = 50; -const MAX_PAGE_SIZE = 100; -const EMPTY_PAGE: MemoryPage = { rows: [], nextCursor: null, hasMore: false }; - -/** - * List a keyset page of the memories the caller can see, newest first, with - * optional combinable filters (scope / substring / date interval / labels). - * - * Ordering defaults to `created_at desc` — `sort: 'created_at'` — not the - * route's `updated_at` default: a memory migrated with a backdated `created_at` - * belongs at its original position in the Explorer, which is the order the list - * has always been in. `filters.sort` swaps in `opened_count` ("Most chosen"). - * - * Sent over `POST /memories/list` rather than `GET /memories`: the filter bar's - * dimensions are unbounded, and the query transport caps each one at 2048 - * characters — the ceiling that made the Explorer stop loading past ~50-75 - * selected values in a dimension. The BODY carries the filters; the ordering, - * the page size and the cursor are unchanged. - * - * Fails closed to an empty page on auth failure or API error — read-only, so - * failing closed is safe. - */ -export async function listMemories(filters: MemoryFilters = {}): Promise { - const token = await serverAccessToken(); - if (!token) return EMPTY_PAGE; - - const pageSize = clampPageSize(filters.pageSize, { def: DEFAULT_PAGE_SIZE, max: MAX_PAGE_SIZE }); - const bounds = dateRangeBounds(filters.range); - - // The deprecated `tags` shorthand becomes a `label` filter with the `all` - // operator — exactly what it has always meant — unless the caller already - // supplied one, in which case the explicit bar wins. - const legacyTags = normalizeTags(filters.tags); - const explicit = normalizeFilters(filters.filters ?? []); - const bar = - legacyTags.length === 0 || explicit.some((f) => f.field === 'label') - ? explicit - : normalizeFilters([...explicit, { field: 'label', operator: 'all', values: legacyTags }]); - - try { - const page = await listMemoriesPostRequest(token, { - limit: pageSize, - sort: filters.sort ?? DEFAULT_EXPLORER_SORT, - archived: filters.showArchived ?? false, - ...(filters.expiringWithinDays !== undefined - ? { expiring_within_days: filters.expiringWithinDays } - : {}), - ...(filters.scope ? { scope: filters.scope } : {}), - ...(filters.search ? { q: filters.search } : {}), - ...(bounds.gte ? { created_since: bounds.gte } : {}), - ...(bounds.lt ? { created_until: bounds.lt } : {}), - // OR within a dimension, AND across dimensions — see `filtersToBody`. - ...filtersToBody(bar), - ...retentionConditionsToListBody(filters.retentionConditions ?? {}), - ...(filters.cursor ? { cursor: filters.cursor } : {}), - }); - - return { - rows: page.entries.map(lessonFromMemoryEntry), - nextCursor: page.nextCursor, - hasMore: page.hasMore, - total: page.total, - }; - } catch (err) { - console.error('[listMemories] REST error:', messageFor(err)); - return EMPTY_PAGE; - } -} diff --git a/packages/web/src/lib/memory-list.spec.ts b/packages/web/src/lib/memory-list.spec.ts new file mode 100644 index 000000000..170c94396 --- /dev/null +++ b/packages/web/src/lib/memory-list.spec.ts @@ -0,0 +1,101 @@ +/** + * `memoryListBody` / `memoryPageFromResponse` are the parts of the old + * `listMemories` server action that were never about transport. These pin the + * body it used to send, so moving the request into the browser changed where + * the list is fetched, not what is asked for. + */ + +import { describe, expect, it } from 'vitest'; +import type { MemoryEntry, MemoryPageResponse } from '@lorekit/schemas/memory'; +import { memoryListBody, memoryPageFromResponse } from './memory-list'; + +describe('memoryListBody', () => { + it('asks for one page of active memories, newest first, when nothing is filtered', () => { + expect(memoryListBody()).toEqual({ limit: 50, sort: 'created_at', archived: false }); + }); + + it('clamps the page size to the route maximum', () => { + expect(memoryListBody({ pageSize: 500 }).limit).toBe(100); + expect(memoryListBody({ pageSize: 0 }).limit).toBe(50); + }); + + it('sends scope, search, archived view, expiry horizon and cursor under their wire names', () => { + expect( + memoryListBody({ + scope: 'repo::mthines/lorekit', + search: 'flaky', + showArchived: true, + expiringWithinDays: 7, + cursor: 'abc', + sort: 'opened_count', + }), + ).toEqual({ + limit: 50, + sort: 'opened_count', + archived: true, + expiring_within_days: 7, + scope: 'repo::mthines/lorekit', + q: 'flaky', + cursor: 'abc', + }); + }); + + it('leaves out empty scope and search rather than sending blanks', () => { + const body = memoryListBody({ scope: null, search: '' }); + expect(body).not.toHaveProperty('scope'); + expect(body).not.toHaveProperty('q'); + }); + + it('turns an inclusive day range into a half-open created_at window', () => { + expect(memoryListBody({ range: { from: '2026-07-01', to: '2026-07-26' } })).toMatchObject({ + created_since: '2026-07-01', + created_until: '2026-07-27T00:00:00.000Z', + }); + }); + + it('folds the deprecated tags shorthand into an all-of label filter', () => { + expect(memoryListBody({ tags: ['ci', 'flaky'] })).toMatchObject({ + tags: ['ci', 'flaky'], + tags_mode: 'all', + }); + }); + + it('lets an explicit label filter win over the tags shorthand', () => { + const body = memoryListBody({ + tags: ['ignored'], + filters: [{ field: 'label', operator: 'in', values: ['kept'] }], + }); + expect(body).toMatchObject({ tags: ['kept'], tags_mode: 'any' }); + }); + + it('carries retention thresholds so the list matches what a policy would catch', () => { + expect(memoryListBody({ retentionConditions: { minAgeDays: 30, maxReadCount: 0 } })).toMatchObject({ + min_age_days: 30, + max_read_count: 0, + }); + }); +}); + +describe('memoryPageFromResponse', () => { + it('keeps the keyset envelope and the total, and maps every entry to a lesson row', () => { + const entry = { + id: '00000000-0000-0000-0000-000000000001', + scope: 'global', + key: 'k', + value: 'v', + tags: [], + } as unknown as MemoryEntry; + const response = { + entries: [entry], + hasMore: true, + nextCursor: 'next', + total: 7, + } as unknown as MemoryPageResponse; + + const page = memoryPageFromResponse(response); + + expect(page).toMatchObject({ hasMore: true, nextCursor: 'next', total: 7 }); + expect(page.rows).toHaveLength(1); + expect(page.rows[0]).toMatchObject({ id: entry.id, scope: 'global', key: 'k' }); + }); +}); diff --git a/packages/web/src/lib/memory-list.ts b/packages/web/src/lib/memory-list.ts new file mode 100644 index 000000000..d7c21561a --- /dev/null +++ b/packages/web/src/lib/memory-list.ts @@ -0,0 +1,154 @@ +/** + * The Explorer's memory-list request: the filter state the UI holds, translated + * into the `POST /memories/list` body, and the response translated back into a + * page of `LessonEntry` rows. + * + * Pure on purpose, and in its own module rather than in `lib/lore.ts`: the list + * used to be the `listMemories` SERVER ACTION, and Next's app router runs server + * actions one at a time per client. On `/lore` it queued behind the two other + * server actions the top bar fired on every load and every window focus (plan + * usage, pending invites), so the list arrived last and the header's activity + * bar stayed on long after every visible panel had loaded. The list is now a + * plain browser request (`fetchMemoryPage` in `queries/lore.ts`) that runs in + * parallel and honours React Query's AbortSignal; this module is the part of + * the old action that was never about transport, so it moved here unchanged and + * gained a spec. + */ + +import type { ListMemoriesBody, MemoryPageResponse } from '@lorekit/schemas/memory'; +import type { LessonEntry } from '@/components/lore/LessonCard'; +import type { Page } from '@/lib/pagination/keyset'; +import { clampPageSize } from '@/lib/pagination/keyset'; +import { dateRangeBounds, type DateRangeInput } from '@/lib/pagination/filters'; +import { normalizeTags } from '@/lib/tag-filter'; +import { filtersToBody, normalizeFilters, type Filter } from '@/lib/filters'; +import { DEFAULT_EXPLORER_SORT, type ExplorerSort } from '@/lib/explorer-sort'; +import { + retentionConditionsToListBody, + type RetentionConditions, +} from '@/lib/retention-filter'; +import { lessonFromMemoryEntry } from '@/lib/lesson-entry'; + +export interface MemoryFilters { + /** Filter to a single scope. Omit or pass null to return all scopes. */ + scope?: string | null; + /** Case-insensitive substring match against `key` or `value`. */ + search?: string; + /** Inclusive `from` / exclusive-day-end `to` interval on `created_at`. */ + range?: DateRangeInput | null; + /** + * Labels (`memories.tags`) a row must carry — ALL of them, not any. + * Absent or empty means "no label filter". + * + * @deprecated Superseded by {@link MemoryFilters.filters}. Folded into it + * below (as a `label` filter with the `all` operator) so there is one + * translation to the wire. + */ + tags?: string[]; + /** + * The Explorer's filter bar: one condition per dimension (label / agent / + * trigger / repo / branch / pull request), OR within a dimension and AND + * across them. Translated by the pure `filtersToBody`, which is the + * single place the UI vocabulary meets the wire vocabulary. + */ + filters?: Filter[]; + /** + * The retention-preview trio (`lib/retention-filter.ts`) — the same three + * conditions a saved retention policy matches on, narrowing the list to + * what that policy would catch. Absent/empty means no narrowing. + */ + retentionConditions?: RetentionConditions; + /** + * The list order (`lib/explorer-sort.ts`) — `created_at` (Newest, the + * default) or `opened_count` (Most chosen, migration 00113). A cursor is only + * valid for the order that minted it; the route restarts at page one if the + * two disagree, and the Explorer's query key changes with the sort anyway. + */ + sort?: ExplorerSort; + /** Page size, default 50, hard max 100. */ + pageSize?: number; + /** Opaque keyset cursor from a previous page's `nextCursor`. */ + cursor?: string | null; + /** + * When true, returns only archived memories (archived_at IS NOT NULL). + * When false/absent, returns only active memories (archived_at IS NULL). + */ + showArchived?: boolean; + /** + * Narrow to memories whose TTL runs out within N days — the route's + * `expiring_within_days` (1–365). Absent means no expiry narrowing. + * + * Passed straight through and bounded by the route, not here: an + * out-of-range value is a 400 the caller should see, and re-implementing the + * bound in the dashboard would be a second copy of it to keep in step. + */ + expiringWithinDays?: number; +} + +/** + * `total` — exact count of every memory the request's filters matched, + * ignoring pagination (`MemoryPageResponseSchema`'s optional field, + * `lorekit_memory_list`'s `total_count` column, migration 00094). `Page` + * itself stays total-less: it is shared with `useAuditLog`, which has no + * equivalent aggregate, so this is `MemoryPage`'s own addition rather than a + * field every keyset page now carries. + */ +export type MemoryPage = Page & { total?: number }; + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 100; + +/** + * The `POST /memories/list` body for one keyset page of the memories the caller + * can see. + * + * Ordering defaults to `created_at desc` — `sort: 'created_at'` — not the + * route's `updated_at` default: a memory migrated with a backdated `created_at` + * belongs at its original position in the Explorer, which is the order the list + * has always been in. `filters.sort` swaps in `opened_count` ("Most chosen"). + * + * A body rather than a query string: the filter bar's dimensions are unbounded, + * and the query transport caps each one at 2048 characters — the ceiling that + * made the Explorer stop loading past ~50-75 selected values in a dimension. + */ +export function memoryListBody(filters: MemoryFilters = {}): Partial { + const pageSize = clampPageSize(filters.pageSize, { def: DEFAULT_PAGE_SIZE, max: MAX_PAGE_SIZE }); + const bounds = dateRangeBounds(filters.range); + + // The deprecated `tags` shorthand becomes a `label` filter with the `all` + // operator — exactly what it has always meant — unless the caller already + // supplied one, in which case the explicit bar wins. + const legacyTags = normalizeTags(filters.tags); + const explicit = normalizeFilters(filters.filters ?? []); + const bar = + legacyTags.length === 0 || explicit.some((f) => f.field === 'label') + ? explicit + : normalizeFilters([...explicit, { field: 'label', operator: 'all', values: legacyTags }]); + + return { + limit: pageSize, + sort: filters.sort ?? DEFAULT_EXPLORER_SORT, + archived: filters.showArchived ?? false, + ...(filters.expiringWithinDays !== undefined + ? { expiring_within_days: filters.expiringWithinDays } + : {}), + ...(filters.scope ? { scope: filters.scope } : {}), + ...(filters.search ? { q: filters.search } : {}), + ...(bounds.gte ? { created_since: bounds.gte } : {}), + ...(bounds.lt ? { created_until: bounds.lt } : {}), + // OR within a dimension, AND across dimensions — see `filtersToBody`. + ...filtersToBody(bar), + ...retentionConditionsToListBody(filters.retentionConditions ?? {}), + ...(filters.cursor ? { cursor: filters.cursor } : {}), + }; +} + +/** A `POST /memories/list` response as the page shape the Explorer renders. */ +export function memoryPageFromResponse(page: MemoryPageResponse): MemoryPage { + return { + rows: page.entries.map(lessonFromMemoryEntry), + nextCursor: page.nextCursor, + hasMore: page.hasMore, + total: page.total, + }; +} diff --git a/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts b/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts index 23071e01c..ff194bcc2 100644 --- a/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts +++ b/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts @@ -40,6 +40,7 @@ vi.mock('@/lib/api/memories', () => ({ getMemoryByIdRequest: vi.fn(), getMemoryByRefRequest: vi.fn(), listFacetsPostRequest: vi.fn(), + listMemoriesPostRequest: vi.fn(), pivotPostRequest: vi.fn(), })); diff --git a/packages/web/src/lib/queries/lore.ts b/packages/web/src/lib/queries/lore.ts index d141c17b8..01885bbae 100644 --- a/packages/web/src/lib/queries/lore.ts +++ b/packages/web/src/lib/queries/lore.ts @@ -16,7 +16,13 @@ import { dayCountsFromActivity } from '@/lib/aggregations'; import { heatmapSince } from '@/lib/heatmap-window'; import type { ScopeNode } from '@/components/lore/ScopeTree'; import type { LessonEntry } from '@/components/lore/LessonCard'; -import { listMemories, archiveLesson, restoreLesson, type MemoryFilters, type MemoryPage } from '@/lib/lore'; +import { archiveLesson, restoreLesson } from '@/lib/lore'; +import { + memoryListBody, + memoryPageFromResponse, + type MemoryFilters, + type MemoryPage, +} from '@/lib/memory-list'; import { DEFAULT_EXPLORER_SORT, type ExplorerSort } from '@/lib/explorer-sort'; import { NO_RETENTION_CONDITIONS, @@ -33,6 +39,7 @@ import { getMemoryByIdRequest, getMemoryByRefRequest, listFacetsPostRequest, + listMemoriesPostRequest, listMemoriesRequest, listScopesRequest, pivotPostRequest, @@ -613,7 +620,31 @@ function mergedFilters(filters: UseMemoriesFilters): Filter[] { } /** - * `useInfiniteQuery` over the `listMemories` server action. + * One page of the Explorer's list, straight from `POST /memories/list`. + * + * A browser request, not a server action, and that is the point. Next runs + * server actions one at a time per client, so the list used to queue behind + * the top bar's plan-usage and invites reads on every load and every window + * focus, arriving last while the header's activity bar stayed lit. A server + * action also cannot take an AbortSignal, so a page superseded by a filter + * change ran to completion anyway. This one runs in parallel with the other + * reads and is cancelled when React Query drops it. + * + * Unlike the old action it does not fail closed to an empty page: an API error + * throws, so the Explorer shows "Failed to load memories" instead of passing an + * outage off as "No memories" (only while nothing is loaded — a failed "Load + * more" keeps the loaded rows and offers a retry beside the button), and a + * lapsed session is a `NotAuthenticatedError`, like every other read hook in + * this file. + */ +async function fetchMemoryPage(filters: MemoryFilters, signal?: AbortSignal): Promise { + const token = await requireBrowserToken(); + const page = await listMemoriesPostRequest(token, memoryListBody(filters), signal); + return memoryPageFromResponse(page); +} + +/** + * `useInfiniteQuery` over `POST /memories/list` ({@link fetchMemoryPage}). * * Changing any filter key resets pagination automatically because the query * key changes — no manual "reset cursor on filter change" bookkeeping needed. @@ -649,7 +680,7 @@ export function useMemories(filters: UseMemoriesFilters) { // under another — so the sort must split the cache, not share it. filters.sort ?? DEFAULT_EXPLORER_SORT, ], - queryFn: ({ pageParam }) => { + queryFn: ({ pageParam, signal }) => { const args: MemoryFilters = { scope: filters.scope ?? undefined, search: filters.search || undefined, @@ -661,12 +692,13 @@ export function useMemories(filters: UseMemoriesFilters) { showArchived: filters.showArchived, expiringWithinDays: filters.expiringWithinDays, }; - return listMemories(args); + return fetchMemoryPage(args, signal); }, initialPageParam: null as string | null, getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextCursor : undefined), // Lore explorer is read-heavy — 90 s matches the scope-tree stale time. staleTime: 90_000, + retry: retryUnlessSignedOut, }); } diff --git a/packages/web/src/lib/queries/parallel-reads.spec.ts b/packages/web/src/lib/queries/parallel-reads.spec.ts new file mode 100644 index 000000000..b7e9a1994 --- /dev/null +++ b/packages/web/src/lib/queries/parallel-reads.spec.ts @@ -0,0 +1,184 @@ +/** + * The three reads `/lore` fires on every load and every window-focus refetch — + * the Explorer's list, the top bar's memory badge and the pending-invites query + * — used to be Next.js server actions. The app router runs server actions one + * at a time per client, and a server action cannot take an AbortSignal, so the + * three queued (badge → invites → list) and kept the header's activity bar lit + * long after every visible panel had loaded. + * + * Pinned here: each hook now issues an ordinary request that carries React + * Query's signal, and none of them reaches for a server action. The server + * action modules are mocked to THROW, so a hook that regressed to calling one + * fails loudly instead of passing against a stub. + * + * No DOM environment in this package, so the hooks run as plain functions with + * `useQuery` / `useInfiniteQuery` swapped for a recorder of their options — + * the same harness `lore-scopes-dedupe.spec.ts` uses. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface RecordedOptions { + queryKey: readonly unknown[]; + queryFn: (context: { pageParam?: unknown; signal: AbortSignal }) => Promise; +} + +const harness = vi.hoisted(() => ({ recorded: [] as RecordedOptions[] })); + +const api = vi.hoisted(() => ({ listMemoriesPostRequest: vi.fn() })); + +const calledServerAction = vi.hoisted(() => () => { + throw new Error('a read went through a server action'); +}); + +vi.mock('@/lib/plan', () => ({ getPlanUsage: calledServerAction })); +vi.mock('@/lib/org-invites', () => ({ listPendingInvitesForMe: calledServerAction })); +vi.mock('@/lib/lore', () => ({ + archiveLesson: calledServerAction, + restoreLesson: calledServerAction, +})); + +vi.mock('@/lib/api/session-browser', () => ({ + browserAccessToken: async () => 'test-token', +})); + +vi.mock('@/lib/api/memories', async (importOriginal) => ({ + ...(await importOriginal()), + listMemoriesPostRequest: api.listMemoriesPostRequest, +})); + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal(); + const record = (options: RecordedOptions) => { + harness.recorded.push(options); + return { data: undefined }; + }; + return { ...actual, useQuery: record, useInfiniteQuery: record }; +}); + +const { useMemories } = await import('./lore'); +const { useMemoryTotal, PLAN_USAGE_PATH } = await import('./plan'); +const { usePendingInvitesForMe, PENDING_INVITES_PATH } = await import('./pending-invites'); + +/** The options a hook hands React Query, captured by running it once. */ +function optionsOf(hook: () => unknown): RecordedOptions { + harness.recorded = []; + hook(); + const [options] = harness.recorded; + if (!options) throw new Error('the hook registered no query'); + return options; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('useMemories', () => { + const listOptions = () => + optionsOf(() => useMemories({ scope: 'repo::mthines/lorekit', search: '', range: null })); + + it('reads POST /memories/list from the browser and hands it the query signal', async () => { + api.listMemoriesPostRequest.mockResolvedValue({ + entries: [], + hasMore: false, + nextCursor: null, + total: 0, + }); + const { signal } = new AbortController(); + + const page = await listOptions().queryFn({ pageParam: null, signal }); + + expect(api.listMemoriesPostRequest).toHaveBeenCalledWith( + 'test-token', + expect.objectContaining({ scope: 'repo::mthines/lorekit', limit: 50, sort: 'created_at' }), + signal, + ); + expect(page).toEqual({ rows: [], hasMore: false, nextCursor: null, total: 0 }); + }); + + it('sends the page cursor React Query passes for "Load more"', async () => { + api.listMemoriesPostRequest.mockResolvedValue({ entries: [], hasMore: false, nextCursor: null }); + + await listOptions().queryFn({ pageParam: 'cursor-2', signal: new AbortController().signal }); + + expect(api.listMemoriesPostRequest.mock.calls[0]?.[1]).toMatchObject({ cursor: 'cursor-2' }); + }); + + it('rejects on an API failure instead of passing it off as an empty list', async () => { + api.listMemoriesPostRequest.mockRejectedValue(new Error('upstream 503')); + + await expect( + listOptions().queryFn({ pageParam: null, signal: new AbortController().signal }), + ).rejects.toThrow('upstream 503'); + }); +}); + +describe('useMemoryTotal', () => { + it('reads the count from GET /api/plan/usage with the query signal', async () => { + fetchMock.mockResolvedValue(jsonResponse({ count: 3318, limit: 5000, plan: 'free' })); + const { signal } = new AbortController(); + + await expect(optionsOf(() => useMemoryTotal()).queryFn({ signal })).resolves.toBe(3318); + expect(fetchMock).toHaveBeenCalledWith( + PLAN_USAGE_PATH, + expect.objectContaining({ method: 'GET', cache: 'no-store', signal }), + ); + }); + + it('degrades to 0 when the route answers null (no session, or the RPC failed)', async () => { + fetchMock.mockResolvedValue(jsonResponse(null)); + + await expect( + optionsOf(() => useMemoryTotal()).queryFn({ signal: new AbortController().signal }), + ).resolves.toBe(0); + }); + + it('rejects on a non-2xx answer so React Query can retry it', async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: 'boom' }, 500)); + + await expect( + optionsOf(() => useMemoryTotal()).queryFn({ signal: new AbortController().signal }), + ).rejects.toThrow(`GET ${PLAN_USAGE_PATH} failed with HTTP 500`); + }); +}); + +describe('usePendingInvitesForMe', () => { + it('reads GET /api/invites/pending with the query signal', async () => { + const invites = [{ id: 'inv-1', org_id: 'org-1', status: 'pending' }]; + fetchMock.mockResolvedValue(jsonResponse(invites)); + const { signal } = new AbortController(); + + await expect(optionsOf(() => usePendingInvitesForMe()).queryFn({ signal })).resolves.toEqual( + invites, + ); + expect(fetchMock).toHaveBeenCalledWith( + PENDING_INVITES_PATH, + expect.objectContaining({ method: 'GET', cache: 'no-store', signal }), + ); + }); +}); + +describe('the paths the hooks read', () => { + it.each([PLAN_USAGE_PATH, PENDING_INVITES_PATH])('%s is served by a GET route handler', (path) => { + const file = fileURLToPath(new URL(`../../app${path}/route.ts`, import.meta.url)); + + expect(existsSync(file)).toBe(true); + expect(readFileSync(file, 'utf8')).toMatch(/export async function GET\(/); + }); +}); diff --git a/packages/web/src/lib/queries/pending-invites.ts b/packages/web/src/lib/queries/pending-invites.ts index fff57c746..b3c4772b0 100644 --- a/packages/web/src/lib/queries/pending-invites.ts +++ b/packages/web/src/lib/queries/pending-invites.ts @@ -1,5 +1,6 @@ import { useQuery, type UseQueryResult } from '@tanstack/react-query'; -import { listPendingInvitesForMe, type OrgInvite } from '@/lib/org-invites'; +import type { OrgInvite } from '@/lib/org-invites'; +import { getAppRoute } from '@/lib/app-route'; /** * Shared TanStack query key for pending invites — the ONE cache entry consumed @@ -9,14 +10,24 @@ import { listPendingInvitesForMe, type OrgInvite } from '@/lib/org-invites'; */ export const PENDING_INVITES_QUERY_KEY = ['pending-invites'] as const; +/** The route handler (`app/api/invites/pending/route.ts`) the query reads. */ +export const PENDING_INVITES_PATH = '/api/invites/pending'; + /** * Shared query hook — SSR-seeded via `initialData` so there is exactly ONE * cache entry rather than independent fetches per consumer. + * + * Read through `GET /api/invites/pending` rather than by calling the server + * action: Next runs server actions one at a time per client, so this read used + * to queue with the plan-usage badge and the Explorer's list on every `/lore` + * load and window focus, and could not be cancelled (see `lib/app-route.ts`). + * The server-rendered pages still call `listPendingInvitesForMe` directly — on + * the server it is a plain function call, with no queue. */ export function usePendingInvitesForMe(initialData?: OrgInvite[]): UseQueryResult { return useQuery({ queryKey: PENDING_INVITES_QUERY_KEY, - queryFn: () => listPendingInvitesForMe(), + queryFn: ({ signal }) => getAppRoute(PENDING_INVITES_PATH, signal), initialData, staleTime: 60_000, }); diff --git a/packages/web/src/lib/queries/plan.ts b/packages/web/src/lib/queries/plan.ts index a6c67f139..3df7a92e8 100644 --- a/packages/web/src/lib/queries/plan.ts +++ b/packages/web/src/lib/queries/plan.ts @@ -1,7 +1,11 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { getPlanUsage } from '@/lib/plan'; +import type { PlanUsage } from '@/lib/plan'; +import { getAppRoute } from '@/lib/app-route'; + +/** The route handler (`app/api/plan/usage/route.ts`) the badge reads. */ +export const PLAN_USAGE_PATH = '/api/plan/usage'; /** * The caller's total memory count — the SAME figure the /settings/plan page @@ -18,11 +22,17 @@ import { getPlanUsage } from '@/lib/plan'; * `getPlanUsage` returns count + limit + plan in one round-trip; the badge needs * only the count. Degrades to 0 when there is no session / the RPC fails, matching * the plan page's graceful-null handling. + * + * Read through `GET /api/plan/usage` rather than by calling the server action: + * Next runs server actions one at a time per client, so this badge used to hold + * the Explorer's list and the invites read behind it on every `/lore` load and + * window focus, and none of them could be cancelled (see `lib/app-route.ts`). */ export function useMemoryTotal() { return useQuery({ queryKey: ['memory-total'], - queryFn: async () => (await getPlanUsage())?.count ?? 0, + queryFn: async ({ signal }) => + (await getAppRoute(PLAN_USAGE_PATH, signal))?.count ?? 0, // Changes only when an agent writes — align with the other read-heavy hooks. staleTime: 60_000, }); diff --git a/packages/web/src/mocks/memories.ts b/packages/web/src/mocks/memories.ts index 8d8beec95..d4235edc8 100644 --- a/packages/web/src/mocks/memories.ts +++ b/packages/web/src/mocks/memories.ts @@ -3,11 +3,9 @@ * Query hooks call. * * These back the full-page / full-view visual-regression stories. The hooks - * (`useScopeTree`, `useFacetCatalog`, `useLoreData`, and the - * `listMemories` server action, which — with `next/headers` auto-mocked by - * `@storybook/nextjs-vite` — executes in the browser) all talk to the - * `memories` edge function now rather than to PostgREST directly, so the - * handlers below mock `…/functions/v1/memories*`. + * (`useScopeTree`, `useFacetCatalog`, `useLoreData`, `useMemories`) all talk to + * the `memories` edge function from the browser rather than to PostgREST + * directly, so the handlers below mock `…/functions/v1/memories*`. * * ONE fixture set drives every consumer: `MEMORY_ROWS` is the "database", and * each handler derives its own response from it exactly as the real endpoint @@ -429,8 +427,8 @@ function filterRows(rows: MemoryRow[], url: URL): MemoryRow[] { function listFrom(rows: MemoryRow[], url: URL) { const limit = Number(url.searchParams.get('limit') ?? 50); // Honour `sort` rather than pinning one: the route defaults to `updated_at`, - // but `listMemories` (lib/lore.ts) always sends `sort: 'created_at'`, so the - // Explorer reads in creation order. A mock that pinned EITHER value would + // but `memoryListBody` (lib/memory-list.ts) always sends one, `created_at` + // by default, so the Explorer reads in creation order. A mock that pinned EITHER value would // render one order while the caller asked for another, which is the sort of // difference a screenshot baseline then freezes in place. const sort = url.searchParams.get('sort') === 'created_at' ? 'created_at' : 'updated_at';