@beignet/core 0.0.44 → 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.
- package/CHANGELOG.md +6 -0
- package/README.md +41 -4
- package/dist/application/index.d.ts.map +1 -1
- package/dist/application/index.js +9 -4
- package/dist/application/index.js.map +1 -1
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +45 -8
- package/dist/client/client.js.map +1 -1
- package/dist/events/index.js +2 -2
- package/dist/events/index.js.map +1 -1
- package/dist/idempotency/index.d.ts +26 -0
- package/dist/idempotency/index.d.ts.map +1 -1
- package/dist/idempotency/index.js +116 -47
- package/dist/idempotency/index.js.map +1 -1
- package/dist/jobs/index.js +2 -2
- package/dist/jobs/index.js.map +1 -1
- package/dist/notifications/index.js +1 -1
- package/dist/notifications/index.js.map +1 -1
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +9 -9
- package/dist/outbox/index.js.map +1 -1
- package/dist/ports/unit-of-work.d.ts.map +1 -1
- package/dist/ports/unit-of-work.js +2 -2
- package/dist/ports/unit-of-work.js.map +1 -1
- package/dist/server/hooks/idempotency.d.ts.map +1 -1
- package/dist/server/hooks/idempotency.js +37 -9
- package/dist/server/hooks/idempotency.js.map +1 -1
- package/dist/server/request-preparation.d.ts.map +1 -1
- package/dist/server/request-preparation.js +19 -11
- package/dist/server/request-preparation.js.map +1 -1
- package/dist/server/use-case-route.d.ts +2 -0
- package/dist/server/use-case-route.d.ts.map +1 -1
- package/dist/server/use-case-route.js +3 -1
- package/dist/server/use-case-route.js.map +1 -1
- package/dist/webhooks/index.d.ts.map +1 -1
- package/dist/webhooks/index.js +1 -1
- package/dist/webhooks/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/app-architecture/SKILL.md +11 -0
- package/src/application/index.ts +10 -12
- package/src/client/client.ts +48 -8
- package/src/events/index.ts +2 -2
- package/src/idempotency/index.ts +148 -57
- package/src/jobs/index.ts +2 -2
- package/src/notifications/index.ts +1 -1
- package/src/outbox/index.ts +13 -9
- package/src/ports/unit-of-work.ts +6 -2
- package/src/server/hooks/idempotency.ts +44 -8
- package/src/server/request-preparation.ts +21 -10
- package/src/server/use-case-route.ts +7 -1
- package/src/webhooks/index.ts +4 -1
package/src/application/index.ts
CHANGED
|
@@ -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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
package/src/client/client.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
844
|
-
|
|
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
|
|
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()
|
|
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
|
-
|
|
1120
|
+
try {
|
|
1121
|
+
return JSON.parse(text);
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
throw new MalformedResponseJsonError(error);
|
|
1124
|
+
}
|
|
1085
1125
|
}
|
|
1086
1126
|
|
|
1087
1127
|
return text;
|
package/src/events/index.ts
CHANGED
|
@@ -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
|
-
|
|
319
|
-
await eventBus.publish(event,
|
|
318
|
+
await parseEventPayload(event, payload);
|
|
319
|
+
await eventBus.publish(event, payload, options);
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
/**
|
package/src/idempotency/index.ts
CHANGED
|
@@ -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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
741
|
-
|
|
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
|
-
|
|
745
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
1219
|
+
payload,
|
|
1220
1220
|
options: queuedSendOptions(sendOptions),
|
|
1221
1221
|
});
|
|
1222
1222
|
result = {
|
package/src/outbox/index.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
249
|
-
await eventBus.publish(
|
|
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
|
},
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
* Idempotency hooks for @beignet/core/server
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
type ErrorReporterPort,
|
|
7
|
+
tryReportException,
|
|
8
|
+
} from "../../error-reporting/index.js";
|
|
5
9
|
import { AppError, httpErrors } from "../../errors/index.js";
|
|
6
10
|
import {
|
|
7
11
|
createIdempotencyFingerprint,
|
|
@@ -35,6 +39,10 @@ export type IdempotencyPorts = {
|
|
|
35
39
|
idempotency: IdempotencyPort;
|
|
36
40
|
};
|
|
37
41
|
|
|
42
|
+
type OptionalErrorReporterPorts = IdempotencyPorts & {
|
|
43
|
+
errorReporter?: ErrorReporterPort;
|
|
44
|
+
};
|
|
45
|
+
|
|
38
46
|
/**
|
|
39
47
|
* Minimal context shape required for actor- and tenant-scoped idempotency.
|
|
40
48
|
*/
|
|
@@ -252,6 +260,7 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
|
|
|
252
260
|
},
|
|
253
261
|
[responseFinalizerHook]: async ({
|
|
254
262
|
req,
|
|
263
|
+
ctx,
|
|
255
264
|
response,
|
|
256
265
|
error,
|
|
257
266
|
native,
|
|
@@ -292,14 +301,41 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
|
|
|
292
301
|
|
|
293
302
|
// Framework-owned responses, errors, non-2xx responses, and native
|
|
294
303
|
// `Response` results release the reservation. Streams are not replayable.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
304
|
+
try {
|
|
305
|
+
await port.fail({
|
|
306
|
+
namespace,
|
|
307
|
+
key,
|
|
308
|
+
scope,
|
|
309
|
+
fingerprint,
|
|
310
|
+
reservationToken,
|
|
311
|
+
error,
|
|
312
|
+
});
|
|
313
|
+
} catch (settlementError) {
|
|
314
|
+
if (error === undefined) {
|
|
315
|
+
throw settlementError;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
await tryReportException({
|
|
319
|
+
reporter: ctx
|
|
320
|
+
? (ctx.ports as OptionalErrorReporterPorts).errorReporter
|
|
321
|
+
: undefined,
|
|
322
|
+
error: settlementError,
|
|
323
|
+
reportOptions: {
|
|
324
|
+
level: "error",
|
|
325
|
+
mechanism: "beignet.idempotency.settlement",
|
|
326
|
+
handled: true,
|
|
327
|
+
tags: {
|
|
328
|
+
"idempotency.namespace": namespace,
|
|
329
|
+
},
|
|
330
|
+
contexts: {
|
|
331
|
+
idempotency: {
|
|
332
|
+
namespace,
|
|
333
|
+
key,
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
}
|
|
303
339
|
return undefined;
|
|
304
340
|
},
|
|
305
341
|
};
|