@basaltkit/queue 1.0.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/LICENSE +21 -0
- package/README.md +319 -0
- package/dist/index.d.ts +242 -0
- package/dist/index.js +324 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Machize Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# @basaltkit/queue
|
|
2
|
+
|
|
3
|
+
Job queues for Basalt applications: define declarative "jobs" with Zod validation, run them in the background with BullMQ/Redis in production, and synchronously in development and tests — without changing a line of code.
|
|
4
|
+
|
|
5
|
+
You need this module when you have work that **must not block the user's request**: sending emails, generating reports, processing images, syncing data, etc.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What this module solves
|
|
10
|
+
|
|
11
|
+
A **queue** is a waiting list of tasks. Instead of doing the heavy work immediately (and making the user wait), you put the task on the queue and respond right away. A **worker** — a process that may live on another machine — pulls tasks off the queue and runs them, one at a time or several in parallel. Each individual task is called a **job**.
|
|
12
|
+
|
|
13
|
+
This module gives you three things you'd normally have to build by hand:
|
|
14
|
+
|
|
15
|
+
1. **Declarative, type-safe jobs** — you define each job once with `defineJob` (name, validation schema, number of attempts) and then call `MyJob.dispatch(data)` anywhere in the application. Data is validated with Zod *before* entering the queue, so invalid data never reaches the worker.
|
|
16
|
+
2. **Context propagation** — information from the current request (`requestId`, `tenantId`, `userId`, etc.) automatically travels along with the job and is restored inside the worker. Your logs and tenant checks work in the worker the same way they did in the HTTP request.
|
|
17
|
+
3. **Two interchangeable drivers** — in production, use **BullMQ** (over Redis, with real retries and delays); in development and tests, use the **sync** driver, which runs the job immediately, in the same process, without needing Redis installed.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm add @basaltkit/queue
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The package depends on `@basaltkit/core` and `@basaltkit/events` (installed automatically). For production you also need an accessible **Redis** server (BullMQ stores the queues there). For development and tests you need nothing.
|
|
26
|
+
|
|
27
|
+
## Get started in 5 minutes
|
|
28
|
+
|
|
29
|
+
Step by step to get a job working:
|
|
30
|
+
|
|
31
|
+
**1. Define the job** (in its own file, e.g. `src/jobs/send-welcome-email.ts`):
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { defineJob } from '@basaltkit/queue'
|
|
35
|
+
import { z } from 'zod'
|
|
36
|
+
|
|
37
|
+
export const SendWelcomeEmail = defineJob({
|
|
38
|
+
name: 'email.welcome', // unique job name
|
|
39
|
+
schema: z.object({ userId: z.string() }), // shape of the data (validated)
|
|
40
|
+
attempts: 3, // retry up to 3 times on failure
|
|
41
|
+
backoff: { type: 'exponential', delay: '30s' }, // growing wait time between attempts
|
|
42
|
+
async handle({ userId }) {
|
|
43
|
+
// the actual work — runs on the worker
|
|
44
|
+
console.log(`Sending welcome email to user ${userId}`)
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**2. Register the plugin in the application** (e.g. `src/app.ts`):
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { createApp } from '@basaltkit/core'
|
|
53
|
+
import { queuePlugin } from '@basaltkit/queue'
|
|
54
|
+
import { SendWelcomeEmail } from './jobs/send-welcome-email.js'
|
|
55
|
+
|
|
56
|
+
const app = await createApp({
|
|
57
|
+
plugins: [
|
|
58
|
+
queuePlugin({
|
|
59
|
+
jobs: [SendWelcomeEmail],
|
|
60
|
+
// no `connection` → sync driver: runs immediately, no Redis needed (ideal for dev)
|
|
61
|
+
}),
|
|
62
|
+
],
|
|
63
|
+
}).boot()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**3. Dispatch the job wherever you need it:**
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
await SendWelcomeEmail.dispatch({ userId: 'u-123' })
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Done. In dev, `handle` runs immediately. When you want real production behavior, add the Redis connection and the workers:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
queuePlugin({
|
|
76
|
+
jobs: [SendWelcomeEmail],
|
|
77
|
+
connection: 'redis://localhost:6379', // activates the BullMQ driver
|
|
78
|
+
workers: [{ queue: 'default', concurrency: 5 }], // this process processes the queue
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Usage guide
|
|
83
|
+
|
|
84
|
+
### Defining a job with `defineJob`
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { defineJob } from '@basaltkit/queue'
|
|
88
|
+
import { z } from 'zod'
|
|
89
|
+
|
|
90
|
+
export const GenerateInvoice = defineJob({
|
|
91
|
+
name: 'billing.invoice',
|
|
92
|
+
schema: z.object({ orderId: z.string() }),
|
|
93
|
+
queue: 'billing', // dedicated queue (default: 'default')
|
|
94
|
+
attempts: 5,
|
|
95
|
+
backoff: { type: 'fixed', delay: '1m' },
|
|
96
|
+
async handle({ orderId }) {
|
|
97
|
+
// generate the invoice…
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`schema` is optional but recommended: it validates the data **twice** — on `dispatch` (before entering the queue) and on the worker (before running). An invalid payload throws `JobValidationError` right at `dispatch`, without polluting the queue.
|
|
103
|
+
|
|
104
|
+
### Dispatching with delay or priority
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { GenerateInvoice } from './jobs/generate-invoice.js'
|
|
108
|
+
|
|
109
|
+
// runs 10 minutes from now
|
|
110
|
+
await GenerateInvoice.dispatch({ orderId: 'o-1' }, { delay: '10m' })
|
|
111
|
+
|
|
112
|
+
// priority (lower number = higher priority, BullMQ semantics)
|
|
113
|
+
await GenerateInvoice.dispatch({ orderId: 'o-2' }, { priority: 1 })
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Note: with the sync driver, `delay` is ignored — the job runs immediately.
|
|
117
|
+
|
|
118
|
+
### Context propagation (tenant, requestId…)
|
|
119
|
+
|
|
120
|
+
If you dispatch a job inside a request with active context (`runWithContext` from `@basaltkit/core`, usually done by HTTP middleware), the fields `requestId`, `correlationId`, `traceId`, `userId`, `tenantId` — and `tenant.id` / `user.id` — are captured and restored inside `handle`:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { ctx, runWithContext } from '@basaltkit/core'
|
|
124
|
+
import { defineJob, QueueManager, SyncQueueDriver } from '@basaltkit/queue'
|
|
125
|
+
|
|
126
|
+
const job = defineJob({
|
|
127
|
+
name: 'ctx.probe',
|
|
128
|
+
handle: () => {
|
|
129
|
+
// inside the worker, the original context is available
|
|
130
|
+
console.log(ctx().requestId, ctx()['tenant'])
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const manager = new QueueManager(new SyncQueueDriver())
|
|
135
|
+
manager.register(job)
|
|
136
|
+
|
|
137
|
+
await runWithContext({ requestId: 'req-7', tenant: { id: 'acme', name: 'Acme' } }, () =>
|
|
138
|
+
job.dispatch({}),
|
|
139
|
+
)
|
|
140
|
+
// inside handle: requestId = 'req-7', tenant = { id: 'acme' } (only the id is serialized)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Turning an event listener into a job: `queuedOn`
|
|
144
|
+
|
|
145
|
+
If you use `@basaltkit/events`, `queuedOn` bridges events→queue: `emit` just puts the job on the queue, and the handler runs on the worker with retries and restored context.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { EventBus, defineEvent } from '@basaltkit/events'
|
|
149
|
+
import { QueueManager, SyncQueueDriver, queuedOn } from '@basaltkit/queue'
|
|
150
|
+
import { z } from 'zod'
|
|
151
|
+
|
|
152
|
+
const bus = new EventBus()
|
|
153
|
+
const manager = new QueueManager(new SyncQueueDriver())
|
|
154
|
+
const OrderCreated = defineEvent('order.created', z.object({ orderId: z.string() }))
|
|
155
|
+
|
|
156
|
+
const unsubscribe = queuedOn(bus, manager, OrderCreated, async ({ orderId }) => {
|
|
157
|
+
// runs on the worker, with the driver's retry/backoff
|
|
158
|
+
}, { queue: 'orders', attempts: 3 })
|
|
159
|
+
|
|
160
|
+
await bus.emit(OrderCreated, { orderId: 'o-1' })
|
|
161
|
+
// the created job is named 'listener:order.created'
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`queuedOn` returns the function to cancel the subscription.
|
|
165
|
+
|
|
166
|
+
### Producer and worker in separate processes (production)
|
|
167
|
+
|
|
168
|
+
One process can only **produce** (call `dispatch`) and another only **consume** (run workers). Both must register the **same jobs** (the worker needs the `handle`):
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// API process (produces only)
|
|
172
|
+
queuePlugin({ jobs: [SendWelcomeEmail, GenerateInvoice], connection: process.env.REDIS_URL! })
|
|
173
|
+
|
|
174
|
+
// worker process (consumes)
|
|
175
|
+
queuePlugin({
|
|
176
|
+
jobs: [SendWelcomeEmail, GenerateInvoice],
|
|
177
|
+
connection: process.env.REDIS_URL!,
|
|
178
|
+
workers: [
|
|
179
|
+
{ queue: 'default', concurrency: 5 },
|
|
180
|
+
{ queue: 'billing', concurrency: 2 },
|
|
181
|
+
],
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
If a job reaches a worker that hasn't registered it, `UnknownJobError` is thrown.
|
|
186
|
+
|
|
187
|
+
### Manual use without a plugin (e.g. in tests)
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { QueueManager, SyncQueueDriver, defineJob } from '@basaltkit/queue'
|
|
191
|
+
|
|
192
|
+
const driver = new SyncQueueDriver()
|
|
193
|
+
const manager = new QueueManager(driver)
|
|
194
|
+
|
|
195
|
+
const job = defineJob({ name: 'demo', handle: () => {} })
|
|
196
|
+
manager.register(job)
|
|
197
|
+
|
|
198
|
+
await job.dispatch({})
|
|
199
|
+
console.log(driver.executed) // [{ queue: 'default', jobName: 'demo', attempts: 1 }]
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`SyncQueueDriver` keeps a history in `driver.executed` — very handy for test assertions.
|
|
203
|
+
|
|
204
|
+
## API reference
|
|
205
|
+
|
|
206
|
+
### `defineJob<T>(config): JobDefinition<T>`
|
|
207
|
+
|
|
208
|
+
| Option | Type | Required? | Default | Description |
|
|
209
|
+
|---|---|---|---|---|
|
|
210
|
+
| `name` | `string` | Yes | — | Unique job name (e.g. `'email.welcome'`). |
|
|
211
|
+
| `schema` | `JobSchema<T>` (Zod-compatible) | No | — | Validates the payload on dispatch and on the worker. |
|
|
212
|
+
| `queue` | `string` | No | `'default'` | Name of the queue the job goes into. |
|
|
213
|
+
| `attempts` | `number` | No | `1` | Maximum number of attempts on failure. |
|
|
214
|
+
| `backoff` | `JobBackoff` | No | — | Wait strategy between attempts. |
|
|
215
|
+
| `handle` | `(payload: T) => void \| Promise<void>` | Yes | — | The function that does the work (runs on the worker). |
|
|
216
|
+
|
|
217
|
+
The returned object (`JobDefinition<T>`) exposes:
|
|
218
|
+
|
|
219
|
+
- `dispatch(payload, options?)` — puts the job on the queue. Throws `JobNotRegisteredError` if the job hasn't yet been registered with a `QueueManager`.
|
|
220
|
+
- `name`, `schema`, `queue`, `attempts`, `backoff`, `handle` — the configured values.
|
|
221
|
+
- `__bind(dispatcher)` — **Advanced/internal**: used by `QueueManager` on registration.
|
|
222
|
+
|
|
223
|
+
### `DispatchOptions`
|
|
224
|
+
|
|
225
|
+
| Field | Type | Required? | Default | Description |
|
|
226
|
+
|---|---|---|---|---|
|
|
227
|
+
| `delay` | `DurationInput` (e.g. `'30s'`, `'10m'`, or ms) | No | no delay | Delays execution (BullMQ driver only). |
|
|
228
|
+
| `priority` | `number` | No | — | BullMQ priority (lower = higher priority). |
|
|
229
|
+
|
|
230
|
+
### `JobBackoff`
|
|
231
|
+
|
|
232
|
+
| Field | Type | Required? | Default | Description |
|
|
233
|
+
|---|---|---|---|---|
|
|
234
|
+
| `type` | `'exponential' \| 'fixed'` | Yes | — | Growing or constant wait between attempts. |
|
|
235
|
+
| `delay` | `DurationInput` | Yes | — | Base wait (e.g. `'30s'`). |
|
|
236
|
+
|
|
237
|
+
### `queuePlugin(options?: QueuePluginOptions)`
|
|
238
|
+
|
|
239
|
+
Basalt plugin that registers a `QueueManager` in the container under the `QUEUE` token, starts workers on `boot`, and closes everything on `shutdown`.
|
|
240
|
+
|
|
241
|
+
| Option | Type | Required? | Default | Description |
|
|
242
|
+
|---|---|---|---|---|
|
|
243
|
+
| `jobs` | `JobDefinition[]` | No | `[]` | Jobs known to this process (producer and/or worker). |
|
|
244
|
+
| `connection` | `string \| ConnectionOptions` | No | — | Redis URL (`redis://…` or `rediss://…`) or ioredis options. With a value → BullMQ driver; without one → sync driver. |
|
|
245
|
+
| `driver` | `QueueDriver` | No | — | Custom driver — takes precedence over `connection`. |
|
|
246
|
+
| `workers` | `{ queue: string; concurrency?: number }[]` | No | `[]` | Queues whose workers start in this process on boot. |
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import { QUEUE } from '@basaltkit/queue'
|
|
250
|
+
const manager = app.container.get(QUEUE) // get the QueueManager from the container
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### `class QueueManager` — implements `JobDispatcher`
|
|
254
|
+
|
|
255
|
+
| Method | Signature | Description |
|
|
256
|
+
|---|---|---|
|
|
257
|
+
| `constructor` | `new QueueManager(driver: QueueDriver)` | Creates the manager over a driver. |
|
|
258
|
+
| `register` | `(job) => this` | Registers a job and wires its `dispatch`. |
|
|
259
|
+
| `dispatch` | `<T>(job, payload: T, options?: DispatchOptions) => Promise<void>` | Validates and puts the job on the queue. Auto-registers the job if not registered yet. |
|
|
260
|
+
| `work` | `(queue = 'default', { concurrency? }?) => void` | Starts a worker for the queue (no-op on the sync driver). |
|
|
261
|
+
| `close` | `() => Promise<void>` | Closes workers and connections. |
|
|
262
|
+
|
|
263
|
+
### `queuedOn<T>(bus, manager, event, handler, options?): () => void`
|
|
264
|
+
|
|
265
|
+
Creates the event→job bridge. Returns the subscription cancel function.
|
|
266
|
+
|
|
267
|
+
`QueuedListenerOptions`:
|
|
268
|
+
|
|
269
|
+
| Field | Type | Required? | Default | Description |
|
|
270
|
+
|---|---|---|---|---|
|
|
271
|
+
| `queue` | `string` | No | `'default'` | Queue for the created job. |
|
|
272
|
+
| `attempts` | `number` | No | `1` | Job attempts. |
|
|
273
|
+
| `backoff` | `JobBackoff` | No | — | Job backoff. |
|
|
274
|
+
|
|
275
|
+
### Drivers
|
|
276
|
+
|
|
277
|
+
- **`class SyncQueueDriver`** — runs inline on `dispatch`, honors `attempts` (immediate retry). Public property `executed: { queue, jobName, attempts }[]` with the execution history. For testing and dev without Redis.
|
|
278
|
+
- **`class BullmqQueueDriver`** — production over Redis. `new BullmqQueueDriver({ connection })`, where `connection` is a Redis URL or ioredis options (`BullmqDriverOptions`). Completed jobs are cleaned up (keeps 1000); failed jobs are kept.
|
|
279
|
+
- **`interface QueueDriver`** (Advanced) — contract for custom drivers: `setExecutor(executor)`, `add(queue, jobName, data, options: AddJobOptions)`, `startWorker(queue, { concurrency? })`, `close()`. Helper types: `AddJobOptions`, `JobExecutor`.
|
|
280
|
+
|
|
281
|
+
### Exported errors
|
|
282
|
+
|
|
283
|
+
| Class | Code | When it occurs |
|
|
284
|
+
|---|---|---|
|
|
285
|
+
| `JobValidationError` | `JOB_INVALID` | Payload doesn't pass the `schema` (has `.job` and `.issues`). |
|
|
286
|
+
| `JobNotRegisteredError` | `QUEUE_JOB_NOT_REGISTERED` | `dispatch` before registering the job with a manager. |
|
|
287
|
+
| `UnknownJobError` | `QUEUE_UNKNOWN_JOB` | The job reached the worker but isn't registered in that process. |
|
|
288
|
+
|
|
289
|
+
### Token
|
|
290
|
+
|
|
291
|
+
- `QUEUE: Token<QueueManager>` — injection token to get the manager from the container.
|
|
292
|
+
|
|
293
|
+
## Common issues and solutions (FAQ)
|
|
294
|
+
|
|
295
|
+
**"Job X has not been registered in a QueueManager yet" on `dispatch`.**
|
|
296
|
+
The job wasn't passed in `queuePlugin({ jobs: [...] })` nor registered with `manager.register(job)`. Add it to the plugin's job list.
|
|
297
|
+
|
|
298
|
+
**"Job X reached the worker but is not registered in this process".**
|
|
299
|
+
The worker process doesn't know that job. Producer and worker must register the **same** list of jobs.
|
|
300
|
+
|
|
301
|
+
**The job never runs in production.**
|
|
302
|
+
Check whether any process started workers for the right queue: `queuePlugin({ workers: [{ queue: 'default' }] })` or `manager.work('default')`. Also check that the job's `queue` matches the worker's.
|
|
303
|
+
|
|
304
|
+
**`JobValidationError: Invalid payload…`**
|
|
305
|
+
The data passed to `dispatch` doesn't match the `schema`. The error includes `issues` with Zod's details. This is intentional — it protects the queue from corrupted data.
|
|
306
|
+
|
|
307
|
+
**In dev, `delay` doesn't work.**
|
|
308
|
+
The sync driver always runs immediately. Delays, timed backoff, and priority only have a real effect with the BullMQ driver (with `connection`).
|
|
309
|
+
|
|
310
|
+
**Do I need Redis to run the tests?**
|
|
311
|
+
No. Without `connection`, the plugin uses `SyncQueueDriver`. You can also instantiate the driver directly and inspect `driver.executed`.
|
|
312
|
+
|
|
313
|
+
## How it connects to other modules
|
|
314
|
+
|
|
315
|
+
- **`@basaltkit/core`** — provides `createApp`/`definePlugin` (`queuePlugin` is a core plugin), the ALS context (`runWithContext`/`ctx`) propagated to workers, `parseDuration` (formats `'30s'`, `'10m'`), and the base `BasaltError` class.
|
|
316
|
+
- **`@basaltkit/events`** — via `queuedOn`, any domain event can be processed in the background with retries.
|
|
317
|
+
- **`@basaltkit/scheduler`** — `schedule.job(MyJob, payload)` schedules a `dispatch` for a job from this queue on cron schedules (e.g. daily reconciliation at 03:00).
|
|
318
|
+
- **`@basaltkit/logger`** — since context is restored on the worker, logs written inside `handle` automatically carry `requestId`/`tenantId` from the original request.
|
|
319
|
+
- **`@basaltkit/audit`** and **`@basaltkit/activity`** — records made inside a `handle` inherit the same context (actor, tenant), keeping the trail consistent between the request and the worker.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import * as _basaltkit_core from '@basaltkit/core';
|
|
2
|
+
import { DurationInput, BasaltError } from '@basaltkit/core';
|
|
3
|
+
import { ConnectionOptions } from 'bullmq';
|
|
4
|
+
import { EventBus, BasaltEvent } from '@basaltkit/events';
|
|
5
|
+
|
|
6
|
+
interface AddJobOptions {
|
|
7
|
+
attempts: number;
|
|
8
|
+
backoff?: {
|
|
9
|
+
type: 'exponential' | 'fixed';
|
|
10
|
+
delayMs: number;
|
|
11
|
+
} | undefined;
|
|
12
|
+
delayMs?: number | undefined;
|
|
13
|
+
priority?: number | undefined;
|
|
14
|
+
}
|
|
15
|
+
type JobExecutor = (jobName: string, data: unknown) => Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* What a driver's backend honors. Backends differ: RabbitMQ needs a
|
|
18
|
+
* dead-letter setup for delayed jobs, Kafka has no message priority, etc. The
|
|
19
|
+
* QueueManager checks a dispatch's options against these and reacts per the
|
|
20
|
+
* `onUnsupported` policy instead of silently dropping them. A driver that omits
|
|
21
|
+
* `capabilities` is assumed fully capable (back-compat for existing drivers).
|
|
22
|
+
*/
|
|
23
|
+
interface DriverCapabilities {
|
|
24
|
+
/** Honors delayed delivery (`delay`). */
|
|
25
|
+
delayed: boolean;
|
|
26
|
+
/** Honors message priority. */
|
|
27
|
+
priority: boolean;
|
|
28
|
+
/** Re-runs a failed job up to `attempts` times. */
|
|
29
|
+
retries: boolean;
|
|
30
|
+
/** Waits `backoff` between retries (vs retrying immediately). */
|
|
31
|
+
backoff: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** Queue driver contract. BullMQ in production; sync in tests/dev. */
|
|
34
|
+
interface QueueDriver {
|
|
35
|
+
/** Short identifier used in diagnostics (e.g. 'bullmq', 'sync'). */
|
|
36
|
+
readonly name?: string;
|
|
37
|
+
/** What this backend honors — see {@link DriverCapabilities}. */
|
|
38
|
+
readonly capabilities?: DriverCapabilities;
|
|
39
|
+
/** Called once by the QueueManager — how to execute a received job. */
|
|
40
|
+
setExecutor(executor: JobExecutor): void;
|
|
41
|
+
add(queue: string, jobName: string, data: unknown, options: AddJobOptions): Promise<void>;
|
|
42
|
+
/** Starts a worker for the queue (no-op in the sync driver: add executes inline). */
|
|
43
|
+
startWorker(queue: string, options?: {
|
|
44
|
+
concurrency?: number;
|
|
45
|
+
}): void;
|
|
46
|
+
close(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface BullmqDriverOptions {
|
|
50
|
+
/** Redis URL (redis://... or rediss://...) or ioredis connection options. */
|
|
51
|
+
connection: string | ConnectionOptions;
|
|
52
|
+
}
|
|
53
|
+
declare class BullmqQueueDriver implements QueueDriver {
|
|
54
|
+
readonly name = "bullmq";
|
|
55
|
+
readonly capabilities: {
|
|
56
|
+
delayed: boolean;
|
|
57
|
+
priority: boolean;
|
|
58
|
+
retries: boolean;
|
|
59
|
+
backoff: boolean;
|
|
60
|
+
};
|
|
61
|
+
private readonly connection;
|
|
62
|
+
private readonly queues;
|
|
63
|
+
private readonly workers;
|
|
64
|
+
private executor;
|
|
65
|
+
constructor(options: BullmqDriverOptions);
|
|
66
|
+
setExecutor(executor: JobExecutor): void;
|
|
67
|
+
add(queue: string, jobName: string, data: unknown, options: AddJobOptions): Promise<void>;
|
|
68
|
+
startWorker(queue: string, options?: {
|
|
69
|
+
concurrency?: number;
|
|
70
|
+
}): void;
|
|
71
|
+
close(): Promise<void>;
|
|
72
|
+
private queue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Structural schema compatible with Zod. */
|
|
76
|
+
interface JobSchema<T> {
|
|
77
|
+
safeParse(input: unknown): {
|
|
78
|
+
success: boolean;
|
|
79
|
+
data?: T;
|
|
80
|
+
error?: unknown;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
declare class JobValidationError extends BasaltError {
|
|
84
|
+
readonly job: string;
|
|
85
|
+
readonly issues: unknown;
|
|
86
|
+
constructor(job: string, issues: unknown);
|
|
87
|
+
}
|
|
88
|
+
declare class JobNotRegisteredError extends BasaltError {
|
|
89
|
+
constructor(job: string);
|
|
90
|
+
}
|
|
91
|
+
interface DispatchOptions {
|
|
92
|
+
delay?: DurationInput;
|
|
93
|
+
priority?: number;
|
|
94
|
+
}
|
|
95
|
+
interface JobBackoff {
|
|
96
|
+
type: 'exponential' | 'fixed';
|
|
97
|
+
delay: DurationInput;
|
|
98
|
+
}
|
|
99
|
+
interface JobDefinition<T = unknown> {
|
|
100
|
+
readonly name: string;
|
|
101
|
+
readonly schema?: JobSchema<T> | undefined;
|
|
102
|
+
readonly queue: string;
|
|
103
|
+
readonly attempts: number;
|
|
104
|
+
readonly backoff?: JobBackoff | undefined;
|
|
105
|
+
handle(payload: T): void | Promise<void>;
|
|
106
|
+
/** Enqueues the job — available after registration in a QueueManager. */
|
|
107
|
+
dispatch(payload: T, options?: DispatchOptions): Promise<void>;
|
|
108
|
+
/** @internal used by the QueueManager when registering */
|
|
109
|
+
__bind(dispatcher: JobDispatcher): void;
|
|
110
|
+
}
|
|
111
|
+
interface JobDispatcher {
|
|
112
|
+
dispatch<T>(job: JobDefinition<T>, payload: T, options?: DispatchOptions): Promise<void>;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Defines a declarative job:
|
|
116
|
+
*
|
|
117
|
+
* export const SendWelcomeEmail = defineJob({
|
|
118
|
+
* name: 'email.welcome',
|
|
119
|
+
* schema: z.object({ userId: z.string() }),
|
|
120
|
+
* attempts: 3,
|
|
121
|
+
* backoff: { type: 'exponential', delay: '30s' },
|
|
122
|
+
* async handle({ userId }) { ... },
|
|
123
|
+
* })
|
|
124
|
+
*/
|
|
125
|
+
declare function defineJob<T = unknown>(config: {
|
|
126
|
+
name: string;
|
|
127
|
+
schema?: JobSchema<T>;
|
|
128
|
+
queue?: string;
|
|
129
|
+
attempts?: number;
|
|
130
|
+
backoff?: JobBackoff;
|
|
131
|
+
handle(payload: T): void | Promise<void>;
|
|
132
|
+
}): JobDefinition<T>;
|
|
133
|
+
|
|
134
|
+
declare class UnknownJobError extends BasaltError {
|
|
135
|
+
constructor(job: string);
|
|
136
|
+
}
|
|
137
|
+
/** A job used an option the active driver doesn't support (with policy 'throw'). */
|
|
138
|
+
declare class UnsupportedJobOptionError extends BasaltError {
|
|
139
|
+
readonly status = 500;
|
|
140
|
+
constructor(driver: string, job: string, features: string[]);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* What to do when a dispatch uses an option the driver can't honor:
|
|
144
|
+
* - `throw`: raise {@link UnsupportedJobOptionError} (strict; recommended in prod)
|
|
145
|
+
* - `warn`: log once per job+feature and proceed (default — never silent)
|
|
146
|
+
* - `ignore`: proceed silently (legacy behavior)
|
|
147
|
+
*/
|
|
148
|
+
type UnsupportedPolicy = 'throw' | 'warn' | 'ignore';
|
|
149
|
+
interface QueueManagerOptions {
|
|
150
|
+
/** Reaction when a job uses an option the driver can't honor. Default 'warn'. */
|
|
151
|
+
onUnsupported?: UnsupportedPolicy;
|
|
152
|
+
/** Sink for 'warn' diagnostics. Default console.warn. */
|
|
153
|
+
warn?: (message: string) => void;
|
|
154
|
+
}
|
|
155
|
+
declare class QueueManager implements JobDispatcher {
|
|
156
|
+
private readonly driver;
|
|
157
|
+
private readonly jobs;
|
|
158
|
+
private readonly onUnsupported;
|
|
159
|
+
private readonly warn;
|
|
160
|
+
private readonly warned;
|
|
161
|
+
constructor(driver: QueueDriver, options?: QueueManagerOptions);
|
|
162
|
+
/**
|
|
163
|
+
* Checks the dispatch's options against the driver's declared capabilities.
|
|
164
|
+
* A driver that omits `capabilities` is assumed fully capable (back-compat).
|
|
165
|
+
*/
|
|
166
|
+
private assertSupported;
|
|
167
|
+
register(job: JobDefinition<never> | JobDefinition<unknown>): this;
|
|
168
|
+
dispatch<T>(job: JobDefinition<T>, payload: T, options?: DispatchOptions): Promise<void>;
|
|
169
|
+
/** Starts a worker for the queue. With the sync driver it is a no-op. */
|
|
170
|
+
work(queue?: string, options?: {
|
|
171
|
+
concurrency?: number;
|
|
172
|
+
}): void;
|
|
173
|
+
close(): Promise<void>;
|
|
174
|
+
/** Executes a job received from the driver: validates, restores the context, runs the handler. */
|
|
175
|
+
private execute;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface QueuedListenerOptions {
|
|
179
|
+
queue?: string;
|
|
180
|
+
attempts?: number;
|
|
181
|
+
backoff?: JobBackoff;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The events→queue bridge: the listener becomes a job — emit only enqueues,
|
|
185
|
+
* and the handler runs in the worker with the driver's retry/backoff and the
|
|
186
|
+
* context (tenant/requestId) restored.
|
|
187
|
+
*
|
|
188
|
+
* queuedOn(bus, queue, OrderCreated, async ({ orderId }) => { ... })
|
|
189
|
+
*
|
|
190
|
+
* Returns the listener's unsubscribe function.
|
|
191
|
+
*/
|
|
192
|
+
declare function queuedOn<T>(bus: EventBus, manager: QueueManager, event: BasaltEvent<T>, handler: (payload: T) => void | Promise<void>, options?: QueuedListenerOptions): () => void;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Synchronous driver: executes the job inline on dispatch, honoring `attempts`
|
|
196
|
+
* (immediate retry). It is the driver for tests and Redis-less dev — the
|
|
197
|
+
* equivalent of Laravel's `sync` queue driver.
|
|
198
|
+
*/
|
|
199
|
+
declare class SyncQueueDriver implements QueueDriver {
|
|
200
|
+
readonly name = "sync";
|
|
201
|
+
readonly capabilities: {
|
|
202
|
+
delayed: boolean;
|
|
203
|
+
priority: boolean;
|
|
204
|
+
retries: boolean;
|
|
205
|
+
backoff: boolean;
|
|
206
|
+
};
|
|
207
|
+
private executor;
|
|
208
|
+
/** execution history — useful in test assertions */
|
|
209
|
+
readonly executed: {
|
|
210
|
+
queue: string;
|
|
211
|
+
jobName: string;
|
|
212
|
+
attempts: number;
|
|
213
|
+
}[];
|
|
214
|
+
setExecutor(executor: JobExecutor): void;
|
|
215
|
+
add(queue: string, jobName: string, data: unknown, options: AddJobOptions): Promise<void>;
|
|
216
|
+
startWorker(): void;
|
|
217
|
+
close(): Promise<void>;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
declare const QUEUE: _basaltkit_core.Token<QueueManager>;
|
|
221
|
+
interface QueuePluginOptions {
|
|
222
|
+
/** Jobs known to this process (producer and/or worker). */
|
|
223
|
+
jobs?: JobDefinition<unknown>[];
|
|
224
|
+
/** Redis connection → BullMQ driver. No connection → sync driver (dev/test). */
|
|
225
|
+
connection?: BullmqDriverOptions['connection'];
|
|
226
|
+
/** Custom driver — overrides `connection`. */
|
|
227
|
+
driver?: QueueDriver;
|
|
228
|
+
/** Queues to start workers for in this process at boot. */
|
|
229
|
+
workers?: {
|
|
230
|
+
queue: string;
|
|
231
|
+
concurrency?: number;
|
|
232
|
+
}[];
|
|
233
|
+
/**
|
|
234
|
+
* What to do when a job uses an option the driver can't honor (e.g. a
|
|
235
|
+
* delayed job on a driver without delayed delivery). Default 'warn' — set
|
|
236
|
+
* 'throw' in production for a hard guarantee, 'ignore' for the old behavior.
|
|
237
|
+
*/
|
|
238
|
+
onUnsupported?: UnsupportedPolicy;
|
|
239
|
+
}
|
|
240
|
+
declare function queuePlugin(options?: QueuePluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
|
|
241
|
+
|
|
242
|
+
export { type AddJobOptions, type BullmqDriverOptions, BullmqQueueDriver, type DispatchOptions, type DriverCapabilities, type JobBackoff, type JobDefinition, type JobExecutor, JobNotRegisteredError, type JobSchema, JobValidationError, QUEUE, type QueueDriver, QueueManager, type QueueManagerOptions, type QueuePluginOptions, type QueuedListenerOptions, SyncQueueDriver, UnknownJobError, UnsupportedJobOptionError, type UnsupportedPolicy, defineJob, queuePlugin, queuedOn };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createToken, definePlugin } from "@basaltkit/core";
|
|
3
|
+
|
|
4
|
+
// src/drivers/bullmq.ts
|
|
5
|
+
import { Queue, Worker } from "bullmq";
|
|
6
|
+
var BullmqQueueDriver = class {
|
|
7
|
+
name = "bullmq";
|
|
8
|
+
capabilities = { delayed: true, priority: true, retries: true, backoff: true };
|
|
9
|
+
connection;
|
|
10
|
+
queues = /* @__PURE__ */ new Map();
|
|
11
|
+
workers = [];
|
|
12
|
+
executor;
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.connection = typeof options.connection === "string" ? parseRedisUrl(options.connection) : options.connection;
|
|
15
|
+
}
|
|
16
|
+
setExecutor(executor) {
|
|
17
|
+
this.executor = executor;
|
|
18
|
+
}
|
|
19
|
+
async add(queue, jobName, data, options) {
|
|
20
|
+
await this.queue(queue).add(jobName, data, {
|
|
21
|
+
attempts: options.attempts,
|
|
22
|
+
...options.backoff ? { backoff: { type: options.backoff.type, delay: options.backoff.delayMs } } : {},
|
|
23
|
+
...options.delayMs !== void 0 ? { delay: options.delayMs } : {},
|
|
24
|
+
...options.priority !== void 0 ? { priority: options.priority } : {},
|
|
25
|
+
removeOnComplete: { count: 1e3 },
|
|
26
|
+
removeOnFail: false
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
startWorker(queue, options = {}) {
|
|
30
|
+
this.workers.push(
|
|
31
|
+
new Worker(queue, async (job) => this.executor?.(job.name, job.data), {
|
|
32
|
+
connection: this.connection,
|
|
33
|
+
concurrency: options.concurrency ?? 1
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
async close() {
|
|
38
|
+
await Promise.all(this.workers.map((worker) => worker.close()));
|
|
39
|
+
await Promise.all([...this.queues.values()].map((queue) => queue.close()));
|
|
40
|
+
}
|
|
41
|
+
queue(name) {
|
|
42
|
+
let queue = this.queues.get(name);
|
|
43
|
+
if (!queue) {
|
|
44
|
+
queue = new Queue(name, { connection: this.connection });
|
|
45
|
+
this.queues.set(name, queue);
|
|
46
|
+
}
|
|
47
|
+
return queue;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
function parseRedisUrl(url) {
|
|
51
|
+
const parsed = new URL(url);
|
|
52
|
+
return {
|
|
53
|
+
host: parsed.hostname,
|
|
54
|
+
port: parsed.port ? Number(parsed.port) : 6379,
|
|
55
|
+
...parsed.username ? { username: parsed.username } : {},
|
|
56
|
+
...parsed.password ? { password: parsed.password } : {},
|
|
57
|
+
...parsed.pathname && parsed.pathname !== "/" ? { db: Number(parsed.pathname.slice(1)) } : {},
|
|
58
|
+
...parsed.protocol === "rediss:" ? { tls: {} } : {},
|
|
59
|
+
// required by BullMQ for workers
|
|
60
|
+
maxRetriesPerRequest: null
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/drivers/sync.ts
|
|
65
|
+
var SyncQueueDriver = class {
|
|
66
|
+
name = "sync";
|
|
67
|
+
// Runs inline on dispatch: retries are honored (immediately), but there is no
|
|
68
|
+
// deferred delivery and no ordering, so delayed/priority are not supported.
|
|
69
|
+
capabilities = { delayed: false, priority: false, retries: true, backoff: false };
|
|
70
|
+
executor;
|
|
71
|
+
/** execution history — useful in test assertions */
|
|
72
|
+
executed = [];
|
|
73
|
+
setExecutor(executor) {
|
|
74
|
+
this.executor = executor;
|
|
75
|
+
}
|
|
76
|
+
async add(queue, jobName, data, options) {
|
|
77
|
+
let lastError;
|
|
78
|
+
for (let attempt = 1; attempt <= options.attempts; attempt++) {
|
|
79
|
+
try {
|
|
80
|
+
await this.executor?.(jobName, data);
|
|
81
|
+
this.executed.push({ queue, jobName, attempts: attempt });
|
|
82
|
+
return;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
lastError = error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
this.executed.push({ queue, jobName, attempts: options.attempts });
|
|
88
|
+
throw lastError;
|
|
89
|
+
}
|
|
90
|
+
startWorker() {
|
|
91
|
+
}
|
|
92
|
+
async close() {
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// src/manager.ts
|
|
97
|
+
import {
|
|
98
|
+
BasaltError as BasaltError2,
|
|
99
|
+
parseDuration,
|
|
100
|
+
runWithContext,
|
|
101
|
+
tryCtx
|
|
102
|
+
} from "@basaltkit/core";
|
|
103
|
+
|
|
104
|
+
// src/job.ts
|
|
105
|
+
import { BasaltError } from "@basaltkit/core";
|
|
106
|
+
var JobValidationError = class extends BasaltError {
|
|
107
|
+
constructor(job, issues) {
|
|
108
|
+
super("JOB_INVALID", `Invalid payload for job "${job}": ${JSON.stringify(issues)}`);
|
|
109
|
+
this.job = job;
|
|
110
|
+
this.issues = issues;
|
|
111
|
+
}
|
|
112
|
+
job;
|
|
113
|
+
issues;
|
|
114
|
+
};
|
|
115
|
+
var JobNotRegisteredError = class extends BasaltError {
|
|
116
|
+
constructor(job) {
|
|
117
|
+
super(
|
|
118
|
+
"QUEUE_JOB_NOT_REGISTERED",
|
|
119
|
+
`Job "${job}" has not been registered in a QueueManager yet. Add it to queuePlugin({ jobs: [...] }) or call manager.register(job).`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
function defineJob(config) {
|
|
124
|
+
let dispatcher;
|
|
125
|
+
const job = {
|
|
126
|
+
name: config.name,
|
|
127
|
+
schema: config.schema,
|
|
128
|
+
queue: config.queue ?? "default",
|
|
129
|
+
attempts: config.attempts ?? 1,
|
|
130
|
+
backoff: config.backoff,
|
|
131
|
+
handle: config.handle,
|
|
132
|
+
async dispatch(payload, options) {
|
|
133
|
+
if (!dispatcher) throw new JobNotRegisteredError(config.name);
|
|
134
|
+
return dispatcher.dispatch(job, payload, options);
|
|
135
|
+
},
|
|
136
|
+
__bind(d) {
|
|
137
|
+
dispatcher = d;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
return job;
|
|
141
|
+
}
|
|
142
|
+
function validatePayload(job, payload) {
|
|
143
|
+
if (!job.schema) return payload;
|
|
144
|
+
const result = job.schema.safeParse(payload);
|
|
145
|
+
if (!result.success) {
|
|
146
|
+
const issues = result.error?.issues ?? result.error ?? "unknown";
|
|
147
|
+
throw new JobValidationError(job.name, issues);
|
|
148
|
+
}
|
|
149
|
+
return result.data;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/manager.ts
|
|
153
|
+
var UnknownJobError = class extends BasaltError2 {
|
|
154
|
+
constructor(job) {
|
|
155
|
+
super(
|
|
156
|
+
"QUEUE_UNKNOWN_JOB",
|
|
157
|
+
`Job "${job}" reached the worker but is not registered in this process. Make sure the worker registers the same jobs as the producer.`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
var UnsupportedJobOptionError = class extends BasaltError2 {
|
|
162
|
+
status = 500;
|
|
163
|
+
constructor(driver, job, features) {
|
|
164
|
+
super(
|
|
165
|
+
"QUEUE_UNSUPPORTED_OPTION",
|
|
166
|
+
`The "${driver}" queue driver does not support ${features.join(", ")} (job "${job}"). Use a driver that supports it, remove the option, or set queuePlugin({ onUnsupported: 'warn' | 'ignore' }).`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
var requiredCapabilities = (options) => {
|
|
171
|
+
const needed = [];
|
|
172
|
+
if (options.delayMs !== void 0 && options.delayMs > 0) needed.push("delayed");
|
|
173
|
+
if (options.priority !== void 0) needed.push("priority");
|
|
174
|
+
if (options.attempts > 1) needed.push("retries");
|
|
175
|
+
if (options.backoff && options.attempts > 1) needed.push("backoff");
|
|
176
|
+
return needed;
|
|
177
|
+
};
|
|
178
|
+
var FEATURE_LABELS = {
|
|
179
|
+
delayed: "delayed jobs (delay)",
|
|
180
|
+
priority: "priority",
|
|
181
|
+
retries: "retries (attempts > 1)",
|
|
182
|
+
backoff: "retry backoff"
|
|
183
|
+
};
|
|
184
|
+
var SNAPSHOT_FIELDS = ["requestId", "correlationId", "traceId", "userId", "tenantId"];
|
|
185
|
+
var QueueManager = class {
|
|
186
|
+
constructor(driver, options = {}) {
|
|
187
|
+
this.driver = driver;
|
|
188
|
+
this.onUnsupported = options.onUnsupported ?? "warn";
|
|
189
|
+
this.warn = options.warn ?? ((message) => console.warn(message));
|
|
190
|
+
driver.setExecutor((jobName, data) => this.execute(jobName, data));
|
|
191
|
+
}
|
|
192
|
+
driver;
|
|
193
|
+
jobs = /* @__PURE__ */ new Map();
|
|
194
|
+
onUnsupported;
|
|
195
|
+
warn;
|
|
196
|
+
warned = /* @__PURE__ */ new Set();
|
|
197
|
+
/**
|
|
198
|
+
* Checks the dispatch's options against the driver's declared capabilities.
|
|
199
|
+
* A driver that omits `capabilities` is assumed fully capable (back-compat).
|
|
200
|
+
*/
|
|
201
|
+
assertSupported(jobName, options) {
|
|
202
|
+
const caps = this.driver.capabilities;
|
|
203
|
+
if (!caps || this.onUnsupported === "ignore") return;
|
|
204
|
+
const missing = requiredCapabilities(options).filter((cap) => !caps[cap]);
|
|
205
|
+
if (missing.length === 0) return;
|
|
206
|
+
const driverName = this.driver.name ?? "queue";
|
|
207
|
+
const features = missing.map((cap) => FEATURE_LABELS[cap]);
|
|
208
|
+
if (this.onUnsupported === "throw") throw new UnsupportedJobOptionError(driverName, jobName, features);
|
|
209
|
+
const key = `${jobName}:${missing.join(",")}`;
|
|
210
|
+
if (this.warned.has(key)) return;
|
|
211
|
+
this.warned.add(key);
|
|
212
|
+
this.warn(
|
|
213
|
+
`[basalt/queue] The "${driverName}" driver does not support ${features.join(", ")} \u2014 job "${jobName}" will run without it.`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
register(job) {
|
|
217
|
+
this.jobs.set(job.name, job);
|
|
218
|
+
job.__bind(this);
|
|
219
|
+
return this;
|
|
220
|
+
}
|
|
221
|
+
async dispatch(job, payload, options = {}) {
|
|
222
|
+
if (!this.jobs.has(job.name)) this.register(job);
|
|
223
|
+
const envelope = {
|
|
224
|
+
payload: validatePayload(job, payload),
|
|
225
|
+
context: snapshotContext()
|
|
226
|
+
};
|
|
227
|
+
const addOptions = {
|
|
228
|
+
attempts: job.attempts,
|
|
229
|
+
backoff: job.backoff ? { type: job.backoff.type, delayMs: parseDuration(job.backoff.delay) } : void 0,
|
|
230
|
+
delayMs: options.delay === void 0 ? void 0 : parseDuration(options.delay),
|
|
231
|
+
priority: options.priority
|
|
232
|
+
};
|
|
233
|
+
this.assertSupported(job.name, addOptions);
|
|
234
|
+
await this.driver.add(job.queue, job.name, envelope, addOptions);
|
|
235
|
+
}
|
|
236
|
+
/** Starts a worker for the queue. With the sync driver it is a no-op. */
|
|
237
|
+
work(queue = "default", options = {}) {
|
|
238
|
+
this.driver.startWorker(queue, options);
|
|
239
|
+
}
|
|
240
|
+
async close() {
|
|
241
|
+
await this.driver.close();
|
|
242
|
+
}
|
|
243
|
+
/** Executes a job received from the driver: validates, restores the context, runs the handler. */
|
|
244
|
+
async execute(jobName, data) {
|
|
245
|
+
const job = this.jobs.get(jobName);
|
|
246
|
+
if (!job) throw new UnknownJobError(jobName);
|
|
247
|
+
const envelope = data;
|
|
248
|
+
const payload = validatePayload(job, envelope.payload);
|
|
249
|
+
await runWithContext({ ...envelope.context ?? {} }, () => job.handle(payload));
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
function snapshotContext() {
|
|
253
|
+
const context = tryCtx();
|
|
254
|
+
if (!context) return void 0;
|
|
255
|
+
const snapshot = {};
|
|
256
|
+
for (const field of SNAPSHOT_FIELDS) {
|
|
257
|
+
if (context[field] !== void 0) snapshot[field] = context[field];
|
|
258
|
+
}
|
|
259
|
+
const tenant = context["tenant"];
|
|
260
|
+
if (tenant?.id) snapshot["tenant"] = { id: tenant.id };
|
|
261
|
+
const user = context["user"];
|
|
262
|
+
if (user?.id && snapshot["userId"] === void 0) snapshot["userId"] = user.id;
|
|
263
|
+
return Object.keys(snapshot).length > 0 ? snapshot : void 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// src/bridge.ts
|
|
267
|
+
function queuedOn(bus, manager, event, handler, options = {}) {
|
|
268
|
+
const job = defineJob({
|
|
269
|
+
name: `listener:${event.name}`,
|
|
270
|
+
...event.schema ? { schema: event.schema } : {},
|
|
271
|
+
...options.queue ? { queue: options.queue } : {},
|
|
272
|
+
...options.attempts !== void 0 ? { attempts: options.attempts } : {},
|
|
273
|
+
...options.backoff ? { backoff: options.backoff } : {},
|
|
274
|
+
handle: handler
|
|
275
|
+
});
|
|
276
|
+
manager.register(job);
|
|
277
|
+
return bus.on(event, async (payload) => {
|
|
278
|
+
await job.dispatch(payload);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/index.ts
|
|
283
|
+
var QUEUE = createToken("queue");
|
|
284
|
+
function queuePlugin(options = {}) {
|
|
285
|
+
return definePlugin({
|
|
286
|
+
name: "basalt:queue",
|
|
287
|
+
register({ container }) {
|
|
288
|
+
container.singleton(QUEUE, () => {
|
|
289
|
+
const driver = options.driver ?? (options.connection ? new BullmqQueueDriver({ connection: options.connection }) : new SyncQueueDriver());
|
|
290
|
+
const manager = new QueueManager(
|
|
291
|
+
driver,
|
|
292
|
+
options.onUnsupported !== void 0 ? { onUnsupported: options.onUnsupported } : {}
|
|
293
|
+
);
|
|
294
|
+
for (const job of options.jobs ?? []) manager.register(job);
|
|
295
|
+
return manager;
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
boot({ container }) {
|
|
299
|
+
const manager = container.get(QUEUE);
|
|
300
|
+
for (const worker of options.workers ?? []) {
|
|
301
|
+
manager.work(
|
|
302
|
+
worker.queue,
|
|
303
|
+
worker.concurrency !== void 0 ? { concurrency: worker.concurrency } : {}
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
async shutdown({ container }) {
|
|
308
|
+
await container.get(QUEUE).close();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
export {
|
|
313
|
+
BullmqQueueDriver,
|
|
314
|
+
JobNotRegisteredError,
|
|
315
|
+
JobValidationError,
|
|
316
|
+
QUEUE,
|
|
317
|
+
QueueManager,
|
|
318
|
+
SyncQueueDriver,
|
|
319
|
+
UnknownJobError,
|
|
320
|
+
UnsupportedJobOptionError,
|
|
321
|
+
defineJob,
|
|
322
|
+
queuePlugin,
|
|
323
|
+
queuedOn
|
|
324
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@basaltkit/queue",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Basalt queues on top of BullMQ: declarative jobs with Zod payloads, context propagation (tenant/requestId) to workers and a sync driver for tests.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"bullmq": "^5.44.0",
|
|
18
|
+
"@basaltkit/core": "^1.0.0",
|
|
19
|
+
"@basaltkit/events": "^1.0.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^22.15.0",
|
|
23
|
+
"tsup": "^8.4.0",
|
|
24
|
+
"typescript": "^5.8.0",
|
|
25
|
+
"vitest": "^3.1.0",
|
|
26
|
+
"zod": "^3.24.0",
|
|
27
|
+
"@basaltkit/tsconfig": "^0.24.0"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/Zebedeu/basalt.git",
|
|
35
|
+
"directory": "packages/queue"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/queue#readme",
|
|
38
|
+
"bugs": "https://github.com/Zebedeu/basalt/issues",
|
|
39
|
+
"keywords": [
|
|
40
|
+
"basalt",
|
|
41
|
+
"typescript",
|
|
42
|
+
"queue",
|
|
43
|
+
"jobs",
|
|
44
|
+
"bullmq"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"typecheck": "tsc --noEmit"
|
|
50
|
+
}
|
|
51
|
+
}
|