@gkoos/caracal 0.1.0 → 0.2.0
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 +40 -5
- package/README.md +13 -3
- package/dist/chunk-FD7IZJKY.js +84 -0
- package/dist/chunk-FD7IZJKY.js.map +1 -0
- package/dist/{chunk-5CXDW7W6.js → chunk-JV2OLYOF.js} +5 -82
- package/dist/chunk-JV2OLYOF.js.map +1 -0
- package/dist/{circuit-breaker-BSkcV0W_.d.ts → circuit-breaker-xZ8uenT8.d.ts} +7 -2
- package/dist/fetch.d.ts +6 -3
- package/dist/fetch.js +4 -2
- package/dist/fetch.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +96 -41
- package/dist/index.js.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/redis.d.ts +10 -4
- package/dist/redis.js +15 -1
- package/dist/redis.js.map +1 -1
- package/dist/{retry-BFP_k3Hg.d.ts → retry-DD85oXL9.d.ts} +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/{types-Tf9T76C7.d.ts → types-C-Ml-MKp.d.ts} +7 -0
- package/package.json +9 -5
- package/src/adapters/fetch/retry-after.ts +11 -3
- package/src/coordination/redis/client.ts +15 -1
- package/src/coordination/redis/scripts.ts +20 -3
- package/src/core/bulkhead.ts +20 -3
- package/src/core/circuit-breaker.ts +81 -36
- package/src/core/operation.ts +2 -5
- package/src/core/retry.ts +46 -2
- package/src/core/runtime.ts +26 -5
- package/src/core/timeout.ts +11 -1
- package/src/core/types.ts +8 -0
- package/test/harness/adapter-contract.ts +170 -0
- package/test/harness/index.ts +15 -0
- package/dist/chunk-5CXDW7W6.js.map +0 -1
|
@@ -108,10 +108,35 @@ const DEFAULT_HALF_OPEN_SUCCESSES = 1
|
|
|
108
108
|
const DEFAULT_HALF_OPEN_PROBES = 1
|
|
109
109
|
const DEFAULT_WINDOW_SIZE = 100
|
|
110
110
|
|
|
111
|
+
// The distributed coordinator compares the failure threshold as an integer
|
|
112
|
+
// numerator of thousandths (`wFail * 1000 >= numerator * wTotal`). A threshold
|
|
113
|
+
// that rounds to 0 makes that comparison unconditionally true, so the breaker
|
|
114
|
+
// opens on a success-only window and re-opens after every recovery; a threshold
|
|
115
|
+
// that rounds to the full scale requires every observation to fail, so the
|
|
116
|
+
// breaker effectively never opens. Both are rejected instead of silently
|
|
117
|
+
// reinterpreted, and the local breaker enforces the same bounds so one policy
|
|
118
|
+
// config works with either coordination.
|
|
119
|
+
const FAILURE_THRESHOLD_SCALE = 1000
|
|
120
|
+
|
|
111
121
|
// ---------------------------------------------------------------------------
|
|
112
122
|
// Validation
|
|
113
123
|
// ---------------------------------------------------------------------------
|
|
114
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Rejects a failureThreshold the thousandths comparison cannot represent.
|
|
127
|
+
*
|
|
128
|
+
* Accepts `0.0005 <= failureThreshold < 0.9995`. Anything else resolves to a
|
|
129
|
+
* numerator of 0 or of the full scale, which changes what the threshold means
|
|
130
|
+
* rather than how precisely it is expressed.
|
|
131
|
+
*/
|
|
132
|
+
function assertResolvableThreshold(failureThreshold: number): void {
|
|
133
|
+
const numerator = Math.round(failureThreshold * FAILURE_THRESHOLD_SCALE)
|
|
134
|
+
if (numerator < 1 || numerator >= FAILURE_THRESHOLD_SCALE)
|
|
135
|
+
throw new RangeError(
|
|
136
|
+
`failureThreshold must be at least 0.0005 and below 0.9995 (thresholds are resolved to thousandths); got ${failureThreshold}`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
115
140
|
function validate(opts: LocalBreakerOptions): void {
|
|
116
141
|
if (!opts.name.trim())
|
|
117
142
|
throw new RangeError("Circuit breaker name must not be empty")
|
|
@@ -127,6 +152,7 @@ function validate(opts: LocalBreakerOptions): void {
|
|
|
127
152
|
failureThreshold >= 1
|
|
128
153
|
)
|
|
129
154
|
throw new RangeError("failureThreshold must be a number in (0, 1)")
|
|
155
|
+
assertResolvableThreshold(failureThreshold)
|
|
130
156
|
|
|
131
157
|
const { openMs = DEFAULT_OPEN_MS } = opts
|
|
132
158
|
if (!Number.isInteger(openMs) || openMs < 1)
|
|
@@ -519,7 +545,12 @@ export interface BreakerCoordinator {
|
|
|
519
545
|
identity: BreakerIdentity,
|
|
520
546
|
params: {
|
|
521
547
|
readonly probeToken: string
|
|
522
|
-
|
|
548
|
+
/**
|
|
549
|
+
* `"success"` and `"failure"` record an outcome; `"ignored"` releases the
|
|
550
|
+
* probe's slot without recording anything, which is what the policy sends
|
|
551
|
+
* when its classifier ignores the result.
|
|
552
|
+
*/
|
|
553
|
+
readonly outcome: "success" | "failure" | "ignored"
|
|
523
554
|
readonly generation: number
|
|
524
555
|
readonly halfOpenSuccesses: number
|
|
525
556
|
readonly openMs: number
|
|
@@ -648,6 +679,7 @@ function validateDistributed(opts: DistributedBreakerOptions): void {
|
|
|
648
679
|
failureThreshold >= 1
|
|
649
680
|
)
|
|
650
681
|
throw new RangeError("failureThreshold must be a number in (0, 1)")
|
|
682
|
+
assertResolvableThreshold(failureThreshold)
|
|
651
683
|
|
|
652
684
|
const { openMs = DEFAULT_DIST_OPEN_MS } = opts
|
|
653
685
|
if (!Number.isInteger(openMs) || openMs < 1)
|
|
@@ -701,7 +733,9 @@ function distributed(
|
|
|
701
733
|
options.minimumThroughput ?? DEFAULT_DIST_MINIMUM_THROUGHPUT
|
|
702
734
|
const failureThreshold =
|
|
703
735
|
options.failureThreshold ?? DEFAULT_DIST_FAILURE_THRESHOLD
|
|
704
|
-
const failureThresholdNumerator = Math.round(
|
|
736
|
+
const failureThresholdNumerator = Math.round(
|
|
737
|
+
failureThreshold * FAILURE_THRESHOLD_SCALE,
|
|
738
|
+
)
|
|
705
739
|
const windowSize = options.windowSize ?? DEFAULT_DIST_WINDOW_SIZE
|
|
706
740
|
const openMs = options.openMs ?? DEFAULT_DIST_OPEN_MS
|
|
707
741
|
const windowTtlMs = options.windowTtlMs ?? Math.max(openMs * 3, 60_000)
|
|
@@ -930,11 +964,18 @@ function distributed(
|
|
|
930
964
|
? { status: "success", value }
|
|
931
965
|
: { status: "failure", error: thrownError },
|
|
932
966
|
)
|
|
933
|
-
|
|
967
|
+
// An ignored result is never recorded. A probe still has to settle
|
|
968
|
+
// though: releasing the slot it holds is what lets the next probe
|
|
969
|
+
// through, instead of stalling the recovery window until the probe lease
|
|
970
|
+
// elapses. The local breaker frees its slot immediately, and a release
|
|
971
|
+
// records nothing and does not advance recovery.
|
|
972
|
+
if (breakerOutcome !== "ignored" || admission.kind === "probe") {
|
|
934
973
|
const outcomeStr =
|
|
935
974
|
breakerOutcome === "success"
|
|
936
975
|
? ("success" as const)
|
|
937
976
|
: ("failure" as const)
|
|
977
|
+
const settleOutcome =
|
|
978
|
+
breakerOutcome === "ignored" ? ("ignored" as const) : outcomeStr
|
|
938
979
|
|
|
939
980
|
if (admission.kind === "closed") {
|
|
940
981
|
try {
|
|
@@ -998,49 +1039,53 @@ function distributed(
|
|
|
998
1039
|
try {
|
|
999
1040
|
const result = await coordinator.settleProbe(identity, {
|
|
1000
1041
|
probeToken: admission.probeToken,
|
|
1001
|
-
outcome:
|
|
1042
|
+
outcome: settleOutcome,
|
|
1002
1043
|
generation: admission.generation,
|
|
1003
1044
|
halfOpenSuccesses,
|
|
1004
1045
|
openMs,
|
|
1005
1046
|
windowTtlMs,
|
|
1006
1047
|
})
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
policyName: name,
|
|
1012
|
-
scope,
|
|
1013
|
-
attemptGeneration: admission.generation,
|
|
1014
|
-
currentGeneration: result.generation,
|
|
1015
|
-
})
|
|
1016
|
-
} else {
|
|
1017
|
-
emitRuntimeEvent(context, {
|
|
1018
|
-
type: "breaker.observation",
|
|
1019
|
-
coordination: "distributed",
|
|
1020
|
-
policyName: name,
|
|
1021
|
-
scope,
|
|
1022
|
-
outcome: outcomeStr,
|
|
1023
|
-
generation: admission.generation,
|
|
1024
|
-
})
|
|
1025
|
-
if (result.type === "transitioned") {
|
|
1026
|
-
if (result.newState === "closed") {
|
|
1027
|
-
lastKnownState.forget(identity.operation, scope)
|
|
1028
|
-
} else {
|
|
1029
|
-
lastKnownState.remember(
|
|
1030
|
-
identity.operation,
|
|
1031
|
-
scope,
|
|
1032
|
-
result.newState,
|
|
1033
|
-
)
|
|
1034
|
-
}
|
|
1048
|
+
// A release reports nothing: it is not an observation, so there is
|
|
1049
|
+
// no breaker.observation event and no transition to announce.
|
|
1050
|
+
if (breakerOutcome !== "ignored") {
|
|
1051
|
+
if (result.type === "stale") {
|
|
1035
1052
|
emitRuntimeEvent(context, {
|
|
1036
|
-
type: "breaker.
|
|
1053
|
+
type: "breaker.observation-stale",
|
|
1037
1054
|
coordination: "distributed",
|
|
1038
1055
|
policyName: name,
|
|
1039
1056
|
scope,
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1057
|
+
attemptGeneration: admission.generation,
|
|
1058
|
+
currentGeneration: result.generation,
|
|
1059
|
+
})
|
|
1060
|
+
} else {
|
|
1061
|
+
emitRuntimeEvent(context, {
|
|
1062
|
+
type: "breaker.observation",
|
|
1063
|
+
coordination: "distributed",
|
|
1064
|
+
policyName: name,
|
|
1065
|
+
scope,
|
|
1066
|
+
outcome: outcomeStr,
|
|
1067
|
+
generation: admission.generation,
|
|
1043
1068
|
})
|
|
1069
|
+
if (result.type === "transitioned") {
|
|
1070
|
+
if (result.newState === "closed") {
|
|
1071
|
+
lastKnownState.forget(identity.operation, scope)
|
|
1072
|
+
} else {
|
|
1073
|
+
lastKnownState.remember(
|
|
1074
|
+
identity.operation,
|
|
1075
|
+
scope,
|
|
1076
|
+
result.newState,
|
|
1077
|
+
)
|
|
1078
|
+
}
|
|
1079
|
+
emitRuntimeEvent(context, {
|
|
1080
|
+
type: "breaker.state-changed",
|
|
1081
|
+
coordination: "distributed",
|
|
1082
|
+
policyName: name,
|
|
1083
|
+
scope,
|
|
1084
|
+
state: result.newState,
|
|
1085
|
+
previousState: "half-open",
|
|
1086
|
+
generation: result.newGeneration,
|
|
1087
|
+
})
|
|
1088
|
+
}
|
|
1044
1089
|
}
|
|
1045
1090
|
}
|
|
1046
1091
|
} catch (settleError) {
|
package/src/core/operation.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
admissionSignal,
|
|
5
5
|
createClassifier,
|
|
6
6
|
createExecutionContext,
|
|
7
|
+
emitToSink,
|
|
7
8
|
} from "./runtime.js"
|
|
8
9
|
import type {
|
|
9
10
|
Adapter,
|
|
@@ -51,11 +52,7 @@ function normalizeSinks(events: EventSinks | undefined): readonly EventSink[] {
|
|
|
51
52
|
|
|
52
53
|
function emit(sinks: readonly EventSink[], event: OperationEvent): void {
|
|
53
54
|
for (const sink of sinks) {
|
|
54
|
-
|
|
55
|
-
sink.emit(event)
|
|
56
|
-
} catch {
|
|
57
|
-
// Observability must not modify resilience execution.
|
|
58
|
-
}
|
|
55
|
+
emitToSink(sink, event)
|
|
59
56
|
}
|
|
60
57
|
}
|
|
61
58
|
|
package/src/core/retry.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
MAX_TIMER_MS,
|
|
3
|
+
admissionSignal,
|
|
4
|
+
emitRuntimeEvent,
|
|
5
|
+
nextAttempt,
|
|
6
|
+
} from "./runtime.js"
|
|
2
7
|
import type {
|
|
3
8
|
Classification,
|
|
4
9
|
ExecutionContext,
|
|
@@ -71,6 +76,11 @@ function delayFor(
|
|
|
71
76
|
if (!Number.isFinite(delay) || delay < 0) {
|
|
72
77
|
throw new RangeError("retry delay must be a finite non-negative number")
|
|
73
78
|
}
|
|
79
|
+
if (delay > MAX_TIMER_MS) {
|
|
80
|
+
throw new RangeError(
|
|
81
|
+
`retry delay must not exceed ${MAX_TIMER_MS} ms, the largest delay setTimeout honours`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
74
84
|
|
|
75
85
|
return delay
|
|
76
86
|
}
|
|
@@ -124,6 +134,16 @@ export function retry(options: RetryOptions): Policy {
|
|
|
124
134
|
if (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 1) {
|
|
125
135
|
throw new RangeError("maxAttempts must be a positive integer")
|
|
126
136
|
}
|
|
137
|
+
if (
|
|
138
|
+
typeof options.delay === "number" &&
|
|
139
|
+
(!Number.isFinite(options.delay) ||
|
|
140
|
+
options.delay < 0 ||
|
|
141
|
+
options.delay > MAX_TIMER_MS)
|
|
142
|
+
) {
|
|
143
|
+
throw new RangeError(
|
|
144
|
+
`retry delay must be a finite number within 0..${MAX_TIMER_MS} ms`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
127
147
|
|
|
128
148
|
return Object.freeze({
|
|
129
149
|
name: "retry",
|
|
@@ -144,6 +164,17 @@ export function retry(options: RetryOptions): Policy {
|
|
|
144
164
|
outcomeClassification !== "retryable" ||
|
|
145
165
|
context.capabilities.replay !== "safe"
|
|
146
166
|
) {
|
|
167
|
+
// Declining to retry is observable: without this, a call that was
|
|
168
|
+
// never retried looks identical to one with no retry policy.
|
|
169
|
+
emitRuntimeEvent(context, {
|
|
170
|
+
type: "retry.declined",
|
|
171
|
+
outcome: summarized(outcome),
|
|
172
|
+
classification: outcomeClassification,
|
|
173
|
+
reason:
|
|
174
|
+
context.capabilities.replay !== "safe"
|
|
175
|
+
? "replay-unsafe"
|
|
176
|
+
: "not-retryable",
|
|
177
|
+
})
|
|
147
178
|
return value
|
|
148
179
|
}
|
|
149
180
|
|
|
@@ -170,11 +201,24 @@ export function retry(options: RetryOptions): Policy {
|
|
|
170
201
|
const outcome: Outcome<Result> = { status: "failure", error }
|
|
171
202
|
const outcomeClassification = classification(context, outcome)
|
|
172
203
|
|
|
204
|
+
if (admissionSignal(context)?.aborted) {
|
|
205
|
+
throw error
|
|
206
|
+
}
|
|
173
207
|
if (
|
|
174
|
-
admissionSignal(context)?.aborted ||
|
|
175
208
|
context.capabilities.replay !== "safe" ||
|
|
176
209
|
outcomeClassification !== "retryable"
|
|
177
210
|
) {
|
|
211
|
+
// Same event as the value path: the caller can tell a declined
|
|
212
|
+
// retry from a missing retry policy.
|
|
213
|
+
emitRuntimeEvent(context, {
|
|
214
|
+
type: "retry.declined",
|
|
215
|
+
outcome: summarized(outcome),
|
|
216
|
+
classification: outcomeClassification,
|
|
217
|
+
reason:
|
|
218
|
+
context.capabilities.replay !== "safe"
|
|
219
|
+
? "replay-unsafe"
|
|
220
|
+
: "not-retryable",
|
|
221
|
+
})
|
|
178
222
|
throw error
|
|
179
223
|
}
|
|
180
224
|
|
package/src/core/runtime.ts
CHANGED
|
@@ -94,6 +94,31 @@ export function withSignal(
|
|
|
94
94
|
)
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Largest delay `setTimeout` honours. Anything above it is silently clamped to
|
|
99
|
+
* 1 ms by the platform, so accepting larger values would turn a long wait into
|
|
100
|
+
* an immediate one.
|
|
101
|
+
*/
|
|
102
|
+
export const MAX_TIMER_MS = 2_147_483_647
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Delivers one event to one sink, isolating execution from it. Sinks are
|
|
106
|
+
* fire-and-forget: a synchronous throw and a rejected promise are both dropped,
|
|
107
|
+
* so a failing sink can neither modify resilience execution nor surface as an
|
|
108
|
+
* unhandled rejection.
|
|
109
|
+
*/
|
|
110
|
+
export function emitToSink(sink: EventSink, event: OperationEvent): void {
|
|
111
|
+
try {
|
|
112
|
+
const pending = sink.emit(event) as unknown
|
|
113
|
+
const thenable = pending as { catch?: unknown } | null | undefined
|
|
114
|
+
if (typeof thenable?.catch === "function") {
|
|
115
|
+
void (pending as Promise<unknown>).catch(() => {})
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// See above: a failing sink must never modify execution.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
97
122
|
export function emitRuntimeEvent(
|
|
98
123
|
context: ExecutionContext,
|
|
99
124
|
event: EventWithoutRuntimeFields,
|
|
@@ -102,11 +127,7 @@ export function emitRuntimeEvent(
|
|
|
102
127
|
const fullEvent = { ...event, at: Date.now(), context } as OperationEvent
|
|
103
128
|
|
|
104
129
|
for (const sink of sinks) {
|
|
105
|
-
|
|
106
|
-
sink.emit(fullEvent)
|
|
107
|
-
} catch {
|
|
108
|
-
// Observability must not modify resilience execution.
|
|
109
|
-
}
|
|
130
|
+
emitToSink(sink, fullEvent)
|
|
110
131
|
}
|
|
111
132
|
}
|
|
112
133
|
|
package/src/core/timeout.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
MAX_TIMER_MS,
|
|
3
|
+
emitRuntimeEvent,
|
|
4
|
+
withAdmissionSignal,
|
|
5
|
+
withSignal,
|
|
6
|
+
} from "./runtime.js"
|
|
2
7
|
import type { ExecutionContext, Next, Policy } from "./types.js"
|
|
3
8
|
|
|
4
9
|
export class TimeoutError extends Error {
|
|
@@ -31,6 +36,11 @@ export function timeout(options: TimeoutOptions): Policy {
|
|
|
31
36
|
if (!Number.isFinite(options.ms) || options.ms <= 0) {
|
|
32
37
|
throw new RangeError("timeout ms must be a finite positive number")
|
|
33
38
|
}
|
|
39
|
+
if (options.ms > MAX_TIMER_MS) {
|
|
40
|
+
throw new RangeError(
|
|
41
|
+
`timeout ms must not exceed ${MAX_TIMER_MS} ms, the largest delay setTimeout honours`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
34
44
|
|
|
35
45
|
return Object.freeze({
|
|
36
46
|
name: "timeout",
|
package/src/core/types.ts
CHANGED
|
@@ -190,6 +190,14 @@ export type OperationEvent =
|
|
|
190
190
|
outcome: Outcome<undefined>
|
|
191
191
|
classification: Classification
|
|
192
192
|
}>
|
|
193
|
+
| Readonly<{
|
|
194
|
+
type: "retry.declined"
|
|
195
|
+
at: number
|
|
196
|
+
context: ExecutionContext
|
|
197
|
+
outcome: Outcome<undefined>
|
|
198
|
+
classification: Classification
|
|
199
|
+
reason: "replay-unsafe" | "not-retryable"
|
|
200
|
+
}>
|
|
193
201
|
|
|
194
202
|
/** Output-only observability contract. Sinks cannot alter policy execution. */
|
|
195
203
|
export interface EventSink {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { operation } from "../../src/core/operation.js"
|
|
2
|
+
import type {
|
|
3
|
+
Adapter,
|
|
4
|
+
Classification,
|
|
5
|
+
OperationCapabilities,
|
|
6
|
+
OperationEvent,
|
|
7
|
+
Outcome,
|
|
8
|
+
} from "../../src/core/types.js"
|
|
9
|
+
|
|
10
|
+
export interface AdapterContractSuccess<Args, Result> {
|
|
11
|
+
readonly args: Args
|
|
12
|
+
readonly assertResult?: (result: Result) => void | Promise<void>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AdapterContractCapabilityCase<Args> {
|
|
16
|
+
readonly args: Args
|
|
17
|
+
readonly expected: OperationCapabilities
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AdapterContractClassificationCase<Result> {
|
|
21
|
+
readonly outcome: Outcome<Result>
|
|
22
|
+
readonly expected: Classification
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AdapterContractAbortCase<Args> {
|
|
26
|
+
readonly args: Args
|
|
27
|
+
readonly verify: (controls: {
|
|
28
|
+
readonly controller: AbortController
|
|
29
|
+
readonly execute: () => Promise<unknown>
|
|
30
|
+
}) => void | Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AdapterContractOptions<Args, Result> {
|
|
34
|
+
readonly name: string
|
|
35
|
+
readonly adapter: Adapter<Args, Result>
|
|
36
|
+
readonly success: AdapterContractSuccess<Args, Result>
|
|
37
|
+
readonly capabilities: readonly AdapterContractCapabilityCase<Args>[]
|
|
38
|
+
readonly classifications?: readonly AdapterContractClassificationCase<Result>[]
|
|
39
|
+
readonly abort?: AdapterContractAbortCase<Args>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AdapterContractCheck {
|
|
43
|
+
readonly name: string
|
|
44
|
+
run(): Promise<void>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AdapterContractSuite {
|
|
48
|
+
readonly name: string
|
|
49
|
+
readonly checks: readonly AdapterContractCheck[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertEqual<T>(actual: T, expected: T, message: string): void {
|
|
53
|
+
if (!Object.is(actual, expected)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`${message}: expected ${String(expected)}, received ${String(actual)}`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function assertLifecycle(events: readonly OperationEvent[]): void {
|
|
61
|
+
const types = events.map((event) => event.type)
|
|
62
|
+
const expected = [
|
|
63
|
+
"execution.started",
|
|
64
|
+
"attempt.started",
|
|
65
|
+
"attempt.settled",
|
|
66
|
+
"execution.settled",
|
|
67
|
+
]
|
|
68
|
+
if (
|
|
69
|
+
types.length !== expected.length ||
|
|
70
|
+
types.some((type, index) => type !== expected[index])
|
|
71
|
+
) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`expected operation lifecycle ${expected.join(" -> ")}; received ${types.join(" -> ")}`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns runner-agnostic checks for a third-party adapter. Register each
|
|
80
|
+
* check with the application's test runner; this module imports no test runner.
|
|
81
|
+
*/
|
|
82
|
+
export function defineAdapterContractSuite<Args, Result>(
|
|
83
|
+
options: AdapterContractOptions<Args, Result>,
|
|
84
|
+
): AdapterContractSuite {
|
|
85
|
+
const checks: AdapterContractCheck[] = options.capabilities.map(
|
|
86
|
+
(capabilityCase, index) => ({
|
|
87
|
+
name: `${options.name}: capabilities ${index + 1}`,
|
|
88
|
+
async run(): Promise<void> {
|
|
89
|
+
const actual = options.adapter.capabilities(capabilityCase.args)
|
|
90
|
+
assertEqual(
|
|
91
|
+
actual.abort,
|
|
92
|
+
capabilityCase.expected.abort,
|
|
93
|
+
"abort capability",
|
|
94
|
+
)
|
|
95
|
+
assertEqual(
|
|
96
|
+
actual.replay,
|
|
97
|
+
capabilityCase.expected.replay,
|
|
98
|
+
"replay capability",
|
|
99
|
+
)
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
checks.push({
|
|
105
|
+
name: `${options.name}: successful operation lifecycle`,
|
|
106
|
+
async run(): Promise<void> {
|
|
107
|
+
const events: OperationEvent[] = []
|
|
108
|
+
const subject = operation({
|
|
109
|
+
name: `adapter-contract:${options.name}`,
|
|
110
|
+
adapter: options.adapter,
|
|
111
|
+
events: { emit: (event) => events.push(event) },
|
|
112
|
+
})
|
|
113
|
+
const result = await subject.execute(options.success.args, {
|
|
114
|
+
executionId: "adapter-contract",
|
|
115
|
+
})
|
|
116
|
+
await options.success.assertResult?.(result)
|
|
117
|
+
assertLifecycle(events)
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
for (const [index, classificationCase] of (
|
|
122
|
+
options.classifications ?? []
|
|
123
|
+
).entries()) {
|
|
124
|
+
checks.push({
|
|
125
|
+
name: `${options.name}: classification ${index + 1}`,
|
|
126
|
+
async run(): Promise<void> {
|
|
127
|
+
const actual =
|
|
128
|
+
options.adapter.classify?.(classificationCase.outcome) ??
|
|
129
|
+
(classificationCase.outcome.status === "success"
|
|
130
|
+
? "success"
|
|
131
|
+
: "failure")
|
|
132
|
+
assertEqual(
|
|
133
|
+
actual,
|
|
134
|
+
classificationCase.expected,
|
|
135
|
+
"outcome classification",
|
|
136
|
+
)
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (options.abort !== undefined) {
|
|
142
|
+
checks.push({
|
|
143
|
+
name: `${options.name}: abort behavior`,
|
|
144
|
+
async run(): Promise<void> {
|
|
145
|
+
const controller = new AbortController()
|
|
146
|
+
const subject = operation({
|
|
147
|
+
name: `adapter-contract:${options.name}`,
|
|
148
|
+
adapter: options.adapter,
|
|
149
|
+
})
|
|
150
|
+
await options.abort?.verify({
|
|
151
|
+
controller,
|
|
152
|
+
execute: () =>
|
|
153
|
+
subject.execute(options.abort?.args as Args, {
|
|
154
|
+
signal: controller.signal,
|
|
155
|
+
}),
|
|
156
|
+
})
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return Object.freeze({ name: options.name, checks: Object.freeze(checks) })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function runAdapterContractSuite(
|
|
165
|
+
suite: AdapterContractSuite,
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
for (const check of suite.checks) {
|
|
168
|
+
await check.run()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Runner-agnostic adapter contract checks; source remains outside src/. */
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
AdapterContractAbortCase,
|
|
5
|
+
AdapterContractCapabilityCase,
|
|
6
|
+
AdapterContractCheck,
|
|
7
|
+
AdapterContractClassificationCase,
|
|
8
|
+
AdapterContractOptions,
|
|
9
|
+
AdapterContractSuccess,
|
|
10
|
+
AdapterContractSuite,
|
|
11
|
+
} from "./adapter-contract.js"
|
|
12
|
+
export {
|
|
13
|
+
defineAdapterContractSuite,
|
|
14
|
+
runAdapterContractSuite,
|
|
15
|
+
} from "./adapter-contract.js"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/core/runtime.ts","../src/core/operation.ts"],"names":[],"mappings":";;;;;AASA,IAAM,UAAA,0BAAoB,oBAAoB,CAAA;AAC9C,IAAM,gBAAA,uBAAuB,OAAA,EAAuC;AAC7D,SAAS,gBACd,OAAA,EACyB;AACzB,EAAA,MAAM,SAAA,GAAY,gBAAA,CAAiB,GAAA,CAAI,OAAO,CAAA;AAC9C,EAAA,OAAO,SAAA,IAAa,OAAA,CAAQ,MAAA,GACxB,WAAA,CAAY,GAAA,CAAI,CAAC,SAAA,EAAW,OAAA,CAAQ,MAAM,CAAC,CAAA,GAC1C,SAAA,IAAa,OAAA,CAAQ,MAAA;AAC5B;AACO,SAAS,mBAAA,CACd,SACA,MAAA,EACkB;AAClB,EAAA,MAAM,OAAA,GAAU,aAAA;AAAA,IACd,EAAE,GAAG,OAAA,EAAQ;AAAA,IACb,cAAA,CAAe,OAAO,CAAA,CAAE,UAAU,KAAK;AAAC,GAC1C;AACA,EAAA,MAAM,QAAA,GAAW,gBAAgB,OAAO,CAAA;AACxC,EAAA,gBAAA,CAAiB,GAAA;AAAA,IACf,OAAA;AAAA,IACA,WAAW,WAAA,CAAY,GAAA,CAAI,CAAC,QAAA,EAAU,MAAM,CAAC,CAAA,GAAI;AAAA,GACnD;AACA,EAAA,OAAO,OAAA;AACT;AACA,SAAS,gBAAA,CACP,QACA,MAAA,EACkB;AAClB,EAAA,MAAM,MAAA,GAAS,gBAAA,CAAiB,GAAA,CAAI,MAAM,CAAA;AAC1C,EAAA,IAAI,MAAA,EAAQ,gBAAA,CAAiB,GAAA,CAAI,MAAA,EAAQ,MAAM,CAAA;AAC/C,EAAA,OAAO,MAAA;AACT;AAYA,SAAS,eAAe,OAAA,EAAoD;AAC1E,EAAA,OAAO,OAAA;AACT;AAEA,SAAS,aAAA,CACP,QACA,KAAA,EACkB;AAClB,EAAA,MAAM,OAAA,GAAU,MAAA;AAChB,EAAA,MAAA,CAAO,eAAe,OAAA,EAAS,UAAA,EAAY,EAAE,KAAA,EAAO,OAAO,CAAA;AAC3D,EAAA,OAAO,MAAA,CAAO,OAAO,OAAO,CAAA;AAC9B;AAEO,SAAS,sBAAA,CACd,QACA,KAAA,EACkB;AAClB,EAAA,OAAO,cAAc,EAAE,OAAA,EAAS,GAAG,GAAG,MAAA,IAAU,KAAK,CAAA;AACvD;AAEO,SAAS,YAAY,OAAA,EAA6C;AACvE,EAAA,OAAO,gBAAA;AAAA,IACL,OAAA;AAAA,IACA,aAAA;AAAA,MACE,EAAE,GAAG,OAAA,EAAS,OAAA,EAAS,OAAA,CAAQ,UAAU,CAAA,EAAE;AAAA,MAC3C,cAAA,CAAe,OAAO,CAAA,CAAE,UAAU,KAAK;AAAC;AAC1C,GACF;AACF;AAEO,SAAS,UAAA,CACd,SACA,MAAA,EACkB;AAClB,EAAA,OAAO,gBAAA;AAAA,IACL,OAAA;AAAA,IACA,aAAA;AAAA,MACE,EAAE,GAAG,OAAA,EAAS,MAAA,EAAO;AAAA,MACrB,cAAA,CAAe,OAAO,CAAA,CAAE,UAAU,KAAK;AAAC;AAC1C,GACF;AACF;AAEO,SAAS,gBAAA,CACd,SACA,KAAA,EACM;AACN,EAAA,MAAM,QAAQ,cAAA,CAAe,OAAO,CAAA,CAAE,UAAU,KAAK,EAAC;AACtD,EAAA,MAAM,SAAA,GAAY,EAAE,GAAG,KAAA,EAAO,IAAI,IAAA,CAAK,GAAA,IAAO,OAAA,EAAQ;AAEtD,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,IAAI;AACF,MAAA,IAAA,CAAK,KAAK,SAAS,CAAA;AAAA,IACrB,CAAA,CAAA,MAAQ;AAAA,IAER;AAAA,EACF;AACF;AAEO,SAAS,iBACd,QAAA,EACmB;AACnB,EAAA,OAAO,CAAC,OAAA,KAAY;AAClB,IAAA,IAAI,aAAa,MAAA,EAAW;AAC1B,MAAA,OAAO,OAAA,CAAQ,MAAA,KAAW,SAAA,GAAY,SAAA,GAAY,SAAA;AAAA,IACpD;AAEA,IAAA,OAAO,SAAS,OAA0B,CAAA;AAAA,EAC5C,CAAA;AACF;;;ACpGA,IAAM,mBAAmB,OAA2B;AAAA,EAClD,MAAA,EAAQ,SAAA;AAAA,EACR,KAAA,EAAO;AACT,CAAA,CAAA;AACA,IAAM,gBAAA,GAAmB,CAAC,KAAA,MAAwC;AAAA,EAChE,MAAA,EAAQ,SAAA;AAAA,EACR;AACF,CAAA,CAAA;AAEA,SAAS,sBACP,YAAA,EACuB;AACvB,EAAA,OAAO,MAAA,CAAO,MAAA,CAAO,EAAE,GAAG,cAAc,CAAA;AAC1C;AAEA,SAAS,kBACP,QAAA,EACmB;AACnB,EAAA,OAAO,OAAO,MAAA,CAAO,EAAE,GAAI,QAAA,IAAY,IAAK,CAAA;AAC9C;AAEA,SAAS,eAAe,MAAA,EAAsD;AAC5E,EAAA,IAAI,WAAW,MAAA,EAAW;AACxB,IAAA,OAAO,EAAC;AAAA,EACV;AAEA,EAAA,OAAO,MAAA,IAAU,MAAA,GAAS,CAAC,MAAM,CAAA,GAAI,MAAA;AACvC;AAEA,SAAS,IAAA,CAAK,OAA6B,KAAA,EAA6B;AACtE,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,IAAI;AACF,MAAA,IAAA,CAAK,KAAK,KAAK,CAAA;AAAA,IACjB,CAAA,CAAA,MAAQ;AAAA,IAER;AAAA,EACF;AACF;AAEA,SAAS,YAAA,CAAa,MAAc,IAAA,EAAoC;AACtE,EAAA,IAAI,IAAA,CAAK,IAAA,EAAK,CAAE,MAAA,KAAW,CAAA,EAAG;AAC5B,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,EAAG,IAAI,CAAA,uBAAA,CAAyB,CAAA;AAAA,EAClD;AACF;AAEA,SAAS,cAAA,CACP,UACA,OAAA,EACc;AACd,EAAA,OAAO,QAAA,CAAS,WAAA;AAAA,IACd,CAAC,MAAM,MAAA,KAAW,OAAO,YAAY,MAAA,CAAO,OAAA,CAAQ,SAAS,IAAI,CAAA;AAAA,IACjE;AAAA,GACF;AACF;AAEA,SAAS,aAAA,CACP,OAAA,EACA,IAAA,EACA,KAAA,EACc;AACd,EAAA,OAAO,OAAO,OAAA,KAAY;AACxB,IAAA,eAAA,CAAgB,OAAO,GAAG,cAAA,EAAe;AACzC,IAAA,IAAA,CAAK,KAAA,EAAO,EAAE,IAAA,EAAM,iBAAA,EAAmB,IAAI,IAAA,CAAK,GAAA,EAAI,EAAG,OAAA,EAAS,CAAA;AAEhE,IAAA,IAAI;AACF,MAAA,MAAM,KAAA,GAAQ,MAAM,OAAA,CAAQ,OAAA,CAAQ,MAAM,OAAO,CAAA;AACjD,MAAA,MAAM,OAAA,GAA2B,EAAE,MAAA,EAAQ,SAAA,EAAW,KAAA,EAAM;AAC5D,MAAA,MAAM,cAAA,GAAiB,OAAA,CAAQ,QAAA,CAAS,OAAO,CAAA;AAC/C,MAAA,IAAA,CAAK,KAAA,EAAO;AAAA,QACV,IAAA,EAAM,iBAAA;AAAA,QACN,EAAA,EAAI,KAAK,GAAA,EAAI;AAAA,QACb,OAAA;AAAA,QACA,SAAS,gBAAA,EAAiB;AAAA,QAC1B;AAAA,OACD,CAAA;AACD,MAAA,OAAO,KAAA;AAAA,IACT,SAAS,KAAA,EAAO;AACd,MAAA,MAAM,OAAA,GAA2B,EAAE,MAAA,EAAQ,SAAA,EAAW,KAAA,EAAM;AAC5D,MAAA,MAAM,cAAA,GAAiB,OAAA,CAAQ,QAAA,CAAS,OAAO,CAAA;AAC/C,MAAA,IAAA,CAAK,KAAA,EAAO;AAAA,QACV,IAAA,EAAM,iBAAA;AAAA,QACN,EAAA,EAAI,KAAK,GAAA,EAAI;AAAA,QACb,OAAA;AAAA,QACA,OAAA,EAAS,iBAAiB,KAAK,CAAA;AAAA,QAC/B;AAAA,OACD,CAAA;AACD,MAAA,MAAM,KAAA;AAAA,IACR;AAAA,EACF,CAAA;AACF;AAGO,SAAS,UACd,OAAA,EACyB;AACzB,EAAA,YAAA,CAAa,OAAA,CAAQ,MAAM,WAAW,CAAA;AACtC,EAAA,KAAA,MAAW,MAAA,IAAU,OAAA,CAAQ,QAAA,IAAY,EAAC,EAAG;AAC3C,IAAA,YAAA,CAAa,MAAA,CAAO,MAAM,QAAQ,CAAA;AAAA,EACpC;AAEA,EAAA,MAAM,QAAA,GAAW,OAAO,MAAA,CAAO,CAAC,GAAI,OAAA,CAAQ,QAAA,IAAY,EAAG,CAAC,CAAA;AAC5D,EAAA,MAAM,QAAQ,MAAA,CAAO,MAAA,CAAO,cAAA,CAAe,OAAA,CAAQ,MAAM,CAAC,CAAA;AAE1D,EAAA,OAAO,OAAO,MAAA,CAAO;AAAA,IACnB,MAAM,OAAA,CAAQ,IAAA;AAAA,IACd,MAAM,OAAA,CACJ,IAAA,EACA,cAAA,GAA0C,EAAC,EAC1B;AACjB,MAAA,MAAM,YAAA,GAAe,OAAA,CAAQ,OAAA,CAAQ,YAAA,CAAa,IAAI,CAAA;AACtD,MAAA,MAAM,OAAA,GAAU,sBAAA;AAAA,QACd;AAAA,UACE,eAAe,OAAA,CAAQ,IAAA;AAAA,UACvB,WAAA,EAAa,cAAA,CAAe,WAAA,IAAe,UAAA,EAAW;AAAA,UACtD,QAAQ,cAAA,CAAe,MAAA;AAAA,UACvB,QAAA,EAAU,iBAAA,CAAkB,cAAA,CAAe,QAAQ,CAAA;AAAA,UACnD,YAAA,EAAc,sBAAsB,YAAY,CAAA;AAAA,UAChD,QAAA,EAAU,gBAAA,CAAiB,OAAA,CAAQ,OAAA,CAAQ,QAAQ;AAAA,SACrD;AAAA,QACA;AAAA,OACF;AACA,MAAA,MAAM,OAAA,GAAU,cAAA;AAAA,QACd,SAAS,MAAA,CAAO,CAAC,MAAA,KAAW,MAAA,CAAO,UAAU,SAAS,CAAA;AAAA,QACtD,aAAA,CAAc,OAAA,CAAQ,OAAA,EAAS,IAAA,EAAM,KAAK;AAAA,OAC5C;AACA,MAAA,MAAM,QAAA,GAAW,cAAA;AAAA,QACf,SAAS,MAAA,CAAO,CAAC,MAAA,KAAW,MAAA,CAAO,UAAU,SAAS,CAAA;AAAA,QACtD;AAAA,OACF;AAEA,MAAA,IAAA,CAAK,KAAA,EAAO,EAAE,IAAA,EAAM,mBAAA,EAAqB,IAAI,IAAA,CAAK,GAAA,EAAI,EAAG,OAAA,EAAS,CAAA;AAClE,MAAA,IAAI;AACF,QAAA,MAAM,KAAA,GAAQ,MAAM,QAAA,CAAS,OAAO,CAAA;AACpC,QAAA,IAAA,CAAK,KAAA,EAAO;AAAA,UACV,IAAA,EAAM,mBAAA;AAAA,UACN,EAAA,EAAI,KAAK,GAAA,EAAI;AAAA,UACb,OAAA;AAAA,UACA,SAAS,gBAAA;AAAiB,SAC3B,CAAA;AACD,QAAA,OAAO,KAAA;AAAA,MACT,SAAS,KAAA,EAAO;AACd,QAAA,IAAA,CAAK,KAAA,EAAO;AAAA,UACV,IAAA,EAAM,mBAAA;AAAA,UACN,EAAA,EAAI,KAAK,GAAA,EAAI;AAAA,UACb,OAAA;AAAA,UACA,OAAA,EAAS,iBAAiB,KAAK;AAAA,SAChC,CAAA;AACD,QAAA,MAAM,KAAA;AAAA,MACR;AAAA,IACF;AAAA,GACD,CAAA;AACH","file":"chunk-5CXDW7W6.js","sourcesContent":["import type {\n Classification,\n EventSink,\n ExecutionContext,\n OperationEvent,\n Outcome,\n OutcomeClassifier,\n} from \"./types.js\"\n\nconst eventSinks = Symbol(\"caracal.eventSinks\")\nconst admissionSignals = new WeakMap<ExecutionContext, AbortSignal>()\nexport function admissionSignal(\n context: ExecutionContext,\n): AbortSignal | undefined {\n const admission = admissionSignals.get(context)\n return admission && context.signal\n ? AbortSignal.any([admission, context.signal])\n : (admission ?? context.signal)\n}\nexport function withAdmissionSignal(\n context: ExecutionContext,\n signal: AbortSignal,\n): ExecutionContext {\n const derived = attachRuntime(\n { ...context },\n runtimeContext(context)[eventSinks] ?? [],\n )\n const previous = admissionSignal(context)\n admissionSignals.set(\n derived,\n previous ? AbortSignal.any([previous, signal]) : signal,\n )\n return derived\n}\nfunction inheritAdmission(\n source: ExecutionContext,\n target: ExecutionContext,\n): ExecutionContext {\n const signal = admissionSignals.get(source)\n if (signal) admissionSignals.set(target, signal)\n return target\n}\n\ntype EventWithoutRuntimeFields = OperationEvent extends infer Event\n ? Event extends OperationEvent\n ? Omit<Event, \"at\" | \"context\">\n : never\n : never\n\ntype RuntimeExecutionContext = ExecutionContext & {\n readonly [eventSinks]: readonly EventSink[]\n}\n\nfunction runtimeContext(context: ExecutionContext): RuntimeExecutionContext {\n return context as RuntimeExecutionContext\n}\n\nfunction attachRuntime(\n values: ExecutionContext,\n sinks: readonly EventSink[],\n): ExecutionContext {\n const context = values as RuntimeExecutionContext\n Object.defineProperty(context, eventSinks, { value: sinks })\n return Object.freeze(context)\n}\n\nexport function createExecutionContext(\n values: Omit<ExecutionContext, \"attempt\">,\n sinks: readonly EventSink[],\n): ExecutionContext {\n return attachRuntime({ attempt: 1, ...values }, sinks)\n}\n\nexport function nextAttempt(context: ExecutionContext): ExecutionContext {\n return inheritAdmission(\n context,\n attachRuntime(\n { ...context, attempt: context.attempt + 1 },\n runtimeContext(context)[eventSinks] ?? [],\n ),\n )\n}\n\nexport function withSignal(\n context: ExecutionContext,\n signal: AbortSignal | undefined,\n): ExecutionContext {\n return inheritAdmission(\n context,\n attachRuntime(\n { ...context, signal },\n runtimeContext(context)[eventSinks] ?? [],\n ),\n )\n}\n\nexport function emitRuntimeEvent(\n context: ExecutionContext,\n event: EventWithoutRuntimeFields,\n): void {\n const sinks = runtimeContext(context)[eventSinks] ?? []\n const fullEvent = { ...event, at: Date.now(), context } as OperationEvent\n\n for (const sink of sinks) {\n try {\n sink.emit(fullEvent)\n } catch {\n // Observability must not modify resilience execution.\n }\n }\n}\n\nexport function createClassifier<Result>(\n classify: ((outcome: Outcome<Result>) => Classification) | undefined,\n): OutcomeClassifier {\n return (outcome) => {\n if (classify === undefined) {\n return outcome.status === \"success\" ? \"success\" : \"failure\"\n }\n\n return classify(outcome as Outcome<Result>)\n }\n}\n","import { randomUUID } from \"node:crypto\"\n\nimport {\n admissionSignal,\n createClassifier,\n createExecutionContext,\n} from \"./runtime.js\"\nimport type {\n Adapter,\n EventSink,\n EventSinks,\n ExecutionMetadata,\n Next,\n Operation,\n OperationCapabilities,\n OperationEvent,\n OperationExecuteOptions,\n OperationOptions,\n Outcome,\n Policy,\n} from \"./types.js\"\n\nconst summarizeSuccess = (): Outcome<undefined> => ({\n status: \"success\",\n value: undefined,\n})\nconst summarizeFailure = (error: unknown): Outcome<undefined> => ({\n status: \"failure\",\n error,\n})\n\nfunction immutableCapabilities(\n capabilities: OperationCapabilities,\n): OperationCapabilities {\n return Object.freeze({ ...capabilities })\n}\n\nfunction immutableMetadata(\n metadata: Readonly<Record<string, unknown>> | undefined,\n): ExecutionMetadata {\n return Object.freeze({ ...(metadata ?? {}) })\n}\n\nfunction normalizeSinks(events: EventSinks | undefined): readonly EventSink[] {\n if (events === undefined) {\n return []\n }\n\n return \"emit\" in events ? [events] : events\n}\n\nfunction emit(sinks: readonly EventSink[], event: OperationEvent): void {\n for (const sink of sinks) {\n try {\n sink.emit(event)\n } catch {\n // Observability must not modify resilience execution.\n }\n }\n}\n\nfunction validateName(name: string, kind: \"operation\" | \"policy\"): void {\n if (name.trim().length === 0) {\n throw new Error(`${kind} name must not be empty`)\n }\n}\n\nfunction createPipeline<Result>(\n policies: readonly Policy[],\n adapter: Next<Result>,\n): Next<Result> {\n return policies.reduceRight<Next<Result>>(\n (next, policy) => async (context) => policy.execute(context, next),\n adapter,\n )\n}\n\nfunction invokeAdapter<Args, Result>(\n adapter: Adapter<Args, Result>,\n args: Args,\n sinks: readonly EventSink[],\n): Next<Result> {\n return async (context) => {\n admissionSignal(context)?.throwIfAborted()\n emit(sinks, { type: \"attempt.started\", at: Date.now(), context })\n\n try {\n const value = await adapter.execute(args, context)\n const outcome: Outcome<Result> = { status: \"success\", value }\n const classification = context.classify(outcome)\n emit(sinks, {\n type: \"attempt.settled\",\n at: Date.now(),\n context,\n outcome: summarizeSuccess(),\n classification,\n })\n return value\n } catch (error) {\n const outcome: Outcome<Result> = { status: \"failure\", error }\n const classification = context.classify(outcome)\n emit(sinks, {\n type: \"attempt.settled\",\n at: Date.now(),\n context,\n outcome: summarizeFailure(error),\n classification,\n })\n throw error\n }\n }\n}\n\n/** Creates a named, protocol-agnostic operation. */\nexport function operation<Args, Result>(\n options: OperationOptions<Args, Result>,\n): Operation<Args, Result> {\n validateName(options.name, \"operation\")\n for (const policy of options.policies ?? []) {\n validateName(policy.name, \"policy\")\n }\n\n const policies = Object.freeze([...(options.policies ?? [])])\n const sinks = Object.freeze(normalizeSinks(options.events))\n\n return Object.freeze({\n name: options.name,\n async execute(\n args: Args,\n executeOptions: OperationExecuteOptions = {},\n ): Promise<Result> {\n const capabilities = options.adapter.capabilities(args)\n const context = createExecutionContext(\n {\n operationName: options.name,\n executionId: executeOptions.executionId ?? randomUUID(),\n signal: executeOptions.signal,\n metadata: immutableMetadata(executeOptions.metadata),\n capabilities: immutableCapabilities(capabilities),\n classify: createClassifier(options.adapter.classify),\n },\n sinks,\n )\n const adapter = createPipeline(\n policies.filter((policy) => policy.phase === \"attempt\"),\n invokeAdapter(options.adapter, args, sinks),\n )\n const pipeline = createPipeline(\n policies.filter((policy) => policy.phase !== \"attempt\"),\n adapter,\n )\n\n emit(sinks, { type: \"execution.started\", at: Date.now(), context })\n try {\n const value = await pipeline(context)\n emit(sinks, {\n type: \"execution.settled\",\n at: Date.now(),\n context,\n outcome: summarizeSuccess(),\n })\n return value\n } catch (error) {\n emit(sinks, {\n type: \"execution.settled\",\n at: Date.now(),\n context,\n outcome: summarizeFailure(error),\n })\n throw error\n }\n },\n })\n}\n"]}
|