@queuedash/sdk 1.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/LICENSE +21 -0
- package/README.md +13 -15
- package/dist/client.d.ts +18 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +212 -93
- package/dist/client.spec.js +375 -6
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Alex Budure
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @queuedash/sdk
|
|
2
2
|
|
|
3
|
-
Official SDK for Queuedash -
|
|
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
|
|
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.
|
|
86
|
-
2.
|
|
87
|
-
3.
|
|
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:
|
|
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
|
|
132
|
-
- Jobs
|
|
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,23 +149,19 @@ 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
|
-
-
|
|
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
|
-
-
|
|
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
162
|
- **Auto-detection**: Works with BullMQ, Bull, and Bee-Queue automatically
|
|
161
|
-
- **
|
|
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
|
|
165
167
|
- **TypeScript**: Full TypeScript support
|
|
166
|
-
|
|
167
|
-
## License
|
|
168
|
-
|
|
169
|
-
MIT
|
package/dist/client.d.ts
CHANGED
|
@@ -15,11 +15,13 @@ 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);
|
|
@@ -60,14 +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;
|
|
77
|
+
private splitIntoChunks;
|
|
78
|
+
private sendBatch;
|
|
67
79
|
private openCircuitBreaker;
|
|
68
80
|
private log;
|
|
81
|
+
private clearRetryTimer;
|
|
69
82
|
/**
|
|
70
|
-
* 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.
|
|
71
86
|
*/
|
|
72
87
|
stop(): Promise<void>;
|
|
73
88
|
private startFlushTimer;
|
package/dist/client.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
@@ -34,14 +34,17 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.Queuedash = void 0;
|
|
37
|
+
const MAX_PAYLOAD_SIZE = 4.5 * 1024 * 1024; // 4.5MB
|
|
37
38
|
class Queuedash {
|
|
38
39
|
constructor(options) {
|
|
39
|
-
|
|
40
|
+
// Latest unsent snapshot per `${queueName}:${jobId}`, oldest first
|
|
41
|
+
this.jobQueue = new Map();
|
|
40
42
|
this.isFlushing = false;
|
|
43
|
+
// Set by stop(): failed syncs are reported instead of retried
|
|
44
|
+
this.stopped = false;
|
|
41
45
|
this.retryCount = 0;
|
|
42
46
|
this.circuitBreakerFailures = 0;
|
|
43
47
|
this.circuitBreakerOpen = false;
|
|
44
|
-
this.syncingJobIds = new Set();
|
|
45
48
|
// Track attached resources for cleanup
|
|
46
49
|
this.managedQueueEvents = [];
|
|
47
50
|
this.attachedQueues = new Set();
|
|
@@ -52,8 +55,8 @@ class Queuedash {
|
|
|
52
55
|
this.apiUrl =
|
|
53
56
|
options.baseUrl ||
|
|
54
57
|
(process.env.NODE_ENV === "production"
|
|
55
|
-
? "https://
|
|
56
|
-
: "http://localhost:
|
|
58
|
+
? "https://sync.queuedash.com"
|
|
59
|
+
: "http://localhost:4002");
|
|
57
60
|
this.batchSize = options.batchSize ?? 50;
|
|
58
61
|
this.flushInterval = options.flushInterval ?? 100;
|
|
59
62
|
this.maxRetries = options.maxRetries ?? 5;
|
|
@@ -84,9 +87,22 @@ class Queuedash {
|
|
|
84
87
|
attach(resource, options) {
|
|
85
88
|
const type = this.detectType(resource);
|
|
86
89
|
switch (type) {
|
|
87
|
-
case "bullmq-queue":
|
|
88
|
-
|
|
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
|
+
});
|
|
89
104
|
break;
|
|
105
|
+
}
|
|
90
106
|
case "bullmq-worker":
|
|
91
107
|
this.attachBullMQWorker(resource);
|
|
92
108
|
break;
|
|
@@ -97,7 +113,7 @@ class Queuedash {
|
|
|
97
113
|
this.attachBee(resource);
|
|
98
114
|
break;
|
|
99
115
|
default:
|
|
100
|
-
throw new Error("Queuedash: Unknown queue type. Supported: BullMQ Queue
|
|
116
|
+
throw new Error("Queuedash: Unknown queue type. Supported: BullMQ Queue, Bull, Bee-Queue");
|
|
101
117
|
}
|
|
102
118
|
return this;
|
|
103
119
|
}
|
|
@@ -138,40 +154,44 @@ class Queuedash {
|
|
|
138
154
|
events = existingEvents;
|
|
139
155
|
}
|
|
140
156
|
else {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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;
|
|
154
171
|
}
|
|
155
172
|
this.attachedQueues.add(`bullmq:${queueName}`);
|
|
156
|
-
const syncJobById = (jobId) => {
|
|
157
|
-
queue
|
|
173
|
+
const syncJobById = (jobId, status) => {
|
|
174
|
+
queue
|
|
175
|
+
.getJob(jobId)
|
|
176
|
+
.then((job) => {
|
|
158
177
|
if (job) {
|
|
159
|
-
this.syncJob(this.extractBullMQJobData(job, queueName));
|
|
178
|
+
this.syncJob(this.extractBullMQJobData(job, queueName, status));
|
|
160
179
|
}
|
|
161
|
-
})
|
|
180
|
+
})
|
|
181
|
+
.catch((error) => this.log(`Failed to load job ${jobId}`, error));
|
|
162
182
|
};
|
|
163
|
-
events.on("waiting", ({ jobId }) => syncJobById(jobId));
|
|
164
|
-
events.on("progress", ({ jobId }) => syncJobById(jobId));
|
|
165
|
-
events.on("completed", ({ jobId }) => syncJobById(jobId));
|
|
166
|
-
events.on("failed", ({ jobId }) => syncJobById(jobId));
|
|
167
|
-
events.on("waiting-children", ({ jobId }) => syncJobById(jobId));
|
|
168
|
-
events.on("added", ({ jobId }) => syncJobById(jobId));
|
|
169
|
-
events.on("active", ({ jobId }) => syncJobById(jobId));
|
|
170
|
-
events.on("delayed", ({ jobId }) => syncJobById(jobId));
|
|
183
|
+
events.on("waiting", ({ jobId }) => syncJobById(jobId, "waiting"));
|
|
184
|
+
events.on("progress", ({ jobId }) => syncJobById(jobId, "active"));
|
|
185
|
+
events.on("completed", ({ jobId }) => syncJobById(jobId, "completed"));
|
|
186
|
+
events.on("failed", ({ jobId }) => syncJobById(jobId, "failed"));
|
|
187
|
+
events.on("waiting-children", ({ jobId }) => syncJobById(jobId, "waiting-children"));
|
|
188
|
+
events.on("added", ({ jobId }) => syncJobById(jobId, "waiting"));
|
|
189
|
+
events.on("active", ({ jobId }) => syncJobById(jobId, "active"));
|
|
190
|
+
events.on("delayed", ({ jobId }) => syncJobById(jobId, "delayed"));
|
|
171
191
|
events.on("deduplicated", ({ jobId }) => syncJobById(jobId));
|
|
172
192
|
events.on("removed", ({ jobId }) => this.deleteJob(jobId, queueName));
|
|
173
|
-
events.on("stalled", ({ jobId }) => syncJobById(jobId));
|
|
174
|
-
events.on("retries-exhausted", ({ jobId }) => syncJobById(jobId));
|
|
193
|
+
events.on("stalled", ({ jobId }) => syncJobById(jobId, "waiting"));
|
|
194
|
+
events.on("retries-exhausted", ({ jobId }) => syncJobById(jobId, "failed"));
|
|
175
195
|
}
|
|
176
196
|
attachBullMQWorker(_worker) {
|
|
177
197
|
// TODO: Worker monitoring - track active jobs, stalled detection, etc.
|
|
@@ -184,14 +204,19 @@ class Queuedash {
|
|
|
184
204
|
return;
|
|
185
205
|
}
|
|
186
206
|
this.attachedQueues.add(`bull:${queueName}`);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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));
|
|
195
220
|
queue.on("active", (job) => {
|
|
196
221
|
this.syncJob(this.extractBullJobData(job, queueName));
|
|
197
222
|
});
|
|
@@ -227,7 +252,7 @@ class Queuedash {
|
|
|
227
252
|
this.syncJob(jobData);
|
|
228
253
|
});
|
|
229
254
|
}
|
|
230
|
-
extractBullMQJobData(job, queueName) {
|
|
255
|
+
extractBullMQJobData(job, queueName, status) {
|
|
231
256
|
return {
|
|
232
257
|
jobId: job.id,
|
|
233
258
|
name: job.name,
|
|
@@ -243,6 +268,7 @@ class Queuedash {
|
|
|
243
268
|
delay: job.opts?.delay,
|
|
244
269
|
timestamp: job.timestamp,
|
|
245
270
|
progress: typeof job.progress === "number" ? job.progress : null,
|
|
271
|
+
status,
|
|
246
272
|
};
|
|
247
273
|
}
|
|
248
274
|
extractBullJobData(job, queueName) {
|
|
@@ -283,9 +309,9 @@ class Queuedash {
|
|
|
283
309
|
* Delete a job (soft delete in Queuedash)
|
|
284
310
|
*/
|
|
285
311
|
async deleteJob(jobId, queueName) {
|
|
312
|
+
const controller = new AbortController();
|
|
313
|
+
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
286
314
|
try {
|
|
287
|
-
const controller = new AbortController();
|
|
288
|
-
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
289
315
|
const response = await fetch(`${this.apiUrl}/api/v1/jobs/${encodeURIComponent(jobId)}?queueName=${encodeURIComponent(queueName)}`, {
|
|
290
316
|
method: "DELETE",
|
|
291
317
|
headers: {
|
|
@@ -293,7 +319,6 @@ class Queuedash {
|
|
|
293
319
|
},
|
|
294
320
|
signal: controller.signal,
|
|
295
321
|
});
|
|
296
|
-
clearTimeout(timeoutId);
|
|
297
322
|
if (!response.ok) {
|
|
298
323
|
const errorText = await response.text();
|
|
299
324
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
@@ -308,6 +333,9 @@ class Queuedash {
|
|
|
308
333
|
console.error(`Queuedash: Failed to delete job ${jobId}`, err);
|
|
309
334
|
}
|
|
310
335
|
}
|
|
336
|
+
finally {
|
|
337
|
+
clearTimeout(timeoutId);
|
|
338
|
+
}
|
|
311
339
|
}
|
|
312
340
|
/**
|
|
313
341
|
* Queue a job for syncing (internal use)
|
|
@@ -316,58 +344,72 @@ class Queuedash {
|
|
|
316
344
|
if (this.circuitBreakerOpen) {
|
|
317
345
|
return;
|
|
318
346
|
}
|
|
319
|
-
const jobKey =
|
|
320
|
-
|
|
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);
|
|
321
353
|
return;
|
|
322
354
|
}
|
|
323
|
-
this.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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}`);
|
|
327
361
|
}
|
|
328
|
-
this.jobQueue.
|
|
329
|
-
if (this.jobQueue.
|
|
362
|
+
this.jobQueue.set(jobKey, job);
|
|
363
|
+
if (this.jobQueue.size >= this.batchSize && !this.retryTimer) {
|
|
330
364
|
this.flush();
|
|
331
365
|
}
|
|
332
366
|
}
|
|
367
|
+
jobKey(job) {
|
|
368
|
+
return `${job.queueName}:${job.jobId}`;
|
|
369
|
+
}
|
|
333
370
|
/**
|
|
334
|
-
* 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.
|
|
335
373
|
*/
|
|
336
374
|
async flush() {
|
|
337
375
|
if (this.isFlushing ||
|
|
338
|
-
this.jobQueue.
|
|
376
|
+
this.jobQueue.size === 0 ||
|
|
339
377
|
this.circuitBreakerOpen) {
|
|
340
378
|
return;
|
|
341
379
|
}
|
|
380
|
+
// This attempt replaces any scheduled retry; if it fails, the next retry
|
|
381
|
+
// is scheduled with a longer backoff
|
|
382
|
+
this.clearRetryTimer();
|
|
342
383
|
this.isFlushing = true;
|
|
343
|
-
|
|
344
|
-
this.jobQueue = [];
|
|
384
|
+
this.inFlightFlush = this.syncPendingJobs();
|
|
345
385
|
try {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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();
|
|
396
|
+
try {
|
|
397
|
+
// Check if payload is too large and split into chunks if needed
|
|
398
|
+
const payload = JSON.stringify({ jobs: jobsToSync });
|
|
399
|
+
if (payload.length > MAX_PAYLOAD_SIZE) {
|
|
400
|
+
const chunks = this.splitIntoChunks(jobsToSync, MAX_PAYLOAD_SIZE);
|
|
401
|
+
for (const chunk of chunks) {
|
|
402
|
+
await this.sendBatch(chunk);
|
|
403
|
+
// Remove successfully sent jobs so they aren't re-queued on later chunk failure
|
|
404
|
+
jobsToSync.splice(0, chunk.length);
|
|
405
|
+
}
|
|
406
|
+
this.retryCount = 0;
|
|
407
|
+
this.circuitBreakerFailures = 0;
|
|
408
|
+
return;
|
|
365
409
|
}
|
|
410
|
+
await this.sendBatch(jobsToSync);
|
|
366
411
|
this.retryCount = 0;
|
|
367
412
|
this.circuitBreakerFailures = 0;
|
|
368
|
-
jobsToSync.forEach((job) => {
|
|
369
|
-
this.syncingJobIds.delete(`${job.queueName}:${job.jobId}`);
|
|
370
|
-
});
|
|
371
413
|
}
|
|
372
414
|
catch (error) {
|
|
373
415
|
this.retryCount++;
|
|
@@ -376,22 +418,22 @@ class Queuedash {
|
|
|
376
418
|
if (this.circuitBreakerFailures >= 10) {
|
|
377
419
|
this.openCircuitBreaker();
|
|
378
420
|
}
|
|
379
|
-
if (this.retryCount <= this.maxRetries) {
|
|
380
|
-
this.
|
|
421
|
+
if (this.retryCount <= this.maxRetries && !this.stopped) {
|
|
422
|
+
this.requeue(jobsToSync);
|
|
381
423
|
const backoffMs = Math.min(1000 * Math.pow(2, this.retryCount - 1), 30000);
|
|
382
424
|
this.log(`Retry ${this.retryCount}/${this.maxRetries} after ${backoffMs}ms`, err);
|
|
383
|
-
setTimeout(() => {
|
|
384
|
-
|
|
425
|
+
this.retryTimer = setTimeout(() => {
|
|
426
|
+
this.retryTimer = undefined;
|
|
427
|
+
if (this.jobQueue.size > 0) {
|
|
385
428
|
this.flush();
|
|
386
429
|
}
|
|
387
430
|
}, backoffMs);
|
|
388
431
|
}
|
|
389
432
|
else {
|
|
390
433
|
this.retryCount = 0;
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
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`;
|
|
395
437
|
if (this.onError) {
|
|
396
438
|
this.onError(new Error(`${errorMsg}: ${err.message}`));
|
|
397
439
|
}
|
|
@@ -400,8 +442,69 @@ class Queuedash {
|
|
|
400
442
|
}
|
|
401
443
|
}
|
|
402
444
|
}
|
|
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);
|
|
460
|
+
}
|
|
461
|
+
this.jobQueue = requeued;
|
|
462
|
+
}
|
|
463
|
+
splitIntoChunks(jobs, maxSize) {
|
|
464
|
+
const chunks = [];
|
|
465
|
+
let currentChunk = [];
|
|
466
|
+
let currentSize = 0;
|
|
467
|
+
for (const job of jobs) {
|
|
468
|
+
const jobSize = JSON.stringify(job).length;
|
|
469
|
+
if (currentSize + jobSize > maxSize && currentChunk.length > 0) {
|
|
470
|
+
chunks.push(currentChunk);
|
|
471
|
+
currentChunk = [];
|
|
472
|
+
currentSize = 0;
|
|
473
|
+
}
|
|
474
|
+
currentChunk.push(job);
|
|
475
|
+
currentSize += jobSize;
|
|
476
|
+
}
|
|
477
|
+
if (currentChunk.length > 0) {
|
|
478
|
+
chunks.push(currentChunk);
|
|
479
|
+
}
|
|
480
|
+
return chunks;
|
|
481
|
+
}
|
|
482
|
+
async sendBatch(jobs) {
|
|
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
|
|
486
|
+
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
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
|
+
}
|
|
505
|
+
}
|
|
403
506
|
finally {
|
|
404
|
-
|
|
507
|
+
clearTimeout(timeoutId);
|
|
405
508
|
}
|
|
406
509
|
}
|
|
407
510
|
openCircuitBreaker() {
|
|
@@ -426,8 +529,16 @@ class Queuedash {
|
|
|
426
529
|
console.warn(`Queuedash: ${message}`);
|
|
427
530
|
}
|
|
428
531
|
}
|
|
532
|
+
clearRetryTimer() {
|
|
533
|
+
if (this.retryTimer) {
|
|
534
|
+
clearTimeout(this.retryTimer);
|
|
535
|
+
this.retryTimer = undefined;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
429
538
|
/**
|
|
430
|
-
* 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.
|
|
431
542
|
*/
|
|
432
543
|
async stop() {
|
|
433
544
|
if (this.flushTimer) {
|
|
@@ -445,11 +556,19 @@ class Queuedash {
|
|
|
445
556
|
// Ignore close errors
|
|
446
557
|
}
|
|
447
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();
|
|
448
567
|
await this.flush();
|
|
449
568
|
}
|
|
450
569
|
startFlushTimer() {
|
|
451
570
|
this.flushTimer = setInterval(() => {
|
|
452
|
-
if (this.jobQueue.
|
|
571
|
+
if (this.jobQueue.size > 0 && !this.retryTimer) {
|
|
453
572
|
this.flush();
|
|
454
573
|
}
|
|
455
574
|
}, this.flushInterval);
|
|
@@ -466,8 +585,8 @@ class Queuedash {
|
|
|
466
585
|
process.once("SIGTERM", shutdown);
|
|
467
586
|
process.once("SIGINT", shutdown);
|
|
468
587
|
process.once("beforeExit", () => {
|
|
469
|
-
if (this.jobQueue.
|
|
470
|
-
console.warn(`Queuedash: Process exiting with ${this.jobQueue.
|
|
588
|
+
if (this.jobQueue.size > 0) {
|
|
589
|
+
console.warn(`Queuedash: Process exiting with ${this.jobQueue.size} unsync'd jobs`);
|
|
471
590
|
}
|
|
472
591
|
});
|
|
473
592
|
}
|
package/dist/client.spec.js
CHANGED
|
@@ -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
|
|
@@ -85,7 +95,7 @@ describe("Queuedash SDK", () => {
|
|
|
85
95
|
sdk.syncJob(createMockJobData("job-1"));
|
|
86
96
|
// Trigger flush by advancing timer
|
|
87
97
|
jest.advanceTimersByTime(100);
|
|
88
|
-
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("https://
|
|
98
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("https://sync.queuedash.com"), expect.any(Object));
|
|
89
99
|
process.env.NODE_ENV = originalEnv;
|
|
90
100
|
});
|
|
91
101
|
it("should use localhost URL in non-production environment", () => {
|
|
@@ -98,7 +108,7 @@ describe("Queuedash SDK", () => {
|
|
|
98
108
|
sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
|
|
99
109
|
sdk.syncJob(createMockJobData("job-1"));
|
|
100
110
|
jest.advanceTimersByTime(100);
|
|
101
|
-
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("http://localhost:
|
|
111
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("http://localhost:4002"), expect.any(Object));
|
|
102
112
|
process.env.NODE_ENV = originalEnv;
|
|
103
113
|
});
|
|
104
114
|
it("should override default URL with baseUrl option", () => {
|
|
@@ -172,12 +182,9 @@ describe("Queuedash SDK", () => {
|
|
|
172
182
|
redisPrefix: "bull",
|
|
173
183
|
};
|
|
174
184
|
// BullMQ detection works - attach returns this for chaining
|
|
175
|
-
//
|
|
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/dist/types.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export interface QueuedashOptions {
|
|
2
2
|
/** Your Queuedash API key */
|
|
3
3
|
apiKey: string;
|
|
4
|
-
/** Override the API URL (defaults to https://
|
|
4
|
+
/** Override the API URL (defaults to https://sync.queuedash.com in production) */
|
|
5
5
|
baseUrl?: string;
|
|
6
6
|
/** Number of jobs to batch before syncing (default: 50) */
|
|
7
7
|
batchSize?: number;
|
|
@@ -33,6 +33,7 @@ export interface JobData {
|
|
|
33
33
|
delay?: number | null;
|
|
34
34
|
timestamp?: number | null;
|
|
35
35
|
progress?: number | null;
|
|
36
|
+
status?: string;
|
|
36
37
|
}
|
|
37
38
|
export interface SyncResponse {
|
|
38
39
|
success: boolean;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,6BAA6B;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,6BAA6B;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,kFAAkF;IAClF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2DAA2D;IAC3D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kDAAkD;IAClD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mDAAmD;IACnD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2BAA2B;IAC3B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAED,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,OAAO,EAAE,IAAI,CAAC;IACd,WAAW,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAC1B,UAAU,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,SAAS,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@queuedash/sdk",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Queuedash SDK for
|
|
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
|
],
|
|
@@ -40,6 +39,7 @@
|
|
|
40
39
|
"@changesets/cli": "^2.29.7",
|
|
41
40
|
"@types/jest": "^29.5.14",
|
|
42
41
|
"@types/node": "^22.14.0",
|
|
42
|
+
"bullmq": "^5.67.2",
|
|
43
43
|
"husky": "^9.1.7",
|
|
44
44
|
"jest": "^29.7.0",
|
|
45
45
|
"lint-staged": "^16.2.7",
|