workmatic 1.0.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A persistent job queue for Node.js using SQLite. Simple, reliable, and zero external dependencies beyond SQLite.
4
4
 
5
+ **Full single-page reference (same material, browsable offline): [`docs/index.html`](docs/index.html)**.
6
+
5
7
  ## Why?
6
8
 
7
9
  I love [fastq](https://github.com/mcollina/fastq) - it's fast, simple, and has a great API. But it's in-memory only, which can be frustrating when you need jobs to survive process restarts or crashes.
@@ -61,13 +63,57 @@ worker.process(async (job) => {
61
63
  worker.start();
62
64
  ```
63
65
 
66
+ ## Multi-queue orchestration
67
+
68
+ Use `createOrchestrator` to register several queues, control all workers together, and move jobs between queues:
69
+
70
+ ```typescript
71
+ import { createDatabase, createOrchestrator } from 'workmatic';
72
+
73
+ const db = createDatabase({ filename: './jobs.db' });
74
+ const orch = createOrchestrator({ db });
75
+
76
+ orch.register('emails', { worker: { concurrency: 4 } });
77
+ orch.register('reports', { worker: { concurrency: 1 } });
78
+
79
+ orch.process('emails', async (job) => { /* send email */ });
80
+ orch.process('reports', async (job) => { /* generate PDF */ });
81
+
82
+ await orch.client('emails').add({ to: 'user@example.com' });
83
+
84
+ orch.startAll();
85
+
86
+ // Move ready/dead jobs to another queue (e.g. retry pipeline)
87
+ await orch.transfer({ from: 'emails', to: 'emails-retry', status: 'dead', resetForRetry: true });
88
+
89
+ await orch.pause('reports');
90
+ await orch.resume('reports');
91
+
92
+ await orch.stopAll();
93
+ ```
94
+
95
+ CLI transfer: `workmatic transfer ./jobs.db emails emails-retry --status=dead --retry`
96
+
97
+ See [`examples/orchestrator.ts`](examples/orchestrator.ts) (transfer between queues) and [`examples/orchestrator-processors.ts`](examples/orchestrator-processors.ts) (different `process()` per queue).
98
+
99
+ ## Testing
100
+
101
+ ```bash
102
+ npm test
103
+ npm run test:coverage # requires ≥90% line/branch/function coverage
104
+ ```
105
+
64
106
  ## API Reference
65
107
 
108
+ Browsable copy with anchored sections: **[`docs/index.html`](docs/index.html)** (API from [`#api-database`](docs/index.html#api-database)).
109
+
66
110
  ### `createDatabase(options)`
67
111
 
68
112
  Initialize the database connection and schema.
69
113
 
70
114
  ```typescript
115
+ import { createDatabase, getUnderlyingDb } from 'workmatic';
116
+
71
117
  const db = createDatabase({
72
118
  // Option 1: File path (creates or opens existing)
73
119
  filename: './jobs.db',
@@ -78,6 +124,9 @@ const db = createDatabase({
78
124
  // Option 3: Existing better-sqlite3 instance
79
125
  db: existingSqliteInstance,
80
126
  });
127
+
128
+ // Underlying driver (only reliable for instances from createDatabase above)
129
+ const sqlite = getUnderlyingDb(db);
81
130
  ```
82
131
 
83
132
  ### `createClient(options)`
@@ -112,7 +161,18 @@ Get job statistics for the queue.
112
161
 
113
162
  ```typescript
114
163
  const stats = await client.stats();
115
- // { ready: 5, running: 2, done: 100, failed: 0, dead: 1, total: 108 }
164
+ // { ready: 5, running: 2, done: 100, dead: 1, total: 108 }
165
+ ```
166
+
167
+ #### `client.addMany(payloads, options?)`
168
+
169
+ Insert many jobs in a **single transaction**, sharing the same `priority`, `delayMs`, and `maxAttempts`.
170
+
171
+ ```typescript
172
+ const { ok, ids } = await client.addMany(
173
+ [{ email: 'a@x.com' }, { email: 'b@x.com' }],
174
+ { priority: 1, delayMs: 0, maxAttempts: 3 }
175
+ );
116
176
  ```
117
177
 
118
178
  #### `client.clear(options?)`
@@ -140,10 +200,13 @@ const worker = createWorker({
140
200
  concurrency: 1, // Optional: Parallel job count (default: 1)
141
201
  leaseMs: 30000, // Optional: Job lease duration in ms (default: 30000)
142
202
  pollMs: 1000, // Optional: Poll interval when idle (default: 1000)
143
- timeoutMs: 60000, // Optional: Job execution timeout in ms (default: none)
203
+ timeoutMs: 60000, // Optional: Job execution timeout in ms (default: 60000). Use 0 for no limit
144
204
  backoff: (n) => 1000 * Math.pow(2, n), // Optional: Retry backoff function
145
205
  persistState: false, // Optional: Persist worker state to database (default: false)
146
206
  autoRestore: true, // Optional: Auto-restore state on creation (default: true)
207
+ pauseCheckIntervalMs: 300, // Optional: Min ms between DB pause checks (CLI pause). Default: 300
208
+ requeueExpiredIntervalMs: 0, // Optional: Min ms between lease requeue scans; 0 = every pump
209
+ onPumpError: (err) => { /* optional hook after default log */ },
147
210
  });
148
211
  ```
149
212
 
@@ -178,6 +241,26 @@ Stop processing and wait for current jobs to finish.
178
241
  await worker.stop();
179
242
  ```
180
243
 
244
+ ### `createOrchestrator(options)`
245
+
246
+ Manage multiple queues with shared lifecycle and transfer helpers.
247
+
248
+ ```typescript
249
+ const orch = createOrchestrator({ db });
250
+
251
+ orch.register('emails', { worker: { concurrency: 4 } });
252
+ const client = orch.client('emails'); // or use return value of register()
253
+
254
+ orch.process('emails', async (job) => { /* ... */ });
255
+ orch.startAll();
256
+ await orch.stopAll();
257
+
258
+ await orch.transfer({ from: 'emails', to: 'retry', status: 'dead', resetForRetry: true });
259
+ await orch.moveJob(jobId, 'archive');
260
+ await orch.pause('emails');
261
+ await orch.resume('emails');
262
+ ```
263
+
181
264
  #### `worker.pause()` / `worker.resume()`
182
265
 
183
266
  Pause and resume job processing.
@@ -330,7 +413,7 @@ interface Job<TPayload> {
330
413
  id: string; // Unique public ID (nanoid)
331
414
  queue: string; // Queue name
332
415
  payload: TPayload; // Your job data
333
- status: JobStatus; // 'ready' | 'running' | 'done' | 'failed' | 'dead'
416
+ status: JobStatus; // 'ready' | 'running' | 'done' | 'dead' while running
334
417
  priority: number; // Priority value
335
418
  attempts: number; // Current attempt count (starts at 0)
336
419
  maxAttempts: number; // Maximum attempts allowed
@@ -406,11 +489,14 @@ worker.process(async (job) => {
406
489
  | `concurrency` | `1` | Number of jobs to process in parallel |
407
490
  | `leaseMs` | `30000` | How long a job is "locked" during processing |
408
491
  | `pollMs` | `1000` | How often to check for new jobs when idle |
409
- | `timeoutMs` | `undefined` | Job execution timeout (fails job if exceeded) |
492
+ | `timeoutMs` | `60000` | Job timeout in ms (`0` = no limit) |
410
493
  | `priority` | `0` | Job priority (lower = processed first) |
411
494
  | `delayMs` | `0` | Delay before job becomes available |
412
495
  | `maxAttempts` | `3` | Maximum processing attempts |
413
496
  | `backoff` | `2^n * 1000` | Function returning retry delay in ms |
497
+ | `pauseCheckIntervalMs` | `300` | Throttle CLI/live pause checks from the pump loop |
498
+ | `requeueExpiredIntervalMs` | `0` | Throttle expired-lease requeue (0 = run every pump tick) |
499
+ | `onPumpError` | `undefined` | Callback after the default pump error log |
414
500
 
415
501
  ## Dashboard
416
502
 
@@ -450,6 +536,25 @@ npm run bench
450
536
  npm run bench -- --file
451
537
  ```
452
538
 
539
+ ### Micro benchmarks
540
+
541
+ Short suite: **2,000 sequential `add()` calls** (same code path as the full insert benchmark, smaller batch) plus **1,000 `client.stats()` calls** on a queue that already holds jobs. Useful for quick regression checks without running the full workload.
542
+
543
+ ```bash
544
+ npm run bench:micro
545
+ # or: npm run bench -- --micro
546
+
547
+ npm run bench:micro -- --file
548
+ # or: npm run bench -- --micro --file
549
+ ```
550
+
551
+ | Benchmark | In-Memory | File-based |
552
+ |-----------|-----------|------------|
553
+ | Micro Sequential Insert (2,000) | ~27,000/s | ~9,300/s |
554
+ | Stats Query (×1,000) | ~9,200/s | ~5,500/s |
555
+
556
+ Figures are rounded from a representative run; throughput changes with hardware, SQLite settings, and how “warm” the database is.
557
+
453
558
  ### Results Comparison
454
559
 
455
560
  | Benchmark | In-Memory | File-based |
@@ -461,6 +566,8 @@ npm run bench -- --file
461
566
  | Process (concurrency=8) | 10,000/s | 8,300/s |
462
567
  | Process (concurrency=16) | 18,000/s | 5,700/s |
463
568
  | Mixed Insert+Process | 7,500/s | 3,500/s |
569
+ | Micro Sequential Insert (2,000) | ~27,000/s | ~9,300/s |
570
+ | Stats Query (×1,000) | ~9,200/s | ~5,500/s |
464
571
  | Claim + Process Batch | 23,600/s | 11,800/s |
465
572
 
466
573
  **Note**: File-based performance degrades at high concurrency due to disk I/O. For file-based databases, `concurrency=8` is often optimal. Performance varies by hardware.
@@ -485,15 +592,9 @@ npx workmatic list ./jobs.db --status=dead --limit=10
485
592
 
486
593
  # Export jobs to CSV
487
594
  npx workmatic export ./jobs.db backup.csv
488
- npx workmatic export ./jobs.db --status=failed > failed.csv
489
-
490
- # Import jobs from CSV
491
- npx workmatic import ./jobs.db backup.csv
492
-
493
- # Delete jobs by status
494
- npx workmatic purge ./jobs.db --status=done
595
+ npx workmatic export ./jobs.db --status=dead > dead-export.csv
495
596
 
496
- # Retry dead/failed jobs (reset to ready)
597
+ # Retry dead jobs (reset to ready)
497
598
  npx workmatic retry ./jobs.db --status=dead
498
599
  ```
499
600
 
@@ -515,7 +616,7 @@ npx workmatic retry ./jobs.db --status=dead
515
616
 
516
617
  | Option | Description |
517
618
  |--------|-------------|
518
- | `--status=<status>` | Filter by status (ready/running/done/failed/dead) |
619
+ | `--status=<status>` | Filter by status (ready/running/done/dead) |
519
620
  | `--queue=<queue>` | Filter by queue name |
520
621
  | `--limit=<n>` | Limit results (default: 100) |
521
622
 
package/dashboard/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Dashboard state
2
2
  const state = {
3
- stats: { ready: 0, running: 0, done: 0, failed: 0, dead: 0, total: 0 },
3
+ stats: { ready: 0, running: 0, done: 0, dead: 0, total: 0 },
4
4
  jobs: [],
5
5
  queues: [],
6
6
  workers: [],
@@ -18,7 +18,6 @@ const elements = {
18
18
  statReady: document.getElementById('stat-ready'),
19
19
  statRunning: document.getElementById('stat-running'),
20
20
  statDone: document.getElementById('stat-done'),
21
- statFailed: document.getElementById('stat-failed'),
22
21
  statDead: document.getElementById('stat-dead'),
23
22
  workersSection: document.getElementById('workers-section'),
24
23
  workersGrid: document.getElementById('workers-grid'),
@@ -95,7 +94,6 @@ function updateStatsUI() {
95
94
  elements.statReady.textContent = state.stats.ready.toLocaleString();
96
95
  elements.statRunning.textContent = state.stats.running.toLocaleString();
97
96
  elements.statDone.textContent = state.stats.done.toLocaleString();
98
- elements.statFailed.textContent = state.stats.failed.toLocaleString();
99
97
  elements.statDead.textContent = state.stats.dead.toLocaleString();
100
98
  }
101
99
 
@@ -60,19 +60,6 @@
60
60
  <span class="stat-label">Done</span>
61
61
  </div>
62
62
  </div>
63
- <div class="stat-card failed">
64
- <div class="stat-icon">
65
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
66
- <circle cx="12" cy="12" r="10"/>
67
- <line x1="12" y1="8" x2="12" y2="12"/>
68
- <line x1="12" y1="16" x2="12.01" y2="16"/>
69
- </svg>
70
- </div>
71
- <div class="stat-content">
72
- <span class="stat-value" id="stat-failed">0</span>
73
- <span class="stat-label">Failed</span>
74
- </div>
75
- </div>
76
63
  <div class="stat-card dead">
77
64
  <div class="stat-icon">
78
65
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -105,7 +92,6 @@
105
92
  <option value="ready">Ready</option>
106
93
  <option value="running">Running</option>
107
94
  <option value="done">Done</option>
108
- <option value="failed">Failed</option>
109
95
  <option value="dead">Dead</option>
110
96
  </select>
111
97
  </div>