From 674b39cb6bd601495a316d2757aee97addd06318 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 26 Jun 2026 15:32:44 -0500 Subject: [PATCH] refactor: remove VirtualNetwork from card-api resolveRef, isLocalId, and serialize paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identifiers are canonical RRI inside the runtime, so the form-bridging that was threaded through these interior paths is no longer needed: - resolveRef resolves relative references with pure RRI path math (new resolveRRIReference in url.ts); no VirtualNetwork. - isLocalId is a pure syntactic test (not a URL, not an @-prefix). - SerializeOpts no longer carries a VirtualNetwork; the serialize path (card-serialization.ts, serializers/code-ref.ts) preserves prefix-form refs and resolves URL-form refs with plain URL math. The host Store's asURL keeps resolving keys to normalized URL form (via the VN) so it stays consistent with gc-card-store, which keys instances by their URL-form data.id. Collapsing the store's canonical key to an opaque RRI token is deferred to CS-11730 — it needs gc-card-store keyed the same way and must preserve the URL normalization toURL provides. VirtualNetwork stays where it resolves an RRI to a real URL at the network boundary (document loading), at render-time pill resolution, and for the Store's URL-form keying. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/base/card-api.gts | 60 +++--------- packages/base/card-serialization.ts | 64 ++++++------- packages/host/app/commands/copy-and-edit.ts | 8 +- .../code-submode/spec-preview.gts | 5 +- packages/host/app/lib/gc-card-store.ts | 29 ++---- packages/host/app/routes/render/meta.ts | 1 - packages/host/app/services/card-service.ts | 3 +- .../services/operator-mode-state-service.ts | 11 +-- .../app/services/playground-panel-service.ts | 4 +- .../host/app/services/recent-cards-service.ts | 2 +- packages/host/app/services/render-service.ts | 4 +- .../host/app/services/spec-panel-service.ts | 2 +- packages/host/app/services/store.ts | 35 +++---- ...-submode-creation-and-permissions-test.gts | 4 +- packages/host/tests/helpers/adapter.ts | 8 +- packages/host/tests/helpers/base-realm.ts | 18 +--- packages/host/tests/helpers/index.gts | 8 +- packages/host/tests/helpers/indexer.ts | 9 +- packages/host/tests/unit/code-ref-test.ts | 2 - packages/runtime-common/index.ts | 9 +- .../runtime-common/serializers/code-ref.ts | 93 ++++++++----------- packages/runtime-common/url.ts | 47 ++++++++++ 22 files changed, 175 insertions(+), 251 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index ffa4d4a7904..ce1072768ad 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -93,6 +93,7 @@ import { runtimeQueryDependencyContext, type RuntimeDependencyTrackingContext, rri, + resolveRRIReference, type RealmResourceIdentifier, type VirtualNetwork, isDirectIndexedFieldKey, @@ -1433,7 +1434,7 @@ class LinksTo implements Field { if (reference == null || reference === '') { return null; } - let href = resolveRef(store.virtualNetwork, reference, relativeTo); + let href = resolveRef(reference, relativeTo); let cachedInstance = isFileDef(this.card) ? store.getFileMeta(href) : store.getCard(href); @@ -2006,11 +2007,7 @@ class LinksToMany implements Field< if (reference == null) { return null; } - let normalizedReference = resolveRef( - store.virtualNetwork, - reference, - relativeTo, - ); + let normalizedReference = resolveRef(reference, relativeTo); let cachedInstance = isFileDef(this.card) ? store.getFileMeta(normalizedReference) : store.getCard(normalizedReference); @@ -2463,11 +2460,7 @@ export class BaseDef { if (!value[relativeTo]) { return maybeRelativeReference; } - return resolveRef( - getStore(value).virtualNetwork, - maybeRelativeReference, - value[relativeTo], - ); + return resolveRef(maybeRelativeReference, value[relativeTo]); } return Object.fromEntries( Object.entries( @@ -2498,11 +2491,7 @@ export class BaseDef { if (isNonPresentLink(rawValue)) { let normalizedId = rawValue.reference; if (value[relativeTo]) { - normalizedId = resolveRef( - getStore(value).virtualNetwork, - normalizedId, - value[relativeTo], - ); + normalizedId = resolveRef(normalizedId, value[relativeTo]); } return [fieldName, { id: makeAbsoluteURL(rawValue.reference) }]; } @@ -3472,11 +3461,7 @@ function lazilyLoadLink( inflightLinkLoads.set(instance, inflightLoads); } let store = getStore(instance); - let reference = resolveRef( - store.virtualNetwork, - link, - instance.id ?? instance[relativeTo], - ); + let reference = resolveRef(link, instance.id ?? instance[relativeTo]); let key = `${field.name}/${reference}`; let promise = inflightLoads.get(key); if (promise) { @@ -3592,7 +3577,6 @@ function lazilyLoadLink( continue; } let notLoadedRef = resolveRef( - store.virtualNetwork, item.reference, instance.id ?? instance[relativeTo], ); @@ -3680,7 +3664,6 @@ function lazilyLoadLink( continue; } let notLoadedRef = resolveRef( - store.virtualNetwork, item.reference, instance.id ?? instance[relativeTo], ); @@ -4819,35 +4802,16 @@ export function virtualNetworkFor( } } -// Resolve a (possibly prefix-form or relative) reference to an absolute URL -// 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). +// Resolve a (possibly relative) reference to its absolute canonical RRI, +// relative to `relativeTo`. Identifiers are canonical RRI by the time they +// reach here, so this is pure form-preserving path math (see +// `resolveRRIReference`) — no VirtualNetwork, no realm-mapping lookup. The +// returned string is used as an opaque store key / "did this resolve?" signal. function resolveRef( - virtualNetwork: VirtualNetwork | undefined, reference: string, relativeTo: RealmResourceIdentifier | URL | undefined, ): string { - if (virtualNetwork) { - return virtualNetwork.resolveURL(reference, relativeTo).href; - } - let base: URL | string | undefined; - if (relativeTo instanceof URL) { - base = relativeTo; - } else if (typeof relativeTo === 'string') { - if (relativeTo.startsWith('http://') || relativeTo.startsWith('https://')) { - base = relativeTo; - } - } - try { - return new URL(reference, base).href; - } catch { - return reference; - } + return resolveRRIReference(reference, relativeTo); } function myLoader(): Loader { diff --git a/packages/base/card-serialization.ts b/packages/base/card-serialization.ts index 9af323e45df..dc959012c82 100644 --- a/packages/base/card-serialization.ts +++ b/packages/base/card-serialization.ts @@ -17,7 +17,7 @@ import type { } from '@cardstack/runtime-common'; import type { BaseDef, BaseDefConstructor, CardDef } from './card-api'; import type { FileDef } from './file-api'; -import type { ResourceID, VirtualNetwork } from '@cardstack/runtime-common'; +import type { ResourceID } from '@cardstack/runtime-common'; // --- Runtime Imports --- @@ -35,10 +35,10 @@ import { loadCardDef, localId, maybeRelativeReference, - maybeURL, meta, primitive, relativeTo, + resolveRRIReference, rri, } from '@cardstack/runtime-common'; import { getFieldOverrides, getFields, serializedGet } from './field-support'; @@ -72,9 +72,6 @@ export interface SerializeOpts { omitQueryFields?: boolean; maybeRelativeReference?: (possibleReference: string) => string; overrides?: Map; - // The VirtualNetwork to consult for prefix/RRI resolution during - // serialization. Required — every caller must thread a VN. - virtualNetwork: VirtualNetwork; } export interface DeserializeOpts { @@ -220,32 +217,25 @@ 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) { - // Registered prefix refs (e.g. @cardstack/catalog/foo) are already - // in their canonical portable form — return as-is. - if (vn.isRegisteredPrefix(possibleReference)) { + // Prefix-form RRIs (e.g. @cardstack/catalog/foo) are already in their + // canonical portable form — return as-is. + if (possibleReference.startsWith('@')) { return possibleReference; } - let modelRelativeToForURL: URL | undefined = - typeof modelRelativeTo === 'string' - ? vn.toURL(modelRelativeTo) - : modelRelativeTo; - let url = maybeURL(possibleReference, modelRelativeToForURL); - if (!url) { - throw new Error( - `could not determine url from '${possibleReference}' relative to ${modelRelativeTo}`, - ); - } + // Identifiers are canonical RRI, so resolve relative refs to their + // absolute form with plain path math (no VirtualNetwork), then + // relativize against the model's own id. + let absolute = resolveRRIReference(possibleReference, modelRelativeTo); if (!modelRelativeTo) { - return url.href; + return absolute; } const realmURLString = getCardMeta(model, 'realmURL'); const realmURL = realmURLString ? new URL(realmURLString) : undefined; - return maybeRelativeReference(url, modelRelativeTo, realmURL); + return maybeRelativeReference(rri(absolute), modelRelativeTo, realmURL); }, }, }); @@ -325,7 +315,6 @@ export function serializeFileDef( }; let modelRelativeTo: RealmResourceIdentifier | URL | undefined = model.id ?? model[relativeTo]; - let vn = opts.virtualNetwork; let data = serializeCardResource( model, doc, @@ -333,27 +322,28 @@ export function serializeFileDef( ...opts, ...{ maybeRelativeReference(possibleReference: string) { - // Registered prefix refs (e.g. @cardstack/catalog/foo) are - // already in their canonical portable form — return as-is. - if (vn.isRegisteredPrefix(possibleReference)) { + // Prefix-form RRIs (e.g. @cardstack/catalog/foo) are already in + // their canonical portable form — return as-is. + if (possibleReference.startsWith('@')) { return possibleReference; } - let modelRelativeToForURL: URL | undefined = - typeof modelRelativeTo === 'string' - ? vn.toURL(modelRelativeTo) - : modelRelativeTo; - let url = maybeURL(possibleReference, modelRelativeToForURL); - if (!url) { - throw new Error( - `could not determine url from '${possibleReference}' relative to ${modelRelativeTo}`, - ); - } + // Identifiers are canonical RRI, so resolve relative refs to their + // absolute form with plain path math (no VirtualNetwork), then + // relativize against the model's own id. + let absolute = resolveRRIReference( + possibleReference, + modelRelativeTo, + ); if (!modelRelativeTo) { - return url.href; + return absolute; } const realmURLString = getCardMeta(model, 'realmURL'); const realmURL = realmURLString ? new URL(realmURLString) : undefined; - return maybeRelativeReference(url, modelRelativeTo, realmURL); + return maybeRelativeReference( + rri(absolute), + modelRelativeTo, + realmURL, + ); }, }, }, diff --git a/packages/host/app/commands/copy-and-edit.ts b/packages/host/app/commands/copy-and-edit.ts index 08209655d6c..083efd94d2d 100644 --- a/packages/host/app/commands/copy-and-edit.ts +++ b/packages/host/app/commands/copy-and-edit.ts @@ -279,13 +279,7 @@ export default class CopyAndEditCommand extends HostBaseCommand< fieldName: string, ): string | undefined { try { - let vn = this.loaderService.loader.getVirtualNetwork(); - if (!vn) { - return undefined; - } - let serialized = this.#cardAPI?.serializeCard(card, { - virtualNetwork: vn, - }); + let serialized = this.#cardAPI?.serializeCard(card, {}); let relationships = (serialized?.data as any)?.relationships ?? {}; return Object.keys(relationships).find( (key) => key === fieldName || key.endsWith(`.${fieldName}`), diff --git a/packages/host/app/components/operator-mode/code-submode/spec-preview.gts b/packages/host/app/components/operator-mode/code-submode/spec-preview.gts index 269c979195b..c20e4e84cc6 100644 --- a/packages/host/app/components/operator-mode/code-submode/spec-preview.gts +++ b/packages/host/app/components/operator-mode/code-submode/spec-preview.gts @@ -166,10 +166,7 @@ class SpecPreviewContent extends GlimmerComponent { } @action private async viewSpecInstance() { - if ( - !this.selectedId || - isLocalId(this.selectedId, this.network.virtualNetwork) - ) { + if (!this.selectedId || isLocalId(this.selectedId)) { return; } diff --git a/packages/host/app/lib/gc-card-store.ts b/packages/host/app/lib/gc-card-store.ts index b2d4e632769..db0391d7de5 100644 --- a/packages/host/app/lib/gc-card-store.ts +++ b/packages/host/app/lib/gc-card-store.ts @@ -525,8 +525,8 @@ export default class CardStoreWithGarbageCollection implements CardStore { this.deleteFileMeta(id); return; } - let localId = isLocalId(id, this.#virtualNetwork) ? id : undefined; - let remoteId = !isLocalId(id, this.#virtualNetwork) ? id : undefined; + let localId = isLocalId(id) ? id : undefined; + let remoteId = !isLocalId(id) ? id : undefined; if (localId) { let remoteIds = this.#idResolver.getRemoteIds(localId); @@ -792,7 +792,7 @@ export default class CardStoreWithGarbageCollection implements CardStore { id = id.replace(/\.json$/, ''); let { item, localId } = this.tryFindingCardItem(type, id); - if (!item && isLocalId(id, this.#virtualNetwork)) { + if (!item && isLocalId(id)) { let maybeRemoteId = this.#idResolver.findRemoteId(id); if (maybeRemoteId) { ({ item, localId } = this.tryFindingCardItem(type, maybeRemoteId)); @@ -846,12 +846,8 @@ export default class CardStoreWithGarbageCollection implements CardStore { type === 'instance' ? this.#nonTrackedCardInstances : this.#nonTrackedCardInstanceErrors; - let localId = isLocalId(localOrRemoteId, this.#virtualNetwork) - ? localOrRemoteId - : undefined; - let remoteId = !isLocalId(localOrRemoteId, this.#virtualNetwork) - ? localOrRemoteId - : undefined; + let localId = isLocalId(localOrRemoteId) ? localOrRemoteId : undefined; + let remoteId = !isLocalId(localOrRemoteId) ? localOrRemoteId : undefined; let item: CardDef | CardErrorJSONAPI | undefined; if (remoteId) { if (localId) { @@ -897,7 +893,7 @@ export default class CardStoreWithGarbageCollection implements CardStore { let errorBucket = notTracked ? this.#nonTrackedCardInstanceErrors : this.#cardInstanceErrors; - let isRemoteId = !isLocalId(id, this.#virtualNetwork); + let isRemoteId = !isLocalId(id); if (isRemoteId) { if (isCardInstance(item)) { this.#idResolver.addIdPair(item[localIdSymbol], id); @@ -913,15 +909,10 @@ export default class CardStoreWithGarbageCollection implements CardStore { } let instance = isCardInstance(item) ? item : undefined; let error = !isCardInstance(item) ? item : undefined; - if ( - error && - isRemoteId && - error.id && - isLocalId(error.id, this.#virtualNetwork) - ) { + if (error && isRemoteId && error.id && isLocalId(error.id)) { this.#idResolver.addIdPair(error.id, id); } - let localId = isLocalId(id, this.#virtualNetwork) ? id : undefined; + let localId = isLocalId(id) ? id : undefined; let remoteIds = isRemoteId ? [id] : []; if (localId) { remoteIds = this.#idResolver.getRemoteIds(localId); @@ -996,9 +987,7 @@ export default class CardStoreWithGarbageCollection implements CardStore { private hasReferences(id: string): boolean { let idsToCheck = new Set([id]); - let localId = isLocalId(id, this.#virtualNetwork) - ? id - : this.#idResolver.getLocalId(id); + let localId = isLocalId(id) ? id : this.#idResolver.getLocalId(id); if (localId) { idsToCheck.add(localId); for (let remoteId of this.#idResolver.getRemoteIds(localId)) { diff --git a/packages/host/app/routes/render/meta.ts b/packages/host/app/routes/render/meta.ts index ff2723bcb26..1f8ca63a97d 100644 --- a/packages/host/app/routes/render/meta.ts +++ b/packages/host/app/routes/render/meta.ts @@ -102,7 +102,6 @@ export default class RenderMetaRoute extends Route { // relationships, so omit query fields here (the relationship data is // stripped below regardless). omitQueryFields: true, - virtualNetwork: vn, maybeRelativeReference: (reference: string) => maybeRelativeReference( vn.toURL(reference), diff --git a/packages/host/app/services/card-service.ts b/packages/host/app/services/card-service.ts index 676dc8edbab..6b422d9d8e4 100644 --- a/packages/host/app/services/card-service.ts +++ b/packages/host/app/services/card-service.ts @@ -200,11 +200,10 @@ export default class CardService extends Service { async serializeCard( card: CardDef, - opts?: Omit & { withIncluded?: true }, + opts?: SerializeOpts & { withIncluded?: true }, ): Promise { let api = await this.getAPI(); let serialized = api.serializeCard(card, { - virtualNetwork: this.network.virtualNetwork, ...opts, }); if (!opts?.withIncluded) { diff --git a/packages/host/app/services/operator-mode-state-service.ts b/packages/host/app/services/operator-mode-state-service.ts index bada366522e..548563d1699 100644 --- a/packages/host/app/services/operator-mode-state-service.ts +++ b/packages/host/app/services/operator-mode-state-service.ts @@ -638,7 +638,7 @@ export default class OperatorModeStateService extends Service { } setHostModePrimaryCard(cardId?: string) { - if (cardId && !isLocalId(cardId, this.network.virtualNetwork)) { + if (cardId && !isLocalId(cardId)) { this._state.hostModePrimaryCard = cardId.replace(/\.json$/, ''); } else if (!cardId) { this._state.hostModePrimaryCard = null; @@ -1049,10 +1049,7 @@ export default class OperatorModeStateService extends Service { let instance = this.store.peek(item.id) ?? this.store.peek(item.id, { type: 'file-meta' }); - if ( - !isLocalId(item.id, this.network.virtualNetwork) || - instance?.id - ) { + if (!isLocalId(item.id) || instance?.id) { serializedStack.push({ id: instance?.id ?? item.id, format: item.format, @@ -1355,7 +1352,7 @@ export default class OperatorModeStateService extends Service { this.clearStacks(); // Determine realm URL. If id is a localId, look up the instance in the store to read its realm. let realmHref: string | undefined; - if (isLocalId(id, this.network.virtualNetwork)) { + if (isLocalId(id)) { let instance = this.store.peek(id); if (instance && isCardInstance(instance)) { realmHref = (instance as any)[realmURLSymbol]?.href; @@ -1580,7 +1577,7 @@ export default class OperatorModeStateService extends Service { if (!id) { return undefined; } - if (isLocalId(id, this.network.virtualNetwork)) { + if (isLocalId(id)) { let maybeInstance = this.store.peek(id); if ( maybeInstance && diff --git a/packages/host/app/services/playground-panel-service.ts b/packages/host/app/services/playground-panel-service.ts index 4da99e9109c..701c5f9b4bf 100644 --- a/packages/host/app/services/playground-panel-service.ts +++ b/packages/host/app/services/playground-panel-service.ts @@ -93,7 +93,7 @@ export default class PlaygroundPanelService extends Service { fieldIndex, url, }; - if (isLocalId(cardId, this.network.virtualNetwork)) { + if (isLocalId(cardId)) { this.storeWhenIdAssignedTask.perform( moduleId, cardId, @@ -108,7 +108,7 @@ export default class PlaygroundPanelService extends Service { private get resolvedSelections(): Record { return Object.fromEntries( Object.entries(this.playgroundSelections).flatMap(([id, selections]) => { - if (!isLocalId(id, this.network.virtualNetwork)) { + if (!isLocalId(id)) { return [[id, selections]]; } let instance = this.store.peek(id); diff --git a/packages/host/app/services/recent-cards-service.ts b/packages/host/app/services/recent-cards-service.ts index 636a33cbe54..0d3444752b4 100644 --- a/packages/host/app/services/recent-cards-service.ts +++ b/packages/host/app/services/recent-cards-service.ts @@ -85,7 +85,7 @@ export default class RecentCardsService extends Service { } add(newId: string) { - if (isLocalId(newId, this.network.virtualNetwork)) { + if (isLocalId(newId)) { let instance = this.store.peek(newId); if (isCardInstance(instance)) { this.addNewCard(instance); diff --git a/packages/host/app/services/render-service.ts b/packages/host/app/services/render-service.ts index 9780a3f10e0..ee10cd8ccd8 100644 --- a/packages/host/app/services/render-service.ts +++ b/packages/host/app/services/render-service.ts @@ -144,9 +144,7 @@ export class CardStoreWithErrors implements CardStore { let key = id.replace(/\.json$/, ''); // Local IDs pass through; remote IDs canonicalize to URL form via the // VN so prefix-form and URL-form lookups share a cache key. - return isLocalId(key, this.#virtualNetwork) - ? id - : this.#virtualNetwork.toURL(id).href; + return isLocalId(key) ? id : this.#virtualNetwork.toURL(id).href; } trackLoad(load: Promise) { diff --git a/packages/host/app/services/spec-panel-service.ts b/packages/host/app/services/spec-panel-service.ts index 0925bd72d31..cedfdcb1b41 100644 --- a/packages/host/app/services/spec-panel-service.ts +++ b/packages/host/app/services/spec-panel-service.ts @@ -44,7 +44,7 @@ export default class SpecPanelService extends Service { setSelection = (id: string | null) => { this.specSelection = id; - if (id && isLocalId(id, this.network.virtualNetwork)) { + if (id && isLocalId(id)) { this.storeWhenIdAssignedTask.perform(id); } else { this.clearPendingCardIdSubscriptions(); diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index aefc249b82c..91521770617 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -502,7 +502,7 @@ export default class StoreService extends Service implements StoreInterface { `adding reference to ${id}, current reference count: ${this.referenceCount.get(id)}`, ); - if (isLocalId(id, this.network.virtualNetwork)) { + if (isLocalId(id)) { let instanceOrError = this.peek(id); if (instanceOrError) { let realmURL = isCardInstance(instanceOrError) @@ -883,9 +883,7 @@ export default class StoreService extends Service implements StoreInterface { fileDef: FileDef, ): Promise { let api = await this.cardService.getAPI(); - return api.serializeFileDef(fileDef, { - virtualNetwork: this.network.virtualNetwork, - }) as SingleFileMetaDocument; + return api.serializeFileDef(fileDef, {}) as SingleFileMetaDocument; } async delete(id: string): Promise { @@ -1562,7 +1560,7 @@ export default class StoreService extends Service implements StoreInterface { } // if there are no more subscribers to this realm then unsubscribe from realm - let realmHref = !isLocalId(id, this.network.virtualNetwork) + let realmHref = !isLocalId(id) ? [...this.subscriptions.keys()].find((realmURL) => id.startsWith(realmURL), ) @@ -1576,7 +1574,7 @@ export default class StoreService extends Service implements StoreInterface { subscription && ![...this.referenceCount.entries()].find( ([referenceId, count]) => - !isLocalId(referenceId, this.network.virtualNetwork) && + !isLocalId(referenceId) && count > 0 && referenceId.startsWith(realmHref), ) @@ -1762,7 +1760,7 @@ export default class StoreService extends Service implements StoreInterface { if (referenceCount === 0) { continue; } - if (isLocalId(id, this.network.virtualNetwork)) { + if (isLocalId(id)) { let remoteIdsForLocal = this.store.getRemoteIds(id); if (remoteIdsForLocal.length === 0) { let error = this.store.getCardError(id); @@ -2037,7 +2035,7 @@ export default class StoreService extends Service implements StoreInterface { return existingInstance as T; } let vn = this.network.virtualNetwork; - if (isLocalId(id, vn) && !vn.isRegisteredPrefix(id)) { + if (isLocalId(id) && !vn.isRegisteredPrefix(id)) { // we might have lost the local id via a loader refresh, try loading from remote id instead let remoteId = this.store.getRemoteIds(id)?.[0]; if (!remoteId) { @@ -2216,7 +2214,7 @@ export default class StoreService extends Service implements StoreInterface { return existingInstance as T | CardErrorJSONAPI; } let vn = this.network.virtualNetwork; - if (isLocalId(id, vn) && !vn.isRegisteredPrefix(id)) { + if (isLocalId(id) && !vn.isRegisteredPrefix(id)) { throw new Error(`file-meta reads do not support local ids (${id})`); } let url = vn.isRegisteredPrefix(id) ? vn.toURL(id).href : id; @@ -2374,10 +2372,7 @@ export default class StoreService extends Service implements StoreInterface { } finally { autoSaveState.isSaving = false; this.calculateLastSavedMsg(autoSaveState); - if ( - isLocalId(queueName, this.network.virtualNetwork) && - instance.id - ) { + if (isLocalId(queueName) && instance.id) { this.autoSaveStates.set(instance.id, autoSaveState); } } @@ -2569,11 +2564,7 @@ export default class StoreService extends Service implements StoreInterface { let cardError = errorResponse.errors[0]; this.setIdentityContext(cardError); let remoteId = cardError.meta?.remoteId; - if ( - remoteId && - (!cardError.id || - isLocalId(cardError.id, this.network.virtualNetwork)) - ) { + if (remoteId && (!cardError.id || isLocalId(cardError.id))) { this.store.addCardInstanceOrError(remoteId, cardError); } return cardError; @@ -2797,8 +2788,12 @@ export function asURL( return urlOrDoc.data.id; } let id = urlOrDoc.replace(/\.json$/, ''); - // Locals stay as-is; remotes resolve through the VN. - return isLocalId(id, vn) ? id : vn.toURL(id).href; + // Locals stay as-is; remotes resolve through the VN to a normalized URL. + // Keying stays in URL form so it matches gc-card-store, which keys instances + // by their (URL-form) data.id. Flipping the store's canonical key to RRI is + // deferred — it needs gc-card-store keyed the same way and the URL + // normalization `toURL` provides here (see CS-11730). + return isLocalId(id) ? id : vn.toURL(id).href; } function isSystemCardDefaultId( diff --git a/packages/host/tests/acceptance/interact-submode-creation-and-permissions-test.gts b/packages/host/tests/acceptance/interact-submode-creation-and-permissions-test.gts index 250be25b247..f670a4809e7 100644 --- a/packages/host/tests/acceptance/interact-submode-creation-and-permissions-test.gts +++ b/packages/host/tests/acceptance/interact-submode-creation-and-permissions-test.gts @@ -97,9 +97,7 @@ module( `the newly created card's remote id is in recent cards`, ); assert.notOk( - recentCards.find((c) => - isLocalId(c.cardId, getService('network').virtualNetwork), - ), + recentCards.find((c) => isLocalId(c.cardId)), `no local ID's are in recent cards`, ); }); diff --git a/packages/host/tests/helpers/adapter.ts b/packages/host/tests/helpers/adapter.ts index 02b02006e73..af64be8f02e 100644 --- a/packages/host/tests/helpers/adapter.ts +++ b/packages/host/tests/helpers/adapter.ts @@ -265,13 +265,7 @@ export class TestRealmAdapter implements RealmAdapter { `${baseRealm.url}card-api`, ); if (cardApi.isCard(value)) { - 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 }); + let doc = cardApi.serializeCard(value, {}); fileRefContent = JSON.stringify(doc); } else { fileRefContent = diff --git a/packages/host/tests/helpers/base-realm.ts b/packages/host/tests/helpers/base-realm.ts index 3c473f9638f..72e2f24e73c 100644 --- a/packages/host/tests/helpers/base-realm.ts +++ b/packages/host/tests/helpers/base-realm.ts @@ -168,28 +168,14 @@ function serializeCard( card: Parameters<(typeof CardAPIModule)['serializeCard']>[0], opts?: Partial[1]>, ): ReturnType<(typeof CardAPIModule)['serializeCard']> { - let loader = getService('loader-service').loader; - let vn = loader.getVirtualNetwork(); - if (!vn) { - throw new Error( - `base-realm test helper's serializeCard requires the active loader to have a VirtualNetwork`, - ); - } - return rawSerializeCard(card, { virtualNetwork: vn, ...opts }); + return rawSerializeCard(card, { ...opts }); } function serializeFileDef( fileDef: Parameters<(typeof CardAPIModule)['serializeFileDef']>[0], opts?: Partial[1]>, ): ReturnType<(typeof CardAPIModule)['serializeFileDef']> { - let loader = getService('loader-service').loader; - let vn = loader.getVirtualNetwork(); - if (!vn) { - throw new Error( - `base-realm test helper's serializeFileDef requires the active loader to have a VirtualNetwork`, - ); - } - return rawSerializeFileDef(fileDef, { virtualNetwork: vn, ...opts }); + return rawSerializeFileDef(fileDef, { ...opts }); } let isSaved: (typeof CardAPIModule)['isSaved']; let getRelationshipMembershipState: (typeof CardAPIModule)['getRelationshipMembershipState']; diff --git a/packages/host/tests/helpers/index.gts b/packages/host/tests/helpers/index.gts index 4e4ed6b4fe7..ad1496f45fd 100644 --- a/packages/host/tests/helpers/index.gts +++ b/packages/host/tests/helpers/index.gts @@ -1480,13 +1480,7 @@ export async function saveCard( realmURL?: RealmIdentifier, ) { let api = await loader.import(`${baseRealm.url}card-api`); - let vn = loader.getVirtualNetwork(); - if (!vn) { - throw new Error( - `setCardAsSavedWithId test helper needs the loader to have a VirtualNetwork`, - ); - } - let doc = api.serializeCard(instance, { virtualNetwork: vn }); + let doc = api.serializeCard(instance, {}); doc.data.id = id; if (realmURL) { doc.data.meta = { diff --git a/packages/host/tests/helpers/indexer.ts b/packages/host/tests/helpers/indexer.ts index 880f2723474..71cedf6c98b 100644 --- a/packages/host/tests/helpers/indexer.ts +++ b/packages/host/tests/helpers/indexer.ts @@ -77,14 +77,7 @@ export async function getTypes(instance: CardDef): Promise { export async function serializeCard(card: CardDef): Promise { let api = await apiFor(card); - let loader = loaderFor(card); - let virtualNetwork = loader.getVirtualNetwork(); - if (!virtualNetwork) { - throw new Error( - `serializeCard test helper requires a Loader with an attached VirtualNetwork`, - ); - } - return api.serializeCard(card, { virtualNetwork }).data as CardResource; + return api.serializeCard(card, {}).data as CardResource; } // we can relax the resource here since we will be asserting an ID when we diff --git a/packages/host/tests/unit/code-ref-test.ts b/packages/host/tests/unit/code-ref-test.ts index 926a31bd8d4..b8376da0b22 100644 --- a/packages/host/tests/unit/code-ref-test.ts +++ b/packages/host/tests/unit/code-ref-test.ts @@ -6,7 +6,6 @@ import { module, test } from 'qunit'; import type { Loader, LooseCardResource } from '@cardstack/runtime-common'; import { - VirtualNetwork, baseRealm, loadCardDef, rri, @@ -195,7 +194,6 @@ module('code-ref', function (hooks) { let doc = { data: { id: base.href } }; let serialized = CodeRefSerializer.serialize(ref, doc, undefined, { relativeTo: base, - virtualNetwork: new VirtualNetwork(), }) as any; assert.strictEqual( serialized.module, diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index 68984a747b3..4dc5301afff 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -1343,8 +1343,13 @@ export function unixTime(epochTimeMs: number) { return Math.floor(epochTimeMs / 1000); } -export function isLocalId(id: string, virtualNetwork: VirtualNetwork) { - return !id.startsWith('http') && !virtualNetwork.isRegisteredPrefix(id); +// A local id is a client-minted token for an instance that has not yet been +// saved to a realm — it is neither a URL nor a prefix-form RRI. Both remote +// forms are syntactically distinguishable (URLs start with `http`, prefix-form +// RRIs start with `@`), so this needs no VirtualNetwork: identifiers are +// canonical RRI by the time they reach here. +export function isLocalId(id: string) { + return !id.startsWith('http') && !id.startsWith('@'); } export function isBrowserTestEnv() { diff --git a/packages/runtime-common/serializers/code-ref.ts b/packages/runtime-common/serializers/code-ref.ts index 3803394cf19..b7a57ee7072 100644 --- a/packages/runtime-common/serializers/code-ref.ts +++ b/packages/runtime-common/serializers/code-ref.ts @@ -9,9 +9,7 @@ import { isResolvedCodeRef, executableExtensions, } from '../index.ts'; -import { resolveModuleHref } from '../code-ref.ts'; import { rri, type RealmResourceIdentifier } from '../realm-identifiers.ts'; -import type { VirtualNetwork } from '../virtual-network.ts'; // We only use a subset of SerializeOpts here; accept any to align with the // serializer interface without surfacing unused properties. import type { SerializeOpts } from 'https://cardstack.com/base/card-api'; @@ -32,40 +30,33 @@ export function serialize( trimExecutableExtension?: true; maybeRelativeReference?: (reference: string) => string; allowRelative?: true; - virtualNetwork?: VirtualNetwork; }, ): ResolvedCodeRef | {} { // The recursive serialize path through a non-primitive `Contains` field // intentionally isolates the inner card's serialization from the outer // card's opts (see `Contains.serialize` in card-api.gts), so opts can - // arrive here as `undefined` or as a synthesized `{ overrides }` object - // with no `virtualNetwork`. URL-form refs can still be resolved with - // plain URL math; prefix-form refs need a VN and are left alone. + // arrive here as `undefined` or as a synthesized `{ overrides }` object. + // Identifiers are canonical RRI, so this is pure URL/path math: URL-form + // refs resolve against a URL base; prefix-form refs are already portable + // and are preserved by `codeRefAdjustments`. if (!opts) { return { ...codeRef }; } - let vn = opts.virtualNetwork; let baseURL: URL | undefined; if (opts.relativeTo instanceof URL) { baseURL = opts.relativeTo; - } else if (typeof opts.relativeTo === 'string') { - if (vn) { - baseURL = vn.toURL(opts.relativeTo); - } else if ( - opts.relativeTo.startsWith('http://') || - opts.relativeTo.startsWith('https://') - ) { - baseURL = new URL(opts.relativeTo); - } - } else if (doc?.data?.id && typeof doc.data.id === 'string') { - if (vn) { - baseURL = vn.toURL(doc.data.id); - } else if ( - doc.data.id.startsWith('http://') || - doc.data.id.startsWith('https://') - ) { - baseURL = new URL(doc.data.id); - } + } else if ( + typeof opts.relativeTo === 'string' && + (opts.relativeTo.startsWith('http://') || + opts.relativeTo.startsWith('https://')) + ) { + baseURL = new URL(opts.relativeTo); + } else if ( + doc?.data?.id && + typeof doc.data.id === 'string' && + (doc.data.id.startsWith('http://') || doc.data.id.startsWith('https://')) + ) { + baseURL = new URL(doc.data.id); } return { ...codeRef, @@ -94,17 +85,14 @@ export async function deserializeAbsolute( ): Promise> { if (!store) { // Reached only by direct test callers that bypass the framework - // protocol; the framework's field-deserialize path always supplies - // a store. Without a VN we can't resolve prefix-form refs or - // round-trip URL-form refs through registered mappings, so leave - // the codeRef untouched. + // protocol; the framework's field-deserialize path always supplies a + // store. Preserve the historical "leave the codeRef untouched" behavior + // for that path. return { ...codeRef } as BaseInstanceType; } return { ...codeRef, - ...codeRefAdjustments(codeRef, relativeTo, { - virtualNetwork: store.virtualNetwork, - }), + ...codeRefAdjustments(codeRef, relativeTo, {}), } as BaseInstanceType; } @@ -115,7 +103,6 @@ function codeRefAdjustments( trimExecutableExtension?: true; maybeRelativeReference?: (reference: string) => string; allowRelative?: true; - virtualNetwork?: VirtualNetwork; }, ) { if (!codeRef) { @@ -124,41 +111,41 @@ function codeRefAdjustments( if (!isResolvedCodeRef(codeRef)) { return {}; } - // opts may arrive without a VN — the recursive non-primitive-Contains - // serialize path isolates inner cards from the outer card's opts, and - // `deserializeAbsolute` may also be called without a store. URL-like - // refs still resolve through plain URL math; bare specifiers fall - // through to the loader's importMap shim via the surrounding try/catch. - let vn = opts?.virtualNetwork; + // Identifiers are canonical RRI here, so resolution is plain URL math — + // no VirtualNetwork. A URL-form module joins against a URL-form base; a + // scoped RRI / bare specifier is already portable and is preserved. + let urlBase: URL | string | undefined = + relativeTo instanceof URL + ? relativeTo + : typeof relativeTo === 'string' && + (relativeTo.startsWith('http://') || + relativeTo.startsWith('https://')) + ? relativeTo + : undefined; let resolve = (ref: string) => { - if (vn) { - return resolveModuleHref(ref, relativeTo, vn); - } if (!isUrlLike(ref)) { throw new Error( - `Cannot resolve bare package specifier "${ref}" — no matching prefix mapping registered`, + `Cannot resolve bare package specifier "${ref}" — not a URL`, ); } - return new URL(ref, relativeTo).href; + return new URL(ref, urlBase).href; }; if (!isUrlLike(codeRef.module)) { // A scoped RRI (e.g. `@cardstack/base/card-api`) is already the canonical, // deployment-independent portable form. Preserve it verbatim rather than - // resolving it to a concrete realm URL: resolution maps the prefix to the - // serializing realm's real base URL, baking an environment-specific (and - // possibly cross-origin) URL into the stored card and defeating the - // portability the RRI exists to provide. (A URL-form alias is left as-is - // by `resolveURL`, so this matches how the alias form round-trips.) - if (vn?.isRegisteredPrefix(codeRef.module)) { + // resolving it to a concrete realm URL: resolution would bake an + // environment-specific (and possibly cross-origin) URL into the stored + // card and defeat the portability the RRI exists to provide. + if (codeRef.module.startsWith('@')) { let module: string = codeRef.module; if (opts?.trimExecutableExtension) { module = trimExecutableExtension(rri(module)); } return { module }; } - // Otherwise it is a loader-only bare specifier (e.g. a boxel-host - // command). Try registered prefix mappings, and if unresolvable leave it - // for the loader's importMap shim via the surrounding try/catch. + // Otherwise it is a non-scoped bare specifier. Try plain URL resolution, + // and if unresolvable leave it for the loader's importMap shim via the + // surrounding try/catch. try { let resolved = resolve(codeRef.module); if (resolved !== codeRef.module) { diff --git a/packages/runtime-common/url.ts b/packages/runtime-common/url.ts index 9dc2e5dca4e..06298e0bfd7 100644 --- a/packages/runtime-common/url.ts +++ b/packages/runtime-common/url.ts @@ -127,6 +127,53 @@ export function maybeRelativeReference( return reference instanceof URL ? reference.href : reference; } +// Synthetic origin used to borrow the URL parser's path-normalization for +// prefix-form RRI bases. The host is invalid-by-construction so it can never +// collide with a real realm URL. +const RRI_SYNTHETIC_ORIGIN = 'https://rri.invalid'; + +// Resolve a (possibly relative) reference against `relativeTo`, in RRI space, +// without consulting realm mappings — the inverse of `relativeReference`. +// Identifiers are already in canonical RRI form once they reach here (a card's +// own id / a relationship `links.self`), so this is pure form-preserving path +// math: +// - an absolute reference (URL form, or `@scope/name` prefix form) is already +// canonical — returned unchanged; +// - a relative reference is joined onto the base. A URL-form base uses the +// native URL parser; a prefix-form base borrows the parser via a synthetic +// origin, then the `@scope/name` namespace is restored. +// Falls back to the reference unchanged when there is no usable base. +export function resolveRRIReference( + reference: string, + relativeTo: RealmResourceIdentifier | URL | undefined, +): string { + if ( + reference.startsWith('http://') || + reference.startsWith('https://') || + reference.startsWith('@') + ) { + return reference; + } + if (relativeTo == null) { + return reference; + } + let base = relativeTo instanceof URL ? relativeTo.href : relativeTo; + if (base.startsWith('http://') || base.startsWith('https://')) { + return new URL(reference, base).href; + } + // Prefix-form RRI base. The namespace is the first two segments + // (`@scope/name`), matching `sharedNamespace`; the remainder is the in-realm + // path the reference resolves against. + let parts = base.split('/'); + if (parts.length >= 2 && parts[0].startsWith('@')) { + let namespace = `${parts[0]}/${parts[1]}`; + let path = base.slice(namespace.length) || '/'; + let resolved = new URL(reference, `${RRI_SYNTHETIC_ORIGIN}${path}`); + return `${namespace}${resolved.pathname}${resolved.search}${resolved.hash}`; + } + return reference; +} + export function trimJsonExtension(str: string) { return str.replace(/\.json$/, ''); }