@beignet/core 0.0.2 → 0.0.3

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 (56) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +55 -6
  3. package/dist/jobs/index.d.ts +138 -4
  4. package/dist/jobs/index.d.ts.map +1 -1
  5. package/dist/jobs/index.js +161 -1
  6. package/dist/jobs/index.js.map +1 -1
  7. package/dist/outbox/index.d.ts +5 -0
  8. package/dist/outbox/index.d.ts.map +1 -1
  9. package/dist/outbox/index.js +59 -3
  10. package/dist/outbox/index.js.map +1 -1
  11. package/dist/providers/instrumentation.d.ts +1 -1
  12. package/dist/providers/instrumentation.d.ts.map +1 -1
  13. package/dist/providers/instrumentation.js.map +1 -1
  14. package/dist/server/hooks/auth.d.ts +50 -65
  15. package/dist/server/hooks/auth.d.ts.map +1 -1
  16. package/dist/server/hooks/auth.js +44 -55
  17. package/dist/server/hooks/auth.js.map +1 -1
  18. package/dist/server/hooks/index.d.ts +1 -1
  19. package/dist/server/hooks/index.d.ts.map +1 -1
  20. package/dist/server/hooks/index.js.map +1 -1
  21. package/dist/server/http.d.ts +52 -0
  22. package/dist/server/http.d.ts.map +1 -1
  23. package/dist/server/http.js +20 -1
  24. package/dist/server/http.js.map +1 -1
  25. package/dist/server/index.d.ts +1 -1
  26. package/dist/server/index.d.ts.map +1 -1
  27. package/dist/server/index.js +1 -1
  28. package/dist/server/index.js.map +1 -1
  29. package/dist/server/server.d.ts +54 -13
  30. package/dist/server/server.d.ts.map +1 -1
  31. package/dist/server/server.js +56 -35
  32. package/dist/server/server.js.map +1 -1
  33. package/dist/testing/index.d.ts +4 -0
  34. package/dist/testing/index.d.ts.map +1 -1
  35. package/dist/testing/index.js +8 -0
  36. package/dist/testing/index.js.map +1 -1
  37. package/dist/uploads/client.d.ts +278 -0
  38. package/dist/uploads/client.d.ts.map +1 -0
  39. package/dist/uploads/client.js +428 -0
  40. package/dist/uploads/client.js.map +1 -0
  41. package/dist/uploads/index.d.ts +361 -0
  42. package/dist/uploads/index.d.ts.map +1 -0
  43. package/dist/uploads/index.js +543 -0
  44. package/dist/uploads/index.js.map +1 -0
  45. package/package.json +11 -2
  46. package/src/jobs/index.ts +326 -5
  47. package/src/outbox/index.ts +83 -3
  48. package/src/providers/instrumentation.ts +7 -1
  49. package/src/server/hooks/auth.ts +89 -162
  50. package/src/server/hooks/index.ts +1 -5
  51. package/src/server/http.ts +79 -0
  52. package/src/server/index.ts +1 -0
  53. package/src/server/server.ts +191 -23
  54. package/src/testing/index.ts +11 -0
  55. package/src/uploads/client.ts +861 -0
  56. package/src/uploads/index.ts +1067 -0
package/src/jobs/index.ts CHANGED
@@ -16,6 +16,48 @@ export type MaybePromise<T> = T | Promise<T>;
16
16
  export type InferSchemaOutput<T extends StandardSchemaV1> =
17
17
  StandardSchemaV1.InferOutput<T>;
18
18
 
19
+ /**
20
+ * Duration accepted by retry helpers. Numbers are milliseconds.
21
+ */
22
+ export type JobRetryDuration =
23
+ | number
24
+ | `${number}ms`
25
+ | `${number}s`
26
+ | `${number}m`
27
+ | `${number}h`;
28
+
29
+ /**
30
+ * Retry strategy understood by Beignet job adapters.
31
+ */
32
+ export type JobRetryStrategy = "none" | "fixed" | "exponential";
33
+
34
+ /**
35
+ * Arguments passed to a retry predicate.
36
+ */
37
+ export interface JobRetryPredicateArgs {
38
+ /**
39
+ * Error thrown by the previous attempt.
40
+ */
41
+ error: unknown;
42
+ /**
43
+ * One-based attempt number that just failed.
44
+ */
45
+ attempt: number;
46
+ /**
47
+ * Maximum attempts allowed for this delivery.
48
+ */
49
+ maxAttempts: number;
50
+ /**
51
+ * Job name when the retry decision is for a job.
52
+ */
53
+ jobName?: string;
54
+ }
55
+
56
+ /**
57
+ * Return whether a failed attempt should be retried.
58
+ */
59
+ export type JobRetryPredicate = (args: JobRetryPredicateArgs) => boolean;
60
+
19
61
  /**
20
62
  * Job definition created by `defineJob(...)`.
21
63
  */
