Skip to content
Open
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
55 changes: 44 additions & 11 deletions packages/boxel-cli/src/commands/realm/ingest-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
type ProfileManager,
} from '../../lib/profile-manager.ts';
import type { RealmAuthenticator } from '../../lib/realm-authenticator.ts';
import { search } from '../search.ts';

const CARD_JSON = 'application/vnd.card+json';
const MODULE_EXTENSIONS = ['.gts', '.gjs', '.ts', '.js'];
Expand Down Expand Up @@ -104,17 +103,14 @@ export function resolveSameRealmFile(
class RealmCardIngester extends RealmSyncBase {
hasError = false;
copiedFiles: string[] = [];
private profileManager: ProfileManager;
private cardUrl: string;
private sourceCache = new Map<string, string | null>();

constructor(
options: SyncOptions & { cardUrl: string },
authenticator: RealmAuthenticator,
profileManager: ProfileManager,
) {
super(options, authenticator);
this.profileManager = profileManager;
this.cardUrl = options.cardUrl;
}

Expand Down Expand Up @@ -342,15 +338,36 @@ class RealmCardIngester extends RealmSyncBase {
private async searchCards(
query: Record<string, unknown>,
): Promise<CardResource[]> {
let result = await search([this.realmRoot], query, {
profileManager: this.profileManager,
});
if (!result.ok) {
// Query the SOURCE realm's own `_search` directly rather than the
// profile-scoped `_federated-search`. A shared/published source realm
// (e.g. the catalog) isn't in the active profile's federated set, so
// federated search returns nothing for it — which is why instances and
// Specs went uncopied (the module crawl survives because it uses direct
// file fetches). The realm's own `_search` sees its full index.
let res = await this.authenticator.authedRealmFetch(
`${this.realmRoot}_search`,
{
method: 'QUERY',
headers: {
Accept: CARD_JSON,
'Content-Type': 'application/json',
},
body: JSON.stringify(query),
},
);
Comment on lines +347 to +357

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve best-effort behavior when search throws

If the direct _search fetch rejects (for example during token refresh/network failure) or the response body cannot be parsed as JSON, the exception now escapes searchCards() and aborts sync() before downloadAll(), so even the module graph already discovered is not copied. The previous federated search() converted these failures into ok: false, and the surrounding code still treats search as optional via hasError and return []; this direct path should catch fetch/parse failures and use the same warning/empty-result path.

Useful? React with 👍 / 👎.

if (!res.ok) {
this.hasError = true;
console.warn(` search failed: ${result.error ?? 'unknown'}`);
// Include statusText + a body snippet so auth errors, malformed queries,
// etc. are diagnosable from the CLI output, not just a bare status code.
let body = await res.text().catch(() => '');
console.warn(
` search failed: HTTP ${res.status} ${res.statusText}`.trimEnd() +
(body ? ` — ${body.slice(0, 300)}` : ''),
);
return [];
}
Comment on lines +358 to 368

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.

(Written by Claude on Matic's behalf.)

Fixed in 814f807. The _search failure now logs HTTP <status> <statusText> plus a truncated (300-char) response-body snippet, so auth errors / malformed queries are diagnosable from the CLI output — parity with the old federated-search path.

return (result.data ?? []) as CardResource[];
let json = (await res.json()) as SearchResponse;
return selectSearchResults(json);
}

private cardIdToInstanceRel(id: string | undefined): string | null {
Expand Down Expand Up @@ -418,6 +435,23 @@ interface CardResource {
attributes?: { specType?: string; ref?: unknown; [k: string]: unknown };
}

interface SearchResponse {
data?: CardResource[];
included?: CardResource[];
}

/**
* Pick the matched cards out of a realm `_search` response. A normal realm
* returns matches in `data`; a published realm (e.g. the catalog) returns them
* in `included` with an empty `data`. Prefer `data`, fall back to `included` —
* never merge, so a normal realm's `included` (linked deps of the matches) is
* not mistaken for matches. Exported for unit testing.
*/
export function selectSearchResults(json: SearchResponse): CardResource[] {
let data = json.data ?? [];
return data.length > 0 ? data : (json.included ?? []);
}

function tryParseCardDoc(
source: string,
): { data?: { meta?: { adoptsFrom?: { module?: string } } } } | null {
Expand Down Expand Up @@ -490,7 +524,6 @@ export async function ingestCard(
cardUrl,
},
authenticator,
pm,
);
console.log(
`Ingesting ${cardUrl}\n from realm ${realmRoot}\n into ${localDir}`,
Expand Down
100 changes: 55 additions & 45 deletions packages/boxel-cli/tests/commands/ingest-card-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ function makeFakeAuthenticator(fetchedUrls: string[]): RealmAuthenticator {
Object.keys(REALM_FILES).map((p, i) => [`${ROOT}${p}`, 1000 + i]),
);
return {
async authedRealmFetch(input: string | URL | Request) {
async authedRealmFetch(input: string | URL | Request, init?: RequestInit) {
let url = String(input);
fetchedUrls.push(url);
if (url === `${ROOT}_mtimes`) {
Expand All @@ -139,6 +139,15 @@ function makeFakeAuthenticator(fetchedUrls: string[]): RealmAuthenticator {
},
);
}
// The ingester discovers instances + Specs via the source realm's own
// `_search` endpoint (a QUERY request), not the profile-scoped
// federated search — so a shared/published source realm is reachable.
if (url === `${ROOT}_search`) {
return new Response(
JSON.stringify({ data: fakeSearchData(String(init?.body ?? '{}')) }),
{ status: 200 },
);
}
let rel = url.startsWith(ROOT) ? url.slice(ROOT.length) : null;
if (rel != null && REALM_FILES[rel] != null) {
return new Response(REALM_FILES[rel], { status: 200 });
Expand All @@ -148,57 +157,58 @@ 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).
// The data the source realm's `_search` returns for the two 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).
function fakeSearchData(bodyStr: string): unknown[] {
let body = JSON.parse(bodyStr) as {
filter?: { type?: { module?: string; name?: string } };
};
let type = body.filter?.type;
if (type?.module === 'https://cardstack.com/base/spec') {
return [
{
id: `${ROOT}Spec/gadget`,
attributes: {
specType: 'card',
ref: { module: '../widgets/gadget/gadget', name: 'Gadget' },
},
},
{
id: `${ROOT}Spec/clock`,
attributes: {
specType: 'card',
ref: { module: '../standalone/clock', name: 'Clock' },
},
},
{
id: `${ROOT}Spec/widget-part-component`,
attributes: {
specType: 'component',
ref: {
module: '../widgets/gadget/parts/widget-part',
name: 'WidgetPart',
},
},
},
];
}
if (type?.module === GADGET_MODULE_ABS && type?.name === 'Gadget') {
return [{ id: `${ROOT}Gadget/g1` }];
}
return [];
}

// Auth is supplied directly via `authenticator`, so the profile manager is
// only here to satisfy ingestCard's option plumbing — search no longer goes
// through it.
function makeFakeProfileManager(): ProfileManager {
return {
getActiveProfile() {
return {
profile: { realmServerUrl: 'https://realm-server.example.test' },
};
},
async authedRealmServerFetch(_url: string, init?: RequestInit) {
let body = JSON.parse(String(init?.body ?? '{}')) as {
filter?: { type?: { module?: string; name?: string } };
};
let type = body.filter?.type;
let data: unknown[] = [];
if (type?.module === 'https://cardstack.com/base/spec') {
data = [
{
id: `${ROOT}Spec/gadget`,
attributes: {
specType: 'card',
ref: { module: '../widgets/gadget/gadget', name: 'Gadget' },
},
},
{
id: `${ROOT}Spec/clock`,
attributes: {
specType: 'card',
ref: { module: '../standalone/clock', name: 'Clock' },
},
},
{
id: `${ROOT}Spec/widget-part-component`,
attributes: {
specType: 'component',
ref: {
module: '../widgets/gadget/parts/widget-part',
name: 'WidgetPart',
},
},
},
];
} else if (
type?.module === GADGET_MODULE_ABS &&
type?.name === 'Gadget'
) {
data = [{ id: `${ROOT}Gadget/g1` }];
}
return new Response(JSON.stringify({ data }), { status: 200 });
},
} as unknown as ProfileManager;
}

Expand Down
25 changes: 25 additions & 0 deletions packages/boxel-cli/tests/commands/ingest-card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,34 @@ import {
extractImportSpecifiers,
extractExportedClassNames,
resolveSameRealmFile,
selectSearchResults,
} from '../../src/commands/realm/ingest-card.js';

describe('ingest-card helpers', () => {
describe('selectSearchResults', () => {
it('uses `data` when present (normal realm)', () => {
let json = {
data: [{ id: 'a' }, { id: 'b' }],
included: [{ id: 'dep' }],
};
expect(selectSearchResults(json)).toEqual([{ id: 'a' }, { id: 'b' }]);
});

it('falls back to `included` when `data` is empty (published realm)', () => {
// The catalog returns matches in `included` with `data: []`.
let json = { data: [], included: [{ id: 'spec-1' }, { id: 'spec-2' }] };
expect(selectSearchResults(json)).toEqual([
{ id: 'spec-1' },
{ id: 'spec-2' },
]);
});

it('returns [] when both are empty/absent', () => {
expect(selectSearchResults({})).toEqual([]);
expect(selectSearchResults({ data: [], included: [] })).toEqual([]);
});
});

describe('extractImportSpecifiers', () => {
it('captures value, type, namespace, re-export, and side-effect imports', () => {
let src = `
Expand Down
Loading