effect-mq 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.
Files changed (67) hide show
  1. package/README.md +305 -25
  2. package/dist/Job.d.ts +118 -5
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +119 -4
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +260 -9
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js +115 -1
  9. package/dist/JobStore.js.map +1 -1
  10. package/dist/MemoryJobStore.d.ts +38 -6
  11. package/dist/MemoryJobStore.d.ts.map +1 -1
  12. package/dist/MemoryJobStore.js +351 -47
  13. package/dist/MemoryJobStore.js.map +1 -1
  14. package/dist/Worker.d.ts +5 -1
  15. package/dist/Worker.d.ts.map +1 -1
  16. package/dist/Worker.js +117 -10
  17. package/dist/Worker.js.map +1 -1
  18. package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.d.ts +35 -2
  19. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -0
  20. package/dist/drizzle-postgres/DrizzleJobStore.js +941 -0
  21. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -0
  22. package/dist/drizzle-postgres/index.d.ts.map +1 -0
  23. package/dist/drizzle-postgres/index.js.map +1 -0
  24. package/dist/drizzle-postgres/schema.d.ts +670 -0
  25. package/dist/drizzle-postgres/schema.d.ts.map +1 -0
  26. package/dist/drizzle-postgres/schema.js +150 -0
  27. package/dist/drizzle-postgres/schema.js.map +1 -0
  28. package/dist/redis/RedisJobStore.d.ts +58 -0
  29. package/dist/redis/RedisJobStore.d.ts.map +1 -0
  30. package/dist/redis/RedisJobStore.js +424 -0
  31. package/dist/redis/RedisJobStore.js.map +1 -0
  32. package/dist/redis/index.d.ts +9 -0
  33. package/dist/redis/index.d.ts.map +1 -0
  34. package/dist/redis/index.js +9 -0
  35. package/dist/redis/index.js.map +1 -0
  36. package/dist/redis/scripts.d.ts +181 -0
  37. package/dist/redis/scripts.d.ts.map +1 -0
  38. package/dist/redis/scripts.js +940 -0
  39. package/dist/redis/scripts.js.map +1 -0
  40. package/dist/testing/conformance.d.ts.map +1 -1
  41. package/dist/testing/conformance.js +502 -8
  42. package/dist/testing/conformance.js.map +1 -1
  43. package/package.json +8 -4
  44. package/src/Job.ts +301 -10
  45. package/src/JobStore.ts +373 -9
  46. package/src/MemoryJobStore.ts +440 -53
  47. package/src/Worker.ts +153 -10
  48. package/src/drizzle-postgres/DrizzleJobStore.ts +1311 -0
  49. package/src/drizzle-postgres/schema.ts +279 -0
  50. package/src/redis/RedisJobStore.ts +652 -0
  51. package/src/redis/index.ts +8 -0
  52. package/src/redis/scripts.ts +1055 -0
  53. package/src/testing/conformance.ts +665 -8
  54. package/dist/drizzle/DrizzleJobStore.d.ts.map +0 -1
  55. package/dist/drizzle/DrizzleJobStore.js +0 -426
  56. package/dist/drizzle/DrizzleJobStore.js.map +0 -1
  57. package/dist/drizzle/index.d.ts.map +0 -1
  58. package/dist/drizzle/index.js.map +0 -1
  59. package/dist/drizzle/schema.d.ts +0 -464
  60. package/dist/drizzle/schema.d.ts.map +0 -1
  61. package/dist/drizzle/schema.js +0 -68
  62. package/dist/drizzle/schema.js.map +0 -1
  63. package/src/drizzle/DrizzleJobStore.ts +0 -599
  64. package/src/drizzle/schema.ts +0 -116
  65. /package/dist/{drizzle → drizzle-postgres}/index.d.ts +0 -0
  66. /package/dist/{drizzle → drizzle-postgres}/index.js +0 -0
  67. /package/src/{drizzle → drizzle-postgres}/index.ts +0 -0
package/src/Job.ts CHANGED
@@ -30,18 +30,37 @@
30
30
  *
31
31
  * @since 0.1.0
32
32
  */
