Writing processes
Shipped
A process is the third extension kind, beside hooks and conduits. A hook runs on one record’s write; a conduit fires when a record crosses a source boundary; a process is asked for by an app and runs a fixed sequence of record writes on the server: “raise a purchase order, wait until the source has posted it, then raise the bill against it.” The ordering, the waiting, the validation and the undo all happen on the server, survive a worker restart, and are reusable by every app on the instance — instead of living in one app’s browser code, where a closed tab or a locked phone loses the run mid-chain.
It is authored with the same SDK, validated by the same Zod machinery, run in
the same isolated-vm sandbox and stored in the same DB-native store as the
other two kinds. The SDK is the same @turbobase/extensions package — see
Install the SDK for local
typechecking (@turbobase/extensions/process is the process-only subpath).
The defineProcess contract
Section titled “The defineProcess contract”import { defineProcess } from '@turbobase/extensions';
export default defineProcess({ id: 'acme:raise-po-and-bill', // the :id an app invokes input: { // JSON Schema — validated at invoke type: 'object', properties: { vendorId: { type: 'string' }, amount: { type: 'number' } }, required: ['vendorId', 'amount'], }, steps: [ { id: 'po', recordType: 'purchaseOrder', awaitSource: true, // hold the next step for the source's verdict build: (ctx) => ({ parent: { entity_id: ctx.input.vendorId } }), }, { id: 'bill', recordType: 'vendorBill', build: async (ctx) => { // a read, as the invoking user — exactly what POST /v1/batch does const [vendor] = await ctx.query(`entity?select=terms_id&entity_type=eq.vendor&external_id=eq.${ctx.input.vendorId}`); return { parent: { created_from_id: ctx.steps.po?.externalId, // the id the source minted for step one entity_id: ctx.input.vendorId, terms_id: vendor?.terms_id ?? null, }, }; }, }, ], onFailure: 'rollback', // undo what landed when a step fails});| Field | Required | Meaning |
|---|---|---|
id |
yes | Stable id; collisions are loader errors. Convention '<scope>:<verb-noun>'. It is the :id in POST /v1/processes/:id. |
input |
yes | A JSON-Schema object the invoke body is validated against. A body that fails it is refused with the field named, and no run is created. |
steps |
yes | The ordered step list, at least one entry, ids unique. An entry is one step, or a repeated group of them — see Repeating a group of steps. A step acts on one record of recordType through the boundary your app’s own writes cross: it creates one, changes one, or removes one. |
steps[].awaitSource |
no | Hold the next step until the source has given its verdict on this one — confirmed with the minted id, or failed with the source’s own sentence. Declare it whenever a later step needs ctx.steps.<id>.externalId. |
steps[].build |
yes, unless delete |
Pure ctx → { parent, lines? } — the same payload POST /v1/data/:recordType takes — or null to decline this time. Nothing else is read from it: a pgRowId or externalId returned beside parent is ignored, and the step creates. To change an existing record, declare update. The engine performs the write; the step never does. A step that declares delete has nothing to build and must not carry one. |
steps[].update |
no | Makes this step change a record instead of creating one. See Changing a record. |
steps[].delete |
no | Makes this step remove a record instead of writing one, and the step carries no build. See Removing a record. |
steps[].when |
no | ctx → boolean. false records the step skipped and the run carries on. See A step that declines. |
steps[].rollback |
no | 'undo' (default) or 'keep' — what a failure at this step does to what the run already created. See Scoping the rollback. A delete step is always 'keep'. |
steps[].onRefused |
no | Delete steps only. 'stop' (default) or 'continue' — whether a refused delete ends the run. See What a refused delete does to the run. Declaring it on any other step is refused at deploy time, because a refused write always ends the run. |
eachRow |
no | Makes this a migration: the step list runs once per outstanding row, a slice at a time. See Moving many records. |
onFailure |
no | 'stop' (default — what landed stays) or 'rollback' (delete everything the run created, newest first). |
description |
no | Operator-friendly text in the UI. |
What a step can see
Section titled “What a step can see”ctx carries input (already validated), runId, steps — every earlier
step’s { pgRowId, externalId, disposition } keyed by step id — row (the
source row this pass is migrating, for a process that declares eachRow),
item and index (the element this iteration of a repeated group is for, and
its position), log(...), and
one read, ctx.query(q). The read takes a PostgREST path-and-query exactly
like the client’s select() (order?select=id,total&limit=100) and runs as
the user who invoked the process, carrying their session: a row their
row-level security hides is absent from the result, and a process cannot widen
its own read scope. There are no secrets in the context and no way to call a
source system — a process is pure, like a conduit, and the engine does every
write.
ctx.query cannot write, and three things stop it. The request leaves as a
GET; the path is one resource name, so no stored procedure can be named
(PostgREST exposes those under rpc/, which the read refuses); and the session
it carries is the invoking user’s, which holds no write grant of its own. A
step changes a record by returning one from build() — the engine takes it
through the write boundary, with the hooks, the guardrails and the audit trail
that door runs. Reaching for a write through the read is not a shortcut past
them; it is refused three times over.
A read works however long the run has been going. The session ctx.query
carries is renewed before it expires, so a step that reads an hour into a run —
after a few awaitSource waits — reads as the first step did. It is still the
invoking user’s session: if an administrator changes that user’s access while
the run is going, the run’s later reads are refused rather than carried on
under the old access.
Changing a record instead of creating one
Section titled “Changing a record instead of creating one”A step creates a record by default. Declare update and it changes one
instead — which is what a linking pass needs, because the first half of a pair
is created before the second half’s id exists:
steps: [ { id: 'out', recordType: 'salesOrder', awaitSource: true, build: (ctx) => ({ parent: { … } }) }, { id: 'back', recordType: 'salesOrder', awaitSource: true, build: (ctx) => ({ parent: { … } }) }, { id: 'link', recordType: 'salesOrder', update: { step: 'out' }, // what an earlier step landed build: (ctx) => ({ parent: { paired_with_id: ctx.steps.back?.externalId } }), },]Address the record one of three ways, and only step is a plain string — the
other two are functions of the context, because a declaration cannot know the
value until the run:
| Key | Addresses |
|---|---|
update.step |
What an earlier step of this run landed, by its id. Inside a repeated group that is the namespaced id, 'leg:2:doc'. |
update.pgRowId |
(ctx) => string — a row the run did not create, by its surrogate key. |
update.externalId |
(ctx) => string — the same, by the id the source minted. |
update.expectedSyncVersion |
(ctx) => number — optimistic concurrency. The row’s current version must match. |
build is unchanged: it returns the payload, and only the fields it names are
written. The write goes through the same boundary as a create, so your
before_save hooks, guardrails, write RLS and the write trail all apply — and
the refusal on the step’s ledger row is the boundary’s own. A row the invoking
user cannot see is a 404; a moved expectedSyncVersion is a 409.
An address that resolves to nothing — a step that was skipped or has not run,
or a pgRowId function that returned undefined — fails the step with
STEP_ADDRESS_UNRESOLVED, naming what it could not resolve. It is never allowed
to fall through to a create: that would quietly make the record the update was
meant to change, with nothing downstream able to tell.
The address lives on update, never in what build returns. A step that
returns { pgRowId, parent } from build and declares no update is a
create — the extra key is not read — and it inserts a new row every time it
runs. recordType must be the record type of the row you address; the
boundary looks the row up in that type’s table.
If an update step still creates rows, the code that ran is not the code you
wrote. Check the two things that cause it, in order: the instance is older
than v1.38.0 (where update first shipped — an older instance drops the
key at deploy and the step creates), or an older deployed copy of the process is
still the one being served — see Deploying a new version.
Removing a record
Section titled “Removing a record”Declare delete and the step removes a record instead of writing one. It
carries no build — a delete has nothing to build — and it addresses records
your run did not create, which is what a background cleanup needs: undoing a
migration, clearing a batch an import got wrong. Your app declares the work
once and the platform runs it, so it survives the page being closed.
steps: [ { id: 'drop-invoice', recordType: 'invoice', delete: { externalId: (ctx) => ctx.input.invoiceId, // the id the source minted sourceSystem: 'netsuite', }, onRefused: 'continue', },]Address the record one of two ways — the same two keys
DELETE /v1/data/:recordType takes, and both functions of the context for the
reason an update address is:
| Key | Addresses |
|---|---|
delete.pgRowId |
(ctx) => string — the row by its surrogate key. |
delete.externalId |
(ctx) => string — the row by the id the source minted. |
delete.sourceSystem |
Which system the record belongs to. A qualifier: it narrows which table the id is looked up in on an instance where one record type means different things under two connected systems. It never decides where the delete is sent — the row’s own system does that. |
There is deliberately no delete.step. A step addresses a record the run did
not create; a run that deleted its own work is a rollback, which is what
onFailure: 'rollback' is for.
It is an ordinary delete. The step crosses the same boundary your app’s own
DELETE /v1/data/:recordType crosses, as the user who invoked the run — so
write RLS, the write-back entitlement, the <record>.delete guardrail and your
before_delete and after_delete hooks all apply exactly as they do to a
delete your app sends, and the refusal on the step’s ledger row is theirs. It
is charged to your instance’s ordinary delete cap.
A delete cannot be undone, so the step is always rollback: 'keep':
declaring rollback: 'undo' is refused at deploy time, and a rollback some
other step causes passes over it. The records it removed stay removed.
What a refused delete does to the run
Section titled “What a refused delete does to the run”onRefused decides, and it is the one choice this step asks you to make:
| Value | The run |
|---|---|
'stop' (default) |
Ends at the refusal, as every other step does. The remaining steps are skipped. |
'continue' |
Carries on to the next step and commits. The refusal is still on this step’s ledger row. |
Reach for 'continue' on a tier-ordered cleanup — transactions, then
entities, then items. A record the connected system will not delete because
another document still points at it is a record to leave alone, not a
reason to abandon the remaining tiers.
Either way the outcome of every record is on the run’s step ledger. Read it
with GET /v1/processes/runs/:runId, which answers one entry per step in the
shape Watching a run sets out:
disposition—confirmedfor a record that is gone,failedfor one that was refused,skippedfor a step that was never attempted: itswhendeclined, an earlier step failed, or the run paused on the delete cap with steps still to go.refusal—null, or{ code, field, message }: the refusal’s own code and sentence, whichever party refused — your guardrail, a hook, or the connected system.pgRowIdandexternalId— which record the step removed.
Read refusal on a confirmed entry too, because there is one confirmed
that does not mean the record is gone from the connected system — the held
delete below. Its refusal.code is SOURCE_AWAITING_CONFIRMATION, and every
other confirmed leaves refusal null.
Those are the route’s names. The same rows are also readable through PostgREST
as process_run_step, where the ledger’s own columns carry the same facts:
disposition, refusal_code, refusal_field, refusal_message, pg_row_id
and external_id. Reach for the table when you are already querying your data;
reach for the route when you are watching one run.
A delete the connected system refuses is un-hidden again: the record comes
back and the step row explains why. An address that resolves to nothing fails
the step with STEP_ADDRESS_UNRESOLVED and deletes nothing — it is never
allowed to fall through.
Pacing, and your delete cap
Section titled “Pacing, and your delete cap”Deletes are capped tighter than updates, because the cap is a circuit-breaker against a mass delete and a background run is exactly the thing that would trip one. A delete step waits for the rolling window to free and carries on in the same run, so you do not have to spread a cleanup thin to stay under it.
A step still waiting when its budget runs out stops the run with the cap named
on it, having deleted nothing at that step. That step and the ones after it are
recorded skipped. Invoke the process again to carry on, or raise the cap in
Settings → Write guardrails.
A delete your instance holds for approval
Section titled “A delete your instance holds for approval”This is the default, not an edge case. Turbobase classifies a delete as destructive and asks an operator to approve it unless a guardrail rule on your instance says otherwise — so on a stock instance every delete a process makes is held, exactly as every delete your app sends is.
A hold is not a refusal. The record is hidden in Turbobase straight away, the delete is queued, the run carries on to the next step and commits, and a person approves it on Compliance → Guardrails — the same path a delete your app sends takes. The step does not wait for that approval: nothing bounds how long a person takes.
What tells you it happened is the step’s entry:
disposition |
refusal |
|
|---|---|---|
| Removed, and the connected system has done it | confirmed |
null |
| Hidden here, waiting for an operator to approve it | confirmed |
code: 'SOURCE_AWAITING_CONFIRMATION' |
| Refused | failed |
the refusing party’s own code |
If your cleanup is meant to run unattended, write a rule for the record types
it deletes — allow_with_audit on <record>.delete keeps the audit trail and
removes the hold.
Repeating a group of steps
Section titled “Repeating a group of steps”A step-list entry may carry nested steps instead of a recordType and a
build. Declare forEach on it and the group runs once per element of an array
read off the validated input:
input: { type: 'object', properties: { legs: { type: 'array', items: { type: 'object' } } }, required: ['legs'],},steps: [ { id: 'leg', forEach: { input: 'legs' }, // names a property of `input` steps: [ { id: 'doc', recordType: 'salesOrder', build: (ctx) => ({ parent: { memo: `leg ${ctx.index}`, entity_id: ctx.item.vendorId } }) }, { id: 'note', recordType: 'salesOrder', build: (ctx) => ({ parent: { created_from_id: ctx.steps[`leg:${ctx.index}:doc`]?.externalId } }) }, ], }, { id: 'close', recordType: 'salesOrder', update: { step: 'leg:0:doc' }, // address one iteration from outside the loop build: (ctx) => ({ parent: { memo: 'chain closed' } }), },]ctx.itemis the element andctx.indexits position, from 0.- Every iteration’s steps get their own ledger rows, named
<groupId>:<index>:<stepId>—leg:0:doc,leg:0:note,leg:1:doc, … That id is part of the contract you write against: it is how a later step reads one iteration’s outcome out ofctx.steps, and how it addresses one withupdate: { step: … }. forEach.inputnames a property rather than taking a function. That is what lets the instance check the expansion against its cap before any of your code runs and before the first write.
One run may repeat a group at most 100 times
(DEFAULT_PROCESS_FOR_EACH_MAX_ITEMS). Over that, the run is refused with both
numbers in its reason and nothing is written — a chain that wants hundreds
of legs wants eachRow, which the platform paces a
slice at a time.
A step that declines
Section titled “A step that declines”Two ways, one outcome. when(ctx) says “not this time” before the payload is
built; a build that returns null says it after:
{ id: 'rush', recordType: 'salesOrder', when: (ctx) => ctx.input.express === true, build: … },{ id: 'fee', recordType: 'salesOrder', build: (ctx) => (ctx.input.fee ? { parent: { … } } : null) },Either way the step is recorded skipped with no refusal on its row, the
steps after it still run, and the run ends committed. Declining is not a
refusal: a chain with an optional leg is a chain that succeeded.
A when that throws is a different thing — an author whose condition is
broken, not an author saying no — so it fails the step and stops the run rather
than being read as a false that would silently drop a leg.
Scoping the rollback
Section titled “Scoping the rollback”onFailure is the run’s switch; steps[].rollback narrows it to the step:
onFailure: 'rollback',steps: [ { id: 'out', recordType: 'salesOrder', build: … }, // 'undo' — the default { id: 'back', recordType: 'salesOrder', build: … }, { id: 'stamp', recordType: 'salesOrder', rollback: 'keep', build: … }, // the closing stamp]It is the failing step’s setting that decides whether the run rolls back at
all. A failure at stamp above undoes nothing and the run ends failed, so
a closing stamp that could not be written does not destroy the chain it was
closing. A failure at out or back rolls the run back exactly as it always
has, and stamp’s own record is spared there too — keep is about this step’s
record as well as about the run’s ending.
Rolling a finished run back on request — POST /v1/processes/runs/:runId/rollback,
see Stopping work — is somebody asking rather than a failure,
so the failing step’s keep does not stop it. Each step’s own keep still
spares that step’s record.
A record an update step merely changed is never deleted by a rollback and
never put back: the run did not create it, the prior values are not the run’s to
own, and restoring them would silently overwrite an edit somebody else made in
between. The ledger is what says which steps changed what.
ctx.query answers with the whole result set, or it throws. There is no
row cap on it, and that is deliberate: a step reads in order to decide what to
write, so a silently shortened answer would be a wrong write rather than a slow
one. The POST /v1/batch row cap does not apply here. What does apply is a
size ceiling — a single read whose response exceeds the per-read byte budget
fails the step with the limit named, rather than coming back short. Ask for a
page explicitly (?limit=…&offset=…) when a page is what you want, and prefer
a filter to reading a table and narrowing it in build.
Two time bounds apply as well. A read that has started is abandoned at the
per-read ceiling. And reads take from a small pool of slots shared with the other
runs on the same worker, so a read that waits too long for a slot is never sent
at all. That second bound is the worker’s own, and it is several times longer
than the one a request from an app gets: nobody is watching a background run, so
it is given room to be slow, and the bound is there to end a run that is waiting
on a queue that has stopped moving rather than to hurry one along. Either bound
fails the step by name, with the bound that stopped it in the message, rather
than waiting indefinitely — and a failed step ends the run (failed, or rolled
back where the process asks for that), so the remedy is to invoke it again rather
than to wait. One more reason to filter a read rather than drain a table: a step
that reads everything holds a slot for as long as it takes, and the runs behind
it are the ones that pay.
Invoking a process
Section titled “Invoking a process”An app calls the process under its ordinary session — the same cookie its own
writes use — with the input as the body. The body is the input, with no
{ input: … } envelope around it:
const res = await fetch('/v1/processes/acme:raise-po-and-bill', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ vendorId: '123', amount: 250 }),});// 202 { runId, processId, state: 'queued' }The response comes back before the first step is written. Nothing runs on
the request: the run is queued, and a worker performs it. Refusals carry an
envelope and never a runId:
| Status | Body | Meaning |
|---|---|---|
401 |
{ error } |
No session. |
404 |
{ error: 'process_not_found', id } |
No deployed process of that id. |
400 |
{ error: 'invalid_input', rejection: { code, field, message } } |
The body fails the process’s input schema; field names what to fix. |
400 |
{ error: 'invalid_mode', rejection } |
?mode was neither sync nor async. |
409 |
{ error: 'process_disabled', id, reason } |
An administrator switched this process, or every process on the instance, off. See Stopping work. |
202 |
{ runId, processId, state: 'queued' } |
Queued. |
Send the input bare. A caller that posts { "input": { "batch": 7 } }
gets exactly that as ctx.input — ctx.input.input.batch — and eachRow.outstanding
receives the same wrapped object. If one of your callers wraps the body and
another does not, the process sees two shapes. An input schema with
additionalProperties: false and the real properties required turns the
wrapped shape into a 400 instead.
Who can invoke. A signed-in session — an app, or a person in the browser —
or a personal access token carrying extensions:deploy whose owner is an
administrator, which is what turbobase processes invoke sends (see
From a terminal). A token’s run is its owner’s: it writes
as that person, exactly as their browser session would. A token owned by
anyone else is refused (403 admin_required). A process cannot start another
process — a step has no network and no way to call the platform. For work that
must carry on with nobody signed in, see
Keeping a long run going.
Waiting for the answer
Section titled “Waiting for the answer”Add ?mode=sync when the app wants the outcome rather than a ticket — a short
chain a person is watching a spinner for:
const res = await fetch('/v1/processes/acme:raise-po-and-bill?mode=sync', { … });// 200 { runId, processId, state: 'committed', reason: null, outstanding: 0, steps: [ … ] }The run itself is queued and performed by a worker exactly as an asynchronous
one is — the same queue, the same engine, the same ledger. All that changes is
that the request stays open until the run settles, and it is bounded: a run that
outlasts the instance’s synchronous budget answers 202 { error: 'sync_budget_exceeded', runId, processId, state: 'queued', message }. That is
not a failure and the run is not cancelled — it is still going, which is why
the answer carries its id. Watch it as below, or invoke it asynchronously in the
first place.
So in ?mode=sync a 202 is the one answer that does not mean “finished”:
branch on error, not on the status. A 200 carries the settled run, a
202 carries error: 'sync_budget_exceeded' and a run still going, and the
4xx refusals above carry no runId at all.
The two modes run in separate lanes with separate limits, so a long migration cannot crowd out the short interactive runs.
Watching a run
Section titled “Watching a run”Poll GET /v1/processes/runs/:runId under the same session. It answers the run
— state is queued → running → committed | failed | rolled_back |
cancelled, with a reason on any ending that needs one — and one entry per
step:
{ "run": { "runId": "…", "processId": "acme:raise-po-and-bill", "state": "committed", "reason": null, "outstanding": 0, "…": "…" }, "steps": [ { "sequence": 1, "stepId": "po", "recordType": "purchaseOrder", "pgRowId": "…", "externalId": "PO-1041", "disposition": "confirmed", "rowKey": "", "slice": 1, "refusal": null }, { "sequence": 2, "stepId": "bill", "recordType": "vendorBill", "pgRowId": "…", "externalId": null, "disposition": "confirmed", "rowKey": "", "slice": 1, "refusal": null } ]}rowKey and slice matter for an eachRow migration: rowKey is the source
row the entry belongs to, and slice is the slice of the run that recorded it.
A row that a later slice offers again gets a second set of entries, one per
visit. For a chain they are always "" and 1.
A step’s disposition is one of confirmed, failed, skipped (it was never
attempted — its when declined, an earlier step failed, or the run paused on
the delete cap) or rolled_back. A step that was refused carries the refusing
party’s own code, field and message — a
before_save hook’s rejection, the write boundary’s, or the source’s sentence
from the write trail — so a failed run explains itself the way a 422 from
POST /v1/data does. The refusal stays on the row when a rollback later
undoes that step’s record, so a rolled_back step can still say why. The same rows are readable through PostgREST as
process_run and process_run_step, scoped by row-level security to the
invoking user (an administrator sees every run).
committed does not always mean there is nothing left to do. A run carries
an outstanding count — the work it did not get to — and a slice of a migration
ends committed with a non-zero one. You do not have to ask for the next
slice: the run queues it itself, under the same run id, until nothing is
outstanding or it reaches its slice cap. A run that stopped at the cap ends
committed with a reason naming it, which is how “stopped early” and
“finished” stay two different things to read.
Watching the whole queue
Section titled “Watching the whole queue”One run at a time is the wrong shape once a migration is thousands of records.
GET /v1/processes/queue answers everything in one read, scoped exactly as the
single-run read is:
{ "runs": [ { "runId": "…", "processId": "acme:migrate-customers", "mode": "async", "state": "committed", "outstanding": 4, "failure": null }, { "runId": "…", "processId": "acme:migrate-customers", "mode": "async", "state": "failed", "failure": { "stepId": "customer", "code": "MANDATORY_FIELD_MISSING", "message": "company_name is required" } } ], "counts": { "queued": 12, "running": 2, "committed": 40, "failed": 1, "rolled_back": 0, "cancelled": 0 }}Add ?mode=async for the migration lane alone. Each failure travels with
the run it belongs to, so nothing has to be looked up separately, and every
bucket is reported even when it is empty — so “none cancelled” reads
differently from “cancellation is not reported”.
Stopping work
Section titled “Stopping work”await fetch('/v1/processes/acme:migrate-customers/cancel', { method: 'POST' }); // { cancelled: 387 }await fetch(`/v1/processes/runs/${runId}/cancel`, { method: 'POST' }); // { runId, state: 'cancelled' }await fetch(`/v1/processes/runs/${runId}/rollback`, { method: 'POST' }); // 202 { runId, state, queued: true }The first stops every queued run of one process in a single call — a caller who queued four hundred slices is not asked for four hundred requests. The second stops one run, queued or running.
A queued run never executes. A running one stops at its next boundary — the
next step, and for a migration the next row, so a one-step migration stops
after the row it is on rather than running to the end of its slice. The write in
flight finishes rather than being killed halfway through, and records already
sent upstream stay — even when the process declares onFailure: 'rollback'. A
cancel is not a failure, and undoing records the source has already accepted is
exactly the half-built chain rollback exists to prevent.
A cancelled slice reports the rows it never started in outstanding, the same
way one stopped by the budget does. Cancelling one slice cancels the run: a
cancelled run queues no continuation.
To undo a run after it has stopped, roll it back. The third call above
deletes, newest first, the records the run’s ledger shows it created — the same
records an onFailure: 'rollback' would have deleted, so a record an update
step changed, a row an earlier run had already migrated and a step declared
rollback: 'keep' are left alone. It works on a run that is committed,
failed or cancelled; a run still queued or running answers 409 (cancel
it first), and one already rolled_back answers 200 with queued: false. It
is open to whoever invoked the run and to an administrator, and the deletes are
made as the person who invoked it, through the same delete rules and hooks as
any other delete. The work is queued rather than done inside the request, so
the answer is 202; watch the run until it reads rolled_back (or failed,
with the records it could not remove named in its reason).
An administrator can also stop a process without touching its runs. Switch
one process off, or every process on the instance off, and no new run starts by
schedule or by hand — an invoke answers 409 naming the switch — while a run
already going stops at its next step boundary. A run also stops there when it
passes the instance’s maximum runtime or maximum writes for one run. Every one
of those endings is committed with a reason naming the limit or the switch,
rather than failed — they are not faults, and counting them as failures would
set off the alarms that watch for real ones.
Those two ceilings are the RUN’s, and they apply even where nobody has set one. An instance that has never been told a number gets shipped defaults sized off the slice budget — the most work the slice caps already allow one run to do — so nothing that was going to finish is stopped by them, and a run that is stuck is bounded rather than endless. They count across the whole run, not per slice: a migration of a thousand slices has one runtime allowance and one write allowance between them, which is what “per run” means on an instance’s settings page.
Carrying a stopped run on
Section titled “Carrying a stopped run on”await fetch(`/v1/processes/runs/${runId}/resume`, { method: 'POST' }); // 202 { runId, state, queued: true }A run that stopped on an awaitSource wait is resumed, not started again.
The same run goes back on the queue at the step it stopped on: every step its
ledger already recorded is passed over, the one whose verdict never arrived is
waited for again, and the rest of the chain runs. Nothing is written twice,
because each step’s idempotency key comes from the run id and the run id does
not change — which is exactly what invoking the process a second time cannot
give you, since a new run’s keys are its own and its first steps create again.
A step that already said no is not asked again. “Already recorded” covers a
step whose when declined as well as one that wrote: the run reached it and
decided, so the continuation leaves that decision alone even where the data the
when read has changed in the meantime. Only the steps the run never reached
are performed. If you want a declined step reconsidered against today’s data,
invoke the process again rather than resuming the old run.
Only a run that stopped with work left. committed with outstanding above
zero is the one state a resume is for. A run still queued or running has not
stopped, a failed or rolled_back one needs its refused step re-keyed rather
than re-offered, a cancelled one was stopped by a person, and a committed
run with nothing outstanding has finished — each answers 409
(process_run_not_resumable) naming the state on the body, with nothing queued.
Two resumes in quick succession start one continuation; the second answers
409.
A migration is invoked again instead. An eachRow process keys each write
by the source row, so a slice that stopped at its cap carries on by being
invoked again — re-offering the same slice would be wrong rather than merely
unnecessary. The resume answers 409 process_is_a_migration and says so.
It is open to whoever invoked the run and to an administrator, the continuation
runs as the person who invoked it, and the work is queued rather than done
inside the request — so the answer is 202; watch the run until it reads
committed with outstanding: 0. The clock stays the run’s: startedAt is
not reset, so a resume cannot buy a run more than the instance’s maximum
runtime, and a run already past it stops again at its first step boundary with
the ceiling named in its reason.
From a terminal
Section titled “From a terminal”turbobase processes list # what is deployed, and how it firesturbobase processes runs acme:migrate-customers # that process's whole historyturbobase processes run 1111… # one run and its step ledgerturbobase processes invoke acme:migrate-customers --input '{"batch":"2026-10"}' # start one, queuedturbobase processes invoke acme:raise-po-and-bill --input-file po.json --sync # start one and waitturbobase processes cancel 1111… # stop itturbobase processes resume 1111… # carry on a run that stopped on a waitturbobase processes rollback 1111… # undo what a finished run createdturbobase logs --process-run 1111… --follow # watch one run until it finishesprocesses invoke sends the input — a JSON object, from --input or
--input-file — as the whole request body, exactly as an app does. Queued, it
prints the run id and the logs --process-run line to follow it; with --sync
it prints the settled state, anything outstanding and each step, or — past the
instance’s budget — that the run is still going and how to watch it.
processes list is the read behind the admin Processes screen as well:
every deployed process with its trigger — manual, or scheduled with both
the cron expression and the timezone it is read in — whether it is switched on,
and its last five runs. It is scoped by your own row-level security, so the
processes are everyone’s and the runs are yours (an administrator sees every
run).
Following a run is a flag on the one log verb rather than a processes logs of
its own, because all log access on this CLI unifies under turbobase logs.
Unlike --conduit, it ends: a run reaches a terminal state, so --follow
returns there instead of polling until you interrupt it.
Moving many records
Section titled “Moving many records”A process runs its step list once, which is the right shape for a three-record
chain and the wrong one for moving tens of thousands of records. Declare
eachRow and one invocation becomes a slice of that work:
export default defineProcess({ id: 'acme:migrate-customers', input: { type: 'object' }, eachRow: { // Re-run at the start of EVERY slice. Write the predicate that excludes // what is already done, and rows an earlier slice finished simply are not // in the answer any more. outstanding: () => 'legacy_customer?select=id,company_name&migrated_at=is.null&order=id', key: 'id', }, steps: [ { id: 'customer', recordType: 'customer', build: (ctx) => ({ parent: { company_name: ctx.row!.company_name } }), }, ],});outstandingreturns one PostgREST query — the same shapectx.querytakes — read as the invoking user and row-level-security-scoped for free.keynames the column that identifies a row. That value becomes the write’s idempotency key, which is what makes re-offering a row a no-op rather than a second record upstream. Without it every retry of a slice would duplicate everything it had already moved.- Each step reads its row from
ctx.row, not frominput:inputdescribes the whole migration, the row describes one record of it.
One invocation takes rows off the front of that answer until the instance’s
slice budget — a record count or a wall clock, whichever comes first — stops it.
It finishes the step it is on, ends committed with the remainder in
outstanding, and queues the next slice itself. One run id, one ledger,
several deliveries: you invoke a migration once and it carries on until nothing
is outstanding.
A separate job each time rather than a loop, deliberately — a loop would hold its lane for the whole migration, which is the starvation the two-lane design exists to prevent. The chain is bounded by a per-run slice cap, so a migration that is larger than anyone expected stops and says so rather than running forever.
Re-offering a row is still safe, which is why the key above matters: a
create step offered the same row twice writes it once. An update step does not
work that way. It runs again on every visit, so the row can move through
states over several slices (see the contract below).
The eachRow contract
Section titled “The eachRow contract”outstanding(input)receives the bare invoke body — the same object asctx.input— and returns one PostgREST query.- It is read once per slice, not once per row. The slice works through the rows that one read returned. A row that becomes outstanding while the slice is running is not in that answer; the next slice’s read picks it up. If the slice finished every row it read, nothing is outstanding and the run ends — a row added during that last slice waits for the next invocation or schedule.
outstandingon the run is counted from that read: the rows the slice read minus the rows it finished.- Your predicate must exclude a row once it is done. That is what makes a slice’s work shrink. A predicate that keeps returning finished rows makes every slice re-offer them, and the run keeps re-queuing until it reaches its slice cap. The usual shape is an update step at the end of the list that stamps the row out of the predicate. See the migration recipe.
- Each time a later slice or a later run offers a row again, that is a new
visit. On that visit:
- Create steps run once per row
key. The step recordsskippedand handsctx.stepsthe record that was created first, with itspgRowIdandexternalId, so later steps can still address it. - Update steps run again on a later visit. Their write goes through the
boundary again, so a row can be stamped
senton one visit andrecordedon a later one. - A step that declined runs its
whenandbuildagain. That covers awhenthat returned false and abuildthat returnednull. A step that declines while the row is waiting acts on the visit where it is ready. - An AI step reuses its answer for the rest of the run. Once the model
has answered for a row, later slices of the same run hand that answer on as
skippedand do not bill again. A new run asks the model again.
- Create steps run once per row
- A crash re-delivery is not a new visit. If a worker dies mid-slice, the
queue hands the same slice back. That delivery resumes after every step the
slice already recorded, whether
confirmedorskipped, and writes nothing twice. A row the outstanding read lists twice in one slice is also visited only once. - Steps share state only through
ctx.stepsandctx.row.ctx.stepsholds the outcomes of earlier steps for the row being processed and starts empty for the next row. There is no memory across rows or slices: anything a later row needs to know belongs in a table the steps write andctx.queryreads. - A step that fails on one row ends the run under
onFailure, the same as in a chain. The rows after it are counted inoutstanding.
| Limit | Default | Set by |
|---|---|---|
| Rows one slice works through | 500 (DEFAULT_PROCESS_SLICE_MAX_ROWS) |
the instance (PROCESS_SLICE_MAX_ROWS) |
| Wall clock one slice may run | 10 minutes (DEFAULT_PROCESS_SLICE_MAX_MS) |
the instance (PROCESS_SLICE_MAX_MS) |
| Slices one run may take | 1000 (DEFAULT_PROCESS_MAX_SLICES) |
the instance (processes.slice_cap) |
A slice checks both of its limits before starting a row, never in the middle of one.
A migration recipe
Section titled “A migration recipe”The common shape is a tracking table your app owns — one row per record to move,
with a status — and a process that creates the target record for each pending
row and then marks that same row done. Everything below needs an instance on
v1.38.0 or later: update steps, ctx.row inside a deployed process, slices
that queue themselves and { kind: 'process' } schedules all first shipped there.
import { defineProcess } from '@turbobase/extensions';
export default defineProcess({ // Keep this the FIRST `id:` in the file — see "Deploying a new version". id: 'acme:migrate-customers-tracked', input: { type: 'object', properties: { batch: { type: 'string' } }, additionalProperties: false }, eachRow: { // Select pg_row_id: the update step below addresses the row by it. outstanding: (input) => `migration_item?select=pg_row_id,legacy_id,company_name&status=eq.pending&batch=eq.${input.batch}&order=legacy_id`, key: 'legacy_id', }, steps: [ { id: 'create', recordType: 'customer', build: (ctx) => ({ parent: { company_name: ctx.row!.company_name } }), }, { id: 'mark', recordType: 'migration_item', // the record type of the row you change update: { pgRowId: (ctx) => ctx.row!.pg_row_id as string }, rollback: 'keep', build: (ctx) => ({ parent: { status: 'sent', target_pg_row_id: ctx.steps.create?.pgRowId ?? null }, }), }, ],});markchanges the row this pass is on. It does not insert, because it declaresupdate, and it takes the row out ofoutstandingbecause it movesstatusoffpending.createdoes not declareawaitSource, somarkrecords the new row’spgRowId— set the moment the write lands — rather than waiting for the connected system to mint its id. In bulk, a wait for the source’s verdict runs out behind the queue of other writes; read the verdict later from the new row (synced_at,external_id) or the write trail.pg_row_idis in theselect. Leave it out andctx.row.pg_row_idisundefined, andmarkfails withSTEP_ADDRESS_UNRESOLVED.rollback: 'keep'on the stamp means a failure while stamping does not undo the recordcreatealready made upstream.- Invoke it once —
POST /v1/processes/acme:migrate-customers-trackedwith{ "batch": "2026-10" }— and it carries on by itself.
Keeping a long run going
Section titled “Keeping a long run going”You do not need a browser tab open, and you do not need to re-invoke after each slice.
-
An asynchronous run continues itself. A slice that stops at its row or time limit with work outstanding queues the next slice, under the same run id, and the run stays
running. It ends when nothing is outstanding, or at the slice cap. At the cap it endscommittedwith areasonsaying to invoke it again. A cancel, an administrator’s off switch or a per-run ceiling stops it without continuing. -
A schedule invokes it with nobody signed in. Add an entry to
turbobase/schedules.jsonand deploy it withturbobase ext deploy --to <env>:[{"name": "migrate-customers-nightly","scheduleKind": "cron","scheduleExpr": "0 2 * * *","scheduleTz": "UTC","body": { "kind": "process", "process": "acme:migrate-customers-tracked", "input": { "batch": "2026-10" } }}]- Only an administrator can deploy one. The deploy is refused if
processis missing or names a process that is not deployed. Declaring the process and the schedule that runs it in the SAME deploy is fine — the check reads the processes the deploy will leave behind, not the ones already there. - A process a schedule runs cannot be removed on its own. Dropping the process while this entry survives is refused, because the schedule would go on firing against nothing and switch itself off after five failures. Delete both in the same deploy — that is the supported way to retire a process and its schedule — or point the schedule at another process in the same deploy, or keep the process declared. See Deploy, plan, promote.
namemay not begin withsystem.— that prefix is the platform’s own recurring jobs, and a bundle schedule using it is refused.- A schedule written by
ext deployruns as the instance’s administrator identity, and the run’s ledger records the schedule, not a person. - The schedule’s own run says
successas soon as the process run is queued. The process run’s outcome is on the run itself — read it withturbobase processes runs <id>. - A scheduled invoke is skipped, not failed, while the process or the instance is switched off.
inputis checked against the process’sinputschema on every run, exactly as the invoke body is. Leave it out and the run gets{}. Input that is not a JSON object, or does not match the schema, fails that scheduled run with the field and the reason, and no process run is queued. Repeated failures pause the schedule like any other.
A schedule is also how you restart a run that stopped at its slice cap, and it is safe to fire while an earlier run is still going: rows are keyed, so two runs that reach one row write it once.
- Only an administrator can deploy one. The deploy is refused if
What the engine guarantees
Section titled “What the engine guarantees”- Every write goes through the write boundary, as the invoking user. Hooks,
guardrails, row-level security and the write trail apply to a step exactly
as they apply to a write the app makes itself. A payload a
before_savehook rejects fails that step with the hook’s own code and field, and no later step is attempted. awaitSourcewaits for the source’s verdict, not for the first attempt. A failed attempt is not terminal — the outbound worker retries — so the wait ends only onsynced(the source said yes) or on a parked refusal (the source said no). The wait budget defaults to five minutes per step.- A record the source created but could not be linked back is kept. When
the source accepts an
awaitSourcestep’s create and the link from the new record back to its row fails (write_settle_conflict), the step endsfailedwithrefusal_code: 'SOURCE_APPLIED_UNLINKED', the source’s id on the step’sexternalId, and the source’s own sentence. The run stops there, but no rollback deletes that row — the record exists upstream, and deleting the row would leave it with nothing pointing at it. OfferPOST /v1/data/:recordType/settlewith the step’spgRowIdto link it. - A wait that runs out is not a failure. Nothing was refused: the write is
on the trail and the source has not answered either way. Behind the one-at-a-
time ordering below, exceeding the budget is what an ordinary busy day looks
like — so the run stops there with its record intact, ends
committed, counts the steps it never reached inoutstanding, and names the waiting step in itsreason. Branch on that rather than onfailedif you need to spot a slow source, and carry it on with a resume rather than starting over — see Carrying a stopped run on. - A run survives a worker restart. Every step carries an idempotency key and
is recorded the moment its write lands, so a re-delivered run resumes at the
first unfinished step and never creates a duplicate. For a chain that key is
per run. For a migration it is per source row, which makes a create step
a no-op when the same row is offered again, across runs as well as within
one. Update steps and declined steps run again on that later visit; see
the
eachRowcontract. - Rollback is newest-first, through the delete boundary. With
onFailure: 'rollback', every record the run created is deleted in reverse order —before_deletehooks see it, and a record that had already reached the source is deleted there too — and the run endsrolled_back. That includes a record the source refused after it landed locally. What a rollback never touches is a record the run did not create — a migration step skipped because an earlier run had already moved that source row — and a record the source created whose link back failed (SOURCE_APPLIED_UNLINKED, above). A record whose own upstream create is still in flight IS deleted: a step that did not await the source, or one whose wait ran out, is recorded as landed, and a rollback removes it here and queues its delete. If the create then reaches the source after the delete was decided, the source can keep a record nothing on the instance points at — so resume a run that stopped on a wait rather than rolling it back, and roll one back only when you want the work undone. - A rollback has its own delete limit, and waits rather than failing. Every
delete that reaches a connected system is capped per source system. A delete
your app or a person sends counts against the instance’s delete limit — by
default 60 a minute, 1,000 an hour and 5,000 a day, raised with
guardrails.max_deletes_per_min,guardrails.max_deletes_per_hourandguardrails.max_deletes_per_day— and over it the delete is refused and the record stays. A run’s rollback is not counted there. Each of its deletes is checked against the run’s own ledger, and one that removes a record that run created counts against a separate rollback limit — by default 600 a minute, 10,000 an hour and 50,000 a day, raised withguardrails.max_rollback_deletes_per_min,guardrails.max_rollback_deletes_per_hourandguardrails.max_rollback_deletes_per_day. Over that limit the delete waits for the window to free and is then sent; it is never refused for being over it. A run can never spend more of it than the number of records its ledger shows it created, and a delete of anything else — including a record the run only updated — is an ordinary delete under the first limit. rolled_backmeans removed here and on its way to the source. A run readsrolled_backonce every record it created has been removed from the instance and its delete accepted for sending. While deletes are still being sent, the run’sreasonsays how many. If the connected system then refuses one, that record comes back, its step returns to what it was, and the run readsfailedwith the refusal named — roll it back again once the cause is fixed. There is no bulk delete endpoint.- Same-type writes reach the source one at a time, in order. Two writes of one record type are never committed to the source together, so a source that numbers documents per type cannot hand one number to two of them. This holds for every writer on the instance, not only for processes.
When the write boundary refuses a step
Section titled “When the write boundary refuses a step”A step’s refusal is the boundary’s own, with its code. Two are worth knowing
before you write a NetSuite migration:
ATOMIC_WRITE_REQUIRED— a NetSuite transaction create (a credit memo, a vendor credit, a deposit, an invoice, …) arrived with no lines. Header and lines go to NetSuite in one write, so a create step’sbuildmust return a non-emptylinesarray:{ parent: { … }, lines: [{ … }] }. A document transformed from another one —parent.created_from_idset — is the exception; NetSuite copies the lines from its source.write_settle_conflict— the record was created in the connected system and handed back its id, and linking that id to the row here failed. Do not write the row again: a second create makes a second record upstream. The id is onrecord_write_state.external_idfor that row, withreason_code = 'write_settle_conflict'— read it there rather than parsing it out of the message. You cannot set the row’sexternal_idby writing the row (it is a protected column); link it with the settle verb instead —POST /v1/data/<recordType>/settlewith{ "pgRowId": "…" }, orturbobase data settle <recordType> <pgRowId>. It reads the id from the row’s write state (never from you), discards the inbound copy of the same record if one arrived meanwhile, marks the row synced and resolves the failure. It answers409 NOT_SETTLE_CONFLICTfor a row that is not in this state.
Scaffold, deploy, and share a rule
Section titled “Scaffold, deploy, and share a rule”turbobase ext new process --record purchaseOrder --then vendorBill --on-failure rollbackturbobase ext lint processes/purchase-order-then-vendor-bill.tsturbobase ext deploy processes/purchase-order-then-vendor-bill.tsext lint is the offline check for a process — it reads the file and warns
about the id-literal trap below, which is the only rule a process can trip. The
four conduit rules it also carries (a weak idempotencyKey, a missing
createdFrom, an extreme cycleBudget, a same-source “conduit”) describe a
cross-source bridge and are not raised on a process, so ext lint --ci is safe
to put in your deploy lane: a correct process exits 0. ext test is not:
it invokes a hook’s run(), which a process does not export, so every sample
comes back failed with extension default export has no run() function. A
process is exercised by invoking it for a few rows and reading the run ledger.
A deploy validates the module the way the loader will and refuses one whose
steps are not a list, whose step has no build, or whose step names a
record type the write boundary would not accept — with the field named, and
nothing stored.
Deploying a new version
Section titled “Deploying a new version”ext deploy answers new for a process the instance did not have, and
replaced existing for one it did (the API’s outcome is written /
duplicate). A replace is not a no-op: the new code is stored either way. The
instance picks it up within about 30 seconds; a slice that is already running finishes on the version it
started with, and the next slice uses the new one.
The stored copy is keyed on the first id: '…' literal in the file, and the
running instance serves the module by its own id. So make the first literal
the process’s own id — not a constant, and with no step, comment or inlined
helper carrying an id: literal above it. A deploy evaluates the module and
refuses a file where the two disagree, with extension_id_mismatch, naming the
literal it read and the id the module defines; nothing is stored, and a bundle
apply (ext deploy --to <env>) refuses the whole plan before writing anything.
turbobase ext lint warns about the same file before you deploy it.
The deploy line names the id it stored under and whether it replaced a copy —
deployed <id> → status=active (replaced existing). A copy stored under the
wrong name by an instance that predates the refusal is still there: both define
the same process, only one is served — chosen by the stored names’ order, not
by which is newer — so your new code may never run. turbobase ext plan --to <env> lists what is deployed that the project does not declare; a copy there
under a step’s id or another stray name is one of these, and turbobase ext remove <id> takes it out.
A preview runs no process from its branch
Section titled “A preview runs no process from its branch”turbobase apps deploy --preview ships an app’s screens and nothing else — a
process is installed only by turbobase ext deploy, and that is a step of your
live deploy, not of a preview. So on a preview build a process the branch
adds answers 404 { error: 'process_not_found' }, and a process the branch
changes runs the version already installed on the instance, not the
branch’s. Have the screen say that a process action cannot run from a preview
rather than show the 404, and plan for the first real run of a changed process
to be the one after it is deployed.
Extension files may now import a relative sibling inside their own
directory — import { requireMemo } from './shared-rule.js' — so two hooks (or
a hook and a process) share one rule instead of carrying two copies. ext test, ext deploy and ext lint fold the sibling in; an npm package, a Node
builtin, or a path that escapes the extension’s directory is still refused.
Next steps
Section titled “Next steps”- Migrating between systems — the recipe for moving data one connected system mirrored into another, and where that shape stops.
- Writing hooks — validate one record on its write.
- Writing conduits — bridge a record between sources.
- Sandboxes and testing — how extensions execute, and the offline checks that apply.
- Deploy, plan, promote — ship it.