@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
package/docs/match.md ADDED
@@ -0,0 +1,417 @@
1
+ # Match
2
+
3
+ Exhaustive pattern matching for discriminated unions. Ensures all cases are handled at compile time.
4
+
5
+ ## The Problem: Switch Statements Miss Cases
6
+
7
+ TypeScript doesn't catch missing cases in switch statements:
8
+
9
+ ```typescript
10
+ type Status =
11
+ | { _tag: 'Pending' }
12
+ | { _tag: 'Running'; progress: number }
13
+ | { _tag: 'Completed'; result: string }
14
+ | { _tag: 'Failed'; error: string }
15
+
16
+ // ❌ No compile error when you add a new status!
17
+ function getMessage(status: Status): string {
18
+ switch (status._tag) {
19
+ case 'Pending': return 'Waiting...';
20
+ case 'Running': return `${status.progress}%`;
21
+ case 'Completed': return status.result;
22
+ // Forgot 'Failed' - runtime error!
23
+ }
24
+ }
25
+ ```
26
+
27
+ ## The Solution: Exhaustive Matching
28
+
29
+ Use `Match` for compile-time exhaustiveness checking:
30
+
31
+ ```typescript
32
+ import { Match } from '@jagreehal/workflow';
33
+
34
+ // ✓ TypeScript error if any case is missing
35
+ function getMessage(status: Status): string {
36
+ return Match.value(status)
37
+ .pipe(Match.tag("Pending", () => "Waiting..."))
38
+ .pipe(Match.tag("Running", s => `${s.progress}%`))
39
+ .pipe(Match.tag("Completed", s => s.result))
40
+ .pipe(Match.tag("Failed", s => `Error: ${s.error}`))
41
+ .pipe(Match.exhaustive);
42
+ }
43
+ ```
44
+
45
+ If you forget a case, TypeScript shows an error at `.pipe(Match.exhaustive)`.
46
+
47
+ ## Core API
48
+
49
+ ### `Match.value` - Start Matching
50
+
51
+ ```typescript
52
+ import { Match } from '@jagreehal/workflow';
53
+
54
+ type Event =
55
+ | { _tag: 'UserCreated'; userId: string; name: string }
56
+ | { _tag: 'UserUpdated'; userId: string }
57
+ | { _tag: 'UserDeleted'; userId: string }
58
+
59
+ const message = Match.value(event)
60
+ .pipe(Match.tag("UserCreated", e => `Created: ${e.name}`))
61
+ .pipe(Match.tag("UserUpdated", e => `Updated: ${e.userId}`))
62
+ .pipe(Match.tag("UserDeleted", e => `Deleted: ${e.userId}`))
63
+ .pipe(Match.exhaustive);
64
+ ```
65
+
66
+ ### `Match.tag` - Handle One Case
67
+
68
+ ```typescript
69
+ Match.value(status)
70
+ .pipe(Match.tag("Pending", () => "waiting")) // Handle Pending
71
+ .pipe(Match.tag("Running", s => s.progress)) // Handle Running
72
+ .pipe(Match.tag("Completed", s => s.result)) // Handle Completed
73
+ .pipe(Match.tag("Failed", s => s.error)) // Handle Failed
74
+ .pipe(Match.exhaustive);
75
+ ```
76
+
77
+ Each `Match.tag` removes that case from the remaining unhandled set. TypeScript tracks this.
78
+
79
+ ### `Match.tags` - Handle Multiple Cases
80
+
81
+ ```typescript
82
+ // Handle multiple cases at once
83
+ Match.value(event)
84
+ .pipe(Match.tags({
85
+ UserCreated: e => `Created: ${e.name}`,
86
+ UserUpdated: e => `Updated: ${e.userId}`,
87
+ UserDeleted: e => `Deleted: ${e.userId}`,
88
+ }))
89
+ .pipe(Match.exhaustive);
90
+ ```
91
+
92
+ ### `Match.exhaustive` - Complete the Match
93
+
94
+ ```typescript
95
+ // TypeScript error if any case is not handled
96
+ const result = Match.value(status)
97
+ .pipe(Match.tag("Pending", () => 0))
98
+ .pipe(Match.tag("Running", s => s.progress))
99
+ // Missing: Completed, Failed
100
+ .pipe(Match.exhaustive); // ❌ Type error!
101
+ ```
102
+
103
+ ## Default Handling
104
+
105
+ ### `Match.orElse` - Default Handler
106
+
107
+ Handle specific cases, use a function for everything else:
108
+
109
+ ```typescript
110
+ const message = Match.value(status)
111
+ .pipe(Match.tag("Completed", s => `Done: ${s.result}`))
112
+ .pipe(Match.orElse(s => `Status: ${s._tag}`));
113
+
114
+ // Completed → "Done: success"
115
+ // Pending → "Status: Pending"
116
+ // Running → "Status: Running"
117
+ // Failed → "Status: Failed"
118
+ ```
119
+
120
+ ### `Match.orElseValue` - Default Value
121
+
122
+ Return a constant for unhandled cases:
123
+
124
+ ```typescript
125
+ const result = Match.value(status)
126
+ .pipe(Match.tag("Completed", s => s.result))
127
+ .pipe(Match.orElseValue("not completed"));
128
+
129
+ // Completed → "success"
130
+ // Everything else → "not completed"
131
+ ```
132
+
133
+ ## Conditional Matching
134
+
135
+ ### `Match.when` - Predicate-Based Matching
136
+
137
+ Add conditions beyond just the tag:
138
+
139
+ ```typescript
140
+ type User = { _tag: 'User'; name: string; isAdmin: boolean };
141
+
142
+ const greeting = Match.value(user)
143
+ .pipe(Match.tag("User", u => `Hello ${u.name}`)) // Default
144
+ .pipe(Match.when("User", u => u.isAdmin, u => `Hello Admin ${u.name}!`)) // Override for admins
145
+ .pipe(Match.exhaustive);
146
+
147
+ // { _tag: 'User', name: 'Alice', isAdmin: true } → "Hello Admin Alice!"
148
+ // { _tag: 'User', name: 'Bob', isAdmin: false } → "Hello Bob"
149
+ ```
150
+
151
+ Order matters: `when` overrides a previous `tag` handler when the predicate is true.
152
+
153
+ ## Type Guards
154
+
155
+ ### `Match.is` - Single Tag Check
156
+
157
+ ```typescript
158
+ const event: Event = { _tag: 'UserCreated', userId: '1', name: 'Alice' };
159
+
160
+ if (Match.is<Event, "UserCreated">("UserCreated")(event)) {
161
+ // TypeScript knows: event.name exists
162
+ console.log(event.name); // "Alice"
163
+ }
164
+ ```
165
+
166
+ ### `Match.isOneOf` - Multiple Tag Check
167
+
168
+ ```typescript
169
+ const isModification = Match.isOneOf<Event, ("UserCreated" | "UserUpdated")[]>(
170
+ "UserCreated",
171
+ "UserUpdated"
172
+ );
173
+
174
+ if (isModification(event)) {
175
+ // TypeScript knows: event is UserCreated | UserUpdated
176
+ }
177
+ ```
178
+
179
+ ## Real-World Scenarios
180
+
181
+ ### Payment Processing: Handle Every Possible Outcome
182
+
183
+ Your checkout API returns different result types. Missing one means silent bugs in production.
184
+
185
+ ```typescript
186
+ import { Match } from '@jagreehal/workflow';
187
+
188
+ type PaymentResult =
189
+ | { _tag: 'Success'; transactionId: string; amount: number }
190
+ | { _tag: 'Declined'; reason: string; canRetry: boolean }
191
+ | { _tag: 'Fraud'; riskScore: number }
192
+ | { _tag: 'NetworkError'; provider: string }
193
+
194
+ function handlePayment(result: PaymentResult): { action: string; notify: boolean } {
195
+ return Match.value(result)
196
+ .pipe(Match.tag("Success", r => ({
197
+ action: `Charge ${r.transactionId} complete`,
198
+ notify: false
199
+ })))
200
+ .pipe(Match.tag("Declined", r => ({
201
+ action: r.canRetry ? "Show retry prompt" : "Show alternative payment",
202
+ notify: false
203
+ })))
204
+ .pipe(Match.tag("Fraud", r => ({
205
+ action: `Flag order for review (score: ${r.riskScore})`,
206
+ notify: true // Alert fraud team
207
+ })))
208
+ .pipe(Match.tag("NetworkError", r => ({
209
+ action: `Retry with fallback provider (${r.provider} down)`,
210
+ notify: true // Alert ops
211
+ })))
212
+ .pipe(Match.exhaustive);
213
+ }
214
+ ```
215
+
216
+ Add a new payment status later? TypeScript errors until you handle it.
217
+
218
+ ### Subscription Billing: Different Logic per Plan
219
+
220
+ Your billing system charges differently based on plan type. Each plan has unique fields.
221
+
222
+ ```typescript
223
+ import { Match } from '@jagreehal/workflow';
224
+
225
+ type Subscription =
226
+ | { _tag: 'Free'; userId: string }
227
+ | { _tag: 'Pro'; userId: string; monthlyRate: number }
228
+ | { _tag: 'Enterprise'; userId: string; contractValue: number; billingContact: string }
229
+
230
+ function calculateInvoice(sub: Subscription): number {
231
+ return Match.value(sub)
232
+ .pipe(Match.tag("Free", () => 0))
233
+ .pipe(Match.tag("Pro", s => s.monthlyRate))
234
+ .pipe(Match.tag("Enterprise", s => s.contractValue / 12))
235
+ .pipe(Match.exhaustive);
236
+ }
237
+
238
+ function getBillingEmail(sub: Subscription): string {
239
+ return Match.value(sub)
240
+ .pipe(Match.tag("Enterprise", s => s.billingContact))
241
+ .pipe(Match.orElse(s => `user-${s.userId}@example.com`));
242
+ }
243
+ ```
244
+
245
+ ### Notification Routing: Send to the Right Channel
246
+
247
+ Different event types need different notification channels. Miss one and users don't get notified.
248
+
249
+ ```typescript
250
+ import { Match } from '@jagreehal/workflow';
251
+
252
+ type NotificationEvent =
253
+ | { _tag: 'OrderShipped'; orderId: string; trackingUrl: string }
254
+ | { _tag: 'PaymentFailed'; amount: number; retryUrl: string }
255
+ | { _tag: 'AccountLocked'; reason: string }
256
+ | { _tag: 'PasswordChanged' }
257
+
258
+ type Channel = 'email' | 'sms' | 'push' | 'slack';
259
+
260
+ function routeNotification(event: NotificationEvent): { channel: Channel; urgent: boolean } {
261
+ return Match.value(event)
262
+ .pipe(Match.tag("OrderShipped", () => ({ channel: 'push' as const, urgent: false })))
263
+ .pipe(Match.tag("PaymentFailed", () => ({ channel: 'email' as const, urgent: true })))
264
+ .pipe(Match.tag("AccountLocked", () => ({ channel: 'sms' as const, urgent: true })))
265
+ .pipe(Match.tag("PasswordChanged", () => ({ channel: 'email' as const, urgent: false })))
266
+ .pipe(Match.exhaustive);
267
+ }
268
+ ```
269
+
270
+ ### Form Validation: Collect All Errors with Context
271
+
272
+ Validation returns different error types. You need to show the right message for each.
273
+
274
+ ```typescript
275
+ import { Match } from '@jagreehal/workflow';
276
+
277
+ type ValidationError =
278
+ | { _tag: 'Required'; field: string }
279
+ | { _tag: 'TooShort'; field: string; min: number; actual: number }
280
+ | { _tag: 'InvalidFormat'; field: string; expected: string }
281
+ | { _tag: 'AlreadyExists'; field: string; value: string }
282
+
283
+ function formatValidationError(error: ValidationError): string {
284
+ return Match.value(error)
285
+ .pipe(Match.tag("Required", e => `${e.field} is required`))
286
+ .pipe(Match.tag("TooShort", e => `${e.field} must be at least ${e.min} characters (got ${e.actual})`))
287
+ .pipe(Match.tag("InvalidFormat", e => `${e.field} must be a valid ${e.expected}`))
288
+ .pipe(Match.tag("AlreadyExists", e => `${e.field} "${e.value}" is already taken`))
289
+ .pipe(Match.exhaustive);
290
+ }
291
+ ```
292
+
293
+ ## More Examples
294
+
295
+ ### Event Handler
296
+
297
+ ```typescript
298
+ type DomainEvent =
299
+ | { _tag: 'OrderPlaced'; orderId: string; items: string[] }
300
+ | { _tag: 'OrderShipped'; orderId: string; trackingNumber: string }
301
+ | { _tag: 'OrderDelivered'; orderId: string }
302
+ | { _tag: 'OrderCancelled'; orderId: string; reason: string }
303
+
304
+ function handleEvent(event: DomainEvent): void {
305
+ const message = Match.value(event)
306
+ .pipe(Match.tag("OrderPlaced", e => `Order ${e.orderId} placed with ${e.items.length} items`))
307
+ .pipe(Match.tag("OrderShipped", e => `Order ${e.orderId} shipped: ${e.trackingNumber}`))
308
+ .pipe(Match.tag("OrderDelivered", e => `Order ${e.orderId} delivered`))
309
+ .pipe(Match.tag("OrderCancelled", e => `Order ${e.orderId} cancelled: ${e.reason}`))
310
+ .pipe(Match.exhaustive);
311
+
312
+ console.log(message);
313
+ }
314
+ ```
315
+
316
+ ### Result Handling
317
+
318
+ ```typescript
319
+ type ApiResult =
320
+ | { _tag: 'Success'; data: unknown }
321
+ | { _tag: 'NotFound'; resource: string }
322
+ | { _tag: 'Unauthorized' }
323
+ | { _tag: 'RateLimited'; retryAfter: number }
324
+
325
+ function formatError(result: ApiResult): string | null {
326
+ return Match.value(result)
327
+ .pipe(Match.tag("Success", () => null))
328
+ .pipe(Match.tag("NotFound", r => `Resource not found: ${r.resource}`))
329
+ .pipe(Match.tag("Unauthorized", () => "Please log in"))
330
+ .pipe(Match.tag("RateLimited", r => `Too many requests. Retry in ${r.retryAfter}s`))
331
+ .pipe(Match.exhaustive);
332
+ }
333
+ ```
334
+
335
+ ### State Machine
336
+
337
+ ```typescript
338
+ type ConnectionState =
339
+ | { _tag: 'Disconnected' }
340
+ | { _tag: 'Connecting'; attempt: number }
341
+ | { _tag: 'Connected'; sessionId: string }
342
+ | { _tag: 'Reconnecting'; attempt: number; lastError: string }
343
+
344
+ function getStatusIcon(state: ConnectionState): string {
345
+ return Match.value(state)
346
+ .pipe(Match.tag("Disconnected", () => "⚫"))
347
+ .pipe(Match.tag("Connecting", () => "🟡"))
348
+ .pipe(Match.tag("Connected", () => "🟢"))
349
+ .pipe(Match.tag("Reconnecting", () => "🟠"))
350
+ .pipe(Match.exhaustive);
351
+ }
352
+ ```
353
+
354
+ ### Partial Handling with Defaults
355
+
356
+ ```typescript
357
+ // Only care about certain states
358
+ const isActive = Match.value(state)
359
+ .pipe(Match.tag("Connected", () => true))
360
+ .pipe(Match.tag("Reconnecting", () => true))
361
+ .pipe(Match.orElseValue(false));
362
+ ```
363
+
364
+ ## Best Practices
365
+
366
+ ### DO: Use for discriminated unions
367
+
368
+ ```typescript
369
+ // ✓ Good - tagged union
370
+ type Result =
371
+ | { _tag: 'Ok'; value: string }
372
+ | { _tag: 'Err'; error: Error }
373
+
374
+ Match.value(result)
375
+ .pipe(Match.tag("Ok", r => r.value))
376
+ .pipe(Match.tag("Err", r => r.error.message))
377
+ .pipe(Match.exhaustive);
378
+ ```
379
+
380
+ ### DON'T: Use for simple boolean/null checks
381
+
382
+ ```typescript
383
+ // ✗ Overkill - just use if/else
384
+ const message = value ? "yes" : "no";
385
+ ```
386
+
387
+ ### DO: Extract handlers for complex logic
388
+
389
+ ```typescript
390
+ // ✓ Keep match expressions clean
391
+ const handleCreated = (e: Extract<Event, { _tag: 'UserCreated' }>) => {
392
+ // Complex logic here
393
+ return `Created: ${e.name}`;
394
+ };
395
+
396
+ Match.value(event)
397
+ .pipe(Match.tag("UserCreated", handleCreated))
398
+ .pipe(Match.tag("UserUpdated", handleUpdated))
399
+ .pipe(Match.tag("UserDeleted", handleDeleted))
400
+ .pipe(Match.exhaustive);
401
+ ```
402
+
403
+ ## Summary
404
+
405
+ | Function | Purpose |
406
+ | -------- | ------- |
407
+ | `Match.value(x)` | Start matching on a value |
408
+ | `Match.tag(tag, fn)` | Handle one case |
409
+ | `Match.tags({ ... })` | Handle multiple cases |
410
+ | `Match.when(tag, pred, fn)` | Conditional handling |
411
+ | `Match.exhaustive` | Complete match (all cases required) |
412
+ | `Match.orElse(fn)` | Default handler |
413
+ | `Match.orElseValue(v)` | Default value |
414
+ | `Match.is(tag)` | Type guard for one tag |
415
+ | `Match.isOneOf(...tags)` | Type guard for multiple tags |
416
+
417
+ **The key insight:** Let the compiler catch missing cases. When you add a new variant to a union, TypeScript tells you everywhere that needs updating.