@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
@@ -0,0 +1,509 @@
1
+ # Resource Management
2
+
3
+ RAII-style (Resource Acquisition Is Initialization) resource cleanup for async operations. Connections, file handles, and locks get cleaned up automatically—even when errors occur.
4
+
5
+ ## Table of Contents
6
+
7
+ - [The Problem: Resource Leaks](#the-problem-resource-leaks)
8
+ - [The Solution: `withScope`](#the-solution-withscope)
9
+ - [Basic Usage](#basic-usage)
10
+ - [LIFO Cleanup Order](#lifo-cleanup-order)
11
+ - [The `createResource` Helper](#the-createresource-helper)
12
+ - [Cleanup Errors](#cleanup-errors)
13
+ - [Manual Scope Management](#manual-scope-management)
14
+ - [Integration with Workflows](#integration-with-workflows)
15
+ - [Best Practices](#best-practices)
16
+ - [When NOT to Use This](#when-not-to-use-this)
17
+ - [Summary](#summary)
18
+
19
+ ## The Problem: Resource Leaks
20
+
21
+ Async code makes cleanup hard. Early returns, exceptions, and complex control flow all create opportunities for leaks:
22
+
23
+ ```typescript
24
+ // ❌ Leak on early return
25
+ async function processData(userId: string) {
26
+ const db = await createConnection();
27
+ const cache = await createCache();
28
+
29
+ const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
30
+ if (!user) {
31
+ return err('USER_NOT_FOUND'); // Leaked: db and cache never closed!
32
+ }
33
+
34
+ const result = await processUser(user);
35
+ await db.close();
36
+ await cache.disconnect();
37
+ return ok(result);
38
+ }
39
+ ```
40
+
41
+ The `try/finally` pattern helps but gets nested and error-prone:
42
+
43
+ ```typescript
44
+ // ❌ Verbose and error-prone
45
+ async function processData(userId: string) {
46
+ const db = await createConnection();
47
+ try {
48
+ const cache = await createCache();
49
+ try {
50
+ // ... actual work
51
+ } finally {
52
+ await cache.disconnect();
53
+ }
54
+ } finally {
55
+ await db.close();
56
+ }
57
+ }
58
+ ```
59
+
60
+ ## The Solution: `withScope`
61
+
62
+ Register resources with a scope. They're cleaned up automatically when the scope exits—success, error, or exception:
63
+
64
+ ```typescript
65
+ import { withScope, ok, err } from '@jagreehal/workflow';
66
+
67
+ const result = await withScope(async (scope) => {
68
+ // scope.add returns the value directly for convenience
69
+ const db = scope.add({
70
+ value: await createConnection(),
71
+ close: async () => db.close(), // db IS the connection
72
+ });
73
+
74
+ const cache = scope.add({
75
+ value: await createCache(),
76
+ close: async () => cache.disconnect(), // cache IS the client
77
+ });
78
+
79
+ const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
80
+ if (!user) {
81
+ return err('USER_NOT_FOUND'); // ✓ db and cache still get closed
82
+ }
83
+
84
+ return ok(await processUser(user));
85
+ // ✓ Resources closed automatically in LIFO order
86
+ });
87
+ ```
88
+
89
+ ## Basic Usage
90
+
91
+ ```typescript
92
+ import { withScope, ok, type AsyncResult } from '@jagreehal/workflow';
93
+
94
+ interface DbClient {
95
+ query: (sql: string) => Promise<unknown[]>;
96
+ close: () => Promise<void>;
97
+ }
98
+
99
+ async function getUsers(): AsyncResult<User[], 'DB_ERROR'> {
100
+ return withScope(async (scope) => {
101
+ // scope.add returns the value directly
102
+ const db = scope.add({
103
+ value: await createDbConnection(),
104
+ close: async () => db.close(),
105
+ });
106
+
107
+ const users = await db.query('SELECT * FROM users');
108
+ return ok(users as User[]);
109
+ });
110
+ }
111
+ ```
112
+
113
+ ## LIFO Cleanup Order
114
+
115
+ Resources are cleaned up in **reverse order** (LIFO: Last-In-First-Out). This handles dependencies correctly—later resources often depend on earlier ones:
116
+
117
+ ```typescript
118
+ const result = await withScope(async (scope) => {
119
+ // 1. Connection first
120
+ const db = scope.add({
121
+ value: await createConnection(),
122
+ close: async () => {
123
+ console.log('Closing db');
124
+ await db.close(); // db IS the connection
125
+ },
126
+ });
127
+
128
+ // 2. Cache depends on connection
129
+ const cache = scope.add({
130
+ value: await createCache(db), // pass db directly
131
+ close: async () => {
132
+ console.log('Closing cache');
133
+ await cache.disconnect();
134
+ },
135
+ });
136
+
137
+ // 3. Session depends on both
138
+ const session = scope.add({
139
+ value: await createSession(db, cache),
140
+ close: async () => {
141
+ console.log('Closing session');
142
+ await session.end();
143
+ },
144
+ });
145
+
146
+ return ok('done');
147
+ });
148
+
149
+ // Output:
150
+ // Closing session (added last, closed first)
151
+ // Closing cache
152
+ // Closing db (added first, closed last)
153
+ ```
154
+
155
+ ## The `createResource` Helper
156
+
157
+ For cleaner syntax when the acquire/release pattern is straightforward:
158
+
159
+ ```typescript
160
+ import { withScope, createResource, ok } from '@jagreehal/workflow';
161
+
162
+ const result = await withScope(async (scope) => {
163
+ // createResource wraps acquire + release into a Resource
164
+ const dbResource = await createResource(
165
+ () => createConnection({ host: 'localhost' }),
166
+ (conn) => conn.close()
167
+ );
168
+ const db = scope.add(dbResource);
169
+
170
+ const cacheResource = await createResource(
171
+ () => createCache(),
172
+ (cache) => cache.disconnect()
173
+ );
174
+ const cache = scope.add(cacheResource);
175
+
176
+ // Use resources
177
+ return ok(await db.query('SELECT 1'));
178
+ });
179
+ ```
180
+
181
+ ## Cleanup Errors
182
+
183
+ If cleanup fails, `withScope` returns a `ResourceCleanupError`:
184
+
185
+ ```typescript
186
+ import { withScope, isResourceCleanupError, ok } from '@jagreehal/workflow';
187
+
188
+ const result = await withScope(async (scope) => {
189
+ scope.add({
190
+ value: 'resource1',
191
+ close: async () => {
192
+ throw new Error('Cleanup failed!');
193
+ },
194
+ });
195
+
196
+ return ok('done');
197
+ });
198
+
199
+ if (!result.ok && isResourceCleanupError(result.error)) {
200
+ console.log('Cleanup errors:', result.error.errors);
201
+ console.log('Original result:', result.error.originalResult);
202
+ }
203
+ ```
204
+
205
+ Key behaviors:
206
+
207
+ - **All resources are attempted** - Even if one cleanup fails, others still run
208
+ - **Errors are collected** - Multiple cleanup failures are grouped
209
+ - **Original result preserved** - You can see what the scope returned before cleanup failed
210
+
211
+ ## Manual Scope Management
212
+
213
+ For advanced cases, use `createResourceScope` directly:
214
+
215
+ ```typescript
216
+ import { createResourceScope } from '@jagreehal/workflow';
217
+
218
+ const scope = createResourceScope();
219
+
220
+ try {
221
+ // scope.add returns the value directly, not a Resource wrapper
222
+ const db = scope.add({
223
+ value: await createConnection(),
224
+ close: async () => db.close(), // db IS the connection
225
+ });
226
+
227
+ const cache = scope.add({
228
+ value: await createCache(),
229
+ close: async () => cache.disconnect(), // cache IS the client
230
+ });
231
+
232
+ // Check scope state
233
+ console.log('Resources:', scope.size()); // 2
234
+
235
+ // Use resources...
236
+ await db.query('SELECT 1');
237
+ } finally {
238
+ await scope.close(); // Manual cleanup
239
+ }
240
+ ```
241
+
242
+ **Note:** `scope.has()` expects a `Resource<T>` object, not the value. To check if a resource is in the scope, store the Resource object before calling `add()`:
243
+
244
+ ```typescript
245
+ const dbResource = { value: await createConnection(), close: async () => dbResource.value.close() };
246
+ const db = scope.add(dbResource);
247
+ console.log('Has db:', scope.has(dbResource)); // true
248
+ ```
249
+
250
+ ## Integration with Workflows
251
+
252
+ Use `withScope` inside workflow steps:
253
+
254
+ ```typescript
255
+ import { createWorkflow, withScope, ok } from '@jagreehal/workflow';
256
+
257
+ const deps = {
258
+ processWithResources: async (id: string) => {
259
+ return withScope(async (scope) => {
260
+ const db = scope.add({
261
+ value: await createConnection(),
262
+ close: async () => db.close(),
263
+ });
264
+
265
+ const data = await db.query('SELECT * FROM items WHERE id = ?', [id]);
266
+ return ok(data);
267
+ });
268
+ },
269
+ };
270
+
271
+ const workflow = createWorkflow(deps);
272
+
273
+ const result = await workflow(async (step, { processWithResources }) => {
274
+ const data = await step(processWithResources('123'));
275
+ return ok(data);
276
+ });
277
+ ```
278
+
279
+ ## Best Practices
280
+
281
+ ### DO: Register cleanup immediately after acquisition
282
+
283
+ ```typescript
284
+ // ✓ Register right after acquiring
285
+ const db = scope.add({
286
+ value: await createConnection(),
287
+ close: async () => db.close(), // db IS the connection
288
+ });
289
+ ```
290
+
291
+ ### DON'T: Defer registration
292
+
293
+ ```typescript
294
+ // ✗ Gap where leak can occur
295
+ const conn = await createConnection();
296
+ // If something throws here, conn leaks
297
+ const db = scope.add({ value: conn, close: () => conn.close() });
298
+ ```
299
+
300
+ ### DO: Keep cleanup functions simple
301
+
302
+ ```typescript
303
+ // ✓ Simple, focused cleanup
304
+ const db = scope.add({
305
+ value: await createConnection(),
306
+ close: async () => {
307
+ await db.close(); // db IS the connection
308
+ },
309
+ });
310
+ ```
311
+
312
+ ### DON'T: Add complex logic in cleanup
313
+
314
+ ```typescript
315
+ // ✗ Cleanup shouldn't have business logic
316
+ const db = scope.add({
317
+ value: await createConnection(),
318
+ close: async () => {
319
+ await saveState(db); // Could fail, complicates cleanup
320
+ await notifyOthers(); // Side effects in cleanup
321
+ await db.close();
322
+ },
323
+ });
324
+ ```
325
+
326
+ ### DO: Handle cleanup errors appropriately
327
+
328
+ ```typescript
329
+ const result = await withScope(async (scope) => { /* ... */ });
330
+
331
+ if (!result.ok) {
332
+ if (isResourceCleanupError(result.error)) {
333
+ // Log cleanup issues but don't expose to users
334
+ logger.error('Resource cleanup failed', result.error.errors);
335
+ // Return the original error if there was one
336
+ if (result.error.originalResult && !result.error.originalResult.ok) {
337
+ return result.error.originalResult;
338
+ }
339
+ }
340
+ }
341
+ ```
342
+
343
+ ## Real-World Scenarios
344
+
345
+ ### Database Transaction with File Upload
346
+
347
+ You're processing an order that requires both a database transaction and uploading a receipt to S3. If either fails, both must be rolled back.
348
+
349
+ ```typescript
350
+ import { withScope, ok, err } from '@jagreehal/workflow';
351
+
352
+ async function processOrder(orderId: string, receiptData: Buffer) {
353
+ return withScope(async (scope) => {
354
+ // Track if transaction is committed to avoid rollback on success
355
+ let committed = false;
356
+
357
+ // Start transaction - rolled back on failure
358
+ const tx = scope.add({
359
+ value: await db.beginTransaction(),
360
+ close: async () => {
361
+ // Only rollback if not committed
362
+ if (!committed) {
363
+ await tx.rollback();
364
+ }
365
+ },
366
+ });
367
+
368
+ // Upload receipt - deleted on failure
369
+ const receiptKey = `receipts/${orderId}.pdf`;
370
+ scope.add({
371
+ value: await s3.upload(receiptKey, receiptData),
372
+ close: async () => {
373
+ await s3.delete(receiptKey);
374
+ },
375
+ });
376
+
377
+ // Do the work
378
+ await tx.execute('UPDATE orders SET status = ? WHERE id = ?', ['completed', orderId]);
379
+ await tx.execute('INSERT INTO receipts (order_id, s3_key) VALUES (?, ?)', [orderId, receiptKey]);
380
+
381
+ // Commit transaction - mark as committed so rollback doesn't run
382
+ await tx.commit();
383
+ committed = true;
384
+
385
+ return ok({ orderId, receiptKey });
386
+ });
387
+ }
388
+ ```
389
+
390
+ Why this works: If the database commit fails, the S3 file is automatically deleted. If S3 upload fails, the transaction is automatically rolled back. No orphaned files, no partial commits.
391
+
392
+ ### Multi-Tenant Data Export
393
+
394
+ You're exporting data for multiple tenants, each requiring a temporary directory and database connection. If any tenant fails, clean up their resources but continue with others.
395
+
396
+ ```typescript
397
+ import { withScope, ok } from '@jagreehal/workflow';
398
+
399
+ // Helper functions for temp directory management
400
+ async function createTempDir(tenantId: string): Promise<string> {
401
+ const dir = `/tmp/export-${tenantId}-${Date.now()}`;
402
+ await fs.mkdir(dir, { recursive: true });
403
+ return dir;
404
+ }
405
+
406
+ async function releaseTempDir(dir: string): Promise<void> {
407
+ await fs.rm(dir, { recursive: true, force: true });
408
+ }
409
+
410
+ async function exportTenantData(tenantId: string) {
411
+ return withScope(async (scope) => {
412
+ // Temp directory - cleaned up on success or failure
413
+ const exportDir = scope.add({
414
+ value: await createTempDir(tenantId),
415
+ close: async () => {
416
+ await releaseTempDir(exportDir);
417
+ },
418
+ });
419
+
420
+ // Tenant database connection - closed when done
421
+ const conn = scope.add({
422
+ value: await getTenantConnection(tenantId),
423
+ close: async () => {
424
+ await conn.close();
425
+ },
426
+ });
427
+
428
+ // Export data to temp directory
429
+ const data = await conn.query('SELECT * FROM user_data');
430
+ await fs.writeFile(`${exportDir}/data.json`, JSON.stringify(data));
431
+
432
+ // Upload to permanent storage
433
+ const zipPath = `${exportDir}/export.zip`;
434
+ await zipDirectory(exportDir, zipPath);
435
+ await s3.upload(`exports/${tenantId}/${Date.now()}.zip`, zipPath);
436
+
437
+ return ok({ tenantId, recordCount: data.length });
438
+ });
439
+ }
440
+ ```
441
+
442
+ Why this works: Each tenant's temp directory and connection are cleaned up regardless of success or failure. One tenant's error doesn't leave resources hanging for others.
443
+
444
+ ### Webhook Processing with Temporary Credentials
445
+
446
+ You're processing webhooks that require temporary AWS credentials and a distributed lock. Both must be released properly.
447
+
448
+ ```typescript
449
+ import { withScope, ok, err } from '@jagreehal/workflow';
450
+
451
+ async function processWebhook(webhookId: string, payload: unknown) {
452
+ return withScope(async (scope) => {
453
+ // Acquire distributed lock - released on exit
454
+ const lock = scope.add({
455
+ value: await redis.lock(`webhook:${webhookId}`, { ttl: 30000 }),
456
+ close: async () => {
457
+ await lock.release();
458
+ },
459
+ });
460
+
461
+ // Get temporary credentials - no cleanup needed (they expire)
462
+ const creds = await sts.assumeRole({
463
+ RoleArn: 'arn:aws:iam::xxx:role/webhook-processor',
464
+ DurationSeconds: 900
465
+ });
466
+
467
+ // Create S3 client with temp creds
468
+ const s3 = new S3Client({ credentials: creds });
469
+
470
+ // Process the webhook
471
+ const result = await processPayload(payload, s3);
472
+
473
+ // Store result - lock prevents duplicate processing
474
+ await db.execute(
475
+ 'INSERT INTO webhook_results (id, result) VALUES (?, ?)',
476
+ [webhookId, JSON.stringify(result)]
477
+ );
478
+
479
+ return ok(result);
480
+ });
481
+ }
482
+ ```
483
+
484
+ Why this works: The distributed lock is always released, even if processing fails. No deadlocks from crashed handlers. Temporary credentials expire naturally, so no cleanup needed.
485
+
486
+ ## When to Use This
487
+
488
+ - **Multiple resources** - Managing 2+ resources that need cleanup
489
+ - **Complex control flow** - Early returns, multiple error paths, nested conditions
490
+ - **Async cleanup** - Resources that require async operations to release
491
+ - **Dependency chains** - Resources that depend on each other (LIFO order handles this)
492
+
493
+ ## When NOT to Use This
494
+
495
+ - **Single resource** - A simple `try/finally` is clearer for one resource
496
+ - **Non-async cleanup** - If cleanup is synchronous, RAII patterns are simpler
497
+ - **Global resources** - Connection pools, singletons—these have their own lifecycle
498
+ - **Framework-managed** - If your framework handles cleanup (e.g., request-scoped DI), use that
499
+
500
+ ## Summary
501
+
502
+ | Function | Purpose |
503
+ | -------- | ------- |
504
+ | `withScope(fn)` | Auto-cleanup on scope exit |
505
+ | `createResourceScope()` | Manual scope management |
506
+ | `createResource(acquire, release)` | Helper for acquire/release pattern |
507
+ | `isResourceCleanupError(err)` | Type guard for cleanup errors |
508
+
509
+ **The key insight:** Register resources immediately. Cleanup happens automatically in reverse order. No more leaks from early returns or exceptions.