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

ts-defer-task

v1.0.1

Published

Addressable, awaitable result handles — a durable, distributed Deferred. The library moves state; it never runs work.

Readme

ts-defer-task

Addressable, awaitable result handles — a durable, distributed Deferred.

A Promise lets one piece of code wait for a result produced by another — but only in the same process, for the lifetime of that process. ts-defer-task is the same idea made durable and distributed: a result handle identified by a string id that any process can settle (report(id)) and any process can await (claim(id)), across restarts and machines.

The library's one rule: it moves state, it never runs work. Where and how the work executes is entirely up to you. ts-defer-task only tracks "is this result pending, running, or settled — and what is it?"

worker process                              consumer process
  defer.report(id).run(doWork)        ──▶   await defer.claim(id).result()
        │                                            │
        └────────────────  Store (truth)  ───────────┘
                           Backplane (signal)

Install

npm install ts-defer-task

Single package, zero runtime dependencies, shipped as ESM with sideEffects: false and per-feature subpath exports so bundlers tree-shake away anything you don't import. The Redis backend needs ioredis, and the conformance suite needs vitest — both are optional peer dependencies, installed only if you use those entry points.

Subpath exports

| Import | Provides | | ---------------------- | -------------------------------------------------------------------------------- | | ts-defer-task | Core engine: createDefer, the transition reducer, Store/Backplane, errors | | ts-defer-task/memory | In-memory Store + Backplane, createMemoryDefer. Single process. | | ts-defer-task/redis | Redis Store (Lua-CAS, ZSET indexes) + Backplane; redisAdapter. Needs ioredis. | | ts-defer-task/contract | Typed companion: a contract() builder + opt-in Standard Schema validation. | | ts-defer-task/daemon | Optional janitor: orphan reaping, pending-timeout, retention/archive. | | ts-defer-task/conformance | Shared behavioral suites for custom Stores/wirings (needs vitest). |

Quick start

The in-memory store/backplane pair is perfect for a single process, tests, and learning:

import { createMemoryDefer } from 'ts-defer-task/memory'

const defer = createMemoryDefer()

// 1. Declare a deferred (id generated; or pass your own / meta / input).
const { id } = await defer.create()

// 2. A worker reports the result. `run` drives the whole lifecycle — start →
//    heartbeat → complete on return / fail on throw / canceled on cooperative abort.
await defer.report(id).run(async (ctx) => {
  await step1()
  await ctx.progress({ pct: 0.5 })          // optional, last-write-wins
  if (ctx.signal.aborted) throw new Error()  // cooperative cancel (see below)
  return await step2()
})

// 3. Anywhere else, a consumer awaits it.
const view = await defer.claim(id).settled()  // never throws; failure is a value
console.log(view.state, view.result)          // 'succeeded', <result>

Typed values & exceptions — the contract builder

ts-defer-task/contract layers static types (and optional runtime validation) over the stringly-typed core, with a fluent builder and a throwing result():

import { contract, createContract } from 'ts-defer-task/contract'

const ExportReport = contract('export-report')
  .input<{ orgId: string }>()
  .progress<{ stage: 'querying' | 'rendering'; pct: number }>()
  .result<{ url: string; bytes: number }>()

const jobs = createContract(defer, ExportReport)

const { id } = await jobs.create({ orgId: 'acme' })          // input typed
await jobs.report(id).run(async () => ({ url, bytes: 42 }))   // complete typed as R
const out = await jobs.claim(id).result()                     // typed { url; bytes }
// result() *throws* ResultFailedError / ResultCanceledError on a failed/canceled
// settlement; settled() instead returns failure as a value to inspect.

Validation is opt-in and pluggable via the Standard Schema contract (defined inline — zero deps). Pass a schema instead of a type parameter and it is checked at that boundary (input on create, result inside result()):

const ExportReport = contract('export-report').result(ExportResultSchema) // any standard-schema
// a settled value that fails the schema rejects result() with ContractValidationError

Phantom types (.result<R>()) add types only — no runtime presence. Core never knows whether validation happened; it only ever moved unknown.

Going multi-process — Redis

Swap the store/backplane for Redis and nothing else changes. redisAdapter wires both from one client:

import { createDefer } from 'ts-defer-task'
import { redisAdapter } from 'ts-defer-task/redis'
import Redis from 'ioredis'

const { store, backplane } = redisAdapter(new Redis(process.env.REDIS_URL!), {
  keyPrefix: 'myapp:defer:',
})
const defer = createDefer({ store, backplane })

The Redis store keeps three ZSET indexes (running/pending/terminal) in sync with each record inside the same atomic Lua scripts, so the daemon's sweeps are O(log N + M) rather than full scans.

How it works

Two capabilities, both rebuilt from id

  • report(id) rebuilds the settle capability (worker side): start, progress, heartbeat, complete, fail, acknowledgeCancel, and the all-in-one run.
  • claim(id) rebuilds the observe capability (consumer side): get, status, settled, result, watch, cancel, and reap.

Both are pure functions of id + the store, so a bare id string is the only thing you need to pass between processes.

