Saya Membangun Library npm untuk Workflow Approval Hierarkis ERP

Foto oleh Unsplash
Pola approval multi-level yang sama — level sekuensial, rantai kondisional, log audit, dan penanganan konkurensi — muncul di hampir setiap modul ERP yang menyangkut uang atau kepatuhan: purchase order, pengajuan cuti, onboarding vendor, rilis anggaran. Menulis semuanya dengan benar setiap kali membutuhkan waktu, dan bagian yang halus seperti approval bersamaan dan idempotency pada retry jaringan mudah salah. Mengekstraknya ke dalam library yang sudah diuji dan diverifikasi berarti logikanya ditulis sekali dan jaminan kebenarannya ikut tersedia di mana pun library digunakan.
Setiap instance menyimpan nomor versi. Saat approval disubmit, engine memanggil updateInstance dengan versi yang dibaca dari database. Adapter penyimpanan membandingkan versi yang diharapkan dengan yang tersimpan dan melempar ApprovalConflictError jika berbeda, artinya penulis lain sudah meng-commit perubahan. Engine mencoba ulang secara otomatis dengan exponential backoff dan jitter opsional, yang dapat dikonfigurasi melalui opsi retryPolicy. Ini adalah optimistic locking standar — tidak memerlukan row lock di level database.
Idempotency berarti mensubmit dokumen yang sama dua kali mengembalikan instance approval yang sama, bukan membuat duplikat. Library ini meng-hash konteks pengajuan (tenantId, documentType, documentId, templateName) menjadi sebuah kunci, memeriksa instance yang sudah ada dengan kunci tersebut sebelum menulis, dan mengembalikannya jika ditemukan. Ini penting karena retry jaringan dan double-click tombol Submit adalah hal umum di UI nyata — tanpa idempotency, respons lambat diikuti retry akan membuat dua workflow approval terpisah untuk dokumen yang sama.
Bisa. Library ini menyertakan ManualClock di sub-path hierarchical-approval/testing. Anda meneruskannya ke engine melalui opsi clock dan ia menggantikan semua pemanggilan new Date() internal. Panggil clock.advanceDays(3) untuk melompat tiga hari seketika, lalu panggil engine.escalation.tick() untuk memicu pengecekan scheduler secara manual. Helper ApprovalTestKit.create menggabungkan ManualClock dan MemoryAdapter sehingga Anda bisa menulis pengujian deterministik sepenuhnya tanpa I/O nyata dan tanpa pemanggilan sleep.
Library ini menyertakan MemoryAdapter untuk pengembangan dan pengujian, serta PostgresAdapter untuk produksi. Database apa pun dapat digunakan dengan mengimplementasikan antarmuka IStorageAdapter, yang memiliki metode untuk menyimpan dan membaca template serta instance, pagination offset, pagination kursor, pencarian kunci idempotency, dan penambahan entri audit. Satu kontrak yang tidak bisa ditawar ada di updateInstance: harus membandingkan argumen expectedVersion dengan versi yang tersimpan dan melempar ApprovalConflictError jika berbeda — itulah fondasi dari jaminan optimistic locking.

