tasklane 0.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 ADDED
@@ -0,0 +1,734 @@
1
+ # tasklane
2
+
3
+ Background jobs that feel like normal async functions, powered by [BullMQ](https://docs.bullmq.io/) and Redis.
4
+
5
+ No central job registry. No manual registration. You wrap a function with `job()`, and calling it enqueues it. That's it.
6
+
7
+ ```typescript
8
+ import { job } from "tasklane";
9
+
10
+ export const sendSms = job(async function sendSms(to: string, message: string) {
11
+ await smsProvider.send({ to, message });
12
+ });
13
+
14
+ // Enqueues in the background — returns immediately
15
+ await sendSms("Mushud", "Your OTP is 1234");
16
+ ```
17
+
18
+ ---
19
+
20
+ ## Table of Contents
21
+
22
+ - [Requirements](#requirements)
23
+ - [Installation](#installation)
24
+ - [Quick Start](#quick-start)
25
+ - [API Reference](#api-reference)
26
+ - [initJobs](#initjobs)
27
+ - [job](#job)
28
+ - [startWorker](#startworker)
29
+ - [stopWorker](#stopworker)
30
+ - [onJobFailed](#onjobfailed)
31
+ - [onJobCompleted](#onjobcompleted)
32
+ - [flow](#flow)
33
+ - [getFailed](#getfailed)
34
+ - [retryJob](#retryjob)
35
+ - [Dispatching Jobs](#dispatching-jobs)
36
+ - [Immediate](#immediate)
37
+ - [Delayed](#delayed)
38
+ - [One-time scheduled](#one-time-scheduled)
39
+ - [Recurring cron](#recurring-cron)
40
+ - [Run directly without queue](#run-directly-without-queue)
41
+ - [Backoff Strategies](#backoff-strategies)
42
+ - [Job Flows](#job-flows)
43
+ - [Failure Handling](#failure-handling)
44
+ - [Multiple Processes](#multiple-processes)
45
+ - [TypeScript](#typescript)
46
+ - [Contributing](#contributing)
47
+ - [License](#license)
48
+
49
+ ---
50
+
51
+ ## Requirements
52
+
53
+ - Node.js 18+
54
+ - Redis 6+
55
+
56
+ ---
57
+
58
+ ## Installation
59
+
60
+ ```bash
61
+ npm install tasklane
62
+ # or
63
+ pnpm add tasklane
64
+ # or
65
+ yarn add tasklane
66
+ ```
67
+
68
+ BullMQ (and its Redis client `ioredis`) are included as dependencies — nothing extra to install. You just need a running Redis server.
69
+
70
+ ```typescript
71
+ initJobs({ redis: "redis://127.0.0.1:6379" }); // local, no auth
72
+ initJobs({ redis: "redis://:password@host:6379" }); // with password
73
+ initJobs({ redis: "redis://host:6379/2" }); // database 2
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Quick Start
79
+
80
+ **1. Define a job anywhere**
81
+
82
+ ```typescript
83
+ // jobs/sms.ts
84
+ import { job } from "tasklane";
85
+
86
+ export const sendSms = job(async function sendSms(to: string, message: string) {
87
+ await smsProvider.send({ to, message });
88
+ });
89
+ ```
90
+
91
+ **2. Use it anywhere in your app**
92
+
93
+ ```typescript
94
+ // routes/booking.ts
95
+ import { sendSms } from "../jobs/sms";
96
+
97
+ router.post("/book", async (req, res) => {
98
+ await createBooking(req.body);
99
+ await sendSms(req.body.phone, "Booking confirmed!");
100
+ res.json({ ok: true });
101
+ });
102
+ ```
103
+
104
+ Importing `sendSms` is enough — the handler self-registers the moment the module loads. No separate registration step.
105
+
106
+ **3. Initialize and start the worker once at app startup**
107
+
108
+ ```typescript
109
+ // app.ts
110
+ import { initJobs, startWorker, onJobFailed } from "tasklane";
111
+ import "./routes"; // your routes import job functions, which registers their handlers
112
+
113
+ initJobs({ redis: process.env.REDIS_URL! });
114
+ await startWorker();
115
+
116
+ onJobFailed((event) => {
117
+ console.error(`Job "${event.name}" failed:`, event.error.message);
118
+ if (event.isFinalFailure) {
119
+ console.error("All retries exhausted.");
120
+ }
121
+ });
122
+ ```
123
+
124
+ The only rule: call `startWorker()` after your app's modules have been imported — so all handlers are registered before the worker starts picking up jobs.
125
+
126
+ ---
127
+
128
+ ## API Reference
129
+
130
+ ### `initJobs`
131
+
132
+ Initializes the jobs runtime. Call this **once** at application startup before dispatching or starting any workers.
133
+
134
+ ```typescript
135
+ initJobs(config: JobsConfig): void
136
+ ```
137
+
138
+ ```typescript
139
+ initJobs({
140
+ redis: "redis://127.0.0.1:6379", // required
141
+ queue: "default", // optional, default: "default"
142
+ attempts: 3, // optional, default: 3
143
+ });
144
+ ```
145
+
146
+ | Option | Type | Default | Description |
147
+ |---|---|---|---|
148
+ | `redis` | `string` | — | Redis connection URL |
149
+ | `queue` | `string` | `"default"` | Queue name for all jobs |
150
+ | `attempts` | `number` | `3` | Default retry attempts for all jobs |
151
+ | `backoff` | `BackoffStrategy` | `{ type: "exponential", delay: 1000 }` | Default backoff strategy for all jobs |
152
+
153
+ ---
154
+
155
+ ### `job`
156
+
157
+ Wraps an async function as a background job. The returned function has the same call signature as the original, but calling it enqueues the job instead of running it inline.
158
+
159
+ ```typescript
160
+ job(fn: AsyncFunction, opts?: JobFnOptions): JobFn
161
+ ```
162
+
163
+ ```typescript
164
+ export const sendSms = job(async function sendSms(to: string, message: string) {
165
+ await smsProvider.send({ to, message });
166
+ });
167
+ ```
168
+
169
+ The function **must be named** — arrow functions are not allowed because the function name is used as the job identifier.
170
+
171
+ ```typescript
172
+ // ✅ correct
173
+ const sendSms = job(async function sendSms(to: string) { ... });
174
+
175
+ // ❌ throws — no name to use as job id
176
+ const sendSms = job(async (to: string) => { ... });
177
+ ```
178
+
179
+ **Per-job options:**
180
+
181
+ ```typescript
182
+ export const sendSms = job(
183
+ async function sendSms(to: string, message: string) { ... },
184
+ {
185
+ attempts: 5,
186
+ backoff: { type: "fixed", delay: 2000 },
187
+ }
188
+ );
189
+ ```
190
+
191
+ | Option | Type | Default | Description |
192
+ |---|---|---|---|
193
+ | `attempts` | `number` | global `attempts` | Retry attempts for this job |
194
+ | `backoff` | `BackoffStrategy` | global `backoff` | Backoff strategy for this job (overrides global) |
195
+
196
+ ---
197
+
198
+ ### `startWorker`
199
+
200
+ Starts consuming jobs from the queue. Call after `initJobs()` and after importing all job definition files.
201
+
202
+ ```typescript
203
+ await startWorker(): Promise<void>
204
+ ```
205
+
206
+ Retry backoff is configured via `initJobs()` or per-job options. See [Backoff Strategies](#backoff-strategies).
207
+
208
+ ---
209
+
210
+ ### `stopWorker`
211
+
212
+ Gracefully closes the worker and all Redis connections.
213
+
214
+ ```typescript
215
+ await stopWorker(): Promise<void>
216
+ ```
217
+
218
+ ---
219
+
220
+ ### `onJobFailed`
221
+
222
+ Registers a listener that fires on every failed job attempt. Returns an unsubscribe function.
223
+
224
+ ```typescript
225
+ onJobFailed(listener: (event: FailedEvent) => void): () => void
226
+ ```
227
+
228
+ ```typescript
229
+ const unsub = onJobFailed((event) => {
230
+ console.error(event.name, event.error.message);
231
+
232
+ if (event.isFinalFailure) {
233
+ // All retries exhausted — alert, log to DB, etc.
234
+ alerting.send(`Job ${event.name} permanently failed`);
235
+ }
236
+ });
237
+
238
+ // Stop listening later
239
+ unsub();
240
+ ```
241
+
242
+ **`FailedEvent` shape:**
243
+
244
+ | Field | Type | Description |
245
+ |---|---|---|
246
+ | `jobId` | `string` | BullMQ job ID |
247
+ | `name` | `string` | Job function name |
248
+ | `args` | `unknown[]` | Arguments the job was called with |
249
+ | `error` | `Error` | The error that was thrown |
250
+ | `attemptsMade` | `number` | How many attempts have been made |
251
+ | `isFinalFailure` | `boolean` | `true` when all retries are exhausted |
252
+
253
+ ---
254
+
255
+ ### `onJobCompleted`
256
+
257
+ Registers a listener that fires when a job completes successfully. Returns an unsubscribe function.
258
+
259
+ ```typescript
260
+ onJobCompleted(listener: (event: CompletedEvent) => void): () => void
261
+ ```
262
+
263
+ ```typescript
264
+ const unsub = onJobCompleted((event) => {
265
+ console.log(`Job "${event.name}" completed`, event.result);
266
+ });
267
+
268
+ // Stop listening later
269
+ unsub();
270
+ ```
271
+
272
+ **`CompletedEvent` shape:**
273
+
274
+ | Field | Type | Description |
275
+ |---|---|---|
276
+ | `jobId` | `string` | BullMQ job ID |
277
+ | `name` | `string` | Job function name |
278
+ | `args` | `unknown[]` | Arguments the job was called with |
279
+ | `result` | `unknown` | Return value of the handler |
280
+
281
+ ---
282
+
283
+ ### `flow`
284
+
285
+ Dispatches a job flow — a parent job that runs only after all its children complete. Supports unlimited nesting depth.
286
+
287
+ ```typescript
288
+ flow(node: FlowNode): Promise<void>
289
+ ```
290
+
291
+ ```typescript
292
+ import { flow } from "tasklane";
293
+
294
+ await flow({
295
+ job: processOrder,
296
+ args: ["ord_123"],
297
+ children: [
298
+ { job: chargePayment, args: ["ord_123"] },
299
+ { job: reserveInventory, args: ["ord_123"] },
300
+ ],
301
+ });
302
+ ```
303
+
304
+ Children run in parallel. The parent runs only after every child (and their children) completes. If any child fails permanently, the parent is not executed.
305
+
306
+ See [Job Flows](#job-flows) for detailed examples.
307
+
308
+ ---
309
+
310
+ ### `getFailed`
311
+
312
+ Returns a list of permanently failed jobs (all retries exhausted). Useful for building an admin dashboard or alerting pipeline.
313
+
314
+ ```typescript
315
+ getFailed(start?: number, limit?: number): Promise<FailedJob[]>
316
+ ```
317
+
318
+ ```typescript
319
+ import { getFailed } from "tasklane";
320
+
321
+ const jobs = await getFailed(); // first 50
322
+ const next = await getFailed(50, 50); // next page
323
+
324
+ for (const job of jobs) {
325
+ console.log(job.jobId, job.name, job.failedReason, job.attemptsMade);
326
+ }
327
+ ```
328
+
329
+ **`FailedJob` shape:**
330
+
331
+ | Field | Type | Description |
332
+ |---|---|---|
333
+ | `jobId` | `string` | Pass this to `retryJob()` to re-queue |
334
+ | `name` | `string` | Job function name |
335
+ | `args` | `unknown[]` | Arguments the job was originally called with |
336
+ | `failedReason` | `string` | Last error message |
337
+ | `attemptsMade` | `number` | Total attempts made before giving up |
338
+ | `timestamp` | `number` | Unix ms when the job was first created |
339
+ | `finishedOn` | `number \| undefined` | Unix ms when the job finally failed |
340
+
341
+ ---
342
+
343
+ ### `retryJob`
344
+
345
+ Re-queues a permanently failed job by its ID. The job is picked up by a worker and retried from scratch — attempt count resets to zero.
346
+
347
+ ```typescript
348
+ retryJob(jobId: string): Promise<void>
349
+ ```
350
+
351
+ ```typescript
352
+ import { getFailed, retryJob } from "tasklane";
353
+
354
+ // Retry all permanently failed jobs
355
+ const failed = await getFailed();
356
+ await Promise.all(failed.map((job) => retryJob(job.jobId)));
357
+
358
+ // Or retry a single job by known ID
359
+ await retryJob("job_12345");
360
+ ```
361
+
362
+ ---
363
+
364
+ ## Dispatching Jobs
365
+
366
+ Every job created with `job()` supports five dispatch modes.
367
+
368
+ ### Immediate
369
+
370
+ Enqueues the job to run as soon as a worker is available.
371
+
372
+ ```typescript
373
+ await sendSms("Mushud", "Your OTP is 1234");
374
+ ```
375
+
376
+ ### Delayed
377
+
378
+ Enqueues the job to run after a delay in milliseconds.
379
+
380
+ ```typescript
381
+ // Run after 1 hour
382
+ await sendSms.delay(60 * 60 * 1000)("Mushud", "Just checking in");
383
+ ```
384
+
385
+ ### One-time scheduled
386
+
387
+ Enqueues the job to run once at a specific `Date`.
388
+
389
+ ```typescript
390
+ await sendSms.at(new Date("2026-04-13T09:00:00Z"))("Mushud", "Good morning");
391
+ ```
392
+
393
+ Throws if the date is in the past.
394
+
395
+ ### Recurring cron
396
+
397
+ Registers a repeating schedule using a cron expression. Uses BullMQ's job scheduler internally — safe to call on every deploy since it is an idempotent upsert.
398
+
399
+ ```typescript
400
+ // Every day at 9:00 AM
401
+ await sendSms.cron("0 9 * * *")("Mushud", "Daily reminder");
402
+
403
+ // Every Monday at 8:00 AM
404
+ await sendSms.cron("0 8 * * 1")("Mushud", "Weekly report");
405
+
406
+ // Every hour
407
+ await sendSms.cron("0 * * * *")("Mushud", "Hourly ping");
408
+ ```
409
+
410
+ **Cron expression format:**
411
+
412
+ ```
413
+ ┌─ minute (0-59)
414
+ │ ┌─ hour (0-23)
415
+ │ │ ┌─ day of month (1-31)
416
+ │ │ │ ┌─ month (1-12)
417
+ │ │ │ │ ┌─ day of week (0-6, Sunday = 0)
418
+ │ │ │ │ │
419
+ * * * * *
420
+ ```
421
+
422
+ ### Run directly without queue
423
+
424
+ Calls the original handler function immediately, bypassing Redis and the queue entirely. Returns the handler's actual return value.
425
+
426
+ ```typescript
427
+ // No Redis needed — runs inline like a normal async function
428
+ await sendSms.run("Mushud", "Hello");
429
+ ```
430
+
431
+ Useful for:
432
+
433
+ - Testing handlers without a Redis connection
434
+ - Running a job inline when you need the result right away
435
+ - CLI scripts and one-off tasks
436
+
437
+ ---
438
+
439
+ ## Backoff Strategies
440
+
441
+ Backoff controls how long BullMQ waits before retrying a failed job. You can configure it globally, per job definition, or leave it at the default.
442
+
443
+ **Default:** exponential backoff starting at 1 second.
444
+
445
+ ### Exponential backoff
446
+
447
+ Doubles the wait on each retry: 1s → 2s → 4s → 8s → ...
448
+
449
+ ```typescript
450
+ initJobs({
451
+ redis: process.env.REDIS_URL!,
452
+ backoff: { type: "exponential", delay: 1000 },
453
+ });
454
+ ```
455
+
456
+ ### Fixed backoff
457
+
458
+ Waits the same amount of time between every retry.
459
+
460
+ ```typescript
461
+ initJobs({
462
+ redis: process.env.REDIS_URL!,
463
+ backoff: { type: "fixed", delay: 5000 }, // always 5s
464
+ });
465
+ ```
466
+
467
+ ### Per-job backoff
468
+
469
+ Override the global default for a specific job. Per-job settings always win over the global setting.
470
+
471
+ ```typescript
472
+ export const chargePayment = job(
473
+ async function chargePayment(orderId: string) { ... },
474
+ { backoff: { type: "fixed", delay: 2000 }, attempts: 5 }
475
+ );
476
+ ```
477
+
478
+ **`BackoffStrategy` shape:**
479
+
480
+ | Field | Type | Description |
481
+ |---|---|---|
482
+ | `type` | `"exponential" \| "fixed"` | Retry timing pattern |
483
+ | `delay` | `number` | Base delay in milliseconds |
484
+
485
+ **Priority order:** per-job `backoff` → global `initJobs` `backoff` → default `{ type: "exponential", delay: 1000 }`
486
+
487
+ ---
488
+
489
+ ## Job Flows
490
+
491
+ A flow is a group of jobs with explicit dependencies. Children always run before their parent, so you can model multi-step pipelines without polling or callbacks.
492
+
493
+ ```typescript
494
+ import { flow } from "tasklane";
495
+
496
+ await flow({
497
+ job: processOrder,
498
+ args: ["ord_123"],
499
+ children: [
500
+ { job: chargePayment, args: ["ord_123"] },
501
+ { job: reserveInventory, args: ["ord_123"] },
502
+ ],
503
+ });
504
+ ```
505
+
506
+ `chargePayment` and `reserveInventory` run in parallel. `processOrder` runs only after both complete.
507
+
508
+ ### Nested flows
509
+
510
+ Children can have their own children, to any depth:
511
+
512
+ ```typescript
513
+ await flow({
514
+ job: processOrder,
515
+ args: ["ord_123"],
516
+ children: [
517
+ { job: chargePayment, args: ["ord_123"] },
518
+ {
519
+ job: prepareShipment,
520
+ args: ["ord_123"],
521
+ children: [
522
+ { job: validateAddress, args: ["ord_123"] },
523
+ { job: pickInventory, args: ["ord_123"] },
524
+ ],
525
+ },
526
+ ],
527
+ });
528
+ ```
529
+
530
+ Execution order: `validateAddress` + `pickInventory` → `prepareShipment` + `chargePayment` → `processOrder`.
531
+
532
+ ### Flow options
533
+
534
+ Each node in the flow accepts `attempts` and `backoff` overrides:
535
+
536
+ ```typescript
537
+ await flow({
538
+ job: processOrder,
539
+ args: ["ord_123"],
540
+ children: [
541
+ {
542
+ job: chargePayment,
543
+ args: ["ord_123"],
544
+ attempts: 5,
545
+ backoff: { type: "fixed", delay: 3000 },
546
+ },
547
+ ],
548
+ });
549
+ ```
550
+
551
+ ### Failure behaviour
552
+
553
+ If any child fails permanently (all retries exhausted), the parent job is never executed. The parent stays in a waiting state in Redis and can be inspected or cleaned up via the BullMQ dashboard.
554
+
555
+ ### Completed events
556
+
557
+ `onJobCompleted` fires for every job in the flow — children and parent alike.
558
+
559
+ ```typescript
560
+ onJobCompleted((event) => {
561
+ console.log(`${event.name} done`);
562
+ });
563
+
564
+ await flow({
565
+ job: processOrder,
566
+ args: ["ord_123"],
567
+ children: [{ job: chargePayment, args: ["ord_123"] }],
568
+ });
569
+ // fires: "chargePayment done", then "processOrder done"
570
+ ```
571
+
572
+ ---
573
+
574
+ ## Failure Handling
575
+
576
+ ### How retries work
577
+
578
+ `onJobFailed` fires on **every failed attempt** — not just the final one. Use `isFinalFailure` to tell them apart.
579
+
580
+ ```typescript
581
+ onJobFailed((event) => {
582
+ if (!event.isFinalFailure) {
583
+ // This attempt failed but BullMQ will retry automatically
584
+ console.warn(`Job "${event.name}" failed (attempt ${event.attemptsMade}), retrying...`);
585
+ } else {
586
+ // All retries exhausted — job is now permanently failed
587
+ console.error(`Job "${event.name}" permanently failed:`, event.error.message);
588
+ // Alert, write to DB, notify a human, etc.
589
+ }
590
+ });
591
+ ```
592
+
593
+ Jobs automatically retry with exponential backoff when they throw. Default is 3 attempts. Configure attempts and backoff per job or globally — see [Backoff Strategies](#backoff-strategies).
594
+
595
+ Once `isFinalFailure` is `true`, the job sits in the failed set and will not run again unless you manually retry it via [`retryJob`](#retryjob).
596
+
597
+ **Common pattern — retry on deploy:**
598
+
599
+ ```typescript
600
+ // scripts/retry-failed.ts
601
+ import { initJobs, getFailed, retryJob } from "tasklane";
602
+
603
+ initJobs({ redis: process.env.REDIS_URL! });
604
+
605
+ const failed = await getFailed();
606
+ console.log(`Retrying ${failed.length} failed jobs...`);
607
+ await Promise.all(failed.map((j) => retryJob(j.jobId)));
608
+ console.log("Done.");
609
+ process.exit(0);
610
+ ```
611
+
612
+ ---
613
+
614
+ ## Multiple Processes
615
+
616
+ Running multiple processes (PM2, cluster, Docker replicas) works out of the box. Each process has its own in-memory state so a few rules apply.
617
+
618
+ **Every process must:**
619
+
620
+ 1. Call `initJobs()` independently
621
+ 2. Have all job modules in its import graph before calling `startWorker()`
622
+ 3. Call `startWorker()` if it should process jobs
623
+
624
+ In most apps this is automatic — your worker entry point imports your routes or services, which import job functions, which register their handlers:
625
+
626
+ ```typescript
627
+ // worker.ts
628
+ import { initJobs, startWorker } from "tasklane";
629
+ import "./routes"; // transitively imports all job definitions
630
+
631
+ initJobs({ redis: process.env.REDIS_URL! });
632
+ await startWorker();
633
+ ```
634
+
635
+ If you run a dedicated worker process that doesn't import routes, explicitly import the job files that process handles:
636
+
637
+ ```typescript
638
+ // worker.ts — dedicated worker without routes
639
+ import { initJobs, startWorker } from "tasklane";
640
+ import "./jobs/sms";
641
+ import "./jobs/email";
642
+
643
+ initJobs({ redis: process.env.REDIS_URL! });
644
+ await startWorker();
645
+ ```
646
+
647
+ **Cron schedules** — call `.cron()` from only one process per deploy. Since `upsertJobScheduler` is idempotent (it upserts by a stable key in Redis), calling it from multiple processes is safe but unnecessary. The cleanest approach:
648
+
649
+ ```typescript
650
+ // PM2 / cluster: only the primary process sets up cron schedules
651
+ if (process.env.pm_id === "0") {
652
+ await sendSms.cron("0 9 * * *")("Mushud", "Daily reminder");
653
+ await generateReport.cron("0 0 * * *")();
654
+ }
655
+
656
+ await startWorker(); // all processes run this
657
+ ```
658
+
659
+ **With 4 worker processes**, each job is still executed exactly once — BullMQ uses atomic Redis operations to ensure no duplicate processing.
660
+
661
+ ---
662
+
663
+ ## TypeScript
664
+
665
+ `job()` is fully generic. The return type and parameter types of the original function flow through automatically.
666
+
667
+ ```typescript
668
+ export const sendSms = job(async function sendSms(to: string, message: string) {
669
+ return { sent: true };
670
+ });
671
+
672
+ // ✅ TypeScript knows these are (string, string)
673
+ await sendSms("Mushud", "Hello");
674
+
675
+ // ✅ .run() preserves the return type
676
+ const result = await sendSms.run("Mushud", "Hello");
677
+ // result: { sent: boolean }
678
+
679
+ // ❌ TypeScript error — wrong argument types
680
+ await sendSms(123, "Hello");
681
+ ```
682
+
683
+ **Available types:**
684
+
685
+ ```typescript
686
+ import type {
687
+ JobsConfig,
688
+ JobFnOptions,
689
+ JobFn,
690
+ BackoffStrategy,
691
+ FlowNode,
692
+ FailedEvent,
693
+ CompletedEvent,
694
+ FailedJob,
695
+ } from "tasklane";
696
+ ```
697
+
698
+ ---
699
+
700
+ ## Contributing
701
+
702
+ Contributions are welcome. Please open an issue before submitting a pull request for large changes.
703
+
704
+ **Setup:**
705
+
706
+ ```bash
707
+ git clone https://github.com/mushud/tasklane
708
+ cd tasklane
709
+ pnpm install
710
+ pnpm build
711
+ ```
712
+
713
+ **Project structure:**
714
+
715
+ ```
716
+ src/
717
+ index.ts — public API exports
718
+ job.ts — job() factory
719
+ registry.ts — global singleton (BullMQ Queue + Worker + handler map)
720
+ types.ts — TypeScript interfaces
721
+ ```
722
+
723
+ **Before submitting a PR:**
724
+
725
+ ```bash
726
+ pnpm typecheck
727
+ pnpm build
728
+ ```
729
+
730
+ ---
731
+
732
+ ## License
733
+
734
+ MIT — see [LICENSE](LICENSE).