Lifecycle

pending ──start──▶ running ──complete──▶ succeeded ┐
                      │     ──fail──────▶ failed    ├─ terminal (write-once, frozen)
                      │     ──ackCancel─▶ canceled ┘
                      ├── progress (self-loop, last-write-wins)
                      └── (heartbeat ages past staleAfter) ··▶ reads as "orphaned"

orphaned is never a stored state — it's derived at read time from running + heartbeat age (claim(id).status().effectiveState). A worker that comes back and heartbeats un-orphans itself automatically. Promotion of a true orphan to terminal failed is opt-in, via claim(id).reap() or the daemon.

Guarantees worth knowing

  • Terminal is write-once. The first complete/fail/acknowledgeCancel wins; a late or duplicate one is a silent no-op that returns the already-settled record (never a throw). Always branch on view.state to learn the real outcome.
  • Idempotency. create({ idempotencyKey }) returns the existing record (created: false) on a repeat — a refreshed double-submit starts nothing new.
  • Fencing. start mints an attempt token; if a newer worker takes over a stale record, the old worker's writes are rejected with FencingError.
  • Cancellation is cooperative and two-phase. claim(id).cancel() only sets a flag. run() watches for it (backplane signal, or poll) and aborts ctx.signal; if the worker then unwinds by throwing, the deferred settles canceled. If the worker returns a value instead, that completion wins (succeeded) — a requested-but-ignored cancel never retro-fails a finished operation. The library can't force-kill an uncooperative worker.
  • Store vs Backplane. The store is truth; the backplane is just a "go re-read" signal (change:{id} for any change, cancel:{id} for cancel requests). With no backplane, claims poll and workers poll for cancel; with one, they subscribe. Behavior is identical — only latency differs (proven by the shared conformance suite, which runs both ways).

The daemon (optional)

A separate janitor process that finalizes the state-machine's gaps on a timer. It is cluster-safe by construction — every action is CAS-guarded and idempotent, so you can run N copies with no leader election or locks.

import { createDaemon } from 'ts-defer-task/daemon'

const daemon = createDaemon({
  store,
  interval: '30s',
  reapOrphans: { after: '2m' },                       // running+stale → failed (CAS)
  reapPending: { after: '5m' },                       // pending too long → failed
  cleanup: {
    succeeded: { after: '1h', action: 'delete' },
    failed: { after: '7d', action: 'archive', to: myArchiveSink },
  },
  onSweep: (stats) => metrics.gauge('ts_defer', stats),
})
daemon.start()

It uses the store's bulk capability (index-backed) when present, else falls back to list. Archive delivery is at-least-once under concurrent daemons (records are archived before deletion); make your ArchiveSink idempotent on record.id.

Writing your own Store

A store owns exactly two things: atomicity and persistence. The CAS rules are a pure reducer exported from core — you never re-implement them:

import { transition, fenceOf, FencingError } from 'ts-defer-task'

// inside one atomic critical section (a lock, a transaction, a Lua script):
const intent = { op: 'settle', state: 'succeeded', now, fence, result: value } as const
const result = transition(current, intent)
switch (result.status) {
  case 'absent':  return null
  case 'noop':    return result.record       // unchanged; persist nothing
  case 'written': await persist(result.record); return result.record
  case 'fencing': throw new FencingError(id, fenceOf(intent), result.current)
}

Then prove it with the shared suites — the same ones memory and redis pass (ts-defer-task/conformance lists vitest as an optional peer):

import { runStoreConformance, runDeferConformance } from 'ts-defer-task/conformance'

runStoreConformance({ name: 'my-store', makeStore: (clock) => new MyStore({ clock }) })
runDeferConformance({ name: 'my-store', makeDefer: (cfg) => createDefer({ store: new MyStore({ clock: cfg.clock }), /* … */ }) })

runStoreConformance covers the Store contract (CAS, write-once, idempotency, progress, orphan/reap, bulk); runDeferConformance covers the end-to-end wiring (settlement delivery and cooperative cancel) with the backplane both on and off.

Requirements & packaging

  • Node ≥ 18, ESM-only. The published exports declare only import; there is no CommonJS require() build.
  • Zero runtime dependencies. ioredis (for ts-defer-task/redis) and vitest (for ts-defer-task/conformance) are optional peer dependencies.
  • sideEffects: false — unused subpaths are tree-shaken out of your bundle.

Develop

npm install
npm run build     # tsc → build/
npm test          # vitest run
npm run lint
npm run example   # tsx examples/basic.ts (single-process tour)

Cross-process Redis demo (after a build; needs Redis + the ioredis peer):

REDIS_URL=… npx tsx examples/redis-consumer.ts job-1   # waits...
REDIS_URL=… npx tsx examples/redis-worker.ts   job-1   # ...settles it

Run the Redis conformance against a real server (verifies the actual Lua + indexes, not just the in-process emulation):

REDIS_URL=redis://localhost:6379 npm test

Before publishing: npm pack --dry-run, then npx publint and npx @arethetypeswrong/cli --pack ..

License

MIT