You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit ea4577e
Browse filesBrowse the repository at this point in the historyBrowse files
perf(prefetch): finish the migration, delete the legacy helper, ratchet page graphs
Answers the question the previous commit left open: the tables list did not have
to stay on HTTP. lib/table/service reached the executor through
jobs/service -> rows/service -> workflow-columns, for one symbol.
pendingDeleteMask is a delete-visibility SQL clause with no executor
involvement, so it moves to its own leaf and that chain is cut. The tables
prefetch now reads the data layer like every other one, and
prefetch-internal-fetch.ts is deleted: nothing in the app calls its own API over
HTTP during a server render any more.
stripGroupDeps likewise moves to a leaf rather than being re-exported through
workflow-columns, so its importers no longer pull the executor to get a pure
projection.
React Query mechanism fixes, all found by audit:
- settings/[section] fired two prefetches without awaiting them. Only a settled
query is dehydrated, so those were shipped mid-flight; a rejection hydrated
into an error state retryOnMount: false never retries, leaving the panel
broken for the session. Awaited now, and the pending-dehydration opt-in is
removed since nothing streams.
- The viewer profile was prefetched by both the layout and the settings page.
Separate server QueryClients mean that was a real second read per request.
- prefetchSubscriptionData was dead, and hand-rolled an unannotated raw fetch.
- retry is scoped to the browser. Query core defaults it to 0 on the server;
stating one value for both opted awaited prefetches into a retry backoff. The
gcTime default is dropped entirely — 5 minutes is already the browser default,
and setting it explicitly overrode the server's Infinity, leaving a live timer
and payload per request.
check:tool-registry-boundary now also ratchets per-page module counts against a
committed baseline, attributing a regression to the import that caused it via a
dominator tree. It caught a +444 regression in this branch by hand; it would
have caught it in CI. Its import regex also missed bare side-effect imports,
so `import '@/tools/registry'` could have slipped past it entirely.
Prefetch guidance added to .claude/rules/sim-queries.md.
Copy file name to clipboardExpand all lines: .agents/skills/tool-registry-boundary/SKILL.md
+4Lines changed: 4 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -68,6 +68,10 @@ If it fails, do not add the entry to an allowlist — there isn't one. Find the
68
68
69
69
Run it with `--verbose` to print per-route module counts, which is also the quickest way to see whether a change moved the graph.
70
70
71
+
The same command also ratchets those counts. `scripts/check-tool-registry-boundary.baseline.json` records each entry's module count plus its heaviest "gateway" modules (how many modules reach the graph *only* through each one). `--check` — what CI runs — fails when an entry exceeds its baseline by more than `max(25 modules, 2%)`, and names the gateway whose weight grew along with the import chain that reaches it. That catches the graph bloat the registry rule misses: the `listTables` prefetch that cost the Tables page 444 modules never touched `@/tools/registry`.
72
+
73
+
When the growth is deliberate (a real new feature on the page), re-record it with `--update-baseline` and commit the JSON. When an entry *shrinks*, the check says so and passes — re-record then too, or the win is silently spendable again.
74
+
71
75
## How to verify an edge actually got cut
72
76
73
77
Do not eyeball imports — the registry is reached through several redundant paths, so cutting one buys nothing while another survives. Walk the graph:
Copy file name to clipboardExpand all lines: .claude/commands/tool-registry-boundary.md
+4Lines changed: 4 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -67,6 +67,10 @@ If it fails, do not add the entry to an allowlist — there isn't one. Find the
67
67
68
68
Run it with `--verbose` to print per-route module counts, which is also the quickest way to see whether a change moved the graph.
69
69
70
+
The same command also ratchets those counts. `scripts/check-tool-registry-boundary.baseline.json` records each entry's module count plus its heaviest "gateway" modules (how many modules reach the graph *only* through each one). `--check` — what CI runs — fails when an entry exceeds its baseline by more than `max(25 modules, 2%)`, and names the gateway whose weight grew along with the import chain that reaches it. That catches the graph bloat the registry rule misses: the `listTables` prefetch that cost the Tables page 444 modules never touched `@/tools/registry`.
71
+
72
+
When the growth is deliberate (a real new feature on the page), re-record it with `--update-baseline` and commit the JSON. When an entry *shrinks*, the check says so and passes — re-record then too, or the win is silently spendable again.
73
+
70
74
## How to verify an edge actually got cut
71
75
72
76
Do not eyeball imports — the registry is reached through several redundant paths, so cutting one buys nothing while another survives. Walk the graph:
A server prefetch fills the *same* cache key a client hook fills, so it must be indistinguishable from a client fetch. Five rules:
149
+
150
+
1.**Read the data layer, never our own API over HTTP.** A server-to-server call to `/api/...` costs a round trip and a second authentication for data the process can already read. Where the route runs an application use case, call that same use case with a principal from the same auth policy the route declares — not a manager underneath it.
151
+
2.**Match the wire shape the hook caches.** The hook's data is whatever `requestJson(contract, …)` produced, so the seed must equal it. Two traps: a contract field declared `z.coerce.date()` means the hook holds a `Date` where raw route JSON holds a string; a passthrough response schema (`z.custom`) means the hook caches route JSON *verbatim*, so seeding raw rows leaks `Date`s and server-only fields. When the route projects before responding, share that projection — have the route and the prefetch call one function.
152
+
3.**Prove the viewer.** Data-layer reads carry no authorization; the route used to provide it. Resolve the viewer (`getWorkspaceHostContextForViewer`, already `cache`d by the layout so it costs nothing) and return early on failure, caching nothing — the client fetch then reaches the route for the real 403. Never widen what a viewer can see.
153
+
4.**Always `await`.** Only a settled query is dehydrated, so an unawaited prefetch is silently dropped from the payload and the pane waterfalls anyway.
154
+
5.**Don't repeat what the layout already seeded.**`getQueryClient()` builds a new client per server call, so a page re-seeding a layout key is a genuine second read — and `HydrationBoundary` defers an already-seen query to an effect, which SSR never runs, so it never reaches the server render either.
155
+
156
+
Reuse the hook's exported `staleTime` constant and its key factory; `dehydrate` carries neither options nor `staleTime`, and freshness is per-observer.
157
+
158
+
Seed with `setQueryData` only when the prefetch must be able to *decline* to create an entry (an empty list that has to fall through to a route's creation path). `prefetchQuery` and `ensureQueryData` always create one.
159
+
160
+
Keep prefetch imports light. A page prefetch's imports land in that route's server graph, so pulling a barrel to reach one function can drag thousands of modules behind it — `bun run check:tool-registry-boundary` gates this per page.
161
+
146
162
## Boundary Types
147
163
148
164
- Hooks import named type aliases from `@/lib/api/contracts/**` (e.g., `import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities'`). Never write `z.input<...>` / `z.output<...>` in hooks, and never `import { z } from 'zod'` in client code.
Copy file name to clipboardExpand all lines: .cursor/commands/tool-registry-boundary.md
+4Lines changed: 4 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -63,6 +63,10 @@ If it fails, do not add the entry to an allowlist — there isn't one. Find the
63
63
64
64
Run it with `--verbose` to print per-route module counts, which is also the quickest way to see whether a change moved the graph.
65
65
66
+
The same command also ratchets those counts. `scripts/check-tool-registry-boundary.baseline.json` records each entry's module count plus its heaviest "gateway" modules (how many modules reach the graph *only* through each one). `--check` — what CI runs — fails when an entry exceeds its baseline by more than `max(25 modules, 2%)`, and names the gateway whose weight grew along with the import chain that reaches it. That catches the graph bloat the registry rule misses: the `listTables` prefetch that cost the Tables page 444 modules never touched `@/tools/registry`.
67
+
68
+
When the growth is deliberate (a real new feature on the page), re-record it with `--update-baseline` and commit the JSON. When an entry *shrinks*, the check says so and passes — re-record then too, or the win is silently spendable again.
69
+
66
70
## How to verify an edge actually got cut
67
71
68
72
Do not eyeball imports — the registry is reached through several redundant paths, so cutting one buys nothing while another survives. Walk the graph:
0 commit comments