@oneunit/redis 0.0.0-stage → 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.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +422 -0
  2. package/CHANGELOG.md +186 -0
  3. package/CONTRIBUTING.md +353 -0
  4. package/LICENSE +21 -0
  5. package/README.md +760 -2
  6. package/dist/client/check.d.ts +19 -0
  7. package/dist/client/check.d.ts.map +1 -0
  8. package/dist/client/check.js +44 -0
  9. package/dist/client/check.js.map +1 -0
  10. package/dist/client/client.d.ts +10 -0
  11. package/dist/client/client.d.ts.map +1 -0
  12. package/dist/client/client.js +25 -0
  13. package/dist/client/client.js.map +1 -0
  14. package/dist/client/events.d.ts +5 -0
  15. package/dist/client/events.d.ts.map +1 -0
  16. package/dist/client/events.js +66 -0
  17. package/dist/client/events.js.map +1 -0
  18. package/dist/client/index.d.ts +6 -0
  19. package/dist/client/index.d.ts.map +1 -0
  20. package/dist/client/index.js +5 -0
  21. package/dist/client/index.js.map +1 -0
  22. package/dist/client/shutdown.d.ts +4 -0
  23. package/dist/client/shutdown.d.ts.map +1 -0
  24. package/dist/client/shutdown.js +78 -0
  25. package/dist/client/shutdown.js.map +1 -0
  26. package/dist/index.d.ts +5 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +5 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/logger.d.ts +23 -0
  31. package/dist/logger.d.ts.map +1 -0
  32. package/dist/logger.js +120 -0
  33. package/dist/logger.js.map +1 -0
  34. package/dist/pipeline/builder.d.ts +111 -0
  35. package/dist/pipeline/builder.d.ts.map +1 -0
  36. package/dist/pipeline/builder.js +197 -0
  37. package/dist/pipeline/builder.js.map +1 -0
  38. package/dist/pipeline/index.d.ts +3 -0
  39. package/dist/pipeline/index.d.ts.map +1 -0
  40. package/dist/pipeline/index.js +2 -0
  41. package/dist/pipeline/index.js.map +1 -0
  42. package/dist/queue/events.d.ts +13 -0
  43. package/dist/queue/events.d.ts.map +1 -0
  44. package/dist/queue/events.js +109 -0
  45. package/dist/queue/events.js.map +1 -0
  46. package/dist/queue/index.d.ts +7 -0
  47. package/dist/queue/index.d.ts.map +1 -0
  48. package/dist/queue/index.js +4 -0
  49. package/dist/queue/index.js.map +1 -0
  50. package/dist/queue/queue.d.ts +13 -0
  51. package/dist/queue/queue.d.ts.map +1 -0
  52. package/dist/queue/queue.js +37 -0
  53. package/dist/queue/queue.js.map +1 -0
  54. package/dist/queue/worker.d.ts +15 -0
  55. package/dist/queue/worker.d.ts.map +1 -0
  56. package/dist/queue/worker.js +20 -0
  57. package/dist/queue/worker.js.map +1 -0
  58. package/examples/README.md +86 -0
  59. package/examples/_setup.js +143 -0
  60. package/examples/cache.js +111 -0
  61. package/examples/pipeline.js +161 -0
  62. package/examples/pubsub.js +101 -0
  63. package/examples/queue-worker.js +189 -0
  64. package/examples/session.js +145 -0
  65. package/examples/standalone.js +58 -0
  66. package/package.json +100 -4
  67. package/src/client/check.ts +69 -0
  68. package/src/client/client.ts +45 -0
  69. package/src/client/events.ts +101 -0
  70. package/src/client/index.ts +5 -0
  71. package/src/client/shutdown.ts +97 -0
  72. package/src/index.ts +4 -0
  73. package/src/logger.ts +159 -0
  74. package/src/pipeline/builder.ts +307 -0
  75. package/src/pipeline/index.ts +7 -0
  76. package/src/queue/events.ts +158 -0
  77. package/src/queue/index.ts +6 -0
  78. package/src/queue/queue.ts +60 -0
  79. package/src/queue/worker.ts +44 -0
