@jagreehal/workflow 1.11.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 (60) hide show
  1. package/README.md +1197 -20
  2. package/dist/batch.cjs +1 -1
  3. package/dist/batch.cjs.map +1 -1
  4. package/dist/batch.js +1 -1
  5. package/dist/batch.js.map +1 -1
  6. package/dist/core.cjs +1 -1
  7. package/dist/core.cjs.map +1 -1
  8. package/dist/core.d.cts +31 -17
  9. package/dist/core.d.ts +31 -17
  10. package/dist/core.js +1 -1
  11. package/dist/core.js.map +1 -1
  12. package/dist/duration.cjs +2 -0
  13. package/dist/duration.cjs.map +1 -0
  14. package/dist/duration.d.cts +246 -0
  15. package/dist/duration.d.ts +246 -0
  16. package/dist/duration.js +2 -0
  17. package/dist/duration.js.map +1 -0
  18. package/dist/index.cjs +5 -5
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +3 -0
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.js +5 -5
  23. package/dist/index.js.map +1 -1
  24. package/dist/match.cjs +2 -0
  25. package/dist/match.cjs.map +1 -0
  26. package/dist/match.d.cts +216 -0
  27. package/dist/match.d.ts +216 -0
  28. package/dist/match.js +2 -0
  29. package/dist/match.js.map +1 -0
  30. package/dist/resource.cjs +1 -1
  31. package/dist/resource.cjs.map +1 -1
  32. package/dist/resource.js +1 -1
  33. package/dist/resource.js.map +1 -1
  34. package/dist/schedule.cjs +2 -0
  35. package/dist/schedule.cjs.map +1 -0
  36. package/dist/schedule.d.cts +387 -0
  37. package/dist/schedule.d.ts +387 -0
  38. package/dist/schedule.js +2 -0
  39. package/dist/schedule.js.map +1 -0
  40. package/dist/workflow.cjs +1 -1
  41. package/dist/workflow.cjs.map +1 -1
  42. package/dist/workflow.js +1 -1
  43. package/dist/workflow.js.map +1 -1
  44. package/docs/api.md +30 -0
  45. package/docs/coming-from-neverthrow.md +103 -10
  46. package/docs/effect-features-to-port.md +210 -0
  47. package/docs/match-examples.test.ts +558 -0
  48. package/docs/match.md +417 -0
  49. package/docs/policies-examples.test.ts +750 -0
  50. package/docs/policies.md +508 -0
  51. package/docs/resource-management-examples.test.ts +729 -0
  52. package/docs/resource-management.md +509 -0
  53. package/docs/schedule-examples.test.ts +736 -0
  54. package/docs/schedule.md +467 -0
  55. package/docs/tagged-error-examples.test.ts +494 -0
  56. package/docs/tagged-error.md +730 -0
  57. package/docs/visualization-examples.test.ts +663 -0
  58. package/docs/visualization.md +395 -0
  59. package/docs/visualize-examples.md +1 -1
  60. package/package.json +17 -2