Foto oleh Unsplash
Delapan belas bulan lalu saya membangun sistem approval purchase order multi-level untuk klien ERP. Enam bulan kemudian saya membangun yang lain untuk pengajuan cuti. Tiga bulan setelah itu, yang ketiga untuk onboarding vendor. Setiap kali logika bisnisnya hampir identik — level sekuensial, rantai kondisional berdasarkan data dokumen, jejak audit, penanganan race condition saat dua approver mengklik secara bersamaan — dan setiap kali saya menulis ulang dari awal. Pada rebuild ketiga saya berhenti, mengekstrak polanya, menambahkan jaminan kebenaran yang telah saya pelajari dengan susah payah, dan mempublikasikannya sebagai hierarchical-approval di npm.
Setiap modul ERP yang melibatkan uang, headcount, atau kepatuhan memiliki langkah approval. Bentuknya bervariasi — purchase order diarahkan berdasarkan jumlah, permintaan cuti berdasarkan departemen, onboarding vendor berdasarkan tingkat risiko — tetapi mekanisme dasarnya sama. Anda membutuhkan: definisi rantai yang dapat digunakan kembali (template), instance yang melacak kemajuan setiap dokumen melalui rantai tersebut, kemajuan level sekuensial, penyisipan level kondisional saat submit, log audit yang tidak dapat diubah, dan penanganan kasus di mana dua approver bertindak pada level yang sama secara bersamaan. Menulis itu dengan benar setiap saat membutuhkan waktu, dan bagian yang halus — terutama penanganan konkurensi — mudah salah.
The pattern I kept rebuilding — every time from scratch:
Document submitted
│
▼
┌─────────────────────────────────────┐
│ Evaluate conditions on data │
│ e.g. amount > 10,000 → add CFO │
└──────────────┬──────────────────────┘
│
┌───────▼────────┐
│ Level 1 │──APPROVE──▶ advance to next level
│ Manager │──REJECT───▶ notify submitter
└───────┬────────┘
│ (if more levels)
┌───────▼────────┐
│ Level 2 │──APPROVE──▶ advance / complete
│ Director │──REJECT───▶ cancel remaining chain
└────────────────┘
Built this 3× in 18 months. On the 3rd time, I extracted it.Saya melihat apa yang tersedia di npm sebelum menulis sendiri. Workflow engine serbaguna memang powerful tetapi mengharuskan Anda mengimplementasikan setiap guard, setiap kondisi, setiap entri audit sendiri — mereka memberi Anda state machine, bukan workflow approval. Package khusus approval yang saya temukan sudah tidak terawat, tidak memiliki dukungan TypeScript, dan sebagian besar hanya untuk single-level. Tidak ada yang memiliki idempotency, tidak ada yang memiliki optimistic locking, dan tidak ada yang memiliki cara untuk menyuntikkan test clock. Poin terakhir itulah faktor penentu: jika saya tidak dapat mengontrol waktu dalam pengujian, saya tidak dapat menguji logika SLA dan eskalasi secara andal tanpa penantian nyata.
Abstraksi sentral adalah template — definisi rantai yang dapat digunakan kembali yang disimpan berdasarkan nama. Ketika sebuah dokumen disubmit, engine membuat instance dari template tersebut dan mengevaluasi aturan kondisional terhadap data dokumen untuk menentukan level mana yang berlaku. Kondisi dievaluasi sekali saat submit dan dibekukan ke dalam instance, sehingga perubahan template di tengah jalan tidak pernah mempengaruhi approval yang sedang berjalan. Instance membawa nomor versi untuk optimistic locking dan kunci idempotency sehingga dokumen yang disubmit dua kali mengembalikan instance yang sudah ada alih-alih membuat duplikat.
Kunci idempotency secara default adalah hash SHA-256 dari tenantId + documentType + documentId + templateName. Anda dapat menggantinya dengan fungsi kustom jika logika bisnis Anda memerlukan kunci gabungan — misalnya, menyertakan periode fiskal sehingga dokumen dapat melewati template yang sama lagi di periode baru.
API-nya sengaja dibuat minimal. Definisikan template sekali, lalu panggil submit, approve, reject, delegate, atau cancel pada instance mana pun. Library menangani kemajuan level, pelacakan SLA, penjadwalan eskalasi, dan emisi event secara otomatis. Berikut alur approval purchase order lengkap dengan level keuangan kondisional:
import { ApprovalEngine } from 'hierarchical-approval';
import { MemoryAdapter } from 'hierarchical-approval/adapters/memory';
const engine = new ApprovalEngine({
adapter: new MemoryAdapter(),
tenantId: 'acme',
});
// Define the approval chain once — reuse across any document
await engine.defineTemplate({
name: 'purchase-order',
documentType: 'purchase_order',
levels: [
{ level: 1, name: 'Manager',
approvers: [{ type: 'user', userId: 'mgr-1' }], mode: 'any' },
],
// Finance level only activates if amount > 10,000
conditions: [{
when: { field: 'amount', operator: '>', value: 10000 },
addLevels: [{
level: 2, name: 'Finance',
approvers: [{ type: 'user', userId: 'fin-1' }], mode: 'any',
}],
}],
slaDeadlineDays: 2,
});
// Submit a document — idempotent (retry-safe)
const instance = await engine.submit({
templateName: 'purchase-order',
documentId: 'po-0042',
documentType: 'purchase_order',
submittedBy: 'alice',
data: { amount: 15000 },
});
// Approve at each level
await engine.approve(instance.id, { approverId: 'mgr-1' });
await engine.approve(instance.id, { approverId: 'fin-1' });
// instance.status === 'approved'Terlepas dari API yang bersih, sebagian besar kode library ada di empat masalah kebenaran yang muncul dalam produksi tetapi tidak terlihat dalam contoh sederhana.
Dalam ERP dengan ratusan pengguna aktif, dua manajer dapat menyetujui level yang sama secara bersamaan. Tanpa kontrol konkurensi, kedua pembacaan melihat versi 1, kedua penulisan berhasil, dan approval kedua secara diam-diam menimpa yang pertama — log audit kehilangan entri dan level mungkin melewati secara salah. Library menyelesaikan ini dengan field versi pada setiap instance. Setiap penulisan memanggil updateInstance dengan versi yang dibaca; adapter penyimpanan melempar ApprovalConflictError jika versi yang tersimpan telah berubah. Engine mencoba ulang secara otomatis dengan exponential backoff, dapat dikonfigurasi melalui retryPolicy.
Retry jaringan dan double-click tombol adalah masalah nyata di UI approval. Mensubmit dokumen yang sama dua kali seharusnya tidak membuat dua instance. Library meng-hash konteks submission menjadi kunci idempotency, memeriksa instance yang sudah ada dengan kunci tersebut sebelum menulis, dan mengembalikan yang sudah ada jika ditemukan. Ini transparan bagi pemanggil — panggilan submit kedua mengembalikan instance yang sama seperti yang pertama tanpa penanganan khusus.
// Configurable retry policy for optimistic locking
const engine = new ApprovalEngine({
adapter: new PostgresAdapter({ connectionString: process.env.DATABASE_URL }),
tenantId: 'acme',
retryPolicy: {
maxAttempts: 5,
baseDelayMs: 100,
maxDelayMs: 2000,
jitter: true, // randomise delay to avoid thundering-herd
},
});
// Idempotency: submitting the same document twice
// returns the existing instance instead of creating a duplicate
const a = await engine.submit({ templateName: 'po', documentId: 'po-0042', ... });
const b = await engine.submit({ templateName: 'po', documentId: 'po-0042', ... });
console.log(a.id === b.id); // trueSnapshot template melindungi instance yang sedang berjalan dari pembaruan template. Ketika Anda memanggil updateTemplate, engine membuat versi template baru dengan penghitung versi yang dinaikkan. Instance yang sudah ada mempertahankan konfigurasi eskalasi dan SLA asli mereka dalam templateSnapshot yang dibekukan. Perbarui template dengan bebas — Anda tidak akan merusak approval yang sedang berjalan.
Library ini dirancang untuk hidup di dalam infrastruktur yang sudah Anda miliki. Enam antarmuka adapter memungkinkan Anda menghubungkan sistem notifikasi, backend metrik, audit sink, job scheduler, kebijakan otorisasi, dan middleware operasi tanpa mem-fork library. Semua adapter bersifat fire-and-forget — kegagalan pada adapter mana pun ditangkap, dicatat, dan diabaikan. Koneksi Kafka yang rusak tidak akan pernah mencegah approval selesai.
// Plug in whatever notification/metrics/audit system you use
const engine = new ApprovalEngine({
adapter: pgAdapter,
tenantId: 'acme',
// Fire notifications after every approval event
notificationAdapter: {
notify: async (event) => {
if (event.type === 'approval:level_advanced') {
await slack.post(event.recipients, 'Your approval is needed');
}
},
},
// Write to Kafka / S3 / CloudTrail in addition to the DB
auditAdapter: {
append: async (tenantId, instanceId, entry, instance) => {
await kafka.send({ topic: 'approvals.audit',
messages: [{ value: JSON.stringify({ tenantId, entry }) }] });
},
},
// Prometheus / Datadog counters
metricsAdapter: {
increment: (metric, labels) => counters[metric].labels(labels).inc(),
timing: (metric, ms, labels) => histograms[metric].observe(ms / 1000),
},
// Authorization: signing authority cap
authorizationPolicy: {
authorize: async (ctx) => {
if (ctx.operation === 'approve') {
const cap = await budgetSystem.getCap(ctx.actorId);
if (ctx.instance.data.amount > cap)
return 'Signing cap exceeded';
}
},
},
});Bagian tersulit dalam menguji workflow approval adalah waktu. Pengujian SLA breach, eskalasi, dan deadline semuanya memerlukan waktu untuk berlalu, dan timer nyata dalam pengujian itu lambat, tidak dapat diandalkan, dan mimpi buruk untuk di-debug. Library menyertakan ManualClock dan ApprovalTestKit di sub-path pengujian khusus. ManualClock memungkinkan Anda melompat beberapa hari seketika. ApprovalTestKit.create memberi Anda engine yang sudah terkonfigurasi dengan MemoryAdapter dan ManualClock. ApprovalTestKit.fullyApprove menjalankan instance melalui semua level dalam satu panggilan. Tidak ada timer nyata, tidak ada database, deterministik dalam semua kasus.
import { ApprovalTestKit, ManualClock } from 'hierarchical-approval/testing';
test('SLA breach fires after 2 days', async () => {
// Pre-wired: MemoryAdapter + ManualClock at 2025-01-01
const { engine, clock } = ApprovalTestKit.create();
await engine.defineTemplate({
name: 'invoice',
documentType: 'invoice',
levels: [{ level: 1, name: 'Mgr',
approvers: [{ type: 'user', userId: 'mgr' }], mode: 'any' }],
slaDeadlineDays: 2,
});
const { id } = await engine.submit({
templateName: 'invoice', documentId: 'inv-1',
documentType: 'invoice', submittedBy: 'alice', data: {},
});
const breached = [];
engine.on('approval:sla_breached', (p) => breached.push(p.instanceId));
// No real timers — jump 3 days instantly
clock.advanceDays(3);
await engine['escalation'].tick();
expect(breached).toContain(id); // deterministic, zero ms wall-clock
await engine.shutdown();
});
// Walk all levels in one line
const final = await ApprovalTestKit.fullyApprove(engine, id, {
1: 'manager-id',
2: 'director-id',
});
// final.status === 'approved'Library ini diterbitkan di npm sebagai hierarchical-approval, ditulis dalam TypeScript 5 dengan strict mode diaktifkan, output CJS dan ESM ganda, serta deklarasi tipe lengkap. Peer dependency pada pg bersifat opsional dan hanya diperlukan untuk adapter Postgres. Sub-path pengujian sengaja dipisahkan dari bundle utama agar tidak pernah dikirim ke produksi.
hierarchical-approval di npm
TypeScript-first. CJS/ESM ganda. Tidak ada peer dep yang diperlukan untuk inti.
npmjs.com/package/hierarchical-approval