@beignet/core 0.0.32 → 0.0.34

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 (139) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +409 -34
  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/contracts/contract-builder.d.ts +0 -4
  7. package/dist/contracts/contract-builder.d.ts.map +1 -1
  8. package/dist/contracts/contract-builder.js +0 -6
  9. package/dist/contracts/contract-builder.js.map +1 -1
  10. package/dist/events/index.d.ts +6 -0
  11. package/dist/events/index.d.ts.map +1 -1
  12. package/dist/events/index.js +20 -10
  13. package/dist/events/index.js.map +1 -1
  14. package/dist/flags/index.d.ts +0 -4
  15. package/dist/flags/index.d.ts.map +1 -1
  16. package/dist/flags/index.js +0 -4
  17. package/dist/flags/index.js.map +1 -1
  18. package/dist/jobs/index.d.ts +415 -4
  19. package/dist/jobs/index.d.ts.map +1 -1
  20. package/dist/jobs/index.js +392 -3
  21. package/dist/jobs/index.js.map +1 -1
  22. package/dist/notifications/index.d.ts +194 -10
  23. package/dist/notifications/index.d.ts.map +1 -1
  24. package/dist/notifications/index.js +397 -59
  25. package/dist/notifications/index.js.map +1 -1
  26. package/dist/outbox/index.d.ts +150 -1
  27. package/dist/outbox/index.d.ts.map +1 -1
  28. package/dist/outbox/index.js +170 -1
  29. package/dist/outbox/index.js.map +1 -1
  30. package/dist/ports/events.d.ts +2 -2
  31. package/dist/ports/index.d.ts +7 -2
  32. package/dist/ports/index.d.ts.map +1 -1
  33. package/dist/ports/index.js +2 -1
  34. package/dist/ports/index.js.map +1 -1
  35. package/dist/providers/instrumentation.d.ts +4 -0
  36. package/dist/providers/instrumentation.d.ts.map +1 -1
  37. package/dist/providers/instrumentation.js.map +1 -1
  38. package/dist/providers/provider.d.ts +1 -1
  39. package/dist/schedules/index.d.ts +8 -7
  40. package/dist/schedules/index.d.ts.map +1 -1
  41. package/dist/schedules/index.js +47 -16
  42. package/dist/schedules/index.js.map +1 -1
  43. package/dist/server/hooks/index.d.ts +1 -0
  44. package/dist/server/hooks/index.d.ts.map +1 -1
  45. package/dist/server/hooks/index.js +1 -0
  46. package/dist/server/hooks/index.js.map +1 -1
  47. package/dist/server/hooks/rate-limit.d.ts +26 -13
  48. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  49. package/dist/server/hooks/rate-limit.js +28 -34
  50. package/dist/server/hooks/rate-limit.js.map +1 -1
  51. package/dist/server/hooks/security.d.ts +179 -0
  52. package/dist/server/hooks/security.d.ts.map +1 -0
  53. package/dist/server/hooks/security.js +225 -0
  54. package/dist/server/hooks/security.js.map +1 -0
  55. package/dist/server/index.d.ts +8 -0
  56. package/dist/server/index.d.ts.map +1 -1
  57. package/dist/server/index.js +8 -0
  58. package/dist/server/index.js.map +1 -1
  59. package/dist/server/instrumentation.d.ts.map +1 -1
  60. package/dist/server/instrumentation.js +22 -6
  61. package/dist/server/instrumentation.js.map +1 -1
  62. package/dist/server/request-context.d.ts +4 -0
  63. package/dist/server/request-context.d.ts.map +1 -1
  64. package/dist/server/request-context.js +3 -0
  65. package/dist/server/request-context.js.map +1 -1
  66. package/dist/server/runtime-integrity.d.ts +84 -0
  67. package/dist/server/runtime-integrity.d.ts.map +1 -0
  68. package/dist/server/runtime-integrity.js +160 -0
  69. package/dist/server/runtime-integrity.js.map +1 -0
  70. package/dist/server/server.d.ts +10 -10
  71. package/dist/server/server.d.ts.map +1 -1
  72. package/dist/server/server.js +75 -11
  73. package/dist/server/server.js.map +1 -1
  74. package/dist/server/trusted-proxy.d.ts +77 -0
  75. package/dist/server/trusted-proxy.d.ts.map +1 -0
  76. package/dist/server/trusted-proxy.js +129 -0
  77. package/dist/server/trusted-proxy.js.map +1 -0
  78. package/dist/tasks/index.d.ts +6 -7
  79. package/dist/tasks/index.d.ts.map +1 -1
  80. package/dist/tasks/index.js +22 -5
  81. package/dist/tasks/index.js.map +1 -1
  82. package/dist/tenancy/index.d.ts +41 -0
  83. package/dist/tenancy/index.d.ts.map +1 -0
  84. package/dist/tenancy/index.js +34 -0
  85. package/dist/tenancy/index.js.map +1 -0
  86. package/dist/testing/index.d.ts +1 -1
  87. package/dist/testing/index.d.ts.map +1 -1
  88. package/dist/testing/index.js +1 -0
  89. package/dist/testing/index.js.map +1 -1
  90. package/dist/tracing/execution.d.ts +14 -0
  91. package/dist/tracing/execution.d.ts.map +1 -0
  92. package/dist/tracing/execution.js +17 -0
  93. package/dist/tracing/execution.js.map +1 -0
  94. package/dist/tracing/index.d.ts +49 -0
  95. package/dist/tracing/index.d.ts.map +1 -1
  96. package/dist/tracing/index.js +59 -0
  97. package/dist/tracing/index.js.map +1 -1
  98. package/dist/uploads/client.d.ts.map +1 -1
  99. package/dist/uploads/client.js +44 -6
  100. package/dist/uploads/client.js.map +1 -1
  101. package/dist/uploads/index.d.ts +71 -0
  102. package/dist/uploads/index.d.ts.map +1 -1
  103. package/dist/uploads/index.js +344 -15
  104. package/dist/uploads/index.js.map +1 -1
  105. package/dist/webhooks/index.d.ts +55 -4
  106. package/dist/webhooks/index.d.ts.map +1 -1
  107. package/dist/webhooks/index.js +106 -2
  108. package/dist/webhooks/index.js.map +1 -1
  109. package/package.json +5 -5
  110. package/skills/app-architecture/SKILL.md +31 -2
  111. package/src/application/index.ts +88 -56
  112. package/src/contracts/contract-builder.ts +0 -7
  113. package/src/events/index.ts +27 -14
  114. package/src/flags/index.ts +0 -5
  115. package/src/jobs/index.ts +895 -7
  116. package/src/notifications/index.ts +678 -70
  117. package/src/outbox/index.ts +358 -2
  118. package/src/ports/events.ts +2 -2
  119. package/src/ports/index.ts +17 -1
  120. package/src/providers/instrumentation.ts +4 -0
  121. package/src/providers/provider.ts +1 -1
  122. package/src/schedules/index.ts +60 -26
  123. package/src/server/hooks/index.ts +10 -0
  124. package/src/server/hooks/rate-limit.ts +52 -46
  125. package/src/server/hooks/security.ts +503 -0
  126. package/src/server/index.ts +8 -0
  127. package/src/server/instrumentation.ts +25 -5
  128. package/src/server/request-context.ts +7 -0
  129. package/src/server/runtime-integrity.ts +322 -0
  130. package/src/server/server.ts +88 -35
  131. package/src/server/trusted-proxy.ts +249 -0
  132. package/src/tasks/index.ts +29 -12
  133. package/src/tenancy/index.ts +55 -0
  134. package/src/testing/index.ts +2 -0
  135. package/src/tracing/execution.ts +37 -0
  136. package/src/tracing/index.ts +137 -0
  137. package/src/uploads/client.ts +65 -6
  138. package/src/uploads/index.ts +539 -14
  139. package/src/webhooks/index.ts +238 -9
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
  */