@@ -0,0 +1,729 @@
1
+ /**
2
+ * Test file to verify all code examples in resource-management.md actually work
3
+ * This file should compile and run without errors
4
+ */
5
+
6
+ import { describe, it, expect, vi } from "vitest";
7
+ import {
8
+ withScope,
9
+ createResourceScope,
10
+ createResource,
11
+ isResourceCleanupError,
12
+ type Resource,
13
+ type ResourceCleanupError,
14
+ } from "../src/resource";
15
+ import { ok, err, createWorkflow, type AsyncResult } from "../src/index";
16
+
17
+ // ============================================================================
18
+ // Mock Resources
19
+ // ============================================================================
20
+
21
+ interface DbClient {
22
+ query: (sql: string, params?: unknown[]) => Promise<unknown[]>;
23
+ close: () => Promise<void>;
24
+ }
25
+
26
+ interface CacheClient {
27
+ get: (key: string) => Promise<unknown>;
28
+ disconnect: () => Promise<void>;
29
+ }
30
+
31
+ interface Session {
32
+ id: string;
33
+ end: () => Promise<void>;
34
+ }
35
+
36
+ async function createDbConnection(): Promise<DbClient> {
37
+ return {
38
+ query: async (_sql: string) => [{ id: "1", name: "Test" }],
39
+ close: async () => {},
40
+ };
41
+ }
42
+
43
+ async function createCache(_db?: DbClient): Promise<CacheClient> {
44
+ return {
45
+ get: async (_key: string) => ({ cached: true }),
46
+ disconnect: async () => {},
47
+ };
48
+ }
49
+
50
+ async function createSession(
51
+ _db: DbClient,
52
+ _cache: CacheClient
53
+ ): Promise<Session> {
54
+ return {
55
+ id: "session-123",
56
+ end: async () => {},
57
+ };
58
+ }
59
+
60
+ type User = { id: string; name: string };
61
+
62
+ async function processUser(_user: unknown): Promise<unknown> {
63
+ return { processed: true };
64
+ }
65
+
66
+ // Alias for consistency with markdown examples
67
+ // In markdown, createConnection is used, but we use createDbConnection in tests
68
+ const createConnection = createDbConnection;
69
+
70
+ // ============================================================================
71
+ // Basic Usage Examples
72
+ // ============================================================================
73
+
74
+ describe("resource-management", () => {
75
+ describe("The Solution: withScope", () => {
76
+ it("processData example with early return", async () => {
77
+ const closeCalls: string[] = [];
78
+
79
+ // Create a mock that returns empty for this test
80
+ const mockDb: DbClient = {
81
+ query: async (_sql: string) => [], // Return empty to trigger early return
82
+ close: async () => {},
83
+ };
84
+
85
+ async function processData(userId: string) {
86
+ return withScope(async (scope) => {
87
+ // scope.add returns the value directly for convenience
88
+ const db = scope.add({
89
+ value: mockDb,
90
+ close: async () => {
91
+ closeCalls.push("db");
92
+ await db.close();
93
+ },
94
+ });
95
+
96
+ const cache = scope.add({
97
+ value: await createCache(),
98
+ close: async () => {
99
+ closeCalls.push("cache");
100
+ await cache.disconnect();
101
+ },
102
+ });
103
+
104
+ const user = await db.query("SELECT * FROM users WHERE id = ?", [
105
+ userId,
106
+ ]);
107
+ if (!user || user.length === 0) {
108
+ return err("USER_NOT_FOUND" as const); // ✓ db and cache still get closed
109
+ }
110
+
111
+ return ok(await processUser(user));
112
+ // ✓ Resources closed automatically in LIFO order
113
+ });
114
+ }
115
+
116
+ const result = await processData("123");
117
+ expect(result.ok).toBe(false);
118
+ expect(closeCalls).toEqual(["cache", "db"]); // LIFO order
119
+ });
120
+ });
121
+
122
+ describe("Basic Usage", () => {
123
+ it("getUsers function example", async () => {
124
+ const closeCalls: string[] = [];
125
+
126
+ async function getUsers(): AsyncResult<User[], "DB_ERROR"> {
127
+ return withScope(async (scope) => {
128
+ // scope.add returns the value directly
129
+ const db = scope.add({
130
+ value: await createDbConnection(),
131
+ close: async () => {
132
+ closeCalls.push("db");
133
+ await db.close();
134
+ },
135
+ });
136
+
137
+ const users = await db.query("SELECT * FROM users");
138
+ return ok(users as User[]);
139
+ });
140
+ }
141
+
142
+ const result = await getUsers();
143
+ expect(result.ok).toBe(true);
144
+ if (result.ok) {
145
+ expect(result.value).toEqual([{ id: "1", name: "Test" }]);
146
+ }
147
+ expect(closeCalls).toEqual(["db"]);
148
+ });
149
+ });
150
+
151
+ describe("withScope", () => {
152
+ it("automatically cleans up resources on success", async () => {
153
+ const closeCalls: string[] = [];
154
+
155
+ const result = await withScope(async (scope) => {
156
+ // scope.add returns the value directly, not a wrapper
157
+ const db = scope.add({
158
+ value: await createDbConnection(),
159
+ close: async () => {
160
+ closeCalls.push("db");
161
+ },
162
+ });
163
+
164
+ scope.add({
165
+ value: await createCache(),
166
+ close: async () => {
167
+ closeCalls.push("cache");
168
+ },
169
+ });
170
+
171
+ // db is the DbClient directly
172
+ const users = await db.query("SELECT * FROM users");
173
+ return ok(users);
174
+ });
175
+
176
+ expect(result.ok).toBe(true);
177
+ expect(closeCalls).toEqual(["cache", "db"]); // LIFO order
178
+ });
179
+
180
+ it("cleans up resources on early return", async () => {
181
+ const closeCalls: string[] = [];
182
+
183
+ const result = await withScope(async (scope) => {
184
+ scope.add({
185
+ value: await createDbConnection(),
186
+ close: async () => {
187
+ closeCalls.push("db");
188
+ },
189
+ });
190
+
191
+ scope.add({
192
+ value: await createCache(),
193
+ close: async () => {
194
+ closeCalls.push("cache");
195
+ },
196
+ });
197
+
198
+ // Early return - resources still get cleaned up
199
+ return err("USER_NOT_FOUND" as const);
200
+ });
201
+
202
+ expect(result.ok).toBe(false);
203
+ expect(closeCalls).toEqual(["cache", "db"]);
204
+ });
205
+
206
+ it("cleans up resources on exception", async () => {
207
+ const closeCalls: string[] = [];
208
+
209
+ await expect(
210
+ withScope(async (scope) => {
211
+ scope.add({
212
+ value: "resource",
213
+ close: async () => {
214
+ closeCalls.push("resource");
215
+ },
216
+ });
217
+
218
+ throw new Error("Unexpected error");
219
+ })
220
+ ).rejects.toThrow("Unexpected error");
221
+
222
+ expect(closeCalls).toEqual(["resource"]);
223
+ });
224
+ });
225
+
226
+ describe("LIFO cleanup order", () => {
227
+ it("closes resources in reverse order", async () => {
228
+ const closeCalls: string[] = [];
229
+
230
+ const result = await withScope(async (scope) => {
231
+ // 1. Connection first
232
+ const db = scope.add({
233
+ value: await createConnection(),
234
+ close: async () => {
235
+ closeCalls.push("db");
236
+ await db.close();
237
+ },
238
+ });
239
+
240
+ // 2. Cache depends on connection
241
+ const cache = scope.add({
242
+ value: await createCache(db), // pass db directly
243
+ close: async () => {
244
+ closeCalls.push("cache");
245
+ await cache.disconnect();
246
+ },
247
+ });
248
+
249
+ // 3. Session depends on both
250
+ const session = scope.add({
251
+ value: await createSession(db, cache),
252
+ close: async () => {
253
+ closeCalls.push("session");
254
+ await session.end();
255
+ },
256
+ });
257
+
258
+ return ok("done");
259
+ });
260
+
261
+ expect(result.ok).toBe(true);
262
+ // Output: Closing session (added last, closed first)
263
+ // Closing cache
264
+ // Closing db (added first, closed last)
265
+ expect(closeCalls).toEqual(["session", "cache", "db"]);
266
+ });
267
+ });
268
+
269
+ describe("createResource helper", () => {
270
+ it("wraps acquire/release pattern", async () => {
271
+ const closeCalls: string[] = [];
272
+
273
+ const result = await withScope(async (scope) => {
274
+ // createResource wraps acquire + release into a Resource
275
+ const dbResource = await createResource(
276
+ () => createConnection(),
277
+ (conn) => {
278
+ closeCalls.push("db");
279
+ return conn.close();
280
+ }
281
+ );
282
+ const db = scope.add(dbResource);
283
+
284
+ const cacheResource = await createResource(
285
+ () => createCache(),
286
+ (cache) => {
287
+ closeCalls.push("cache");
288
+ return cache.disconnect();
289
+ }
290
+ );
291
+ scope.add(cacheResource);
292
+
293
+ // Use resources
294
+ return ok(await db.query("SELECT 1"));
295
+ });
296
+
297
+ expect(result.ok).toBe(true);
298
+ expect(closeCalls).toEqual(["cache", "db"]);
299
+ });
300
+ });
301
+
302
+ describe("cleanup errors", () => {
303
+ it("returns ResourceCleanupError on cleanup failure", async () => {
304
+ const result = await withScope(async (scope) => {
305
+ scope.add({
306
+ value: "resource1",
307
+ close: async () => {
308
+ throw new Error("Cleanup failed!");
309
+ },
310
+ });
311
+
312
+ return ok("done");
313
+ });
314
+
315
+ expect(result.ok).toBe(false);
316
+ if (!result.ok && isResourceCleanupError(result.error)) {
317
+ expect(result.error.type).toBe("RESOURCE_CLEANUP_ERROR");
318
+ expect(result.error.errors.length).toBe(1);
319
+ expect(result.error.originalResult).toEqual({ ok: true, value: "done" });
320
+ }
321
+ });
322
+
323
+ it("collects multiple cleanup errors", async () => {
324
+ const result = await withScope(async (scope) => {
325
+ scope.add({
326
+ value: "resource1",
327
+ close: async () => {
328
+ throw new Error("Cleanup 1 failed");
329
+ },
330
+ });
331
+
332
+ scope.add({
333
+ value: "resource2",
334
+ close: async () => {
335
+ throw new Error("Cleanup 2 failed");
336
+ },
337
+ });
338
+
339
+ return ok("done");
340
+ });
341
+
342
+ expect(result.ok).toBe(false);
343
+ if (!result.ok && isResourceCleanupError(result.error)) {
344
+ expect(result.error.errors.length).toBe(2);
345
+ }
346
+ });
347
+
348
+ it("continues cleanup even if one resource fails", async () => {
349
+ const closeCalls: string[] = [];
350
+
351
+ await withScope(async (scope) => {
352
+ scope.add({
353
+ value: "resource1",
354
+ close: async () => {
355
+ closeCalls.push("resource1");
356
+ },
357
+ });
358
+
359
+ scope.add({
360
+ value: "resource2",
361
+ close: async () => {
362
+ closeCalls.push("resource2-fail");
363
+ throw new Error("Cleanup failed");
364
+ },
365
+ });
366
+
367
+ scope.add({
368
+ value: "resource3",
369
+ close: async () => {
370
+ closeCalls.push("resource3");
371
+ },
372
+ });
373
+
374
+ return ok("done");
375
+ });
376
+
377
+ // All resources attempted in LIFO order
378
+ expect(closeCalls).toEqual(["resource3", "resource2-fail", "resource1"]);
379
+ });
380
+ });
381
+
382
+ describe("isResourceCleanupError type guard", () => {
383
+ it("correctly identifies ResourceCleanupError", () => {
384
+ const error: ResourceCleanupError = {
385
+ type: "RESOURCE_CLEANUP_ERROR",
386
+ errors: [new Error("test")],
387
+ };
388
+
389
+ expect(isResourceCleanupError(error)).toBe(true);
390
+ expect(isResourceCleanupError({ type: "OTHER" })).toBe(false);
391
+ expect(isResourceCleanupError(null)).toBe(false);
392
+ expect(isResourceCleanupError("string")).toBe(false);
393
+ });
394
+ });
395
+
396
+ describe("createResourceScope (manual)", () => {
397
+ it("allows manual scope management", async () => {
398
+ const closeCalls: string[] = [];
399
+ const scope = createResourceScope();
400
+
401
+ try {
402
+ // scope.add returns the value directly, not a Resource wrapper
403
+ const db = scope.add({
404
+ value: await createConnection(),
405
+ close: async () => {
406
+ closeCalls.push("db");
407
+ await db.close(); // db IS the connection
408
+ },
409
+ });
410
+
411
+ const cache = scope.add({
412
+ value: await createCache(),
413
+ close: async () => {
414
+ closeCalls.push("cache");
415
+ await cache.disconnect(); // cache IS the client
416
+ },
417
+ });
418
+
419
+ // Check scope state
420
+ expect(scope.size()).toBe(2);
421
+
422
+ // Use resources...
423
+ await db.query("SELECT 1");
424
+ } finally {
425
+ await scope.close(); // Manual cleanup
426
+ }
427
+
428
+ expect(closeCalls).toEqual(["cache", "db"]);
429
+ expect(scope.size()).toBe(0); // Cleared after close
430
+ });
431
+
432
+ it("provides has() method to check resource membership", async () => {
433
+ const scope = createResourceScope();
434
+
435
+ const resource: Resource<string> = {
436
+ value: "test",
437
+ close: async () => {},
438
+ };
439
+
440
+ scope.add(resource);
441
+ expect(scope.has(resource)).toBe(true);
442
+
443
+ const otherResource: Resource<string> = {
444
+ value: "other",
445
+ close: async () => {},
446
+ };
447
+ expect(scope.has(otherResource)).toBe(false);
448
+
449
+ await scope.close();
450
+ });
451
+
452
+ it("scope.has() example from documentation note", async () => {
453
+ const scope = createResourceScope();
454
+
455
+ // scope.has() expects a Resource<T> object, not the value
456
+ const dbResource: Resource<DbClient> = {
457
+ value: await createDbConnection(),
458
+ close: async () => {
459
+ await dbResource.value.close();
460
+ },
461
+ };
462
+ scope.add(dbResource);
463
+ expect(scope.has(dbResource)).toBe(true); // true
464
+
465
+ await scope.close();
466
+ });
467
+ });
468
+
469
+ describe("integration with workflows", () => {
470
+ it("works inside workflow steps", async () => {
471
+ const closeCalls: string[] = [];
472
+
473
+ const deps = {
474
+ processWithResources: async (
475
+ id: string
476
+ ): AsyncResult<unknown[], "PROCESS_ERROR" | ResourceCleanupError> => {
477
+ return withScope(async (scope) => {
478
+ const db = scope.add({
479
+ value: await createConnection(),
480
+ close: async () => {
481
+ closeCalls.push("db");
482
+ await db.close();
483
+ },
484
+ });
485
+
486
+ const data = await db.query("SELECT * FROM items WHERE id = ?", [
487
+ id,
488
+ ]);
489
+ return ok(data);
490
+ });
491
+ },
492
+ };
493
+
494
+ const workflow = createWorkflow(deps);
495
+
496
+ const result = await workflow(async (step, { processWithResources }) => {
497
+ const data = await step(processWithResources("123"));
498
+ return ok(data);
499
+ });
500
+
501
+ expect(result.ok).toBe(true);
502
+ expect(closeCalls).toEqual(["db"]);
503
+ });
504
+ });
505
+
506
+ describe("Best Practices", () => {
507
+ it("DO: Register cleanup immediately after acquisition", async () => {
508
+ const closeCalls: string[] = [];
509
+
510
+ await withScope(async (scope) => {
511
+ // ✓ Register right after acquiring
512
+ const db = scope.add({
513
+ value: await createConnection(),
514
+ close: async () => {
515
+ closeCalls.push("db");
516
+ await db.close();
517
+ },
518
+ });
519
+
520
+ return ok("done");
521
+ });
522
+
523
+ expect(closeCalls).toEqual(["db"]);
524
+ });
525
+
526
+ it("DO: Handle cleanup errors appropriately", async () => {
527
+ const logger = {
528
+ error: vi.fn(),
529
+ };
530
+
531
+ const result = await withScope(async (scope) => {
532
+ scope.add({
533
+ value: await createConnection(),
534
+ close: async () => {
535
+ throw new Error("Cleanup failed");
536
+ },
537
+ });
538
+
539
+ return ok("success");
540
+ });
541
+
542
+ if (!result.ok) {
543
+ if (isResourceCleanupError(result.error)) {
544
+ // Log cleanup issues but don't expose to users
545
+ logger.error("Resource cleanup failed", result.error.errors);
546
+ // Return the original error if there was one
547
+ if (
548
+ result.error.originalResult &&
549
+ typeof result.error.originalResult === "object" &&
550
+ result.error.originalResult !== null &&
551
+ "ok" in result.error.originalResult &&
552
+ !result.error.originalResult.ok
553
+ ) {
554
+ return result.error.originalResult;
555
+ }
556
+ }
557
+ }
558
+
559
+ expect(result.ok).toBe(false);
560
+ expect(logger.error).toHaveBeenCalled();
561
+ });
562
+ });
563
+
564
+ // ============================================================================
565
+ // Real-World Scenarios
566
+ // ============================================================================
567
+
568
+ describe("real-world scenarios", () => {
569
+ describe("Database Transaction with File Upload", () => {
570
+ it("rolls back transaction and deletes file on failure", async () => {
571
+ const rollbackCalls: string[] = [];
572
+ const deleteCalls: string[] = [];
573
+
574
+ const mockDb = {
575
+ beginTransaction: async () => ({
576
+ execute: async (_sql: string, _params: unknown[]) => {},
577
+ commit: async () => {},
578
+ rollback: async () => {
579
+ rollbackCalls.push("rollback");
580
+ },
581
+ }),
582
+ };
583
+
584
+ const mockS3 = {
585
+ upload: async (_key: string, _data: unknown) => ({ key: "receipts/123.pdf" }),
586
+ delete: async (_key: string) => {
587
+ deleteCalls.push("delete");
588
+ },
589
+ };
590
+
591
+ async function processOrder(orderId: string, _receiptData: Buffer) {
592
+ return withScope(async (scope) => {
593
+ // In failure case, committed stays false
594
+ const committed = false;
595
+
596
+ const tx = scope.add({
597
+ value: await mockDb.beginTransaction(),
598
+ close: async () => {
599
+ if (!committed) {
600
+ await tx.rollback();
601
+ }
602
+ },
603
+ });
604
+
605
+ const receiptKey = `receipts/${orderId}.pdf`;
606
+ scope.add({
607
+ value: await mockS3.upload(receiptKey, Buffer.from("data")),
608
+ close: async () => {
609
+ await mockS3.delete(receiptKey);
610
+ },
611
+ });
612
+
613
+ // Simulate failure before commit - return error result
614
+ return err("PROCESSING_FAILED" as const);
615
+ });
616
+ }
617
+
618
+ const result = await processOrder("123", Buffer.from("data"));
619
+
620
+ expect(result.ok).toBe(false);
621
+ expect(rollbackCalls).toContain("rollback");
622
+ expect(deleteCalls).toContain("delete");
623
+ });
624
+ });
625
+
626
+ describe("Multi-Tenant Data Export", () => {
627
+ it("cleans up resources for each tenant", async () => {
628
+ const cleanupCalls: string[] = [];
629
+
630
+ async function createTempDir(tenantId: string): Promise<string> {
631
+ return `/tmp/export-${tenantId}-${Date.now()}`;
632
+ }
633
+
634
+ async function releaseTempDir(dir: string): Promise<void> {
635
+ cleanupCalls.push(`cleanup-${dir}`);
636
+ }
637
+
638
+ const mockConn = {
639
+ query: async (_sql: string) => [{ id: "1" }, { id: "2" }],
640
+ close: async () => {
641
+ cleanupCalls.push("close-conn");
642
+ },
643
+ };
644
+
645
+ async function getTenantConnection(_tenantId: string) {
646
+ return mockConn;
647
+ }
648
+
649
+ async function exportTenantData(tenantId: string) {
650
+ return withScope(async (scope) => {
651
+ const exportDir = scope.add({
652
+ value: await createTempDir(tenantId),
653
+ close: async () => {
654
+ await releaseTempDir(exportDir);
655
+ },
656
+ });
657
+
658
+ const conn = scope.add({
659
+ value: await getTenantConnection(tenantId),
660
+ close: async () => {
661
+ await conn.close();
662
+ },
663
+ });
664
+
665
+ const data = await conn.query("SELECT * FROM user_data");
666
+
667
+ return ok({ tenantId, recordCount: data.length });
668
+ });
669
+ }
670
+
671
+ const result = await exportTenantData("tenant-1");
672
+
673
+ expect(result.ok).toBe(true);
674
+ if (result.ok) {
675
+ expect(result.value.recordCount).toBe(2);
676
+ }
677
+ // Resources should be cleaned up
678
+ expect(cleanupCalls.length).toBeGreaterThan(0);
679
+ });
680
+ });
681
+
682
+ describe("Webhook Processing with Temporary Credentials", () => {
683
+ it("releases lock even on failure", async () => {
684
+ const releaseCalls: string[] = [];
685
+
686
+ const mockLock = {
687
+ release: async () => {
688
+ releaseCalls.push("lock-released");
689
+ },
690
+ };
691
+
692
+ const mockRedis = {
693
+ lock: async (_key: string, _opts: unknown) => mockLock,
694
+ };
695
+
696
+ async function processWebhook(webhookId: string, _payload: unknown) {
697
+ return withScope(async (scope) => {
698
+ const lock = scope.add({
699
+ value: await mockRedis.lock(`webhook:${webhookId}`, { ttl: 30000 }),
700
+ close: async () => {
701
+ await lock.release();
702
+ },
703
+ });
704
+
705
+ // Simulate processing failure - return error result instead of throwing
706
+ return err("PROCESSING_FAILED" as const);
707
+ });
708
+ }
709
+
710
+ const result = await processWebhook("webhook-123", {});
711
+
712
+ expect(result.ok).toBe(false);
713
+ // Lock should be released even on failure
714
+ expect(releaseCalls).toContain("lock-released");
715
+ });
716
+ });
717
+ });
718
+ });
719
+
720
+ // ============================================================================
721
+ // Export to avoid unused variable warnings
722
+ // ============================================================================
723
+
724
+ export {
725
+ createDbConnection,
726
+ createCache,
727
+ createSession,
728
+ processUser,
729
+ };