@beignet/core 0.0.36 → 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 (145) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +135 -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/contracts/contract-builder.d.ts +7 -0
  7. package/dist/contracts/contract-builder.d.ts.map +1 -1
  8. package/dist/contracts/contract-builder.js +22 -0
  9. package/dist/contracts/contract-builder.js.map +1 -1
  10. package/dist/contracts/contract-group.d.ts +7 -0
  11. package/dist/contracts/contract-group.d.ts.map +1 -1
  12. package/dist/contracts/contract-group.js +14 -0
  13. package/dist/contracts/contract-group.js.map +1 -1
  14. package/dist/contracts/index.d.ts +4 -0
  15. package/dist/contracts/index.d.ts.map +1 -1
  16. package/dist/contracts/index.js +4 -0
  17. package/dist/contracts/index.js.map +1 -1
  18. package/dist/contracts/lifecycle.d.ts +40 -0
  19. package/dist/contracts/lifecycle.d.ts.map +1 -0
  20. package/dist/contracts/lifecycle.js +174 -0
  21. package/dist/contracts/lifecycle.js.map +1 -0
  22. package/dist/contracts/types.d.ts +5 -0
  23. package/dist/contracts/types.d.ts.map +1 -1
  24. package/dist/contracts/types.js.map +1 -1
  25. package/dist/error-reporting/index.d.ts +42 -1
  26. package/dist/error-reporting/index.d.ts.map +1 -1
  27. package/dist/error-reporting/index.js +60 -0
  28. package/dist/error-reporting/index.js.map +1 -1
  29. package/dist/error-reporting/internal.d.ts +11 -0
  30. package/dist/error-reporting/internal.d.ts.map +1 -0
  31. package/dist/error-reporting/internal.js +36 -0
  32. package/dist/error-reporting/internal.js.map +1 -0
  33. package/dist/events/index.d.ts +9 -4
  34. package/dist/events/index.d.ts.map +1 -1
  35. package/dist/events/index.js +5 -3
  36. package/dist/events/index.js.map +1 -1
  37. package/dist/idempotency/index.d.ts +25 -2
  38. package/dist/idempotency/index.d.ts.map +1 -1
  39. package/dist/idempotency/index.js +35 -5
  40. package/dist/idempotency/index.js.map +1 -1
  41. package/dist/jobs/index.d.ts +26 -4
  42. package/dist/jobs/index.d.ts.map +1 -1
  43. package/dist/jobs/index.js +51 -6
  44. package/dist/jobs/index.js.map +1 -1
  45. package/dist/locks/index.d.ts +19 -1
  46. package/dist/locks/index.d.ts.map +1 -1
  47. package/dist/locks/index.js +27 -12
  48. package/dist/locks/index.js.map +1 -1
  49. package/dist/openapi/index.d.ts +3 -1
  50. package/dist/openapi/index.d.ts.map +1 -1
  51. package/dist/openapi/index.js +14 -2
  52. package/dist/openapi/index.js.map +1 -1
  53. package/dist/outbox/index.d.ts +23 -4
  54. package/dist/outbox/index.d.ts.map +1 -1
  55. package/dist/outbox/index.js +71 -23
  56. package/dist/outbox/index.js.map +1 -1
  57. package/dist/ports/events.d.ts +5 -5
  58. package/dist/ports/events.d.ts.map +1 -1
  59. package/dist/ports/index.d.ts +4 -4
  60. package/dist/ports/unbound.js +1 -1
  61. package/dist/ports/unbound.js.map +1 -1
  62. package/dist/ports/unit-of-work.d.ts +4 -1
  63. package/dist/ports/unit-of-work.d.ts.map +1 -1
  64. package/dist/ports/unit-of-work.js +4 -3
  65. package/dist/ports/unit-of-work.js.map +1 -1
  66. package/dist/query-codec.d.ts +3 -0
  67. package/dist/query-codec.d.ts.map +1 -0
  68. package/dist/query-codec.js +110 -0
  69. package/dist/query-codec.js.map +1 -0
  70. package/dist/server/hooks/cors.d.ts +3 -2
  71. package/dist/server/hooks/cors.d.ts.map +1 -1
  72. package/dist/server/hooks/cors.js +7 -3
  73. package/dist/server/hooks/cors.js.map +1 -1
  74. package/dist/server/hooks/error-reporting.d.ts +8 -0
  75. package/dist/server/hooks/error-reporting.d.ts.map +1 -1
  76. package/dist/server/hooks/error-reporting.js +55 -25
  77. package/dist/server/hooks/error-reporting.js.map +1 -1
  78. package/dist/server/hooks/idempotency.d.ts +1 -1
  79. package/dist/server/hooks/idempotency.d.ts.map +1 -1
  80. package/dist/server/hooks/idempotency.js +30 -9
  81. package/dist/server/hooks/idempotency.js.map +1 -1
  82. package/dist/server/request-executor.d.ts.map +1 -1
  83. package/dist/server/request-executor.js +7 -12
  84. package/dist/server/request-executor.js.map +1 -1
  85. package/dist/server/request-preparation.d.ts.map +1 -1
  86. package/dist/server/request-preparation.js +3 -1
  87. package/dist/server/request-preparation.js.map +1 -1
  88. package/dist/server/response-finalization.d.ts +5 -1
  89. package/dist/server/response-finalization.d.ts.map +1 -1
  90. package/dist/server/response-finalization.js +66 -2
  91. package/dist/server/response-finalization.js.map +1 -1
  92. package/dist/server/server.d.ts.map +1 -1
  93. package/dist/server/server.js +10 -1
  94. package/dist/server/server.js.map +1 -1
  95. package/dist/testing/index.d.ts +5 -5
  96. package/dist/testing/index.d.ts.map +1 -1
  97. package/dist/testing/index.js +9 -4
  98. package/dist/testing/index.js.map +1 -1
  99. package/dist/tracing/index.d.ts +27 -0
  100. package/dist/tracing/index.d.ts.map +1 -1
  101. package/dist/tracing/index.js +96 -0
  102. package/dist/tracing/index.js.map +1 -1
  103. package/dist/uploads/index.d.ts.map +1 -1
  104. package/dist/uploads/index.js +39 -6
  105. package/dist/uploads/index.js.map +1 -1
  106. package/dist/webhooks/index.d.ts +2 -1
  107. package/dist/webhooks/index.d.ts.map +1 -1
  108. package/dist/webhooks/index.js +25 -1
  109. package/dist/webhooks/index.js.map +1 -1
  110. package/package.json +1 -1
  111. package/skills/app-architecture/SKILL.md +19 -1
  112. package/src/client/client.ts +6 -31
  113. package/src/contracts/contract-builder.ts +38 -0
  114. package/src/contracts/contract-group.ts +25 -0
  115. package/src/contracts/index.ts +8 -0
  116. package/src/contracts/lifecycle.ts +236 -0
  117. package/src/contracts/types.ts +5 -0
  118. package/src/error-reporting/index.ts +111 -0
  119. package/src/error-reporting/internal.ts +47 -0
  120. package/src/events/index.ts +20 -4
  121. package/src/idempotency/index.ts +81 -7
  122. package/src/jobs/index.ts +85 -6
  123. package/src/locks/index.ts +65 -16
  124. package/src/openapi/index.ts +22 -2
  125. package/src/outbox/index.ts +119 -27
  126. package/src/ports/events.ts +8 -1
  127. package/src/ports/index.ts +4 -4
  128. package/src/ports/unbound.ts +1 -1
  129. package/src/ports/unit-of-work.ts +10 -3
  130. package/src/query-codec.ts +130 -0
  131. package/src/server/hooks/cors.ts +10 -3
  132. package/src/server/hooks/error-reporting.ts +79 -23
  133. package/src/server/hooks/idempotency.ts +36 -10
  134. package/src/server/request-executor.ts +10 -13
  135. package/src/server/request-preparation.ts +4 -2
  136. package/src/server/response-finalization.ts +94 -5
  137. package/src/server/server.ts +15 -1
  138. package/src/testing/index.ts +25 -14
  139. package/src/tracing/index.ts +120 -0
  140. package/src/uploads/index.ts +44 -6
  141. package/src/webhooks/index.ts +34 -2
  142. package/dist/domain/events.d.ts +0 -44
  143. package/dist/domain/events.d.ts.map +0 -1
  144. package/dist/domain/events.js +0 -24
  145. package/dist/domain/events.js.map +0 -1
