diff --git a/packages/realm-server/tests/card-endpoints-test.ts b/packages/realm-server/tests/card-endpoints-test.ts index bd62b5ad22..fb648ea401 100644 --- a/packages/realm-server/tests/card-endpoints-test.ts +++ b/packages/realm-server/tests/card-endpoints-test.ts @@ -4208,7 +4208,32 @@ module(basename(import.meta.filename), function () { '@node-test_realm:localhost': ['read', 'realm-owner'], }, fileSystem: { - 'instructions.md': '# Cross-realm instructions', + 'instructions.md': `--- +boxel: + kind: skill +--- +# Cross-realm instructions`, + 'model.gts': ` + import { CardDef, field, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Model extends CardDef { + @field cardTitle = contains(StringField); + } + `, + 'model-4.6.json': { + data: { + attributes: { + cardTitle: 'Model 4.6', + }, + meta: { + adoptsFrom: { + module: rri('./model'), + name: 'Model', + }, + }, + }, + }, }, }, { @@ -4226,8 +4251,29 @@ module(basename(import.meta.filename), function () { export class SkillCard extends CardDef { @field cardTitle = contains(StringField); @field instructionsSource = linksTo(MarkdownDef); + @field model = linksTo(CardDef); } `, + 'skill-with-model.json': { + data: { + attributes: { + cardTitle: 'Skill with dotted model link', + }, + relationships: { + model: { + links: { + self: `${providerRealmURL}model-4.6`, + }, + }, + }, + meta: { + adoptsFrom: { + module: rri('./skill-card'), + name: 'SkillCard', + }, + }, + }, + }, 'skill.json': { data: { attributes: { @@ -4298,6 +4344,41 @@ module(basename(import.meta.filename), function () { 'cross-realm linked resource is a file-meta resource', ); assert.strictEqual(linkedFile?.attributes?.name, 'instructions.md'); + // Index-derived attributes must survive the cross-realm hop: a partial + // file-meta here gets cached by the host, where a markdown skill + // without `kind: 'skill'` silently stops counting as a skill. + assert.strictEqual( + linkedFile?.attributes?.kind, + 'skill', + 'cross-realm file-meta carries index-derived attributes', + ); + }); + + test('serves a card linking to a cross-realm card whose id contains a dot', async function (assert) { + // Card ids may legitimately contain dots (e.g. a versioned + // ModelConfiguration). The file-link detection keys on known FileDef + // extensions, so ".6" must not classify this link as a file. + let response = await consumerRequest + .get('/skill-with-model') + .set('Accept', 'application/vnd.card+json'); + + assert.strictEqual( + response.status, + 200, + `HTTP 200 status: ${response.text}`, + ); + + let doc = response.body as LooseSingleCardDocument; + let included = doc.included ?? []; + let linkedCard = included.find( + (resource) => resource.id === `${providerRealmURL}model-4.6`, + ); + assert.ok(linkedCard, 'includes the cross-realm dotted-id card'); + assert.strictEqual( + linkedCard?.attributes?.cardTitle, + 'Model 4.6', + 'the dotted-id link resolves as a card, not a file', + ); }); }); diff --git a/packages/runtime-common/file-def-code-ref.ts b/packages/runtime-common/file-def-code-ref.ts index feafe839ee..e492d0d1cf 100644 --- a/packages/runtime-common/file-def-code-ref.ts +++ b/packages/runtime-common/file-def-code-ref.ts @@ -77,13 +77,27 @@ export function isFileDefCodeRef( return false; } +function extensionOf(url: URL): string { + let name = url.pathname.split('/').pop() ?? ''; + let dot = name.lastIndexOf('.'); + return dot <= 0 ? '' : name.slice(dot).toLowerCase(); +} + +// Whether a URL names a file rather than a card, judged by a known FileDef +// extension on the last path segment. Card ids never end in one (an +// instance's `.json` is stripped from its id) — but a bare "has a dot" +// check would misfire on card ids that legitimately contain dots (e.g. a +// `ModelConfiguration/claude-sonnet-4.6` instance), so only registered +// file extensions count. +export function urlNamesFile(url: URL): boolean { + return extensionOf(url) in FILEDEF_CODE_REF_BY_EXTENSION; +} + export function resolveFileDefCodeRef( fileURL: URL, virtualNetwork: VirtualNetwork, ): ResolvedCodeRef { - let name = fileURL.pathname.split('/').pop() ?? ''; - let dot = name.lastIndexOf('.'); - let extension = dot === -1 ? '' : name.slice(dot).toLowerCase(); + let extension = extensionOf(fileURL); let mapping = extension ? FILEDEF_CODE_REF_BY_EXTENSION[extension] : undefined; diff --git a/packages/runtime-common/realm-index-query-engine.ts b/packages/runtime-common/realm-index-query-engine.ts index b9f4c6a744..f7766afc35 100644 --- a/packages/runtime-common/realm-index-query-engine.ts +++ b/packages/runtime-common/realm-index-query-engine.ts @@ -84,7 +84,8 @@ import { type SearchEntryFieldset, type SearchEntryQuery, } from './search-entry.ts'; -import { getImmediateFieldDef, type FieldDefinition } from './definitions.ts'; +import type { FieldDefinition } from './definitions.ts'; +import { urlNamesFile } from './file-def-code-ref.ts'; import { normalizeQueryDefinition, buildQuerySearchURL, @@ -697,71 +698,6 @@ export class RealmIndexQueryEngine { return fileMatch && !instanceMatch; } - // When a relationship in the pristine_doc is missing data.type (stale - // index data from before the fix that added data to NotLoadedValue - // serialization), we need to consult the field definition to determine - // whether the relationship targets a FileDef or a CardDef. - private async fieldExpectsFileMeta( - resource: LooseCardResource | FileMetaResource, - fieldKey: string, - opts?: Options, - ): Promise { - if (!resource.meta?.adoptsFrom) { - return false; - } - let relativeTo = resource.id - ? this.#realm.virtualNetwork.toURL(resource.id) - : this.realmURL; - let codeRef = codeRefWithAbsoluteIdentifier( - resource.meta.adoptsFrom, - relativeTo, - undefined, - this.#realm.virtualNetwork, - ); - if (!isResolvedCodeRef(codeRef)) { - return false; - } - try { - let definition: import('./definitions.ts').Definition | undefined; - if (opts?.cacheOnlyDefinitions) { - definition = - await this.#definitionLookup.lookupCachedDefinition(codeRef); - if (!definition) { - return false; - } - } else { - definition = await this.#definitionLookup.lookupDefinition(codeRef, { - ...(opts?.priority !== undefined ? { priority: opts.priority } : {}), - }); - } - if (!definition) { - return false; - } - // Strip the linksToMany index suffix (e.g., "friends.0" -> "friends") - let fieldName = fieldKey.includes('.') - ? fieldKey.slice(0, fieldKey.indexOf('.')) - : fieldKey; - let fieldDefinition = getImmediateFieldDef(definition, fieldName); - if (!fieldDefinition) { - return false; - } - let fieldCardRef = fieldDefinition.fieldOrCard; - let isFileType = await this.#indexQueryEngine.hasFileType( - this.realmURL, - fieldCardRef, - opts, - ); - let isInstanceType = await this.#indexQueryEngine.hasInstanceType( - this.realmURL, - fieldCardRef, - opts, - ); - return isFileType && !isInstanceType; - } catch { - return false; - } - } - async fetchCardTypeSummary() { let results = await this.#indexQueryEngine.fetchCardTypeSummary( new URL(this.#realm.url), @@ -1377,9 +1313,17 @@ export class RealmIndexQueryEngine { let entries = await Promise.all( urls.map(async (url) => { let response: Response; + // A file link (the URL ends in a known FileDef extension; card ids + // never do) is requested with the file-meta mime so the serving + // realm returns the index-enriched document — consumers rely on + // index-derived attributes like a markdown skill's `kind`, which + // the card-mime fallback for file paths omits. + let accept = urlNamesFile(new URL(url)) + ? SupportedMimeType.FileMeta + : SupportedMimeType.CardJson; try { response = await this.#fetch(url, { - headers: { Accept: SupportedMimeType.CardJson }, + headers: { Accept: accept }, }); } catch (err: unknown) { let message = @@ -1654,35 +1598,25 @@ export class RealmIndexQueryEngine { } processed.add(key); - let relationshipType = relationship.data?.type as - | typeof CardResourceType - | typeof FileMetaResourceType - | undefined; - let expectsFileMeta = relationshipType === FileMetaResourceType; - let expectsCard = relationshipType === CardResourceType; - // Stale index payloads can incorrectly record file relationships - // as type "card" (or omit type entirely) when linked files were - // indexed after instances. Trust the field declaration in that - // case. - if ( - !expectsFileMeta && - (relationshipType === CardResourceType || !relationshipType) - ) { - expectsFileMeta = await this.fieldExpectsFileMeta( - resource, - key, - activeOpts, - ); - if (expectsFileMeta) { - expectsCard = false; - } - } - let vn = this.#realm.virtualNetwork; let linkURL = vn.resolveURL( relationship.links.self, resource.id ? vn.toURL(resource.id) : realmURL, ); + + let relationshipType = relationship.data?.type as + | typeof CardResourceType + | typeof FileMetaResourceType + | undefined; + // Card ids never end in a known FileDef extension, so the URL + // itself says whether the link targets a file. That also corrects + // stale index payloads which record file relationships as type + // "card" (or omit the type) when linked files were indexed after + // instances. + let expectsFileMeta = + relationshipType === FileMetaResourceType || urlNamesFile(linkURL); + let expectsCard = + relationshipType === CardResourceType && !expectsFileMeta; let resolvedSelf: string; try { resolvedSelf = vn.resolveURL( diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 96fa6facfe..3f603a0e61 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -59,6 +59,7 @@ import { CardError, responseWithError, formattedError, + stringifyErrorForLog, unsupportedMediaType, type SerializedError, } from './error.ts'; @@ -171,7 +172,7 @@ import type { FileWatcherEventContent, RealmEventContent, UpdateRealmEventContent, -} from 'https://cardstack.com/base/matrix-event'; +} from '@cardstack/base/matrix-event'; import type { AtomicOperation, AtomicOperationResult, @@ -2128,11 +2129,12 @@ export class Realm { }, ); settled.catch((err: unknown) => { - let message = err instanceof Error ? err.message : String(err); // Covers worker job rejection AND post-worker realm-side work // (onInvalidation / handleExecutableInvalidations / broadcast). this.#log.error( - `Deferred indexing chain failed for ${this.url}: ${message}`, + `Deferred indexing chain failed for ${this.url} (urls: ${urls + .map((u) => u.href) + .join(', ')}): ${stringifyErrorForLog(err)}`, ); }); } @@ -2626,12 +2628,8 @@ export class Realm { ); }, (err: unknown) => { - let detail = - err instanceof Error - ? `${err.message}${err.stack ? `\n${err.stack}` : ''}` - : String(err); this.#log.error( - `Deferred delete-indexing chain failed for ${url.href} after ${Date.now() - enqueueStart}ms: ${detail}`, + `Deferred delete-indexing chain failed for ${url.href} after ${Date.now() - enqueueStart}ms: ${stringifyErrorForLog(err)}`, ); }, );