@beignet/core 0.0.31 → 0.0.33

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 (156) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +346 -21
  3. package/dist/application/index.d.ts.map +1 -1
  4. package/dist/application/index.js +63 -47
  5. package/dist/application/index.js.map +1 -1
  6. package/dist/domain/entity.d.ts +37 -6
  7. package/dist/domain/entity.d.ts.map +1 -1
  8. package/dist/domain/entity.js +9 -7
  9. package/dist/domain/entity.js.map +1 -1
  10. package/dist/domain/index.d.ts +4 -4
  11. package/dist/domain/index.d.ts.map +1 -1
  12. package/dist/domain/index.js +4 -4
  13. package/dist/domain/index.js.map +1 -1
  14. package/dist/events/index.d.ts +6 -0
  15. package/dist/events/index.d.ts.map +1 -1
  16. package/dist/events/index.js +20 -10
  17. package/dist/events/index.js.map +1 -1
  18. package/dist/flags/index.d.ts +5 -4
  19. package/dist/flags/index.d.ts.map +1 -1
  20. package/dist/flags/index.js +10 -9
  21. package/dist/flags/index.js.map +1 -1
  22. package/dist/idempotency/index.d.ts +11 -1
  23. package/dist/idempotency/index.d.ts.map +1 -1
  24. package/dist/idempotency/index.js +4 -3
  25. package/dist/idempotency/index.js.map +1 -1
  26. package/dist/jobs/index.d.ts +449 -9
  27. package/dist/jobs/index.d.ts.map +1 -1
  28. package/dist/jobs/index.js +472 -12
  29. package/dist/jobs/index.js.map +1 -1
  30. package/dist/outbox/index.d.ts +170 -3
  31. package/dist/outbox/index.d.ts.map +1 -1
  32. package/dist/outbox/index.js +192 -8
  33. package/dist/outbox/index.js.map +1 -1
  34. package/dist/ports/events.d.ts +2 -2
  35. package/dist/ports/index.d.ts +6 -1
  36. package/dist/ports/index.d.ts.map +1 -1
  37. package/dist/ports/index.js +1 -0
  38. package/dist/ports/index.js.map +1 -1
  39. package/dist/providers/instrumentation.d.ts +4 -0
  40. package/dist/providers/instrumentation.d.ts.map +1 -1
  41. package/dist/providers/instrumentation.js.map +1 -1
  42. package/dist/providers/provider.d.ts +10 -2
  43. package/dist/providers/provider.d.ts.map +1 -1
  44. package/dist/providers/provider.js.map +1 -1
  45. package/dist/schedules/index.d.ts +8 -7
  46. package/dist/schedules/index.d.ts.map +1 -1
  47. package/dist/schedules/index.js +47 -16
  48. package/dist/schedules/index.js.map +1 -1
  49. package/dist/search/index.d.ts +2 -1
  50. package/dist/search/index.d.ts.map +1 -1
  51. package/dist/search/index.js +1 -0
  52. package/dist/search/index.js.map +1 -1
  53. package/dist/server/hooks/index.d.ts +1 -0
  54. package/dist/server/hooks/index.d.ts.map +1 -1
  55. package/dist/server/hooks/index.js +1 -0
  56. package/dist/server/hooks/index.js.map +1 -1
  57. package/dist/server/hooks/rate-limit.d.ts +26 -13
  58. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  59. package/dist/server/hooks/rate-limit.js +28 -34
  60. package/dist/server/hooks/rate-limit.js.map +1 -1
  61. package/dist/server/hooks/security.d.ts +179 -0
  62. package/dist/server/hooks/security.d.ts.map +1 -0
  63. package/dist/server/hooks/security.js +225 -0
  64. package/dist/server/hooks/security.js.map +1 -0
  65. package/dist/server/index.d.ts +8 -0
  66. package/dist/server/index.d.ts.map +1 -1
  67. package/dist/server/index.js +8 -0
  68. package/dist/server/index.js.map +1 -1
  69. package/dist/server/instrumentation.d.ts.map +1 -1
  70. package/dist/server/instrumentation.js +22 -6
  71. package/dist/server/instrumentation.js.map +1 -1
  72. package/dist/server/providers/loadProviderConfig.d.ts.map +1 -1
  73. package/dist/server/providers/loadProviderConfig.js +15 -2
  74. package/dist/server/providers/loadProviderConfig.js.map +1 -1
  75. package/dist/server/request-context.d.ts +4 -0
  76. package/dist/server/request-context.d.ts.map +1 -1
  77. package/dist/server/request-context.js +3 -0
  78. package/dist/server/request-context.js.map +1 -1
  79. package/dist/server/runtime-integrity.d.ts +84 -0
  80. package/dist/server/runtime-integrity.d.ts.map +1 -0
  81. package/dist/server/runtime-integrity.js +160 -0
  82. package/dist/server/runtime-integrity.js.map +1 -0
  83. package/dist/server/server.d.ts +10 -0
  84. package/dist/server/server.d.ts.map +1 -1
  85. package/dist/server/server.js +55 -1
  86. package/dist/server/server.js.map +1 -1
  87. package/dist/server/trusted-proxy.d.ts +77 -0
  88. package/dist/server/trusted-proxy.d.ts.map +1 -0
  89. package/dist/server/trusted-proxy.js +129 -0
  90. package/dist/server/trusted-proxy.js.map +1 -0
  91. package/dist/tasks/index.d.ts +6 -7
  92. package/dist/tasks/index.d.ts.map +1 -1
  93. package/dist/tasks/index.js +22 -5
  94. package/dist/tasks/index.js.map +1 -1
  95. package/dist/tenancy/index.d.ts +41 -0
  96. package/dist/tenancy/index.d.ts.map +1 -0
  97. package/dist/tenancy/index.js +34 -0
  98. package/dist/tenancy/index.js.map +1 -0
  99. package/dist/testing/index.d.ts +6 -0
  100. package/dist/testing/index.d.ts.map +1 -1
  101. package/dist/testing/index.js +12 -3
  102. package/dist/testing/index.js.map +1 -1
  103. package/dist/tracing/execution.d.ts +14 -0
  104. package/dist/tracing/execution.d.ts.map +1 -0
  105. package/dist/tracing/execution.js +17 -0
  106. package/dist/tracing/execution.js.map +1 -0
  107. package/dist/tracing/index.d.ts +49 -0
  108. package/dist/tracing/index.d.ts.map +1 -1
  109. package/dist/tracing/index.js +59 -0
  110. package/dist/tracing/index.js.map +1 -1
  111. package/dist/uploads/client.d.ts.map +1 -1
  112. package/dist/uploads/client.js +44 -6
  113. package/dist/uploads/client.js.map +1 -1
  114. package/dist/uploads/index.d.ts +182 -2
  115. package/dist/uploads/index.d.ts.map +1 -1
  116. package/dist/uploads/index.js +468 -76
  117. package/dist/uploads/index.js.map +1 -1
  118. package/dist/webhooks/index.d.ts +56 -4
  119. package/dist/webhooks/index.d.ts.map +1 -1
  120. package/dist/webhooks/index.js +107 -2
  121. package/dist/webhooks/index.js.map +1 -1
  122. package/package.json +5 -1
  123. package/skills/app-architecture/SKILL.md +23 -2
  124. package/src/application/index.ts +88 -56
  125. package/src/domain/entity.ts +61 -13
  126. package/src/domain/index.ts +8 -7
  127. package/src/events/index.ts +27 -14
  128. package/src/flags/index.ts +23 -19
  129. package/src/idempotency/index.ts +17 -3
  130. package/src/jobs/index.ts +1022 -19
  131. package/src/outbox/index.ts +409 -9
  132. package/src/ports/events.ts +2 -2
  133. package/src/ports/index.ts +17 -0
  134. package/src/providers/instrumentation.ts +4 -0
  135. package/src/providers/provider.ts +11 -2
  136. package/src/schedules/index.ts +60 -26
  137. package/src/search/index.ts +3 -1
  138. package/src/server/hooks/index.ts +10 -0
  139. package/src/server/hooks/rate-limit.ts +52 -46
  140. package/src/server/hooks/security.ts +503 -0
  141. package/src/server/index.ts +8 -0
  142. package/src/server/instrumentation.ts +25 -5
  143. package/src/server/providers/loadProviderConfig.ts +19 -2
  144. package/src/server/request-context.ts +7 -0
  145. package/src/server/runtime-integrity.ts +322 -0
  146. package/src/server/server.ts +86 -1
  147. package/src/server/trusted-proxy.ts +249 -0
  148. package/src/tasks/index.ts +29 -12
  149. package/src/tenancy/index.ts +55 -0
  150. package/src/testing/index.ts +19 -3
  151. package/src/tracing/execution.ts +37 -0
  152. package/src/tracing/index.ts +137 -0
  153. package/src/uploads/client.ts +65 -6
  154. package/src/uploads/index.ts +713 -76
  155. package/src/webhooks/index.ts +240 -9
  156. package/src/domain/events.ts +0 -59
