@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,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operation handler registry.
|
|
3
|
+
*
|
|
4
|
+
* This module provides registration and lookup of operation handlers.
|
|
5
|
+
* Handlers must be registered before workflows using them can be executed.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { createOperationRegistry } from '@sprqvntrs/workflows';
|
|
10
|
+
*
|
|
11
|
+
* const registry = createOperationRegistry();
|
|
12
|
+
*
|
|
13
|
+
* registry.register('gather.data', async (context) => {
|
|
14
|
+
* const data = await fetchData(context.previousResults.url);
|
|
15
|
+
* return { status: 'completed', data: { fetchedData: data } };
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* const handler = registry.get('gather.data');
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { OperationHandler, OperationContext, OperationResult } from '../types';
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Operation registry interface.
|
|
30
|
+
*
|
|
31
|
+
* Provides methods for registering and retrieving operation handlers.
|
|
32
|
+
*/
|
|
33
|
+
export interface OperationRegistry {
|
|
34
|
+
/**
|
|
35
|
+
* Registers an operation handler.
|
|
36
|
+
*
|
|
37
|
+
* @param type - Operation type identifier
|
|
38
|
+
* @param handler - Handler function
|
|
39
|
+
* @throws Error if handler already registered
|
|
40
|
+
*/
|
|
41
|
+
register: <TContext = Record<string, unknown>, TResult = Record<string, unknown>>(
|
|
42
|
+
type: string,
|
|
43
|
+
handler: OperationHandler<TContext, TResult>,
|
|
44
|
+
) => void;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Registers multiple handlers at once.
|
|
48
|
+
*
|
|
49
|
+
* @param handlers - Map of type to handler
|
|
50
|
+
*/
|
|
51
|
+
registerMany: (handlers: Record<string, OperationHandler>) => void;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Gets a handler by type.
|
|
55
|
+
*
|
|
56
|
+
* @param type - Operation type
|
|
57
|
+
* @returns Handler or undefined
|
|
58
|
+
*/
|
|
59
|
+
get: (type: string) => OperationHandler | undefined;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Gets a handler by type, throwing if not found.
|
|
63
|
+
*
|
|
64
|
+
* @param type - Operation type
|
|
65
|
+
* @returns Handler
|
|
66
|
+
* @throws Error if handler not found
|
|
67
|
+
*/
|
|
68
|
+
getOrThrow: (type: string) => OperationHandler;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Checks if a handler is registered.
|
|
72
|
+
*
|
|
73
|
+
* @param type - Operation type
|
|
74
|
+
* @returns True if handler exists
|
|
75
|
+
*/
|
|
76
|
+
has: (type: string) => boolean;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Gets all registered operation types.
|
|
80
|
+
*
|
|
81
|
+
* @returns Set of operation types
|
|
82
|
+
*/
|
|
83
|
+
types: () => Set<string>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Unregisters a handler.
|
|
87
|
+
*
|
|
88
|
+
* @param type - Operation type to remove
|
|
89
|
+
* @returns True if handler was removed
|
|
90
|
+
*/
|
|
91
|
+
unregister: (type: string) => boolean;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Clears all registered handlers.
|
|
95
|
+
*/
|
|
96
|
+
clear: () => void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// =============================================================================
|
|
100
|
+
// Registry Factory
|
|
101
|
+
// =============================================================================
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Creates a new operation registry.
|
|
105
|
+
*
|
|
106
|
+
* The registry stores operation handlers and provides type-safe lookup.
|
|
107
|
+
*
|
|
108
|
+
* @returns Operation registry instance
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```typescript
|
|
112
|
+
* const registry = createOperationRegistry();
|
|
113
|
+
*
|
|
114
|
+
* // Register individual handlers
|
|
115
|
+
* registry.register('gather.data', async (context) => {
|
|
116
|
+
* const url = context.previousResults.url as string;
|
|
117
|
+
* const data = await fetch(url).then(r => r.json());
|
|
118
|
+
* return { status: 'completed', data: { fetchedData: data } };
|
|
119
|
+
* });
|
|
120
|
+
*
|
|
121
|
+
* // Register multiple handlers at once
|
|
122
|
+
* registry.registerMany({
|
|
123
|
+
* 'analyze.content': analyzeContentHandler,
|
|
124
|
+
* 'analyze.market': analyzeMarketHandler,
|
|
125
|
+
* 'generate.report': generateReportHandler,
|
|
126
|
+
* });
|
|
127
|
+
*
|
|
128
|
+
* // Get handler
|
|
129
|
+
* const handler = registry.getOrThrow('gather.data');
|
|
130
|
+
*
|
|
131
|
+
* // Check available types
|
|
132
|
+
* const types = registry.types();
|
|
133
|
+
* console.log('Registered operations:', Array.from(types));
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export function createOperationRegistry(): OperationRegistry {
|
|
137
|
+
const handlers = new Map<string, OperationHandler>();
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
register<TContext = Record<string, unknown>, TResult = Record<string, unknown>>(
|
|
141
|
+
type: string,
|
|
142
|
+
handler: OperationHandler<TContext, TResult>,
|
|
143
|
+
): void {
|
|
144
|
+
if (handlers.has(type)) {
|
|
145
|
+
throw new Error(`Operation handler "${type}" is already registered`);
|
|
146
|
+
}
|
|
147
|
+
handlers.set(type, handler as OperationHandler);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
registerMany(handlersMap: Record<string, OperationHandler>): void {
|
|
151
|
+
// Check for duplicates first
|
|
152
|
+
for (const type of Object.keys(handlersMap)) {
|
|
153
|
+
if (handlers.has(type)) {
|
|
154
|
+
throw new Error(`Operation handler "${type}" is already registered`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Register all
|
|
159
|
+
for (const [type, handler] of Object.entries(handlersMap)) {
|
|
160
|
+
handlers.set(type, handler);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
get(type: string): OperationHandler | undefined {
|
|
165
|
+
return handlers.get(type);
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
getOrThrow(type: string): OperationHandler {
|
|
169
|
+
const handler = handlers.get(type);
|
|
170
|
+
if (!handler) {
|
|
171
|
+
throw new Error(`Operation handler "${type}" not found`);
|
|
172
|
+
}
|
|
173
|
+
return handler;
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
has(type: string): boolean {
|
|
177
|
+
return handlers.has(type);
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
types(): Set<string> {
|
|
181
|
+
return new Set(handlers.keys());
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
unregister(type: string): boolean {
|
|
185
|
+
return handlers.delete(type);
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
clear(): void {
|
|
189
|
+
handlers.clear();
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// =============================================================================
|
|
195
|
+
// Handler Utilities
|
|
196
|
+
// =============================================================================
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Wraps an operation handler with timeout handling.
|
|
200
|
+
*
|
|
201
|
+
* Creates a new handler that will reject after the specified timeout.
|
|
202
|
+
*
|
|
203
|
+
* @param handler - Original handler
|
|
204
|
+
* @param timeoutMs - Timeout in milliseconds
|
|
205
|
+
* @returns Wrapped handler with timeout
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```typescript
|
|
209
|
+
* const handler = withTimeout(originalHandler, 30000);
|
|
210
|
+
* // Handler will reject after 30 seconds
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
export function withTimeout<TContext = Record<string, unknown>, TResult = Record<string, unknown>>(
|
|
214
|
+
handler: OperationHandler<TContext, TResult>,
|
|
215
|
+
timeoutMs: number,
|
|
216
|
+
): OperationHandler<TContext, TResult> {
|
|
217
|
+
return async (context: OperationContext<TContext>): Promise<OperationResult<TResult>> => {
|
|
218
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
219
|
+
setTimeout(() => {
|
|
220
|
+
reject(new Error(`Operation timed out after ${timeoutMs}ms`));
|
|
221
|
+
}, timeoutMs);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return Promise.race([handler(context), timeoutPromise]);
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Wraps an operation handler with retry logic.
|
|
230
|
+
*
|
|
231
|
+
* Creates a new handler that will retry on failure with exponential backoff.
|
|
232
|
+
*
|
|
233
|
+
* @param handler - Original handler
|
|
234
|
+
* @param options - Retry options
|
|
235
|
+
* @returns Wrapped handler with retry
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```typescript
|
|
239
|
+
* const handler = withRetry(originalHandler, {
|
|
240
|
+
* maxAttempts: 3,
|
|
241
|
+
* baseDelayMs: 1000,
|
|
242
|
+
* backoffMultiplier: 2,
|
|
243
|
+
* });
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
export function withRetry<TContext = Record<string, unknown>, TResult = Record<string, unknown>>(
|
|
247
|
+
handler: OperationHandler<TContext, TResult>,
|
|
248
|
+
options: {
|
|
249
|
+
/**
|
|
250
|
+
* Maximum number of attempts.
|
|
251
|
+
* @default 3
|
|
252
|
+
*/
|
|
253
|
+
maxAttempts?: number;
|
|
254
|
+
/**
|
|
255
|
+
* Base delay between retries in milliseconds.
|
|
256
|
+
* @default 1000
|
|
257
|
+
*/
|
|
258
|
+
baseDelayMs?: number;
|
|
259
|
+
/**
|
|
260
|
+
* Multiplier for exponential backoff.
|
|
261
|
+
* @default 2
|
|
262
|
+
*/
|
|
263
|
+
backoffMultiplier?: number;
|
|
264
|
+
/**
|
|
265
|
+
* Function to determine if error should be retried.
|
|
266
|
+
* @default () => true
|
|
267
|
+
*/
|
|
268
|
+
shouldRetry?: (error: Error) => boolean;
|
|
269
|
+
} = {},
|
|
270
|
+
): OperationHandler<TContext, TResult> {
|
|
271
|
+
const { maxAttempts = 3, baseDelayMs = 1000, backoffMultiplier = 2, shouldRetry = () => true } = options;
|
|
272
|
+
|
|
273
|
+
return async (context: OperationContext<TContext>): Promise<OperationResult<TResult>> => {
|
|
274
|
+
let lastError: Error | undefined;
|
|
275
|
+
|
|
276
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
277
|
+
try {
|
|
278
|
+
const result = await handler(context);
|
|
279
|
+
return result;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
282
|
+
|
|
283
|
+
if (attempt === maxAttempts || !shouldRetry(lastError)) {
|
|
284
|
+
return {
|
|
285
|
+
status: 'failed',
|
|
286
|
+
reason: lastError.message,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Exponential backoff
|
|
291
|
+
const delay = baseDelayMs * Math.pow(backoffMultiplier, attempt - 1);
|
|
292
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
status: 'failed',
|
|
298
|
+
reason: lastError?.message ?? 'Unknown error',
|
|
299
|
+
};
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Wraps an operation handler to catch exceptions and convert to failed result.
|
|
305
|
+
*
|
|
306
|
+
* Ensures handler never throws, returning a failed result instead.
|
|
307
|
+
*
|
|
308
|
+
* @param handler - Original handler
|
|
309
|
+
* @returns Safe handler that never throws
|
|
310
|
+
*
|
|
311
|
+
* @example
|
|
312
|
+
* ```typescript
|
|
313
|
+
* const handler = withErrorBoundary(originalHandler);
|
|
314
|
+
* // Exceptions are caught and converted to { status: 'failed', reason: '...' }
|
|
315
|
+
* ```
|
|
316
|
+
*/
|
|
317
|
+
export function withErrorBoundary<TContext = Record<string, unknown>, TResult = Record<string, unknown>>(
|
|
318
|
+
handler: OperationHandler<TContext, TResult>,
|
|
319
|
+
): OperationHandler<TContext, TResult> {
|
|
320
|
+
return async (context: OperationContext<TContext>): Promise<OperationResult<TResult>> => {
|
|
321
|
+
try {
|
|
322
|
+
return await handler(context);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
325
|
+
return {
|
|
326
|
+
status: 'failed',
|
|
327
|
+
reason: message,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Combines multiple handler wrappers.
|
|
335
|
+
*
|
|
336
|
+
* Applies wrappers from right to left (innermost to outermost).
|
|
337
|
+
*
|
|
338
|
+
* @param handler - Original handler
|
|
339
|
+
* @param wrappers - Array of wrapper functions
|
|
340
|
+
* @returns Handler with all wrappers applied
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* ```typescript
|
|
344
|
+
* const handler = compose(
|
|
345
|
+
* originalHandler,
|
|
346
|
+
* (h) => withErrorBoundary(h),
|
|
347
|
+
* (h) => withTimeout(h, 30000),
|
|
348
|
+
* (h) => withRetry(h, { maxAttempts: 3 }),
|
|
349
|
+
* );
|
|
350
|
+
* ```
|
|
351
|
+
*/
|
|
352
|
+
export function compose<TContext = Record<string, unknown>, TResult = Record<string, unknown>>(
|
|
353
|
+
handler: OperationHandler<TContext, TResult>,
|
|
354
|
+
...wrappers: Array<(h: OperationHandler<TContext, TResult>) => OperationHandler<TContext, TResult>>
|
|
355
|
+
): OperationHandler<TContext, TResult> {
|
|
356
|
+
return wrappers.reduceRight((h, wrapper) => wrapper(h), handler);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// =============================================================================
|
|
360
|
+
// Helper Types
|
|
361
|
+
// =============================================================================
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Type helper for defining operation handlers with typed context.
|
|
365
|
+
*
|
|
366
|
+
* @example
|
|
367
|
+
* ```typescript
|
|
368
|
+
* interface GatherContext {
|
|
369
|
+
* url: string;
|
|
370
|
+
* userId: string;
|
|
371
|
+
* }
|
|
372
|
+
*
|
|
373
|
+
* interface GatherResult {
|
|
374
|
+
* data: unknown;
|
|
375
|
+
* fetchedAt: string;
|
|
376
|
+
* }
|
|
377
|
+
*
|
|
378
|
+
* const gatherHandler: TypedOperationHandler<GatherContext, GatherResult> = async (ctx) => {
|
|
379
|
+
* // ctx.previousResults.url is typed as string
|
|
380
|
+
* return {
|
|
381
|
+
* status: 'completed',
|
|
382
|
+
* data: { data: {}, fetchedAt: new Date().toISOString() },
|
|
383
|
+
* };
|
|
384
|
+
* };
|
|
385
|
+
* ```
|
|
386
|
+
*/
|
|
387
|
+
export type TypedOperationHandler<TContext, TResult> = OperationHandler<TContext, TResult>;
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Creates a typed handler with proper inference.
|
|
391
|
+
*
|
|
392
|
+
* Helper function for better TypeScript inference when creating handlers.
|
|
393
|
+
*
|
|
394
|
+
* @param handler - Handler function
|
|
395
|
+
* @returns The same handler with proper typing
|
|
396
|
+
*
|
|
397
|
+
* @example
|
|
398
|
+
* ```typescript
|
|
399
|
+
* const gatherHandler = defineHandler<{ url: string }, { data: unknown }>(async (ctx) => {
|
|
400
|
+
* const response = await fetch(ctx.previousResults.url);
|
|
401
|
+
* return { status: 'completed', data: { data: await response.json() } };
|
|
402
|
+
* });
|
|
403
|
+
* ```
|
|
404
|
+
*/
|
|
405
|
+
export function defineHandler<TContext = Record<string, unknown>, TResult = Record<string, unknown>>(
|
|
406
|
+
handler: OperationHandler<TContext, TResult>,
|
|
407
|
+
): OperationHandler<TContext, TResult> {
|
|
408
|
+
return handler;
|
|
409
|
+
}
|