@gkoos/caracal 0.1.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +311 -0
  4. package/dist/chunk-5CXDW7W6.js +202 -0
  5. package/dist/chunk-5CXDW7W6.js.map +1 -0
  6. package/dist/circuit-breaker-BSkcV0W_.d.ts +296 -0
  7. package/dist/fetch.d.ts +58 -0
  8. package/dist/fetch.js +117 -0
  9. package/dist/fetch.js.map +1 -0
  10. package/dist/index.d.ts +19 -0
  11. package/dist/index.js +1065 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/postgres.d.ts +28 -0
  14. package/dist/postgres.js +56 -0
  15. package/dist/postgres.js.map +1 -0
  16. package/dist/redis.d.ts +59 -0
  17. package/dist/redis.js +549 -0
  18. package/dist/redis.js.map +1 -0
  19. package/dist/retry-BFP_k3Hg.d.ts +26 -0
  20. package/dist/testing/index.d.ts +45 -0
  21. package/dist/testing/index.js +101 -0
  22. package/dist/testing/index.js.map +1 -0
  23. package/dist/types-Tf9T76C7.d.ts +187 -0
  24. package/package.json +127 -0
  25. package/src/adapters/fetch/adapter.ts +122 -0
  26. package/src/adapters/fetch/index.ts +15 -0
  27. package/src/adapters/fetch/retry-after.ts +111 -0
  28. package/src/adapters/postgres/adapter.ts +102 -0
  29. package/src/adapters/postgres/index.ts +7 -0
  30. package/src/coordination/redis/bulkhead.ts +61 -0
  31. package/src/coordination/redis/circuit-breaker.ts +270 -0
  32. package/src/coordination/redis/client.ts +78 -0
  33. package/src/coordination/redis/eval-script.ts +71 -0
  34. package/src/coordination/redis/keys.ts +32 -0
  35. package/src/coordination/redis/leases.ts +44 -0
  36. package/src/coordination/redis/scripts.ts +314 -0
  37. package/src/core/bulkhead.ts +336 -0
  38. package/src/core/circuit-breaker.ts +1066 -0
  39. package/src/core/index.ts +36 -0
  40. package/src/core/operation.ts +174 -0
  41. package/src/core/retry.ts +204 -0
  42. package/src/core/runtime.ts +123 -0
  43. package/src/core/scope-state-cache.ts +50 -0
  44. package/src/core/timeout.ts +73 -0
  45. package/src/core/types.ts +230 -0
  46. package/src/fetch.ts +17 -0
  47. package/src/index.ts +49 -0
  48. package/src/postgres.ts +9 -0
  49. package/src/redis.ts +8 -0
