Skip to content
78 changes: 42 additions & 36 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -479,10 +479,10 @@ export type GetSearchResourceFunc<T extends CardDef | FileDef = CardDef> = (

export interface CardStore {
// The VirtualNetwork that owns this store's realm mappings, used for
// prefix/RRI resolution during (de)serialization. Optional so test doubles
// don't need to implement it; resolution sites degrade — URL-form refs
// still URL-join, prefix-form refs pass through unchanged.
virtualNetwork?: VirtualNetwork;
// prefix/RRI resolution during (de)serialization. Required — every store
// implementation must supply one (production stores, test stubs, the
// FallbackCardStore).
virtualNetwork: VirtualNetwork;
getCard(url: string): CardDef | undefined;
getFileMeta(url: string): FileDef | undefined;
setCard(url: string, instance: CardDef): void;
Expand Down Expand Up @@ -1450,7 +1450,7 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
if (reference == null || reference === '') {
return null;
}
let href = resolveRef(store, reference, relativeTo);
let href = resolveRef(store.virtualNetwork, reference, relativeTo);
let cachedInstance = isFileDef(this.card)
? store.getFileMeta(href)
: store.getCard(href);
Expand Down Expand Up @@ -2030,7 +2030,11 @@ class LinksToMany<FieldT extends LinkableDefConstructor> implements Field<
if (reference == null) {
return null;
}
let normalizedReference = resolveRef(store, reference, relativeTo);
let normalizedReference = resolveRef(
store.virtualNetwork,
reference,
relativeTo,
);
let cachedInstance = isFileDef(this.card)
? store.getFileMeta(normalizedReference)
: store.getCard(normalizedReference);
Expand Down Expand Up @@ -2480,7 +2484,7 @@ export class BaseDef {
return maybeRelativeReference;
}
return resolveRef(
getStore(value),
getStore(value).virtualNetwork,
maybeRelativeReference,
value[relativeTo],
);
Expand All @@ -2507,7 +2511,7 @@ export class BaseDef {
let normalizedId = rawValue.reference;
if (value[relativeTo]) {
normalizedId = resolveRef(
getStore(value),
getStore(value).virtualNetwork,
normalizedId,
value[relativeTo],
);
Expand Down Expand Up @@ -3480,7 +3484,11 @@ function lazilyLoadLink(
inflightLinkLoads.set(instance, inflightLoads);
}
let store = getStore(instance);
let reference = resolveRef(store, link, instance.id ?? instance[relativeTo]);
let reference = resolveRef(
store.virtualNetwork,
link,
instance.id ?? instance[relativeTo],
);
let key = `${field.name}/${reference}`;
let promise = inflightLoads.get(key);
if (promise) {
Expand Down Expand Up @@ -3552,7 +3560,7 @@ function lazilyLoadLink(
continue;
}
let notLoadedRef = resolveRef(
store,
store.virtualNetwork,
item.reference,
instance.id ?? instance[relativeTo],
);
Expand Down Expand Up @@ -3640,7 +3648,7 @@ function lazilyLoadLink(
continue;
}
let notLoadedRef = resolveRef(
store,
store.virtualNetwork,
item.reference,
instance.id ?? instance[relativeTo],
);
Expand Down Expand Up @@ -4682,30 +4690,34 @@ function getStore(instance: BaseDef): CardStore {
}

// The VirtualNetwork associated with an instance's store, for prefix/RRI
// resolution outside this module. Returns undefined when the store can't
// supply one — callers handle that by degrading to URL math or throwing.
// resolution outside this module. Returns undefined when the instance is
// detached (no store, no loader-attached VN) — callers handle that by
// degrading to URL math or throwing.
export function virtualNetworkFor(
instance: BaseDef,
): VirtualNetwork | undefined {
return getStore(instance).virtualNetwork;
try {
return getStore(instance).virtualNetwork;
} catch {
return undefined;
}
}

// Resolve a (possibly prefix-form or relative) reference to an absolute URL
// string through the store's VirtualNetwork. When the store doesn't carry
// a VN (test stubs, detached instances), fall back to plain URL math: it
// string through the supplied VirtualNetwork. When the caller can't supply
// one (test stubs, detached instances), fall back to plain URL math: it
// covers URL-form refs and relative refs against URL-form bases. Prefix-form
// refs and refs against prefix-form bases can't be resolved without a VN —
// `new URL()` throws on those, so we return the raw reference unchanged
// instead of bubbling the error to callers (e.g. relationship deserialize
// uses the returned string as a "did this resolve?" signal).
function resolveRef(
store: CardStore | undefined,
virtualNetwork: VirtualNetwork | undefined,
reference: string,
relativeTo: RealmResourceIdentifier | URL | undefined,
): string {
let vn = store?.virtualNetwork;
if (vn) {
return vn.resolveURL(reference, relativeTo).href;
if (virtualNetwork) {
return virtualNetwork.resolveURL(reference, relativeTo).href;
}
let base: URL | string | undefined;
if (relativeTo instanceof URL) {
Expand Down Expand Up @@ -4742,8 +4754,14 @@ class FallbackCardStore implements CardStore {
#inFlight: Set<Promise<unknown>> = new Set();
#loadGeneration = 0; // mirrors host store tracking to detect new loads

get virtualNetwork(): VirtualNetwork | undefined {
return myLoader().getVirtualNetwork();
get virtualNetwork(): VirtualNetwork {
let vn = myLoader().getVirtualNetwork();
if (!vn) {
throw new Error(
`FallbackCardStore.virtualNetwork requires the active Loader to have a VirtualNetwork`,
);
}
return vn;
}

getCard(id: string) {
Expand Down Expand Up @@ -4804,13 +4822,7 @@ class FallbackCardStore implements CardStore {
opts?: { dependencyTrackingContext?: RuntimeDependencyTrackingContext },
) {
trackRuntimeInstanceDependency(url, opts?.dependencyTrackingContext);
let vn = this.virtualNetwork;
if (!vn) {
throw new Error(
`CardStore.loadCardDocument requires a Loader with a VirtualNetwork`,
);
}
let promise = loadCardDocument(fetch, url, vn);
let promise = loadCardDocument(fetch, url, this.virtualNetwork);
this.trackLoad(promise);
return await promise;
}
Expand All @@ -4820,13 +4832,7 @@ class FallbackCardStore implements CardStore {
opts?: { dependencyTrackingContext?: RuntimeDependencyTrackingContext },
) {
trackRuntimeFileDependency(url, opts?.dependencyTrackingContext);
let vn = this.virtualNetwork;
if (!vn) {
throw new Error(
`CardStore.loadFileMetaDocument requires a Loader with a VirtualNetwork`,
);
}
let promise = loadFileMetaDocument(fetch, url, vn);
let promise = loadFileMetaDocument(fetch, url, this.virtualNetwork);
this.trackLoad(promise);
return await promise;
}
Expand Down
76 changes: 23 additions & 53 deletions packages/base/card-serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,8 @@ export interface SerializeOpts {
maybeRelativeReference?: (possibleReference: string) => string;
overrides?: Map<string, typeof BaseDef>;
// The VirtualNetwork to consult for prefix/RRI resolution during
// serialization. Optional: when absent, the `maybeRelativeReference`
// closures degrade — prefix-form refs pass through unchanged and only
// URL-form bases support URL math.
virtualNetwork?: VirtualNetwork;
// serialization. Required — every caller must thread a VN.
virtualNetwork: VirtualNetwork;
}

export interface DeserializeOpts {
Expand Down Expand Up @@ -212,7 +210,7 @@ export function resourceFrom(

export function serializeCard(
model: CardDef,
opts?: SerializeOpts,
opts: SerializeOpts,
): LooseSingleCardDocument {
let doc = {
data: {
Expand All @@ -222,40 +220,22 @@ export function serializeCard(
};
let modelRelativeTo: RealmResourceIdentifier | URL | undefined =
model.id ?? model[relativeTo];
let vn = opts.virtualNetwork;
let data = serializeCardResource(model, doc, {
...opts,
...{
maybeRelativeReference(possibleReference: string) {
let vn = opts?.virtualNetwork;
// Registered prefix refs (e.g. @cardstack/catalog/foo) are already
// in their canonical portable form — return as-is. Without a VN
// we can't know which prefixes are registered, so the most we can
// do for prefix-form refs is pass them through unchanged.
if (vn ? vn.isRegisteredPrefix(possibleReference) : false) {
// in their canonical portable form — return as-is.
if (vn.isRegisteredPrefix(possibleReference)) {
return possibleReference;
}
let modelRelativeToForURL: URL | undefined;
if (typeof modelRelativeTo === 'string') {
if (vn) {
modelRelativeToForURL = vn.toURL(modelRelativeTo);
} else if (
modelRelativeTo.startsWith('http://') ||
modelRelativeTo.startsWith('https://')
) {
modelRelativeToForURL = new URL(modelRelativeTo);
}
} else {
modelRelativeToForURL = modelRelativeTo;
}
let modelRelativeToForURL: URL | undefined =
typeof modelRelativeTo === 'string'
? vn.toURL(modelRelativeTo)
: modelRelativeTo;
let url = maybeURL(possibleReference, modelRelativeToForURL);
if (!url) {
if (!vn) {
// Without a VN we can't resolve a prefix-form reference. Pass
// through unchanged so callers that don't need relativization
// (e.g. test adapters serializing same-realm cards by URL)
// don't blow up on portable refs.
return possibleReference;
}
throw new Error(
`could not determine url from '${possibleReference}' relative to ${modelRelativeTo}`,
);
Expand Down Expand Up @@ -302,7 +282,12 @@ export function serializeCardResource(
usedLinksToFieldsOnly: !opts?.includeUnrenderedFields,
});
let overrides = getFieldOverrides(model);
opts = { ...(opts ?? {}), overrides };
// `serializeCardResource` is reachable from the recursive field-serialize
// symbol path without opts (e.g. callSerializeHook with no opts arg).
// That path doesn't read `opts.virtualNetwork`, so the synthesized
// working opts can lack it; cast through SerializeOpts | undefined to
// satisfy the required-VN type while preserving runtime behavior.
opts = { ...(opts ?? {}), overrides } as SerializeOpts | undefined;
let fieldResources = Object.entries(fields)
.filter(
([_fieldName, field]) =>
Expand Down Expand Up @@ -330,7 +315,7 @@ export function serializeCardResource(

export function serializeFileDef(
model: FileDef,
opts?: SerializeOpts,
opts: SerializeOpts,
): LooseSingleFileMetaDocument {
let doc = {
data: {
Expand All @@ -340,40 +325,25 @@ export function serializeFileDef(
};
let modelRelativeTo: RealmResourceIdentifier | URL | undefined =
model.id ?? model[relativeTo];
let vn = opts.virtualNetwork;
let data = serializeCardResource(
model,
doc,
{
...opts,
...{
maybeRelativeReference(possibleReference: string) {
let vn = opts?.virtualNetwork;
// Registered prefix refs (e.g. @cardstack/catalog/foo) are
// already in their canonical portable form — return as-is.
// Without a VN we can't know which prefixes are registered,
// so the most we can do for prefix-form refs is pass them
// through unchanged.
if (vn ? vn.isRegisteredPrefix(possibleReference) : false) {
if (vn.isRegisteredPrefix(possibleReference)) {
return possibleReference;
}
let modelRelativeToForURL: URL | undefined;
if (typeof modelRelativeTo === 'string') {
if (vn) {
modelRelativeToForURL = vn.toURL(modelRelativeTo);
} else if (
modelRelativeTo.startsWith('http://') ||
modelRelativeTo.startsWith('https://')
) {
modelRelativeToForURL = new URL(modelRelativeTo);
}
} else {
modelRelativeToForURL = modelRelativeTo;
}
let modelRelativeToForURL: URL | undefined =
typeof modelRelativeTo === 'string'
? vn.toURL(modelRelativeTo)
: modelRelativeTo;
let url = maybeURL(possibleReference, modelRelativeToForURL);
if (!url) {
if (!vn) {
return possibleReference;
}
throw new Error(
`could not determine url from '${possibleReference}' relative to ${modelRelativeTo}`,
);
Expand Down
8 changes: 7 additions & 1 deletion packages/host/app/commands/copy-and-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,13 @@ export default class CopyAndEditCommand extends HostBaseCommand<
fieldName: string,
): string | undefined {
try {
let serialized = this.#cardAPI?.serializeCard(card);
let vn = this.loaderService.loader.getVirtualNetwork();
if (!vn) {
return undefined;
}
let serialized = this.#cardAPI?.serializeCard(card, {
virtualNetwork: vn,
});
let relationships = (serialized?.data as any)?.relationships ?? {};
return Object.keys(relationships).find(
(key) => key === fieldName || key.endsWith(`.${fieldName}`),
Expand Down
2 changes: 1 addition & 1 deletion packages/host/app/lib/file-def-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ export default class FileDefManagerImpl
serialization: LooseSingleCardDocument;
}[] = await Promise.all(
cards.map(async (card) => {
let opts: CardAPI.SerializeOpts = {
let opts: Omit<CardAPI.SerializeOpts, 'virtualNetwork'> = {
useAbsoluteURL: true,
includeComputeds: true,
};
Expand Down
2 changes: 1 addition & 1 deletion packages/host/app/services/card-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export default class CardService extends Service {

async serializeCard(
card: CardDef,
opts?: SerializeOpts & { withIncluded?: true },
opts?: Omit<SerializeOpts, 'virtualNetwork'> & { withIncluded?: true },
): Promise<LooseSingleCardDocument> {
let api = await this.getAPI();
let serialized = api.serializeCard(card, {
Expand Down
4 changes: 4 additions & 0 deletions packages/host/app/services/render-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export class CardStoreWithErrors implements CardStore {
this.#virtualNetwork = virtualNetwork;
}

get virtualNetwork(): VirtualNetwork {
return this.#virtualNetwork;
}

getCard(id: string): CardDef | undefined {
id = this.normalizeKey(id);
return this.#cards.get(id);
Expand Down
10 changes: 7 additions & 3 deletions packages/host/tests/helpers/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,13 @@ export class TestRealmAdapter implements RealmAdapter {
`${baseRealm.url}card-api`,
);
if (cardApi.isCard(value)) {
let doc = cardApi.serializeCard(value, {
virtualNetwork: this.#loader.getVirtualNetwork(),
});
let vn = this.#loader.getVirtualNetwork();
if (!vn) {
throw new Error(
`TestRealmAdapter.openFile needs the test loader to have a VirtualNetwork to serialize ${path}`,
);
}
let doc = cardApi.serializeCard(value, { virtualNetwork: vn });
fileRefContent = JSON.stringify(doc);
} else {
fileRefContent =
Expand Down
Loading
Loading