npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zakkster/lite-di-lock

v1.0.0

Published

DI-scoped lock lifecycle over a pluggable store: positive-whitelist acquire, fail-closed fencing token, RAII release via a child scope, and a heartbeat lease. Zero deps; ships an in-process store and a validated Store Adapter interface.

Readme

@zakkster/lite-di-lock

A DI-scoped lock LIFECYCLE over a pluggable store. Positive-whitelist acquire, a fail-closed fencing token injected into a child DI scope, RAII release via the lock's own try/finally, and a chained one-shot heartbeat lease. Ships an in-process MemoryStore plus a validated three-method Store Adapter interface. Zero deps, single file, zero allocation on the hot lane.

npm version sponsor Zero-GC npm bundle size npm downloads npm total downloads Tree-Shakeable TypeScript Dependencies license

The lock the DI ecosystem was missing

lite-di-lock is the mutual-exclusion / lease hinge of the @zakkster/lite-di-* service-kernel line. The container OWNS graph teardown, the supervisor HEALS, health REPORTS, the orchestrator RETIRES on SIGTERM. Nothing guarded a CRITICAL SECTION -- the one holder that may run a nightly report, a migration, or a leader-only loop -- with a FENCING lease that downstream resources can use to reject a stale holder, and that retires through the container's own reverse-topological shutdown. This is that piece.

It is deliberately NOT a distributed lock primitive. It is the LIFECYCLE (acquire -> hold -> heartbeat -> release/lose); the STORE is the primitive. Ship the same lifecycle over an in-process MemoryStore, a Redis lease, or an etcd key by implementing three methods.

npm install @zakkster/lite-di-lock
import { Container } from '@zakkster/lite-di-container';
import { Lock } from '@zakkster/lite-di-lock';

const c = new Container();
c.singleton('db', Db);
c.boot();

// key + ttlMs are required; the store defaults to a fresh in-process MemoryStore.
const lock = new Lock(c, { key: 'nightly-report', ttlMs: 30000 });

// RAII: acquire -> fenced child scope -> body -> release, all fail-closed.
await lock.run(async (scope) => {
  const fence = scope.get('lock:fence');   // guaranteed an integer >= 1, or the body never ran
  const db = scope.get('db');              // resolves through the parent container
  await writeReport(db, { fence });        // pass the fence downstream to reject stale writers
});
// The lease is released and the child scope is torn down even if the body threw.

One acquire that fails CLOSED against every malformed store response, one fencing token that is provably present or the work never ran, one RAII path that always releases. Zero allocation on the getter lane a supervisor or readiness probe reads often.


Table of contents


Why this exists

A lock has two failure modes that small libraries get wrong at the same time.

The first is the PHANTOM LOCK. A store returns some truthy blob and the client treats it as "held". The reference sketch this package supersedes used a naive res !== false && res !== null recognizer -- which wrongly grants on undefined, {}, 'ok', and every other truthy-but-malformed shape. The result is two holders that both think they own the section. lite-di-lock inverts the default: acquire is a POSITIVE whitelist. A response is a grant ONLY if it is { ok:true, token:<integer >= 1> } whose token strictly advances. Everything else -- false, null, undefined, a bare true/1/'ok', [], {}, a missing/zero/negative/fractional/NaN/string token, a sync throw, a rejected promise, a thenable whose .then getter throws, a resolved grant whose .ok/.token getter throws -- is no grant, and the machine stays IDLE with fencingToken 0.

The second is the UNFENCED WRITE. The holder does its work, but by the time the write lands the lease has silently expired and another holder has taken over. Both writes hit the resource. The fix is a FENCING TOKEN: a monotonically increasing number issued with the lease, passed downstream, so the resource can reject a write carrying a stale token. lite-di-lock injects that token into a child DI scope BEFORE the body runs and fails CLOSED at injection: if the scope or the bind throws, the lock is released and the error rethrown -- the body never runs unfenced. Protected work reads scope.get('lock:fence') and is guaranteed an integer >= 1, or it never ran.

