Remediation of the 2026-08 architecture review (#52). Two reproduced bugs fixed, four API-consistency gaps closed, no breaking changes. Coverage of src/ remains 100% across 389 tests.
Fixed
A failed lock release is no longer silent. MutexInterface.releaseLock() signals failure two ways — it throws, or it returns false. Only the throw was handled. A false return (what a PostgreSQL advisory unlock or a Redis DEL that removed nothing produces) meant the operation resolved successfully, onReleaseError never fired, and isLockAcquired() stayed true forever — after which every subsequent operation piggybacked on the stuck lock and never released it. Both failure modes now raise the new LockCanNotBeReleasedError.
Behavior change to check if you use a custom mutex. An operation whose release fails now rejects where it previously resolved. Implementations conforming to
MaybePromise<boolean>are unaffected, and the defaultNullMutexalways returnstrue— but a mutex returningundefined(untyped JavaScript, or a loose test double) will now surface an error instead of passing silently.
whenIdle() no longer deadlocks. It was missing the re-entrancy guard triggerEvent/checkTransitions received in v4. Awaiting it inside an observer could never resolve — the machine cannot reach idle while the runner is blocked on that very callback — so the machine wedged permanently and silently. It now throws ReentrancyError.
LockAdapterMutex no longer double-acquires. The acquired flag is only set after the adapter resolves, so two overlapping acquireLock() calls both passed the guard and acquired twice on a non-idempotent adapter. Overlapping calls now share one in-flight acquire; a failed acquire is still retryable.
Observer accessors return snapshots. getBeforeObservers(), getAfterObservers(), and Event.getObservers() handed out their live collections, so a later detach mutated a list a caller already held.
Added
Factoryaccepts engine options — a third constructor argument typedFactoryStatemachineOptions, forwardingmaxQueueLength,maxAutomaticHops,autoreleaseLock,onChainedOperationError, andonReleaseErrorto every machine it creates. Previously factory-created machines silently ran on defaults, with no back-pressure and no diagnostic sinks — precisely the fleet-of-machines case those options exist for. The three fields the factory derives per subject (initialStateName,mutex,transitionSelector) are excluded at the type level.LockCanNotBeReleasedError— typed error for the failed-release path, with codelockCanNotBeReleased.AmbiguousTransitionError.candidates— the competing transitions (target, event, condition, weight), also rendered into the message. A bare count never identified the culprits.Statemachine.releaseLock()now reports failures throughonReleaseError. ItsPromise<void>signature is unchanged; returning the boolean is breaking and is queued for v5 (#55).
Documentation
Release-failure semantics, the factory options template, whenIdle re-entrancy, and two things easy to misread from the API alone: Timeout schedules nothing — it only fires when something drives the machine — and event observers are shared by every machine built from the same process.
Also
Deprecation notices previously marked "removed in v4" are re-dated to v5, and the Codecov project floor moved from 80% to 95% (#65).
Outstanding work from the review is tracked in #64, with the breaking items batched under the v5 milestone.
Full changelog: v4.1.0...v4.2.0