@beignet/core 0.0.37 → 0.0.38

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 (111) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +108 -19
  3. package/dist/client/client.d.ts.map +1 -1
  4. package/dist/client/client.js +6 -28
  5. package/dist/client/client.js.map +1 -1
  6. package/dist/error-reporting/index.d.ts +42 -1
  7. package/dist/error-reporting/index.d.ts.map +1 -1
  8. package/dist/error-reporting/index.js +60 -0
  9. package/dist/error-reporting/index.js.map +1 -1
  10. package/dist/error-reporting/internal.d.ts +11 -0
  11. package/dist/error-reporting/internal.d.ts.map +1 -0
  12. package/dist/error-reporting/internal.js +36 -0
  13. package/dist/error-reporting/internal.js.map +1 -0
  14. package/dist/events/index.d.ts +9 -4
  15. package/dist/events/index.d.ts.map +1 -1
  16. package/dist/events/index.js +5 -3
  17. package/dist/events/index.js.map +1 -1
  18. package/dist/idempotency/index.d.ts +25 -2
  19. package/dist/idempotency/index.d.ts.map +1 -1
  20. package/dist/idempotency/index.js +35 -5
  21. package/dist/idempotency/index.js.map +1 -1
  22. package/dist/jobs/index.d.ts +26 -4
  23. package/dist/jobs/index.d.ts.map +1 -1
  24. package/dist/jobs/index.js +51 -6
  25. package/dist/jobs/index.js.map +1 -1
  26. package/dist/locks/index.d.ts +19 -1
  27. package/dist/locks/index.d.ts.map +1 -1
  28. package/dist/locks/index.js +27 -12
  29. package/dist/locks/index.js.map +1 -1
  30. package/dist/outbox/index.d.ts +23 -4
  31. package/dist/outbox/index.d.ts.map +1 -1
  32. package/dist/outbox/index.js +71 -23
  33. package/dist/outbox/index.js.map +1 -1
  34. package/dist/ports/events.d.ts +5 -5
  35. package/dist/ports/events.d.ts.map +1 -1
  36. package/dist/ports/index.d.ts +4 -4
  37. package/dist/ports/unbound.js +1 -1
  38. package/dist/ports/unbound.js.map +1 -1
  39. package/dist/ports/unit-of-work.d.ts +4 -1
  40. package/dist/ports/unit-of-work.d.ts.map +1 -1
  41. package/dist/ports/unit-of-work.js +4 -3
  42. package/dist/ports/unit-of-work.js.map +1 -1
  43. package/dist/query-codec.d.ts +3 -0
  44. package/dist/query-codec.d.ts.map +1 -0
  45. package/dist/query-codec.js +110 -0
  46. package/dist/query-codec.js.map +1 -0
  47. package/dist/server/hooks/cors.d.ts +3 -2
  48. package/dist/server/hooks/cors.d.ts.map +1 -1
  49. package/dist/server/hooks/cors.js +7 -3
  50. package/dist/server/hooks/cors.js.map +1 -1
  51. package/dist/server/hooks/error-reporting.d.ts +8 -0
  52. package/dist/server/hooks/error-reporting.d.ts.map +1 -1
  53. package/dist/server/hooks/error-reporting.js +55 -25
  54. package/dist/server/hooks/error-reporting.js.map +1 -1
  55. package/dist/server/hooks/idempotency.d.ts +1 -1
  56. package/dist/server/hooks/idempotency.d.ts.map +1 -1
  57. package/dist/server/hooks/idempotency.js +30 -9
  58. package/dist/server/hooks/idempotency.js.map +1 -1
  59. package/dist/server/request-executor.d.ts.map +1 -1
  60. package/dist/server/request-executor.js +4 -10
  61. package/dist/server/request-executor.js.map +1 -1
  62. package/dist/server/request-preparation.d.ts.map +1 -1
  63. package/dist/server/request-preparation.js +3 -1
  64. package/dist/server/request-preparation.js.map +1 -1
  65. package/dist/server/response-finalization.d.ts +3 -1
  66. package/dist/server/response-finalization.d.ts.map +1 -1
  67. package/dist/server/response-finalization.js +24 -2
  68. package/dist/server/response-finalization.js.map +1 -1
  69. package/dist/server/server.js +1 -1
  70. package/dist/server/server.js.map +1 -1
  71. package/dist/testing/index.d.ts +5 -5
  72. package/dist/testing/index.d.ts.map +1 -1
  73. package/dist/testing/index.js +9 -4
  74. package/dist/testing/index.js.map +1 -1
  75. package/dist/tracing/index.d.ts +27 -0
  76. package/dist/tracing/index.d.ts.map +1 -1
  77. package/dist/tracing/index.js +96 -0
  78. package/dist/tracing/index.js.map +1 -1
  79. package/dist/uploads/index.d.ts.map +1 -1
  80. package/dist/uploads/index.js +39 -6
  81. package/dist/uploads/index.js.map +1 -1
  82. package/dist/webhooks/index.d.ts +2 -1
  83. package/dist/webhooks/index.d.ts.map +1 -1
  84. package/dist/webhooks/index.js +25 -1
  85. package/dist/webhooks/index.js.map +1 -1
  86. package/package.json +1 -1
  87. package/skills/app-architecture/SKILL.md +19 -1
  88. package/src/client/client.ts +6 -31
  89. package/src/error-reporting/index.ts +111 -0
  90. package/src/error-reporting/internal.ts +47 -0
  91. package/src/events/index.ts +20 -4
  92. package/src/idempotency/index.ts +81 -7
  93. package/src/jobs/index.ts +85 -6
  94. package/src/locks/index.ts +65 -16
  95. package/src/outbox/index.ts +119 -27
  96. package/src/ports/events.ts +8 -1
  97. package/src/ports/index.ts +4 -4
  98. package/src/ports/unbound.ts +1 -1
  99. package/src/ports/unit-of-work.ts +10 -3
  100. package/src/query-codec.ts +130 -0
  101. package/src/server/hooks/cors.ts +10 -3
  102. package/src/server/hooks/error-reporting.ts +79 -23
  103. package/src/server/hooks/idempotency.ts +36 -10
  104. package/src/server/request-executor.ts +4 -12
  105. package/src/server/request-preparation.ts +4 -2
  106. package/src/server/response-finalization.ts +36 -5
  107. package/src/server/server.ts +1 -1
  108. package/src/testing/index.ts +25 -14
  109. package/src/tracing/index.ts +120 -0
  110. package/src/uploads/index.ts +44 -6
  111. package/src/webhooks/index.ts +34 -2
