ElementsFlow

Workflows

Journaled multi-step Flows — fx.step replay, LIFO undo, durable sleep, and crash resume.

A durable Flow is an ordinary Flow with durable: true. Named fx.step calls journal their results so a crash resumes without re-running completed work — checkout, onboarding, anything that must not double-charge.

For developers writing multi-step work on okengine — set durable: true, wrap side effects in fx.step, keep time on fx.clock.

The one rule

Wrap every side effect in a uniquely named fx.step. Replay returns the journaled value and never re-runs the body. Register {undo} on steps that must reverse; sleep only with fx.clock.sleep(label, duration) on a durable Flow.

Kill mid-run — same steps, opposite resume

durable: true · fx.step
create-intent
shared beat · create-intent → kill → resume → confirm
  • durable: true
    1. create-intentrunning
    2. process killed·
    3. confirm·

    Journal resume

    Completed steps replay from the journal — create-intent never re-runs.

    create-intent ×1 · no double charge
  • durable: false
    1. create-intentrunning
    2. process killed·
    3. confirm·

    Restart from scratch

    No journal — the run is lost. A retry re-enters create-intent.

    create-intent ×2 · double-charge risk

Smallest Example

Define a durable Flow

src/flows/orders/checkout.ts
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db, charges, orders } from "@/schema";

export const checkout = on(
  http.post({
    in: z.object({ userId: z.string(), sku: z.string() }),
    out: z.object({ orderId: z.string() }),
  }),
  flow({
    durable: true,
    do: async ({ userId, sku }, fx) => {
      const charge = await fx.step("charge", async () => {
        const id = fx.id();
        await fx.store(db).insert(charges).values({ id, userId, amount: 50 });
        return { id };
      });

      const orderId = await fx.step("create-order", async () => {
        const id = fx.id();
        await fx.store(db).insert(orders).values({
          id,
          userId,
          sku,
          chargeId: charge.id,
        });
        return id;
      });

      return { orderId };
    },
  }),
);

Call the endpoint

curl -X POST http://localhost:6530/orders/checkout \
  -H "content-type: application/json" \
  -d '{"userId":"usr_1","sku":"sku_42"}'

Response:

{
  "data": { "orderId": "ord_1" },
  "error": null
}

Kill the process after charge persists and before create-order finishes. Boot again — charge replays from the journal; the card is not charged twice.

Not a separate species

There is no workflow engine API. durable: true is a Flow option — HTTP, Signal, Clock, CDC, and call-only Flows all journal the same way. See Consumers.

Progressive Patterns

Explore durable Flows from a two-step journal to undo, sleep, and flow-level compensation:

Two named steps. fx.id() lives inside the step so resume reuses the journaled id:

src/flows/billing/charge.ts
import { on, flow, http } from "okengine";
import { z } from "zod";
import { db, payments } from "@/schema";

export const charge = on(
  http.post({
    in: z.object({ userId: z.string(), amount: z.number() }),
    out: z.object({ paymentId: z.string() }),
  }),
  flow({
    durable: true,
    do: async ({ userId, amount }, fx) => {
      const paymentId = await fx.step("create-intent", async () => {
        const id = fx.id();
        await fx.store(db).insert(payments).values({ id, userId, amount });
        return id;
      });
      await fx.step("confirm", async () => {
        await fx.call(capturePayment, { paymentId });
      });
      return { paymentId };
    },
  }),
);

Options Reference

Option / callTypeDefaultMeaning
durablebooleanfalseJournal fx.step, fx.clock.sleep, and gated fx calls
compensate(ctx, fx) => unknownomittedAfter LIFO undos, before the run commits failed
retryFxRetryOptionsomittedWhole-do retry on the same journal session
fx.step(name, fn, opts?)stepNamed checkpoint; { undo } is optional
fx.clock.sleep(label, duration)parkDurable pause; two arguments (label then duration)
fx.retry(fn, opts?)inner retrysee RetryPut inside a step so a completed charge never re-runs

compensate context: { input, error, completedSteps }completedSteps are forward names only (undo:… entries are excluded).

