effect-mq 0.1.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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +235 -0
  3. package/dist/Job.d.ts +222 -0
  4. package/dist/Job.d.ts.map +1 -0
  5. package/dist/Job.js +218 -0
  6. package/dist/Job.js.map +1 -0
  7. package/dist/JobStore.d.ts +401 -0
  8. package/dist/JobStore.d.ts.map +1 -0
  9. package/dist/JobStore.js +89 -0
  10. package/dist/JobStore.js.map +1 -0
  11. package/dist/MemoryJobStore.d.ts +34 -0
  12. package/dist/MemoryJobStore.d.ts.map +1 -0
  13. package/dist/MemoryJobStore.js +381 -0
  14. package/dist/MemoryJobStore.js.map +1 -0
  15. package/dist/Worker.d.ts +127 -0
  16. package/dist/Worker.d.ts.map +1 -0
  17. package/dist/Worker.js +274 -0
  18. package/dist/Worker.js.map +1 -0
  19. package/dist/drizzle/DrizzleJobStore.d.ts +59 -0
  20. package/dist/drizzle/DrizzleJobStore.d.ts.map +1 -0
  21. package/dist/drizzle/DrizzleJobStore.js +426 -0
  22. package/dist/drizzle/DrizzleJobStore.js.map +1 -0
  23. package/dist/drizzle/index.d.ts +19 -0
  24. package/dist/drizzle/index.d.ts.map +1 -0
  25. package/dist/drizzle/index.js +19 -0
  26. package/dist/drizzle/index.js.map +1 -0
  27. package/dist/drizzle/schema.d.ts +464 -0
  28. package/dist/drizzle/schema.d.ts.map +1 -0
  29. package/dist/drizzle/schema.js +68 -0
  30. package/dist/drizzle/schema.js.map +1 -0
  31. package/dist/index.d.ts +30 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +30 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/testing/conformance.d.ts +27 -0
  36. package/dist/testing/conformance.d.ts.map +1 -0
  37. package/dist/testing/conformance.js +451 -0
  38. package/dist/testing/conformance.js.map +1 -0
  39. package/dist/testing/index.d.ts +8 -0
  40. package/dist/testing/index.d.ts.map +1 -0
  41. package/dist/testing/index.js +8 -0
  42. package/dist/testing/index.js.map +1 -0
  43. package/package.json +71 -0
  44. package/src/Job.ts +606 -0
  45. package/src/JobStore.ts +446 -0
  46. package/src/MemoryJobStore.ts +467 -0
  47. package/src/Worker.ts +514 -0
  48. package/src/drizzle/DrizzleJobStore.ts +599 -0
  49. package/src/drizzle/index.ts +20 -0
  50. package/src/drizzle/schema.ts +116 -0
  51. package/src/index.ts +33 -0
  52. package/src/testing/conformance.ts +654 -0
  53. package/src/testing/index.ts +7 -0