@@ -4,6 +4,18 @@
4
4
  * Provider-neutral error reporting primitives for Beignet applications.
5
5
  */
6
6
 
7
+ import { type RedactionOptions, redactValue } from "../ports/redaction.js";
8
+ import {
9
+ DEFAULT_ERROR_REPORTING_TIMEOUT_MS,
10
+ errorReportingObserverTimeout,
11
+ runErrorReportingOperation,
12
+ } from "./internal.js";
13
+
14
+ export {
15
+ DEFAULT_ERROR_REPORTING_TIMEOUT_MS,
16
+ ErrorReportingTimeoutError,
17
+ } from "./internal.js";
18
+
7
19
  type MaybePromise<T> = T | Promise<T>;
8
20
 
9
21
  /**
@@ -67,6 +79,33 @@ export type ErrorReportOptions = {
67
79
  traceparent?: string;
68
80
  };
69
81
 
82
+ /**
83
+ * Arguments accepted by `tryReportException(...)`.
84
+ */
85
+ export interface TryReportExceptionOptions {
86
+ /** Reporter that owns the capture. Omit it to make reporting a no-op. */
87
+ reporter?: ErrorReporterPort | ErrorReporterResolver;
88
+ /** Original application or infrastructure error. */
89
+ error: unknown;
90
+ /** Structured metadata attached to the report. */
91
+ reportOptions?: ErrorReportOptions;
92
+ /**
93
+ * Maximum time allowed for capture and, separately, the failure observer.
94
+ * Set to `false` only when the reporting implementation is intentionally
95
+ * unbounded.
96
+ *
97
+ * @default 1000
98
+ */
99
+ timeoutMs?: number | false;
100
+ /**
101
+ * Observer for reporter failures. Observer failures are also isolated.
102
+ */
103
+ onReporterError?: (args: {
104
+ error: unknown;
105
+ reportingError: unknown;
106
+ }) => MaybePromise<void>;
107
+ }
108
+
70
109
  /**
71
110
  * Result returned by a reporting provider after capture.
72
111
  */
