II Business Logic

Call Operations and Select State

Approving an order charges the customer’s wallet, and the back office lists a customer’s pending orders, newest first. Both are calls: one operation asking another to do something, and saying which state to do it to.

§A call

approve is a transition of store.orders, the component Create a Component writes; billing.wallets.debit is the operation Refuse a Request writes, which takes an amount and a ref, what the money is for, and the whole approve is there too. A customer’s wallet is kept under the customer’s id.

// components/store.orders/operations/approve.ts
const reply = await context.remote.billing.wallets.debit({
  query: { id: entry.customer },
  input: { amount: entry.total, ref: entry.id }
})

The argument is a request. Its two main parts are kept apart: input is what the operation is asked to do, query is which state it does it to. The same request from the command line:

$ npx toa call billing.wallets.debit "{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' }, input: { amount: 1500, ref: 'order-1' } }"
{ balance: 3500 }
context.remote.<namespace>.<name>.<operation>(request)An operation of another component.
context.local.<operation>(request)An operation of the same component.

Both return a promise of the reply: the output, or an Error the operation returned (Refuse a Request). Neither needs to know where the other component runs. A component without a namespace is called as context.remote.<name>.<operation>: context.remote.notifications.send(request).

A local call is a real call, not a function call: the request is validated, state is retrieved and stored, and events are published, as for a call from outside.

The request may be left out for an operation that takes no input and no query.

§Input

input must fit the operation’s input schema. A request that does not fit throws an exception at the call, before anything is sent.

§Query

A query selects state within the operation’s scope.

§One object

{ query: { id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11' } }

A query that selects nothing is an exception for a transition or an assignment, and the operation does not run. An observation over entry runs nevertheless, and the inherited observe answers null:

$ npx toa call store.orders.observe "{ query: { id: '00000000000000000000000000000000' } }"
null

A query may also select one object by criteria; the first match in sort order is taken:

{ query: { criteria: 'customer==8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f;status==pending', sort: ['CREATED:desc'] } }

Criteria, sort and projection name a property inside an object by the path to it: delivery.city==Porto. See Properties inside an object.

§Several objects

$ npx toa call store.orders.enumerate "{ query: { criteria: 'status==pending;total>=1000', sort: ['CREATED:desc'], limit: 10 } }"
[
  { id: '0c9e3b1f6a2d4e8f9b7c5d3a1e2f4b6c', total: 4200, status: 'pending', /* … */ },
  { id: '3f7a9d2c1b4e4a6f8c5d0e1b2a3c4d5e', total: 1800, status: 'pending', /* … */ }
]

From an operation:

const orders = await context.remote.store.orders.enumerate({
  query: {
    criteria: 'status==pending;total>=1000',
    sort: ['total:desc', 'id:asc'],
    omit: 20,
    limit: 10
  }
})

§Query properties

PropertyMeaning
idOne object by identity.
idsSeveral objects by identity.
criteriaA filter over properties, in RSQL.
searchA full-text search; needs a text index.
sortA list of property, property:asc or property:desc.
omit, limitSkip the first objects of the selection, and take at most so many.
sampleTake so many objects at random from the selection.
projectionThe properties the operation is given; id always is. Observations only.
versionThe VERSION the object must be at. Not for observations.
deletedtrue to select deleted objects as well.
token, stopContinuing a stream; see Read a Large Collection.

§Criteria

Criteria are written in RSQL:

status==pending, status!=pendingequal, not equal
total>1000, total>=1000, total<1000, total<=1000comparison
status=in=(pending,approved), status=out=(cancelled)one of, none of
a==1;b==2and
a==1,b==2or
(a==1,b==2);c==3grouping

Values are read as the type the property is declared with. note==null matches objects where the property is absent or null; quote a value to mean the text: note=="null".

Criteria can name system properties: CREATED>1764000000000.

Add an index for every query the application makes often.

§What each operation accepts

  • omit and limit are accepted by an operation over entries — an observation, a transition or an effect. Such a call requires ids or limit, and a limit is at least 1.
  • omit requires sort: pages of an unordered selection repeat and miss objects.
  • A stream accepts limit and a token instead of omit.
  • projection is for observations only: a transition writes back what it read, so it reads all of it.
  • An operation declared query: false accepts no query; one declared query: true requires one. An operation of a component without an entity accepts none.

A query that breaks one of these rules is an exception, like an input that does not fit.

§Creating

A transition called without a query creates an object: it receives a blank one with a fresh id, and what it leaves is stored as new. Written that way, create of store.orders is this file and this declaration, in place of what the component has for it:

// components/store.orders/operations/create.ts
import type { CreateInput, State } from '../types/index.d.ts'

export async function transition (input: CreateInput, entry: State) {
  entry.customer = input.customer
  entry.items = input.items
  entry.total = input.total

  return { id: entry.id }
}
# components/store.orders/manifest.toa.yaml
operations:
  create:
    query: false
    input:
      type: object
      properties:
        customer: { type: string }
        items: { type: array }
        total: { type: integer, minimum: 0 }
      required: [customer, items, total]
$ npm run restart
$ npx toa call store.orders.create "{ input: { customer: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f', items: [{ product: 'p1', quantity: 2 }], total: 1800 } }"
{ id: '3f7a9d2c1b4e4a6f8c5d0e1b2a3c4d5e' }

query: false makes it create-only. Without it, the same operation would also change an existing order when called with a query — which is what the inherited transit does, and rarely what an operation with rules wants.

For an associated entity there is no creation: a query by id always selects an object, written or not.

§Changing only what you have seen

Every write increments the object’s VERSION. A caller that read an object and wants its change applied only if nothing changed since names the version it saw:

const reply = await context.remote.store.orders.approve({
  query: { id: order.id, version: order.VERSION }
})

If the object is at another version, the call fails with an exception — over HTTP, 412. This is the caller’s check; concurrency on the operation is about changes that happen while it runs.

§Deleted objects

Nothing is removed from storage. The inherited terminate sets DELETED, and from then on queries do not find the object. deleted: true in a query finds it again — to show it, or to restore it with a transition that sets DELETED to null:

// components/store.orders/operations/restore.ts
import type { State } from '../types/index.d.ts'

export async function transition (input: null, entry: State) {
  entry.DELETED = null
}
# components/store.orders/manifest.toa.yaml
operations:
  restore:
    concurrency: retry
    query: true
$ npm run restart
$ npx toa call store.orders.restore "{ query: { id: '3f7a9d2c1b4e4a6f8c5d0e1b2a3c4d5e', deleted: true } }"

§Choosing what comes back

output in a request names the properties of the reply the caller wants; the rest is not sent:

$ npx toa call store.orders.enumerate "{ query: { criteria: 'status==pending', limit: 100 }, output: [id, total] }"
[
  { id: '0c9e3b1f6a2d4e8f9b7c5d3a1e2f4b6c', total: 4200 },
  { id: '3f7a9d2c1b4e4a6f8c5d0e1b2a3c4d5e', total: 1800 }
]

It applies to each object of a reply that is a list. Unlike projection, it works for any operation, and it trims what the operation returned rather than what it was given.

§The whole request

PropertyMeaningWhere
inputThe operation’s arguments.above
queryWhich state it works on.above
outputThe properties of the reply to send back.above
entityState handed to an effect instead of a query: the matching object is found or created.Use the Inherited Operations
tasktrue hands the call over to be run later, without waiting for it.Do Work in the Background
idThe identity of the call, for operations declared once.Handle Repeated Calls
instanceThe process a stateful operation is called on.Keep Something in a Process

Calls in the model explains why input and query are separate.

Search
Search the documentation