Announce a Change
When an order is approved, the rest of the shop must learn about it — a receipt is sent, loyalty points are credited — and so must the warehouse system outside the application. The orders component does not call any of them. It declares that “an order became approved” is an event, and whoever cares subscribes.
An operation never publishes an event. You describe which change is one, and it is published whenever such a change is stored.
§Declaring an event
An event is a file in the component’s events directory. The file name is the event’s name, and
it exports a condition. The orders are those of store.orders, the component Create a Component writes, and its approve sets an order’s status to approved:
// components/store.orders/events/approved.ts
import type { Event } from '@toa.io/core/types'
import type { Entity } from '../types/index.d.ts'
export function condition (event: Event<Entity>) {
return event.origin?.status !== 'approved' && event.state.status === 'approved'
} After every stored change of an order the condition is called. Where it returns true, store.orders.approved is published, carrying the order’s new state. Nothing is added to the
manifest.
To see it arrive, bind a receiver to it and approve an order:
$ npm run restart
$ npx toa call store.orders.approve "{ query: { id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11' } }" The argument describes the change:
state | The object after the change. |
origin | The object before it; null if it was just created. |
input | The input of the operation that made the change. |
trailers | What the operation left in TRAILERS; see below. |
The condition is evaluated for a change made by any operation — yours, inherited or forwarded. approved is published however the order became approved.
An event file with no condition is published on every change.
The second argument is the operation context, for a condition that depends on configuration.
§Payload
By default the payload is the object’s new state. Export payload to send something else:
// components/store.orders/events/approved.ts
import type { Event } from '@toa.io/core/types'
import type { Entity } from '../types/index.d.ts'
export function condition (event: Event<Entity>) {
return event.origin?.status !== 'approved' && event.state.status === 'approved'
}
export function payload (event: Event<Entity>) {
return {
id: event.state.id,
customer: event.state.customer,
total: event.state.total,
version: event.state.VERSION
}
} A narrow payload is a smaller promise to subscribers. Keep the id, and keep VERSION if
subscribers need to tell a repeat.
§Trailers
The cancelled event must say why the order was cancelled, and the order does not keep the
reason. A transition writes such a fact into TRAILERS:
// components/store.orders/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
} // components/store.orders/events/cancelled.ts
import type { Event } from '@toa.io/core/types'
import type { Entity } from '../types/index.d.ts'
export function condition (event: Event<Entity, { reason: string }>) {
return event.origin?.status !== 'cancelled' && event.state.status === 'cancelled'
}
export function payload (event: Event<Entity, { reason: string }>) {
return { id: event.state.id, reason: event.trailers?.reason }
} # components/store.orders/manifest.toa.yaml
operations:
cancel:
concurrency: retry
query: true
input:
type: object
properties:
reason: { type: string }
required: [reason]
errors: [NOT_PENDING] $ npm run restart
$ npx toa call store.orders.cancel "{ query: { id: '3f7a9d2c1b4e4a6f8c5d0e1b2a3c4d5e' }, input: { reason: 'out of stock' } }" publishes store.orders.cancelled with { id: '3f7a…', reason: 'out of stock' }.
A trailer can also be the event. billing.wallets.debit, the operation Refuse a Request writes, takes amount and ref — what the
money is for — and keeps what it took under ref in the wallet’s paid, so that asked twice for
the same thing it takes once: the repeat sets DISCARD. Here it announces every debit of a customer’s
wallet with its amount:
// components/billing.wallets/operations/debit.ts
import type { DebitInput, State } from '../types/index.d.ts'
export async function transition (input: DebitInput, entry: State) {
if (entry.paid[input.ref] !== undefined) {
entry.DISCARD = true // asked again for the same thing: nothing is stored or announced
return { balance: entry.balance }
}
if (entry.balance < input.amount)
return new Error('INSUFFICIENT_FUNDS')
entry.balance -= input.amount
entry.paid[input.ref] = input.amount
entry.TRAILERS.amount = input.amount
return { balance: entry.balance }
} // components/billing.wallets/events/debited.ts
import type { Event } from '@toa.io/core/types'
import type { Entity } from '../types/index.d.ts'
export const condition = (event: Event<Entity, { amount: number }>) =>
event.trailers?.amount !== undefined
export const payload = (event: Event<Entity, { amount: number }>) => ({
wallet: event.state.id,
amount: event.trailers?.amount,
balance: event.state.balance,
key: `${event.state.id}:${event.state.VERSION}`
}) The key is worth copying: an object’s id together with its VERSION names one write. A
subscriber that keeps a ledger line per debit stores the line under that key, and a repeated
event writes the same line again instead of a second one.
Rules for trailers:
TRAILERSis never stored with the object, never returned, and cannot be declared in the entity schema.- Values must be serializable: a function, a stream or a class instance does not survive.
- Only a transition has them. An assignment has no object to hold trailers, so its events have
an
originand notrailers.
§Inherited events
Every component with an entity already has created, updated, deleted and sync (Use the Inherited Operations). An event file of the same
name replaces the inherited one.
§Consumers outside the application
Inside the application, an event is published once some component has a receiver for it; nothing else is needed. An event consumed by another system — the warehouse — has no receiver to be found by, so name it in the Context:
# context.toa.yaml
events:
- store.orders.approved # the warehouse system listens An event that has neither a receiver nor a line here is not published when the application is
deployed. On a local run — npm start, a test — every event is published.
§What a subscriber must expect
An event is published only for a change that was stored: an operation that returned an error or discarded its change announces nothing. An event may arrive more than once, and two events may arrive in either order. Write whatever handles them accordingly: React to an Event, Handle Repeated Calls. The promises behind this are in the model: Transactional Outbox.
§Writing conditions and payloads
- Compare
originwithstate.state.status === 'approved'alone is true on every later change of an approved order, and the event would be published each time. originisnullfor a new object. Useevent.origin?.status.- Keep them pure. Decide from the argument only: no clock, no counter, no calls. The same change must always give the same event.
- Never throw. A condition or a payload function that throws holds back every event of that change until a fixed version is deployed; then they follow.
- Keep objects small. A change is recorded with the object before it, the object after it and the input, so an object of 1 MB needs about 2 MB of MongoDB’s 16 MB document limit.
What an operator sees when events are held back is in Operating.