@debonet/es6pledges
v3.0.0
Published
Cancellable Promises for Javascript ES6
Maintainers
Readme
es6pledges
Releasable Promises for JavaScript ES6 (also known as "cancellable promises").
TL;DR
- A Pledge is a Promise subclass whose instances can be released from their obligation through a consent-based release pipeline.
new Pledge( fxExecutor )takes ONE argument, exactly like Promise. The executor's RETURN VALUE is the release policy.pledge.release( xReason )asks the policy for consent: the policy may clean up and consent, or refuse. No forced settlement exists anywhere.- The direction of settlement is decided by type dispatch on
xReason:PactResolveandPactRejectwrappers force direction, a bare Error rejects, anything else resolves. resolve()/reject()/fulfill()are refusable sugar overrelease().- Cancel a pipeline at its SOURCE. Releasing a derived pledge affects only that pledge; release never propagates upstream.
releaseOn( signal )adapts an AbortSignal into a release request.- Dependency-free at runtime.
- v3 is breaking. See "Migration from v2" below.
Usage
const Pledge = require( "@debonet/es6pledges" );The primary example is a delay with cleanup: an interval ticks toward a deadline, and the executor returns the release policy — a cleanup function that stops the interval.
function fpledgeDelay( dtm, dtmTick = 100 ){
return new Pledge(( fResolve, fReject ) => {
let dtmElapsed = 0;
const interval = setInterval(() => {
dtmElapsed += dtmTick;
if ( dtmElapsed >= dtm ){
clearInterval( interval );
fResolve( dtm );
}
}, dtmTick );
return () => clearInterval( interval );
});
}Usage — hold the SOURCE pledge, per the cancel-at-source idiom; do not release a derived pledge here:
const pledgeDelay = fpledgeDelay( 10000 );
pledgeDelay.then(( x ) => console.log( "result:", x ));
setTimeout(() => { pledgeDelay.resolve( "faster" ); }, 250 );Output:
result: fasterThe executor runs exactly as in a native Promise. Whatever it returns
becomes the release policy. When resolve() is called, the policy
runs, clears the interval, consents by fulfilling, and the pledge
resolves with the given value.
This example is shared across @debonet/es6pledges,
@debonet/es6tasks, and @debonet/es6pacts, so the composition is
visible by inspection. This is the delay WITH cleanup and WITHOUT
reporting. The diff contract: adding a third executor parameter
fReport and the single body line fReport( dtmElapsed ); yields
es6tasks' ftaskDelay reporting lines inside es6pacts' fpactDelay;
this form plus those two additions IS fpactDelay
(@debonet/es6pacts). All other lines are identical across the three
libraries.
Cancel at the source
Release is scoped to the pledge it is called on. Releasing a
derived pledge (one produced by then / catch / finally) settles
only that pledge and deterministically skips its own stage handler. It
never settles, rejects, or otherwise affects an ancestor: ancestors may
have other consumers, and a derived pledge does not own them.
To cancel a whole pipeline, hold the SOURCE and release it. Cancellation then flows DOWNSTREAM as ordinary settlement:
// v2: hold the end of the chain, release it, everything unwinds
const pledge = fpledgeFetch( sUrl ).then( fParse ).then( fRender );
pledge.reject( "navigated away" );
// v3: hold the SOURCE; cancellation flows DOWNSTREAM as ordinary
// settlement
const pledgeSource = fpledgeFetch( sUrl );
const pledgeDone = pledgeSource.then( fParse ).then( fRender );
pledgeSource.reject( "navigated away" );- A reject-flavored release of the source rejects it; the rejection skips downstream then-handlers natively and reaches every descendant's catch.
- A resolve-flavored release of the source runs downstream then-handlers with the early value. This is ordinary promise semantics, and correct: the consumer asked for an early result, and the pipeline processes it as it would any result.
The release policy
The executor's return value is the release policy. It takes one of three forms:
- A function
( xReason ) => ...— the release handler. Its signature is( xReason )only; it closes overfResolve/fRejectand all executor locals. This is the useEffect-cleanup idiom. - An Error instance — unconditional refusal: every
release()rejects with that Error and the pledge is untouched. undefinedor any other value — releasable with no cleanup: release settles the pledge via type dispatch. Accidental non-function returns (a number, a string, an object) are not errors; they mean "no cleanup".
An external or shared release policy needs no special support — wrap it in scope:
return ( xReason ) => fExternal( fResolve, fReject, xReason );Async executors: acquire and return
With an async executor, the policy arrives when the executor's
returned promise resolves. A release() requested before then WAITS
for executor completion: you cannot release a pledge before its
executor finishes. Async executors should therefore acquire-and-return
— acquire resources, start the work, and return the policy quickly:
new Pledge( async ( fResolve, fReject ) => {
const connection = await fpconnectionOpen( sHost );
fpRun( connection ).then( fResolve, fReject );
return () => connection.close();
});Doing the whole work inside the executor's await chain makes the pledge unreleasable in practice.
The release pipeline
pledge.release( xReason ) proceeds:
- Already settled — the returned promise fulfills with the settlement record (trivial success).
- Wait for the policy (the async-executor gap).
- Policy is an Error —
release()'s promise rejects with it; the pledge is untouched. - Policy is a function — invoke it with
xReasonand await its return unconditionally (synchronous returns are normalized; there is exactly one handler flavor, promise-semantics).- Handler fulfills — release SUCCEEDED. If the handler already
settled the pledge (via closured
fResolve/fReject), done. Otherwise the pledge auto-settles by type dispatch onxReason. The handler's fulfillment value is discarded — there is no return-value protocol. - Handler rejects or throws — release REFUSED: the pledge is
untouched, all handlers stay attached, and
release()'s promise rejects with the handler's reason. Precedence: if the pledge settled anyway during the await (the work finished mid-refusal),release()fulfills with the settlement record — refusal after settlement is moot.
- Handler fulfills — release SUCCEEDED. If the handler already
settled the pledge (via closured
- Policy is any other value — auto-settle by type dispatch directly.
- A release handler that never settles stalls the pipeline. "Must settle" is an author obligation — the same trust extended to an executor that never resolves.
Type dispatch
The direction of auto-settle is decided by what xReason IS:
xReason instanceof PactResolve— resolve withxReason.causexReason instanceof PactReject— reject withxReason.causexReason instanceof Error— reject withxReason- anything else — resolve with
xReason
Rules:
- The wrapper checks precede the bare-Error check (the wrappers extend Error).
- Unwrapping happens exactly once. No recursion:
new PactResolve( new PactReject( x ))resolves with the innerPactRejectinstance. - A bare foreign
controller.abort()produces an AbortError DOMException, which rejects, matching platform semantics.controller.abort( "value" )passes the raw value, which resolves.AbortSignal.timeout()'s reason is a TimeoutError, which rejects.
Wrappers: PactResolve and PactReject
const { PactResolve, PactReject } =
require( "@debonet/es6pledges/wrappers" );Also available as statics: Pledge.PactResolve, Pledge.PactReject.
They exist for direction-forcing through an untyped channel: rejecting
with a non-Error, resolving with an Error, and any release intent that
must travel through a signal. They extend Error deliberately: stack
capture at the release call site (debugging "who cancelled me"), and
they are exception-shaped if they leak to foreign signal consumers.
The wrapped value travels in .cause.
release() return contract
release( xReason ) returns a promise that:
- FULFILLS with a settlement record
{ sStatus, x }(sStatusis"resolved"or"rejected",xthe value or reason) AFTER the pledge settles, ordered after handlers registered before therelease()call. It never rejects because the pledge rejected (allSettled-style): releasing means you caused the outcome; you are not forced to catch it. - REJECTS only on refusal while the pledge was still pending.
Guaranteed ordering:
pledge.then(() => console.log( "here1" ));
pledge.release( "x" ).then(() => console.log( "here2" ));
// always logs here1 then here2Repeat and concurrent release
Release is idempotent. The first pipeline wins; later release()
calls made while a pipeline is in flight JOIN the in-flight outcome:
they return promises that settle identically to the first call's
promise, and their xReason is discarded. After a refusal completes,
the in-flight slot clears and a new release() starts a fresh
pipeline — refusal is not permanent unless the policy is an Error.
After a success the pledge is settled, so later calls fulfill with the
settlement record.
Settle verbs
Sugar through the SAME pipeline — no forced settlement exists anywhere:
pledge.resolve( x )≡pledge.release( new PactResolve( x ))pledge.reject( e )≡pledge.release( new PactReject( e ))pledge.fulfill( x )— alias ofresolve( x ), kept for v2 continuity.
All are refusable like any release.
Design principle: settlement is a REPORT that the obligation ended;
nothing may emit it without the release handler's consent. This is the
"robot arm" principle — settling while the physical work continues
makes the promise lie to its consumers. Callers who want to stop
caring do not need settlement: abandonment is not awaiting, or a
consumer-side Promise.race.
Note the instance / static name collision, unchanged from v2: the
instance method pledge.resolve() requests early release of an
existing Pledge, while the inherited static Pledge.resolve()
constructs an already-resolved Pledge. Likewise pledge.reject()
versus Pledge.reject().
releaseOn( signal )
The only place AbortSignal appears in the API:
const pledge = fpledgeWork().releaseOn( controller.signal );When the signal aborts, this.release( signal.reason ) runs. A
TimeoutError from AbortSignal.timeout() then rejects via type
dispatch with zero timeout-specific code. Returns this for chaining.
This is a refusable REQUEST, like any release. A consumer needing an unconditional deadline races at the consumer layer instead.
Outbound cancellation needs no API: the producer creates an
AbortController inside the executor and returns
( x ) => controller.abort( x ).
Chains: the ownership principle
A pledge may only release what it OWNS. release() affects the pledge
it is called on, plus at most one owned child: an in-flight pledge
returned by that stage's own handler. Ancestors are not owned — they
may have other consumers — so upstream propagation does not exist.
- Derived pledges have the default policy. Pledges produced by
then/catch/finallyare releasable with no cleanup, unless their stage owns in-flight work (below). - Releasing a derived pledge before its parent settles: the derived pledge settles NOW by type dispatch, and when the parent later settles, the stage's handler is SKIPPED — deterministically, not by timing inference.
- The one owned child: if the stage's handler already ran and
returned an in-flight pledge, releasing the derived pledge releases
that child through the child's own pipeline. Child consent: the
child settles, the derived pledge adopts its settlement, and
release()fulfills with the settlement record. Child refusal:release()rejects with the child's refusal reason and both pledges are untouched. Delegation is one level from the caller's perspective; a child with its own owned child recurses under the same rule. - A handler that returned a plain promise (not a Pledge) owns nothing releasable: the default policy applies, the derived pledge settles by dispatch, and the plain promise's later settlement is discarded. Non-Pledge work is uncancellable; the release is honest about it.
Edge to know about: if the parent later REJECTS and the released derived pledge was its only consumer, the platform may emit an unhandled-rejection warning. Consumers who care can attach their own no-op catch to the parent.
Combinators
Pledge.all / any / race / allSettled work as in Promise, and
the combined pledge is releasable with no cleanup. Members are
caller-supplied and possibly shared — NOT owned by the combined
pledge. Releasing the combined pledge settles the combined pledge
only, via the standard pipeline, and touches no member. Members keep
running to their real settlements, which are then discarded.
To stop members you hold, release them yourself:
vpledge.forEach(( pledge ) => pledge.release( xReason ));Each member runs its own pipeline; each refusal is individually
visible on its own release() promise.
allSettled: releasing the combined pledge settles the aggregate directly per dispatch. No synthesized member records exist.race([])and other never-settling combinations are unremarkable: release settles the combined pledge by dispatch; nothing hangs.- A combined pledge in a chain is an ordinary parent; the chain rules apply unchanged.
Statics
Pledge.resolve( x )/Pledge.reject( e )— inherited already-settled construction; a subsequentrelease()fulfills with the settlement record.Pledge.detachable( p )— wraps a foreign promise in a releasable-with-no-cleanup Pledge. Releasing it orphansp:pkeeps running, and its settlement is discarded.
Building your own releasable class
const fclassReleasable =
require( "@debonet/es6pledges/fclassReleasable" );
const Releasable = fclassReleasable( classPromiseCompatible );fclassReleasable( classPromise ) produces a releasable subclass of
any Promise-compatible class. Pledge is fclassReleasable( Promise ).
Also exported as @debonet/es6pledges/makereleasable.
Migration from v2
v3 is breaking (version 3.0.0).
- Chain-wide release is removed. v2's documented behavior — "when a chain of pledges is released, ALL of the .then() clauses in the chain also get released" — no longer exists. Release is scoped to the pledge it is called on (plus a handler-returned child). Cancel a pipeline at its SOURCE; see "Cancel at the source" above.
- Constructor:
new Pledge( fxExecutor, fOnRelease )becomesnew Pledge( fxExecutor ). Port by moving the cleanup into the executor's return value:
// v2
new Pledge(
( fResolve ) => { timeout = setTimeout( fResolve, dtm ); },
() => clearTimeout( timeout )
);
// v3
new Pledge(( fResolve ) => {
const timeout = setTimeout( fResolve, dtm );
return () => clearTimeout( timeout );
});- Release handler signature: v2's
( fResolve, fReject, bResolve, xStatus, ...vx )becomes( xReason )only. The handler closes overfResolve/fRejectand executor locals. release()arguments:release( bResolve, xStatus, ...vx )becomesrelease( xReason ). ThebResolveflag is removed; direction comes from type dispatch on the reason. UsePactResolve/PactRejectto force direction. Extra variadic arguments are removed.- Veto: v2 cancelled the release when the handler returned truthy without settling. v3 refuses by rejecting or throwing from the handler; a fulfilled handler always consents; the fulfillment value is discarded.
release()return: nevertrue/Promise<boolean>. Always a promise: fulfills with a settlement record on success, rejects on refusal.- Combinator release: v2 released every member. v3 releases the combined pledge only; no member is touched.
allSettledrelease: v2 synthesizedreleased-fulfilled/released-rejectedpseudo-records. v3 settles the aggregate per dispatch; members run to their real settlements; no pseudo-records exist.race: v2 appended a hidden extra member; v3 does not.- New API:
releaseOn( signal ),PactResolve,PactReject(statics on the class and via the"./wrappers"export). - Kept:
resolve/reject/fulfillverbs (now refusable sugar overrelease()),detachable( p ), and the instance-versus-staticresolve/rejectdistinction.
