@blokjs/trigger-worker 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ /**
3
+ * @blok/trigger-worker
4
+ *
5
+ * Worker-based trigger for Blok workflows.
6
+ * Supports background job processing with:
7
+ * - Configurable concurrency per queue
8
+ * - Automatic retries with exponential backoff
9
+ * - Job timeouts
10
+ * - Priority-based job ordering
11
+ * - Delayed job scheduling
12
+ * - Queue statistics and monitoring
13
+ *
14
+ * Adapters:
15
+ * - BullMQ (Redis-backed, production)
16
+ * - InMemory (development/testing)
17
+ *
18
+ * @example BullMQ
19
+ * ```typescript
20
+ * import { WorkerTrigger, BullMQAdapter } from "@blok/trigger-worker";
21
+ *
22
+ * class MyWorkerTrigger extends WorkerTrigger {
23
+ * protected adapter = new BullMQAdapter({
24
+ * host: "localhost",
25
+ * port: 6379,
26
+ * });
27
+ *
28
+ * protected nodes = myNodes;
29
+ * protected workflows = myWorkflows;
30
+ * }
31
+ *
32
+ * const trigger = new MyWorkerTrigger();
33
+ * await trigger.listen();
34
+ *
35
+ * // Dispatch a job
36
+ * await trigger.dispatch("background-jobs", { userId: "123" }, {
37
+ * priority: 10,
38
+ * retries: 3,
39
+ * delay: 5000, // delay 5 seconds
40
+ * });
41
+ * ```
42
+ *
43
+ * @example InMemory (development)
44
+ * ```typescript
45
+ * import { WorkerTrigger, InMemoryAdapter } from "@blok/trigger-worker";
46
+ *
47
+ * class DevWorkerTrigger extends WorkerTrigger {
48
+ * protected adapter = new InMemoryAdapter();
49
+ * protected nodes = myNodes;
50
+ * protected workflows = myWorkflows;
51
+ * }
52
+ * ```
53
+ */
54
+ Object.defineProperty(exports, "__esModule", { value: true });
55
+ exports.InMemoryAdapter = exports.BullMQAdapter = exports.WorkerTrigger = void 0;
56
+ // Core exports
57
+ var WorkerTrigger_1 = require("./WorkerTrigger");
58
+ Object.defineProperty(exports, "WorkerTrigger", { enumerable: true, get: function () { return WorkerTrigger_1.WorkerTrigger; } });
59
+ // Adapters
60
+ var BullMQAdapter_1 = require("./adapters/BullMQAdapter");
61
+ Object.defineProperty(exports, "BullMQAdapter", { enumerable: true, get: function () { return BullMQAdapter_1.BullMQAdapter; } });
62
+ var InMemoryAdapter_1 = require("./adapters/InMemoryAdapter");
63
+ Object.defineProperty(exports, "InMemoryAdapter", { enumerable: true, get: function () { return InMemoryAdapter_1.InMemoryAdapter; } });
64
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvaW5kZXgudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUFBOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7R0FtREc7OztBQUVILGVBQWU7QUFDZixpREFLeUI7QUFKeEIsOEdBQUEsYUFBYSxPQUFBO0FBTWQsV0FBVztBQUNYLDBEQUE0RTtBQUFuRSw4R0FBQSxhQUFhLE9BQUE7QUFDdEIsOERBQTZEO0FBQXBELGtIQUFBLGVBQWUsT0FBQSIsInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogQGJsb2svdHJpZ2dlci13b3JrZXJcbiAqXG4gKiBXb3JrZXItYmFzZWQgdHJpZ2dlciBmb3IgQmxvayB3b3JrZmxvd3MuXG4gKiBTdXBwb3J0cyBiYWNrZ3JvdW5kIGpvYiBwcm9jZXNzaW5nIHdpdGg6XG4gKiAtIENvbmZpZ3VyYWJsZSBjb25jdXJyZW5jeSBwZXIgcXVldWVcbiAqIC0gQXV0b21hdGljIHJldHJpZXMgd2l0aCBleHBvbmVudGlhbCBiYWNrb2ZmXG4gKiAtIEpvYiB0aW1lb3V0c1xuICogLSBQcmlvcml0eS1iYXNlZCBqb2Igb3JkZXJpbmdcbiAqIC0gRGVsYXllZCBqb2Igc2NoZWR1bGluZ1xuICogLSBRdWV1ZSBzdGF0aXN0aWNzIGFuZCBtb25pdG9yaW5nXG4gKlxuICogQWRhcHRlcnM6XG4gKiAtIEJ1bGxNUSAoUmVkaXMtYmFja2VkLCBwcm9kdWN0aW9uKVxuICogLSBJbk1lbW9yeSAoZGV2ZWxvcG1lbnQvdGVzdGluZylcbiAqXG4gKiBAZXhhbXBsZSBCdWxsTVFcbiAqIGBgYHR5cGVzY3JpcHRcbiAqIGltcG9ydCB7IFdvcmtlclRyaWdnZXIsIEJ1bGxNUUFkYXB0ZXIgfSBmcm9tIFwiQGJsb2svdHJpZ2dlci13b3JrZXJcIjtcbiAqXG4gKiBjbGFzcyBNeVdvcmtlclRyaWdnZXIgZXh0ZW5kcyBXb3JrZXJUcmlnZ2VyIHtcbiAqICAgcHJvdGVjdGVkIGFkYXB0ZXIgPSBuZXcgQnVsbE1RQWRhcHRlcih7XG4gKiAgICAgaG9zdDogXCJsb2NhbGhvc3RcIixcbiAqICAgICBwb3J0OiA2Mzc5LFxuICogICB9KTtcbiAqXG4gKiAgIHByb3RlY3RlZCBub2RlcyA9IG15Tm9kZXM7XG4gKiAgIHByb3RlY3RlZCB3b3JrZmxvd3MgPSBteVdvcmtmbG93cztcbiAqIH1cbiAqXG4gKiBjb25zdCB0cmlnZ2VyID0gbmV3IE15V29ya2VyVHJpZ2dlcigpO1xuICogYXdhaXQgdHJpZ2dlci5saXN0ZW4oKTtcbiAqXG4gKiAvLyBEaXNwYXRjaCBhIGpvYlxuICogYXdhaXQgdHJpZ2dlci5kaXNwYXRjaChcImJhY2tncm91bmQtam9ic1wiLCB7IHVzZXJJZDogXCIxMjNcIiB9LCB7XG4gKiAgIHByaW9yaXR5OiAxMCxcbiAqICAgcmV0cmllczogMyxcbiAqICAgZGVsYXk6IDUwMDAsIC8vIGRlbGF5IDUgc2Vjb25kc1xuICogfSk7XG4gKiBgYGBcbiAqXG4gKiBAZXhhbXBsZSBJbk1lbW9yeSAoZGV2ZWxvcG1lbnQpXG4gKiBgYGB0eXBlc2NyaXB0XG4gKiBpbXBvcnQgeyBXb3JrZXJUcmlnZ2VyLCBJbk1lbW9yeUFkYXB0ZXIgfSBmcm9tIFwiQGJsb2svdHJpZ2dlci13b3JrZXJcIjtcbiAqXG4gKiBjbGFzcyBEZXZXb3JrZXJUcmlnZ2VyIGV4dGVuZHMgV29ya2VyVHJpZ2dlciB7XG4gKiAgIHByb3RlY3RlZCBhZGFwdGVyID0gbmV3IEluTWVtb3J5QWRhcHRlcigpO1xuICogICBwcm90ZWN0ZWQgbm9kZXMgPSBteU5vZGVzO1xuICogICBwcm90ZWN0ZWQgd29ya2Zsb3dzID0gbXlXb3JrZmxvd3M7XG4gKiB9XG4gKiBgYGBcbiAqL1xuXG4vLyBDb3JlIGV4cG9ydHNcbmV4cG9ydCB7XG5cdFdvcmtlclRyaWdnZXIsXG5cdHR5cGUgV29ya2VyQWRhcHRlcixcblx0dHlwZSBXb3JrZXJKb2IsXG5cdHR5cGUgV29ya2VyUXVldWVTdGF0cyxcbn0gZnJvbSBcIi4vV29ya2VyVHJpZ2dlclwiO1xuXG4vLyBBZGFwdGVyc1xuZXhwb3J0IHsgQnVsbE1RQWRhcHRlciwgdHlwZSBCdWxsTVFDb25maWcgfSBmcm9tIFwiLi9hZGFwdGVycy9CdWxsTVFBZGFwdGVyXCI7XG5leHBvcnQgeyBJbk1lbW9yeUFkYXB0ZXIgfSBmcm9tIFwiLi9hZGFwdGVycy9Jbk1lbW9yeUFkYXB0ZXJcIjtcblxuLy8gUmUtZXhwb3J0IHR5cGVzIGZyb20gaGVscGVyIGZvciBjb252ZW5pZW5jZVxuZXhwb3J0IHR5cGUgeyBXb3JrZXJUcmlnZ2VyT3B0cyB9IGZyb20gXCJAYmxvay9oZWxwZXJcIjtcbiJdfQ==
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@blokjs/trigger-worker",
3
+ "version": "0.2.0",
4
+ "description": "Worker-based trigger for Blok workflows - supports background job processing with concurrency, retries, and scheduling",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "rm -rf dist && bun run tsc",
10
+ "build:dev": "tsc --watch",
11
+ "test": "vitest run",
12
+ "test:dev": "vitest"
13
+ },
14
+ "author": "Deskree Technologies Inc.",
15
+ "license": "Apache-2.0",
16
+ "dependencies": {
17
+ "@blokjs/helper": "workspace:*",
18
+ "@blokjs/runner": "workspace:*",
19
+ "@blokjs/shared": "workspace:*",
20
+ "@opentelemetry/api": "^1.9.0",
21
+ "uuid": "^11.1.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.15.21",
25
+ "@types/uuid": "^11.0.0",
26
+ "typescript": "^5.8.3",
27
+ "vitest": "^4.0.18"
28
+ },
29
+ "peerDependencies": {
30
+ "bullmq": "^5.67.2",
31
+ "ioredis": "^5.9.2"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "bullmq": {
35
+ "optional": true
36
+ },
37
+ "ioredis": {
38
+ "optional": true
39
+ }
40
+ },
41
+ "private": false,
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }
@@ -0,0 +1,510 @@
1
+ /**
2
+ * WorkerTrigger Tests
3
+ *
4
+ * Tests the WorkerTrigger base class, WorkerAdapter interface,
5
+ * InMemoryAdapter, and BullMQAdapter configuration.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import type { WorkerAdapter, WorkerJob, WorkerQueueStats } from "./WorkerTrigger";
10
+ import { InMemoryAdapter } from "./adapters/InMemoryAdapter";
11
+
12
+ // ============================================================================
13
+ // WorkerJob Interface Tests
14
+ // ============================================================================
15
+
16
+ describe("WorkerTrigger", () => {
17
+ describe("WorkerJob Interface", () => {
18
+ it("should accept valid worker job structure", () => {
19
+ const job: WorkerJob = {
20
+ id: "job-123",
21
+ data: { userId: "user-1", action: "send-email" },
22
+ headers: { "content-type": "application/json" },
23
+ queue: "background-jobs",
24
+ priority: 5,
25
+ attempts: 0,
26
+ maxRetries: 3,
27
+ createdAt: new Date(),
28
+ delay: 0,
29
+ timeout: 30000,
30
+ raw: {},
31
+ complete: async () => {},
32
+ fail: async () => {},
33
+ };
34
+
35
+ expect(job.id).toBe("job-123");
36
+ expect(job.data).toEqual({ userId: "user-1", action: "send-email" });
37
+ expect(job.queue).toBe("background-jobs");
38
+ expect(job.priority).toBe(5);
39
+ expect(job.maxRetries).toBe(3);
40
+ });
41
+
42
+ it("should handle minimal required fields", () => {
43
+ const job: WorkerJob = {
44
+ id: "job-min",
45
+ data: null,
46
+ headers: {},
47
+ queue: "default",
48
+ priority: 0,
49
+ attempts: 0,
50
+ maxRetries: 0,
51
+ createdAt: new Date(),
52
+ raw: null,
53
+ complete: async () => {},
54
+ fail: async () => {},
55
+ };
56
+
57
+ expect(job.id).toBeDefined();
58
+ expect(job.queue).toBeDefined();
59
+ expect(job.complete).toBeDefined();
60
+ expect(job.fail).toBeDefined();
61
+ });
62
+
63
+ it("should support optional delay and timeout", () => {
64
+ const job: WorkerJob = {
65
+ id: "job-delayed",
66
+ data: { type: "scheduled-report" },
67
+ headers: {},
68
+ queue: "reports",
69
+ priority: 1,
70
+ attempts: 0,
71
+ maxRetries: 2,
72
+ createdAt: new Date(),
73
+ delay: 60000,
74
+ timeout: 120000,
75
+ raw: {},
76
+ complete: async () => {},
77
+ fail: async () => {},
78
+ };
79
+
80
+ expect(job.delay).toBe(60000);
81
+ expect(job.timeout).toBe(120000);
82
+ });
83
+ });
84
+
85
+ describe("WorkerAdapter Interface", () => {
86
+ it("should validate adapter interface methods", () => {
87
+ const mockAdapter: WorkerAdapter = {
88
+ provider: "mock",
89
+ connect: vi.fn().mockResolvedValue(undefined),
90
+ disconnect: vi.fn().mockResolvedValue(undefined),
91
+ process: vi.fn().mockResolvedValue(undefined),
92
+ addJob: vi.fn().mockResolvedValue("job-1"),
93
+ stopProcessing: vi.fn().mockResolvedValue(undefined),
94
+ isConnected: vi.fn().mockReturnValue(true),
95
+ healthCheck: vi.fn().mockResolvedValue(true),
96
+ getQueueStats: vi.fn().mockResolvedValue({
97
+ waiting: 5,
98
+ active: 2,
99
+ completed: 100,
100
+ failed: 3,
101
+ delayed: 1,
102
+ }),
103
+ };
104
+
105
+ expect(mockAdapter.provider).toBe("mock");
106
+ expect(typeof mockAdapter.connect).toBe("function");
107
+ expect(typeof mockAdapter.disconnect).toBe("function");
108
+ expect(typeof mockAdapter.process).toBe("function");
109
+ expect(typeof mockAdapter.addJob).toBe("function");
110
+ expect(typeof mockAdapter.stopProcessing).toBe("function");
111
+ expect(typeof mockAdapter.isConnected).toBe("function");
112
+ expect(typeof mockAdapter.healthCheck).toBe("function");
113
+ expect(typeof mockAdapter.getQueueStats).toBe("function");
114
+ });
115
+
116
+ it("should return correct queue stats structure", async () => {
117
+ const stats: WorkerQueueStats = {
118
+ waiting: 10,
119
+ active: 3,
120
+ completed: 500,
121
+ failed: 12,
122
+ delayed: 5,
123
+ };
124
+
125
+ expect(stats.waiting).toBe(10);
126
+ expect(stats.active).toBe(3);
127
+ expect(stats.completed).toBe(500);
128
+ expect(stats.failed).toBe(12);
129
+ expect(stats.delayed).toBe(5);
130
+ });
131
+ });
132
+ });
133
+
134
+ // ============================================================================
135
+ // InMemoryAdapter Tests
136
+ // ============================================================================
137
+
138
+ describe("InMemoryAdapter", () => {
139
+ let adapter: InMemoryAdapter;
140
+
141
+ beforeEach(() => {
142
+ adapter = new InMemoryAdapter();
143
+ });
144
+
145
+ afterEach(async () => {
146
+ await adapter.disconnect();
147
+ });
148
+
149
+ describe("Connection Lifecycle", () => {
150
+ it("should connect successfully", async () => {
151
+ expect(adapter.isConnected()).toBe(false);
152
+ await adapter.connect();
153
+ expect(adapter.isConnected()).toBe(true);
154
+ });
155
+
156
+ it("should disconnect successfully", async () => {
157
+ await adapter.connect();
158
+ expect(adapter.isConnected()).toBe(true);
159
+ await adapter.disconnect();
160
+ expect(adapter.isConnected()).toBe(false);
161
+ });
162
+
163
+ it("should report healthy when connected", async () => {
164
+ await adapter.connect();
165
+ expect(await adapter.healthCheck()).toBe(true);
166
+ });
167
+
168
+ it("should report unhealthy when disconnected", async () => {
169
+ expect(await adapter.healthCheck()).toBe(false);
170
+ });
171
+
172
+ it("should have provider name 'in-memory'", () => {
173
+ expect(adapter.provider).toBe("in-memory");
174
+ });
175
+ });
176
+
177
+ describe("Job Dispatching", () => {
178
+ it("should add a job and return its ID", async () => {
179
+ await adapter.connect();
180
+ const jobId = await adapter.addJob("test-queue", { action: "test" });
181
+ expect(jobId).toBeDefined();
182
+ expect(typeof jobId).toBe("string");
183
+ });
184
+
185
+ it("should accept custom job ID", async () => {
186
+ await adapter.connect();
187
+ const jobId = await adapter.addJob(
188
+ "test-queue",
189
+ { data: 1 },
190
+ {
191
+ jobId: "custom-id-123",
192
+ },
193
+ );
194
+ expect(jobId).toBe("custom-id-123");
195
+ });
196
+
197
+ it("should add jobs with priority ordering", async () => {
198
+ await adapter.connect();
199
+ await adapter.addJob("priority-queue", { order: "low" }, { priority: 1 });
200
+ await adapter.addJob("priority-queue", { order: "high" }, { priority: 10 });
201
+ await adapter.addJob("priority-queue", { order: "medium" }, { priority: 5 });
202
+
203
+ const stats = await adapter.getQueueStats("priority-queue");
204
+ expect(stats.waiting).toBe(3);
205
+ });
206
+
207
+ it("should add delayed jobs", async () => {
208
+ await adapter.connect();
209
+ await adapter.addJob(
210
+ "delayed-queue",
211
+ { data: 1 },
212
+ {
213
+ delay: 5000,
214
+ },
215
+ );
216
+
217
+ const stats = await adapter.getQueueStats("delayed-queue");
218
+ expect(stats.delayed).toBe(1);
219
+ expect(stats.waiting).toBe(0);
220
+ });
221
+
222
+ it("should throw when not connected", async () => {
223
+ await expect(adapter.addJob("test-queue", { data: 1 })).rejects.toThrow("Not connected");
224
+ });
225
+ });
226
+
227
+ describe("Job Processing", () => {
228
+ it("should process jobs from a queue", async () => {
229
+ await adapter.connect();
230
+
231
+ const processedJobs: WorkerJob[] = [];
232
+
233
+ await adapter.process({ queue: "process-queue", concurrency: 1, retries: 3, priority: 0 }, async (job) => {
234
+ processedJobs.push(job);
235
+ await job.complete();
236
+ });
237
+
238
+ await adapter.addJob("process-queue", { item: "test-1" });
239
+
240
+ // Wait for processing
241
+ await new Promise((resolve) => setTimeout(resolve, 200));
242
+
243
+ expect(processedJobs).toHaveLength(1);
244
+ expect(processedJobs[0].data).toEqual({ item: "test-1" });
245
+ });
246
+
247
+ it("should process multiple jobs sequentially", async () => {
248
+ await adapter.connect();
249
+
250
+ const processedOrder: number[] = [];
251
+
252
+ await adapter.process({ queue: "seq-queue", concurrency: 1, retries: 3, priority: 0 }, async (job) => {
253
+ processedOrder.push(job.data as number);
254
+ await job.complete();
255
+ });
256
+
257
+ await adapter.addJob("seq-queue", 1);
258
+ await adapter.addJob("seq-queue", 2);
259
+ await adapter.addJob("seq-queue", 3);
260
+
261
+ await new Promise((resolve) => setTimeout(resolve, 500));
262
+
263
+ expect(processedOrder).toEqual([1, 2, 3]);
264
+ });
265
+
266
+ it("should track queue stats correctly", async () => {
267
+ await adapter.connect();
268
+
269
+ await adapter.process({ queue: "stats-queue", concurrency: 1, retries: 3, priority: 0 }, async (job) => {
270
+ await job.complete();
271
+ });
272
+
273
+ await adapter.addJob("stats-queue", { a: 1 });
274
+ await adapter.addJob("stats-queue", { a: 2 });
275
+
276
+ await new Promise((resolve) => setTimeout(resolve, 300));
277
+
278
+ const stats = await adapter.getQueueStats("stats-queue");
279
+ expect(stats.completed).toBe(2);
280
+ expect(stats.waiting).toBe(0);
281
+ });
282
+
283
+ it("should handle job failures", async () => {
284
+ await adapter.connect();
285
+
286
+ await adapter.process({ queue: "fail-queue", concurrency: 1, retries: 0, priority: 0 }, async (job) => {
287
+ await job.fail(new Error("test failure"), false);
288
+ });
289
+
290
+ await adapter.addJob("fail-queue", { data: "will-fail" }, { retries: 0 });
291
+
292
+ await new Promise((resolve) => setTimeout(resolve, 200));
293
+
294
+ const stats = await adapter.getQueueStats("fail-queue");
295
+ expect(stats.failed).toBe(1);
296
+ });
297
+
298
+ it("should requeue failed jobs for retry", async () => {
299
+ await adapter.connect();
300
+
301
+ let attemptCount = 0;
302
+
303
+ await adapter.process({ queue: "retry-queue", concurrency: 1, retries: 3, priority: 0 }, async (job) => {
304
+ attemptCount++;
305
+ if (attemptCount < 2) {
306
+ await job.fail(new Error("temporary failure"), true);
307
+ } else {
308
+ await job.complete();
309
+ }
310
+ });
311
+
312
+ await adapter.addJob("retry-queue", { data: "retry-me" }, { retries: 3 });
313
+
314
+ // Wait long enough for retry backoff + processing
315
+ await new Promise((resolve) => setTimeout(resolve, 3000));
316
+
317
+ expect(attemptCount).toBeGreaterThanOrEqual(2);
318
+ }, 5000);
319
+
320
+ it("should stop processing a queue", async () => {
321
+ await adapter.connect();
322
+
323
+ const processed: string[] = [];
324
+
325
+ await adapter.process({ queue: "stop-queue", concurrency: 1, retries: 3, priority: 0 }, async (job) => {
326
+ processed.push(job.id);
327
+ await job.complete();
328
+ });
329
+
330
+ await adapter.addJob("stop-queue", { first: true });
331
+ await new Promise((resolve) => setTimeout(resolve, 200));
332
+
333
+ await adapter.stopProcessing("stop-queue");
334
+
335
+ await adapter.addJob("stop-queue", { second: true });
336
+ await new Promise((resolve) => setTimeout(resolve, 200));
337
+
338
+ // Only first job should have been processed
339
+ expect(processed).toHaveLength(1);
340
+ });
341
+
342
+ it("should throw when processing without connection", async () => {
343
+ await expect(
344
+ adapter.process({ queue: "q", concurrency: 1, retries: 0, priority: 0 }, async () => {}),
345
+ ).rejects.toThrow("Not connected");
346
+ });
347
+ });
348
+
349
+ describe("Queue Stats", () => {
350
+ it("should return zeros for unknown queue", async () => {
351
+ await adapter.connect();
352
+ const stats = await adapter.getQueueStats("nonexistent");
353
+ expect(stats).toEqual({
354
+ waiting: 0,
355
+ active: 0,
356
+ completed: 0,
357
+ failed: 0,
358
+ delayed: 0,
359
+ });
360
+ });
361
+
362
+ it("should track waiting count", async () => {
363
+ await adapter.connect();
364
+ await adapter.addJob("count-queue", { a: 1 });
365
+ await adapter.addJob("count-queue", { a: 2 });
366
+ await adapter.addJob("count-queue", { a: 3 });
367
+
368
+ const stats = await adapter.getQueueStats("count-queue");
369
+ expect(stats.waiting).toBe(3);
370
+ });
371
+ });
372
+ });
373
+
374
+ // ============================================================================
375
+ // BullMQAdapter Config Tests
376
+ // ============================================================================
377
+
378
+ describe("BullMQAdapter", () => {
379
+ it("should read config from environment variables", () => {
380
+ const originalHost = process.env.REDIS_HOST;
381
+ const originalPort = process.env.REDIS_PORT;
382
+ const originalPassword = process.env.REDIS_PASSWORD;
383
+ const originalDb = process.env.REDIS_DB;
384
+
385
+ process.env.REDIS_HOST = "redis.example.com";
386
+ process.env.REDIS_PORT = "6380";
387
+ process.env.REDIS_PASSWORD = "secret123";
388
+ process.env.REDIS_DB = "2";
389
+
390
+ const config = {
391
+ host: process.env.REDIS_HOST || "localhost",
392
+ port: Number.parseInt(process.env.REDIS_PORT || "6379", 10),
393
+ password: process.env.REDIS_PASSWORD,
394
+ db: Number.parseInt(process.env.REDIS_DB || "0", 10),
395
+ };
396
+
397
+ expect(config.host).toBe("redis.example.com");
398
+ expect(config.port).toBe(6380);
399
+ expect(config.password).toBe("secret123");
400
+ expect(config.db).toBe(2);
401
+
402
+ // Restore
403
+ process.env.REDIS_HOST = originalHost;
404
+ process.env.REDIS_PORT = originalPort;
405
+ process.env.REDIS_PASSWORD = originalPassword;
406
+ process.env.REDIS_DB = originalDb;
407
+ });
408
+
409
+ it("should use default values when env vars not set", () => {
410
+ const originalHost = process.env.REDIS_HOST;
411
+ const originalPort = process.env.REDIS_PORT;
412
+
413
+ delete process.env.REDIS_HOST;
414
+ delete process.env.REDIS_PORT;
415
+
416
+ const config = {
417
+ host: process.env.REDIS_HOST || "localhost",
418
+ port: Number.parseInt(process.env.REDIS_PORT || "6379", 10),
419
+ };
420
+
421
+ expect(config.host).toBe("localhost");
422
+ expect(config.port).toBe(6379);
423
+
424
+ // Restore
425
+ process.env.REDIS_HOST = originalHost;
426
+ process.env.REDIS_PORT = originalPort;
427
+ });
428
+ });
429
+
430
+ // ============================================================================
431
+ // WorkerTriggerOpts Schema Tests
432
+ // ============================================================================
433
+
434
+ describe("WorkerTriggerOpts Schema", () => {
435
+ it("should validate worker trigger configuration", () => {
436
+ const validConfig = {
437
+ queue: "background-jobs",
438
+ concurrency: 5,
439
+ timeout: 30000,
440
+ retries: 3,
441
+ priority: 10,
442
+ delay: 1000,
443
+ };
444
+
445
+ expect(validConfig.queue).toBe("background-jobs");
446
+ expect(validConfig.concurrency).toBe(5);
447
+ expect(validConfig.timeout).toBe(30000);
448
+ expect(validConfig.retries).toBe(3);
449
+ expect(validConfig.priority).toBe(10);
450
+ expect(validConfig.delay).toBe(1000);
451
+ });
452
+
453
+ it("should support minimal configuration", () => {
454
+ const minConfig = {
455
+ queue: "default",
456
+ };
457
+
458
+ expect(minConfig.queue).toBe("default");
459
+ });
460
+
461
+ it("should support high-concurrency configuration", () => {
462
+ const config = {
463
+ queue: "high-throughput",
464
+ concurrency: 50,
465
+ retries: 5,
466
+ timeout: 60000,
467
+ priority: 0,
468
+ };
469
+
470
+ expect(config.concurrency).toBe(50);
471
+ expect(config.retries).toBe(5);
472
+ });
473
+ });
474
+
475
+ // ============================================================================
476
+ // Exponential Backoff Tests
477
+ // ============================================================================
478
+
479
+ describe("Exponential Backoff", () => {
480
+ it("should calculate increasing delays", () => {
481
+ const base = 1000;
482
+ const maxDelay = 30000;
483
+
484
+ const delays = [0, 1, 2, 3, 4, 5].map((attempt) => {
485
+ const exponential = Math.min(base * Math.pow(2, attempt), maxDelay);
486
+ return exponential;
487
+ });
488
+
489
+ expect(delays[0]).toBe(1000); // 1s
490
+ expect(delays[1]).toBe(2000); // 2s
491
+ expect(delays[2]).toBe(4000); // 4s
492
+ expect(delays[3]).toBe(8000); // 8s
493
+ expect(delays[4]).toBe(16000); // 16s
494
+ expect(delays[5]).toBe(30000); // capped at 30s
495
+ });
496
+
497
+ it("should cap at maximum delay", () => {
498
+ const base = 1000;
499
+ const maxDelay = 30000;
500
+
501
+ const delay = Math.min(base * Math.pow(2, 10), maxDelay);
502
+ expect(delay).toBe(30000);
503
+ });
504
+
505
+ it("should support custom base delay", () => {
506
+ const base = 500;
507
+ const exponential = base * Math.pow(2, 2);
508
+ expect(exponential).toBe(2000);
509
+ });
510
+ });