Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion packages/host/app/resources/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import {
isFileDefInstance,
isFileDefCodeRef,
isClientEvaluable,
canonicalizeFilterRefs,
identifyCard,
isResolvedCodeRef,
matchInstanceAgainstFilter,
makeInstanceComparator,
logger as runtimeLogger,
Expand All @@ -44,6 +47,7 @@ import type { Filter, Query } from '@cardstack/runtime-common/query';

import { searchErrorEntry } from '../lib/search-error-entry';

import type LoaderService from '../services/loader-service';
import type NetworkService from '../services/network';
import type RealmServerService from '../services/realm-server';
import type StoreService from '../services/store';
Expand Down Expand Up @@ -162,6 +166,7 @@ export class SearchResource<
@service declare private network: NetworkService;
@service declare private realmServer: RealmServerService;
@service declare private store: StoreService;
@service declare private loaderService: LoaderService;
#storeServiceOverride: StoreService | undefined;
@tracked private realmsToSearch: RealmIdentifier[] = [];
// Resist the urge to expose this property publicly as that may entice
Expand All @@ -183,6 +188,19 @@ export class SearchResource<
// once it lands.
@tracked private matchAPI: CardAPIForMatching | undefined;
#matchAPILoading = false;
// The active filter with its `type`/`on` refs rewritten to canonical
// (defining-module) form, mirroring the rewrite the query engine applies
// before compiling `types` membership predicates. The matcher compares refs
// against `identifyCard` of an instance's class — the canonical ref — so a
// filter carrying a re-export spelling (e.g. file-api's FileDef) must be
// canonicalized here too or the reconciler would strip the server's correct
// results. `source` ties the rewrite to the exact filter object it was
// computed from; `incomplete` means some ref didn't resolve, in which case
// the search stays a server-only passthrough rather than risking a client
// evaluation the server disagrees with.
@tracked private canonicalizedFilter:
| { source: Filter; filter: Filter; incomplete: boolean }
| undefined;
// 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).
Expand Down Expand Up @@ -238,6 +256,62 @@ export class SearchResource<
});
}

