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.