Skip to content

perf(web): run the /lore list, plan-usage and invites reads in parallel - #712

Merged
dash0-dev[bot] merged 9 commits into
mainfrom
perf/web-parallel-explorer-reads
Oct 21, 2026
Merged

dash0-dev[bot] merged 9 commits into
mainfrom
perf/web-parallel-explorer-reads

Conversation

@dash0-dev

@dash0-dev dash0-dev Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On /lore the TopBar activity bar could stay on for seconds after everything on screen had loaded. Three reads were Next.js server actions: the memory badge, the pending-invites query and the Explorer list. The app router runs server actions one at a time per client, and they can't take an AbortSignal, so on every load and every window focus they queued in a fixed order: plan usage → invites → list. This PR moves all three off server actions so they run in parallel with the other reads and can be cancelled.

Changes

  • Explorer list: useMemories now calls POST /memories/list from the browser (fetchMemoryPage) and passes React Query's signal, the same way the scope tree and facets already work. The body building moved unchanged from the listMemories action into the pure lib/memory-list.ts, and the action was removed.
  • Memory badge and pending invites: useMemoryTotal and usePendingInvitesForMe now read new GET route handlers, /api/plan/usage and /api/invites/pending. These wrap the existing getPlanUsage / listPendingInvitesForMe with the same contract (null / [] when signed out). They have no REST endpoint yet, so a route handler is the step that removes the queue without a schema/handler/OpenAPI change. The browser reads them through getAppRoute(path, signal) (lib/app-route.ts). The server-rendered pages still call the functions directly.
  • Behaviour change: a failed list read now retries (retryUnlessSignedOut) and, when nothing is loaded yet, shows the Explorer's existing "Failed to load memories" state. A failed "Load more" keeps the loaded rows and offers "Try again" beside the button. Before, the action failed closed to an empty page, so an outage read as "No memories".
  • Analytics: lore.results_paged is now sent only when the requested page actually lands, so a failed attempt, its retry, or a page cancelled by a filter change mid-load no longer counts as an appended page.
  • Tests:
    • parallel-reads.spec.ts checks that each hook sends an ordinary request with the signal and never calls a server action. The action modules are mocked to throw, and all 9 cases fail against main.
    • read-routes.spec.ts checks the route contracts and no-store.
    • memory-list.spec.ts pins the list body.
  • Docs: a new rule in packages/web/CLAUDE.md ("A client read is never a server action") plus the matching docs/decisions.md entry and CLAUDE.md key-decision line. docs/key-files.md and docs/storybook.md are updated.

User-facing docs / llms.txt: none apply. Nothing a user can do or configure changed; only the transport behind three dashboard reads did.

Context

Measured from the web service's RUM and server spans (2026-10-20, www.lorekit.io):

  • Back-to-back calls: each POST /lore started the millisecond the previous one ended. Server spans show the order never changed: lorekit.plan.get_usage → org_invites read → memories/list.
  • Typical cost: on a page load, the chain ran about 6.6 s past the last parallel REST response. Window-focus refetches added about 2 s each.
  • Worst case: a 50.6 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 after the visible data had loaded.
  • First paint: on a cold load, LCP landed 0.5 s after the list action, which was third in the queue. The list itself waited about 10 s behind the two top-bar reads.

