> ## Documentation Index
> Fetch the complete documentation index at: https://docs.duraton.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrating from Inngest

> Map Inngest functions, steps, and flow control onto Duraton - and the three differences that silently break a naive port.

Duraton's model lines up closely with Inngest's: an event triggers a durable function, work happens in
replay-safe steps, and flow control lives in the definition. Most of a port is mechanical renames -
`createFunction` becomes `workflow`, `step.run` stays `step.run`. This guide gives you the
renames first, then the **three differences that compile fine and fail at runtime**, which are the ones
worth reading before you start.

## Core primitives

| Inngest                                             | Duraton                                          | Notes                                                                                                                                                              |
| --------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `inngest.createFunction({ id, triggers }, handler)` | `workflow({ name, triggers?, handler })`         | Duraton's `name` is both the identifier **and** the implicit event trigger, so a workflow named `order.created` needs no explicit trigger. Drop the separate `id`. |
| handler args `{ event, step }`                      | one `ctx` object (`ctx.event`, `ctx.step`)       | Your payload is `ctx.event.data`.                                                                                                                                  |
| `step.run(id, fn)`                                  | `step.run(id, fn)`                               | Identical. Duraton also has `step.run(id, input, fn)` to record the step's input.                                                                                  |
| `step.sleep(id, "10s")`                             | `step.sleep(id, "10s")`                          | Identical.                                                                                                                                                         |
| `step.sleepUntil(id, date)`                         | `step.sleepUntil(id, date)`                      | Identical.                                                                                                                                                         |
| `step.waitForEvent(id, { event, timeout, match })`  | `step.waitForEvent(id, { event, timeout, if? })` | Duraton has **no `match` shorthand**: express the correlation as an `if` CEL filter, e.g. `if: "event.data.orderId == '..'"`.                                      |
| `step.invoke(id, { function })`                     | `step.runWorkflow(id, { name })`                 | Invoke a child run by workflow `name` and await its result.                                                                                                        |
| `step.sendEvent(id, event)`                         | `step.emit(id, { name, data })`                  | Renamed - and the shape differs. See [the silent bites](#what-breaks-silently).                                                                                    |
| `inngest.send(event)` (outside a function)          | `duraton.events.send({ name, app, data })`       | Client-side ingest.                                                                                                                                                |

## Before / after

An Inngest function:

```ts theme={null}
export const orderCreated = inngest.createFunction(
  { id: "order-created", retries: 3 },
  { event: "order.created" },
  async ({ event, step }) => {
    const { orderId } = event.data;

    const charge = await step.run("charge", () => chargeCard(orderId));
    await step.sleep("settle", "10s");
    const ship = await step.run("ship", () => bookShipment(orderId));

    return { orderId, charge, ship };
  },
);
```

The Duraton equivalent - the handler body is unchanged; the wrapper differs, and the runner dials out
with `connect()` instead of exposing an HTTP route:

```ts theme={null}
import { connect, workflow } from "@duraton/sdk";

interface OrderData {
  orderId: string;
}

const orderCreated = workflow<OrderData>({
  name: "order.created",           // id + implicit event trigger in one
  retry: { maxAttempts: 3 },       // Inngest `retries: 3`
  handler: async (ctx) => {
    const { orderId } = ctx.event.data;

    const charge = await ctx.step.run("charge", () => chargeCard(orderId));
    await ctx.step.sleep("settle", "10s");
    const ship = await ctx.step.run("ship", () => bookShipment(orderId));

    return { orderId, charge, ship };
  },
});

connect({ app: "shop", workflows: [orderCreated] });
```

## Flow control

Every knob has a counterpart, with two renames and one shape change to watch:

| Inngest config                           | Duraton config                            | Notes                                                                                     |
| ---------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------- |
| `concurrency: { limit, key }`            | `concurrency: { limit, key }`             | Same shape; `key` semantics differ (see below).                                           |
| `idempotency: "event.data.cartId"`       | `idempotency: { key: "cartId", period? }` | Object, not a bare string; default `period` is 24h.                                       |
| `rateLimit: { limit, period, key }`      | `rateLimit: { limit, per, key }`          | `period` -> `per`. Sheds the trigger when over the limit.                                 |
| `throttle: { limit, period, key }`       | `throttle: { limit, per, key }`           | `period` -> `per`. Spreads new run starts rather than shedding.                           |
| `debounce: { period, key }`              | `debounce: { period, key }`               | Same shape; only the last event's data survives the window.                               |
| `batchEvents: { maxSize, timeout, key }` | `batch: { maxSize, timeout, key }`        | Renamed `batchEvents` -> `batch`.                                                         |
| `priority: { run: "<expr>" }`            | `priority: { shift }`                     | Duraton shifts scheduling by a fixed duration; there is no per-event priority expression. |

<Note>
  Duraton also has flow control Inngest does not: `singleton`, plus the AI-spend controls `cap` and
  `tokenThrottle`. See [Flow control](/core/flow-control).
</Note>

## What breaks silently

These three compile cleanly against the Duraton types but behave differently from Inngest at runtime -
the ones that actually bite during a port.

### 1. Key fields are a field **path**, not an expression

In Inngest, `idempotency` (and the `key` on `concurrency` / `rateLimit`) is a **CEL expression**
evaluated against the event - you can compute and concatenate:

```ts theme={null}
// Inngest: an arbitrary expression is valid
idempotency: `event.data.promptHash + "-" + event.data.userId`
```

In Duraton, every key field is a **dotted field path into the event data** - a lookup, never an
expression. Duraton resolves it by walking the path (`user.id` reads `data.user.id`); a missing field or
a non-scalar value yields the workflow-global scope, and there is **no arithmetic, concatenation, or
CEL**:

```ts theme={null}
// Duraton: a dot-path only
idempotency: { key: "userId", period: "24h" }   // reads event.data.userId
```

If you need a composite key, compute it **upstream** and emit it as a single field on the event, then
point the path at that field. This applies to `concurrency.key`, `rateLimit.key`, `throttle.key`,
`debounce.key`, and `batch.key` too - all of them are paths, so any Inngest expression key must be
flattened into one event field first.

### 2. Middleware has a different shape

Inngest middleware is a nested factory registered on the client, with per-run, per-step, and
input/output transform hooks:

```ts theme={null}
// Inngest
const mw = new InngestMiddleware({
  name: "my-mw",
  init() {
    return {
      onFunctionRun() {
        return {
          beforeExecution() {},
          afterExecution() {},
          transformOutput() {},
        };
      },
    };
  },
});
const inngest = new Inngest({ id: "app", middleware: [mw] });
```

Duraton middleware is a **flat object with two hooks**, passed straight to `connect()` -
there is no client object to register on and no step-level interception:

```ts theme={null}
// Duraton
import { connect, type Middleware } from "@duraton/sdk";

const mw: Middleware = {
  onInvoke(info) {
    // runs before your handler on every pass; return bindings to attach to logs
    return { runId: info.runId, attempt: info.attempt };
  },
  onResult(info, outcome) {
    // runs after the handler settles; return a replacement Outcome to transform it
    return outcome;
  },
};

connect({ app: "shop", workflows: [orderCreated], middleware: mw });
```

`onInvoke` covers Inngest's `onFunctionRun` + `beforeExecution`; `onResult` covers `transformOutput` for
the terminal result or error. There is **no per-step hook** and **no `transformInput`** - anything that
wrapped individual steps has no Duraton equivalent and must move into the step body. The built-in
`sanitizeErrors()` and `bindLogContext()` helpers ship as ready-made middleware.

### 3. There is no `step.sendEvent` - it's `step.emit`

Inngest sends events from inside a function with `step.sendEvent`, which also accepts an array of
events:

```ts theme={null}
await step.sendEvent("notify", { name: "order.shipped", data: { orderId } });
```

Duraton's method is `step.emit`, and it sends **one** event per call - `name` is the event name, with
an optional `app` to target a single app (omit it to broadcast project-wide) and an optional `dedupeId`:

```ts theme={null}
await ctx.step.emit("notify", { name: "order.shipped", data: { orderId } });
```

Outside a run, the analog of `inngest.send` is `duraton.events.send({ name, app, data })`. See
[Events](/core/workflows).
