@jagreehal/workflow 1.12.0 → 1.13.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 (42) hide show
  1. package/README.md +1197 -20
  2. package/dist/duration.cjs +2 -0
  3. package/dist/duration.cjs.map +1 -0
  4. package/dist/duration.d.cts +246 -0
  5. package/dist/duration.d.ts +246 -0
  6. package/dist/duration.js +2 -0
  7. package/dist/duration.js.map +1 -0
  8. package/dist/index.cjs +5 -5
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +3 -0
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.js +5 -5
  13. package/dist/index.js.map +1 -1
  14. package/dist/match.cjs +2 -0
  15. package/dist/match.cjs.map +1 -0
  16. package/dist/match.d.cts +216 -0
  17. package/dist/match.d.ts +216 -0
  18. package/dist/match.js +2 -0
  19. package/dist/match.js.map +1 -0
  20. package/dist/schedule.cjs +2 -0
  21. package/dist/schedule.cjs.map +1 -0
  22. package/dist/schedule.d.cts +387 -0
  23. package/dist/schedule.d.ts +387 -0
  24. package/dist/schedule.js +2 -0
  25. package/dist/schedule.js.map +1 -0
  26. package/docs/api.md +30 -0
  27. package/docs/coming-from-neverthrow.md +103 -10
  28. package/docs/effect-features-to-port.md +210 -0
  29. package/docs/match-examples.test.ts +558 -0
  30. package/docs/match.md +417 -0
  31. package/docs/policies-examples.test.ts +750 -0
  32. package/docs/policies.md +508 -0
  33. package/docs/resource-management-examples.test.ts +729 -0
  34. package/docs/resource-management.md +509 -0
  35. package/docs/schedule-examples.test.ts +736 -0
  36. package/docs/schedule.md +467 -0
  37. package/docs/tagged-error-examples.test.ts +494 -0
  38. package/docs/tagged-error.md +730 -0
  39. package/docs/visualization-examples.test.ts +663 -0
  40. package/docs/visualization.md +395 -0
  41. package/docs/visualize-examples.md +1 -1
  42. package/package.json +17 -2
