diff --git a/packages/host/app/resources/search-entries.ts b/packages/host/app/resources/search-entries.ts index bd3b751fe2..b5eb1a981f 100644 --- a/packages/host/app/resources/search-entries.ts +++ b/packages/host/app/resources/search-entries.ts @@ -12,6 +12,7 @@ import { TrackedArray } from 'tracked-built-ins'; import { subscribeToRealm, + htmlQueryRenderingSelection, isCardResource, isCssResource, isFileMetaResource, @@ -20,6 +21,7 @@ import { logger as runtimeLogger, resourceIdentity, rri, + wireFilterHasMatches, RealmPaths, type CardResource, type ErrorEntry, @@ -28,12 +30,16 @@ import { type IconResource, type ResolvedCodeRef, type Saved, + type StoreReadType, type EntryCollectionDocument, type SearchEntryRendering, type SearchEntryWireQuery, } from '@cardstack/runtime-common'; -import type { RealmEventContent } from 'https://cardstack.com/base/matrix-event'; +import type { + RealmEventContent, + PrerenderHtmlEventContent, +} from 'https://cardstack.com/base/matrix-event'; import { knownFileMetaUrls } from '../lib/known-file-meta-urls'; import { normalizeRealms } from '../lib/realm-utils'; @@ -72,6 +78,14 @@ export interface SearchEntry { iconHtml?: string; displayName?: string; codeRef?: ResolvedCodeRef; + // The two generations the row was served at (from response `meta`), + // independent channels: the index-data generation and the generation the + // chosen rendering was produced at (absent when the row carries no + // rendering). The selective refresh reads them to decide whether a + // `prerender_html` event carries newer HTML for this member and to + // reconstruct the composite validator it sends as `If-None-Match`. + indexGeneration?: number; + htmlGeneration?: number; } interface Args { @@ -101,6 +115,24 @@ export class SearchEntriesResource extends Resource { // never consumes it, which is what would create a tracking hazard. private realmsNeedingRefresh = new Set(); + // The coalesced prerender_html invalidations queued for a selective + // per-member refresh: invalidated URL → the highest generation an event + // carried for it (generations are per-realm counters, so they are never + // compared across URLs from different realms). Set only for structured, + // refreshable queries (a prerender_html event can't change their + // membership, so the visible members need only their HTML refreshed, not a + // whole-search re-query). An index event supersedes it — the search task + // consumes or folds it, never drops it silently. Untracked, like + // `realmsNeedingRefresh`. + private pendingSelectiveRefresh: Map | undefined; + + // Bumped by every search-task run (and on teardown) so an in-flight + // `#computeSelectiveRefresh` from a superseded run stops issuing GETs: the + // task body's awaits are cancellable, but a plain async method keeps + // executing after its caller was cancelled, and without this check it + // would keep fetching members the replacement run already owns. + #refreshEpoch = 0; + // A partial refresh splices fetched-realm rows into the standing result // set, so it is only sound once a full run has populated that set. Until // then every run fetches all realms (an event arriving during the initial @@ -124,6 +156,8 @@ export class SearchEntriesResource extends Resource { } this.subscriptions = []; this.realmsNeedingRefresh.clear(); + this.pendingSelectiveRefresh = undefined; + this.#refreshEpoch++; }); } @@ -180,6 +214,10 @@ export class SearchEntriesResource extends Resource { this.#previousQuery = undefined; this.#previousRealms = undefined; this.realmsNeedingRefresh.clear(); + this.pendingSelectiveRefresh = undefined; + // An in-flight selective refresh must stop issuing GETs for the + // cleared query, same as on teardown. + this.#refreshEpoch++; this.hasCompletedFullRun = false; this.search.cancelAll(); for (let subscription of this.subscriptions) { @@ -227,17 +265,38 @@ export class SearchEntriesResource extends Resource { if (this.#previousQuery === undefined) { return; } - // Two coarse "anything moved in a subscribed realm" triggers - // re-run the search: an incremental index event (the search doc - // changed) and a prerender_html event (fresh HTML / corrected - // full-text membership landed on its own channel after the - // index pass). + // Two triggers re-run a subscribed search: an incremental index + // event (the search doc — and so membership — changed) and a + // prerender_html event (fresh HTML / corrected full-text membership + // landed on its own channel after the index pass). let isIncrementalIndex = event.eventName === 'index' && (!('indexType' in event) || event.indexType === 'incremental'); if (!isIncrementalIndex && event.eventName !== 'prerender_html') { return; } + + // A prerender_html event can't change a structured query's + // membership — only its members' renderings. So for a structured, + // refreshable query, refresh just the members the event carries + // newer HTML for, through a conditional card+html GET, instead of + // re-querying the whole realm. Full-text (matches) queries, + // paginated queries, composite/sparse selections, and a malformed + // event (no usable generation to judge staleness by) can't refresh + // in isolation and fall through to the coarse re-run. + if ( + event.eventName === 'prerender_html' && + typeof event.generation === 'number' && + this.#canSelectivelyRefresh() + ) { + this.#log.info( + `prerender_html event on ${realm}; scheduling selective per-member refresh`, + ); + this.#recordSelectiveRefresh(event); + this.#trackSearchLoad(this.search.perform()); + return; + } + this.#log.info( `${event.eventName === 'prerender_html' ? 'prerender_html' : 'incremental index'} event on ${realm}; scheduling partial refresh`, ); @@ -258,6 +317,7 @@ export class SearchEntriesResource extends Resource { this.#previousRealms = realms; // A query/realm change owns the whole result set again. this.realmsNeedingRefresh.clear(); + this.pendingSelectiveRefresh = undefined; this.hasCompletedFullRun = false; this.#trackSearchLoad(this.search.perform()); } @@ -284,7 +344,57 @@ export class SearchEntriesResource extends Resource { return; } let token = waiter.beginAsync(); + this.#refreshEpoch++; try { + // A prerender_html event queued a selective per-member refresh. Perform + // the targeted card+html GETs when no index event is also pending (an + // index event changes membership and supersedes it) and the query still + // supports it; otherwise fold the queued invalidations into the coarse + // re-run. The queue is consumed only at the point its content is + // applied or folded — never at task start — so a restart that cancels + // this run mid-GET (any later realm event re-performs this restartable + // task) leaves it queued for the replacement run instead of dropping + // it. The compute is pure and the mutations happen here in the task + // body, whose awaits are cancellation points — a cancelled run can + // never splice its (now superseded) result into the entries. + let selective = this.pendingSelectiveRefresh; + if (selective) { + if ( + this.realmsNeedingRefresh.size === 0 && + this.#canSelectivelyRefresh() + ) { + let refresh = await this.#computeSelectiveRefresh(selective, query); + if (refresh !== 'fallback') { + if (refresh.size > 0) { + // Splice in place, preserving object identity for every member + // whose visible content didn't change (a 200 can advance only + // the stamps), so unchanged rows never re-render or lose + // hydration. + let next = this._entries.map((member) => { + let replacement = refresh.get(member.id); + return replacement === undefined + ? member + : adoptFresh(member, replacement); + }); + this._entries.splice(0, this._entries.length, ...next); + this._errors = undefined; + } + // Membership is stable across a prerender_html event, so the + // standing set, order, and `meta` all remain correct. The queued + // invalidations are exactly what was just applied — an event + // arriving mid-run would have restarted this task before this + // line. + this.pendingSelectiveRefresh = undefined; + return; + } + // A member couldn't be refreshed in isolation (a GET failed or the + // response was malformed) — fall through to a coarse re-run over + // the realms the queued invalidations spanned. + } + this.pendingSelectiveRefresh = undefined; + this.#foldSelectiveRealmsIntoRefresh(selective); + } + // A paginated query never takes the realm-scoped path: with the held // rows page-limited, no whole-set total can be reconstituted from a // realm-subset fetch — the full re-run keeps `meta` server-accurate. @@ -317,7 +427,7 @@ export class SearchEntriesResource extends Resource { } let replacement = freshById.get(entry.id); if (replacement !== undefined) { - merged.push(isEqual(entry, replacement) ? entry : replacement); + merged.push(adoptFresh(entry, replacement)); freshById.delete(entry.id); } } @@ -337,12 +447,9 @@ export class SearchEntriesResource extends Resource { let previousById = new Map( this._entries.map((entry) => [entry.id, entry]), ); - let next = fresh.map((entry) => { - let previous = previousById.get(entry.id); - return previous !== undefined && isEqual(previous, entry) - ? previous - : entry; - }); + let next = fresh.map((entry) => + adoptFresh(previousById.get(entry.id), entry), + ); this._entries.splice(0, this._entries.length, ...next); this._meta = doc.meta; this.hasCompletedFullRun = true; @@ -369,6 +476,167 @@ export class SearchEntriesResource extends Resource { } }); + // Whether the current query supports a selective per-member refresh on a + // prerender_html event, rather than a whole-search re-query. Requires a + // standing result set to splice into, and a query whose members can be + // refreshed one URL at a time through the card+html GET: not paginated (the + // held rows are page-limited, so no splice), not full-text (matches + // membership can change), and with a rendering selection / fieldset the GET + // can spell as query params. When false the prerender_html event falls + // through to the coarse re-run, which handles every case. + #canSelectivelyRefresh(): boolean { + let query = this.#previousQuery; + if (query === undefined || !this.hasCompletedFullRun) { + return false; + } + if (query.page !== undefined) { + return false; + } + if (wireFilterHasMatches(query.filter)) { + return false; + } + // A composite htmlQuery has no ?format=/?renderType= spelling. + if ( + this._meta.htmlQuery !== undefined && + htmlQueryRenderingSelection(this._meta.htmlQuery) === undefined + ) { + return false; + } + // The GET's ?fields= serves only html / item / html,item; a sparse + // item. selection has no query-string spelling. + return this.#fieldsetIsRefreshable(query.fields?.entry); + } + + // Refreshable = the html branch is in play (the default resolution or an + // explicit `html`) and any other selection is `item`. An item-only fieldset + // is NOT refreshable even though the GET could spell it: without the html + // branch the search runs the live-search projection, which excludes rows + // with an effective error — and a render error lands on the + // prerendered_html channel, so such a query's membership can flip on the + // very event being routed. It takes the coarse re-run instead. + #fieldsetIsRefreshable(fields: string[] | undefined): boolean { + return ( + fields === undefined || + (fields.includes('html') && + fields.every((field) => field === 'html' || field === 'item')) + ); + } + + // Merge a prerender_html event into the queued selective refresh, taking + // the max generation per URL (a member is a candidate when an event carried + // HTML newer than the one it holds; the GET returns the newest rendering + // regardless). + #recordSelectiveRefresh(event: PrerenderHtmlEventContent): void { + let pending = (this.pendingSelectiveRefresh ??= new Map()); + for (let url of event.invalidations ?? []) { + pending.set(url, Math.max(pending.get(url) ?? 0, event.generation)); + } + } + + // When a selective refresh can't run (superseded by an index event, or a + // member GET failed), fold the realms the queued invalidations spanned into + // the coarse re-run so their prerender_html update still lands. A different + // realm's index event must not swallow this realm's HTML update. + #foldSelectiveRealmsIntoRefresh(invalidations: Map): void { + for (let member of this._entries) { + if (invalidationGenerationFor(member, invalidations) !== undefined) { + this.realmsNeedingRefresh.add(member.realmUrl); + } + } + } + + // Compute the replacements for the members a prerender_html event carries + // newer HTML for, each through a conditional card+html GET keyed on the + // composite validator the member holds: a `304` keeps the current rendering + // (the member is skipped, so its identity — and any hydration — survives), + // a `200` yields the fresh entry. Pure with respect to the resource's + // result state: the task body applies the returned map, so a run cancelled + // mid-GET can never splice a superseded result (this method keeps executing + // after its caller is cancelled — a plain async method has no cancellation + // points — which is also why it re-checks `#refreshEpoch` between GETs). + // Nothing is written to the store — a member a consumer has hydrated is + // owned by the store's reactive reload. Returns 'fallback' to request a + // coarse re-run when a member can't be refreshed in isolation. + async #computeSelectiveRefresh( + invalidations: Map, + query: SearchEntryWireQuery, + ): Promise | 'fallback'> { + let epoch = this.#refreshEpoch; + let selection = htmlQueryRenderingSelection(this._meta.htmlQuery); + let fieldsParam = this.#fieldsParam(query.fields?.entry); + + // A member with a rendering is a candidate only when an event names it at + // a newer generation than it holds; one with no rendering yet is always a + // candidate (the upgrade opportunity this event may be announcing). + // Content that changes WITHOUT a generation advance (a dual-read fallback + // row whose real HTML lands at the generation the fallback already + // reported, or a generation reused after a failed commit) is invisible to + // this gate — and equally to the composite validator, which would `304` + // such a member anyway — so those heal on the next generation advance + // rather than here. + let candidates = this._entries.filter((member) => { + let eventGeneration = invalidationGenerationFor(member, invalidations); + if (eventGeneration === undefined) { + return false; + } + return ( + member.htmlGeneration === undefined || + member.htmlGeneration < eventGeneration + ); + }); + + let replacements = new Map(); + for (let member of candidates) { + if (epoch !== this.#refreshEpoch || isDestroyed(this)) { + // A newer run (or teardown) superseded this one: stop fetching. The + // returned value is discarded — the cancelled caller never resumes. + return replacements; + } + // The whole per-member pipeline falls back on any failure — the GET, + // the stylesheet imports, and the rebuild alike — so a member that + // can't be refreshed in isolation always reaches the coarse re-run. + try { + let result = await this.runtimeStore.fetchCardEntry(member.id, { + kind: memberKind(member), + format: selection?.format, + renderType: selection?.renderType, + fields: fieldsParam, + ifNoneMatch: memberValidator(member), + }); + if (result.notModified) { + continue; + } + // Reuse the collection builders: wrap the single doc so the refreshed + // member is shaped exactly as the search would have returned it. + let collectionDoc: EntryCollectionDocument = { + data: [result.doc.data], + included: result.doc.included, + meta: { page: { total: 1 } }, + }; + await this.loadStylesheets(collectionDoc); + let [refreshed] = this.buildEntries(collectionDoc); + if (refreshed === undefined) { + return 'fallback'; + } + replacements.set(member.id, refreshed); + } catch (err) { + this.#log.warn( + `selective refresh failed for ${member.id}; falling back to a full re-run`, + err, + ); + return 'fallback'; + } + } + return replacements; + } + + // The ?fields= value mirroring the query's entry fieldset (validated + // refreshable by `#fieldsetIsRefreshable`); undefined for the default + // resolution. + #fieldsParam(fields: string[] | undefined): string | undefined { + return fields === undefined ? undefined : fields.join(','); + } + // The `css` resources base64-embed their whole stylesheet in the href; the // loader import is what registers each scoped stylesheet with the document, // so entries are paint-ready when exposed. @@ -419,10 +687,15 @@ export class SearchEntriesResource extends Resource { }; return doc.data.map((entry) => { - let renderings = (entry.relationships.html?.data ?? []) + let htmlResources = (entry.relationships.html?.data ?? []) .map((ref) => htmlById.get(ref.id)) - .filter(Boolean) - .map((html) => buildRendering(html!, cssHrefById)); + .filter(Boolean) as HtmlResource[]; + let renderings = htmlResources.map((html) => + buildRendering(html, cssHrefById), + ); + // The chosen rendering's generation (all of a row's renderings share the + // per-row `prerendered_html.generation`, so the first is authoritative). + let htmlGeneration = htmlResources[0]?.meta?.generation; let itemRef = entry.relationships.item?.data; let item = itemRef ? itemsByIdentity.get(resourceIdentity(itemRef.type, itemRef.id)) @@ -455,6 +728,10 @@ export class SearchEntriesResource extends Resource { codeRef: icon.codeRef, } : {}), + ...(entry.meta?.generation !== undefined + ? { indexGeneration: entry.meta.generation } + : {}), + ...(htmlGeneration !== undefined ? { htmlGeneration } : {}), }; }); } @@ -478,6 +755,110 @@ function buildRendering( }; } +// Render-stability identity. The generation stamps are freshness metadata the +// refresh machinery reads — no template consumes them — so a row whose +// visible content is unchanged keeps its object identity even when a +// re-index bumped its stamps: the fresh stamps are copied onto the retained +// object instead (plain untracked fields, so the mutation re-renders +// nothing). Without this, the invalidation fan-out — which re-indexes +// dependents whose content is often byte-identical — would remount every +// dependent row (and drop its hydration) on every edit. +function adoptFresh( + previous: SearchEntry | undefined, + fresh: SearchEntry, +): SearchEntry { + if (previous === undefined || !contentEquals(previous, fresh)) { + return fresh; + } + if (fresh.indexGeneration !== undefined) { + previous.indexGeneration = fresh.indexGeneration; + } else { + delete previous.indexGeneration; + } + if (fresh.htmlGeneration !== undefined) { + previous.htmlGeneration = fresh.htmlGeneration; + } else { + delete previous.htmlGeneration; + } + return previous; +} + +function contentEquals(a: SearchEntry, b: SearchEntry): boolean { + let { + indexGeneration: _aIndexGeneration, + htmlGeneration: _aHtmlGeneration, + ...aContent + } = a; + let { + indexGeneration: _bIndexGeneration, + htmlGeneration: _bHtmlGeneration, + ...bContent + } = b; + return isEqual(aContent, bContent); +} + +// A prerender_html invalidation names the underlying file (`books/1.json`), +// which can back TWO result rows: the card instance (id `books/1` — instance +// ids never carry the extension) and the file-meta row (id `books/1.json` — +// every file gets a file entry, card JSON included). Stripping a trailing +// `.json` from both sides lets the one invalidation reach both rows without +// knowing which kind a member is: the instance row needs the invalidation +// normalized, and the file row then needs its own id normalized the same way +// to keep matching. For every other file kind (`notes.md`, `book.gts`) the +// strip is a no-op and the comparison is exact. +function stripJsonSuffix(url: string): string { + return url.replace(/\.json$/, ''); +} + +// The highest generation among the queued invalidation URLs that name this +// member, or undefined when none do. +function invalidationGenerationFor( + member: SearchEntry, + invalidations: Map, +): number | undefined { + let normalized = stripJsonSuffix(member.id); + let result: number | undefined; + for (let [url, generation] of invalidations) { + if (stripJsonSuffix(url) === normalized) { + result = result === undefined ? generation : Math.max(result, generation); + } + } + return result; +} + +// `card` vs `file-meta` for the card+html GET's Accept header — the same +// classification `RenderableSearchEntry.type` makes: a file is an `item` of +// type `file-meta`, or an html-backed row whose rendering carries no render +// type (files render natively; card renderings always name one). +function memberKind(member: SearchEntry): StoreReadType { + if (member.item) { + return member.item.type === 'file-meta' ? 'file-meta' : 'card'; + } + if (member.html.length > 0 && !member.html[0].renderType) { + return 'file-meta'; + } + return 'card'; +} + +// The composite validator the member holds, in the `card+html` GET's ETag +// spelling — `":"` (see +// `buildEntryHtmlEtag`). For a member that holds a rendering this matches the +// server's pure-html ETag exactly, so an unchanged rendering returns `304`. +// The server folds a realm-info hash — which the client can't reconstruct — +// into an ITEM-bearing response's validator, so those never match: a member +// with no rendering (the item fallback) always refetches, which is exactly +// the upgrade a prerender_html event should pick up, and an `html,item` +// fieldset's members refetch on every qualifying event (`adoptFresh` keeps +// their row identity when the content comes back unchanged). +function memberValidator(member: SearchEntry): string { + let indexSegment = member.indexGeneration ?? 0; + let htmlSegment = + member.htmlGeneration !== undefined + ? String(member.htmlGeneration) + : 'none'; + return `"${indexSegment}:${htmlSegment}"`; +} + // The one host live-search resource: issues the `entry` wire query // through `StoreService.searchEntries`, subscribes to each searched realm, // and re-runs on incremental index events with a per-realm partial refresh. diff --git a/packages/host/app/resources/search.ts b/packages/host/app/resources/search.ts index ad7d5d42da..f3372b6127 100644 --- a/packages/host/app/resources/search.ts +++ b/packages/host/app/resources/search.ts @@ -424,8 +424,13 @@ export class SearchResource< return; } // Re-run on incremental index events (the search doc changed) - // and on prerender_html events (fresh HTML / corrected - // full-text membership landed on its own channel). + // and on prerender_html events. The latter matter even to + // structured queries: this search excludes rows with an + // effective error, and a render error lands on the + // prerendered_html channel at-or-above the row's index + // generation — so membership can flip on a prerender_html event + // with no index event announcing it. (Full-text `matches` + // membership rides that channel too, via `markdown`.) let isIncrementalIndex = event.eventName === 'index' && (!('indexType' in event) || event.indexType === 'incremental'); diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index e368fe9f0a..4d90124093 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -77,6 +77,10 @@ import { type CardResource, type SearchEntryResults, type SearchEntryWireQuery, + type EntrySingleDocument, + isEntrySingleDocument, + type PrerenderedHtmlFormat, + type ResolvedCodeRef, type RealmIdentifier, type RealmResourceIdentifier, type Saved, @@ -1339,6 +1343,84 @@ export default class StoreService extends Service implements StoreInterface { return json; } + // Conditional single-instance card+html GET: fetch one `entry` sourced by + // URL (the single-instance counterpart of `_search`), with the rendering + // selection spelled as query params and the client's held composite + // validator as `If-None-Match`. A `304` means the client's rendering is + // current; a `200` returns the fresh entry (with an `item` fallback when no + // rendering exists). The live-search selective refresh uses this to bring one + // member's HTML up to date without re-querying the whole search. Nothing is + // hydrated into the store. + async fetchCardEntry( + url: string, + opts: { + kind: StoreReadType; + format?: PrerenderedHtmlFormat; + renderType?: ResolvedCodeRef; + // `html` | `item` | `html,item`; omit for the default resolution (the + // selected rendering, falling back to `item` where none matched). + fields?: string; + ifNoneMatch?: string; + }, + ): Promise< + { notModified: true } | { notModified: false; doc: EntrySingleDocument } + > { + let requestURL = new URL(url); + if (opts.format) { + requestURL.searchParams.set('format', opts.format); + } + if (opts.renderType) { + requestURL.searchParams.set( + 'renderType', + `${opts.renderType.module}/${opts.renderType.name}`, + ); + } + if (opts.fields) { + requestURL.searchParams.set('fields', opts.fields); + } + let headers: Record = { + Accept: + opts.kind === 'file-meta' + ? SupportedMimeType.FileMetaHtml + : SupportedMimeType.CardHtml, + ...duringPrerenderHeaders(), + ...consumingRealmHeader(), + ...jobIdHeader(), + ...jobPriorityHeader(), + ...loggingCorrelationIdHeader(), + }; + if (opts.ifNoneMatch) { + headers['If-None-Match'] = opts.ifNoneMatch; + } + let response = await this.network.authedFetch(requestURL.href, { + method: 'GET', + headers, + }); + if (response.status === 304) { + return { notModified: true }; + } + if (!response.ok) { + let responseText = await response.text(); + let err = new Error( + `status: ${response.status} - ${response.statusText}. ${responseText}`, + ) as any; + err.status = response.status; + err.responseText = responseText; + err.responseHeaders = response.headers; + throw err; + } + // The response content-type is the negotiated `application/vnd.card+html` + // (not `+json`), but the body is a JSON:API document — parse the text. + let json = JSON.parse(await response.text()); + if (!isEntrySingleDocument(json)) { + throw new Error( + `The card+html response was not a valid entry single document: + ${JSON.stringify(json, null, 2)}`, + ); + } + return { notModified: false, doc: json }; + } + getSearchResource( parent: object, getQuery: () => Query | undefined, diff --git a/packages/host/tests/integration/resources/search-entries-test.gts b/packages/host/tests/integration/resources/search-entries-test.gts index e8b7bf4c53..d8256e6ac6 100644 --- a/packages/host/tests/integration/resources/search-entries-test.gts +++ b/packages/host/tests/integration/resources/search-entries-test.gts @@ -13,6 +13,7 @@ import { CssResourceType, HtmlResourceType, EntryResourceType, + type EntrySingleDocument, type Loader, type Realm, type SearchEntryResults, @@ -140,6 +141,101 @@ module('Integration | search-entries resource', function (hooks) { }); } + // A book result carrying the generations the selective refresh reads. `htmlGen` + // omitted → an empty-html (item-fallback) row. `cssHref` links a scoped + // stylesheet, so `loadStylesheets` has something to import. + interface BookEntrySpec { + id: string; + indexGen: number; + htmlGen?: number; + html?: string; + cssHref?: string; + } + + function fittedHtmlId(id: string) { + return htmlResourceId({ url: id, format: 'fitted', renderType: bookRef }); + } + + function entryResourceFor( + spec: BookEntrySpec, + ): SearchEntryResults['data'][number] { + return { + type: EntryResourceType, + id: spec.id, + relationships: { + html: { + data: + spec.htmlGen !== undefined + ? [{ type: HtmlResourceType, id: fittedHtmlId(spec.id) }] + : [], + }, + }, + meta: { generation: spec.indexGen }, + }; + } + + function htmlResourceFor( + spec: BookEntrySpec, + ): NonNullable[number] { + return { + type: HtmlResourceType, + id: fittedHtmlId(spec.id), + attributes: { + html: spec.html ?? '
book
', + cardType: 'Book', + format: 'fitted', + renderType: bookRef, + }, + relationships: { + styles: { + data: spec.cssHref + ? [{ type: CssResourceType, id: `css-${spec.id}` }] + : [], + }, + }, + meta: { generation: spec.htmlGen! }, + }; + } + + function cssResourcesFor( + specs: BookEntrySpec[], + ): NonNullable { + return specs + .filter((spec) => spec.cssHref !== undefined) + .map((spec) => ({ + type: CssResourceType, + id: `css-${spec.id}`, + attributes: { href: spec.cssHref! }, + })); + } + + // A `_federated-search` collection document echoing the default htmlQuery + // (`{ eq: { format: 'fitted' } }` — no renderType), as the server returns + // for `{ 'item.on': Book }` with no htmlQuery bound. + function entryCollectionDoc(specs: BookEntrySpec[]): SearchEntryResults { + return { + data: specs.map(entryResourceFor), + included: specs + .filter((spec) => spec.htmlGen !== undefined) + .map(htmlResourceFor), + meta: { + page: { total: specs.length }, + htmlQuery: { eq: { format: 'fitted' } }, + }, + }; + } + + // The single-instance card+html GET's response for one book. + function entrySingleDoc(spec: BookEntrySpec): EntrySingleDocument { + return { + data: entryResourceFor(spec), + included: + spec.htmlGen !== undefined + ? [htmlResourceFor(spec), ...cssResourcesFor([spec])] + : [], + }; + } + test('exposes entries joined from the wire document (default fieldset: html renderings)', async function (assert) { let search = getResourceForTest(storeService, () => ({ named: { @@ -356,56 +452,890 @@ module('Integration | search-entries resource', function (hooks) { ); }); - test('re-runs the search on a prerender_html event; other realm events leave it alone', async function (assert) { - let fetchCount = 0; - let originalSearchEntries = storeService.searchEntries.bind(storeService); - storeService.searchEntries = async (...args) => { - fetchCount++; - return originalSearchEntries(...args); - }; - try { - let search = getResourceForTest(storeService, () => ({ - named: { - query: { - filter: { 'item.on': bookRef }, - realms: [testRealmURL], - }, - }, - })); - await search.loaded; - let baseline = fetchCount; - - // The realm-server broadcasts prerender_html when fresh HTML lands on - // its own channel after the index pass; the in-browser test realm - // renders fused and never emits one, so inject it synthetically. + // A prerender_html event can't change a structured query's membership, so a + // live search refreshes only the invalidated members' HTML through a + // conditional card+html GET — it never re-queries the whole search. + module('prerender_html selective per-member refresh', function () { + function relayPrerenderHtml(invalidations: string[], generation: number) { getService('message-service').relayRealmEvent({ eventName: 'prerender_html', realmURL: testRealmURL, - generation: 2, - invalidations: [`${testRealmURL}books/1.json`], + generation, + invalidations, }); - await waitUntil(() => fetchCount > baseline, { timeout: 10_000 }); - await settled(); - assert.ok( - fetchCount > baseline, - 'a prerender_html event re-runs the search', - ); + } - let afterPrerender = fetchCount; - getService('message-service').relayRealmEvent({ - eventName: 'index', - indexType: 'full', - realmURL: testRealmURL, + test('refreshes only the invalidated member via a card+html GET; no full re-query', async function (assert) { + let searchCount = 0; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => { + searchCount++; + return entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango v1
', + }, + { + id: `${testRealmURL}books/2`, + indexGen: 1, + htmlGen: 1, + html: '
Van Gogh v1
', + }, + ]); + }; + + let getCalls: { url: string; ifNoneMatch?: string; format?: string }[] = + []; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async (url: string, opts: any) => { + getCalls.push({ + url, + ifNoneMatch: opts.ifNoneMatch, + format: opts.format, + }); + return { + notModified: false, + doc: entrySingleDoc({ + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 2, + html: '
Mango v2
', + }), + }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + assert.strictEqual(search.entries.length, 2); + let book2Before = search.entries.find( + (entry) => entry.id === `${testRealmURL}books/2`, + ); + let baseline = searchCount; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => getCalls.length > 0, { timeout: 10_000 }); + await settled(); + + assert.strictEqual( + searchCount, + baseline, + 'no whole-search re-query is issued', + ); + assert.strictEqual(getCalls.length, 1, 'exactly one card+html GET'); + assert.strictEqual(getCalls[0].url, `${testRealmURL}books/1`); + assert.strictEqual( + getCalls[0].ifNoneMatch, + '"1:1"', + 'the held composite validator (index:html) is sent as If-None-Match', + ); + assert.strictEqual(getCalls[0].format, 'fitted'); + + assert.strictEqual( + search.entries[0].html[0].html, + '
Mango v2
', + 'the invalidated member swaps in the fresh HTML', + ); + assert.strictEqual( + search.entries.find((entry) => entry.id === `${testRealmURL}books/2`), + book2Before, + 'the uninvalidated member keeps its identity', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('a 304 keeps the current rendering and the member identity', async function (assert) { + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => + entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango v1
', + }, + ]); + + let getCount = 0; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return { notModified: true }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let before = search.entries[0]; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => getCount > 0, { timeout: 10_000 }); + await settled(); + + assert.strictEqual(getCount, 1, 'the conditional GET was issued'); + assert.strictEqual( + search.entries[0], + before, + 'a 304 leaves the member object untouched (a hydrated row stays live)', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('a member already at or above the event generation is not fetched', async function (assert) { + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => + entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 5, + htmlGen: 5, + html: '
Mango
', + }, + ]); + + let getCount = 0; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return { notModified: true }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let before = search.entries[0]; + + // The event's generation (5) is not newer than the member's held HTML + // generation (5), so there is nothing to refresh. + relayPrerenderHtml([`${testRealmURL}books/1.json`], 5); + await settled(); + + assert.strictEqual(getCount, 0, 'no GET is issued for a fresh member'); + assert.strictEqual( + search.entries[0], + before, + 'the member is untouched', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('an event whose URLs are not in the visible set does no work', async function (assert) { + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => + entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango
', + }, + ]); + + let getCount = 0; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return { notModified: true }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let before = search.entries[0]; + + relayPrerenderHtml([`${testRealmURL}books/999.json`], 9); + await settled(); + + assert.strictEqual(getCount, 0, 'no GET for an unrelated invalidation'); + assert.strictEqual(search.entries[0], before); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('an empty-html member upgrades when its rendering lands', async function (assert) { + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => + // No rendering yet (empty html array), so no held html generation. + entryCollectionDoc([{ id: `${testRealmURL}books/1`, indexGen: 2 }]); + + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + let capturedIfNoneMatch: string | undefined; + storeService.fetchCardEntry = (async (_url: string, opts: any) => { + capturedIfNoneMatch = opts.ifNoneMatch; + return { + notModified: false, + doc: entrySingleDoc({ + id: `${testRealmURL}books/1`, + indexGen: 2, + htmlGen: 2, + html: '
Mango
', + }), + }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + assert.deepEqual(search.entries[0].html, [], 'no rendering initially'); + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => search.entries[0]?.html.length === 1, { + timeout: 10_000, + }); + + assert.strictEqual(search.entries[0].html[0].html, '
Mango
'); + assert.strictEqual( + capturedIfNoneMatch, + '"2:none"', + 'a member with no rendering sends the none-html validator', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('a full-text (matches) query re-runs in full instead of refreshing members', async function (assert) { + let searchCount = 0; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => { + searchCount++; + return entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango
', + }, + ]); + }; + + let getCount = 0; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return { notModified: true }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { + filter: { 'item.on': bookRef, matches: 'mango' }, + realms: [testRealmURL], + }, + }, + })); + await search.loaded; + let baseline = searchCount; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => searchCount > baseline, { timeout: 10_000 }); + await settled(); + + assert.ok( + searchCount > baseline, + 'a matches query re-runs the whole search (membership can change)', + ); + assert.strictEqual( + getCount, + 0, + 'no per-member GET for a matches query', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('a failed member GET falls back to a full re-run', async function (assert) { + let searchCount = 0; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => { + searchCount++; + return entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango
', + }, + ]); + }; + + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + throw new Error('boom'); + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let baseline = searchCount; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => searchCount > baseline, { timeout: 10_000 }); + await settled(); + + assert.ok( + searchCount > baseline, + 'a member the GET could not refresh triggers a coarse re-run', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('an item-only fieldset takes the coarse re-run — render errors can flip its membership', async function (assert) { + // Without the html branch the search runs the live-search projection, + // which excludes rows with an effective error — and a render error + // lands on the prerendered_html channel. So a prerender_html event can + // change this query's membership and a per-member refresh would miss + // that. The doc content is irrelevant to the routing decision under + // test. + let itemOnlyDoc: SearchEntryResults = { + data: [ + { + type: EntryResourceType, + id: `${testRealmURL}books/1`, + relationships: {}, + meta: { generation: 1 }, + }, + ], + meta: { page: { total: 1 } }, + }; + let searchCount = 0; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => { + searchCount++; + return itemOnlyDoc; + }; + let getCount = 0; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return { notModified: true }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { + filter: { 'item.on': bookRef }, + fields: { entry: ['item'] }, + realms: [testRealmURL], + }, + }, + })); + await search.loaded; + let baseline = searchCount; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => searchCount > baseline, { timeout: 10_000 }); + await settled(); + + assert.ok( + searchCount > baseline, + 'an item-only query re-runs the whole search on a prerender_html event', + ); + assert.strictEqual(getCount, 0, 'no per-member GET is attempted'); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('coalesced events refresh the union of their members, each judged at its own generation', async function (assert) { + let book1 = `${testRealmURL}books/1`; + let book2 = `${testRealmURL}books/2`; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => + entryCollectionDoc([ + { id: book1, indexGen: 1, htmlGen: 1, html: '
Mango v1
' }, + { + id: book2, + indexGen: 1, + htmlGen: 1, + html: '
Van Gogh v1
', + }, + ]); + + // The first GET (event 1's member) never resolves, holding the run + // mid-GET so event 2 restarts it; the replacement run must cover both + // events' members. + let getUrls: string[] = []; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async (url: string) => { + getUrls.push(url); + if (getUrls.length === 1) { + return new Promise(() => {}); + } + return { + notModified: false, + doc: entrySingleDoc({ + id: url, + indexGen: 1, + htmlGen: url === book2 ? 3 : 2, + html: `
${url === book2 ? 'Van Gogh' : 'Mango'} v2
`, + }), + }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => getUrls.length === 1, { timeout: 10_000 }); + relayPrerenderHtml([`${testRealmURL}books/2.json`], 3); + await waitUntil( + () => + search.entries.find((e) => e.id === book1)?.htmlGeneration === 2 && + search.entries.find((e) => e.id === book2)?.htmlGeneration === 3, + { timeout: 10_000 }, + ); + await settled(); + + assert.deepEqual( + getUrls.slice(1).sort(), + [book1, book2], + "the replacement run refreshes both events' members", + ); + assert.strictEqual( + search.entries.find((e) => e.id === book1)?.html[0]?.html, + '
Mango v2
', + ); + assert.strictEqual( + search.entries.find((e) => e.id === book2)?.html[0]?.html, + '
Van Gogh v2
', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('an index event on another realm mid-refresh does not drop the HTML update', async function (assert) { + let bookAId = `${testRealmURL}books/1`; + let bookBId = `${testRealm2URL}books/other`; + let staleDoc = entryCollectionDoc([ + { id: bookAId, indexGen: 1, htmlGen: 1, html: '
Mango v1
' }, + { id: bookBId, indexGen: 1, htmlGen: 1, html: '
Paper
' }, + ]); + let freshDoc = entryCollectionDoc([ + { id: bookAId, indexGen: 1, htmlGen: 2, html: '
Mango v2
' }, + { id: bookBId, indexGen: 1, htmlGen: 1, html: '
Paper
' }, + ]); + let fetchedRealms: string[][] = []; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async (_query, realms) => { + fetchedRealms.push([...(realms ?? [])].sort()); + return fetchedRealms.length === 1 ? staleDoc : freshDoc; + }; + + // The member GET never resolves, holding the selective refresh mid-GET + // so the index event's restart lands while it is in flight. + let getCount = 0; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return new Promise(() => {}); + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { + filter: { 'item.on': bookRef }, + realms: [testRealmURL, testRealm2URL], + }, + }, + })); + await search.loaded; + assert.strictEqual(search.entries.length, 2); + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => getCount > 0, { timeout: 10_000 }); + + // An incremental index event on the OTHER realm restarts the task + // while the member GET is in flight. The queued HTML invalidation + // must fold into the coarse re-run — not die with the cancelled run. + getService('message-service').relayRealmEvent({ + eventName: 'index', + indexType: 'incremental', + invalidations: [], + realmURL: testRealm2URL, + }); + await waitUntil( + () => + search.entries.find((e) => e.id === bookAId)?.htmlGeneration === 2, + { timeout: 10_000 }, + ); + await settled(); + + assert.deepEqual( + fetchedRealms[fetchedRealms.length - 1], + [testRealmURL, testRealm2URL].sort(), + "the coarse re-run covers the prerender event's realm too", + ); + assert.strictEqual( + search.entries.find((e) => e.id === bookAId)?.html[0]?.html, + '
Mango v2
', + 'the invalidated member picked up the fresh HTML through the fold', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('a stylesheet-import failure during a member refresh falls back to a full re-run', async function (assert) { + let cssHref = `${testRealmURL}book.gts.deadbeef.glimmer-scoped.css`; + let searchCount = 0; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => { + searchCount++; + return entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango v1
', + }, + ]); + }; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => ({ + notModified: false, + doc: entrySingleDoc({ + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 2, + html: '
Mango v2
', + cssHref, + }), + })) as typeof storeService.fetchCardEntry; + // Stubbed on the loader current at event time: `loaderService.loader` + // is a field the service REPLACES on reset/clone, so an instance + // captured earlier (e.g. in beforeEach) can be stale by now. + let importCalls: string[] = []; + let stubbedLoader: Loader | undefined; + let originalImport: Loader['import'] | undefined; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let baseline = searchCount; + + stubbedLoader = loaderService.loader; + originalImport = stubbedLoader.import.bind(stubbedLoader); + stubbedLoader.import = (async (url: string) => { + importCalls.push(url); + if (url === cssHref) { + throw new Error('stylesheet fetch failed'); + } + return originalImport!(url); + }) as Loader['import']; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => searchCount > baseline, { timeout: 10_000 }); + await settled(); + + assert.true( + importCalls.includes(cssHref), + 'the member refresh actually attempted the stylesheet import', + ); + assert.ok( + searchCount > baseline, + 'the failed stylesheet import falls back to the coarse re-run', + ); + assert.strictEqual( + search.entries[0]?.htmlGeneration, + 1, + "the failed member's refresh was not applied", + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + if (stubbedLoader && originalImport) { + stubbedLoader.import = originalImport; + } + } + }); + + test('a row whose only change is its generation stamps keeps identity and adopts the stamps', async function (assert) { + // The invalidation fan-out re-indexes dependents whose content is often + // byte-identical — only the stamps move. Those rows must not remount + // (identity preserved) but must carry the fresh stamps, or later events + // would judge staleness against outdated generations. + let docs = [ + entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango
', + }, + ]), + entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 2, + htmlGen: 2, + html: '
Mango
', + }, + ]), + ]; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => docs.shift() ?? docs[0]; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let before = search.entries[0]; + assert.strictEqual(before.htmlGeneration, 1); + + getService('message-service').relayRealmEvent({ + eventName: 'index', + indexType: 'incremental', + invalidations: [`${testRealmURL}books/1.json`], + realmURL: testRealmURL, + }); + await waitUntil(() => search.entries[0]?.htmlGeneration === 2, { + timeout: 10_000, + }); + await settled(); + + assert.strictEqual( + search.entries[0], + before, + 'the content-unchanged row keeps its object identity', + ); + assert.strictEqual(before.indexGeneration, 2, 'the stamps are adopted'); + } finally { + storeService.searchEntries = originalSearchEntries; + } + }); + + test('clearing the query mid-refresh stops the remaining member GETs', async function (assert) { + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => + entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango
', + }, + { + id: `${testRealmURL}books/2`, + indexGen: 1, + htmlGen: 1, + html: '
Van Gogh
', + }, + ]); + + // The first GET is held open; once the query is cleared and the GET + // resolves, the (uncancellable) helper must not proceed to the second + // member. + let getCount = 0; + let releaseFirstGet: (value: { + notModified: false; + doc: EntrySingleDocument; + }) => void; + let firstGet = new Promise<{ + notModified: false; + doc: EntrySingleDocument; + }>((resolve) => (releaseFirstGet = resolve)); + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return firstGet; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + + relayPrerenderHtml( + [`${testRealmURL}books/1.json`, `${testRealmURL}books/2.json`], + 2, + ); + await waitUntil(() => getCount === 1, { timeout: 10_000 }); + + search.modify([], { query: undefined }); + releaseFirstGet!({ + notModified: false, + doc: entrySingleDoc({ + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 2, + html: '
Mango v2
', + }), + }); + await settled(); + + assert.strictEqual( + getCount, + 1, + 'no further member GET fires after the query is cleared', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('a non-incremental index event does not re-run the search', async function (assert) { + let searchCount = 0; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => { + searchCount++; + return entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango
', + }, + ]); + }; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let baseline = searchCount; + + getService('message-service').relayRealmEvent({ + eventName: 'index', + indexType: 'full', + realmURL: testRealmURL, + }); + await settled(); + + assert.strictEqual( + searchCount, + baseline, + 'a full (non-incremental) index event leaves the search alone', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + } + }); + + // Exercises the real card+html GET (not stubbed) against the in-browser + // realm: the validator the client reconstructs from a member's held + // generations must match the server's composite ETag, or the 304 that keeps + // a rendering live would never fire. + test('the real card+html GET round-trips and its ETag matches the reconstructed validator', async function (assert) { + let url = `${testRealmURL}books/1`; + let first = await storeService.fetchCardEntry(url, { + kind: 'card', + format: 'fitted', }); - await settled(); - assert.strictEqual( - fetchCount, - afterPrerender, - 'a non-incremental index event does not re-run the search', - ); - } finally { - storeService.searchEntries = originalSearchEntries; - } + assert.false(first.notModified, 'the first GET returns the entry'); + if (!first.notModified) { + let entry = first.doc.data; + assert.strictEqual(entry.id, url); + let htmlRef = entry.relationships.html?.data?.[0]; + assert.ok(htmlRef, 'the entry carries a rendering'); + let htmlResource = first.doc.included?.find( + (resource) => resource.id === htmlRef!.id, + ); + assert.ok(htmlResource, 'the rendering rides in included'); + + // Reconstruct the composite validator the selective refresh sends, + // from the generations the response carries — the same shape + // `memberValidator` builds. + let indexGeneration = ( + entry.meta as { generation?: number } | undefined + )?.generation; + let htmlGeneration = ( + htmlResource as { meta?: { generation?: number } } | undefined + )?.meta?.generation; + let validator = `"${indexGeneration ?? 0}:${htmlGeneration ?? 'none'}"`; + + let second = await storeService.fetchCardEntry(url, { + kind: 'card', + format: 'fitted', + ifNoneMatch: validator, + }); + assert.true( + second.notModified, + 'the reconstructed validator matches the server ETag, so the GET 304s', + ); + } + }); }); test('an entry with an empty html array upgrades when its rendering lands on a later event', async function (assert) { diff --git a/packages/realm-server/tests/search-entry-test.ts b/packages/realm-server/tests/search-entry-test.ts index 9445321043..138eb30937 100644 --- a/packages/realm-server/tests/search-entry-test.ts +++ b/packages/realm-server/tests/search-entry-test.ts @@ -8,6 +8,7 @@ import { buildSparseItemResource, fieldsetFromParam, htmlQueryFromParams, + htmlQueryRenderingSelection, htmlResourceId, cssResourceId, htmlQueryHasRenderTypePredicate, @@ -20,6 +21,7 @@ import { parseSearchEntryQueryFromPayload, resolveHtmlQuery, searchEntryWireQueryFromQuery, + wireFilterHasMatches, SearchRequestError, DEFAULT_HTML_QUERY, rri, @@ -420,6 +422,94 @@ module(basename(import.meta.filename), function () { }); }); + // The (format, renderType) a single-leaf htmlQuery selects — what the + // live-search selective refresh spells as the card+html GET's query params. + module('htmlQuery rendering selection', function () { + test('a single eq leaf yields its format and renderType', function (assert) { + assert.deepEqual( + htmlQueryRenderingSelection({ + eq: { format: 'embedded', renderType: authorRef }, + }), + { format: 'embedded', renderType: authorRef }, + ); + assert.deepEqual( + htmlQueryRenderingSelection({ eq: { format: 'fitted' } }), + { format: 'fitted' }, + ); + }); + + test('a format-less eq leaf has no selection — it leaves format unconstrained (one rendering per format), which one ?format= cannot spell', function (assert) { + assert.strictEqual( + htmlQueryRenderingSelection({ eq: { renderType: authorRef } }), + undefined, + ); + }); + + test('an absent htmlQuery and composite htmlQueries have no selection', function (assert) { + assert.strictEqual(htmlQueryRenderingSelection(undefined), undefined); + assert.strictEqual( + htmlQueryRenderingSelection({ + any: [{ eq: { format: 'fitted' } }, { eq: { format: 'embedded' } }], + }), + undefined, + ); + assert.strictEqual( + htmlQueryRenderingSelection({ not: { eq: { format: 'atom' } } }), + undefined, + ); + }); + + test('a malformed value reads as no selection instead of throwing', function (assert) { + // The input can come straight off a wire document's meta.htmlQuery, + // which the document guard does not validate. + for (let malformed of [ + 'fitted', + 42, + null, + { eq: null }, + { eq: 'fitted' }, + { eq: { format: 'nonsense' } }, + { eq: { format: 'fitted', renderType: 'not-a-code-ref' } }, + ]) { + assert.strictEqual( + htmlQueryRenderingSelection(malformed as any), + undefined, + `${JSON.stringify(malformed)} → undefined`, + ); + } + }); + }); + + // Whether a full-text `matches` predicate appears anywhere in an entry wire + // filter — what routes a prerender_html event to a full re-run vs a + // selective refresh. + module('wire filter matches detection', function () { + test('detects matches at the top level and nested in connectives', function (assert) { + assert.true(wireFilterHasMatches({ matches: 'pigeon' })); + assert.true( + wireFilterHasMatches({ + every: [ + { 'item.on': authorRef }, + { any: [{ not: { matches: 'pigeon' } }] }, + ], + }), + ); + }); + + test('structured filters carry no matches', function (assert) { + assert.false(wireFilterHasMatches(undefined)); + assert.false(wireFilterHasMatches({ 'item.on': authorRef })); + assert.false( + wireFilterHasMatches({ + every: [ + { eq: { 'item.status': 'ready' } }, + { not: { contains: { 'item.title': 'x' } } }, + ], + }), + ); + }); + }); + // The legacy-Query → wire-grammar translation is the parser's inverse; // round-tripping a translated query through the parser must recover the // original itemQuery exactly. diff --git a/packages/runtime-common/document-types.ts b/packages/runtime-common/document-types.ts index d76c021fb3..04a95a9e74 100644 --- a/packages/runtime-common/document-types.ts +++ b/packages/runtime-common/document-types.ts @@ -160,6 +160,34 @@ export function isEntryCollectionDocument( return data.every((resource) => isEntryResource(resource)); } +export function isEntrySingleDocument(doc: any): doc is EntrySingleDocument { + if (typeof doc !== 'object' || doc == null) { + return false; + } + if (!('data' in doc)) { + return false; + } + // Same untrusted-JSON discipline as the collection guard: a + // present-but-malformed `included` must fail rather than throw downstream. + if ('included' in doc && doc.included !== undefined) { + let { included } = doc; + if (!Array.isArray(included)) { + return false; + } + for (let resource of included) { + if ( + typeof resource !== 'object' || + resource == null || + typeof resource.type !== 'string' || + typeof resource.id !== 'string' + ) { + return false; + } + } + } + return isEntryResource(doc.data); +} + export type CardTypeSummaryKind = 'instance' | 'file'; // JSON:API representation of one entry from `realm_meta.value`. Clients diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index 9d1f6cf483..18e634fe12 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -942,6 +942,7 @@ export { isSingleFileMetaDocument, isFileMetaCollectionDocument, isEntryCollectionDocument, + isEntrySingleDocument, isCardDocumentString, } from './document-types.ts'; export { diff --git a/packages/runtime-common/search-entry.ts b/packages/runtime-common/search-entry.ts index 344f58a925..101702eb06 100644 --- a/packages/runtime-common/search-entry.ts +++ b/packages/runtime-common/search-entry.ts @@ -40,7 +40,7 @@ import { PRERENDERED_HTML_FORMATS, type PrerenderedHtmlFormat, } from './prerendered-html-format.ts'; -import { isCodeRef } from './card-document-shape.ts'; +import { isCodeRef, isResolvedCodeRef } from './card-document-shape.ts'; import { generalSortFields } from './index-query-engine.ts'; import { ensureTrailingSlash } from './paths.ts'; import { parseUsedRenderType } from './search-resource-helpers.ts'; @@ -738,6 +738,77 @@ export function fieldsetFromParam( }; } +// The (format, renderType) a single-leaf htmlQuery selects — the pair the +// card+html GET spells as `?format=` / `?renderType=`. Returns `undefined` +// whenever the htmlQuery has no query-string spelling, and the caller must +// fall back to a full search instead of refreshing one member in isolation: +// a composite htmlQuery (`every`/`any`/`not`), an `eq` leaf with no `format` +// (that leaves format UNCONSTRAINED — the row carries a rendering per format, +// which one GET's single `?format=` cannot reproduce), or an `eq` leaf whose +// renderType isn't a resolved `/` ref. +export function htmlQueryRenderingSelection( + htmlQuery: HtmlQuery | undefined, +): { format: PrerenderedHtmlFormat; renderType?: ResolvedCodeRef } | undefined { + // Structural guards, not just types: the input can come straight off a wire + // document's `meta.htmlQuery`, whose shape the document guard does not + // validate. A malformed value must read as "no single-leaf selection" (the + // caller falls back to a full re-run) rather than throw. + if ( + typeof htmlQuery !== 'object' || + htmlQuery == null || + !('eq' in htmlQuery) || + typeof htmlQuery.eq !== 'object' || + htmlQuery.eq == null + ) { + return undefined; + } + let { format, renderType } = htmlQuery.eq; + if (!isValidPrerenderedHtmlFormat(format)) { + return undefined; + } + if ( + renderType !== undefined && + // `isResolvedCodeRef` assumes an object (it probes with `in`), so the + // structural check must come first for the same malformed-wire reason as + // above. + (typeof renderType !== 'object' || + renderType == null || + !isResolvedCodeRef(renderType)) + ) { + return undefined; + } + return { + format, + ...(renderType !== undefined ? { renderType } : {}), + }; +} + +// Whether a full-text `matches` predicate appears anywhere in the entry wire +// filter tree — the wire-grammar counterpart of `filterHasMatches`. A live +// search reads this to route a `prerender_html` event: a query whose membership +// depends on `markdown` (matches) needs a full re-run, a structured one only a +// targeted rendering refresh. +export function wireFilterHasMatches( + filter: SearchEntryWireFilter | undefined, +): boolean { + if (!filter) { + return false; + } + if (filter.matches !== undefined) { + return true; + } + if (filter.any) { + return filter.any.some(wireFilterHasMatches); + } + if (filter.every) { + return filter.every.some(wireFilterHasMatches); + } + if (filter.not) { + return wireFilterHasMatches(filter.not); + } + return false; +} + // --------------------------------------------------------------------------- // The wire grammar — what a client sends to `_search` / // `_federated-search`. `SearchEntryWireQuery` is the entry-rooted