eslint-plugin-awaitly
v1.1.1
Published
ESLint rules for awaitly workflow patterns - catch common mistakes like immediate execution and unstable cache keys
Downloads
938
Maintainers
Readme
eslint-plugin-awaitly
ESLint rules for awaitly workflow patterns. Catch common mistakes automatically.
Installation
npm install eslint-plugin-awaitly --save-devUsage (ESLint v9 Flat Config)
// eslint.config.js
import awaitly from 'eslint-plugin-awaitly';
export default [
...awaitly.configs.recommended,
// your other configs
];Strict Preset (recommended for AI-first workflows)
// eslint.config.js
import awaitly from 'eslint-plugin-awaitly';
export default [
...awaitly.configs['recommended-strict'],
];recommended-strict is the same rule set as recommended, but upgrades awaitly/result-require-handling from warn to error.
Rules
awaitly/step-require-id (error)
Requires a string literal as the first argument to step(). Use step('id', fn, options?) or step('id', result, options?).
// BAD - missing step ID
step(() => fetchUser('1'));
step(fetchUser('1'), { key: 'user:1' });
// GOOD - string ID as first argument
step('fetchUser', () => fetchUser('1'));
step('fetchUser', () => deps.fetchUser('1'), { key: 'user:1' });Note: All step types take a string as the first argument (ID or name): step.retry(id, operation, options), step.withTimeout(id, operation, options), step.try(id, operation, opts), step.sleep(id, duration, opts?), step.fromResult(id, operation, opts), step.parallel(name, operations | callback), step.race(name, callback), step.allSettled(name, callback), step.run(id, result | getter, options?), step.andThen(id, value, fn, options?), step.match(id, result, handlers, options?), step.all(name, shape, options?), step.map(id, items, mapper, options?), step.withFallback(id, primaryGetter, opts?), step.withResource(id, { acquire, use, release }, opts?), step.workflow(id, getter, opts?).
awaitly/step-no-immediate-execution (error)
Prevents step('id', fn()) patterns where the function executes immediately instead of being wrapped in a thunk. The executor is the second argument (after the ID).
// BAD - executes immediately, defeats caching/retries
step('fetchUser', fetchUser('1'));
step('fetchUser', deps.fetchUser('1'), { key: 'user:1' });
// GOOD - thunk lets step control execution
step('fetchUser', () => fetchUser('1'));
step('fetchUser', () => deps.fetchUser('1'), { key: 'user:1' });Autofix: Wraps the executor in an arrow function (and inserts a suggested ID if missing).
awaitly/step-require-thunk-for-key (error)
When using step() with a key option, the executor (second argument, after the ID) must be a thunk. Without a thunk, the function executes immediately before the cache can be checked.
Important clarification: The cache IS populated and step_complete events ARE emitted with the direct pattern. However, the operation runs regardless of cache state, defeating the purpose of caching.
// BAD - fetchUser() runs immediately, even if cache has value
step('fetchUser', fetchUser('1'), { key: 'user:1' });
// GOOD - fetchUser() only runs on cache miss
step('fetchUser', () => fetchUser('1'), { key: 'user:1' });Autofix: Wraps the executor in an arrow function (and inserts a suggested ID if missing).
awaitly/step-stable-cache-keys (error)
Prevents non-deterministic values like Date.now(), Math.random(), or uuid() in cache keys.
// BAD - new key every time, cache never hits
step('fetch', () => fetch(id), { key: `user:${Date.now()}` });
step('fetch', () => fetch(id), { key: `user:${Math.random()}` });
// GOOD - stable key enables caching
step('fetch', () => fetch(id), { key: `user:${userId}` });awaitly/workflow-options-position (error)
Prevents passing workflow options in the wrong argument position. Use workflow.run(fn, config) or workflow.run(name, fn, config), where the callback comes before config.
// BAD - options as first argument to .run() (wrong order; first arg must be callback)
await workflow.run({ cache: new Map() }, async ({ step }) => { ... });
// BAD - legacy callable form; options are ignored
await workflow({ cache: new Map() }, async ({ step }) => { ... });
// BAD - named run with options before callback (wrong order)
await workflow.run('my-run', { onEvent: handler }, async ({ step }) => { ... });
// GOOD - per-run options as second argument
await workflow.run(async ({ step, deps }) => { ... }, { deps: mockDeps, onEvent });
// GOOD - named run: callback second, config third
await workflow.run('my-run', async ({ step }) => { ... }, { onEvent: handler });Detected option keys: cache, deps, onEvent, resumeState, snapshot, serialization, snapshotSerialization, onUnknownSteps, onDefinitionChange, onError, onBeforeStart, onAfterStep, shouldRun, createContext, signal, strict, catchUnexpected, description, markdown, streamStore.
Why These Rules?
The #1 mistake with awaitly is forgetting the thunk:
// This looks correct but is wrong:
const user = await step('fetchUser', fetchUser('1'), { key: 'user:1' });The function fetchUser('1') executes immediately when JavaScript evaluates this line. The step() function receives the Promise (already started), not a function it can call.
Common misconception: The cache IS populated and step_complete events ARE emitted with the direct pattern. However, the operation has already run before step() could check the cache. This defeats:
- Caching efficiency: step can't skip execution on cache hit - the function already ran
- Retries: step can't re-call on failure - it only has the Promise
- Resume: step can't skip already-completed work - it already started
The correct pattern:
const user = await step('fetchUser', () => fetchUser('1'), { key: 'user:1' });Now step() receives a function it can call after checking the cache, and can skip execution entirely on cache hit.
Configuration
To enable only specific rules:
// eslint.config.js
import awaitly from 'eslint-plugin-awaitly';
export default [
{
plugins: {
awaitly,
},
rules: {
'awaitly/step-no-immediate-execution': 'error',
// disable others if needed
},
},
];License
MIT
