Migrating to 2.1
2.1.0-rc.0 corrects existing capabilities: cancellation, concurrency, async
behavior and validation. It is prepared first as a release candidate and has not
been published yet. Node 20+, standard TC39 decorators, zero runtime dependencies
and the existing entrypoints remain supported. Review these observable changes
before upgrading.
Cancellation belongs to each caller
Section titled “Cancellation belongs to each caller”If the signal supplied to run(receiver, args, operation, { signal }) aborts,
that caller’s promise rejects with the same signal.reason, including values
that are not Error instances. An already-aborted signal prevents its work from
starting.
Caller cancellation:
- Does not invoke fallback.
- Does not increment or reset CircuitBreaker failures, or count as success.
- Is not retained as a rejected cache result.
- Does not cancel other consumers of shared execution.
An unrelated AbortError remains an ordinary error. Business errors and internal
TimeoutErrors retain their normal treatment: for example, fallback can recover
an internal timeout and retry can attempt again according to composition order.
Aborting stops waiting for that result; it cannot physically stop code that does not cooperate. Underlying work may continue and its effects may occur after the caller’s rejection.
Shared execution and caching
Section titled “Shared execution and caching”memoize, cachedFor and overlap({ mode: 'share', ... }) separate shared
execution from consumers’ signals. If A starts and B joins, cancelling A rejects
only A: B can receive the result. If both cancel, the work may continue; 2.1 does
not introduce reference-counted cancellation.
A’s cancellation is not cached. A valid result of the shared execution can still populate the cache and be reused later.
Existing contracts remain:
- Memoize/CachedFor decorators isolate caches by instance.
- Functional operators share their cache across receivers of one applied pipeline; default keys remain argument-based.
- TTL starts at invocation, including pending promises, rather than resolution.
- Rejected cached work is evicted to allow another attempt.
- Functional
cachedFor(0)disables reuse; Infinity means no expiration.
Timeout, context and reusable policies
Section titled “Timeout, context and reusable policies”pipe(fn, timeout(...), retry(...)) propagates the timeout’s internal execution
signal to contextual retry even when invoked without run(). Internal contexts
do not consume public operation IDs.
A policy with global timeout and retry no longer requires a static
timeout.abortSignal. It can be reused with a fresh signal in each run().
The static option remains supported; reusing an already-aborted signal still
leaves that signal aborted.
Outer contextual operators, identity operators and nested pipelines preserve context. An ordinary custom operator that wraps its function in an opaque function remains a context boundary for its inner operators: 2.1 introduces no ambient context to traverse arbitrary async wrappers. Outer context remains available.
Queues, Semaphore and CircuitBreaker
Section titled “Queues, Semaphore and CircuitBreaker”An aborted overlap(queue) waiter withdraws, frees maxQueue capacity, rejects
with its reason, and never executes the method. Abort is also checked between
admission and execution. The queue remains FIFO without allowing a new caller
to overtake one already admitted.
The active holder keeps its permit until its inner execution settles. Cancelling
the caller’s wait does not prematurely release a permit while non-cooperative
work remains active. @Semaphore(n) transfers permits directly and always
respects its concurrency limit. keyedLock retains its existing contracts and
public event names remain unchanged.
Each HALF_OPEN probe has a lease of resetMs from its start. If it hangs, a later
call may start another probe when that interval expires. No scheduler is added
and old work is not forcibly terminated. A generation identifies the current
probe: an obsolete probe’s result or cancellation cannot close, reopen, or release
the new probe’s position. The old probe’s caller keeps its own eventual result or
error. Cancelling the current probe releases its position without deciding
success or failure.
Numeric validation by domain
Section titled “Numeric validation by domain”Invalid values fail before work starts. Error classes and InvalidPolicyError paths remain intact; code relying on silently accepted invalid values must correct its configuration.
| Domain | APIs | Accepted values |
|---|---|---|
| Timer allowing zero | timeout(), Retry delay, hedge delay, RemoteAsyncStore timeout |
Finite, from 0 through 2_147_483_647, inclusive |
| Positive timer | @Timeout, definePolicy timeout, @Debounce |
Finite, greater than 0 and at most 2_147_483_647 |
| Positive count | Retry attempts, CircuitBreaker failures, Semaphore permits, RateLimit maxCalls | Safe integer at least 1 |
| Explicit queue limit | overlap and keyedLock maxQueue | Safe integer at least 0; omission preserves the unbounded default |
| Backoff | Retry backoffFactor | Finite, at least 1 |
| Elapsed-time window | Throttle interval, RateLimit window, CircuitBreaker reset | Finite and greater than 0, without a timer upper bound |
| TTL allowing zero | cachedFor(), cachedForWithHandle(), AsyncMemoryStore TTL |
Finite, at least 0, or Infinity |
| Positive TTL | @CachedFor |
Finite, greater than 0, or Infinity |
Valid fractional delays and windows remain supported; counts do not accept fractions. NaN and -Infinity are never valid. Infinity is accepted only for TTL. Retry internally clamps each computed delay, including an overflowing backoff, to the portable timer maximum. AsyncMemoryStore additionally accepts TTL Infinity for a non-expiring entry; zero retains immediate expiration.
Revertible, Observer and builder
Section titled “Revertible, Observer and builder”@Revertible records synchronous success immediately and async success on
resolution, including thenables. Throws and rejections are not recorded. Two
concurrent operations are recorded in completion order, preserving the
original arguments. undoLast() executes synchronous undo before removing the
entry; if undo throws, the entry remains available. Async undo is not added.
Subject.emit() snapshots listeners at the beginning of each emission and calls
them in subscription order. Adding, removing or re-adding listeners during an
emission does not alter that snapshot. A reentrant emission takes a fresh
snapshot; a listener added during the outer emission can receive the reentrant
event, but not the outer event already in progress.
The builder uses a lexical rule: suffixes starting with two ASCII uppercase
letters retain their spelling; other suffixes lowercase their first character.
withName writes name, withURL writes URL, and withHTTPPort writes
HTTPPort. uRL and URL are ambiguous because both generate withURL; URL
wins. Likewise, Name and name share withName, which chooses name.
The constructor is neither inspected nor executed until build(), and readonly
properties and methods included in the Builder type remain unchanged.
Stricter types and their limits
Section titled “Stricter types and their limits”With a closed event map, @On checks the event name and received payload;
@Emits checks the event name and the method’s resolved value. A Subject without
a type argument retains generic string event names and unknown payloads: its
listener must accept and narrow that value before using it.
With StateMachine<'draft' | 'published'>, both @When('publised') and
@TransitionTo('publised') are compile errors. Use StateMachine<string> when
states must be open and dynamic.
These are observable declaration changes: previously accepted names or payloads may stop compiling. Correct the contract or deliberately use the corresponding generic model; identical runtime behavior does not imply type compatibility.
StateMachine still does not serialize async operations. Two methods can check the same state before either finishes and then commit transitions in completion order. Closed types do not fix that race; coordinate these operations in your application when mutual exclusion is required.
2.1 does not change cache growth or replace Date.now with a monotonic clock. It also adds no isFailure, maxDelayMs, jitter, cancellation refcounts or new distributed services.