33
- import { type Context, Duration, Effect, type Exit, Layer, Option, Schedule, Schema } from "effect"
33
+ import { Clock, type Context, Duration, Effect, type Exit, Layer, Option, Predicate, Schedule, Schema } from "effect"
34
34
  import {
35
35
  type BackoffPolicy,
36
+ type DedupePolicy,
37
+ type KeepStatePolicy,
38
+ JobCancelledError,
36
39
  JobId,
40
+ type JobNotCancellableError,
37
41
  JobNotFoundError,
42
+ type JobNotPromotableError,
38
43
  type JobNotRetryableError,
39
44
  type JobState,
40
45
  JobStore,
41
46
  type KeepPolicy,
47
+ nextOccurrence,
42
48
  QueueName,
43
- type Service as StoreService
49
+ ScheduleKey,
50
+ type ScheduleRecord,
51
+ type Service as StoreService,
52
+ unrecoverable
44
53
  } from "./JobStore.ts"
54
+
55
+ export {
56
+ /**
57
+ * Mark an error value as unrecoverable: the worker skips the remaining
58
+ * retry budget and fails the job immediately. Returns the error unchanged.
59
+ *
60
+ * @since 0.2.0
61
+ */
62
+ unrecoverable
63
+ }
45
64
  import { type JobContext, type RegisterOptions, Worker } from "./Worker.ts"
46
65
 
47
66
  const TypeId = "~effect-mq/Job" as const
@@ -72,13 +91,32 @@ export interface BackoffInput {
72
91
  *
73
92
  * @since 0.1.0
74
93
  */
75
- export interface KeepInput {
94
+ export interface KeepStateInput {
76
95
  /** Keep at most this many terminal records (per name + state). */
77
96
  readonly count?: number | undefined
78
97
  /** Remove terminal records older than this. */
79
98
  readonly age?: Duration.Input | undefined
80
99
  }
81
100
 
101
+ /**
102
+ * Retention input: a flat `{ count, age }` applies to all terminal states;
103
+ * the split form sets independent rules per state (completed jobs are
104
+ * usually noise, failed ones evidence) — an absent state keeps its records
105
+ * until the store's `historyTtl` ceiling:
106
+ *
107
+ * ```ts
108
+ * keep: { count: 100 } // all states
109
+ * keep: { completed: { age: "1 day" }, failed: { age: "30 days" } }
110
+ * ```
111
+ *
112
+ * @since 0.1.0
113
+ */
114
+ export type KeepInput = KeepStateInput | {
115
+ readonly completed?: KeepStateInput | undefined
116
+ readonly failed?: KeepStateInput | undefined
117
+ readonly cancelled?: KeepStateInput | undefined
118
+ }
119
+
82
120
  /**
83
121
  * Options shared between job defaults and per-enqueue overrides.
84
122
  *
@@ -94,6 +132,52 @@ export interface JobOptions {
94
132
  readonly backoff?: BackoffInput | undefined
95
133
  /** Retention for terminal records. Default: keep forever. */
96
134
  readonly keep?: KeepInput | undefined
135
+ /**
136
+ * Per-run execution time limit; the worker interrupts the handler fiber
137
+ * past it and the run counts as a failed attempt (retried per backoff).
138
+ */
139
+ readonly timeout?: Duration.Input | undefined
140
+ }
141
+
142
+ /**
143
+ * @since 0.1.0
144
+ */
145
+ /**
146
+ * Deduplication input for `Job.make({ dedupe })` and per-enqueue overrides.
147
+ * A bare string is shorthand for `{ key }`. Dedup never changes the job id —
148
+ * it is a separate, name-scoped key:
149
+ *
150
+ * - `{ key }`: dedupe while the keyed job is pending; done jobs free the key.
151
+ * - `{ key, ttl }`: throttle — at most one job per window.
152
+ * - `{ key, ttl, extend: true }`: debounce — dropped enqueues push the
153
+ * window out.
154
+ * - `{ key, replace: true }`: while the keyed job is still delayed, the new
155
+ * enqueue's content replaces it (latest wins).
156
+ *
157
+ * @since 0.3.0
158
+ */
159
+ export type DedupeInput = string | {
160
+ readonly key: string
161
+ readonly ttl?: Duration.Input | undefined
162
+ readonly extend?: boolean | undefined
163
+ readonly replace?: boolean | undefined
164
+ }
165
+
166
+ const normalizeDedupe = (input: DedupeInput | undefined): DedupePolicy | undefined => {
167
+ if (input === undefined) return undefined
168
+ const config = Predicate.isString(input) ? { key: input } : input
169
+ if (config.key === "") {
170
+ throw new Error("effect-mq: dedupe `key` must be non-empty")
171
+ }
172
+ if (config.extend === true && config.ttl === undefined) {
173
+ throw new Error("effect-mq: dedupe `extend` requires a `ttl`")
174
+ }
175
+ return {
176
+ key: config.key,
177
+ ttlMs: config.ttl !== undefined ? Duration.toMillis(config.ttl) : undefined,
178
+ extend: config.extend === true,
179
+ replace: config.replace === true
180
+ }
97
181
  }
98
182
 
99
183
  /**
@@ -110,6 +194,8 @@ export interface EnqueueOptions extends JobOptions {
110
194
  readonly queue?: string | undefined
111
195
  /** Queryable business context, merged over the definition's `metadata`. */
112
196
  readonly metadata?: Readonly<Record<string, string>> | undefined
197
+ /** Deduplicate by key (see `DedupeInput`); overrides the definition's `dedupe`. */
198
+ readonly dedupe?: DedupeInput | undefined
113
199
  }