package/src/Job.ts ADDED
@@ -0,0 +1,606 @@
1
+ /**
2
+ * Schema-first background job definitions.
3
+ *
4
+ * A `Job` is defined once (name, payload/success/error schemas, defaults) and
5
+ * used from both sides:
6
+ *
7
+ * - producers call `MyJob.enqueue(payload, options)` (requires the job's store)
8
+ * - runners provide `MyJob.toLayer(handler)` on top of a `Worker.layer`
9
+ *
10
+ * ```ts
11
+ * import { Job, JobStore } from "effect-mq"
12
+ * import { Effect, Schema } from "effect"
13
+ *
14
+ * const Durable = JobStore.named("durable")
15
+ *
16
+ * class SendEmail extends Job.make("SendEmail", {
17
+ * payload: { to: Schema.String, body: Schema.String },
18
+ * queue: "email",
19
+ * store: Durable,
20
+ * metadata: ({ to }) => ({ to }),
21
+ * defaults: { attempts: 5, backoff: { type: "exponential", delay: "1 second" } }
22
+ * }) {}
23
+ *
24
+ * // producer — requires the Durable store in context, enforced at compile time
25
+ * const jobId = yield* SendEmail.enqueue({ to: "a@b.c", body: "hi" }, { delay: "5 seconds" })
26
+ *
27
+ * // runner
28
+ * const SendEmailWorker = SendEmail.toLayer((payload) => Effect.log(`sending to ${payload.to}`))
29
+ * ```
30
+ *
31
+ * @since 0.1.0
32
+ */
33
+ import { type Context, Duration, Effect, type Exit, Layer, Option, Schedule, Schema } from "effect"
34
+ import {
35
+ type BackoffPolicy,
36
+ JobId,
37
+ JobNotFoundError,
38
+ type JobNotRetryableError,
39
+ type JobState,
40
+ JobStore,
41
+ type KeepPolicy,
42
+ QueueName,
43
+ type Service as StoreService
44
+ } from "./JobStore.ts"
45
+ import { type JobContext, type RegisterOptions, Worker } from "./Worker.ts"
46
+
47
+ const TypeId = "~effect-mq/Job" as const
48
+
49
+ /**
50
+ * A struct schema (or anything with struct fields).
51
+ *
52
+ * @since 0.1.0
53
+ */
54
+ export interface AnyStructSchema extends Schema.Top {
55
+ readonly fields: Schema.Struct.Fields
56
+ }
57
+
58
+ /**
59
+ * User-facing backoff configuration.
60
+ *
61
+ * @since 0.1.0
62
+ */
63
+ export interface BackoffInput {
64
+ readonly type: "fixed" | "exponential"
65
+ readonly delay: Duration.Input
66
+ /** Exponential growth factor (default 2). */
67
+ readonly factor?: number | undefined
68
+ }
69
+
70
+ /**
71
+ * User-facing retention configuration for terminal jobs.
72
+ *
73
+ * @since 0.1.0
74
+ */
75
+ export interface KeepInput {
76
+ /** Keep at most this many terminal records (per name + state). */
77
+ readonly count?: number | undefined
78
+ /** Remove terminal records older than this. */
79
+ readonly age?: Duration.Input | undefined
80
+ }
81
+
82
+ /**
83
+ * Options shared between job defaults and per-enqueue overrides.
84
+ *
85
+ * @since 0.1.0
86
+ */
87
+ export interface JobOptions {
88
+ /** Do not run before this long from enqueue time. */
89
+ readonly delay?: Duration.Input | undefined
90
+ /** Higher runs first; ties are FIFO. Default 0. */
91
+ readonly priority?: number | undefined
92
+ /** Total attempts including the first run. Default 1 (no retries). */
93
+ readonly attempts?: number | undefined
94
+ readonly backoff?: BackoffInput | undefined
95
+ /** Retention for terminal records. Default: keep forever. */
96
+ readonly keep?: KeepInput | undefined
97
+ }
98
+
99
+ /**
100
+ * @since 0.1.0
101
+ */
102
+ export interface EnqueueOptions extends JobOptions {
103
+ /**
104
+ * Explicit job id. Enqueueing an id that already exists is a no-op that
105
+ * returns the existing id (idempotency). Overrides the definition's
106
+ * `idempotencyKey`.
107
+ */
108
+ readonly jobId?: string | undefined
109
+ /** Send to a different queue than the definition's. */
110
+ readonly queue?: string | undefined
111
+ /** Queryable business context, merged over the definition's `metadata`. */
112
+ readonly metadata?: Readonly<Record<string, string>> | undefined
113
+ }
114
+
115
+ interface ResolvedDefaults {
116
+ readonly delayMs: number
117
+ readonly priority: number
118
+ readonly attempts: number
119
+ readonly backoff: BackoffPolicy | undefined
120
+ readonly keep: KeepPolicy | undefined
121
+ }
122
+
123
+ /**
124
+ * The status of a job as seen by `poll`. Fetch the full run ledger with
125
+ * `Job.attempts`.
126
+ *
127
+ * @since 0.1.0
128
+ */
129
+ export interface JobStatus<A, E> {
130
+ readonly state: JobState
131
+ readonly attemptsMade: number
132
+ readonly metadata: Readonly<Record<string, string>>
133
+ /** Present for completed/failed jobs (except store-side failures like stalling). */
134
+ readonly exit: Option.Option<Exit.Exit<A, E>>
135
+ readonly failedReason: string | undefined
136
+ }
137
+
138
+ /**
139
+ * One decoded entry of a job's run ledger.
140
+ *
141
+ * @since 0.1.0
142
+ */
143
+ export interface JobAttempt<A, E> {
144
+ readonly attempt: number
145
+ readonly startedAt: number | undefined
146
+ readonly finishedAt: number
147
+ readonly outcome: "completed" | "retried" | "failed" | "stalled"
148
+ /** Absent for `stalled` entries. */
149
+ readonly exit: Option.Option<Exit.Exit<A, E>>
150
+ }
151
+
152
+ /**
153
+ * @since 0.1.0
154
+ */
155
+ export interface Job<
156
+ Name extends string,
157
+ Payload extends AnyStructSchema,
158
+ Success extends Schema.Top,
159
+ Error extends Schema.Top,
160
+ StoreId = JobStore
161
+ > {
162
+ new(_: never): {}
163
+
164
+ readonly [TypeId]: typeof TypeId
165
+ /**
166
+ * The job's unique name. (Named `_tag` rather than `name` because a
167
+ * `class X extends Job.make(...) {}` subclass shadows `Function.name`.)
168
+ */
169
+ readonly _tag: Name
170
+ readonly queue: QueueName
171
+ /** The store this job's runs live on. */
172
+ readonly store: Context.Key<StoreId, StoreService>
173
+ readonly payloadSchema: Payload
174
+ readonly successSchema: Success
175
+ readonly errorSchema: Error
176
+ /** JSON codec for the payload — what is actually persisted. */
177
+ readonly payloadJsonSchema: Schema.toCodecJson<Payload>
178
+ /** JSON codec for handler exits — what is actually persisted. */
179
+ readonly exitSchema: Schema.toCodecJson<
180
+ Schema.Exit<
181
+ Schema.toCodecJson<Success>,
182
+ Schema.toCodecJson<Error>,
183
+ Schema.Defect
184
+ >
185
+ >
186
+ readonly idempotencyKey: ((payload: Payload["Type"]) => string) | undefined
187
+ readonly metadata: ((payload: Payload["Type"]) => Readonly<Record<string, string>>) | undefined
188
+ readonly defaults: ResolvedDefaults
189
+
190
+ /**
191
+ * Queue this job. Returns the job id. Duplicate ids (via `jobId` or
192
+ * `idempotencyKey`) are a silent no-op returning the existing id.
193
+ */
194
+ readonly enqueue: (
195
+ payload: Payload["~type.make.in"],
196
+ options?: EnqueueOptions | undefined
197
+ ) => Effect.Effect<JobId, never, StoreId | Payload["EncodingServices"]>
198
+
199
+ /** Read the current status of a previously enqueued job. */
200
+ readonly poll: (
201
+ jobId: JobId
202
+ ) => Effect.Effect<
203
+ Option.Option<JobStatus<Success["Type"], Error["Type"]>>,
204
+ never,
205
+ StoreId | Success["DecodingServices"] | Error["DecodingServices"]
206
+ >
207
+
208
+ /** The job's decoded run ledger, oldest first. */
209
+ readonly attempts: (
210
+ jobId: JobId
211
+ ) => Effect.Effect<
212
+ ReadonlyArray<JobAttempt<Success["Type"], Error["Type"]>>,
213
+ never,
214
+ StoreId | Success["DecodingServices"] | Error["DecodingServices"]
215
+ >
216
+
217
+ /**
218
+ * Wait (by polling) until the job finishes, then return its result. Dies
219
+ * if the job id does not exist or the job was failed by the store itself.
220
+ */
221
+ readonly awaitResult: (
222
+ jobId: JobId,
223
+ options?: { readonly pollSchedule?: Schedule.Schedule<unknown> | undefined } | undefined
224
+ ) => Effect.Effect<
225
+ Success["Type"],
226
+ Error["Type"],
227
+ StoreId | Success["DecodingServices"] | Error["DecodingServices"]
228
+ >
229
+
230
+ /** `enqueue` + `awaitResult` in one call. */
231
+ readonly execute: (
232
+ payload: Payload["~type.make.in"],
233
+ options?: EnqueueOptions | undefined
234
+ ) => Effect.Effect<
235
+ Success["Type"],
236
+ Error["Type"],
237
+ | StoreId
238
+ | Payload["EncodingServices"]
239
+ | Success["DecodingServices"]
240
+ | Error["DecodingServices"]
241
+ >
242
+
243
+ /**
244
+ * Re-run a failed job with a fresh attempt budget. The run ledger is
245
+ * preserved. (The admin op behind a dashboard's "retry" button.)
246
+ */
247
+ readonly retry: (
248
+ jobId: JobId
249
+ ) => Effect.Effect<void, JobNotFoundError | JobNotRetryableError, StoreId>
250
+
251
+ /**
252
+ * Attach the handler that processes this job, as a layer to provide on top
253
+ * of `Worker.layer` (bound to the same store).
254
+ */
255
+ readonly toLayer: <R>(
256
+ handler: (
257
+ payload: Payload["Type"],
258
+ context: JobContext
259
+ ) => Effect.Effect<Success["Type"], Error["Type"], R>,
260
+ options?: RegisterOptions | undefined
261
+ ) => Layer.Layer<
262
+ never,
263
+ never,
264
+ | Worker
265
+ | R
266
+ | Payload["DecodingServices"]
267
+ | Success["EncodingServices"]
268
+ | Error["EncodingServices"]
269
+ >
270
+ }
271
+
272
+ /**
273
+ * @since 0.1.0
274
+ */
275
+ export interface Any {
276
+ readonly [TypeId]: typeof TypeId
277
+ readonly _tag: string
278
+ readonly queue: QueueName
279
+ }
280
+
281
+ interface AnyWithProps extends Job<string, AnyStructSchema, Schema.Top, Schema.Top, any> {}
282
+
283
+ const defaultPollSchedule = Schedule.min([
284
+ Schedule.exponential(10, 2),
285
+ Schedule.spaced("1 second")
286
+ ])
287
+
288
+ const normalizeBackoff = (input: BackoffInput | undefined): BackoffPolicy | undefined =>
289
+ input === undefined ? undefined : {
290
+ _tag: input.type,
291
+ delayMs: Duration.toMillis(input.delay),
292
+ factor: input.factor
293
+ }
294
+
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
299
+ }
300
+
301
+ const Proto = {
302
+ [TypeId]: TypeId,
303
+
304
+ enqueue(this: AnyWithProps, fields: any, options?: EnqueueOptions) {
305
+ return Effect.suspend(() => {
306
+ const payload = this.payloadSchema.make(fields)
307
+ const id = options?.jobId !== undefined
308
+ ? JobId(options.jobId)
309
+ : this.idempotencyKey !== undefined
310
+ ? JobId(`${this._tag}/${this.idempotencyKey(payload)}`)
311
+ : undefined
312
+ const metadata = {
313
+ ...this.metadata?.(payload),
314
+ ...options?.metadata
315
+ }
316
+ return Schema.encodeEffect(this.payloadJsonSchema)(payload).pipe(
317
+ Effect.orDie,
318
+ Effect.flatMap((encoded) =>
319
+ Effect.flatMap(this.store, (store) =>
320
+ store.enqueue({
321
+ id,
322
+ name: this._tag,
323
+ queue: options?.queue !== undefined
324
+ ? QueueName(options.queue)
325
+ : this.queue,
326
+ payload: encoded,
327
+ metadata,
328
+ priority: options?.priority ?? this.defaults.priority,
329
+ attemptsMax: Math.max(1, options?.attempts ?? this.defaults.attempts),
330
+ backoff: options?.backoff !== undefined
331
+ ? normalizeBackoff(options.backoff)
332
+ : this.defaults.backoff,
333
+ keep: options?.keep !== undefined
334
+ ? normalizeKeep(options.keep)
335
+ : this.defaults.keep,
336
+ delayMs: options?.delay !== undefined
337
+ ? Duration.toMillis(options.delay)
338
+ : this.defaults.delayMs
339
+ }))
340
+ ),
341
+ Effect.orDie,
342
+ Effect.map((result) => result.id)
343
+ )
344
+ }).pipe(
345
+ Effect.withSpan(`${this._tag}.enqueue`, {}, { captureStackTrace: false })
346
+ )
347
+ },
348
+
349
+ poll(this: AnyWithProps, jobId: JobId) {
350
+ const self = this
351
+ return Effect.flatMap(this.store, (store) =>
352
+ store.getJob(jobId).pipe(
353
+ Effect.orDie,
354
+ Effect.flatMap(Option.match({
355
+ onNone: () => Effect.succeedNone,
356
+ onSome: (record) =>
357
+ Effect.gen(function*() {
358
+ const exit = record.exit === undefined
359
+ ? Option.none()
360
+ : Option.some(
361
+ yield* Schema.decodeUnknownEffect(self.exitSchema)(record.exit).pipe(
362
+ Effect.orDie
363
+ )
364
+ )
365
+ return Option.some({
366
+ state: record.state,
367
+ attemptsMade: record.attemptsMade,
368
+ metadata: record.metadata,
369
+ exit,
370
+ failedReason: record.failedReason
371
+ })
372
+ })
373
+ }))
374
+ )).pipe(
375
+ Effect.withSpan(`${this._tag}.poll`, { attributes: { jobId } }, { captureStackTrace: false })
376
+ )
377
+ },
378
+
379
+ attempts(this: AnyWithProps, jobId: JobId) {
380
+ const self = this
381
+ return Effect.flatMap(this.store, (store) =>
382
+ store.getAttempts(jobId).pipe(
383
+ Effect.orDie,
384
+ Effect.flatMap(Effect.forEach((attempt) =>
385
+ Effect.map(
386
+ attempt.exit === undefined
387
+ ? Effect.succeedNone
388
+ : Schema.decodeUnknownEffect(self.exitSchema)(attempt.exit).pipe(
389
+ Effect.orDie,
390
+ Effect.map(Option.some)
391
+ ),
392
+ (exit) => ({
393
+ attempt: attempt.attempt,
394
+ startedAt: attempt.startedAt,
395
+ finishedAt: attempt.finishedAt,
396
+ outcome: attempt.outcome,
397
+ exit
398
+ })
399
+ )
400
+ ))
401
+ )).pipe(
402
+ Effect.withSpan(`${this._tag}.attempts`, { attributes: { jobId } }, { captureStackTrace: false })
403
+ )
404
+ },
405
+
406
+ awaitResult(
407
+ this: AnyWithProps,
408
+ jobId: JobId,
409
+ options?: { readonly pollSchedule?: Schedule.Schedule<unknown> | undefined }
410
+ ) {
411
+ const self = this
412
+ return Effect.gen(function*() {
413
+ const schedule = options?.pollSchedule ?? defaultPollSchedule
414
+ let sleep: Effect.Effect<unknown> | undefined
415
+ while (true) {
416
+ const status = yield* self.poll(jobId)
417
+ if (Option.isNone(status)) {
418
+ return yield* Effect.die(new JobNotFoundError({ jobId }))
419
+ }
420
+ const { exit, failedReason, state } = status.value
421
+ if (state === "completed" || state === "failed") {
422
+ if (Option.isSome(exit)) {
423
+ return yield* exit.value
424
+ }
425
+ return yield* Effect.die(
426
+ new Error(
427
+ `effect-mq: job "${jobId}" failed without a result${
428
+ failedReason === undefined ? "" : `: ${failedReason}`
429
+ }`
430
+ )
431
+ )
432
+ }
433
+ sleep ??= (yield* Schedule.toStepWithSleep(schedule))(void 0).pipe(
434
+ Effect.catch(() =>
435
+ Effect.die(`${self._tag}.awaitResult: poll schedule exhausted`)
436
+ )
437
+ )
438
+ yield* sleep
439
+ }
440
+ }).pipe(
441
+ Effect.withSpan(`${this._tag}.awaitResult`, { attributes: { jobId } }, { captureStackTrace: false })
442
+ )
443
+ },
444
+
445
+ execute(this: AnyWithProps, fields: any, options?: EnqueueOptions) {
446
+ return Effect.flatMap(
447
+ this.enqueue(fields, options),
448
+ (jobId) => this.awaitResult(jobId)
449
+ )
450
+ },
451
+
452
+ retry(this: AnyWithProps, jobId: JobId) {
453
+ return Effect.flatMap(this.store, (store) =>
454
+ store.retry(jobId).pipe(
455
+ Effect.catchTag("JobStoreError", (error) => Effect.die(error))
456
+ )).pipe(
457
+ Effect.withSpan(`${this._tag}.retry`, { attributes: { jobId } }, { captureStackTrace: false })
458
+ )
459
+ },
460
+
461
+ toLayer(
462
+ this: AnyWithProps,
463
+ handler: (payload: any, context: JobContext) => Effect.Effect<any, any, any>,
464
+ options?: RegisterOptions
465
+ ) {
466
+ return Layer.effectDiscard(
467
+ Effect.flatMap(Worker, (worker) => worker.register(this, handler, options))
468
+ )
469
+ }
470
+ }
471
+
472
+ const boundMethods = [
473
+ "enqueue",
474
+ "poll",
475
+ "attempts",
476
+ "awaitResult",
477
+ "execute",
478
+ "retry",
479
+ "toLayer"
480
+ ] as const
481
+
482
+ const makeProto = (options: {
483
+ readonly _tag: string
484
+ readonly queue: QueueName
485
+ readonly store: Context.Key<any, StoreService>
486
+ readonly payloadSchema: Schema.Top
487
+ readonly payloadJsonSchema: Schema.Top
488
+ readonly successSchema: Schema.Top
489
+ readonly errorSchema: Schema.Top
490
+ readonly exitSchema: Schema.Top
491
+ readonly idempotencyKey: ((payload: any) => string) | undefined
492
+ readonly metadata: ((payload: any) => Readonly<Record<string, string>>) | undefined
493
+ readonly defaults: ResolvedDefaults
494
+ }): any => {
495
+ function JobDefinition() {}
496
+ Object.setPrototypeOf(JobDefinition, Proto)
497
+ Object.assign(JobDefinition, options)
498
+ // Cosmetic only (job identity is `_tag`): function `name` is read-only for
499
+ // assignment but configurable.
500
+ Object.defineProperty(JobDefinition, "name", { value: options._tag, configurable: true })
501
+ // Bind the API as own properties so methods survive destructuring
502
+ // (`const { enqueue } = MyJob`) and passing as values.
503
+ for (const key of boundMethods) {
504
+ // SAFETY: every entry in `boundMethods` is a `this`-dependent function on
505
+ // `Proto`; binding only fixes the receiver. The precise signatures are
506
+ // re-declared by the public `Job` interface.
507
+ const method = Proto[key] as (...args: ReadonlyArray<never>) => object
508
+ Object.defineProperty(JobDefinition, key, {
509
+ value: method.bind(JobDefinition),
510
+ configurable: true
511
+ })
512
+ }
513
+ return JobDefinition
514
+ }
515
+
516
+ /**
517
+ * Define a job.
518
+ *
519
+ * @since 0.1.0
520
+ */
521
+ export const make = <
522
+ const Name extends string,
523
+ Payload extends Schema.Struct.Fields | AnyStructSchema,
524
+ Success extends Schema.Top = Schema.Void,
525
+ Error extends Schema.Top = Schema.Never,
526
+ StoreId = JobStore
527
+ >(
528
+ name: Name,
529
+ options: {
530
+ readonly payload: Payload
531
+ readonly success?: Success | undefined
532
+ readonly error?: Error | undefined
533
+ /**
534
+ * Derive a stable job id from the payload. Enqueueing the same key twice
535
+ * while the first job still exists is a no-op (returns the existing id).
536
+ */
537
+ readonly idempotencyKey?:
538
+ | ((
539
+ payload: Payload extends Schema.Struct.Fields ? Schema.Struct.Type<Payload>
540
+ : Payload["Type"]
541
+ ) => string)
542
+ | undefined
543
+ /**
544
+ * Derive queryable business context from the payload (flat string map, so
545
+ * every driver can index it). Merged with per-enqueue `metadata`.
546
+ */
547
+ readonly metadata?:
548
+ | ((
549
+ payload: Payload extends Schema.Struct.Fields ? Schema.Struct.Type<Payload>
550
+ : Payload["Type"]
551
+ ) => Readonly<Record<string, string>>)
552
+ | undefined
553
+ /** The queue this job runs on. Default `"default"`. */
554
+ readonly queue?: string | undefined
555
+ /**
556
+ * The store this job's runs live on (a `JobStore.named(...)` key).
557
+ * Default: the default `JobStore`.
558
+ */
559
+ readonly store?: Context.Key<StoreId, StoreService> | undefined
560
+ readonly defaults?: JobOptions | undefined
561
+ }
562
+ ): Job<
563
+ Name,
564
+ Payload extends Schema.Struct.Fields ? Schema.Struct<Payload> : Payload,
565
+ Success,
566
+ Error,
567
+ StoreId
568
+ > => {
569
+ // SAFETY: `Schema.isSchema` discriminates the `Payload` union at runtime;
570
+ // TypeScript cannot narrow an unresolved generic, so each branch asserts
571
+ // the side the guard just proved.
572
+ const payloadSchema = Schema.isSchema(options.payload)
573
+ ? options.payload as AnyStructSchema
574
+ : Schema.Struct(options.payload as Schema.Struct.Fields)
575
+ const successSchema = options.success ?? Schema.Void
576
+ const errorSchema = options.error ?? Schema.Never
577
+ // Wrapping the whole Exit schema in the JSON codec makes the *encoded* side
578
+ // plain JSON (a live Exit/Cause instance would not survive serializing
579
+ // drivers like Redis/Postgres).
580
+ const exitSchema = Schema.toCodecJson(Schema.Exit(
581
+ Schema.toCodecJson(successSchema),
582
+ Schema.toCodecJson(errorSchema),
583
+ Schema.Defect()
584
+ ))
585
+ return makeProto({
586
+ _tag: name,
587
+ queue: QueueName(options.queue ?? "default"),
588
+ store: options.store ?? JobStore,
589
+ payloadSchema,
590
+ payloadJsonSchema: Schema.toCodecJson(payloadSchema),
591
+ successSchema,
592
+ errorSchema,
593
+ exitSchema,
594
+ idempotencyKey: options.idempotencyKey,
595
+ metadata: options.metadata,
596
+ defaults: {
597
+ delayMs: options.defaults?.delay !== undefined
598
+ ? Duration.toMillis(options.defaults.delay)
599
+ : 0,
600
+ priority: options.defaults?.priority ?? 0,
601
+ attempts: Math.max(1, options.defaults?.attempts ?? 1),
602
+ backoff: normalizeBackoff(options.defaults?.backoff),
603
+ keep: normalizeKeep(options.defaults?.keep)
604
+ }
605
+ })
606
+ }