clutchit 0.0.3 → 0.0.5
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/README.md +530 -0
- package/dist/circuit/circuit-breaker.d.ts +13 -9
- package/dist/circuit/circuit-breaker.js +6 -11
- package/dist/circuit/index.d.ts +1 -1
- package/dist/circuit/index.js +1 -0
- package/dist/concurrency/bulkhead.d.ts +10 -9
- package/dist/concurrency/bulkhead.js +4 -8
- package/dist/concurrency/index.d.ts +2 -2
- package/dist/concurrency/index.js +2 -0
- package/dist/concurrency/rate-limiter.d.ts +13 -9
- package/dist/concurrency/rate-limiter.js +4 -10
- package/dist/queue/base.queue.js +1 -1
- package/dist/queue/bounded.queue.d.ts +2 -2
- package/dist/queue/bounded.queue.js +2 -2
- package/dist/queue/faults.d.ts +22 -18
- package/dist/queue/faults.js +6 -15
- package/dist/queue/index.d.ts +1 -1
- package/dist/queue/index.js +1 -0
- package/dist/schedule/index.d.ts +3 -0
- package/dist/schedule/index.js +1 -0
- package/dist/schedule/runner.d.ts +10 -9
- package/dist/schedule/runner.js +5 -9
- package/dist/schedule/schedule.d.ts +4 -0
- package/dist/schedule/schedule.js +12 -0
- package/dist/timeout/index.d.ts +3 -11
- package/dist/timeout/index.js +1 -0
- package/dist/timeout/timeout.d.ts +13 -9
- package/dist/timeout/timeout.js +4 -10
- package/dist/unthrow/fault.d.ts +9 -24
- package/dist/unthrow/fault.js +18 -13
- package/dist/unthrow/index.d.ts +5 -6
- package/dist/unthrow/index.js +5 -6
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
# clutchit
|
|
2
|
+
|
|
3
|
+
Resilience primitives for TypeScript — retry, circuit breaker, timeout, rate limiting, and more with typed errors.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/clutchit)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install clutchit
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick Example
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { ResultAsync, Fault } from 'clutchit/unthrow';
|
|
18
|
+
import { Retry } from 'clutchit/retry';
|
|
19
|
+
import { Timeout } from 'clutchit/timeout';
|
|
20
|
+
import { Schedule } from 'clutchit/schedule';
|
|
21
|
+
|
|
22
|
+
class ApiFault extends Fault.Tagged('API_ERROR', 'infrastructure', true)<{
|
|
23
|
+
status: number;
|
|
24
|
+
}>() {
|
|
25
|
+
readonly message = `API returned ${this.status}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fetchUser(id: string, signal: AbortSignal) {
|
|
29
|
+
return ResultAsync.fromPromise(
|
|
30
|
+
fetch(`/api/users/${id}`, { signal }).then((r) => r.json()),
|
|
31
|
+
(err) => new ApiFault({ status: 500 }),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const retry = new Retry.Policy({
|
|
36
|
+
schedule: Schedule.exponential(200).pipe(Schedule.recurs(3)),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const result = Timeout.wrap(
|
|
40
|
+
(signal) => retry.execute((s) => fetchUser('123', s)),
|
|
41
|
+
5000,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
await result.match({
|
|
45
|
+
ok: (user) => console.log(user),
|
|
46
|
+
err: (fault) => console.error(fault.code, fault.message),
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Modules
|
|
51
|
+
|
|
52
|
+
| Import | Description |
|
|
53
|
+
|--------|-------------|
|
|
54
|
+
| `clutchit/unthrow` | `Result<T, E>`, `ResultAsync<T, E>`, `Fault`, Do notation |
|
|
55
|
+
| `clutchit/schedule` | Composable timing schedules (exponential, linear, fibonacci, cron, ...) |
|
|
56
|
+
| `clutchit/retry` | Retry policies with configurable backoff and cancellation |
|
|
57
|
+
| `clutchit/timeout` | Timeout wrappers with `AbortSignal` propagation |
|
|
58
|
+
| `clutchit/concurrency` | Semaphore, RateLimiter, Bulkhead, Ref |
|
|
59
|
+
| `clutchit/circuit` | Circuit breaker with progressive reset |
|
|
60
|
+
| `clutchit/queue` | Bounded, Dropping, and Sliding queues with backpressure |
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## `clutchit/unthrow`
|
|
65
|
+
|
|
66
|
+
Typed error handling without exceptions. `Result<T, E>` for sync, `ResultAsync<T, E>` for async.
|
|
67
|
+
|
|
68
|
+
### Result
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import { Result } from 'clutchit/unthrow';
|
|
72
|
+
|
|
73
|
+
const ok = Result.ok(42); // Ok<number, never>
|
|
74
|
+
const fail = Result.err('oops'); // Err<never, string>
|
|
75
|
+
|
|
76
|
+
ok.map((n) => n * 2); // Ok(84)
|
|
77
|
+
ok.andThen((n) => Result.ok(n)); // Ok(42)
|
|
78
|
+
ok.unwrapOr(0); // 42
|
|
79
|
+
fail.unwrapOr(0); // 0
|
|
80
|
+
|
|
81
|
+
ok.match({
|
|
82
|
+
ok: (value) => `got ${value}`,
|
|
83
|
+
err: (error) => `failed: ${error}`,
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Methods: `map`, `mapErr`, `andThen`, `orElse`, `unwrapOr`, `match`, `isOk`, `isErr`
|
|
88
|
+
|
|
89
|
+
### ResultAsync
|
|
90
|
+
|
|
91
|
+
Wraps `Promise<Result<T, E>>` with the same chainable methods. Awaitable via `PromiseLike`.
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import { ResultAsync } from 'clutchit/unthrow';
|
|
95
|
+
|
|
96
|
+
const result = ResultAsync.fromPromise(
|
|
97
|
+
fetch('/api/data').then((r) => r.json()),
|
|
98
|
+
(err) => ({ code: 'FETCH_FAILED', message: String(err) }),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const final = result
|
|
102
|
+
.map((data) => data.items)
|
|
103
|
+
.andThen((items) => validateItems(items))
|
|
104
|
+
.mapErr((err) => normalize(err));
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Helpers
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { Result, ResultAsync } from 'clutchit/unthrow';
|
|
111
|
+
|
|
112
|
+
// Wrap a throwable sync function
|
|
113
|
+
Result.fromThrowable(() => JSON.parse(raw), (err) => ParseFault(String(err)));
|
|
114
|
+
|
|
115
|
+
// Combine results — fail on first error
|
|
116
|
+
Result.combine([resultA, resultB, resultC]); // Result<[A, B, C], E>
|
|
117
|
+
ResultAsync.combine([asyncA, asyncB]); // ResultAsync<[A, B], E>
|
|
118
|
+
|
|
119
|
+
// Combine results — accumulate all errors
|
|
120
|
+
Result.combineWithAllErrors([resultA, resultB]); // Result<[A, B], E[]>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Do Notation
|
|
124
|
+
|
|
125
|
+
Generator-based monadic syntax. Yields unwrap `Ok` values; any `Err` short-circuits.
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import { Result, ResultAsync } from 'clutchit/unthrow';
|
|
129
|
+
|
|
130
|
+
// Sync
|
|
131
|
+
const result = Result.Do(function* () {
|
|
132
|
+
const user = yield* findUser(id);
|
|
133
|
+
const perms = yield* getPermissions(user);
|
|
134
|
+
return { user, perms };
|
|
135
|
+
});
|
|
136
|
+
// Result<{ user: User; perms: Permissions }, NotFoundFault | PermissionFault>
|
|
137
|
+
|
|
138
|
+
// Async
|
|
139
|
+
const result = ResultAsync.Do(async function* () {
|
|
140
|
+
const user = yield* findUser(id);
|
|
141
|
+
const order = yield* createOrder(user, items);
|
|
142
|
+
yield* sendConfirmation(order);
|
|
143
|
+
return order;
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## `clutchit/schedule`
|
|
150
|
+
|
|
151
|
+
Composable timing primitive. Determines delay and continuation for each attempt. Used internally by retry and circuit breaker — also useful standalone.
|
|
152
|
+
|
|
153
|
+
### Factories
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
import { Schedule } from 'clutchit/schedule';
|
|
157
|
+
|
|
158
|
+
Schedule.spaced(1000); // Fixed 1s delay
|
|
159
|
+
Schedule.exponential(200); // 200, 400, 800, 1600, ...
|
|
160
|
+
Schedule.exponential(200, 3); // 200, 600, 1800, ... (custom multiplier)
|
|
161
|
+
Schedule.linear(100); // 100, 200, 300, 400, ...
|
|
162
|
+
Schedule.fibonacci(100); // 100, 100, 200, 300, 500, ...
|
|
163
|
+
Schedule.constant(5000); // Alias for spaced
|
|
164
|
+
Schedule.fixed(1000); // Fixed 1s intervals (compensates for execution time)
|
|
165
|
+
Schedule.windowed(60_000); // Aligned to wall-clock minute boundaries
|
|
166
|
+
Schedule.cron('*/5 * * * *'); // Every 5 minutes (cron expression)
|
|
167
|
+
Schedule.cron('0 4 * * MON-FRI', { timezone: 'UTC' }); // 4am weekdays UTC
|
|
168
|
+
Schedule.forever; // Zero-delay, never stops
|
|
169
|
+
Schedule.once; // Single attempt then stop
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Operators
|
|
173
|
+
|
|
174
|
+
Compose via `.pipe()`:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
const schedule = Schedule.exponential(200)
|
|
178
|
+
.pipe(Schedule.recurs(5)) // Max 5 attempts
|
|
179
|
+
.pipe(Schedule.cappedDelay(10_000)) // Cap at 10s
|
|
180
|
+
.pipe(Schedule.jittered()) // +/- 25% random jitter
|
|
181
|
+
.pipe(Schedule.upTo(60_000)); // Stop after 60s total
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
| Operator | Description |
|
|
185
|
+
|----------|-------------|
|
|
186
|
+
| `recurs(n)` | Limit to `n` total attempts |
|
|
187
|
+
| `cappedDelay(ms)` | Cap individual delay |
|
|
188
|
+
| `jittered(factor?)` | Random jitter (default +/- 25%) |
|
|
189
|
+
| `upTo(ms)` | Stop after total elapsed time |
|
|
190
|
+
| `andThen(schedule)` | Run first schedule, then second |
|
|
191
|
+
| `union(schedule)` | Shorter delay wins, either continues |
|
|
192
|
+
| `intersect(schedule)` | Longer delay wins, both must continue |
|
|
193
|
+
| `whileInput(pred)` | Continue while input matches |
|
|
194
|
+
| `whileOutput(pred)` | Continue while output matches |
|
|
195
|
+
| `map(fn)` | Transform schedule output |
|
|
196
|
+
|
|
197
|
+
### Repeat
|
|
198
|
+
|
|
199
|
+
Execute a function repeatedly on a schedule:
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
const schedule = Schedule.spaced(5000).pipe(Schedule.recurs(10));
|
|
203
|
+
|
|
204
|
+
await Schedule.repeat(
|
|
205
|
+
schedule,
|
|
206
|
+
(signal) => pollForUpdates(signal),
|
|
207
|
+
{ signal: controller.signal },
|
|
208
|
+
);
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Returns `ScheduleInterruptedFault` if cancelled via signal.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## `clutchit/retry`
|
|
216
|
+
|
|
217
|
+
Retry policies with configurable backoff, error filtering, and cancellation.
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
import { Retry } from 'clutchit/retry';
|
|
221
|
+
import { Schedule } from 'clutchit/schedule';
|
|
222
|
+
|
|
223
|
+
const policy = new Retry.Policy({
|
|
224
|
+
schedule: Schedule.exponential(200).pipe(Schedule.recurs(3)),
|
|
225
|
+
retryOn: (error) => Fault.isTransient(error),
|
|
226
|
+
onRetry: (error, attempt, delay) => {
|
|
227
|
+
console.log(`Retry #${attempt} in ${delay}ms: ${error.code}`);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const result = policy.execute((signal) => fetchData(signal));
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### RetryOptions
|
|
235
|
+
|
|
236
|
+
| Option | Type | Default |
|
|
237
|
+
|--------|------|---------|
|
|
238
|
+
| `schedule` | `Schedule` | `exponential(200) \| recurs(3)` |
|
|
239
|
+
| `retryOn` | `(error: E) => boolean` | Retries transient errors (`_transient === true`) |
|
|
240
|
+
| `onRetry` | `(error, attempt, delay) => void` | — |
|
|
241
|
+
| `signal` | `AbortSignal` | — |
|
|
242
|
+
|
|
243
|
+
Per-call options override constructor defaults.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## `clutchit/timeout`
|
|
248
|
+
|
|
249
|
+
Wrap async operations with a timeout. Propagates cancellation via `AbortSignal`.
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
import { Timeout } from 'clutchit/timeout';
|
|
253
|
+
|
|
254
|
+
// Inline
|
|
255
|
+
const result = Timeout.wrap(
|
|
256
|
+
(signal) => fetchData(signal),
|
|
257
|
+
5000,
|
|
258
|
+
);
|
|
259
|
+
// ResultAsync<Data, FetchFault | TimeoutFault>
|
|
260
|
+
|
|
261
|
+
// Reusable wrapper
|
|
262
|
+
const withTimeout = Timeout.create(5000);
|
|
263
|
+
const result = withTimeout((signal) => fetchData(signal));
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`TimeoutFault` is `transient: true` — it will be retried by default when composed with `Retry.Policy`.
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## `clutchit/concurrency`
|
|
271
|
+
|
|
272
|
+
### Semaphore
|
|
273
|
+
|
|
274
|
+
Limit concurrent access to a resource:
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
import { Concurrency } from 'clutchit/concurrency';
|
|
278
|
+
|
|
279
|
+
const sem = new Concurrency.Semaphore({ permits: 3 });
|
|
280
|
+
|
|
281
|
+
const result = sem.execute(() => fetchData());
|
|
282
|
+
|
|
283
|
+
sem.available; // remaining permits
|
|
284
|
+
sem.waiting; // queued requests
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### RateLimiter
|
|
288
|
+
|
|
289
|
+
Sliding-window rate limiting:
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
const limiter = new Concurrency.RateLimiter({
|
|
293
|
+
limit: 100,
|
|
294
|
+
window: 60_000, // 100 requests per minute
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const result = limiter.execute(() => callApi());
|
|
298
|
+
// Err(RateLimitFault) if limit exceeded
|
|
299
|
+
|
|
300
|
+
limiter.remaining; // available capacity
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
`RateLimitFault` is `transient: true` with a `retryAfter` field.
|
|
304
|
+
|
|
305
|
+
### Bulkhead
|
|
306
|
+
|
|
307
|
+
Isolate failures by limiting concurrent executions with an optional queue:
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
const bulkhead = new Concurrency.Bulkhead({
|
|
311
|
+
concurrency: 5,
|
|
312
|
+
queue: 10, // default: 0
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const result = bulkhead.execute(() => processRequest());
|
|
316
|
+
// Err(BulkheadRejectedFault) if capacity + queue full
|
|
317
|
+
|
|
318
|
+
bulkhead.activeCount; // currently executing
|
|
319
|
+
bulkhead.queueSize; // waiting in queue
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Ref
|
|
323
|
+
|
|
324
|
+
Thread-safe mutable reference (atomic via internal mutex):
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
const counter = new Concurrency.Ref(0);
|
|
328
|
+
|
|
329
|
+
await counter.update((n) => n + 1);
|
|
330
|
+
await counter.set(0);
|
|
331
|
+
|
|
332
|
+
const prev = await counter.modify((n) => [n + 1, n]); // [newValue, result]
|
|
333
|
+
counter.get(); // current value (non-atomic read)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## `clutchit/circuit`
|
|
339
|
+
|
|
340
|
+
Circuit breaker with three states: **closed** (normal), **open** (rejecting), **half-open** (testing recovery).
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
import { Circuit } from 'clutchit/circuit';
|
|
344
|
+
import { Schedule } from 'clutchit/schedule';
|
|
345
|
+
|
|
346
|
+
const breaker = new Circuit.Breaker({
|
|
347
|
+
failureThreshold: 5,
|
|
348
|
+
resetSchedule: Schedule.exponential(30_000).pipe(Schedule.cappedDelay(300_000)),
|
|
349
|
+
halfOpenAttempts: 1,
|
|
350
|
+
isFailure: (error) => error.code !== 'NOT_FOUND',
|
|
351
|
+
onStateChange: (from, to) => console.log(`Circuit: ${from} -> ${to}`),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const result = breaker.protect(() => callService());
|
|
355
|
+
// Err(CircuitOpenFault) when circuit is open
|
|
356
|
+
|
|
357
|
+
breaker.state; // 'closed' | 'open' | 'half-open'
|
|
358
|
+
breaker.failureCount; // consecutive failures
|
|
359
|
+
breaker.reset(); // force-close
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### State Machine
|
|
363
|
+
|
|
364
|
+
```
|
|
365
|
+
CLOSED ──(failures >= threshold)──> OPEN
|
|
366
|
+
OPEN ──(reset delay expires)───> HALF-OPEN
|
|
367
|
+
HALF-OPEN ──(success)────────────> CLOSED
|
|
368
|
+
HALF-OPEN ──(failure)────────────> OPEN (progressive delay increase)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### CircuitBreakerOptions
|
|
372
|
+
|
|
373
|
+
| Option | Type | Default |
|
|
374
|
+
|--------|------|---------|
|
|
375
|
+
| `failureThreshold` | `number` | `5` |
|
|
376
|
+
| `resetSchedule` | `Schedule` | `constant(30_000)` |
|
|
377
|
+
| `halfOpenAttempts` | `number` | `1` |
|
|
378
|
+
| `isFailure` | `(error) => boolean` | All errors count |
|
|
379
|
+
| `onStateChange` | `(from, to) => void` | — |
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## `clutchit/queue`
|
|
384
|
+
|
|
385
|
+
In-memory bounded queues with backpressure. All blocking operations support `AbortSignal`.
|
|
386
|
+
|
|
387
|
+
### BoundedQueue
|
|
388
|
+
|
|
389
|
+
Both producers and consumers can block:
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
import { Queue } from 'clutchit/queue';
|
|
393
|
+
|
|
394
|
+
const q = new Queue.Bounded<Job>({ capacity: 100 });
|
|
395
|
+
|
|
396
|
+
q.offer(job); // Result<void, QueueFullFault> — non-blocking
|
|
397
|
+
await q.put(job); // blocks until space available
|
|
398
|
+
await q.put(job, signal); // cancellable
|
|
399
|
+
|
|
400
|
+
const item = await q.take(); // blocks until item available
|
|
401
|
+
const batch = await q.takeBatch(10); // at least 1, up to 10
|
|
402
|
+
const maybe = q.poll(); // Result<T, QueueEmptyFault> — non-blocking
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### DroppingQueue
|
|
406
|
+
|
|
407
|
+
Drops the **newest** item when full. Producer never blocks:
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
const q = new Queue.Dropping<Event>({ capacity: 1000 });
|
|
411
|
+
|
|
412
|
+
q.offer(event); // boolean — true if accepted, false if dropped
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### SlidingQueue
|
|
416
|
+
|
|
417
|
+
Drops the **oldest** item when full. Producer never blocks:
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
const q = new Queue.Sliding<Event>({ capacity: 1000 });
|
|
421
|
+
|
|
422
|
+
q.offer(event); // void — always accepts, evicts oldest if needed
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## Fault Model
|
|
428
|
+
|
|
429
|
+
Faults are classes created via `Fault.Tagged(code, category, transient?)`. Each class serves as both a value (constructor) and a type — no separate `type X = ReturnType<typeof X>` needed. Discriminated via `code` string literal and `instanceof`.
|
|
430
|
+
|
|
431
|
+
### Defining Faults
|
|
432
|
+
|
|
433
|
+
```typescript
|
|
434
|
+
import { Fault } from 'clutchit/unthrow';
|
|
435
|
+
|
|
436
|
+
// Domain fault with fields
|
|
437
|
+
class NotFoundFault extends Fault.Tagged('NOT_FOUND', 'domain')<{
|
|
438
|
+
resource: string;
|
|
439
|
+
id: string;
|
|
440
|
+
}>() {
|
|
441
|
+
readonly message = `${this.resource} "${this.id}" not found`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Infrastructure fault, no fields
|
|
445
|
+
class ServiceDownFault extends Fault.Tagged('SERVICE_DOWN', 'infrastructure')() {
|
|
446
|
+
readonly message = 'Service is unavailable';
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Transient fault (eligible for automatic retry)
|
|
450
|
+
class TimeoutFault extends Fault.Tagged('TIMEOUT', 'infrastructure', true)<{
|
|
451
|
+
duration: number;
|
|
452
|
+
}>() {
|
|
453
|
+
readonly message = `Operation timed out after ${this.duration}ms`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Usage
|
|
457
|
+
const fault = new NotFoundFault({ resource: 'User', id: '123' });
|
|
458
|
+
fault.code; // 'NOT_FOUND'
|
|
459
|
+
fault.resource; // 'User'
|
|
460
|
+
fault instanceof NotFoundFault; // true
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Categories
|
|
464
|
+
|
|
465
|
+
| Category | `_category` | `_transient` | Meaning |
|
|
466
|
+
|----------|------------|-------------|---------|
|
|
467
|
+
| Domain | `'domain'` | `false` | Business rule violation — not retryable |
|
|
468
|
+
| Infrastructure | `'infrastructure'` | `false` | System failure — not retryable |
|
|
469
|
+
| Transient | `'infrastructure'` | `true` | Temporary failure — safe to retry |
|
|
470
|
+
|
|
471
|
+
### Type Guards
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
Fault.isTransient(fault); // infrastructure + transient
|
|
475
|
+
Fault.isDomain(fault); // domain fault
|
|
476
|
+
Fault.isInfrastructure(fault); // infrastructure (transient or not)
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### Built-in Faults
|
|
480
|
+
|
|
481
|
+
| Fault | Code | Transient | Module |
|
|
482
|
+
|-------|------|-----------|--------|
|
|
483
|
+
| `TimeoutFault` | `TIMEOUT` | yes | `clutchit/timeout` |
|
|
484
|
+
| `RateLimitFault` | `RATE_LIMITED` | yes | `clutchit/concurrency` |
|
|
485
|
+
| `CircuitOpenFault` | `CIRCUIT_OPEN` | no | `clutchit/circuit` |
|
|
486
|
+
| `BulkheadRejectedFault` | `BULKHEAD_REJECTED` | no | `clutchit/concurrency` |
|
|
487
|
+
| `ScheduleInterruptedFault` | `SCHEDULE_INTERRUPTED` | no | `clutchit/schedule` |
|
|
488
|
+
| `QueueFullFault` | `QUEUE_FULL` | no | `clutchit/queue` |
|
|
489
|
+
| `QueueEmptyFault` | `QUEUE_EMPTY` | no | `clutchit/queue` |
|
|
490
|
+
|
|
491
|
+
---
|
|
492
|
+
|
|
493
|
+
## Composing Primitives
|
|
494
|
+
|
|
495
|
+
Retry, timeout, and circuit breaker compose naturally:
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
import { Retry } from 'clutchit/retry';
|
|
499
|
+
import { Timeout } from 'clutchit/timeout';
|
|
500
|
+
import { Circuit } from 'clutchit/circuit';
|
|
501
|
+
import { Schedule } from 'clutchit/schedule';
|
|
502
|
+
|
|
503
|
+
const retry = new Retry.Policy({
|
|
504
|
+
schedule: Schedule.exponential(200).pipe(
|
|
505
|
+
Schedule.recurs(3),
|
|
506
|
+
Schedule.cappedDelay(5000),
|
|
507
|
+
Schedule.jittered(),
|
|
508
|
+
),
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const breaker = new Circuit.Breaker({
|
|
512
|
+
failureThreshold: 5,
|
|
513
|
+
resetSchedule: Schedule.exponential(30_000),
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
function callService() {
|
|
517
|
+
return breaker.protect(() =>
|
|
518
|
+
Timeout.wrap(
|
|
519
|
+
(signal) => retry.execute(
|
|
520
|
+
(signal) => doRequest(signal),
|
|
521
|
+
),
|
|
522
|
+
10_000,
|
|
523
|
+
),
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
## License
|
|
529
|
+
|
|
530
|
+
[MIT](./LICENSE)
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { ResultAsync } from '../unthrow/index.js';
|
|
2
2
|
import { Schedule } from '../schedule/index.js';
|
|
3
3
|
export type CircuitState = 'closed' | 'open' | 'half-open';
|
|
4
|
-
|
|
5
|
-
code: "CIRCUIT_OPEN";
|
|
6
|
-
_category: "infrastructure";
|
|
7
|
-
_transient: false;
|
|
8
|
-
message: string;
|
|
9
|
-
} & Omit<{
|
|
10
|
-
message: string;
|
|
4
|
+
declare const CircuitOpenFault_base: abstract new (fields: {
|
|
11
5
|
remainingTimeout: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
6
|
+
}) => Readonly<{
|
|
7
|
+
remainingTimeout: number;
|
|
8
|
+
}> & {
|
|
9
|
+
readonly code: "CIRCUIT_OPEN";
|
|
10
|
+
readonly _category: "infrastructure";
|
|
11
|
+
readonly _transient: false;
|
|
12
|
+
readonly message: string;
|
|
13
|
+
};
|
|
14
|
+
export declare class CircuitOpenFault extends CircuitOpenFault_base {
|
|
15
|
+
readonly message: string;
|
|
16
|
+
}
|
|
14
17
|
export interface CircuitBreakerOptions {
|
|
15
18
|
/** Number of consecutive failures before opening. Default: 5 */
|
|
16
19
|
failureThreshold?: number;
|
|
@@ -44,4 +47,5 @@ export declare class CircuitBreaker {
|
|
|
44
47
|
private scheduleReset;
|
|
45
48
|
private transition;
|
|
46
49
|
}
|
|
50
|
+
export {};
|
|
47
51
|
//# sourceMappingURL=circuit-breaker.d.ts.map
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { Fault, ResultAsync } from '../unthrow/index.js';
|
|
2
2
|
import { Schedule } from '../schedule/index.js';
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
create: (remainingTimeout) => ({
|
|
7
|
-
message: `Circuit breaker is open. Retry after ${remainingTimeout}ms`,
|
|
8
|
-
remainingTimeout,
|
|
9
|
-
}),
|
|
10
|
-
});
|
|
3
|
+
export class CircuitOpenFault extends Fault.Tagged('CIRCUIT_OPEN', 'infrastructure')() {
|
|
4
|
+
message = `Circuit breaker is open. Retry after ${this.remainingTimeout}ms`;
|
|
5
|
+
}
|
|
11
6
|
export class CircuitBreaker {
|
|
12
7
|
_state = 'closed';
|
|
13
8
|
_failureCount = 0;
|
|
@@ -33,15 +28,15 @@ export class CircuitBreaker {
|
|
|
33
28
|
this._halfOpenTrials = 0;
|
|
34
29
|
}
|
|
35
30
|
else {
|
|
36
|
-
const
|
|
31
|
+
const remainingTimeout = this._nextResetTime
|
|
37
32
|
? Math.max(0, this._nextResetTime - Date.now())
|
|
38
33
|
: 0;
|
|
39
|
-
return ResultAsync.err(CircuitOpenFault(
|
|
34
|
+
return ResultAsync.err(new CircuitOpenFault({ remainingTimeout }));
|
|
40
35
|
}
|
|
41
36
|
}
|
|
42
37
|
if (this._state === 'half-open') {
|
|
43
38
|
if (this._halfOpenTrials >= this._maxHalfOpenAttempts) {
|
|
44
|
-
return ResultAsync.err(CircuitOpenFault(0));
|
|
39
|
+
return ResultAsync.err(new CircuitOpenFault({ remainingTimeout: 0 }));
|
|
45
40
|
}
|
|
46
41
|
this._halfOpenTrials++;
|
|
47
42
|
}
|
package/dist/circuit/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CircuitBreaker as _CircuitBreaker } from './circuit-breaker.js';
|
|
2
2
|
export type { CircuitState, CircuitBreakerOptions } from './circuit-breaker.js';
|
|
3
|
-
export
|
|
3
|
+
export { CircuitOpenFault } from './circuit-breaker.js';
|
|
4
4
|
export declare namespace Circuit {
|
|
5
5
|
const Breaker: typeof _CircuitBreaker;
|
|
6
6
|
}
|
package/dist/circuit/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { ResultAsync } from '../unthrow/index.js';
|
|
2
|
-
|
|
3
|
-
code: "BULKHEAD_REJECTED";
|
|
4
|
-
_category: "infrastructure";
|
|
5
|
-
_transient: false;
|
|
6
|
-
message: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
declare const BulkheadRejectedFault_base: abstract new () => Readonly<Record<string, unknown>> & {
|
|
3
|
+
readonly code: "BULKHEAD_REJECTED";
|
|
4
|
+
readonly _category: "infrastructure";
|
|
5
|
+
readonly _transient: false;
|
|
6
|
+
readonly message: string;
|
|
7
|
+
};
|
|
8
|
+
export declare class BulkheadRejectedFault extends BulkheadRejectedFault_base {
|
|
9
|
+
readonly message = "Bulkhead capacity exceeded";
|
|
10
|
+
}
|
|
11
11
|
export interface BulkheadOptions {
|
|
12
12
|
concurrency: number;
|
|
13
13
|
queue?: number;
|
|
@@ -24,4 +24,5 @@ export declare class Bulkhead {
|
|
|
24
24
|
private acquire;
|
|
25
25
|
private release;
|
|
26
26
|
}
|
|
27
|
+
export {};
|
|
27
28
|
//# sourceMappingURL=bulkhead.d.ts.map
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { Fault, ResultAsync } from '../unthrow/index.js';
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
create: () => ({
|
|
6
|
-
message: 'Bulkhead capacity exceeded',
|
|
7
|
-
}),
|
|
8
|
-
});
|
|
2
|
+
export class BulkheadRejectedFault extends Fault.Tagged('BULKHEAD_REJECTED', 'infrastructure')() {
|
|
3
|
+
message = 'Bulkhead capacity exceeded';
|
|
4
|
+
}
|
|
9
5
|
export class Bulkhead {
|
|
10
6
|
_active = 0;
|
|
11
7
|
_maxConcurrency;
|
|
@@ -18,7 +14,7 @@ export class Bulkhead {
|
|
|
18
14
|
execute(fn) {
|
|
19
15
|
if (this._active >= this._maxConcurrency &&
|
|
20
16
|
this._queue.length >= this._maxQueue) {
|
|
21
|
-
return ResultAsync.err(BulkheadRejectedFault());
|
|
17
|
+
return ResultAsync.err(new BulkheadRejectedFault());
|
|
22
18
|
}
|
|
23
19
|
return ResultAsync.fromPromise((async () => {
|
|
24
20
|
await this.acquire();
|
|
@@ -4,9 +4,9 @@ import { Bulkhead as _Bulkhead } from './bulkhead.js';
|
|
|
4
4
|
import { Ref as _Ref } from './ref.js';
|
|
5
5
|
export type { SemaphoreOptions } from './semaphore.js';
|
|
6
6
|
export type { RateLimiterOptions } from './rate-limiter.js';
|
|
7
|
-
export
|
|
7
|
+
export { RateLimitFault } from './rate-limiter.js';
|
|
8
8
|
export type { BulkheadOptions } from './bulkhead.js';
|
|
9
|
-
export
|
|
9
|
+
export { BulkheadRejectedFault } from './bulkhead.js';
|
|
10
10
|
export declare namespace Concurrency {
|
|
11
11
|
const Semaphore: typeof _Semaphore;
|
|
12
12
|
const RateLimiter: typeof _RateLimiter;
|
|
@@ -2,6 +2,8 @@ import { Semaphore as _Semaphore } from './semaphore.js';
|
|
|
2
2
|
import { RateLimiter as _RateLimiter } from './rate-limiter.js';
|
|
3
3
|
import { Bulkhead as _Bulkhead } from './bulkhead.js';
|
|
4
4
|
import { Ref as _Ref } from './ref.js';
|
|
5
|
+
export { RateLimitFault } from './rate-limiter.js';
|
|
6
|
+
export { BulkheadRejectedFault } from './bulkhead.js';
|
|
5
7
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
6
8
|
export var Concurrency;
|
|
7
9
|
(function (Concurrency) {
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { ResultAsync } from '../unthrow/index.js';
|
|
2
|
-
|
|
3
|
-
code: "RATE_LIMITED";
|
|
4
|
-
_category: "infrastructure";
|
|
5
|
-
_transient: true;
|
|
6
|
-
message: string;
|
|
7
|
-
} & Omit<{
|
|
8
|
-
message: string;
|
|
2
|
+
declare const RateLimitFault_base: abstract new (fields: {
|
|
9
3
|
retryAfter: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
4
|
+
}) => Readonly<{
|
|
5
|
+
retryAfter: number;
|
|
6
|
+
}> & {
|
|
7
|
+
readonly code: "RATE_LIMITED";
|
|
8
|
+
readonly _category: "infrastructure";
|
|
9
|
+
readonly _transient: true;
|
|
10
|
+
readonly message: string;
|
|
11
|
+
};
|
|
12
|
+
export declare class RateLimitFault extends RateLimitFault_base {
|
|
13
|
+
readonly message: string;
|
|
14
|
+
}
|
|
12
15
|
export interface RateLimiterOptions {
|
|
13
16
|
limit: number;
|
|
14
17
|
window: number;
|
|
@@ -22,4 +25,5 @@ export declare class RateLimiter {
|
|
|
22
25
|
get remaining(): number;
|
|
23
26
|
private cleanup;
|
|
24
27
|
}
|
|
28
|
+
export {};
|
|
25
29
|
//# sourceMappingURL=rate-limiter.d.ts.map
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { Fault, ResultAsync } from '../unthrow/index.js';
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
transient: true,
|
|
6
|
-
create: (retryAfter) => ({
|
|
7
|
-
message: `Rate limit exceeded. Retry after ${retryAfter}ms`,
|
|
8
|
-
retryAfter,
|
|
9
|
-
}),
|
|
10
|
-
});
|
|
2
|
+
export class RateLimitFault extends Fault.Tagged('RATE_LIMITED', 'infrastructure', true)() {
|
|
3
|
+
message = `Rate limit exceeded. Retry after ${this.retryAfter}ms`;
|
|
4
|
+
}
|
|
11
5
|
export class RateLimiter {
|
|
12
6
|
_limit;
|
|
13
7
|
_window;
|
|
@@ -21,7 +15,7 @@ export class RateLimiter {
|
|
|
21
15
|
if (this._timestamps.length >= this._limit) {
|
|
22
16
|
const oldest = this._timestamps[0];
|
|
23
17
|
const retryAfter = Math.max(0, oldest + this._window - Date.now());
|
|
24
|
-
return ResultAsync.err(RateLimitFault(retryAfter));
|
|
18
|
+
return ResultAsync.err(new RateLimitFault({ retryAfter }));
|
|
25
19
|
}
|
|
26
20
|
this._timestamps.push(Date.now());
|
|
27
21
|
return fn();
|
package/dist/queue/base.queue.js
CHANGED
|
@@ -4,8 +4,8 @@ import { BaseQueue, type QueueOptions } from './base.queue.js';
|
|
|
4
4
|
export declare class BoundedQueue<T> extends BaseQueue<T> {
|
|
5
5
|
private readonly _putWaiters;
|
|
6
6
|
constructor(options: QueueOptions);
|
|
7
|
-
protected _tryTake(): Result<T,
|
|
8
|
-
offer(item: T): Result<void,
|
|
7
|
+
protected _tryTake(): Result<T, QueueEmptyFault>;
|
|
8
|
+
offer(item: T): Result<void, QueueFullFault>;
|
|
9
9
|
put(item: T, signal?: AbortSignal): ResultAsync<void, never>;
|
|
10
10
|
}
|
|
11
11
|
//# sourceMappingURL=bounded.queue.d.ts.map
|
|
@@ -23,7 +23,7 @@ export class BoundedQueue extends BaseQueue {
|
|
|
23
23
|
pw.resolve();
|
|
24
24
|
return Result.ok(pw.item);
|
|
25
25
|
}
|
|
26
|
-
return Result.err(QueueEmptyFault());
|
|
26
|
+
return Result.err(new QueueEmptyFault());
|
|
27
27
|
}
|
|
28
28
|
offer(item) {
|
|
29
29
|
// Give directly to a waiting consumer
|
|
@@ -33,7 +33,7 @@ export class BoundedQueue extends BaseQueue {
|
|
|
33
33
|
return Result.ok(undefined);
|
|
34
34
|
}
|
|
35
35
|
if (this._buffer.length >= this._capacity) {
|
|
36
|
-
return Result.err(QueueFullFault(this._capacity));
|
|
36
|
+
return Result.err(new QueueFullFault({ capacity: this._capacity }));
|
|
37
37
|
}
|
|
38
38
|
this._buffer.push(item);
|
|
39
39
|
return Result.ok(undefined);
|
package/dist/queue/faults.d.ts
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
code: "QUEUE_FULL";
|
|
3
|
-
_category: "infrastructure";
|
|
4
|
-
_transient: false;
|
|
5
|
-
message: string;
|
|
6
|
-
} & Omit<{
|
|
7
|
-
message: string;
|
|
1
|
+
declare const QueueFullFault_base: abstract new (fields: {
|
|
8
2
|
capacity: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
code: "
|
|
13
|
-
_category: "infrastructure";
|
|
14
|
-
_transient: false;
|
|
15
|
-
message: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
3
|
+
}) => Readonly<{
|
|
4
|
+
capacity: number;
|
|
5
|
+
}> & {
|
|
6
|
+
readonly code: "QUEUE_FULL";
|
|
7
|
+
readonly _category: "infrastructure";
|
|
8
|
+
readonly _transient: false;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
};
|
|
11
|
+
export declare class QueueFullFault extends QueueFullFault_base {
|
|
12
|
+
readonly message: string;
|
|
13
|
+
}
|
|
14
|
+
declare const QueueEmptyFault_base: abstract new () => Readonly<Record<string, unknown>> & {
|
|
15
|
+
readonly code: "QUEUE_EMPTY";
|
|
16
|
+
readonly _category: "infrastructure";
|
|
17
|
+
readonly _transient: false;
|
|
18
|
+
readonly message: string;
|
|
19
|
+
};
|
|
20
|
+
export declare class QueueEmptyFault extends QueueEmptyFault_base {
|
|
21
|
+
readonly message = "Queue is empty";
|
|
22
|
+
}
|
|
23
|
+
export {};
|
|
20
24
|
//# sourceMappingURL=faults.d.ts.map
|
package/dist/queue/faults.js
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
import { Fault } from '../unthrow/index.js';
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}),
|
|
9
|
-
});
|
|
10
|
-
export const QueueEmptyFault = Fault.define({
|
|
11
|
-
code: 'QUEUE_EMPTY',
|
|
12
|
-
category: 'infrastructure',
|
|
13
|
-
create: () => ({
|
|
14
|
-
message: 'Queue is empty',
|
|
15
|
-
}),
|
|
16
|
-
});
|
|
2
|
+
export class QueueFullFault extends Fault.Tagged('QUEUE_FULL', 'infrastructure')() {
|
|
3
|
+
message = `Queue is full (capacity: ${this.capacity})`;
|
|
4
|
+
}
|
|
5
|
+
export class QueueEmptyFault extends Fault.Tagged('QUEUE_EMPTY', 'infrastructure')() {
|
|
6
|
+
message = 'Queue is empty';
|
|
7
|
+
}
|
|
17
8
|
//# sourceMappingURL=faults.js.map
|
package/dist/queue/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { BoundedQueue as _BoundedQueue } from './bounded.queue.js';
|
|
|
2
2
|
import { DroppingQueue as _DroppingQueue } from './dropping.queue.js';
|
|
3
3
|
import { SlidingQueue as _SlidingQueue } from './sliding.queue.js';
|
|
4
4
|
export type { QueueOptions } from './base.queue.js';
|
|
5
|
-
export
|
|
5
|
+
export { QueueFullFault, QueueEmptyFault } from './faults.js';
|
|
6
6
|
export declare namespace Queue {
|
|
7
7
|
const Bounded: typeof _BoundedQueue;
|
|
8
8
|
const Dropping: typeof _DroppingQueue;
|
package/dist/queue/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BoundedQueue as _BoundedQueue } from './bounded.queue.js';
|
|
2
2
|
import { DroppingQueue as _DroppingQueue } from './dropping.queue.js';
|
|
3
3
|
import { SlidingQueue as _SlidingQueue } from './sliding.queue.js';
|
|
4
|
+
export { QueueFullFault, QueueEmptyFault } from './faults.js';
|
|
4
5
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
5
6
|
export var Queue;
|
|
6
7
|
(function (Queue) {
|
package/dist/schedule/index.d.ts
CHANGED
|
@@ -15,6 +15,9 @@ export declare namespace Schedule {
|
|
|
15
15
|
const forever: ScheduleClass<unknown, number>;
|
|
16
16
|
const once: ScheduleClass<unknown, number>;
|
|
17
17
|
const fibonacci: (one: number) => ScheduleClass;
|
|
18
|
+
const cron: (expression: string, options?: {
|
|
19
|
+
timezone?: string;
|
|
20
|
+
}) => ScheduleClass;
|
|
18
21
|
const recurs: typeof _recurs;
|
|
19
22
|
const cappedDelay: typeof _cappedDelay;
|
|
20
23
|
const jittered: typeof _jittered;
|
package/dist/schedule/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export var Schedule;
|
|
|
14
14
|
Schedule.forever = ScheduleClass.forever;
|
|
15
15
|
Schedule.once = ScheduleClass.once;
|
|
16
16
|
Schedule.fibonacci = ScheduleClass.fibonacci;
|
|
17
|
+
Schedule.cron = ScheduleClass.cron;
|
|
17
18
|
Schedule.recurs = _recurs;
|
|
18
19
|
Schedule.cappedDelay = _cappedDelay;
|
|
19
20
|
Schedule.jittered = _jittered;
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { ResultAsync } from '../unthrow/index.js';
|
|
2
2
|
import type { Schedule } from './schedule.js';
|
|
3
|
-
|
|
4
|
-
code: "SCHEDULE_INTERRUPTED";
|
|
5
|
-
_category: "infrastructure";
|
|
6
|
-
_transient: false;
|
|
7
|
-
message: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
declare const ScheduleInterruptedFault_base: abstract new () => Readonly<Record<string, unknown>> & {
|
|
4
|
+
readonly code: "SCHEDULE_INTERRUPTED";
|
|
5
|
+
readonly _category: "infrastructure";
|
|
6
|
+
readonly _transient: false;
|
|
7
|
+
readonly message: string;
|
|
8
|
+
};
|
|
9
|
+
export declare class ScheduleInterruptedFault extends ScheduleInterruptedFault_base {
|
|
10
|
+
readonly message = "Scheduled execution was interrupted";
|
|
11
|
+
}
|
|
12
12
|
export interface RepeatOptions {
|
|
13
13
|
/** External cancellation signal. Aborts the loop when signalled. */
|
|
14
14
|
signal?: AbortSignal;
|
|
@@ -27,4 +27,5 @@ export interface RepeatOptions {
|
|
|
27
27
|
* Returns the last schedule output on completion.
|
|
28
28
|
*/
|
|
29
29
|
export declare function repeat<Out, E>(schedule: Schedule<unknown, Out>, fn: (signal: AbortSignal) => ResultAsync<void, E>, options?: RepeatOptions): ResultAsync<Out, E | ScheduleInterruptedFault>;
|
|
30
|
+
export {};
|
|
30
31
|
//# sourceMappingURL=runner.d.ts.map
|
package/dist/schedule/runner.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { Fault, ResultAsync } from '../unthrow/index.js';
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
create: () => ({
|
|
6
|
-
message: 'Scheduled execution was interrupted',
|
|
7
|
-
}),
|
|
8
|
-
});
|
|
2
|
+
export class ScheduleInterruptedFault extends Fault.Tagged('SCHEDULE_INTERRUPTED', 'infrastructure')() {
|
|
3
|
+
message = 'Scheduled execution was interrupted';
|
|
4
|
+
}
|
|
9
5
|
const NEVER_ABORT = new AbortController().signal;
|
|
10
6
|
function sleep(ms, signal) {
|
|
11
7
|
return new Promise((resolve) => {
|
|
@@ -38,7 +34,7 @@ export function repeat(schedule, fn, options) {
|
|
|
38
34
|
for (let attempt = 0;; attempt++) {
|
|
39
35
|
// eslint-disable-next-line @typescript-eslint/only-throw-error -- intentional: fromPromise catches and maps E
|
|
40
36
|
if (signal?.aborted)
|
|
41
|
-
throw ScheduleInterruptedFault();
|
|
37
|
+
throw new ScheduleInterruptedFault();
|
|
42
38
|
const result = await fn(signal ?? NEVER_ABORT);
|
|
43
39
|
// eslint-disable-next-line @typescript-eslint/only-throw-error -- intentional: fromPromise catches and maps E
|
|
44
40
|
if (result.isErr())
|
|
@@ -50,7 +46,7 @@ export function repeat(schedule, fn, options) {
|
|
|
50
46
|
await sleep(step.delay, signal);
|
|
51
47
|
// eslint-disable-next-line @typescript-eslint/only-throw-error -- intentional: fromPromise catches and maps E
|
|
52
48
|
if (signal?.aborted)
|
|
53
|
-
throw ScheduleInterruptedFault();
|
|
49
|
+
throw new ScheduleInterruptedFault();
|
|
54
50
|
}
|
|
55
51
|
})(), (e) => e);
|
|
56
52
|
}
|
|
@@ -27,5 +27,9 @@ export declare class Schedule<In = unknown, Out = number> {
|
|
|
27
27
|
static once: Schedule;
|
|
28
28
|
/** Fibonacci delay sequence: one, one, 2*one, 3*one, 5*one, 8*one, ... */
|
|
29
29
|
static fibonacci: (one: number) => Schedule;
|
|
30
|
+
/** Cron expression schedule (aligned to cron windows) */
|
|
31
|
+
static cron: (expression: string, options?: {
|
|
32
|
+
timezone?: string;
|
|
33
|
+
}) => Schedule;
|
|
30
34
|
}
|
|
31
35
|
//# sourceMappingURL=schedule.d.ts.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Cron } from 'croner';
|
|
1
2
|
export class Schedule {
|
|
2
3
|
_next;
|
|
3
4
|
constructor(_next) {
|
|
@@ -90,5 +91,16 @@ export class Schedule {
|
|
|
90
91
|
return { delay, continue: true, output: attempt };
|
|
91
92
|
});
|
|
92
93
|
};
|
|
94
|
+
/** Cron expression schedule (aligned to cron windows) */
|
|
95
|
+
static cron = (expression, options) => {
|
|
96
|
+
const pattern = new Cron(expression, options?.timezone ? { timezone: options.timezone } : undefined);
|
|
97
|
+
return new Schedule((_input, attempt) => {
|
|
98
|
+
const next = pattern.nextRun();
|
|
99
|
+
if (!next)
|
|
100
|
+
return { delay: 0, continue: false, output: attempt };
|
|
101
|
+
const delay = Math.max(0, next.getTime() - Date.now());
|
|
102
|
+
return { delay, continue: true, output: attempt };
|
|
103
|
+
});
|
|
104
|
+
};
|
|
93
105
|
}
|
|
94
106
|
//# sourceMappingURL=schedule.js.map
|
package/dist/timeout/index.d.ts
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
import { withTimeout as _withTimeout, createTimeout as _createTimeout } from './timeout.js';
|
|
2
|
-
export
|
|
1
|
+
import { TimeoutFault as _TimeoutFault, withTimeout as _withTimeout, createTimeout as _createTimeout } from './timeout.js';
|
|
2
|
+
export { TimeoutFault } from './timeout.js';
|
|
3
3
|
export declare namespace Timeout {
|
|
4
4
|
const wrap: typeof _withTimeout;
|
|
5
5
|
const create: typeof _createTimeout;
|
|
6
|
-
const Fault:
|
|
7
|
-
code: "TIMEOUT";
|
|
8
|
-
_category: "infrastructure";
|
|
9
|
-
_transient: true;
|
|
10
|
-
message: string;
|
|
11
|
-
} & Omit<{
|
|
12
|
-
message: string;
|
|
13
|
-
duration: number;
|
|
14
|
-
}, "message">>;
|
|
6
|
+
const Fault: typeof _TimeoutFault;
|
|
15
7
|
}
|
|
16
8
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/timeout/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TimeoutFault as _TimeoutFault, withTimeout as _withTimeout, createTimeout as _createTimeout, } from './timeout.js';
|
|
2
|
+
export { TimeoutFault } from './timeout.js';
|
|
2
3
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
3
4
|
export var Timeout;
|
|
4
5
|
(function (Timeout) {
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { ResultAsync } from '../unthrow/index.js';
|
|
2
|
-
|
|
3
|
-
code: "TIMEOUT";
|
|
4
|
-
_category: "infrastructure";
|
|
5
|
-
_transient: true;
|
|
6
|
-
message: string;
|
|
7
|
-
} & Omit<{
|
|
8
|
-
message: string;
|
|
2
|
+
declare const TimeoutFault_base: abstract new (fields: {
|
|
9
3
|
duration: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
4
|
+
}) => Readonly<{
|
|
5
|
+
duration: number;
|
|
6
|
+
}> & {
|
|
7
|
+
readonly code: "TIMEOUT";
|
|
8
|
+
readonly _category: "infrastructure";
|
|
9
|
+
readonly _transient: true;
|
|
10
|
+
readonly message: string;
|
|
11
|
+
};
|
|
12
|
+
export declare class TimeoutFault extends TimeoutFault_base {
|
|
13
|
+
readonly message: string;
|
|
14
|
+
}
|
|
12
15
|
export declare function withTimeout<T, E>(fn: (signal: AbortSignal) => ResultAsync<T, E>, duration: number): ResultAsync<T, E | TimeoutFault>;
|
|
13
16
|
export declare function createTimeout(duration: number): <T, E>(fn: (signal: AbortSignal) => ResultAsync<T, E>) => ResultAsync<T, E | TimeoutFault>;
|
|
17
|
+
export {};
|
|
14
18
|
//# sourceMappingURL=timeout.d.ts.map
|
package/dist/timeout/timeout.js
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { Fault, ResultAsync } from '../unthrow/index.js';
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
transient: true,
|
|
6
|
-
create: (duration) => ({
|
|
7
|
-
message: `Operation timed out after ${duration}ms`,
|
|
8
|
-
duration,
|
|
9
|
-
}),
|
|
10
|
-
});
|
|
2
|
+
export class TimeoutFault extends Fault.Tagged('TIMEOUT', 'infrastructure', true)() {
|
|
3
|
+
message = `Operation timed out after ${this.duration}ms`;
|
|
4
|
+
}
|
|
11
5
|
export function withTimeout(fn, duration) {
|
|
12
6
|
const controller = new AbortController();
|
|
13
7
|
return ResultAsync.fromPromise(
|
|
@@ -15,7 +9,7 @@ export function withTimeout(fn, duration) {
|
|
|
15
9
|
new Promise((resolve, reject) => {
|
|
16
10
|
const timer = setTimeout(() => {
|
|
17
11
|
controller.abort();
|
|
18
|
-
reject(TimeoutFault(duration));
|
|
12
|
+
reject(new TimeoutFault({ duration }));
|
|
19
13
|
}, duration);
|
|
20
14
|
void fn(controller.signal).then((result) => {
|
|
21
15
|
clearTimeout(timer);
|
package/dist/unthrow/fault.d.ts
CHANGED
|
@@ -4,34 +4,19 @@ export interface Fault {
|
|
|
4
4
|
readonly _category: 'domain' | 'infrastructure';
|
|
5
5
|
readonly _transient: boolean;
|
|
6
6
|
}
|
|
7
|
-
export
|
|
7
|
+
export declare function isDomainFault(error: Fault): error is Fault & {
|
|
8
8
|
readonly _category: 'domain';
|
|
9
|
-
readonly _transient: false;
|
|
10
9
|
};
|
|
11
|
-
export
|
|
10
|
+
export declare function isInfrastructureFault(error: Fault): error is Fault & {
|
|
12
11
|
readonly _category: 'infrastructure';
|
|
13
12
|
};
|
|
14
|
-
export
|
|
13
|
+
export declare function isTransientFault(error: Fault): error is Fault & {
|
|
15
14
|
readonly _transient: true;
|
|
16
15
|
};
|
|
17
|
-
export declare function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
export declare function defineFault<const O extends {
|
|
24
|
-
code: string;
|
|
25
|
-
category: 'domain' | 'infrastructure';
|
|
26
|
-
transient?: boolean;
|
|
27
|
-
create: (...args: any[]) => {
|
|
28
|
-
message: string;
|
|
29
|
-
};
|
|
30
|
-
}>(options: O): (...args: Parameters<O['create']>) => Readonly<{
|
|
31
|
-
code: O['code'];
|
|
32
|
-
_category: O['category'];
|
|
33
|
-
_transient: InferTransient<O>;
|
|
34
|
-
message: string;
|
|
35
|
-
} & Omit<ReturnType<O['create']>, 'message'>>;
|
|
36
|
-
export {};
|
|
16
|
+
export declare function Fault<const Code extends string, const Category extends 'domain' | 'infrastructure', const Transient extends boolean = false>(code: Code, category: Category, transient?: Transient): <A extends Record<string, unknown> = Record<string, unknown>>() => abstract new (...args: Record<string, unknown> extends A ? [] : [fields: A]) => Readonly<A> & {
|
|
17
|
+
readonly code: Code;
|
|
18
|
+
readonly _category: Category;
|
|
19
|
+
readonly _transient: Transient;
|
|
20
|
+
readonly message: string;
|
|
21
|
+
};
|
|
37
22
|
//# sourceMappingURL=fault.d.ts.map
|
package/dist/unthrow/fault.js
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
|
-
export function isTransient(error) {
|
|
2
|
-
return error._transient && error._category === 'infrastructure';
|
|
3
|
-
}
|
|
4
1
|
export function isDomainFault(error) {
|
|
5
2
|
return error._category === 'domain';
|
|
6
3
|
}
|
|
7
4
|
export function isInfrastructureFault(error) {
|
|
8
5
|
return error._category === 'infrastructure';
|
|
9
6
|
}
|
|
10
|
-
export function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
7
|
+
export function isTransientFault(error) {
|
|
8
|
+
return error._transient && error._category === 'infrastructure';
|
|
9
|
+
}
|
|
10
|
+
export function Fault(code, category, transient) {
|
|
11
|
+
const _transient = (transient ?? false);
|
|
12
|
+
return () => {
|
|
13
|
+
class FaultImpl {
|
|
14
|
+
code = code;
|
|
15
|
+
_category = category;
|
|
16
|
+
_transient = _transient;
|
|
17
|
+
constructor(...args) {
|
|
18
|
+
const fields = args[0];
|
|
19
|
+
if (fields) {
|
|
20
|
+
Object.assign(this, fields);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return FaultImpl;
|
|
20
25
|
};
|
|
21
26
|
}
|
|
22
27
|
//# sourceMappingURL=fault.js.map
|
package/dist/unthrow/index.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { ResultAsync as ResultAsyncClass, okAsync, errAsync } from './result.async.js';
|
|
2
2
|
import { DoAsync } from './do.js';
|
|
3
|
-
import {
|
|
3
|
+
import { Fault as _Fault, isDomainFault as _isDomainFault, isInfrastructureFault as _isInfrastructureFault, isTransientFault as _isTransientFault } from './fault.js';
|
|
4
4
|
export { Ok, Err } from './result.js';
|
|
5
|
-
export type { DomainFault, InfrastructureFault, TransientFault, } from './fault.js';
|
|
6
5
|
export type Result<T, E> = import('./result.js').Ok<T, E> | import('./result.js').Err<T, E>;
|
|
7
6
|
type _Result<T, E> = Result<T, E>;
|
|
8
7
|
export declare namespace Result {
|
|
@@ -24,9 +23,9 @@ export declare namespace ResultAsync {
|
|
|
24
23
|
}
|
|
25
24
|
export type Fault = import('./fault.js').Fault;
|
|
26
25
|
export declare namespace Fault {
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
26
|
+
const Tagged: typeof _Fault;
|
|
27
|
+
const isDomain: typeof _isDomainFault;
|
|
28
|
+
const isInfrastructure: typeof _isInfrastructureFault;
|
|
29
|
+
const isTransient: typeof _isTransientFault;
|
|
31
30
|
}
|
|
32
31
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/unthrow/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { ok, err } from './result.js';
|
|
|
2
2
|
import { okAsync, errAsync, } from './result.async.js';
|
|
3
3
|
import { fromPromise, fromThrowable, combine as combineFn, combineWithAllErrors as combineWithAllErrorsFn, } from './helpers.js';
|
|
4
4
|
import { Do, DoAsync } from './do.js';
|
|
5
|
-
import {
|
|
5
|
+
import { Fault as _Fault, isDomainFault as _isDomainFault, isInfrastructureFault as _isInfrastructureFault, isTransientFault as _isTransientFault, } from './fault.js';
|
|
6
6
|
// Re-export instance types (needed in user signatures)
|
|
7
7
|
export { Ok, Err } from './result.js';
|
|
8
8
|
// Internal aliases (must precede namespaces that reference them)
|
|
@@ -10,7 +10,6 @@ const _ok = ok;
|
|
|
10
10
|
const _err = err;
|
|
11
11
|
const _fromThrowable = fromThrowable;
|
|
12
12
|
const _fromPromise = fromPromise;
|
|
13
|
-
const _isTransient = isTransient;
|
|
14
13
|
const _Do = Do;
|
|
15
14
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
16
15
|
export var Result;
|
|
@@ -47,9 +46,9 @@ export var ResultAsync;
|
|
|
47
46
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
48
47
|
export var Fault;
|
|
49
48
|
(function (Fault) {
|
|
50
|
-
Fault.
|
|
51
|
-
Fault.
|
|
52
|
-
Fault.
|
|
53
|
-
Fault.
|
|
49
|
+
Fault.Tagged = _Fault;
|
|
50
|
+
Fault.isDomain = _isDomainFault;
|
|
51
|
+
Fault.isInfrastructure = _isInfrastructureFault;
|
|
52
|
+
Fault.isTransient = _isTransientFault;
|
|
54
53
|
})(Fault || (Fault = {}));
|
|
55
54
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clutchit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "hexac",
|
|
@@ -94,6 +94,9 @@
|
|
|
94
94
|
"node": ">=20",
|
|
95
95
|
"bun": ">=1"
|
|
96
96
|
},
|
|
97
|
+
"dependencies": {
|
|
98
|
+
"croner": "^10.0.1"
|
|
99
|
+
},
|
|
97
100
|
"scripts": {
|
|
98
101
|
"build": "rimraf dist && tsc -p tsconfig.build.json",
|
|
99
102
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|