package/src/jobs/index.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import type { LeaseHandle, LeaseMetadata, LocksPort } from "../locks/index.js";
3
+ import { runWithResolvedTracingContext } from "../tracing/execution.js";
4
+ import type { TracingPort } from "../tracing/index.js";
2
5
 
3
6
  /**
4
7
  * Any Standard Schema compatible validator.
@@ -10,6 +13,8 @@ export type StandardSchema = StandardSchemaV1<unknown, unknown>;
10
13
  */
11
14
  export type MaybePromise<T> = T | Promise<T>;
12
15
 
16
+ type NoInferJob<T> = [T][T extends unknown ? 0 : never];
17
+
13
18
  /**
14
19
  * Infer the parsed output type from a Standard Schema.
15
20
  */
@@ -17,15 +22,30 @@ export type InferSchemaOutput<T extends StandardSchemaV1> =
17
22
  StandardSchemaV1.InferOutput<T>;
18
23
 
19
24
  /**
20
- * Duration accepted by retry helpers. Numbers are milliseconds.
25
+ * Duration accepted by job helpers. Numbers are milliseconds.
21
26
  */
22
- export type JobRetryDuration =
27
+ export type JobDuration =
23
28
  | number
24
29
  | `${number}ms`
25
30
  | `${number}s`
26
31
  | `${number}m`
27
32
  | `${number}h`;
28
33
 
34
+ /**
35
+ * Duration accepted by retry helpers. Numbers are milliseconds.
36
+ */
37
+ export type JobRetryDuration = JobDuration;
38
+
39
+ /**
40
+ * Duration accepted by job timeout helpers. Numbers are milliseconds.
41
+ */
42
+ export type JobTimeoutDuration = JobDuration;
43
+
44
+ /**
45
+ * Duration accepted by job execution lease helpers. Numbers are milliseconds.
46
+ */
47
+ export type JobExecutionLeaseDuration = JobDuration;
48
+
29
49
  /**
30
50
  * Retry strategy understood by Beignet job adapters.
31
51
  */