@@ -0,0 +1,45 @@
1
+ import { O as OperationCapabilities, h as Outcome, C as Classification, A as Adapter } from '../types-Tf9T76C7.js';
2
+
3
+ interface AdapterContractSuccess<Args, Result> {
4
+ readonly args: Args;
5
+ readonly assertResult?: (result: Result) => void | Promise<void>;
6
+ }
7
+ interface AdapterContractCapabilityCase<Args> {
8
+ readonly args: Args;
9
+ readonly expected: OperationCapabilities;
10
+ }
11
+ interface AdapterContractClassificationCase<Result> {
12
+ readonly outcome: Outcome<Result>;
13
+ readonly expected: Classification;
14
+ }
15
+ interface AdapterContractAbortCase<Args> {
16
+ readonly args: Args;
17
+ readonly verify: (controls: {
18
+ readonly controller: AbortController;
19
+ readonly execute: () => Promise<unknown>;
20
+ }) => void | Promise<void>;
21
+ }
22
+ interface AdapterContractOptions<Args, Result> {
23
+ readonly name: string;
24
+ readonly adapter: Adapter<Args, Result>;
25
+ readonly success: AdapterContractSuccess<Args, Result>;
26
+ readonly capabilities: readonly AdapterContractCapabilityCase<Args>[];
27
+ readonly classifications?: readonly AdapterContractClassificationCase<Result>[];
28
+ readonly abort?: AdapterContractAbortCase<Args>;
29
+ }
30
+ interface AdapterContractCheck {
31
+ readonly name: string;
32
+ run(): Promise<void>;
33
+ }
34
+ interface AdapterContractSuite {
35
+ readonly name: string;
36
+ readonly checks: readonly AdapterContractCheck[];
37
+ }
38
+ /**
39
+ * Returns runner-agnostic checks for a third-party adapter. Register each
40
+ * check with the application's test runner; this module imports no test runner.
41
+ */
42
+ declare function defineAdapterContractSuite<Args, Result>(options: AdapterContractOptions<Args, Result>): AdapterContractSuite;
43
+ declare function runAdapterContractSuite(suite: AdapterContractSuite): Promise<void>;
44
+
45
+ export { type AdapterContractAbortCase, type AdapterContractCapabilityCase, type AdapterContractCheck, type AdapterContractClassificationCase, type AdapterContractOptions, type AdapterContractSuccess, type AdapterContractSuite, defineAdapterContractSuite, runAdapterContractSuite };
@@ -0,0 +1,101 @@
1
+ import { operation } from '../chunk-5CXDW7W6.js';
2
+
3
+ // test/harness/adapter-contract.ts
4
+ function assertEqual(actual, expected, message) {
5
+ if (!Object.is(actual, expected)) {
6
+ throw new Error(
7
+ `${message}: expected ${String(expected)}, received ${String(actual)}`
8
+ );
9
+ }
10
+ }
11
+ function assertLifecycle(events) {
12
+ const types = events.map((event) => event.type);
13
+ const expected = [
14
+ "execution.started",
15
+ "attempt.started",
16
+ "attempt.settled",
17
+ "execution.settled"
18
+ ];
19
+ if (types.length !== expected.length || types.some((type, index) => type !== expected[index])) {
20
+ throw new Error(
21
+ `expected operation lifecycle ${expected.join(" -> ")}; received ${types.join(" -> ")}`
22
+ );
23
+ }
24
+ }
25
+ function defineAdapterContractSuite(options) {
26
+ const checks = options.capabilities.map(
27
+ (capabilityCase, index) => ({
28
+ name: `${options.name}: capabilities ${index + 1}`,
29
+ async run() {
30
+ const actual = options.adapter.capabilities(capabilityCase.args);
31
+ assertEqual(
32
+ actual.abort,
33
+ capabilityCase.expected.abort,
34
+ "abort capability"
35
+ );
36
+ assertEqual(
37
+ actual.replay,
38
+ capabilityCase.expected.replay,
39
+ "replay capability"
40
+ );
41
+ }
42
+ })
43
+ );
44
+ checks.push({
45
+ name: `${options.name}: successful operation lifecycle`,
46
+ async run() {
47
+ const events = [];
48
+ const subject = operation({
49
+ name: `adapter-contract:${options.name}`,
50
+ adapter: options.adapter,
51
+ events: { emit: (event) => events.push(event) }
52
+ });
53
+ const result = await subject.execute(options.success.args, {
54
+ executionId: "adapter-contract"
55
+ });
56
+ await options.success.assertResult?.(result);
57
+ assertLifecycle(events);
58
+ }
59
+ });
60
+ for (const [index, classificationCase] of (options.classifications ?? []).entries()) {
61
+ checks.push({
62
+ name: `${options.name}: classification ${index + 1}`,
63
+ async run() {
64
+ const actual = options.adapter.classify?.(classificationCase.outcome) ?? (classificationCase.outcome.status === "success" ? "success" : "failure");
65
+ assertEqual(
66
+ actual,
67
+ classificationCase.expected,
68
+ "outcome classification"
69
+ );
70
+ }
71
+ });
72
+ }
73
+ if (options.abort !== void 0) {
74
+ checks.push({
75
+ name: `${options.name}: abort behavior`,
76
+ async run() {
77
+ const controller = new AbortController();
78
+ const subject = operation({
79
+ name: `adapter-contract:${options.name}`,
80
+ adapter: options.adapter
81
+ });
82
+ await options.abort?.verify({
83
+ controller,
84
+ execute: () => subject.execute(options.abort?.args, {
85
+ signal: controller.signal
86
+ })
87
+ });
88
+ }
89
+ });
90
+ }
91
+ return Object.freeze({ name: options.name, checks: Object.freeze(checks) });
92
+ }
93
+ async function runAdapterContractSuite(suite) {
94
+ for (const check of suite.checks) {
95
+ await check.run();
96
+ }
97
+ }
98
+
99
+ export { defineAdapterContractSuite, runAdapterContractSuite };
100
+ //# sourceMappingURL=index.js.map
101
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../test/harness/adapter-contract.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import { operation } from \"../../src/core/operation.js\"\nimport type {\n Adapter,\n Classification,\n OperationCapabilities,\n OperationEvent,\n Outcome,\n} from \"../../src/core/types.js\"\n\nexport interface AdapterContractSuccess<Args, Result> {\n readonly args: Args\n readonly assertResult?: (result: Result) => void | Promise<void>\n}\n\nexport interface AdapterContractCapabilityCase<Args> {\n readonly args: Args\n readonly expected: OperationCapabilities\n}\n\nexport interface AdapterContractClassificationCase<Result> {\n readonly outcome: Outcome<Result>\n readonly expected: Classification\n}\n\nexport interface AdapterContractAbortCase<Args> {\n readonly args: Args\n readonly verify: (controls: {\n readonly controller: AbortController\n readonly execute: () => Promise<unknown>\n }) => void | Promise<void>\n}\n\nexport interface AdapterContractOptions<Args, Result> {\n readonly name: string\n readonly adapter: Adapter<Args, Result>\n readonly success: AdapterContractSuccess<Args, Result>\n readonly capabilities: readonly AdapterContractCapabilityCase<Args>[]\n readonly classifications?: readonly AdapterContractClassificationCase<Result>[]\n readonly abort?: AdapterContractAbortCase<Args>\n}\n\nexport interface AdapterContractCheck {\n readonly name: string\n run(): Promise<void>\n}\n\nexport interface AdapterContractSuite {\n readonly name: string\n readonly checks: readonly AdapterContractCheck[]\n}\n\nfunction assertEqual<T>(actual: T, expected: T, message: string): void {\n if (!Object.is(actual, expected)) {\n throw new Error(\n `${message}: expected ${String(expected)}, received ${String(actual)}`,\n )\n }\n}\n\nfunction assertLifecycle(events: readonly OperationEvent[]): void {\n const types = events.map((event) => event.type)\n const expected = [\n \"execution.started\",\n \"attempt.started\",\n \"attempt.settled\",\n \"execution.settled\",\n ]\n if (\n types.length !== expected.length ||\n types.some((type, index) => type !== expected[index])\n ) {\n throw new Error(\n `expected operation lifecycle ${expected.join(\" -> \")}; received ${types.join(\" -> \")}`,\n )\n }\n}\n\n/**\n * Returns runner-agnostic checks for a third-party adapter. Register each\n * check with the application's test runner; this module imports no test runner.\n */\nexport function defineAdapterContractSuite<Args, Result>(\n options: AdapterContractOptions<Args, Result>,\n): AdapterContractSuite {\n const checks: AdapterContractCheck[] = options.capabilities.map(\n (capabilityCase, index) => ({\n name: `${options.name}: capabilities ${index + 1}`,\n async run(): Promise<void> {\n const actual = options.adapter.capabilities(capabilityCase.args)\n assertEqual(\n actual.abort,\n capabilityCase.expected.abort,\n \"abort capability\",\n )\n assertEqual(\n actual.replay,\n capabilityCase.expected.replay,\n \"replay capability\",\n )\n },\n }),\n )\n\n checks.push({\n name: `${options.name}: successful operation lifecycle`,\n async run(): Promise<void> {\n const events: OperationEvent[] = []\n const subject = operation({\n name: `adapter-contract:${options.name}`,\n adapter: options.adapter,\n events: { emit: (event) => events.push(event) },\n })\n const result = await subject.execute(options.success.args, {\n executionId: \"adapter-contract\",\n })\n await options.success.assertResult?.(result)\n assertLifecycle(events)\n },\n })\n\n for (const [index, classificationCase] of (\n options.classifications ?? []\n ).entries()) {\n checks.push({\n name: `${options.name}: classification ${index + 1}`,\n async run(): Promise<void> {\n const actual =\n options.adapter.classify?.(classificationCase.outcome) ??\n (classificationCase.outcome.status === \"success\"\n ? \"success\"\n : \"failure\")\n assertEqual(\n actual,\n classificationCase.expected,\n \"outcome classification\",\n )\n },\n })\n }\n\n if (options.abort !== undefined) {\n checks.push({\n name: `${options.name}: abort behavior`,\n async run(): Promise<void> {\n const controller = new AbortController()\n const subject = operation({\n name: `adapter-contract:${options.name}`,\n adapter: options.adapter,\n })\n await options.abort?.verify({\n controller,\n execute: () =>\n subject.execute(options.abort?.args as Args, {\n signal: controller.signal,\n }),\n })\n },\n })\n }\n\n return Object.freeze({ name: options.name, checks: Object.freeze(checks) })\n}\n\nexport async function runAdapterContractSuite(\n suite: AdapterContractSuite,\n): Promise<void> {\n for (const check of suite.checks) {\n await check.run()\n }\n}\n"]}
@@ -0,0 +1,187 @@
1
+ /** Traits that may vary for each invocation of an adapter. */
2
+ type OperationCapabilities = Readonly<{
3
+ abort: "supported" | "unsupported";
4
+ replay: "safe" | "unsafe" | "unknown";
5
+ }>;
6
+ type Outcome<Result> = Readonly<{
7
+ status: "success";
8
+ value: Result;
9
+ }> | Readonly<{
10
+ status: "failure";
11
+ error: unknown;
12
+ }>;
13
+ /** An adapter's interpretation of an outcome for resilience policies. */
14
+ /** `retryable` is a failure eligible for the local retry policy. */
15
+ type Classification = "success" | "failure" | "retryable" | "ignored";
16
+ type OutcomeClassifier = (outcome: Outcome<unknown>) => Classification;
17
+ interface Adapter<Args, Result> {
18
+ execute(args: Args, context: ExecutionContext): Promise<Result>;
19
+ capabilities(args: Args): OperationCapabilities;
20
+ classify?(outcome: Outcome<Result>): Classification;
21
+ }
22
+ type ExecutionMetadata = Readonly<Record<string, unknown>>;
23
+ /**
24
+ * Immutable state for one attempt. Retry derives later attempt contexts from
25
+ * this value; an operation starts at attempt 1.
26
+ */
27
+ interface ExecutionContext {
28
+ readonly operationName: string;
29
+ readonly executionId: string;
30
+ readonly attempt: number;
31
+ readonly signal: AbortSignal | undefined;
32
+ readonly metadata: ExecutionMetadata;
33
+ readonly capabilities: OperationCapabilities;
34
+ readonly classify: OutcomeClassifier;
35
+ }
36
+ type BreakerStateChangedEvent = Readonly<{
37
+ type: "breaker.state-changed";
38
+ at: number;
39
+ context: ExecutionContext;
40
+ coordination: "local" | "distributed";
41
+ policyName: string;
42
+ scope: string;
43
+ state: "open" | "half-open" | "closed";
44
+ previousState: "closed" | "open" | "half-open";
45
+ /** Present on distributed events; absent on local events. */
46
+ generation?: number;
47
+ }>;
48
+ type BreakerRejectedEvent = Readonly<{
49
+ type: "breaker.rejected";
50
+ at: number;
51
+ context: ExecutionContext;
52
+ coordination: "local" | "distributed";
53
+ policyName: string;
54
+ scope: string;
55
+ state: "open" | "half-open";
56
+ generation?: number;
57
+ }>;
58
+ type BreakerObservationEvent = Readonly<{
59
+ type: "breaker.observation";
60
+ at: number;
61
+ context: ExecutionContext;
62
+ coordination: "local" | "distributed";
63
+ policyName: string;
64
+ scope: string;
65
+ outcome: "success" | "failure";
66
+ generation?: number;
67
+ }>;
68
+ type BreakerProbeStartedEvent = Readonly<{
69
+ type: "breaker.probe-started";
70
+ at: number;
71
+ context: ExecutionContext;
72
+ coordination: "local" | "distributed";
73
+ policyName: string;
74
+ scope: string;
75
+ generation?: number;
76
+ }>;
77
+ type BreakerObservationStaleEvent = Readonly<{
78
+ type: "breaker.observation-stale";
79
+ at: number;
80
+ context: ExecutionContext;
81
+ coordination: "distributed";
82
+ policyName: string;
83
+ scope: string;
84
+ /** Generation of the attempt that was dropped. */
85
+ attemptGeneration: number;
86
+ /** Current generation in the coordinator at the time the stale result arrived. */
87
+ currentGeneration: number;
88
+ }>;
89
+ type BreakerCoordinatorErrorEvent = Readonly<{
90
+ type: "breaker.coordinator-error";
91
+ at: number;
92
+ context: ExecutionContext;
93
+ coordination: "distributed";
94
+ policyName: string;
95
+ scope: string;
96
+ operation: "admit" | "observe" | "settle-probe";
97
+ error: unknown;
98
+ }>;
99
+ type BreakerDegradedEvent = Readonly<{
100
+ type: "breaker.degraded";
101
+ at: number;
102
+ context: ExecutionContext;
103
+ coordination: "distributed";
104
+ policyName: string;
105
+ scope: string;
106
+ reason: "coordinator-unavailable";
107
+ behavior: "fail-open" | "fail-closed";
108
+ }>;
109
+ type OperationEvent = BreakerStateChangedEvent | BreakerRejectedEvent | BreakerObservationEvent | BreakerProbeStartedEvent | BreakerObservationStaleEvent | BreakerCoordinatorErrorEvent | BreakerDegradedEvent | Readonly<{
110
+ type: "bulkhead.admitted" | "bulkhead.rejected" | "bulkhead.waited" | "bulkhead.released" | "bulkhead.lease-lost" | "bulkhead.degraded";
111
+ at: number;
112
+ context: ExecutionContext;
113
+ coordination: "local" | "distributed";
114
+ policyName: string;
115
+ scope: string;
116
+ occupancy?: number;
117
+ reason?: string;
118
+ }> | Readonly<{
119
+ type: "execution.started";
120
+ at: number;
121
+ context: ExecutionContext;
122
+ }> | Readonly<{
123
+ type: "attempt.started";
124
+ at: number;
125
+ context: ExecutionContext;
126
+ }> | Readonly<{
127
+ type: "attempt.settled";
128
+ at: number;
129
+ context: ExecutionContext;
130
+ outcome: Outcome<undefined>;
131
+ classification: Classification;
132
+ }> | Readonly<{
133
+ type: "execution.settled";
134
+ at: number;
135
+ context: ExecutionContext;
136
+ outcome: Outcome<undefined>;
137
+ }> | Readonly<{
138
+ type: "timeout.triggered";
139
+ at: number;
140
+ context: ExecutionContext;
141
+ timeoutMs: number;
142
+ abortRequested: boolean;
143
+ }> | Readonly<{
144
+ type: "retry.scheduled";
145
+ at: number;
146
+ context: ExecutionContext;
147
+ nextAttempt: number;
148
+ delayMs: number;
149
+ outcome: Outcome<undefined>;
150
+ classification: Classification;
151
+ }> | Readonly<{
152
+ type: "retry.exhausted";
153
+ at: number;
154
+ context: ExecutionContext;
155
+ outcome: Outcome<undefined>;
156
+ classification: Classification;
157
+ }>;
158
+ /** Output-only observability contract. Sinks cannot alter policy execution. */
159
+ interface EventSink {
160
+ emit(event: OperationEvent): void;
161
+ }
162
+ type EventSinks = EventSink | readonly EventSink[];
163
+ type OperationExecuteOptions = Readonly<{
164
+ signal?: AbortSignal;
165
+ metadata?: Readonly<Record<string, unknown>>;
166
+ executionId?: string;
167
+ }>;
168
+ type Next<Result> = (context: ExecutionContext) => Promise<Result>;
169
+ /** A policy wraps execution; it is not a generic lifecycle hook system. */
170
+ interface Policy {
171
+ /** Attempt-phase policies wrap each adapter call and must await underlying settlement. */
172
+ readonly phase?: "attempt";
173
+ readonly name: string;
174
+ execute<Result>(context: ExecutionContext, next: Next<Result>): Promise<Result>;
175
+ }
176
+ interface Operation<Args, Result> {
177
+ readonly name: string;
178
+ execute(args: Args, options?: OperationExecuteOptions): Promise<Result>;
179
+ }
180
+ interface OperationOptions<Args, Result> {
181
+ readonly name: string;
182
+ readonly adapter: Adapter<Args, Result>;
183
+ readonly policies?: readonly Policy[];
184
+ readonly events?: EventSinks;
185
+ }
186
+
187
+ export type { Adapter as A, Classification as C, EventSink as E, Next as N, OperationCapabilities as O, Policy as P, OperationOptions as a, Operation as b, EventSinks as c, ExecutionContext as d, ExecutionMetadata as e, OperationEvent as f, OperationExecuteOptions as g, Outcome as h, OutcomeClassifier as i };
package/package.json ADDED
@@ -0,0 +1,127 @@
1
+ {
2
+ "name": "@gkoos/caracal",
3
+ "version": "0.1.0",
4
+ "description": "Scoped distributed resilience for asynchronous operations.",
5
+ "keywords": [
6
+ "resilience",
7
+ "circuit-breaker",
8
+ "bulkhead",
9
+ "retry",
10
+ "timeout",
11
+ "fault-tolerance",
12
+ "distributed-systems",
13
+ "redis",
14
+ "concurrency",
15
+ "typescript"
16
+ ],
17
+ "homepage": "https://github.com/gkoos/caracal#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/gkoos/caracal/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/gkoos/caracal.git"
24
+ },
25
+ "license": "MIT",
26
+ "author": {
27
+ "name": "Gabor Koos",
28
+ "email": "gabor@gaborkoos.com",
29
+ "url": "https://gaborkoos.com"
30
+ },
31
+ "type": "module",
32
+ "sideEffects": false,
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "exports": {
37
+ ".": {
38
+ "types": "./dist/index.d.ts",
39
+ "import": "./dist/index.js"
40
+ },
41
+ "./redis": {
42
+ "types": "./dist/redis.d.ts",
43
+ "import": "./dist/redis.js"
44
+ },
45
+ "./fetch": {
46
+ "types": "./dist/fetch.d.ts",
47
+ "import": "./dist/fetch.js"
48
+ },
49
+ "./postgres": {
50
+ "types": "./dist/postgres.d.ts",
51
+ "import": "./dist/postgres.js"
52
+ },
53
+ "./testing": {
54
+ "types": "./dist/testing/index.d.ts",
55
+ "import": "./dist/testing/index.js"
56
+ },
57
+ "./package.json": "./package.json"
58
+ },
59
+ "files": [
60
+ "dist",
61
+ "src",
62
+ "README.md",
63
+ "CHANGELOG.md",
64
+ "LICENSE"
65
+ ],
66
+ "publishConfig": {
67
+ "access": "public"
68
+ },
69
+ "scripts": {
70
+ "build": "tsup",
71
+ "clean": "rimraf dist coverage",
72
+ "typecheck": "tsc --noEmit",
73
+ "test": "vitest run test/unit",
74
+ "test:watch": "vitest",
75
+ "test:integration": "vitest run test/integration",
76
+ "test:property": "vitest run test/property",
77
+ "test:fuzz": "vitest run test/fuzz",
78
+ "format": "biome format --write .",
79
+ "format:check": "biome format .",
80
+ "lint": "biome lint .",
81
+ "check": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm test",
82
+ "test:all": "npm run check && npm run test:property && npm run test:fuzz && npm run test:integration",
83
+ "redis:up": "docker compose up -d valkey",
84
+ "redis:down": "docker compose down",
85
+ "redis:logs": "docker compose logs -f valkey",
86
+ "test:integration:redis": "node scripts/test-redis.mjs",
87
+ "node:check": "node scripts/check-node-version.mjs",
88
+ "precheck": "npm run node:check",
89
+ "postgres:up": "docker compose up -d --wait postgres",
90
+ "postgres:down": "docker compose down",
91
+ "test:integration:postgres": "node scripts/test-postgres.mjs",
92
+ "redis:cluster:up": "docker compose -f compose.cluster.yaml up -d --wait",
93
+ "redis:cluster:logs": "docker compose -f compose.cluster.yaml logs -f valkey-cluster",
94
+ "redis:cluster:down": "docker compose -f compose.cluster.yaml down",
95
+ "test:integration:cluster": "node scripts/test-redis-cluster.mjs",
96
+ "bench": "npm run build && node scripts/bench.mjs",
97
+ "audit:bundle": "npm run build && node scripts/audit-bundle.mjs",
98
+ "changeset": "changeset",
99
+ "version": "changeset version"
100
+ },
101
+ "devDependencies": {
102
+ "@biomejs/biome": "^2.5.12",
103
+ "@changesets/cli": "^3.0.2",
104
+ "@types/node": "^22.0.0",
105
+ "@types/pg": "^8.23.1",
106
+ "fast-check": "^4.0.0",
107
+ "ioredis": "^5.11.1",
108
+ "pg": "^8.23.0",
109
+ "rimraf": "^6.0.0",
110
+ "tsup": "^8.0.0",
111
+ "tsx": "^4.23.13",
112
+ "typescript": "^5.0.0",
113
+ "vitest": "^3.0.0"
114
+ },
115
+ "peerDependencies": {
116
+ "ioredis": "^5.0.0",
117
+ "pg": ">=8.11 <9"
118
+ },
119
+ "peerDependenciesMeta": {
120
+ "pg": {
121
+ "optional": true
122
+ },
123
+ "ioredis": {
124
+ "optional": true
125
+ }
126
+ }
127
+ }
@@ -0,0 +1,122 @@
1
+ import type {
2
+ Adapter,
3
+ Classification,
4
+ ExecutionContext,
5
+ OperationCapabilities,
6
+ Outcome,
7
+ } from "../../core/types.js"
8
+
9
+ export type FetchOperationArgs = Readonly<{
10
+ url: RequestInfo | URL
11
+ options?: RequestInit
12
+ }>
13
+
14
+ export type FetchReplay =
15
+ | OperationCapabilities["replay"]
16
+ | ((args: FetchOperationArgs) => OperationCapabilities["replay"])
17
+
18
+ export interface FetchAdapterOptions {
19
+ readonly fetch?: typeof globalThis.fetch
20
+ readonly replay?: FetchReplay
21
+ readonly classifyResponse?: (response: Response) => Classification
22
+ readonly classifyError?: (error: unknown) => Classification
23
+ }
24
+
25
+ function requestMethod(args: FetchOperationArgs): string {
26
+ if (args.options?.method !== undefined) {
27
+ return args.options.method.toUpperCase()
28
+ }
29
+
30
+ if (args.url instanceof Request) {
31
+ return args.url.method.toUpperCase()
32
+ }
33
+
34
+ return "GET"
35
+ }
36
+
37
+ function defaultReplay(
38
+ args: FetchOperationArgs,
39
+ ): OperationCapabilities["replay"] {
40
+ const method = requestMethod(args)
41
+ if (method === "GET" || method === "HEAD") {
42
+ return "safe"
43
+ }
44
+
45
+ if (method === "POST" || method === "PATCH") {
46
+ return "unsafe"
47
+ }
48
+
49
+ return "unknown"
50
+ }
51
+
52
+ function defaultResponseClassification(response: Response): Classification {
53
+ return response.status >= 500 ||
54
+ response.status === 408 ||
55
+ response.status === 429
56
+ ? "retryable"
57
+ : "success"
58
+ }
59
+
60
+ function urlSignal(url: RequestInfo | URL): AbortSignal | undefined {
61
+ return url instanceof Request ? url.signal : undefined
62
+ }
63
+
64
+ function combinedSignal(
65
+ first: AbortSignal | undefined,
66
+ second: AbortSignal | undefined,
67
+ third: AbortSignal | undefined,
68
+ ): AbortSignal | undefined {
69
+ const signals = [first, second, third].filter(
70
+ (signal): signal is AbortSignal => signal !== undefined,
71
+ )
72
+ if (signals.length === 0) {
73
+ return undefined
74
+ }
75
+
76
+ return signals.length === 1 ? signals[0] : AbortSignal.any(signals)
77
+ }
78
+
79
+ /**
80
+ * Creates a fetch adapter with conservative per-request replay traits.
81
+ * It performs no implicit retry or timeout; those remain operation policies.
82
+ */
83
+ export function fetchAdapter(
84
+ options: FetchAdapterOptions = {},
85
+ ): Adapter<FetchOperationArgs, Response> {
86
+ const fetchImplementation = options.fetch ?? globalThis.fetch
87
+ if (fetchImplementation === undefined) {
88
+ throw new Error("fetch is not available; provide FetchAdapterOptions.fetch")
89
+ }
90
+
91
+ return Object.freeze({
92
+ capabilities(args: FetchOperationArgs): OperationCapabilities {
93
+ const configured = options.replay
94
+ const replay =
95
+ typeof configured === "function"
96
+ ? configured(args)
97
+ : (configured ?? defaultReplay(args))
98
+ return { abort: "supported", replay }
99
+ },
100
+ async execute(
101
+ args: FetchOperationArgs,
102
+ context: ExecutionContext,
103
+ ): Promise<Response> {
104
+ const signal = combinedSignal(
105
+ urlSignal(args.url),
106
+ args.options?.signal ?? undefined,
107
+ context.signal,
108
+ )
109
+ return fetchImplementation(args.url, { ...args.options, signal })
110
+ },
111
+ classify(outcome: Outcome<Response>): Classification {
112
+ if (outcome.status === "failure") {
113
+ return options.classifyError?.(outcome.error) ?? "retryable"
114
+ }
115
+
116
+ return (
117
+ options.classifyResponse?.(outcome.value) ??
118
+ defaultResponseClassification(outcome.value)
119
+ )
120
+ },
121
+ })
122
+ }
@@ -0,0 +1,15 @@
1
+ export type {
2
+ FetchAdapterOptions,
3
+ FetchOperationArgs,
4
+ FetchReplay,
5
+ } from "./adapter.js"
6
+ export { fetchAdapter } from "./adapter.js"
7
+ export {
8
+ createRetryAfterDelay,
9
+ retryAfterDelay,
10
+ retryAfterMs,
11
+ } from "./retry-after.js"
12
+ export type {
13
+ RetryAfterDelay,
14
+ RetryAfterDelayOptions,
15
+ } from "./retry-after.js"