Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/base/avif-image-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ export class AvifDef extends RasterImageDef {
};
}
}

export default AvifDef;
55 changes: 43 additions & 12 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -2397,10 +2397,10 @@ export function containsMany<FieldT extends FieldDefConstructor>(
options?: Options,
): BaseInstanceType<FieldT>[] {
return {
setupField(fieldName: string, _ownerPrototype: BaseDef) {
setupField(fieldName: string, ownerPrototype: BaseDef) {
let { computeVia, searchable } = options ?? {};
let instance = new ContainsMany({
cardThunk: cardThunk(field),
cardThunk: cardThunk(field, { fieldName, ownerPrototype }),
computeVia,
name: fieldName,
searchable,
Expand All @@ -2417,10 +2417,10 @@ export function contains<FieldT extends FieldDefConstructor>(
options?: Options,
): BaseInstanceType<FieldT> {
return {
setupField(fieldName: string, _ownerPrototype: BaseDef) {
setupField(fieldName: string, ownerPrototype: BaseDef) {
let { computeVia, searchable } = options ?? {};
let instance = new Contains({
cardThunk: cardThunk(field),
cardThunk: cardThunk(field, { fieldName, ownerPrototype }),
computeVia,
name: fieldName,
searchable,
Expand All @@ -2439,7 +2439,10 @@ export function linksTo<CardT extends LinkableDefConstructor>(
return {
setupField(fieldName: string, ownerPrototype: BaseDef) {
let { computeVia, searchable, query } = options ?? {};
let fieldCardThunk = cardThunk(cardOrThunk);
let fieldCardThunk = cardThunk(cardOrThunk, {
fieldName,
ownerPrototype,
});
if (query) {
validateRelationshipQuery(ownerPrototype, fieldName, query);
}
Expand All @@ -2465,7 +2468,10 @@ export function linksToMany<CardT extends LinkableDefConstructor>(
return {
setupField(fieldName: string, ownerPrototype: BaseDef) {
let { computeVia, searchable, query } = options ?? {};
let fieldCardThunk = cardThunk(cardOrThunk);
let fieldCardThunk = cardThunk(cardOrThunk, {
fieldName,
ownerPrototype,
});
if (query) {
validateRelationshipQuery(ownerPrototype, fieldName, query);
}
Expand Down Expand Up @@ -4783,17 +4789,42 @@ function notifySubscribers(

function cardThunk<CardT extends BaseDefConstructor>(
cardOrThunk: CardT | (() => CardT),
// Where the field lives, so the thrown error can name the exact field
// instead of leaving the author to bisect their schema. The module the bad
// value came from isn't knowable here — by the time the value reaches us
// the import has already evaluated to undefined — so the message names the
// two ways that happens instead.
fieldContext: { fieldName: string; ownerPrototype: BaseDef },
): () => CardT {
// `||` rather than `??`: an anonymous class's `.name` is `''`, not nullish.
let fieldDescription = `field '${fieldContext.fieldName}' on '${
fieldContext.ownerPrototype.constructor?.name || 'unknown card'
}'`;
let causes = `Two common causes:
(1) the import doesn't match the module's export shape — e.g. \`import X from '…'\` where the module only has a named export; use \`import { X } from '…'\`;
(2) a cyclic dependency between cards — use the thunk form in all cards in the cycle, e.g. '@field friend = linksTo(() => Person)'.`;
if (!cardOrThunk) {
throw new Error(
`cardOrThunk was ${cardOrThunk}. There might be a cyclic dependency in one of your fields.
Use '() => CardName' format for the fields with the cycle in all related cards.
e.g.: '@field friend = linksTo(() => Person)'`,
`The card class for ${fieldDescription} was ${cardOrThunk}. ${causes}`,
);
}
return (
'baseDef' in cardOrThunk ? () => cardOrThunk : cardOrThunk
) as () => CardT;
if ('baseDef' in cardOrThunk) {
return () => cardOrThunk as CardT;
}
// The thunk form fails the same two ways the eager form does, just later —
// at the first `field.card` read instead of at decoration. The accessors
// re-invoke the thunk on every read (nothing is memoized), so this adds a
// truthiness check per read, not an extra call.
let thunk = cardOrThunk as () => CardT;
return () => {
let card = thunk();
if (!card) {
throw new Error(
`The card class thunk for ${fieldDescription} returned ${card}. ${causes}`,
);
}
return card;
};
}

export type SignatureFor<CardT extends BaseDefConstructor> = {
Expand Down
2 changes: 2 additions & 0 deletions packages/base/csv-file-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -463,3 +463,5 @@ export class CsvFileDef extends FileDef {
};
}
}

export default CsvFileDef;
2 changes: 2 additions & 0 deletions packages/base/flac-audio-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,5 @@ export class FlacDef extends AudioDef {
};
}
}

export default FlacDef;
2 changes: 2 additions & 0 deletions packages/base/gif-image-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ export class GifDef extends RasterImageDef {
};
}
}

export default GifDef;
2 changes: 2 additions & 0 deletions packages/base/gts-file-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ export class GtsFileDef extends TsFileDef {
// GTS adds, so the inherited CodePreview renders a `.gts` file correctly.
static fileKind = 'Glimmer TS';
}

export default GtsFileDef;
2 changes: 2 additions & 0 deletions packages/base/jpg-image-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,5 @@ export class JpgDef extends RasterImageDef {
};
}
}

export default JpgDef;
2 changes: 2 additions & 0 deletions packages/base/json-file-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -594,3 +594,5 @@ export class JsonFileDef extends FileDef {
};
}
}

export default JsonFileDef;
2 changes: 2 additions & 0 deletions packages/base/m4a-audio-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,5 @@ export class M4aDef extends AudioDef {
};
}
}

export default M4aDef;
2 changes: 2 additions & 0 deletions packages/base/markdown-file-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -558,3 +558,5 @@ export class MarkdownDef extends FileDef {
return attributes;
}
}

export default MarkdownDef;
Comment thread
lukemelia marked this conversation as resolved.
2 changes: 2 additions & 0 deletions packages/base/mp3-audio-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,5 @@ export class Mp3Def extends AudioDef {
}
}
}

export default Mp3Def;
2 changes: 2 additions & 0 deletions packages/base/ogg-audio-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,5 @@ export class OggDef extends AudioDef {
};
}
}

export default OggDef;
2 changes: 2 additions & 0 deletions packages/base/png-image-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ export class PngDef extends RasterImageDef {
};
}
}

export default PngDef;
4 changes: 3 additions & 1 deletion packages/base/svg-image-def.gts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { byteStreamToUint8Array } from '@cardstack/runtime-common';
import SvgIcon from '@cardstack/boxel-icons/file-type-svg';
import ImageDef from './image-file-def';
import { type ByteStream, type SerializedFile } from './file-api';
import type { ByteStream, SerializedFile } from './file-api';
import { extractSvgDimensions } from './svg-meta-extractor';

export class SvgDef extends ImageDef {
Expand All @@ -25,3 +25,5 @@ export class SvgDef extends ImageDef {
};
}
}