// Canonicalizes the live filter's `type`/`on` refs via the loader (import
// the named module, identify the export's defining-module ref) — the same
// rewrite the query engine performs through its definition lookup. Until
// this settles for the current filter, `isClientFilterEligible` keeps the
// search a server-only passthrough.
private loadCanonicalizedFilter(filter: Filter | undefined): void {
if (!filter) {
this.canonicalizedFilter = undefined;
return;
}
if (this.canonicalizedFilter?.source === filter) {
return;
}
let token = waiter.beginAsync();
canonicalizeFilterRefs(filter, async (ref) => {
try {
let module = await this.loaderService.loader.import<
Record<string, unknown>
>(ref.module);
let canonical = identifyCard(
module[ref.name] as Parameters<typeof identifyCard>[0],
);
return canonical && isResolvedCodeRef(canonical)
? canonical
: undefined;
} catch (error) {
this.#log.warn(
`could not canonicalize filter ref ${ref.module}/${ref.name}: ${error}`,
);
return undefined;
}
})
.then(({ filter: canonical, incomplete }) => {
if (isDestroyed(this) || isDestroying(this)) {
return;
}
// A newer filter may have become the active one while this resolved
// (sibling canonicalizations race, and a cache-warm import resolves
// ahead of a cold one). Drop the stale result so a late resolution
// can't clobber the current filter's result and leave `source` pointing
// at a filter that's no longer active — which would keep the search a
// server-only passthrough until the next query change.
if (this.activeQuery?.filter !== filter) {
return;
}
this.canonicalizedFilter = {
source: filter,
filter: canonical,
incomplete,
};
Comment on lines +291 to +308

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Out-of-order canonicalization leaves a stale source and stickily disables client reconciliation (regression introduced by this PR; non-blocking — it degrades to a safe server-only passthrough, but silently and until the next query change).

The mechanism. loadCanonicalizedFilter runs on every live modify() (search.ts :424, before this.activeQuery = query at :426). Its early-return guard (this.canonicalizedFilter?.source === filter, :269) compares only against the settled value, so while a first canonicalization for filter A is still in flight, a second modify() carrying filter B (a new object) passes the guard and starts a second concurrent canonicalizeFilterRefs. This .then then writes canonicalizedFilter = { source: filter, … } unconditionally, so whichever promise settles last wins.

The two resolutions are loader.import(ref.module) calls: A triggers the cold import (possibly a network fetch); B, arriving later, resolves the same module from the loader cache almost immediately. So B settles first and sets source: B, then A settles and clobbers it with source: A. Now this.activeQuery.filter === B but this.canonicalizedFilter.source === A.

The consequence. isClientFilterEligible requires canonicalized.source === filter (:712–717), so a stale source makes it return false and displayedInstances returns the server set untouched (:589–593). Results stay correct, but live client-side reconciliation (candidate add + local no-match removal) stays off until the next modify() whose canonicalization happens to win the race — a live-refresh realm event calls this.search.perform(this.#previousQuery) without re-running modify(), so nothing re-triggers it in between. Sticky.

The fix. Drop a stale result in the .then. It fires synchronously after this.activeQuery = query is set within the same modify(), so activeQuery.filter is already the latest by the time any resolution lands:

Suggested change
.then(({ filter: canonical, incomplete }) => {
if (isDestroyed(this) || isDestroying(this)) {
return;
}
this.canonicalizedFilter = {
source: filter,
filter: canonical,
incomplete,
};
.then(({ filter: canonical, incomplete }) => {
if (isDestroyed(this) || isDestroying(this)) {
return;
}
// A newer filter may have become active while this resolved; drop the
// stale result so a late resolution can't clobber the current one.
if (this.activeQuery?.filter !== filter) {
return;
}
this.canonicalizedFilter = {
source: filter,
filter: canonical,
incomplete,
};
})

Related. Because the guard and memo key on filter object identity, an unstable () => query thunk that rebuilds a deeply-equal filter on each recompute re-triggers canonicalization (and a transient passthrough window) on every modify(), even though the querySignature deep-equal check at :537 already skips the redundant search. Keying eligibility off querySignature rather than identity would also close that gap — but the staleness guard above is the load-bearing fix.

Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in 2a58eb1006. The .then now drops its result when this.activeQuery?.filter !== filter, so a late resolution (a cold loader.import settling after a cache-warm sibling) can no longer overwrite canonicalizedFilter with a source that points at a filter that is no longer active. The guard is sound because the .then fires only after this.activeQuery = query has run synchronously within the same modify(), so the comparison always sees the current filter.

The related identity-keying note (an unstable () => query thunk re-triggering canonicalization) is left as-is — the staleness guard makes the transient passthrough self-correct, and moving eligibility onto querySignature is a larger change than this finding warrants.

})
.finally(() => {
waiter.endAsync(token);
});
}

private trackStoreLoad(
load: Promise<void> | undefined,
source: 'seed' | 'search' | 'live-refresh',
Expand Down Expand Up @@ -356,6 +430,7 @@ export class SearchResource<
// matcher dependencies eagerly so the first eligible result set can be
// reconciled without waiting on a Store mutation to trigger it.
this.loadMatchAPI();
this.loadCanonicalizedFilter(query.filter);
}
this.activeQuery = query;
this.#doWhileRefreshing = doWhileRefreshing;
Expand Down Expand Up @@ -527,7 +602,9 @@ export class SearchResource<
return serverInstances;
}
let api = this.matchAPI!;
let filter = this.activeQuery?.filter;
// Eligibility (below) guarantees the canonicalized form is present and
// complete whenever the active query carries a filter.
let filter = this.canonicalizedFilter?.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
Expand Down Expand Up @@ -636,6 +713,20 @@ export class SearchResource<
if (filter && !isClientEvaluable(filter)) {
return false;
}
// The matcher needs the filter's refs in canonical form (the engine
// rewrites them server-side; see loadCanonicalizedFilter). Until the
// rewrite lands for this exact filter — or if some ref didn't resolve —
// client evaluation could disagree with the server, so pass through.
if (filter) {
let canonicalized = this.canonicalizedFilter;
if (
!canonicalized ||
canonicalized.source !== filter ||
canonicalized.incomplete
) {
return false;
}
}
return true;
}

