diff --git a/packages/experiments-realm/ClientFilterPlayground/1.json b/packages/experiments-realm/ClientFilterPlayground/1.json new file mode 100644 index 00000000000..160120f0e2f --- /dev/null +++ b/packages/experiments-realm/ClientFilterPlayground/1.json @@ -0,0 +1,19 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardInfo": { + "name": "Client Filter Playground", + "summary": "Live demo of the client-side Store filtering step", + "notes": null, + "cardThumbnailURL": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../client-filter-playground", + "name": "ClientFilterPlayground" + } + } + } +} diff --git a/packages/experiments-realm/PlaygroundWidget/alpha.json b/packages/experiments-realm/PlaygroundWidget/alpha.json new file mode 100644 index 00000000000..1a4759170f6 --- /dev/null +++ b/packages/experiments-realm/PlaygroundWidget/alpha.json @@ -0,0 +1,34 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "PlaygroundWidget", + "module": "../client-filter-playground" + } + }, + "type": "card", + "attributes": { + "label": "Alpha", + "status": "archived", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "priority": 5 + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "cardInfo.cardThumbnail": { + "links": { + "self": null + } + } + } + } +} diff --git a/packages/experiments-realm/PlaygroundWidget/beta.json b/packages/experiments-realm/PlaygroundWidget/beta.json new file mode 100644 index 00000000000..3ef73878c73 --- /dev/null +++ b/packages/experiments-realm/PlaygroundWidget/beta.json @@ -0,0 +1,34 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "PlaygroundWidget", + "module": "../client-filter-playground" + } + }, + "type": "card", + "attributes": { + "label": "Beta", + "status": "archived", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "priority": 4 + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "cardInfo.cardThumbnail": { + "links": { + "self": null + } + } + } + } +} diff --git a/packages/experiments-realm/PlaygroundWidget/delta.json b/packages/experiments-realm/PlaygroundWidget/delta.json new file mode 100644 index 00000000000..a1aa8dd2246 --- /dev/null +++ b/packages/experiments-realm/PlaygroundWidget/delta.json @@ -0,0 +1,34 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "PlaygroundWidget", + "module": "../client-filter-playground" + } + }, + "type": "card", + "attributes": { + "label": "Delta", + "status": "active", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "priority": 7 + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "cardInfo.cardThumbnail": { + "links": { + "self": null + } + } + } + } +} diff --git a/packages/experiments-realm/PlaygroundWidget/gamma.json b/packages/experiments-realm/PlaygroundWidget/gamma.json new file mode 100644 index 00000000000..16b543a8b46 --- /dev/null +++ b/packages/experiments-realm/PlaygroundWidget/gamma.json @@ -0,0 +1,34 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "PlaygroundWidget", + "module": "../client-filter-playground" + } + }, + "type": "card", + "attributes": { + "label": "Gamma", + "status": "active", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "priority": 7 + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "cardInfo.cardThumbnail": { + "links": { + "self": null + } + } + } + } +} diff --git a/packages/experiments-realm/client-filter-playground.gts b/packages/experiments-realm/client-filter-playground.gts new file mode 100644 index 00000000000..ff3515b4db9 --- /dev/null +++ b/packages/experiments-realm/client-filter-playground.gts @@ -0,0 +1,352 @@ +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import type Owner from '@ember/owner'; + +import { identifyCard, type getCards } from '@cardstack/runtime-common'; + +import NumberField from 'https://cardstack.com/base/number'; +import StringField from 'https://cardstack.com/base/string'; +import { + CardDef, + Component, + field, + contains, + realmURL, +} from 'https://cardstack.com/base/card-api'; + +// A single item the playground lists, creates, edits, and deletes. The status +// field drives which live query a widget belongs to; priority drives the sort. +export class PlaygroundWidget extends CardDef { + static displayName = 'Playground Widget'; + @field label = contains(StringField); + // status is either active or archived + @field status = contains(StringField); + @field priority = contains(NumberField); +} + +// Demonstrates the client-side Store filtering step (CS-11416): two live, +// query-based searches over the same Store — one for status active, one for +// status archived, each sorted by priority. Creating, editing, or deleting a +// widget reconciles both lists against in-memory Store state immediately, +// before the realm reindexes — so a widget hops between the lists, sorts into +// place, or vanishes the instant you act, with no _federated-search round trip +// (watch the Network tab to confirm). +class Isolated extends Component { + + + // `@context.getCards` is the default (CardDef) signature; the widget getters + // below narrow `.instances` to PlaygroundWidget. + private activeList: ReturnType | undefined; + private archivedList: ReturnType | undefined; + + constructor(owner: Owner, args: any) { + super(owner, args); + this.activeList = this.args.context?.getCards( + this, + () => this.queryFor('active'), + () => this.realms, + { isLive: true }, + ); + this.archivedList = this.args.context?.getCards( + this, + () => this.queryFor('archived'), + () => this.realms, + { isLive: true }, + ); + } + + private get realms(): string[] | undefined { + let url = this.args.model[realmURL]; + return url ? [url.href] : undefined; + } + + private queryFor(status: 'active' | 'archived') { + let ref = identifyCard(PlaygroundWidget); + if (!ref) { + return undefined; + } + return { + filter: { on: ref, eq: { status } }, + sort: [{ on: ref, by: 'priority', direction: 'asc' as const }], + }; + } + + private get activeWidgets(): PlaygroundWidget[] { + return (this.activeList?.instances ?? []) as PlaygroundWidget[]; + } + + private get archivedWidgets(): PlaygroundWidget[] { + return (this.archivedList?.instances ?? []) as PlaygroundWidget[]; + } + + private get nextPriority(): number { + let all = [...this.activeWidgets, ...this.archivedWidgets]; + return all.reduce((max, w) => Math.max(max, w.priority ?? 0), 0) + 1; + } + + private addWidget(status: 'active' | 'archived') { + let store = this.args.context?.store; + let ref = identifyCard(PlaygroundWidget); + let realm = this.args.model[realmURL]?.href; + if (!store || !ref) { + return; + } + let priority = this.nextPriority; + store.create( + { + data: { + type: 'card', + attributes: { label: `Widget ${priority}`, status, priority }, + meta: { adoptsFrom: ref }, + }, + }, + realm ? { realm } : undefined, + ); + } + + @action private addActive() { + this.addWidget('active'); + } + + @action private addArchived() { + this.addWidget('archived'); + } + + // Edit through store.patch rather than assigning the field inline: patch + // applies the change asynchronously (it awaits a fresh read first), so the + // write lands outside the click's autotracking frame — and it mirrors how + // cards are actually edited. The Store mutation then re-derives both live + // lists with no server round trip. + @action private toggle(widget: PlaygroundWidget) { + let store = this.args.context?.store; + if (!store || !widget.id) { + return; + } + store.patch(widget.id, { + attributes: { status: widget.status === 'active' ? 'archived' : 'active' }, + }); + } + + @action private bump(widget: PlaygroundWidget) { + let store = this.args.context?.store; + if (!store || !widget.id) { + return; + } + store.patch(widget.id, { + attributes: { priority: (widget.priority ?? 0) + 1 }, + }); + } + + @action private remove(widget: PlaygroundWidget) { + if (widget.id) { + this.args.context?.store?.delete(widget.id); + } + } +} + +export class ClientFilterPlayground extends CardDef { + static displayName = 'Client Filter Playground'; + static isolated = Isolated; +} diff --git a/packages/host/app/lib/gc-card-store.ts b/packages/host/app/lib/gc-card-store.ts index c28b89c5bff..b2d4e632769 100644 --- a/packages/host/app/lib/gc-card-store.ts +++ b/packages/host/app/lib/gc-card-store.ts @@ -459,6 +459,40 @@ export default class CardStoreWithGarbageCollection implements CardStore { this.getCardItem('error', id)) as T | CardErrorJSONAPI | undefined; } + // All hydrated (non-error) card instances currently in the identity map. + // Reads the tracked `#cardInstances` map, so a caller that consumes the + // result inside an autotracked computation re-runs when an instance is + // added or removed — the candidate set for the client-side search filter. + // Field-level edits to an already-present instance don't change the map; + // those are surfaced separately by StoreService's mutation-version signal. + // + // A single instance is keyed under both its local and remote id (see + // setCardItem), so the map yields it more than once; collapse to a unique + // set so the candidate pool never contains the same card twice. + allCardInstances(): CardDef[] { + let result = new Set(); + for (let instance of this.#cardInstances.values()) { + if (isCardInstance(instance)) { + result.add(instance); + } + } + return [...result]; + } + + // The file-meta counterpart of `allCardInstances`, reading the tracked + // `#fileMetaInstances` map so file-meta searches get the same client-side + // candidate set as card searches. Deduped to a unique set for the same + // reason — an instance can appear under more than one key. + allFileMetaInstances(): FileDef[] { + let result = new Set(); + for (let instance of this.#fileMetaInstances.values()) { + if (isFileDefInstance(instance)) { + result.add(instance); + } + } + return [...result]; + } + addFileMetaInstanceOrError( id: string, instanceOrError: FileDef | CardErrorJSONAPI, diff --git a/packages/host/app/resources/search.ts b/packages/host/app/resources/search.ts index e2b75a721c0..18f6157da88 100644 --- a/packages/host/app/resources/search.ts +++ b/packages/host/app/resources/search.ts @@ -1,4 +1,8 @@ -import { registerDestructor } from '@ember/destroyable'; +import { + isDestroyed, + isDestroying, + registerDestructor, +} from '@ember/destroyable'; import type Owner from '@ember/owner'; import { setOwner } from '@ember/owner'; import { service } from '@ember/service'; @@ -21,6 +25,10 @@ import type { import { subscribeToRealm, isFileDefInstance, + isFileDefCodeRef, + isClientEvaluable, + matchInstanceAgainstFilter, + makeInstanceComparator, logger as runtimeLogger, normalizeQueryForSignature, buildQueryParamValue, @@ -28,8 +36,12 @@ import { ri, RealmPaths, runtimeDependencyContextWithSource, + type CardAPIForMatching, + type CodeRef, + type MatchResult, + type RealmResourceIdentifier, } from '@cardstack/runtime-common'; -import type { Query } from '@cardstack/runtime-common/query'; +import type { Filter, Query } from '@cardstack/runtime-common/query'; import type { CardDef } from 'https://cardstack.com/base/card-api'; import type { FileDef } from 'https://cardstack.com/base/file-api'; @@ -42,6 +54,70 @@ import type StoreService from '../services/store'; const waiter = buildWaiter('search-resource:search-waiter'); +// Retains Store references for a changing set of instance ids and reconciles +// them as that set changes, releasing all on teardown. `addReference` / +// `dropReference` can mutate tracked Store state (e.g. `autoSaveStates`), so +// reconciliation is deferred to a microtask — it must never run inside the +// tracked computation that declares the set. Used for the live-search +// candidates surfaced by the client-side merge but absent from the server +// result: `updateInstances` references only the server result, so without this +// a displayed candidate at reference count zero could be swept by the Store GC +// while still on screen. +class CandidateReferenceRetainer { + #held = new Set(); + #desired = new Set(); + #flushScheduled = false; + #torn = false; + + constructor( + private getStore: () => StoreService, + parent: object, + ) { + registerDestructor(parent, () => { + this.#torn = true; + this.#desired = new Set(); + this.#reconcile(); + }); + } + + // Declare the ids currently in use. Safe to call from a tracked getter — the + // reference mutation happens later, in a microtask, outside this frame. + retain(ids: string[]): void { + if (this.#torn) { + return; + } + this.#desired = new Set(ids); + if (this.#flushScheduled) { + return; + } + this.#flushScheduled = true; + queueMicrotask(() => { + this.#flushScheduled = false; + if (!this.#torn) { + this.#reconcile(); + } + }); + } + + #reconcile(): void { + if (this.#held.size === 0 && this.#desired.size === 0) { + return; + } + let store = this.getStore(); + for (let id of this.#held) { + if (!this.#desired.has(id)) { + store.dropReference(id); + } + } + for (let id of this.#desired) { + if (!this.#held.has(id)) { + store.addReference(id); + } + } + this.#held = new Set(this.#desired); + } +} + export interface Args { named: { query: Query | undefined; @@ -87,9 +163,23 @@ export class SearchResource< // Kept private for tests/internal load bookkeeping. private loaded: Promise | undefined; private subscriptions: { url: string; unsubscribe: () => void }[] = []; + // The result set as returned by the server. The publicly-exposed + // `instances` getter derives from this: for an eligible live search it is + // reconciled against in-memory Store state (the client-side filtering step); + // otherwise it is passed through unchanged. private _instances = new TrackedArray(); @tracked private _meta: QueryResultsMeta = { page: { total: 0 } }; @tracked private _errors: ErrorEntry[] | undefined; + // The card-api slice the client-side matcher/comparator need. Loaded + // asynchronously for live searches; until it resolves the search behaves as + // a server-only passthrough. Tracked so the derived result set recomputes + // once it lands. + @tracked private matchAPI: CardAPIForMatching | undefined; + #matchAPILoading = false; + // The query currently driving results, tracked so the client filtering step + // re-derives when the filter/sort changes (the server result set also + // changes, but reading this keeps the derivation self-contained). + @tracked private activeQuery: Query | undefined; #isLive = false; #seedApplied = false; #doWhileRefreshing: (() => void) | undefined; @@ -99,11 +189,46 @@ export class SearchResource< #dependencyTracking: RuntimeDependencyTrackingContext | undefined; #log = runtimeLogger('search-resource'); #trackedLoadCount = 0; + // Holds Store references for merged candidates that aren't in `_instances` + // (those server results are referenced by `updateInstances`). Reconciliation + // is deferred out of the render frame; released on teardown. + #candidateRefs = new CandidateReferenceRetainer( + () => this.runtimeStore, + this, + ); private get runtimeStore(): StoreService { return this.#storeServiceOverride ?? this.store; } + private loadMatchAPI(): void { + if (this.matchAPI || this.#matchAPILoading) { + return; + } + this.#matchAPILoading = true; + // Held by the test waiter so `settled()` blocks until the matcher + // dependencies are loaded and the search is eligible to reconcile. + let token = waiter.beginAsync(); + this.runtimeStore + .getMatchAPI() + .then((api) => { + if (isDestroyed(this) || isDestroying(this)) { + return; + } + this.matchAPI = api; + }) + .catch((error) => { + this.#log.error( + 'failed to load card-api for client-side filtering', + error, + ); + }) + .finally(() => { + this.#matchAPILoading = false; + waiter.endAsync(token); + }); + } + private trackStoreLoad( load: Promise | undefined, source: 'seed' | 'search' | 'live-refresh', @@ -209,6 +334,13 @@ export class SearchResource< `modify: query present; isLive=${isLive}; realms=${realms?.join(',') ?? '(default)'}`, ); this.#isLive = isLive; + if (isLive) { + // The client-side filtering step only runs for live searches; load its + // matcher dependencies eagerly so the first eligible result set can be + // reconciled without waiting on a Store mutation to trigger it. + this.loadMatchAPI(); + } + this.activeQuery = query; this.#doWhileRefreshing = doWhileRefreshing; this.#dependencyTracking = named.dependencyTracking; this.realmsToSearch = @@ -337,8 +469,146 @@ export class SearchResource< return this.#isLive; } get instances() { - return this._instances; + return this.displayedInstances; + } + + // The displayed result set. For an eligible live search this is the server + // result reconciled against in-memory Store state (CS-11416); otherwise the + // server result is returned untouched. + // + // Reactive recompute: this getter reads the tracked Store identity map + // (`allCardInstances`, which changes on create/delete) and the Store's + // `instanceMutationVersion` (which bumps on edit/save), so it re-derives + // when a relevant Store card mutates — not only when the server search + // re-runs. As a memoized getter it is recomputed lazily, on the next read + // after an invalidation, which naturally coalesces bursts of mutations + // between renders. A dirty-set / incremental optimization is deferred to + // CS-11419. + @cached + private get displayedInstances(): T[] { + let serverInstances = this._instances; + if (!this.isClientFilterEligible) { + // Passthrough: nothing is merged, so release any candidate references + // a prior eligible recompute retained. + this.#candidateRefs.retain([]); + return serverInstances; + } + let api = this.matchAPI!; + let filter = this.activeQuery?.filter; + + // Reading the mutation-version signal here establishes the dependency that + // re-derives on in-place field edits/saves (adds and deletes already flow + // through the tracked identity map read below). + void this.runtimeStore.instanceMutationVersion; + + // The matcher and comparator operate on the card-api instance surface, + // which both CardDef and FileDef expose; file-meta searches are handled + // exactly like card searches. + let localMatch = (instance: CardDef | FileDef): MatchResult => + filter + ? matchInstanceAgainstFilter(instance as CardDef, filter, api) + : 'match'; + + // Keep every server-returned instance unless it now demonstrably fails the + // filter locally. An `unresolvable` predicate (e.g. a linked target absent + // from the Store) never removes a server result. + let serverResults = serverInstances as (CardDef | FileDef)[]; + let kept = serverResults.filter( + (instance) => localMatch(instance) !== 'no-match', + ); + + let serverIds = new Set( + serverResults.map((instance) => instance.id).filter(Boolean) as string[], + ); + + // The candidate pool is drawn from the same kind (card vs file-meta) as the + // search. Dispatch by the query's target type rather than sniffing the + // first returned row: a complete file-meta search can return zero rows yet + // still need the file-meta pool to surface a locally hydrated FileDef. Fall + // back to the row when the query carries no top-level type ref. + let filterRef = filter as { on?: CodeRef; type?: CodeRef } | undefined; + let isFileSearch = isFileDefCodeRef(filterRef?.on ?? filterRef?.type) + ? true + : serverResults.length > 0 && isFileDefInstance(serverResults[0]); + let candidatePool: (CardDef | FileDef)[] = isFileSearch + ? this.runtimeStore.allFileMetaInstances() + : this.runtimeStore.allCardInstances(); + + // Add candidates the server didn't return but that match locally, scoped to + // the query's target realm(s). `unresolvable` candidates are not added. + let added = candidatePool.filter( + (instance) => + instance.id != null && + !serverIds.has(instance.id) && + this.isInTargetRealm(instance.id) && + localMatch(instance) === 'match', + ); + + // These candidates are displayed but absent from `_instances`, so the + // server-result reference bookkeeping in `updateInstances` doesn't cover + // them. Retain Store references while they're shown so the GC can't sweep a + // card that's still on screen; reconciliation is deferred out of this + // render frame (see CandidateReferenceRetainer). + this.#candidateRefs.retain( + added.map((instance) => instance.id).filter(Boolean) as string[], + ); + + // Dedupe by id before sorting: the candidate pool collapses local/remote + // id aliases already, but a defensive pass here guarantees the displayed + // set never renders the same card twice regardless of how the pool or + // server result was assembled. + let seenIds = new Set(); + let merged: (CardDef | FileDef)[] = []; + for (let instance of [...kept, ...added]) { + let id = instance.id; + if (id != null) { + if (seenIds.has(id)) { + continue; + } + seenIds.add(id); + } + merged.push(instance); + } + let comparator = makeInstanceComparator(this.activeQuery?.sort, api); + merged.sort((a, b) => comparator(a as CardDef, b as CardDef)); + return merged as unknown as T[]; } + + // Per CS-11417: the client filtering step runs only for a live search whose + // server response is a complete, fully-client-evaluable result set (card or + // file-meta alike). Any other search is a server-only passthrough. + private get isClientFilterEligible(): boolean { + if (!this.#isLive) { + return false; + } + let api = this.matchAPI; + if (!api) { + // Matcher dependencies not loaded yet — pass through until they are. + return false; + } + // Complete result set: the server returns the full match count across all + // pages in `meta.page.total`; an incompletely-loaded set (load-more / + // infinite scroll) is not safe to reconcile locally. + let total = this._meta?.page?.total; + if (total == null || this._instances.length !== total) { + return false; + } + // A `matches` (full-text) or otherwise unsupported operator forces + // server-only evaluation. A query with no filter matches everything and is + // eligible. + let filter = this.activeQuery?.filter as Filter | undefined; + if (filter && !isClientEvaluable(filter)) { + return false; + } + return true; + } + + private isInTargetRealm(id: RealmResourceIdentifier): boolean { + return this.realmsToSearch.some((realm) => + new RealmPaths(realm).inRealm(id), + ); + } + @cached get instancesByRealm() { return this.realmsToSearch diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 6a649e8f23f..9268c234b94 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -7,8 +7,8 @@ import type Owner from '@ember/owner'; import { getOwner } from '@ember/owner'; import Service, { service } from '@ember/service'; import { buildWaiter } from '@ember/test-waiters'; - import { isTesting } from '@embroider/macros'; +import { tracked } from '@glimmer/tracking'; import { formatDistanceToNow } from 'date-fns'; import { task } from 'ember-concurrency'; @@ -49,6 +49,7 @@ import { isJsonContentType, SupportedMimeType, RealmPaths, + type CardAPIForMatching, type Store as StoreInterface, type AddOptions, type CreateOptions, @@ -228,6 +229,13 @@ export default class StoreService extends Service implements StoreInterface { private referenceCount: ReferenceCount = new Map(); private newReferencePromises: Promise[] = []; private autoSaveStates: TrackedMap = new TrackedMap(); + // Bumped whenever a hydrated card instance's fields change (edit / save). + // Adds and deletes are already observable through the tracked identity map + // (see `allCardInstances`); this signal covers the in-place mutations that + // leave the map's membership unchanged, so a reactive consumer like the + // client-side search filter recomputes on edits too — not only on the + // server search re-running. + @tracked private _instanceMutationVersion = 0; private cardApiCache?: typeof CardAPI; private gcInterval: number | undefined; private ready: Promise; @@ -756,6 +764,44 @@ export default class StoreService extends Service implements StoreInterface { return this.store.getCardInstanceOrError(id); } + // All hydrated (non-error) card instances currently in the Store. The result + // is the candidate set for the client-side search filter; reading it inside + // an autotracked computation re-runs when an instance is added or removed. + allCardInstances(): CardDef[] { + return this.store.allCardInstances(); + } + + // The file-meta counterpart of `allCardInstances`, so a file-meta search + // gets the same client-side candidate set as a card search. + allFileMetaInstances(): FileDef[] { + return this.store.allFileMetaInstances(); + } + + // Tracked counter bumped on every in-place field edit/save of a hydrated + // card. Reading it inside an autotracked computation makes that computation + // recompute when any Store card mutates — used by the client-side search + // filter to re-derive its result set without a server round-trip. Adds and + // removes are already covered by the tracked identity map behind + // `allCardInstances`. + get instanceMutationVersion(): number { + return this._instanceMutationVersion; + } + + // The slice of the card-api module the client-side filter matcher and sort + // comparator need (see runtime-common's instance-filter-matcher). Loaded + // through the same loader-scoped cache the rest of the Store uses. + async getMatchAPI(): Promise { + let api = await this.cardService.getAPI(); + return { + getQueryableValue: api.getQueryableValue, + formatQueryValue: api.formatQueryValue, + peekAtField: api.peekAtField, + isNonPresentLink: api.isNonPresentLink, + getCardMeta: api.getCardMeta as CardAPIForMatching['getCardMeta'], + primitive: api.primitive, + }; + } + // peekError will always return the current server state regarding errors for this id peekError(id: string, opts?: { type?: 'card' }): CardErrorJSONAPI | undefined; peekError( @@ -1777,6 +1823,7 @@ export default class StoreService extends Service implements StoreInterface { return; } if (isCardInstance(instance)) { + this._instanceMutationVersion++; let autoSaveState = this.initOrGetAutoSaveState(instance); autoSaveState.hasUnsavedChanges = true; this.doAutoSave(instance); diff --git a/packages/host/memory-baseline.json b/packages/host/memory-baseline.json index ff5b2494b5c..e5df0e638a6 100644 --- a/packages/host/memory-baseline.json +++ b/packages/host/memory-baseline.json @@ -1023,6 +1023,10 @@ "delta_mb": 1, "samples": [1, 1, 1, 1, 1] }, + "Unit | isFileDefCodeRef": { + "delta_mb": 0, + "samples": [0, 0, 0, 0, 0] + }, "Unit | isMarkdownSkillId": { "delta_mb": 0, "samples": [0, 0, 0, 0, 0] diff --git a/packages/host/tests/integration/resources/search-test.ts b/packages/host/tests/integration/resources/search-test.ts index 84e766a6f46..9fe44406e78 100644 --- a/packages/host/tests/integration/resources/search-test.ts +++ b/packages/host/tests/integration/resources/search-test.ts @@ -24,6 +24,8 @@ import RealmService from '@cardstack/host/services/realm'; import type RealmServerService from '@cardstack/host/services/realm-server'; import type StoreService from '@cardstack/host/services/store'; +import type { CardDef } from 'https://cardstack.com/base/card-api'; + import { setupIntegrationTestRealm, setupLocalIndexing, @@ -1363,4 +1365,455 @@ module(`Integration | search resource`, function (hooks) { }); }, ); + + module(`client-side Store filtering step`, function (innerHooks) { + // Counts only `_federated-search` calls, so an assertion that the client + // step recomputed "without a server round-trip" isn't perturbed by other + // traffic in the test environment. + let fetchCalls: number; + let restoreFetch: (() => void) | undefined; + + innerHooks.beforeEach(function () { + fetchCalls = 0; + let realmServer = getService('realm-server') as RealmServerService; + let original = realmServer.maybeAuthedFetchForRealms.bind(realmServer); + realmServer.maybeAuthedFetchForRealms = (async (url, ...args) => { + if (typeof url === 'string' && url.includes('_federated-search')) { + fetchCalls++; + } + return await original(url, ...args); + }) as RealmServerService['maybeAuthedFetchForRealms']; + restoreFetch = () => { + realmServer.maybeAuthedFetchForRealms = original; + }; + }); + + innerHooks.afterEach(function () { + restoreFetch?.(); + restoreFetch = undefined; + }); + + const bookRef = { module: testRRI('book'), name: 'Book' }; + let abdelRahmanQuery: Query = { + filter: { on: bookRef, eq: { 'author.lastName': 'Abdel-Rahman' } }, + }; + + // Hydrate a Book into the Store without persisting it — a candidate the + // server doesn't (yet) know about, standing in for a card created/edited + // locally before the realm reindexes. + async function addBookCandidate( + idPath: string, + firstName: string, + lastName: string, + ): Promise { + return await storeService.add( + { + data: { + type: 'card', + id: `${testRealmURL}${idPath}`, + attributes: { + author: { firstName, lastName }, + editions: 0, + pubDate: '2024-01-01', + }, + meta: { adoptsFrom: bookRef }, + }, + } as LooseSingleCardDocument, + { doNotPersist: true }, + ); + } + + test(`a locally added matching card appears in an eligible live search without a server round-trip`, async function (assert) { + let search = getSearchResourceForTest(loaderService, () => ({ + named: { + query: abdelRahmanQuery, + realms: [testRealmURL], + isLive: true, + isAutoSaved: false, + storeService, + owner: this.owner, + }, + })); + await search.loaded; + await settled(); + assert.strictEqual( + search.instances.length, + 2, + 'server returns the two matching books', + ); + + let fetchesBefore = fetchCalls; + await addBookCandidate('books/local-new', 'Apple', 'Abdel-Rahman'); + await settled(); + + let ids = search.instances.map((i) => i.id); + assert.strictEqual( + search.instances.length, + 3, + 'the locally added matching card is merged into the result set', + ); + assert.ok( + ids.includes(rri(`${testRealmURL}books/local-new`)), + 'the new card appears in results', + ); + assert.strictEqual( + fetchCalls, + fetchesBefore, + 'no _federated-search fetch fired for the client-side recompute', + ); + }); + + test(`a surfaced candidate is not duplicated even though it is keyed by both local and remote id`, async function (assert) { + // A hydrated instance lives in the Store identity map under BOTH its + // local id and its remote id (see gc-card-store's setCardItem). Without + // deduping, the candidate pool yields it twice and the client merge + // renders the same card as two rows. This guards that the displayed set + // holds each card once regardless of the dual keying. + let search = getSearchResourceForTest(loaderService, () => ({ + named: { + query: abdelRahmanQuery, + realms: [testRealmURL], + isLive: true, + isAutoSaved: false, + storeService, + owner: this.owner, + }, + })); + await search.loaded; + await settled(); + + let candidate = await addBookCandidate( + 'books/dupe-check', + 'Apple', + 'Abdel-Rahman', + ); + await settled(); + + // The Store keys the same instance under two ids, but the candidate pool + // must surface it once. + let poolOccurrences = storeService + .allCardInstances() + .filter((c) => c === candidate).length; + assert.strictEqual( + poolOccurrences, + 1, + 'the candidate appears exactly once in allCardInstances despite dual keying', + ); + + let ids = search.instances.map((i) => i.id); + assert.strictEqual( + ids.length, + new Set(ids).size, + 'the displayed result set has no duplicate ids', + ); + assert.strictEqual( + ids.filter((id) => id === rri(`${testRealmURL}books/dupe-check`)) + .length, + 1, + 'the surfaced candidate is rendered exactly once', + ); + assert.strictEqual( + search.instances.length, + 3, + 'two server books plus the single surfaced candidate', + ); + }); + + test(`a server-returned card that no longer matches local state is removed`, async function (assert) { + let search = getSearchResourceForTest(loaderService, () => ({ + named: { + query: abdelRahmanQuery, + realms: [testRealmURL], + isLive: true, + isAutoSaved: false, + storeService, + owner: this.owner, + }, + })); + await search.loaded; + await settled(); + assert.strictEqual(search.instances.length, 2); + + // Mutate a server-returned card so it no longer satisfies the filter. + // The removal is derived from in-memory state; we read synchronously, + // before the (async, debounced) autosave + reindex can re-query. + let book1 = storeService.peek(`${testRealmURL}books/1`) as any; + book1.author.lastName = 'Changed'; + + let ids = search.instances.map((i) => i.id); + assert.strictEqual( + search.instances.length, + 1, + 'the now-non-matching server card is removed', + ); + assert.notOk( + ids.includes(rri(`${testRealmURL}books/1`)), + 'books/1 dropped from results', + ); + }); + + test(`editing a candidate toggles its membership reactively (recompute on Store mutation)`, async function (assert) { + let search = getSearchResourceForTest(loaderService, () => ({ + named: { + query: abdelRahmanQuery, + realms: [testRealmURL], + isLive: true, + isAutoSaved: false, + storeService, + owner: this.owner, + }, + })); + await search.loaded; + await settled(); + assert.strictEqual(search.instances.length, 2); + + let candidate = await addBookCandidate( + 'books/edit-me', + 'Switch', + 'Other', + ); + await settled(); + assert.strictEqual( + search.instances.length, + 2, + 'a candidate that does not match is not shown', + ); + + candidate.author.lastName = 'Abdel-Rahman'; + assert.strictEqual( + search.instances.length, + 3, + 'after editing to match, the candidate appears', + ); + assert.ok( + search.instances + .map((i) => i.id) + .includes(rri(`${testRealmURL}books/edit-me`)), + ); + + candidate.author.lastName = 'Other again'; + assert.strictEqual( + search.instances.length, + 2, + 'after editing away from match, the candidate disappears', + ); + }); + + test(`a merged candidate is reference-retained while displayed and released when it leaves`, async function (assert) { + // Candidates surfaced by the merge are absent from `_instances`, so + // `updateInstances`' reference bookkeeping doesn't cover them. Without a + // retained reference they sit at count zero and the Store GC can sweep + // them mid-render. The resource must hold a reference while the candidate + // is displayed and drop it once it leaves the result set. + let search = getSearchResourceForTest(loaderService, () => ({ + named: { + query: abdelRahmanQuery, + realms: [testRealmURL], + isLive: true, + isAutoSaved: false, + storeService, + owner: this.owner, + }, + })); + await search.loaded; + await settled(); + + let candidate = await addBookCandidate( + 'books/retained', + 'Apple', + 'Abdel-Rahman', + ); + let candidateId = `${testRealmURL}books/retained`; + await settled(); + assert.strictEqual( + storeService.getReferenceCount(candidateId), + 0, + 'a freshly hydrated candidate carries no reference before it is displayed', + ); + + // Reading `instances` drives the merge derivation, which declares the + // candidate as in-use; the reference reconciliation is deferred, so flush + // with settled() before asserting. + assert.ok( + search.instances.map((i) => i.id).includes(rri(candidateId)), + 'the candidate is displayed', + ); + await settled(); + assert.ok( + storeService.getReferenceCount(candidateId) > 0, + 'a Store reference is retained while the candidate is displayed', + ); + + // Edit it out of the result set; the retained reference must be released. + candidate.author.lastName = 'Other'; + assert.notOk( + search.instances.map((i) => i.id).includes(rri(candidateId)), + 'the candidate leaves the result set', + ); + await settled(); + assert.strictEqual( + storeService.getReferenceCount(candidateId), + 0, + 'the reference is released once the candidate is no longer displayed', + ); + }); + + test(`the merged set is ordered per the query sort`, async function (assert) { + let sortedQuery: Query = { + filter: { on: bookRef, eq: { 'author.lastName': 'Abdel-Rahman' } }, + sort: [{ by: 'author.firstName', on: bookRef, direction: 'asc' }], + }; + let search = getSearchResourceForTest(loaderService, () => ({ + named: { + query: sortedQuery, + realms: [testRealmURL], + isLive: true, + isAutoSaved: false, + storeService, + owner: this.owner, + }, + })); + await search.loaded; + await settled(); + // Server order by author.firstName asc: Mango (books/1), Van Gogh (books/2). + assert.deepEqual( + search.instances.map((i) => i.id), + [`${testRealmURL}books/1`, `${testRealmURL}books/2`], + ); + + await addBookCandidate('books/apple', 'Apple', 'Abdel-Rahman'); + await settled(); + assert.deepEqual( + search.instances.map((i) => i.id), + [ + `${testRealmURL}books/apple`, + `${testRealmURL}books/1`, + `${testRealmURL}books/2`, + ], + 'the candidate sorts into position by author.firstName', + ); + }); + + test(`one-shot store.search never runs the client step (server-only)`, async function (assert) { + await addBookCandidate('books/local-only', 'Local', 'Abdel-Rahman'); + + let result = (await storeService.search(abdelRahmanQuery, [ + testRealmURL, + ])) as CardDef[]; + assert.notOk( + result + .map((c) => c.id) + .includes(`${testRealmURL}books/local-only` as CardDef['id']), + 'one-shot store.search ignores the local-only candidate', + ); + }); + + test(`a non-live search is a server-only passthrough`, async function (assert) { + await addBookCandidate('books/passthrough', 'Zoe', 'Abdel-Rahman'); + + let search = getSearchResourceForTest(loaderService, () => ({ + named: { + query: abdelRahmanQuery, + realms: [testRealmURL], + isLive: false, + isAutoSaved: false, + storeService, + owner: this.owner, + }, + })); + await search.loaded; + await settled(); + assert.strictEqual( + search.instances.length, + 2, + 'a non-live search ignores the local candidate', + ); + assert.notOk( + search.instances + .map((i) => i.id) + .includes(rri(`${testRealmURL}books/passthrough`)), + ); + }); + + test(`an incompletely-loaded (paginated) result set skips the client step`, async function (assert) { + let pagedQuery: Query = { + filter: { type: bookRef }, + page: { number: 0, size: 2 }, + sort: [{ by: 'author.firstName', on: bookRef, direction: 'asc' }], + }; + let search = getSearchResourceForTest(loaderService, () => ({ + named: { + query: pagedQuery, + realms: [testRealmURL], + isLive: true, + isAutoSaved: false, + storeService, + owner: this.owner, + }, + })); + await search.loaded; + await settled(); + assert.strictEqual(search.instances.length, 2, 'first page holds 2'); + assert.strictEqual( + search.meta.page?.total, + 4, + 'total is 4 across all pages — the loaded set is incomplete', + ); + + await addBookCandidate('books/paged-extra', 'Aaa', 'Zzz'); + await settled(); + assert.strictEqual( + search.instances.length, + 2, + 'an incomplete result set is not reconciled against the Store', + ); + assert.notOk( + search.instances + .map((i) => i.id) + .includes(rri(`${testRealmURL}books/paged-extra`)), + ); + }); + + test(`a non-client-evaluable filter (matches) forces server-only`, async function (assert) { + // `matches` is a full-text (markdown) predicate the client matcher cannot + // evaluate, so an otherwise-live search over it is a server-only + // passthrough: its displayed set stays exactly the server result, with no + // Store candidate merged in. + let matchesQuery: Query = { filter: { matches: 'Abdel-Rahman' } }; + let search = getSearchResourceForTest(loaderService, () => ({ + named: { + query: matchesQuery, + realms: [testRealmURL], + isLive: true, + isAutoSaved: false, + storeService, + owner: this.owner, + }, + })); + await search.loaded; + await settled(); + let serverIds = search.instances.map((i) => i.id); + + // A hydrated Store candidate in the target realm — the kind a + // client-evaluable filter would surface — must not be merged into a + // matches search. + await addBookCandidate('books/matches-extra', 'Nope', 'Abdel-Rahman'); + await settled(); + assert.ok( + storeService.peek(`${testRealmURL}books/matches-extra`), + 'the candidate is hydrated in the Store', + ); + assert.deepEqual( + search.instances.map((i) => i.id), + serverIds, + 'the displayed set stays equal to the server result — no candidate merged', + ); + assert.notOk( + search.instances + .map((i) => i.id) + .includes(rri(`${testRealmURL}books/matches-extra`)), + 'the locally added candidate is not pulled into a matches search', + ); + }); + }); }); diff --git a/packages/host/tests/unit/file-def-code-ref-test.ts b/packages/host/tests/unit/file-def-code-ref-test.ts new file mode 100644 index 00000000000..0b67a61f3d3 --- /dev/null +++ b/packages/host/tests/unit/file-def-code-ref-test.ts @@ -0,0 +1,54 @@ +import { module, test } from 'qunit'; + +import { baseRRI, isFileDefCodeRef } from '@cardstack/runtime-common'; +import type { RealmResourceIdentifier } from '@cardstack/runtime-common'; + +// `isFileDefCodeRef` lets the client-side search filter dispatch its candidate +// pool (cards vs. file-meta) from the query's target type, so a file-meta +// search that is complete with zero server rows still reconciles locally +// hydrated FileDefs — rather than sniffing the kind off the first returned row, +// which is unavailable when the result set is empty. +module('Unit | isFileDefCodeRef', function () { + test('recognizes the base FileDef ref', function (assert) { + assert.true( + isFileDefCodeRef({ module: baseRRI('card-api'), name: 'FileDef' }), + ); + }); + + test('recognizes a known extension subtype ref', function (assert) { + assert.true( + isFileDefCodeRef({ + module: baseRRI('markdown-file-def'), + name: 'MarkdownDef', + }), + 'MarkdownDef', + ); + assert.true( + isFileDefCodeRef({ module: baseRRI('png-image-def'), name: 'PngDef' }), + 'PngDef', + ); + }); + + test('rejects a non-FileDef card ref', function (assert) { + assert.false( + isFileDefCodeRef({ module: baseRRI('card-api'), name: 'CardDef' }), + ); + assert.false( + isFileDefCodeRef({ + module: 'http://test-realm/test/book' as RealmResourceIdentifier, + name: 'Book', + }), + ); + }); + + test('rejects undefined and non-resolved refs', function (assert) { + assert.false(isFileDefCodeRef(undefined)); + assert.false( + isFileDefCodeRef({ + type: 'ancestorOf', + card: { module: baseRRI('card-api'), name: 'FileDef' }, + }), + 'a structured (non-module/name) ref is not treated as a FileDef ref', + ); + }); +}); diff --git a/packages/runtime-common/file-def-code-ref.ts b/packages/runtime-common/file-def-code-ref.ts index 01fdcdf9cd9..a7771e78db0 100644 --- a/packages/runtime-common/file-def-code-ref.ts +++ b/packages/runtime-common/file-def-code-ref.ts @@ -1,5 +1,5 @@ import { baseRealm, baseFileRef } from './constants.ts'; -import type { ResolvedCodeRef } from './code-ref.ts'; +import type { CodeRef, ResolvedCodeRef } from './code-ref.ts'; import type { RealmResourceIdentifier } from './realm-identifiers.ts'; import type { VirtualNetwork } from './virtual-network.ts'; @@ -39,6 +39,31 @@ const FILEDEF_CODE_REF_BY_EXTENSION: Record = { }, }; +// Whether a code ref targets a file-meta (FileDef) type: the base FileDef ref +// or one of the known extension-specific subtypes. Used to dispatch a search to +// the file-meta candidate pool from the query alone — without depending on the +// server having returned a row to sniff — so an empty-but-complete file-meta +// search still reconciles locally hydrated FileDefs. +export function isFileDefCodeRef(ref: CodeRef | undefined): boolean { + if ( + !ref || + !('module' in ref) || + typeof ref.module !== 'string' || + typeof ref.name !== 'string' + ) { + return false; + } + if (ref.module === baseFileRef.module && ref.name === baseFileRef.name) { + return true; + } + for (let subtype of Object.values(FILEDEF_CODE_REF_BY_EXTENSION)) { + if (ref.module === subtype.module && ref.name === subtype.name) { + return true; + } + } + return false; +} + export function resolveFileDefCodeRef( fileURL: URL, virtualNetwork: VirtualNetwork,