@@ -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();
@@ -64,6 +64,25 @@ export type LeaseRenewOptions = {
64
64
  ttlMs?: number;
65
65
  };
66
66
 
67
+ /**
68
+ * State required to restore a lease handle in another runtime invocation.
69
+ */
70
+ export type LeaseRestoreOptions = {
71
+ /**
72
+ * TTL to use when `renew()` is called without an explicit override.
73
+ */
74
+ ttlMs: number;
75
+ /**
76
+ * Persisted expiry when the caller has it. Omitted values stay unknown.
77
+ */
78
+ expiresAt?: Date;
79
+ /**
80
+ * Persisted fencing token when the caller has it. Omitted values stay
81
+ * unknown rather than being fabricated.
82
+ */
83
+ fencingToken?: string | number;
84
+ };
85
+
67
86
  /**
68
87
  * Active lease handle returned by a lock provider.
69
88
  */
@@ -100,7 +119,11 @@ export type LocksPort = {
100
119
  options: LeaseAcquireOptions,
101
120
  fn: (ctx: { lease: LeaseHandle }) => MaybePromise<T>,
102
121
  ): Promise<T | undefined>;
103
- restore(key: string, ownerToken: string): LeaseHandle;
122
+ restore(
123
+ key: string,
124
+ ownerToken: string,
125
+ options: LeaseRestoreOptions,
126
+ ): LeaseHandle;
104
127
  forceRelease(key: string): Promise<boolean>;
