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
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'];

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 the two `NOT_DECLARABLE` names
// cannot be written at all, so a declaration type that admitted them 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
116 changes: 99 additions & 17 deletions packages/host/tests/integration/operations-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,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 +137,7 @@ module('Integration | operations', function (hooks) {
'listMine',
'query',
'read',
'readSource',
'transform',
'update',
],
Expand Down Expand Up @@ -172,14 +184,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 +201,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 +224,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 +251,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 +419,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 +438,64 @@ 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 rejects an operation name that is already a static', function (assert) {
assert.throws(
() => {
Expand Down Expand Up @@ -1378,12 +1460,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 +1477,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