package/src/logger.ts ADDED
@@ -0,0 +1,159 @@
1
+ const LOGGER_KEYS = ["error", "warn", "info", "debug"] as const;
2
+
3
+ export interface Logger {
4
+ error(message: unknown, extra?: unknown): void;
5
+ warn(message: unknown, extra?: unknown): void;
6
+ info(message: unknown, extra?: unknown): void;
7
+ debug(message: unknown, extra?: unknown): void;
8
+ child?(bindings?: Record<string, unknown>): Logger;
9
+ }
10
+
11
+ export const silentLogger: Logger = Object.freeze({
12
+ error() {},
13
+ warn() {},
14
+ info() {},
15
+ debug() {},
16
+ });
17
+
18
+ export const consoleLogger: Logger = Object.freeze({
19
+ error(message: unknown, extra?: unknown) {
20
+ write(console.error, message, extra);
21
+ },
22
+ warn(message: unknown, extra?: unknown) {
23
+ write(console.warn, message, extra);
24
+ },
25
+ info(message: unknown, extra?: unknown) {
26
+ write(console.info, message, extra);
27
+ },
28
+ debug(message: unknown, extra?: unknown) {
29
+ write(console.debug, message, extra);
30
+ },
31
+ });
32
+
33
+ /**
34
+ * Does this logger take `(bindings, message)` instead of `(message, extra)`?
35
+ *
36
+ * pino and its drop-in forks accept `log.info(bindings, message)`, where the
37
+ * first argument is merged into the record and the second is the message.
38
+ * Those signatures are indistinguishable at the call site, so a caller has to
39
+ * be detected rather than chosen.
40
+ *
41
+ * `child()` was the first thing tried here and it is not a usable signal: this
42
+ * package's own `Logger` interface declares `child?()`, so every conforming
43
+ * logger is allowed to have one. Swapping arguments for those loggers silently
44
+ * moved the message into the bindings slot of every record.
45
+ *
46
+ * A pino instance exposes both `bindings()` and the `levels` map. `child()`
47
+ * loggers inherit `bindings()` from the same prototype, so a caller that hands
48
+ * us `logger.child({ service: "redis" })` is still recognised. Custom loggers
49
+ * have neither.
50
+ */
51
+ function isBindingsFirst(logger: Logger): boolean {
52
+ const candidate = logger as unknown as Record<string, unknown>;
53
+
54
+ return (
55
+ typeof candidate.bindings === "function" &&
56
+ typeof candidate.levels === "object" &&
57
+ candidate.levels !== null
58
+ );
59
+ }
60
+
61
+ export function isLogger(value: unknown): value is Logger {
62
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
63
+ return false;
64
+ }
65
+
66
+ const hasLogFn = LOGGER_KEYS.some(
67
+ (key) => typeof (value as Record<string, unknown>)[key] === "function",
68
+ );
69
+ if (!hasLogFn) {
70
+ return false;
71
+ }
72
+
73
+ return true;
74
+ }
75
+
76
+ /**
77
+ * Complete a caller-supplied logger, or keep "no logger" as no logging.
78
+ *
79
+ * `createLogger` is the public entry point and defaults to the console, which
80
+ * is right when someone asks for a logger. Internal call sites are different:
81
+ * every `createClient(url)` in the wild passes nothing and expects silence, so
82
+ * they need a logger that is either complete or absent. Without this, a logger
83
+ * that implements only some levels throws `logger?.info is not a function` from
84
+ * a connection event, where it is least likely to be caught.
85
+ */
86
+ export function normalizeLogger(logger?: Logger | null): Logger | undefined {
87
+ if (!logger) {
88
+ return undefined;
89
+ }
90
+
91
+ return createLogger(logger);
92
+ }
93
+
94
+ export function createLogger(input?: Logger | null): Logger {
95
+ if (!input) {
96
+ return consoleLogger;
97
+ }
98
+
99
+ if (input === silentLogger || input === consoleLogger) {
100
+ return input;
101
+ }
102
+
103
+ return {
104
+ error(message: unknown, extra?: unknown) {
105
+ invoke(input, "error", message, extra);
106
+ },
107
+ warn(message: unknown, extra?: unknown) {
108
+ invoke(input, "warn", message, extra);
109
+ },
110
+ info(message: unknown, extra?: unknown) {
111
+ invoke(input, "info", message, extra);
112
+ },
113
+ debug(message: unknown, extra?: unknown) {
114
+ invoke(input, "debug", message, extra);
115
+ },
116
+ };
117
+ }
118
+
119
+ function write(
120
+ fn: (...args: unknown[]) => void,
121
+ message: unknown,
122
+ extra?: unknown,
123
+ ): void {
124
+ if (extra === undefined) {
125
+ fn(message);
126
+ return;
127
+ }
128
+ fn(message, extra);
129
+ }
130
+
131
+ function invoke(
132
+ logger: Logger,
133
+ level: "error" | "warn" | "info" | "debug",
134
+ message: unknown,
135
+ extra?: unknown,
136
+ ): void {
137
+ const fn =
138
+ typeof logger[level] === "function"
139
+ ? logger[level]
140
+ : typeof logger.info === "function"
141
+ ? logger.info
142
+ : undefined;
143
+
144
+ if (typeof fn !== "function") {
145
+ return;
146
+ }
147
+
148
+ if (extra === undefined) {
149
+ fn.call(logger, message);
150
+ return;
151
+ }
152
+
153
+ if (isBindingsFirst(logger)) {
154
+ fn.call(logger, extra, message);
155
+ return;
156
+ }
157
+
158
+ fn.call(logger, message, extra);
159
+ }
@@ -0,0 +1,307 @@
1
+ import { type Redis as RedisClient, type ChainableCommander } from "ioredis";
2
+ import { normalizeLogger, type Logger } from "../logger.js";
3
+ import { redactError } from "../client/events.js";
4
+
5
+ /**
6
+ * One command in a pipeline, plus a label for reporting failures.
7
+ *
8
+ * The label is what makes a failure identifiable. A pipeline result is a
9
+ * positional array, so a bare `results[7]` tells an operator nothing about which
10
+ * of several hundred commands went wrong; ioredis attaches the command itself to
11
+ * the error, but only for the commands it can describe, and the label is what
12
+ * this package logs.
13
+ */
14
+ export interface PipelineStep {
15
+ label: string;
16
+ run: (pipeline: ChainableCommander) => void;
17
+ }
18
+
19
+ export interface PipelineOptions {
20
+ /**
21
+ * Milliseconds to wait for EXEC before giving up.
22
+ *
23
+ * ioredis queues commands while reconnecting and never flushes them until the
24
+ * connection is back, so EXEC against an unreachable server does not reject —
25
+ * it never settles. Without a bound a request handler awaiting a pipeline
26
+ * hangs for the whole outage and takes the process's request capacity with it.
27
+ */
28
+ timeout?: number;
29
+ logger?: Logger;
30
+ /**
31
+ * Reject if any single command in the batch failed.
32
+ *
33
+ * Off by default. EXEC resolves even when individual commands fail: each
34
+ * failure arrives as a `[error, null]` tuple in the results, and a pipeline
35
+ * that silently drops one of its writes looks exactly like a pipeline that
36
+ * succeeded. Turn this on where a partial batch is not acceptable.
37
+ */
38
+ throwOnError?: boolean;
39
+ }
40
+
41
+ /** Result of one command. Mirrors ioredis's own tuple, with the error redacted. */
42
+ export interface PipelineStepResult {
43
+ label: string;
44
+ value: unknown;
45
+ error?: Error;
46
+ }
47
+
48
+ export interface PipelineResult {
49
+ results: PipelineStepResult[];
50
+ /** Wall-clock duration of EXEC, in milliseconds. */
51
+ durationMs: number;
52
+ /** How many commands failed. Zero when all succeeded. */
53
+ failed: number;
54
+ }
55
+
56
+ const DEFAULT_TIMEOUT_MS = 5000;
57
+
58
+ /** Whether a value is promise-like, i.e. something a caller forgot to await. */
59
+ function isThenable(value: unknown): boolean {
60
+ return (
61
+ typeof value === "object" &&
62
+ value !== null &&
63
+ typeof (value as { then?: unknown }).then === "function"
64
+ );
65
+ }
66
+
67
+ export class PipelineTimeoutError extends Error {
68
+ readonly steps: number;
69
+
70
+ constructor(timeout: number, steps: number) {
71
+ super(
72
+ `Redis pipeline of ${steps} command${steps === 1 ? "" : "s"} did not complete within ${timeout}ms`,
73
+ );
74
+ this.name = "PipelineTimeoutError";
75
+ this.steps = steps;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Thrown when `throwOnError` is set and a command failed.
81
+ *
82
+ * Carries the per-step results so the caller can see which command broke
83
+ * without re-running the batch. The `message` lists labels only — ioredis
84
+ * attaches command arguments to its errors, and a pipeline that batched an
85
+ * `AUTH` would otherwise put the password in an exception message.
86
+ */
87
+ export class PipelineCommandError extends Error {
88
+ readonly results: PipelineStepResult[];
89
+
90
+ constructor(results: PipelineStepResult[]) {
91
+ const failed = results.filter((result) => result.error);
92
+ super(
93
+ `Redis pipeline: ${failed.length} of ${results.length} commands failed (${failed
94
+ .map((result) => result.label)
95
+ .join(", ")})`,
96
+ );
97
+ this.name = "PipelineCommandError";
98
+ this.results = results;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Thrown when a step does not queue exactly one command.
104
+ *
105
+ * `PipelineStep.run` is typed as returning `void`, and TypeScript allows any
106
+ * value to be returned from a `void` signature — so a step that queues two
107
+ * commands, or an `async` step whose command is only queued after `runPipeline`
108
+ * has already called `exec`, compiles without complaint. Both shift the
109
+ * positional pairing between `steps` and the result tuples, which is the one
110
+ * thing the label exists to prevent, so the batch is rejected rather than
111
+ * returned with a value against the wrong label.
112
+ */
113
+ export class PipelineStepError extends Error {
114
+ readonly label: string;
115
+ readonly queued: number;
116
+
117
+ constructor(label: string, queued: number) {
118
+ super(
119
+ `Redis pipeline step "${label}" queued ${queued} command${
120
+ queued === 1 ? "" : "s"
121
+ }, expected exactly 1`,
122
+ );
123
+ this.name = "PipelineStepError";
124
+ this.label = label;
125
+ this.queued = queued;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Run a batch of commands in one round trip.
131
+ *
132
+ * Wraps `client.pipeline()` rather than reimplementing it. The value is in the
133
+ * four sharp edges this closes, all of which are silent in ioredis itself:
134
+ *
135
+ * 1. **A failed command does not fail the pipeline.** EXEC resolves with a
136
+ * `[error, null]` tuple for the command that failed. Code that reads
137
+ * `results.map(([, value]) => value)` gets `null` and carries on as if the
138
+ * write landed. Every result here carries an explicit `error` field.
139
+ * 2. **EXEC can hang forever.** ioredis parks queued commands while
140
+ * reconnecting. `timeout` bounds that.
141
+ * 3. **Command errors carry their arguments.** Redacted via `redactError`
142
+ * before they reach a logger or an exception — on both the per-command
143
+ * results and a rejected `exec()`.
144
+ * 4. **Results are positional.** Each step must queue exactly one command, or
145
+ * every later label is paired with the wrong value. A step that does not
146
+ * raises `PipelineStepError`.
147
+ */
148
+ export async function runPipeline(
149
+ client: RedisClient,
150
+ steps: PipelineStep[],
151
+ options: PipelineOptions = {},
152
+ ): Promise<PipelineResult> {
153
+ const logger = normalizeLogger(options.logger);
154
+ const requested = options.timeout ?? DEFAULT_TIMEOUT_MS;
155
+ // setTimeout coerces a negative or NaN budget to 1ms and prints a warning for
156
+ // each; treat any invalid value as "no bound configured" instead.
157
+ const timeout =
158
+ Number.isFinite(requested) && requested > 0
159
+ ? requested
160
+ : DEFAULT_TIMEOUT_MS;
161
+
162
+ if (steps.length === 0) {
163
+ // No round trip is needed, and building a pipeline just to exec an empty
164
+ // one is a wasted allocation.
165
+ return { results: [], durationMs: 0, failed: 0 };
166
+ }
167
+
168
+ const pipeline = client.pipeline();
169
+
170
+ for (const step of steps) {
171
+ // Results are paired with steps by position, so the queue has to grow by
172
+ // exactly one per step. `length` is ioredis's own queue length, and it is
173
+ // read before and after rather than counted here so the check is against
174
+ // what was actually queued, not against what `run` appears to have done.
175
+ const before = pipeline.length;
176
+
177
+ let returned: unknown;
178
+
179
+ try {
180
+ returned = step.run(pipeline);
181
+ } catch (error) {
182
+ // A throwing `run` means the command was never queued, so the batch is
183
+ // already malformed. Fail the whole pipeline: silently dropping the
184
+ // command would shift every later result by one and return the wrong
185
+ // value against the wrong label.
186
+ logger?.error(`Redis pipeline step failed to queue: ${step.label}`, {
187
+ err: redactError(error),
188
+ });
189
+
190
+ throw error;
191
+ }
192
+
193
+ const queued = pipeline.length - before;
194
+
195
+ if (isThenable(returned)) {
196
+ // An `async` step is invisible to the count below: it queues nothing
197
+ // synchronously, so `exec()` runs first and the command it was going to
198
+ // queue lands in a pipeline that has already been sent. TypeScript
199
+ // permits returning a value from a `void`-typed signature, so this is
200
+ // not a compile error either.
201
+ //
202
+ // The step's promise is abandoned here, so its eventual rejection is
203
+ // nobody's to handle — which on Node 20 is a process-level crash that
204
+ // would mask the error naming the actual fault. Swallow it; the throw
205
+ // below is the real diagnosis.
206
+ void Promise.resolve(returned).catch(() => undefined);
207
+
208
+ logger?.error(
209
+ `Redis pipeline step returned a promise instead of queuing: ${step.label}`,
210
+ );
211
+
212
+ throw new PipelineStepError(step.label, queued);
213
+ }
214
+
215
+ if (queued !== 1) {
216
+ logger?.error(
217
+ `Redis pipeline step queued ${queued} commands: ${step.label}`,
218
+ );
219
+
220
+ throw new PipelineStepError(step.label, queued);
221
+ }
222
+ }
223
+
224
+ const started = performance.now();
225
+
226
+ let timer: NodeJS.Timeout | undefined;
227
+ const expiry = new Promise<"timeout">((resolve) => {
228
+ timer = setTimeout(() => resolve("timeout"), timeout);
229
+ });
230
+
231
+ let raw: Array<[Error | null, unknown]>;
232
+
233
+ try {
234
+ const outcome = await Promise.race([pipeline.exec(), expiry]);
235
+
236
+ if (outcome === "timeout") {
237
+ throw new PipelineTimeoutError(timeout, steps.length);
238
+ }
239
+
240
+ raw = outcome as Array<[Error | null, unknown]>;
241
+ } catch (error) {
242
+ if (error instanceof PipelineTimeoutError) {
243
+ throw error;
244
+ }
245
+
246
+ // A rejected `exec()` is a connection-level failure, and it bypasses the
247
+ // per-tuple redaction below entirely. The module documents that errors are
248
+ // redacted, so the rejection goes through the same path rather than
249
+ // reaching the caller — and from there a logger — as ioredis built it.
250
+ throw redactError(error);
251
+ } finally {
252
+ // A pending timeout keeps the event loop alive. Always clear it, including
253
+ // on the timeout path where it has already fired.
254
+ clearTimeout(timer);
255
+ }
256
+
257
+ const results: PipelineStepResult[] = raw.map(([error, value], index) => {
258
+ const step = steps[index];
259
+
260
+ return {
261
+ label: step?.label ?? `#${index}`,
262
+ value,
263
+ // Redacted here rather than at each call site so a caller that logs a
264
+ // whole result array cannot leak credentials by accident.
265
+ ...(error ? { error: redactError(error) as Error } : {}),
266
+ };
267
+ });
268
+
269
+ const failed = results.filter((result) => result.error).length;
270
+ const durationMs = Math.round(performance.now() - started);
271
+
272
+ if (failed > 0) {
273
+ logger?.warn(
274
+ `Redis pipeline: ${failed} of ${results.length} commands failed`,
275
+ );
276
+ }
277
+
278
+ logger?.debug?.(
279
+ `Redis pipeline completed ${results.length} command${
280
+ results.length === 1 ? "" : "s"
281
+ } in ${durationMs}ms`,
282
+ );
283
+
284
+ if (failed > 0 && options.throwOnError) {
285
+ throw new PipelineCommandError(results);
286
+ }
287
+
288
+ return { results, durationMs, failed };
289
+ }
290
+
291
+ /**
292
+ * Values of the successful results, in order.
293
+ *
294
+ * Throws if any command failed, because the alternative is handing back a
295
+ * sparse array whose length matches the batch but whose contents silently
296
+ * include a failed write. Use `results` directly when a partial batch is
297
+ * expected and worth handling.
298
+ */
299
+ export function pipelineValues(results: PipelineStepResult[]): unknown[] {
300
+ const failed = results.find((result) => result.error);
301
+
302
+ if (failed) {
303
+ throw new PipelineCommandError(results);
304
+ }
305
+
306
+ return results.map((result) => result.value);
307
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./builder.js";
2
+ export type {
3
+ PipelineStep,
4
+ PipelineOptions,
5
+ PipelineResult,
6
+ PipelineStepResult,
7
+ } from "./builder.js";
@@ -0,0 +1,158 @@
1
+ import {
2
+ ConnectionClosedError,
3
+ QueueEvents,
4
+ type QueueEventsOptions,
5
+ } from "bullmq";
6
+ import { Queue } from "bullmq";
7
+ import type { Logger } from "../logger.js";
8
+ import type { Redis } from "ioredis";
9
+ import { normalizeLogger } from "../logger.js";
10
+ import { redactError } from "../client/events.js";
11
+
12
+ export interface QueueEventsConfig {
13
+ queue: Queue;
14
+ logger?: Logger;
15
+ prefix?: string;
16
+ connection?: QueueEventsOptions["connection"];
17
+ }
18
+
19
+ /**
20
+ * QueueEvents that can be closed after a failed startup.
21
+ *
22
+ * BullMQ's own `close()` awaits `this.client` before disconnecting, and that
23
+ * getter resolves to the connection's `initializing` promise. When the
24
+ * connection never became ready that promise has already rejected with
25
+ * "Connection is closed.", so `close()` throws before it reaches
26
+ * `connection.close()` and the duplicated ioredis client is left running its
27
+ * reconnect loop. Nothing else in the process can stop it, because the caller
28
+ * has no reference to the duplicate, so the process never exits.
29
+ *
30
+ * `Queue` does not have this problem: `RedisConnection.close()` handles
31
+ * `status === "initializing"` itself.
32
+ *
33
+ * Disconnecting the duplicate first is safe in both directions. When the
34
+ * connection is healthy, `disconnect()` ends it and the subsequent
35
+ * `connection.close()` sees a client already at `end` and skips its own quit.
36
+ */
37
+ class ManagedQueueEvents extends QueueEvents {
38
+ override async close(): Promise<void> {
39
+ const connection = this.connection as unknown as {
40
+ _client?: Redis;
41
+ close(force?: boolean): Promise<void>;
42
+ };
43
+
44
+ // A duplicate that is already gone does not need disconnecting, and
45
+ // `disconnect()` is a no-op at `end`.
46
+ connection._client?.disconnect();
47
+
48
+ try {
49
+ await super.close();
50
+ } catch (error) {
51
+ // `super.close()` propagates the rejection from the failed startup
52
+ // instead of reporting that the connection is now closed. The connection
53
+ // itself does know how to close from `initializing`, so drive it directly
54
+ // and only rethrow errors that are not about the connection being gone.
55
+ await connection.close(true).catch(() => undefined);
56
+
57
+ if (!isConnectionGone(error)) {
58
+ throw error;
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * True for the "this connection is already gone" family of errors.
66
+ *
67
+ * `ConnectionClosedError` is checked first and structurally, because BullMQ
68
+ * introduced it for exactly this reason — its own comment on the class says it
69
+ * exists so `isNotConnectionError` can "do a structural `instanceof` check
70
+ * rather than fragile message-substring matching". Matching the message cannot
71
+ * work here: only some of BullMQ's construction sites pass ioredis's
72
+ * `CONNECTION_CLOSED_ERROR_MSG`, and the others pass their own wording or no
73
+ * message at all, in which case the class default (`"Connection is closed"`,
74
+ * with no trailing period) applies. An exact string comparison therefore
75
+ * rethrows precisely the failures this function exists to absorb, and the
76
+ * caller gets an exception from teardown instead of a clean close.
77
+ *
78
+ * The string clauses stay as a fallback: they still cover a `bullmq` error that
79
+ * predates the class, and an error forwarded from another adapter. `instanceof`
80
+ * is identity-based, so a consumer with two copies of `bullmq` in one tree
81
+ * would miss the class check — the fallbacks catch the ioredis wording, and
82
+ * missing them is the safe direction to fail.
83
+ */
84
+ function isConnectionGone(error: unknown): boolean {
85
+ if (error instanceof ConnectionClosedError) {
86
+ return true;
87
+ }
88
+
89
+ if (!(error instanceof Error)) {
90
+ return false;
91
+ }
92
+
93
+ return (
94
+ error.message === "Connection is closed." ||
95
+ error.message.includes("ECONNREFUSED") ||
96
+ (error as NodeJS.ErrnoException).code === "ECONNREFUSED"
97
+ );
98
+ }
99
+
100
+ export function attachQueueEvents(config: QueueEventsConfig): QueueEvents {
101
+ const { queue, connection } = config;
102
+
103
+ // BullMQ swallows a throwing event listener and re-emits the failure as an
104
+ // "error" event, which then throws again and lands on console.error. A logger
105
+ // missing `info` would trigger that on every completed job.
106
+ const logger = normalizeLogger(config.logger);
107
+
108
+ // QueueEvents subscribes to a key derived from the prefix. Defaulting to a
109
+ // literal "queue" here silently dropped every event for a queue created
110
+ // with a custom prefix, so inherit the queue's own prefix instead.
111
+ const prefix = config.prefix ?? queue.opts.prefix ?? "queue";
112
+
113
+ const queueEvents = new ManagedQueueEvents(queue.name, {
114
+ prefix,
115
+ connection: connection ?? queue.opts.connection,
116
+ });
117
+
118
+ queueEvents.on(
119
+ "completed",
120
+ (
121
+ args: { jobId: string; returnvalue: string; prev?: string },
122
+ _id: string,
123
+ ) => {
124
+ logger?.info(`Job ${args.jobId} completed`);
125
+ },
126
+ );
127
+
128
+ queueEvents.on(
129
+ "failed",
130
+ (
131
+ args: { jobId: string; failedReason: string; prev?: string },
132
+ _id: string,
133
+ ) => {
134
+ logger?.error(`Job ${args.jobId} failed`, args.failedReason);
135
+ },
136
+ );
137
+
138
+ queueEvents.on(
139
+ "progress",
140
+ (args: { jobId: string; data: unknown }, _id: string) => {
141
+ logger?.info(`Job ${args.jobId} progress`, args.data);
142
+ },
143
+ );
144
+
145
+ queueEvents.on("error", (error: Error) => {
146
+ // QueueEvents duplicates the caller's client, so it authenticates with the
147
+ // same password and BullMQ re-emits any AUTH failure here. ioredis attaches
148
+ // the failing command to that error, and for AUTH its args are the password
149
+ // in plaintext — logging it as-is would write the credential to the app's
150
+ // log on every reconnect attempt against a misconfigured server.
151
+ logger?.error(`Queue "${queue.name}" events error`, redactError(error));
152
+ });
153
+
154
+ return queueEvents;
155
+ }
156
+
157
+ export type { QueueEventsOptions } from "bullmq";
158
+ export { QueueEvents } from "bullmq";
@@ -0,0 +1,6 @@
1
+ export * from "./queue.js";
2
+ export * from "./worker.js";
3
+ export * from "./events.js";
4
+ export type { QueueConfig } from "./queue.js";
5
+ export type { WorkerConfig } from "./worker.js";
6
+ export type { QueueEventsConfig } from "./events.js";
@@ -0,0 +1,60 @@
1
+ import { Queue, type QueueOptions, type JobsOptions } from "bullmq";
2
+ import { type Redis as RedisClient } from "ioredis";
3
+
4
+ export interface QueueConfig {
5
+ name: string;
6
+ connection: RedisClient;
7
+ prefix?: string;
8
+ defaultJobOptions?: JobsOptions;
9
+ settings?: QueueOptions["settings"];
10
+ }
11
+
12
+ export function createQueue(config: QueueConfig): Queue {
13
+ const {
14
+ name,
15
+ connection,
16
+ prefix = "queue",
17
+ defaultJobOptions,
18
+ settings,
19
+ ...options
20
+ } = config;
21
+
22
+ const defaults: JobsOptions = {
23
+ removeOnComplete: 100,
24
+ removeOnFail: 1000,
25
+ attempts: 3,
26
+ backoff: {
27
+ type: "exponential",
28
+ delay: 1000,
29
+ },
30
+ };
31
+
32
+ // Merged key by key rather than by spreading. A spread writes `undefined` for
33
+ // every key the caller left unset, which erases the default instead of falling
34
+ // through to it — and that is how a config built by spreading another object
35
+ // (`{ ...base, attempts: maybeUndefined }`) quietly loses the package default.
36
+ // `null` is a deliberate value rather than a missing one, so it passes
37
+ // through: `removeOnComplete: null` is how BullMQ is told to keep a job.
38
+ const merged: Record<string, unknown> = { ...defaults };
39
+
40
+ for (const [key, value] of Object.entries(defaultJobOptions ?? {})) {
41
+ if (value !== undefined) {
42
+ merged[key] = value;
43
+ }
44
+ }
45
+
46
+ const queueOptions: QueueOptions = {
47
+ prefix,
48
+ connection,
49
+ defaultJobOptions: merged as JobsOptions,
50
+ ...(settings === undefined ? {} : { settings }),
51
+ ...options,
52
+ };
53
+
54
+ const queue = new Queue(name, queueOptions);
55
+
56
+ return queue;
57
+ }
58
+
59
+ export type { QueueOptions, JobsOptions } from "bullmq";
60
+ export { Queue } from "bullmq";