Actions
An action is a typed command that changes your domain model or calls an external system. Define its inputs and behavior, then run it from your app, an agent, or a workflow.
Define an action
Export an action from actions/. Sixb discovers it automatically. Use .on() to bind it to an
object type, .params() to declare its inputs, and .edits() to change objects.
import { defineAction } from "@sixb/core"
import { Invoice } from "../ontology/invoice"
export const markPaid = defineAction("markPaid")
.on(Invoice)
.params({})
.edits(({ objects, subject }) => {
objects(Invoice).byId(subject.primaryId).update({ status: "paid" })
})
subject.primaryId identifies the invoice the action runs on. Edits are saved together when the
handler succeeds. Keep .params({}) when there are no inputs.
For a global action, omit .on(...). Use one for a command that creates an object or works across
several objects without a single target.
Parameters
Use param() for required inputs and wrap it in optional() for optional inputs. Both come from
@sixb/core. For example, extend the invoice action to accept a payment method and an optional note:
.params({
paymentMethod: param("string"),
note: optional(param("string", { nullable: true })),
})
Handlers receive validated, typed values through params. Use params.paymentMethod in the
handler and pass { paymentMethod: "card" } when requesting this version of the action.
optional() allows an input to be omitted. { nullable: true } allows an explicit null value.
Your handler decides how each affects the edit, such as leaving a note unchanged or clearing it.
Use ref() from @sixb/core for an object-reference input:
.params({
customer: param(ref(Customer)),
})
Callers pass { objectTypeId: "Customer", primaryId: "cus-1" } for customer. In a handler,
read its ID from params.customer.primaryId or pass the reference to a link edit.
Use ref.user() for an input that names a project member:
.params({
assignee: param(ref.user()),
})
Callers pass { type: "user", id: "usr_1" }. Sixb rejects the request when the user does not exist
or is suspended. Store the value in a user reference property
or compare it with one.
Other input types use the property schemas, including
enums and structured values. Date and timestamp inputs arrive in handlers as Date values.
Validate a request
Add .validate() after .params() to check the request before any external call or edit. For an
object action, target contains the current object. Throw an error to reject the request:
.validate(({ target }) => {
if (target.properties.status === "paid") {
throw new Error("This invoice is already paid.")
}
})
Validation checks are read-only. You can attach more than one validator.
Call external systems
Use .writeback() before .edits() when a change depends on an external call. Return the data
your edit needs; Sixb passes it to the next handler as writeback.
This example uses a project connector whose
recordPayment() method accepts an invoice number and idempotency key, and returns a receipt ID:
import { defineAction } from "@sixb/core"
import { billing } from "../connectors/billing"
import { Invoice } from "../ontology/invoice"
export const recordPayment = defineAction("recordPayment")
.on(Invoice)
.params({})
.writeback(async ({ target, sixb, run }) => {
const client = await sixb.connector(billing)
const receipt = await client.recordPayment({
invoiceNumber: target.properties.number,
idempotencyKey: run.idempotencyKey,
})
return { receiptId: receipt.id }
})
.edits(({ objects, subject, writeback }) => {
objects(Invoice).byId(subject.primaryId).update({
status: "paid",
receiptId: writeback.receiptId,
})
})
Return JSON-compatible values or no value from .writeback(). For example, return an ID or a
string timestamp rather than an SDK client or Date instance. An action can use .writeback()
without .edits() when it only needs to call an external system.
External calls are not part of the local atomic commit, and Sixb cannot undo one that succeeded.
Once .writeback() succeeds, the run always goes on to its edits and commit, even past its
time limit; an error thrown by .edits() still fails the run and leaves the
external change unrecorded. Pass run.idempotencyKey to the external API, as above, so that a call
retried after a lost response applies once.
For notifications or other work after saving, add .effects() after .edits(). Its handler gets
writeback and commit, which describes the saved changes. Effects run after the request returns
the run, which does not wait for them, and only for a run that committed edits. Their outcome is
recorded later in run.effects, when the run's phase becomes effects; an effects error does not
undo the edits or make the committed action fail. If the process stops, or their outcome cannot be
recorded, a succeeded run may never get run.effects, so do not wait for it indefinitely. Pass
run.idempotencyKey to these calls as well.
The order is validate → writeback → edits → effects. Only add the handlers you need, with
at least .writeback() or .edits(). The object and link events of the
saved edits carry origin: { kind: "action", actionId, runId }. The run itself emits no event: its
record is returned to the requester and listed in GET /api/action-runs.
Edit objects and relationships
Inside .edits(), use objects(Type) to create an object or get an edit handle with .byId(id).
These methods stage changes synchronously; all edits commit together after the handler returns.
Use read.objects(Type) when you need to read existing values or relationships.
| Method | Purpose |
|---|---|
objects(Type).create(properties) | Create an object; fails if its ID already exists |
handle.update(properties) | Set the given properties and leave others unchanged |
handle.unset(...propertyIds) | Explicitly clear properties |
handle.reset(...propertyIds) | Release action overrides so projected values can apply |
handle.delete() / handle.restore() | Delete an object or restore its projected state |
handle.link(link, target) / handle.unlink(link, target) | Add or remove a relationship |
handle.resetLink(link, target) | Release an action's override of a relationship |
create() generates a stable ID for the run if you omit the primary property. Provide the other
required properties from your object type.
For a link with cardinality "one", remove its existing target before linking a different one.
An action with a customer: param(ref(Customer)) input can reassign an invoice like this:
.edits(async ({ objects, read, params, subject }) => {
const invoice = objects(Invoice).byId(subject.primaryId)
const existing = await read.objects(Invoice).byId(subject.primaryId)
.listLinks(Invoice.l.customer)
for (const current of existing) {
invoice.unlink(Invoice.l.customer, {
objectTypeId: Customer.id,
primaryId: current.targetId,
})
}
invoice.link(Invoice.l.customer, params.customer)
})
Both changes commit together.
When a projection also supplies an object, action values take
precedence by default. A projection using mostRecent can make a newer source value effective.
Use reset() to return a property to its projected value. For a cardinality-one relationship,
unlinking also hides later projected targets. Use resetLink() to return the choice of target
to the projection.
Concurrent changes
Action runs execute at the same time, including several runs of one action. If data the action
read through read changes before the commit, Sixb runs .edits() again against the new data.
An action without a writeback also reruns .validate(); one with a writeback reuses its result
and never calls the external system twice. After three conflicting attempts, nothing is committed
and the run fails with action.read_conflict.
Request a new run; if the action has a .writeback(), its external call already ran.
| Checked | Not checked |
|---|---|
The target object and get(), even for a missing object | Objects that start matching a query later |
list(), query(), and expanded objects | count(), exists(), and facets() |
listLinks() | Telemetry history and file content |
To depend on an object not existing yet, give it a deterministic ID and read it with get().
A condition on an unchecked read, such as a limit on count(), does not hold across runs that
execute at the same time: both can pass it and commit.
Request an action
sixb.actions.request(), and requestAction() on an object handle, run the action in the
requesting process and return its finished run:
const run = await sixb.objects(Invoice).byId("inv-1").requestAction({
action: markPaid,
params: {},
})
if (run.status === "failed") {
console.error(run.error?.message)
}
A run that fails comes back with status: "failed" and its error; it is not thrown. The call
throws when no run was requested, such as for an unknown action, invalid params, or a missing
permission. It can also throw internal.unexpected after a run started, when that run's record
could not be written: request it again with the same runId to get the record, or to run the
action again if it was never written.
A run is recorded once, when it ends. Its record holds the request (params and subject), how it
ended (status, and error when it failed), the last phase it reached, its writeback's result,
and when it started and finished. A run that commits edits is recorded with them, so no edits are
ever saved without their run. A run cut short, for example by a crash, leaves no record.
Pass a runId to make a request safe to retry. A repeated request with a runId that is recorded
returns that run without running the action again. While the same process still runs it, a repeated
request fails with action.run_in_progress. Two processes
can both run the same runId: the one that records it second returns the first one's record, and
both may have called the writeback, which is why it should pass run.idempotencyKey along.
A run has 30 seconds to get through validation and a successful writeback, or through validation
and edits when it has no writeback. A run that runs out of time before then stops, commits nothing,
and fails with action.timeout; if its writeback was cut
short, check the external system before requesting a new run. Handlers receive a signal that
aborts at the limit: forward it to the calls they await so they stop in time. The run stops waiting
at the limit for a .validate() handler, and for .edits() when the action has no writeback, even
if the handler keeps going. It always waits for .writeback(), whose result alone says whether the
external change happened: a writeback that never returns holds its request open. Effects get 30
more seconds after the commit, and are not waited for past them. Move longer work into a
workflow.
Use your action
Call an action from your app or include it in a workflow.
- Running actions in apps: Connect an action to a React interface.
- Workflow action steps: Run an action as part of a larger process.