@@ -102,6 +141,11 @@ export type ErrorReporterPort = {
102
141
  flush(options?: ErrorReporterFlushOptions): Promise<boolean>;
103
142
  };
104
143
 
144
+ /** Lazy reporter resolver evaluated inside the best-effort capture deadline. */
145
+ export type ErrorReporterResolver = () => MaybePromise<
146
+ ErrorReporterPort | undefined
147
+ >;
148
+
105
149
  /**
106
150
  * Captured exception stored by `createMemoryErrorReporter(...)`.
107
151
  */
@@ -252,6 +296,73 @@ export function reportMessage(
252
296
  return reporter.captureMessage(message, options);
253
297
  }
254
298
 
299
+ /**
300
+ * Best-effort exception capture for runtime boundaries.
301
+ *
302
+ * Missing reporters, reporter failures, and reporter-failure observer errors
303
+ * resolve to `undefined` so diagnostics cannot replace application behavior.
304
+ */
305
+ export async function tryReportException(
306
+ options: TryReportExceptionOptions,
307
+ ): Promise<ErrorReportResult | undefined> {
308
+ const reporterSource = options.reporter;
309
+ if (!reporterSource) return undefined;
310
+
311
+ const timeoutMs = options.timeoutMs ?? DEFAULT_ERROR_REPORTING_TIMEOUT_MS;
312
+
313
+ try {
314
+ return await runErrorReportingOperation(async () => {
315
+ const reporter =
316
+ typeof reporterSource === "function"
317
+ ? await reporterSource()
318
+ : reporterSource;
319
+ if (!reporter) return undefined;
320
+ return reporter.captureException(options.error, options.reportOptions);
321
+ }, timeoutMs);
322
+ } catch (reportingError) {
323
+ try {
324
+ if (options.onReporterError) {
325
+ await runErrorReportingOperation(
326
+ () =>
327
+ options.onReporterError?.({
328
+ error: options.error,
329
+ reportingError,
330
+ }),
331
+ errorReportingObserverTimeout(timeoutMs),
332
+ );
333
+ }
334
+ } catch {
335
+ // Reporter failure observers must not replace application behavior.
336
+ }
337
+ return undefined;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Redact structured error-report metadata with Beignet's shared sensitive-key
343
+ * rules. The original exception is intentionally not part of this operation.
344
+ */
345
+ export function redactErrorReportOptions(
346
+ options: ErrorReportOptions,
347
+ redactionOptions: RedactionOptions = {},
348
+ ): ErrorReportOptions {
349
+ return {
350
+ ...options,
351
+ user: options.user
352
+ ? redactValue(options.user, redactionOptions)
353
+ : options.user,
354
+ tags: options.tags
355
+ ? redactValue(options.tags, redactionOptions)
356
+ : options.tags,
357
+ contexts: options.contexts
358
+ ? redactValue(options.contexts, redactionOptions)
359
+ : options.contexts,
360
+ extra: options.extra
361
+ ? redactValue(options.extra, redactionOptions)
362
+ : options.extra,
363
+ };
364
+ }
365
+
255
366
  function withAmbientState(
256
367
  port: MemoryErrorReporterPort,
257
368
  options: ErrorReportOptions | undefined,
@@ -0,0 +1,47 @@
1
+ type MaybePromise<T> = T | Promise<T>;
2
+
3
+ export const DEFAULT_ERROR_REPORTING_TIMEOUT_MS = 1000;
4
+
5
+ export class ErrorReportingTimeoutError extends Error {
6
+ readonly code = "ERROR_REPORTING_TIMEOUT" as const;
7
+ readonly timeoutMs: number;
8
+
9
+ constructor(timeoutMs: number) {
10
+ super(`Error reporting timed out after ${timeoutMs}ms.`);
11
+ this.name = "ErrorReportingTimeoutError";
12
+ this.timeoutMs = timeoutMs;
13
+ }
14
+ }
15
+
16
+ export function errorReportingObserverTimeout(
17
+ timeoutMs: number | false,
18
+ ): number | false {
19
+ return timeoutMs === false || (Number.isFinite(timeoutMs) && timeoutMs > 0)
20
+ ? timeoutMs
21
+ : DEFAULT_ERROR_REPORTING_TIMEOUT_MS;
22
+ }
23
+
24
+ export async function runErrorReportingOperation<T>(
25
+ operation: () => MaybePromise<T>,
26
+ timeoutMs: number | false = DEFAULT_ERROR_REPORTING_TIMEOUT_MS,
27
+ ): Promise<T> {
28
+ if (timeoutMs === false) return operation();
29
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
30
+ throw new TypeError("Error reporting timeoutMs must be a positive number.");
31
+ }
32
+
33
+ let timeout: ReturnType<typeof setTimeout> | undefined;
34
+ try {
35
+ return await Promise.race([
36
+ Promise.resolve().then(operation),
37
+ new Promise<never>((_, reject) => {
38
+ timeout = setTimeout(
39
+ () => reject(new ErrorReportingTimeoutError(timeoutMs)),
40
+ timeoutMs,
41
+ );
42
+ }),
43
+ ]);
44
+ } finally {
45
+ if (timeout !== undefined) clearTimeout(timeout);
46
+ }
47
+ }
@@ -1,6 +1,10 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
2
  import { runWithResolvedTracingContext } from "../tracing/execution.js";
3
- import type { TracingPort } from "../tracing/index.js";
3
+ import {
4
+ parseTraceCarrier,
5
+ type TraceCarrier,
6
+ type TracingPort,
7
+ } from "../tracing/index.js";
4
8
 
5
9
  /**
6
10
  * Any Standard Schema compatible validator.
@@ -58,6 +62,12 @@ export interface EventDef<
58
62
  export type InferEventPayload<E extends EventPayloadDef> =
59
63
  E["payload"] extends StandardSchemaV1<unknown, infer Output> ? Output : never;
60
64
 
65
+ /** Metadata propagated with an event delivery. */
66
+ export interface EventPublishOptions {
67
+ /** Versioned trace context captured by the event producer. */
68
+ trace?: TraceCarrier;
69
+ }
70
+
61
71
  /**
62
72
  * Options for `defineEvent(...)`.
63
73
  */
@@ -140,13 +150,17 @@ export interface EventBusLike {
140
150
  publish<E extends EventPayloadDef>(
141
151
  event: E,
142
152
  payload: InferEventPayload<E>,
153
+ options?: EventPublishOptions,
143
154
  ): MaybePromise<void>;
144
155
  /**
145
156
  * Subscribe to an event and return an unsubscribe function.
146
157
  */
147
158
  subscribe<E extends EventPayloadDef>(
148
159
  event: E,
149
- handler: (payload: InferEventPayload<E>) => MaybePromise<void>,
160
+ handler: (
161
+ payload: InferEventPayload<E>,
162
+ options?: EventPublishOptions,
163
+ ) => MaybePromise<void>,
150
164
  ): () => void;
151
165
  }
152
166
 
@@ -299,9 +313,10 @@ export async function publishEvent<E extends EventPayloadDef>(
299
313
  eventBus: EventBusLike,
300
314
  event: E,
301
315
  payload: InferEventPayload<E>,
316
+ options?: EventPublishOptions,
302
317
  ): Promise<void> {
303
318
  const parsed = await parseEventPayload(event, payload);
304
- await eventBus.publish(event, parsed);
319
+ await eventBus.publish(event, parsed, options);
305
320
  }
306
321
 
307
322
  /**
@@ -316,7 +331,7 @@ export function registerListeners<Ctx>(
316
331
  options: RegisterListenersOptions<Ctx> = {},
317
332
  ): () => void {
318
333
  const unsubscribes = listeners.map((listener) =>
319
- eventBus.subscribe(listener.event, async (rawPayload) => {
334
+ eventBus.subscribe(listener.event, async (rawPayload, publishOptions) => {
320
335
  try {
321
336
  const payload = await parseEventPayload(listener.event, rawPayload);
322
337
  const traceAttributes = {
@@ -330,6 +345,7 @@ export function registerListeners<Ctx>(
330
345
  name: `beignet.listener ${listener.name}`,
331
346
  type: "listener",
332
347
  kind: "consumer",
348
+ parent: parseTraceCarrier(publishOptions?.trace),
333
349
  attributes: traceAttributes,
334
350
  metricAttributes: traceAttributes,
335
351
  },
@@ -36,6 +36,9 @@ export type IdempotencyScopeMode =
36
36
  | "tenant"
37
37
  | "actor-tenant";
38
38
 
39
+ /** Default lifetime for unfinished idempotency reservations. */
40
+ export const DEFAULT_IDEMPOTENCY_RESERVATION_TTL_SEC = 300;
41
+
39
42
  /**
40
43
  * Contract metadata for idempotency-aware routes.
41
44
  *
@@ -65,9 +68,16 @@ export interface IdempotencyMeta {
65
68
  scope?: IdempotencyScopeMode;
66
69
 
67
70
  /**
68
- * Time-to-live for reserved and completed keys.
71
+ * Time-to-live for completed replay results.
69
72
  */
70
73
  ttlSec?: number;
74
+
75
+ /**
76
+ * Time-to-live for unfinished reservations.
77
+ *
78
+ * @default 300
79
+ */
80
+ reservationTtlSec?: number;
71
81
  }
72
82
 
73
83
  /**
@@ -91,9 +101,15 @@ export interface IdempotencyReserveInput {
91
101
  */
92
102
  fingerprint: string;
93
103
  /**
94
- * Optional key time-to-live in seconds.
104
+ * Optional time-to-live for the completed replay result.
95
105
  */
96
106
  ttlSec?: number;
107
+ /**
108
+ * Time-to-live for unfinished work before a successor may reserve the key.
109
+ *
110
+ * @default 300
111
+ */
112
+ reservationTtlSec?: number;
97
113
  }
