From b2f4a0592b8dac07672d9b42f2e5f414e01e60e1 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 18 May 2026 18:02:43 -0400 Subject: [PATCH] realm-server: extend job-scoped search cache to _federated-search-prerendered Wraps `searchPrerenderedRealms` in the `_federated-search-prerendered` handler with the same `JobScopedSearchCache` instance already used by `_federated-search`, gated on the same `x-boxel-job-id` + `x-boxel-consuming-realm` indexer-traffic headers. User-facing API callers continue to bypass the cache and observe live state. The prerendered handler's request shape carries htmlFormat / cardUrls / renderType which materially change the response body. These are passed through `opts` so the cache's sortKeysDeep-canonicalised inner key segregates entries that differ on any of them; the cache class stays signature-stable (`getOrPopulate(jobId, realms, query, opts, populate)`), and stores results opaquely so both endpoints' document types share the same store. Host: PrerenderedSearchResource stamps the consuming-realm and job-id headers on its outbound `_federated-search-prerendered` request, the same pattern store.ts uses for `_federated-search`. The three header helpers (during-prerender / consuming-realm / job-id) move from store.ts to a shared `lib/prerender-fetch-headers.ts` module so both fetch sites read the globals identically. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../host/app/lib/prerender-fetch-headers.ts | 46 +++ .../host/app/resources/prerendered-search.ts | 13 + packages/host/app/services/store.ts | 46 +-- .../handlers/handle-search-prerendered.ts | 73 +++- .../realm-server/job-scoped-search-cache.ts | 32 +- packages/realm-server/routes.ts | 2 +- .../search-prerendered-test.ts | 321 ++++++++++++++++++ 7 files changed, 469 insertions(+), 64 deletions(-) create mode 100644 packages/host/app/lib/prerender-fetch-headers.ts diff --git a/packages/host/app/lib/prerender-fetch-headers.ts b/packages/host/app/lib/prerender-fetch-headers.ts new file mode 100644 index 00000000000..4f5d800a2d3 --- /dev/null +++ b/packages/host/app/lib/prerender-fetch-headers.ts @@ -0,0 +1,46 @@ +import { + DURING_PRERENDER_HEADER, + X_BOXEL_CONSUMING_REALM_HEADER, + X_BOXEL_JOB_ID_HEADER, +} from '@cardstack/runtime-common'; + +// Set by the prerender server's `evaluateOnNewDocument` before the +// SPA boots — `__boxelDuringPrerender = true`. Read here so the +// realm-server fetch wrappers can attach the marker header on +// search calls only, narrowly scoping the signal to the endpoints +// that need it. See realm.ts:DURING_PRERENDER_HEADER for the full +// chain. +export function duringPrerenderHeaders(): Record { + let flag = (globalThis as unknown as { __boxelDuringPrerender?: boolean }) + .__boxelDuringPrerender; + return flag ? { [DURING_PRERENDER_HEADER]: '1' } : {}; +} + +// While rendering inside a prerender tab the render route writes +// `__boxelConsumingRealm` with the URL of the realm whose card is +// being rendered. Attach it to outbound search requests so the +// realm-server's job-scoped cache layer can gate caching on the +// indexer-traffic shape. Read each fetch (not cached at module +// scope) so a tab that renders cards from multiple realms in +// sequence sends the correct header per request. Returns an empty +// object when the global is not set so non-prerender (live SPA) +// fetches behave exactly as before. +export function consumingRealmHeader(): Record { + let r = (globalThis as unknown as { __boxelConsumingRealm?: string }) + .__boxelConsumingRealm; + return r ? { [X_BOXEL_CONSUMING_REALM_HEADER]: r } : {}; +} + +// Companion to `consumingRealmHeader()`. The prerender server's +// `prerenderVisitAttempt` injects `__boxelJobId` onto the page +// before transitioning into the render route — see +// `packages/realm-server/prerender/render-runner.ts`. Read it on +// each fetch (not module-scope-cached) so a page reused across +// multiple visits picks up the current visit's job id. Outside a +// prerender tab the global is undefined and we send no header, so +// user / API callers continue to bypass the realm-server's +// job-scoped cache. +export function jobIdHeader(): Record { + let j = (globalThis as unknown as { __boxelJobId?: string }).__boxelJobId; + return j ? { [X_BOXEL_JOB_ID_HEADER]: j } : {}; +} diff --git a/packages/host/app/resources/prerendered-search.ts b/packages/host/app/resources/prerendered-search.ts index a631ab7dc29..b184eea602f 100644 --- a/packages/host/app/resources/prerendered-search.ts +++ b/packages/host/app/resources/prerendered-search.ts @@ -29,6 +29,11 @@ import { isPrerenderedCardCollectionDocument } from '@cardstack/runtime-common/d import type { RealmEventContent } from 'https://cardstack.com/base/matrix-event'; import { PrerenderedCard } from '../components/prerendered-card-search'; +import { + consumingRealmHeader, + duringPrerenderHeaders, + jobIdHeader, +} from '../lib/prerender-fetch-headers'; import { normalizeRealms, resolveCardRealmUrl } from '../lib/realm-utils'; import type LoaderService from '../services/loader-service'; @@ -274,6 +279,14 @@ export class PrerenderedSearchResource extends Resource { headers: { Accept: SupportedMimeType.CardJson, 'Content-Type': 'application/json', + // Plumb the prerender-context headers so a worker-driven + // render hits the realm-server's job-scoped search cache + // (the same instance shared with `_federated-search`). + // Live (non-prerender) SPA fetches send no values for any of + // these globals and continue to bypass the cache. + ...duringPrerenderHeaders(), + ...consumingRealmHeader(), + ...jobIdHeader(), }, body: JSON.stringify({ ...query, diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 38b2ca893c1..820ee42c47f 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -23,7 +23,6 @@ import { baseFileRef, CardError, cardIdToURL, - DURING_PRERENDER_HEADER, isRegisteredPrefix, hasExecutableExtension, isCardError, @@ -33,8 +32,6 @@ import { isSingleCardDocument, isLinkableCollectionDocument, resolveFileDefCodeRef, - X_BOXEL_CONSUMING_REALM_HEADER, - X_BOXEL_JOB_ID_HEADER, Deferred, delay, mergeRelationships, @@ -86,6 +83,11 @@ import type { RealmEventContent } from 'https://cardstack.com/base/matrix-event' import CardStore, { getDeps, type ReferenceCount } from '../lib/gc-card-store'; +import { + consumingRealmHeader, + duringPrerenderHeaders, + jobIdHeader, +} from '../lib/prerender-fetch-headers'; import { errorJsonApiToErrorEntry } from '../lib/window-error-handler'; import { getSearch } from '../resources/search'; import { @@ -121,44 +123,6 @@ let waiter = buildWaiter('store-service'); const realmEventsLogger = logger('realm:events'); const storeLogger = logger('store'); -// Set by the prerender server's `evaluateOnNewDocument` before the -// SPA boots — `__boxelDuringPrerender = true`. Read here so the -// federated-search fetch wrapper can attach the marker header on -// realm-server-bound calls only, narrowly scoping the signal to the -// endpoint that needs it. See realm.ts:DURING_PRERENDER_HEADER for -// the full chain. -function duringPrerenderHeaders(): Record { - let flag = (globalThis as unknown as { __boxelDuringPrerender?: boolean }) - .__boxelDuringPrerender; - return flag ? { [DURING_PRERENDER_HEADER]: '1' } : {}; -} - -// While rendering inside a prerender tab the render route writes -// `__boxelConsumingRealm` with the URL of the realm whose card is being -// rendered. Attach it to outbound `_federated-search` requests so the -// realm-server's job-scoped cache layer can gate same-realm-only -// caching. Read each fetch (not cached at module scope) so a tab that -// renders cards from multiple realms in sequence sends the correct -// header per request. Returns an empty object when the global is not -// set so non-prerender (live SPA) fetches behave exactly as before. -function consumingRealmHeader(): Record { - let r = (globalThis as unknown as { __boxelConsumingRealm?: string }) - .__boxelConsumingRealm; - return r ? { [X_BOXEL_CONSUMING_REALM_HEADER]: r } : {}; -} - -// Companion to `consumingRealmHeader()`. The prerender server's -// `prerenderVisitAttempt` injects `__boxelJobId` onto the page before -// transitioning into the render route — see -// `packages/realm-server/prerender/render-runner.ts`. Read it on each -// fetch (not module-scope-cached) so a page reused across multiple -// visits picks up the current visit's job id. Outside a prerender -// tab the global is undefined and we send no header, so user / API -// callers continue to bypass the realm-server's job-scoped cache. -function jobIdHeader(): Record { - let j = (globalThis as unknown as { __boxelJobId?: string }).__boxelJobId; - return j ? { [X_BOXEL_JOB_ID_HEADER]: j } : {}; -} const queryFieldSeedFromSearchSymbol = Symbol.for( 'cardstack-query-field-seed-from-search', ); diff --git a/packages/realm-server/handlers/handle-search-prerendered.ts b/packages/realm-server/handlers/handle-search-prerendered.ts index aa7baf15c8d..9c7eb1eb3d4 100644 --- a/packages/realm-server/handlers/handle-search-prerendered.ts +++ b/packages/realm-server/handlers/handle-search-prerendered.ts @@ -2,8 +2,10 @@ import type Koa from 'koa'; import { buildSearchErrorResponse, SupportedMimeType, + X_BOXEL_CONSUMING_REALM_HEADER, parsePrerenderedSearchRequestFromPayload, parsePrerenderedSearchRequestFromRequest, + sanitizeConsumingRealmHeader, SearchRequestError, searchPrerenderedRealms, } from '@cardstack/runtime-common'; @@ -16,10 +18,16 @@ import { getMultiRealmAuthorization, getSearchRequestPayload, } from '../middleware/multi-realm-authorization'; +import type { JobScopedSearchCache } from '../job-scoped-search-cache'; +import { + PRERENDER_JOB_ID_HEADER, + sanitizePrerenderJobId, +} from '../prerender/prerender-constants'; -export default function handleSearchPrerendered(): ( - ctxt: Koa.Context, -) => Promise { +export default function handleSearchPrerendered(opts?: { + searchCache?: JobScopedSearchCache; +}): (ctxt: Koa.Context) => Promise { + let searchCache = opts?.searchCache; return async function (ctxt: Koa.Context) { let { realmList, realmByURL } = getMultiRealmAuthorization(ctxt); @@ -43,15 +51,56 @@ export default function handleSearchPrerendered(): ( throw e; } - let combined = await searchPrerenderedRealms( - realmList.map((realmURL) => realmByURL.get(realmURL)), - parsed.cardsQuery, - { - htmlFormat: parsed.htmlFormat, - cardUrls: parsed.cardUrls, - renderType: parsed.renderType, - }, - ); + let searchOpts = { + htmlFormat: parsed.htmlFormat, + cardUrls: parsed.cardUrls, + renderType: parsed.renderType, + }; + let runSearch = () => + searchPrerenderedRealms( + realmList.map((realmURL) => realmByURL.get(realmURL)), + parsed.cardsQuery, + searchOpts, + ); + + // Symmetric to `_federated-search`'s gating. Cache is consulted + // only when both indexer-traffic headers are present and well- + // formed: + // (a) `x-boxel-job-id` — only the indexer worker stamps this, + // (b) `x-boxel-consuming-realm` — the host's render route only + // sets it during prerender. + // User-facing API callers never carry both, so they always + // bypass the cache and observe live SQL state. + // + // The prerendered handler's request shape carries + // `htmlFormat` / `cardUrls` / `renderType` which materially + // change the response body. These are passed through `opts` so + // the cache's `sortKeysDeep`-canonicalised inner key segregates + // entries that differ on any of them. `cardsQuery` is whatever + // remains after those three keys are stripped by + // `parsePrerenderedSearchRequest…` — so the cache key reflects + // the full request shape, not just the query body. + // + // `multiRealmAuthorization` has already validated read access + // for every entry of `realmList`, so the cache cannot surface + // results across an authorization boundary. + let jobId = searchCache + ? sanitizePrerenderJobId(ctxt.get(PRERENDER_JOB_ID_HEADER)) + : null; + let consumingRealm = searchCache + ? sanitizeConsumingRealmHeader(ctxt.get(X_BOXEL_CONSUMING_REALM_HEADER)) + : null; + let cacheable = searchCache && jobId && consumingRealm; + + let combined = cacheable + ? await searchCache!.getOrPopulate({ + jobId: jobId!, + realms: realmList, + query: parsed.cardsQuery, + opts: searchOpts, + populate: runSearch, + }) + : await runSearch(); await setContextResponse( ctxt, diff --git a/packages/realm-server/job-scoped-search-cache.ts b/packages/realm-server/job-scoped-search-cache.ts index 2a97a02a67d..3a8611f921e 100644 --- a/packages/realm-server/job-scoped-search-cache.ts +++ b/packages/realm-server/job-scoped-search-cache.ts @@ -1,7 +1,6 @@ import { normalizeQueryForSignature, sortKeysDeep, - type LinkableCollectionDocument, type Query, } from '@cardstack/runtime-common'; @@ -28,7 +27,14 @@ const DEFAULT_TTL_MS = 10 * 60 * 1000; const DEFAULT_MAX_ENTRIES = 5000; type CachedEntry = { - result: LinkableCollectionDocument; + // Result is stored opaquely so both `_federated-search`'s + // `LinkableCollectionDocument` and `_federated-search-prerendered`'s + // `PrerenderedCardCollectionDocument` can share the same cache + // instance. Inner-key canonicalisation already includes the + // endpoint-distinguishing params (htmlFormat / cardUrls / renderType + // are passed through `opts`), so two endpoints' entries cannot + // collide on a key they don't both fully share. + result: unknown; timer: ReturnType; // Position in the FIFO eviction ring. Stored on the entry so a // cache hit doesn't need a separate map lookup to know its slot. @@ -37,10 +43,16 @@ type CachedEntry = { // Per-batch read cache used during indexing. Each entry is keyed by // `(jobId, normalizedRealms, normalizedQuery, normalizedOpts)` and -// represents one `_federated-search` populate computed during the -// lifetime of one indexing job. The job-id boundary scopes the cache -// to a single batch; a subsequent job hashes to different keys and -// never reuses a stale value. +// represents one search populate computed during the lifetime of one +// indexing job. The cache is shared across both `_federated-search` +// (`LinkableCollectionDocument` results) and +// `_federated-search-prerendered` (`PrerenderedCardCollectionDocument` +// results) — the endpoint-specific request shape (`htmlFormat`, +// `cardUrls`, `renderType` for the prerendered handler) is folded into +// `opts` before the call here, so the canonicalised inner key already +// segregates the two endpoints' entries. The job-id boundary scopes +// the cache to a single batch; a subsequent job hashes to different +// keys and never reuses a stale value. // // Same-realm reads are safe by construction: within an indexing batch // the writer touches `boxel_index_working`, not `boxel_index`, so @@ -94,18 +106,18 @@ export class JobScopedSearchCache { this.#maxEntries = opts?.maxEntries ?? DEFAULT_MAX_ENTRIES; } - async getOrPopulate(args: { + async getOrPopulate(args: { jobId: string; realms: string[]; query: Query; opts: unknown | undefined; - populate: () => Promise; - }): Promise { + populate: () => Promise; + }): Promise { let innerKey = buildInnerKey(args.realms, args.query, args.opts); let jobMap = this.#byJob.get(args.jobId); let existing = jobMap?.get(innerKey); if (existing) { - return existing.result; + return existing.result as T; } let result = await args.populate(); diff --git a/packages/realm-server/routes.ts b/packages/realm-server/routes.ts index f3bd90c4719..6b0b3604d3f 100644 --- a/packages/realm-server/routes.ts +++ b/packages/realm-server/routes.ts @@ -200,7 +200,7 @@ export function createRoutes(args: CreateRoutesArgs) { router.all( '/_federated-search-prerendered', multiRealmAuthorization(args), - handleSearchPrerendered(), + handleSearchPrerendered({ searchCache }), ); router.post( '/_prerender-card', diff --git a/packages/realm-server/tests/server-endpoints/search-prerendered-test.ts b/packages/realm-server/tests/server-endpoints/search-prerendered-test.ts index 7a87c529c64..ff007211cb5 100644 --- a/packages/realm-server/tests/server-endpoints/search-prerendered-test.ts +++ b/packages/realm-server/tests/server-endpoints/search-prerendered-test.ts @@ -205,6 +205,327 @@ module(`server-endpoints/${basename(__filename)}`, function (_hooks) { ); }); + // Verifies the shared `JobScopedSearchCache` hits at the + // `_federated-search-prerendered` handler boundary too. Counts + // populates by spying on each realm's `searchPrerendered` method + // — a cache hit short-circuits before `searchPrerenderedRealms` + // reaches the realm, so spy invocations are the unambiguous + // tell. + test('QUERY /_federated-search-prerendered caches reads under one jobId and bypasses other jobs', async function (assert) { + let realmServerToken = createRealmServerJWT( + { user: ownerUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + ); + + let primaryCalls = 0; + let secondaryCalls = 0; + let primaryProto = testRealm.searchPrerendered; + let secondaryProto = secondaryRealm.searchPrerendered; + ( + testRealm as unknown as { + searchPrerendered: typeof testRealm.searchPrerendered; + } + ).searchPrerendered = function ( + this: typeof testRealm, + ...args: Parameters + ) { + primaryCalls++; + return primaryProto.apply(this, args); + }; + ( + secondaryRealm as unknown as { + searchPrerendered: typeof secondaryRealm.searchPrerendered; + } + ).searchPrerendered = function ( + this: typeof secondaryRealm, + ...args: Parameters + ) { + secondaryCalls++; + return secondaryProto.apply(this, args); + }; + + try { + let query: Query = { + filter: { + on: baseCardRef, + eq: { cardTitle: 'Shared Card' }, + }, + }; + let searchURL = new URL( + '/_federated-search-prerendered', + testRealm.url, + ); + let post = (jobId: string) => + request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${realmServerToken}`) + .set('x-boxel-job-id', jobId) + .set('x-boxel-consuming-realm', testRealm.url) + .send({ + ...query, + realms: [testRealm.url, secondaryRealm.url], + prerenderedHtmlFormat: 'embedded', + }); + + let first = await post('42.1'); + assert.strictEqual(first.status, 200, 'first request: HTTP 200'); + assert.strictEqual( + first.body.data.length, + 2, + 'first request returns both realms’ results', + ); + assert.strictEqual( + primaryCalls, + 1, + 'first request hit testRealm exactly once', + ); + assert.strictEqual( + secondaryCalls, + 1, + 'first request hit secondaryRealm exactly once', + ); + + let second = await post('42.1'); + assert.strictEqual(second.status, 200, 'second request: HTTP 200'); + assert.strictEqual( + second.body.data.length, + 2, + 'second request returns the cached result', + ); + assert.strictEqual( + primaryCalls, + 1, + 'second request was a cache hit (testRealm not re-queried)', + ); + assert.strictEqual( + secondaryCalls, + 1, + 'second request was a cache hit (secondaryRealm not re-queried)', + ); + + // A different jobId is a different batch — fresh populate. + let third = await post('43.1'); + assert.strictEqual(third.status, 200, 'third request: HTTP 200'); + assert.strictEqual( + primaryCalls, + 2, + 'different jobId re-queried testRealm', + ); + assert.strictEqual( + secondaryCalls, + 2, + 'different jobId re-queried secondaryRealm', + ); + } finally { + delete (testRealm as unknown as { searchPrerendered?: unknown }) + .searchPrerendered; + delete (secondaryRealm as unknown as { searchPrerendered?: unknown }) + .searchPrerendered; + } + }); + + // Cache key reflects the endpoint-specific request shape. Changing + // any of `prerenderedHtmlFormat`, `cardUrls`, or `renderType` under + // an otherwise identical (jobId, realms, query) tuple must miss + // the cache and fire a fresh populate. + test('QUERY /_federated-search-prerendered cache key segregates entries by htmlFormat / cardUrls / renderType', async function (assert) { + let realmServerToken = createRealmServerJWT( + { user: ownerUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + ); + + let primaryCalls = 0; + let primaryProto = testRealm.searchPrerendered; + ( + testRealm as unknown as { + searchPrerendered: typeof testRealm.searchPrerendered; + } + ).searchPrerendered = function ( + this: typeof testRealm, + ...args: Parameters + ) { + primaryCalls++; + return primaryProto.apply(this, args); + }; + + try { + let query: Query = { + filter: { + on: baseCardRef, + eq: { cardTitle: 'Shared Card' }, + }, + }; + let searchURL = new URL( + '/_federated-search-prerendered', + testRealm.url, + ); + let post = (body: Record) => + request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${realmServerToken}`) + .set('x-boxel-job-id', '42.1') + .set('x-boxel-consuming-realm', testRealm.url) + .send({ + ...query, + realms: [testRealm.url], + ...body, + }); + + let first = await post({ prerenderedHtmlFormat: 'embedded' }); + assert.strictEqual(first.status, 200, 'first request: HTTP 200'); + assert.strictEqual(primaryCalls, 1, 'first request populated'); + + // Same jobId, same query, same realms — but htmlFormat differs. + // Must miss the cache. + let second = await post({ prerenderedHtmlFormat: 'fitted' }); + assert.strictEqual(second.status, 200, 'second request: HTTP 200'); + assert.strictEqual( + primaryCalls, + 2, + 'different htmlFormat fired a fresh populate', + ); + + // Identical to `second` — must hit the cache. + let third = await post({ prerenderedHtmlFormat: 'fitted' }); + assert.strictEqual(third.status, 200, 'third request: HTTP 200'); + assert.strictEqual( + primaryCalls, + 2, + 'repeat of `second` was a cache hit', + ); + + // Adding a `cardUrls` filter changes the response → miss. + let fourth = await post({ + prerenderedHtmlFormat: 'fitted', + cardUrls: [`${testRealm.url}test-card`], + }); + assert.strictEqual(fourth.status, 200, 'fourth request: HTTP 200'); + assert.strictEqual( + primaryCalls, + 3, + 'different cardUrls fired a fresh populate', + ); + } finally { + delete (testRealm as unknown as { searchPrerendered?: unknown }) + .searchPrerendered; + } + }); + + // Without both the job-id and consuming-realm headers a request + // is treated as user-facing traffic and bypasses the cache. + test('QUERY /_federated-search-prerendered bypasses cache when either prerender-context header is absent', async function (assert) { + let realmServerToken = createRealmServerJWT( + { user: ownerUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + ); + + let primaryCalls = 0; + let primaryProto = testRealm.searchPrerendered; + ( + testRealm as unknown as { + searchPrerendered: typeof testRealm.searchPrerendered; + } + ).searchPrerendered = function ( + this: typeof testRealm, + ...args: Parameters + ) { + primaryCalls++; + return primaryProto.apply(this, args); + }; + + try { + let query: Query = { + filter: { + on: baseCardRef, + eq: { cardTitle: 'Shared Card' }, + }, + }; + let searchURL = new URL( + '/_federated-search-prerendered', + testRealm.url, + ); + + // No prerender-context headers — every request must re-populate. + let plain = () => + request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${realmServerToken}`) + .send({ + ...query, + realms: [testRealm.url], + prerenderedHtmlFormat: 'embedded', + }); + + let first = await plain(); + assert.strictEqual(first.status, 200, 'first request: HTTP 200'); + assert.strictEqual(primaryCalls, 1, 'first request populated'); + + let second = await plain(); + assert.strictEqual(second.status, 200, 'second request: HTTP 200'); + assert.strictEqual( + primaryCalls, + 2, + 'no headers → cache bypassed, populate ran again', + ); + + // jobId present, but consumingRealm missing → bypass. + let jobIdOnly = await request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${realmServerToken}`) + .set('x-boxel-job-id', '42.1') + .send({ + ...query, + realms: [testRealm.url], + prerenderedHtmlFormat: 'embedded', + }); + assert.strictEqual(jobIdOnly.status, 200, 'job-id only: HTTP 200'); + assert.strictEqual( + primaryCalls, + 3, + 'job-id without consuming-realm → cache bypassed', + ); + + // consumingRealm present, but jobId missing → bypass. + let consumingOnly = await request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${realmServerToken}`) + .set('x-boxel-consuming-realm', testRealm.url) + .send({ + ...query, + realms: [testRealm.url], + prerenderedHtmlFormat: 'embedded', + }); + assert.strictEqual( + consumingOnly.status, + 200, + 'consuming-realm only: HTTP 200', + ); + assert.strictEqual( + primaryCalls, + 4, + 'consuming-realm without job-id → cache bypassed', + ); + } finally { + delete (testRealm as unknown as { searchPrerendered?: unknown }) + .searchPrerendered; + } + }); + test('GET /_federated-search-prerendered returns 400 for unsupported method', async function (assert) { let realmServerToken = createRealmServerJWT( { user: ownerUserId, sessionRoom: 'session-room-test' },