@@ -58,6 +78,71 @@ export interface JobRetryPredicateArgs {
58
78
  */
59
79
  export type JobRetryPredicate = (args: JobRetryPredicateArgs) => boolean;
60
80
 
81
+ /**
82
+ * Dispatch-time uniqueness metadata for a job.
83
+ *
84
+ * Unique jobs are suppressed while the resolved key's lease is active.
85
+ * The lease is intentionally left to expire after a successful dispatch.
86
+ */
87
+ export interface JobUniqueOptions {
88
+ /**
89
+ * Logical uniqueness key within this job name.
90
+ */
91
+ key: string;
92
+ /**
93
+ * How long duplicate dispatches should be suppressed.
94
+ */
95
+ ttl: JobDuration;
96
+ }
97
+
98
+ /**
99
+ * Arguments passed to a per-payload unique job resolver.
100
+ */
101
+ export interface JobUniqueResolverArgs<Payload extends StandardSchema> {
102
+ /**
103
+ * Stable job name.
104
+ */
105
+ jobName: string;
106
+ /**
107
+ * Parsed job payload.
108
+ */
109
+ payload: StandardSchemaV1.InferOutput<Payload>;
110
+ }
111
+
112
+ /**
113
+ * Per-payload resolver for dispatch-time uniqueness.
114
+ */
115
+ export type JobUniqueResolver<Payload extends StandardSchema> = {
116
+ bivarianceHack(
117
+ args: JobUniqueResolverArgs<Payload>,
118
+ ): MaybePromise<JobUniqueOptions | null | undefined>;
119
+ }["bivarianceHack"];
120
+
121
+ /**
122
+ * Unique job declaration value.
123
+ */
124
+ export type JobUniqueConfig<Payload extends StandardSchema = StandardSchema> =
125
+ | JobUniqueOptions
126
+ | JobUniqueResolver<Payload>;
127
+
128
+ /**
129
+ * Resolved uniqueness metadata used by dispatchers.
130
+ */
131
+ export interface ResolvedJobUniqueOptions {
132
+ /**
133
+ * Logical uniqueness key returned by the job declaration.
134
+ */
135
+ key: string;
136
+ /**
137
+ * Concrete lock key used by `LocksPort`.
138
+ */
139
+ lockKey: string;
140
+ /**
141
+ * Lease TTL in milliseconds.
142
+ */
143
+ ttlMs: number;
144
+ }
145
+
61
146
  /**
62
147
  * Job definition created by `defineJob(...)`.
63
148
  */
@@ -86,6 +171,18 @@ export interface JobDef<
86
171
  * Retry metadata for durable job providers.
87
172
  */
88
173
  readonly retry?: JobRetryOptions;
174
+ /**
175
+ * Optional dispatch-time uniqueness guard.
176
+ */
177
+ readonly unique?: JobUniqueConfig<Payload>;
178
+ /**
179
+ * Optional maximum execution time for each job handler attempt.
180
+ */
181
+ readonly timeout?: JobTimeoutDuration;
182
+ /**
183
+ * Optional hooks that wrap each handler attempt for this job.
184
+ */
185
+ readonly hooks?: readonly JobHook<JobDef<Name, Payload, Ctx>, Ctx>[];
89
186
  /**
90
187
  * Handle a parsed job payload.
91
188
  */
@@ -112,10 +209,174 @@ export interface JobHandleArgs<J extends JobDef, Ctx> {
112
209
  * Parsed job payload.
113
210
  */
114
211
  payload: InferJobPayload<J>;
212
+ /** Handler context. */
213
+ ctx: Ctx;
115
214
  /**
116
- * Handler context.
215
+ * Abort signal that fires when the job's declared timeout expires.
216
+ *
217
+ * Beignet dispatchers and worker helpers provide this signal. It is optional
218
+ * in the type so direct handler tests can stay lightweight.
219
+ *
220
+ * JavaScript cannot forcibly stop arbitrary async work, so handlers that
221
+ * call cancellable APIs should pass this signal through.
117
222
  */
118
- ctx: Ctx;
223
+ signal?: AbortSignal;
224
+ }
225
+
226
+ /**
227
+ * Arguments passed to job execution hooks.
228
+ */
229
+ export interface JobHookArgs<J extends JobDef, Ctx>
230
+ extends JobHandleArgs<J, Ctx> {
231
+ /**
232
+ * Abort signal provided by the Beignet execution runner.
233
+ */
234
+ signal: AbortSignal;
235
+ /**
236
+ * One-based execution attempt when the runner can report it.
237
+ */
238
+ attempt?: number;
239
+ /**
240
+ * Maximum execution attempts when the runner can report it.
241
+ */
242
+ maxAttempts?: number;
243
+ }
244
+
245
+ /**
246
+ * Hook that wraps one job handler attempt.
247
+ *
248
+ * Hooks run only through Beignet dispatchers and worker helpers. Direct calls
249
+ * to `job.handle(...)` bypass hooks, which keeps lightweight unit tests
250
+ * possible.
251
+ */
252
+ export type JobHook<J extends JobDef = JobDef, Ctx = unknown> = {
253
+ bivarianceHack(
254
+ args: JobHookArgs<J, Ctx>,
255
+ next: () => Promise<void>,
256
+ ): MaybePromise<void>;
257
+ }["bivarianceHack"];
258
+
259
+ /**
260
+ * Arguments passed to an execution lease key resolver.
261
+ */
262
+ export type JobExecutionLeaseResolverArgs<J extends JobDef, Ctx> = JobHookArgs<
263
+ J,
264
+ Ctx
265
+ >;
266
+
267
+ /**
268
+ * Resolver for the logical execution lease key within a job name.
269
+ */
270
+ export type JobExecutionLeaseKeyResolver<J extends JobDef, Ctx> = {
271
+ bivarianceHack(
272
+ args: JobExecutionLeaseResolverArgs<J, Ctx>,
273
+ ): MaybePromise<string>;
274
+ }["bivarianceHack"];
275
+
276
+ /**
277
+ * Resolver for the locks port used by an execution lease hook.
278
+ */
279
+ export type JobExecutionLeaseLocksResolver<J extends JobDef, Ctx> =
280
+ | LocksPort
281
+ | {
282
+ bivarianceHack(
283
+ args: JobExecutionLeaseResolverArgs<J, Ctx>,
284
+ ): MaybePromise<LocksPort>;
285
+ }["bivarianceHack"];
286
+
287
+ /**
288
+ * Resolver for optional diagnostics metadata attached to lease acquisition.
289
+ */
290
+ export type JobExecutionLeaseMetadataResolver<J extends JobDef, Ctx> =
291
+ | LeaseMetadata
292
+ | {
293
+ bivarianceHack(
294
+ args: JobExecutionLeaseResolverArgs<J, Ctx>,
295
+ ): MaybePromise<LeaseMetadata | undefined>;
296
+ }["bivarianceHack"];
297
+
298
+ /**
299
+ * Arguments passed when an execution lease cannot be acquired.
300
+ */
301
+ export interface JobExecutionLeaseUnavailableArgs<J extends JobDef, Ctx>
302
+ extends JobHookArgs<J, Ctx> {
303
+ /**
304
+ * Logical lease key returned by the hook configuration.
305
+ */
306
+ key: string;
307
+ /**
308
+ * Concrete lock key passed to `LocksPort`.
309
+ */
310
+ lockKey: string;
311
+ /**
312
+ * Acquisition failure reason returned by `LocksPort`.
313
+ */
314
+ reason: "unavailable" | "timeout";
315
+ }
316
+
317
+ /**
318
+ * Behavior when an execution lease cannot be acquired.
319
+ *
320
+ * `"skip"` treats the attempt as successful without running the handler.
321
+ * `"throw"` fails the attempt with `JobExecutionLeaseUnavailableError` so the
322
+ * job retry policy can classify it. A function can log or throw custom errors;
323
+ * returning from it skips the handler.
324
+ */
325
+ export type JobExecutionLeaseUnavailableBehavior<J extends JobDef, Ctx> =
326
+ | "skip"
327
+ | "throw"
328
+ | {
329
+ bivarianceHack(
330
+ args: JobExecutionLeaseUnavailableArgs<J, Ctx>,
331
+ ): MaybePromise<void>;
332
+ }["bivarianceHack"];
333
+
334
+ /**
335
+ * Options for `createJobExecutionLeaseHook(...)`.
336
+ */
337
+ export interface JobExecutionLeaseHookOptions<
338
+ J extends JobDef = JobDef,
339
+ Ctx = unknown,
340
+ > {
341
+ /**
342
+ * Locks port or resolver used to acquire the execution lease.
343
+ */
344
+ locks: JobExecutionLeaseLocksResolver<J, Ctx>;
345
+ /**
346
+ * Logical lease key within this job name.
347
+ */
348
+ key: string | JobExecutionLeaseKeyResolver<J, Ctx>;
349
+ /**
350
+ * Lease time-to-live. This is the real safety boundary in serverless runtimes
351
+ * where an invocation may terminate before `finally` runs.
352
+ */
353
+ ttl: JobExecutionLeaseDuration;
354
+ /**
355
+ * How long to wait for an existing lease before applying `onUnavailable`.
356
+ *
357
+ * Defaults to no wait.
358
+ */
359
+ wait?: JobExecutionLeaseDuration;
360
+ /**
361
+ * Delay between acquisition attempts while waiting.
362
+ */
363
+ retryDelay?: JobExecutionLeaseDuration;
364
+ /**
365
+ * Prefix used to build the concrete lock key.
366
+ *
367
+ * Defaults to `"jobs:lease"`.
368
+ */
369
+ keyPrefix?: string;
370
+ /**
371
+ * Optional diagnostics metadata attached to the lease acquire call.
372
+ */
373
+ metadata?: JobExecutionLeaseMetadataResolver<J, Ctx>;
374
+ /**
375
+ * Behavior when the lease cannot be acquired.
376
+ *
377
+ * Defaults to `"skip"` so overlapping executions do not create retry storms.
378
+ */
379
+ onUnavailable?: JobExecutionLeaseUnavailableBehavior<J, Ctx>;
119
380
  }
120
381
 
121
382
  /**
@@ -177,6 +438,21 @@ export interface DefineJobOptions<
177
438
  * Retry metadata for durable job providers.
178
439
  */
179
440
  retry?: JobRetryOptions;
441
+ /**
442
+ * Optional dispatch-time uniqueness guard.
443
+ */
444
+ unique?: JobUniqueConfig<Payload>;
445
+ /**
446
+ * Optional maximum execution time for each job handler attempt.
447
+ */
448
+ timeout?: JobTimeoutDuration;
449
+ /**
450
+ * Optional hooks that wrap each handler attempt for this job.
451
+ */
452
+ hooks?: readonly JobHook<
453
+ JobDef<NoInferJob<Name>, NoInferJob<Payload>, Ctx>,
454
+ Ctx
455
+ >[];
180
456
  /**
181
457
  * Handle a parsed job payload.
182
458
  */
@@ -284,10 +560,126 @@ export interface InlineJobDispatcherOptions<Ctx> {
284
560
  */
285
561
  ctx?: Ctx | (() => MaybePromise<Ctx>);
286
562
  /**
287
- * Called when a dispatched inline job fails. When omitted, errors are
288
- * rethrown to the caller.
563
+ * Called when a dispatched inline job fails all attempts allowed by its
564
+ * retry policy. When omitted, the final error is rethrown to the caller.
289
565
  */
290
566
  onError?: (error: unknown, job: JobDef<string, StandardSchema, Ctx>) => void;
567
+ /**
568
+ * Sleep implementation used between retry attempts. Defaults to a real
569
+ * `setTimeout` delay; inject a fake in tests to keep retries instant.
570
+ */
571
+ sleep?: (ms: number) => Promise<void>;
572
+ /**
573
+ * Honor the job's declared retry policy inline. Defaults to `true`. Set
574
+ * `false` when another layer owns execution retries for every dispatch
575
+ * through this dispatcher.
576
+ */
577
+ retry?: boolean;
578
+ /**
579
+ * Hooks that wrap every job attempt executed by this dispatcher. Runner hooks
580
+ * wrap job-local hooks.
581
+ */
582
+ hooks?: readonly JobHook<JobDef<string, StandardSchema, Ctx>, Ctx>[];
583
+ }
584
+
585
+ /**
586
+ * Well-known symbol under which the inline dispatcher exposes a
587
+ * single-attempt dispatch. Delivery systems that own execution retries
588
+ * themselves — the outbox drain — call it instead of `dispatch(...)` so a
589
+ * job's retry policy runs in exactly one layer. Registered with `Symbol.for`
590
+ * so multiple core copies in one process agree on the key.
591
+ */
592
+ export const SINGLE_ATTEMPT_DISPATCH: unique symbol = Symbol.for(
593
+ "beignet.jobs.singleAttemptDispatch",
594
+ );
595
+
596
+ /**
597
+ * Metadata passed to a single-attempt dispatch when another delivery layer
598
+ * owns retry scheduling.
599
+ */
600
+ export interface SingleAttemptJobDispatchOptions {
601
+ /**
602
+ * One-based delivery attempt.
603
+ */
604
+ attempt?: number;
605
+ /**
606
+ * Maximum delivery attempts.
607
+ */
608
+ maxAttempts?: number;
609
+ }
610
+
611
+ /**
612
+ * Shape of the single-attempt dispatch exposed under
613
+ * `SINGLE_ATTEMPT_DISPATCH`.
614
+ */
615
+ export type SingleAttemptJobDispatch = <J extends JobDef>(
616
+ job: J,
617
+ payload: InferJobPayload<J>,
618
+ options?: SingleAttemptJobDispatchOptions,
619
+ ) => Promise<void>;
620
+
621
+ /**
622
+ * Job dispatcher shape accepted by job wrappers.
623
+ */
624
+ export interface JobDispatcher {
625
+ dispatch<J extends JobDef>(
626
+ job: J,
627
+ payload: InferJobPayload<J>,
628
+ ): MaybePromise<void>;
629
+ }
630
+
631
+ /**
632
+ * Arguments passed when a unique job dispatch is suppressed.
633
+ */
634
+ export interface UniqueJobDuplicateArgs<J extends JobDef = JobDef> {
635
+ /**
636
+ * Job definition whose dispatch was suppressed.
637
+ */
638
+ job: J;
639
+ /**
640
+ * Parsed job payload.
641
+ */
642
+ payload: InferJobPayload<J>;
643
+ /**
644
+ * Logical uniqueness key returned by the job declaration.
645
+ */
646
+ key: string;
647
+ /**
648
+ * Concrete lock key used by `LocksPort`.
649
+ */
650
+ lockKey: string;
651
+ /**
652
+ * Lease TTL in milliseconds.
653
+ */
654
+ ttlMs: number;
655
+ /**
656
+ * Lock acquisition reason returned by `LocksPort`.
657
+ */
658
+ reason: "unavailable" | "timeout";
659
+ }
660
+
661
+ /**
662
+ * Options for `createUniqueJobDispatcher(...)`.
663
+ */
664
+ export interface UniqueJobDispatcherOptions {
665
+ /**
666
+ * Dispatcher to call when a job is not unique or the unique lease is acquired.
667
+ */
668
+ jobs: JobDispatcher;
669
+ /**
670
+ * Lease-backed lock port used as the uniqueness guard.
671
+ */
672
+ locks: LocksPort;
673
+ /**
674
+ * Prefix for concrete lock keys.
675
+ *
676
+ * Defaults to `"jobs:unique"`.
677
+ */
678
+ keyPrefix?: string;
679
+ /**
680
+ * Called when a duplicate dispatch is suppressed.
681
+ */
682
+ onDuplicate?: (args: UniqueJobDuplicateArgs) => MaybePromise<void>;
291
683
  }
292
684
 
293
685
  /**
@@ -295,7 +687,8 @@ export interface InlineJobDispatcherOptions<Ctx> {
295
687
  */
296
688
  export interface InlineJobDispatcher<Ctx = unknown> {
297
689
  /**
298
- * Validate a payload and run the job handler immediately.
690
+ * Validate a payload and run the job handler inline, honoring the job's
691
+ * declared retry policy before failing.
299
692
  */
300
693
  dispatch<J extends JobDef<string, StandardSchema, Ctx>>(
301
694
  job: J,
@@ -337,6 +730,66 @@ export class JobValidationError extends Error {
337
730
  }
338
731
  }
339
732
 
733
+ /**
734
+ * Error thrown when a job handler exceeds its declared timeout.
735
+ */
736
+ export class JobTimeoutError extends Error {
737
+ /**
738
+ * Stable job name that timed out.
739
+ */
740
+ readonly jobName: string;
741
+ /**
742
+ * Timeout in milliseconds.
743
+ */
744
+ readonly timeoutMs: number;
745
+
746
+ constructor(args: { jobName: string; timeoutMs: number }) {
747
+ super(`Job "${args.jobName}" timed out after ${args.timeoutMs}ms.`);
748
+ this.name = "JobTimeoutError";
749
+ this.jobName = args.jobName;
750
+ this.timeoutMs = args.timeoutMs;
751
+ }
752
+ }
753
+
754
+ /**
755
+ * Error thrown when an execution lease hook is configured to fail on an
756
+ * unavailable lease.
757
+ */
758
+ export class JobExecutionLeaseUnavailableError extends Error {
759
+ /**
760
+ * Stable job name whose execution lease was unavailable.
761
+ */
762
+ readonly jobName: string;
763
+ /**
764
+ * Logical lease key returned by the hook configuration.
765
+ */
766
+ readonly key: string;
767
+ /**
768
+ * Concrete lock key passed to `LocksPort`.
769
+ */
770
+ readonly lockKey: string;
771
+ /**
772
+ * Acquisition failure reason returned by `LocksPort`.
773
+ */
774
+ readonly reason: "unavailable" | "timeout";
775
+
776
+ constructor(args: {
777
+ jobName: string;
778
+ key: string;
779
+ lockKey: string;
780
+ reason: "unavailable" | "timeout";
781
+ }) {
782
+ super(
783
+ `Job "${args.jobName}" execution lease "${args.key}" is unavailable (${args.reason}).`,
784
+ );
785
+ this.name = "JobExecutionLeaseUnavailableError";
786
+ this.jobName = args.jobName;
787
+ this.key = args.key;
788
+ this.lockKey = args.lockKey;
789
+ this.reason = args.reason;
790
+ }
791
+ }
792
+
340
793
  function formatPath(path: StandardSchemaV1.Issue["path"]): string {
341
794
  if (!path?.length) return "";
342
795
 
@@ -370,12 +823,24 @@ function assertPositiveNumber(name: string, value: number): void {
370
823
  }
371
824
  }
372
825
 
373
- function durationToMs(name: string, value: JobRetryDuration): number {
826
+ function assertNonEmptyString(name: string, value: string): void {
827
+ if (typeof value !== "string" || value.trim().length === 0) {
828
+ throw new Error(`${name} must be a non-empty string`);
829
+ }
830
+ }
831
+
832
+ function durationToMs(name: string, value: JobDuration | unknown): number {
374
833
  if (typeof value === "number") {
375
834
  assertPositiveInteger(name, value);
376
835
  return value;
377
836
  }
378
837
 
838
+ if (typeof value !== "string") {
839
+ throw new Error(
840
+ `${name} must be a positive millisecond value or duration string like "500ms", "30s", "5m", or "1h".`,
841
+ );
842
+ }
843
+
379
844
  const match = /^(\d+)(ms|s|m|h)$/.exec(value);
380
845
  if (!match) {
381
846
  throw new Error(
@@ -400,7 +865,50 @@ function durationToMs(name: string, value: JobRetryDuration): number {
400
865
  }
401
866
  }
402
867
 
868
+ function isRecord(value: unknown): value is Record<string, unknown> {
869
+ return typeof value === "object" && value !== null;
870
+ }
871
+
872
+ function validateJobUniqueOptions(options: JobUniqueOptions): JobUniqueOptions {
873
+ if (!isRecord(options)) {
874
+ throw new Error("unique must be an object or resolver function");
875
+ }
876
+
877
+ assertNonEmptyString("unique.key", options.key);
878
+ durationToMs("unique.ttl", options.ttl);
879
+ return options;
880
+ }
881
+
882
+ function validateJobUniqueConfig<Payload extends StandardSchema>(
883
+ config: JobUniqueConfig<Payload> | undefined,
884
+ ): JobUniqueConfig<Payload> | undefined {
885
+ if (config === undefined) return undefined;
886
+ if (typeof config === "function") return config;
887
+ return validateJobUniqueOptions(config);
888
+ }
889
+
890
+ function validateJobTimeout(
891
+ timeout: JobTimeoutDuration | undefined,
892
+ ): JobTimeoutDuration | undefined {
893
+ if (timeout === undefined) return undefined;
894
+ durationToMs("timeout", timeout);
895
+ return timeout;
896
+ }
897
+
898
+ const VALIDATED_RETRY_OPTIONS = Symbol("beignet.jobs.validatedRetryOptions");
899
+
403
900
  function validateJobRetryOptions(options: JobRetryOptions): JobRetryOptions {
901
+ // Options returned by this function are branded so repeated validation —
902
+ // per attempt in the retry helpers — short-circuits instead of re-parsing
903
+ // duration strings and reallocating.
904
+ if (
905
+ (options as { [VALIDATED_RETRY_OPTIONS]?: boolean })[
906
+ VALIDATED_RETRY_OPTIONS
907
+ ]
908
+ ) {
909
+ return options;
910
+ }
911
+
404
912
  const strategy = options.strategy ?? "exponential";
405
913
 
406
914
  if (!["none", "fixed", "exponential"].includes(strategy)) {
@@ -436,11 +944,16 @@ function validateJobRetryOptions(options: JobRetryOptions): JobRetryOptions {
436
944
  }
437
945
  }
438
946
 
439
- return {
947
+ const validated = {
440
948
  ...options,
441
949
  strategy,
442
950
  attempts,
443
951
  };
952
+ Object.defineProperty(validated, VALIDATED_RETRY_OPTIONS, {
953
+ value: true,
954
+ enumerable: false,
955
+ });
956
+ return validated;
444
957
  }
445
958
 
446
959
  /**
@@ -517,6 +1030,162 @@ export function getJobRetryDelayMs(
517
1030
  return delayMs;
518
1031
  }
519
1032
 
1033
+ /**
1034
+ * Return the execution timeout in milliseconds configured by a job.
1035
+ */
1036
+ export function getJobTimeoutMs(
1037
+ job: Pick<JobDef, "timeout">,
1038
+ ): number | undefined {
1039
+ return job.timeout === undefined
1040
+ ? undefined
1041
+ : durationToMs("timeout", job.timeout);
1042
+ }
1043
+
1044
+ function jobExecutionLeaseLockKey(
1045
+ jobName: string,
1046
+ key: string,
1047
+ keyPrefix = "jobs:lease",
1048
+ ): string {
1049
+ return `${keyPrefix}:${jobName}:${key}`;
1050
+ }
1051
+
1052
+ function validateExecutionLeaseKey(key: string): string {
1053
+ assertNonEmptyString("executionLease.key", key);
1054
+ return key;
1055
+ }
1056
+
1057
+ function abortReason(signal: AbortSignal): unknown {
1058
+ return signal.reason ?? new Error("Job execution aborted.");
1059
+ }
1060
+
1061
+ async function resolveExecutionLeaseLocks<J extends JobDef, Ctx>(
1062
+ locks: JobExecutionLeaseLocksResolver<J, Ctx>,
1063
+ args: JobExecutionLeaseResolverArgs<J, Ctx>,
1064
+ ): Promise<LocksPort> {
1065
+ return typeof locks === "function" ? await locks(args) : locks;
1066
+ }
1067
+
1068
+ async function resolveExecutionLeaseMetadata<J extends JobDef, Ctx>(
1069
+ metadata: JobExecutionLeaseMetadataResolver<J, Ctx> | undefined,
1070
+ args: JobExecutionLeaseResolverArgs<J, Ctx>,
1071
+ ): Promise<LeaseMetadata | undefined> {
1072
+ return typeof metadata === "function" ? await metadata(args) : metadata;
1073
+ }
1074
+
1075
+ async function releaseExecutionLease(lease: LeaseHandle): Promise<void> {
1076
+ try {
1077
+ await lease.release();
1078
+ } catch {
1079
+ // The lease TTL is the correctness boundary. Release is best effort so a
1080
+ // provider outage after handler side effects does not turn success into a
1081
+ // retry.
1082
+ }
1083
+ }
1084
+
1085
+ async function handleUnavailableExecutionLease<J extends JobDef, Ctx>(
1086
+ behavior: JobExecutionLeaseUnavailableBehavior<J, Ctx> | undefined,
1087
+ args: JobExecutionLeaseUnavailableArgs<J, Ctx>,
1088
+ ): Promise<void> {
1089
+ if (behavior === undefined || behavior === "skip") return;
1090
+
1091
+ if (behavior === "throw") {
1092
+ throw new JobExecutionLeaseUnavailableError({
1093
+ jobName: args.job.name,
1094
+ key: args.key,
1095
+ lockKey: args.lockKey,
1096
+ reason: args.reason,
1097
+ });
1098
+ }
1099
+
1100
+ await behavior(args);
1101
+ }
1102
+
1103
+ /**
1104
+ * Create a job hook that prevents overlapping handler attempts for the same
1105
+ * logical execution key.
1106
+ *
1107
+ * The hook uses one bounded `LocksPort.acquire(...)` call and never starts
1108
+ * renewal loops, so it can run in serverless entrypoints as long as `locks`
1109
+ * points at shared storage. `ttl` is the real safety boundary when a runtime
1110
+ * terminates before best-effort release runs.
1111
+ */
1112
+ export function createJobExecutionLeaseHook<
1113
+ J extends JobDef = JobDef,
1114
+ Ctx = unknown,
1115
+ >(options: JobExecutionLeaseHookOptions<J, Ctx>): JobHook<J, Ctx> {
1116
+ const ttlMs = durationToMs("executionLease.ttl", options.ttl);
1117
+ const waitMs =
1118
+ options.wait === undefined
1119
+ ? undefined
1120
+ : durationToMs("executionLease.wait", options.wait);
1121
+ const retryDelayMs =
1122
+ options.retryDelay === undefined
1123
+ ? undefined
1124
+ : durationToMs("executionLease.retryDelay", options.retryDelay);
1125
+ const keyPrefix = options.keyPrefix ?? "jobs:lease";
1126
+ assertNonEmptyString("executionLease.keyPrefix", keyPrefix);
1127
+
1128
+ if (typeof options.key === "string") {
1129
+ validateExecutionLeaseKey(options.key);
1130
+ }
1131
+
1132
+ return async (args, next) => {
1133
+ if (args.signal.aborted) throw abortReason(args.signal);
1134
+
1135
+ const key = validateExecutionLeaseKey(
1136
+ typeof options.key === "function" ? await options.key(args) : options.key,
1137
+ );
1138
+ if (args.signal.aborted) throw abortReason(args.signal);
1139
+
1140
+ const lockKey = jobExecutionLeaseLockKey(args.job.name, key, keyPrefix);
1141
+ const locks = await resolveExecutionLeaseLocks(options.locks, args);
1142
+ if (args.signal.aborted) throw abortReason(args.signal);
1143
+
1144
+ const metadata = await resolveExecutionLeaseMetadata(
1145
+ options.metadata,
1146
+ args,
1147
+ );
1148
+ if (args.signal.aborted) throw abortReason(args.signal);
1149
+
1150
+ const result = await locks.acquire(lockKey, {
1151
+ ttlMs,
1152
+ ...(waitMs === undefined ? {} : { waitMs }),
1153
+ ...(retryDelayMs === undefined ? {} : { retryDelayMs }),
1154
+ metadata: {
1155
+ ...(metadata ?? {}),
1156
+ capability: "jobs",
1157
+ jobName: args.job.name,
1158
+ leaseKey: key,
1159
+ attempt: args.attempt ?? null,
1160
+ maxAttempts: args.maxAttempts ?? null,
1161
+ },
1162
+ });
1163
+
1164
+ if (!result.acquired) {
1165
+ if (args.signal.aborted) throw abortReason(args.signal);
1166
+
1167
+ await handleUnavailableExecutionLease(options.onUnavailable, {
1168
+ ...args,
1169
+ key,
1170
+ lockKey,
1171
+ reason: result.reason,
1172
+ });
1173
+ return;
1174
+ }
1175
+
1176
+ if (args.signal.aborted) {
1177
+ await releaseExecutionLease(result.lease);
1178
+ throw abortReason(args.signal);
1179
+ }
1180
+
1181
+ try {
1182
+ await next();
1183
+ } finally {
1184
+ await releaseExecutionLease(result.lease);
1185
+ }
1186
+ };
1187
+ }
1188
+
520
1189
  async function parsePayload<Schema extends StandardSchemaV1>(
521
1190
  schema: Schema,
522
1191
  input: unknown,
@@ -559,6 +1228,8 @@ function defineJobImpl<
559
1228
  const retryOptions = options.retry
560
1229
  ? validateJobRetryOptions(options.retry)
561
1230
  : undefined;
1231
+ const uniqueOptions = validateJobUniqueConfig(options.unique);
1232
+ const timeout = validateJobTimeout(options.timeout);
562
1233
 
563
1234
  return {
564
1235
  kind: "job",
@@ -566,6 +1237,9 @@ function defineJobImpl<
566
1237
  payload: options.payload,
567
1238
  description: options.description,
568
1239
  retry: retryOptions,
1240
+ unique: uniqueOptions,
1241
+ timeout,
1242
+ hooks: options.hooks,
569
1243
  handle: options.handle as JobDef<Name, Payload, Ctx>["handle"],
570
1244
  };
571
1245
  }
@@ -582,30 +1256,359 @@ export async function parseJobPayload<J extends JobDef>(
582
1256
  })) as InferJobPayload<J>;
583
1257
  }
584
1258
 
1259
+ /**
1260
+ * Options for running one parsed job handler attempt.
1261
+ */
1262
+ export interface RunJobHandlerOptions<
1263
+ J extends JobDef<string, StandardSchema, Ctx>,
1264
+ Ctx,
1265
+ > {
1266
+ /**
1267
+ * Job definition to execute.
1268
+ */
1269
+ job: J;
1270
+ /**
1271
+ * Parsed job payload.
1272
+ */
1273
+ payload: InferJobPayload<J>;
1274
+ /** Handler context or factory resolved inside the job span. */
1275
+ ctx: Ctx | (() => MaybePromise<Ctx>);
1276
+ /** Runtime tracing port used before a lazy context factory runs. */
1277
+ tracing?: TracingPort;
1278
+ /**
1279
+ * Runner-level hooks. These wrap job-local hooks.
1280
+ */
1281
+ hooks?: readonly JobHook<JobDef<string, StandardSchema, Ctx>, Ctx>[];
1282
+ /**
1283
+ * One-based execution attempt when the runner can report it.
1284
+ */
1285
+ attempt?: number;
1286
+ /**
1287
+ * Maximum execution attempts when the runner can report it.
1288
+ */
1289
+ maxAttempts?: number;
1290
+ }
1291
+
1292
+ function normalizeJobHooks<Ctx>(
1293
+ job: JobDef<string, StandardSchema, Ctx>,
1294
+ hooks: readonly JobHook<JobDef<string, StandardSchema, Ctx>, Ctx>[] = [],
1295
+ ): readonly JobHook<JobDef<string, StandardSchema, Ctx>, Ctx>[] {
1296
+ return [
1297
+ ...hooks,
1298
+ ...((job.hooks ?? []) as readonly JobHook<
1299
+ JobDef<string, StandardSchema, Ctx>,
1300
+ Ctx
1301
+ >[]),
1302
+ ];
1303
+ }
1304
+
1305
+ /**
1306
+ * Run a parsed job handler once, enforcing hooks and the job's declared
1307
+ * timeout.
1308
+ */
1309
+ export async function runJobHandler<
1310
+ J extends JobDef<string, StandardSchema, Ctx>,
1311
+ Ctx,
1312
+ >(args: RunJobHandlerOptions<J, Ctx>): Promise<void> {
1313
+ const traceAttributes = {
1314
+ "beignet.job.name": args.job.name,
1315
+ ...(args.attempt === undefined
1316
+ ? {}
1317
+ : { "beignet.job.attempt": args.attempt }),
1318
+ ...(args.maxAttempts === undefined
1319
+ ? {}
1320
+ : { "beignet.job.max_attempts": args.maxAttempts }),
1321
+ } as const;
1322
+
1323
+ await runWithResolvedTracingContext({
1324
+ tracing: args.tracing,
1325
+ ctx: args.ctx,
1326
+ operation: {
1327
+ name: `beignet.job ${args.job.name}`,
1328
+ type: "job",
1329
+ kind: "consumer",
1330
+ attributes: traceAttributes,
1331
+ metricAttributes: traceAttributes,
1332
+ },
1333
+ run: async (ctx) => {
1334
+ const timeoutMs = getJobTimeoutMs(args.job);
1335
+ const controller = new AbortController();
1336
+ const hooks = normalizeJobHooks(args.job, args.hooks);
1337
+ const hookArgs = {
1338
+ job: args.job,
1339
+ payload: args.payload,
1340
+ ctx,
1341
+ signal: controller.signal,
1342
+ attempt: args.attempt,
1343
+ maxAttempts: args.maxAttempts,
1344
+ } satisfies JobHookArgs<J, Ctx>;
1345
+
1346
+ const run = Promise.resolve().then(async () => {
1347
+ let index = -1;
1348
+ const dispatch = async (nextIndex: number): Promise<void> => {
1349
+ if (nextIndex <= index) {
1350
+ throw new Error(
1351
+ `Job "${args.job.name}" hook called next() multiple times.`,
1352
+ );
1353
+ }
1354
+ index = nextIndex;
1355
+
1356
+ const hook = hooks[nextIndex];
1357
+ if (!hook) {
1358
+ await args.job.handle({
1359
+ job: args.job,
1360
+ payload: args.payload,
1361
+ ctx,
1362
+ signal: controller.signal,
1363
+ });
1364
+ return;
1365
+ }
1366
+
1367
+ await hook(hookArgs, () => dispatch(nextIndex + 1));
1368
+ };
1369
+
1370
+ await dispatch(0);
1371
+ });
1372
+
1373
+ if (timeoutMs === undefined) {
1374
+ await run;
1375
+ return;
1376
+ }
1377
+
1378
+ const timeoutError = new JobTimeoutError({
1379
+ jobName: args.job.name,
1380
+ timeoutMs,
1381
+ });
1382
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1383
+
1384
+ try {
1385
+ await Promise.race([
1386
+ run,
1387
+ new Promise<void>((_, reject) => {
1388
+ timeout = setTimeout(() => {
1389
+ controller.abort(timeoutError);
1390
+ reject(timeoutError);
1391
+ }, timeoutMs);
1392
+ }),
1393
+ ]);
1394
+ } finally {
1395
+ if (timeout !== undefined) {
1396
+ clearTimeout(timeout);
1397
+ }
1398
+ }
1399
+ },
1400
+ });
1401
+ }
1402
+
1403
+ function jobUniqueLockKey(
1404
+ jobName: string,
1405
+ key: string,
1406
+ keyPrefix = "jobs:unique",
1407
+ ): string {
1408
+ return `${keyPrefix}:${jobName}:${key}`;
1409
+ }
1410
+
1411
+ /**
1412
+ * Resolve a job's dispatch-time uniqueness metadata for a parsed payload.
1413
+ */
1414
+ export async function resolveJobUnique<J extends JobDef>(
1415
+ job: J,
1416
+ payload: InferJobPayload<J>,
1417
+ options: { keyPrefix?: string } = {},
1418
+ ): Promise<ResolvedJobUniqueOptions | undefined> {
1419
+ const config = job.unique;
1420
+ if (!config) return undefined;
1421
+
1422
+ const unique =
1423
+ typeof config === "function"
1424
+ ? await config({ jobName: job.name, payload })
1425
+ : config;
1426
+ if (unique == null) return undefined;
1427
+
1428
+ const validated = validateJobUniqueOptions(unique);
1429
+ return {
1430
+ key: validated.key,
1431
+ lockKey: jobUniqueLockKey(job.name, validated.key, options.keyPrefix),
1432
+ ttlMs: durationToMs("unique.ttl", validated.ttl),
1433
+ };
1434
+ }
1435
+
585
1436
  /**
586
1437
  * Create a local/test dispatcher that runs job handlers inline.
1438
+ *
1439
+ * Dispatch honors the job's declared retry policy: failed attempts retry with
1440
+ * the policy's delays until the policy is exhausted. Payload validation
1441
+ * failures never retry. Jobs without a retry policy run exactly once.
587
1442
  */
588
1443
  export function createInlineJobDispatcher<Ctx>(
589
1444
  options: InlineJobDispatcherOptions<Ctx> = {},
590
1445
  ): InlineJobDispatcher<Ctx> {
591
- return {
1446
+ const sleep =
1447
+ options.sleep ??
1448
+ ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
1449
+
1450
+ async function run<J extends JobDef<string, StandardSchema, Ctx>>(
1451
+ job: J,
1452
+ payload: InferJobPayload<J>,
1453
+ retryEnabled: boolean,
1454
+ dispatchOptions: SingleAttemptJobDispatchOptions = {},
1455
+ ): Promise<void> {
1456
+ const fail = (error: unknown): void => {
1457
+ options.onError?.(error, job);
1458
+ if (!options.onError) throw error;
1459
+ };
1460
+
1461
+ let parsed: InferJobPayload<J>;
1462
+ let ctx: Ctx;
1463
+ try {
1464
+ parsed = await parseJobPayload(job, payload);
1465
+ // Resolve once per dispatch so a ctx factory does not run (and cannot
1466
+ // produce different contexts) across retry attempts.
1467
+ ctx = await resolveCtx(options.ctx);
1468
+ } catch (error) {
1469
+ fail(error);
1470
+ return;
1471
+ }
1472
+
1473
+ const policy = retryEnabled ? job.retry : undefined;
1474
+ const maxAttempts = policy ? (getJobRetryMaxAttempts(policy) ?? 1) : 1;
1475
+
1476
+ for (let attempt = 1; ; attempt += 1) {
1477
+ try {
1478
+ await runJobHandler({
1479
+ job,
1480
+ payload: parsed,
1481
+ ctx,
1482
+ hooks: options.hooks,
1483
+ attempt: dispatchOptions.attempt ?? attempt,
1484
+ maxAttempts: dispatchOptions.maxAttempts ?? maxAttempts,
1485
+ });
1486
+ return;
1487
+ } catch (error) {
1488
+ const willRetry = shouldRetryJob(policy, {
1489
+ error,
1490
+ attempt,
1491
+ maxAttempts,
1492
+ jobName: job.name,
1493
+ });
1494
+ if (!willRetry) {
1495
+ fail(error);
1496
+ return;
1497
+ }
1498
+
1499
+ const delayMs = getJobRetryDelayMs(policy, {
1500
+ error,
1501
+ attempt,
1502
+ jobName: job.name,
1503
+ });
1504
+ if (delayMs > 0) await sleep(delayMs);
1505
+ }
1506
+ }
1507
+ }
1508
+
1509
+ const dispatcher: InlineJobDispatcher<Ctx> = {
592
1510
  async dispatch<J extends JobDef<string, StandardSchema, Ctx>>(
593
1511
  job: J,
594
1512
  payload: InferJobPayload<J>,
595
1513
  ) {
596
- try {
597
- const parsed = await parseJobPayload(job, payload);
598
- await job.handle({
1514
+ await run(job, payload, options.retry !== false);
1515
+ },
1516
+ };
1517
+
1518
+ // Non-enumerable so spreads and serialization keep treating the dispatcher
1519
+ // as a plain port; the outbox drain discovers it by symbol.
1520
+ Object.defineProperty(dispatcher, SINGLE_ATTEMPT_DISPATCH, {
1521
+ value: <J extends JobDef<string, StandardSchema, Ctx>>(
1522
+ job: J,
1523
+ payload: InferJobPayload<J>,
1524
+ dispatchOptions?: SingleAttemptJobDispatchOptions,
1525
+ ) => run(job, payload, false, dispatchOptions),
1526
+ enumerable: false,
1527
+ });
1528
+
1529
+ return dispatcher;
1530
+ }
1531
+
1532
+ /**
1533
+ * Wrap any job dispatcher with dispatch-time unique job suppression.
1534
+ *
1535
+ * When a job has no `unique` declaration, dispatch passes through unchanged.
1536
+ * When it does, the wrapper validates the payload, resolves the unique key,
1537
+ * acquires the matching lease, and calls the underlying dispatcher only when
1538
+ * the lease is acquired. Successful dispatches intentionally keep the lease
1539
+ * until its TTL expires; failed dispatches release it so callers can retry.
1540
+ */
1541
+ export function createUniqueJobDispatcher(
1542
+ options: UniqueJobDispatcherOptions,
1543
+ ): JobDispatcher {
1544
+ const dispatcher: JobDispatcher = {
1545
+ async dispatch<J extends JobDef>(
1546
+ job: J,
1547
+ payload: InferJobPayload<J>,
1548
+ ): Promise<void> {
1549
+ if (!job.unique) {
1550
+ await options.jobs.dispatch(job, payload);
1551
+ return;
1552
+ }
1553
+
1554
+ const parsed = await parseJobPayload(job, payload);
1555
+ const unique = await resolveJobUnique(job, parsed, {
1556
+ keyPrefix: options.keyPrefix,
1557
+ });
1558
+ if (!unique) {
1559
+ await options.jobs.dispatch(job, parsed);
1560
+ return;
1561
+ }
1562
+
1563
+ const result = await options.locks.acquire(unique.lockKey, {
1564
+ ttlMs: unique.ttlMs,
1565
+ waitMs: 0,
1566
+ metadata: {
1567
+ capability: "jobs",
1568
+ jobName: job.name,
1569
+ uniqueKey: unique.key,
1570
+ },
1571
+ });
1572
+ if (!result.acquired) {
1573
+ await options.onDuplicate?.({
599
1574
  job,
600
1575
  payload: parsed,
601
- ctx: await resolveCtx(options.ctx),
1576
+ key: unique.key,
1577
+ lockKey: unique.lockKey,
1578
+ ttlMs: unique.ttlMs,
1579
+ reason: result.reason,
602
1580
  });
1581
+ return;
1582
+ }
1583
+
1584
+ try {
1585
+ await options.jobs.dispatch(job, parsed);
603
1586
  } catch (error) {
604
- options.onError?.(error, job);
605
- if (!options.onError) throw error;
1587
+ try {
1588
+ await result.lease.release();
1589
+ } catch {
1590
+ // Preserve the dispatch failure; lease release is best effort and
1591
+ // the TTL still bounds duplicate suppression if release fails.
1592
+ }
1593
+ throw error;
606
1594
  }
607
1595
  },
608
1596
  };
1597
+
1598
+ const singleAttempt = (
1599
+ options.jobs as {
1600
+ [SINGLE_ATTEMPT_DISPATCH]?: SingleAttemptJobDispatch;
1601
+ }
1602
+ )[SINGLE_ATTEMPT_DISPATCH];
1603
+
1604
+ if (singleAttempt) {
1605
+ Object.defineProperty(dispatcher, SINGLE_ATTEMPT_DISPATCH, {
1606
+ value: singleAttempt,
1607
+ enumerable: false,
1608
+ });
1609
+ }
1610
+
1611
+ return dispatcher;
609
1612
  }
610
1613
 
611
1614
  /**
@@ -617,9 +1620,9 @@ export function createInlineJobDispatcher<Ctx>(
617
1620
  * export const { defineJob } = createJobs<AppContext>();
618
1621
  * ```
619
1622
  *
620
- * Retry options are provider hints. Inline dispatchers validate payloads and
621
- * call `handle(...)` immediately; durable providers may enqueue or schedule the
622
- * job according to their own runtime.
1623
+ * Retry options describe the job's retry policy. Inline dispatchers run the
1624
+ * policy in-process with real delays; durable providers map the policy onto
1625
+ * their own runtime and reject options they cannot honor.
623
1626
  */
624
1627
  export function createJobs<Ctx>(): Jobs<Ctx> {
625
1628
  return {