98
114
 
99
115
  /**
@@ -106,6 +122,7 @@ export type IdempotencyReservation =
106
122
  key: string;
107
123
  scopeKey: string;
108
124
  fingerprint: string;
125
+ reservationToken: string;
109
126
  reservedAt: Date;
110
127
  expiresAt: Date | null;
111
128
  }
@@ -161,6 +178,8 @@ export interface IdempotencyCompleteInput {
161
178
  * Fingerprint that must match the reserved operation.
162
179
  */
163
180
  fingerprint: string;
181
+ /** Opaque identity returned by the matching reserved result. */
182
+ reservationToken: string;
164
183
  /**
165
184
  * Result to replay for future matching requests.
166
185
  */
@@ -187,6 +206,8 @@ export interface IdempotencyFailInput {
187
206
  * Fingerprint that must match the reserved operation.
188
207
  */
189
208
  fingerprint: string;
209
+ /** Opaque identity returned by the matching reserved result. */
210
+ reservationToken: string;
190
211
  /**
191
212
  * Error that caused the protected operation to fail.
192
213
  */
@@ -281,6 +302,8 @@ export interface MemoryIdempotencyEntry {
281
302
  * Fingerprint of the logical command payload.
282
303
  */
283
304
  fingerprint: string;
305
+ /** Opaque identity of the current in-progress reservation. */
306
+ reservationToken: string;
284
307
  /**
285
308
  * Memory store status.
286
309
  */
@@ -388,7 +411,9 @@ type CanonicalValue =
388
411
  | readonly CanonicalValue[]
389
412
  | { readonly [key: string]: CanonicalValue };
390
413
 
391
- type MemoryRecord = MemoryIdempotencyEntry;
414
+ type MemoryRecord = MemoryIdempotencyEntry & {
415
+ replayTtlSec?: number;
416
+ };
392
417
 
393
418
  function assertNonEmptyString(name: string, value: string): void {
394
419
  if (typeof value !== "string" || value.trim().length === 0) {
@@ -516,6 +541,8 @@ export interface MemoryIdempotencyStoreOptions {
516
541
  * system clock.
517
542
  */
518
543
  now?: () => Date;
544
+ /** Token factory used for deterministic tests. */
545
+ createReservationToken?: () => string;
519
546
  }
520
547
 
521
548
  /**
@@ -528,6 +555,8 @@ export function createMemoryIdempotencyStore(
528
555
  options: MemoryIdempotencyStoreOptions = {},
529
556
  ): MemoryIdempotencyStore {
530
557
  const storeNow = options.now ?? (() => new Date());
558
+ const createReservationToken =
559
+ options.createReservationToken ?? createRandomReservationToken;
531
560
  const records = new Map<string, MemoryRecord>();
532
561
 
533
562
  return {
@@ -540,6 +569,7 @@ export function createMemoryIdempotencyStore(
540
569
  assertNonEmptyString("key", input.key);
541
570
  assertNonEmptyString("fingerprint", input.fingerprint);
542
571
  assertTtl(input.ttlSec);
572
+ assertTtl(input.reservationTtlSec);
543
573
 
544
574
  const now = storeNow();
545
575
  const storageKey = createIdempotencyStorageKey(input);
@@ -558,9 +588,14 @@ export function createMemoryIdempotencyStore(
558
588
  key: input.key,
559
589
  scopeKey: normalizeIdempotencyScope(input.scope),
560
590
  fingerprint: input.fingerprint,
591
+ reservationToken: createReservationToken(),
592
+ replayTtlSec: input.ttlSec,
561
593
  status: "in-progress",
562
594
  reservedAt: now,
563
- expiresAt: resolveExpiresAt(input.ttlSec, now),
595
+ expiresAt: resolveExpiresAt(
596
+ input.reservationTtlSec ?? DEFAULT_IDEMPOTENCY_RESERVATION_TTL_SEC,
597
+ now,
598
+ ),
564
599
  };
565
600
 
566
601
  records.set(storageKey, record);
@@ -571,6 +606,7 @@ export function createMemoryIdempotencyStore(
571
606
  key: record.key,
572
607
  scopeKey: record.scopeKey,
573
608
  fingerprint: record.fingerprint,
609
+ reservationToken: record.reservationToken,
574
610
  reservedAt: record.reservedAt,
575
611
  expiresAt: record.expiresAt,
576
612
  };
@@ -583,11 +619,23 @@ export function createMemoryIdempotencyStore(
583
619
 
584
620
  const storageKey = createIdempotencyStorageKey(input);
585
621
  const existing = records.get(storageKey);
586
- if (!existing || existing.fingerprint !== input.fingerprint) return;
622
+ assertNonEmptyString("reservationToken", input.reservationToken);
623
+ if (
624
+ !existing ||
625
+ existing.fingerprint !== input.fingerprint ||
626
+ existing.reservationToken !== input.reservationToken ||
627
+ existing.status !== "in-progress"
628
+ ) {
629
+ return;
630
+ }
587
631
 
588
632
  existing.status = "completed";
589
633
  existing.result = input.result;
590
634
  existing.completedAt = storeNow();
635
+ existing.expiresAt = resolveExpiresAt(
636
+ existing.replayTtlSec,
637
+ existing.completedAt,
638
+ );
591
639
  },
592
640
 
593
641
  async fail(input) {
@@ -597,8 +645,15 @@ export function createMemoryIdempotencyStore(
597
645
 
598
646
  const storageKey = createIdempotencyStorageKey(input);
599
647
  const existing = records.get(storageKey);
600
- if (!existing || existing.fingerprint !== input.fingerprint) return;
601
- if (existing.status === "completed") return;
648
+ assertNonEmptyString("reservationToken", input.reservationToken);
649
+ if (
650
+ !existing ||
651
+ existing.fingerprint !== input.fingerprint ||
652
+ existing.reservationToken !== input.reservationToken ||
653
+ existing.status === "completed"
654
+ ) {
655
+ return;
656
+ }
602
657
 
603
658
  records.delete(storageKey);
604
659
  },
@@ -627,6 +682,7 @@ export async function runIdempotently<Result>(
627
682
  scope: options.scope,
628
683
  fingerprint: options.fingerprint,
629
684
  ttlSec: options.ttlSec,
685
+ reservationTtlSec: options.reservationTtlSec,
630
686
  };
631
687
  const reservation = await idempotency.reserve(operation);
632
688
 
@@ -653,6 +709,7 @@ export async function runIdempotently<Result>(
653
709
  key: operation.key,
654
710
  scope: operation.scope,
655
711
  fingerprint: operation.fingerprint,
712
+ reservationToken: reservation.reservationToken,
656
713
  error,
657
714
  });
658
715
  throw error;
@@ -663,6 +720,7 @@ export async function runIdempotently<Result>(
663
720
  key: operation.key,
664
721
  scope: operation.scope,
665
722
  fingerprint: operation.fingerprint,
723
+ reservationToken: reservation.reservationToken,
666
724
  result,
667
725
  });
668
726
  return result;
@@ -676,6 +734,22 @@ export async function runIdempotently<Result>(
676
734
  }
677
735
  }
678
736
 
737
+ function createRandomReservationToken(): string {
738
+ if (typeof globalThis.crypto?.randomUUID === "function") {
739
+ return globalThis.crypto.randomUUID();
740
+ }
741
+
742
+ const bytes = new Uint8Array(16);
743
+ globalThis.crypto?.getRandomValues?.(bytes);
744
+ if (bytes.some((value) => value !== 0)) {
745
+ return Array.from(bytes, (value) =>
746
+ value.toString(16).padStart(2, "0"),
747
+ ).join("");
748
+ }
749
+
750
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
751
+ }
752
+
679
753
  function normalizeOmitPath(
680
754
  path: string | readonly string[],
681
755
  ): readonly string[] {
package/src/jobs/index.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
2
  import type { LeaseHandle, LeaseMetadata, LocksPort } from "../locks/index.js";
3
3
  import { runWithResolvedTracingContext } from "../tracing/execution.js";
4
- import type { TracingPort } from "../tracing/index.js";
4
+ import {
5
+ parseTraceCarrier,
6
+ type TraceCarrier,
7
+ type TracingPort,
8
+ } from "../tracing/index.js";
5
9
 
6
10
  /**
7
11
  * Any Standard Schema compatible validator.
@@ -582,6 +586,73 @@ export interface InlineJobDispatcherOptions<Ctx> {
582
586
  hooks?: readonly JobHook<JobDef<string, StandardSchema, Ctx>, Ctx>[];
583
587
  }
584
588
 
589
+ /** Metadata propagated when a job is dispatched. */
590
+ export interface JobDispatchOptions {
591
+ /** Versioned trace context captured by the job producer. */
592
+ trace?: TraceCarrier;
593
+ }
594
+
595
+ const JOB_TRANSPORT_ENVELOPE_TYPE = "beignet.job";
596
+ const JOB_TRANSPORT_ENVELOPE_VERSION = 1;
597
+
598
+ /** Parsed payload and propagation metadata from a job transport envelope. */
599
+ export interface ParsedJobTransportEnvelope {
600
+ payload: unknown;
601
+ trace?: TraceCarrier;
602
+ }
603
+
604
+ /**
605
+ * Wrap a job payload with transport metadata when a trace is present.
606
+ * Payloads without metadata retain their legacy wire shape.
607
+ */
608
+ export function createJobTransportEnvelope(
609
+ payload: unknown,
610
+ options?: JobDispatchOptions,
611
+ ): unknown {
612
+ const trace = parseTraceCarrier(options?.trace);
613
+ if (!trace) return payload;
614
+
615
+ return {
616
+ __beignet: {
617
+ type: JOB_TRANSPORT_ENVELOPE_TYPE,
618
+ version: JOB_TRANSPORT_ENVELOPE_VERSION,
619
+ trace,
620
+ },
621
+ payload,
622
+ };
623
+ }
624
+
625
+ /**
626
+ * Decode a Beignet job transport envelope while accepting legacy raw payloads.
627
+ * Unknown or malformed trace metadata is ignored without dropping the payload.
628
+ */
629
+ export function parseJobTransportEnvelope(
630
+ value: unknown,
631
+ ): ParsedJobTransportEnvelope {
632
+ if (typeof value !== "object" || value === null || !("payload" in value)) {
633
+ return { payload: value };
634
+ }
635
+
636
+ const metadata = "__beignet" in value ? value.__beignet : undefined;
637
+ if (
638
+ typeof metadata !== "object" ||
639
+ metadata === null ||
640
+ !("type" in metadata) ||
641
+ metadata.type !== JOB_TRANSPORT_ENVELOPE_TYPE ||
642
+ !("version" in metadata) ||
643
+ metadata.version !== JOB_TRANSPORT_ENVELOPE_VERSION
644
+ ) {
645
+ return { payload: value };
646
+ }
647
+
648
+ const trace =
649
+ "trace" in metadata ? parseTraceCarrier(metadata.trace) : undefined;
650
+ return {
651
+ payload: value.payload,
652
+ ...(trace ? { trace } : {}),
653
+ };
654
+ }
655
+
585
656
  /**
586
657
  * Well-known symbol under which the inline dispatcher exposes a
587
658
  * single-attempt dispatch. Delivery systems that own execution retries
@@ -597,7 +668,7 @@ export const SINGLE_ATTEMPT_DISPATCH: unique symbol = Symbol.for(
597
668
  * Metadata passed to a single-attempt dispatch when another delivery layer
598
669
  * owns retry scheduling.
599
670
  */
600
- export interface SingleAttemptJobDispatchOptions {
671
+ export interface SingleAttemptJobDispatchOptions extends JobDispatchOptions {
601
672
  /**
602
673
  * One-based delivery attempt.
603
674
  */
@@ -625,6 +696,7 @@ export interface JobDispatcher {
625
696
  dispatch<J extends JobDef>(
626
697
  job: J,
627
698
  payload: InferJobPayload<J>,
699
+ options?: JobDispatchOptions,
628
700
  ): MaybePromise<void>;
629
701
  }
630
702
 
@@ -693,6 +765,7 @@ export interface InlineJobDispatcher<Ctx = unknown> {
693
765
  dispatch<J extends JobDef<string, StandardSchema, Ctx>>(
694
766
  job: J,
695
767
  payload: InferJobPayload<J>,
768
+ options?: JobDispatchOptions,
696
769
  ): Promise<void>;
697
770
  }
698
771
 
@@ -1275,6 +1348,8 @@ export interface RunJobHandlerOptions<
1275
1348
  ctx: Ctx | (() => MaybePromise<Ctx>);
1276
1349
  /** Runtime tracing port used before a lazy context factory runs. */
1277
1350
  tracing?: TracingPort;
1351
+ /** Trace context captured by the dispatching process. */
1352
+ trace?: TraceCarrier;
1278
1353
  /**
1279
1354
  * Runner-level hooks. These wrap job-local hooks.
1280
1355
  */
@@ -1327,6 +1402,7 @@ export async function runJobHandler<
1327
1402
  name: `beignet.job ${args.job.name}`,
1328
1403
  type: "job",
1329
1404
  kind: "consumer",
1405
+ parent: parseTraceCarrier(args.trace),
1330
1406
  attributes: traceAttributes,
1331
1407
  metricAttributes: traceAttributes,
1332
1408
  },
@@ -1485,6 +1561,7 @@ export function createInlineJobDispatcher<Ctx>(
1485
1561
  payload: parsed,
1486
1562
  ctx,
1487
1563
  hooks: options.hooks,
1564
+ trace: dispatchOptions.trace,
1488
1565
  attempt: dispatchOptions.attempt ?? attempt,
1489
1566
  maxAttempts: dispatchOptions.maxAttempts ?? maxAttempts,
1490
1567
  });
@@ -1515,8 +1592,9 @@ export function createInlineJobDispatcher<Ctx>(
1515
1592
  async dispatch<J extends JobDef<string, StandardSchema, Ctx>>(
1516
1593
  job: J,
1517
1594
  payload: InferJobPayload<J>,
1595
+ dispatchOptions?: JobDispatchOptions,
1518
1596
  ) {
1519
- await run(job, payload, options.retry !== false);
1597
+ await run(job, payload, options.retry !== false, dispatchOptions);
1520
1598
  },
1521
1599
  };
1522
1600
 
@@ -1550,9 +1628,10 @@ export function createUniqueJobDispatcher(
1550
1628
  async dispatch<J extends JobDef>(
1551
1629
  job: J,
1552
1630
  payload: InferJobPayload<J>,
1631
+ dispatchOptions?: JobDispatchOptions,
1553
1632
  ): Promise<void> {
1554
1633
  if (!job.unique) {
1555
- await options.jobs.dispatch(job, payload);
1634
+ await options.jobs.dispatch(job, payload, dispatchOptions);
1556
1635
  return;
1557
1636
  }
1558
1637
 
@@ -1561,7 +1640,7 @@ export function createUniqueJobDispatcher(
1561
1640
  keyPrefix: options.keyPrefix,
1562
1641
  });
1563
1642
  if (!unique) {
1564
- await options.jobs.dispatch(job, parsed);
1643
+ await options.jobs.dispatch(job, parsed, dispatchOptions);
1565
1644
  return;
1566
1645
  }
1567
1646
 
@@ -1587,7 +1666,7 @@ export function createUniqueJobDispatcher(
1587
1666
  }
1588
1667
 
1589
1668
  try {
1590
- await options.jobs.dispatch(job, parsed);
1669
+ await options.jobs.dispatch(job, parsed, dispatchOptions);
1591
1670
  } catch (error) {
1592
1671
  try {
1593
1672
  await result.lease.release();