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
110 changes: 102 additions & 8 deletions packages/boxel-cli/src/commands/realm/ingest-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,34 @@ export function extractImportSpecifiers(source: string): string[] {
return [...specs];
}

/**
* Non-null `relationships.*.links.self` values from a card instance's JSON —
* the `linksTo` / `linksToMany` targets to follow when crawling an instance's
* link graph. `linksToMany` serializes as `field.0`, `field.1`, … each with its
* own `links.self`; unset links are `null` and skipped.
*/
export function extractRelationshipLinks(source: string): string[] {
let parsed: unknown;
try {
parsed = JSON.parse(source);
} catch {
return [];
}
let rels = (parsed as { data?: { relationships?: Record<string, unknown> } })
?.data?.relationships;
if (!rels || typeof rels !== 'object') {
return [];
}
let out: string[] = [];
for (let value of Object.values(rels)) {
let self = (value as { links?: { self?: unknown } })?.links?.self;
if (typeof self === 'string' && self) {
out.push(self);
}
}
return out;
}

/** Exported card/field class names declared in a module (for instance lookup). */
export function extractExportedClassNames(source: string): string[] {
let names = new Set<string>();
Expand Down Expand Up @@ -196,14 +224,20 @@ class RealmCardIngester extends RealmSyncBase {
if (test !== f && fileSet.has(test)) toCopy.add(test);
}

// 3. The entry card's own instances.
let entryModuleAbs = entry.moduleRels.map((r) => this.relToAbs(r));
let instanceRels = await this.findEntryInstances(
moduleFiles,
entryModuleAbs,
fileSet,
);
for (let r of entry.instanceRels) instanceRels.add(r);
// 3. Instances. A module entry means "the card type" → copy every
// instance of it. An instance entry means "this record" → copy just it
// and the records it links to (transitively), not unrelated siblings.
let instanceRels: Set<string>;
if (entry.instanceRels.length > 0) {
instanceRels = await this.crawlInstanceLinks(entry.instanceRels, fileSet);
} else {
let entryModuleAbs = entry.moduleRels.map((r) => this.relToAbs(r));
instanceRels = await this.findEntryInstances(
moduleFiles,
entryModuleAbs,
fileSet,
);
}
for (let r of instanceRels) toCopy.add(r);

// 4. The card's own Catalog Spec(s) — card/app specType only.
Expand Down Expand Up @@ -313,6 +347,66 @@ class RealmCardIngester extends RealmSyncBase {
return out;
}

/**
* BFS the same-realm link graph from the seed instances: each instance plus
* every instance it references via `linksTo` / `linksToMany`, transitively.
* The instance analogue of `crawlModules` — for an instance entry we copy
* that record and the records it links to (e.g. a cellar and its bottles),
* but not unrelated siblings of the same type.
*/
private async crawlInstanceLinks(
seeds: string[],
fileSet: Set<string>,
): Promise<Set<string>> {
let seen = new Set<string>();
let queue = [...seeds];
while (queue.length) {
let rel = queue.shift()!;
if (seen.has(rel)) continue;
seen.add(rel);
let source = await this.fetchText(rel);
if (source == null) continue;
for (let self of extractRelationshipLinks(source)) {
let linked = this.resolveLinkedInstanceRel(self, rel, fileSet);
if (linked && !seen.has(linked)) queue.push(linked);
}
}
return seen;
}

/**
* Resolve a relationship `links.self` (relative `../Foo/x`, published alias
* `@cardstack/<realm>/…`, or absolute https — and without a `.json` extension)
* to a same-realm instance file that exists in `fileSet`, or null for
* cross-realm / missing links.
*/
private resolveLinkedInstanceRel(
self: string,
fromRel: string,
fileSet: Set<string>,
): string | null {
let rel: string;
if (self.startsWith('@')) {
// Published-alias form — map onto this realm by its path tail. A tail
// that isn't ours (another realm's alias) won't resolve to a local file.
rel = this.relativize(self);
} else {
// Relative or absolute https: resolve to an absolute URL and require it
// to live under THIS realm's served root. A link into another realm —
// even one whose URL happens to share our path tail — is left as a
// runtime reference, not copied (mirrors resolveSameRealmFile).
let absUrl = /^https?:\/\//.test(self)
? self
: new URL(self, this.relToAbs(fromRel)).href;
if (!absUrl.startsWith(this.realmRoot)) {
return null;
}
rel = absUrl.slice(this.realmRoot.length).replace(/^\/+/, '');
}
let candidate = rel.endsWith('.json') ? rel : `${rel}.json`;
return fileSet.has(candidate) ? candidate : null;
}

/** Card/app Spec cards whose `ref` resolves to a seeded module. */
private async findCardSpecs(
moduleFiles: Set<string>,
Expand Down
158 changes: 158 additions & 0 deletions packages/boxel-cli/tests/commands/ingest-card-instance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ingestCard } from '../../src/commands/realm/ingest-card.js';
import type { RealmAuthenticator } from '../../src/lib/realm-authenticator.js';
import type { ProfileManager } from '../../src/lib/profile-manager.js';

// Ingesting a single *instance* URL should copy that record + its module graph
// + the records it links to (transitively) — NOT every instance of its type.
// Fixture: a Garage links to two Tools; a second Garage and a third Tool are
// unrelated siblings that must be left behind. g1 also carries a decoy link to
// a *different realm* whose URL shares this realm's path tail (`/garage/`) and
// points at a path that exists locally — it must NOT be followed (cross-realm).
//
// garage.gts Garage (linksToMany Tool)
// tool.gts Tool
// Garage/g1.json entry — links to Tool/t1, Tool/t2 (+ cross-realm decoy → Tool/t3)
// Garage/g2.json sibling Garage — links to Tool/t3 (NOT ingested)
// Tool/t1.json, t2.json linked from g1 (ingested)
// Tool/t3.json only via g2 / the decoy (NOT ingested)

const ROOT = 'https://realms.example.test/garage/';

const REALM_FILES: Record<string, string> = {
'garage.gts': `
import { CardDef, field, linksToMany } from 'https://cardstack.com/base/card-api';
import { Tool } from './tool';
export class Garage extends CardDef {
@field tools = linksToMany(() => Tool);
}
`,
'tool.gts': `
import StringField from 'https://cardstack.com/base/string';
import { CardDef, field, contains } from 'https://cardstack.com/base/card-api';
export class Tool extends CardDef {
@field name = contains(StringField);
}
`,
'Garage/g1.json': JSON.stringify({
data: {
type: 'card',
meta: { adoptsFrom: { module: '../garage', name: 'Garage' } },
relationships: {
'tools.0': { links: { self: '../Tool/t1' } },
'tools.1': { links: { self: '../Tool/t2' } },
// Cross-realm decoy: different host, same `/garage/` path tail, path
// resolves to a file that exists locally. Must be skipped, not copied.
decoy: {
links: { self: 'https://other-host.example.test/garage/Tool/t3' },
},
},
},
}),
'Garage/g2.json': JSON.stringify({
data: {
type: 'card',
meta: { adoptsFrom: { module: '../garage', name: 'Garage' } },
relationships: { 'tools.0': { links: { self: '../Tool/t3' } } },
},
}),
'Tool/t1.json': JSON.stringify({
data: {
type: 'card',
attributes: { name: 'Hammer' },
meta: { adoptsFrom: { module: '../tool', name: 'Tool' } },
},
}),
'Tool/t2.json': JSON.stringify({
data: {
type: 'card',
attributes: { name: 'Wrench' },
meta: { adoptsFrom: { module: '../tool', name: 'Tool' } },
},
}),
'Tool/t3.json': JSON.stringify({
data: {
type: 'card',
attributes: { name: 'Drill' },
meta: { adoptsFrom: { module: '../tool', name: 'Tool' } },
},
}),
};

const EXPECTED_INGESTED = [
'Garage/g1.json',
'Tool/t1.json',
'Tool/t2.json',
'garage.gts',
'tool.gts',
];

function makeFakeAuthenticator(): RealmAuthenticator {
let mtimes = Object.fromEntries(
Object.keys(REALM_FILES).map((p, i) => [`${ROOT}${p}`, 1000 + i]),
);
return {
async authedRealmFetch(input: string | URL | Request) {
let url = String(input);
if (url === `${ROOT}_mtimes`) {
return new Response(
JSON.stringify({ data: { attributes: { mtimes } } }),
{ 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 });
}
return new Response('not found', { status: 404 });
},
} as RealmAuthenticator;
}

// No Catalog Spec in this fixture — the search returns nothing.
function makeFakeProfileManager(): ProfileManager {
return {
getActiveProfile() {
return {
profile: { realmServerUrl: 'https://realm-server.example.test' },
};
},
async authedRealmServerFetch() {
return new Response(JSON.stringify({ data: [] }), { status: 200 });
},
} as unknown as ProfileManager;
}

describe('ingest-card from an instance URL', () => {
let localDir: string;
let result: { files: string[]; error?: string };

beforeAll(async () => {
localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ingest-inst-'));
result = await ingestCard(`${ROOT}Garage/g1`, localDir, {
realm: ROOT,
authenticator: makeFakeAuthenticator(),
profileManager: makeFakeProfileManager(),
});
});

afterAll(() => {
fs.rmSync(localDir, { recursive: true, force: true });
});

it('copies the entry instance + its module graph + linked instances only', () => {
expect(result.files).toEqual(EXPECTED_INGESTED);
});

it('does NOT copy unrelated siblings of the same type', () => {
for (let rel of ['Garage/g2.json', 'Tool/t3.json']) {
expect(
fs.existsSync(path.join(localDir, rel)),
`${rel} should NOT be ingested`,
).toBe(false);
}
});
});
24 changes: 24 additions & 0 deletions packages/boxel-cli/tests/commands/ingest-card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,34 @@ import { describe, it, expect } from 'vitest';
import {
extractImportSpecifiers,
extractExportedClassNames,
extractRelationshipLinks,
resolveSameRealmFile,
} from '../../src/commands/realm/ingest-card.js';

describe('ingest-card helpers', () => {
describe('extractRelationshipLinks', () => {
it('collects linksToMany (field.N) + linksTo self URLs, skipping nulls', () => {
let src = JSON.stringify({
data: {
relationships: {
'bottles.0': { links: { self: '../WineBottle/a' } },
'bottles.1': { links: { self: '../WineBottle/b' } },
label: { links: { self: '../Image/x' } },
'cardInfo.theme': { links: { self: null } },
},
},
});
expect(new Set(extractRelationshipLinks(src))).toEqual(
new Set(['../WineBottle/a', '../WineBottle/b', '../Image/x']),
);
});

it('returns [] for no relationships or invalid JSON', () => {
expect(extractRelationshipLinks('{"data":{}}')).toEqual([]);
expect(extractRelationshipLinks('not json')).toEqual([]);
});
});

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