@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.
- package/CHANGELOG.md +15 -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 +44 -2
- 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
|
@@ -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.
|
|
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.
|
|
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:
|
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
|
},
|