114
200
 
115
201
  interface ResolvedDefaults {
@@ -118,6 +204,28 @@ interface ResolvedDefaults {
118
204
  readonly attempts: number
119
205
  readonly backoff: BackoffPolicy | undefined
120
206
  readonly keep: KeepPolicy | undefined
207
+ readonly timeoutMs: number | undefined
208
+ }
209
+
210
+ /**
211
+ * Options for `Job.schedule`. Exactly one of `cron` (with optional IANA `tz`)
212
+ * or `every` must be set. `cron` schedules first fire at the next matching
213
+ * occurrence; `every` schedules first fire one interval from now.
214
+ *
215
+ * @since 0.2.0
216
+ */
217
+ export interface ScheduleOptions<PayloadInput> {
218
+ readonly cron?: string | undefined
219
+ readonly tz?: string | undefined
220
+ readonly every?: Duration.Input | undefined
221
+ readonly payload: PayloadInput
222
+ /** Queryable business context, merged over the definition's `metadata`. */
223
+ readonly metadata?: Readonly<Record<string, string>> | undefined
224
+ readonly priority?: number | undefined
225
+ readonly attempts?: number | undefined
226
+ readonly backoff?: BackoffInput | undefined
227
+ readonly keep?: KeepInput | undefined
228
+ readonly timeout?: Duration.Input | undefined
121
229
  }
122
230
 
123
231
  /**
@@ -144,8 +252,8 @@ export interface JobAttempt<A, E> {
144
252
  readonly attempt: number
145
253
  readonly startedAt: number | undefined
146
254
  readonly finishedAt: number
147
- readonly outcome: "completed" | "retried" | "failed" | "stalled"
148
- /** Absent for `stalled` entries. */
255
+ readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled"
256
+ /** Absent for `stalled` and `cancelled` entries. */
149
257
  readonly exit: Option.Option<Exit.Exit<A, E>>
150
258
  }
151
259
 
@@ -184,7 +292,9 @@ export interface Job<
184
292
  >
185
293
  >
186
294
  readonly idempotencyKey: ((payload: Payload["Type"]) => string) | undefined
295
+ readonly dedupe: ((payload: Payload["Type"]) => DedupeInput) | undefined
187
296
  readonly metadata: ((payload: Payload["Type"]) => Readonly<Record<string, string>>) | undefined
297
+ readonly retryable: ((error: Error["Type"]) => boolean) | undefined
188
298
  readonly defaults: ResolvedDefaults
189
299
 
