@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.
- package/README.md +1197 -20
- package/dist/duration.cjs +2 -0
- package/dist/duration.cjs.map +1 -0
- package/dist/duration.d.cts +246 -0
- package/dist/duration.d.ts +246 -0
- package/dist/duration.js +2 -0
- package/dist/duration.js.map +1 -0
- package/dist/index.cjs +5 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/match.cjs +2 -0
- package/dist/match.cjs.map +1 -0
- package/dist/match.d.cts +216 -0
- package/dist/match.d.ts +216 -0
- package/dist/match.js +2 -0
- package/dist/match.js.map +1 -0
- package/dist/schedule.cjs +2 -0
- package/dist/schedule.cjs.map +1 -0
- package/dist/schedule.d.cts +387 -0
- package/dist/schedule.d.ts +387 -0
- package/dist/schedule.js +2 -0
- package/dist/schedule.js.map +1 -0
- package/docs/api.md +30 -0
- package/docs/coming-from-neverthrow.md +103 -10
- package/docs/effect-features-to-port.md +210 -0
- package/docs/match-examples.test.ts +558 -0
- package/docs/match.md +417 -0
- package/docs/policies-examples.test.ts +750 -0
- package/docs/policies.md +508 -0
- package/docs/resource-management-examples.test.ts +729 -0
- package/docs/resource-management.md +509 -0
- package/docs/schedule-examples.test.ts +736 -0
- package/docs/schedule.md +467 -0
- package/docs/tagged-error-examples.test.ts +494 -0
- package/docs/tagged-error.md +730 -0
- package/docs/visualization-examples.test.ts +663 -0
- package/docs/visualization.md +395 -0
- package/docs/visualize-examples.md +1 -1
- 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.
|