Realm-server: make _federated-search JSON:API-compliant via include#4667
Realm-server: make _federated-search JSON:API-compliant via include#4667
Conversation
Accept `include` in the request body. When absent, behavior is unchanged (loadLinks: true) so all current callers keep working. When `include: []` is sent explicitly, the server returns `data[]` only and skips loadLinks entirely; when `include: ["fieldA", ...]` is sent, only the named relationships are side-loaded (uses the existing `linkFields` seam in realm-index-query-engine). Cards-grid in-prerender fallback (renderContextSearchResource in prerendered-card-search.gts) and the boxel-cli `search` command now opt out via `include: []`. The cards-grid path is the primary perf win — 87% of staging render-timeouts trace to its N×M sequential link fan-out — and boxel-cli (plus all software-factory callers that go through it) only ever reads `result.data`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ebab785bae
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The host realm-server mock at tests/helpers/realm-server-mock/routes.ts
still parsed the entire `_federated-search` payload through
`parseSearchQueryFromPayload`, which goes through `assertQuery` and
rejects any unknown key. Without this fix, host integration tests that
send `{ include: [] }` would 400 with `unknown field in query: include`
instead of exercising the new behavior.
Switch to `parseSearchRequestFromPayload` (matching the real handler)
and forward `{ include }` into `searchRealms`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds JSON:API-style include semantics to the realm-server _federated-search endpoint (and host/CLI callers) to control relationship side-loading, enabling a high-impact performance opt-out for prerendered cards-grid searches while preserving existing default behavior.
Changes:
- Introduces parsing/propagation of a top-level
includearray in_federated-searchrequest bodies, with tri-state behavior (absent vs[]vs named relationships). - Updates
Realm.search()andsearchRealms()plumbing to translateincludeinto link-loading options (loadLinks/linkFields). - Updates host + CLI call sites and adds integration tests covering default behavior, opt-out, selective includes, and malformed input.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/runtime-common/search-utils.ts | Adds SearchRequest parsing (include) and threads include options through searchRealms(). |
| packages/runtime-common/realm.ts | Implements tri-state include behavior by mapping to loadLinks/linkFields for searchCards(). |
| packages/realm-server/handlers/handle-search.ts | Switches endpoint parsing to parseSearchRequest* and forwards include into realm searches. |
| packages/realm-server/tests/server-endpoints/search-test.ts | Adds fixtures + tests for default side-loading, include: [], selective include, and malformed include. |
| packages/host/app/services/store.ts | Forwards optional include into _federated-search request body (omits key when unspecified). |
| packages/host/app/resources/search.ts | Exposes include as a resource arg and includes it in change-detection + search requests. |
| packages/host/app/components/prerendered-card-search.gts | Opts prerendered cards-grid searches into include: [] to skip link side-loading. |
| packages/host/tests/integration/store-test.gts | Tests that host requests include include: [] when specified and omit the key otherwise. |
| packages/boxel-cli/src/commands/search.ts | Defaults CLI search requests to include: [] to avoid side-loading overhead. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
`parseSearchQueryFromPayload` is now only used internally by `parseSearchRequestFromPayload` (post-include-strip), and `parseSearchQueryFromRequest` has no remaining callers across the monorepo. The exports were a forward-misuse footgun: any external caller that passed a full request payload (now containing `include`) to `parseSearchQueryFromPayload` would get `unknown field in query: include` because `assertQuery` whitelists keys. Drop the dead `parseSearchQueryFromRequest` entirely and make `parseSearchQueryFromPayload` module-private. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Preview deploymentsHost Test Results 1 files ±0 1 suites ±0 1h 57m 36s ⏱️ - 4m 3s Results for commit 62aaaeb. ± Comparison against earlier commit b940f4d. Realm Server Test Results 1 files ± 0 1 suites ±0 16m 27s ⏱️ +11s Results for commit 62aaaeb. ± Comparison against earlier commit b940f4d. |
|
packages/boxel-cli/src/commands/search.ts When the CLI searches cards that have linked relationships, defaulting every request to include: [] changes the returned data[] shape even though SearchResult only exposes data: the server's link-loading path does not just populate top-level included, it also mutates each root resource's relationships to add relationship.data identifiers after resolving links.self. With this default, CLI/API clients that read linked card ids from result.data[*].relationships now get only relationship links unless they know to opt back in by passing an include key inside the query. |
|
Thanks for forwarding this — checked it against the actual SF/CLI consumers and it's a defensive guard for non-reachable state, not a real reachable bug. Codex's technical claim is accurate: But every current consumer of CLI search results that reads
A future caller that does let body: Record<string, unknown> = {
realms,
include: [],
...query, // ← caller-set include wins
};So the contract is: by default the CLI does the cheap thing; callers who need link resolution can opt in. Worth a JSDoc comment on the |
|
Heads-up from CI investigation on the stack (#4668 → #4670): the
All three fail with Confirmed pre-existing: same exact tests fail on cs-11038's This blocks #4668 → main, which in turn blocks #4670. The Codex
Posting here per Hassan's request so the context isn't lost when #4667 lands first. CC @habdelra. |
|
Got it — those two Confirmed scope-isolation for #4667:
So the cycle-back-to-root regression you're tracking lives entirely in #4668's BFS, and #4667 landing first doesn't affect that investigation. I'll watch the monitor for any in-PR feedback on this code path here. |
Single-run measurement on b940f4d showed Acceptance | Commands tests at 66.8 MB (+50.4 MB over baseline, 0.4 MB over the +50MB hard threshold). Sibling PRs on the same baseline measured ~50.5 MB on this same module on their first run. Triggering a fresh measurement to confirm whether this PR consistently regresses or whether the original was a single-run variance hit on a noisy module. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Pause on merging this until we have a deeper discussion around how to handle links for searches during indexing time |
Why
Closes CS-11037.
Two problems with
_federated-searchtoday:data[]only.live-prerendered-search.ts) only readsinstance.idandinstance.constructor; it never traverses relationships, but the server still resolves N×M sequential link queries per search.What changes
_federated-searchaccepts anincludefield in the request body alongside the existing query fields:{ "filter": { ... }, "sort": [ ... ], "realms": [ ... ], "include": [], // NEW: JSON:API include semantics "fields": { ... } // already supported }Tri-state behavior at
Realm.search→searchCards:includevalueloadLinks: true. Preserves every existing caller.[]data[]only. Noincluded[].loadLinksis skipped entirely.["fieldA", "fieldB"]linkFieldsseam inrealm-index-query-engine.ts. Essentially free given the seam already exists.Why
includelives outsideQueryassertQuerywhitelists keys and rejects unknown ones. AddingincludetoQuerywould force updatingassertQuery+normalizeQueryForSignatureand conflate query semantics with response-shape semantics. Instead, a newparseSearchRequestFromPayload(payload): { query, include }extractsincludefirst, then runsassertQueryon the rest — mirroring howparsePrerenderedSearchRequestFromPayloadalready extractsprerenderedHtmlFormat/cardUrls/renderTypebefore delegating.includeis a JSON:API document concern, not a query filter concern.Wire path
Client → realm-server middleware (parses payload once, stashes in ctxt state) →
handle-search.ts(extracts{ query, include }) →searchRealms(realms, query, { include })→Realm.search(query, { include })→searchCards(query, searchOpts). The gating decision (whether to skiploadLinks) lives at theRealm.search→searchCardsboundary — the smallest, lowest-risk seam.Host side:
getSearch(... { include })→SearchResource.modify(also foldsincludeinto the previous-args comparison so re-renders with a differentincludere-fetch correctly) →runtimeStore.search→ both private fetch helpers conditionally serializeincludeinto the body.Opt-out sites in this PR
Two callers explicitly send
include: []:prerendered-card-search.gts:213-227(renderContextSearchResource)live-prerendered-search.ts:104,172,210) only readsinstance.id+instance.constructor. This is the primary perf win — 87% of staging render-timeouts trace here.searchcommandboxel-cli/src/commands/search.ts:48-58result.datato its callers. Spread order preserves any caller-setincludeif a future caller wants links.By transitive consequence, every software-factory caller that goes through
BoxelCliClient.searchalso getsinclude: []for free:scripts/boxel-search.ts:159issue-scheduler.ts:214,231,313data/data.lengthinstantiate-execution.ts:598test-run-execution.ts:107factory-tool-builder.ts:354Audit of remaining
_federated-searchcallersCaller-by-caller audit confirms the only production callers of the endpoint are the host store (
fetchAndHydrateSearchResults/fetchSearchData) and the boxel-clisearchcommand. The realm-server's per-realmsearchResponseHTTP handler (realm.ts:3670) is not federated and stays untouched per the ticket's "Out of scope".Host call sites that go through
StoreService.search/getSearchResourceare explicitly deferred to a follow-up (each needs per-template auditing before opting out):commands/search-cards.ts:81— returns instances to a command consumercommands/create-specs.ts:184— existence check + opaque returncomponents/card-catalog/modal.gts:379— catalog listcomponents/operator-mode/detail-panel.gts:457— detail panel side inforesources/search.ts:405(genericSearchResource) — many consumers viagetCards/getSearchResourceresources/search-data.ts:169— many consumers viagetSearchDataCompatibility
includeabsent → unchanged behavior. Every existing client (host store on the un-migrated paths, third-party callers, internal tooling) continues to receive fully-hydrated responses. Flipping the JSON:API-spec default (no side-load whenincludeis absent) is explicitly deferred to a follow-up after all callers migrate — keeps each PR small and reviewable.Companion work: browser-cacheable linked-card fetches (CS-11010)
Once a caller opts out of side-loading via
include: [](orinclude: ["someField"]for a partial set), any other relationships it later needs have to be fetched individually asGET /<card>withAccept: application/vnd.card+json. Today every one of those requests is served withcache-control: no-store, no-cache, must-revalidateand a fresh full-body response, so a page that does N follow-up link fetches pays the server's full JSON-assembly cost on every page view — each repeat visit re-runs the same DB reads + serialization.CS-11010 (in progress, sibling ticket) makes
vnd.card+jsonETag-aware on published realms — the server emits an ETag based on the realm'slast_published_atand short-circuits to a 304 before the JSON-assembly work runs, mirroring what the host-shell HTML route already does (packages/realm-server/server.ts:444-462, landed in2abf744a45). With both PRs in, the cards-grid prerender path becomes:_federated-searchreturnsdata[]only (no included resources, no link-loading work) — this PR's win.So the cost shifted off
_federated-searchhere doesn't just reappear as N individual unconditional GETs; CS-11010 ensures those GETs short-circuit on a warm cache.Out of scope
includeis absent."loadLinksperformance improvements where the side-load is still needed — sibling tickets CS-11038 / CS-11039._federated-search-prerendered— already correct (noloadLinkscall insearchPrerendered).Test plan
tests/server-endpoints/search-test.ts. Fixtures extended with aFriend.linksTo(Friend)card (Alice → Bob) so the assertions are non-trivial. Cases:includeabsent → response hasincluded[]populated (today's behavior preserved)include: []→ response has noincluded[]; relationship metadata still present indatafor client-side traversalinclude: ["friend"]→ only the named relationship is side-loadedinclude: "friend"(malformed string) → 400invalid-querystore-test.gtsmounting a virtualNetwork handler to capture_federated-searchrequest bodies:runtimeStore.search(query, realms, { include: [] })→ captured body hasinclude: []runtimeStore.search(query, realms)(no opts) → captured body has noincludekey, so server-side default behavior is preservedpnpm lintclean per-package on every package edited (runtime-common, realm-server, host, boxel-cli).🤖 Generated with Claude Code