@@ -4,8 +4,11 @@ You already get it: **errors should be in the type system, not hidden behind `un
4
4
 
5
5
  The difference? neverthrow gives you typed Results. This library gives you typed Results *plus* orchestration—retries, timeouts, caching, resume, and visualization built in.
6
6
 
7
+ This library **automatically infers error types** from your dependencies. No more manually tracking error unions—add a step, the union updates. Remove one? It updates. TypeScript enforces it at compile time.
8
+
7
9
  **TL;DR:**
8
10
  - `andThen` chains → `step()` calls with async/await
11
+ - **Automatic error inference** — no manual union tracking
9
12
  - Same error-first mindset, different syntax
10
13
  - Keep your existing neverthrow code—they interop cleanly
11
14
 
@@ -15,10 +18,10 @@ The difference? neverthrow gives you typed Results. This library gives you typed
15
18
  |---|------------|---------------------|
16
19
  | **Mental model** | "The Realist" — explicit about what can fail | "The Orchestrator" — explicit failures + execution control |
17
20
  | **Syntax** | Functional chaining (`.andThen()`, `.map()`) | Imperative async/await with `step()` |
18
- | **Error inference** | Manual union types | Automatic from dependencies |
21
+ | **Error inference** | ⚠️ **Manual union types** — you maintain them | ✅ **Automatic from dependencies** — always in sync |
19
22
  | **Orchestration** | DIY (retries, caching, timeouts) | Built-in primitives |
20
23
 
21
- Both make your functions *honest*—the signature says what can go wrong. The difference is in ergonomics and what's included.
24
+ Both make your functions *honest*—the signature says what can go wrong. The key difference: **workflow eliminates the manual union tracking burden** while adding orchestration features.
22
25
 
23
26
  ---
24
27
 
@@ -107,6 +110,65 @@ Both neverthrow and workflow fix this by making errors part of the return type.
107
110
 
108
111
  ---
109
112
 
113
+ ## The manual union tracking problem
114
+
115
+ **neverthrow's pain point:** You must manually declare and maintain error unions. Every time you add or remove a step, you update the type annotation:
116
+
117
+ ```typescript
118
+ // ❌ Manual union tracking - easy to get out of sync
119
+ type SignUpError = 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'DB_ERROR' | 'EMAIL_EXISTS';
120
+
121
+ const signUp = (
122
+ email: string,
123
+ password: string
124
+ ): ResultAsync<User, SignUpError> =>
125
+ validateEmail(email)
126
+ .andThen(() => validatePassword(password))
127
+ .andThen(() => checkDuplicate(email)) // Oops! Forgot to add 'EMAIL_EXISTS' to SignUpError
128
+ .andThen(() => createUser(email, password));
129
+
130
+ // TypeScript won't catch this - the error union is manually declared
131
+ // You'll only find out at runtime when you try to handle 'EMAIL_EXISTS'
132
+ ```
133
+
134
+ **What happens:**
135
+ - Add a new step? Update the union manually
136
+ - Remove a step? Update the union manually
137
+ - Forget to update? TypeScript won't catch it
138
+ - Switch on an error that can't happen? TypeScript won't warn you
139
+ - Miss handling a possible error? TypeScript won't warn you
140
+
141
+ **workflow's solution:** Error types are **automatically inferred** from your dependencies:
142
+
143
+ ```typescript
144
+ // ✅ Automatic inference - always in sync
145
+ const signUp = createWorkflow({
146
+ validateEmail, // returns AsyncResult<string, 'INVALID_EMAIL'>
147
+ validatePassword, // returns AsyncResult<string, 'WEAK_PASSWORD'>
148
+ checkDuplicate, // returns AsyncResult<void, 'EMAIL_EXISTS'>
149
+ createUser, // returns AsyncResult<User, 'DB_ERROR'>
150
+ });
151
+
152
+ const result = await signUp(async (step, deps) => {
153
+ const email = await step(deps.validateEmail('alice@example.com'));
154
+ const password = await step(deps.validatePassword('securepass123'));
155
+ await step(deps.checkDuplicate(email));
156
+ return await step(deps.createUser(email, password));
157
+ });
158
+
159
+ // TypeScript knows: Result<User, 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'EMAIL_EXISTS' | 'DB_ERROR' | UnexpectedError>
160
+ // Add a step? Union updates automatically. Remove one? Updates automatically.
161
+ // Switch on an impossible error? TypeScript error. Miss a possible error? TypeScript error.
162
+ ```
163
+
164
+ **The difference:**
165
+ - neverthrow: You maintain the error union manually (error-prone)
166
+ - workflow: The error union is computed from your dependencies (always correct)
167
+
168
+ This is especially valuable in complex workflows with 5+ steps where manual tracking becomes a maintenance burden.
169
+
170
+ ---
171
+
110
172
  ## Pattern-by-pattern comparison
111
173
 
112
174
  ### Basic Result construction
@@ -426,10 +488,12 @@ const enrichedResult = mapError(userResult, error => ({
426
488
 
427
489
  ### Automatic error type inference
428
490
 
491
+ > Error unions are computed automatically—no manual tracking, no drift, no bugs.
492
+
429
493
  **neverthrow** requires you to declare error unions explicitly:
430
494
 
431
495
  ```typescript
432
- // You MUST manually track the error union
496
+ // You MUST manually track the error union
433
497
  type SignUpError = 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'DB_ERROR';
434
498
 
