II Business Logic

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:

  1. input — the arguments the caller sent; null for an operation that declares no input.
  2. state — what the call’s query selected. Absent for operations that use no state.
  3. 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, effect or unmanaged.
  • The name of the second parameter is its scope: entry, entries, changeset or stream.

§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

TypeReceivesStores changesAllowed in a read-only call
transitioncurrent stateyesno
observationcurrent statenoyes
assignmentempty changesetyesno
computation——yes
effectoptionally, current statenono
unmanagedthe 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.

ScopeSecond parameterThe operation receivesAllowed for
entryentryone objecttransition, observation, effect
entriesentriesan array of objectstransition, observation, effect
streamstreama stream of objectsobservation, effect
changesetchangesetan empty object to fillassignment
none—nothingcomputation, 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:

concurrencyOn conflict
retryThe operation is run again on the fresh state.
noneThe 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

queryMeaning
(not stated)A call may carry a query.
trueA call must carry one.
falseA 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

PropertyValuesWhere
descriptiontextabove
type, scopesee Types, Scopesabove
input, outputJSON Schemaabove
errorslist of codesRefuse a Request
querytrue, falseabove, Call Operations and Select State
concurrencyretry, noneabove
forwardan operation’s nameUse the Inherited Operations
oncetrue, or seconds (600 at least)Handle Repeated Calls
unchainedtrueLoops and Read-Only Calls
streaman input property’s nameStream a Reply or an Input
statefultrueKeep Something in a Process
bindings, bridgeconnector namesConnectors

§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

  1. Keep nothing between calls. No module-level variables that change. What must live in a process is in Keep Something in a Process.
  2. Same input, state and context, same result. Take ids, time and randomness from the context, not from Date or Math.
  3. 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.
  4. 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 without once.
  5. Do not throw to say no. Return an error.

Operations in the model explains what each rule buys.

Search
Search the documentation