@@ -299,6 +575,11 @@ export interface InlineJobDispatcherOptions<Ctx> {
299
575
  * through this dispatcher.
300
576
  */
301
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>[];
302
583
  }
303
584
 
304
585
  /**
@@ -312,6 +593,21 @@ export const SINGLE_ATTEMPT_DISPATCH: unique symbol = Symbol.for(
312
593
  "beignet.jobs.singleAttemptDispatch",
313
594
  );
314
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
+
315
611
  /**
316
612
  * Shape of the single-attempt dispatch exposed under
317
613
  * `SINGLE_ATTEMPT_DISPATCH`.
@@ -319,8 +615,73 @@ export const SINGLE_ATTEMPT_DISPATCH: unique symbol = Symbol.for(
319
615
  export type SingleAttemptJobDispatch = <J extends JobDef>(
320
616
  job: J,
321
617
  payload: InferJobPayload<J>,
618
+ options?: SingleAttemptJobDispatchOptions,
322
619
  ) => Promise<void>;
323
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>;
683
+ }
684
+
324
685
  /**
325
686
  * Local/test job dispatcher that executes job handlers inline.
326
687
  */
@@ -369,6 +730,66 @@ export class JobValidationError extends Error {
369
730
  }
370
731
  }
371
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
+
372
793
  function formatPath(path: StandardSchemaV1.Issue["path"]): string {
373
794
  if (!path?.length) return "";
374
795
 
@@ -402,12 +823,24 @@ function assertPositiveNumber(name: string, value: number): void {
402
823
  }
403
824
  }