Consequence: durable: true disables automatic read-cache for that Flow.

Steps

Detailed section

If you only need a named checkpoint, jump to Replay below. Step names must be unique per run. The prefix undo: is reserved for compensation — a forward step with that prefix throws journal: step name "…" uses reserved prefix "undo:".

Every fx.step persists { name, value } before the next line runs. On resume the engine matches by name, returns the stored value, and skips the function.

Completed steps are skipped. Generate ids and call providers inside the step:

const intent = await fx.step("create-intent", async () => {
  const id = fx.id();
  await fx.call(createPaymentIntent, { id, amount: input.total });
  return { id };
});

fx.id() outside a step mints a new id on every resume. Gated fx calls (store, emit, send, ask, call, vault) are also journaled in call order — named steps are the stable checkpoint when the sequence might branch.

Compensation

Detailed section

If you only need per-step refunds, jump to the table below. Compensation runs on throw and fx.fail — after retries are exhausted, never on sleep park.

When a durable run fails terminally, status becomes compensating, then failed.

StepStateAction when fulfill throws
1. charge with { undo }Succeededundo(journaledValue) — refund
2. fulfillFailed (not persisted)No undo for this step
3. later workNot startedNever executed

Order: reverse { undo } frames, then flow.compensate, then commit failed. If an undo or compensate throws, the journal error is compensate:{code}.

Durable Sleep

Detailed section

If you only need a pause, jump to the example below. Signature is fx.clock.sleep(label, duration) — a single duration string is the label, not the wait. Durations: "200ms" · "30s" · "2m" · "1h" · "7d".

Sleep writes a wake time, sets status sleeping, and releases the run lease so a parked flow does not hold a 30s lock for days. Any instance may claim the row when wakeAt is due.

src/flows/onboarding/welcome.ts
import { on, flow } from "okengine";
import { userSignedUp } from "@/signals";
import { welcomeEmail } from "@/channels/welcome";

export const sendWelcome = on(
  userSignedUp,
  flow("onboarding.welcome", {
    durable: true,
    do: async ({ email }, fx) => {
      await fx.step("provision", async () => {
        await fx.call(createWorkspace, { email });
      });
      await fx.clock.sleep("morning-window", "8h");
      await fx.step("notify", async () => {
        await fx.send(welcomeEmail, { to: email });
      });
    },
  }),
);

HTTP + sleep: the request returns 204 with an empty body. Resume is a scheduler job, not a second response to that client.

Retry

Two layers — do not mix them up.

LayerWhereJournalUndo
fx.retry inside fx.stepOne provider callStep commits once, after successNo
flow({ retry })Whole doSame session; rewind + replayAfter the last attempt fails
retry optionDefaultMeaning
retries0Extra attempts after the first
delay50 (ms) or "100ms"Initial backoff
backoff2Multiplier after each retry
jittertrueFull jitter (thundering-herd)
whenthrown errorsSkips abort and sleep park (JournalSuspend)

Consequence: put provider retries inside the step; use Flow retry for transient failures after a step (network to your own fx.call).

Journal

Detailed section

If you only need defaults, jump to the table. drivers.journal is postgres in dev/prod and memory in test. Pin file for a single host without Postgres.

The journal is a driver, not an element. Runs are rows: running · sleeping · compensating · completed · failed.

DriverDefault envBest for
postgresdev, prodShared durable runs across replicas (DATABASE_URL)
memorytestProcess-local; lost on exit
filepin explicitlyOne machine — .oke/journal.json

Unknown ids throw oke boot: unknown journal driver "…" (expected memory · file · postgres). Postgres without a URL throws oke boot: journal driver "postgres" needs DATABASE_URL.

Troubleshooting

Learn more

  • Flowdurable, retry, compensate on the Flow options table
  • Consumers — Signal / Clock / CDC as the same species
  • HTTP — request envelope; 204 from undefined
  • Clock · Durable Sleep — pause physics
  • fxfx.step, fx.retry, fx.clock.sleep, fx.using
  • Configurationdrivers.journal

Next

On this page