export default SvgDef;
2 changes: 2 additions & 0 deletions packages/base/text-file-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,5 @@ export class TextFileDef extends FileDef {
};
}
}

export default TextFileDef;
2 changes: 2 additions & 0 deletions packages/base/ts-file-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,5 @@ export class TsFileDef extends FileDef {
};
}
}

export default TsFileDef;
2 changes: 2 additions & 0 deletions packages/base/wav-audio-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,5 @@ export class WavDef extends AudioDef {
};
}
}

export default WavDef;
2 changes: 2 additions & 0 deletions packages/base/webp-image-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ export class WebpDef extends RasterImageDef {
};
}
}

export default WebpDef;
72 changes: 72 additions & 0 deletions packages/host/tests/unit/card-thunk-error-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { getService } from '@universal-ember/test-support';
import { module, test } from 'qunit';

import { setupRenderingTest } from '../helpers/setup';

// When a field's card class evaluates to undefined — most often a default
// import of a module that only has named exports, or a genuine cycle — the
// thrown error names the field and its owning card and spells out both
// causes, so the author lands on the exact declaration instead of bisecting
// the schema. The eager form throws at decoration time; the thunk form
// throws at the first `field.card` read. These call the `field` decorator
// the same way Babel's decorator transform does.
module('Unit | card field thunk errors', function (hooks) {
setupRenderingTest(hooks);

let fieldKinds = (api: any): [string, string, (value: any) => unknown][] => [
['linksTo', 'src', api.linksTo],
['linksToMany', 'links', api.linksToMany],
['contains', 'meta', api.contains],
['containsMany', 'items', api.containsMany],
];

test('an undefined field card class names the field, the owner, and both causes', async function (assert) {
let loader = getService('loader-service').loader;
let api = (await loader.import('@cardstack/base/card-api')) as any;
class Broken extends api.CardDef {}

for (let [label, fieldName, fieldFn] of fieldKinds(api)) {
assert.throws(
() =>
api.field(Broken.prototype, fieldName, {
initializer: () => fieldFn(undefined),
}),
(err: Error) =>
new RegExp(`field '${fieldName}' on 'Broken'`).test(err.message) &&
/export shape/.test(err.message) &&
/cyclic dependency/.test(err.message),
`${label} names the field, the owner, and both causes`,
);
}
});

test('a thunk resolving to undefined names the field at first read', async function (assert) {
let loader = getService('loader-service').loader;
let api = (await loader.import('@cardstack/base/card-api')) as any;
let { isField } = (await loader.import('@cardstack/runtime-common')) as any;
class BrokenThunk extends api.CardDef {}

for (let [label, fieldName, fieldFn] of fieldKinds(api)) {
// The thunk defers evaluation, so decoration itself succeeds. The
// decorator returns the property descriptor whose getter carries the
// Field object; `field.card` is the accessor every consumer reads
// through.
let descriptor = api.field(BrokenThunk.prototype, fieldName, {
initializer: () => fieldFn(() => undefined),
});
let field = (descriptor?.get as any)?.[isField];
assert.ok(field, `${label} field is registered`);
// …and the named error surfaces the first time the class is needed.
assert.throws(
() => field.card,
(err: Error) =>
new RegExp(`field '${fieldName}' on 'BrokenThunk'`).test(
err.message,
) &&
/export shape/.test(err.message) &&
/cyclic dependency/.test(err.message),
`${label} thunk form names the field and owner at first read`,
);
Comment thread
lukemelia marked this conversation as resolved.
}
});
});
94 changes: 94 additions & 0 deletions packages/host/tests/unit/filedef-export-shape-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { getService } from '@universal-ember/test-support';
import { module, test } from 'qunit';

