Skip to content
Merged
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
83 changes: 82 additions & 1 deletion packages/realm-server/tests/card-endpoints-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
},
},
},
},
{
Expand All @@ -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: {
Expand Down Expand Up @@ -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',
);
});
});

Expand Down
20 changes: 17 additions & 3 deletions packages/runtime-common/file-def-code-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
116 changes: 25 additions & 91 deletions packages/runtime-common/realm-index-query-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<boolean> {
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),
Expand Down Expand Up @@ -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;
Comment on lines +1322 to +1323

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep file-link fetches on an index-draining endpoint

When the relationship is already typed as file-meta, this switches the remote request to Accept: application/vnd.card.file-meta+json. I checked the serving path in Realm.getFileMeta, and unlike getCard it does not await incrementalIndexing() before fileMetaResponse; after a provider realm has just created or updated an indexed markdown skill, this can race the index, fall back to raw file metadata, and still omit attributes.kind, so the cross-realm skill can fail to attach intermittently. Either drain indexing in the file-meta GET path or avoid bypassing the card JSON path for these cross-realm loads.

Useful? React with 👍 / 👎.

Comment thread
jurgenwerk marked this conversation as resolved.
try {
response = await this.#fetch(url, {
headers: { Accept: SupportedMimeType.CardJson },
headers: { Accept: accept },
});
} catch (err: unknown) {
let message =
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 6 additions & 8 deletions packages/runtime-common/realm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
CardError,
responseWithError,
formattedError,
stringifyErrorForLog,
unsupportedMediaType,
type SerializedError,
} from './error.ts';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)}`,
);
});
}
Expand Down Expand Up @@ -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)}`,
);
},
);
Expand Down
Loading