Skip to content
Merged
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 thread
lukemelia marked this conversation as resolved.
})
.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