Imported from jagreehal/awaitly (
.claude/skills/awaitly-patterns/SKILL.md). Install upstream withnpx skills add jagreehal/awaitly --skill awaitly-patterns. Copyright stays with the author.
Awaitly Core Patterns
This document defines the supported patterns for using awaitly. Avoid inventing alternatives. awaitly stays in async/await — no generators, no method chains, no DSL.
Workflows are sequential unless explicitly composed using step concurrency helpers (step.all, step.map, step.race). allAsync() is the underlying Result combinator and should typically be executed inside a step.
Execution is only via workflow.run(). There is no callable form (workflow(fn) or workflow(args, fn)). Use closures for workflow input (e.g. userId in scope).
Callback shape: Workflow callbacks receive a single destructured object:
run()(fromawaitly):async ({ step }) => { ... }— deps via closures.createWorkflow('name', deps)→ execute withworkflow.run(fn):async ({ step, deps }) => { ... }. OptionalctxwhencreateContextis set:async ({ step, deps, ctx }) => { ... }.
Call form (mechanical):
- Anonymous run:
await workflow.run(async ({ step, deps }) => { ... }) - With per-run config (deps override, onEvent, etc.):
await workflow.run(async ({ step, deps }) => { ... }, { deps: overrideDeps, onEvent }) - Named run (for logging/tracing/resume):
await workflow.run('my-run', async ({ step, deps }) => { ... }) - Named run with config:
await workflow.run('my-run', async ({ step, deps }) => { ... }, { deps: mockDeps }) - Persistence (resume state):
await workflow.runWithState(fn)orworkflow.runWithState(fn, config)returns{ result, resumeState }for persisting partial state.
Agent Contract (MUST follow)
Use this as a checklist when generating or editing awaitly code. Satisfy every item; no interpretation.
Execution
- MUST execute workflows via
workflow.run(...)orworkflow.runWithState(...). - MUST NOT use callable form:
workflow(fn)orworkflow(args, fn).
Async discipline
- MUST wrap all async work in
step()or a step helper. - MUST NOT use bare
await deps.fn()inside workflow callbacks. Replace withawait step('id', () => deps.fn()). - MUST NOT wrap
step()calls intry/catch. Usestep.try()for throw-to-typed conversion.
Step identity
- The first argument to every step MUST be a static string literal (e.g.
step('getUser', ...)). - MUST NOT use a computed, concatenated, templated, or variable-derived value (e.g.
step(`user-${i}`, ...)orconst id = 'getUser'; step(id, ...)). Use a literal ID + optional{ key }for per-item identity.
Error handling at boundaries
- MUST check
isUnexpectedError(result.error)first when handling!result.ok. - MUST access the original thrown value via
result.error.cause(it's a property on theUnexpectedErrorinstance). - MUST narrow before reading a tag:
typeof result.error === 'string' ? result.error : result.error.type.result.error.type ?? result.errordoes not typecheck, because.typedoes not exist on the string members of the union.
Concurrency inside workflows
- MUST NOT use
Promise.all,Promise.race, orPromise.allSettledinside workflows. Replace withstep.all,step.map, orstep.race(consult types).
Telemetry
- awaitly opens OpenTelemetry spans for every
run,step, retry attempt, andstep.all/step.racescope. Register a provider at startup and the spans appear. - MUST NOT wrap the workflow callback or a step function in
trace()(autotel),startActiveSpan(), or any other span helper. awaitly already opened that span, so a wrapper reports the same call twice. - MUST put your own spans inside the step body when you want detail below the step.
- Turn spans off with
{ telemetry: false }on one run,setTelemetryEnabled(false)for the process, orAWAITLY_TELEMETRY=0in the environment.
Workflow callback invariants
Inside a workflow callback:
- MUST return raw values (not
Result). - MUST NOT call
ok()orerr()directly. - MUST NOT manually propagate
Resultobjects (e.g.return userResult). - MUST NOT call
return step(...)directly from inside conditionals without awaiting it. step()always returns the unwrapped Ok value.- On Err, the callback is exited automatically; do not return the Err.
Callback shape by entry point
Three entry points, three shapes. Picking the wrong one gives you an undefined
step or a dep you never wired up.
| Call | Callback receives | Step call looks like |
|---|---|---|
run(cb) |
{ step } |
step('getUser', () => getUser(id)) |
run(deps, cb) |
the deps, bound as steps | s.getUser(id) |
createWorkflow(name, deps).run(cb) |
{ step, deps } |
step('getUser', () => deps.getUser(id)) |
durable.run(deps, cb, opts) |
{ step, deps } |
step('getUser', () => deps.getUser(id)) |
- MUST NOT destructure
{ step }fromrun(deps, cb). That callback receives bound steps, sostepisundefined. - A bound step is still a step:
await s.getUser(id)emitsstep_successand is cached and retried likestep('getUser', ...). - MUST NOT assume
run(deps, cb)anddurable.run(deps, cb, opts)share a callback shape. They do not.
Durability rules
Under durable.run, a step is restored on resume only when it has a cache key.
step('id', fn)andstep.retry('id', fn, opts)key by their id by default.- A step inside
step.forEachis checkpointed per iteration. The identity comes fromstepIdPatternwhen you give one, and from the iteration index otherwise. Supply the pattern so the runtime key matches the id the analyzer draws, which keeps a trace readable against the diagram. - MUST pass
maxIterationstostep.forEachwhen the diagram has to be deterministic.awaitly-analyze --assert-diagrammablefails without it. - A collection longer than
maxIterationsraisesIterationLimitError. PassonMaxIterations: 'stop'when a bounded prefix is what you want, and catch it withisIterationLimitErrorat the boundary. - MUST declare
errors: []on a step that cannot fail, or the same CI gate reports it as undeclared. - A step that fails by throwing is retried on resume. A step that returns a
typed
errstays decided. Change withresumeFailedSteps.
API surface constraint
- MUST NOT invent new step helpers.
- MUST NOT assume undocumented overloads.
- MUST NOT assume
step()is globally available outside workflow callbacks. - If a helper is not listed here, consult package types before using it.
Rules
R1: step() requires an explicit string ID and a thunk
step() requires a string ID as the first argument and a thunk as the second in both run() and createWorkflow() workflows:
Signature: step('id', () => fn(args), opts?)
// canonical form — operation starts when step runs
await step('getUser', () => deps.getUser(id));
// run(): deps via closures
await step('getUser', () => getUser(id));
The thunk form is required so that retries, caching, conditional execution, and resume can re-evaluate or skip the operation. Passing an already-started promise/Result defeats those mechanisms.
The thunk is non-negotiable when:
- Retries (
step.retry,step.trywithretry) — needs to re-execute. - Caching with keys — checks cache before executing.
- Expensive operations — defer until needed.
Step APIs: Every step type takes a string as the first argument (ID or name). There is no name in options—the first argument is the step name/id. Use optional key in options for per-iteration identity (e.g. in loops). See the Step Helpers table below and package types for full signatures.
Label vs instance: The first argument is the step label (category, e.g. 'fetchUser'). The optional key is the instance identity (which iteration or entity). In loops, use one literal ID + key: step('fetchUser', () => fetchUser(id), { key: \user:${id}` }). Rules of thumb for key: stable (same input → same key); scoped to the step (e.g. fetchUser:${id}, user:${id}`); keep short for logs/snapshots.
Step ID naming cheatsheet:
| Pattern | Step ID | Notes |
|---|---|---|
| Fetch a resource | 'getUser', 'fetchOrder' |
Verb + noun, camelCase |
| Create/write | 'createOrder', 'sendEmail' |
Action verb + noun |
| Validation | 'validateInput', 'checkInventory' |
Verb + what's checked |
| Parallel group | 'fetchAll', 'validateOrder' |
Describes the group |
| Retry target | 'chargeCard' |
Same name as the dep being retried |
| Sleep/delay | 'rateLimitDelay', 'cooldown' |
Describes the wait reason |
| In a loop | 'processItem' + { key: \item:${id}` }` |
Literal ID + dynamic key |
R2: On Err, step() short-circuits the workflow
When a step resolves to Err, step() short-circuits the workflow and workflow.run() resolves to that Err. Inside workflows, step() returns the unwrapped Ok value; on Err the workflow callback is not continued. Do not return Result objects from the callback—return raw values.
MUST NOT call deps directly and then manually branch on .ok—it bypasses step tracking and breaks retries/caching. MUST use step() (or a step helper) for any async dep call. step.fromResult(id, fn, opts) is the right helper when you need to remap a typed Result error.
// step handles early exit automatically
const user = await step('getUser', () => deps.getUser(id));
const order = await step('createOrder', () => deps.createOrder(user));
// MUST NOT - calling dep directly and branching bypasses step tracking
const userResult = await deps.getUser(id); // not through step
if (!userResult.ok) return userResult;
const order = await step('createOrder', () => deps.createOrder(userResult.value));
// Replace with: const user = await step('getUser', () => deps.getUser(id)); then use user.
R3: Handle UnexpectedError at boundaries, then narrow the error before reading its tag
Errors can be strings ('NOT_FOUND'), objects ({ type: 'NOT_FOUND', id }), or an UnexpectedError instance for uncaught exceptions. Always check for UnexpectedError first using the type guard, then normalize the rest:
import { isUnexpectedError } from 'awaitly';
if (!result.ok) {
if (isUnexpectedError(result.error)) {
// result.error.cause has the original thrown Error
console.error('Bug:', result.error.cause);
return { status: 500 };
}
// Typed errors: narrow, then switch. Covers string and object unions,
// including STEP_TIMEOUT.
const code =
typeof result.error === 'string' ? result.error : result.error.type;
switch (code) {
case 'NOT_FOUND': return { status: 404 };
case 'ORDER_FAILED': return { status: 400 };
case 'STEP_TIMEOUT': return { status: 504 };
}
}
R4: UnexpectedError is a TaggedError class in the error union
run() and createWorkflow always include UnexpectedError in the error union. It's a TaggedError class (with _tag: "UnexpectedError") representing any thrown exception escaping a dep. The original thrown value is in result.error.cause.
Use isUnexpectedError(error) to narrow, or use matchError / matchErrorPartial for exhaustive pattern matching:
import { matchError } from 'awaitly';
matchError(result.error, {
NOT_FOUND: (e) => ({ status: 404 }),
ORDER_FAILED: (e) => ({ status: 400 }),
UnexpectedError: (e) => {
console.error('Bug:', e.cause);
return { status: 500 };
},
});
Handlers are keyed by tag: a string error is its own tag, a TaggedError class is keyed by its type. A union may mix the two — AsyncResult<Order, 'NOT_FOUND' | ValidationError> matches with NOT_FOUND and ValidationError keys, and the matched member arrives narrowed, so a class's props are reachable without a guard. MUST NOT use a switch for class errors: case ValidationError: compares an instance to the constructor and never matches.
R5: All async work inside workflows must go through step()
MUST use step() or a step helper for every async operation. MUST NOT use bare await on async deps inside workflow callbacks. Replace with await step('id', () => deps.fn()).
const user = await step('getUser', () => deps.getUser(id));
const data = await step.try('fetch', () => deps.fetchExternal(url), { error: 'FETCH_ERROR' });
// MUST NOT - bare await bypasses error handling and tracking
const user = await deps.getUser(id);
const response = await deps.fetchExternal(url);
// Replace with: await step('getUser', () => deps.getUser(id)); await step('fetch', () => deps.fetchExternal(url));
R6: Don't wrap step() in try/catch
Errors from step() propagate automatically to the workflow result. Wrapping steps in try/catch breaks that guarantee. If you need to convert thrown errors to typed errors, use step.try()—not try/catch.
// Errors propagate to workflow result
const result = await workflow.run(async ({ step, deps }) => {
const payment = await step('makePayment', () => deps.makePayment());
return payment;
});
// Handle errors at the boundary
if (!result.ok) {
if (isUnexpectedError(result.error)) {
console.error('Bug:', result.error.cause);
} else {
const code =
typeof result.error === 'string' ? result.error : result.error.type;
switch (code) {
case 'PAYMENT_FAILED':
await handleFailedPayment(result.error);
break;
}
}
}
// MUST NOT - try/catch defeats typed error propagation
try {
const result = await step('makePayment', () => deps.makePayment());
} catch (error) {
await step('handleFailed', () => deps.handleFailed(error));
}
// Replace with: step.try() for throw-to-typed conversion, or handle errors at boundary only.
If you need per-item error handling in a loop, use step.forEach() with error collection.
MUST NOT + Replacement (Agent Rules)
Execution
| MUST NOT | Replacement |
|---|---|
workflow(fn) or workflow(args, fn) (callable) |
workflow.run(async ({ step, deps }) => { ... }). Use closures for input. |
Pass options as first arg to .run() |
Pass per-run options as the second argument: workflow.run(fn, { deps, onEvent }). |
Inside Workflows
| MUST NOT | Replacement |
|---|---|
step(...) without string ID as first argument |
step('id', fn, opts) or step('id', result, opts). First argument MUST be a literal string. |
Template literal as step ID (e.g. step(`step-${i}`, ...)) |
Literal ID + key: step('fetchUser', () => fetchUser(i), { key: \user:${i}` })`. |
step(promise) when using retries/caching |
Thunk form: step('id', () => deps.fn(args)). |
Bare await deps.fn() (or any async without step) |
await step('id', () => deps.fn()). |
Manual if (!result.ok) after calling deps directly |
Call through step; step() short-circuits on Err. |
Promise.all, Promise.race, or Promise.allSettled inside workflows |
step.all, step.map, or step.race. See Concurrency section. |
throw in deps |
Return err() instead, or wrap with step.try(id, fn, { error: 'TYPED_ERROR' }). |
try/catch around step() |
Remove try/catch; errors propagate to workflow result. Use step.try() only for converting throws to typed errors. |
Synchronous computation and pure logic are allowed inside workflows. Only async operations require step().
Autofix Rules (deterministic rewrites)
When you see these patterns, apply the rewrite:
| See | Rewrite to |
|---|---|
workflow(async ...) or workflow(args, async ...) |
workflow.run(async ...). Use closures for args. |
Promise.all([...]) inside a workflow callback |
step.all('name', { a: () => opA(), b: () => opB() }) or array form step.all('name', () => allAsync([...])). |
try { await step(...) } catch (e) { ... } |
Remove try/catch; handle errors at boundary. If converting throws: step.try('id', fn, { error: 'ERR' }). |
const x = await deps.fn() (no step) |
const x = await step('id', () => deps.fn()). |
Options object as first argument to workflow.run(...) |
Move options to second argument: workflow.run(fn, options). |
return result from a boundary handler (e.g. HTTP) |
MUST NOT let Result objects escape. Convert to HTTP/status mapping using the boundary handling canonical snippet (check result.ok, then isUnexpectedError(result.error), then narrow with typeof result.error === 'string' ? result.error : result.error.type). |
Choosing Your Pattern
Canonical signatures:
run(callback, options?)—import { run } from 'awaitly'(standalone, no workflow object). Also supports the deps-first formrun(deps, callback)with automatic error inference.createWorkflow('name', deps, options?)returns a workflow object; execute only viaworkflow.run(fn),workflow.run(fn, config),workflow.run(name, fn), orworkflow.run(name, fn, config)—import { createWorkflow } from 'awaitly'.
| Aspect | run() |
createWorkflow('name', deps) |
|---|---|---|
| Import | awaitly |
awaitly |
| Execute | run(fn) or run(fn, options) |
workflow.run(fn) or workflow.run(fn, config) or workflow.run(name, fn) or workflow.run(name, fn, config) — no callable |
| Step syntax | step('id', () => deps.fn(args)) (explicit) |
step('id', () => deps.fn(args)) (explicit) |
| Deps | Closures | Injected at creation; override per run with workflow.run(fn, { deps: partialOverride }) |
| Error types | Recommended: run<T, ErrorOf<typeof dep>>(fn) (single dep), run<T, Errors<[typeof d1, typeof d2]>>(fn) (tuple deps), or run<T, ErrorsOf<typeof deps>>(fn) (deps object). Or manual E; or catchUnexpected for custom unexpected. UnexpectedError always included. |
Auto-inferred from deps (includes UnexpectedError) |
| Features | Basic step execution | Retries, timeout, state persistence, caching |
| Bundle | Smaller | Larger |
| Best for | Single-use, wrapping throwing APIs | Shared deps, DI, testing (deps override), typed errors (best DX) |
Use run() when:
- Single-use workflow (not reused across files)
- Dependencies available via closures
- Wrapping throwing APIs with
step.try() - Minimal bundle size matters
Use createWorkflow('name', deps) when:
- Shared deps across multiple workflows
- Need dependency injection for testing
- Deps already return
AsyncResult - Need retries, timeout, or state persistence
Use durable.run(deps, fn, { id, store }) when:
- Work must survive a crash and resume where it stopped
- A batch fans out and you cannot afford to repeat completed items
- Several workers compete for the same job and need a lease
import { durable } from 'awaitly/durable';
import { mongo } from 'awaitly-mongo';
const store = mongo({ url: process.env.MONGODB_URI!, lock: {} });
const result = await durable.run(deps, async ({ step, deps: d }) => {
const batch = await step('loadBatch', () => d.loadBatch(id));
// A batch longer than maxIterations raises IterationLimitError instead of
// submitting a prefix and reporting success.
await step.forEach('submit', batch.payments, {
stepIdPattern: 'submit-{i}',
maxIterations: 500,
run: async (p) => step.retry('submit', () => d.submit(p), { attempts: 3 }),
});
return step('complete', () => d.complete(batch.id), { errors: [] });
}, { id: `batch-${id}`, store, lockTtlMs: 60_000 });
Persist entity status for what other systems query. Leave step-level progress
on the execution rather than adding *-ING statuses to the entity.
Deps and throwing: Prefer deps that return Results and never throw. If you can't control a dep (e.g. third-party), wrap it with step.try() or convert at the boundary.
Do / Don't — Canonical Snippets (copy these)
Agents: use these as the single canonical style for each entrypoint.
run() canonical
import { run, type ErrorOf } from 'awaitly';
type RunErrors = ErrorOf<typeof fetchUser>;
const result = await run<Value, RunErrors>(async ({ step }) => {
const user = await step('fetchUser', () => fetchUser(id));
return user;
});
createWorkflow() canonical (execute only via .run())
import { createWorkflow } from 'awaitly';
const workflow = createWorkflow('myWorkflow', deps);
const result = await workflow.run(async ({ step, deps }) => {
const user = await step('getUser', () => deps.getUser(id));
return user;
});
Boundary handling canonical
import { isUnexpectedError } from 'awaitly';
if (!result.ok) {
if (isUnexpectedError(result.error)) {
console.error('Bug:', result.error.cause);
return { status: 500 };
}
const code =
typeof result.error === 'string' ? result.error : result.error.type;
switch (code) {
case 'NOT_FOUND': return { status: 404 };
case 'ORDER_FAILED': return { status: 400 };
case 'STEP_TIMEOUT': return { status: 504 };
}
}
Migration: 3 Steps
Step 1: Change functions to return Result
// BEFORE
async function getUser(id: string): Promise<User | null> {
const user = await db.find(id);
if (!user) throw new Error('Not found');
return user;
}
// AFTER
import { ok, err, type AsyncResult } from 'awaitly';
async function getUser(id: string): AsyncResult<User, 'NOT_FOUND'> {
const user = await db.find(id);
return user ? ok(user) : err('NOT_FOUND');
}
Step 2a: Use run() for simple cases
For single-use workflows where deps are available via closures.
Recommended pattern for run(): Derive the error type with ErrorOf<typeof dep> (single dep), Errors<[...]> (tuple deps), or ErrorsOf<typeof deps> (deps object), and pass it as the second type parameter to run<T, RunErrors>(). This gives typed result.error (your errors plus UnexpectedError) without manual unions.
Zero-annotation alternative — run(deps, fn): pass deps as the first argument and skip the type parameters. The error union is inferred and rendered as its concrete literal union on hover — e.g. 'NOT_FOUND' | 'FETCH_ERROR' | UnexpectedError, not an opaque ErrorsOf<{…}> alias:
import { run, ok, type AsyncResult } from 'awaitly';
const fetchUser = (id: string): AsyncResult<User, 'NOT_FOUND'> =>
Promise.resolve(ok({ id, name: 'Alice' }));
const fetchPosts = (userId: string): AsyncResult<Post[], 'FETCH_ERROR'> =>
Promise.resolve(ok([]));
const result = await run({ fetchUser, fetchPosts }, async (s) => {
const user = await s.fetchUser('1');
return { user, posts: await s.fetchPosts(user.id) };
});
// hover result.error → 'NOT_FOUND' | 'FETCH_ERROR' | UnexpectedError
import { run, ok, type AsyncResult, type ErrorOf } from 'awaitly';
type User = { id: string; name: string };
const fetchUser = async (): AsyncResult<User, 'NOT_FOUND'> =>
ok({ id: '1', name: 'Alice' });
type RunErrors = ErrorOf<typeof fetchUser>;
const result = await run<User, RunErrors>(async ({ step }) => {
const user = await step('fetchUser', () => fetchUser());
return user;
});
// result.error is: 'NOT_FOUND' | UnexpectedError
Multiple deps: Use Errors<[typeof dep1, typeof dep2, ...]> for tuple-style deps, or ErrorsOf<typeof deps> when deps are already in an object:
import { run, type ErrorOf, type Errors, type ErrorsOf } from 'awaitly';
// Single dep: ErrorOf<typeof fn>
type RunErrors = ErrorOf<typeof getUser>;
const result = await run<Order, RunErrors>(async ({ step }) => {
const user = await step('getUser', () => getUser(userId));
return user;
});
// result.error is: 'NOT_FOUND' | UnexpectedError
// Multiple deps: Errors<[...]> (union of all dep errors)
type AllErrors = Errors<[typeof getUser, typeof createOrder]>;
const result2 = await run<Order, AllErrors>(async ({ step }) => {
const user = await step('getUser', () => getUser(userId));
const order = await step('createOrder', () => createOrder(user));
return order;
});
// result2.error is: 'NOT_FOUND' | 'ORDER_FAILED' | UnexpectedError
// Object deps: ErrorsOf<typeof deps> (union of all dep errors in an object)
const deps = { getUser, createOrder };
type ObjectErrors = ErrorsOf<typeof deps>;
const result3 = await run<Order, ObjectErrors>(async ({ step }) => {
const user = await step('getUser', () => deps.getUser(userId));
const order = await step('createOrder', () => deps.createOrder(user));
return order;
});
// result3.error is: 'NOT_FOUND' | 'ORDER_FAILED' | UnexpectedError
With explicit E (manual type params):
const result = await run<Order, 'NOT_FOUND' | 'ORDER_FAILED'>(
async ({ step }) => {
const user = await step('getUser', () => getUser(userId));
const order = await step('createOrder', () => createOrder(user));
return order;
}
);
// result.error is: 'NOT_FOUND' | 'ORDER_FAILED' | UnexpectedError
With catchUnexpected (custom unexpected type — replaces UnexpectedError with your type):
type MyErrors = 'NOT_FOUND' | 'ORDER_FAILED' | 'UNEXPECTED';
const result = await run<Order, MyErrors>(
async ({ step }) => {
const user = await step('getUser', () => getUser(userId));
const order = await step('createOrder', () => createOrder(user));
return order;
},
{ catchUnexpected: () => 'UNEXPECTED' }
);
// result.error is: 'NOT_FOUND' | 'ORDER_FAILED' | 'UNEXPECTED' (custom unexpected)
Without type params (error is UnexpectedError only):
const result = await run(async ({ step }) => {
const user = await step('getUser', () => getUser(userId));
return user;
});
// result.error is: UnexpectedError (step error types not preserved at compile time)
Step 2b: Use createWorkflow('name', deps) for DI cases
For shared deps, testing, or advanced features (retries, timeout):
import { createWorkflow } from 'awaitly';
const deps = { getUser, createOrder, sendEmail };
const processOrder = createWorkflow('processOrder', deps);
// TypeScript infers all error types from deps
Step 3: Execute with workflow.run() and use step() inside (createWorkflow only)
const result = await processOrder.run(async ({ step, deps }) => {
const user = await step('getUser', () => deps.getUser(userId));
const order = await step('createOrder', () => deps.createOrder(user));
await step('sendEmail', () => deps.sendEmail(user.email, order));
return order;
});
Step Helpers
Invariant: Every step helper takes a string as the first argument (ID or name). There is no name in options. Use optional key in options for per-iteration identity (e.g. in loops). For full signatures and any helpers not listed here, consult package types.
The canonical six — these cover ~90% of usage:
| Need | Use (first arg = string ID) | Example |
|---|---|---|
| Result-returning fn | step(id, fn, opts?) |
step('getUser', () => deps.getUser(id)) |
| Throwing fn → typed error | step.try(id, fn, opts) |
step.try('parse', () => JSON.parse(s), { error: 'PARSE_ERROR' }) — also accepts retry, timeout, compensate |
| Parallel (named results) | step.all(name, shape, opts?) |
step.all('fetchAll', { user: () => deps.getUser(id), posts: () => deps.getPosts(id) }) |
| Parallel over array | step.map(id, items, mapper, opts?) |
step.map('fetchUsers', ids, (id) => deps.getUser(id)) |
| First-to-succeed | step.race(name, callback) |
step.race('fastest', () => anyAsync([primary(), fallback()])) |
| Sleep/delay | step.sleep(id, duration, opts?) |
step.sleep('rate-limit', '1s') |
Specialized — when you need them:
| Need | Use | Example |
|---|---|---|
| Result error remapping | step.fromResult(id, fn, opts) |
step.fromResult('callApi', () => callApi(), { onError: (e) => ... }) |
| Null/undefined → typed error | step.fromNullable(id, fn, onNull) |
step.fromNullable('lookup', () => map.get(id), () => 'NOT_FOUND') |
| Retries (as primary verb) | step.retry(id, fn, opts) |
step.retry('fetch', () => deps.fn(), { attempts: 3 }) |
| Timeout (as primary verb) | step.withTimeout(id, fn, opts) |
step.withTimeout('slowOp', () => deps.fn(), { ms: 5000 }) |
| Fallback on primary error | step.withFallback(id, primaryGetter, opts?) |
step.withFallback('getUser', () => deps.getUser(id), { fallback: () => deps.getUserFromCache(id) }) |
| Resource (acquire/use/release) | step.withResource(id, { acquire, use, release }, opts?) |
step.withResource('useDb', { acquire: () => connect(), use: (db) => query(db), release: (db) => db.close() }) |
| Child workflow as step | step.workflow(id, getter, opts?) |
step.workflow('child', () => childWorkflow.run(async ({ step }) => { ... })) |
| Saga compensation | step(id, fn, { compensate }) |
step('reserve', () => deps.reserve(items), { compensate: (r) => deps.release(r.id) }) |
All step helpers run through the full step engine: they emit step events, support retry/timeout options where relevant, and in createWorkflow use the cache and onAfterStep when you pass a key. For step.all and step.map, caching applies only when you pass an explicit key; without a key they do not cache by step id (matches core run() semantics).
step.try is the swiss-army edge wrapper. It accepts { error | onError, retry?, timeout?, compensate?, key?, ttl? } so you can wrap a throwing op with retry, timeout, error mapping, and rollback in one call.
step.try() handles both sync and async: It catches exceptions from sync code (like JSON.parse) and rejections from async code (like fetch).
step.try() has the same control-flow as step(): It returns the unwrapped value on success, or exits the workflow with the provided typed error on throw/rejection. Do not check .ok on its return value.
Timeout returns STEP_TIMEOUT: When step.withTimeout() times out, it returns { type: 'STEP_TIMEOUT', timeoutMs, stepName } directly (not wrapped in UnexpectedError). Handle it at the boundary like other typed errors (narrow with typeof result.error === 'string' ? result.error : result.error.type; see R3).
Loops with step.forEach()
Prefer step.forEach() when you want static analyzability and predictable per-item step IDs. For analyzability use an index-based stepIdPattern (e.g. 'item-{i}'); use { key } inside the loop only when you need cache identity tied to the input.
Agent rule: iterations are keyed for you
- A step inside the
run(oritem) callback is keyed by its iteration —processItem@item-0,processItem@item-1. MUST NOT add a hand-written{ key }just to keep iterations apart;stepIdPatternalready does it, and it keeps the diagram and the cache key in agreement. - Nested loops concatenate their iteration names, and the scope is per run, so concurrent runs never share keys.
- Under
durable.run, this means a resume skips items that already completed. Same forstep.try,step.fromResult,step.withFallback,step.withResource,step.retry, andstep.withTimeoutinside the loop.
Agent rule: double-step is intentional
step.forEach(..., { stepIdPattern, run })provides per-item structural step IDs for static analysis (e.g.item-0,item-1).- The inner
step('processItem', () => deps.processItem(item))insiderunis required: it provides retries, caching, timeout, and typed error propagation for the actual operation. - MUST NOT remove the inner
step(...)thinking it is redundant. Both layers are intentional: forEach for structure, inner step for the engine.
Basic Usage
// Process items with automatic indexing
await step.forEach('process-items', items, {
stepIdPattern: 'item-{i}',
run: async (item) => {
const processed = await step('processItem', () => deps.processItem(item));
return processed;
}
});
With Collected Results
// Collect all results
const results = await step.forEach('fetch-users', userIds, {
stepIdPattern: 'user-{i}',
collect: 'array', // or 'last' for only final result
run: async (userId) => {
return await step('getUser', () => deps.getUser(userId));
}
});
Why Not Manual Loops?
// step.forEach() is statically analyzable
await step.forEach('process', items, {
stepIdPattern: 'process-{i}',
run: (item) => step('processItem', () => process(item))
});
// Prefer step.forEach for analyzability; dynamic keys in manual loops reduce static analysis
for (const item of items) {
await step('process', () => process(item), { key: `process-${item.id}` });
}
Manual for loops with dynamic keys like ${item.id}:
- Cannot be enumerated by static analysis
- Reduce path generation accuracy
- Make test matrix generation incomplete
Concurrency (Agent Rules)
Preferred order (use first that fits):
step.all(name, { key: () => op(), ... })— object form, named keys.step.map(id, items, mapper)— parallel over array.step.all(name, () => allAsync([...]))— array form when you need a tuple of heterogeneous results.allAsync([...])— ONLY inside a step callback.
MUST NOT use Promise.all, Promise.race, or Promise.allSettled inside workflows. Replace with step.all, step.map, or step.race.
Object form (named keys):
const { user, posts } = await step.all('fetchAll', {
user: () => deps.getUser(id),
posts: () => deps.getPosts(id),
});
Array form (wraps allAsync):
import { allAsync } from 'awaitly';
const [user1, user2] = await step.all('Fetch users', () =>
allAsync([deps.getUser('1'), deps.getUser('2')])
);
Map over array (parallel, step-tracked):
const users = await step.map('fetchUsers', userIds, (id) => deps.getUser(id));
step.all and step.map only use the workflow cache when you pass an explicit key; without a key they do not cache by step id.
Common Patterns
These utilities work on Result values outside workflows (at boundaries, in deps, in tests).
Default values
import { unwrapOr, unwrapOrElse } from 'awaitly';
// Static default
const name = unwrapOr(result, 'Anonymous');
// Computed default (only called on Err)
const user = unwrapOrElse(result, () => createGuestUser());
Transform values
import { map, mapError } from 'awaitly';
// Transform Ok value
const upperName = map(result, user => user.name.toUpperCase());
// Transform Err value
const httpError = mapError(result, e => ({ code: 404, message: e }));
Chain operations
import { andThen } from 'awaitly';
// Chain Result-returning functions (flatMap)
const orderResult = andThen(userResult, user => createOrder(user));
Fallback on error
import { orElse } from 'awaitly';
// Try alternative on Err
const result = orElse(primaryResult, () => fallbackResult);
Convert nullable to Result
import { fromNullable } from 'awaitly';
// null/undefined → Err, value → Ok
const result = fromNullable(maybeUser, () => 'NOT_FOUND');
Wrap throwing code (outside workflows)
import { from, fromPromise } from 'awaitly';
// Sync throwing code → Result
const parsed = from(() => JSON.parse(data), () => 'PARSE_ERROR');
// Async throwing code → AsyncResult
const response = await fromPromise(fetch(url), () => 'FETCH_ERROR');
// With error context from the cause
const detailed = from(
() => JSON.parse(data),
(cause) => ({ type: 'PARSE_ERROR', message: String(cause) })
);
Type guards
import { isOk, isErr } from 'awaitly';
if (isOk(result)) {
console.log(result.value); // TypeScript knows it's Ok
}
if (isErr(result)) {
console.log(result.error); // TypeScript knows it's Err
}
Side effects without changing Result
import { tap, tapError } from 'awaitly';
// Log success without changing result
const logged = tap(result, user => console.log('Got user:', user.id));
// Log error without changing result
const loggedErr = tapError(result, e => console.error('Failed:', e));
Overriding workflow deps
import { withDeps } from 'awaitly';
// Derive a workflow with some deps replaced (e.g. mocks in tests)
const testWorkflow = withDeps(processOrder, { sendEmail: mockSendEmail });
const result = await testWorkflow.run(async ({ step, deps }) => {
// deps.sendEmail is the mock; other deps are creation-time
});
Error Types
| Need | Use |
|---|---|
| Simple states | String: 'NOT_FOUND' |
| Error with context | Object: { type: 'NOT_FOUND', userId: string } |
| 3+ variants | TaggedError with match() |
Start with strings. Migrate to objects when you need context.
Complete Template
Simple: run() with closures
Recommended: Use ErrorOf<typeof dep> (single dep), Errors<[typeof d1, typeof d2, ...]> (tuple deps), or ErrorsOf<typeof deps> (deps object) to derive the error type and pass it to run<T, RunErrors>(). UnexpectedError is always included automatically.
import { run, ok, err, isUnexpectedError, type AsyncResult, type ErrorsOf } from 'awaitly';
// deps return Results, never throw
async function getUser(id: string): AsyncResult<User, 'NOT_FOUND'> {
const user = await db.find(id);
return user ? ok(user) : err('NOT_FOUND');
}
async function createOrder(user: User): AsyncResult<Order, 'ORDER_FAILED'> {
// ...
}
// Recommended: derive errors from deps
const deps = { getUser, createOrder };
type RunErrors = ErrorsOf<typeof deps>;
// Execute workflow with typed errors
export async function handleRequest(userId: string) {
const result = await run<Order, RunErrors>(
async ({ step }) => {
const user = await step('getUser', () => getUser(userId));
const order = await step('createOrder', () => createOrder(user));
return order;
}
);
// result.error is: 'NOT_FOUND' | 'ORDER_FAILED' | UnexpectedError
if (result.ok) {
return { status: 200, body: result.value };
}
if (isUnexpectedError(result.error)) {
console.error('Bug:', result.error.cause);
return { status: 500 };
}
switch (result.error) {
case 'NOT_FOUND': return { status: 404 };
case 'ORDER_FAILED': return { status: 400 };
}
}
With catchUnexpected (custom unexpected type):
type MyErrors = 'NOT_FOUND' | 'ORDER_FAILED' | 'UNEXPECTED';
const result = await run<Order, MyErrors>(
async ({ step }) => {
const user = await step('getUser', () => getUser(userId));
const order = await step('createOrder', () => createOrder(user));
return order;
},
{ catchUnexpected: () => 'UNEXPECTED' }
);
// result.error is: 'NOT_FOUND' | 'ORDER_FAILED' | 'UNEXPECTED' (custom unexpected)
Without type params (only UnexpectedError in the type):
import { isUnexpectedError } from 'awaitly';
const result = await run(async ({ step }) => {
const user = await step('getUser', () => getUser(userId));
return user;
});
if (!result.ok) {
// result.error is UnexpectedError
if (isUnexpectedError(result.error)) {
console.error('Failed:', result.error.cause);
}
}
Full: createWorkflow('name', deps) with DI — execute only via .run()
import { ok, err, isUnexpectedError, type AsyncResult } from 'awaitly';
import { createWorkflow } from 'awaitly';
// 1. deps return Results, never throw (see "Deps and throwing" above)
const deps = {
getUser: async (id: string): AsyncResult<User, 'NOT_FOUND'> => {
const user = await db.find(id);
return user ? ok(user) : err('NOT_FOUND');
},
createOrder: async (user: User): AsyncResult<Order, 'ORDER_FAILED'> => {
// ...
},
};
// 2. Create workflow (no callable; execute via .run() or .runWithState())
const processOrder = createWorkflow('processOrder', deps);
// 3. Execute with workflow.run() — no branching, no try/catch
export async function handleRequest(userId: string) {
const result = await processOrder.run(async ({ step, deps }) => {
const user = await step('getUser', () => deps.getUser(userId));
const order = await step('createOrder', () => deps.createOrder(user));
return order;
});
// 4. Handle at boundary — check UnexpectedError first
if (result.ok) {
return { status: 200, body: result.value };
}
if (isUnexpectedError(result.error)) {
console.error('Bug:', result.error.cause);
return { status: 500 };
}
// These errors are string literals, so switch on them directly. Reach for
// narrow with `typeof` when the union mixes strings and objects (R3).
switch (result.error) {
case 'NOT_FOUND': return { status: 404 };
case 'ORDER_FAILED': return { status: 400 };
case 'STEP_TIMEOUT': return { status: 504 };
}
}
Testing
Use type-safe assertions from awaitly/testing. Execute workflows only with workflow.run() (no callable form).
Note: Test helpers like unwrapOk throw on failure. This is acceptable in tests. Workflow rules (no throws, use err()) apply to workflow and dep code, not test code.
Result assertions
import { unwrapOk, unwrapErr } from 'awaitly/testing';
// unwrapOk returns the value directly, throws if Err
const user = unwrapOk(await deps.fetchUser('123'));
expect(user.name).toBe('Alice');
// unwrapErr returns the error, throws if Ok
const error = unwrapErr(await deps.fetchUser('unknown'));
expect(error).toBe('NOT_FOUND');
Testing workflows (always use workflow.run())
Test workflows by creating the workflow with deps and calling workflow.run(async ({ step, deps }) => { ... }):
import { createWorkflow } from 'awaitly';
import { ok, err } from 'awaitly';
import { unwrapOk, unwrapErr } from 'awaitly/testing';
it('completes order flow', async () => {
const deps = {
getUser: async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND'),
createOrder: async (user: User): AsyncResult<Order, 'ORDER_FAILED'> =>
ok({ orderId: '123' }),
};
const workflow = createWorkflow('orderFlow', deps);
const result = await workflow.run(async ({ step, deps }) => {
const user = await step('getUser', () => deps.getUser('1'));
return await step('createOrder', () => deps.createOrder(user));
});
expect(unwrapOk(result).orderId).toBe('123');
});
it('returns NOT_FOUND for unknown user', async () => {
const deps = {
getUser: async (id: string): AsyncResult<User, 'NOT_FOUND'> => err('NOT_FOUND'),
createOrder: async (user: User): AsyncResult<Order, 'ORDER_FAILED'> => ok({ orderId: '123' }),
};
const workflow = createWorkflow('orderFlow', deps);
const result = await workflow.run(async ({ step, deps }) => {
const user = await step('getUser', () => deps.getUser('unknown'));
return await step('createOrder', () => deps.createOrder(user));
});
expect(unwrapErr(result)).toBe('NOT_FOUND');
});
Overriding deps at run time (testing)
workflow.run(fn, { deps }) overrides creation-time deps for that run only. Partial overrides merge with creation-time deps. Use this to inject mocks in tests without creating a new workflow.
it('run(fn, { deps }) overrides creation-time deps for that run only', async () => {
const getPosts = createWorkflow('getPosts', { fetchUser, fetchPosts });
// First run: uses creation-time deps
const result1 = await getPosts.run(async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser('1'));
return user.name;
});
expect(unwrapOk(result1)).toBe('Alice');
// Second run: override fetchUser with a mock for this run only
const mockFetchUser = vi.fn(async (id: string) =>
ok({ id, name: 'Mock User', email: 'mock@test.com' })
);
const result2 = await getPosts.run(
async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser('1'));
return user.name;
},
{ deps: { fetchUser: mockFetchUser } }
);
expect(unwrapOk(result2)).toBe('Mock User');
expect(mockFetchUser).toHaveBeenCalledWith('1');
// Third run: no override, still uses original deps
const result3 = await getPosts.run(async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser('1'));
return user.name;
});
expect(unwrapOk(result3)).toBe('Alice');
});
it('partial deps override merges with creation-time deps', async () => {
const getPosts = createWorkflow('getPosts', { fetchUser, fetchPosts });
const mockFetchUser = vi.fn(async (id: string) =>
ok({ id, name: 'Overridden', email: 'o@test.com' })
);
// Override only fetchUser; fetchPosts stays from creation-time
const result = await getPosts.run(
async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser('1'));
const posts = await step('fetchPosts', () => deps.fetchPosts(user.id));
return { userName: user.name, postsCount: posts.length };
},
{ deps: { fetchUser: mockFetchUser } }
);
expect(unwrapOk(result).userName).toBe('Overridden');
expect(unwrapOk(result).postsCount).toBe(1); // fetchPosts still original
});
Testing retries
it('retries on failure', async () => {
let attempts = 0;
const deps = {
fetchData: async (): AsyncResult<{ data: string }, 'NETWORK_ERROR'> => {
attempts++;
if (attempts < 3) return err('NETWORK_ERROR');
return ok({ data: 'success' });
},
};
const workflow = createWorkflow('retryTest', deps);
const result = await workflow.run(async ({ step, deps }) => {
return await step.retry('fetchData', () => deps.fetchData(), { attempts: 3 });
});
expect(unwrapOk(result).data).toBe('success');
expect(attempts).toBe(3);
});
Named runs in tests
Use workflow.run('test-run', fn) or workflow.run('test-run', fn, config) when you need a stable run id for events or assertions:
it('run(name, fn) uses name as workflowId in events', async () => {
const events = [];
const workflow = createWorkflow('myWorkflow', { fetchUser }, {
onEvent: (e) => events.push(e),
});
await workflow.run('custom-run-id', async ({ step, deps }) => {
return await step('getUser', () => deps.fetchUser('1'));
});
expect(events[0].workflowId).toBe('custom-run-id');
});
Documentation and static analysis
Documentation options
- Workflows: Set
descriptionandmarkdownincreateWorkflow(deps or second-argument options) for doc generation and static analysis. Not available onrun()/runSaga()(no options object). - Steps: Set
descriptionandmarkdownin step options, e.g.step('id', fn, { key, description, markdown }),step.sleep(id, duration, { description, markdown }). Saga callbacks expose the samestep(with optional{ compensate }). Optional metadata for observability and static analysis:intent,domain,owner,tags,stateChanges,emits,calls,errorMeta(see API reference).
Static analysis output
awaitly-analyze can output JSON via renderStaticJSON(ir). The shape includes:
root.workflowName,root.description,root.markdownroot.children(steps and control nodes; steps havestepId,name,key,description,markdown; when step options include metadata, alsointent,domain,owner,tags,stateChanges,emits,calls,errorMeta)root.dependencies(each:name,typeSignature?when type checker available,errorTypes)
Full structure is documented in awaitly-analyze README (“JSON output shape”) and in packages/awaitly-analyze/schema/static-workflow-ir.schema.json.
Options quick reference
| Context | Option keys (use when generating/editing workflow code) |
|---|---|
| Creation-time (createWorkflow / createSagaWorkflow) | description, markdown, strict, catchUnexpected, onEvent, createContext, cache, resumeState, signal, streamStore, graph (declared workflow graph — a WorkflowDiagramDSL from awaitly-analyze or a plain list of ids; any runtime step/decision id not in the graph fails the workflow immediately, guaranteeing the diagram matches reality; ids with {placeholder} segments like item-{i} match any value in that slot) |
Per-run (second argument to workflow.run(fn, config) or workflow.run(name, fn, config)) |
deps (partial override; merges with creation-time deps), onEvent, resumeState, cache, signal, createContext, onError, onBeforeStart, onAfterStep, shouldRun, streamStore, graph (overrides creation-time declared graph) — use for testing (deps override) or per-run hooks. |
| Step (step, step.try, step.all, step.map, step.race, step.sleep, step.retry, step.withTimeout, step.fromResult, step.fromNullable, step.withFallback, step.withResource, step.workflow) | Every step type: first arg is string (ID or name, required). No name in options. step(id, fn, opts), step.try(id, fn, { error | onError, retry?, timeout?, compensate?, key?, ttl? }), step.all(name, shape, opts?), step.map(id, items, mapper, opts?), step.race(name, callback), step.sleep(id, duration, opts?), step.retry(id, fn, opts), step.withTimeout(id, fn, opts), step.fromResult(id, fn, opts), step.fromNullable(id, fn, onNull), step.withFallback(id, primaryGetter, opts?), step.withResource(id, { acquire, use, release }, opts?), step.workflow(id, getter, opts?). Options (where applicable): key, description, markdown, ttl, retry, timeout, signal, compensate, and optional metadata intent, domain, owner, tags, stateChanges, emits, calls, errorMeta. For createWorkflow cache: step(id, () => dep(), { key }) for lazy cache checks; step.all/step.map only cache when key is provided. |
| Saga callback | ({ step, deps }) => … — step(id, fn, { compensate }) mirrors workflow step. Use step.try(id, fn, { error | onError, compensate? }) for throwing ops. Run via saga.run(fn). |
Imports
Use the task-shaped entry point for the capability being implemented. All imports are named imports — there is no Awaitly namespace object:
// Core front door — Result primitives, TaggedError, matching, and durations
import {
ok, err, // constructors
type AsyncResult, type Result, // types
type ErrorOf, type Errors, type ErrorsOf, // type helpers
UnexpectedError, isUnexpectedError, matchError, // error handling
unwrapOr, unwrapOrElse, // defaults
map, mapError, // transform
andThen, orElse, // chain
fromNullable, from, fromPromise, // wrap
isOk, isErr, // guards
tap, tapError, // side effects
allAsync, // parallel
} from 'awaitly';
// Lightweight step composition and dependency protection
import { run } from 'awaitly';
import { retry, timeout, createCircuitBreaker } from 'awaitly';
// Workflow composition
import { createWorkflow } from 'awaitly';
// Independent production capabilities
import { durable } from 'awaitly/durable';
import { type SnapshotStore, type DurableStore, serializeResumeState } from 'awaitly/durable';
import { createSagaWorkflow } from 'awaitly/durable';
import { createApprovalStep } from 'awaitly/durable';
import { createMemoryStreamStore } from 'awaitly/durable';
import { createWebhookHandler } from 'awaitly/durable';
import { createEngine } from 'awaitly/durable';
// Testing
import { unwrapOk, unwrapErr } from 'awaitly/testing';
Tree-shaking: For minimal bundle size, use the awaitly/result entry point:
// Result types only (minimal bundle)
import { ok, err, type AsyncResult } from 'awaitly/result';
Workflow Engine
createEngine() provides a polling background engine for durable workflow orchestration.
import { createEngine } from 'awaitly/durable';
const engine = createEngine({
store,
workflows: {
checkout: { deps, fn: async ({ step, deps }) => { /* ... */ } },
},
concurrency: 5,
onEvent: (e) => console.log(e.type),
});
// Enqueue a workflow run
await engine.enqueue('checkout', { id: 'order-123', input: { orderId: '123' } });
// Schedule recurring runs
const scheduleId = engine.schedule('checkout', { intervalMs: 86_400_000 });
// Start polling loop
await engine.start();
// Graceful shutdown
await engine.stop();
Import: 'awaitly/durable' — exports createEngine, Engine, EngineOptions, EngineEvent, EnqueueOptions, ScheduleOptions, WorkflowRegistration.
Input Validation (Standard Schema)
Validate workflow input against any Standard Schema-compatible schema (Zod, Valibot, ArkType).
import { validateInput, isInputValidationError } from 'awaitly';
import { z } from 'zod';
const schema = z.object({ orderId: z.string(), amount: z.number().positive() });
const result = await validateInput(schema, rawInput);
if (!result.ok) {
// result.error is InputValidationError { type: "INPUT_VALIDATION_ERROR", issues, message }
console.error(result.error.issues);
}
Import: 'awaitly' — exports validateInput, isInputValidationError, type InputValidationError.
Optional peer dep: @standard-schema/spec.
Test Runner
Run real workflows with real deps, capturing per-step results and events. Complements createWorkflowHarness (mocking) — no mocks needed.
import { testWorkflow } from 'awaitly/testing';
const result = await testWorkflow(
{ getUser, getPosts },
async ({ step, deps: { getUser, getPosts } }) => {
const user = await step('user', () => getUser('1'));
const posts = await step('posts', () => getPosts(user.id));
return { user, posts };
}
);
expect(result.result.ok).toBe(true);
expect(result.steps['user'].result.ok).toBe(true);
expect(result.stepOrder).toEqual(['user', 'posts']);
console.log(`Took ${result.durationMs}ms`);
Import: 'awaitly/testing' — exports testWorkflow, TestWorkflowResult, TestWorkflowOptions, TestStepResult.
Durable Error Types
LeaseExpiredError
Returned when a workflow's lock lease expires mid-execution.
import { isLeaseExpired } from 'awaitly/durable';
if (isLeaseExpired(error)) {
console.warn(`Lease lost for workflow ${error.workflowId}`);
}
IdempotencyConflictError
Returned when an idempotency key is reused with different input.
import { isIdempotencyConflict } from 'awaitly/durable';
if (isIdempotencyConflict(error)) {
console.warn(`Duplicate key ${error.idempotencyKey} for ${error.workflowId}`);
}
Resuming failed steps
resumeFailedSteps decides which failed steps a resume restores from the snapshot:
'crashed'(default) — a step that failed by throwing is retried (the worker died, the socket dropped); a step that failed with a typederrreached a decision and stays decided.'all'— restore every failed step, crashes included.
const result = await durable.run(deps, fn, {
id: 'my-workflow',
store,
resumeFailedSteps: 'crashed',
});
Store types
durable.run's store option takes DurableStore, the contract the shipped adapters implement (save also accepts a ResumeState, load may return one). Pass postgres(), mongo(), or libsql() directly — no cast. Implement SnapshotStore for a custom store.
Each adapter creates its schema on first use, safely across concurrent workers. Two operational options:
// Report background errors on idle connections in a pool the store owns.
postgres({ url, onPoolError: (error) => logger.warn({ error }) });
// Several processes can share one local file; the store waits for SQLite's writer lock.
libsql('file:./workflow.db');
A pool or client you supply keeps its own error handling, lifecycle, and busy policy.
WorkflowLock.renew()
Optional lease renewal. When a store implements renew, durable.run starts a heartbeat that extends the lease during execution. Failure aborts the workflow.
// Store implementations (libsql, mongo, postgres) now support renew()
const result = await durable.run(deps, fn, {
id: 'my-workflow',
store,
lockTtlMs: 30_000,
heartbeatIntervalMs: 10_000,
});