What you get

  • A positive-whitelist acquire. One pure grantOf decides grant-ness; no caller re-derives it. The store call, its await, and the whitelist inspection all sit inside ONE try/catch, so even a grant object with a throwing getter maps to no-grant instead of wedging the machine.
  • A fail-closed fencing token injected into a child container.scope(), plus the lock itself injected as the holder so work can poll holder.held / holder.fencingToken at 0 B.
  • RAII via the lock's OWN try/finally. run(fn) tears the child scope down BEFORE releasing (scoped resources die while the fence is valid), runs both finally legs even when the body threw, and aggregates a finally-leg failure with the primary error via AggregateError -- never masking it.
  • A chained one-shot heartbeat (never setInterval) that fails closed to LOST the instant a renew is anything but a strict true, firing onLost(reason) exactly once through a single funnel.
  • A shipped in-process MemoryStore with monotonic per-key fencing tokens and TTL against an injected clock, plus a validated three-method Store Adapter interface for Redis/etc.
  • bindLock so the held resource retires through container.shutdown() in reverse-topological order, with no leaked timer or lease.
  • Zero allocation on the state / held / fencingToken getters (0.000 B/op, hard-gated).

The lock lifecycle

IDLE -acquire-> ACQUIRING -grant-> HELD              (heartbeat armed)
ACQUIRING -no grant-> IDLE                           (acquire() resolves false)
HELD -renew !== true-> LOST                          (onLost(reason); heartbeat disarmed)
HELD -release-> RELEASING -> RELEASED                (disarm FIRST, then store.release)
LOST -release/dispose-> RELEASED
any  -dispose-> RELEASED                             (idempotent terminal)

acquire() is NON-REENTRANT: called from ACQUIRING / HELD / RELEASING / LOST it throws. release() from IDLE resolves false (nothing to release), never a fake success.


API reference

new Lock(container, options)

container is REQUIRED: a non-null object exposing scope(), value(), and shutdown() functions (else TypeError). options:

| option | default | contract | | ------------- | -------------------------------- | --------------------------------------------------------------- | | store | a fresh MemoryStore | a Store Adapter (acquire/renew/release functions) | | key | -- (REQUIRED) | a non-empty string or a symbol | | ttlMs | -- (REQUIRED, no default) | a finite number > 0 | | heartbeatMs | ttlMs/3 | a finite number > 0; must satisfy heartbeatMs * 2 <= ttlMs | | now | performance.now ?? Date.now | a () => number clock | | timers | host { setTimeout, clearTimeout } | an injectable timer surface | | tokenName | 'lock:fence' | the child-scope binding name for the fencing token | | holderName | 'lock:holder' | the child-scope binding name for the lock holder | | onLost | no-op | (reason:int) => void, invoked once when a held lease is lost |

An unknown option key throws with a did-you-mean hint.

Methods (all COLD, all async except the getters):

  • acquire(): Promise<boolean> -- true ONLY on a whitelisted, fence-advancing grant. Any non-grant resolves false with state IDLE and token 0.
  • release(): Promise<boolean> -- true ONLY on a strict === true release ack. From IDLE resolves false. Idempotent; disarms the heartbeat first.
  • run(fn): Promise<any> -- RAII. acquire -> child scope (fence + holder bound) -> fn(scope) -> finally { child.shutdown(); release() }. A non-grant throws NotAcquired (fn never runs). A fence-bind throw releases and rethrows. On a body throw the ORIGINAL error propagates; an AggregateError is thrown ONLY when a finally leg also fails.
  • tick(): Promise<boolean> -- the headless heartbeat the timer calls. A renew that is anything but strict true loses the lease to LOST and fires onLost once. Re-arms only while HELD. After RELEASED/dispose returns false and never touches the store.
  • dispose(): Promise<void> -- idempotent terminal. Releases a held lease, disarms, drops the container/store references.

