Skip to content

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).

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.

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.

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.

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.

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 — confirmed for a record that is gone, failed for one that was refused, skipped for a step that was never attempted: its when declined, 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.
  • pgRowId and externalId — 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.

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.

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.

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.item is the element and ctx.index its 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 of ctx.steps, and how it addresses one with update: { step: … }.
  • forEach.input names 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.

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.

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.

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.

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.

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.

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”.

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.

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.

Terminal window
turbobase processes list # what is deployed, and how it fires
turbobase processes runs acme:migrate-customers # that process's whole history
turbobase processes run 1111… # one run and its step ledger
turbobase processes invoke acme:migrate-customers --input '{"batch":"2026-10"}' # start one, queued
turbobase processes invoke acme:raise-po-and-bill --input-file po.json --sync # start one and wait
turbobase processes cancel 1111… # stop it
turbobase processes resume 1111… # carry on a run that stopped on a wait
turbobase processes rollback 1111… # undo what a finished run created
turbobase logs --process-run 1111… --follow # watch one run until it finishes

processes 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.

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 } }),
},
],
});
  • outstanding returns one PostgREST query — the same shape ctx.query takes — read as the invoking user and row-level-security-scoped for free.
  • key names 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 from input: input describes 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).

  • outstanding(input) receives the bare invoke body — the same object as ctx.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.
  • outstanding on 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 records skipped and hands ctx.steps the record that was created first, with its pgRowId and externalId, 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 sent on one visit and recorded on a later one.
    • A step that declined runs its when and build again. That covers a when that returned false and a build that returned null. 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 skipped and do not bill again. A new run asks the model again.
  • 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 confirmed or skipped, 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.steps and ctx.row. ctx.steps holds 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 and ctx.query reads.
  • A step that fails on one row ends the run under onFailure, the same as in a chain. The rows after it are counted in outstanding.
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.

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 },
}),
},
],
});
  • mark changes the row this pass is on. It does not insert, because it declares update, and it takes the row out of outstanding because it moves status off pending.
  • create does not declare awaitSource, so mark records the new row’s pgRowId — 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_id is in the select. Leave it out and ctx.row.pg_row_id is undefined, and mark fails with STEP_ADDRESS_UNRESOLVED.
  • rollback: 'keep' on the stamp means a failure while stamping does not undo the record create already made upstream.
  • Invoke it once — POST /v1/processes/acme:migrate-customers-tracked with { "batch": "2026-10" } — and it carries on by itself.

You do not need a browser tab open, and you do not need to re-invoke after each slice.

  1. 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 ends committed with a reason saying to invoke it again. A cancel, an administrator’s off switch or a per-run ceiling stops it without continuing.

  2. A schedule invokes it with nobody signed in. Add an entry to turbobase/schedules.json and deploy it with turbobase 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 process is 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.
    • name may not begin with system. — that prefix is the platform’s own recurring jobs, and a bundle schedule using it is refused.
    • A schedule written by ext deploy runs as the instance’s administrator identity, and the run’s ledger records the schedule, not a person.
    • The schedule’s own run says success as soon as the process run is queued. The process run’s outcome is on the run itself — read it with turbobase processes runs <id>.
    • A scheduled invoke is skipped, not failed, while the process or the instance is switched off.
    • input is checked against the process’s input schema 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.

  • 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_save hook rejects fails that step with the hook’s own code and field, and no later step is attempted.
  • awaitSource waits 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 on synced (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 awaitSource step’s create and the link from the new record back to its row fails (write_settle_conflict), the step ends failed with refusal_code: 'SOURCE_APPLIED_UNLINKED', the source’s id on the step’s externalId, 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. Offer POST /v1/data/:recordType/settle with the step’s pgRowId to 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 in outstanding, and names the waiting step in its reason. Branch on that rather than on failed if 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 eachRow contract.
  • Rollback is newest-first, through the delete boundary. With onFailure: 'rollback', every record the run created is deleted in reverse order — before_delete hooks see it, and a record that had already reached the source is deleted there too — and the run ends rolled_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_hour and guardrails.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 with guardrails.max_rollback_deletes_per_min, guardrails.max_rollback_deletes_per_hour and guardrails.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_back means removed here and on its way to the source. A run reads rolled_back once every record it created has been removed from the instance and its delete accepted for sending. While deletes are still being sent, the run’s reason says how many. If the connected system then refuses one, that record comes back, its step returns to what it was, and the run reads failed with 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.

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’s build must return a non-empty lines array: { parent: { … }, lines: [{ … }] }. A document transformed from another one — parent.created_from_id set — 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 on record_write_state.external_id for that row, with reason_code = 'write_settle_conflict' — read it there rather than parsing it out of the message. You cannot set the row’s external_id by writing the row (it is a protected column); link it with the settle verb instead — POST /v1/data/<recordType>/settle with { "pgRowId": "…" }, or turbobase 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 answers 409 NOT_SETTLE_CONFLICT for a row that is not in this state.
Terminal window
turbobase ext new process --record purchaseOrder --then vendorBill --on-failure rollback
turbobase ext lint processes/purchase-order-then-vendor-bill.ts
turbobase ext deploy processes/purchase-order-then-vendor-bill.ts

ext 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.

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.

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.