@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.
@@ -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
- readonly outcome: "success" | "failure"
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(failureThreshold * 1000)
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
- if (breakerOutcome !== "ignored") {
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: outcomeStr,
1042
+ outcome: settleOutcome,
1002
1043
  generation: admission.generation,
1003
1044
  halfOpenSuccesses,
1004
1045
  openMs,
1005
1046
  windowTtlMs,
1006
1047
  })
1007
- if (result.type === "stale") {
1008
- emitRuntimeEvent(context, {
1009
- type: "breaker.observation-stale",
1010
- coordination: "distributed",
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.state-changed",
1053
+ type: "breaker.observation-stale",
1037
1054
  coordination: "distributed",
1038
1055
  policyName: name,
1039
1056
  scope,
1040
- state: result.newState,
1041
- previousState: "half-open",
1042
- generation: result.newGeneration,
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) {
@@ -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
- try {
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 { admissionSignal, emitRuntimeEvent, nextAttempt } from "./runtime.js"
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
 
@@ -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
- try {
106
- sink.emit(fullEvent)
107
- } catch {
108
- // Observability must not modify resilience execution.
109
- }
130
+ emitToSink(sink, fullEvent)
110
131
  }
111
132
  }
112
133
 
@@ -1,4 +1,9 @@
1
- import { emitRuntimeEvent, withAdmissionSignal, withSignal } from "./runtime.js"
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":"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","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"]}