Docs

Actions

Actions are typed commands against your ontology. They are the only sanctioned way to mutate objects: instead of writing to storage directly, you define an action that declares its params, validates the request, talks to external systems, and stages the object edits to commit. Every request becomes a durable, replayable run with a lifecycle you can wait on.

Their real power is doing both sides of a change as one run: write back to external systems and edit the ontology graph, so the two stay in step. Reach for just one phase when that is all you need — graph-only edits, or an external-only writeback — but actions shine when a change must land in both places.

Put action definitions in actions/. createSixb() auto-discovers them.

TS
import { defineAction, optional, param } from "@sixb/core"
import { acmeErpConnector } from "../connectors/acme-erp"
import { Invoice } from "../ontology/invoice"

export const markPaid = defineAction("markPaid", {
  description: "Record a payment in the ERP, then mark the invoice paid.",
})
  .on(Invoice)
  .params({
    paymentMethod: optional(param("string")),
  })
  // writeback: update the external system. Runs before the commit; no graph edits here.
  .writeback(async ({ target, params, sixb }) => {
    const erp = await sixb.connector(acmeErpConnector)
    const receipt = await erp.recordPayment({
      invoiceNumber: target.properties.number,
      amount: target.properties.amount,
      method: params.paymentMethod ?? "manual",
    })
    return { receiptId: receipt.id } // flows into edits + effects as `writeback`
  })
  // edits: update the graph, carrying the ERP receipt id across the commit.
  .edits(({ objects, params, writeback, run, subject }) => {
    objects(Invoice).byId(subject.primaryId).update({
      status: "paid",
      paymentInfo: {
        method: params.paymentMethod ?? "manual",
        reference: writeback.receiptId,
        recordedAt: run.startedAt.toISOString(),
      },
    })
  })

markPaid does both sides as one run: the writeback phase records the payment in the ERP, then the edits phase marks the invoice paid in the graph — committed only if the writeback succeeded, and carrying the ERP receipt id across the boundary. The graph and the external system stay in step.

The builder

defineAction(id, options?) starts the chain. options.description is optional human-readable text. From there you pick a binding, declare params, then attach phase handlers.

StepMethodNotes
Binding.on(ObjectType)Object action — runs against one object instance. Omit for a global action.
Params.params({ ... })Declares the typed input shape. Required even when empty (.params({})).
Validate.validate(fn)Optional, repeatable. Read-only checks before any mutation.
Phase.writeback(fn) / .edits(fn) / .effects(fn)Attach handlers in fixed order (see below).

Bindings

  • Object actions chain .on(ObjectType). The runtime resolves the target object and exposes it as target (in validate/writeback) and subject (in edits/effects).
  • Global actions skip .on(...) and go straight to .params(...). They are not tied to a single object — use them to create objects or run cross-object commands.
TS
// Global action: create a draft invoice and link it to a customer and project.
import { defineAction, optional, param, ref } from "@sixb/core"
import { stringEnum } from "@sixb/core/ontology"
import { Customer } from "../ontology/customer"
import { Invoice } from "../ontology/invoice"
import { Project } from "../ontology/project"

export const createDraftInvoice = defineAction("createDraftInvoice", {
  description: "Create a draft invoice and attach it to a customer and project.",
})
  .params({
    id: param("string"),
    number: param("string"),
    amount: param("double"),
    currency: optional(param(stringEnum(["EUR", "USD", "GBP"]))),
    customer: param(ref(Customer), { description: "Customer to bill." }),
    project: param(ref(Project), { description: "Project the invoice belongs to." }),
  })
  .edits(({ objects, params, run }) => {
    const invoice = objects(Invoice).create({
      id: params.id,
      number: params.number,
      amount: params.amount,
      currency: params.currency ?? "EUR",
      status: "draft",
      paymentInfo: {
        method: "pending",
        reference: `draft:${params.id}`,
        recordedAt: run.startedAt.toISOString(),
      },
    })

    invoice.link(Invoice.l.customer, objects(Customer).byId(params.customer.primaryId))
    invoice.link(Invoice.l.project, objects(Project).byId(params.project.primaryId))
  })

Params

Each entry in .params({ ... }) is built with param(schema, options?), which marks the param required. Wrap it in optional(...) to make it optional.

TS
.params({
  approved: param("boolean"),                       // required
  message: param("string"),                         // required
  reviewerNote: optional(param("string")),          // optional
  currency: param(stringEnum(["EUR", "USD", "GBP"])),
  customer: param(ref(Customer)),                   // object reference param
})

param schemas include the primitives "string", "uuid", "boolean", "integer", "double", "decimal", "date", "timestamp", plus stringEnum([...]) and ref(ObjectType). param options are description and semanticType. Handlers receive params validated and narrowed to TypeScript types — date/timestamp arrive as Date, ref(...) as an ObjectRef (read .primaryId to resolve it). See properties and value types.

Execution model

A run executes up to four phases in a fixed order. Each phase is optional except that you must attach at least writeback or edits, and effects requires edits first.

#PhasePurposeMutations?
1validateRead-only preconditions; throw to reject. Runs every attached validator.No
2writebackTalk to external systems before committing locally.No object edits
3editsStage object create/update/delete/link edits. Committed atomically.Yes — the only place
4effectsSide effects after the commit lands (notify, fan-out).No object edits

The hard rule: object mutations happen only in the edits phase. edits stages edits via the objects(...) facade and the runtime commits them in one atomic batch; validate, writeback, and effects must not mutate ontology objects. Phases short-circuit on the first thrown error and the run is marked failed at the phase that threw.

Phase contexts

Each handler receives a context object. Every phase gets params (validated), run ({ id, startedAt, idempotencyKey }), subject, and signal (an AbortSignal). The rest vary:

