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
memoized steps, and flow control lives in the definition. Most of a port is mechanical renames -
createFunction becomes defineWorkflow, 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) | defineWorkflow({ 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. |
inngest.send(event) (outside a function) | duraton.events.send({ name, app, data }) | Client-side ingest. |
Before / after
An Inngest function:
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 and the mounting differ:
import { defineWorkflow, serve } from "@duraton/sdk";
interface OrderData {
orderId: string;
}
const orderCreated = defineWorkflow<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 };
},
});
export const { POST } = serve({ 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. |
Duraton also has flow control Inngest does not: singleton, plus the AI-spend controls cap, budget,
and tokenThrottle. See Flow control.
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:
// 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:
// Duraton: a dot-path only
idempotency: { key: "userId", period: "24h" } // reads event.data.userIdIf 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:
// 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 serve() / connect() -
there is no client object to register on and no step-level interception:
// Duraton
import { serve, 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;
},
};
serve({ 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:
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:
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.