diff --git a/packages/boxel-cli/src/commands/search.ts b/packages/boxel-cli/src/commands/search.ts index b3dea23df68..53d3799c9d4 100644 --- a/packages/boxel-cli/src/commands/search.ts +++ b/packages/boxel-cli/src/commands/search.ts @@ -5,6 +5,7 @@ import { type ProfileManager, } from '../lib/profile-manager.ts'; import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; +import { resourceIdentity } from '@cardstack/runtime-common/resource-identity'; import { FG_RED, DIM, RESET } from '../lib/colors.ts'; import { cliLog } from '../lib/cli-log.ts'; @@ -19,12 +20,190 @@ export interface SearchCommandOptions { profileManager?: ProfileManager; } +// `_federated-search-v2` speaks the search-entry wire grammar: one query +// rooted on `search-entry`, where entry membership is addressed through +// `item.` (the card/file serialization). The type anchor is `item.on` and the +// field paths inside the filter operators carry the `item.` prefix. Callers +// here author ordinary card-rooted queries, so these helpers rewrite a query +// into the `item.`-addressed form the endpoint expects. +// +// This mirrors runtime-common's `searchEntryWireQueryFromQuery`, kept local +// because that module pulls the whole runtime-common index — and its +// `https://cardstack.com/base/*` imports — into boxel-cli's deliberately +// dependency-light graph. +const ITEM_PREFIX = 'item.'; +const ITEM_ANCHOR = 'item.on'; + +// The filter operators whose value is an object keyed by field paths; their +// keys are the ones that take the `item.` prefix. +const FIELD_KEYED_OPERATORS = ['eq', 'contains', 'in', 'range']; + +function toItemFilter( + filter: Record, +): Record { + let out: Record = {}; + for (let [key, value] of Object.entries(filter)) { + if (key === 'type' || key === 'on') { + // both legacy spellings of the type anchor map to item.on + out[ITEM_ANCHOR] = value; + } else if (key === 'any' || key === 'every') { + if (!Array.isArray(value)) { + throw new Error(`filter.${key} must be an array`); + } + out[key] = value.map((node) => + toItemFilter(node as Record), + ); + } else if (key === 'not') { + out.not = toItemFilter(value as Record); + } else if (FIELD_KEYED_OPERATORS.includes(key)) { + if (typeof value !== 'object' || value == null || Array.isArray(value)) { + throw new Error(`filter.${key} must be an object`); + } + out[key] = Object.fromEntries( + Object.entries(value as Record).map( + ([fieldPath, fieldValue]) => [ + `${ITEM_PREFIX}${fieldPath}`, + fieldValue, + ], + ), + ); + } else if (key === 'matches') { + // full-text match over the whole document — no field path to address + out.matches = value; + } else { + throw new Error( + `cannot translate filter member "${key}" to a search-entry query — the type anchor is "on"/"type" and field paths live under the ${FIELD_KEYED_OPERATORS.join('/')} operators`, + ); + } + } + return out; +} + +function toItemSort(entry: Record): Record { + let out: Record = {}; + for (let [key, value] of Object.entries(entry)) { + if (key === 'by') { + if (typeof value !== 'string') { + throw new Error('sort entry "by" must be a string'); + } + out.by = `${ITEM_PREFIX}${value}`; + } else if (key === 'on') { + out[ITEM_ANCHOR] = value; + } else if (key === 'direction') { + out.direction = value; + } else { + throw new Error(`unknown sort member "${key}"`); + } + } + return out; +} + +interface SearchEntryRequestBody { + realms: string[]; + // boxel-cli never renders HTML, so it requests the data-only fieldset: each + // entry carries only its full `item` serialization (no prerendered `html`). + fields: { 'search-entry': ['item'] }; + filter?: Record; + sort?: Record[]; + page?: unknown; + cardUrls?: unknown; +} + +/** + * Build the `_federated-search-v2` request body from a card-rooted query: the + * `item.`-addressed filter/sort, the realms to federate across, and the + * data-only fieldset. + */ +export function searchEntryRequestBody( + query: Record, + realms: string[], +): SearchEntryRequestBody { + let body: SearchEntryRequestBody = { + realms, + fields: { 'search-entry': ['item'] }, + }; + if (query.filter !== undefined) { + if ( + typeof query.filter !== 'object' || + query.filter == null || + Array.isArray(query.filter) + ) { + throw new Error('filter must be an object'); + } + body.filter = toItemFilter(query.filter as Record); + } + if (query.sort !== undefined) { + if (!Array.isArray(query.sort)) { + throw new Error('sort must be an array'); + } + body.sort = query.sort.map((entry) => + toItemSort(entry as Record), + ); + } + if (query.page !== undefined) { + body.page = query.page; + } + if (query.cardUrls !== undefined) { + body.cardUrls = query.cardUrls; + } + return body; +} + +// A data-only search-entry document, narrowed to the shape this client reads: +// each entry links its serialization through `item`, and the `card`/`file-meta` +// resource itself travels in `included`. A structural local type rather than +// runtime-common's `SearchEntryCollectionDocument` — that one transitively +// pulls the index's `https://cardstack.com/base/*` imports, which don't resolve +// in a plain Node CLI (the same boundary the query helpers above note). +interface SearchEntryDoc { + data?: { + relationships?: { + item?: { data?: { type: string; id: string } }; + }; + }[]; + included?: { type: string; id: string }[]; +} + /** - * Federated search across one or more realms via the `_federated-search` + * Flatten a data-only search-entry document into the `item` serializations, in + * result order — the same `card`/`file-meta` resources the legacy endpoint + * returned as its top-level `data`. Each entry points at its serialization in + * `included`; resolve and collect them. + */ +export function itemsFromSearchEntryDoc( + doc: SearchEntryDoc, +): Record[] { + let byIdentity = new Map>(); + for (let resource of doc.included ?? []) { + if (resource.type === 'card' || resource.type === 'file-meta') { + byIdentity.set( + resourceIdentity(resource.type, resource.id), + resource as Record, + ); + } + } + let items: Record[] = []; + for (let entry of doc.data ?? []) { + let ref = entry.relationships?.item?.data; + if (!ref) { + continue; + } + let item = byIdentity.get(resourceIdentity(ref.type, ref.id)); + if (item) { + items.push(item); + } + } + return items; +} + +/** + * Federated search across one or more realms via the `_federated-search-v2` * server endpoint. * - * Sends a QUERY request with the provided query object and a `realms` array - * merged into the request body. Uses the server JWT via + * Sends the search-entry-rooted query as a QUERY request requesting the + * data-only fieldset (`fields[search-entry]=item`), and returns the `item` + * serializations the endpoint links in `included` — the `card`/`file-meta` + * resources callers consume. Uses the server JWT via * `ProfileManager.authedRealmServerFetch`. */ export async function search( @@ -42,12 +221,23 @@ export async function search( } let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); - let searchUrl = `${realmServerUrl}/_federated-search`; + let searchUrl = `${realmServerUrl}/_federated-search-v2`; let realms = (Array.isArray(realmUrls) ? realmUrls : [realmUrls]).map( ensureTrailingSlash, ); + let body: SearchEntryRequestBody; + try { + body = searchEntryRequestBody(query, realms); + } catch (err) { + return { + ok: false, + status: 0, + error: err instanceof Error ? err.message : String(err), + }; + } + try { let response = await pm.authedRealmServerFetch(searchUrl, { method: 'QUERY', @@ -55,22 +245,24 @@ export async function search( Accept: 'application/vnd.card+json', 'Content-Type': 'application/json', }, - body: JSON.stringify({ realms, ...query }), + body: JSON.stringify(body), }); if (!response.ok) { - let body = await response.text(); + let responseBody = await response.text(); return { ok: false, status: response.status, - error: `HTTP ${response.status}: ${body.slice(0, 300)}`, + error: `HTTP ${response.status}: ${responseBody.slice(0, 300)}`, }; } - let result = (await response.json()) as { - data?: Record[]; + let result = (await response.json()) as SearchEntryDoc; + return { + ok: true, + status: response.status, + data: itemsFromSearchEntryDoc(result), }; - return { ok: true, status: response.status, data: result.data }; } catch (err) { return { ok: false, diff --git a/packages/boxel-cli/src/lib/boxel-cli-client.ts b/packages/boxel-cli/src/lib/boxel-cli-client.ts index 9770d15f3fc..d1fc1ec3318 100644 --- a/packages/boxel-cli/src/lib/boxel-cli-client.ts +++ b/packages/boxel-cli/src/lib/boxel-cli-client.ts @@ -256,8 +256,9 @@ export class BoxelCLIClient { } /** - * Federated search across one or more realms via `_federated-search`. - * Delegates to the standalone `search()` in `commands/search.ts`. + * Federated search across one or more realms via `_federated-search-v2`. + * Delegates to the standalone `search()` in `commands/search.ts`, which + * returns the `item` serializations (the `card`/`file-meta` resources). */ async search( realmUrls: string | string[], diff --git a/packages/boxel-cli/tests/commands/search-query.test.ts b/packages/boxel-cli/tests/commands/search-query.test.ts new file mode 100644 index 00000000000..896f0b27f1f --- /dev/null +++ b/packages/boxel-cli/tests/commands/search-query.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from 'vitest'; +import { + searchEntryRequestBody, + itemsFromSearchEntryDoc, +} from '../../src/commands/search.ts'; + +const SkillRef = { + module: 'https://cardstack.com/base/skill', + name: 'Skill', +}; +const CardDefRef = { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', +}; + +describe('searchEntryRequestBody — card-rooted query → search-entry wire grammar', () => { + it('always requests the data-only fieldset and the given realms', () => { + let body = searchEntryRequestBody({}, ['https://realm/a/']); + expect(body).toEqual({ + realms: ['https://realm/a/'], + fields: { 'search-entry': ['item'] }, + }); + }); + + it('rewrites a standalone type filter to the item.on anchor', () => { + let body = searchEntryRequestBody({ filter: { type: SkillRef } }, [ + 'https://realm/a/', + ]); + expect(body.filter).toEqual({ 'item.on': SkillRef }); + }); + + it('rewrites a node `on` + field operators with the item. prefix', () => { + let body = searchEntryRequestBody( + { filter: { on: CardDefRef, eq: { cardTitle: 'Shared Card' } } }, + ['https://realm/a/'], + ); + expect(body.filter).toEqual({ + 'item.on': CardDefRef, + eq: { 'item.cardTitle': 'Shared Card' }, + }); + }); + + it('recurses into any/every/not connectives', () => { + let body = searchEntryRequestBody( + { + filter: { + every: [ + { type: SkillRef }, + { not: { eq: { status: 'archived' } } }, + { + any: [ + { contains: { title: 'a' } }, + { range: { rank: { gt: 1 } } }, + ], + }, + ], + }, + }, + ['https://realm/a/'], + ); + expect(body.filter).toEqual({ + every: [ + { 'item.on': SkillRef }, + { not: { eq: { 'item.status': 'archived' } } }, + { + any: [ + { contains: { 'item.title': 'a' } }, + { range: { 'item.rank': { gt: 1 } } }, + ], + }, + ], + }); + }); + + it('passes `matches` through unprefixed (whole-document full-text)', () => { + let body = searchEntryRequestBody({ filter: { matches: 'hello' } }, [ + 'https://realm/a/', + ]); + expect(body.filter).toEqual({ matches: 'hello' }); + }); + + it('prefixes sort `by`, maps sort `on` to item.on, keeps direction', () => { + let body = searchEntryRequestBody( + { sort: [{ by: 'title', on: CardDefRef, direction: 'desc' }] }, + ['https://realm/a/'], + ); + expect(body.sort).toEqual([ + { by: 'item.title', 'item.on': CardDefRef, direction: 'desc' }, + ]); + }); + + it('carries page and cardUrls through verbatim', () => { + let body = searchEntryRequestBody( + { page: { size: 10 }, cardUrls: ['https://realm/a/x'] }, + ['https://realm/a/'], + ); + expect(body.page).toEqual({ size: 10 }); + expect(body.cardUrls).toEqual(['https://realm/a/x']); + }); + + it('throws on a filter member it cannot translate', () => { + expect(() => + searchEntryRequestBody({ filter: { bogus: 1 } }, ['https://realm/a/']), + ).toThrow(/cannot translate filter member "bogus"/); + }); +}); + +describe('itemsFromSearchEntryDoc — flatten a data-only search-entry doc to items', () => { + it("resolves each entry's item from included, in entry order", () => { + let doc = { + data: [ + { + id: 'https://realm/a/two', + relationships: { + item: { data: { type: 'card', id: 'https://realm/a/two' } }, + }, + }, + { + id: 'https://realm/a/one', + relationships: { + item: { data: { type: 'card', id: 'https://realm/a/one' } }, + }, + }, + ], + included: [ + { + type: 'card', + id: 'https://realm/a/one', + attributes: { title: 'One' }, + }, + { + type: 'card', + id: 'https://realm/a/two', + attributes: { title: 'Two' }, + }, + ], + meta: { page: { total: 2 } }, + }; + let items = itemsFromSearchEntryDoc(doc); + expect(items.map((i) => (i as any).id)).toEqual([ + 'https://realm/a/two', + 'https://realm/a/one', + ]); + }); + + it('resolves file-meta items the same way', () => { + let doc = { + data: [ + { + id: 'https://realm/a/f.gts', + relationships: { + item: { data: { type: 'file-meta', id: 'https://realm/a/f.gts' } }, + }, + }, + ], + included: [ + { type: 'file-meta', id: 'https://realm/a/f.gts', attributes: {} }, + ], + }; + expect(itemsFromSearchEntryDoc(doc).map((i) => (i as any).id)).toEqual([ + 'https://realm/a/f.gts', + ]); + }); + + it('skips entries with no item relationship or no matching included resource', () => { + let doc = { + data: [ + { id: 'https://realm/a/html-only', relationships: {} }, + { + id: 'https://realm/a/missing', + relationships: { + item: { data: { type: 'card', id: 'https://realm/a/missing' } }, + }, + }, + ], + included: [], + }; + expect(itemsFromSearchEntryDoc(doc)).toEqual([]); + }); + + it('returns an empty array for an empty document', () => { + expect(itemsFromSearchEntryDoc({})).toEqual([]); + }); +}); diff --git a/packages/runtime-common/resource-identity.ts b/packages/runtime-common/resource-identity.ts new file mode 100644 index 00000000000..4f6eacd6180 --- /dev/null +++ b/packages/runtime-common/resource-identity.ts @@ -0,0 +1,13 @@ +// The separator that joins a JSON:API resource's `(type, id)` into a single +// identity string for map keys and dedup sets. A NUL byte can't appear in a +// resource type or id, so a key can never alias another by concatenation. +// +// Kept in its own dependency-free module — no other runtime-common imports — +// so consumers outside the card-api module graph (e.g. boxel-cli, a plain +// Node CLI) can import the canonical identity helper without pulling in the +// index's `https://cardstack.com/base/*` imports. +export const RESOURCE_IDENTITY_SEPARATOR = '\u0000'; + +export function resourceIdentity(type: string, id: string | undefined): string { + return `${type}${RESOURCE_IDENTITY_SEPARATOR}${id}`; +} diff --git a/packages/runtime-common/resource-types.ts b/packages/runtime-common/resource-types.ts index bfefbf6a542..f3d6816500e 100644 --- a/packages/runtime-common/resource-types.ts +++ b/packages/runtime-common/resource-types.ts @@ -361,14 +361,14 @@ export { isSparseItemResource, } from './card-document-shape.ts'; -// The map/set key for a JSON:API `(type, id)` identity pair. NUL-separated so -// one pair can't alias another by concatenation — no resource type or id -// contains a NUL byte. -// `id` may be absent on an unsaved resource; the literal "undefined" segment -// it produces is still a stable, non-aliasing key. -export function resourceIdentity(type: string, id: string | undefined): string { - return `${type}\u0000${id}`; -} +// The map/set key for a JSON:API `(type, id)` identity pair lives in its own +// dependency-free module so it is importable outside the card-api graph; it is +// re-exported here so the index and existing call sites keep resolving it from +// `resource-types`. +export { + RESOURCE_IDENTITY_SEPARATOR, + resourceIdentity, +} from './resource-identity.ts'; // The `css` resource id: a content hash of the (base64-embedding) scoped-CSS // URL. Server and host compute it through this one helper so identical diff --git a/packages/vscode-boxel-tools/src/skills.ts b/packages/vscode-boxel-tools/src/skills.ts index 1ea2d627e28..ffd231fe65b 100644 --- a/packages/vscode-boxel-tools/src/skills.ts +++ b/packages/vscode-boxel-tools/src/skills.ts @@ -378,23 +378,29 @@ export class SkillList extends vscode.TreeItem { headers['Authorization'] = jwt; } - const searchUrl = new URL('./_search', this.realmUrl); + // `_search-v2` speaks the search-entry wire grammar: entry membership is + // addressed through `item.` (the card serialization), so the type anchor + // is `item.on` and sort keys carry the `item.` prefix. Request the + // data-only fieldset (`fields[search-entry]=item`) — skill discovery never + // renders HTML, so each entry carries only its full `item` serialization. + const searchUrl = new URL('./_search-v2', this.realmUrl); const query = { sort: [ { - by: 'title', - on: { + by: 'item.title', + 'item.on': { module: 'https://cardstack.com/base/card-api', name: 'CardDef', }, }, ], filter: { - type: { + 'item.on': { module: 'https://cardstack.com/base/skill', name: 'Skill', }, }, + fields: { 'search-entry': ['item'] }, }; const response = await fetch(searchUrl, { method: 'QUERY', @@ -416,12 +422,31 @@ export class SkillList extends vscode.TreeItem { const data: any = await response.json(); console.log('Skill search data:', data); - this.skills = data.data.map((skill: any) => { - return new Skill( - skill.attributes.title, - skill.attributes.instructions, - skill.id, + // The `item` serializations live in `included`; each `search-entry` in + // `data` links one through its `item` relationship. Resolve them in result + // order — these are the Skill card resources we read title/instructions + // off of. + const itemsByIdentity = new Map(); + for (const resource of data.included ?? []) { + if (resource.type === 'card' || resource.type === 'file-meta') { + itemsByIdentity.set(`${resource.type}\u0000${resource.id}`, resource); + } + } + this.skills = (data.data ?? []) + .map((entry: any) => { + const ref = entry.relationships?.item?.data; + return ref + ? itemsByIdentity.get(`${ref.type}\u0000${ref.id}`) + : undefined; + }) + .filter((item: any) => Boolean(item)) + .map( + (item: any) => + new Skill( + item.attributes.title, + item.attributes.instructions, + item.id, + ), ); - }); } }