React to an Event
After an order is approved, the customer gets a receipt. The orders component, store.orders, only announces the
approval as store.orders.approved; notifications, a component of no namespace, reacts to it.
A receiver turns an event into a call to one of the component’s own operations.
§Binding an event to an operation
# components/notifications/manifest.toa.yaml
name: notifications
entity:
associated: true
properties:
kind: { type: string, enum: [receipt, cancellation] }
customer: { type: string }
total: { type: integer }
operations:
receipt:
concurrency: retry
query: true
input:
type: object
properties:
customer: { type: string }
total: { type: integer }
required: [customer, total]
receivers:
store.orders.approved: receipt The event is named <namespace>.<component>.<event>, and <component>.<event> where the
component that publishes it has no namespace. A component has at most one receiver per event.
// components/notifications/receivers/store.orders.approved.ts
import type { Entity as Order } from '@components/store.orders'
export function request (order: Order) {
return {
query: { id: order.id },
input: { customer: order.customer, total: order.total }
}
} // components/notifications/operations/receipt.ts
import type { ReceiptInput, State } from '../types/index.d.ts'
export async function transition (input: ReceiptInput, entry: State) {
entry.kind = 'receipt'
entry.customer = input.customer
entry.total = input.total
} The file in receivers, named after the event, exports request: it is given the event’s
payload and builds the request the operation is called with. Here a notification is kept under
the order’s own id, so one order has one receipt however many times the event arrives. Order is the type generated from the entity of store.orders. Sending
the email itself is in Call an External Service.
Restart the application, approve a pending order, and look:
$ npm run restart
$ npx toa call store.orders.approve "{ query: { id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11' } }"
$ npx toa call notifications.observe "{ query: { id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11' } }" {
id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11',
kind: 'receipt',
customer: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f',
total: 1500,
VERSION: 1,
// …
} The reaction happens after approve has answered, so in a test wait for it rather than reading
at once.
store.orders knows nothing of this, and nothing in it changes when a subscriber is added.
§Without a receiver file
With only the line in the manifest, the operation is called with the event’s payload as its input and no query. That fits an operation whose input is the payload as it is:
receivers:
store.orders.approved: record §Skipping events
Loyalty points are credited only for orders of 1000 or more. loyalty keeps a customer’s points
under the customer’s id, so its entity is associated and nothing creates it. Create components/loyalty:
# components/loyalty/manifest.toa.yaml
name: loyalty
entity:
associated: true
properties:
points: { type: integer, minimum: 0 }
orders:
type: object
additionalProperties: { type: integer }
required: [points]
blank:
points: 0
orders: {}
operations:
award:
concurrency: retry
query: true
input:
type: object
properties:
order: { type: string }
points: { type: integer }
required: [order, points]
receivers:
store.orders.approved: award // components/loyalty/operations/award.ts
import type { AwardInput, State } from '../types/index.d.ts'
export async function transition (input: AwardInput, entry: State) {
const before = entry.orders[input.order] ?? 0
entry.orders[input.order] = input.points
entry.points = Math.max(0, entry.points + input.points - before)
return entry
} award keeps what each order is worth and moves the total by the difference, so an event that
arrives twice adds nothing. In the receiver’s file, export condition to receive only some of
the events:
// components/loyalty/receivers/store.orders.approved.ts
import type { Entity as Order } from '@components/store.orders'
export function condition (order: Order) {
return order.total >= 1000
}
export function request (order: Order) {
return { query: { id: order.customer }, input: { order: order.id, points: 10 } }
} Where it returns false, the event is done with and the operation is not called. Restart the
application, approve a pending order of 1000 or more, and read the points of its customer:
$ npm run restart
$ npx toa call store.orders.approve "{ query: { id: '0c9e3b1f6a2d4e8f9b7c5d3a1e2f4b6c' } }"
$ npx toa call loyalty.observe "{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' } }" {
id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f',
points: 10,
orders: { '0c9e3b1f6a2d4e8f9b7c5d3a1e2f4b6c': 10 },
VERSION: 1,
// …
} §One operation for several events
The shop notifies about approvals and cancellations alike; store.orders.cancelled is the event of a cancellation. arguments are
passed to request after the payload, so receivers can share an operation and differ by a
constant:
# components/notifications/manifest.toa.yaml
receivers:
store.orders.approved:
operation: notify
arguments: [receipt]
store.orders.cancelled:
operation: notify
arguments: [cancellation] // components/notifications/receivers/store.orders.approved.ts
import type { Entity as Order } from '@components/store.orders'
export function request (order: Order, kind: 'receipt' | 'cancellation') {
return { query: { id: order.id }, input: { kind } }
} // components/notifications/operations/notify.ts
import type { NotifyInput, State } from '../types/index.d.ts'
export async function transition (input: NotifyInput, entry: State) {
entry.kind = input.kind
} Each event still has its own file in receivers.
§Receiving your own events
A component may receive its own events. It is a way to continue work after a change is stored without making the caller wait:
# components/store.orders/manifest.toa.yaml
receivers:
store.orders.created: reserve §What the operation must expect
Nobody waits for the reply of a call a receiver makes. So:
- A returned error ends it. The event is done with. If the refusal must be visible, the operation writes it down.
- An exception has it tried again, after a growing pause, and set aside for an operator if it keeps failing (Operating). Never throw to refuse.
- The event may arrive more than once, and out of order.
So the operation must leave the same state when given the same event twice:
// applied twice, adds twice
entry.points += input.points
// applied twice, ends the same
entry.status = input.status Where that is not natural, see Handle Repeated Calls. The reasons are in the model: Delivery.
§Events from outside the application
The payment provider’s system publishes payment_settled to a broker of its own. Such an event
has no component to be found by, so state the binding and where to consume it from:
# components/store.orders/manifest.toa.yaml
receivers:
payment_settled:
operation: settle
binding: amqp
source: payments source names a broker declared in the Context; see Connectors.
It cannot be context. The receiver’s file is named after the event as written: receivers/payment_settled.ts.
A name in the <namespace>.<component>.<event> form needs no source: it is <namespace>.<component>.
receivers:
billing.invoices.paid:
operation: settle
binding: amqp §All properties
| Property | Meaning |
|---|---|
operation | The operation to call. A receiver written as a string is this alone. |
arguments | Values passed to request after the payload. |
binding | Where the event is consumed from; stated for events from outside. |
source | The broker of an outside event, as named in the Context. |
bridge | What runs the receiver’s code; node by default. |