Durable Flows
Durable flows let you build multi-step workflows that survive server restarts, crashes, and deployments. Every step's input and output is persisted to a SQLite database using Write-Ahead Logging (WAL), so bext can resume interrupted flows exactly where they left off. This is a Pro feature.
Use durable flows for operations that span multiple services or take longer than a single request: order processing, payment workflows, onboarding sequences, data pipelines, and scheduled batch jobs.
How It Works
A flow is a named sequence of steps. When you start a flow run, bext:
1. Creates a run record in SQLite with status pending
2. Executes steps in order, persisting each step's result before moving to the next
3. On crash/restart, queries for runs with status running and resumes from the last completed step
4. On failure, applies the retry policy; if exhausted, marks the run as failed and executes compensation steps
The SQLite WAL mode ensures that writes are durable even if the process is killed mid-transaction.
Flow Definition
Define flows using the JavaScript/TypeScript SDK:
export const orderFlow = defineFlow("process-order", {
maxAttempts: 3,
timeout: "30m",
version: "1.0",
steps: [
step("validate", async (input, ctx) => {
const order = await validateOrder(input.orderId);
if (!order.valid) throw new Error("Invalid order");
return { order };
}),
step("charge-payment", {
timeout: "60s",
retries: 2,
compensate: async (result, ctx) => {
// Rollback: refund if a later step fails
await refundPayment(result.chargeId);
},
run: async (input, ctx) => {
const charge = await chargeCard(input.order.paymentMethod, input.order.total);
return { chargeId: charge.id };
}
}),
step("fulfill", async (input, ctx) => {
await createShipment(input.order.items, input.order.address);
return { shipped: true };
}),
step("notify", async (input, ctx) => {
await sendConfirmationEmail(input.order.email, input.order.id);
return { notified: true };
})
]
});
Starting a Flow Run
Trigger a flow run from an API handler, a scheduled task, or another flow:
// Start a run and get the run ID
const runId = await startFlow("process-order", {
tenantId: "tenant_abc",
input: { orderId: "ord_12345" },
priority: 1, // Higher priority runs first
idempotencyKey: "ord_12345", // Prevents duplicate runs
});
The idempotencyKey ensures that retried API calls do not create duplicate flow runs. If a run with the same key already exists, the existing run ID is returned.
Retry Policies
Each step and the overall flow can define retry behavior:
step("external-api-call", {
retries: 5, // Max retry attempts for this step
timeout: "30s", // Per-attempt timeout
backoff: "exponential", // "fixed", "exponential", or "linear"
backoffBase: "1s", // Starting delay (doubles each retry with exponential)
run: async (input) => {
return await callExternalService(input);
}
})
The flow-level maxAttempts controls how many times the entire flow restarts from the beginning after an unrecoverable step failure.
Compensation (Rollback)
When a step fails after earlier steps have completed, bext runs compensation handlers in reverse order. This is the saga pattern:
Step 1: validate -> OK
Step 2: charge-payment -> OK (chargeId: ch_123)
Step 3: fulfill -> FAILED
Compensation runs:
Step 2 compensate: refundPayment(ch_123)
Compensation handlers receive the step's original result, so they have the data needed to undo the operation. If a compensation handler itself fails, the failure is logged and bext continues compensating earlier steps.
Timeout Handling
Flows support timeouts at two levels:
- Flow timeout -- the total time a run is allowed to take across all steps and retries (e.g. "30m")
- Step timeout -- the time a single step execution is allowed (e.g. "60s")
Duration strings support ms, s, m, h, and d suffixes. When a timeout fires, the step is marked as failed and the retry/compensation logic activates.
Run Status
Each run progresses through these statuses:
| Status | Description |
|---|---|
pending |
Created, waiting to execute |
running |
Currently executing steps |
completed |
All steps finished successfully |
failed |
Exhausted retries or unrecoverable error |
cancelled |
Manually cancelled via API |
Steps have a similar lifecycle: pending, running, completed, failed, skipped.
Real-Time Streaming
Subscribe to flow run events via SSE for live dashboards and monitoring:
GET /_bext/flows/{runId}/stream
Accept: text/event-stream
Events are pushed for each status change:
event: step_started
data: {"run_id":"01abc...","step_name":"charge-payment","attempt":1}
event: step_completed
data: {"run_id":"01abc...","step_name":"charge-payment","result":{"chargeId":"ch_123"}}
event: run_completed
data: {"run_id":"01abc...","status":"completed","duration_ms":4521}
Querying Runs
List and filter flow runs via the admin API:
GET /_bext/admin/flows?flow_id=process-order&status=failed&limit=50
{
"runs": [
{
"id": "018f3a2b...",
"flow_id": "process-order",
"tenant_id": "tenant_abc",
"status": "failed",
"error": "Payment declined",
"created_at": 1712150400000,
"attempts": 3
}
]
}
Timers and Delays
Insert a delay between steps or schedule a step to run at a future time:
step("wait-for-confirmation", {
delay: "24h", // Wait 24 hours before executing
run: async (input, ctx) => {
const confirmed = await checkConfirmation(input.order.id);
if (!confirmed) throw new Error("Not confirmed in time");
return { confirmed: true };
}
})
Timers are persisted to SQLite with a wake_at timestamp. On restart, bext queries for fired timers and resumes the corresponding flows.
Flow Visualization
The bext companion app provides a visual timeline of flow runs, showing each step's status, duration, retries, and errors. This is invaluable for debugging production workflows.
Access it at /_bext/admin/flows in the admin dashboard, or connect the standalone companion app to your bext instance.