From 4edcfc727145d717f812f34647dcfcc990079371 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 19 Jun 2026 09:39:23 -0400 Subject: [PATCH 1/2] refactor: migrate boxel-cli and vscode-boxel-tools search to /_federated-search-v2 (data-only) boxel-cli search() and vscode-boxel-tools skill discovery now issue v2 search-entry queries with the data-only fieldset (fields[search-entry]=item) and read the item serializations out of the document's included, instead of the legacy /_federated-search and /_search top-level data array. SearchResult, the CLI surface, and the skill tree are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/boxel-cli/src/commands/search.ts | 209 +++++++++++++++++- .../boxel-cli/src/lib/boxel-cli-client.ts | 5 +- .../tests/commands/ingest-card-graph.test.ts | 44 ++-- .../tests/commands/search-query.test.ts | 184 +++++++++++++++ packages/vscode-boxel-tools/src/skills.ts | 45 +++- 5 files changed, 451 insertions(+), 36 deletions(-) create mode 100644 packages/boxel-cli/tests/commands/search-query.test.ts diff --git a/packages/boxel-cli/src/commands/search.ts b/packages/boxel-cli/src/commands/search.ts index e661cc79c3..d279eb395b 100644 --- a/packages/boxel-cli/src/commands/search.ts +++ b/packages/boxel-cli/src/commands/search.ts @@ -19,12 +19,188 @@ 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 search-entry result links its serialization through `item`; the +// `card`/`file-meta` resource itself travels in `included`. +interface SearchEntryDoc { + data?: { + relationships?: { + item?: { data?: { type: string; id: string } }; + }; + }[]; + included?: { type: string; id: string }[]; +} + +/** + * 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') { + // NUL-separated so a (type, id) pair can't alias another by + // concatenation (no type or id contains a NUL byte). + byIdentity.set( + `${resource.type}\u0000${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(`${ref.type}\u0000${ref.id}`); + if (item) { + items.push(item); + } + } + return items; +} + /** - * Federated search across one or more realms via the `_federated-search` + * 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 +218,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 +242,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 9770d15f3f..d1fc1ec331 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/ingest-card-graph.test.ts b/packages/boxel-cli/tests/commands/ingest-card-graph.test.ts index 009b9b9190..605c6b38a2 100644 --- a/packages/boxel-cli/tests/commands/ingest-card-graph.test.ts +++ b/packages/boxel-cli/tests/commands/ingest-card-graph.test.ts @@ -148,10 +148,23 @@ function makeFakeAuthenticator(fetchedUrls: string[]): RealmAuthenticator { }; } -// Answers the two search shapes the ingester issues: instances of the entry -// card's exported classes, and all base-realm Spec cards (filtered by -// specType + ref in the ingester itself). +// Answers the two search shapes the ingester issues — instances of the entry +// card's exported classes, and all base-realm Spec cards — as v2 data-only +// `search-entry` documents: each entry links its `item` serialization, which +// rides in `included`. The ingester filters Specs (specType + ref) itself. The +// type anchor arrives `item.`-addressed (`filter['item.on']`), the search-entry +// query grammar `_federated-search-v2` speaks. function makeFakeProfileManager(): ProfileManager { + // Wrap card resources as a data-only search-entry document: one entry per + // card linking its `item`, with the card resources themselves in `included`. + let searchEntryDoc = (cards: { id: string; attributes?: unknown }[]) => ({ + data: cards.map((card) => ({ + id: card.id, + relationships: { item: { data: { type: 'card', id: card.id } } }, + })), + included: cards.map((card) => ({ type: 'card', ...card })), + meta: { page: { total: cards.length } }, + }); return { getActiveProfile() { return { @@ -160,12 +173,16 @@ function makeFakeProfileManager(): ProfileManager { }, async authedRealmServerFetch(_url: string, init?: RequestInit) { let body = JSON.parse(String(init?.body ?? '{}')) as { - filter?: { type?: { module?: string; name?: string } }; + filter?: { 'item.on'?: { module?: string; name?: string } }; + fields?: unknown; }; - let type = body.filter?.type; - let data: unknown[] = []; - if (type?.module === 'https://cardstack.com/base/spec') { - data = [ + // Data-only consumers must request the item fieldset — that's what keeps + // prerendered html/css off the wire. + expect(body.fields).toEqual({ 'search-entry': ['item'] }); + let on = body.filter?.['item.on']; + let cards: { id: string; attributes?: unknown }[] = []; + if (on?.module === 'https://cardstack.com/base/spec') { + cards = [ { id: `${ROOT}Spec/gadget`, attributes: { @@ -191,13 +208,12 @@ function makeFakeProfileManager(): ProfileManager { }, }, ]; - } else if ( - type?.module === GADGET_MODULE_ABS && - type?.name === 'Gadget' - ) { - data = [{ id: `${ROOT}Gadget/g1` }]; + } else if (on?.module === GADGET_MODULE_ABS && on?.name === 'Gadget') { + cards = [{ id: `${ROOT}Gadget/g1` }]; } - return new Response(JSON.stringify({ data }), { status: 200 }); + return new Response(JSON.stringify(searchEntryDoc(cards)), { + status: 200, + }); }, } as unknown as ProfileManager; } 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 0000000000..9dffbb175b --- /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/vscode-boxel-tools/src/skills.ts b/packages/vscode-boxel-tools/src/skills.ts index 1ea2d627e2..ffd231fe65 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, + ), ); - }); } } From 2513ca3a2b16ca80c4e750c0fa964289858a18c6 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 19 Jun 2026 09:51:22 -0400 Subject: [PATCH 2/2] refactor: use canonical resourceIdentity in boxel-cli search Extract resourceIdentity + RESOURCE_IDENTITY_SEPARATOR into a dependency-free runtime-common/resource-identity.ts leaf (re-exported from resource-types) so boxel-cli can import the canonical identity helper without pulling the index's url-style base imports. boxel-cli's search uses it for the included-resource lookup instead of a hand-rolled key. Document why the search-entry doc type is a local structural shape, and fix a test-name typo. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/boxel-cli/src/commands/search.ts | 15 +++++++++------ .../tests/commands/search-query.test.ts | 2 +- packages/runtime-common/resource-identity.ts | 13 +++++++++++++ packages/runtime-common/resource-types.ts | 16 ++++++++-------- 4 files changed, 31 insertions(+), 15 deletions(-) create mode 100644 packages/runtime-common/resource-identity.ts diff --git a/packages/boxel-cli/src/commands/search.ts b/packages/boxel-cli/src/commands/search.ts index d279eb395b..1e8fd96643 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'; @@ -148,8 +149,12 @@ export function searchEntryRequestBody( return body; } -// A search-entry result links its serialization through `item`; the -// `card`/`file-meta` resource itself travels in `included`. +// 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?: { @@ -171,10 +176,8 @@ export function itemsFromSearchEntryDoc( let byIdentity = new Map>(); for (let resource of doc.included ?? []) { if (resource.type === 'card' || resource.type === 'file-meta') { - // NUL-separated so a (type, id) pair can't alias another by - // concatenation (no type or id contains a NUL byte). byIdentity.set( - `${resource.type}\u0000${resource.id}`, + resourceIdentity(resource.type, resource.id), resource as Record, ); } @@ -185,7 +188,7 @@ export function itemsFromSearchEntryDoc( if (!ref) { continue; } - let item = byIdentity.get(`${ref.type}\u0000${ref.id}`); + let item = byIdentity.get(resourceIdentity(ref.type, ref.id)); if (item) { items.push(item); } diff --git a/packages/boxel-cli/tests/commands/search-query.test.ts b/packages/boxel-cli/tests/commands/search-query.test.ts index 9dffbb175b..896f0b27f1 100644 --- a/packages/boxel-cli/tests/commands/search-query.test.ts +++ b/packages/boxel-cli/tests/commands/search-query.test.ts @@ -106,7 +106,7 @@ describe('searchEntryRequestBody — card-rooted query → search-entry wire gra }); describe('itemsFromSearchEntryDoc — flatten a data-only search-entry doc to items', () => { - it('resolves each entry`s item from included, in entry order', () => { + it("resolves each entry's item from included, in entry order", () => { let doc = { data: [ { diff --git a/packages/runtime-common/resource-identity.ts b/packages/runtime-common/resource-identity.ts new file mode 100644 index 0000000000..4f6eacd618 --- /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 bfefbf6a54..f3d6816500 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