ts-defer-task
v1.0.1
Published
Addressable, awaitable result handles — a durable, distributed Deferred. The library moves state; it never runs work.
Maintainers
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-taskSingle 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 ContractValidationErrorPhantom 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-onerun.claim(id)rebuilds the observe capability (consumer side):get,status,settled,result,watch,cancel, andreap.
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/acknowledgeCancelwins; a late or duplicate one is a silent no-op that returns the already-settled record (never a throw). Always branch onview.stateto learn the real outcome. - Idempotency.
create({ idempotencyKey })returns the existing record (created: false) on a repeat — a refreshed double-submit starts nothing new. - Fencing.
startmints anattempttoken; if a newer worker takes over a stale record, the old worker's writes are rejected withFencingError. - Cancellation is cooperative and two-phase.
claim(id).cancel()only sets a flag.run()watches for it (backplane signal, or poll) and abortsctx.signal; if the worker then unwinds by throwing, the deferred settlescanceled. 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
exportsdeclare onlyimport; there is no CommonJSrequire()build. - Zero runtime dependencies.
ioredis(forts-defer-task/redis) andvitest(forts-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 itRun the Redis conformance against a real server (verifies the actual Lua + indexes, not just the in-process emulation):
REDIS_URL=redis://localhost:6379 npm testBefore publishing: npm pack --dry-run, then npx publint and
npx @arethetypeswrong/cli --pack ..
License
MIT
