@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.
@@ -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
+ }