Protect an Invariant
A cancelled order stays cancelled — whichever operation touches it, including ones nobody has
written yet and the inherited transit. A rule that must hold for every write of a component is
a guard.
§Writing a guard
A guard is a file in the component’s guards directory that exports guard. The orders are
those of store.orders, the component Create a Component writes; an
order’s status is pending, approved or cancelled:
// components/store.orders/guards/final.ts
import type { Entity } from '../types/index.d.ts'
export function guard (order: Entity, origin: Entity | null) {
return origin?.status !== 'cancelled' || order.status === 'cancelled'
} | Argument | |
|---|---|
| first | The state about to be stored. |
origin | The state it replaces; null for a new object. |
context | The operation context. |
Return true to let the change through. Nothing is declared in the manifest.
Every guard of the component is checked before each write. A guard is synchronous: it decides from the two states it is given.
§When a guard refuses
Two operations to try it with: cancel, the transition Write Operations writes, which takes a reason, and the inherited transit, declared so that it
may set status:
# components/store.orders/manifest.toa.yaml
operations:
cancel:
concurrency: retry
query: true
input:
type: object
properties:
reason: { type: string }
required: [reason]
errors: [NOT_PENDING]
transit:
concurrency: retry
input:
type: object
properties:
status: { type: string } Cancel an order, then try to bring it back with transit:
$ npm run restart
$ npx toa call store.orders.cancel "{ query: { id: '3f7a9d2c1b4e4a6f8c5d0e1b2a3c4d5e' }, input: { reason: 'out of stock' } }"
$ npx toa call store.orders.transit "{ query: { id: '3f7a9d2c1b4e4a6f8c5d0e1b2a3c4d5e' }, input: { status: 'pending' } }" The change is not stored. The second command prints an exception to stderr and exits with 1;
the exception’s code is 213, its message is the guard’s name — final — and its cause is
the state that was refused. Over HTTP it is a 500.
A guard is a safety net, not a way to answer callers. A refusal a caller should expect is an error returned by the operation:
// components/store.orders/operations/reopen.ts
import type { State } from '../types/index.d.ts'
export async function transition (input: null, entry: State) {
if (entry.status === 'cancelled')
return new Error('CANCELLED')
entry.status = 'pending'
} The guard is what still holds when somebody adds another operation next year and forgets the check.
§Guard, schema or operation
| The rule is about | Put it in |
|---|---|
| one property’s shape or range | the entity schema |
several properties of one state: a wallet’s held <= balance | a guard |
the state before and after: total only decreases | a guard |
| who may do it, or anything outside the object | the operation |