404
825
 
405
- 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 {
406
833
  if (typeof value === "number") {
407
834
  assertPositiveInteger(name, value);
408
835
  return value;
409
836
  }
410
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
+
411
844
  const match = /^(\d+)(ms|s|m|h)$/.exec(value);
412
845
  if (!match) {
413
846
  throw new Error(
@@ -432,6 +865,36 @@ function durationToMs(name: string, value: JobRetryDuration): number {
432
865
  }
433
866
  }
434
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
+
435
898
  const VALIDATED_RETRY_OPTIONS = Symbol("beignet.jobs.validatedRetryOptions");
436
899
 
437
900
  function validateJobRetryOptions(options: JobRetryOptions): JobRetryOptions {
@@ -567,6 +1030,162 @@ export function getJobRetryDelayMs(
567
1030
  return delayMs;
568
1031
  }
569
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
+
570
1189
  async function parsePayload<Schema extends StandardSchemaV1>(
571
1190
  schema: Schema,
572
1191
  input: unknown,
@@ -609,6 +1228,8 @@ function defineJobImpl<
609
1228
  const retryOptions = options.retry
610
1229
  ? validateJobRetryOptions(options.retry)
611
1230
  : undefined;
1231
+ const uniqueOptions = validateJobUniqueConfig(options.unique);
1232
+ const timeout = validateJobTimeout(options.timeout);
612
1233
 
613
1234
  return {
614
1235
  kind: "job",
@@ -616,6 +1237,9 @@ function defineJobImpl<
616
1237
  payload: options.payload,
617
1238
  description: options.description,
618
1239
  retry: retryOptions,
1240
+ unique: uniqueOptions,
1241
+ timeout,
1242
+ hooks: options.hooks,
619
1243
  handle: options.handle as JobDef<Name, Payload, Ctx>["handle"],
620
1244
  };
621
1245
  }
@@ -632,6 +1256,183 @@ export async function parseJobPayload<J extends JobDef>(
632
1256
  })) as InferJobPayload<J>;
633
1257
  }
634
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
+
635
1436
  /**
636
1437
  * Create a local/test dispatcher that runs job handlers inline.
637
1438
  *
@@ -650,6 +1451,7 @@ export function createInlineJobDispatcher<Ctx>(
650
1451
  job: J,
651
1452
  payload: InferJobPayload<J>,
652
1453
  retryEnabled: boolean,
1454
+ dispatchOptions: SingleAttemptJobDispatchOptions = {},
653
1455
  ): Promise<void> {
654
1456
  const fail = (error: unknown): void => {
655
1457
  options.onError?.(error, job);
@@ -673,10 +1475,13 @@ export function createInlineJobDispatcher<Ctx>(
673
1475
 
674
1476
  for (let attempt = 1; ; attempt += 1) {
675
1477
  try {
676
- await job.handle({
1478
+ await runJobHandler({
677
1479
  job,
678
1480
  payload: parsed,
679
1481
  ctx,
1482
+ hooks: options.hooks,
1483
+ attempt: dispatchOptions.attempt ?? attempt,
1484
+ maxAttempts: dispatchOptions.maxAttempts ?? maxAttempts,
680
1485
  });
681
1486
  return;
682
1487
  } catch (error) {
@@ -716,13 +1521,96 @@ export function createInlineJobDispatcher<Ctx>(
716
1521
  value: <J extends JobDef<string, StandardSchema, Ctx>>(
717
1522
  job: J,
718
1523
  payload: InferJobPayload<J>,
719
- ) => run(job, payload, false),
1524
+ dispatchOptions?: SingleAttemptJobDispatchOptions,
1525
+ ) => run(job, payload, false, dispatchOptions),
720
1526
  enumerable: false,
721
1527
  });
722
1528
 
723
1529
  return dispatcher;
724
1530
  }
725
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?.({
1574
+ job,
1575
+ payload: parsed,
1576
+ key: unique.key,
1577
+ lockKey: unique.lockKey,
1578
+ ttlMs: unique.ttlMs,
1579
+ reason: result.reason,
1580
+ });
1581
+ return;
1582
+ }
1583
+
1584
+ try {
1585
+ await options.jobs.dispatch(job, parsed);
1586
+ } catch (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;
1594
+ }
1595
+ },
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;
1612
+ }
1613
+
726
1614
  /**
727
1615
  * Create job helper methods bound to an application context type.
728
1616
  *