import {
FILEDEF_CODE_REF_BY_EXTENSION,
baseRealm,
} from '@cardstack/runtime-common';

import { setupRenderingTest } from '../helpers/setup';

// Every FileDef module exports its def class both named and as the default,
// so `import X from '…'` and `import { X } from '…'` are equally valid. A
// named-only module makes a default import silently evaluate to undefined,
// which then fails far away at schema time; a consistent shape removes the
// trap. Walking the extension registry keeps this guard covering every
// registered subtype, including ones added later.
//
// What this pins is "a default import never yields undefined" — not "a
// default import yields the class you named". A module carrying a second,
// named-only class is the sharper trap: `import RasterImageDef from
// './image-file-def'` evaluates to ImageDef (the default), a perfectly valid
// class, so nothing throws and the field is silently wired to the wrong
// class. image-file-def (RasterImageDef) and zip-file-def (ArchiveEntryField)
// are the two such modules today; only their registered class is pinned to
// the default below.
module('Unit | FileDef subtype export shapes', function (hooks) {
setupRenderingTest(hooks);

test('every registered subtype module has matching named and default exports', async function (assert) {
let loader = getService('loader-service').loader;
let seen = new Set<string>();
for (let ref of Object.values(FILEDEF_CODE_REF_BY_EXTENSION)) {
// Real entries are built by `baseModule()` in full-URL form; a bare
// relative specifier marks a synthetic test-only entry with no module
// behind it (`.mismatch` today).
if (!ref.module.includes('://')) {
continue;
}
let key = `${ref.module}#${ref.name}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
let ns = (await loader.import(ref.module)) as Record<string, unknown>;
assert.ok(ns[ref.name], `${ref.module} has named export ${ref.name}`);
assert.ok(ns['default'], `${ref.module} has a default export`);
Comment thread
lukemelia marked this conversation as resolved.
// A module registered under exactly one class defaults to that class.
// A module that ever registers two leaf classes would default to one
// of them; there, existence of *a* default is all this walk can
// require (see the module comment for why that case is a trap of its
// own).
let namesInModule = new Set(
Object.values(FILEDEF_CODE_REF_BY_EXTENSION)
.filter((r) => r.module === ref.module)
.map((r) => r.name),
);
if (namesInModule.size === 1) {
assert.strictEqual(
ns['default'],
ns[ref.name],
`${ref.module} default export is ${ref.name}`,
);
}
}
});

// The registry maps extensions to leaf subtypes only, so the family's base
// modules — the ones a subtype author actually imports from — appear in no
// entry and the walk above never touches them. One of these defaults is
// already load-bearing in the shipped tree: svg-image-def default-imports
// image-file-def.
test('family base modules have matching named and default exports', async function (assert) {
let loader = getService('loader-service').loader;
let baseModules: { module: string; name: string }[] = [
{ module: 'image-file-def', name: 'ImageDef' },
{ module: 'audio-file-def', name: 'AudioDef' },
{ module: 'video-file-def', name: 'VideoDef' },
{ module: 'font-file-def', name: 'FontDef' },
{ module: 'three-d-model-def', name: 'ThreeDModelDef' },
{ module: 'file-api', name: 'FileDef' },
];
for (let { module: moduleName, name } of baseModules) {
let moduleId = `${baseRealm.url}${moduleName}`;
let ns = (await loader.import(moduleId)) as Record<string, unknown>;
assert.ok(ns[name], `${moduleId} has named export ${name}`);
assert.ok(ns['default'], `${moduleId} has a default export`);
assert.strictEqual(
ns['default'],
ns[name],
`${moduleId} default export is ${name}`,
);
}
});
});
4 changes: 3 additions & 1 deletion packages/runtime-common/file-def-code-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ function baseModule(name: string): RealmResourceIdentifier {
return `${baseRealm.url}${name}` as RealmResourceIdentifier;
}

const FILEDEF_CODE_REF_BY_EXTENSION: Record<string, ResolvedCodeRef> = {
export const FILEDEF_CODE_REF_BY_EXTENSION: Readonly<
Record<string, ResolvedCodeRef>
> = {
// TODO: Replace with realm metadata configuration.
'.markdown': { module: baseModule('markdown-file-def'), name: 'MarkdownDef' },
'.md': { module: baseModule('markdown-file-def'), name: 'MarkdownDef' },
Expand Down
Loading