@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.
- package/README.md +1197 -20
- package/dist/batch.cjs +1 -1
- package/dist/batch.cjs.map +1 -1
- package/dist/batch.js +1 -1
- package/dist/batch.js.map +1 -1
- package/dist/core.cjs +1 -1
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +31 -17
- package/dist/core.d.ts +31 -17
- package/dist/core.js +1 -1
- package/dist/core.js.map +1 -1
- 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/resource.cjs +1 -1
- package/dist/resource.cjs.map +1 -1
- package/dist/resource.js +1 -1
- package/dist/resource.js.map +1 -1
- 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/dist/workflow.cjs +1 -1
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.js +1 -1
- package/dist/workflow.js.map +1 -1
- 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,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
|
+
};
|