@@ -81,12 +123,38 @@ export interface JobHandleArgs<J extends JobDef, Ctx> {
81
123
  */
82
124
  export interface JobRetryOptions {
83
125
  /**
84
- * Maximum number of retry attempts a durable job adapter should request.
85
- *
86
- * Providers may impose their own range limits. For example, the Inngest
87
- * adapter validates this as a function-level `retries` value.
126
+ * Retry strategy. Raw objects without a strategy default to exponential
127
+ * backoff so existing `{ attempts }` style definitions stay meaningful.
128
+ */
129
+ strategy?: JobRetryStrategy;
130
+ /**
131
+ * Maximum total attempts, including the first attempt.
88
132
  */
89
133
  attempts?: number;
134
+ /**
135
+ * Delay between attempts for fixed retry policies.
136
+ */
137
+ delay?: JobRetryDuration;
138
+ /**
139
+ * Initial delay for exponential retry policies.
140
+ */
141
+ initialDelay?: JobRetryDuration;
142
+ /**
143
+ * Maximum delay for exponential retry policies.
144
+ */
145
+ maxDelay?: JobRetryDuration;
146
+ /**
147
+ * Exponential multiplier. Defaults to `2`.
148
+ */
149
+ factor?: number;
150
+ /**
151
+ * Whether adapters that compute delays should add jitter.
152
+ */
153
+ jitter?: boolean;
154
+ /**
155
+ * Optional app-owned retry classifier.
156
+ */
157
+ retryIf?: JobRetryPredicate;
90
158
  }
91
159
 
92
160
  /**
@@ -117,6 +185,96 @@ export interface DefineJobOptions<
117
185
  ): MaybePromise<void>;
118
186
  }
119
187
 
188
+ /**
189
+ * Options for a fixed job retry policy.
190
+ */
191
+ export interface FixedJobRetryOptions {
192
+ /**
193
+ * Maximum total attempts, including the first attempt.
194
+ */
195
+ attempts: number;
196
+ /**
197
+ * Delay between attempts.
198
+ */
199
+ delay: JobRetryDuration;
200
+ /**
201
+ * Optional app-owned retry classifier.
202
+ */
203
+ retryIf?: JobRetryPredicate;
204
+ }
205
+
206
+ /**
207
+ * Options for an exponential job retry policy.
208
+ */
209
+ export interface ExponentialJobRetryOptions {
210
+ /**
211
+ * Maximum total attempts, including the first attempt.
212
+ */
213
+ attempts: number;
214
+ /**
215
+ * Initial delay. Defaults to `1s`.
216
+ */
217
+ initialDelay?: JobRetryDuration;
218
+ /**
219
+ * Maximum delay. Defaults to `1m`.
220
+ */
221
+ maxDelay?: JobRetryDuration;
222
+ /**
223
+ * Exponential multiplier. Defaults to `2`.
224
+ */
225
+ factor?: number;
226
+ /**
227
+ * Whether computed delays should include jitter.
228
+ */
229
+ jitter?: boolean;
230
+ /**
231
+ * Optional app-owned retry classifier.
232
+ */
233
+ retryIf?: JobRetryPredicate;
234
+ }
235
+
236
+ /**
237
+ * Retry helper namespace for job definitions.
238
+ */
239
+ export const retry = {
240
+ /**
241
+ * Disable retries. The first failure is terminal.
242
+ */
243
+ none(): JobRetryOptions {
244
+ return {
245
+ strategy: "none",
246
+ attempts: 1,
247
+ };
248
+ },
249
+
250
+ /**
251
+ * Retry with the same delay between attempts.
252
+ */
253
+ fixed(options: FixedJobRetryOptions): JobRetryOptions {
254
+ return validateJobRetryOptions({
255
+ strategy: "fixed",
256
+ attempts: options.attempts,
257
+ delay: options.delay,
258
+ retryIf: options.retryIf,
259
+ });
260
+ },
261
+
262
+ /**
263
+ * Retry with exponential backoff.
264
+ */
265
+ exponential(options: ExponentialJobRetryOptions): JobRetryOptions {
266
+ return validateJobRetryOptions({
267
+ strategy: "exponential",
268
+ attempts: options.attempts,
269
+ initialDelay: options.initialDelay,
270
+ maxDelay: options.maxDelay,
271
+ factor: options.factor,
272
+ jitter: options.jitter,
273
+ retryIf: options.retryIf,
274
+ });
275
+ },
276
+ } as const;
277
+
120
278
  /**
121
279
  * Options for the inline job dispatcher.
122
280
  */
@@ -207,6 +365,165 @@ function formatIssues(issues: readonly StandardSchemaV1.Issue[]): string {
207
365
  .join("; ");
208
366
  }
209
367
 
368
+ function assertPositiveInteger(name: string, value: number): void {
369
+ if (!Number.isInteger(value) || value <= 0) {
370
+ throw new Error(`${name} must be a positive integer`);
371
+ }
372
+ }
373
+
374
+ function assertPositiveNumber(name: string, value: number): void {
375
+ if (!Number.isFinite(value) || value <= 0) {
376
+ throw new Error(`${name} must be a positive number`);
377
+ }
378
+ }
379
+
380
+ function durationToMs(name: string, value: JobRetryDuration): number {
381
+ if (typeof value === "number") {
382
+ assertPositiveInteger(name, value);
383
+ return value;
384
+ }
385
+
386
+ const match = /^(\d+)(ms|s|m|h)$/.exec(value);
387
+ if (!match) {
388
+ throw new Error(
389
+ `${name} must be a positive millisecond value or duration string like "500ms", "30s", "5m", or "1h".`,
390
+ );
391
+ }
392
+
393
+ const amount = Number(match[1]);
394
+ assertPositiveInteger(name, amount);
395
+
396
+ switch (match[2]) {
397
+ case "ms":
398
+ return amount;
399
+ case "s":
400
+ return amount * 1000;
401
+ case "m":
402
+ return amount * 60_000;
403
+ case "h":
404
+ return amount * 3_600_000;
405
+ default:
406
+ throw new Error(`${name} has an unsupported duration unit.`);
407
+ }
408
+ }
409
+
410
+ function validateJobRetryOptions(options: JobRetryOptions): JobRetryOptions {
411
+ const strategy = options.strategy ?? "exponential";
412
+
413
+ if (!["none", "fixed", "exponential"].includes(strategy)) {
414
+ throw new Error("retry.strategy must be none, fixed, or exponential");
415
+ }
416
+
417
+ const attempts = options.attempts ?? (strategy === "none" ? 1 : undefined);
418
+ if (attempts === undefined) {
419
+ throw new Error("retry.attempts is required");
420
+ }
421
+ assertPositiveInteger("retry.attempts", attempts);
422
+
423
+ if (strategy === "none" && attempts !== 1) {
424
+ throw new Error("retry.none() must use exactly one attempt");
425
+ }
426
+
427
+ if (strategy === "fixed") {
428
+ if (options.delay === undefined) {
429
+ throw new Error("retry.delay is required for fixed retry policies");
430
+ }
431
+ durationToMs("retry.delay", options.delay);
432
+ }
433
+
434
+ if (strategy === "exponential") {
435
+ if (options.initialDelay !== undefined) {
436
+ durationToMs("retry.initialDelay", options.initialDelay);
437
+ }
438
+ if (options.maxDelay !== undefined) {
439
+ durationToMs("retry.maxDelay", options.maxDelay);
440
+ }
441
+ if (options.factor !== undefined) {
442
+ assertPositiveNumber("retry.factor", options.factor);
443
+ }
444
+ }
445
+
446
+ return {
447
+ ...options,
448
+ strategy,
449
+ attempts,
450
+ };
451
+ }
452
+
453
+ /**
454
+ * Return the maximum total attempts configured by a retry policy.
455
+ */
456
+ export function getJobRetryMaxAttempts(
457
+ options: JobRetryOptions | undefined,
458
+ ): number | undefined {
459
+ return options ? validateJobRetryOptions(options).attempts : undefined;
460
+ }
461
+
462
+ /**
463
+ * Return whether a failed job attempt should be retried.
464
+ */
465
+ export function shouldRetryJob(
466
+ options: JobRetryOptions | undefined,
467
+ args: JobRetryPredicateArgs,
468
+ ): boolean {
469
+ if (!options) return args.attempt < args.maxAttempts;
470
+
471
+ const retryOptions = validateJobRetryOptions(options);
472
+ const maxAttempts = Math.min(
473
+ args.maxAttempts,
474
+ retryOptions.attempts ?? args.maxAttempts,
475
+ );
476
+ if (retryOptions.strategy === "none") return false;
477
+ if (args.attempt >= maxAttempts) return false;
478
+
479
+ return retryOptions.retryIf?.({ ...args, maxAttempts }) ?? true;
480
+ }
481
+
482
+ /**
483
+ * Compute the next retry delay in milliseconds for a failed job attempt.
484
+ */
485
+ export function getJobRetryDelayMs(
486
+ options: JobRetryOptions | undefined,
487
+ args: Pick<JobRetryPredicateArgs, "attempt" | "error" | "jobName">,
488
+ ): number {
489
+ const retryOptions = options
490
+ ? validateJobRetryOptions(options)
491
+ : retry.exponential({ attempts: 3 });
492
+
493
+ let delayMs: number;
494
+ if (retryOptions.strategy === "fixed") {
495
+ delayMs = durationToMs("retry.delay", retryOptions.delay ?? "1s");
496
+ } else if (retryOptions.strategy === "none") {
497
+ delayMs = 0;
498
+ } else {
499
+ const initialDelayMs = durationToMs(
500
+ "retry.initialDelay",
501
+ retryOptions.initialDelay ?? "1s",
502
+ );
503
+ const maxDelayMs = durationToMs(
504
+ "retry.maxDelay",
505
+ retryOptions.maxDelay ?? "1m",
506
+ );
507
+ const factor = retryOptions.factor ?? 2;
508
+ delayMs = Math.min(
509
+ maxDelayMs,
510
+ initialDelayMs * factor ** Math.max(0, args.attempt - 1),
511
+ );
512
+ }
513
+
514
+ if (retryOptions.jitter && delayMs > 0) {
515
+ delayMs = Math.ceil(delayMs * (0.5 + Math.random()));
516
+ if (retryOptions.strategy === "exponential") {
517
+ delayMs = Math.min(
518
+ delayMs,
519
+ durationToMs("retry.maxDelay", retryOptions.maxDelay ?? "1m"),
520
+ );
521
+ }
522
+ }
523
+
524
+ return delayMs;
525
+ }
526
+
210
527
  async function parsePayload<Schema extends StandardSchemaV1>(
211
528
  schema: Schema,
212
529
  input: unknown,
@@ -253,12 +570,16 @@ export function defineJob<
253
570
  name: Name,
254
571
  options: DefineJobOptions<Name, Payload, Ctx>,
255
572
  ): JobDef<Name, Payload, Ctx> {
573
+ const retryOptions = options.retry
574
+ ? validateJobRetryOptions(options.retry)
575
+ : undefined;
576
+
256
577
  return {
257
578
  kind: "job",
258
579
  name,
259
580
  payload: options.payload,
260
581
  description: options.description,
261
- retry: options.retry,
582
+ retry: retryOptions,
262
583
  handle: options.handle as JobDef<Name, Payload, Ctx>["handle"],
263
584
  };
264
585
  }
@@ -10,12 +10,23 @@ import {
10
10
  type InferEventPayload,
11
11
  parseEventPayload,
12
12
  } from "../events";
13
- import { type InferJobPayload, type JobDef, parseJobPayload } from "../jobs";
13
+ import {
14
+ getJobRetryDelayMs,
15
+ getJobRetryMaxAttempts,
16
+ type InferJobPayload,
17
+ type JobDef,
18
+ parseJobPayload,
19
+ shouldRetryJob,
20
+ } from "../jobs";
14
21
  import type { JobDispatcherPort } from "../ports/events";
15
22
  import type {
16
23
  BufferedDomainEventRecorder,
17
24
  DomainEventRecorderPort,
18
25
  } from "../ports/unit-of-work";
26
+ import {
27
+ createProviderInstrumentation,
28
+ type ProviderInstrumentationTarget,
29
+ } from "../providers";
19
30
 
20
31
  /**
21
32
  * Value or promise of that value.
@@ -401,6 +412,10 @@ export interface DrainOutboxOptions {
401
412
  error: unknown;
402
413
  now: Date;
403
414
  }) => number);
415
+ /**
416
+ * Optional instrumentation target for retry and dead-letter visibility.
417
+ */
418
+ instrumentation?: ProviderInstrumentationTarget;
404
419
  /**
405
420
  * Observer called when delivery fails. Observer failures are ignored so the
406
421
  * original delivery failure still controls retry/dead-letter behavior.
@@ -852,7 +867,7 @@ export async function enqueueJob<J extends JobDef>(
852
867
  name: job.name,
853
868
  payload: toOutboxJsonValue(parsed),
854
869
  availableAt: options.availableAt,
855
- maxAttempts: options.maxAttempts,
870
+ maxAttempts: options.maxAttempts ?? getJobRetryMaxAttempts(job.retry),
856
871
  });
857
872
  }
858
873
 
@@ -906,9 +921,38 @@ function resolveRetryDelayMs(
906
921
  return options.retryDelayMs;
907
922
  }
908
923
 
924
+ if (message.kind === "job") {
925
+ const job = options.registry.jobs.get(message.name);
926
+ if (job?.retry) {
927
+ return getJobRetryDelayMs(job.retry, {
928
+ attempt: message.attempts,
929
+ error,
930
+ jobName: message.name,
931
+ });
932
+ }
933
+ }
934
+
909
935
  return Math.min(60_000, 1000 * 2 ** Math.max(0, message.attempts - 1));
910
936
  }
911
937
 
938
+ function shouldRetryOutboxMessage(
939
+ options: DrainOutboxOptions,
940
+ message: ClaimedOutboxMessage,
941
+ error: unknown,
942
+ ): boolean {
943
+ if (message.kind !== "job") {
944
+ return message.attempts < message.maxAttempts;
945
+ }
946
+
947
+ const job = options.registry.jobs.get(message.name);
948
+ return shouldRetryJob(job?.retry, {
949
+ attempt: message.attempts,
950
+ error,
951
+ jobName: message.name,
952
+ maxAttempts: message.maxAttempts,
953
+ });
954
+ }
955
+
912
956
  async function deliverOutboxMessage(
913
957
  options: DrainOutboxOptions,
914
958
  message: ClaimedOutboxMessage,
@@ -962,6 +1006,13 @@ export async function drainOutbox(
962
1006
  ): Promise<DrainOutboxResult> {
963
1007
  const batchSize = options.batchSize ?? 100;
964
1008
  assertPositiveInteger("batchSize", batchSize);
1009
+ const instrumentation = createProviderInstrumentation(
1010
+ options.instrumentation,
1011
+ {
1012
+ providerName: "outbox",
1013
+ watcher: "jobs",
1014
+ },
1015
+ );
965
1016
 
966
1017
  const now = options.now ?? new Date();
967
1018
  const messages = await options.outbox.claimBatch({
@@ -992,7 +1043,8 @@ export async function drainOutbox(
992
1043
  // Preserve the delivery failure path so the message is retried or
993
1044
  // dead-lettered even if the observer fails.
994
1045
  }
995
- const deadLetter = message.attempts >= message.maxAttempts;
1046
+ const shouldRetry = shouldRetryOutboxMessage(options, message, error);
1047
+ const deadLetter = !shouldRetry;
996
1048
  const retryDelayMs = deadLetter
997
1049
  ? 0
998
1050
  : resolveRetryDelayMs(options, message, error, now);
@@ -1008,8 +1060,36 @@ export async function drainOutbox(
1008
1060
  });
1009
1061
 
1010
1062
  if (deadLetter) {
1063
+ if (message.kind === "job") {
1064
+ instrumentation.record({
1065
+ type: "job",
1066
+ jobName: message.name,
1067
+ status: "deadLettered",
1068
+ details: {
1069
+ attempt: message.attempts,
1070
+ maxAttempts: message.maxAttempts,
1071
+ messageId: message.id,
1072
+ error: serializeOutboxError(error),
1073
+ },
1074
+ });
1075
+ }
1011
1076
  result.deadLettered += 1;
1012
1077
  } else {
1078
+ if (message.kind === "job") {
1079
+ instrumentation.record({
1080
+ type: "job",
1081
+ jobName: message.name,
1082
+ status: "retryScheduled",
1083
+ details: {
1084
+ attempt: message.attempts,
1085
+ maxAttempts: message.maxAttempts,
1086
+ messageId: message.id,
1087
+ retryDelayMs,
1088
+ retryAt: new Date(now.getTime() + retryDelayMs).toISOString(),
1089
+ error: serializeOutboxError(error),
1090
+ },
1091
+ });
1092
+ }
1013
1093
  result.retried += 1;
1014
1094
  }
1015
1095
  }
@@ -159,7 +159,13 @@ export interface JobProviderInstrumentationEvent
159
159
  /**
160
160
  * Job lifecycle status.
161
161
  */
162
- status: "scheduled" | "started" | "completed" | "failed";
162
+ status:
163
+ | "scheduled"
164
+ | "started"
165
+ | "completed"
166
+ | "failed"
167
+ | "retryScheduled"
168
+ | "deadLettered";
163
169
  }
164
170
 
165
171
  /**