Skip to content

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.

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.

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.

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.

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.

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 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.

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.