@queuedash/sdk 2.0.0 → 2.0.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @queuedash/sdk
2
2
 
3
- Official SDK for Queuedash - Real-time monitoring for BullMQ, Bull, Bee-Queue, and GroupMQ.
3
+ Official SDK for Queuedash - queue monitoring for BullMQ, Bull, and Bee-Queue.
4
4
 
5
5
  ## Installation
6
6
 
@@ -14,7 +14,7 @@ yarn add @queuedash/sdk
14
14
 
15
15
  ## Quick Start
16
16
 
17
- The SDK auto-detects your queue library (BullMQ, Bull, Bee-Queue, or GroupMQ) - just use `attach()`:
17
+ The SDK auto-detects your queue library (BullMQ 5+, Bull 4+, or Bee-Queue 1+) - just use `attach()`. First add the queue in Queuedash under the same name: jobs sent for a queue name Queuedash doesn't know are dropped.
18
18
 
19
19
  ### BullMQ
20
20
 
@@ -82,9 +82,9 @@ qd.attach(new Queue("notifications", { connection }));
82
82
 
83
83
  Get your API key from [Queuedash](https://queuedash.com):
84
84
 
85
- 1. Navigate to your project
86
- 2. Click "API Keys"
87
- 3. Generate a new API key
85
+ 1. Open your project
86
+ 2. In the environment's API Keys section, click Add
87
+ 3. Copy the key (it's shown once)
88
88
 
89
89
  ```bash
90
90
  # Set in your environment
@@ -98,7 +98,8 @@ const qd = new Queuedash({
98
98
  // Required: Your API key from Queuedash
99
99
  apiKey: string,
100
100
 
101
- // Optional: Custom API URL (default: https://api.queuedash.com)
101
+ // Optional: Where batches are sent (default: https://sync.queuedash.com
102
+ // when NODE_ENV is "production", otherwise http://localhost:4002)
102
103
  baseUrl?: string,
103
104
 
104
105
  // Optional: Batch size for syncing jobs (default: 50)
@@ -128,8 +129,9 @@ The SDK is production-ready with multiple layers of protection:
128
129
  **Retry Logic**
129
130
 
130
131
  - Automatic retries up to 5 times (configurable)
131
- - Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s (capped)
132
- - Jobs preserved in local queue until successfully synced
132
+ - Exponential backoff: 1s → 2s → 4s → 8s → 16s (capped at 30s)
133
+ - Jobs that arrive during a backoff are held and sent with the retry
134
+ - A batch that still fails after the last retry (~31s after the first failure, with defaults) is dropped and reported via `onError`
133
135
 
134
136
  **Circuit Breaker**
135
137
 
@@ -147,18 +149,18 @@ The SDK is production-ready with multiple layers of protection:
147
149
 
148
150
  - SIGTERM/SIGINT handlers flush pending jobs before exit
149
151
  - Call `await qd.stop()` to manually stop
150
- - No jobs lost during normal shutdown
152
+ - Shutdown doesn't wait out a retry backoff: pending jobs get one final attempt, and if that fails they're reported via `onError` instead of retried
151
153
 
152
154
  **Request Safety**
153
155
 
154
156
  - 30-second timeout on all HTTP requests
155
- - Duplicate detection prevents same job syncing twice
157
+ - Only the latest pending update for each job is sent
156
158
  - Network errors handled gracefully
157
159
 
158
160
  ## Features
159
161
 
160
- - **Auto-detection**: Works with BullMQ, Bull, Bee-Queue, and GroupMQ automatically
161
- - **Real-time sync**: Jobs are synced as events happen
162
+ - **Auto-detection**: Works with BullMQ, Bull, and Bee-Queue automatically
163
+ - **Event-driven**: A job is sent when the SDK hears an event for it (GroupMQ isn't supported)
162
164
  - **Smart batching**: Events are automatically batched for performance
163
165
  - **Auto-retry**: Failed syncs are automatically retried
164
166
  - **Zero config**: Works out of the box with sensible defaults
package/dist/client.d.ts CHANGED
@@ -15,17 +15,19 @@ export declare class Queuedash {
15
15
  private jobQueue;
16
16
  private flushTimer?;
17
17
  private isFlushing;
18
+ private inFlightFlush?;
19
+ private retryTimer?;
20
+ private stopped;
18
21
  private retryCount;
19
22
  private circuitBreakerFailures;
20
23
  private circuitBreakerOpen;
21
24
  private circuitBreakerResetTimer?;
22
- private syncingJobIds;
23
25
  private managedQueueEvents;
24
26
  private attachedQueues;
25
27
  constructor(options: QueuedashOptions);
26
28
  /**
27
29
  * Attach a queue or worker for monitoring.
28
- * Auto-detects the queue library (BullMQ, Bull, Bee-Queue, GroupMQ).
30
+ * Auto-detects the queue library (BullMQ, Bull, Bee-Queue).
29
31
  *
30
32
  * @example
31
33
  * ```ts
@@ -60,16 +62,27 @@ export declare class Queuedash {
60
62
  * Queue a job for syncing (internal use)
61
63
  */
62
64
  syncJob(job: JobData): void;
65
+ private jobKey;
63
66
  /**
64
- * Manually flush all pending jobs
67
+ * Manually flush all pending jobs. Unlike the periodic and batch-size
68
+ * flushes, this doesn't wait for a pending retry backoff.
65
69
  */
66
70
  flush(): Promise<void>;
71
+ private syncPendingJobs;
72
+ /**
73
+ * Put unsent jobs back at the front of the queue, unless a newer snapshot
74
+ * of the same job was queued while they were in flight
75
+ */
76
+ private requeue;
67
77
  private splitIntoChunks;
68
78
  private sendBatch;
69
79
  private openCircuitBreaker;
70
80
  private log;
81
+ private clearRetryTimer;
71
82
  /**
72
- * Stop the client, close managed resources, and flush remaining jobs
83
+ * Stop the client, close managed resources, and flush remaining jobs.
84
+ * A pending retry backoff is skipped: remaining jobs get one final attempt,
85
+ * and if that fails they're reported via onError instead of retried.
73
86
  */
74
87
  stop(): Promise<void>;
75
88
  private startFlushTimer;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAgB,MAAM,SAAS,CAAC;AAiBlE,KAAK,iBAAiB,GAAG;IACvB,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,KAAK,GAAG,CAAC;IAC9D,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B,CAAC;AAcF,qBAAa,SAAS;IACpB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,OAAO,CAAC,CAAyB;IAEzC,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,UAAU,CAAC,CAAiB;IACpC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,sBAAsB,CAAK;IACnC,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,wBAAwB,CAAC,CAAiB;IAClD,OAAO,CAAC,aAAa,CAAqB;IAG1C,OAAO,CAAC,kBAAkB,CAA2B;IACrD,OAAO,CAAC,cAAc,CAAqB;gBAE/B,OAAO,EAAE,gBAAgB;IAsBrC;;;;;;;;;;;;;;;;;OAiBG;IACH,MAAM,CAAC,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,iBAAiB,CAAA;KAAE,GAAG,IAAI;IAyBzE,OAAO,CAAC,UAAU;YAqCJ,YAAY;IAqE1B,OAAO,CAAC,kBAAkB;IAK1B,OAAO,CAAC,UAAU;IAiClB,OAAO,CAAC,SAAS;IA+BjB,OAAO,CAAC,oBAAoB;IAoB5B,OAAO,CAAC,kBAAkB;IAmB1B,OAAO,CAAC,iBAAiB;IAiBzB;;OAEG;IACG,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAqChE;;OAEG;IACH,OAAO,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI;IAyB3B;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAsF5B,OAAO,CAAC,eAAe;YAuBT,SAAS;IA+BvB,OAAO,CAAC,kBAAkB;IAmB1B,OAAO,CAAC,GAAG;IAQX;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAoB3B,OAAO,CAAC,eAAe;IAYvB,OAAO,CAAC,qBAAqB;CAiB9B"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAgB,MAAM,SAAS,CAAC;AAiBlE,KAAK,iBAAiB,GAAG;IACvB,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,KAAK,GAAG,CAAC;IAC9D,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B,CAAC;AAeF,qBAAa,SAAS;IACpB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,OAAO,CAAC,CAAyB;IAGzC,OAAO,CAAC,QAAQ,CAA8B;IAC9C,OAAO,CAAC,UAAU,CAAC,CAAiB;IACpC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,aAAa,CAAC,CAAgB;IAGtC,OAAO,CAAC,UAAU,CAAC,CAAiB;IAEpC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,sBAAsB,CAAK;IACnC,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,wBAAwB,CAAC,CAAiB;IAGlD,OAAO,CAAC,kBAAkB,CAA2B;IACrD,OAAO,CAAC,cAAc,CAAqB;gBAE/B,OAAO,EAAE,gBAAgB;IAsBrC;;;;;;;;;;;;;;;;;OAiBG;IACH,MAAM,CAAC,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,iBAAiB,CAAA;KAAE,GAAG,IAAI;IAqCzE,OAAO,CAAC,UAAU;YAqCJ,YAAY;IAwE1B,OAAO,CAAC,kBAAkB;IAK1B,OAAO,CAAC,UAAU;IAsClB,OAAO,CAAC,SAAS;IA+BjB,OAAO,CAAC,oBAAoB;IAoB5B,OAAO,CAAC,kBAAkB;IAmB1B,OAAO,CAAC,iBAAiB;IAiBzB;;OAEG;IACG,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAqChE;;OAEG;IACH,OAAO,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI;IAgC3B,OAAO,CAAC,MAAM;IAId;;;OAGG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAsBd,eAAe;IAoE7B;;;OAGG;IACH,OAAO,CAAC,OAAO;IAcf,OAAO,CAAC,eAAe;YAuBT,SAAS;IAmCvB,OAAO,CAAC,kBAAkB;IAmB1B,OAAO,CAAC,GAAG;IAQX,OAAO,CAAC,eAAe;IAOvB;;;;OAIG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IA6B3B,OAAO,CAAC,eAAe;IAYvB,OAAO,CAAC,qBAAqB;CAiB9B"}
package/dist/client.js CHANGED
@@ -37,12 +37,14 @@ exports.Queuedash = void 0;
37
37
  const MAX_PAYLOAD_SIZE = 4.5 * 1024 * 1024; // 4.5MB
38
38
  class Queuedash {
39
39
  constructor(options) {
40
- this.jobQueue = [];
40
+ // Latest unsent snapshot per `${queueName}:${jobId}`, oldest first
41
+ this.jobQueue = new Map();
41
42
  this.isFlushing = false;
43
+ // Set by stop(): failed syncs are reported instead of retried
44
+ this.stopped = false;
42
45
  this.retryCount = 0;
43
46
  this.circuitBreakerFailures = 0;
44
47
  this.circuitBreakerOpen = false;
45
- this.syncingJobIds = new Set();
46
48
  // Track attached resources for cleanup
47
49
  this.managedQueueEvents = [];
48
50
  this.attachedQueues = new Set();
@@ -66,7 +68,7 @@ class Queuedash {
66
68
  }
67
69
  /**
68
70
  * Attach a queue or worker for monitoring.
69
- * Auto-detects the queue library (BullMQ, Bull, Bee-Queue, GroupMQ).
71
+ * Auto-detects the queue library (BullMQ, Bull, Bee-Queue).
70
72
  *
71
73
  * @example
72
74
  * ```ts
@@ -85,9 +87,22 @@ class Queuedash {
85
87
  attach(resource, options) {
86
88
  const type = this.detectType(resource);
87
89
  switch (type) {
88
- case "bullmq-queue":
89
- this.attachBullMQ(resource, options?.events);
90
+ case "bullmq-queue": {
91
+ const queue = resource;
92
+ // This can fail after attach() has returned, where the caller can't
93
+ // catch it, and an unhandled rejection would crash the process
94
+ this.attachBullMQ(queue, options?.events).catch((error) => {
95
+ const err = error instanceof Error ? error : new Error(String(error));
96
+ const errorMsg = `Failed to attach queue "${queue.name}"`;
97
+ if (this.onError) {
98
+ this.onError(new Error(`${errorMsg}: ${err.message}`));
99
+ }
100
+ else {
101
+ console.error(`Queuedash: ${errorMsg}`, err);
102
+ }
103
+ });
90
104
  break;
105
+ }
91
106
  case "bullmq-worker":
92
107
  this.attachBullMQWorker(resource);
93
108
  break;
@@ -98,7 +113,7 @@ class Queuedash {
98
113
  this.attachBee(resource);
99
114
  break;
100
115
  default:
101
- throw new Error("Queuedash: Unknown queue type. Supported: BullMQ Queue/Worker, Bull, Bee-Queue, GroupMQ");
116
+ throw new Error("Queuedash: Unknown queue type. Supported: BullMQ Queue, Bull, Bee-Queue");
102
117
  }
103
118
  return this;
104
119
  }
@@ -139,27 +154,31 @@ class Queuedash {
139
154
  events = existingEvents;
140
155
  }
141
156
  else {
142
- try {
143
- // Dynamic import to avoid requiring bullmq as a direct dependency
144
- const { QueueEvents } = await Promise.resolve().then(() => __importStar(require("bullmq")));
145
- const queueEvents = new QueueEvents(queueName, {
146
- connection: queue.opts?.connection,
147
- prefix: queue.redisPrefix,
148
- });
149
- this.managedQueueEvents.push(queueEvents);
150
- events = queueEvents;
151
- }
152
- catch (e) {
153
- throw new Error("Queuedash: Failed to create QueueEvents. Make sure bullmq is installed.");
154
- }
157
+ // Dynamic import to avoid requiring bullmq as a direct dependency
158
+ const { QueueEvents } = await Promise.resolve().then(() => __importStar(require("bullmq"))).catch((error) => {
159
+ throw new Error(`Could not load bullmq (is it installed?): ${error.message}`);
160
+ });
161
+ // Throws if BullMQ rejects the connection, e.g. a shared ioredis instance
162
+ // without maxRetriesPerRequest: null. attach() reports the error as is
163
+ const queueEvents = new QueueEvents(queueName, {
164
+ connection: queue.opts?.connection,
165
+ // BullMQ keeps the key prefix in opts.prefix; without it QueueEvents
166
+ // listens on the default "bull" keys
167
+ prefix: queue.opts?.prefix ?? queue.redisPrefix,
168
+ });
169
+ this.managedQueueEvents.push(queueEvents);
170
+ events = queueEvents;
155
171
  }
156
172
  this.attachedQueues.add(`bullmq:${queueName}`);
157
173
  const syncJobById = (jobId, status) => {
158
- queue.getJob(jobId).then((job) => {
174
+ queue
175
+ .getJob(jobId)
176
+ .then((job) => {
159
177
  if (job) {
160
178
  this.syncJob(this.extractBullMQJobData(job, queueName, status));
161
179
  }
162
- });
180
+ })
181
+ .catch((error) => this.log(`Failed to load job ${jobId}`, error));
163
182
  };
164
183
  events.on("waiting", ({ jobId }) => syncJobById(jobId, "waiting"));
165
184
  events.on("progress", ({ jobId }) => syncJobById(jobId, "active"));
@@ -185,14 +204,19 @@ class Queuedash {
185
204
  return;
186
205
  }
187
206
  this.attachedQueues.add(`bull:${queueName}`);
188
- queue.on("global:completed", (job) => {
189
- this.syncJob(this.extractBullJobData(job, queueName));
190
- });
191
- queue.on("global:failed", (job) => {
192
- if (job) {
193
- this.syncJob(this.extractBullJobData(job, queueName));
194
- }
195
- });
207
+ // Global events pass (jobId, result) rather than a Job, so load the job
208
+ const syncJobById = (jobId) => {
209
+ queue
210
+ .getJob(jobId)
211
+ .then((job) => {
212
+ if (job) {
213
+ this.syncJob(this.extractBullJobData(job, queueName));
214
+ }
215
+ })
216
+ .catch((error) => this.log(`Failed to load job ${jobId}`, error));
217
+ };
218
+ queue.on("global:completed", (jobId) => syncJobById(jobId));
219
+ queue.on("global:failed", (jobId) => syncJobById(jobId));
196
220
  queue.on("active", (job) => {
197
221
  this.syncJob(this.extractBullJobData(job, queueName));
198
222
  });
@@ -285,9 +309,9 @@ class Queuedash {
285
309
  * Delete a job (soft delete in Queuedash)
286
310
  */
287
311
  async deleteJob(jobId, queueName) {
312
+ const controller = new AbortController();
313
+ const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
288
314
  try {
289
- const controller = new AbortController();
290
- const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
291
315
  const response = await fetch(`${this.apiUrl}/api/v1/jobs/${encodeURIComponent(jobId)}?queueName=${encodeURIComponent(queueName)}`, {
292
316
  method: "DELETE",
293
317
  headers: {
@@ -295,7 +319,6 @@ class Queuedash {
295
319
  },
296
320
  signal: controller.signal,
297
321
  });
298
- clearTimeout(timeoutId);
299
322
  if (!response.ok) {
300
323
  const errorText = await response.text();
301
324
  throw new Error(`HTTP ${response.status}: ${errorText}`);
@@ -310,6 +333,9 @@ class Queuedash {
310
333
  console.error(`Queuedash: Failed to delete job ${jobId}`, err);
311
334
  }
312
335
  }
336
+ finally {
337
+ clearTimeout(timeoutId);
338
+ }
313
339
  }
314
340
  /**
315
341
  * Queue a job for syncing (internal use)
@@ -318,32 +344,55 @@ class Queuedash {
318
344
  if (this.circuitBreakerOpen) {
319
345
  return;
320
346
  }
321
- const jobKey = `${job.queueName}:${job.jobId}`;
322
- if (this.syncingJobIds.has(jobKey)) {
347
+ const jobKey = this.jobKey(job);
348
+ // A newer snapshot replaces the pending one and keeps its place in line.
349
+ // If the previous snapshot is already in flight, this one waits here for
350
+ // the next flush (the API upserts on queue + jobId).
351
+ if (this.jobQueue.has(jobKey)) {
352
+ this.jobQueue.set(jobKey, job);
323
353
  return;
324
354
  }
325
- this.syncingJobIds.add(jobKey);
326
- if (this.jobQueue.length >= this.maxQueueSize) {
327
- const dropped = this.jobQueue.shift();
328
- this.log(`Queue size limit reached (${this.maxQueueSize}), dropping oldest job: ${dropped?.jobId}`);
355
+ if (this.jobQueue.size >= this.maxQueueSize) {
356
+ const oldest = this.jobQueue.entries().next().value;
357
+ if (oldest) {
358
+ this.jobQueue.delete(oldest[0]);
359
+ }
360
+ this.log(`Queue size limit reached (${this.maxQueueSize}), dropping oldest job: ${oldest?.[1].jobId}`);
329
361
  }
330
- this.jobQueue.push(job);
331
- if (this.jobQueue.length >= this.batchSize) {
362
+ this.jobQueue.set(jobKey, job);
363
+ if (this.jobQueue.size >= this.batchSize && !this.retryTimer) {
332
364
  this.flush();
333
365
  }
334
366
  }
367
+ jobKey(job) {
368
+ return `${job.queueName}:${job.jobId}`;
369
+ }
335
370
  /**
336
- * Manually flush all pending jobs
371
+ * Manually flush all pending jobs. Unlike the periodic and batch-size
372
+ * flushes, this doesn't wait for a pending retry backoff.
337
373
  */
338
374
  async flush() {
339
375
  if (this.isFlushing ||
340
- this.jobQueue.length === 0 ||
376
+ this.jobQueue.size === 0 ||
341
377
  this.circuitBreakerOpen) {
342
378
  return;
343
379
  }
380
+ // This attempt replaces any scheduled retry; if it fails, the next retry
381
+ // is scheduled with a longer backoff
382
+ this.clearRetryTimer();
344
383
  this.isFlushing = true;
345
- const jobsToSync = [...this.jobQueue];
346
- this.jobQueue = [];
384
+ this.inFlightFlush = this.syncPendingJobs();
385
+ try {
386
+ await this.inFlightFlush;
387
+ }
388
+ finally {
389
+ this.isFlushing = false;
390
+ this.inFlightFlush = undefined;
391
+ }
392
+ }
393
+ async syncPendingJobs() {
394
+ const jobsToSync = [...this.jobQueue.values()];
395
+ this.jobQueue = new Map();
347
396
  try {
348
397
  // Check if payload is too large and split into chunks if needed
349
398
  const payload = JSON.stringify({ jobs: jobsToSync });
@@ -353,9 +402,6 @@ class Queuedash {
353
402
  await this.sendBatch(chunk);
354
403
  // Remove successfully sent jobs so they aren't re-queued on later chunk failure
355
404
  jobsToSync.splice(0, chunk.length);
356
- chunk.forEach((job) => {
357
- this.syncingJobIds.delete(`${job.queueName}:${job.jobId}`);
358
- });
359
405
  }
360
406
  this.retryCount = 0;
361
407
  this.circuitBreakerFailures = 0;
@@ -364,9 +410,6 @@ class Queuedash {
364
410
  await this.sendBatch(jobsToSync);
365
411
  this.retryCount = 0;
366
412
  this.circuitBreakerFailures = 0;
367
- jobsToSync.forEach((job) => {
368
- this.syncingJobIds.delete(`${job.queueName}:${job.jobId}`);
369
- });
370
413
  }
371
414
  catch (error) {
372
415
  this.retryCount++;
@@ -375,22 +418,22 @@ class Queuedash {
375
418
  if (this.circuitBreakerFailures >= 10) {
376
419
  this.openCircuitBreaker();
377
420
  }
378
- if (this.retryCount <= this.maxRetries) {
379
- this.jobQueue.unshift(...jobsToSync);
421
+ if (this.retryCount <= this.maxRetries && !this.stopped) {
422
+ this.requeue(jobsToSync);
380
423
  const backoffMs = Math.min(1000 * Math.pow(2, this.retryCount - 1), 30000);
381
424
  this.log(`Retry ${this.retryCount}/${this.maxRetries} after ${backoffMs}ms`, err);
382
- setTimeout(() => {
383
- if (this.jobQueue.length > 0) {
425
+ this.retryTimer = setTimeout(() => {
426
+ this.retryTimer = undefined;
427
+ if (this.jobQueue.size > 0) {
384
428
  this.flush();
385
429
  }
386
430
  }, backoffMs);
387
431
  }
388
432
  else {
389
433
  this.retryCount = 0;
390
- jobsToSync.forEach((job) => {
391
- this.syncingJobIds.delete(`${job.queueName}:${job.jobId}`);
392
- });
393
- const errorMsg = `Failed to sync ${jobsToSync.length} jobs after ${this.maxRetries} retries`;
434
+ const errorMsg = this.stopped
435
+ ? `Failed to sync ${jobsToSync.length} jobs while stopping`
436
+ : `Failed to sync ${jobsToSync.length} jobs after ${this.maxRetries} retries`;
394
437
  if (this.onError) {
395
438
  this.onError(new Error(`${errorMsg}: ${err.message}`));
396
439
  }
@@ -399,9 +442,23 @@ class Queuedash {
399
442
  }
400
443
  }
401
444
  }
402
- finally {
403
- this.isFlushing = false;
445
+ }
446
+ /**
447
+ * Put unsent jobs back at the front of the queue, unless a newer snapshot
448
+ * of the same job was queued while they were in flight
449
+ */
450
+ requeue(jobs) {
451
+ const requeued = new Map();
452
+ for (const job of jobs) {
453
+ const jobKey = this.jobKey(job);
454
+ if (!this.jobQueue.has(jobKey)) {
455
+ requeued.set(jobKey, job);
456
+ }
457
+ }
458
+ for (const [jobKey, job] of this.jobQueue) {
459
+ requeued.set(jobKey, job);
404
460
  }
461
+ this.jobQueue = requeued;
405
462
  }
406
463
  splitIntoChunks(jobs, maxSize) {
407
464
  const chunks = [];
@@ -424,24 +481,30 @@ class Queuedash {
424
481
  }
425
482
  async sendBatch(jobs) {
426
483
  const controller = new AbortController();
484
+ // Covers reading the body too: if a response stalled mid-body, this flush
485
+ // would never finish and no later flush could start
427
486
  const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
428
- const response = await fetch(`${this.apiUrl}/api/v1/jobs/sync`, {
429
- method: "POST",
430
- headers: {
431
- "Content-Type": "application/json",
432
- Authorization: `Bearer ${this.apiKey}`,
433
- },
434
- body: JSON.stringify({ jobs }),
435
- signal: controller.signal,
436
- });
437
- clearTimeout(timeoutId);
438
- if (!response.ok) {
439
- const errorText = await response.text();
440
- throw new Error(`HTTP ${response.status}: ${errorText}`);
487
+ try {
488
+ const response = await fetch(`${this.apiUrl}/api/v1/jobs/sync`, {
489
+ method: "POST",
490
+ headers: {
491
+ "Content-Type": "application/json",
492
+ Authorization: `Bearer ${this.apiKey}`,
493
+ },
494
+ body: JSON.stringify({ jobs }),
495
+ signal: controller.signal,
496
+ });
497
+ if (!response.ok) {
498
+ const errorText = await response.text();
499
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
500
+ }
501
+ const result = (await response.json());
502
+ if (!result.success && result.errors) {
503
+ console.warn("Queuedash: Some jobs failed to sync:", result.errors);
504
+ }
441
505
  }
442
- const result = (await response.json());
443
- if (!result.success && result.errors) {
444
- console.warn("Queuedash: Some jobs failed to sync:", result.errors);
506
+ finally {
507
+ clearTimeout(timeoutId);
445
508
  }
446
509
  }
447
510
  openCircuitBreaker() {
@@ -466,8 +529,16 @@ class Queuedash {
466
529
  console.warn(`Queuedash: ${message}`);
467
530
  }
468
531
  }
532
+ clearRetryTimer() {
533
+ if (this.retryTimer) {
534
+ clearTimeout(this.retryTimer);
535
+ this.retryTimer = undefined;
536
+ }
537
+ }
469
538
  /**
470
- * Stop the client, close managed resources, and flush remaining jobs
539
+ * Stop the client, close managed resources, and flush remaining jobs.
540
+ * A pending retry backoff is skipped: remaining jobs get one final attempt,
541
+ * and if that fails they're reported via onError instead of retried.
471
542
  */
472
543
  async stop() {
473
544
  if (this.flushTimer) {
@@ -485,11 +556,19 @@ class Queuedash {
485
556
  // Ignore close errors
486
557
  }
487
558
  }
559
+ // flush() is a no-op while another flush is in flight, so let that one
560
+ // finish first; otherwise snapshots queued behind it would never be sent
561
+ while (this.inFlightFlush) {
562
+ await this.inFlightFlush.catch(() => { });
563
+ }
564
+ // Don't wait out a pending backoff (up to 30s) during shutdown
565
+ this.stopped = true;
566
+ this.clearRetryTimer();
488
567
  await this.flush();
489
568
  }
490
569
  startFlushTimer() {
491
570
  this.flushTimer = setInterval(() => {
492
- if (this.jobQueue.length > 0) {
571
+ if (this.jobQueue.size > 0 && !this.retryTimer) {
493
572
  this.flush();
494
573
  }
495
574
  }, this.flushInterval);
@@ -506,8 +585,8 @@ class Queuedash {
506
585
  process.once("SIGTERM", shutdown);
507
586
  process.once("SIGINT", shutdown);
508
587
  process.once("beforeExit", () => {
509
- if (this.jobQueue.length > 0) {
510
- console.warn(`Queuedash: Process exiting with ${this.jobQueue.length} unsync'd jobs`);
588
+ if (this.jobQueue.size > 0) {
589
+ console.warn(`Queuedash: Process exiting with ${this.jobQueue.size} unsync'd jobs`);
511
590
  }
512
591
  });
513
592
  }
@@ -1,6 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const events_1 = require("events");
4
+ const bullmq_1 = require("bullmq");
3
5
  const client_1 = require("./client");
6
+ // Stub QueueEvents so attaching a BullMQ queue doesn't connect to Redis
7
+ jest.mock("bullmq", () => ({
8
+ QueueEvents: jest.fn().mockImplementation(() => ({
9
+ on: jest.fn(),
10
+ close: jest.fn().mockResolvedValue(undefined),
11
+ })),
12
+ }));
13
+ const MockQueueEvents = bullmq_1.QueueEvents;
4
14
  // Increase max listeners to avoid warnings from multiple SDK instances
5
15
  process.setMaxListeners(100);
6
16
  // Mock timers for testing
@@ -172,12 +182,9 @@ describe("Queuedash SDK", () => {
172
182
  redisPrefix: "bull",
173
183
  };
174
184
  // BullMQ detection works - attach returns this for chaining
175
- // The internal async call to create QueueEvents will fail (no bullmq installed)
176
- // but detection itself should not throw "Unknown queue type"
185
+ // and doesn't throw "Unknown queue type"
177
186
  const result = sdk.attach(mockBullMQQueue);
178
187
  expect(result).toBe(sdk);
179
- // The error will be thrown asynchronously but detection worked
180
- // because it didn't throw "Unknown queue type" synchronously
181
188
  });
182
189
  it("should detect BullMQ Worker (has opts.connection, no getJob)", () => {
183
190
  const mockBullMQWorker = {
@@ -219,6 +226,89 @@ describe("Queuedash SDK", () => {
219
226
  expect(() => sdk.attach(123)).toThrow("Queuedash: Unknown queue type");
220
227
  });
221
228
  });
229
+ describe("BullMQ QueueEvents", () => {
230
+ beforeEach(() => {
231
+ MockQueueEvents.mockClear();
232
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
233
+ });
234
+ // attach() creates QueueEvents after an async import of bullmq
235
+ const attachAndWait = async (queue) => {
236
+ sdk.attach(queue);
237
+ jest.useRealTimers();
238
+ await new Promise((resolve) => setImmediate(resolve));
239
+ jest.useFakeTimers();
240
+ };
241
+ it("should listen with the queue's custom prefix", async () => {
242
+ // Shaped like a BullMQ 5 Queue: the prefix lives in opts, no redisPrefix
243
+ await attachAndWait({
244
+ name: "prefixed-queue",
245
+ getJob: jest.fn(),
246
+ opts: { connection: {}, prefix: "myapp" },
247
+ });
248
+ expect(MockQueueEvents).toHaveBeenCalledTimes(1);
249
+ expect(MockQueueEvents).toHaveBeenCalledWith("prefixed-queue", expect.objectContaining({ prefix: "myapp" }));
250
+ });
251
+ it("should fall back to redisPrefix when opts.prefix is not set", async () => {
252
+ await attachAndWait({
253
+ name: "legacy-queue",
254
+ getJob: jest.fn(),
255
+ opts: { connection: {} },
256
+ redisPrefix: "legacy",
257
+ });
258
+ expect(MockQueueEvents).toHaveBeenCalledWith("legacy-queue", expect.objectContaining({ prefix: "legacy" }));
259
+ });
260
+ // e.g. the queue shares an ioredis instance with default options: fine
261
+ // for a Queue, but BullMQ rejects it for QueueEvents' blocking connection
262
+ const connectionError = () => new Error("BullMQ: Your redis options maxRetriesPerRequest must be null.");
263
+ it("should report a QueueEvents failure instead of crashing the process", async () => {
264
+ MockQueueEvents.mockImplementationOnce(() => {
265
+ throw connectionError();
266
+ });
267
+ // attach() has already returned when this fails; Jest fails this test
268
+ // if the rejection goes unhandled
269
+ await attachAndWait({
270
+ name: "shared-connection-queue",
271
+ getJob: jest.fn(),
272
+ opts: { connection: {} },
273
+ });
274
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Queuedash: Failed to attach queue "shared-connection-queue"', connectionError());
275
+ });
276
+ it("should pass a QueueEvents failure to onError", async () => {
277
+ const onError = jest.fn();
278
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key", onError });
279
+ MockQueueEvents.mockImplementationOnce(() => {
280
+ throw connectionError();
281
+ });
282
+ await attachAndWait({
283
+ name: "shared-connection-queue",
284
+ getJob: jest.fn(),
285
+ opts: { connection: {} },
286
+ });
287
+ expect(onError).toHaveBeenCalledTimes(1);
288
+ expect(onError.mock.calls[0][0].message).toBe('Failed to attach queue "shared-connection-queue": ' +
289
+ connectionError().message);
290
+ });
291
+ it("should warn instead of leaving an unhandled rejection when getJob fails", async () => {
292
+ // e.g. the app closed its Queue before calling qd.stop()
293
+ const queue = {
294
+ name: "closed-queue",
295
+ opts: { connection: {} },
296
+ getJob: jest.fn().mockRejectedValue(new Error("Connection is closed.")),
297
+ };
298
+ // Shaped like QueueEvents: an EventEmitter with close()
299
+ const events = Object.assign(new events_1.EventEmitter(), {
300
+ close: async () => { },
301
+ });
302
+ sdk.attach(queue, { events });
303
+ events.emit("completed", { jobId: "1" });
304
+ // Let the rejection settle; Jest fails this test if it goes unhandled
305
+ jest.useRealTimers();
306
+ await new Promise((resolve) => setImmediate(resolve));
307
+ jest.useFakeTimers();
308
+ expect(queue.getJob).toHaveBeenCalledWith("1");
309
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to load job 1"));
310
+ });
311
+ });
222
312
  describe("attach() Method", () => {
223
313
  beforeEach(() => {
224
314
  sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
@@ -340,6 +430,98 @@ describe("Queuedash SDK", () => {
340
430
  expect(mockFetch).not.toHaveBeenCalled();
341
431
  });
342
432
  });
433
+ describe("Snapshot Updates", () => {
434
+ const okResponse = {
435
+ ok: true,
436
+ json: () => Promise.resolve({ success: true, synced: 1 }),
437
+ };
438
+ // Keeps the next sync request in flight until the test settles it
439
+ const holdNextFetch = () => {
440
+ const request = {
441
+ resolve: (_response) => { },
442
+ reject: (_error) => { },
443
+ };
444
+ mockFetch.mockImplementationOnce(() => new Promise((resolve, reject) => {
445
+ request.resolve = resolve;
446
+ request.reject = reject;
447
+ }));
448
+ return request;
449
+ };
450
+ const sentJobs = (call) => JSON.parse(mockFetch.mock.calls[call][1].body).jobs;
451
+ const snapshot = (jobId, status) => ({
452
+ ...createMockJobData(jobId),
453
+ status,
454
+ });
455
+ beforeEach(() => {
456
+ mockFetch.mockResolvedValue(okResponse);
457
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
458
+ });
459
+ it("should replace a pending snapshot with a newer one", () => {
460
+ sdk.syncJob(snapshot("job-1", "waiting"));
461
+ sdk.syncJob(snapshot("job-1", "completed"));
462
+ jest.advanceTimersByTime(100);
463
+ expect(mockFetch).toHaveBeenCalledTimes(1);
464
+ expect(sentJobs(0)).toEqual([
465
+ expect.objectContaining({ jobId: "job-1", status: "completed" }),
466
+ ]);
467
+ });
468
+ it("should send a snapshot that arrives while the job's batch is in flight", async () => {
469
+ const request = holdNextFetch();
470
+ sdk.syncJob(snapshot("job-1", "waiting"));
471
+ const inFlight = sdk.flush();
472
+ sdk.syncJob(snapshot("job-1", "completed"));
473
+ request.resolve(okResponse);
474
+ await inFlight;
475
+ jest.advanceTimersByTime(100);
476
+ expect(mockFetch).toHaveBeenCalledTimes(2);
477
+ expect(sentJobs(1)).toEqual([
478
+ expect.objectContaining({ jobId: "job-1", status: "completed" }),
479
+ ]);
480
+ });
481
+ it("should not requeue a failed snapshot over a newer one", async () => {
482
+ const request = holdNextFetch();
483
+ sdk.syncJob(snapshot("job-1", "waiting"));
484
+ const inFlight = sdk.flush();
485
+ sdk.syncJob(snapshot("job-1", "completed"));
486
+ request.reject(new Error("Network error"));
487
+ await inFlight;
488
+ // Retried once the 1s backoff elapses
489
+ jest.advanceTimersByTime(1000);
490
+ expect(mockFetch).toHaveBeenCalledTimes(2);
491
+ expect(sentJobs(1)).toEqual([
492
+ expect.objectContaining({ jobId: "job-1", status: "completed" }),
493
+ ]);
494
+ });
495
+ it("should send snapshots queued behind an in-flight flush on stop()", async () => {
496
+ const request = holdNextFetch();
497
+ sdk.syncJob(snapshot("job-1", "waiting"));
498
+ const inFlight = sdk.flush();
499
+ sdk.syncJob(snapshot("job-1", "completed"));
500
+ const stopped = sdk.stop();
501
+ request.resolve(okResponse);
502
+ await Promise.all([inFlight, stopped]);
503
+ expect(mockFetch).toHaveBeenCalledTimes(2);
504
+ expect(sentJobs(1)).toEqual([
505
+ expect.objectContaining({ jobId: "job-1", status: "completed" }),
506
+ ]);
507
+ });
508
+ it("should accept new snapshots for a job dropped by maxQueueSize", () => {
509
+ sdk = new client_1.Queuedash({
510
+ apiKey: "test-api-key",
511
+ batchSize: 100,
512
+ maxQueueSize: 2,
513
+ });
514
+ sdk.syncJob(snapshot("job-1", "waiting"));
515
+ sdk.syncJob(snapshot("job-2", "waiting"));
516
+ sdk.syncJob(snapshot("job-3", "waiting")); // drops job-1
517
+ sdk.syncJob(snapshot("job-1", "completed")); // drops job-2
518
+ jest.advanceTimersByTime(100);
519
+ expect(sentJobs(0)).toEqual([
520
+ expect.objectContaining({ jobId: "job-3", status: "waiting" }),
521
+ expect.objectContaining({ jobId: "job-1", status: "completed" }),
522
+ ]);
523
+ });
524
+ });
343
525
  describe("flush() Method", () => {
344
526
  beforeEach(() => {
345
527
  mockFetch.mockResolvedValue({
@@ -478,6 +660,132 @@ describe("Queuedash SDK", () => {
478
660
  expect(onError).toHaveBeenCalled();
479
661
  expect(onError.mock.calls[0][0].message).toContain("401");
480
662
  });
663
+ it("should clear the request timeout when fetch rejects", async () => {
664
+ mockFetch.mockRejectedValue(new Error("Network error"));
665
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key", maxRetries: 0 });
666
+ sdk.syncJob(createMockJobData("job-1"));
667
+ const timerCount = jest.getTimerCount();
668
+ await sdk.flush();
669
+ // A leftover abort timer would keep the process alive for 30s
670
+ expect(mockFetch).toHaveBeenCalledTimes(1);
671
+ expect(jest.getTimerCount()).toBe(timerCount);
672
+ });
673
+ it("should time out a response body that stalls", async () => {
674
+ mockFetch.mockImplementation((_url, { signal }) => Promise.resolve(stalledResponse(signal, { ok: true })));
675
+ const onError = jest.fn();
676
+ sdk = new client_1.Queuedash({
677
+ apiKey: "test-api-key",
678
+ maxRetries: 0,
679
+ requestTimeout: 5000,
680
+ onError,
681
+ });
682
+ sdk.syncJob(createMockJobData("job-1"));
683
+ sdk.flush();
684
+ await jest.advanceTimersByTimeAsync(4999);
685
+ expect(onError).not.toHaveBeenCalled();
686
+ // Fails at requestTimeout instead of blocking every later flush
687
+ await jest.advanceTimersByTimeAsync(1);
688
+ expect(onError).toHaveBeenCalledTimes(1);
689
+ });
690
+ });
691
+ describe("Retry Backoff", () => {
692
+ const okResponse = {
693
+ ok: true,
694
+ json: () => Promise.resolve({ success: true, synced: 1 }),
695
+ };
696
+ let onError;
697
+ beforeEach(() => {
698
+ onError = jest.fn();
699
+ mockFetch.mockRejectedValue(new Error("Network error"));
700
+ });
701
+ it("should not retry before each backoff elapses", async () => {
702
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key", onError });
703
+ sdk.syncJob(createMockJobData("job-1"));
704
+ await jest.advanceTimersByTimeAsync(100); // periodic flush: attempt 1
705
+ expect(mockFetch).toHaveBeenCalledTimes(1);
706
+ // The periodic flush keeps ticking every 100ms throughout
707
+ for (const backoffMs of [1000, 2000, 4000, 8000, 16000]) {
708
+ const attempts = mockFetch.mock.calls.length;
709
+ await jest.advanceTimersByTimeAsync(backoffMs - 1);
710
+ expect(mockFetch).toHaveBeenCalledTimes(attempts);
711
+ await jest.advanceTimersByTimeAsync(1);
712
+ expect(mockFetch).toHaveBeenCalledTimes(attempts + 1);
713
+ }
714
+ // The sixth failed attempt uses up maxRetries (5)
715
+ expect(onError).toHaveBeenCalledTimes(1);
716
+ expect(onError.mock.calls[0][0].message).toContain("Failed to sync 1 jobs after 5 retries");
717
+ });
718
+ it("should not flush a full batch before the backoff elapses", async () => {
719
+ // No periodic flush within the test, only batch-size flushes
720
+ sdk = new client_1.Queuedash({
721
+ apiKey: "test-api-key",
722
+ batchSize: 2,
723
+ flushInterval: 60000,
724
+ onError,
725
+ });
726
+ sdk.syncJob(createMockJobData("job-1"));
727
+ sdk.syncJob(createMockJobData("job-2"));
728
+ await jest.advanceTimersByTimeAsync(0); // attempt 1 fails
729
+ sdk.syncJob(createMockJobData("job-3"));
730
+ sdk.syncJob(createMockJobData("job-4"));
731
+ await jest.advanceTimersByTimeAsync(999);
732
+ expect(mockFetch).toHaveBeenCalledTimes(1);
733
+ // The retry also sends the jobs held back during the backoff
734
+ mockFetch.mockResolvedValue(okResponse);
735
+ await jest.advanceTimersByTimeAsync(1);
736
+ expect(mockFetch).toHaveBeenCalledTimes(2);
737
+ expect(JSON.parse(mockFetch.mock.calls[1][1].body).jobs).toHaveLength(4);
738
+ });
739
+ it("should not wait for the backoff on an explicit flush()", async () => {
740
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key", onError });
741
+ sdk.syncJob(createMockJobData("job-1"));
742
+ await jest.advanceTimersByTimeAsync(100); // attempt 1 fails
743
+ mockFetch.mockResolvedValue(okResponse);
744
+ await sdk.flush();
745
+ expect(mockFetch).toHaveBeenCalledTimes(2);
746
+ // The success cancels the backoff, so periodic flushes resume
747
+ sdk.syncJob(createMockJobData("job-2"));
748
+ await jest.advanceTimersByTimeAsync(100);
749
+ expect(mockFetch).toHaveBeenCalledTimes(3);
750
+ });
751
+ it("should resume flushing held jobs after the circuit breaker resets", async () => {
752
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key", maxRetries: 20, onError });
753
+ sdk.syncJob(createMockJobData("job-1"));
754
+ // 10 consecutive failures, 1s, 2s, 4s, 8s, 16s, then 30s (capped) apart
755
+ await jest.advanceTimersByTimeAsync(100 + 1000 + 2000 + 4000 + 8000 + 16000 + 30000 * 4);
756
+ expect(mockFetch).toHaveBeenCalledTimes(10);
757
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Circuit breaker opened"));
758
+ // Nothing is sent while it's open, including the retry due 30s later
759
+ await jest.advanceTimersByTimeAsync(59999);
760
+ expect(mockFetch).toHaveBeenCalledTimes(10);
761
+ // It resets after 60s and the next periodic flush sends the held job
762
+ mockFetch.mockResolvedValue(okResponse);
763
+ await jest.advanceTimersByTimeAsync(101);
764
+ expect(mockFetch).toHaveBeenCalledTimes(11);
765
+ });
766
+ it("should make one final attempt on stop() instead of waiting out the backoff", async () => {
767
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key", onError });
768
+ sdk.syncJob(createMockJobData("job-1"));
769
+ await jest.advanceTimersByTimeAsync(100); // attempt 1 fails
770
+ mockFetch.mockResolvedValue(okResponse);
771
+ await sdk.stop();
772
+ expect(mockFetch).toHaveBeenCalledTimes(2);
773
+ expect(JSON.parse(mockFetch.mock.calls[1][1].body).jobs).toEqual([
774
+ expect.objectContaining({ jobId: "job-1" }),
775
+ ]);
776
+ expect(onError).not.toHaveBeenCalled();
777
+ });
778
+ it("should report instead of retrying when the final attempt on stop() fails", async () => {
779
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key", onError });
780
+ sdk.syncJob(createMockJobData("job-1"));
781
+ await jest.advanceTimersByTimeAsync(100); // attempt 1 fails
782
+ await sdk.stop();
783
+ expect(mockFetch).toHaveBeenCalledTimes(2);
784
+ expect(onError).toHaveBeenCalledTimes(1);
785
+ expect(onError.mock.calls[0][0].message).toContain("Failed to sync 1 jobs while stopping");
786
+ await jest.advanceTimersByTimeAsync(60000);
787
+ expect(mockFetch).toHaveBeenCalledTimes(2);
788
+ });
481
789
  });
482
790
  describe("deleteJob() Method", () => {
483
791
  beforeEach(() => {
@@ -536,6 +844,27 @@ describe("Queuedash SDK", () => {
536
844
  jest.useFakeTimers();
537
845
  expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to delete job"), expect.any(Error));
538
846
  });
847
+ it("should clear the request timeout when fetch rejects", async () => {
848
+ mockFetch.mockRejectedValue(new Error("Network error"));
849
+ const timerCount = jest.getTimerCount();
850
+ await sdk.deleteJob("job-123", "test-queue");
851
+ expect(mockFetch).toHaveBeenCalledTimes(1);
852
+ expect(jest.getTimerCount()).toBe(timerCount);
853
+ });
854
+ it("should time out an error response body that stalls", async () => {
855
+ mockFetch.mockImplementation((_url, { signal }) => Promise.resolve(stalledResponse(signal, { ok: false, status: 500 })));
856
+ const onError = jest.fn();
857
+ sdk = new client_1.Queuedash({
858
+ apiKey: "test-api-key",
859
+ requestTimeout: 5000,
860
+ onError,
861
+ });
862
+ sdk.deleteJob("job-123", "test-queue");
863
+ await jest.advanceTimersByTimeAsync(4999);
864
+ expect(onError).not.toHaveBeenCalled();
865
+ await jest.advanceTimersByTimeAsync(1);
866
+ expect(onError).toHaveBeenCalledTimes(1);
867
+ });
539
868
  });
540
869
  describe("Circuit Breaker", () => {
541
870
  beforeEach(() => {
@@ -669,6 +998,38 @@ describe("Queuedash SDK", () => {
669
998
  expect(body.jobs[0].data).toEqual({ foo: "bar" });
670
999
  expect(body.jobs[0].progress).toBe(50);
671
1000
  });
1001
+ it("should load the job for Bull global events, which pass a job id", async () => {
1002
+ const mockBullQueue = {
1003
+ name: "test-queue",
1004
+ clients: [{}],
1005
+ on: jest.fn(),
1006
+ getJob: jest.fn((jobId) => Promise.resolve({
1007
+ id: jobId,
1008
+ name: "test-job",
1009
+ data: { foo: "bar" },
1010
+ opts: {},
1011
+ timestamp: Date.now(),
1012
+ finishedOn: Date.now(),
1013
+ progress: () => 100,
1014
+ })),
1015
+ };
1016
+ sdk.attach(mockBullQueue);
1017
+ const handler = (event) => mockBullQueue.on.mock.calls.find((call) => call[0] === event)[1];
1018
+ // Bull emits global events as (jobId, result, previousState)
1019
+ handler("global:completed")("123", '"done"', "active");
1020
+ handler("global:failed")("456", "boom", "active");
1021
+ await Promise.all(mockBullQueue.getJob.mock.results.map((r) => r.value));
1022
+ jest.useRealTimers();
1023
+ await sdk.flush();
1024
+ jest.useFakeTimers();
1025
+ expect(mockBullQueue.getJob).toHaveBeenCalledWith("123");
1026
+ expect(mockBullQueue.getJob).toHaveBeenCalledWith("456");
1027
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
1028
+ expect(body.jobs).toEqual([
1029
+ expect.objectContaining({ jobId: "123", data: { foo: "bar" } }),
1030
+ expect.objectContaining({ jobId: "456", data: { foo: "bar" } }),
1031
+ ]);
1032
+ });
672
1033
  it("should handle Bee-Queue succeeded event and set finishedAt", () => {
673
1034
  const mockBeeQueue = {
674
1035
  name: "test-queue",
@@ -807,3 +1168,11 @@ function createMockJobData(jobId, queueName = "test-queue") {
807
1168
  finishedAt: null,
808
1169
  };
809
1170
  }
1171
+ // Mock response whose headers arrived but whose body never finishes, unless
1172
+ // the request is aborted
1173
+ function stalledResponse(signal, fields) {
1174
+ const readBody = () => new Promise((_resolve, reject) => {
1175
+ signal.addEventListener("abort", () => reject(new Error("This operation was aborted")));
1176
+ });
1177
+ return { ...fields, json: readBody, text: readBody };
1178
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@queuedash/sdk",
3
- "version": "2.0.0",
4
- "description": "Queuedash SDK for real-time queue monitoring",
3
+ "version": "2.0.1",
4
+ "description": "Queuedash SDK for BullMQ, Bull, and Bee-Queue monitoring",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "files": [
@@ -13,7 +13,6 @@
13
13
  "bullmq",
14
14
  "bull",
15
15
  "bee-queue",
16
- "groupmq",
17
16
  "monitoring",
18
17
  "dashboard"
19
18
  ],