PhaseAdded context fields
validatetarget (object actions)
writebacktarget (object actions), sixb (connectors + telemetry)
editsobjects (edit facade), read (read facade), writeback (writeback's return value)
effectssixb, commit (the committed diff), writeback

writeback runs an external call before the local commit, and its return value flows into edits and effects as writeback (see the markPaid example above, which carries the ERP receipt id into the edit). The edits handler can also read current committed state through read and stage writes through objects. This sendReminder gates its edit on an approval read at edit time:

TS
import { defineAction, optional, param } from "@sixb/core"
import { Invoice } from "../ontology/invoice"

export const sendReminder = defineAction("sendReminder", {
  description: "Send a payment reminder to the customer.",
})
  .on(Invoice)
  .params({
    approved: param("boolean"),
    message: param("string"),
    reviewerNote: optional(param("string")),
  })
  .edits(async ({ objects, params, read, subject }) => {
    const invoice = await read.objects(Invoice).get(subject.primaryId)
    if (!invoice) {
      throw new Error(`Invoice '${subject.primaryId}' not found.`)
    }

    const reviewedAt = new Date().toISOString()
    if (!params.approved) {
      objects(Invoice).byId(subject.primaryId).update({
        reminderReviewStatus: "revision_requested",
        reminderReviewedAt: reviewedAt,
        reminderReviewerNote: params.reviewerNote,
      })
      return
    }

    objects(Invoice).byId(subject.primaryId).update({
      status: "sent",
      reminderReviewStatus: "approved",
      reminderReviewedAt: reviewedAt,
      reminderReviewerNote: params.reviewerNote,
    })
  })

The runtime commits every staged edit in a single atomic batch once the handler returns.

The normal link rules apply inside edits(...). For cardinality: "one" links, link(...) does not replace a different existing target. To reassign one, read the current link and stage unlink(...) followed by link(...); the two staged edits commit atomically:

TS
.edits(async ({ objects, read, subject, params }) => {
  const [currentProject] = await read
    .objects(Transcript)
    .byId(subject.primaryId)
    .listLinks(Transcript.l.project)

  const transcript = objects(Transcript).byId(subject.primaryId)

  if (currentProject) {
    transcript.unlink(Transcript.l.project, {
      objectTypeId: currentProject.targetTypeId,
      primaryId: currentProject.targetId,
    })
  }

  transcript.link(Transcript.l.project, params.project)
})

Requesting actions

Actions run asynchronously. Requesting one enqueues a durable run and returns immediately; a worker executes the phases. Request object actions through the object API, and global actions through the runtime actions API. You can pass the imported action definition (typed) or its actionId string.

TS
// Object action: fire-and-forget. Returns { runId, queuedAt, created }.
const { runId } = await sixb.objects(Invoice).byId("inv-1").requestAction({
  action: markPaid,
  params: { paymentMethod: "card" },
})

// Wait for the run to reach a terminal state. Returns the ActionRunRecord.
const run = await sixb.objects(Invoice).byId("inv-1").requestActionAndWait({
  action: markPaid,
  params: { paymentMethod: "card" },
  timeoutMs: 30_000,
})
if (run.status === "failed") {
  throw new Error(run.error?.message)
}

// Global action: no subject. Request through sixb.actions.
// ref(...) params take an object ref ({ objectTypeId, primaryId }), not a bare id.
await sixb.actions.request({
  actionId: "createDraftInvoice",
  params: {
    id: "inv-9",
    number: "INV-009",
    amount: 4200,
    customer: { objectTypeId: "Customer", primaryId: "cus-1" },
    project: { objectTypeId: "Project", primaryId: "prj-1" },
  },
})

requestActionAndWait resolves when the run completes or fails, and rejects with ActionRunTimeoutError if timeoutMs elapses first (default 60s). Pass a runId to make a request idempotent — re-requesting the same runId with the same action, subject, and params returns the existing run (created: false) instead of starting a new one.

In browser apps, prefer useActionRunMutation from @sixb/client/hooks for button-driven commands. Its loading, success, and error states track the terminal run, and invalidateOnCommit: true refreshes object queries after committed edits. The generated requestActionMutation() remains enqueue-only and resolves as soon as the server accepts the request. See running actions from apps.

OptionApplies toMeaning
action / actionIdbothThe action to run — pass the definition or its id.
paramsbothThe action's typed params.
runIdrequestAction, sixb.actions.requestStable id for idempotent retries.
timeoutMsrequestActionAndWaitReject after this many ms. Default 60_000.
signalrequestActionAndWaitAbortSignal to cancel the wait.

Run lifecycle and events

Every request creates an ActionRunRecord with a status and the current phase.

statusMeaning
queuedRequested, waiting for a worker.
runningA worker is executing phases.
succeededAll phases completed.
failedA phase threw; error holds the failure and its phase.
cancelledRun was cancelled.

phase tracks progress through request -> enqueue -> validation -> writeback -> edits -> commit -> effects. The record carries writeback, commit (the object diff), and effects sub-records as each phase lands, so runs stay inspectable.

The runtime also appends domain events you can subscribe to:

EventWhenPayload
action.requestedOn enqueueactionId, subject, params, runId
action.completedRun succeededactionId, runId, subject, finishedAt
action.failedRun failedactionId, runId, subject, error, finishedAt

These are how requestActionAndWait detects completion. In client code, the fluent event builder can scope action events by run, action id, or object subject:

TSX
events.actions().run(runId).terminal()
events.actions().action("markPaid").completed()
events.actions().subject(Invoice).byId("inv-1").failed()

To react to a run on the server, subscribe through sixb.events, or model the reaction as a rule or workflow.

Search docs

Search the documentation