@zerotal/queue 1.0.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +57 -0
- package/src/Batch.ts +63 -0
- package/src/Bus.ts +49 -0
- package/src/Job.ts +37 -0
- package/src/JobRegistry.ts +24 -0
- package/src/PendingBatch.ts +63 -0
- package/src/QueueFake.ts +126 -0
- package/src/QueueManager.ts +427 -0
- package/src/WorkerPool.ts +242 -0
- package/src/admin.ts +254 -0
- package/src/commands/QueueFailedCommand.ts +57 -0
- package/src/commands/QueueFlushCommand.ts +61 -0
- package/src/commands/QueueRetryCommand.ts +84 -0
- package/src/commands/QueueWorkCommand.ts +58 -0
- package/src/commands/index.ts +4 -0
- package/src/config.ts +118 -0
- package/src/drivers/QueueDriver.ts +65 -0
- package/src/drivers/RedisDriver.ts +301 -0
- package/src/drivers/SqliteDriver.ts +366 -0
- package/src/drivers/SyncDriver.ts +85 -0
- package/src/errors.ts +42 -0
- package/src/events.ts +21 -0
- package/src/facades/Queue.ts +3 -0
- package/src/global.d.ts +34 -0
- package/src/index.ts +29 -0
- package/src/observability.ts +131 -0
- package/src/provider/QueueProvider.ts +153 -0
- package/src/queue-worker.ts +98 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog — @zerotal/queue
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
|
+
follows the Zerotal monorepo's unified versioning.
|
|
6
|
+
|
|
7
|
+
**Maturity: `stable`**
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [1.0.0] — 2026-08-05
|
|
12
|
+
|
|
13
|
+
_First public release._
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Added a typed error vocabulary (`QueueError` + `E_QUEUE_*` codes) for init/shutdown/batching failures.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerotal
|
|
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
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# @zerotal/queue
|
|
2
|
+
|
|
3
|
+
> Background job queue with retries, batches, chaining, and a worker pool.
|
|
4
|
+
|
|
5
|
+
Offload slow work to background jobs. Define `Job` classes, dispatch them singly, batched, or chained, choose a driver (SQLite, Redis, or sync), and process them with the worker loop or a `WorkerPool` of Bun threads.
|
|
6
|
+
|
|
7
|
+
Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add @zerotal/queue
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
Register the provider in `bootstrap/providers.ts`:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { QueueProvider } from "@zerotal/queue";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
A job extends `Job`, serialises its state in `payload()`, restores it in a static `fromPayload()`, and does the work in `handle()`. Register it so the worker can rebuild it by class name:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { Job, JobRegistry } from "@zerotal/queue";
|
|
29
|
+
|
|
30
|
+
export class NotifyFollowersJob extends Job {
|
|
31
|
+
override readonly queue = "notifications";
|
|
32
|
+
override readonly maxAttempts = 3;
|
|
33
|
+
override readonly retryDelay = 5000;
|
|
34
|
+
|
|
35
|
+
constructor(public readonly postId: number) {
|
|
36
|
+
super();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
payload(): Record<string, unknown> {
|
|
40
|
+
return { postId: this.postId };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static fromPayload(p: Record<string, unknown>): NotifyFollowersJob {
|
|
44
|
+
return new NotifyFollowersJob(p["postId"] as number);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async handle(): Promise<void> {
|
|
48
|
+
/* … */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
JobRegistry.register(NotifyFollowersJob as never);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Dispatch via the `Queue` facade:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { Queue } from "@zerotal/queue";
|
|
59
|
+
|
|
60
|
+
await Queue.dispatch(new NotifyFollowersJob(post.id));
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Batch jobs and react when they all finish, or chain them to run sequentially:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { Bus } from "@zerotal/queue";
|
|
67
|
+
|
|
68
|
+
await Bus.batch(rows.map((row) => new ImportCsvRowJob(row)))
|
|
69
|
+
.then(new SendImportSummaryJob(user)) // when ALL succeed
|
|
70
|
+
.catch(new NotifyAdminOfFailureJob(user)) // when ANY fail
|
|
71
|
+
.dispatch();
|
|
72
|
+
|
|
73
|
+
await Bus.chain([new ValidateImportJob(id), new ProcessImportJob(id)]).dispatch();
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
In tests, swap the facade for a recorder:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { QueueFake } from "@zerotal/queue";
|
|
80
|
+
|
|
81
|
+
QueueFake.swap();
|
|
82
|
+
expect(QueueFake.dispatched(NotifyFollowersJob)).toHaveLength(1);
|
|
83
|
+
QueueFake.restore();
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Exports
|
|
87
|
+
|
|
88
|
+
| Export | Description |
|
|
89
|
+
| -------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
|
90
|
+
| `Queue` | Facade over `QueueManager`. |
|
|
91
|
+
| `Job`, `JobRegistry` | Base job class and the class-name registry. |
|
|
92
|
+
| `QueueManager`, `queueStats`, `JobStatus`, `QueueStats` | Manager and stats helpers. |
|
|
93
|
+
| `Bus`, `Batch`, `PendingBatch`, `BatchRecord`, `BatchOptions`, `SerializedJob` | Batch and chain orchestration. |
|
|
94
|
+
| `WorkerPool`, `WorkerPoolOptions`, `WorkerResult` | Bun-thread worker pool for CPU-bound jobs. |
|
|
95
|
+
| `QueueFake` | In-memory test double. |
|
|
96
|
+
| `QueueProvider` | Service provider — register in `bootstrap/providers.ts`. |
|
|
97
|
+
| `QueueConfig`, `QueueConfigShape` | Config factory and its type. |
|
|
98
|
+
| `QueueDriver`, `JobRecord`, `BatchStatus`, `SqliteDriver`, `SyncDriver`, `RedisDriver` | Driver contract and built-in drivers. |
|
|
99
|
+
| `errors` | Typed error vocabulary (re-exported). |
|
|
100
|
+
|
|
101
|
+
Subpath exports:
|
|
102
|
+
|
|
103
|
+
- `@zerotal/queue` — the public API above.
|
|
104
|
+
- `@zerotal/queue/commands` — `QueueWorkCommand`, `QueueFailedCommand`, `QueueRetryCommand`, `QueueFlushCommand`.
|
|
105
|
+
|
|
106
|
+
## Documentation
|
|
107
|
+
|
|
108
|
+
- [Queue](../../docs/queue.md)
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerotal/queue",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"maturity": "stable",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./commands": "./src/commands/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"src",
|
|
17
|
+
"!src/**/*.test.ts",
|
|
18
|
+
"!src/**/*.test.tsx",
|
|
19
|
+
"!src/**/*.spec.ts",
|
|
20
|
+
"!src/**/__fixtures__/**"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.14"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@zerotal/core": "1.0.0",
|
|
34
|
+
"@zerotal/orm": "1.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"typescript": "^5.8.0"
|
|
38
|
+
},
|
|
39
|
+
"description": "Background job queue for Zerotal with SQLite and Redis drivers, delayed jobs, and retries.",
|
|
40
|
+
"keywords": [
|
|
41
|
+
"zerotal",
|
|
42
|
+
"bun",
|
|
43
|
+
"typescript",
|
|
44
|
+
"framework",
|
|
45
|
+
"queue",
|
|
46
|
+
"jobs",
|
|
47
|
+
"background",
|
|
48
|
+
"redis"
|
|
49
|
+
],
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/zerotaldev/zerotal.git",
|
|
53
|
+
"directory": "packages/queue"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/queue#readme",
|
|
56
|
+
"bugs": "https://github.com/zerotaldev/zerotal/issues"
|
|
57
|
+
}
|
package/src/Batch.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type SerializedJob = { className: string; payload: Record<string, unknown> };
|
|
2
|
+
|
|
3
|
+
export interface BatchOptions {
|
|
4
|
+
thenJobs?: SerializedJob[] | undefined;
|
|
5
|
+
catchJobs?: SerializedJob[] | undefined;
|
|
6
|
+
finallyJobs?: SerializedJob[] | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface BatchRecord {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
totalJobs: number;
|
|
13
|
+
pendingJobs: number;
|
|
14
|
+
failedJobs: number;
|
|
15
|
+
failedJobIds: number[];
|
|
16
|
+
options: BatchOptions;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
finishedAt: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Batch {
|
|
22
|
+
constructor(private readonly _r: BatchRecord) {}
|
|
23
|
+
|
|
24
|
+
get id(): string {
|
|
25
|
+
return this._r.id;
|
|
26
|
+
}
|
|
27
|
+
get name(): string {
|
|
28
|
+
return this._r.name;
|
|
29
|
+
}
|
|
30
|
+
get totalJobs(): number {
|
|
31
|
+
return this._r.totalJobs;
|
|
32
|
+
}
|
|
33
|
+
get pendingJobs(): number {
|
|
34
|
+
return this._r.pendingJobs;
|
|
35
|
+
}
|
|
36
|
+
get failedJobs(): number {
|
|
37
|
+
return this._r.failedJobs;
|
|
38
|
+
}
|
|
39
|
+
get failedJobIds(): number[] {
|
|
40
|
+
return this._r.failedJobIds;
|
|
41
|
+
}
|
|
42
|
+
get options(): BatchOptions {
|
|
43
|
+
return this._r.options;
|
|
44
|
+
}
|
|
45
|
+
get createdAt(): string {
|
|
46
|
+
return this._r.createdAt;
|
|
47
|
+
}
|
|
48
|
+
get finishedAt(): string | undefined {
|
|
49
|
+
return this._r.finishedAt;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
finished(): boolean {
|
|
53
|
+
return this._r.finishedAt !== undefined;
|
|
54
|
+
}
|
|
55
|
+
failed(): boolean {
|
|
56
|
+
return this._r.failedJobs > 0;
|
|
57
|
+
}
|
|
58
|
+
progress(): number {
|
|
59
|
+
return this._r.totalJobs === 0
|
|
60
|
+
? 1
|
|
61
|
+
: (this._r.totalJobs - this._r.pendingJobs) / this._r.totalJobs;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/Bus.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Job } from "./Job.ts";
|
|
2
|
+
import { QueueNotInitializedError } from "./errors.ts";
|
|
3
|
+
import type { Batch } from "./Batch.ts";
|
|
4
|
+
import type { QueueManager } from "./QueueManager.ts";
|
|
5
|
+
import { PendingBatch } from "./PendingBatch.ts";
|
|
6
|
+
|
|
7
|
+
export class Bus {
|
|
8
|
+
private static _manager: QueueManager | undefined;
|
|
9
|
+
|
|
10
|
+
static setManager(manager: QueueManager): void {
|
|
11
|
+
Bus._manager = manager;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Dispatch a set of jobs as a batch. Returns a fluent builder to attach callbacks. */
|
|
15
|
+
static batch(jobs: Job[]): PendingBatch {
|
|
16
|
+
const manager = Bus._manager;
|
|
17
|
+
if (!manager) throw new QueueNotInitializedError();
|
|
18
|
+
return new PendingBatch(jobs, {
|
|
19
|
+
dispatchBatch: (pending) => manager.dispatchBatch(pending),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Chain jobs sequentially — each job runs only after the previous one succeeds.
|
|
25
|
+
* If any job fails the chain is stopped (no subsequent jobs are dispatched).
|
|
26
|
+
*/
|
|
27
|
+
static chain(jobs: Job[]): { dispatch(): Promise<void> } {
|
|
28
|
+
const manager = Bus._manager;
|
|
29
|
+
if (!manager) throw new QueueNotInitializedError();
|
|
30
|
+
return {
|
|
31
|
+
async dispatch(): Promise<void> {
|
|
32
|
+
if (jobs.length === 0) return;
|
|
33
|
+
const [first, ...rest] = jobs;
|
|
34
|
+
if (!first) return;
|
|
35
|
+
if (rest.length > 0) {
|
|
36
|
+
first._chain = rest.map((j) => ({ className: j.className, payload: j.payload() }));
|
|
37
|
+
}
|
|
38
|
+
await manager.dispatch(first);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Reset the manager reference (for testing). */
|
|
44
|
+
static reset(): void {
|
|
45
|
+
Bus._manager = undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type { Batch };
|
package/src/Job.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { SerializedJob } from "./Batch.ts";
|
|
2
|
+
|
|
3
|
+
export abstract class Job {
|
|
4
|
+
/** Queue name — override in subclass to route to specific queues */
|
|
5
|
+
readonly queue: string = "default";
|
|
6
|
+
|
|
7
|
+
/** Max retry attempts before moving to failed queue */
|
|
8
|
+
readonly maxAttempts: number = 3;
|
|
9
|
+
|
|
10
|
+
/** Milliseconds to wait between retry attempts */
|
|
11
|
+
readonly retryDelay: number = 1000;
|
|
12
|
+
|
|
13
|
+
/** Set by Bus.batch() — tracks which batch this job belongs to. */
|
|
14
|
+
batchId: string | undefined = undefined;
|
|
15
|
+
|
|
16
|
+
/** Set by Bus.chain() — remaining jobs to dispatch after this one succeeds. */
|
|
17
|
+
_chain: SerializedJob[] | undefined = undefined;
|
|
18
|
+
|
|
19
|
+
/** The job's work — implement this in every subclass */
|
|
20
|
+
abstract handle(): Promise<void>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Serialize job state to a plain object for storage.
|
|
24
|
+
* Override in subclasses that have constructor arguments.
|
|
25
|
+
*/
|
|
26
|
+
payload(): Record<string, unknown> {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The class name used as the serialization key.
|
|
32
|
+
* Must match what JobRegistry.register() stores.
|
|
33
|
+
*/
|
|
34
|
+
get className(): string {
|
|
35
|
+
return this.constructor.name;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Job } from "./Job.ts";
|
|
2
|
+
|
|
3
|
+
type JobClass = new (...args: never[]) => Job;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Global registry mapping job class names to their constructors.
|
|
7
|
+
* Required for deserialization — when a job is popped from the queue,
|
|
8
|
+
* the worker looks up the class here to instantiate it.
|
|
9
|
+
*/
|
|
10
|
+
export const JobRegistry = {
|
|
11
|
+
_map: new Map<string, JobClass>(),
|
|
12
|
+
|
|
13
|
+
register(JobClass: JobClass): void {
|
|
14
|
+
JobRegistry._map.set(JobClass.name, JobClass);
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
resolve(className: string): JobClass | undefined {
|
|
18
|
+
return JobRegistry._map.get(className);
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
all(): ReadonlyMap<string, JobClass> {
|
|
22
|
+
return JobRegistry._map;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Job } from "./Job.ts";
|
|
2
|
+
import type { Batch, BatchOptions, SerializedJob } from "./Batch.ts";
|
|
3
|
+
|
|
4
|
+
type BatchDispatcher = {
|
|
5
|
+
dispatchBatch(pending: PendingBatch): Promise<Batch>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function toSerialized(job: Job | Job[]): SerializedJob[] {
|
|
9
|
+
return (Array.isArray(job) ? job : [job]).map((j) => ({
|
|
10
|
+
className: j.className,
|
|
11
|
+
payload: j.payload(),
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class PendingBatch {
|
|
16
|
+
private _name = "";
|
|
17
|
+
private _thenJobs: SerializedJob[] = [];
|
|
18
|
+
private _catchJobs: SerializedJob[] = [];
|
|
19
|
+
private _finallyJobs: SerializedJob[] = [];
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly _jobs: Job[],
|
|
23
|
+
private readonly _dispatcher: BatchDispatcher,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
name(n: string): this {
|
|
27
|
+
this._name = n;
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
then(job: Job | Job[]): this {
|
|
32
|
+
this._thenJobs.push(...toSerialized(job));
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
catch(job: Job | Job[]): this {
|
|
36
|
+
this._catchJobs.push(...toSerialized(job));
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
finally(job: Job | Job[]): this {
|
|
40
|
+
this._finallyJobs.push(...toSerialized(job));
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async dispatch(): Promise<Batch> {
|
|
45
|
+
return this._dispatcher.dispatchBatch(this);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Internal accessors (used by QueueManager) ──────────────────────────
|
|
49
|
+
|
|
50
|
+
get jobs(): Job[] {
|
|
51
|
+
return this._jobs;
|
|
52
|
+
}
|
|
53
|
+
get batchName(): string {
|
|
54
|
+
return this._name;
|
|
55
|
+
}
|
|
56
|
+
get options(): BatchOptions {
|
|
57
|
+
return {
|
|
58
|
+
thenJobs: this._thenJobs.length ? this._thenJobs : undefined,
|
|
59
|
+
catchJobs: this._catchJobs.length ? this._catchJobs : undefined,
|
|
60
|
+
finallyJobs: this._finallyJobs.length ? this._finallyJobs : undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/QueueFake.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Application, currentApp } from "@zerotal/core";
|
|
2
|
+
import type { Job } from "./Job.ts";
|
|
3
|
+
|
|
4
|
+
type Binding = unknown;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drop-in replacement for QueueManager that captures dispatched jobs instead
|
|
8
|
+
* of persisting or executing them. Install at the start of a test; restore after.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const queue = QueueFake.install();
|
|
12
|
+
*
|
|
13
|
+
* await Queue.dispatch(new ProcessPaymentJob(order));
|
|
14
|
+
*
|
|
15
|
+
* queue.assertDispatched(ProcessPaymentJob);
|
|
16
|
+
* queue.assertDispatchedCount(1);
|
|
17
|
+
*
|
|
18
|
+
* queue.restore(); // call in afterEach
|
|
19
|
+
*/
|
|
20
|
+
export class QueueFake {
|
|
21
|
+
private readonly _dispatched: Job[] = [];
|
|
22
|
+
private readonly _app: Application;
|
|
23
|
+
private readonly _original: Binding;
|
|
24
|
+
|
|
25
|
+
private constructor(app: Application, original: Binding) {
|
|
26
|
+
this._app = app;
|
|
27
|
+
this._original = original;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Replace the 'queue' container binding with this fake. */
|
|
31
|
+
static install(): QueueFake {
|
|
32
|
+
const app = currentApp();
|
|
33
|
+
const original = app.container.registry.get("queue");
|
|
34
|
+
const fake = new QueueFake(app, original);
|
|
35
|
+
app.container.value("queue", fake);
|
|
36
|
+
return fake;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Restore the original 'queue' binding. Call in afterEach. */
|
|
40
|
+
restore(): void {
|
|
41
|
+
if (this._original !== undefined) {
|
|
42
|
+
this._app.container.registry.set("queue", this._original as never);
|
|
43
|
+
} else {
|
|
44
|
+
this._app.container.registry.delete("queue");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Queue manager interface ──────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/** Capture the job — does NOT execute it. */
|
|
51
|
+
async dispatch(job: Job): Promise<void> {
|
|
52
|
+
this._dispatched.push(job);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get isShuttingDown(): boolean {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async processNext(): Promise<boolean> {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
async drain(): Promise<void> {}
|
|
63
|
+
async size(): Promise<number> {
|
|
64
|
+
return this._dispatched.length;
|
|
65
|
+
}
|
|
66
|
+
async flush(): Promise<void> {
|
|
67
|
+
this._dispatched.length = 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Assertions ───────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/** All captured jobs. */
|
|
73
|
+
dispatched(): Job[] {
|
|
74
|
+
return [...this._dispatched];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Assert that a specific Job class was dispatched.
|
|
79
|
+
* Pass an optional filter callback to narrow the assertion.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* queue.assertDispatched(ProcessPaymentJob);
|
|
83
|
+
* queue.assertDispatched(ProcessPaymentJob, (j) => j.orderId === 42);
|
|
84
|
+
*/
|
|
85
|
+
assertDispatched<T extends Job>(
|
|
86
|
+
JobClass: new (...args: never[]) => T,
|
|
87
|
+
callback?: (job: T) => boolean,
|
|
88
|
+
): void {
|
|
89
|
+
const matching = this._dispatched.filter(
|
|
90
|
+
(j): j is T => j instanceof JobClass && (callback === undefined || callback(j)),
|
|
91
|
+
);
|
|
92
|
+
if (matching.length === 0) {
|
|
93
|
+
const hint = callback ? " matching the given filter" : "";
|
|
94
|
+
throw new Error(`Expected ${JobClass.name} to have been dispatched${hint}, but it was not.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Assert that a Job class was NOT dispatched. */
|
|
99
|
+
assertNotDispatched<T extends Job>(JobClass: new (...args: never[]) => T): void {
|
|
100
|
+
const matching = this._dispatched.filter((j): j is T => j instanceof JobClass);
|
|
101
|
+
if (matching.length > 0) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Expected ${JobClass.name} NOT to have been dispatched, but it was (${matching.length}×).`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Assert that no jobs were dispatched. */
|
|
109
|
+
assertNothingDispatched(): void {
|
|
110
|
+
if (this._dispatched.length > 0) {
|
|
111
|
+
const names = this._dispatched.map((j) => j.constructor.name).join(", ");
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Expected nothing to be dispatched, but ${this._dispatched.length} job(s) were: ${names}.`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Assert the exact number of dispatched jobs. */
|
|
119
|
+
assertDispatchedCount(count: number): void {
|
|
120
|
+
if (this._dispatched.length !== count) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Expected ${count} job(s) to be dispatched, but ${this._dispatched.length} were.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|