@iskra-bun/worker-kit 0.1.0 → 0.3.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/src/index.ts CHANGED
@@ -1,19 +1,49 @@
1
1
  import type { Driver, App } from '@iskra-bun/core';
2
- import { Queue, Worker, type Job as BullJob } from 'bullmq';
2
+ import { Queue, QueueEvents, UnrecoverableError, Worker, type Job as BullJob } from 'bullmq';
3
3
  import { QueueError, JobError } from './errors';
4
- import type { WorkerManagerOptions, JobOptions, JobHandler } from './types';
4
+ import type {
5
+ WorkerManagerOptions,
6
+ JobOptions,
7
+ JobHandler,
8
+ RepeatSpec,
9
+ JobDescriptor,
10
+ DeadLetterPayload,
11
+ } from './types';
5
12
 
6
- export type { WorkerManagerOptions, JobOptions, JobHandler } from './types';
13
+ export type {
14
+ WorkerManagerOptions,
15
+ JobOptions,
16
+ JobHandler,
17
+ RepeatSpec,
18
+ JobDescriptor,
19
+ DeadLetterPayload,
20
+ } from './types';
7
21
  export * from './errors';
8
22
 
23
+ /**
24
+ * The finished jobs kept in Redis, payload included, unless a job or
25
+ * `defaultJobOptions` says otherwise: BullMQ's own default is to keep every
26
+ * completed and failed job forever.
27
+ */
28
+ const RETENTION = {
29
+ removeOnComplete: { count: 1000 },
30
+ removeOnFail: { age: 7 * 24 * 60 * 60, count: 5000 },
31
+ };
32
+
9
33
  export class WorkerManager implements Driver {
10
34
  name = 'WorkerManager';
11
35
  private app: App | null = null;
12
- private handlers: Map<string, JobHandler> = new Map();
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ private handlers: Map<string, JobHandler<any, any>> = new Map();
13
38
  private queue: Queue | null = null;
14
39
  private worker: Worker | null = null;
40
+ private queueEvents: QueueEvents | null = null;
41
+ private stopped = false;
15
42
  private options: WorkerManagerOptions;
16
43
 
44
+ /** Tope de tamaño (bytes) del payload serializado de un job. */
45
+ private static readonly MAX_PAYLOAD_BYTES = 1024 * 1024; // 1 MB
46
+
17
47
  constructor(options: WorkerManagerOptions) {
18
48
  this.options = options;
19
49
  }
@@ -26,7 +56,7 @@ export class WorkerManager implements Driver {
26
56
  try {
27
57
  this.queue = new Queue(this.options.queueName || 'iskra-jobs', {
28
58
  connection,
29
- defaultJobOptions: this.mapJobOptions(this.options.defaultJobOptions),
59
+ defaultJobOptions: { ...RETENTION, ...this.mapJobOptions(this.options.defaultJobOptions) },
30
60
  });
31
61
  } catch (err) {
32
62
  throw new QueueError('Failed to initialize BullMQ queue', {
@@ -34,32 +64,71 @@ export class WorkerManager implements Driver {
34
64
  context: { queueName: this.options.queueName || 'iskra-jobs' },
35
65
  });
36
66
  }
67
+ // Without an 'error' listener BullMQ prints connection errors to the
68
+ // console; route them through the app logger instead.
69
+ this.queue.on('error', (err) => this.logConnectionError('queue', err));
37
70
  }
38
71
 
39
72
  /**
40
- * Registra un handler para un tipo de job.
73
+ * Registra un handler para un tipo de job. El handler puede devolver un
74
+ * valor `R` que queda disponible como resultado del job.
41
75
  */
42
- register(jobName: string, handler: JobHandler) {
76
+ register<T = unknown, R = void>(jobName: string, handler: JobHandler<T, R>) {
43
77
  this.handlers.set(jobName, handler);
44
78
  return this;
45
79
  }
46
80
 
47
81
  /**
48
- * Encola un job para ser procesado.
82
+ * Encola un job para ser procesado. Devuelve un descriptor que, además de
83
+ * los datos del job, expone `result()` para esperar el valor de retorno del
84
+ * handler.
49
85
  */
50
- async enqueue(name: string, data: any, opts?: JobOptions) {
86
+ async enqueue<T = unknown, R = unknown>(name: string, data: T, opts?: JobOptions): Promise<JobDescriptor<T, R>> {
51
87
  if (!this.queue) {
52
88
  throw new QueueError('Queue not initialized. Did you call init()?', {
53
89
  context: { jobName: name },
54
90
  });
55
91
  }
56
92
 
93
+ this.validateEnqueue(name, data, opts);
94
+
57
95
  const job = await this.queue.add(name, data, this.mapJobOptions(opts));
58
96
  this.app?.logger.debug({ jobId: job.id, jobName: name }, 'Job enqueued');
59
- return { id: job.id!, name, data };
97
+ return this.buildDescriptor<T, R>(job, name, data);
98
+ }
99
+
100
+ /**
101
+ * Programa un job repetible (cron o intervalo). Conveniencia sobre
102
+ * `enqueue` con la opción `repeat` ya configurada.
103
+ *
104
+ * @param repeat patrón cron (string) o `{ every: ms }`/`{ pattern: cron }`.
105
+ */
106
+ async schedule<T = unknown, R = unknown>(
107
+ name: string,
108
+ data: T,
109
+ repeat: RepeatSpec,
110
+ opts?: JobOptions,
111
+ ): Promise<JobDescriptor<T, R>> {
112
+ return this.enqueue<T, R>(name, data, { ...opts, repeat });
113
+ }
114
+
115
+ /**
116
+ * `consume: false`, or `concurrency: 0` (which used to fall back to 1, so
117
+ * a service meant to only enqueue consumed jobs it had no handler for).
118
+ */
119
+ private get producerOnly(): boolean {
120
+ return this.options.consume === false || this.options.concurrency === 0;
60
121
  }
61
122
 
62
123
  async start() {
124
+ if (this.producerOnly) {
125
+ this.app?.logger.info(
126
+ { queue: this.options.queueName || 'iskra-jobs' },
127
+ 'WorkerManager started in producer-only mode (consume: false / concurrency: 0)',
128
+ );
129
+ return;
130
+ }
131
+
63
132
  const connection = this.parseConnection();
64
133
 
65
134
  this.worker = new Worker(
@@ -67,12 +136,15 @@ export class WorkerManager implements Driver {
67
136
  async (job: BullJob) => {
68
137
  const handler = this.handlers.get(job.name);
69
138
  if (!handler) {
70
- this.app?.logger.warn({ jobName: job.name, jobId: job.id }, 'No handler registered for job');
71
- return;
139
+ // Returning would mark the job completed and silently drop
140
+ // it. Fail it permanently instead (no retries), so it stays
141
+ // in the failed set and reaches dead-letter handling.
142
+ this.app?.logger.error({ jobName: job.name, jobId: job.id }, 'No handler registered for job');
143
+ throw new UnrecoverableError(`No handler registered for job "${job.name}"`);
72
144
  }
73
145
 
74
146
  try {
75
- await handler({
147
+ return await handler({
76
148
  id: job.id!,
77
149
  name: job.name,
78
150
  data: job.data,
@@ -83,7 +155,9 @@ export class WorkerManager implements Driver {
83
155
  cause: err instanceof Error ? err : new Error(String(err)),
84
156
  context: { jobId: job.id, jobName: job.name, attemptsMade: job.attemptsMade },
85
157
  });
86
- this.app?.logger.error({ err: jobErr }, jobErr.message);
158
+ // Debug only: BullMQ then emits `failed`, and onFailed logs
159
+ // the failure once at error level.
160
+ this.app?.logger.debug({ err: jobErr }, jobErr.message);
87
161
  throw err; // Re-throw para que BullMQ maneje el retry
88
162
  }
89
163
  },
@@ -98,51 +172,281 @@ export class WorkerManager implements Driver {
98
172
  });
99
173
 
100
174
  this.worker.on('failed', (job, err) => {
101
- this.app?.logger.error({ jobId: job?.id, jobName: job?.name, err }, 'Job failed');
175
+ this.onFailed(job, err);
102
176
  });
103
177
 
104
- this.app?.logger.info({
105
- queue: this.options.queueName || 'iskra-jobs',
106
- concurrency: this.options.concurrency || 1,
107
- }, 'WorkerManager started');
178
+ this.worker.on('error', (err) => this.logConnectionError('worker', err));
179
+
180
+ this.app?.logger.info(
181
+ {
182
+ queue: this.options.queueName || 'iskra-jobs',
183
+ concurrency: this.options.concurrency || 1,
184
+ },
185
+ 'WorkerManager started',
186
+ );
108
187
  }
109
188
 
110
189
  async stop() {
111
- const closePromises: Promise<void>[] = [];
190
+ // Mark as stopped first so any in-flight result()/getQueueEvents() call
191
+ // throws instead of lazily opening a fresh, never-closed QueueEvents.
192
+ this.stopped = true;
112
193
 
194
+ // Close the worker first (without force) so BullMQ waits for any
195
+ // in-flight job to finish before tearing down its Redis connections.
196
+ // Only then close the queue — closing them concurrently can cut the
197
+ // queue connection out from under a still-draining worker.
113
198
  if (this.worker) {
114
- closePromises.push(this.worker.close());
199
+ try {
200
+ await this.worker.close();
201
+ } catch (err) {
202
+ this.app?.logger.error(
203
+ { err: err instanceof Error ? err : new Error(String(err)) },
204
+ 'WorkerManager: error while closing worker',
205
+ );
206
+ }
115
207
  }
208
+
209
+ if (this.queueEvents) {
210
+ try {
211
+ await this.queueEvents.close();
212
+ } catch (err) {
213
+ this.app?.logger.error(
214
+ { err: err instanceof Error ? err : new Error(String(err)) },
215
+ 'WorkerManager: error while closing queue events',
216
+ );
217
+ }
218
+ }
219
+
116
220
  if (this.queue) {
117
- closePromises.push(this.queue.close());
221
+ try {
222
+ await this.queue.close();
223
+ } catch (err) {
224
+ this.app?.logger.error(
225
+ { err: err instanceof Error ? err : new Error(String(err)) },
226
+ 'WorkerManager: error while closing queue',
227
+ );
228
+ }
118
229
  }
119
230
 
120
- await Promise.all(closePromises);
121
231
  this.app?.logger.info('WorkerManager stopped');
122
232
  }
123
233
 
234
+ private logConnectionError(source: string, err: Error) {
235
+ this.app?.logger.error({ err, source }, 'WorkerManager: BullMQ connection error');
236
+ }
237
+
238
+ /**
239
+ * Turns a redis:// or rediss:// URL into ioredis options, keeping the ACL
240
+ * username, the percent-decoded password, the db index and TLS (rediss).
241
+ */
124
242
  private parseConnection() {
125
243
  if (typeof this.options.connection === 'string') {
126
244
  const url = new URL(this.options.connection);
127
245
  return {
128
- host: url.hostname,
246
+ // URL keeps the brackets of an IPv6 host ("[::1]"); ioredis wants the bare address.
247
+ host: url.hostname.replace(/^\[(.*)\]$/, '$1'),
129
248
  port: Number(url.port) || 6379,
130
- password: url.password || undefined,
249
+ username: url.username ? decodeURIComponent(url.username) : undefined,
250
+ password: url.password ? decodeURIComponent(url.password) : undefined,
131
251
  db: url.pathname ? Number(url.pathname.slice(1)) || 0 : 0,
252
+ ...(url.protocol === 'rediss:' ? { tls: {} } : {}),
132
253
  };
133
254
  }
134
255
  return this.options.connection;
135
256
  }
136
257
 
258
+ /**
259
+ * Only the options that were given: BullMQ merges `{ ...defaultJobOptions,
260
+ * ...opts }`, so an explicit `undefined` erased the queue default (a job
261
+ * enqueued with just `{ priority }`, and every scheduled job, lost its
262
+ * attempts, backoff and removeOn* settings).
263
+ */
137
264
  private mapJobOptions(opts?: JobOptions) {
138
265
  if (!opts) return undefined;
266
+ const mapped: Record<string, unknown> = {};
267
+ for (const key of ['attempts', 'delay', 'priority', 'backoff', 'removeOnComplete', 'removeOnFail'] as const) {
268
+ if (opts[key] !== undefined) mapped[key] = opts[key];
269
+ }
270
+ if (opts.repeat !== undefined) {
271
+ mapped.repeat = this.mapRepeat(opts.repeat);
272
+ }
273
+ return mapped;
274
+ }
275
+
276
+ /**
277
+ * Normaliza una RepeatSpec a la forma `repeat` de BullMQ:
278
+ * - string → `{ pattern: cron }`
279
+ * - `{ every }` / `{ pattern }` → se reenvían tal cual.
280
+ */
281
+ private mapRepeat(repeat: RepeatSpec) {
282
+ if (typeof repeat === 'string') {
283
+ return { pattern: repeat };
284
+ }
285
+ return { ...repeat };
286
+ }
287
+
288
+ /**
289
+ * Valida la entrada de `enqueue` ANTES de tocar Redis, para evitar que
290
+ * entrada no confiable inunde la queue, almacene payloads gigantes o
291
+ * programe repeticiones malformadas. Lanza `QueueError` ante cualquier
292
+ * problema; no muta nada.
293
+ */
294
+ private validateEnqueue(name: string, data: unknown, opts?: JobOptions) {
295
+ // A consuming instance only accepts jobs it can process itself; a
296
+ // producer-only instance (consume: false) enqueues for other workers.
297
+ if (!this.producerOnly && !this.handlers.has(name)) {
298
+ throw new QueueError(`No handler registered for job "${name}"`, {
299
+ context: { jobName: name },
300
+ });
301
+ }
302
+
303
+ this.validatePayloadSize(name, data);
304
+
305
+ if (opts?.repeat !== undefined) {
306
+ this.validateRepeat(name, opts.repeat);
307
+ }
308
+ }
309
+
310
+ /** Rechaza payloads cuya serialización JSON excede el tope configurado. */
311
+ private validatePayloadSize(name: string, data: unknown) {
312
+ let serialized: string;
313
+ try {
314
+ serialized = JSON.stringify(data ?? null);
315
+ } catch (err) {
316
+ throw new QueueError(`Job "${name}" data is not serializable`, {
317
+ cause: err instanceof Error ? err : new Error(String(err)),
318
+ context: { jobName: name },
319
+ });
320
+ }
321
+
322
+ const size = Buffer.byteLength(serialized, 'utf8');
323
+ if (size > WorkerManager.MAX_PAYLOAD_BYTES) {
324
+ throw new QueueError(
325
+ `Job "${name}" payload too large: ${size} bytes (max ${WorkerManager.MAX_PAYLOAD_BYTES})`,
326
+ { context: { jobName: name, size, max: WorkerManager.MAX_PAYLOAD_BYTES } },
327
+ );
328
+ }
329
+ }
330
+
331
+ /** Rechaza specs de repetición vacías, intervalos no positivos o crons en blanco. */
332
+ private validateRepeat(name: string, repeat: RepeatSpec) {
333
+ if (typeof repeat === 'string') {
334
+ if (repeat.trim().length === 0) {
335
+ throw new QueueError(`Job "${name}" has an empty cron repeat pattern`, {
336
+ context: { jobName: name },
337
+ });
338
+ }
339
+ return;
340
+ }
341
+
342
+ const hasEvery = 'every' in repeat;
343
+ const hasPattern = 'pattern' in repeat;
344
+ if (!hasEvery && !hasPattern) {
345
+ throw new QueueError(`Job "${name}" repeat spec must define "every" or "pattern"`, {
346
+ context: { jobName: name },
347
+ });
348
+ }
349
+
350
+ if (hasEvery && !(typeof repeat.every === 'number' && repeat.every > 0)) {
351
+ throw new QueueError(`Job "${name}" repeat "every" must be a positive number`, {
352
+ context: { jobName: name, every: repeat.every },
353
+ });
354
+ }
355
+
356
+ if (hasPattern && (typeof repeat.pattern !== 'string' || repeat.pattern.trim().length === 0)) {
357
+ throw new QueueError(`Job "${name}" repeat "pattern" must be a non-empty cron string`, {
358
+ context: { jobName: name },
359
+ });
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Maneja el evento `failed` del worker. Loggea el fallo y, si el job agotó
365
+ * todos sus reintentos y `deadLetter` está activado, emite
366
+ * `worker:dead-letter` en el bus de eventos de la App.
367
+ */
368
+ private onFailed(job: BullJob | undefined, err: Error) {
369
+ this.app?.logger.error(
370
+ { jobId: job?.id, jobName: job?.name, attemptsMade: job?.attemptsMade, err },
371
+ 'Job failed',
372
+ );
373
+
374
+ if (!this.options.deadLetter || !job) return;
375
+
376
+ // BullMQ default attempts is 1 when unspecified.
377
+ //
378
+ // Assumed BullMQ `attemptsMade` semantics at the `failed` event: on a
379
+ // job's TERMINAL failure (all retries exhausted) BullMQ reports
380
+ // `attemptsMade == opts.attempts`, so `attemptsMade < maxAttempts`
381
+ // identifies a non-terminal failure with a retry still pending. This is
382
+ // verified against bullmq 5.78 (see test/dead-letter-attempts*.test.ts);
383
+ // a future bump that changes `attemptsMade` reporting will fail those
384
+ // tests loudly rather than silently skip dead-lettering.
385
+ const maxAttempts = job.opts?.attempts ?? 1;
386
+ // An UnrecoverableError (e.g. no handler) is terminal regardless of attempts left.
387
+ if (job.attemptsMade < maxAttempts && err?.name !== 'UnrecoverableError') return;
388
+
389
+ const payload: DeadLetterPayload = {
390
+ jobId: job.id,
391
+ name: job.name,
392
+ data: job.data,
393
+ failedReason: job.failedReason ?? err?.message,
394
+ attemptsMade: job.attemptsMade,
395
+ };
396
+ this.app?.events.emit('worker:dead-letter', payload);
397
+ this.app?.logger.warn(
398
+ { jobId: job.id, jobName: job.name, attemptsMade: job.attemptsMade },
399
+ 'Job routed to dead-letter',
400
+ );
401
+ }
402
+
403
+ /**
404
+ * Construye el descriptor de un job, incluyendo el helper `result()` que
405
+ * espera el valor de retorno del handler vía `job.waitUntilFinished`.
406
+ */
407
+ private buildDescriptor<T, R>(job: BullJob, name: string, data: T): JobDescriptor<T, R> {
139
408
  return {
140
- attempts: opts.attempts,
141
- delay: opts.delay,
142
- priority: opts.priority,
143
- backoff: opts.backoff,
144
- removeOnComplete: opts.removeOnComplete,
145
- removeOnFail: opts.removeOnFail,
409
+ id: job.id!,
410
+ name,
411
+ data,
412
+ // async so a post-stop getQueueEvents() throw surfaces as a rejected
413
+ // promise rather than a synchronous throw.
414
+ result: async (ttlMs?: number): Promise<R> => {
415
+ const queueEvents = this.getQueueEvents();
416
+ return job.waitUntilFinished(queueEvents, ttlMs) as Promise<R>;
417
+ },
146
418
  };
147
419
  }
420
+
421
+ /**
422
+ * Devuelve (creando perezosamente) una instancia compartida de QueueEvents
423
+ * usada para esperar resultados de jobs.
424
+ */
425
+ private getQueueEvents(): QueueEvents {
426
+ if (this.stopped) {
427
+ throw new QueueError('WorkerManager is stopped; cannot open QueueEvents', {
428
+ context: { queueName: this.options.queueName || 'iskra-jobs' },
429
+ });
430
+ }
431
+ if (!this.queueEvents) {
432
+ try {
433
+ this.queueEvents = new QueueEvents(this.options.queueName || 'iskra-jobs', {
434
+ connection: this.parseConnection(),
435
+ });
436
+ } catch (err) {
437
+ throw new QueueError('Failed to initialize BullMQ QueueEvents', {
438
+ cause: err instanceof Error ? err : new Error(String(err)),
439
+ context: { queueName: this.options.queueName || 'iskra-jobs' },
440
+ });
441
+ }
442
+ }
443
+ return this.queueEvents;
444
+ }
445
+ }
446
+
447
+ // A job that failed for good (see WorkerManager's dead-letter handling).
448
+ declare module '@iskra-bun/core' {
449
+ interface AppEvents {
450
+ 'worker:dead-letter': DeadLetterPayload;
451
+ }
148
452
  }
package/src/types.ts CHANGED
@@ -1,14 +1,38 @@
1
+ import type { KeepJobs } from 'bullmq';
2
+
1
3
  export interface WorkerManagerOptions {
2
- /** URL de conexión a Redis (ej: 'redis://localhost:6379') */
3
- connection: string | { host: string; port: number; password?: string; db?: number };
4
- /** Cantidad de jobs que se procesan en paralelo (default: 1) */
4
+ /** URL de conexión a Redis (ej: 'redis://user:pass@localhost:6379/0'; `rediss://` activa TLS) */
5
+ connection:
6
+ | string
7
+ | { host: string; port: number; username?: string; password?: string; db?: number; tls?: object };
8
+ /**
9
+ * `false` = solo productor: `start()` no crea un Worker y `enqueue` acepta
10
+ * jobs sin handler local (los procesa otro proceso). Default: true.
11
+ */
12
+ consume?: boolean;
13
+ /** Cantidad de jobs que se procesan en paralelo (default: 1). `0` = solo productor, como `consume: false`. */
5
14
  concurrency?: number;
6
15
  /** Nombre de la queue en Redis (default: 'iskra-jobs') */
7
16
  queueName?: string;
8
17
  /** Opciones por defecto para cada job */
9
18
  defaultJobOptions?: JobOptions;
19
+ /**
20
+ * Activa el ruteo a dead-letter: cuando un job agota todos sus reintentos
21
+ * se emite el evento `worker:dead-letter` en el bus de eventos de la App.
22
+ * Opt-in para no cambiar el comportamiento existente (default: false).
23
+ */
24
+ deadLetter?: boolean;
10
25
  }
11
26
 
27
+ /**
28
+ * Especificación de repetición para jobs programados.
29
+ *
30
+ * - Un string se interpreta como un patrón cron (ej: '0 0 * * *').
31
+ * - `{ every: ms }` repite cada `ms` milisegundos.
32
+ * - `{ pattern: cron }` repite según el patrón cron, con opciones extra.
33
+ */
34
+ export type RepeatSpec = string | { every: number; limit?: number } | { pattern: string; limit?: number; tz?: string };
35
+
12
36
  export interface JobOptions {
13
37
  /** Reintentos en caso de fallo */
14
38
  attempts?: number;
@@ -21,14 +45,59 @@ export interface JobOptions {
21
45
  type: 'fixed' | 'exponential';
22
46
  delay: number;
23
47
  };
24
- /** Eliminar el job de Redis al completarse */
25
- removeOnComplete?: boolean | number;
26
- /** Eliminar el job de Redis al fallar */
27
- removeOnFail?: boolean | number;
48
+ /**
49
+ * Jobs completados que quedan en Redis, con sus datos: `true` los borra,
50
+ * un número conserva los últimos N, `{ age, count }` por edad (s) y
51
+ * cantidad. Default `{ count: 1000 }`; `false` los conserva todos.
52
+ */
53
+ removeOnComplete?: boolean | number | KeepJobs;
54
+ /**
55
+ * Jobs fallidos (sin reintentos pendientes) que quedan en Redis, igual que
56
+ * removeOnComplete. Default `{ age: 7 días, count: 5000 }`; `false` los conserva todos.
57
+ */
58
+ removeOnFail?: boolean | number | KeepJobs;
59
+ /**
60
+ * Programa el job como repetible (cron o intervalo).
61
+ * Se reenvía a la opción `repeat` de BullMQ.
62
+ */
63
+ repeat?: RepeatSpec;
28
64
  }
29
65
 
30
- export interface JobData {
31
- [key: string]: any;
66
+ /**
67
+ * Handler de un job. Puede devolver un valor `R` que queda disponible como
68
+ * resultado del job (recuperable vía `job.waitUntilFinished`). Devolver `void`
69
+ * sigue siendo válido (R por defecto es `void`).
70
+ */
71
+ export type JobHandler<T = unknown, R = void> = (job: {
72
+ id: string;
73
+ name: string;
74
+ data: T;
75
+ attemptsMade: number;
76
+ }) => Promise<R>;
77
+
78
+ /**
79
+ * Payload del evento `worker:dead-letter`, emitido cuando un job agota todos
80
+ * sus reintentos y `deadLetter` está activado.
81
+ */
82
+ export interface DeadLetterPayload {
83
+ jobId: string | undefined;
84
+ name: string | undefined;
85
+ data: unknown;
86
+ failedReason: string | undefined;
87
+ attemptsMade: number;
32
88
  }
33
89
 
34
- export type JobHandler = (job: { id: string; name: string; data: any; attemptsMade: number }) => Promise<void>;
90
+ /**
91
+ * Descriptor devuelto por `enqueue`/`schedule`. Además de los datos del job,
92
+ * expone `result()` para esperar el valor de retorno del handler.
93
+ */
94
+ export interface JobDescriptor<T = unknown, R = unknown> {
95
+ id: string;
96
+ name: string;
97
+ data: T;
98
+ /**
99
+ * Espera a que el job termine y resuelve con el valor que devolvió el
100
+ * handler. Lanza si el job falló. Requiere una conexión a Redis viva.
101
+ */
102
+ result(ttlMs?: number): Promise<R>;
103
+ }