@basaltkit/queue 1.0.0 → 1.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Machize Contributors
3
+ Copyright (c) 2026 Basalt Contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -184,6 +184,20 @@ queuePlugin({
184
184
 
185
185
  If a job reaches a worker that hasn't registered it, `UnknownJobError` is thrown.
186
186
 
187
+ ### CLI commands (`basalt queue:*`)
188
+
189
+ Registering `queuePlugin` also wires three CLI commands (run via the `@basaltkit/cli` runner):
190
+
191
+ ```bash
192
+ basalt queue:work --queue=default --concurrency=5 # run a worker until Ctrl+C
193
+ basalt queue:stats --queue=billing # waiting/active/completed/failed/delayed
194
+ basalt queue:retry --queue=billing --limit=100 # re-enqueue failed jobs
195
+ ```
196
+
197
+ `queue:stats` and `queue:retry` need a driver that can introspect job state — the
198
+ **BullMQ** driver (a Redis `connection`). With the inline `sync` driver they
199
+ report the operation as unsupported (it keeps no job state), rather than guessing.
200
+
187
201
  ### Manual use without a plugin (e.g. in tests)
188
202
 
189
203
  ```ts
package/dist/index.d.ts CHANGED
@@ -3,6 +3,11 @@ import { DurationInput, BasaltError } from '@basaltkit/core';
3
3
  import { ConnectionOptions } from 'bullmq';
4
4
  import { EventBus, BasaltEvent } from '@basaltkit/events';
5
5
 
6
+ /** Driver-neutral retention: `true`/`false`, a count, or `{ ageMs, count }`. */
7
+ type RetentionOption = boolean | number | {
8
+ ageMs?: number;
9
+ count?: number;
10
+ };
6
11
  interface AddJobOptions {
7
12
  attempts: number;
8
13
  backoff?: {
@@ -11,6 +16,10 @@ interface AddJobOptions {
11
16
  } | undefined;
12
17
  delayMs?: number | undefined;
13
18
  priority?: number | undefined;
19
+ /** Retention for completed jobs. Undefined → the driver's default. */
20
+ removeOnComplete?: RetentionOption | undefined;
21
+ /** Retention for failed jobs. Undefined → the driver's default. */
22
+ removeOnFail?: RetentionOption | undefined;
14
23
  }
15
24
  type JobExecutor = (jobName: string, data: unknown) => Promise<void>;
16
25
  /**
@@ -30,6 +39,14 @@ interface DriverCapabilities {
30
39
  /** Waits `backoff` between retries (vs retrying immediately). */
31
40
  backoff: boolean;
32
41
  }
42
+ /** Job counts per state, for `basalt queue:stats`. */
43
+ interface QueueStats {
44
+ waiting: number;
45
+ active: number;
46
+ completed: number;
47
+ failed: number;
48
+ delayed: number;
49
+ }
33
50
  /** Queue driver contract. BullMQ in production; sync in tests/dev. */
34
51
  interface QueueDriver {
35
52
  /** Short identifier used in diagnostics (e.g. 'bullmq', 'sync'). */
@@ -43,6 +60,19 @@ interface QueueDriver {
43
60
  startWorker(queue: string, options?: {
44
61
  concurrency?: number;
45
62
  }): void;
63
+ /**
64
+ * Optional: job counts per state, for `basalt queue:stats`. Backends that
65
+ * cannot introspect (e.g. the inline sync driver) omit it — the CLI then
66
+ * reports the operation as unsupported rather than guessing.
67
+ */
68
+ stats?(queue: string): Promise<QueueStats>;
69
+ /**
70
+ * Optional: re-enqueue failed jobs (`basalt queue:retry`). Returns how many
71
+ * were retried. `limit` caps how many are processed (default driver's choice).
72
+ */
73
+ retryFailed?(queue: string, options?: {
74
+ limit?: number;
75
+ }): Promise<number>;
46
76
  close(): Promise<void>;
47
77
  }
48
78
 
@@ -68,6 +98,10 @@ declare class BullmqQueueDriver implements QueueDriver {
68
98
  startWorker(queue: string, options?: {
69
99
  concurrency?: number;
70
100
  }): void;
101
+ stats(queue: string): Promise<QueueStats>;
102
+ retryFailed(queue: string, options?: {
103
+ limit?: number;
104
+ }): Promise<number>;
71
105
  close(): Promise<void>;
72
106
  private queue;
73
107
  }
@@ -96,12 +130,26 @@ interface JobBackoff {
96
130
  type: 'exponential' | 'fixed';
97
131
  delay: DurationInput;
98
132
  }
133
+ /**
134
+ * Redis retention for finished jobs (BullMQ driver): `true` removes it as soon as
135
+ * it finishes, `false` keeps it forever, a number keeps that many most-recent, and
136
+ * `{ age, count }` keeps by age and/or count. Defaults: completed `{ count: 1000 }`,
137
+ * failed `false` (keep all). The sync driver ignores it (it stores nothing).
138
+ */
139
+ type JobRetention = boolean | number | {
140
+ age?: DurationInput;
141
+ count?: number;
142
+ };
99
143
  interface JobDefinition<T = unknown> {
100
144
  readonly name: string;
101
145
  readonly schema?: JobSchema<T> | undefined;
102
146
  readonly queue: string;
103
147
  readonly attempts: number;
104
148
  readonly backoff?: JobBackoff | undefined;
149
+ /** Retention for completed jobs. Overrides the queuePlugin default. */
150
+ readonly removeOnComplete?: JobRetention | undefined;
151
+ /** Retention for failed jobs. Overrides the queuePlugin default. */
152
+ readonly removeOnFail?: JobRetention | undefined;
105
153
  handle(payload: T): void | Promise<void>;
106
154
  /** Enqueues the job — available after registration in a QueueManager. */
107
155
  dispatch(payload: T, options?: DispatchOptions): Promise<void>;
@@ -128,6 +176,8 @@ declare function defineJob<T = unknown>(config: {
128
176
  queue?: string;
129
177
  attempts?: number;
130
178
  backoff?: JobBackoff;
179
+ removeOnComplete?: JobRetention;
180
+ removeOnFail?: JobRetention;
131
181
  handle(payload: T): void | Promise<void>;
132
182
  }): JobDefinition<T>;
133
183
 
@@ -151,6 +201,10 @@ interface QueueManagerOptions {
151
201
  onUnsupported?: UnsupportedPolicy;
152
202
  /** Sink for 'warn' diagnostics. Default console.warn. */
153
203
  warn?: (message: string) => void;
204
+ /** Default retention for completed jobs (a job can override). Driver default: keep 1000. */
205
+ removeOnComplete?: JobRetention;
206
+ /** Default retention for failed jobs (a job can override). Driver default: keep all. */
207
+ removeOnFail?: JobRetention;
154
208
  }
155
209
  declare class QueueManager implements JobDispatcher {
156
210
  private readonly driver;
@@ -158,6 +212,8 @@ declare class QueueManager implements JobDispatcher {
158
212
  private readonly onUnsupported;
159
213
  private readonly warn;
160
214
  private readonly warned;
215
+ private readonly defaultRemoveOnComplete;
216
+ private readonly defaultRemoveOnFail;
161
217
  constructor(driver: QueueDriver, options?: QueueManagerOptions);
162
218
  /**
163
219
  * Checks the dispatch's options against the driver's declared capabilities.
@@ -170,6 +226,15 @@ declare class QueueManager implements JobDispatcher {
170
226
  work(queue?: string, options?: {
171
227
  concurrency?: number;
172
228
  }): void;
229
+ /** Job counts per state, or `undefined` if the driver can't introspect. */
230
+ stats(queue?: string): Promise<QueueStats | undefined>;
231
+ /**
232
+ * Re-enqueues failed jobs; returns the count, or `undefined` if the driver
233
+ * doesn't support retrying (e.g. the inline sync driver).
234
+ */
235
+ retryFailed(queue?: string, options?: {
236
+ limit?: number;
237
+ }): Promise<number | undefined>;
173
238
  close(): Promise<void>;
174
239
  /** Executes a job received from the driver: validates, restores the context, runs the handler. */
175
240
  private execute;
@@ -236,7 +301,18 @@ interface QueuePluginOptions {
236
301
  * 'throw' in production for a hard guarantee, 'ignore' for the old behavior.
237
302
  */
238
303
  onUnsupported?: UnsupportedPolicy;
304
+ /**
305
+ * Default retention for completed jobs in Redis (BullMQ). `true` removes on
306
+ * finish, a number keeps that many, `{ age: '7d', count: 500 }` caps both.
307
+ * Default: keep the last 1000. A job can override via `defineJob`.
308
+ */
309
+ removeOnComplete?: JobRetention;
310
+ /**
311
+ * Default retention for failed jobs. Default `false` (keep all, for inspection
312
+ * and retries) — set e.g. `{ age: '14d' }` so failures don't grow unbounded.
313
+ */
314
+ removeOnFail?: JobRetention;
239
315
  }
240
316
  declare function queuePlugin(options?: QueuePluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
241
317
 
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 };
318
+ export { type AddJobOptions, type BullmqDriverOptions, BullmqQueueDriver, type DispatchOptions, type DriverCapabilities, type JobBackoff, type JobDefinition, type JobExecutor, JobNotRegisteredError, type JobRetention, type JobSchema, JobValidationError, QUEUE, type QueueDriver, QueueManager, type QueueManagerOptions, type QueuePluginOptions, type QueueStats, type QueuedListenerOptions, SyncQueueDriver, UnknownJobError, UnsupportedJobOptionError, type UnsupportedPolicy, defineJob, queuePlugin, queuedOn };
package/dist/index.js CHANGED
@@ -1,8 +1,16 @@
1
1
  // src/index.ts
2
- import { createToken, definePlugin } from "@basaltkit/core";
2
+ import { createToken, definePlugin, ensureMetadata } from "@basaltkit/core";
3
3
 
4
4
  // src/drivers/bullmq.ts
5
5
  import { Queue, Worker } from "bullmq";
6
+ function toBullRetention(retention, fallback) {
7
+ if (retention === void 0) return fallback;
8
+ if (typeof retention === "boolean" || typeof retention === "number") return retention;
9
+ const out = {};
10
+ if (retention.ageMs !== void 0) out.age = Math.max(1, Math.round(retention.ageMs / 1e3));
11
+ if (retention.count !== void 0) out.count = retention.count;
12
+ return out;
13
+ }
6
14
  var BullmqQueueDriver = class {
7
15
  name = "bullmq";
8
16
  capabilities = { delayed: true, priority: true, retries: true, backoff: true };
@@ -22,8 +30,8 @@ var BullmqQueueDriver = class {
22
30
  ...options.backoff ? { backoff: { type: options.backoff.type, delay: options.backoff.delayMs } } : {},
23
31
  ...options.delayMs !== void 0 ? { delay: options.delayMs } : {},
24
32
  ...options.priority !== void 0 ? { priority: options.priority } : {},
25
- removeOnComplete: { count: 1e3 },
26
- removeOnFail: false
33
+ removeOnComplete: toBullRetention(options.removeOnComplete, { count: 1e3 }),
34
+ removeOnFail: toBullRetention(options.removeOnFail, false)
27
35
  });
28
36
  }
29
37
  startWorker(queue, options = {}) {
@@ -34,6 +42,26 @@ var BullmqQueueDriver = class {
34
42
  })
35
43
  );
36
44
  }
45
+ async stats(queue) {
46
+ const c = await this.queue(queue).getJobCounts("waiting", "active", "completed", "failed", "delayed");
47
+ return {
48
+ waiting: c["waiting"] ?? 0,
49
+ active: c["active"] ?? 0,
50
+ completed: c["completed"] ?? 0,
51
+ failed: c["failed"] ?? 0,
52
+ delayed: c["delayed"] ?? 0
53
+ };
54
+ }
55
+ async retryFailed(queue, options = {}) {
56
+ const limit = options.limit ?? 1e3;
57
+ const failed = await this.queue(queue).getFailed(0, limit - 1);
58
+ let retried = 0;
59
+ for (const job of failed) {
60
+ await job.retry();
61
+ retried++;
62
+ }
63
+ return retried;
64
+ }
37
65
  async close() {
38
66
  await Promise.all(this.workers.map((worker) => worker.close()));
39
67
  await Promise.all([...this.queues.values()].map((queue) => queue.close()));
@@ -128,6 +156,8 @@ function defineJob(config) {
128
156
  queue: config.queue ?? "default",
129
157
  attempts: config.attempts ?? 1,
130
158
  backoff: config.backoff,
159
+ removeOnComplete: config.removeOnComplete,
160
+ removeOnFail: config.removeOnFail,
131
161
  handle: config.handle,
132
162
  async dispatch(payload, options) {
133
163
  if (!dispatcher) throw new JobNotRegisteredError(config.name);
@@ -150,6 +180,14 @@ function validatePayload(job, payload) {
150
180
  }
151
181
 
152
182
  // src/manager.ts
183
+ function resolveRetention(retention) {
184
+ if (retention === void 0) return void 0;
185
+ if (typeof retention === "boolean" || typeof retention === "number") return retention;
186
+ const out = {};
187
+ if (retention.age !== void 0) out.ageMs = parseDuration(retention.age);
188
+ if (retention.count !== void 0) out.count = retention.count;
189
+ return out;
190
+ }
153
191
  var UnknownJobError = class extends BasaltError2 {
154
192
  constructor(job) {
155
193
  super(
@@ -187,6 +225,8 @@ var QueueManager = class {
187
225
  this.driver = driver;
188
226
  this.onUnsupported = options.onUnsupported ?? "warn";
189
227
  this.warn = options.warn ?? ((message) => console.warn(message));
228
+ this.defaultRemoveOnComplete = options.removeOnComplete;
229
+ this.defaultRemoveOnFail = options.removeOnFail;
190
230
  driver.setExecutor((jobName, data) => this.execute(jobName, data));
191
231
  }
192
232
  driver;
@@ -194,6 +234,8 @@ var QueueManager = class {
194
234
  onUnsupported;
195
235
  warn;
196
236
  warned = /* @__PURE__ */ new Set();
237
+ defaultRemoveOnComplete;
238
+ defaultRemoveOnFail;
197
239
  /**
198
240
  * Checks the dispatch's options against the driver's declared capabilities.
199
241
  * A driver that omits `capabilities` is assumed fully capable (back-compat).
@@ -228,7 +270,10 @@ var QueueManager = class {
228
270
  attempts: job.attempts,
229
271
  backoff: job.backoff ? { type: job.backoff.type, delayMs: parseDuration(job.backoff.delay) } : void 0,
230
272
  delayMs: options.delay === void 0 ? void 0 : parseDuration(options.delay),
231
- priority: options.priority
273
+ priority: options.priority,
274
+ // Per-job overrides the queuePlugin default; undefined leaves the driver default.
275
+ removeOnComplete: resolveRetention(job.removeOnComplete ?? this.defaultRemoveOnComplete),
276
+ removeOnFail: resolveRetention(job.removeOnFail ?? this.defaultRemoveOnFail)
232
277
  };
233
278
  this.assertSupported(job.name, addOptions);
234
279
  await this.driver.add(job.queue, job.name, envelope, addOptions);
@@ -237,6 +282,17 @@ var QueueManager = class {
237
282
  work(queue = "default", options = {}) {
238
283
  this.driver.startWorker(queue, options);
239
284
  }
285
+ /** Job counts per state, or `undefined` if the driver can't introspect. */
286
+ async stats(queue = "default") {
287
+ return this.driver.stats?.(queue);
288
+ }
289
+ /**
290
+ * Re-enqueues failed jobs; returns the count, or `undefined` if the driver
291
+ * doesn't support retrying (e.g. the inline sync driver).
292
+ */
293
+ async retryFailed(queue = "default", options = {}) {
294
+ return this.driver.retryFailed?.(queue, options);
295
+ }
240
296
  async close() {
241
297
  await this.driver.close();
242
298
  }
@@ -285,12 +341,14 @@ function queuePlugin(options = {}) {
285
341
  return definePlugin({
286
342
  name: "basalt:queue",
287
343
  register({ container }) {
344
+ registerQueueCommands(container);
288
345
  container.singleton(QUEUE, () => {
289
346
  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
- );
347
+ const manager = new QueueManager(driver, {
348
+ ...options.onUnsupported !== void 0 ? { onUnsupported: options.onUnsupported } : {},
349
+ ...options.removeOnComplete !== void 0 ? { removeOnComplete: options.removeOnComplete } : {},
350
+ ...options.removeOnFail !== void 0 ? { removeOnFail: options.removeOnFail } : {}
351
+ });
294
352
  for (const job of options.jobs ?? []) manager.register(job);
295
353
  return manager;
296
354
  });
@@ -309,6 +367,54 @@ function queuePlugin(options = {}) {
309
367
  }
310
368
  });
311
369
  }
370
+ function registerQueueCommands(container) {
371
+ const manager = () => container.get(QUEUE);
372
+ const unsupported = "Not supported by the active queue driver \u2014 the inline sync driver keeps no job state. Use the BullMQ driver (a Redis `connection`).";
373
+ ensureMetadata(container).add("commands", {
374
+ name: "queue:work",
375
+ description: "Run a worker that processes jobs for a queue (Ctrl+C to stop)",
376
+ async handle({ io, flags }) {
377
+ const queue = typeof flags["queue"] === "string" ? flags["queue"] : "default";
378
+ const concurrency = typeof flags["concurrency"] === "string" ? Number(flags["concurrency"]) : void 0;
379
+ manager().work(queue, concurrency !== void 0 ? { concurrency } : {});
380
+ io.log(`Worker started on queue "${queue}"${concurrency ? ` (concurrency ${concurrency})` : ""}. Ctrl+C to stop.`);
381
+ await new Promise((resolve) => process.once("SIGINT", resolve));
382
+ }
383
+ });
384
+ ensureMetadata(container).add("commands", {
385
+ name: "queue:stats",
386
+ description: "Show job counts (waiting/active/completed/failed/delayed) for a queue",
387
+ async handle({
388
+ io,
389
+ flags
390
+ }) {
391
+ const queue = typeof flags["queue"] === "string" ? flags["queue"] : "default";
392
+ const stats = await manager().stats(queue);
393
+ if (!stats) {
394
+ io.log(unsupported);
395
+ return;
396
+ }
397
+ io.table([{ queue, ...stats }]);
398
+ }
399
+ });
400
+ ensureMetadata(container).add("commands", {
401
+ name: "queue:retry",
402
+ description: "Re-enqueue failed jobs on a queue",
403
+ async handle({
404
+ io,
405
+ flags
406
+ }) {
407
+ const queue = typeof flags["queue"] === "string" ? flags["queue"] : "default";
408
+ const limit = typeof flags["limit"] === "string" ? Number(flags["limit"]) : void 0;
409
+ const retried = await manager().retryFailed(queue, limit !== void 0 ? { limit } : {});
410
+ if (retried === void 0) {
411
+ io.log(unsupported);
412
+ return;
413
+ }
414
+ io.log(`Re-enqueued ${retried} failed job(s) on "${queue}".`);
415
+ }
416
+ });
417
+ }
312
418
  export {
313
419
  BullmqQueueDriver,
314
420
  JobNotRegisteredError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/queue",
3
- "version": "1.0.0",
3
+ "version": "1.2.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",