Hot getters (0.000 B/op each):

  • state: number -- one of STATES.
  • held: boolean -- state === HELD.
  • fencingToken: number -- the token, or 0 (never null) when unheld.

The Store Adapter

A store is any object exposing exactly three methods, validated to be functions at construction (a store missing any one -- e.g. renew -- is rejected at the door):

store.acquire(key, ttlMs)       -> Promise<{ ok:true, token:int>=1 } | any>
store.renew(key, token, ttlMs)  -> Promise<true | any>
store.release(key, token)       -> Promise<true | any>

The ONLY recognized grant shape is { ok:true, token:<integer >= 1> } (strict ok === true, Number.isInteger && >= 1). renew/release recognize a strict === true. The token is issued ATOMICALLY by acquire -- there is no separate fence() round-trip.

MemoryStore

The shipped in-process, zero-dependency adapter. Backs a single process with a Map<key, { token, expiresAt }> and a per-key monotonic INCR counter: acquire grants iff the key is free or its lease has EXPIRED against the injected clock, and every grant strictly bumps the counter (an expired-lease steal bumps it too), so a token never repeats. renew/release verify the token matches the current holder and return a strict boolean.

import { MemoryStore } from '@zakkster/lite-di-lock';
const store = new MemoryStore();              // uses performance.now ?? Date.now
const store2 = new MemoryStore({ now: () => 0 }); // deterministic clock for tests

bindLock

import { bindLock } from '@zakkster/lite-di-lock';
bindLock(container, 'lock', lock);

Registers singletonFactory(name, () => lock) + onTeardown(name, () => lock.dispose()). A cached factory is walked by container.shutdown() in reverse resolution order, so the lock disposes at the right moment. Throws if name is already registered (never a silent shadow). A value() registration is deliberately NOT used -- a VALUE is never walked by shutdown, so a lock bound as a value would never dispose.

Constants

STATES (frozen): IDLE:0, ACQUIRING:1, HELD:2, RELEASING:3, RELEASED:4, LOST:5.

REASONS (frozen): NONE:0, RELEASED:1, HEARTBEAT_FAILED:2, STORE_ERROR:3, FENCE_REGRESSED:4, DISPOSED:5, TIMER_UNARMABLE:6.

VERSION: the three-place-synced version string.


The fencing token contract

fencingToken returns the hoisted int 0 (never null) when unheld, so the hot getter stays branch-light; 0 is safe because the whitelist rejects 0 as a fence. The "null is not zero" law is honored at the INJECTION boundary, which throws rather than binding an unverified token.

Each grant's token must STRICTLY exceed the last token this lock accepted. A store replaying a non-advancing token (a broken store emitting constant 1, say) is refused with REASONS.FENCE_REGRESSED. A fencing token that can go backwards is not a fencing token; monotonicity is what lets a downstream resource reject a write from a stale lease holder.


Composability with the ecosystem

import { Container } from '@zakkster/lite-di-container';
import { Lock, MemoryStore, bindLock } from '@zakkster/lite-di-lock';

const c = new Container();
c.singleton('db', Db);
c.boot();

const lock = new Lock(c, { key: 'migrations', ttlMs: 60000, store: new MemoryStore() });
bindLock(c, 'lock', lock);   // container.shutdown() disposes the lock in reverse-topo order

await lock.run((scope) => runMigrations(scope.get('db'), scope.get('lock:fence')));

await c.shutdown();          // tears down db AND disposes the lock; no leaked timer or lease

Inside run(fn) the body receives a real child scope: scope.get('lock:fence') is the token, scope.get('lock:holder') is the lock (poll holder.held at 0 B), and any parent-only registration (db) resolves through the scope chain. After run() the parent's live-child count is back to baseline and parent.shutdown() succeeds.


Zero-GC design notes

The only steady-state hot lane is the getter trio. Everything else (acquire, run, release, dispose) is COLD -- a lifecycle runs a bounded number of times, so allocation on those paths is fine and is proven not to accrete by the retention soak.

