@mantiq/queue 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/package.json +72 -0
- package/src/Job.ts +122 -0
- package/src/JobBatch.ts +188 -0
- package/src/JobChain.ts +119 -0
- package/src/JobRegistry.ts +37 -0
- package/src/PendingDispatch.ts +80 -0
- package/src/QueueManager.ts +76 -0
- package/src/QueueServiceProvider.ts +88 -0
- package/src/Worker.ts +282 -0
- package/src/commands/MakeJobCommand.ts +29 -0
- package/src/commands/QueueFailedCommand.ts +50 -0
- package/src/commands/QueueFlushCommand.ts +19 -0
- package/src/commands/QueueRetryCommand.ts +52 -0
- package/src/commands/QueueWorkCommand.ts +49 -0
- package/src/commands/ScheduleRunCommand.ts +58 -0
- package/src/contracts/JobContract.ts +64 -0
- package/src/contracts/QueueDriver.ts +70 -0
- package/src/drivers/KafkaDriver.ts +334 -0
- package/src/drivers/RedisDriver.ts +292 -0
- package/src/drivers/SQLiteDriver.ts +280 -0
- package/src/drivers/SqsDriver.ts +280 -0
- package/src/drivers/SyncDriver.ts +142 -0
- package/src/errors/QueueError.ts +22 -0
- package/src/events/QueueEvents.ts +36 -0
- package/src/helpers/queue.ts +76 -0
- package/src/index.ts +85 -0
- package/src/schedule/Schedule.ts +252 -0
- package/src/testing/QueueFake.ts +209 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { Job } from '../Job.ts'
|
|
2
|
+
import type { Constructor } from '../contracts/JobContract.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A single scheduled entry — wraps a command, job, or callback
|
|
6
|
+
* with a cron-like schedule expression.
|
|
7
|
+
*/
|
|
8
|
+
export class ScheduleEntry {
|
|
9
|
+
private cronExpression = '* * * * *'
|
|
10
|
+
private _description = ''
|
|
11
|
+
private _type: 'command' | 'job' | 'callback'
|
|
12
|
+
private _value: string | Constructor<Job> | (() => any)
|
|
13
|
+
private _jobData?: Record<string, any> | undefined
|
|
14
|
+
|
|
15
|
+
constructor(type: 'command' | 'job' | 'callback', value: string | Constructor<Job> | (() => any), jobData?: Record<string, any>) {
|
|
16
|
+
this._type = type
|
|
17
|
+
this._value = value
|
|
18
|
+
this._jobData = jobData
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get type() { return this._type }
|
|
22
|
+
get value() { return this._value }
|
|
23
|
+
get jobData() { return this._jobData }
|
|
24
|
+
get description() { return this._description }
|
|
25
|
+
get expression() { return this.cronExpression }
|
|
26
|
+
|
|
27
|
+
// ── Frequency helpers ────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** Run every minute */
|
|
30
|
+
everyMinute(): this {
|
|
31
|
+
this.cronExpression = '* * * * *'
|
|
32
|
+
return this
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Run every 5 minutes */
|
|
36
|
+
everyFiveMinutes(): this {
|
|
37
|
+
this.cronExpression = '*/5 * * * *'
|
|
38
|
+
return this
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Run every 10 minutes */
|
|
42
|
+
everyTenMinutes(): this {
|
|
43
|
+
this.cronExpression = '*/10 * * * *'
|
|
44
|
+
return this
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Run every 15 minutes */
|
|
48
|
+
everyFifteenMinutes(): this {
|
|
49
|
+
this.cronExpression = '*/15 * * * *'
|
|
50
|
+
return this
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Run every 30 minutes */
|
|
54
|
+
everyThirtyMinutes(): this {
|
|
55
|
+
this.cronExpression = '*/30 * * * *'
|
|
56
|
+
return this
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Run once per hour at minute 0 */
|
|
60
|
+
hourly(): this {
|
|
61
|
+
this.cronExpression = '0 * * * *'
|
|
62
|
+
return this
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Run once per hour at a specific minute */
|
|
66
|
+
hourlyAt(minute: number): this {
|
|
67
|
+
this.cronExpression = `${minute} * * * *`
|
|
68
|
+
return this
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Run once per day at midnight */
|
|
72
|
+
daily(): this {
|
|
73
|
+
this.cronExpression = '0 0 * * *'
|
|
74
|
+
return this
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Run daily at a specific time (HH:MM) */
|
|
78
|
+
dailyAt(time: string): this {
|
|
79
|
+
const [hours, minutes] = time.split(':').map(Number)
|
|
80
|
+
this.cronExpression = `${minutes ?? 0} ${hours ?? 0} * * *`
|
|
81
|
+
return this
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Run twice daily at the given hours */
|
|
85
|
+
twiceDaily(hour1 = 1, hour2 = 13): this {
|
|
86
|
+
this.cronExpression = `0 ${hour1},${hour2} * * *`
|
|
87
|
+
return this
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Run once per week on Sunday at midnight */
|
|
91
|
+
weekly(): this {
|
|
92
|
+
this.cronExpression = '0 0 * * 0'
|
|
93
|
+
return this
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Run weekly on a specific day and time */
|
|
97
|
+
weeklyOn(day: number, time = '0:0'): this {
|
|
98
|
+
const [hours, minutes] = time.split(':').map(Number)
|
|
99
|
+
this.cronExpression = `${minutes ?? 0} ${hours ?? 0} * * ${day}`
|
|
100
|
+
return this
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Run once per month on the 1st at midnight */
|
|
104
|
+
monthly(): this {
|
|
105
|
+
this.cronExpression = '0 0 1 * *'
|
|
106
|
+
return this
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Run monthly on a specific day and time */
|
|
110
|
+
monthlyOn(day = 1, time = '0:0'): this {
|
|
111
|
+
const [hours, minutes] = time.split(':').map(Number)
|
|
112
|
+
this.cronExpression = `${minutes ?? 0} ${hours ?? 0} ${day} * *`
|
|
113
|
+
return this
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Run once per year on January 1st at midnight */
|
|
117
|
+
yearly(): this {
|
|
118
|
+
this.cronExpression = '0 0 1 1 *'
|
|
119
|
+
return this
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Set a raw cron expression */
|
|
123
|
+
cron(expression: string): this {
|
|
124
|
+
this.cronExpression = expression
|
|
125
|
+
return this
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Set a human-readable description */
|
|
129
|
+
describedAs(description: string): this {
|
|
130
|
+
this._description = description
|
|
131
|
+
return this
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check if this entry is due to run at a given date (defaults to now).
|
|
136
|
+
* Matches minute, hour, day-of-month, month, and day-of-week.
|
|
137
|
+
*/
|
|
138
|
+
isDue(now?: Date): boolean {
|
|
139
|
+
const date = now ?? new Date()
|
|
140
|
+
const parts = this.cronExpression.split(/\s+/)
|
|
141
|
+
if (parts.length !== 5) return false
|
|
142
|
+
|
|
143
|
+
const minute = date.getMinutes()
|
|
144
|
+
const hour = date.getHours()
|
|
145
|
+
const dayOfMonth = date.getDate()
|
|
146
|
+
const month = date.getMonth() + 1
|
|
147
|
+
const dayOfWeek = date.getDay()
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
matchesCronField(parts[0]!, minute, 0, 59) &&
|
|
151
|
+
matchesCronField(parts[1]!, hour, 0, 23) &&
|
|
152
|
+
matchesCronField(parts[2]!, dayOfMonth, 1, 31) &&
|
|
153
|
+
matchesCronField(parts[3]!, month, 1, 12) &&
|
|
154
|
+
matchesCronField(parts[4]!, dayOfWeek, 0, 6)
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Schedule registry — collects commands, jobs, and callbacks
|
|
161
|
+
* that should be run on a recurring basis.
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```ts
|
|
165
|
+
* // routes/console.ts
|
|
166
|
+
* import { schedule } from '@mantiq/queue'
|
|
167
|
+
*
|
|
168
|
+
* export default function (schedule: Schedule) {
|
|
169
|
+
* schedule.command('cache:prune').daily()
|
|
170
|
+
* schedule.job(ProcessReports).dailyAt('02:00')
|
|
171
|
+
* schedule.call(() => console.log('heartbeat')).everyFiveMinutes()
|
|
172
|
+
* }
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
export class Schedule {
|
|
176
|
+
private entries: ScheduleEntry[] = []
|
|
177
|
+
|
|
178
|
+
/** Schedule an artisan/CLI command */
|
|
179
|
+
command(name: string): ScheduleEntry {
|
|
180
|
+
const entry = new ScheduleEntry('command', name)
|
|
181
|
+
this.entries.push(entry)
|
|
182
|
+
return entry
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Schedule a queued job */
|
|
186
|
+
job(jobClass: Constructor<Job>, data?: Record<string, any>): ScheduleEntry {
|
|
187
|
+
const entry = new ScheduleEntry('job', jobClass, data)
|
|
188
|
+
this.entries.push(entry)
|
|
189
|
+
return entry
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Schedule an arbitrary callback */
|
|
193
|
+
call(callback: () => any): ScheduleEntry {
|
|
194
|
+
const entry = new ScheduleEntry('callback', callback)
|
|
195
|
+
this.entries.push(entry)
|
|
196
|
+
return entry
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Get all entries that are due at the given time (defaults to now) */
|
|
200
|
+
dueEntries(now?: Date): ScheduleEntry[] {
|
|
201
|
+
return this.entries.filter((e) => e.isDue(now))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Get all registered entries */
|
|
205
|
+
allEntries(): ScheduleEntry[] {
|
|
206
|
+
return [...this.entries]
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Cron expression matching ──────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
function matchesCronField(field: string, value: number, min: number, max: number): boolean {
|
|
213
|
+
if (field === '*') return true
|
|
214
|
+
|
|
215
|
+
// Handle comma-separated values: '1,15,30'
|
|
216
|
+
if (field.includes(',')) {
|
|
217
|
+
return field.split(',').some((part) => matchesCronField(part.trim(), value, min, max))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Handle step values: '*/5', '1-30/5'
|
|
221
|
+
if (field.includes('/')) {
|
|
222
|
+
const [range, stepStr] = field.split('/')
|
|
223
|
+
const step = parseInt(stepStr!, 10)
|
|
224
|
+
if (isNaN(step) || step <= 0) return false
|
|
225
|
+
|
|
226
|
+
let start = min
|
|
227
|
+
let end = max
|
|
228
|
+
if (range !== '*') {
|
|
229
|
+
if (range!.includes('-')) {
|
|
230
|
+
const [s, e] = range!.split('-').map(Number)
|
|
231
|
+
start = s!
|
|
232
|
+
end = e!
|
|
233
|
+
} else {
|
|
234
|
+
start = parseInt(range!, 10)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (let i = start; i <= end; i += step) {
|
|
239
|
+
if (i === value) return true
|
|
240
|
+
}
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Handle ranges: '1-5'
|
|
245
|
+
if (field.includes('-')) {
|
|
246
|
+
const [start, end] = field.split('-').map(Number)
|
|
247
|
+
return value >= start! && value <= end!
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Simple number
|
|
251
|
+
return parseInt(field, 10) === value
|
|
252
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { QueueDriver } from '../contracts/QueueDriver.ts'
|
|
2
|
+
import type {
|
|
3
|
+
QueuedJob,
|
|
4
|
+
FailedJob,
|
|
5
|
+
SerializedPayload,
|
|
6
|
+
BatchRecord,
|
|
7
|
+
Constructor,
|
|
8
|
+
} from '../contracts/JobContract.ts'
|
|
9
|
+
import type { Job } from '../Job.ts'
|
|
10
|
+
|
|
11
|
+
interface PushedJob {
|
|
12
|
+
payload: SerializedPayload
|
|
13
|
+
queue: string
|
|
14
|
+
delay: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fake queue driver for testing.
|
|
19
|
+
* Stores jobs in memory without executing them.
|
|
20
|
+
* Provides assertion methods for verifying dispatch behavior.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* const fake = new QueueFake()
|
|
25
|
+
* // ... dispatch jobs ...
|
|
26
|
+
* fake.assertPushed(ProcessPayment)
|
|
27
|
+
* fake.assertPushedOn('payments', ProcessPayment)
|
|
28
|
+
* fake.assertNotPushed(SendEmail)
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export class QueueFake implements QueueDriver {
|
|
32
|
+
private pushedJobs: PushedJob[] = []
|
|
33
|
+
private failedJobs: FailedJob[] = []
|
|
34
|
+
private batches = new Map<string, BatchRecord>()
|
|
35
|
+
private nextId = 1
|
|
36
|
+
|
|
37
|
+
// ── QueueDriver implementation ──────────────────────────────────
|
|
38
|
+
|
|
39
|
+
async push(payload: SerializedPayload, queue: string, delay = 0): Promise<string | number> {
|
|
40
|
+
const id = this.nextId++
|
|
41
|
+
this.pushedJobs.push({ payload, queue, delay })
|
|
42
|
+
return id
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async pop(): Promise<QueuedJob | null> {
|
|
46
|
+
return null // Fake doesn't execute jobs
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async delete(): Promise<void> {}
|
|
50
|
+
async release(): Promise<void> {}
|
|
51
|
+
|
|
52
|
+
async size(queue: string): Promise<number> {
|
|
53
|
+
return this.pushedJobs.filter((j) => j.queue === queue).length
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async clear(queue: string): Promise<void> {
|
|
57
|
+
this.pushedJobs = this.pushedJobs.filter((j) => j.queue !== queue)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async fail(job: QueuedJob, error: Error): Promise<void> {
|
|
61
|
+
this.failedJobs.push({
|
|
62
|
+
id: job.id,
|
|
63
|
+
queue: job.queue,
|
|
64
|
+
payload: job.payload,
|
|
65
|
+
exception: error.message,
|
|
66
|
+
failedAt: Math.floor(Date.now() / 1000),
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async getFailedJobs(): Promise<FailedJob[]> { return [...this.failedJobs] }
|
|
71
|
+
async findFailedJob(id: string | number): Promise<FailedJob | null> {
|
|
72
|
+
return this.failedJobs.find((j) => j.id === id) ?? null
|
|
73
|
+
}
|
|
74
|
+
async forgetFailedJob(id: string | number): Promise<boolean> {
|
|
75
|
+
const idx = this.failedJobs.findIndex((j) => j.id === id)
|
|
76
|
+
if (idx === -1) return false
|
|
77
|
+
this.failedJobs.splice(idx, 1)
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
async flushFailedJobs(): Promise<void> { this.failedJobs = [] }
|
|
81
|
+
|
|
82
|
+
async createBatch(batch: BatchRecord): Promise<string> {
|
|
83
|
+
this.batches.set(batch.id, { ...batch })
|
|
84
|
+
return batch.id
|
|
85
|
+
}
|
|
86
|
+
async findBatch(id: string): Promise<BatchRecord | null> {
|
|
87
|
+
return this.batches.get(id) ?? null
|
|
88
|
+
}
|
|
89
|
+
async updateBatchProgress(id: string, processed: number, failed: number): Promise<BatchRecord | null> {
|
|
90
|
+
const b = this.batches.get(id)
|
|
91
|
+
if (!b) return null
|
|
92
|
+
b.processedJobs += processed
|
|
93
|
+
b.failedJobs += failed
|
|
94
|
+
return { ...b }
|
|
95
|
+
}
|
|
96
|
+
async markBatchFinished(id: string): Promise<void> {
|
|
97
|
+
const b = this.batches.get(id)
|
|
98
|
+
if (b) b.finishedAt = Math.floor(Date.now() / 1000)
|
|
99
|
+
}
|
|
100
|
+
async cancelBatch(id: string): Promise<void> {
|
|
101
|
+
const b = this.batches.get(id)
|
|
102
|
+
if (b) b.cancelledAt = Math.floor(Date.now() / 1000)
|
|
103
|
+
}
|
|
104
|
+
async pruneBatches(olderThanSeconds: number): Promise<void> {
|
|
105
|
+
const cutoff = Math.floor(Date.now() / 1000) - olderThanSeconds
|
|
106
|
+
for (const [id, b] of this.batches) {
|
|
107
|
+
if (b.createdAt < cutoff) this.batches.delete(id)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Assertion methods ──────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/** Get all pushed payloads matching a job class */
|
|
114
|
+
pushed(jobClass: Constructor<Job>): PushedJob[] {
|
|
115
|
+
return this.pushedJobs.filter((j) => j.payload.jobName === jobClass.name)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Assert a job was pushed, optionally checking exact count */
|
|
119
|
+
assertPushed(jobClass: Constructor<Job>, count?: number): void {
|
|
120
|
+
const matching = this.pushed(jobClass)
|
|
121
|
+
if (matching.length === 0) {
|
|
122
|
+
throw new Error(`Expected [${jobClass.name}] to be pushed, but it was not.`)
|
|
123
|
+
}
|
|
124
|
+
if (count !== undefined && matching.length !== count) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Expected [${jobClass.name}] to be pushed ${count} time(s), but it was pushed ${matching.length} time(s).`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Assert a job was pushed to a specific queue */
|
|
132
|
+
assertPushedOn(queue: string, jobClass: Constructor<Job>): void {
|
|
133
|
+
const matching = this.pushedJobs.filter(
|
|
134
|
+
(j) => j.payload.jobName === jobClass.name && j.queue === queue,
|
|
135
|
+
)
|
|
136
|
+
if (matching.length === 0) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Expected [${jobClass.name}] to be pushed on queue [${queue}], but it was not.`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Assert a job was NOT pushed */
|
|
144
|
+
assertNotPushed(jobClass: Constructor<Job>): void {
|
|
145
|
+
const matching = this.pushed(jobClass)
|
|
146
|
+
if (matching.length > 0) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Unexpected [${jobClass.name}] was pushed ${matching.length} time(s).`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Assert nothing was pushed at all */
|
|
154
|
+
assertNothingPushed(): void {
|
|
155
|
+
if (this.pushedJobs.length > 0) {
|
|
156
|
+
const names = [...new Set(this.pushedJobs.map((j) => j.payload.jobName))]
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Expected no jobs to be pushed, but found: ${names.join(', ')}`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Assert a chain was dispatched in the given order */
|
|
164
|
+
assertChained(jobClasses: Constructor<Job>[]): void {
|
|
165
|
+
if (jobClasses.length === 0) {
|
|
166
|
+
throw new Error('assertChained() requires at least one job class')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const firstName = jobClasses[0]!.name
|
|
170
|
+
const first = this.pushedJobs.find((j) => j.payload.jobName === firstName)
|
|
171
|
+
if (!first) {
|
|
172
|
+
throw new Error(`Expected chain starting with [${firstName}] to be dispatched, but it was not.`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const chainedNames = (first.payload.chainedJobs ?? []).map((p) => p.jobName)
|
|
176
|
+
const expectedChained = jobClasses.slice(1).map((c) => c.name)
|
|
177
|
+
|
|
178
|
+
if (chainedNames.length !== expectedChained.length) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Expected chain of [${jobClasses.map((c) => c.name).join(' → ')}] but got [${firstName} → ${chainedNames.join(' → ')}]`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < expectedChained.length; i++) {
|
|
185
|
+
if (chainedNames[i] !== expectedChained[i]) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`Expected chain of [${jobClasses.map((c) => c.name).join(' → ')}] but got [${firstName} → ${chainedNames.join(' → ')}]`,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Assert a batch was dispatched, optionally with a callback to inspect it */
|
|
194
|
+
assertBatched(callback?: (jobs: PushedJob[]) => void): void {
|
|
195
|
+
const batchedJobs = this.pushedJobs.filter((j) => j.payload.batchId)
|
|
196
|
+
if (batchedJobs.length === 0) {
|
|
197
|
+
throw new Error('Expected a batch to be dispatched, but none was.')
|
|
198
|
+
}
|
|
199
|
+
if (callback) callback(batchedJobs)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Reset all pushed jobs (for test isolation) */
|
|
203
|
+
reset(): void {
|
|
204
|
+
this.pushedJobs = []
|
|
205
|
+
this.failedJobs = []
|
|
206
|
+
this.batches.clear()
|
|
207
|
+
this.nextId = 1
|
|
208
|
+
}
|
|
209
|
+
}
|