Expand Down
115 changes: 115 additions & 0 deletions packages/host/tests/unit/query-canonicalization-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { module, test } from 'qunit';

import {
canonicalizeFilterRefs,
rri,
type ResolvedCodeRef,
type Filter,
} from '@cardstack/runtime-common';

const CANONICAL_FILE_DEF: ResolvedCodeRef = {
module: rri('https://cardstack.com/base/card-api'),
name: 'FileDef',
};
const REEXPORT_FILE_DEF: ResolvedCodeRef = {
module: rri('https://cardstack.com/base/file-api'),
name: 'FileDef',
};
const PERSON: ResolvedCodeRef = {
module: rri('http://example.com/person'),
name: 'Person',
};

// Resolves the file-api re-export spelling to the canonical card-api ref,
// echoes already-canonical refs, and fails everything else.
async function resolve(
ref: ResolvedCodeRef,
): Promise<ResolvedCodeRef | undefined> {
if (
ref.module === REEXPORT_FILE_DEF.module &&
ref.name === REEXPORT_FILE_DEF.name
) {
return CANONICAL_FILE_DEF;
}
if (
(ref.module === CANONICAL_FILE_DEF.module &&
ref.name === CANONICAL_FILE_DEF.name) ||
(ref.module === PERSON.module && ref.name === PERSON.name)
) {
return ref;
}
return undefined;
}

module('Unit | query-canonicalization', function () {
test('rewrites a top-level `type` ref to its canonical form', async function (assert) {
let { filter, incomplete } = await canonicalizeFilterRefs(
{ type: REEXPORT_FILE_DEF },
resolve,
);
assert.deepEqual(filter, { type: CANONICAL_FILE_DEF });
assert.false(incomplete);
});

test('rewrites `on` refs nested through any/every/not', async function (assert) {
let input: Filter = {
any: [
{ on: PERSON, eq: { name: 'x' } },
{
every: [
{ not: { on: REEXPORT_FILE_DEF, eq: { name: 'y' } } },
{ type: REEXPORT_FILE_DEF },
],
},
],
};
let { filter, incomplete } = await canonicalizeFilterRefs(input, resolve);
assert.deepEqual(filter, {
any: [
{ on: PERSON, eq: { name: 'x' } },
{
every: [
{ not: { on: CANONICAL_FILE_DEF, eq: { name: 'y' } } },
{ type: CANONICAL_FILE_DEF },
],
},
],
});
assert.false(incomplete);
// the input filter is not mutated
assert.deepEqual(
(input.any![1] as { every: Filter[] }).every[1],
{ type: REEXPORT_FILE_DEF },
'input tree is left untouched',
);
});

test('an unresolvable ref stays as-given and marks the result incomplete', async function (assert) {
let bogus: ResolvedCodeRef = {
module: rri('http://example.com/nope'),
name: 'Nope',
};
let { filter, incomplete } = await canonicalizeFilterRefs(
{ every: [{ type: bogus }, { on: PERSON, eq: { name: 'x' } }] },
resolve,
);
assert.deepEqual(filter, {
every: [{ type: bogus }, { on: PERSON, eq: { name: 'x' } }],
});
assert.true(incomplete);
});

test('duplicate refs resolve once', async function (assert) {
let calls = 0;
await canonicalizeFilterRefs(
{
any: [{ type: REEXPORT_FILE_DEF }, { type: REEXPORT_FILE_DEF }],
},
async (ref) => {
calls++;
return resolve(ref);
},
);
assert.strictEqual(calls, 1);
});
});
68 changes: 68 additions & 0 deletions packages/realm-server/tests/search-entries-engine-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,74 @@ module(basename(import.meta.filename), function () {
);
});

