@beignet/core 0.0.43 → 0.0.45

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 (51) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +41 -4
  3. package/dist/application/index.d.ts.map +1 -1
  4. package/dist/application/index.js +9 -4
  5. package/dist/application/index.js.map +1 -1
  6. package/dist/client/client.d.ts.map +1 -1
  7. package/dist/client/client.js +45 -8
  8. package/dist/client/client.js.map +1 -1
  9. package/dist/events/index.js +2 -2
  10. package/dist/events/index.js.map +1 -1
  11. package/dist/idempotency/index.d.ts +26 -0
  12. package/dist/idempotency/index.d.ts.map +1 -1
  13. package/dist/idempotency/index.js +116 -47
  14. package/dist/idempotency/index.js.map +1 -1
  15. package/dist/jobs/index.js +2 -2
  16. package/dist/jobs/index.js.map +1 -1
  17. package/dist/notifications/index.js +1 -1
  18. package/dist/notifications/index.js.map +1 -1
  19. package/dist/outbox/index.d.ts.map +1 -1
  20. package/dist/outbox/index.js +9 -9
  21. package/dist/outbox/index.js.map +1 -1
  22. package/dist/ports/unit-of-work.d.ts.map +1 -1
  23. package/dist/ports/unit-of-work.js +2 -2
  24. package/dist/ports/unit-of-work.js.map +1 -1
  25. package/dist/server/hooks/idempotency.d.ts.map +1 -1
  26. package/dist/server/hooks/idempotency.js +37 -9
  27. package/dist/server/hooks/idempotency.js.map +1 -1
  28. package/dist/server/request-preparation.d.ts.map +1 -1
  29. package/dist/server/request-preparation.js +19 -11
  30. package/dist/server/request-preparation.js.map +1 -1
  31. package/dist/server/use-case-route.d.ts +2 -0
  32. package/dist/server/use-case-route.d.ts.map +1 -1
  33. package/dist/server/use-case-route.js +3 -1
  34. package/dist/server/use-case-route.js.map +1 -1
  35. package/dist/webhooks/index.d.ts.map +1 -1
  36. package/dist/webhooks/index.js +1 -1
  37. package/dist/webhooks/index.js.map +1 -1
  38. package/package.json +1 -1
  39. package/skills/app-architecture/SKILL.md +44 -2
  40. package/src/application/index.ts +10 -12
  41. package/src/client/client.ts +48 -8
  42. package/src/events/index.ts +2 -2
  43. package/src/idempotency/index.ts +148 -57
  44. package/src/jobs/index.ts +2 -2
  45. package/src/notifications/index.ts +1 -1
  46. package/src/outbox/index.ts +13 -9
  47. package/src/ports/unit-of-work.ts +6 -2
  48. package/src/server/hooks/idempotency.ts +44 -8
  49. package/src/server/request-preparation.ts +21 -10
  50. package/src/server/use-case-route.ts +7 -1
  51. package/src/webhooks/index.ts +4 -1
@@ -84,7 +84,7 @@ import { z } from "zod";
84
84
  import { errors } from "@/features/shared/errors";
85
85
 
86
86
  export const projectSchema = z.object({
87
- id: z.string().uuid(),
87
+ id: z.uuid(),
88
88
  name: z.string().min(1),
89
89
  });
90
90
 
@@ -94,7 +94,7 @@ const projects = defineContractGroup()
94
94
 
95
95
  export const getProject = projects
96
96
  .get("/:id")
97
- .pathParams(z.object({ id: z.string().uuid() }))
97
+ .pathParams(z.object({ id: z.uuid() }))
98
98
  .errors({ ProjectNotFound: errors.ProjectNotFound })
99
99
  .responses({ 200: projectSchema });