435
499
  const signUp = (
@@ -439,14 +503,27 @@ const signUp = (
439
503
  validateEmail(email)
440
504
  .andThen(() => validatePassword(password))
441
505
  .andThen(() => createUser(email, password));
506
+
507
+ // What happens when you add a step?
508
+ // 1. Add checkDuplicate() that returns 'EMAIL_EXISTS'
509
+ // 2. Remember to update SignUpError type
510
+ // 3. If you forget? TypeScript won't catch it
511
+ // 4. Runtime error when you try to handle 'EMAIL_EXISTS'
442
512
  ```
443
513
 
514
+ **The pain:**
515
+ - Add a step? Update the union manually
516
+ - Remove a step? Update the union manually
517
+ - Forget to update? Silent type error
518
+ - Switch on impossible error? No warning
519
+ - Miss handling possible error? No warning
520
+
444
521
  **workflow** with `createWorkflow` infers them automatically:
445
522
 
446
523
  ```typescript
447
524
  import { createWorkflow } from '@jagreehal/workflow';
448
525
 
449
- // NO manual type annotation needed!
526
+ // NO manual type annotation needed!
450
527
  const signUp = createWorkflow({
451
528
  validateEmail, // returns AsyncResult<string, 'INVALID_EMAIL'>
452
529
  validatePassword, // returns AsyncResult<string, 'WEAK_PASSWORD'>
@@ -462,7 +539,23 @@ const result = await signUp(async (step, deps) => {
462
539
  // TypeScript knows: Result<User, 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'DB_ERROR' | UnexpectedError>
463
540
  ```
464
541
 
465
- The error union stays in sync as you add or remove steps.
542
+ **Add a step?** The union updates automatically:
543
+ ```typescript
544
+ // Add checkDuplicate - union updates automatically
545
+ const signUp = createWorkflow({
546
+ validateEmail,
547
+ validatePassword,
548
+ checkDuplicate, // returns AsyncResult<void, 'EMAIL_EXISTS'>
549
+ createUser,
550
+ });
551
+
552
+ // Now TypeScript knows: 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'EMAIL_EXISTS' | 'DB_ERROR' | UnexpectedError
553
+ // No manual type update needed!
554
+ ```
555
+
556
+ **Remove a step?** The union updates automatically. **Switch on impossible error?** TypeScript error. **Miss handling possible error?** TypeScript error.
557
+
558
+ The error union **always** matches your actual dependencies. This becomes invaluable in complex workflows with 5+ steps where manual tracking becomes error-prone.
466
559
 
467
560
  ---
468
561
 
@@ -761,11 +854,11 @@ const data = await limiter.execute(async () => {
761
854
  Render workflow execution:
762
855
 
763
856
  ```typescript
764
- import { createIRBuilder, renderToAscii, renderToMermaid } from '@jagreehal/workflow/visualize';
857
+ import { createVisualizer } from '@jagreehal/workflow/visualize';
765
858
 
766
- const builder = createIRBuilder();
859
+ const viz = createVisualizer({ workflowName: 'User posts flow' });
767
860
  const workflow = createWorkflow({ fetchUser, fetchPosts }, {
768
- onEvent: (event) => builder.addEvent(event),
861
+ onEvent: viz.handleEvent,
769
862
  });
770
863
 
771
864
  await workflow(async (step, deps) => {
@@ -774,8 +867,8 @@ await workflow(async (step, deps) => {
774
867
  return { user, posts };
775
868
  });
776
869
 
777
- console.log(renderToAscii(builder.getIR()));
778
- console.log(renderToMermaid(builder.getIR()));
870
+ console.log(viz.render()); // ASCII for terminal
871
+ console.log(viz.renderAs('mermaid')); // Mermaid for docs
779
872
  ```
780
873
 
781
874
  ---
@@ -0,0 +1,210 @@
1
+ # High-Value Effect Features to Port
2
+
3
+ ## What Makes People *Love* Effect?
4
+
5
+ The "aha moments" in Effect are:
6
+ 1. **Everything composes** - schedules, errors, resources combine naturally
7
+ 2. **The type system catches mistakes** - exhaustive checks, no runtime surprises
8
+ 3. **Declarative over imperative** - describe what you want, not how
9
+
10
+ Your library already delivers on #2 and #3. The biggest gap is **composability** for schedules.
11
+
12
+ ---
13
+
14
+ ## Features That People Would **Miss** If Gone
15
+
16
+ ### 1. **Schedule Combinators** ⭐⭐⭐⭐⭐ (The "Aha Moment")
17
+ **What you have now** (from `src/policies.ts`):
18
+ ```typescript
19
+ // Static presets - pick one
20
+ retryPolicies.transient // 3 attempts, exponential
21
+ retryPolicies.aggressive // 5 attempts, exponential
22
+ ```
23
+
24
+ **What Effect has** - composable at runtime:
25
+ ```typescript
26
+ // "Exponential with jitter, but if that fails, poll every minute forever"
27
+ const schedule = Schedule.exponential("100ms")
28
+ .pipe(Schedule.jittered)
29
+ .pipe(Schedule.upTo(5))
30
+ .pipe(Schedule.andThen(Schedule.spaced("1 minute")))
31
+
32
+ // "Retry until the output satisfies a condition"
33
+ const untilHealthy = Schedule.spaced("5s")
34
+ .pipe(Schedule.whileOutput(resp => resp.status !== "healthy"))
35
+ ```
36
+
37
+ **Why people love it**:
38
+ - **Real-world scenarios**: "Retry 3x fast, then back off to polling" is common but hard to express
39
+ - **Type-safe composition**: Each combinator is typed, IDE autocompletes the next options
40
+ - **Declarative**: You describe the *shape* of retries, not the loop mechanics
41
+
42
+ **Key combinators to port**:
43
+ | Combinator | What it does |
44
+ |------------|--------------|
45
+ | `andThen` | Chain schedules: first exhaust A, then use B |
46
+ | `union` | Take the shorter delay of two schedules |
47
+ | `intersect` | Take the longer delay of two schedules |
48
+ | `jittered` | Add randomness (thundering herd prevention) |
49
+ | `whileInput/Output` | Continue while condition holds |
50
+ | `upTo(n)` | Limit to n repetitions |
51
+ | `elapsed` | Track cumulative time for timeouts |
52
+
53
+ **Effort**: Medium | **Impact**: Very High (transforms retry experience)
54
+
55
+ ---
56
+
57
+ ### 2. **Schema (Validation that Returns Results)** ⭐⭐⭐⭐
58
+ **What exists**: Zod, Yup, etc. - they all throw on failure
59
+ **What Effect has**: Validation that returns `Result<T, ParseError>`
60
+
61
+ ```typescript
62
+ const UserSchema = Schema.Struct({
63
+ id: Schema.NonEmptyString,
64
+ email: Schema.Email,
65
+ age: Schema.Int.pipe(Schema.between(0, 150)),
66
+ })
67
+
68
+ // Integrates with your workflow!
69
+ const result = await workflow(async (step) => {
70
+ const input = await step(Schema.decode(UserSchema, rawInput)) // early-exit on invalid
71
+ return processUser(input)
72
+ })
73
+ ```
74
+
75
+ **Why people love it**:
76
+ - **No try/catch** - validation failures are just Results
77
+ - **Encode + Decode** - bidirectional transforms (API responses, database rows)
78
+ - **Composition** - extend schemas, union them, transform them
79
+
80
+ **However**: Zod + Result wrapper might be "good enough" - this is a big undertaking.
81
+
82
+ **Effort**: Very High | **Impact**: High (but alternatives exist)
83
+
84
+ ---
85
+
86
+ ### 3. **Match (Exhaustive Pattern Matching)** ⭐⭐⭐⭐
87
+ **What you have**: `TaggedError.match()` - but only for errors
88
+ **What Effect has**: Pattern matching on **any** discriminated union
89
+
90
+ ```typescript
91
+ // Works on any { _tag: string } union
92
+ type Event =
93
+ | { _tag: 'UserCreated'; user: User }
94
+ | { _tag: 'UserUpdated'; userId: string; changes: Partial<User> }
95
+ | { _tag: 'UserDeleted'; userId: string }
96
+
97
+ const result = Match.value(event)
98
+ .pipe(Match.tag("UserCreated", e => `Created: ${e.user.name}`))
99
+ .pipe(Match.tag("UserUpdated", e => `Updated: ${e.userId}`))
100
+ .pipe(Match.tag("UserDeleted", e => `Deleted: ${e.userId}`))
101
+ .pipe(Match.exhaustive) // ← Compile error if you miss a case!
102
+ ```
103
+
104
+ **Why people love it**:
105
+ - **Beyond errors** - use for events, commands, state machines
106
+ - **Exhaustive checking** - TypeScript catches missing cases
107
+ - **Natural with your tagged error pattern** - same `_tag` convention
108
+
109
+ **Effort**: Low | **Impact**: High (natural extension of TaggedError)
110
+
111
+ ---
112
+
113
+ ### 4. **Option Type** ⭐⭐⭐
114
+ **What**: Explicit optional value handling (vs null/undefined)
115
+ **Trade-off**: JavaScript has `?.` and `??` - Option is less essential than in other languages
116
+
117
+ ```typescript
118
+ // Instead of: user.address?.city ?? "Unknown"
119
+ Option.fromNullable(user.address)
120
+ .pipe(Option.map(a => a.city))
121
+ .pipe(Option.getOrElse(() => "Unknown"))
122
+ ```
123
+
124
+ **Why some love it**:
125
+ - **Chainable** - long transformation pipelines are cleaner
126
+ - **toResult()** - converts to your Result type
127
+
128
+ **Effort**: Low | **Impact**: Medium (nice-to-have, not essential)
129
+
130
+ ---
131
+
132
+ ### 5. **Duration Type** ⭐⭐⭐
133
+ **What**: Type-safe duration handling instead of raw milliseconds
134
+ **Your current API**: `{ timeout: { ms: 5000 } }` - easy to make unit mistakes
135
+
136
+ ```typescript
137
+ // Before (easy to mess up)
138
+ step(fn, { timeout: { ms: 5000 } }) // is this 5 seconds or 5000 seconds?
139
+
140
+ // After (self-documenting)
141
+ step(fn, { timeout: Duration.seconds(5) })
142
+ step(fn, { timeout: Duration.minutes(2) })
143
+ ```
144
+
145
+ **Bonus**: Integrates beautifully with Schedule combinators:
146
+ ```typescript
147
+ Schedule.exponential(Duration.millis(100))
148
+ .pipe(Schedule.upTo(Duration.seconds(30)))
149
+ ```
150
+
151
+ **Effort**: Low | **Impact**: Medium (clarity + Schedule integration)
152
+
153
+ ---
154
+
155
+ ### 6. **Deferred (External Promise Resolution)** ⭐⭐⭐
156
+ **What**: A promise you can complete from outside
157
+ **Why relevant**: Your HITL patterns could use this internally
158
+
159
+ ```typescript
160
+ const deferred = Deferred.make<string, Error>()
161
+
162
+ // In one place
163
+ Deferred.succeed(deferred, "approved")
164
+
165
+ // In another place (awaiting)
166
+ const result = await Deferred.await(deferred) // Result<string, Error>
167
+ ```
168
+
169
+ **Effort**: Low | **Impact**: Medium (coordination primitive)
170
+
171
+ ---
172
+
173
+ ### 7. **Stream** ⭐⭐⭐
174
+ **What**: Lazy, backpressure-aware data pipelines
175
+ **Trade-off**: RxJS exists, your batch processing covers many use cases
176
+
177
+ ```typescript
178
+ Stream.fromIterable(largeDataset)
179
+ .pipe(Stream.mapPar(4, processItem))
180
+ .pipe(Stream.filter(isValid))
181
+ .pipe(Stream.take(1000))
182
+ .pipe(Stream.runCollect)
183
+ ```
184
+
185
+ **Why some love it**:
186
+ - **Lazy** - doesn't load everything into memory
187
+ - **Backpressure** - slow consumers don't overflow
188
+
189
+ **Effort**: Very High | **Impact**: Medium (alternatives exist)
190
+
191
+ ---
192
+
193
+ ## Top Recommendations (What People Would Love/Miss)
194
+
195
+ | Rank | Feature | Why It's Lovable | Effort |
196
+ |------|---------|-----------------|--------|
197
+ | **1** | **Schedule Combinators** | "Aha moment" - retry strategies that compose | Medium |
198
+ | **2** | **Match** | Extends your TaggedError pattern to everything | Low |
199
+ | **3** | **Duration** | Small but delightful, enables Schedule cleanly | Low |
200
+ | **4** | **Schema** | Validation + Result integration (if not Zod) | Very High |
201
+ | **5** | **Option** | Nice chains, but JS has `?.` and `??` | Low |
202
+
203
+ ### Recommendation
204
+
205
+ **Start with Schedule + Duration + Match** - they're complementary:
206
+ - Duration gives you type-safe time units
207
+ - Schedule uses Duration for composable retry logic
208
+ - Match extends your TaggedError philosophy to all discriminated unions
209
+
210
+ This creates a cohesive "composable primitives" layer that feels distinctly Effect-inspired while being uniquely yours.