Needs you

  • Comment /update-baselines (it only runs for a maintainer's comment). The Pages/Lore Explorer → Default story now renders the populated list from the MSW fixtures instead of "No memories in this scope", so LorePage.stories.tsx/Default-1-chromium-linux.png changes (960×720 → 960×1793). Empty is unchanged. The baseline commit is pushed with the workflow token, so re-run CI (or push a commit) afterwards to turn the Storybook job and CI Summary green.
  • Optional follow-up: move plan usage and "invites for me" onto real edge endpoints (the backlog in packages/web/CLAUDE.md). Four other client surfaces still read through actions, off the /lore refresh path: useAuditLog, LessonDetailSheet, OrganizationManager and InviteDetailsDialog (listed in packages/web/CLAUDE.md).

Verification

  • npx vitest run --root packages/web: 116 files, 1647 tests pass.
  • npx tsc --noEmit -p packages/web/tsconfig.json: clean.
  • eslint on the changed files: 0 errors (one existing warning in mocks/memories.ts).
  • Not run in the sandbox: the Storybook browser suite and the Playwright e2e run. In CI, Playwright e2e passes and the Storybook job fails only on the Default baseline above.

mthines and others added 2 commits October 20, 2026 18:09
The three reads were Next.js server actions, which the app router runs one
at a time per client and which cannot take an AbortSignal. On /lore they
queued (plan usage -> pending invites -> memory list) on every load and
every window focus, so the TopBar activity bar stayed on long after the
visible panels had loaded.

- useMemories calls POST /memories/list from the browser with React Query's
  signal (body building moved unchanged into the pure lib/memory-list.ts)
- useMemoryTotal and usePendingInvitesForMe read new GET route handlers
  (/api/plan/usage, /api/invites/pending) that wrap the existing server
  functions

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
…r-action-reads rule

- parallel-reads.spec.ts: useMemories, useMemoryTotal and
  usePendingInvitesForMe issue ordinary requests with React Query's signal
  and never call a server action (the action modules are mocked to throw)
- read-routes.spec.ts: the two GET route handlers keep the wrapped
  functions' contract (null / [] when signed out) and are never cached
- memory-list.spec.ts pins the POST /memories/list body
- packages/web/CLAUDE.md, docs/decisions.md, CLAUDE.md: a client read is
  never a server action; docs/key-files.md and docs/storybook.md updated
- LorePage story now renders the populated list (baseline needs refresh)

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The dashboard preview for this PR — redeployed on each push that changes the web app.

Project Deployment Actions Updated
lorekit Ready Ready Preview Oct 21, 2026 7:31am UTC

Preview always points at this PR's latest commit; Deployment is the immutable build of abb2666. · Comment /web-preview to force a redeploy. · Workflow logs

@dash0-dev

dash0-dev Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

⚪ 1 non-blocking finding

Moves the /lore list, plan-usage and invites reads off server actions. Since the last review only CI's baseline commit landed (44 PNGs, no code); one doc gap on the plan-usage backlog claim.

Warnings: Says only LorePage Default baseline moves; 659361a rewrote 44 PNGs (Empty + 42 untouched stories, 3 clash with #708)

1 advisory finding below the confidence bar — see Less certain below.

Progress: open review threads 2 → 0 → 1 → 0 → 1 across the last 5 reviews

Finding Where
⚪ 1 Plan usage missing from the backlog list it cites
Fix: Add plan usage (getPlanUsage → lorekit_memory_count) to the backlog list at packages/web/CLAUDE.md:82-83.
packages/web/src/lib/app-route.ts:6

Fix all with Agent0

Less certain (1) — advisory, below the confidence bar
Finding Where
2 suggestion: No spec, story or e2e covers loadMoreFailed (rows kept, Try again) or the page-landed lore.results_paged gate; both changed across three pushes here.
Confidence 75
packages/web/src/components/lore/LoreExplorer.tsx:1163
Review details
Gate Details
Description vs. code ⚠️ Says only LorePage Default baseline moves; 659361a rewrote 44 PNGs (Empty + 42 untouched stories, 3 clash with #708)
Prior review feedback ✅ Earlier review comments are resolved.
Documentation ✅ New CLAUDE.md rule, decisions entry, key-files rows and JSDoc explain the transport change and its interim routes
Self-review signals ✅ No debug logs, leftover TODOs, or unreviewed stubs.
Code review ⚠️ 1 non-blocking finding — see inline comments.
Run Details
Mode full · 0 lines in delta · tier deep · depth checkout · delta since 3201be9 is CI's 44-baseline commit (0 text lines); routed deep on blast radius, so the whole PR was re-read
Anomaly ⚠️ no sub-agent dispatch available — the intent finder ran in-context with the other finders at effective thoroughness 0.95, instead of as its own sub-agent — 1 prepare-time anomaly (gh pr checks unreadable — CI state is informational only, so this never grades)
Checked 20 of 20 changed files read · 6 possible issues → 1 confirmed → 1 posted
Reach 13 changed exports · 9 dependent files · 9 checked · 2 changed files imported by 24 files, not traced
MemoryFilters — 1 consumer file · 1 checked
MemoryPage — 1 consumer file · 1 checked
useMemories — 3 consumer files · 3 checked
PENDING_INVITES_QUERY_KEY — 1 consumer file · 1 checked
getAppRoute — 2 consumer files · 2 checked
+8 more
packages/web/src/mocks/memories.ts — imported by 23 files
packages/web/src/components/lore/LoreExplorer.tsx — imported by 1 file
Found produced 6 → posted inline 1 · cleared 1 · carried forward 0 · deferred 0 · below-bar 1
Dropped 1 below-bar
Severity ⚪ 1 low
CI no checks on 659361a (the baselines commit was pushed with GITHUB_TOKEN, which re-triggers nothing); at 3201be9: red Web — Storybook interaction + visual tests (changed), CI Summary · 5 passed
Memories 77 indexed · 5 used (2 knowledge · 3 hotspots)

Memories used

  • knowledge knowledge::getAppRoute@packages/web/src/lib/app-route.ts — finder pointer (re-verified at 659361a: code unchanged since 3201be9; consumer-default fact cleared the Storybook candidate)
  • knowledge knowledge::useMemories@packages/web/src/lib/queries/lore.ts — finder pointer (re-verified at 659361a: retry and scope-guard facts hold; led to the retry trace)
  • hotspot hotspot::packages/web/src/components/lore/LoreExplorer.tsx — quality + correctness checklist line (test-gap, edge-case) — led to the Load-more test-gap re-check
  • hotspot hotspot::packages/web/src/lib/queries/lore.ts — correctness checklist line (error-path) — led to the list retry-policy trace
  • hotspot hotspot::packages/web/CLAUDE.md — quality checklist line (missing-update) — led to the backlog-list cross-check

Nothing to report — standards (2 docs), measurability (11 paths classified), integrations (not activated), 0 files skipped.

pr-reviewer · commit 659361a · full review · how these findings are produced · updated Oct 21, 2026 7:40am UTC

Comment thread packages/web/src/lib/queries/lore.ts
Comment thread packages/web/CLAUDE.md
mthines and others added 3 commits October 20, 2026 19:06
Addresses the pr-reviewer comment: #712 (comment)

Refs: #712
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
…b CLAUDE.md

Addresses the pr-reviewer comment: #712 (comment)

Refs: #712
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
…appended

Addresses the pr-reviewer report finding: #712 (comment)

Refs: #712
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Comment thread packages/web/src/components/lore/LoreExplorer.tsx Outdated
…ed page landed

Addresses the pr-reviewer comment: #712 (comment)

Refs: #712
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
@mthines

mthines commented Oct 21, 2026

Copy link
Copy Markdown
Owner

/update-baselines

Regenerated by the Update visual baselines workflow for PR #712.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Update visual baselines: committed 44 updated baseline(s) as 659361aba81039f5f94191c4fbf34e6a282082ea.

ℹ️ This commit was pushed with GITHUB_TOKEN, so web-test will not re-run automatically — push a follow-up commit if you need CI to re-verify.

The Update visual baselines workflow pushed 659361a with GITHUB_TOKEN, which does not trigger CI. This empty commit runs ci.yml on the new baselines.

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
@dash0-dev
dash0-dev Bot marked this pull request as ready for review October 21, 2026 07:35
@dash0-dev
dash0-dev Bot requested a review from mthines as a code owner October 21, 2026 07:35
@dash0-dev
dash0-dev Bot merged commit ec35d81 into main Oct 21, 2026
19 checks passed
* 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (low): ⚪ Plan usage missing from the backlog list it cites (non-blocking)

This says packages/web/CLAUDE.md lists plan usage as backlog, but that list (line 82) names only orgs, invites, tokens and audit log, so /api/plan/usage's direct RPC has no exemption.

Evidence: packages/web/CLAUDE.md:82 (backlog list) · docs/decisions.md:863 (same claim)

  (sign-in, session, password), and the server actions covering surfaces the REST
  API does not expose yet (orgs, invites, plan usage, tokens, audit log). Those server actions

Fix with Agent0

pr-reviewer · commit 659361a

dash0-dev Bot pushed a commit that referenced this pull request Oct 21, 2026
#712 (this PR's base) was squash-merged as ec35d81, and #711 landed beside
it. Resolved as main plus this PR's own delta (8f08fd3..01cf83a) replayed
on top, so the diff against main is exactly the memory.quota change.
dash0-dev Bot added a commit that referenced this pull request Oct 21, 2026
…is backlog

Addresses the pr-reviewer comment: #712 (comment)

Refs: #713
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
dash0-dev Bot added a commit that referenced this pull request Oct 21, 2026
… and the CLI (#713)

* perf(web): run the /lore list, plan-usage and invites reads in parallel

The three reads were Next.js server actions, which the app router runs one
at a time per client and which cannot take an AbortSignal. On /lore they
queued (plan usage -> pending invites -> memory list) on every load and
every window focus, so the TopBar activity bar stayed on long after the
visible panels had loaded.

- useMemories calls POST /memories/list from the browser with React Query's
  signal (body building moved unchanged into the pure lib/memory-list.ts)
- useMemoryTotal and usePendingInvitesForMe read new GET route handlers
  (/api/plan/usage, /api/invites/pending) that wrap the existing server
  functions

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>

* test(web): pin parallel, abortable /lore reads; document the no-server-action-reads rule

- parallel-reads.spec.ts: useMemories, useMemoryTotal and
  usePendingInvitesForMe issue ordinary requests with React Query's signal
  and never call a server action (the action modules are mocked to throw)
- read-routes.spec.ts: the two GET route handlers keep the wrapped
  functions' contract (null / [] when signed out) and are never cached
- memory-list.spec.ts pins the POST /memories/list body
- packages/web/CLAUDE.md, docs/decisions.md, CLAUDE.md: a client read is
  never a server action; docs/key-files.md and docs/storybook.md updated
- LorePage story now renders the populated list (baseline needs refresh)

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>

* feat: memory.quota — your memories against your plan cap on REST, MCP and the CLI

GET /memories/quota, the memory.quota MCP tool and `lorekit quota` all answer
{ plan, personal: { count, limit, remaining }, org_count, total } from the
actor-guarded lorekit_memory_count RPC. personal is the pair the cap trigger
enforces; org-owned memories count against each org's own cap, so they sit
beside it instead of being subtracted (the RPC's combined count measured
against the personal limit made 'remaining' wrong for both).

The dashboard reads it over REST too: the TopBar badge (total) from the
browser with React Query's signal, Settings > Plan (personal pair) on the
server. The interim /api/plan/usage route and the direct-RPC getPlanUsage
server action are removed.

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>

* test: pin memory.quota in the permission, local-MCP and usage-stats guards; cover lorekit quota

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>

* address review comment: badge comment no longer claims it matches the plan page

Addresses the pr-reviewer comment: #713 (comment)

Refs: #713
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>

* address review comment: list quota in the MCP, CLI and route references

Addresses the pr-reviewer comment: #713 (comment)

Refs: #713
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>

* address review comment: log a failed quota read on the plan page

Addresses the pr-reviewer comment: #713 (comment)

Refs: #713
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>

* address review comment: app-route header no longer claims plan usage is backlog

Addresses the pr-reviewer comment: #712 (comment)

Refs: #713
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>

* chore: simplify pass (mechanical refactors)

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>

* fix(quota): narrow org_count and total to the key's reachable orgs

lorekit_memory_count counts every org the token owner belongs to, so a
personal-only or selected-orgs key could read how many memories its
owner's other orgs hold. GET /memories/quota and memory.quota now pass
the calling key to quotaForKey, which recounts org_count over
effectiveOrgIds (0 under personal, only org_ids under selected) and
recomputes total. The personal pair is unchanged: the cap is
account-wide.

Refs: #713 (comment)
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>

* docs(quota): say what a tenancy-narrowed token sees from memory.quota

docs/api-tokens.md gains a quota row in "What a scoped token sees". The
llms.txt template no longer claims every operation without a scope is
refused for a scoped token: only the account-wide sweeps are, and quota
is answered with its org half narrowed. The catalog `returns`, the
response schema, and the MCP, CLI and limits docs say the same; llms.txt
and the edge schema mirror are regenerated.

Refs: #713 (comment)
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>

* address review comment: OpenAPI quota description names the token narrowing

Addresses the pr-reviewer comment: #713 (comment)

Refs: #713
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>

* chore: simplify pass (mechanical refactors)

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: mads.thines <[email protected]>
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant