Skip to content

Migrating to 2.3

2.3.0 retains Node 20+, TypeScript 5.0+, TC39 decorators, ESM/CJS subpaths and zero runtime dependencies. A stable package release does not automatically promote experimental APIs. Integration checks do not certify remote providers or distributed coordination.

Feature Status in 2.3 Import
rateLimit, memoize({ maxEntries }) Stable /operators
executionMethod Stable /run
TransitionTo(..., { concurrency: 'reject-stale' }) Stable, opt-in Root or /behavioral/state-machine
Breaker, keyed lock and semaphore handles Stable /operators
Event adapters Stable /observability
cancelWhenUnused Experimental, opt-in Cache/overlap options
observeOperation, asyncCachedFor, CAS Experimental /experimental

New options are disabled by default. Existing operators retain synchronous results and their previous promise behavior without a signal when shared cancellation is disabled. Public event, error-code and policy-layer unions are unchanged.

import { memoize, rateLimit } from 'annotations-design-patterns/operators';
import { pipe } from 'annotations-design-patterns/pipe';
const cachedLength = pipe(
(value: string) => value.length,
memoize<object, readonly [string], number>({ maxEntries: 100 }),
);
const admitted = pipe(() => 'ok', rateLimit({ limit: 20, windowMs: 1_000 }));

rateLimit counts admissions in a monotonic sliding window per applied method. Excess calls return a rejected promise with RateLimitExceededError and retryAfterMs; an admitted synchronous call retains its synchronous result. Failures consume quota. A pre-aborted signal does not. The existing @RateLimit decorator is unchanged.

maxEntries must be a positive safe integer. Entries are FIFO, including pending results; hits do not reorder them. With perReceiver, capacity applies per receiver. Eviction does not stop the underlying execution.

import { executionMethod, run } from 'annotations-design-patterns/run';
import { memoize } from 'annotations-design-patterns/operators';
import { pipe } from 'annotations-design-patterns/pipe';
const load = pipe(
executionMethod(async ({ signal }, id: string) => {
const response = await fetch(`/records/${encodeURIComponent(id)}`, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
}),
memoize<object, readonly [string], string>({
key: (id) => id,
maxEntries: 100,
cancelWhenUnused: true,
}),
);
const caller = new AbortController();
const result = run({}, ['record-1'], load, { signal: caller.signal });

executionMethod receives the effective signal derived by operators. Direct calls have no contextual signal. Use pipe/run: .apply() and opaque wrappers are explicit context boundaries; there is no ambient propagation.

cancelWhenUnused is available on memoize, cachedFor and overlap in share mode. Each signalled consumer can withdraw from its wait. An unsignalled consumer remains until settlement. The last withdrawal retires the visible reservation before aborting the shared root: reentrant calls can create a replacement, and late settlement cannot remove it. The backend stops only if it cooperates.

Enabling this option does not preserve previous promise identity as a contract. A synchronous initiator remains synchronous; a reentrant consumer of a pending reservation receives a promise. Eviction, TTL and invalidation do not withdraw consumers. Cyclic waits on an execution’s own reservation are unsupported.

import { StateMachine, TransitionTo, When } from 'annotations-design-patterns';
class Document extends StateMachine<'draft' | 'published'> {
@When('draft')
@TransitionTo('published', { concurrency: 'reject-stale' })
async publish(): Promise<void> {
// Persist effects that can tolerate a subsequent transition conflict.
}
}

Manual, A-B-A and same-state transitions invalidate pending work. Its completion rejects with StateTransitionConflictError; external effects are not rolled back and no retry is automatic. Optimistic commit is internal and does not invoke a virtual transitionTo override. Overrides used for manual changes must delegate to super.transitionTo. Without the option, virtual dispatch is preserved.

circuitBreakerWithHandle, keyedLockWithHandle and semaphoreWithHandle return { operator, handle }. Applications of that factory’s operator share its state; ordinary operators retain per-application state. inspect returns immutable snapshots. Breaker reset advances its generation so older work and probes cannot mutate the new state. It neither aborts work nor revokes permits. Lock and semaphore handles have no reset.

createCounterSink, createSpanEventSink and combineEventSinks isolate callback throws and accidental rejections. Unknown operators use the custom label; operation IDs and arguments are excluded from labels.

Sinks passed directly to run also isolate accidentally returned asynchronous rejections. emit(): void still does not await the callback or add backpressure; the business result retains its synchronous or asynchronous channel.

observeOperation measures the result visible at its position in a composition, not physical backend completion. Cancellation provenance distinguishes cancelled from timeout or an error merely named AbortError. A pre-abort that run rejects before entering the layer produces no observation. Invocation data requires includeInvocation: true.

External cache and CAS: experimental contracts

Section titled “External cache and CAS: experimental contracts”

asyncCachedFor is always asynchronous. It requires a provider declaring real TTL support, namespace, schema version, explicit key, synchronous codec, finite I/O deadline and storeFailure policy. bypass tolerates all get/set failures, including permissions/configuration; key, envelope and codec errors reject. onDiagnostic reports provider failures without changing the business result.

A deadline bounds waiting; an already-started read or write can complete later. No new business work or write starts after cancellation. There is no distributed miss deduplication or protection from stale late writes.

VersionedMemoryStore is a local CAS reference. Tokens belong to their store, are never reused within an instance, and distinguish absence from an undefined value. Absent versions can be invalidated by writes to other keys, causing conservative conflicts. The fallback without Web Crypto cannot guarantee cross-instance uniqueness. This does not certify distributed locks, breakers or semaphores; those require specific providers and network-failure testing.

Snapshots freeze only their structure. Values are stored and returned by reference, without cloning or deep freezing; treat them as immutable. External mutation of an object does not advance its CAS version.

In 2.2, set, delete or clear could overtake a pending update and be overwritten when it completed. Mutations now follow admission order per key; clear and cleanup are mutation barriers. get/has preserve non-blocking reads of the last committed value, including reads inside an updater.

Before adopting 2.3, review updaters that await another mutation of their own key or a barrier: they would create a circular dependency. Return the new value from the updater instead of writing it inside. The queue has no backpressure, and TTL starts when the write commits.

The minified browser consumer using memoize increases from 1,664 to 2,800 gzip bytes in local 2.2 versus 2.3 runtime measurements. Its existing factory retains the optional sharing path. This is neither a Bundlephobia result nor the size of the whole package. Its budget was explicitly reviewed; other consumer limits were retained.

Stable publication uses a version tag and the release workflow: the exact tarball is validated before publication with npm provenance. Integration and CI results do not promote experimental APIs to stable status.