190
300
  /**
@@ -248,6 +358,43 @@ export interface Job<
248
358
  jobId: JobId
249
359
  ) => Effect.Effect<void, JobNotFoundError | JobNotRetryableError, StoreId>
250
360
 
361
+ /**
362
+ * Cancel a job: waiting/delayed become terminal (`cancelled`) immediately;
363
+ * a running job's handler fiber is interrupted by its worker on the next
364
+ * heartbeat (latency ≤ `lockRenewInterval`).
365
+ */
366
+ readonly cancel: (
367
+ jobId: JobId
368
+ ) => Effect.Effect<void, JobNotFoundError | JobNotCancellableError, StoreId>
369
+
370
+ /** Run a delayed job now. */
371
+ readonly promote: (
372
+ jobId: JobId
373
+ ) => Effect.Effect<void, JobNotFoundError | JobNotPromotableError, StoreId>
374
+
375
+ /**
376
+ * Create or replace a durable repeatable schedule for this job. Ticks
377
+ * enqueue with a slot-deterministic id, so schedules are exactly-once per
378
+ * occurrence across all workers (assuming history retention windows
379
+ * comfortably exceed the sweep interval — a pruned tick job cannot dedup a
380
+ * pathologically stale sweeper). Missed occurrences (downtime) collapse
381
+ * into a single catch-up run.
382
+ *
383
+ * Re-registering with an *unchanged* cadence (same `cron`/`tz`/`every`) is
384
+ * a no-op for the next occurrence — deploy-time re-registration neither
385
+ * re-anchors `every` grids nor drops a pending catch-up run. Changing the
386
+ * cadence resets the next occurrence.
387
+ */
388
+ readonly schedule: (
389
+ key: string,
390
+ options: ScheduleOptions<Payload["~type.make.in"]>
391
+ ) => Effect.Effect<ScheduleKey, never, StoreId | Payload["EncodingServices"]>
392
+
393
+ /** Remove a schedule created by `schedule`. False when it did not exist. */
394
+ readonly unschedule: (
395
+ key: string
396
+ ) => Effect.Effect<boolean, never, StoreId>
397
+
251
398
  /**
252
399
  * Attach the handler that processes this job, as a layer to provide on top
253
400
  * of `Worker.layer` (bound to the same store).
@@ -292,11 +439,36 @@ const normalizeBackoff = (input: BackoffInput | undefined): BackoffPolicy | unde
292
439
  factor: input.factor
293
440
  }
294
441
 
295
- const normalizeKeep = (input: KeepInput | undefined): KeepPolicy | undefined =>
296
- input === undefined ? undefined : {
297
- count: input.count,
298
- ageMs: input.age !== undefined ? Duration.toMillis(input.age) : undefined
442
+ const normalizeKeepState = (input: KeepStateInput): KeepStatePolicy => ({
443
+ count: input.count,
444
+ ageMs: input.age !== undefined ? Duration.toMillis(input.age) : undefined
445
+ })
446
+
447
+ const normalizeKeep = (input: KeepInput | undefined): KeepPolicy | undefined => {
448
+ if (input === undefined) return undefined
449
+ const split = "completed" in input || "failed" in input || "cancelled" in input
450
+ const flat = "count" in input || "age" in input
451
+ if (split && flat) {
452
+ throw new Error("effect-mq: `keep` must be either flat { count, age } or split per state, not both")
453
+ }
454
+ if (split) {
455
+ // SAFETY: the flat branch was excluded above, so the state keys carry
456
+ // KeepStateInput values.
457
+ const perState = input as {
458
+ readonly completed?: KeepStateInput | undefined
459
+ readonly failed?: KeepStateInput | undefined
460
+ readonly cancelled?: KeepStateInput | undefined
461
+ }
462
+ return {
463
+ completed: perState.completed !== undefined ? normalizeKeepState(perState.completed) : undefined,
464
+ failed: perState.failed !== undefined ? normalizeKeepState(perState.failed) : undefined,
465
+ cancelled: perState.cancelled !== undefined ? normalizeKeepState(perState.cancelled) : undefined
466
+ }
299
467
  }
468
+ // SAFETY: the split branch was excluded above, so this is the flat form.
469
+ const policy = normalizeKeepState(input as KeepStateInput)
470
+ return { completed: policy, failed: policy, cancelled: policy }
471
+ }
300
472
 
301
473
  const Proto = {
302
474
  [TypeId]: TypeId,
@@ -313,6 +485,9 @@ const Proto = {
313
485
  ...this.metadata?.(payload),
314
486
  ...options?.metadata
315
487
  }
488
+ const dedupe = normalizeDedupe(
489
+ options?.dedupe ?? this.dedupe?.(payload)
490
+ )
316
491
  return Schema.encodeEffect(this.payloadJsonSchema)(payload).pipe(
317
492
  Effect.orDie,
318
493
  Effect.flatMap((encoded) =>
@@ -333,6 +508,10 @@ const Proto = {
333
508
  keep: options?.keep !== undefined
334
509
  ? normalizeKeep(options.keep)
335
510
  : this.defaults.keep,
511
+ timeoutMs: options?.timeout !== undefined
512
+ ? Duration.toMillis(options.timeout)
513
+ : this.defaults.timeoutMs,
514
+ dedupe,
336
515
  delayMs: options?.delay !== undefined
337
516
  ? Duration.toMillis(options.delay)
338
517
  : this.defaults.delayMs
@@ -418,6 +597,9 @@ const Proto = {
418
597
  return yield* Effect.die(new JobNotFoundError({ jobId }))
419
598
  }
420
599
  const { exit, failedReason, state } = status.value
600
+ if (state === "cancelled") {
601
+ return yield* Effect.die(new JobCancelledError({ jobId }))
602
+ }
421
603
  if (state === "completed" || state === "failed") {
422
604
  if (Option.isSome(exit)) {
423
605
  return yield* exit.value
@@ -458,6 +640,84 @@ const Proto = {
458
640
  )
459
641
  },
460
642
 
643
+ cancel(this: AnyWithProps, jobId: JobId) {
644
+ return Effect.flatMap(this.store, (store) =>
645
+ store.cancel(jobId).pipe(
646
+ Effect.catchTag("JobStoreError", (error) => Effect.die(error))
647
+ )).pipe(
648
+ Effect.withSpan(`${this._tag}.cancel`, { attributes: { jobId } }, { captureStackTrace: false })
649
+ )
650
+ },
651
+
652
+ promote(this: AnyWithProps, jobId: JobId) {
653
+ return Effect.flatMap(this.store, (store) =>
654
+ store.promote(jobId).pipe(
655
+ Effect.catchTag("JobStoreError", (error) => Effect.die(error))
656
+ )).pipe(
657
+ Effect.withSpan(`${this._tag}.promote`, { attributes: { jobId } }, { captureStackTrace: false })
658
+ )
659
+ },
660
+
661
+ schedule(this: AnyWithProps, key: string, options: ScheduleOptions<never>) {
662
+ const self = this
663
+ return Effect.gen(function*() {
664
+ const hasCron = options.cron !== undefined
665
+ const hasEvery = options.every !== undefined
666
+ if (hasCron === hasEvery) {
667
+ return yield* Effect.die(
668
+ new Error(`effect-mq: schedule "${key}" must set exactly one of \`cron\` or \`every\``)
669
+ )
670
+ }
671
+ const everyMs = options.every !== undefined ? Duration.toMillis(options.every) : undefined
672
+ const now = yield* Clock.currentTimeMillis
673
+ const nextRunAt = nextOccurrence(
674
+ { cron: options.cron, tz: options.tz, everyMs },
675
+ now,
676
+ now
677
+ )
678
+ if (nextRunAt === undefined) {
679
+ return yield* Effect.die(
680
+ new Error(`effect-mq: schedule "${key}" has an invalid cron expression: "${options.cron}"`)
681
+ )
682
+ }
683
+ const payload = self.payloadSchema.make(options.payload)
684
+ const encoded = yield* Schema.encodeEffect(self.payloadJsonSchema)(payload).pipe(Effect.orDie)
685
+ const scheduleKey = ScheduleKey(`${self._tag}/${key}`)
686
+ const record: ScheduleRecord = {
687
+ key: scheduleKey,
688
+ jobName: self._tag,
689
+ queue: self.queue,
690
+ cron: options.cron,
691
+ tz: options.tz,
692
+ everyMs,
693
+ payload: encoded,
694
+ metadata: { ...self.metadata?.(payload), ...options.metadata },
695
+ priority: options.priority ?? self.defaults.priority,
696
+ attemptsMax: Math.max(1, options.attempts ?? self.defaults.attempts),
697
+ backoff: options.backoff !== undefined
698
+ ? normalizeBackoff(options.backoff)
699
+ : self.defaults.backoff,
700
+ keep: options.keep !== undefined ? normalizeKeep(options.keep) : self.defaults.keep,
701
+ timeoutMs: options.timeout !== undefined
702
+ ? Duration.toMillis(options.timeout)
703
+ : self.defaults.timeoutMs,
704
+ nextRunAt
705
+ }
706
+ const store = yield* self.store
707
+ yield* store.upsertSchedule(record).pipe(Effect.orDie)
708
+ return scheduleKey
709
+ }).pipe(
710
+ Effect.withSpan(`${this._tag}.schedule`, { attributes: { key } }, { captureStackTrace: false })
711
+ )
712
+ },
713
+
714
+ unschedule(this: AnyWithProps, key: string) {
715
+ return Effect.flatMap(this.store, (store) =>
716
+ store.removeSchedule(ScheduleKey(`${this._tag}/${key}`)).pipe(Effect.orDie)).pipe(
717
+ Effect.withSpan(`${this._tag}.unschedule`, { attributes: { key } }, { captureStackTrace: false })
718
+ )
719
+ },
720
+
461
721
  toLayer(
462
722
  this: AnyWithProps,
463
723
  handler: (payload: any, context: JobContext) => Effect.Effect<any, any, any>,
@@ -476,6 +736,10 @@ const boundMethods = [
476
736
  "awaitResult",
477
737
  "execute",
478
738
  "retry",
739
+ "cancel",
740
+ "promote",
741
+ "schedule",
742
+ "unschedule",
479
743
  "toLayer"
480
744
  ] as const
481
745
 
@@ -489,7 +753,9 @@ const makeProto = (options: {
489
753
  readonly errorSchema: Schema.Top
490
754
  readonly exitSchema: Schema.Top
491
755
  readonly idempotencyKey: ((payload: any) => string) | undefined
756
+ readonly dedupe: ((payload: any) => DedupeInput) | undefined
492
757
  readonly metadata: ((payload: any) => Readonly<Record<string, string>>) | undefined
758
+ readonly retryable: ((error: any) => boolean) | undefined
493
759
  readonly defaults: ResolvedDefaults
494
760
  }): any => {
495
761
  function JobDefinition() {}
@@ -540,6 +806,17 @@ export const make = <
540
806
  : Payload["Type"]
541
807
  ) => string)
542
808
  | undefined
809
+ /**
810
+ * Derive a dedup key (and optional throttle/debounce/replace behavior)
811
+ * from the payload. Unlike `idempotencyKey`, this never changes the job
812
+ * id — see `DedupeInput` for the mode semantics.
813
+ */
814
+ readonly dedupe?:
815
+ | ((
816
+ payload: Payload extends Schema.Struct.Fields ? Schema.Struct.Type<Payload>
817
+ : Payload["Type"]
818
+ ) => DedupeInput)
819
+ | undefined
543
820
  /**
544
821
  * Derive queryable business context from the payload (flat string map, so
545
822
  * every driver can index it). Merged with per-enqueue `metadata`.
@@ -550,6 +827,15 @@ export const make = <
550
827
  : Payload["Type"]
551
828
  ) => Readonly<Record<string, string>>)
552
829
  | undefined
830
+ /**
831
+ * When present, a typed handler failure for which this returns false
832
+ * skips the remaining retry budget (see also `Job.unrecoverable`).
833
+ */
834
+ readonly retryable?:
835
+ | ((
836
+ error: Error["Type"]
837
+ ) => boolean)
838
+ | undefined
553
839
  /** The queue this job runs on. Default `"default"`. */
554
840
  readonly queue?: string | undefined
555
841
  /**
@@ -592,7 +878,9 @@ export const make = <
592
878
  errorSchema,
593
879
  exitSchema,
594
880
  idempotencyKey: options.idempotencyKey,
881
+ dedupe: options.dedupe,
595
882
  metadata: options.metadata,
883
+ retryable: options.retryable,
596
884
  defaults: {
597
885
  delayMs: options.defaults?.delay !== undefined
598
886
  ? Duration.toMillis(options.defaults.delay)
@@ -600,7 +888,10 @@ export const make = <
600
888
  priority: options.defaults?.priority ?? 0,
601
889
  attempts: Math.max(1, options.defaults?.attempts ?? 1),
602
890
  backoff: normalizeBackoff(options.defaults?.backoff),
603
- keep: normalizeKeep(options.defaults?.keep)
891
+ keep: normalizeKeep(options.defaults?.keep),
892
+ timeoutMs: options.defaults?.timeout !== undefined
893
+ ? Duration.toMillis(options.defaults.timeout)
894
+ : undefined
604
895
  }
605
896
  })
606
897
  }