test('a pure base-FileDef type filter matches every file row across subtypes', async function (assert) {
// Every file indexes with its full FileDef adoption chain (subtype →
// base FileDef → BaseDef), so a bare base-FileDef anchor enumerates all
// files regardless of concrete subtype — MarkdownDef, GtsFileDef,
// JsonFileDef alike. `scope: 'files'` pins the file rows so the
// dual-indexed card `.json` rows stay in play without their instance
// rows.
let doc = await testRealm.realmIndexQueryEngine.searchEntries(
parseSearchEntryQueryFromPayload({
scope: 'files',
filter: {
'item.on': { module: baseRRI('card-api'), name: 'FileDef' },
},
fields: { entry: ['item'] },
}),
);
let expectedFiles = [
`${realmHref}hello.md`,
`${realmHref}person.gts`,
`${realmHref}webpage.gts`,
`${johnId}.json`,
`${janeId}.json`,
`${realmHref}home.json`,
`${realmHref}home-slash.json`,
];
for (let url of expectedFiles) {
assert.ok(entryFor(doc, url), `${url} matches the base FileDef anchor`);
}
assert.strictEqual(
doc.meta.page.total,
expectedFiles.length,
'every file row (and nothing else) matches',
);
assert.notOk(
entryFor(doc, johnId),
'card instance rows stay out of a files-scoped search',
);
});

test('the file-api re-export spelling of FileDef matches the same rows as the canonical card-api spelling', async function (assert) {
// `@cardstack/base/file-api` re-exports FileDef from
// `@cardstack/base/card-api`, and index rows stamp the canonical
// (defining-module) key. A filter carrying the re-export spelling must
// match the same rows — a code ref that resolves to the class cannot be
// a silent dead end just because it names the re-exporting module.
for (let module of [
baseRRI('file-api'),
'https://cardstack.com/base/file-api',
]) {
let doc = await testRealm.realmIndexQueryEngine.searchEntries(
parseSearchEntryQueryFromPayload({
scope: 'files',
filter: { 'item.on': { module, name: 'FileDef' } },
fields: { entry: ['item'] },
}),
);
assert.strictEqual(
doc.meta.page.total,
7,
`the ${module} spelling matches all 7 file rows`,
);
assert.ok(
entryFor(doc, `${realmHref}hello.md`),
`hello.md matches via the ${module} spelling`,
);
}
});

test('A-Z `_title` sort gives file rows real sort values (not a NULL that sinks them)', async function (assert) {
// Regression guard for the mixed-search A-Z sort. A file row carries the
// synthetic `_title` but not `cardTitle`, so sorting on `cardTitle`
Expand Down
19 changes: 19 additions & 0 deletions packages/runtime-common/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ export interface FieldArity {
kind: 'field-arity';
}

// A type (`type`/`on`) condition, deferred so pass 1 can resolve the ref's
// definition: rows stamp `types` with the canonical (defining-module) key, so
// a ref that names the type through a re-exporting module only matches once
// the definition's canonical codeRef joins the membership keys. Resolves to
// an `any` of `types-contains` predicates over the union of the as-given
// spelling's keys and the canonical ref's keys.
export interface TypeCondition {
kind: 'type-condition';
ref: CodeRef;
}

export type CardExpression = (
| string
| Param
Expand All @@ -123,6 +134,7 @@ export type CardExpression = (
| FieldQuery
| FieldValue
| FieldArity
| TypeCondition
)[];

export function addExplicitParens(expression: CardExpression): CardExpression;
Expand Down Expand Up @@ -230,6 +242,13 @@ export function typesContains(key: string, column = 'i.types'): TypesContains {
};
}

export function typeCondition(ref: CodeRef): TypeCondition {
return {
kind: 'type-condition',
ref,
};
}

export function fieldQuery(
path: string,
type: CodeRef,
Expand Down
Loading
Loading