@jagreehal/workflow 1.0.0 → 1.1.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 +61 -0
- package/dist/core.cjs +1 -1
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +70 -0
- package/dist/core.d.ts +70 -0
- package/dist/core.js +1 -1
- package/dist/core.js.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- 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/package.json +15 -17
- package/docs/___advanced.test.ts +0 -565
- package/docs/___advanced_VERIFICATION.md +0 -64
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jagreehal/workflow",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed async workflows with automatic error inference. Build type-safe workflows with Result types, step caching, resume state, and human-in-the-loop support.",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -28,21 +28,6 @@
|
|
|
28
28
|
"README.md",
|
|
29
29
|
"docs"
|
|
30
30
|
],
|
|
31
|
-
"scripts": {
|
|
32
|
-
"build": "tsup",
|
|
33
|
-
"build:tsc": "tsc --noEmit",
|
|
34
|
-
"test": "vitest run",
|
|
35
|
-
"test:watch": "vitest watch",
|
|
36
|
-
"test:coverage": "vitest run --coverage",
|
|
37
|
-
"lint": "eslint .",
|
|
38
|
-
"clean": "rm -rf dist lib",
|
|
39
|
-
"prebuild": "pnpm clean",
|
|
40
|
-
"prepare": "pnpm build",
|
|
41
|
-
"prepublishOnly": "pnpm build:tsc && pnpm run test && pnpm run lint",
|
|
42
|
-
"changeset": "changeset",
|
|
43
|
-
"version-packages": "changeset version",
|
|
44
|
-
"release": "pnpm build && changeset publish"
|
|
45
|
-
},
|
|
46
31
|
"keywords": [
|
|
47
32
|
"workflow",
|
|
48
33
|
"workflows",
|
|
@@ -115,5 +100,18 @@
|
|
|
115
100
|
"publishConfig": {
|
|
116
101
|
"access": "public",
|
|
117
102
|
"registry": "https://registry.npmjs.org/"
|
|
103
|
+
},
|
|
104
|
+
"scripts": {
|
|
105
|
+
"build": "tsup",
|
|
106
|
+
"build:tsc": "tsc --noEmit",
|
|
107
|
+
"test": "vitest run",
|
|
108
|
+
"test:watch": "vitest watch",
|
|
109
|
+
"test:coverage": "vitest run --coverage",
|
|
110
|
+
"lint": "eslint .",
|
|
111
|
+
"clean": "rm -rf dist lib",
|
|
112
|
+
"prebuild": "pnpm clean",
|
|
113
|
+
"changeset": "changeset",
|
|
114
|
+
"version-packages": "changeset version",
|
|
115
|
+
"release": "pnpm build && changeset publish"
|
|
118
116
|
}
|
|
119
|
-
}
|
|
117
|
+
}
|
package/docs/___advanced.test.ts
DELETED
|
@@ -1,565 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test file to verify all code examples in docs/advanced.md work as documented.
|
|
3
|
-
* Run with: pnpm vitest run docs/advanced.test.ts
|
|
4
|
-
*/
|
|
5
|
-
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
6
|
-
|
|
7
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
8
|
-
import {
|
|
9
|
-
all,
|
|
10
|
-
allSettled,
|
|
11
|
-
any,
|
|
12
|
-
partition,
|
|
13
|
-
allAsync,
|
|
14
|
-
allSettledAsync,
|
|
15
|
-
anyAsync,
|
|
16
|
-
from,
|
|
17
|
-
fromPromise,
|
|
18
|
-
tryAsync,
|
|
19
|
-
fromNullable,
|
|
20
|
-
map,
|
|
21
|
-
mapError,
|
|
22
|
-
match,
|
|
23
|
-
andThen,
|
|
24
|
-
tap,
|
|
25
|
-
ok,
|
|
26
|
-
err,
|
|
27
|
-
type AsyncResult,
|
|
28
|
-
type Result,
|
|
29
|
-
run,
|
|
30
|
-
createWorkflow,
|
|
31
|
-
createApprovalStep,
|
|
32
|
-
createHITLCollector,
|
|
33
|
-
isPendingApproval,
|
|
34
|
-
injectApproval,
|
|
35
|
-
hasPendingApproval,
|
|
36
|
-
getPendingApprovals,
|
|
37
|
-
clearStep,
|
|
38
|
-
isStepComplete,
|
|
39
|
-
} from '../src/index';
|
|
40
|
-
|
|
41
|
-
// Types for examples
|
|
42
|
-
type User = { id: string; name: string };
|
|
43
|
-
type Post = { id: number; title: string };
|
|
44
|
-
|
|
45
|
-
describe('Advanced Examples', () => {
|
|
46
|
-
describe('Batch operations', () => {
|
|
47
|
-
it('should work with all() - all must succeed', () => {
|
|
48
|
-
const combined = all([ok(1), ok(2), ok(3)]);
|
|
49
|
-
expect(combined.ok).toBe(true);
|
|
50
|
-
if (combined.ok) {
|
|
51
|
-
expect(combined.value).toEqual([1, 2, 3]);
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('should work with all() - short-circuits on first error', () => {
|
|
56
|
-
const combined = all([ok(1), err('ERROR'), ok(3)]);
|
|
57
|
-
expect(combined.ok).toBe(false);
|
|
58
|
-
if (!combined.ok) {
|
|
59
|
-
expect(combined.error).toBe('ERROR');
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('should work with allSettled() - collects all errors', async () => {
|
|
64
|
-
const validateEmail = (email: string): Result<string, 'INVALID_EMAIL'> =>
|
|
65
|
-
email.includes('@') ? ok(email) : err('INVALID_EMAIL');
|
|
66
|
-
|
|
67
|
-
const validatePassword = (password: string): Result<string, 'WEAK_PASSWORD'> =>
|
|
68
|
-
password.length >= 8 ? ok(password) : err('WEAK_PASSWORD');
|
|
69
|
-
|
|
70
|
-
const email = 'test@example.com';
|
|
71
|
-
const password = 'short';
|
|
72
|
-
const validated = allSettled([validateEmail(email), validatePassword(password)]);
|
|
73
|
-
|
|
74
|
-
expect(validated.ok).toBe(false);
|
|
75
|
-
if (!validated.ok) {
|
|
76
|
-
expect(validated.error).toHaveLength(1);
|
|
77
|
-
expect(validated.error[0].error).toBe('WEAK_PASSWORD');
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('should work with any() - first success wins', () => {
|
|
82
|
-
const first = any([err('A'), ok('success'), err('B')]);
|
|
83
|
-
expect(first.ok).toBe(true);
|
|
84
|
-
if (first.ok) {
|
|
85
|
-
expect(first.value).toBe('success');
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('should work with any() - all errors returns first error', () => {
|
|
90
|
-
const first = any([err('A'), err('B'), err('C')]);
|
|
91
|
-
expect(first.ok).toBe(false);
|
|
92
|
-
if (!first.ok) {
|
|
93
|
-
expect(first.error).toBe('A');
|
|
94
|
-
}
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('should work with partition() - split successes and failures', () => {
|
|
98
|
-
const results: Result<number, string>[] = [
|
|
99
|
-
ok(1),
|
|
100
|
-
err('ERROR_1'),
|
|
101
|
-
ok(3),
|
|
102
|
-
err('ERROR_2'),
|
|
103
|
-
];
|
|
104
|
-
const { values, errors } = partition(results);
|
|
105
|
-
|
|
106
|
-
expect(values).toEqual([1, 3]);
|
|
107
|
-
expect(errors).toEqual(['ERROR_1', 'ERROR_2']);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('should work with allAsync()', async () => {
|
|
111
|
-
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
|
|
112
|
-
id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
|
|
113
|
-
|
|
114
|
-
const fetchPosts = async (_userId: string): AsyncResult<Post[], 'FETCH_ERROR'> =>
|
|
115
|
-
ok([{ id: 1, title: 'Hello' }]);
|
|
116
|
-
|
|
117
|
-
const result = await allAsync([fetchUser('1'), fetchPosts('1')]);
|
|
118
|
-
expect(result.ok).toBe(true);
|
|
119
|
-
if (result.ok) {
|
|
120
|
-
expect(result.value[0].name).toBe('Alice');
|
|
121
|
-
expect(result.value[1]).toHaveLength(1);
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('should work with allSettledAsync()', async () => {
|
|
126
|
-
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
|
|
127
|
-
id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
|
|
128
|
-
|
|
129
|
-
const fetchPosts = async (_userId: string): AsyncResult<Post[], 'FETCH_ERROR'> =>
|
|
130
|
-
ok([{ id: 1, title: 'Hello' }]);
|
|
131
|
-
|
|
132
|
-
const result = await allSettledAsync([fetchUser('2'), fetchPosts('1')]);
|
|
133
|
-
expect(result.ok).toBe(false);
|
|
134
|
-
if (!result.ok) {
|
|
135
|
-
expect(result.error).toHaveLength(1);
|
|
136
|
-
expect(result.error[0].error).toBe('NOT_FOUND');
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('should work with anyAsync()', async () => {
|
|
141
|
-
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
|
|
142
|
-
id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
|
|
143
|
-
|
|
144
|
-
const fetchPosts = async (_userId: string): AsyncResult<Post[], 'FETCH_ERROR'> =>
|
|
145
|
-
ok([{ id: 1, title: 'Hello' }]);
|
|
146
|
-
|
|
147
|
-
const result = await anyAsync([fetchUser('2'), fetchPosts('1')]);
|
|
148
|
-
expect(result.ok).toBe(true);
|
|
149
|
-
if (result.ok) {
|
|
150
|
-
expect(result.value).toHaveLength(1);
|
|
151
|
-
}
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
describe('Dynamic error mapping', () => {
|
|
156
|
-
it('should work with onError option in step.try', async () => {
|
|
157
|
-
const workflow = createWorkflow({});
|
|
158
|
-
|
|
159
|
-
// Mock fetch
|
|
160
|
-
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
|
161
|
-
|
|
162
|
-
const result = await workflow(async (step) => {
|
|
163
|
-
const data = await step.try(
|
|
164
|
-
() => fetch('/api/data'),
|
|
165
|
-
{ onError: (e) => ({ type: 'API_ERROR' as const, message: String(e) }) }
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
return data;
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
expect(result.ok).toBe(false);
|
|
172
|
-
if (!result.ok) {
|
|
173
|
-
expect(result.error).toHaveProperty('type', 'API_ERROR');
|
|
174
|
-
expect(result.error).toHaveProperty('message');
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it('should work with onError for validation errors', async () => {
|
|
179
|
-
// Simulate a schema validation library
|
|
180
|
-
const schema = {
|
|
181
|
-
parse: (data: unknown) => {
|
|
182
|
-
if (typeof data !== 'object' || data === null) {
|
|
183
|
-
throw { issues: ['Invalid data'] };
|
|
184
|
-
}
|
|
185
|
-
return data;
|
|
186
|
-
},
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
const workflow = createWorkflow({});
|
|
190
|
-
|
|
191
|
-
const result = await workflow(async (step) => {
|
|
192
|
-
const parsed = await step.try(
|
|
193
|
-
() => schema.parse(null),
|
|
194
|
-
{ onError: (e) => ({ type: 'VALIDATION_ERROR' as const, issues: (e as { issues: string[] }).issues }) }
|
|
195
|
-
);
|
|
196
|
-
|
|
197
|
-
return parsed;
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
expect(result.ok).toBe(false);
|
|
201
|
-
if (!result.ok) {
|
|
202
|
-
expect(result.error).toHaveProperty('type', 'VALIDATION_ERROR');
|
|
203
|
-
expect(result.error).toHaveProperty('issues');
|
|
204
|
-
}
|
|
205
|
-
});
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
describe('Wrapping existing code', () => {
|
|
209
|
-
it('should work with from() for sync throwing functions', () => {
|
|
210
|
-
const parsed = from(
|
|
211
|
-
() => JSON.parse('{"key": "value"}'),
|
|
212
|
-
(cause) => ({ type: 'PARSE_ERROR' as const, cause })
|
|
213
|
-
);
|
|
214
|
-
|
|
215
|
-
expect(parsed.ok).toBe(true);
|
|
216
|
-
if (parsed.ok) {
|
|
217
|
-
expect(parsed.value).toEqual({ key: 'value' });
|
|
218
|
-
}
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it('should work with from() - handles parse errors', () => {
|
|
222
|
-
const parsed = from(
|
|
223
|
-
() => JSON.parse('invalid json'),
|
|
224
|
-
(cause) => ({ type: 'PARSE_ERROR' as const, cause })
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
expect(parsed.ok).toBe(false);
|
|
228
|
-
if (!parsed.ok) {
|
|
229
|
-
expect(parsed.error.type).toBe('PARSE_ERROR');
|
|
230
|
-
}
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it('should work with fromPromise()', async () => {
|
|
234
|
-
// Mock fetch
|
|
235
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
236
|
-
ok: true,
|
|
237
|
-
json: async () => ({ data: 'test' }),
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
const result = await fromPromise(
|
|
241
|
-
fetch('/api').then(async (r) => {
|
|
242
|
-
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
243
|
-
return r.json();
|
|
244
|
-
}),
|
|
245
|
-
() => 'FETCH_FAILED' as const
|
|
246
|
-
);
|
|
247
|
-
|
|
248
|
-
expect(result.ok).toBe(true);
|
|
249
|
-
if (result.ok) {
|
|
250
|
-
expect(result.value).toEqual({ data: 'test' });
|
|
251
|
-
}
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it('should work with fromPromise() - handles fetch errors', async () => {
|
|
255
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
256
|
-
ok: false,
|
|
257
|
-
status: 404,
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
const result = await fromPromise(
|
|
261
|
-
fetch('/api').then(async (r) => {
|
|
262
|
-
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
263
|
-
return r.json();
|
|
264
|
-
}),
|
|
265
|
-
() => 'FETCH_FAILED' as const
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
expect(result.ok).toBe(false);
|
|
269
|
-
if (!result.ok) {
|
|
270
|
-
expect(result.error).toBe('FETCH_FAILED');
|
|
271
|
-
}
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it('should work with tryAsync()', async () => {
|
|
275
|
-
const result = await tryAsync(
|
|
276
|
-
async () => {
|
|
277
|
-
return { data: 'test' };
|
|
278
|
-
},
|
|
279
|
-
() => 'ASYNC_ERROR' as const
|
|
280
|
-
);
|
|
281
|
-
|
|
282
|
-
expect(result.ok).toBe(true);
|
|
283
|
-
if (result.ok) {
|
|
284
|
-
expect(result.value).toEqual({ data: 'test' });
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
it('should work with tryAsync() - handles async errors', async () => {
|
|
289
|
-
const result = await tryAsync(
|
|
290
|
-
async () => {
|
|
291
|
-
throw new Error('Async error');
|
|
292
|
-
},
|
|
293
|
-
() => 'ASYNC_ERROR' as const
|
|
294
|
-
);
|
|
295
|
-
|
|
296
|
-
expect(result.ok).toBe(false);
|
|
297
|
-
if (!result.ok) {
|
|
298
|
-
expect(result.error).toBe('ASYNC_ERROR');
|
|
299
|
-
}
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it('should work with fromNullable()', () => {
|
|
303
|
-
// Simulate a DOM element (in real usage, this would be document.getElementById('app'))
|
|
304
|
-
const element = fromNullable(
|
|
305
|
-
{ id: 'app', tagName: 'DIV' } as unknown as HTMLElement,
|
|
306
|
-
() => 'NOT_FOUND' as const
|
|
307
|
-
);
|
|
308
|
-
|
|
309
|
-
expect(element.ok).toBe(true);
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it('should work with fromNullable() - handles null', () => {
|
|
313
|
-
const element = fromNullable(
|
|
314
|
-
null,
|
|
315
|
-
() => 'NOT_FOUND' as const
|
|
316
|
-
);
|
|
317
|
-
|
|
318
|
-
expect(element.ok).toBe(false);
|
|
319
|
-
if (!element.ok) {
|
|
320
|
-
expect(element.error).toBe('NOT_FOUND');
|
|
321
|
-
}
|
|
322
|
-
});
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
describe('Transformers', () => {
|
|
326
|
-
it('should work with map()', () => {
|
|
327
|
-
const doubled = map(ok(21), (n) => n * 2);
|
|
328
|
-
expect(doubled.ok).toBe(true);
|
|
329
|
-
if (doubled.ok) {
|
|
330
|
-
expect(doubled.value).toBe(42);
|
|
331
|
-
}
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
it('should work with mapError()', () => {
|
|
335
|
-
const mapped = mapError(err('not_found'), (e) => e.toUpperCase());
|
|
336
|
-
expect(mapped.ok).toBe(false);
|
|
337
|
-
if (!mapped.ok) {
|
|
338
|
-
expect(mapped.error).toBe('NOT_FOUND');
|
|
339
|
-
}
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it('should work with match()', () => {
|
|
343
|
-
const result = ok({ name: 'Alice' });
|
|
344
|
-
const message = match(result, {
|
|
345
|
-
ok: (user) => `Hello ${user.name}`,
|
|
346
|
-
err: (error) => `Error: ${error}`,
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
expect(message).toBe('Hello Alice');
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it('should work with match() - error case', () => {
|
|
353
|
-
const result = err('NOT_FOUND');
|
|
354
|
-
const message = match(result, {
|
|
355
|
-
ok: (user) => `Hello ${user.name}`,
|
|
356
|
-
err: (error) => `Error: ${error}`,
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
expect(message).toBe('Error: NOT_FOUND');
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
it('should work with andThen()', async () => {
|
|
363
|
-
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
|
|
364
|
-
id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
|
|
365
|
-
|
|
366
|
-
const fetchPosts = async (userId: string): AsyncResult<Post[], 'FETCH_ERROR'> =>
|
|
367
|
-
ok([{ id: 1, title: 'Hello' }]);
|
|
368
|
-
|
|
369
|
-
const userResult = await fetchUser('1');
|
|
370
|
-
const userPosts = await andThen(userResult, (user) => fetchPosts(user.id));
|
|
371
|
-
|
|
372
|
-
expect(userPosts.ok).toBe(true);
|
|
373
|
-
if (userPosts.ok) {
|
|
374
|
-
expect(userPosts.value).toHaveLength(1);
|
|
375
|
-
}
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
it('should work with tap()', () => {
|
|
379
|
-
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
380
|
-
const result = ok({ name: 'Alice' });
|
|
381
|
-
const logged = tap(result, (user) => console.log('Got user:', user.name));
|
|
382
|
-
|
|
383
|
-
expect(logged.ok).toBe(true);
|
|
384
|
-
expect(consoleSpy).toHaveBeenCalledWith('Got user:', 'Alice');
|
|
385
|
-
consoleSpy.mockRestore();
|
|
386
|
-
});
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
describe('Human-in-the-loop (HITL)', () => {
|
|
390
|
-
it('should work with createApprovalStep and createHITLCollector', async () => {
|
|
391
|
-
const fetchData = async (id: string): AsyncResult<{ data: string }, 'NOT_FOUND'> =>
|
|
392
|
-
ok({ data: 'test data' });
|
|
393
|
-
|
|
394
|
-
const requireManagerApproval = createApprovalStep<{ approvedBy: string }>({
|
|
395
|
-
key: 'manager-approval',
|
|
396
|
-
checkApproval: async () => {
|
|
397
|
-
// Simulate pending approval
|
|
398
|
-
return { status: 'pending' as const };
|
|
399
|
-
},
|
|
400
|
-
pendingReason: 'Waiting for manager approval',
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
const collector = createHITLCollector();
|
|
404
|
-
const workflow = createWorkflow(
|
|
405
|
-
{ fetchData, requireManagerApproval },
|
|
406
|
-
{ onEvent: collector.handleEvent }
|
|
407
|
-
);
|
|
408
|
-
|
|
409
|
-
const result = await workflow(async (step) => {
|
|
410
|
-
const data = await step(() => fetchData('123'), { key: 'data' });
|
|
411
|
-
const approval = await step(requireManagerApproval, { key: 'manager-approval' });
|
|
412
|
-
return { data, approvedBy: approval.approvedBy };
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
expect(result.ok).toBe(false);
|
|
416
|
-
expect(isPendingApproval(result.error)).toBe(true);
|
|
417
|
-
|
|
418
|
-
if (!result.ok && isPendingApproval(result.error)) {
|
|
419
|
-
expect(result.error.reason).toBe('Waiting for manager approval');
|
|
420
|
-
expect(collector.hasPendingApprovals()).toBe(true);
|
|
421
|
-
const pending = collector.getPendingApprovals();
|
|
422
|
-
expect(pending).toHaveLength(1);
|
|
423
|
-
expect(pending[0].stepKey).toBe('manager-approval');
|
|
424
|
-
}
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
it('should work with injectApproval for resuming', async () => {
|
|
428
|
-
const fetchData = async (id: string): AsyncResult<{ data: string }, 'NOT_FOUND'> =>
|
|
429
|
-
ok({ data: 'test data' });
|
|
430
|
-
|
|
431
|
-
const requireManagerApproval = createApprovalStep<{ approvedBy: string }>({
|
|
432
|
-
key: 'manager-approval',
|
|
433
|
-
checkApproval: async () => {
|
|
434
|
-
return { status: 'pending' as const };
|
|
435
|
-
},
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
const collector = createHITLCollector();
|
|
439
|
-
const workflow1 = createWorkflow(
|
|
440
|
-
{ fetchData, requireManagerApproval },
|
|
441
|
-
{ onEvent: collector.handleEvent }
|
|
442
|
-
);
|
|
443
|
-
|
|
444
|
-
const result1 = await workflow1(async (step) => {
|
|
445
|
-
const data = await step(() => fetchData('123'), { key: 'data' });
|
|
446
|
-
const approval = await step(requireManagerApproval, { key: 'manager-approval' });
|
|
447
|
-
return { data, approvedBy: approval.approvedBy };
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
expect(result1.ok).toBe(false);
|
|
451
|
-
expect(isPendingApproval(result1.error)).toBe(true);
|
|
452
|
-
|
|
453
|
-
// Save state
|
|
454
|
-
const savedState = collector.getState();
|
|
455
|
-
|
|
456
|
-
// Inject approval
|
|
457
|
-
const resumeState = injectApproval(savedState, {
|
|
458
|
-
stepKey: 'manager-approval',
|
|
459
|
-
value: { approvedBy: 'alice@example.com' },
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
const workflow2 = createWorkflow(
|
|
463
|
-
{ fetchData, requireManagerApproval },
|
|
464
|
-
{ resumeState }
|
|
465
|
-
);
|
|
466
|
-
|
|
467
|
-
const result2 = await workflow2(async (step) => {
|
|
468
|
-
const data = await step(() => fetchData('123'), { key: 'data' });
|
|
469
|
-
const approval = await step(requireManagerApproval, { key: 'manager-approval' });
|
|
470
|
-
return { data, approvedBy: approval.approvedBy };
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
expect(result2.ok).toBe(true);
|
|
474
|
-
if (result2.ok) {
|
|
475
|
-
expect(result2.value.approvedBy).toBe('alice@example.com');
|
|
476
|
-
}
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
it('should work with HITL utilities', () => {
|
|
480
|
-
const state = {
|
|
481
|
-
steps: new Map([
|
|
482
|
-
['step1', { result: ok('value1') }],
|
|
483
|
-
['approval:deploy', { result: err({ type: 'PENDING_APPROVAL' as const, stepKey: 'approval:deploy' }) }],
|
|
484
|
-
['approval:staging', { result: err({ type: 'PENDING_APPROVAL' as const, stepKey: 'approval:staging' }) }],
|
|
485
|
-
]),
|
|
486
|
-
};
|
|
487
|
-
|
|
488
|
-
expect(hasPendingApproval(state, 'approval:deploy')).toBe(true);
|
|
489
|
-
expect(hasPendingApproval(state, 'step1')).toBe(false);
|
|
490
|
-
|
|
491
|
-
const pending = getPendingApprovals(state);
|
|
492
|
-
expect(pending).toHaveLength(2);
|
|
493
|
-
expect(pending).toContain('approval:deploy');
|
|
494
|
-
expect(pending).toContain('approval:staging');
|
|
495
|
-
|
|
496
|
-
const cleared = clearStep(state, 'approval:deploy');
|
|
497
|
-
expect(cleared.steps.has('approval:deploy')).toBe(false);
|
|
498
|
-
expect(cleared.steps.has('approval:staging')).toBe(true);
|
|
499
|
-
});
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
describe('Interop with neverthrow', () => {
|
|
503
|
-
it('should work with neverthrow Result conversion', async () => {
|
|
504
|
-
// Simulate neverthrow Result
|
|
505
|
-
type NTResult<T, E> = { isOk: () => boolean; value?: T; error?: E };
|
|
506
|
-
const ntResult: NTResult<User, 'NOT_FOUND'> = {
|
|
507
|
-
isOk: () => true,
|
|
508
|
-
value: { id: '1', name: 'Alice' },
|
|
509
|
-
};
|
|
510
|
-
|
|
511
|
-
function fromNeverthrow<T, E>(ntResult: NTResult<T, E>): Result<T, E> {
|
|
512
|
-
return ntResult.isOk() ? ok(ntResult.value!) : err(ntResult.error!);
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
const workflow = createWorkflow({});
|
|
516
|
-
|
|
517
|
-
const result = await workflow(async (step) => {
|
|
518
|
-
const validated = await step(fromNeverthrow(ntResult));
|
|
519
|
-
return validated;
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
expect(result.ok).toBe(true);
|
|
523
|
-
if (result.ok) {
|
|
524
|
-
expect(result.value.name).toBe('Alice');
|
|
525
|
-
}
|
|
526
|
-
});
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
describe('Low-level: run()', () => {
|
|
530
|
-
it('should work with run()', async () => {
|
|
531
|
-
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
|
|
532
|
-
id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
|
|
533
|
-
|
|
534
|
-
const result = await run(async (step) => {
|
|
535
|
-
const user = await step(fetchUser('1'));
|
|
536
|
-
return user;
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
expect(result.ok).toBe(true);
|
|
540
|
-
if (result.ok) {
|
|
541
|
-
expect(result.value.name).toBe('Alice');
|
|
542
|
-
}
|
|
543
|
-
});
|
|
544
|
-
|
|
545
|
-
it('should work with run.strict()', async () => {
|
|
546
|
-
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
|
|
547
|
-
id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
|
|
548
|
-
|
|
549
|
-
type AppError = 'NOT_FOUND' | 'UNAUTHORIZED' | 'UNEXPECTED';
|
|
550
|
-
|
|
551
|
-
const result = await run.strict<User, AppError>(
|
|
552
|
-
async (step) => {
|
|
553
|
-
return await step(fetchUser('1'));
|
|
554
|
-
},
|
|
555
|
-
{ catchUnexpected: () => 'UNEXPECTED' as const }
|
|
556
|
-
);
|
|
557
|
-
|
|
558
|
-
expect(result.ok).toBe(true);
|
|
559
|
-
if (!result.ok) {
|
|
560
|
-
const error: AppError = result.error;
|
|
561
|
-
expect(['NOT_FOUND', 'UNAUTHORIZED', 'UNEXPECTED']).toContain(error);
|
|
562
|
-
}
|
|
563
|
-
});
|
|
564
|
-
});
|
|
565
|
-
});
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# Advanced Documentation Code Verification Report
|
|
2
|
-
|
|
3
|
-
## Summary
|
|
4
|
-
All code examples in docs/advanced.md have been verified. **31/31 tests pass** ✅
|
|
5
|
-
|
|
6
|
-
## Test Coverage
|
|
7
|
-
|
|
8
|
-
### ✅ Working Examples
|
|
9
|
-
|
|
10
|
-
1. **Batch operations**
|
|
11
|
-
- `all()` - All must succeed (short-circuits on first error)
|
|
12
|
-
- `allSettled()` - Collects all errors (great for form validation)
|
|
13
|
-
- `any()` - First success wins
|
|
14
|
-
- `partition()` - Split successes and failures
|
|
15
|
-
- `allAsync()` - Async version of all()
|
|
16
|
-
- `allSettledAsync()` - Async version of allSettled()
|
|
17
|
-
- `anyAsync()` - Async version of any()
|
|
18
|
-
|
|
19
|
-
2. **Dynamic error mapping**
|
|
20
|
-
- `step.try()` with `onError` option for creating errors from caught values
|
|
21
|
-
- API error mapping example
|
|
22
|
-
- Validation error mapping example
|
|
23
|
-
|
|
24
|
-
3. **Type utilities**
|
|
25
|
-
- `ErrorOf<T>` - Extract error type from a function (compile-time only)
|
|
26
|
-
- `Errors<T[]>` - Combine errors from multiple functions (compile-time only)
|
|
27
|
-
- `ErrorsOfDeps<Deps>` - Extract from a deps object (compile-time only)
|
|
28
|
-
- Note: Type utilities are verified via TypeScript compilation, not runtime tests
|
|
29
|
-
|
|
30
|
-
4. **Wrapping existing code**
|
|
31
|
-
- `from()` - Sync throwing function wrapper
|
|
32
|
-
- `fromPromise()` - Existing promise wrapper
|
|
33
|
-
- `tryAsync()` - Async function wrapper
|
|
34
|
-
- `fromNullable()` - Nullable value wrapper
|
|
35
|
-
|
|
36
|
-
5. **Transformers**
|
|
37
|
-
- `map()` - Transform success values
|
|
38
|
-
- `mapError()` - Transform error values
|
|
39
|
-
- `match()` - Pattern matching on results
|
|
40
|
-
- `andThen()` - Chain results (flatMap)
|
|
41
|
-
- `tap()` - Side effects without changing result
|
|
42
|
-
|
|
43
|
-
6. **Human-in-the-loop (HITL)**
|
|
44
|
-
- `createApprovalStep()` - Create approval-gated steps
|
|
45
|
-
- `createHITLCollector()` - Track pending approvals
|
|
46
|
-
- `isPendingApproval()` - Type guard for pending approvals
|
|
47
|
-
- `injectApproval()` - Inject approval results for resuming
|
|
48
|
-
- `hasPendingApproval()` - Check if step has pending approval
|
|
49
|
-
- `getPendingApprovals()` - Get all pending approval step keys
|
|
50
|
-
- `clearStep()` - Remove step from resume state
|
|
51
|
-
|
|
52
|
-
7. **Interop with neverthrow**
|
|
53
|
-
- Conversion function from neverthrow Result to @jreehal/workflow Result
|
|
54
|
-
- Using converted results in workflows
|
|
55
|
-
|
|
56
|
-
8. **Low-level: run()**
|
|
57
|
-
- `run()` - One-off workflow execution
|
|
58
|
-
- `run.strict()` - Closed error union without UnexpectedError
|
|
59
|
-
|
|
60
|
-
## All Tests Pass ✅
|
|
61
|
-
|
|
62
|
-
All examples in docs/advanced.md are verified and working correctly.
|
|
63
|
-
|
|
64
|
-
Run with: `pnpm vitest run docs/advanced.test.ts`
|