@sprqvntrs/workflows 0.2.4
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/LICENSE +21 -0
- package/README.md +649 -0
- package/index.ts +234 -0
- package/package.json +53 -0
- package/src/coordination/lock-manager.ts +431 -0
- package/src/engine/execution-engine.ts +715 -0
- package/src/infrastructure/db-state.ts +703 -0
- package/src/infrastructure/pg-boss.ts +585 -0
- package/src/infrastructure/pgboss-queries.ts +320 -0
- package/src/infrastructure/schema.ts +342 -0
- package/src/operations/registry.ts +409 -0
- package/src/orchestrator.ts +1056 -0
- package/src/templates/registry.ts +591 -0
- package/src/testing/index.ts +476 -0
- package/src/types.ts +946 -0
- package/src/worker/index.ts +325 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testing utilities for workflows.
|
|
3
|
+
*
|
|
4
|
+
* This module provides utilities for testing workflow templates and operations
|
|
5
|
+
* in isolation, without requiring a full database or pg-boss setup.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { createMockOrchestrator, createMockContext } from '@sprqvntrs/workflows/testing';
|
|
10
|
+
*
|
|
11
|
+
* // Test an operation handler
|
|
12
|
+
* const context = createMockContext({
|
|
13
|
+
* workflowId: 'test-workflow',
|
|
14
|
+
* operationType: 'gather.data',
|
|
15
|
+
* previousResults: { url: 'https://example.com' },
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* const result = await myHandler(context);
|
|
19
|
+
* expect(result.status).toBe('completed');
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
OperationContext,
|
|
25
|
+
OperationResult,
|
|
26
|
+
WorkflowTemplate,
|
|
27
|
+
OperationHandler,
|
|
28
|
+
WorkflowContext,
|
|
29
|
+
WorkflowRecord,
|
|
30
|
+
OperationRecord,
|
|
31
|
+
WorkflowStatus,
|
|
32
|
+
OperationStatus,
|
|
33
|
+
} from '../types';
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// Mock Context
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Options for creating a mock operation context.
|
|
41
|
+
*/
|
|
42
|
+
export interface MockContextOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Workflow ID.
|
|
45
|
+
* @default 'test-workflow-id'
|
|
46
|
+
*/
|
|
47
|
+
workflowId?: string;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Operation ID.
|
|
51
|
+
* @default 'test-operation-id'
|
|
52
|
+
*/
|
|
53
|
+
operationId?: string;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Operation type.
|
|
57
|
+
* @default 'test.operation'
|
|
58
|
+
*/
|
|
59
|
+
operationType?: string;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Stage name.
|
|
63
|
+
* @default 'test-stage'
|
|
64
|
+
*/
|
|
65
|
+
stageName?: string;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Current attempt number.
|
|
69
|
+
* @default 1
|
|
70
|
+
*/
|
|
71
|
+
attempt?: number;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Maximum attempts.
|
|
75
|
+
* @default 3
|
|
76
|
+
*/
|
|
77
|
+
maxAttempts?: number;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Previous operation results.
|
|
81
|
+
* @default {}
|
|
82
|
+
*/
|
|
83
|
+
previousResults?: Record<string, unknown>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Initial workflow context.
|
|
87
|
+
* @default {}
|
|
88
|
+
*/
|
|
89
|
+
initialContext?: Record<string, unknown>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Workflow type.
|
|
93
|
+
* @default 'test-workflow'
|
|
94
|
+
*/
|
|
95
|
+
workflowType?: string;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Abort signal for cancellation testing.
|
|
99
|
+
*/
|
|
100
|
+
signal?: AbortSignal;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Creates a mock operation context for testing handlers.
|
|
105
|
+
*
|
|
106
|
+
* @param options - Context options
|
|
107
|
+
* @returns Mock operation context
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```typescript
|
|
111
|
+
* // Basic usage
|
|
112
|
+
* const context = createMockContext();
|
|
113
|
+
*
|
|
114
|
+
* // With custom values
|
|
115
|
+
* const context = createMockContext({
|
|
116
|
+
* operationType: 'gather.data',
|
|
117
|
+
* previousResults: { url: 'https://example.com' },
|
|
118
|
+
* attempt: 2,
|
|
119
|
+
* });
|
|
120
|
+
*
|
|
121
|
+
* // Test handler
|
|
122
|
+
* const result = await myHandler(context);
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export function createMockContext<T = Record<string, unknown>>(
|
|
126
|
+
options?: MockContextOptions,
|
|
127
|
+
): OperationContext<T> {
|
|
128
|
+
return {
|
|
129
|
+
workflowId: options?.workflowId ?? 'test-workflow-id',
|
|
130
|
+
operationId: options?.operationId ?? 'test-operation-id',
|
|
131
|
+
operationType: options?.operationType ?? 'test.operation',
|
|
132
|
+
stageName: options?.stageName ?? 'test-stage',
|
|
133
|
+
attempt: options?.attempt ?? 1,
|
|
134
|
+
maxAttempts: options?.maxAttempts ?? 3,
|
|
135
|
+
previousResults: (options?.previousResults ?? {}) as T,
|
|
136
|
+
initialContext: options?.initialContext ?? {},
|
|
137
|
+
workflowType: options?.workflowType ?? 'test-workflow',
|
|
138
|
+
signal: options?.signal,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// =============================================================================
|
|
143
|
+
// Mock Results
|
|
144
|
+
// =============================================================================
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Creates a successful operation result.
|
|
148
|
+
*
|
|
149
|
+
* @param data - Result data
|
|
150
|
+
* @returns Successful operation result
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```typescript
|
|
154
|
+
* const result = createSuccessResult({ processedCount: 10 });
|
|
155
|
+
* // { status: 'completed', data: { processedCount: 10 } }
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
export function createSuccessResult<T = Record<string, unknown>>(
|
|
159
|
+
data?: T,
|
|
160
|
+
): OperationResult<T> {
|
|
161
|
+
return {
|
|
162
|
+
status: 'completed',
|
|
163
|
+
data,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Creates a failed operation result.
|
|
169
|
+
*
|
|
170
|
+
* @param reason - Failure reason
|
|
171
|
+
* @returns Failed operation result
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* ```typescript
|
|
175
|
+
* const result = createFailureResult('Network timeout');
|
|
176
|
+
* // { status: 'failed', reason: 'Network timeout' }
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
export function createFailureResult(reason: string): OperationResult {
|
|
180
|
+
return {
|
|
181
|
+
status: 'failed',
|
|
182
|
+
reason,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// =============================================================================
|
|
187
|
+
// Mock Workflow Records
|
|
188
|
+
// =============================================================================
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Options for creating a mock workflow record.
|
|
192
|
+
*/
|
|
193
|
+
export interface MockWorkflowOptions {
|
|
194
|
+
id?: string;
|
|
195
|
+
type?: string;
|
|
196
|
+
status?: WorkflowStatus;
|
|
197
|
+
context?: WorkflowContext;
|
|
198
|
+
currentStage?: string | null;
|
|
199
|
+
checkpointStatus?: string | null;
|
|
200
|
+
errorMessage?: string | null;
|
|
201
|
+
templateVersion?: string;
|
|
202
|
+
createdAt?: Date;
|
|
203
|
+
startedAt?: Date | null;
|
|
204
|
+
completedAt?: Date | null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Creates a mock workflow record for testing.
|
|
209
|
+
*
|
|
210
|
+
* @param options - Workflow options
|
|
211
|
+
* @returns Mock workflow record
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```typescript
|
|
215
|
+
* const workflow = createMockWorkflow({
|
|
216
|
+
* status: 'active',
|
|
217
|
+
* currentStage: 'analyze',
|
|
218
|
+
* });
|
|
219
|
+
* ```
|
|
220
|
+
*/
|
|
221
|
+
export function createMockWorkflow(options?: MockWorkflowOptions): WorkflowRecord {
|
|
222
|
+
return {
|
|
223
|
+
id: options?.id ?? 'test-workflow-id',
|
|
224
|
+
type: options?.type ?? 'test-workflow',
|
|
225
|
+
status: options?.status ?? 'pending',
|
|
226
|
+
context: options?.context ?? {},
|
|
227
|
+
currentStage: options?.currentStage ?? null,
|
|
228
|
+
checkpointStatus: options?.checkpointStatus ?? null,
|
|
229
|
+
errorMessage: options?.errorMessage ?? null,
|
|
230
|
+
templateVersion: options?.templateVersion ?? '1.0.0',
|
|
231
|
+
createdAt: options?.createdAt ?? new Date(),
|
|
232
|
+
startedAt: options?.startedAt ?? null,
|
|
233
|
+
completedAt: options?.completedAt ?? null,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Options for creating a mock operation record.
|
|
239
|
+
*/
|
|
240
|
+
export interface MockOperationOptions {
|
|
241
|
+
id?: string;
|
|
242
|
+
workflowId?: string;
|
|
243
|
+
type?: string;
|
|
244
|
+
stage?: string;
|
|
245
|
+
status?: OperationStatus;
|
|
246
|
+
result?: Record<string, unknown> | null;
|
|
247
|
+
errorMessage?: string | null;
|
|
248
|
+
attempts?: number;
|
|
249
|
+
maxAttempts?: number;
|
|
250
|
+
createdAt?: Date;
|
|
251
|
+
startedAt?: Date | null;
|
|
252
|
+
completedAt?: Date | null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Creates a mock operation record for testing.
|
|
257
|
+
*
|
|
258
|
+
* @param options - Operation options
|
|
259
|
+
* @returns Mock operation record
|
|
260
|
+
*
|
|
261
|
+
* @example
|
|
262
|
+
* ```typescript
|
|
263
|
+
* const operation = createMockOperation({
|
|
264
|
+
* type: 'gather.data',
|
|
265
|
+
* status: 'completed',
|
|
266
|
+
* result: { data: {} },
|
|
267
|
+
* });
|
|
268
|
+
* ```
|
|
269
|
+
*/
|
|
270
|
+
export function createMockOperation(options?: MockOperationOptions): OperationRecord {
|
|
271
|
+
return {
|
|
272
|
+
id: options?.id ?? 'test-operation-id',
|
|
273
|
+
workflowId: options?.workflowId ?? 'test-workflow-id',
|
|
274
|
+
type: options?.type ?? 'test.operation',
|
|
275
|
+
stage: options?.stage ?? 'test-stage',
|
|
276
|
+
status: options?.status ?? 'pending',
|
|
277
|
+
result: options?.result ?? null,
|
|
278
|
+
errorMessage: options?.errorMessage ?? null,
|
|
279
|
+
attempts: options?.attempts ?? 0,
|
|
280
|
+
maxAttempts: options?.maxAttempts ?? 3,
|
|
281
|
+
createdAt: options?.createdAt ?? new Date(),
|
|
282
|
+
startedAt: options?.startedAt ?? null,
|
|
283
|
+
completedAt: options?.completedAt ?? null,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// =============================================================================
|
|
288
|
+
// Handler Testing
|
|
289
|
+
// =============================================================================
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Tests an operation handler with the given context.
|
|
293
|
+
*
|
|
294
|
+
* Convenience wrapper that creates a mock context and runs the handler.
|
|
295
|
+
*
|
|
296
|
+
* @param handler - Handler to test
|
|
297
|
+
* @param options - Context options
|
|
298
|
+
* @returns Operation result
|
|
299
|
+
*
|
|
300
|
+
* @example
|
|
301
|
+
* ```typescript
|
|
302
|
+
* const result = await testHandler(myHandler, {
|
|
303
|
+
* previousResults: { url: 'https://example.com' },
|
|
304
|
+
* });
|
|
305
|
+
*
|
|
306
|
+
* expect(result.status).toBe('completed');
|
|
307
|
+
* expect(result.data?.fetchedData).toBeDefined();
|
|
308
|
+
* ```
|
|
309
|
+
*/
|
|
310
|
+
export async function testHandler<TContext = Record<string, unknown>, TResult = Record<string, unknown>>(
|
|
311
|
+
handler: OperationHandler<TContext, TResult>,
|
|
312
|
+
options?: MockContextOptions,
|
|
313
|
+
): Promise<OperationResult<TResult>> {
|
|
314
|
+
const context = createMockContext<TContext>(options);
|
|
315
|
+
return handler(context);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Creates a mock handler that returns a fixed result.
|
|
320
|
+
*
|
|
321
|
+
* Useful for testing workflows without running actual operations.
|
|
322
|
+
*
|
|
323
|
+
* @param result - Result to return
|
|
324
|
+
* @returns Mock handler
|
|
325
|
+
*
|
|
326
|
+
* @example
|
|
327
|
+
* ```typescript
|
|
328
|
+
* const mockHandler = createMockHandler(createSuccessResult({ data: 'test' }));
|
|
329
|
+
* registry.register('test.operation', mockHandler);
|
|
330
|
+
* ```
|
|
331
|
+
*/
|
|
332
|
+
export function createMockHandler<T = Record<string, unknown>>(
|
|
333
|
+
result: OperationResult<T>,
|
|
334
|
+
): OperationHandler<Record<string, unknown>, T> {
|
|
335
|
+
return async () => result;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Creates a mock handler that calls a spy function.
|
|
340
|
+
*
|
|
341
|
+
* Useful for verifying handler calls in tests.
|
|
342
|
+
*
|
|
343
|
+
* @param spy - Spy function to call
|
|
344
|
+
* @param result - Result to return
|
|
345
|
+
* @returns Mock handler
|
|
346
|
+
*
|
|
347
|
+
* @example
|
|
348
|
+
* ```typescript
|
|
349
|
+
* const calls: OperationContext[] = [];
|
|
350
|
+
* const mockHandler = createSpyHandler(
|
|
351
|
+
* (ctx) => calls.push(ctx),
|
|
352
|
+
* createSuccessResult()
|
|
353
|
+
* );
|
|
354
|
+
*
|
|
355
|
+
* await testHandler(mockHandler);
|
|
356
|
+
* expect(calls.length).toBe(1);
|
|
357
|
+
* ```
|
|
358
|
+
*/
|
|
359
|
+
export function createSpyHandler<T = Record<string, unknown>>(
|
|
360
|
+
spy: (context: OperationContext) => void,
|
|
361
|
+
result: OperationResult<T>,
|
|
362
|
+
): OperationHandler<Record<string, unknown>, T> {
|
|
363
|
+
return async (context) => {
|
|
364
|
+
spy(context);
|
|
365
|
+
return result;
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Creates a mock handler that throws an error.
|
|
371
|
+
*
|
|
372
|
+
* Useful for testing error handling.
|
|
373
|
+
*
|
|
374
|
+
* @param error - Error to throw
|
|
375
|
+
* @returns Mock handler that throws
|
|
376
|
+
*
|
|
377
|
+
* @example
|
|
378
|
+
* ```typescript
|
|
379
|
+
* const errorHandler = createThrowingHandler(new Error('Network failure'));
|
|
380
|
+
*
|
|
381
|
+
* await expect(testHandler(errorHandler)).rejects.toThrow('Network failure');
|
|
382
|
+
* ```
|
|
383
|
+
*/
|
|
384
|
+
export function createThrowingHandler(error: Error): OperationHandler {
|
|
385
|
+
return async () => {
|
|
386
|
+
throw error;
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// =============================================================================
|
|
391
|
+
// Template Testing
|
|
392
|
+
// =============================================================================
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Creates a minimal valid workflow template for testing.
|
|
396
|
+
*
|
|
397
|
+
* @param overrides - Template overrides
|
|
398
|
+
* @returns Valid workflow template
|
|
399
|
+
*
|
|
400
|
+
* @example
|
|
401
|
+
* ```typescript
|
|
402
|
+
* const template = createMockTemplate({
|
|
403
|
+
* type: 'my-workflow',
|
|
404
|
+
* stages: [
|
|
405
|
+
* { name: 'gather', operations: [{ type: 'gather.data' }] },
|
|
406
|
+
* ],
|
|
407
|
+
* });
|
|
408
|
+
* ```
|
|
409
|
+
*/
|
|
410
|
+
export function createMockTemplate(
|
|
411
|
+
overrides?: Partial<WorkflowTemplate>,
|
|
412
|
+
): WorkflowTemplate {
|
|
413
|
+
return {
|
|
414
|
+
type: overrides?.type ?? 'test-workflow',
|
|
415
|
+
queue: overrides?.queue ?? 'test-queue',
|
|
416
|
+
version: overrides?.version ?? '1.0.0',
|
|
417
|
+
description: overrides?.description ?? 'Test workflow',
|
|
418
|
+
stages: overrides?.stages ?? [
|
|
419
|
+
{
|
|
420
|
+
name: 'test-stage',
|
|
421
|
+
operations: [{ type: 'test.operation' }],
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
checkpoints: overrides?.checkpoints,
|
|
425
|
+
nextWorkflow: overrides?.nextWorkflow,
|
|
426
|
+
coordination: overrides?.coordination,
|
|
427
|
+
queueConfig: overrides?.queueConfig,
|
|
428
|
+
estimatedDurationSeconds: overrides?.estimatedDurationSeconds,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// =============================================================================
|
|
433
|
+
// Assertions
|
|
434
|
+
// =============================================================================
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Asserts that an operation result is successful.
|
|
438
|
+
*
|
|
439
|
+
* @param result - Result to check
|
|
440
|
+
* @throws Error if result is not successful
|
|
441
|
+
*
|
|
442
|
+
* @example
|
|
443
|
+
* ```typescript
|
|
444
|
+
* const result = await testHandler(myHandler);
|
|
445
|
+
* assertSuccess(result);
|
|
446
|
+
* // Type narrows to { status: 'completed', data: ... }
|
|
447
|
+
* ```
|
|
448
|
+
*/
|
|
449
|
+
export function assertSuccess<T>(
|
|
450
|
+
result: OperationResult<T>,
|
|
451
|
+
): asserts result is OperationResult<T> & { status: 'completed' } {
|
|
452
|
+
if (result.status !== 'completed') {
|
|
453
|
+
throw new Error(`Expected success but got failure: ${result.reason}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Asserts that an operation result is a failure.
|
|
459
|
+
*
|
|
460
|
+
* @param result - Result to check
|
|
461
|
+
* @throws Error if result is not a failure
|
|
462
|
+
*
|
|
463
|
+
* @example
|
|
464
|
+
* ```typescript
|
|
465
|
+
* const result = await testHandler(myHandler);
|
|
466
|
+
* assertFailure(result);
|
|
467
|
+
* // Type narrows to { status: 'failed', reason: ... }
|
|
468
|
+
* ```
|
|
469
|
+
*/
|
|
470
|
+
export function assertFailure(
|
|
471
|
+
result: OperationResult,
|
|
472
|
+
): asserts result is OperationResult & { status: 'failed' } {
|
|
473
|
+
if (result.status !== 'failed') {
|
|
474
|
+
throw new Error(`Expected failure but got success`);
|
|
475
|
+
}
|
|
476
|
+
}
|