100
100
  ```
@@ -154,6 +154,12 @@ or outbox records must commit together.
154
154
  Do not import provider packages from contracts, use cases, routes, UI, or
155
155
  feature client modules.
156
156
 
157
+ Prefer typed options objects for provider factories. Explicit first-party
158
+ options override matching environment values. Treat injected SDK or database
159
+ clients as caller-owned unless an option such as `closeOnStop: true` delegates
160
+ shutdown to one provider instance, and preserve provider instrumentation when
161
+ using a lower-level direct adapter.
162
+
157
163
  Tenant-owned repository methods should accept `TenantScope` instead of raw
158
164
  tenant IDs. Use cases derive it with `requireTenantScope(ctx)` or
159
165
  `createTenantScope(...)`; adapters unwrap it with `tenantScopeId(scope)` and
@@ -161,6 +167,11 @@ include that storage ID in read, write, update, and delete predicates.
161
167
  Provider-correlation lookups such as webhook customer IDs are not tenant
162
168
  authorization by themselves.
163
169
 
170
+ Resolve request tenants only from membership-backed app state or provider
171
+ claims that the app explicitly treats as authoritative and current. Keep that
172
+ decision behind an app-owned resolver. Never turn caller-supplied tenant IDs or
173
+ unverified session fields directly into `TenantContext`.
174
+
164
175
  ## Workflows
165
176
 
166
177
  Feature-owned workflow definitions belong under the feature:
@@ -262,6 +273,37 @@ messages or make `AppError.details` safe to export.
262
273
  Best-effort capture is bounded to one second by default. Tune `timeoutMs` when
263
274
  needed; use `false` only when reporting may intentionally block the boundary.
264
275
 
276
+ ## Runtime Safety
277
+
278
+ - HTTP idempotency defaults to the current actor and includes the current
279
+ tenant when one exists. The default actor scope fails closed when the
280
+ required identity is missing. Use
281
+ `meta.scope: "global"` only for deliberately public operations whose callers
282
+ should share one key namespace.
283
+ - Idempotency ports must reject `complete(...)` and `fail(...)` when the
284
+ fingerprint, reservation token, or in-progress state no longer matches.
285
+ Never silently drop a stale mutation: callers would mistake an unstored
286
+ result for a replayable one. The memory adapter throws
287
+ `IdempotencyMutationError`; Drizzle dialects expose corresponding
288
+ `Drizzle*IdempotencyMutationError` classes.
289
+ - `formatMailAddress(...)` rejects carriage returns and line feeds before a
290
+ provider builds headers; providers still own complete email-syntax
291
+ validation. Mail providers make one vendor call, so put retryable delivery
292
+ behind jobs or the outbox.
293
+ - Keep storage keys relative and reuse `assertValidStorageKey(...)`,
294
+ `normalizeStorageKeyPrefix(...)`, `prefixStorageKey(...)`, and
295
+ `createStoragePublicUrl(...)` from `@beignet/core/ports` in custom adapters.
296
+ Add provider-specific restrictions only after the shared validation.
297
+ - Uploads are protected by default. Declare `authorize(...)` or deliberately
298
+ opt into `access: "public"`; server uploads authorize each file before
299
+ reading bytes for validation. Include actor, tenant, or resource ownership
300
+ in object keys.
301
+ - Direct-upload completion is stateless, so make `onComplete(...)` idempotent
302
+ by upload ID or object key and use app-owned issuance state for single-use or
303
+ revocable grants. Before app-owned completion starts, a failed server batch
304
+ removes objects already written. After `onComplete(...)` starts, durable
305
+ references may exist and transaction or compensation belongs to the app.
306
+
265
307
  ## Validation
266
308
 
267
309
  After core app-architecture changes, run from the app root:
@@ -301,21 +301,13 @@ function createUseCaseEventHelpers<Emits extends readonly DomainEventLike[]>(
301
301
  assertDeclared,
302
302
  async record(recorder, event, payload) {
303
303
  const declaredEvent = resolveDeclared(event) as typeof event;
304
- const parsed = await parseEventPayload(
305
- declaredEvent,
306
- payload,
307
- useCaseName,
308
- );
309
- await recorder.record(declaredEvent, parsed);
304
+ await parseEventPayload(declaredEvent, payload, useCaseName);
305
+ await recorder.record(declaredEvent, payload);
310
306
  },
311
307
  async publish(eventBus, event, payload) {
312
308
  const declaredEvent = resolveDeclared(event) as typeof event;
313
- const parsed = await parseEventPayload(
314
- declaredEvent,
315
- payload,
316
- useCaseName,
317
- );
318
- await eventBus.publish(declaredEvent, parsed);
309
+ await parseEventPayload(declaredEvent, payload, useCaseName);
310
+ await eventBus.publish(declaredEvent, payload);
319
311
  },
320
312
  };
321
313
  }
@@ -340,6 +332,8 @@ export const USE_CASE_TRUSTED_RUN: unique symbol = Symbol.for(
340
332
  "beignet.useCase.trustedRun",
341
333
  );
342
334
 
335
+ const USE_CASE_OUTPUT_VALIDATED = Symbol.for("beignet.useCase.outputValidated");
336
+
343
337
  /**
344
338
  * Finalized use case definition.
345
339
  *
@@ -849,6 +843,10 @@ class UseCaseBuilder<
849
843
  value: (args: RunArgs) => execute(args, false),
850
844
  enumerable: false,
851
845
  });
846
+ Object.defineProperty(def, USE_CASE_OUTPUT_VALIDATED, {
847
+ value: validation.output,
848
+ enumerable: false,
849
+ });
852
850
 
853
851
  return def as unknown as InputSchema extends StandardSchemaV1
854
852
  ? OutputSchema extends StandardSchemaV1
@@ -377,6 +377,16 @@ function formatQuotedList(values: string[]): string {
377
377
  return values.map((value) => `"${value}"`).join(", ");
378
378
  }
379
379
 
380
+ class MalformedResponseJsonError extends Error {
381
+ readonly parseError: unknown;
382
+
383
+ constructor(parseError: unknown) {
384
+ super("Response body contains malformed JSON.");
385
+ this.name = "MalformedResponseJsonError";
386
+ this.parseError = parseError;
387
+ }
388
+ }
389
+
380
390
  /**
381
391
  * Generate an idempotency key. Idempotency keys only need to be unique per
382
392
  * request, not cryptographically strong, so this degrades gracefully when
@@ -615,6 +625,9 @@ export class Endpoint<
615
625
  TContract,
616
626
  TProvidedHeaders
617
627
  >;
628
+ let phase: "client" | "network" | "contract" = "client";
629
+ let response: Response | undefined;
630
+ let responseStatus: number | undefined;
618
631
 
619
632
  try {
620
633
  if (args.body !== undefined && args.rawBody !== undefined) {
@@ -714,7 +727,10 @@ export class Endpoint<
714
727
  options.body = requestBody;
715
728
  }
716
729
 
717
- const response = await fetchFn(url, options);
730
+ phase = "network";
731
+ response = await fetchFn(url, options);
732
+ phase = "contract";
733
+ responseStatus = response.status;
718
734
  const shouldValidateResponses = this.config.validateResponses !== false;
719
735
 
720
736
  // Handle non-2xx responses
@@ -723,12 +739,15 @@ export class Endpoint<
723
739
  try {
724
740
  errorBody = await parseResponseBody(response);
725
741
  } catch (parseErr) {
742
+ if (!(parseErr instanceof MalformedResponseJsonError)) {
743
+ throw parseErr;
744
+ }
726
745
  // JSON parse failed — still report the HTTP error
727
746
  throw this.createError({
728
747
  status: response.status,
729
748
  code: "INVALID_JSON",
730
749
  message: createInvalidJsonMessage("error", response.status),
731
- cause: parseErr,
750
+ cause: parseErr.parseError,
732
751
  response,
733
752
  source: "contract",
734
753
  });
@@ -759,11 +778,14 @@ export class Endpoint<
759
778
  try {
760
779
  data = await parseResponseBody(response);
761
780
  } catch (parseErr) {
781
+ if (!(parseErr instanceof MalformedResponseJsonError)) {
782
+ throw parseErr;
783
+ }
762
784
  throw this.createError({
763
785
  status: response.status,
764
786
  code: "INVALID_JSON",
765
787
  message: createInvalidJsonMessage("success", response.status),
766
- cause: parseErr,
788
+ cause: parseErr.parseError,
767
789
  response,
768
790
  source: "contract",
769
791
  });
@@ -839,11 +861,25 @@ export class Endpoint<
839
861
  response: error.response,
840
862
  };
841
863
  }
864
+ const source = phase;
842
865
  const error = this.createError({
843
- code: "NETWORK_ERROR",
844
- message: err instanceof Error ? err.message : "Network request failed",
866
+ code:
867
+ source === "network"
868
+ ? "NETWORK_ERROR"
869
+ : source === "contract"
870
+ ? "RESPONSE_PROCESSING_ERROR"
871
+ : "CLIENT_ERROR",
872
+ message:
873
+ err instanceof Error
874
+ ? err.message
875
+ : source === "network"
876
+ ? "Network request failed"
877
+ : source === "contract"
878
+ ? "Response processing failed"
879
+ : "Client request preparation failed",
845
880
  cause: err,
846
- source: "network",
881
+ source,
882
+ ...(source === "contract" ? { status: responseStatus, response } : {}),
847
883
  }) as InferEndpointContractError<TContract>;
848
884
  return {
849
885
  ok: false,
@@ -1074,14 +1110,18 @@ async function parseResponseBody(response: Response): Promise<unknown> {
1074
1110
  return undefined;
1075
1111
  }
1076
1112
 
1077
- const text = await response.text().catch(() => "");
1113
+ const text = await response.text();
1078
1114
  if (text === "") {
1079
1115
  return undefined;
1080
1116
  }
1081
1117
 
1082
1118
  const contentType = response.headers.get("content-type");
1083
1119
  if (contentType?.includes("application/json")) {
1084
- return JSON.parse(text);
1120
+ try {
1121
+ return JSON.parse(text);
1122
+ } catch (error) {
1123
+ throw new MalformedResponseJsonError(error);
1124
+ }
1085
1125
  }
1086
1126
 
1087
1127
  return text;
@@ -315,8 +315,8 @@ export async function publishEvent<E extends EventPayloadDef>(
315
315
  payload: InferEventPayload<E>,
316
316
  options?: EventPublishOptions,
317
317
  ): Promise<void> {
318
- const parsed = await parseEventPayload(event, payload);
319
- await eventBus.publish(event, parsed, options);
318
+ await parseEventPayload(event, payload);
319
+ await eventBus.publish(event, payload, options);
320
320
  }
321
321
 
322
322
  /**
@@ -228,11 +228,17 @@ export interface IdempotencyPort {
228
228
 
229
229
  /**
230
230
  * Mark a reserved key as complete and store the result that may be replayed.
231
+ *
232
+ * Implementations must reject when the fingerprint, reservation token, or
233
+ * in-progress state no longer matches the current reservation.
231
234
  */
232
235
  complete(input: IdempotencyCompleteInput): Promise<void>;
233
236
 
234
237
  /**
235
238
  * Release or mark a reserved key after the protected work fails.
239
+ *
240
+ * Implementations must reject when the fingerprint, reservation token, or
241
+ * in-progress state no longer matches the current reservation.
236
242
  */
237
243
  fail(input: IdempotencyFailInput): Promise<void>;
238
244
  }
@@ -405,6 +411,38 @@ export class IdempotencyFingerprintError extends Error {
405
411
  }
406
412
  }
407
413
 
414
+ /**
415
+ * Error thrown when a completion or failure mutation no longer owns the
416
+ * matching in-progress reservation.
417
+ */
418
+ export class IdempotencyMutationError extends Error {
419
+ /** Mutation that failed to match the current reservation. */
420
+ readonly action: "complete" | "fail";
421
+ /** Operation namespace supplied to the failed mutation. */
422
+ readonly namespace: string;
423
+ /** Client-provided idempotency key supplied to the failed mutation. */
424
+ readonly key: string;
425
+ /** Normalized logical scope key supplied to the failed mutation. */
426
+ readonly scopeKey: string;
427
+
428
+ constructor(args: {
429
+ action: "complete" | "fail";
430
+ namespace: string;
431
+ key: string;
432
+ scope?: IdempotencyScope;
433
+ }) {
434
+ const scopeKey = normalizeIdempotencyScope(args.scope);
435
+ super(
436
+ `Idempotency ${args.action} for key "${args.key}" in namespace "${args.namespace}" matched no in-progress reservation with this fingerprint and reservation token. The reservation may be missing, expired, already completed or released, or owned by a different execution.`,
437
+ );
438
+ this.name = "IdempotencyMutationError";
439
+ this.action = args.action;
440
+ this.namespace = args.namespace;
441
+ this.key = args.key;
442
+ this.scopeKey = scopeKey;
443
+ }
444
+ }
445
+
408
446
  type CanonicalValue =
409
447
  | null
410
448
  | string
@@ -557,9 +595,8 @@ export function createMemoryIdempotencyStore(
557
595
  options: MemoryIdempotencyStoreOptions = {},
558
596
  ): MemoryIdempotencyStore {
559
597
  const storeNow = options.now ?? (() => new Date());
560
- const createReservationToken =
561
- options.createReservationToken ?? createRandomReservationToken;
562
598
  const records = new Map<string, MemoryRecord>();
599
+ const pendingReservations = new Map<string, Promise<void>>();
563
600
 
564
601
  return {
565
602
  get entries() {
@@ -573,45 +610,71 @@ export function createMemoryIdempotencyStore(
573
610
  assertTtl(input.ttlSec);
574
611
  assertTtl(input.reservationTtlSec);
575
612
 
576
- const now = storeNow();
577
613
  const storageKey = createIdempotencyStorageKey(input);
578
- const existing = records.get(storageKey);
579
-
580
- if (existing && !isExpired(existing, now)) {
581
- return reservationFromRecord(existing, input.fingerprint);
582
- }
583
-
584
- if (existing) {
585
- records.delete(storageKey);
614
+ while (true) {
615
+ const now = storeNow();
616
+ const existing = records.get(storageKey);
617
+
618
+ if (existing && !isExpired(existing, now)) {
619
+ return reservationFromRecord(existing, input.fingerprint);
620
+ }
621
+
622
+ if (existing) {
623
+ records.delete(storageKey);
624
+ }
625
+
626
+ const pendingReservation = pendingReservations.get(storageKey);
627
+ if (pendingReservation) {
628
+ await pendingReservation;
629
+ continue;
630
+ }
631
+
632
+ let releasePendingReservation!: () => void;
633
+ pendingReservations.set(
634
+ storageKey,
635
+ new Promise<void>((resolve) => {
636
+ releasePendingReservation = resolve;
637
+ }),
638
+ );
639
+
640
+ try {
641
+ const reservationToken =
642
+ options.createReservationToken?.() ??
643
+ (await createRandomReservationToken());
644
+ const reservedAt = storeNow();
645
+ const record: MemoryRecord = {
646
+ namespace: input.namespace,
647
+ key: input.key,
648
+ scopeKey: normalizeIdempotencyScope(input.scope),
649
+ fingerprint: input.fingerprint,
650
+ reservationToken,
651
+ replayTtlSec: input.ttlSec,
652
+ status: "in-progress",
653
+ reservedAt,
654
+ expiresAt: resolveExpiresAt(
655
+ input.reservationTtlSec ??
656
+ DEFAULT_IDEMPOTENCY_RESERVATION_TTL_SEC,
657
+ reservedAt,
658
+ ),
659
+ };
660
+
661
+ records.set(storageKey, record);
662
+
663
+ return {
664
+ status: "reserved",
665
+ namespace: record.namespace,
666
+ key: record.key,
667
+ scopeKey: record.scopeKey,
668
+ fingerprint: record.fingerprint,
669
+ reservationToken: record.reservationToken,
670
+ reservedAt: record.reservedAt,
671
+ expiresAt: record.expiresAt,
672
+ };
673
+ } finally {
674
+ pendingReservations.delete(storageKey);
675
+ releasePendingReservation();
676
+ }
586
677
  }
587
-
588
- const record: MemoryRecord = {
589
- namespace: input.namespace,
590
- key: input.key,
591
- scopeKey: normalizeIdempotencyScope(input.scope),
592
- fingerprint: input.fingerprint,
593
- reservationToken: createReservationToken(),
594
- replayTtlSec: input.ttlSec,
595
- status: "in-progress",
596
- reservedAt: now,
597
- expiresAt: resolveExpiresAt(
598
- input.reservationTtlSec ?? DEFAULT_IDEMPOTENCY_RESERVATION_TTL_SEC,
599
- now,
600
- ),
601
- };
602
-
603
- records.set(storageKey, record);
604
-
605
- return {
606
- status: "reserved",
607
- namespace: record.namespace,
608
- key: record.key,
609
- scopeKey: record.scopeKey,
610
- fingerprint: record.fingerprint,
611
- reservationToken: record.reservationToken,
612
- reservedAt: record.reservedAt,
613
- expiresAt: record.expiresAt,
614
- };
615
678
  },
616
679
 
617
680
  async complete(input) {
@@ -628,7 +691,12 @@ export function createMemoryIdempotencyStore(
628
691
  existing.reservationToken !== input.reservationToken ||
629
692
  existing.status !== "in-progress"
630
693
  ) {
631
- return;
694
+ throw new IdempotencyMutationError({
695
+ action: "complete",
696
+ namespace: input.namespace,
697
+ key: input.key,
698
+ scope: input.scope,
699
+ });
632
700
  }
633
701
 
634
702
  existing.status = "completed";
@@ -654,7 +722,12 @@ export function createMemoryIdempotencyStore(
654
722
  existing.reservationToken !== input.reservationToken ||
655
723
  existing.status === "completed"
656
724
  ) {
657
- return;
725
+ throw new IdempotencyMutationError({
726
+ action: "fail",
727
+ namespace: input.namespace,
728
+ key: input.key,
729
+ scope: input.scope,
730
+ });
658
731
  }
659
732
 
660
733
  records.delete(storageKey);
@@ -706,14 +779,22 @@ export async function runIdempotently<Result>(
706
779
  try {
707
780
  result = await options.run();
708
781
  } catch (error) {
709
- await idempotency.fail({
710
- namespace: operation.namespace,
711
- key: operation.key,
712
- scope: operation.scope,
713
- fingerprint: operation.fingerprint,
714
- reservationToken: reservation.reservationToken,
715
- error,
716
- });
782
+ try {
783
+ await idempotency.fail({
784
+ namespace: operation.namespace,
785
+ key: operation.key,
786
+ scope: operation.scope,
787
+ fingerprint: operation.fingerprint,
788
+ reservationToken: reservation.reservationToken,
789
+ error,
790
+ });
791
+ } catch (settlementError) {
792
+ throw new AggregateError(
793
+ [error, settlementError],
794
+ "Idempotent operation failed and releasing its reservation also failed.",
795
+ { cause: error },
796
+ );
797
+ }
717
798
  throw error;
718
799
  }
719
800
 
@@ -736,20 +817,30 @@ export async function runIdempotently<Result>(
736
817
  }
737
818
  }
738
819
 
739
- function createRandomReservationToken(): string {
740
- if (typeof globalThis.crypto?.randomUUID === "function") {
741
- return globalThis.crypto.randomUUID();
820
+ async function createRandomReservationToken(): Promise<string> {
821
+ const crypto = globalThis.crypto;
822
+ if (typeof crypto?.randomUUID === "function") {
823
+ return crypto.randomUUID();
742
824
  }
743
-
744
- const bytes = new Uint8Array(16);
745
- globalThis.crypto?.getRandomValues?.(bytes);
746
- if (bytes.some((value) => value !== 0)) {
825
+ if (typeof crypto?.getRandomValues === "function") {
826
+ const bytes = new Uint8Array(16);
827
+ crypto.getRandomValues(bytes);
747
828
  return Array.from(bytes, (value) =>
748
829
  value.toString(16).padStart(2, "0"),
749
830
  ).join("");
750
831
  }
751
832
 
752
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
833
+ try {
834
+ const nodeCrypto = await import("node:crypto");
835
+ if (typeof nodeCrypto.randomUUID === "function") {
836
+ return nodeCrypto.randomUUID();
837
+ }
838
+ return nodeCrypto.randomBytes(16).toString("hex");
839
+ } catch {
840
+ throw new Error(
841
+ "Idempotency reservations require Web Crypto or Node.js crypto.",
842
+ );
843
+ }
753
844
  }
754
845
 
755
846
  function normalizeOmitPath(
package/src/jobs/index.ts CHANGED
@@ -1640,7 +1640,7 @@ export function createUniqueJobDispatcher(
1640
1640
  keyPrefix: options.keyPrefix,
1641
1641
  });
1642
1642
  if (!unique) {
1643
- await options.jobs.dispatch(job, parsed, dispatchOptions);
1643
+ await options.jobs.dispatch(job, payload, dispatchOptions);
1644
1644
  return;
1645
1645
  }
1646
1646
 
@@ -1666,7 +1666,7 @@ export function createUniqueJobDispatcher(
1666
1666
  }
1667
1667
 
1668
1668
  try {
1669
- await options.jobs.dispatch(job, parsed, dispatchOptions);
1669
+ await options.jobs.dispatch(job, payload, dispatchOptions);
1670
1670
  } catch (error) {
1671
1671
  try {
1672
1672
  await result.lease.release();
@@ -1216,7 +1216,7 @@ export function createQueuedNotificationDispatcher<Name extends string, Ctx>(
1216
1216
  await options.jobs.dispatch(options.deliveryJob, {
1217
1217
  notificationName: notification.name,
1218
1218
  channel,
1219
- payload: parsed,
1219
+ payload,
1220
1220
  options: queuedSendOptions(sendOptions),
1221
1221
  });
1222
1222
  result = {
@@ -1269,14 +1269,14 @@ export async function enqueueEvent<E extends EventPayloadDef>(
1269
1269
  payload: InferEventPayload<E>,
1270
1270
  options: EnqueueTypedOutboxOptions = {},
1271
1271
  ): Promise<OutboxMessage> {
1272
- const parsed = await parseEventPayload(event, payload);
1272
+ await parseEventPayload(event, payload);
1273
1273
  const trace =
1274
1274
  parseTraceCarrier(options.trace) ?? captureTraceCarrier(options.tracing);
1275
1275
  return outbox.enqueue({
1276
1276
  id: options.id,
1277
1277
  kind: "event",
1278
1278
  name: event.name,
1279
- payload: toOutboxJsonValue(parsed),
1279
+ payload: toOutboxJsonValue(payload),
1280
1280
  trace,
1281
1281
  availableAt: options.availableAt,
1282
1282
  maxAttempts: options.maxAttempts,
@@ -1292,14 +1292,14 @@ export async function enqueueJob<J extends JobDef>(
1292
1292
  payload: InferJobPayload<J>,
1293
1293
  options: EnqueueTypedOutboxOptions = {},
1294
1294
  ): Promise<OutboxMessage> {
1295
- const parsed = await parseJobPayload(job, payload);
1295
+ await parseJobPayload(job, payload);
1296
1296
  const trace =
1297
1297
  parseTraceCarrier(options.trace) ?? captureTraceCarrier(options.tracing);
1298
1298
  return outbox.enqueue({
1299
1299
  id: options.id,
1300
1300
  kind: "job",
1301
1301
  name: job.name,
1302
- payload: toOutboxJsonValue(parsed),
1302
+ payload: toOutboxJsonValue(payload),
1303
1303
  trace,
1304
1304
  availableAt: options.availableAt,
1305
1305
  maxAttempts: options.maxAttempts ?? getJobRetryMaxAttempts(job.retry),
@@ -1422,10 +1422,10 @@ async function deliverOutboxMessage(
1422
1422
  );
1423
1423
  }
1424
1424
 
1425
- const payload = await parseEventPayload(event, message.payload);
1425
+ await parseEventPayload(event, message.payload);
1426
1426
  await options.eventBus.publish(
1427
1427
  event,
1428
- payload,
1428
+ message.payload as never,
1429
1429
  trace ? { trace } : undefined,
1430
1430
  );
1431
1431
  return;
@@ -1444,7 +1444,7 @@ async function deliverOutboxMessage(
1444
1444
  );
1445
1445
  }
1446
1446
 
1447
- const payload = await parseJobPayload(job, message.payload);
1447
+ await parseJobPayload(job, message.payload);
1448
1448
 
1449
1449
  // The drain owns execution retries: failed deliveries are rescheduled with
1450
1450
  // the job's own policy via markFailed/retryAt. When the dispatcher exposes
@@ -1457,7 +1457,7 @@ async function deliverOutboxMessage(
1457
1457
  }
1458
1458
  )[SINGLE_ATTEMPT_DISPATCH];
1459
1459
  if (singleAttempt) {
1460
- await singleAttempt(job, payload, {
1460
+ await singleAttempt(job, message.payload as never, {
1461
1461
  attempt: message.attempts,
1462
1462
  maxAttempts: message.maxAttempts,
1463
1463
  trace,
@@ -1465,7 +1465,11 @@ async function deliverOutboxMessage(
1465
1465
  return;
1466
1466
  }
1467
1467
 
1468
- await options.jobs.dispatch(job, payload, trace ? { trace } : undefined);
1468
+ await options.jobs.dispatch(
1469
+ job,
1470
+ message.payload as never,
1471
+ trace ? { trace } : undefined,
1472
+ );
1469
1473
  }
1470
1474
 
1471
1475
  /**
@@ -245,8 +245,12 @@ export function createDomainEventRecorder(): BufferedDomainEventRecorder {
245
245
  async flush(eventBus) {
246
246
  while (records.length > 0) {
247
247
  const record = records[0];
248
- const payload = await parseEventPayload(record.event, record.payload);
249
- await eventBus.publish(record.event, payload, record.options);
248
+ await parseEventPayload(record.event, record.payload);
249
+ await eventBus.publish(
250
+ record.event,
251
+ record.payload as never,
252
+ record.options,
253
+ );
250
254
  records.shift();
251
255
  }
252
256
  },