@basaltkit/queue 1.1.0 → 1.2.1
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 +20 -0
- package/dist/bridge.d.ts +18 -0
- package/dist/bridge.js +24 -0
- package/dist/driver.d.ts +72 -0
- package/dist/driver.js +1 -0
- package/dist/drivers/bullmq.d.ts +31 -0
- package/dist/drivers/bullmq.js +94 -0
- package/dist/drivers/sync.d.ts +26 -0
- package/dist/drivers/sync.js +36 -0
- package/dist/index.d.ts +13 -255
- package/dist/index.js +85 -347
- package/dist/job.d.ts +76 -0
- package/dist/job.js +59 -0
- package/dist/manager.d.ts +61 -0
- package/dist/manager.js +161 -0
- package/package.json +12 -13
package/dist/index.js
CHANGED
|
@@ -1,350 +1,88 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
1
|
+
import { createToken, definePlugin, ensureMetadata } from '@basaltkit/core';
|
|
2
|
+
import { BullmqQueueDriver } from './drivers/bullmq.js';
|
|
3
|
+
import { SyncQueueDriver } from './drivers/sync.js';
|
|
4
|
+
import { QueueManager } from './manager.js';
|
|
5
|
+
export { defineJob, JobValidationError, JobNotRegisteredError, } from './job.js';
|
|
6
|
+
export { QueueManager, UnknownJobError, UnsupportedJobOptionError, } from './manager.js';
|
|
7
|
+
export { queuedOn } from './bridge.js';
|
|
8
|
+
export { SyncQueueDriver } from './drivers/sync.js';
|
|
9
|
+
export { BullmqQueueDriver } from './drivers/bullmq.js';
|
|
10
|
+
export const QUEUE = createToken('queue');
|
|
11
|
+
export function queuePlugin(options = {}) {
|
|
12
|
+
return definePlugin({
|
|
13
|
+
name: 'basalt:queue',
|
|
14
|
+
register({ container }) {
|
|
15
|
+
registerQueueCommands(container);
|
|
16
|
+
container.singleton(QUEUE, () => {
|
|
17
|
+
const driver = options.driver ??
|
|
18
|
+
(options.connection
|
|
19
|
+
? new BullmqQueueDriver({ connection: options.connection })
|
|
20
|
+
: new SyncQueueDriver());
|
|
21
|
+
const manager = new QueueManager(driver, {
|
|
22
|
+
...(options.onUnsupported !== undefined ? { onUnsupported: options.onUnsupported } : {}),
|
|
23
|
+
...(options.removeOnComplete !== undefined ? { removeOnComplete: options.removeOnComplete } : {}),
|
|
24
|
+
...(options.removeOnFail !== undefined ? { removeOnFail: options.removeOnFail } : {}),
|
|
25
|
+
});
|
|
26
|
+
for (const job of options.jobs ?? [])
|
|
27
|
+
manager.register(job);
|
|
28
|
+
return manager;
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
boot({ container }) {
|
|
32
|
+
const manager = container.get(QUEUE);
|
|
33
|
+
for (const worker of options.workers ?? []) {
|
|
34
|
+
manager.work(worker.queue, worker.concurrency !== undefined ? { concurrency: worker.concurrency } : {});
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
async shutdown({ container }) {
|
|
38
|
+
await container.get(QUEUE).close();
|
|
39
|
+
},
|
|
35
40
|
});
|
|
36
|
-
}
|
|
37
|
-
startWorker(queue, options = {}) {
|
|
38
|
-
this.workers.push(
|
|
39
|
-
new Worker(queue, async (job) => this.executor?.(job.name, job.data), {
|
|
40
|
-
connection: this.connection,
|
|
41
|
-
concurrency: options.concurrency ?? 1
|
|
42
|
-
})
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
async close() {
|
|
46
|
-
await Promise.all(this.workers.map((worker) => worker.close()));
|
|
47
|
-
await Promise.all([...this.queues.values()].map((queue) => queue.close()));
|
|
48
|
-
}
|
|
49
|
-
queue(name) {
|
|
50
|
-
let queue = this.queues.get(name);
|
|
51
|
-
if (!queue) {
|
|
52
|
-
queue = new Queue(name, { connection: this.connection });
|
|
53
|
-
this.queues.set(name, queue);
|
|
54
|
-
}
|
|
55
|
-
return queue;
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
function parseRedisUrl(url) {
|
|
59
|
-
const parsed = new URL(url);
|
|
60
|
-
return {
|
|
61
|
-
host: parsed.hostname,
|
|
62
|
-
port: parsed.port ? Number(parsed.port) : 6379,
|
|
63
|
-
...parsed.username ? { username: parsed.username } : {},
|
|
64
|
-
...parsed.password ? { password: parsed.password } : {},
|
|
65
|
-
...parsed.pathname && parsed.pathname !== "/" ? { db: Number(parsed.pathname.slice(1)) } : {},
|
|
66
|
-
...parsed.protocol === "rediss:" ? { tls: {} } : {},
|
|
67
|
-
// required by BullMQ for workers
|
|
68
|
-
maxRetriesPerRequest: null
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// src/drivers/sync.ts
|
|
73
|
-
var SyncQueueDriver = class {
|
|
74
|
-
name = "sync";
|
|
75
|
-
// Runs inline on dispatch: retries are honored (immediately), but there is no
|
|
76
|
-
// deferred delivery and no ordering, so delayed/priority are not supported.
|
|
77
|
-
capabilities = { delayed: false, priority: false, retries: true, backoff: false };
|
|
78
|
-
executor;
|
|
79
|
-
/** execution history — useful in test assertions */
|
|
80
|
-
executed = [];
|
|
81
|
-
setExecutor(executor) {
|
|
82
|
-
this.executor = executor;
|
|
83
|
-
}
|
|
84
|
-
async add(queue, jobName, data, options) {
|
|
85
|
-
let lastError;
|
|
86
|
-
for (let attempt = 1; attempt <= options.attempts; attempt++) {
|
|
87
|
-
try {
|
|
88
|
-
await this.executor?.(jobName, data);
|
|
89
|
-
this.executed.push({ queue, jobName, attempts: attempt });
|
|
90
|
-
return;
|
|
91
|
-
} catch (error) {
|
|
92
|
-
lastError = error;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
this.executed.push({ queue, jobName, attempts: options.attempts });
|
|
96
|
-
throw lastError;
|
|
97
|
-
}
|
|
98
|
-
startWorker() {
|
|
99
|
-
}
|
|
100
|
-
async close() {
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
// src/manager.ts
|
|
105
|
-
import {
|
|
106
|
-
BasaltError as BasaltError2,
|
|
107
|
-
parseDuration,
|
|
108
|
-
runWithContext,
|
|
109
|
-
tryCtx
|
|
110
|
-
} from "@basaltkit/core";
|
|
111
|
-
|
|
112
|
-
// src/job.ts
|
|
113
|
-
import { BasaltError } from "@basaltkit/core";
|
|
114
|
-
var JobValidationError = class extends BasaltError {
|
|
115
|
-
constructor(job, issues) {
|
|
116
|
-
super("JOB_INVALID", `Invalid payload for job "${job}": ${JSON.stringify(issues)}`);
|
|
117
|
-
this.job = job;
|
|
118
|
-
this.issues = issues;
|
|
119
|
-
}
|
|
120
|
-
job;
|
|
121
|
-
issues;
|
|
122
|
-
};
|
|
123
|
-
var JobNotRegisteredError = class extends BasaltError {
|
|
124
|
-
constructor(job) {
|
|
125
|
-
super(
|
|
126
|
-
"QUEUE_JOB_NOT_REGISTERED",
|
|
127
|
-
`Job "${job}" has not been registered in a QueueManager yet. Add it to queuePlugin({ jobs: [...] }) or call manager.register(job).`
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
function defineJob(config) {
|
|
132
|
-
let dispatcher;
|
|
133
|
-
const job = {
|
|
134
|
-
name: config.name,
|
|
135
|
-
schema: config.schema,
|
|
136
|
-
queue: config.queue ?? "default",
|
|
137
|
-
attempts: config.attempts ?? 1,
|
|
138
|
-
backoff: config.backoff,
|
|
139
|
-
removeOnComplete: config.removeOnComplete,
|
|
140
|
-
removeOnFail: config.removeOnFail,
|
|
141
|
-
handle: config.handle,
|
|
142
|
-
async dispatch(payload, options) {
|
|
143
|
-
if (!dispatcher) throw new JobNotRegisteredError(config.name);
|
|
144
|
-
return dispatcher.dispatch(job, payload, options);
|
|
145
|
-
},
|
|
146
|
-
__bind(d) {
|
|
147
|
-
dispatcher = d;
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
return job;
|
|
151
|
-
}
|
|
152
|
-
function validatePayload(job, payload) {
|
|
153
|
-
if (!job.schema) return payload;
|
|
154
|
-
const result = job.schema.safeParse(payload);
|
|
155
|
-
if (!result.success) {
|
|
156
|
-
const issues = result.error?.issues ?? result.error ?? "unknown";
|
|
157
|
-
throw new JobValidationError(job.name, issues);
|
|
158
|
-
}
|
|
159
|
-
return result.data;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// src/manager.ts
|
|
163
|
-
function resolveRetention(retention) {
|
|
164
|
-
if (retention === void 0) return void 0;
|
|
165
|
-
if (typeof retention === "boolean" || typeof retention === "number") return retention;
|
|
166
|
-
const out = {};
|
|
167
|
-
if (retention.age !== void 0) out.ageMs = parseDuration(retention.age);
|
|
168
|
-
if (retention.count !== void 0) out.count = retention.count;
|
|
169
|
-
return out;
|
|
170
|
-
}
|
|
171
|
-
var UnknownJobError = class extends BasaltError2 {
|
|
172
|
-
constructor(job) {
|
|
173
|
-
super(
|
|
174
|
-
"QUEUE_UNKNOWN_JOB",
|
|
175
|
-
`Job "${job}" reached the worker but is not registered in this process. Make sure the worker registers the same jobs as the producer.`
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
|
-
var UnsupportedJobOptionError = class extends BasaltError2 {
|
|
180
|
-
status = 500;
|
|
181
|
-
constructor(driver, job, features) {
|
|
182
|
-
super(
|
|
183
|
-
"QUEUE_UNSUPPORTED_OPTION",
|
|
184
|
-
`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' }).`
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
};
|
|
188
|
-
var requiredCapabilities = (options) => {
|
|
189
|
-
const needed = [];
|
|
190
|
-
if (options.delayMs !== void 0 && options.delayMs > 0) needed.push("delayed");
|
|
191
|
-
if (options.priority !== void 0) needed.push("priority");
|
|
192
|
-
if (options.attempts > 1) needed.push("retries");
|
|
193
|
-
if (options.backoff && options.attempts > 1) needed.push("backoff");
|
|
194
|
-
return needed;
|
|
195
|
-
};
|
|
196
|
-
var FEATURE_LABELS = {
|
|
197
|
-
delayed: "delayed jobs (delay)",
|
|
198
|
-
priority: "priority",
|
|
199
|
-
retries: "retries (attempts > 1)",
|
|
200
|
-
backoff: "retry backoff"
|
|
201
|
-
};
|
|
202
|
-
var SNAPSHOT_FIELDS = ["requestId", "correlationId", "traceId", "userId", "tenantId"];
|
|
203
|
-
var QueueManager = class {
|
|
204
|
-
constructor(driver, options = {}) {
|
|
205
|
-
this.driver = driver;
|
|
206
|
-
this.onUnsupported = options.onUnsupported ?? "warn";
|
|
207
|
-
this.warn = options.warn ?? ((message) => console.warn(message));
|
|
208
|
-
this.defaultRemoveOnComplete = options.removeOnComplete;
|
|
209
|
-
this.defaultRemoveOnFail = options.removeOnFail;
|
|
210
|
-
driver.setExecutor((jobName, data) => this.execute(jobName, data));
|
|
211
|
-
}
|
|
212
|
-
driver;
|
|
213
|
-
jobs = /* @__PURE__ */ new Map();
|
|
214
|
-
onUnsupported;
|
|
215
|
-
warn;
|
|
216
|
-
warned = /* @__PURE__ */ new Set();
|
|
217
|
-
defaultRemoveOnComplete;
|
|
218
|
-
defaultRemoveOnFail;
|
|
219
|
-
/**
|
|
220
|
-
* Checks the dispatch's options against the driver's declared capabilities.
|
|
221
|
-
* A driver that omits `capabilities` is assumed fully capable (back-compat).
|
|
222
|
-
*/
|
|
223
|
-
assertSupported(jobName, options) {
|
|
224
|
-
const caps = this.driver.capabilities;
|
|
225
|
-
if (!caps || this.onUnsupported === "ignore") return;
|
|
226
|
-
const missing = requiredCapabilities(options).filter((cap) => !caps[cap]);
|
|
227
|
-
if (missing.length === 0) return;
|
|
228
|
-
const driverName = this.driver.name ?? "queue";
|
|
229
|
-
const features = missing.map((cap) => FEATURE_LABELS[cap]);
|
|
230
|
-
if (this.onUnsupported === "throw") throw new UnsupportedJobOptionError(driverName, jobName, features);
|
|
231
|
-
const key = `${jobName}:${missing.join(",")}`;
|
|
232
|
-
if (this.warned.has(key)) return;
|
|
233
|
-
this.warned.add(key);
|
|
234
|
-
this.warn(
|
|
235
|
-
`[basalt/queue] The "${driverName}" driver does not support ${features.join(", ")} \u2014 job "${jobName}" will run without it.`
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
register(job) {
|
|
239
|
-
this.jobs.set(job.name, job);
|
|
240
|
-
job.__bind(this);
|
|
241
|
-
return this;
|
|
242
|
-
}
|
|
243
|
-
async dispatch(job, payload, options = {}) {
|
|
244
|
-
if (!this.jobs.has(job.name)) this.register(job);
|
|
245
|
-
const envelope = {
|
|
246
|
-
payload: validatePayload(job, payload),
|
|
247
|
-
context: snapshotContext()
|
|
248
|
-
};
|
|
249
|
-
const addOptions = {
|
|
250
|
-
attempts: job.attempts,
|
|
251
|
-
backoff: job.backoff ? { type: job.backoff.type, delayMs: parseDuration(job.backoff.delay) } : void 0,
|
|
252
|
-
delayMs: options.delay === void 0 ? void 0 : parseDuration(options.delay),
|
|
253
|
-
priority: options.priority,
|
|
254
|
-
// Per-job overrides the queuePlugin default; undefined leaves the driver default.
|
|
255
|
-
removeOnComplete: resolveRetention(job.removeOnComplete ?? this.defaultRemoveOnComplete),
|
|
256
|
-
removeOnFail: resolveRetention(job.removeOnFail ?? this.defaultRemoveOnFail)
|
|
257
|
-
};
|
|
258
|
-
this.assertSupported(job.name, addOptions);
|
|
259
|
-
await this.driver.add(job.queue, job.name, envelope, addOptions);
|
|
260
|
-
}
|
|
261
|
-
/** Starts a worker for the queue. With the sync driver it is a no-op. */
|
|
262
|
-
work(queue = "default", options = {}) {
|
|
263
|
-
this.driver.startWorker(queue, options);
|
|
264
|
-
}
|
|
265
|
-
async close() {
|
|
266
|
-
await this.driver.close();
|
|
267
|
-
}
|
|
268
|
-
/** Executes a job received from the driver: validates, restores the context, runs the handler. */
|
|
269
|
-
async execute(jobName, data) {
|
|
270
|
-
const job = this.jobs.get(jobName);
|
|
271
|
-
if (!job) throw new UnknownJobError(jobName);
|
|
272
|
-
const envelope = data;
|
|
273
|
-
const payload = validatePayload(job, envelope.payload);
|
|
274
|
-
await runWithContext({ ...envelope.context ?? {} }, () => job.handle(payload));
|
|
275
|
-
}
|
|
276
|
-
};
|
|
277
|
-
function snapshotContext() {
|
|
278
|
-
const context = tryCtx();
|
|
279
|
-
if (!context) return void 0;
|
|
280
|
-
const snapshot = {};
|
|
281
|
-
for (const field of SNAPSHOT_FIELDS) {
|
|
282
|
-
if (context[field] !== void 0) snapshot[field] = context[field];
|
|
283
|
-
}
|
|
284
|
-
const tenant = context["tenant"];
|
|
285
|
-
if (tenant?.id) snapshot["tenant"] = { id: tenant.id };
|
|
286
|
-
const user = context["user"];
|
|
287
|
-
if (user?.id && snapshot["userId"] === void 0) snapshot["userId"] = user.id;
|
|
288
|
-
return Object.keys(snapshot).length > 0 ? snapshot : void 0;
|
|
289
41
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
});
|
|
42
|
+
/**
|
|
43
|
+
* Registers `queue:work`, `queue:stats` and `queue:retry` into the CLI command
|
|
44
|
+
* bucket. Commands resolve the manager lazily, so they work with whatever driver
|
|
45
|
+
* the app configured. Registered structurally to avoid a hard @basaltkit/cli dep.
|
|
46
|
+
*/
|
|
47
|
+
function registerQueueCommands(container) {
|
|
48
|
+
const manager = () => container.get(QUEUE);
|
|
49
|
+
const unsupported = 'Not supported by the active queue driver — the inline sync driver keeps no job state. Use the BullMQ driver (a Redis `connection`).';
|
|
50
|
+
ensureMetadata(container).add('commands', {
|
|
51
|
+
name: 'queue:work',
|
|
52
|
+
description: 'Run a worker that processes jobs for a queue (Ctrl+C to stop)',
|
|
53
|
+
async handle({ io, flags }) {
|
|
54
|
+
const queue = typeof flags['queue'] === 'string' ? flags['queue'] : 'default';
|
|
55
|
+
const concurrency = typeof flags['concurrency'] === 'string' ? Number(flags['concurrency']) : undefined;
|
|
56
|
+
manager().work(queue, concurrency !== undefined ? { concurrency } : {});
|
|
57
|
+
io.log(`Worker started on queue "${queue}"${concurrency ? ` (concurrency ${concurrency})` : ''}. Ctrl+C to stop.`);
|
|
58
|
+
await new Promise((resolve) => process.once('SIGINT', resolve));
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
ensureMetadata(container).add('commands', {
|
|
62
|
+
name: 'queue:stats',
|
|
63
|
+
description: 'Show job counts (waiting/active/completed/failed/delayed) for a queue',
|
|
64
|
+
async handle({ io, flags, }) {
|
|
65
|
+
const queue = typeof flags['queue'] === 'string' ? flags['queue'] : 'default';
|
|
66
|
+
const stats = await manager().stats(queue);
|
|
67
|
+
if (!stats) {
|
|
68
|
+
io.log(unsupported);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
io.table([{ queue, ...stats }]);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
ensureMetadata(container).add('commands', {
|
|
75
|
+
name: 'queue:retry',
|
|
76
|
+
description: 'Re-enqueue failed jobs on a queue',
|
|
77
|
+
async handle({ io, flags, }) {
|
|
78
|
+
const queue = typeof flags['queue'] === 'string' ? flags['queue'] : 'default';
|
|
79
|
+
const limit = typeof flags['limit'] === 'string' ? Number(flags['limit']) : undefined;
|
|
80
|
+
const retried = await manager().retryFailed(queue, limit !== undefined ? { limit } : {});
|
|
81
|
+
if (retried === undefined) {
|
|
82
|
+
io.log(unsupported);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
io.log(`Re-enqueued ${retried} failed job(s) on "${queue}".`);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
337
88
|
}
|
|
338
|
-
export {
|
|
339
|
-
BullmqQueueDriver,
|
|
340
|
-
JobNotRegisteredError,
|
|
341
|
-
JobValidationError,
|
|
342
|
-
QUEUE,
|
|
343
|
-
QueueManager,
|
|
344
|
-
SyncQueueDriver,
|
|
345
|
-
UnknownJobError,
|
|
346
|
-
UnsupportedJobOptionError,
|
|
347
|
-
defineJob,
|
|
348
|
-
queuePlugin,
|
|
349
|
-
queuedOn
|
|
350
|
-
};
|
package/dist/job.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { BasaltError, type DurationInput } from '@basaltkit/core';
|
|
2
|
+
/** Structural schema compatible with Zod. */
|
|
3
|
+
export interface JobSchema<T> {
|
|
4
|
+
safeParse(input: unknown): {
|
|
5
|
+
success: boolean;
|
|
6
|
+
data?: T;
|
|
7
|
+
error?: unknown;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export declare class JobValidationError extends BasaltError {
|
|
11
|
+
readonly job: string;
|
|
12
|
+
readonly issues: unknown;
|
|
13
|
+
constructor(job: string, issues: unknown);
|
|
14
|
+
}
|
|
15
|
+
export declare class JobNotRegisteredError extends BasaltError {
|
|
16
|
+
constructor(job: string);
|
|
17
|
+
}
|
|
18
|
+
export interface DispatchOptions {
|
|
19
|
+
delay?: DurationInput;
|
|
20
|
+
priority?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface JobBackoff {
|
|
23
|
+
type: 'exponential' | 'fixed';
|
|
24
|
+
delay: DurationInput;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Redis retention for finished jobs (BullMQ driver): `true` removes it as soon as
|
|
28
|
+
* it finishes, `false` keeps it forever, a number keeps that many most-recent, and
|
|
29
|
+
* `{ age, count }` keeps by age and/or count. Defaults: completed `{ count: 1000 }`,
|
|
30
|
+
* failed `false` (keep all). The sync driver ignores it (it stores nothing).
|
|
31
|
+
*/
|
|
32
|
+
export type JobRetention = boolean | number | {
|
|
33
|
+
age?: DurationInput;
|
|
34
|
+
count?: number;
|
|
35
|
+
};
|
|
36
|
+
export interface JobDefinition<T = unknown> {
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly schema?: JobSchema<T> | undefined;
|
|
39
|
+
readonly queue: string;
|
|
40
|
+
readonly attempts: number;
|
|
41
|
+
readonly backoff?: JobBackoff | undefined;
|
|
42
|
+
/** Retention for completed jobs. Overrides the queuePlugin default. */
|
|
43
|
+
readonly removeOnComplete?: JobRetention | undefined;
|
|
44
|
+
/** Retention for failed jobs. Overrides the queuePlugin default. */
|
|
45
|
+
readonly removeOnFail?: JobRetention | undefined;
|
|
46
|
+
handle(payload: T): void | Promise<void>;
|
|
47
|
+
/** Enqueues the job — available after registration in a QueueManager. */
|
|
48
|
+
dispatch(payload: T, options?: DispatchOptions): Promise<void>;
|
|
49
|
+
/** @internal used by the QueueManager when registering */
|
|
50
|
+
__bind(dispatcher: JobDispatcher): void;
|
|
51
|
+
}
|
|
52
|
+
export interface JobDispatcher {
|
|
53
|
+
dispatch<T>(job: JobDefinition<T>, payload: T, options?: DispatchOptions): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Defines a declarative job:
|
|
57
|
+
*
|
|
58
|
+
* export const SendWelcomeEmail = defineJob({
|
|
59
|
+
* name: 'email.welcome',
|
|
60
|
+
* schema: z.object({ userId: z.string() }),
|
|
61
|
+
* attempts: 3,
|
|
62
|
+
* backoff: { type: 'exponential', delay: '30s' },
|
|
63
|
+
* async handle({ userId }) { ... },
|
|
64
|
+
* })
|
|
65
|
+
*/
|
|
66
|
+
export declare function defineJob<T = unknown>(config: {
|
|
67
|
+
name: string;
|
|
68
|
+
schema?: JobSchema<T>;
|
|
69
|
+
queue?: string;
|
|
70
|
+
attempts?: number;
|
|
71
|
+
backoff?: JobBackoff;
|
|
72
|
+
removeOnComplete?: JobRetention;
|
|
73
|
+
removeOnFail?: JobRetention;
|
|
74
|
+
handle(payload: T): void | Promise<void>;
|
|
75
|
+
}): JobDefinition<T>;
|
|
76
|
+
export declare function validatePayload<T>(job: JobDefinition<T>, payload: unknown): T;
|
package/dist/job.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { BasaltError } from '@basaltkit/core';
|
|
2
|
+
export class JobValidationError extends BasaltError {
|
|
3
|
+
job;
|
|
4
|
+
issues;
|
|
5
|
+
constructor(job, issues) {
|
|
6
|
+
super('JOB_INVALID', `Invalid payload for job "${job}": ${JSON.stringify(issues)}`);
|
|
7
|
+
this.job = job;
|
|
8
|
+
this.issues = issues;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class JobNotRegisteredError extends BasaltError {
|
|
12
|
+
constructor(job) {
|
|
13
|
+
super('QUEUE_JOB_NOT_REGISTERED', `Job "${job}" has not been registered in a QueueManager yet. ` +
|
|
14
|
+
'Add it to queuePlugin({ jobs: [...] }) or call manager.register(job).');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Defines a declarative job:
|
|
19
|
+
*
|
|
20
|
+
* export const SendWelcomeEmail = defineJob({
|
|
21
|
+
* name: 'email.welcome',
|
|
22
|
+
* schema: z.object({ userId: z.string() }),
|
|
23
|
+
* attempts: 3,
|
|
24
|
+
* backoff: { type: 'exponential', delay: '30s' },
|
|
25
|
+
* async handle({ userId }) { ... },
|
|
26
|
+
* })
|
|
27
|
+
*/
|
|
28
|
+
export function defineJob(config) {
|
|
29
|
+
let dispatcher;
|
|
30
|
+
const job = {
|
|
31
|
+
name: config.name,
|
|
32
|
+
schema: config.schema,
|
|
33
|
+
queue: config.queue ?? 'default',
|
|
34
|
+
attempts: config.attempts ?? 1,
|
|
35
|
+
backoff: config.backoff,
|
|
36
|
+
removeOnComplete: config.removeOnComplete,
|
|
37
|
+
removeOnFail: config.removeOnFail,
|
|
38
|
+
handle: config.handle,
|
|
39
|
+
async dispatch(payload, options) {
|
|
40
|
+
if (!dispatcher)
|
|
41
|
+
throw new JobNotRegisteredError(config.name);
|
|
42
|
+
return dispatcher.dispatch(job, payload, options);
|
|
43
|
+
},
|
|
44
|
+
__bind(d) {
|
|
45
|
+
dispatcher = d;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
return job;
|
|
49
|
+
}
|
|
50
|
+
export function validatePayload(job, payload) {
|
|
51
|
+
if (!job.schema)
|
|
52
|
+
return payload;
|
|
53
|
+
const result = job.schema.safeParse(payload);
|
|
54
|
+
if (!result.success) {
|
|
55
|
+
const issues = result.error?.issues ?? result.error ?? 'unknown';
|
|
56
|
+
throw new JobValidationError(job.name, issues);
|
|
57
|
+
}
|
|
58
|
+
return result.data;
|
|
59
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { BasaltError } from '@basaltkit/core';
|
|
2
|
+
import type { QueueDriver, QueueStats } from './driver.js';
|
|
3
|
+
import { type DispatchOptions, type JobDefinition, type JobDispatcher, type JobRetention } from './job.js';
|
|
4
|
+
export declare class UnknownJobError extends BasaltError {
|
|
5
|
+
constructor(job: string);
|
|
6
|
+
}
|
|
7
|
+
/** A job used an option the active driver doesn't support (with policy 'throw'). */
|
|
8
|
+
export declare class UnsupportedJobOptionError extends BasaltError {
|
|
9
|
+
readonly status = 500;
|
|
10
|
+
constructor(driver: string, job: string, features: string[]);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* What to do when a dispatch uses an option the driver can't honor:
|
|
14
|
+
* - `throw`: raise {@link UnsupportedJobOptionError} (strict; recommended in prod)
|
|
15
|
+
* - `warn`: log once per job+feature and proceed (default — never silent)
|
|
16
|
+
* - `ignore`: proceed silently (legacy behavior)
|
|
17
|
+
*/
|
|
18
|
+
export type UnsupportedPolicy = 'throw' | 'warn' | 'ignore';
|
|
19
|
+
export interface QueueManagerOptions {
|
|
20
|
+
/** Reaction when a job uses an option the driver can't honor. Default 'warn'. */
|
|
21
|
+
onUnsupported?: UnsupportedPolicy;
|
|
22
|
+
/** Sink for 'warn' diagnostics. Default console.warn. */
|
|
23
|
+
warn?: (message: string) => void;
|
|
24
|
+
/** Default retention for completed jobs (a job can override). Driver default: keep 1000. */
|
|
25
|
+
removeOnComplete?: JobRetention;
|
|
26
|
+
/** Default retention for failed jobs (a job can override). Driver default: keep all. */
|
|
27
|
+
removeOnFail?: JobRetention;
|
|
28
|
+
}
|
|
29
|
+
export declare class QueueManager implements JobDispatcher {
|
|
30
|
+
private readonly driver;
|
|
31
|
+
private readonly jobs;
|
|
32
|
+
private readonly onUnsupported;
|
|
33
|
+
private readonly warn;
|
|
34
|
+
private readonly warned;
|
|
35
|
+
private readonly defaultRemoveOnComplete;
|
|
36
|
+
private readonly defaultRemoveOnFail;
|
|
37
|
+
constructor(driver: QueueDriver, options?: QueueManagerOptions);
|
|
38
|
+
/**
|
|
39
|
+
* Checks the dispatch's options against the driver's declared capabilities.
|
|
40
|
+
* A driver that omits `capabilities` is assumed fully capable (back-compat).
|
|
41
|
+
*/
|
|
42
|
+
private assertSupported;
|
|
43
|
+
register(job: JobDefinition<never> | JobDefinition<unknown>): this;
|
|
44
|
+
dispatch<T>(job: JobDefinition<T>, payload: T, options?: DispatchOptions): Promise<void>;
|
|
45
|
+
/** Starts a worker for the queue. With the sync driver it is a no-op. */
|
|
46
|
+
work(queue?: string, options?: {
|
|
47
|
+
concurrency?: number;
|
|
48
|
+
}): void;
|
|
49
|
+
/** Job counts per state, or `undefined` if the driver can't introspect. */
|
|
50
|
+
stats(queue?: string): Promise<QueueStats | undefined>;
|
|
51
|
+
/**
|
|
52
|
+
* Re-enqueues failed jobs; returns the count, or `undefined` if the driver
|
|
53
|
+
* doesn't support retrying (e.g. the inline sync driver).
|
|
54
|
+
*/
|
|
55
|
+
retryFailed(queue?: string, options?: {
|
|
56
|
+
limit?: number;
|
|
57
|
+
}): Promise<number | undefined>;
|
|
58
|
+
close(): Promise<void>;
|
|
59
|
+
/** Executes a job received from the driver: validates, restores the context, runs the handler. */
|
|
60
|
+
private execute;
|
|
61
|
+
}
|