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
147 changes: 146 additions & 1 deletion packages/realm-server/tests/card-endpoints-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { join, basename } from 'path';
import type { Server } from 'http';
import type { DirResult } from 'tmp';
import { existsSync, readJSONSync, statSync } from 'fs-extra';
import type { Realm, Relationship } from '@cardstack/runtime-common';
import type {
Realm,
Relationship,
ResourceID,
} from '@cardstack/runtime-common';
import {
baseRealm,
isSingleCardDocument,
Expand Down Expand Up @@ -301,6 +305,147 @@ module(basename(__filename), function () {
},
);
});
test('linksTo relationship for CardDef uses card type not file-meta', async function (assert) {
let { testRealm: realm, request, dbAdapter } = getRealmSetup();

let writes = new Map<string, string>([
[
'tag.gts',
`
import { CardDef, field, contains } from "https://cardstack.com/base/card-api";
import StringField from "https://cardstack.com/base/string";

export class Tag extends CardDef {
@field label = contains(StringField);
@field cardTitle = contains(StringField, {
computeVia: function (this: Tag) {
return this.label;
},
});
}
`,
],
[
'article.gts',
`
import { CardDef, field, contains, linksTo } from "https://cardstack.com/base/card-api";
import StringField from "https://cardstack.com/base/string";
import { Tag } from "./tag";

export class Article extends CardDef {
@field title = contains(StringField);
@field tag = linksTo(Tag);
@field cardTitle = contains(StringField, {
computeVia: function (this: Article) {
return this.title;
},
});
}
`,
],
[
'Tag/programming.json',
JSON.stringify({
data: {
attributes: {
label: 'Programming',
},
meta: {
adoptsFrom: {
module: '../tag.gts',
name: 'Tag',
},
},
},
}),
],
[
'Article/hello-world.json',
JSON.stringify({
data: {
attributes: {
title: 'Hello World',
},
relationships: {
tag: {
links: {
self: '../Tag/programming',
},
},
},
meta: {
adoptsFrom: {
module: '../article.gts',
name: 'Article',
},
},
},
}),
],
]);

await realm.writeMany(writes);

// Verify the relationship is correct with a fresh index
let response = await request
.get('/Article/hello-world')
.set('Accept', 'application/vnd.card+json');

assert.strictEqual(
response.status,
200,
`HTTP 200 status: ${response.text}`,
);
let doc = response.body as LooseSingleCardDocument;
let tagRelationship = doc.data.relationships?.tag as Relationship;
assert.ok(tagRelationship, 'tag relationship exists');
assert.deepEqual(
tagRelationship.data,
{
type: 'card',
id: `${testRealmHref}Tag/programming`,
},
'linksTo relationship for a CardDef uses type "card" not "file-meta"',
);

// Now simulate a stale index where the pristine_doc relationship
// lacks data.type (as it would be before commit 480362eb12 which
// added data to NotLoadedValue serialization in LinksTo.serialize).
// Also remove the linked card's instance entry so getInstance
// returns nothing, forcing the getFile fallback path.
let articleAlias = `${testRealmHref}Article/hello-world`;
let tagAlias = `${testRealmHref}Tag/programming`;
await dbAdapter.execute(
`UPDATE boxel_index
SET pristine_doc = pristine_doc #- '{relationships,tag,data}'
WHERE file_alias = '${articleAlias}'
AND type = 'instance'`,
);
await dbAdapter.execute(
`UPDATE boxel_index
SET is_deleted = TRUE
WHERE file_alias = '${tagAlias}'
AND type = 'instance'`,
);

let response2 = await request
.get('/Article/hello-world')
.set('Accept', 'application/vnd.card+json');

assert.strictEqual(
response2.status,
200,
`HTTP 200 status after index modification: ${response2.text}`,
);
let doc2 = response2.body as LooseSingleCardDocument;
let tagRelationship2 = doc2.data.relationships?.tag as Relationship;
assert.ok(tagRelationship2, 'tag relationship still exists');
assert.strictEqual(
(tagRelationship2.data as ResourceID)?.type,
'card',
'linksTo relationship for a CardDef should use type "card" even when data.type is missing from stale index and the linked instance is unavailable',
);
});
test('card-level query-backed relationships resolve via search at read time', async function (assert) {
let { testRealm: realm, request } = getRealmSetup();

Expand Down
99 changes: 88 additions & 11 deletions packages/runtime-common/realm-index-query-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,50 @@ 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 ? new URL(resource.id) : this.realmURL;
let codeRef = codeRefWithAbsoluteURL(resource.meta.adoptsFrom, relativeTo);
if (!isResolvedCodeRef(codeRef)) {
return false;
}
try {
let definition = await this.#definitionLookup.lookupDefinition(codeRef);
// Strip the linksToMany index suffix (e.g., "friends.0" -> "friends")
let fieldName = fieldKey.includes('.')
? fieldKey.slice(0, fieldKey.indexOf('.'))
: fieldKey;
let fieldDefinition = definition.fields[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 @@ -687,16 +731,29 @@ export class RealmIndexQueryEngine {
linkResource = maybeResult.instance;
}
}
if (!linkResource && (expectsFileMeta || !relationshipType)) {
let fileEntry = await this.#indexQueryEngine.getFile(linkURL, opts);
if (fileEntry) {
linkResource = fileResourceFromIndex(linkURL, fileEntry);
if (!linkResource) {
// Determine whether to try the file index:
// - If data.type explicitly says file-meta, try it
// - If data.type is missing (stale index data), consult the
// field definition to avoid incorrectly degrading a CardDef
// relationship to file-meta
let shouldTryFile = expectsFileMeta;
if (!shouldTryFile && !relationshipType) {
shouldTryFile = await this.fieldExpectsFileMeta(
resource,
key,
opts,
);
}
if (shouldTryFile) {
let fileEntry = await this.#indexQueryEngine.getFile(
linkURL,
opts,
);
if (fileEntry) {
linkResource = fileResourceFromIndex(linkURL, fileEntry);
}
}
}
if (!relationshipType && !linkResource) {
throw new Error(
`bug: relationship ${key} is missing a resource type when loading links`,
);
}
} else {
let response = await this.#fetch(linkURL, {
Expand Down Expand Up @@ -773,9 +830,29 @@ export class RealmIndexQueryEngine {
omit.includes(relationshipId.href) ||
included.find((i) => i.id === relationshipId!.href)
) {
let relationshipType = linkResource?.type ?? 'card';
relationship.data = {
type: relationshipType,
type: linkResource?.type ?? CardResourceType,
id: relationshipId.href,
};
} else if (!linkResource) {
// Even when the linked resource is unavailable, ensure
// relationship.data has the correct type so stale
// pristine_doc entries (missing data.type) for file
// relationships are not misidentified as card links.
let fallbackRelationshipType:
| typeof CardResourceType
| typeof FileMetaResourceType;
if (expectsFileMeta) {
fallbackRelationshipType = FileMetaResourceType;
} else {
fallbackRelationshipType =
(relationshipType as
| typeof CardResourceType
| typeof FileMetaResourceType
| undefined) ?? CardResourceType;
}
relationship.data = {
type: fallbackRelationshipType,
id: relationshipId.href,
};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-common/url-signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export async function createURLSignature(
// Node.js implementation
export function createURLSignatureSync(token: string, url: URL): string {
// Dynamic import to avoid issues in browser
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
// eslint-disable-next-line @typescript-eslint/no-var-requires
let crypto = require('crypto');

let urlForSigning = new URL(url.href);
Expand Down