All notable changes to @nikhilverma/durably are recorded here.
The format follows Keep a Changelog. This project uses Semantic Versioning.
Second round of field feedback from the same 449-batch harvest, after measuring what the report described. Two of the three findings were worse than reported.
- A run waiting on its children no longer holds an execution slot.
joinAllblocked inline while still counted against the engine's concurrency cap, so a parent starved its own children: eight children under the default cap of four ran three at a time, and four parents each awaiting one child deadlocked the engine permanently. A parked parent now returns its slot and reclaims it on resume. - The event log no longer grows with the square of the step count. Every
state record embeds the whole run, so a 200-step run wrote 9.2 MB of repeated
states.
FileStoragenow compacts the log to its newest state record past 1 MB — configurable withnew FileStorage(dir, { compactAboveBytes }). The same 200-step run writes 45 KB; a 1,600-step run went from 15.2 MB to 727 KB. - A crash no longer discards up to 99 completed steps. The mitigation for the growth above was to stop checkpointing past 200 steps except every hundredth, which silently broke the promise that completed work is never re-executed: SIGKILL after step 250 of 300 re-ran 51 already-completed steps. The interval now scales with the run — every step up to 100, then every one percent of the steps so far, capped at every hundredth — bounding the loss at one percent of a run's completed work.
checkpointEveryonRunOptions,EnqueueOptions, andEngineOptions.checkpointEvery: 1makes "completed work is never re-billed" literally true at any run length, at the cost of an O(state) write per step.CONCURRENCY_CAPPEDadvisory, fired when a fan-out is wider than the engine will run at once, carrying both numbers and namingcreateEngine({ concurrency }).
- The step is the unit of durability: a step killed at 99% yields nothing and
re-runs whole, and
stashis the only hook for progress inside an attempt. - The event log's compaction claim is now accurate rather than aspirational.
Field feedback from a 449-batch, 448k-image harvest built on 0.3.0. Every change here removes something that cost that build time.
onSteponRunOptionsandTestRunOptions: a progress channel symmetric withonAdvisory, delivering{ runId, path, label, status, attempt, executions, ms?, error? }wherestatusisrunning | replayed | ok | retrying | failed.console.loginside a workflow body re-prints on every replay and cannot tell a replayed step from an executing one; a retrying step was previously indistinguishable from a slow one. Observation never fails a run: a listener that throws is swallowed.testEngine().resume(runId, { onStep })takes the same callback and detaches it when that resume returns.list({ key })andkeyonRunState, so the name an operator chose leads back to a run id without globbing everystate.json.
undefinednow followsJSON.stringifyat the durable boundary: an undefined property is dropped from its object and an undefined array element becomesnull.{ maxImages: undefined }and{}are therefore the same input and hash identically. The previous behaviour rejected the ordinaryfield?: Tidiom with aSerializationErrorbefore the run started, which is the one place the serializer diverged from the JS mental model it otherwise matches. Values JSON genuinely cannot express still throw with their path.KeyConflictErrornames what conflicts: the message reports the bound run id and both inputs, and the error carrieskey,runId,boundInput, andinput. Deciding whether the change was meaningful no longer requires locating and readingstate.json.
- The default concurrencies are stated where they bite:
ctx.parallelruns every thunk at once unless capped, while an engine runs 4 runs at a time — the knob that actually throttlesspawnAllfan-out. - The batching pattern for work above the fan-out ceiling is written down: coarse durable steps plus your own per-item ledger inside each step.
- Hydration no longer flattens
Datevalues and recordedErrors to{}when another process resumes a run. Decoding is now idempotent, and a recorded error keeps its class and reported name. - Signals are routed by the durable path of the parked
waitForinstead of by signal name, so a workflow may await the same signal name more than once. - A delivered signal payload stays recorded, so replaying a satisfied
waitForreturns its value instead of parking the run again. - Signal payloads are validated where the wait's schema exists, inside the workflow. A payload sent by a process that never ran the body no longer fails the run permanently: it is discarded, the run stays parked for a corrected signal, and the refusal is visible on the recorded wait.
cancelleaves an already-terminal run and its children untouched instead of rewriting a completed outcome.retryre-executes steps whose compensations already ran, so a retried saga can no longer complete on state that was rolled back.- A step attempt that outlives its lease is discarded instead of being written
over the state of the engine that reclaimed the run; that engine adopts the
stored outcome and
LeaseLostErroris now raised by the runtime. - The default heartbeat interval fits inside short leases instead of being pinned at 100ms.
ctx.chargerejects negative amounts so recorded spend stays monotonic.- Array holes are normalized to
nullat the durable boundary so a live value and the value a resumed process reads back have the same shape.
- Effect-free structural compatibility replay for changed workflow bodies.
- Durable compatibility checks for recorded paths, operation kinds, labels, schemas, wait identities, loop history, and completed-step compensation.
- Coverage for policy edits across parallel concurrency, retries, backoff, jitter, timeouts, keyed concurrency, loop limits, and wait timeouts.
- Coverage for compatible edits across process restarts, anonymous workflows, and multiple resumable runs with the same input or key.
- Execution-policy changes now resume the existing run and preserve completed steps instead of creating a new run or making the run stale.
- Failed and unfinished steps use the latest callback and execution policy when a changed workflow is structurally compatible with its recorded history.
- Anonymous workflows with the same input are matched against all resumable histories so a compatible run wins over simple recency.
- Shared one default runtime across the core, test, and SQLite package entrypoints so mixed imports operate on the same runs.
- Hardened the dependency-audit release gate.
- Initial public release of local-first durable workflow execution for TypeScript.
- File and SQLite storage, resumable steps, parallel execution, retries, timeouts, compensation, waits, loops, child runs, budgets, resources, advisories, and the shadow-replay test engine.