| lane | budget | measured | how it is gated | | --------------------------------- | --------------- | -------------- | -------------------------------------------------------------------- | | get held + get fencingToken | 0 B/op (hard) | 0.000 B/op | measureAllocs maxBytesPerCall 0 AND measureOps stabilize:'deep', over 1e6 reads across a HELD and a RELEASED instance (no constant-fold) | | tick() heartbeat (retained) | <= 64 B/beat | ~0.4 B/beat | measureOpsAsync over 1e5 beats, steady major 0, maxPauseMs <= 2.0, with an always-on self-proof that a retained per-beat regression trips the gate | | soak heap delta (1e4 lifecycles) | < 64 KB | ~9 KB | 1e4 full lifecycles behind a 1000-cycle warmup; lite-leak size 0, every MemoryStore drained, timer census 0 |

Design choices that keep the getters at 0 B: the state is a hoisted int compared against module-level constants; the unheld fencing token is the int 0, not null, so the getter is a bare property read with no branch; every field is assigned unconditionally in the constructor for one stable hidden class; and the heartbeat callback is bound ONCE in the constructor, never per tick.

A note on honesty: the heartbeat is inherently async (a promise per await), so its per-beat gate measures RETAINED bytes -- the real leak risk. A transient allocation is physically collected out of a live-set delta and cannot be gated by bytes/beat; unbounded transient churn is bounded instead by the steady major === 0 / maxPauseMs <= 2.0 GC-pressure gate.

Design decisions worth knowing

  • Positive whitelist, never a blacklist. A new adversarial store shape fails CLOSED because a shape is a grant only if it matches. See decision 0001.
  • The fence fails closed at injection. run(fn) binds the token only after a real grant; a scope/bind throw releases and rethrows. The body runs fenced or never runs.
  • RAII via the lock's own try/finally, not value() teardown: child.shutdown() before release(), both legs always run, AggregateError only on a double fault.
  • Non-reentrant, single-funnel loss. _lose is the one seam a future controller.abort() will land in; the alpha ships the seam (injected holder + onLost) and defers the AbortSignal.
  • Uniform async. MemoryStore returns already-resolved promises -- no sync fast path a test could miss.

Testing

node:test unit suites plus a single torture gate that is the real proof:

npm test            # node --test test/*.test.js
npm run torture     # node --expose-gc test/torture.mjs -> prints "ok" + a GATE line
npm run verify      # test + torture

The torture gate runs nine strictly-sequential tiers: laws + option fail-closed + ASCII control, the positive-whitelist matrix (23 adversarial rows), the fencing token (injection + monotonicity), the lifecycle machine, RAII on throw, the 0 B getter lane + heartbeat ceiling, a 1e4-lifecycle retention soak, a money composition against the REAL @zakkster/[email protected], and controls that prove every gate can fail. Three env controls (DI_ASCII_BREAK, DI_ALLOC_BREAK, DI_TORTURE_BREAK) each make the whole suite exit non-zero.

What this is not

  • A distributed consensus / lock SERVICE (Redlock, etcd, ZooKeeper). This is the LIFECYCLE that drives one; you implement the three-method adapter over that service.
  • A DI container. That is @zakkster/lite-di-container; this locks a section within one and retires through its shutdown().
  • A self-healing supervisor. That is @zakkster/lite-di-supervisor; a lock guards a section, it does not restart failed children.
  • A graceful-shutdown orchestrator. That is @zakkster/lite-di-orchestrator; this disposes via the container it retires last.
  • A semaphore with N permits. This is a single-holder mutual-exclusion lease; N-holder permits are out of scope for the alpha.

Ecosystem

Part of the @zakkster/lite-di-* service-kernel line: lite-di-container (the graph + teardown), lite-di-supervisor (self-healing), lite-di-health (readiness), lite-di-orchestrator (graceful shutdown), and this, lite-di-lock (the mutual-exclusion / lease hinge). Proven zero-GC with @zakkster/lite-gc-profiler and @zakkster/lite-leak.

License

MIT (c) Zahary Shinikchiev [email protected]