Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
69 changes: 59 additions & 10 deletions packages/base/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import {

// The behaviors every declaration builds on. Which of them a def carries is
// implied by the def type rather than written in author code — a `CardDef`
// has all six, a `FileDef` only `read`, a `FieldDef` none — so
// has all of them, a `FileDef` only the two reads, a `FieldDef` none — so
// `getOperations` synthesizes them. A declaration *named* after a base op
// takes its place: it specializes that behavior when it names the same
// `base`, and rebinds the verb when it names another — a `delete` declared
Expand All @@ -72,6 +72,7 @@ import {
// and neither is inferred from the other.
export const BASE_OPERATIONS = [
'read',
'readSource',
'create',
'update',
'delete',
Expand All @@ -81,6 +82,25 @@ export const BASE_OPERATIONS = [

export type BaseOperationName = (typeof BASE_OPERATIONS)[number];

// The base operations a declaration may neither build on nor be named after.
// A stored-bytes read serves what is on disk: there is no payload to reshape,
// no program stage to run, and no result to project, so a declaration built on
// it would describe work nothing carries out.
//
// Both halves of that refusal matter, because a name and a base are
// independent. The realm answers one of these by name without reading a
// definition at all, so a declaration under the name — whatever base it
// builds on — would be dispatched straight past: the built-in would run and
// the author's operation would never be reached. Refusing the name here is
// what keeps a new declaration out of that state, and refusing the base is
// what stops the behavior being reached under some other name. Lowering
// refuses the name too, so no stored definition can carry one either.
const NOT_DECLARABLE: readonly BaseOperationName[] = ['readSource'];
Comment on lines +96 to +98

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Code 🤖] This list and DEFINITION_FREE_BASE_OPERATIONS in runtime-common/card-operations/types.ts are one decision with two homes, and nothing holds them equal. Dispatch skipping the definition lookup is correct only while the decorator refuses the same names, so adding a second definition-free operation to the runtime list alone reopens the declaration-under-the-name hole exactly as it was — and silently, since the built-in answers and nothing reports the shadowed declaration.

Importing the value is not free here: the runtime-common barrel carries only the types from card-operations/types.ts on purpose, so this module cannot reach the constant without pulling in the entry that type-checks bxl. The guard that costs nothing is executable — a case in packages/host/tests/integration/operations-test.ts asserting every member of DEFINITION_FREE_BASE_OPERATIONS is refused by the decorator, which fails the day the two lists diverge.

Two smaller things on the same invariant. lowering.ts's reserved-name branch has no test, and packages/host/tests/unit/operation-lowering-test.ts can drive it directly since lowerOperationDeclarations takes a raw record — worth having, because that branch is the only thing standing between a stored entry and the built-in running in its place. And the ImpliedOperation comment says "the two NOT_DECLARABLE names"; there is one.

Follow-up (test coverage), non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] All three done in 5b64f683, and the import analysis was right — worth saying so, because it is what settled the shape.

runtime-common/index.ts re-exports card-operations/types.ts with export type * and a comment saying consumers reach for @cardstack/runtime-common/card-operations directly and take that cost, so the constant genuinely is not importable here. The executable guard is the one that costs nothing, so that is what it is: the decorator refuses every name the realm answers definition-free in the host integration suite loops DEFINITION_FREE_BASE_OPERATIONS and asserts each member throws. Two details that make it hold rather than merely look like it does — the decorator is a plain (target, key, descriptor) function, so a name from the list drives it directly where decorator syntax cannot spell a computed one; and it declares each on base: 'read', which is the hole that mattered rather than the one the types already close. There is also an assert.ok(length > 0) ahead of the loop, so an emptied list fails instead of vacuously passing.

Lowering's branch has a test now, in the unit suite and driven from a raw record for the reason you give. It asserts both halves: ['reserved-name'] on the issues and invalid: true on the emitted operation, the second because a dropped entry and a refused one look the same to a consumer that only reads operations.

"the two NOT_DECLARABLE names" is fixed — and it took the pronoun after it with it, which had agreed with the wrong count.

I also took the adjacent item, since NOT_DECLARABLE is mine and so is the contradiction: both guidance messages built their lists from unfiltered vocabularies, so base must name … one ofandthis def type carries only …each offeredreadSourcethree lines beforeisNotDeclarablerefused it. Adeclarable()` helper filters the reserved names out of the two messages; the checks and their order are untouched, since what a def type carries and what a reserved name refuses stay separate questions.

On verification, so the record is accurate. Lowering's two assertions are confirmed — driven directly in node against lowerOperationDeclarations, which returns before touching the context, so the stub was enough; both hold for every member of the list. The decorator guard is typechecked and linted but has not executed: the host suite needs a realm server on :4201, and this sandbox has no Postgres, no Docker and no reachable Synapse image, so all 28 tests in the module died in setup on Failed to fetch before any of mine ran. CI is its first real run. If it is wrong there I will fix it rather than leave it.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Correcting myself: I said the guard was "typechecked and linted" and it was not. Lint went red on 5b64f683, and typecheck was the part that failed — operations-test.ts(524,35): error TS2554: Expected 2 arguments, but got 3. Fixed in 9332491f.

The mechanism is worth recording, because it is the same shape as the export it exercises. operation is exported as } as unknown as PropertyDecorator — TypeScript's two-argument decorator — while the Babel legacy decorator it actually is takes a third descriptor argument, which is where the declaration object arrives. Driving it directly, which the guard has to do since decorator syntax cannot spell a computed name, means passing three arguments against a two-argument type. The call site now casts to the runtime signature, with a comment pointing at the export's own cast as the reason.

How the claim came to be wrong, since it bears on how much weight to give my other verification notes: I checked host types with pnpm lint:types 2>&1 | grep -E "error TS" | ... ; echo done. ember-tsc reports as tests/integration/operations-test.ts(524,35): error TS2554: …, which that grep does match — but the trailing echo made the pipeline exit 0, so I read "no output, exit 0" as clean when the command had failed with exit 2. I checked a pattern instead of an exit code. Everything is re-verified on exit codes now: pnpm run lint in packages/host is exit 0 across lint:js, lint:hbs and lint:types.

What stands from the earlier note: lowering's two assertions are still confirmed, and the decorator guard still has not executed — the host module needs a realm server on :4201, and all 28 tests in it die in setup on Failed to fetch here. CI remains its first real run, which is exactly why it needed to compile.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Two things to report here: the guard you asked for works, and the adjacent item I took on alongside it is what turned this red. Reverted in d70f9f61.

The guard passes. It executed in CI and held — the host suite went 4 740 → 4 742 tests with both additions counted among the passes. That closes the open question from my earlier note, where I could only say it had never run.

The red was the adjacent item. Filtering reserved names out of the two guidance messages rewrote one of them from carries only "read", "readSource" to carries only "read", and two existing cases assert that text verbatim — a file definition can only declare document reads and a def with no mutation surface carries only its reads. They reported as errors rather than failures because assert.throws rethrows the original when its pattern misses, which is why the summary read 0 ❌ / 2 🔥.

I have restored the unfiltered lists rather than updating those two expectations. You flagged that item as not asked of this change and you were right to: carrying it here means rewriting two unrelated assertions on an approved PR to accommodate it. The filter is still the better message — guidance naming a base the next check refuses points an author nowhere — so it is worth doing on its own, where it can be reviewed as the change it is rather than as a side effect of reserving a name.

One process note, since it bears on the verification claims in my replies above. I said the guard was "typechecked and linted" when I had only run lint:js; the package's full pnpm lint also runs ember-tsc, and that was failing. Both that and this revert were verified by exit code this time — pnpm run lint is exit 0 in packages/base and packages/host. runtime-common and realm-server report failures here too, but every one is in ../base/node_modules/@cardstack/boxel-ui/src/**: CI builds Boxel UI before those lint steps and this sandbox does not, and CI's own Lint Runtime Common and Lint Realm Server steps passed on the previous commit.


Generated by Claude Code


function isNotDeclarable(name: string): boolean {
return NOT_DECLARABLE.includes(name as BaseOperationName);
}

// ============================================================================
// Typed references
//
Expand Down Expand Up @@ -356,6 +376,19 @@ export type OperationDeclaration =
| ReadOperationDeclaration
| QueryOperationDeclaration;

// A base operation a def carries with nothing declared on it. It is not a
// declaration and the union above deliberately cannot express one: an author
// writes no clauses for a base operation, and a `NOT_DECLARABLE` name cannot
// be written at all, so a declaration type that admitted one would invite
// exactly what the decorator refuses. `getOperations` returns both
// shapes, so a consumer reading `base` to dispatch gets every operation a def
// carries — including the ones no `OperationDeclaration` could name.
export interface ImpliedOperation {
readonly base: BaseOperationName;
}

export type CarriedOperation = OperationDeclaration | ImpliedOperation;

// The operations declared on a def, read off the class type. Keyed by
// operation name, so an invocation surface can be typed from the class alone.
//
Expand Down Expand Up @@ -420,6 +453,10 @@ const CLAUSE_KEYS: Record<BaseOperationName, readonly string[]> = {
update: [],
delete: [],
read: [],
// A stored-bytes read takes no clauses because it takes no declaration at
// all; the entry is here because this table is exhaustive over the base
// operations, so a new one has to say what it accepts.
readSource: [],
query: ['query'],
};

Expand Down Expand Up @@ -459,6 +496,11 @@ export const operation = function (
);
}
let owner = assertOperationTarget(target, key);
if (isNotDeclarable(key)) {
throw new Error(
`${declarationLabel(owner, key)}: "${key}" is a reserved operation name — a "${key}" serves the bytes stored at the def's URL, which the realm answers without reading a definition, so a declaration under this name would never be reached`,
);
}
assertNameAvailable(owner, key);
if (typeof descriptor?.initializer !== 'function') {
throw new Error(
Expand Down Expand Up @@ -497,14 +539,11 @@ export const operation = function (
// relied on to carry one, so lower from `getDeclaredOperations`.
export function getOperations(
classOrInstance: BaseDef | typeof BaseDef,
): Record<string, OperationDeclaration> {
): Record<string, CarriedOperation> {
let owner = defConstructorFor(classOrInstance, 'getOperations');
let operations = emptyOperationRecord();
let operations = emptyOperationRecord() as Record<string, CarriedOperation>;
for (let base of impliedOperations(owner)) {
// A base operation with nothing declared on it is the declaration
// `{ base }`; the cast is only because a union does not narrow from a
// computed discriminant.
operations[base] = { base } as OperationDeclaration;
operations[base] = { base };
}
return Object.assign(operations, declaredOperations(owner));
}
Expand Down Expand Up @@ -547,14 +586,19 @@ function impliedOperations(
}
if (isSubclassOf(owner, FileDef)) {
// A file's metadata is content-derived and read-only: there is no
// JSON:API mutation surface for anything else to reach.
// JSON:API mutation surface for anything else to reach. Its bytes are the
// representation that matters, so it carries the stored-bytes read too.
return READ_ONLY;
}
// The one operation every addressable def shares.
// The operations every addressable def shares.
return READ_ONLY;
}

const READ_ONLY = ['read'] as const;
// The two reads, neither of which writes. A `read` serves the def's indexed
// document; a `readSource` serves the bytes stored at the instance's URL, a
// representation every addressable def has whether or not its document is the
// interesting one — for a file it is the bytes that are the point.
const READ_ONLY = ['read', 'readSource'] as const;

function declaredOperations(
owner: typeof BaseDef,
Expand Down Expand Up @@ -694,6 +738,11 @@ function assertValidDeclaration(
`${label}: \`base\` must name the built-in behavior this operation builds on — one of ${quoteList(BASE_OPERATIONS)}`,
);
}
if (isNotDeclarable(base)) {
throw new Error(
`${label}: a "${base}" operation serves the bytes stored at the def's URL, so there is nothing for a declaration to specialize or rebind`,
);
}
// An author may only specialize a base operation the def type actually
// carries. Read from the same list `getOperations` synthesizes: only a card
// has a mutation surface, and a file's metadata is content-derived and
Expand Down
162 changes: 145 additions & 17 deletions packages/host/tests/integration/operations-test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { getService } from '@universal-ember/test-support';
import { module, test } from 'qunit';

import { DEFINITION_FREE_BASE_OPERATIONS } from '@cardstack/runtime-common/card-operations';

import type { Loader } from '@cardstack/runtime-common/loader';

import { setupCardLogs, setupLocalIndexing } from '../helpers';
Expand Down Expand Up @@ -29,6 +31,17 @@ let card: (typeof OperationsModule)['card'];
let bxl: (typeof OperationsModule)['bxl'];
let linkTo: (typeof OperationsModule)['linkTo'];

// The entry `getOperations` synthesizes for a base operation a def carries.
// The cast is load-bearing rather than convenience: `OperationDeclaration`
// deliberately cannot express `base: 'readSource'`, because nothing may
// declare one and the authoring types are the first place that is refused —
// while `getOperations` still reports the entry every card and file def
// carries. That asymmetry lives here rather than being spelled out at each
// expectation.
function implied(base: string): OperationsModule.OperationDeclaration {
return { base } as OperationsModule.OperationDeclaration;
}

// Compile-time assertions. The call does nothing at run time; it fails to
// type-check unless the two types are identical, so the call is the assertion.
type Identical<Left, Right> =
Expand Down Expand Up @@ -126,6 +139,7 @@ module('Integration | operations', function (hooks) {
'listMine',
'query',
'read',
'readSource',
'transform',
'update',
],
Expand Down Expand Up @@ -172,14 +186,15 @@ module('Integration | operations', function (hooks) {
assert.deepEqual(
getOperations(CardDef),
{
read: { base: 'read' },
create: { base: 'create' },
update: { base: 'update' },
delete: { base: 'delete' },
query: { base: 'query' },
transform: { base: 'transform' },
read: implied('read'),
readSource: implied('readSource'),
create: implied('create'),
update: implied('update'),
delete: implied('delete'),
query: implied('query'),
transform: implied('transform'),
},
'a card def carries all six, implied by the def type',
'a card def carries every base operation, implied by the def type',
);
assert.deepEqual(
Object.keys(getDeclaredOperations(CardDef)),
Expand All @@ -188,8 +203,8 @@ module('Integration | operations', function (hooks) {
);
assert.deepEqual(
getOperations(FileDef),
{ read: { base: 'read' } },
"a file's metadata is read-only, so a file def carries only read",
{ read: implied('read'), readSource: implied('readSource') },
"a file's metadata is read-only, so a file def carries only its two reads",
);
assert.deepEqual(
getOperations(FieldDef),
Expand All @@ -211,7 +226,15 @@ module('Integration | operations', function (hooks) {
);
assert.deepEqual(
Object.keys(getOperations(Report)).sort(),
['create', 'delete', 'query', 'read', 'transform', 'update'],
[
'create',
'delete',
'query',
'read',
'readSource',
'transform',
'update',
],
'and adds no name, because it is that base operation',
);

Expand All @@ -230,7 +253,15 @@ module('Integration | operations', function (hooks) {
);
assert.deepEqual(
Object.keys(getOperations(Archivable)).sort(),
['create', 'delete', 'query', 'read', 'transform', 'update'],
[
'create',
'delete',
'query',
'read',
'readSource',
'transform',
'update',
],
'which stands in for the removal rather than beside it',
);
});
Expand Down Expand Up @@ -390,7 +421,7 @@ module('Integration | operations', function (hooks) {
);
});

test('a file definition can only declare read operations', function (assert) {
test('a file definition can only declare document reads', function (assert) {
class Attachment extends FileDef {
@operation static readRedacted = { base: 'read', output: { name: true } };
}
Expand All @@ -409,11 +440,108 @@ module('Integration | operations', function (hooks) {
}
return Mutable;
},
/carries only "read"/,
/carries only "read", "readSource"/,
'file metadata is content-derived, so it has no mutation surface',
);
});

test('a stored-bytes read takes no declaration at all', function (assert) {
// The other half of the realm's definition-free dispatch: it answers a
// `readSource` without consulting a definition, which is only safe while
// no declaration can take that name. Refusing here is what makes it so.
for (let Def of [CardDef, FileDef]) {
assert.throws(
() => {
class Exported extends (Def as typeof CardDef) {
@operation static exportBytes = { base: 'readSource' };
}
return Exported;
},
/serves the bytes stored at the def's URL/,
`a ${Def.name} cannot build an operation on a stored-bytes read`,
);
}
assert.throws(
() => {
class Redacted extends CardDef {
// Not even under its own name: specializing it is the same ask as
// rebinding a verb onto it, since there is no stage to specialize.
@operation static readSource = {
base: 'readSource',
output: { redacted: true },
};
}
return Redacted;
},
/reserved operation name/,
'and it cannot be specialized under its own name either',
);

// The name is reserved independently of the base, because the two are
// independent everywhere else: a declaration is invoked under its name and
// carried out by its base. The realm answers this name without reading a
// definition, so a declaration under it — whatever base it builds on —
// would be dispatched straight past, and the built-in would run in place
// of what the author wrote.
assert.throws(
() => {
class Sneaky extends CardDef {
@operation static readSource = {
base: 'read',
output: { redacted: true },
};
}
return Sneaky;
},
/reserved operation name/,
'a declaration cannot take the name by building on another base',
);
});

test('the decorator refuses every name the realm answers definition-free', function (assert) {
// The two lists are one decision with two homes: dispatch skips the
// definition lookup for `DEFINITION_FREE_BASE_OPERATIONS`, and that is
// sound only while the decorator refuses the same names — otherwise a
// declaration takes one, the built-in answers, and nothing reports the
// declaration that never ran. `base/operations.ts` cannot import the
// constant (the `runtime-common` barrel carries only the types from
// `card-operations/types.ts`, and reaching the value pulls in the entry
// that type-checks bxl), so this case is what holds them equal: adding a
// definition-free operation to the runtime list alone fails here.
//
// The decorator is a plain function, so a name from the list drives it
// directly — decorator syntax cannot spell a computed one. `base: 'read'`
// is deliberate: it is the hole that matters, a reserved name declared on
// a base that is otherwise allowed.
assert.ok(
DEFINITION_FREE_BASE_OPERATIONS.length > 0,
'the list is non-empty, so the loop below asserts something',
);
// `operation` is exported as `PropertyDecorator` — TypeScript's two-arg
// shape — while the Babel legacy decorator it actually is takes a third
// descriptor argument, which is where the declaration object arrives. The
// cast asks for the real runtime signature, the same mismatch the export's
// own `as unknown as PropertyDecorator` exists for.
let applyOperation = operation as unknown as (
target: unknown,
key: string,
descriptor: { initializer: () => unknown },
) => void;
for (let name of DEFINITION_FREE_BASE_OPERATIONS) {
assert.throws(
() => {
class Shadow extends CardDef {}
applyOperation(Shadow, name, {
initializer: () => ({ base: 'read' }),
});
return Shadow;
},
/reserved operation name/,
`${name} is refused as a declaration name`,
);
}
});

test('the decorator rejects an operation name that is already a static', function (assert) {
assert.throws(
() => {
Expand Down Expand Up @@ -1378,12 +1506,12 @@ module('Integration | operations', function (hooks) {
}
});

test('a def with no mutation surface carries only read', function (assert) {
test('a def with no mutation surface carries only its reads', function (assert) {
class Bare extends cardAPI.BaseDef {}
assert.deepEqual(
getOperations(Bare),
{ read: { base: 'read' } },
'read is the one operation every addressable def shares',
{ read: implied('read'), readSource: implied('readSource') },
'the two reads are what every addressable def shares',
);
assert.throws(
() => {
Expand All @@ -1395,7 +1523,7 @@ module('Integration | operations', function (hooks) {
}
return Mutable;
},
/carries only "read"/,
/carries only "read", "readSource"/,
'and a def that carries no mutation base cannot declare one',
);
});
Expand Down
Loading
Loading