Write Operations
An order may be approved only while it is pending. A rule like that is an operation: a function
in a file of the component’s operations directory, and, where needed, a declaration in the
manifest.
The orders are those of store.orders, the component Create a Component writes: an order has a customer, items, a total and a status that starts as pending.
§A file is an operation
// components/store.orders/operations/approve.ts
import type { Context, State } from '../types/index.d.ts'
export async function transition (input: null, entry: State, context: Context) {
if (entry.status !== 'pending')
return new Error('NOT_PENDING')
entry.status = 'approved'
return entry
} # components/store.orders/manifest.toa.yaml
operations:
approve:
concurrency: retry
query: true
errors: [NOT_PENDING] Restart the application and call it with the id of a pending order:
$ npm run restart
$ npx toa call store.orders.approve "{ query: { id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11' } }" {
id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11',
customer: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f',
items: [],
total: 1500,
status: 'approved',
VERSION: 2,
// …
} A second call answers the error NOT_PENDING (see Refuse a Request).
The file name is the operation’s name. The function receives:
input— the arguments the caller sent;nullfor an operation that declares no input.- state — what the call’s query selected. Absent for operations that use no state.
context— the operation context: calls to other operations, configuration, and the rest of what an operation may use.
What it returns is the reply.
Two things are read from the code:
- The exported name is the operation’s type:
transition,observation,assignment,computation,effectorunmanaged. - The name of the second parameter is its scope:
entry,entries,changesetorstream.
§Types
The type says what the operation does with state. Pick it by what the requirement needs.
§Transition
Change state by a rule
Receives the current state; what it leaves is stored.
// operations/cancel.ts
import type { CancelInput, State } from '../types/index.d.ts'
export async function transition (input: CancelInput, entry: State) {
if (entry.status !== 'pending')
return new Error('NOT_PENDING')
entry.status = 'cancelled'
entry.TRAILERS.reason = input.reason
} reason is declared in the operation’s input. The order does not keep it: TRAILERS hands it to the event of the cancellation.
Change the object you are given; do not replace it. Called without a query, a transition
receives a blank object and creates one (see Creating). One that may be
called with a query declares concurrency in the manifest.
A transition may run more than once for one call, so it must come to the same decision each time it is given the same state. Do not count, send or record anything outside the state from inside one.
§Observation
Answer from state
Receives the current state and returns something derived from it. Nothing it changes is stored.
// operations/summary.ts
import type { Entity } from '../types/index.d.ts'
export async function observation (input: null, entry: Entity) {
return { status: entry.status, total: entry.total }
} $ npx toa call store.orders.summary "{ query: { id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11' } }" { status: 'approved', total: 1500 } §Assignment
Set values without reading
Receives an empty changeset and fills it with the values to set. Properties left out are not touched.
// operations/note.ts
import type { Entity, NoteInput } from '../types/index.d.ts'
export async function assignment (input: NoteInput, changeset: Partial<Entity>) {
changeset.note = input.text
} note is a property declared in the entity like any other, and text in the operation’s input. Use an assignment when the new
values follow from the input alone: nothing is read, and it cannot conflict with another write.
If the decision depends on the current state, write a transition.
§Computation
Calculate
Uses no state and changes nothing. It has no state parameter:
// operations/quote.ts
import type { Context, QuoteInput } from '../types/index.d.ts'
export async function computation (input: QuoteInput, context: Context) {
return input.total * context.configuration.rate
} rate is a configuration value the component declares and
requires.
§Effect
Act outside the component
Sends an email, calls an external API, orchestrates calls to other components.
// components/notifications/operations/receipt.ts
import type { Context, ReceiptInput } from '../types/index.d.ts'
export async function effect (input: ReceiptInput, context: Context) {
const order = await context.remote.store.orders.observe({ query: { id: input.order } })
await context.fetch('https://mail.example.com/send', {
method: 'POST',
body: JSON.stringify({ to: order.customer, total: order.total })
})
} An effect has no state by default. Give it a second parameter named after a scope and it receives state as an observation does:
export async function effect (input: ReceiptInput, entry: Entity, context: Context) { /* … */ } What an effect did outside the component is not undone or deduplicated when it runs again; see Handle Repeated Calls and Call an External Service.
§Unmanaged
A read the query cannot express
Receives the storage’s own client — for MongoDB, the collection:
// operations/totals.ts
import type { Collection } from 'mongodb'
export async function unmanaged (input: null, collection: Collection) {
return await collection
.aggregate([
{ $match: { DELETED: null } },
{ $group: { _id: '$status', total: { $sum: '$total' } } }
])
.toArray()
} $ npx toa call store.orders.totals [ { _id: 'approved', total: 1500 }, { _id: 'pending', total: 4200 } ] An unmanaged operation reads. It never writes. A write made here has no version, no
timestamps, no validation and no event, and does not reach other regions. Filter on DELETED: null yourself: deleted objects are still in the collection.
§Summary
| Type | Receives | Stores changes | Allowed in a read-only call |
|---|---|---|---|
transition | current state | yes | no |
observation | current state | no | yes |
assignment | empty changeset | yes | no |
computation | — | — | yes |
effect | optionally, current state | no | no |
unmanaged | the storage client | — | no |
An observation and a computation may only read, and so may the calls they make: one that has to call a transition, an assignment or an effect is an effect itself. Read-only calls are in Loops and Read-Only Calls.
§Scopes
The scope is how much state the operation works on.
| Scope | Second parameter | The operation receives | Allowed for |
|---|---|---|---|
entry | entry | one object | transition, observation, effect |
entries | entries | an array of objects | transition, observation, effect |
stream | stream | a stream of objects | observation, effect |
changeset | changeset | an empty object to fill | assignment |
none | — | nothing | computation, effect, unmanaged |
// operations/pending.ts
import type { Entity } from '../types/index.d.ts'
export async function observation (input: null, entries: Entity[]) {
return entries.map((order) => ({ id: order.id, total: order.total }))
} $ npx toa call store.orders.pending "{ query: { criteria: 'status==pending', limit: 10 } }" [ { id: '0c9e3b1f6a2d4e8f9b7c5d3a1e2f4b6c', total: 4200 } ] A transition over entries changes several objects together: they are stored all or none.
// operations/expire.ts
import type { State } from '../types/index.d.ts'
export async function transition (input: null, entries: State[]) {
for (const order of entries)
order.status = 'cancelled'
} Every call to an operation over entries says how large its set may be: it lists the objects by ids, or carries a limit. A call with neither is refused with RequestContract.
$ npx toa call store.orders.expire "{ query: { criteria: 'status==pending;CREATED<1764000000000', limit: 100 } }" The transition changes the hundred objects it was handed and no others. Where more may match,
call again until fewer than the limit come back; expire moves each order out of status==pending, so every call finds fewer. Without sort, which hundred is not defined.
stream is for collections too large to hold in memory; see Read a Large Collection.
§Declaring an operation
An operation that needs nothing beyond its file needs no declaration. The manifest adds what the code does not say:
# components/store.orders/manifest.toa.yaml
operations:
approve:
description: Approves a pending order and charges the customer.
concurrency: retry
query: true
input:
type: object
properties:
note: { type: string, maxLength: 256 }
additionalProperties: false
output:
type: object
properties:
id: { type: string }
status: { type: string }
errors: [NOT_PENDING, INSUFFICIENT_FUNDS] INSUFFICIENT_FUNDS is the code approve passes on once it charges the customer’s wallet, as
in Refuse a Request.
§Input and output
input is the JSON Schema of the arguments. A request that does not fit is refused before the
function runs, so the function never checks shapes or types. An operation that declares no input accepts none.
$ npx toa call store.orders.approve "{ query: { id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11' }, input: { gift: true } }" fails with an exception that says what does not fit; over HTTP it is a 400.
output is the JSON Schema of the reply. It is optional, and it is what generated types, the gateway’s description of its routes
and tools for models are built from. On a local run a reply that does not fit output fails the
call with an exception, so the mistake is found before it ships.
Rules that depend on the current state — “only a pending order” — are not schema. They are the operation’s code.
§Concurrency
A transition over existing state must say what happens when someone else changed the state between its read and its write:
concurrency | On conflict |
|---|---|
retry | The operation is run again on the fresh state. |
none | The call fails with an exception; over HTTP, 409. |
retry is right for nearly every transition. Every attempt makes the transition’s calls again,
so what it calls is safe to repeat; a call to
an operation declared once is refused. Only a transition declared query: false — one that always creates — goes without concurrency. A manifest that leaves it
out elsewhere is refused.
§Query
query | Meaning |
|---|---|
| (not stated) | A call may carry a query. |
true | A call must carry one. |
false | A call must not. For a transition: it always creates. |
§Description
description says what the operation is, for a reader. It appears above the endpoint in
generated types, in the gateway’s description of its routes, and in tools offered to models.
§Type and scope, stated
type and scope can be declared, and must be where the code does not show them — a function
produced by another function has no parameter names to read:
// operations/create.ts
import type { CreateInput, State } from '../types/index.d.ts'
export const transition = withAudit(async (input: CreateInput, entry: State) => { /* … */ }) operations:
create:
scope: entry §All properties
| Property | Values | Where |
|---|---|---|
description | text | above |
type, scope | see Types, Scopes | above |
input, output | JSON Schema | above |
errors | list of codes | Refuse a Request |
query | true, false | above, Call Operations and Select State |
concurrency | retry, none | above |
forward | an operation’s name | Use the Inherited Operations |
once | true, or seconds (600 at least) | Handle Repeated Calls |
unchained | true | Loops and Read-Only Calls |
stream | an input property’s name | Stream a Reply or an Input |
stateful | true | Keep Something in a Process |
bindings, bridge | connector names | Connectors |
§Preparing something once
A function is the usual form. Two more exist for an operation that needs to prepare something before its first call.
A class named after the type, with an execute method. mount is called once, before the first
call; unmount, if there is one, when the component stops:
// operations/quote.ts
import type { Context, QuoteInput } from '../types/index.d.ts'
export class Computation {
#rates: Record<string, number> = {}
async mount (context: Context) {
this.#rates = await load(context)
}
execute (input: QuoteInput) {
return input.total * this.#rates[input.currency]
}
} A factory, named <Scope><Type>Factory — EntryTransitionFactory, EntriesObservationFactory, ChangesetAssignmentFactory — whose create returns an object
with execute:
// operations/approve.ts
export class EntryTransitionFactory {
async create () {
return new Approval()
}
} In either form, keep the reference to context and read from it when needed; do not copy values
out of it. What it holds — configuration, for one — can change while the component runs.
§Modules and shared code
A component is written in TypeScript or JavaScript, as ES modules or
CommonJS. The application’s package.json, which toa create wrote, states "type": "module", so its components are ES modules. A component with a package.json of its own states it there, or names its files .mts or .mjs.
Every file directly in operations is an operation. Code shared between operations lives in a
directory beside it or inside it — lib/, operations/lib/ — not among the operation files:
store.orders/
manifest.toa.yaml
package.json
operations/
approve.ts
cancel.ts
lib/
totals.ts A component’s dependencies are its own: list them in its package.json.
§Rules for operation code
- Keep nothing between calls. No module-level variables that change. What must live in a process is in Keep Something in a Process.
- Same input, state and context, same result. Take ids, time and randomness from the context, not from
DateorMath. - Act only through the state and the context. No environment variables, no local files that do not ship with the component, no connections of your own.
- Be safe to run again. An operation may run more than once for one call; write it so that
a second run leaves what the first left, or declare
once: Handle Repeated Calls. A transition that retries calls only what is safe to repeat withoutonce. - Do not throw to say no. Return an error.
Operations in the model explains what each rule buys.