I published version 0.1.0 of hierarchical-approval on 21 June, mostly to stop rebuilding the same approval chain by hand. I had written that code three times in eighteen months for three different ERP projects, and on the third time I extracted it instead. On 7 September the npm page read 1,204 weekly downloads and version 4.0.0. That is a good week, and it is worth writing down what got there.
This post is the eleven-week story: what the library does, the release that made the API stable, the release that fixed the data model rather than the sixth symptom of it, and an honest note on what an npm counter is and is not measuring. Every figure comes from the registry API or the changelog, both linked at the end.
Eleven weeks, 37 versions, 2,620 downloads
The badge shows the last seven days. The range endpoint shows the whole arc, and the arc is a launch, a long quiet middle where I was building rather than shipping, and a September in which the library grew up. 2,620 downloads across the first eleven weeks, ending on the best week so far.
hierarchical-approval, downloads per week since launch
21 Jun 820 ███████████████████████████ 0.1.0 - 0.3.1
28 Jun 50 ██
05 Jul 26 █
12 Jul 5 ▏
19 Jul 286 ██████████ 0.4.0, 0.5.0
26 Jul 17 ▌
02 Aug 15 ▌
09 Aug 22 ▊
16 Aug 145 █████ 0.6.0
23 Aug 26 █
30 Aug 1,020 ██████████████████████████████████ 0.7.0 - 4.0.0
----------------------------------------------------------------
2,620 downloads across the first eleven weeks.
Latest seven-day window: 1,204. Latest version: 4.0.0.The quiet stretch in July and August is the part I would defend. Nothing was being installed because nothing new was being offered: the engine worked, but it could only do sequential chains, and I was writing the parallel-branch model that 1.0.0 shipped. A library that does one thing well and is honest about the rest is a better starting point than a wide one that is wrong in six places.
What the library actually does
The premise has not changed since the first release. An approval chain is not business logic worth rewriting per project, it is infrastructure: a named template, a document moving through it, and a set of guarantees around concurrency and audit that everyone needs and nobody enjoys building. Each of these landed for a reason I hit in production first.
| The problem in every ERP I have worked on | What the library gives you | Since |
|---|---|---|
| Approval chains hardcoded per document type | Named templates, defined once and reused anywhere | 0.1.0 |
| Two approvers clicking at the same moment | Optimistic locking on a version field, with a configurable retry policy | 0.1.0 |
| A double-click or a network retry creating two requests | Idempotency keyed by tenant, document and template | 0.1.0 |
| Tests that need a real database and a real clock | ApprovalTestKit and an injectable ManualClock, zero I/O | 0.4.0 |
| Finance and Legal reviewing one after the other for no reason | Parallel branch groups that open together and join before the chain advances | 1.0.0 |
Around those sit six pluggable adapter interfaces, for notifications, metrics, audit, scheduling, authorisation and middleware, so the engine can write to Kafka, Datadog, BullMQ or whatever your platform already runs without knowing any of them exist. The audit log is immutable and records an old and new state diff on every mutation, which is the part compliance actually asks for.
1.0.0 was the release that let branches run in parallel
Until 1.0.0 every chain was strictly sequential, so a genuinely concurrent step had to be faked. Finance and Legal review the same contract independently, and modelling that as Finance then Legal added days of cycle time to satisfy the data model rather than the business. Levels sharing a group name now activate together and join before the chain advances.
// Before 1.0.0 this needed an arbitrary order: Finance waits on Legal,
// or Legal waits on Finance, and the cycle time pays for the guess.
levels: [
{ level: 1, name: 'Manager', approvers: [...], mode: 'any' },
{ level: 2, name: 'Finance', group: 'review', approvers: [...], mode: 'any' },
{ level: 3, name: 'Legal', group: 'review', approvers: [...], mode: 'any' },
{ level: 4, name: 'CEO', approvers: [...], mode: 'any' },
]
// Levels sharing a group name open together and join before the chain
// advances. Decisions arrive in any order; level 4 stays 'waiting' until
// every branch is approved. Rejecting any branch rejects the instance.
// Five ways a level can pass, not one:
// 'any' one approver is enough
// 'all' every listed approver must act
// 'majority' more than half
// 'quorum' a fixed N-of-M threshold (minApprovals)
// 'weighted' cumulative approver weight (threshold, weights)This was the last item on the roadmap, and the release that let me call the public API settled: breaking changes get a major version from here. The detail I was most pleased about is that all 673 tests written against the sequential engine passed unchanged, because a level without a group is simply its own group of one. Backward compatibility is easier to promise than to earn, and the test suite is where you find out which you did.
4.0.0 fixed the model instead of the sixth symptom
Six defects across the 3.x line shared one root cause. An instance tracked where it was with a single number, currentLevel, and that number simply cannot describe the approval frontier once a parallel group has several levels open at once. Each release corrected one more reader of it: a return that stepped back into the group it was leaving, an escalation that watched the wrong branch, a workload query that missed half the open work. Six symptoms, one bad field.
// Wrong: one number cannot describe a frontier with two branches open.
if (instance.currentLevel === myLevel) { /* may I act? */ }
// Right: ask what the instance is actually waiting on.
const open = await engine.getOpenLevels(id); // number[], ascending
// [2] a sequential chain sitting on level 2
// [2, 3] a parallel group with both branches collecting decisions
// [] terminal - approved, rejected, cancelled or expired
// currentLevel survives as the lowest open level, recomputed by the engine
// on every write. It stays for display and for the audit trail, and a
// terminal instance keeps its last value so the record still shows where
// the request stopped. On a sequential template the two always agree,
// which is why most callers needed no change at all.So 4.0.0 stopped patching readers and changed the model. Every instance write now goes through a single path that recomputes the frontier first, which means a future operation cannot persist levels without updating the open list. That is the same discipline applied to level construction in 3.0.0 and to the Postgres column list in 1.6.0, both of which had drifted for exactly the same reason: two places building the same thing, and only one of them kept current.
If you are on 3.x and reading currentLevel to decide who may act, what to notify, or what is overdue, move those reads to openLevels before upgrading. On a sequential template the two always agree, so most upgrades need no change at all. Custom storage adapters must round-trip the new field, as they already must for levels; the bundled PostgresAdapter adds the column through its own migrate step.
What 1,204 includes, and what I count instead
I checked the number before enjoying it, because npm has always been open about what it counts: HTTP 200 responses served for tarball files, which includes mirrors, CI runners and analysis robots alongside people. Publishing a version guarantees a burst, since every mirror fetches the new tarball, and an evening of releases is visible in the chart for exactly that reason. The registry's own rule of thumb puts confident signal above roughly 50 downloads a day.
That does not make the milestone less real, it makes it specific. The durable figures are the ones that do not move when I publish: 404 tests passing, twelve entry points, eleven open issues from people who read the docs closely enough to find edge cases, and a package that installs cleanly with two runtime dependencies. 1,204 is the headline; those are the reasons it is trending in the right direction, and they are what I will be watching next month.
The release cadence I would defend, and the one I would not
Between 17:16 UTC on 3 September and 02:14 the next morning I published 28 versions. The content was real, every entry in the changelog is a defect with a reproduction and a named failure mode, and shipping fixes the same evening you find them is the right instinct for a library people build approvals on. The packaging of it was not right, and three things changed as a result:
- Four major bumps in nine hours makes a major bump meaningless. Semantic versioning treats it as a promise that something broke, and a promise made four times before breakfast is not one anybody can act on. A major now needs a written breaking change and a migration note before it is allowed out.
- Fixes accumulate on a branch and go out together. The 28 versions carried no more value than four releases would have, and four releases would have been far easier for anyone pinned to a range to follow.
- An audit is now a scheduled activity rather than an accident. 3.0.0 came from auditing the interactions between features added across 2.x rather than from adding anything new, and it found three defects that no individual feature test would ever have caught.
None of that is a reason to be sorry about the evening itself. A library that ships 4.0.0 eleven weeks after 0.1.0 is a library being used seriously enough to find its own edges, and I would rather have found those six frontier defects myself than have someone find them inside a purchase order.
Try it without installing anything
The whole package is MIT-licensed and installs in one line, with pg needed only if you want the Postgres adapter. The entry points are split so that importing the engine does not drag in NestJS, the test kit or seven plugins you are not using.
npm install hierarchical-approval
npm install pg @types/pg # peer dep, Postgres adapter only
# Twelve entry points, so you pay only for what you import:
# hierarchical-approval the engine
# hierarchical-approval/nestjs the NestJS module
# hierarchical-approval/testing ApprovalTestKit + ManualClock
# hierarchical-approval/adapters/memory zero-I/O storage
# hierarchical-approval/adapters/postgres production storage
# hierarchical-approval/plugins/audit + notify, metrics, tracing,
# webhook, scheduler, resilience
# Runtime dependencies: zod and eventemitter3. That is the whole list.You can run the library in a browser before deciding anything. RunKit gives you an instant notebook with require('hierarchical-approval') already resolved, and the StackBlitz playground boots a complete purchase-order approval chain, conditions and all, with no local install. Both links are on the npm page, and they are the fastest way to find out whether the template model fits your documents.
Eleven weeks, 37 versions, 404 tests and a best week of 1,204 downloads. The number that made me open the page is not the number that will keep the library alive, but it is a real signal that a thing I built for my own ERP work is now useful outside it, and that is the whole reason to publish anything. Version 4.0.0 is the one I would tell you to install.
hierarchical-approval on npm
MIT-licensed, TypeScript-first, multi-tenant and audit-ready. Zero runtime dependencies you do not opt into.
npmjs.com/package/hierarchical-approval
