Migrating to 2.2
2.2.0 extends the 2.1 correctness foundation with explicit options and a
functional operator. It is the published reference for migration to 2.3. Node
20+, strict TypeScript, TC39 decorators, ESM/CJS, subpaths, tree-shaking and zero
runtime dependencies remain supported.
Functional Semaphore and decorator
Section titled “Functional Semaphore and decorator”import { semaphore } from 'annotations-design-patterns/operators';import { pipe } from 'annotations-design-patterns/pipe';
const limited = pipe<object, readonly [tenantId: string], string>( async (tenantId) => tenantId, semaphore({ permits: 4, key: ({ args }) => args[0], maxQueue: 100 }),);
await limited.call({}, 'tenant-a');semaphore always returns a Promise. Admitted work starts synchronously and its permit is released when inner execution settles through resolution, rejection, or throw. Permits are positive safe integers; configured maxQueue is a non-negative safe integer. The default queue has no configured bound.
Without a key, one applied pipeline shares a pool across receivers. A key
partitions pools; its resolver receives the readonly invocation-key context used
by overlap and keyedLock. An explicit run() key overrides the resolver.
Admission is FIFO without barging. Pre-aborted signals prevent execution; aborted
waiters withdraw. Aborting an active holder rejects its caller, while
non-cooperative work retains the permit until settlement. A full queue rejects
with SemaphoreQueueFullError, using the existing POLICY_ERROR code and an
optional key identifying its partition.
@Semaphore(n) retains per-instance pools, Promise failures and eager execution;
it now adapts that same operator and PermitQueue. Existing
semaphore.queued, semaphore.acquired and semaphore.released kinds are reused
without extending PolicyEvent.
Opt-in caches per receiver
Section titled “Opt-in caches per receiver”import { cachedFor, memoize } from 'annotations-design-patterns/operators';
const memoized = memoize<object, readonly [id: string], string>({ perReceiver: true,}).apply((id) => id);
const cached = cachedFor<object, readonly [id: string], string>(60_000, { perReceiver: true, maxEntries: 1_000,}).apply((id) => id);With perReceiver true, distinct objects or functions have separate caches; the same receiver and arguments share an entry. Identity is stored in a WeakMap. Undefined, null and all primitive receivers, including symbols, produce an explicit TypeError. A defined perReceiver option must be boolean.
Omitting the option preserves functional pipeline-wide caches across receivers. Decorators remain isolated by instance.
memoizeWithHandle and cachedForWithHandle omit perReceiver from object-literal
options and reject enabling it at runtime. A structurally compatible options
variable may compile, but cannot enable a mode without an invalidation contract.
Handles still provide only invalidate(key: string): void and clear(): void;
there is no size property or weak-partition enumeration.
New perReceiver/maxEntries fields are captured at factory creation. Mutating the original object later cannot change partitions or capacity. Existing custom key functions retain capture at operator apply.
cachedFor capacity
Section titled “cachedFor capacity”maxEntries is a positive safe integer; explicit Infinity, zero and fractions are invalid. Omission preserves unlimited capacity. The bound applies per receiver partition or per handle shared across applications of the same operator.
Eviction follows insertion order: hits do not refresh an entry’s position. Pending promises count; eviction does not cancel their existing consumers. A stale rejection cannot delete a newer entry for the same key.
Only bounded caches prune expired entries lazily on misses. TTL cleanup is
silent; capacity eviction emits existing cache.invalidated with the evicted
key. The bound is restored before notifying sinks, including reentrant sinks.
No permanent timers, global sweeper or LRU are introduced.
TTL still starts at invocation, including pending promises. Infinity means no
expiration; functional cachedFor(0) still disables reuse.
JSON keys and the escape hatch
Section titled “JSON keys and the escape hatch”The default remains JSON.stringify(args); existing cache identities are not
changed and no universal serializer is introduced.
| Input | Default limitation |
|---|---|
| undefined, null, NaN, infinities, Symbols or functions as arguments | Can become [null] and collide |
-0 and 0 |
Same representation |
| undefined/Symbol/function properties and Symbol keys | Omitted; can collide with an empty object |
| Objects | Property insertion order can distinguish keys; this is not reference identity |
| Arrays | Order is preserved; holes, undefined and null can collide |
| Date | Uses ISO and can collide with an equivalent string; invalid dates become null |
| Map and Set | Typically serialize like {} without own serializable properties |
| BigInt or cyclic objects | Throw TypeError before the method executes |
| Custom toJSON or getters | Execute and can change a key or throw |
| Receiver | Excluded from the key; use perReceiver to separate identities |
Use key: (...args) => string for domain keys, including BigInt, cycles and
identity-sensitive inputs. The existing @MemoizeByRef decorator offers distinct
reference semantics; it does not change the functional default.
CircuitBreaker isFailure
Section titled “CircuitBreaker isFailure”The synchronous isFailure(error, info) predicate is available through the
operator, @CircuitBreaker and definePolicy. Readonly info contains receiver, a
readonly argument copy, key, operationId and metadata, without signal, sink or
execution controls. This is shallow protection: the receiver and nested objects
remain application-owned references.
True counts a failure. False ignores it without incrementing or resetting failures and without treating it as success. The original error still rejects. In HALF_OPEN, ignored errors release the current probe, preserve OPEN history and do not close the circuit. A throwing predicate rejects with its decision error without counting the original error, as shouldRetry does. Caller cancellation and stale generations are handled before invoking the predicate.
Retry caps and jitter
Section titled “Retry caps and jitter”maxDelayMs accepts finite values from 0 through 2_147_483_647, including
fractions. The technical timer cap remains mandatory even without this option.
Order: base delay → exponential backoff → minimum of technical cap and configured maxDelayMs → jitter.
| jitter | Wait derived from capped delay cap |
|---|---|
| none, default | cap; does not call Math.random |
| full | random × cap |
| equal | cap / 2 + random × cap / 2 |
A zero result uses the cancellable microtask retry path. No public random source
is added. @Retry and definePolicy share these options; describe includes new
maxDelayMs/jitter fields only when explicitly configured.
Types, diagnostics and preserved limits
Section titled “Types, diagnostics and preserved limits”Observer event maps can now be named interfaces while preserving On/Emits event-payload relationships and the default unknown-payload Subject escape hatch. Existing closed-union When/TransitionTo checks remain unchanged.
Instance-scoped decorators provide explicit diagnostics for unbound calls and primitive receivers, including symbols. Valid objects/functions and each API’s existing synchronous or Promise error channel remain supported.
Shared cancellation remains isolated per consumer: all consumers abandoning
work does not automatically abort it. Functional rateLimit, new async
StateMachine transition policies, distributed stores/locks, vendor adapters and
global testing registries remain outside runtime. Repository
POST-2.1-RFCS.md records designs and decisions, not published APIs.
Observability and measurement
Section titled “Observability and measurement”The observability guide uses actual exports/events and shows external callback recipes. No tracing or metrics SDK enters the package.
npm run bench measures ten paths with warmup and multiple samples: direct call,
operator, decorator, pipe, policy, memoize hit/miss, retry success, breaker CLOSED
and uncontended semaphore. Compare two runs manually:
npm run bench -- --output bench-baseline.jsonnpm run bench -- --compare bench-baseline.json --max-ratio 3 --output bench-current.jsonComparison requires identical Node version, platform, architecture and sampling
parameters. The 3× threshold targets large regressions rather than replacing
production measurements. Percentiles describe sample means, not individual-call
latency. npm run bundle:check maintains reproducible size budgets; changes
require reviewed measurements rather than automatic rebasing.

