@basaltkit/queue 1.2.0 → 1.3.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 +6 -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 +53 -0
- package/dist/drivers/bullmq.js +113 -0
- package/dist/drivers/sync.d.ts +26 -0
- package/dist/drivers/sync.js +36 -0
- package/dist/index.d.ts +13 -289
- package/dist/index.js +84 -426
- 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
|
@@ -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
|
+
}
|
package/dist/manager.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { BasaltError, parseDuration, runWithContext, tryCtx, } from '@basaltkit/core';
|
|
2
|
+
import { validatePayload, } from './job.js';
|
|
3
|
+
/** Convert a public {@link JobRetention} to the driver-neutral shape (age → ms). */
|
|
4
|
+
function resolveRetention(retention) {
|
|
5
|
+
if (retention === undefined)
|
|
6
|
+
return undefined;
|
|
7
|
+
if (typeof retention === 'boolean' || typeof retention === 'number')
|
|
8
|
+
return retention;
|
|
9
|
+
const out = {};
|
|
10
|
+
if (retention.age !== undefined)
|
|
11
|
+
out.ageMs = parseDuration(retention.age);
|
|
12
|
+
if (retention.count !== undefined)
|
|
13
|
+
out.count = retention.count;
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
export class UnknownJobError extends BasaltError {
|
|
17
|
+
constructor(job) {
|
|
18
|
+
super('QUEUE_UNKNOWN_JOB', `Job "${job}" reached the worker but is not registered in this process. ` +
|
|
19
|
+
'Make sure the worker registers the same jobs as the producer.');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** A job used an option the active driver doesn't support (with policy 'throw'). */
|
|
23
|
+
export class UnsupportedJobOptionError extends BasaltError {
|
|
24
|
+
status = 500;
|
|
25
|
+
constructor(driver, job, features) {
|
|
26
|
+
super('QUEUE_UNSUPPORTED_OPTION', `The "${driver}" queue driver does not support ${features.join(', ')} ` +
|
|
27
|
+
`(job "${job}"). Use a driver that supports it, remove the option, or ` +
|
|
28
|
+
`set queuePlugin({ onUnsupported: 'warn' | 'ignore' }).`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Maps a dispatch's options to the capability each one requires. */
|
|
32
|
+
const requiredCapabilities = (options) => {
|
|
33
|
+
const needed = [];
|
|
34
|
+
if (options.delayMs !== undefined && options.delayMs > 0)
|
|
35
|
+
needed.push('delayed');
|
|
36
|
+
if (options.priority !== undefined)
|
|
37
|
+
needed.push('priority');
|
|
38
|
+
if (options.attempts > 1)
|
|
39
|
+
needed.push('retries');
|
|
40
|
+
if (options.backoff && options.attempts > 1)
|
|
41
|
+
needed.push('backoff');
|
|
42
|
+
return needed;
|
|
43
|
+
};
|
|
44
|
+
const FEATURE_LABELS = {
|
|
45
|
+
delayed: 'delayed jobs (delay)',
|
|
46
|
+
priority: 'priority',
|
|
47
|
+
retries: 'retries (attempts > 1)',
|
|
48
|
+
backoff: 'retry backoff',
|
|
49
|
+
};
|
|
50
|
+
/** Context fields serialized along with the payload and restored in the worker. */
|
|
51
|
+
const SNAPSHOT_FIELDS = ['requestId', 'correlationId', 'traceId', 'userId', 'tenantId'];
|
|
52
|
+
export class QueueManager {
|
|
53
|
+
driver;
|
|
54
|
+
jobs = new Map();
|
|
55
|
+
onUnsupported;
|
|
56
|
+
warn;
|
|
57
|
+
warned = new Set();
|
|
58
|
+
defaultRemoveOnComplete;
|
|
59
|
+
defaultRemoveOnFail;
|
|
60
|
+
constructor(driver, options = {}) {
|
|
61
|
+
this.driver = driver;
|
|
62
|
+
this.onUnsupported = options.onUnsupported ?? 'warn';
|
|
63
|
+
this.warn = options.warn ?? ((message) => console.warn(message));
|
|
64
|
+
this.defaultRemoveOnComplete = options.removeOnComplete;
|
|
65
|
+
this.defaultRemoveOnFail = options.removeOnFail;
|
|
66
|
+
driver.setExecutor((jobName, data) => this.execute(jobName, data));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Checks the dispatch's options against the driver's declared capabilities.
|
|
70
|
+
* A driver that omits `capabilities` is assumed fully capable (back-compat).
|
|
71
|
+
*/
|
|
72
|
+
assertSupported(jobName, options) {
|
|
73
|
+
const caps = this.driver.capabilities;
|
|
74
|
+
if (!caps || this.onUnsupported === 'ignore')
|
|
75
|
+
return;
|
|
76
|
+
const missing = requiredCapabilities(options).filter((cap) => !caps[cap]);
|
|
77
|
+
if (missing.length === 0)
|
|
78
|
+
return;
|
|
79
|
+
const driverName = this.driver.name ?? 'queue';
|
|
80
|
+
const features = missing.map((cap) => FEATURE_LABELS[cap]);
|
|
81
|
+
if (this.onUnsupported === 'throw')
|
|
82
|
+
throw new UnsupportedJobOptionError(driverName, jobName, features);
|
|
83
|
+
const key = `${jobName}:${missing.join(',')}`;
|
|
84
|
+
if (this.warned.has(key))
|
|
85
|
+
return; // warn once per job+feature combination
|
|
86
|
+
this.warned.add(key);
|
|
87
|
+
this.warn(`[basalt/queue] The "${driverName}" driver does not support ${features.join(', ')} — ` +
|
|
88
|
+
`job "${jobName}" will run without it.`);
|
|
89
|
+
}
|
|
90
|
+
register(job) {
|
|
91
|
+
this.jobs.set(job.name, job);
|
|
92
|
+
job.__bind(this);
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
async dispatch(job, payload, options = {}) {
|
|
96
|
+
if (!this.jobs.has(job.name))
|
|
97
|
+
this.register(job);
|
|
98
|
+
const envelope = {
|
|
99
|
+
payload: validatePayload(job, payload),
|
|
100
|
+
context: snapshotContext(),
|
|
101
|
+
};
|
|
102
|
+
const addOptions = {
|
|
103
|
+
attempts: job.attempts,
|
|
104
|
+
backoff: job.backoff
|
|
105
|
+
? { type: job.backoff.type, delayMs: parseDuration(job.backoff.delay) }
|
|
106
|
+
: undefined,
|
|
107
|
+
delayMs: options.delay === undefined ? undefined : parseDuration(options.delay),
|
|
108
|
+
priority: options.priority,
|
|
109
|
+
// Per-job overrides the queuePlugin default; undefined leaves the driver default.
|
|
110
|
+
removeOnComplete: resolveRetention(job.removeOnComplete ?? this.defaultRemoveOnComplete),
|
|
111
|
+
removeOnFail: resolveRetention(job.removeOnFail ?? this.defaultRemoveOnFail),
|
|
112
|
+
};
|
|
113
|
+
this.assertSupported(job.name, addOptions);
|
|
114
|
+
await this.driver.add(job.queue, job.name, envelope, addOptions);
|
|
115
|
+
}
|
|
116
|
+
/** Starts a worker for the queue. With the sync driver it is a no-op. */
|
|
117
|
+
work(queue = 'default', options = {}) {
|
|
118
|
+
this.driver.startWorker(queue, options);
|
|
119
|
+
}
|
|
120
|
+
/** Job counts per state, or `undefined` if the driver can't introspect. */
|
|
121
|
+
async stats(queue = 'default') {
|
|
122
|
+
return this.driver.stats?.(queue);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Re-enqueues failed jobs; returns the count, or `undefined` if the driver
|
|
126
|
+
* doesn't support retrying (e.g. the inline sync driver).
|
|
127
|
+
*/
|
|
128
|
+
async retryFailed(queue = 'default', options = {}) {
|
|
129
|
+
return this.driver.retryFailed?.(queue, options);
|
|
130
|
+
}
|
|
131
|
+
async close() {
|
|
132
|
+
await this.driver.close();
|
|
133
|
+
}
|
|
134
|
+
/** Executes a job received from the driver: validates, restores the context, runs the handler. */
|
|
135
|
+
async execute(jobName, data) {
|
|
136
|
+
const job = this.jobs.get(jobName);
|
|
137
|
+
if (!job)
|
|
138
|
+
throw new UnknownJobError(jobName);
|
|
139
|
+
const envelope = data;
|
|
140
|
+
const payload = validatePayload(job, envelope.payload);
|
|
141
|
+
await runWithContext({ ...(envelope.context ?? {}) }, () => job.handle(payload));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Extracts from the current context only what is serializable and useful in the worker. */
|
|
145
|
+
function snapshotContext() {
|
|
146
|
+
const context = tryCtx();
|
|
147
|
+
if (!context)
|
|
148
|
+
return undefined;
|
|
149
|
+
const snapshot = {};
|
|
150
|
+
for (const field of SNAPSHOT_FIELDS) {
|
|
151
|
+
if (context[field] !== undefined)
|
|
152
|
+
snapshot[field] = context[field];
|
|
153
|
+
}
|
|
154
|
+
const tenant = context['tenant'];
|
|
155
|
+
if (tenant?.id)
|
|
156
|
+
snapshot['tenant'] = { id: tenant.id };
|
|
157
|
+
const user = context['user'];
|
|
158
|
+
if (user?.id && snapshot['userId'] === undefined)
|
|
159
|
+
snapshot['userId'] = user.id;
|
|
160
|
+
return Object.keys(snapshot).length > 0 ? snapshot : undefined;
|
|
161
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/queue",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,16 +14,15 @@
|
|
|
14
14
|
"dist"
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"bullmq": "^
|
|
18
|
-
"@basaltkit/core": "^1.
|
|
19
|
-
"@basaltkit/events": "^1.0.
|
|
17
|
+
"bullmq": "^6.2.1",
|
|
18
|
+
"@basaltkit/core": "^1.3.0",
|
|
19
|
+
"@basaltkit/events": "^1.0.1"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@types/node": "^
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"zod": "^3.24.0",
|
|
22
|
+
"@types/node": "^26.3.0",
|
|
23
|
+
"typescript": "^7.0.2",
|
|
24
|
+
"vitest": "^4.1.11",
|
|
25
|
+
"zod": "^3.24.0 || ^4.0.0",
|
|
27
26
|
"@basaltkit/tsconfig": "^0.24.0"
|
|
28
27
|
},
|
|
29
28
|
"publishConfig": {
|
|
@@ -31,11 +30,11 @@
|
|
|
31
30
|
},
|
|
32
31
|
"repository": {
|
|
33
32
|
"type": "git",
|
|
34
|
-
"url": "git+https://github.com/
|
|
33
|
+
"url": "git+https://github.com/basaltkit/basalt.git",
|
|
35
34
|
"directory": "packages/queue"
|
|
36
35
|
},
|
|
37
|
-
"homepage": "https://github.com/
|
|
38
|
-
"bugs": "https://github.com/
|
|
36
|
+
"homepage": "https://github.com/basaltkit/basalt/tree/main/packages/queue#readme",
|
|
37
|
+
"bugs": "https://github.com/basaltkit/basalt/issues",
|
|
39
38
|
"keywords": [
|
|
40
39
|
"basalt",
|
|
41
40
|
"typescript",
|
|
@@ -44,7 +43,7 @@
|
|
|
44
43
|
"bullmq"
|
|
45
44
|
],
|
|
46
45
|
"scripts": {
|
|
47
|
-
"build": "
|
|
46
|
+
"build": "tsc -p tsconfig.build.json",
|
|
48
47
|
"test": "vitest run",
|
|
49
48
|
"typecheck": "tsc --noEmit"
|
|
50
49
|
}
|