105
128
  };
106
129
 
@@ -220,20 +243,21 @@ export function createMemoryLocks(
220
243
  await result.lease.release();
221
244
  }
222
245
  },
223
- restore(key, ownerToken) {
246
+ restore(key, ownerToken, restoreOptions) {
224
247
  if (!key) throw new LeaseOptionsError("Lease key is required.");
225
248
  if (!ownerToken) {
226
249
  throw new LeaseOptionsError("Lease owner token is required.");
227
250
  }
251
+ validateRestoreOptions(restoreOptions);
228
252
 
229
253
  return createMemoryLease(
230
254
  port,
231
255
  {
232
256
  key,
233
257
  ownerToken,
234
- expiresAt: new Date(0),
235
- ttlMs: 1,
236
- fencingToken: 0,
258
+ ttlMs: restoreOptions.ttlMs,
259
+ expiresAt: restoreOptions.expiresAt,
260
+ fencingToken: restoreOptions.fencingToken,
237
261
  },
238
262
  now,
239
263
  );
@@ -306,32 +330,40 @@ export function withLease<T>(
306
330
 
307
331
  function createMemoryLease(
308
332
  port: MemoryLocksPort,
309
- record: MemoryLeaseRecord,
333
+ record: {
334
+ key: string;
335
+ ownerToken: string;
336
+ ttlMs: number;
337
+ expiresAt?: Date;
338
+ fencingToken?: string | number;
339
+ },
310
340
  now: () => Date,
311
341
  ): LeaseHandle {
312
342
  const lease: LeaseHandle = {
313
343
  key: record.key,
314
344
  ownerToken: record.ownerToken,
315
- expiresAt: record.expiresAt,
316
- fencingToken: record.fencingToken,
345
+ ...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
346
+ ...(record.fencingToken !== undefined
347
+ ? { fencingToken: record.fencingToken }
348
+ : {}),
317
349
  async renew(options) {
318
350
  const active = port.leases.get(record.key);
319
- if (
320
- !active ||
321
- active.ownerToken !== record.ownerToken ||
322
- active.expiresAt.getTime() <= now().getTime()
323
- ) {
351
+ if (!active || active.ownerToken !== record.ownerToken) {
352
+ return false;
353
+ }
354
+ if (active.expiresAt.getTime() <= now().getTime()) {
324
355
  port.leases.delete(record.key);
325
356
  return false;
326
357
  }
327
358
 
328
- const ttlMs = options?.ttlMs ?? active.ttlMs;
359
+ const ttlMs = options?.ttlMs ?? record.ttlMs;
329
360
  if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
330
361
  throw new LeaseOptionsError("Lease ttlMs must be a positive number.");
331
362
  }
332
363
 
333
364
  active.ttlMs = ttlMs;
334
365
  active.expiresAt = new Date(now().getTime() + ttlMs);
366
+ record.ttlMs = ttlMs;
335
367
  lease.expiresAt = active.expiresAt;
336
368
  return true;
337
369
  },
@@ -352,6 +384,23 @@ function createMemoryLease(
352
384
  return lease;
353
385
  }
354
386
 
387
+ function validateRestoreOptions(options: LeaseRestoreOptions): void {
388
+ if (!options || !Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
389
+ throw new LeaseOptionsError(
390
+ "Lease restore ttlMs must be a positive number.",
391
+ );
392
+ }
393
+ if (
394
+ options.expiresAt !== undefined &&
395
+ (!(options.expiresAt instanceof Date) ||
396
+ !Number.isFinite(options.expiresAt.getTime()))
397
+ ) {
398
+ throw new LeaseOptionsError(
399
+ "Lease restore expiresAt must be a valid Date.",
400
+ );
401
+ }
402
+ }
403
+
355
404
  function instrumentLocks(
356
405
  locks: LocksPort,
357
406
  instrumentation: ReturnType<typeof createProviderInstrumentation>,
@@ -387,8 +436,8 @@ function instrumentLocks(
387
436
  await result.lease.release();
388
437
  }
389
438
  },
390
- restore(key, ownerToken) {
391
- return locks.restore(key, ownerToken);
439
+ restore(key, ownerToken, options) {
440
+ return locks.restore(key, ownerToken, options);
392
441
  },
393
442
  async forceRelease(key) {
394
443
  const released = await locks.forceRelease(key);
@@ -6,6 +6,7 @@
6
6
 
7
7
  import {
8
8
  type AnyContract,
9
+ type ContractDeprecationMeta,
9
10
  type ContractLike,
10
11
  getContractHeaderSchemas,
11
12
  methodSupportsRequestBody,
@@ -13,6 +14,10 @@ import {
13
14
  resolveContract,
14
15
  STANDARD_ERROR_RESPONSE_SCHEMA,
15
16
  } from "../contracts/index.js";
17
+ import {
18
+ assertValidContractLifecycle,
19
+ getContractOperationId,
20
+ } from "../contracts/lifecycle.js";
16
21
  import {
17
22
  comparePathParamsToTemplate,
18
23
  formatPathParamsMismatch,
@@ -235,6 +240,8 @@ export interface OperationObject {
235
240
  * Whether the operation is deprecated.
236
241
  */
237
242
  deprecated?: boolean;
243
+ /** Beignet lifecycle details for a deprecated operation. */
244
+ "x-beignet-deprecation"?: ContractDeprecationMeta;
238
245
  /**
239
246
  * External documentation.
240
247
  */
@@ -409,9 +416,20 @@ export function contractsToOpenAPI(
409
416
  createZodSchemaConverter(),
410
417
  ],
411
418
  };
419
+ const operationIds = new Map<string, string>();
412
420
 
413
421
  for (const contract of contracts) {
414
422
  const config = resolveContract(contract);
423
+ assertValidContractLifecycle(config);
424
+ const operationId = getContractOperationId(config);
425
+ const route = `${config.method} ${config.path}`;
426
+ const conflictingRoute = operationIds.get(operationId);
427
+ if (conflictingRoute) {
428
+ throw new Error(
429
+ `Duplicate OpenAPI operationId: "${operationId}" is used by both ${conflictingRoute} and ${route}. Operation IDs must be unique within an OpenAPI document.`,
430
+ );
431
+ }
432
+ operationIds.set(operationId, route);
415
433
  addContractToPaths(config, paths, state);
416
434
  }
417
435
 
@@ -454,13 +472,15 @@ function addContractToPaths(
454
472
  const pathItem = paths[pathKey];
455
473
 
456
474
  const meta = contract.metadata?.openapi;
475
+ const deprecation = contract.metadata?.deprecation;
457
476
 
458
477
  const operation: OperationObject = {
459
- operationId: meta?.operationId ?? contract.name,
478
+ operationId: getContractOperationId(contract),
460
479
  summary: meta?.summary,
461
480
  description: meta?.description,
462
481
  tags: meta?.tags,
463
- deprecated: meta?.deprecated,
482
+ deprecated: deprecation ? true : meta?.deprecated,
483
+ "x-beignet-deprecation": deprecation,
464
484
  externalDocs: meta?.externalDocs,
465
485
  security: meta?.security,
466
486
  parameters: [],