Operations
An operation has a type, a scope of state it works on, and rules it keeps so that the runtime can execute it on the application’s behalf. Three of the types manage entity state; two more do not.
§Types
An operation’s type states its relationship to state and to the world outside the component.
| Type | Phases | Purpose |
|---|---|---|
| Transition | Retrieve → Run → Commit | Reads and changes current state. |
| Observation | Retrieve → Run | Reads state. |
| Assignment | Run → Commit | Describes a change without reading state. |
| Computation | Run | Derives a result; touches no state and has no side effects. |
| Effect | Run, optionally after Retrieve | Acts beyond the component’s own state. |
§Transition
A transition is the only type that goes through all three phases: the runtime retrieves the current state, the implementation changes it, and the runtime commits the result. Between the first phase and the last, another call may have committed a change of its own.
§Creation
Creation is not a separate kind of operation. A transition called without a query has no existing entity to retrieve, so it runs against a new one:
// creates an order
await context.remote.orders.transit({
input: { customer, total: 1500 }
})
// changes an existing order
await context.remote.orders.transit({
query: { id: orderId },
input: { total: 1200 }
}) The same implementation serves both calls. In the first, it receives a blank order and its commit creates the entity; in the second, it receives the current state of the order the query selects. A query that selects nothing is a failure of the call: the operation does not run against an order that is not there.
§Optimistic concurrency control
Each entity carries a VERSION that increases with every committed
write. It is how the runtime detects that state changed between a transition’s Retrieve and its
Commit.
Suppose two calls reduce the same order’s total at the same moment. Both retrieve version 4; one commits version 5. The other’s commit no longer applies to the state it read. The runtime does not let it overwrite the first change. What happens instead is declared by the operation, and there is no default: a transition that works on an existing entity has to say.
operations:
reduce:
concurrency: retry With retry, the runtime retrieves the fresh state and runs the implementation again. The
decision is re-made against version 5, as though the calls had arrived in sequence. With none,
the call fails and the conflict is the caller’s to handle.
The runtime can undo part of a lost attempt and not the rest. What the attempt did to its own
entity is discarded with the write that lost. A call it made to another operation was served
elsewhere and committed there, and the next attempt makes it again; making that harmless is the
author’s. A call that says what it is for can be recognized by whoever serves it, whichever
attempt makes it. One case is not left to the author: a transition that retries does not call an
operation that declares once, and such a call is refused.
Nothing is locked while an operation runs, and a conflict costs a re-run only when one actually occurs. It is also why a transition’s implementation has to be safe to run more than once for a single call.
A caller can take part in the same check. A query may name the version the caller last saw, and the operation then applies only if the entity is still at that version.
§Pessimistic concurrency control
There is none: the runtime never locks persistent state.
§Deciding not to commit
A transition can run, look at the current state, and conclude that nothing is to be written. There are two ways to say so, and in both nothing is stored, no version is consumed, and no event is produced.
Return an error. This is the simplest: the operation refuses, and the refusal is its whole answer. The caller receives the error; see Replies.
export async function transition (input: Input, entry: State) {
if (entry.balance < input.amount)
return new Error('INSUFFICIENT_FUNDS')
entry.balance -= input.amount
return { balance: entry.balance }
} Set DISCARD. Where not writing is a successful outcome with an answer of its own, the
operation marks the state as discarded and returns that answer:
export async function transition (input: Input, entry: State) {
if (entry.balance < input.amount) {
entry.DISCARD = true
return { balance: entry.balance, charged: false }
}
entry.balance -= input.amount
return { balance: entry.balance, charged: true }
} DISCARD is a property the state has only while a transition runs. It is never stored.
§Computation
A computation is logic without state. It receives input, may use the context, and returns a result:
export async function computation (input: Input, context: Context) {
return input.total * context.configuration.rate
} A component made only of computations declares no entity at all. It is still a component: it has a logical address, a contract, and a place in a composition.
§Effect
An effect is an operation whose purpose is something other than changing its own entity: sending a message, calling an external system, orchestrating calls to other components.
export async function effect (input: Input, context: Context) {
const order = await context.remote.orders.observe({ query: { id: input.order } })
await context.remote.billing.charge({ input: { order: order.id, total: order.total } })
await context.remote.inventory.reserve({ input: { order: order.id } })
} Declaring such logic as an effect is a statement of intent. The runtime cannot make an effect safe to repeat or undo what it did outside the component, so it treats effects with the caution their type announces. In a component that stores state, an effect can be called for a particular entity; the runtime then supplies that entity’s state, creating the entity if it does not exist yet.
§Scope
The scope is the shape of state an operation is handed in its Retrieve phase.
| Scope | The operation receives |
|---|---|
entry | One entity object. |
entries | A set of entity objects. |
stream | A stream of entity objects, read as the storage finds them. |
changeset | An empty changeset to fill (assignments). |
none | No state. |
The query of a call selects which state; the scope determines how much of it the operation
is prepared to work with. An observation over entries lists orders; a transition over entry changes one.
A transition over entries changes several entities as one unit. The set is committed whole or
not at all: if any member changed since it was read, nothing is written.
Such a set is always bounded. The call names its members by ids or caps what its criteria
select with a limit, and the transition commits exactly the entities it was handed: a limit of two changes two. A change to everything the criteria match is therefore a series of calls,
each over a bounded set, repeated until one comes back short:
await context.local.expire({
query: { criteria: 'status==pending;deadline<1700000000000', limit: 256 }
}) A transition that changes what its criteria read — here, the status — finds fewer entities each
time. A limit without sort takes any of the matching entities, not the same ones twice.
§Safety
Operations are either safe or unsafe, by whether they are allowed to change anything.
| Type | Safety |
|---|---|
| Observation | Safe |
| Computation | Safe |
| Transition | Unsafe |
| Assignment | Unsafe |
| Effect | Unsafe |
Safety is a property of the type, not of a particular call: a transition is unsafe even when a given call of it changes nothing. This lets the runtime reason about calls before making them. A request that is only supposed to read can be refused the moment it reaches an unsafe operation; see Call Chains.
§Genuine operations
The runtime can retrieve state, retry on conflict, deliver again, run replicas, and move a component across process boundaries only if operations behave predictably. An operation’s implementation is expected to be:
- Stateless. It keeps nothing between calls. Running one instance many times gives the same results as running many instances once each.
- Deterministic. The same input, state and context produce the same result.
- Pure. Its only effects are the state it changes and what it does through the context.
- Idempotent. Running it twice for the same call leaves what running it once does.
- Non-exceptional. It does not use exceptions for control flow; a refusal is a returned error.
These are the terms of the trade described in Product–Platform Separation. Each rule corresponds to something the runtime does in the operation’s place: statelessness permits replicas, determinism permits retries, purity makes every interaction visible to the runtime — which is also what lets a component be moved from one process to another — idempotency permits delivering a call again, and returned errors let it distinguish a decision from a failure.
§Leaving the model
Two declarations step outside these rules deliberately. Both are part of the model as acknowledged exceptions: the manifest says where they are, so the rest of the application can still be reasoned about by the rules.
§Unmanaged
An unmanaged operation receives the storage’s own client instead of entity state. It exists for reads the query model cannot express:
// orders/operations/totals.ts
export async function unmanaged (input: null, collection: Collection) {
return await collection
.aggregate([
{ $match: { DELETED: null } },
{ $group: { _id: '$status', total: { $sum: '$total' } } }
])
.toArray()
} Everything the runtime provides around state — versioning, concurrency control, events — depends on writes passing through it, so an unmanaged operation is meant to read and never to write.
§Stateful
An operation declared stateful is tied to one process and is called on that process by name.
It trades the guarantees of an ordinary call for the ability to reach something a particular
process holds; see Stateful Operations.