@c9up/bay 0.1.3
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 +34 -0
- package/dist/BayProvider.d.ts +60 -0
- package/dist/BayProvider.d.ts.map +1 -0
- package/dist/BayProvider.js +50 -0
- package/dist/BayProvider.js.map +1 -0
- package/dist/QueueManager.d.ts +57 -0
- package/dist/QueueManager.d.ts.map +1 -0
- package/dist/QueueManager.js +121 -0
- package/dist/QueueManager.js.map +1 -0
- package/dist/drivers/MemoryDriver.d.ts +18 -0
- package/dist/drivers/MemoryDriver.d.ts.map +1 -0
- package/dist/drivers/MemoryDriver.js +39 -0
- package/dist/drivers/MemoryDriver.js.map +1 -0
- package/dist/drivers/RedisDriver.d.ts +39 -0
- package/dist/drivers/RedisDriver.d.ts.map +1 -0
- package/dist/drivers/RedisDriver.js +163 -0
- package/dist/drivers/RedisDriver.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/services/main.d.ts +22 -0
- package/dist/services/main.d.ts.map +1 -0
- package/dist/services/main.js +35 -0
- package/dist/services/main.js.map +1 -0
- package/dist/testing/FakeQueue.d.ts +44 -0
- package/dist/testing/FakeQueue.d.ts.map +1 -0
- package/dist/testing/FakeQueue.js +131 -0
- package/dist/testing/FakeQueue.js.map +1 -0
- package/package.json +63 -0
- package/src/BayProvider.ts +85 -0
- package/src/QueueManager.ts +165 -0
- package/src/drivers/MemoryDriver.ts +49 -0
- package/src/drivers/RedisDriver.ts +202 -0
- package/src/index.ts +13 -0
- package/src/services/main.ts +43 -0
- package/src/testing/FakeQueue.ts +172 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis queue driver — FIFO job queue with visibility timeout.
|
|
3
|
+
*
|
|
4
|
+
* Uses LMOVE (Redis 6.2+) for at-least-once delivery:
|
|
5
|
+
* - pop() moves the job from pending → processing (atomic)
|
|
6
|
+
* - complete() removes from processing
|
|
7
|
+
* - If a worker crashes, the job stays in processing
|
|
8
|
+
* - recoverStale() moves expired processing jobs back to pending
|
|
9
|
+
*
|
|
10
|
+
* Compatible with ioredis and node-redis clients.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Job, QueueDriver } from "../QueueManager.js";
|
|
14
|
+
|
|
15
|
+
export interface RedisClient {
|
|
16
|
+
rpush(key: string, ...values: string[]): Promise<number>;
|
|
17
|
+
lpop(key: string): Promise<string | null>;
|
|
18
|
+
lmove?(
|
|
19
|
+
source: string,
|
|
20
|
+
destination: string,
|
|
21
|
+
from: "LEFT" | "RIGHT",
|
|
22
|
+
to: "LEFT" | "RIGHT",
|
|
23
|
+
): Promise<string | null>;
|
|
24
|
+
lrem(key: string, count: number, element: string): Promise<number>;
|
|
25
|
+
llen(key: string): Promise<number>;
|
|
26
|
+
lrange(key: string, start: number, stop: number): Promise<string[]>;
|
|
27
|
+
del(key: string): Promise<number>;
|
|
28
|
+
set(key: string, value: string, ...args: string[]): Promise<string | null>;
|
|
29
|
+
get(key: string): Promise<string | null>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isValidJob(obj: unknown): obj is Job {
|
|
33
|
+
if (typeof obj !== "object" || obj === null) return false;
|
|
34
|
+
const j = obj as Record<string, unknown>;
|
|
35
|
+
return (
|
|
36
|
+
typeof j.id === "string" &&
|
|
37
|
+
typeof j.name === "string" &&
|
|
38
|
+
typeof j.attempts === "number" &&
|
|
39
|
+
typeof j.maxAttempts === "number" &&
|
|
40
|
+
typeof j.status === "string"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class RedisDriver implements QueueDriver {
|
|
45
|
+
#client: RedisClient;
|
|
46
|
+
#prefix: string;
|
|
47
|
+
#visibilityTimeout: number;
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
client: RedisClient,
|
|
51
|
+
options?: { prefix?: string; visibilityTimeoutMs?: number },
|
|
52
|
+
) {
|
|
53
|
+
this.#client = client;
|
|
54
|
+
this.#prefix = options?.prefix ?? "queue:";
|
|
55
|
+
this.#visibilityTimeout = options?.visibilityTimeoutMs ?? 30_000;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#pendingKey = () => `${this.#prefix}pending`;
|
|
59
|
+
#processingKey = () => `${this.#prefix}processing`;
|
|
60
|
+
#failedKey = () => `${this.#prefix}failed`;
|
|
61
|
+
#leaseKey = (jobId: string) => `${this.#prefix}lease:${jobId}`;
|
|
62
|
+
|
|
63
|
+
async push(job: Job): Promise<void> {
|
|
64
|
+
await this.#client.rpush(this.#pendingKey(), JSON.stringify(job));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async pop(): Promise<Job | null> {
|
|
68
|
+
let raw: string | null = null;
|
|
69
|
+
|
|
70
|
+
if (this.#client.lmove) {
|
|
71
|
+
raw = await this.#client.lmove(
|
|
72
|
+
this.#pendingKey(),
|
|
73
|
+
this.#processingKey(),
|
|
74
|
+
"LEFT",
|
|
75
|
+
"RIGHT",
|
|
76
|
+
);
|
|
77
|
+
} else {
|
|
78
|
+
raw = await this.#client.lpop(this.#pendingKey());
|
|
79
|
+
if (raw) await this.#client.rpush(this.#processingKey(), raw);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!raw) return null;
|
|
83
|
+
try {
|
|
84
|
+
const parsed: unknown = JSON.parse(raw);
|
|
85
|
+
if (!isValidJob(parsed)) {
|
|
86
|
+
// Malformed payload — purge from `processing` so it can't sit
|
|
87
|
+
// there indefinitely as a poison pill. recoverStale() also
|
|
88
|
+
// catches survivors but pop()'s own move is the primary path.
|
|
89
|
+
await this.#client.lrem(this.#processingKey(), 1, raw);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
await this.#client.set(
|
|
93
|
+
this.#leaseKey(parsed.id),
|
|
94
|
+
raw,
|
|
95
|
+
"PX",
|
|
96
|
+
String(this.#visibilityTimeout),
|
|
97
|
+
);
|
|
98
|
+
return parsed;
|
|
99
|
+
} catch {
|
|
100
|
+
await this.#client.lrem(this.#processingKey(), 1, raw);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async complete(job: Job): Promise<void> {
|
|
106
|
+
await this.#removeFromProcessing(job);
|
|
107
|
+
await this.#client.del(this.#leaseKey(job.id));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async fail(job: Job, error: string): Promise<void> {
|
|
111
|
+
await this.#removeFromProcessing(job);
|
|
112
|
+
await this.#client.del(this.#leaseKey(job.id));
|
|
113
|
+
job.error = error;
|
|
114
|
+
job.status = "failed";
|
|
115
|
+
await this.#client.rpush(this.#failedKey(), JSON.stringify(job));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async retry(job: Job): Promise<void> {
|
|
119
|
+
await this.#removeFromProcessing(job);
|
|
120
|
+
await this.#client.del(this.#leaseKey(job.id));
|
|
121
|
+
job.status = "pending";
|
|
122
|
+
await this.#client.rpush(this.#pendingKey(), JSON.stringify(job));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async recoverStale(): Promise<number> {
|
|
126
|
+
const processing = await this.#client.lrange(this.#processingKey(), 0, -1);
|
|
127
|
+
let recovered = 0;
|
|
128
|
+
for (const raw of processing) {
|
|
129
|
+
let parsed: unknown;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(raw);
|
|
132
|
+
} catch {
|
|
133
|
+
// Malformed JSON would otherwise sit in processing forever —
|
|
134
|
+
// LREM purges it so the queue makes progress.
|
|
135
|
+
await this.#client.lrem(this.#processingKey(), 1, raw);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!isValidJob(parsed)) {
|
|
139
|
+
await this.#client.lrem(this.#processingKey(), 1, raw);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const lease = await this.#client.get(this.#leaseKey(parsed.id));
|
|
143
|
+
if (lease === null) {
|
|
144
|
+
await this.#client.lrem(this.#processingKey(), 1, raw);
|
|
145
|
+
parsed.status = "pending";
|
|
146
|
+
await this.#client.rpush(this.#pendingKey(), JSON.stringify(parsed));
|
|
147
|
+
recovered++;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return recovered;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Remove the entry for `job` from the processing list. The string in
|
|
155
|
+
* Redis is whatever pop() pushed, but QueueManager mutates `job` after
|
|
156
|
+
* pop returns (attempts++, status="processing", processedAt, then
|
|
157
|
+
* completed/failed/pending). LREM-ing on `JSON.stringify(job)` would
|
|
158
|
+
* therefore miss every real-world entry. Use the lease — set to the
|
|
159
|
+
* exact raw string at pop() time — and fall back to a list scan when
|
|
160
|
+
* the lease has expired (e.g. recoverStale already handled it).
|
|
161
|
+
*/
|
|
162
|
+
async #removeFromProcessing(job: Job): Promise<void> {
|
|
163
|
+
const stored = await this.#client.get(this.#leaseKey(job.id));
|
|
164
|
+
if (stored !== null) {
|
|
165
|
+
const removed = await this.#client.lrem(this.#processingKey(), 1, stored);
|
|
166
|
+
if (removed > 0) return;
|
|
167
|
+
}
|
|
168
|
+
// Lease missing or already-LREM'd entry not found — best-effort scan
|
|
169
|
+
// matches by job id and removes the actual stored representation.
|
|
170
|
+
const items = await this.#client.lrange(this.#processingKey(), 0, -1);
|
|
171
|
+
for (const item of items) {
|
|
172
|
+
let parsed: unknown;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(item);
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (isValidJob(parsed) && (parsed as { id: string }).id === job.id) {
|
|
179
|
+
await this.#client.lrem(this.#processingKey(), 1, item);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async failed(): Promise<Job[]> {
|
|
186
|
+
const raws = await this.#client.lrange(this.#failedKey(), 0, -1);
|
|
187
|
+
return raws
|
|
188
|
+
.map((r) => {
|
|
189
|
+
try {
|
|
190
|
+
const parsed: unknown = JSON.parse(r);
|
|
191
|
+
return isValidJob(parsed) ? parsed : null;
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
.filter((j): j is Job => j !== null);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async size(): Promise<number> {
|
|
200
|
+
return this.#client.llen(this.#pendingKey());
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @c9up/bay — Background job queue for the Ream framework.
|
|
3
|
+
*
|
|
4
|
+
* Dispatch/process/retry/fail pattern with pluggable drivers (Memory, Redis).
|
|
5
|
+
*
|
|
6
|
+
* @implements MISS-11
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { MemoryDriver } from "./drivers/MemoryDriver.js";
|
|
10
|
+
export type { RedisClient } from "./drivers/RedisDriver.js";
|
|
11
|
+
export { RedisDriver } from "./drivers/RedisDriver.js";
|
|
12
|
+
export type { Job, JobHandler, QueueDriver } from "./QueueManager.js";
|
|
13
|
+
export { QueueManager } from "./QueueManager.js";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default `QueueManager` singleton — mirror of Adonis's
|
|
3
|
+
* `import queue from '@adonisjs/queue/services/main'` shape.
|
|
4
|
+
*
|
|
5
|
+
* Populated by either:
|
|
6
|
+
* - `BayProvider.boot()`, when the app uses `() => import('@c9up/bay/provider')`
|
|
7
|
+
* - The app itself, via `setQueue(myQueue)`, when it wires a
|
|
8
|
+
* custom-driver `QueueManager` outside the provider flow.
|
|
9
|
+
*
|
|
10
|
+
* import queue from '@c9up/bay/services/main'
|
|
11
|
+
*
|
|
12
|
+
* queue.register('send-email', new SendEmailJob())
|
|
13
|
+
* await queue.dispatch('send-email', { to: 'user@example.com' })
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { QueueManager } from "../QueueManager.js";
|
|
17
|
+
|
|
18
|
+
let instance: QueueManager | undefined;
|
|
19
|
+
|
|
20
|
+
/** @internal Bind the singleton (called by BayProvider or by the app). */
|
|
21
|
+
export function setQueue(value: QueueManager): void {
|
|
22
|
+
instance = value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @internal Read the singleton (or `undefined` pre-boot). */
|
|
26
|
+
export function getQueue(): QueueManager | undefined {
|
|
27
|
+
return instance;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const queue: QueueManager = new Proxy({} as QueueManager, {
|
|
31
|
+
get(_target, prop) {
|
|
32
|
+
if (!instance) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"[bay] QueueManager singleton accessed before BayProvider.boot() ran " +
|
|
35
|
+
"or `setQueue(myQueue)` was called. Wire one of them first.",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const value = Reflect.get(instance, prop, instance);
|
|
39
|
+
return typeof value === "function" ? value.bind(instance) : value;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export default queue;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory `QueueDriver` for tests — captures every dispatched
|
|
3
|
+
* job and exposes Adonis/Laravel-style `assertPushed` /
|
|
4
|
+
* `assertNotPushed` helpers in the same shape as Rover's
|
|
5
|
+
* `FakeMail`.
|
|
6
|
+
*
|
|
7
|
+
* Not re-exported from the main barrel; reach via
|
|
8
|
+
* `@c9up/bay/testing` so production code never accidentally pulls
|
|
9
|
+
* the fake into a runtime build.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Job, QueueDriver } from "../QueueManager.js";
|
|
13
|
+
|
|
14
|
+
export interface FakeQueuePredicate {
|
|
15
|
+
/** Custom payload predicate — receives the job's `payload` and
|
|
16
|
+
* returns `true` to match. The job name is always taken from the
|
|
17
|
+
* positional `name` argument of `assertPushed` / `assertNotPushed`
|
|
18
|
+
* — there is no `name` field on the predicate (would create a
|
|
19
|
+
* silent override footgun where `assertPushed('a', { name: 'b' })`
|
|
20
|
+
* matches 'b' but the error message says 'a'). */
|
|
21
|
+
payloadMatches?: (payload: unknown) => boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type FakeQueuePredicateArg =
|
|
25
|
+
| FakeQueuePredicate
|
|
26
|
+
| ((job: Job) => boolean);
|
|
27
|
+
|
|
28
|
+
export class FakeQueue implements QueueDriver {
|
|
29
|
+
#pushed: Job[] = [];
|
|
30
|
+
|
|
31
|
+
async push(job: Job): Promise<void> {
|
|
32
|
+
// Reject duplicate ids to surface the most common test-fixture
|
|
33
|
+
// mistake — two `makeJob({ id: 'x' })` reused across pushes
|
|
34
|
+
// silently corrupts later `fail`/`complete`/`retry` lookups.
|
|
35
|
+
if (this.#pushed.some((j) => j.id === job.id)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`FakeQueue: duplicate job id '${job.id}' — each push must use a unique id (the real drivers enforce this implicitly via crypto.randomUUID()).`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
this.#pushed.push(job);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Always returns `null` — fake queues never auto-dispatch.
|
|
44
|
+
* Tests that need handler execution should use the memory
|
|
45
|
+
* driver directly. */
|
|
46
|
+
async pop(): Promise<Job | null> {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async fail(job: Job, error: string): Promise<void> {
|
|
51
|
+
const found = this.#requireJob(job, "fail");
|
|
52
|
+
found.status = "failed";
|
|
53
|
+
found.error = error;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async complete(job: Job): Promise<void> {
|
|
57
|
+
const found = this.#requireJob(job, "complete");
|
|
58
|
+
found.status = "completed";
|
|
59
|
+
found.processedAt = Date.now();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async retry(job: Job): Promise<void> {
|
|
63
|
+
const found = this.#requireJob(job, "retry");
|
|
64
|
+
found.attempts += 1;
|
|
65
|
+
found.status = "pending";
|
|
66
|
+
// Reset transient state from the prior failure so a retried
|
|
67
|
+
// job's invariants match a fresh push (a real driver would
|
|
68
|
+
// either drop these on requeue or rely on the worker to clear
|
|
69
|
+
// them — we mirror "drop").
|
|
70
|
+
found.error = undefined;
|
|
71
|
+
found.processedAt = undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Look up the captured copy of a job by id. Throws when the id
|
|
75
|
+
* isn't present — silent no-op on a missing job is the most
|
|
76
|
+
* insidious test bug (caller's local Job ref shows the new
|
|
77
|
+
* status while the FakeQueue's internal capture is unchanged). */
|
|
78
|
+
#requireJob(job: Job, verb: string): Job {
|
|
79
|
+
const found = this.#pushed.find((j) => j.id === job.id);
|
|
80
|
+
if (!found) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`FakeQueue.${verb}: job id '${job.id}' is not in the queue. Did you forget to push it, or is it from a different FakeQueue instance?`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return found;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async failed(): Promise<Job[]> {
|
|
89
|
+
return this.#pushed
|
|
90
|
+
.filter((j) => j.status === "failed")
|
|
91
|
+
.map((j) => ({ ...j }));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async size(): Promise<number> {
|
|
95
|
+
return this.#pushed.filter((j) => j.status === "pending").length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Defensive snapshot of every captured job. Each entry is a
|
|
100
|
+
* shallow clone so test-side mutations can't bleed back into the
|
|
101
|
+
* internal capture store — avoids cross-test contamination.
|
|
102
|
+
*/
|
|
103
|
+
getPushed(): Job[] {
|
|
104
|
+
return this.#pushed.map((j) => ({ ...j }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
reset(): void {
|
|
108
|
+
this.#pushed = [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
assertPushed(name: string, predicate?: FakeQueuePredicateArg): void {
|
|
112
|
+
const match = makeMatcher(name, predicate);
|
|
113
|
+
if (this.#pushed.some(match)) return;
|
|
114
|
+
throw new Error(
|
|
115
|
+
`queue.assertPushed('${name}'${describePredicate(predicate)}) failed — no captured job matches.\n${describeCaptured(this.#pushed)}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
assertNotPushed(name: string, predicate?: FakeQueuePredicateArg): void {
|
|
120
|
+
const match = makeMatcher(name, predicate);
|
|
121
|
+
const found = this.#pushed.find(match);
|
|
122
|
+
if (!found) return;
|
|
123
|
+
throw new Error(
|
|
124
|
+
`queue.assertNotPushed('${name}'${describePredicate(predicate)}) failed — at least one captured job matches.\n${describeCaptured(this.#pushed)}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function makeMatcher(
|
|
130
|
+
name: string,
|
|
131
|
+
predicate: FakeQueuePredicateArg | undefined,
|
|
132
|
+
): (j: Job) => boolean {
|
|
133
|
+
// Function-form predicate — caller does ALL the matching, the
|
|
134
|
+
// `name` arg is still a hard prerequisite.
|
|
135
|
+
if (typeof predicate === "function") {
|
|
136
|
+
return (j) => j.name === name && predicate(j);
|
|
137
|
+
}
|
|
138
|
+
if (predicate === undefined) {
|
|
139
|
+
return (j) => j.name === name;
|
|
140
|
+
}
|
|
141
|
+
// Object-form: positional `name` is the contract; the predicate
|
|
142
|
+
// narrows further via `payloadMatches`.
|
|
143
|
+
return (j) => {
|
|
144
|
+
if (j.name !== name) return false;
|
|
145
|
+
if (predicate.payloadMatches && !predicate.payloadMatches(j.payload)) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function describePredicate(
|
|
153
|
+
predicate: FakeQueuePredicateArg | undefined,
|
|
154
|
+
): string {
|
|
155
|
+
if (predicate === undefined) return "";
|
|
156
|
+
if (typeof predicate === "function") return ", <function predicate>";
|
|
157
|
+
// Empty object collapses to a name-only match — say so explicitly
|
|
158
|
+
// so a mistaken `assertPushed('x', {})` is not mistaken for "I'm
|
|
159
|
+
// narrowing by some predicate I forgot to fill in".
|
|
160
|
+
if (Object.keys(predicate).length === 0)
|
|
161
|
+
return ", <empty predicate (name-only)>";
|
|
162
|
+
return `, ${JSON.stringify(predicate)}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function describeCaptured(captured: Job[]): string {
|
|
166
|
+
if (captured.length === 0) return "Captured: (none)";
|
|
167
|
+
const lines = captured.map(
|
|
168
|
+
(j, i) =>
|
|
169
|
+
` [${i}] name="${j.name}" status=${j.status} attempts=${j.attempts}/${j.maxAttempts}`,
|
|
170
|
+
);
|
|
171
|
+
return `Captured (${captured.length}):\n${lines.join("\n")}`;
|
|
172
|
+
}
|