@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 SPRQVNTRS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
# @sprqvntrs/workflows
|
|
2
|
+
|
|
3
|
+
PostgreSQL-backed workflow orchestration with pg-boss job queuing.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This package provides a declarative, template-driven approach to executing multi-stage background workflows with reliable persistence and job processing. Built on [pg-boss](https://github.com/timgit/pg-boss), it offers:
|
|
8
|
+
|
|
9
|
+
- **Declarative Templates** - Define workflows as configuration objects
|
|
10
|
+
- **Parallel Execution** - Run operations concurrently within stages
|
|
11
|
+
- **Checkpoints** - Pause workflows for manual intervention
|
|
12
|
+
- **Workflow Chaining** - Trigger follow-up workflows on completion
|
|
13
|
+
- **Fix-Verify Loops** - Automatic retry cycles for data integrity
|
|
14
|
+
- **Entity Locking** - Prevent race conditions between workflows
|
|
15
|
+
- **Type Safety** - Full TypeScript support with generics
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm add @sprqvntrs/workflows
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Peer Dependencies
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pnpm add drizzle-orm pg
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
### 1. Set Up the Orchestrator
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { createWorkflowOrchestrator } from '@sprqvntrs/workflows';
|
|
35
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
36
|
+
import { Pool } from 'pg';
|
|
37
|
+
|
|
38
|
+
// Create database connection
|
|
39
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
|
|
40
|
+
const db = drizzle(pool);
|
|
41
|
+
|
|
42
|
+
// Create orchestrator
|
|
43
|
+
const orchestrator = await createWorkflowOrchestrator({
|
|
44
|
+
connectionString: process.env.DATABASE_URL!,
|
|
45
|
+
db,
|
|
46
|
+
queues: [
|
|
47
|
+
{ name: 'default', workers: 5 },
|
|
48
|
+
{ name: 'heavy', workers: 2 },
|
|
49
|
+
{ name: 'sequential', workers: 1 },
|
|
50
|
+
],
|
|
51
|
+
defaultTimeout: 30000,
|
|
52
|
+
defaultRetryLimit: 3,
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 2. Define a Workflow Template
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import type { WorkflowTemplate } from '@sprqvntrs/workflows';
|
|
60
|
+
|
|
61
|
+
const contentGenerationTemplate: WorkflowTemplate = {
|
|
62
|
+
type: 'content-generation',
|
|
63
|
+
queue: 'default',
|
|
64
|
+
version: '1.0.0',
|
|
65
|
+
description: 'Generate content from a URL',
|
|
66
|
+
|
|
67
|
+
stages: [
|
|
68
|
+
{
|
|
69
|
+
name: 'gather',
|
|
70
|
+
description: 'Fetch data from URL',
|
|
71
|
+
operations: [
|
|
72
|
+
{ type: 'gather.scrape', timeout: 60000, maxAttempts: 3 },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'analyze',
|
|
77
|
+
description: 'Analyze the gathered data',
|
|
78
|
+
parallel: true, // Run these operations concurrently
|
|
79
|
+
operations: [
|
|
80
|
+
{ type: 'analyze.content', timeout: 120000 },
|
|
81
|
+
{ type: 'analyze.competitors', timeout: 120000 },
|
|
82
|
+
{ type: 'analyze.market', timeout: 120000 },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'generate',
|
|
87
|
+
description: 'Generate the final report',
|
|
88
|
+
operations: [
|
|
89
|
+
{ type: 'generate.report', timeout: 180000 },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
|
|
94
|
+
// Optional: pause for review after gathering
|
|
95
|
+
checkpoints: [
|
|
96
|
+
{
|
|
97
|
+
after: 'gather',
|
|
98
|
+
status: 'data_ready',
|
|
99
|
+
condition: (ctx) => !ctx.isAutomated,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
orchestrator.registerTemplate(contentGenerationTemplate);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 3. Implement Operation Handlers
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import type { OperationHandler } from '@sprqvntrs/workflows';
|
|
111
|
+
|
|
112
|
+
// Define handler with typed context
|
|
113
|
+
const scrapeHandler: OperationHandler<{ url: string }, { scrapedData: unknown }> = async (ctx) => {
|
|
114
|
+
const { url } = ctx.previousResults;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const data = await scrapeWebsite(url);
|
|
118
|
+
return {
|
|
119
|
+
status: 'completed',
|
|
120
|
+
data: { scrapedData: data },
|
|
121
|
+
};
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return {
|
|
124
|
+
status: 'failed',
|
|
125
|
+
reason: error instanceof Error ? error.message : 'Scraping failed',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Register handlers
|
|
131
|
+
orchestrator.registerOperations({
|
|
132
|
+
'gather.scrape': scrapeHandler,
|
|
133
|
+
'analyze.content': analyzeContentHandler,
|
|
134
|
+
'analyze.competitors': analyzeCompetitorsHandler,
|
|
135
|
+
'analyze.market': analyzeMarketHandler,
|
|
136
|
+
'generate.report': generateReportHandler,
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 4. Start a Workflow
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
// In your API route
|
|
144
|
+
app.post('/workflows/content', async (req, res) => {
|
|
145
|
+
const { workflowId, jobId } = await orchestrator.start({
|
|
146
|
+
type: 'content-generation',
|
|
147
|
+
context: {
|
|
148
|
+
url: req.body.url,
|
|
149
|
+
userId: req.user.id,
|
|
150
|
+
isAutomated: req.body.automated ?? false,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
res.json({ workflowId, jobId });
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### 5. Run the Worker
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// worker.ts
|
|
162
|
+
import { runWorker } from '@sprqvntrs/workflows/worker';
|
|
163
|
+
|
|
164
|
+
async function main() {
|
|
165
|
+
const orchestrator = await createWorkflowOrchestrator({
|
|
166
|
+
// ... config
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Register all templates and operations
|
|
170
|
+
orchestrator.registerTemplates([...]);
|
|
171
|
+
orchestrator.registerOperations({...});
|
|
172
|
+
|
|
173
|
+
// Start processing
|
|
174
|
+
await runWorker({
|
|
175
|
+
orchestrator,
|
|
176
|
+
onReady: () => console.log('Worker ready'),
|
|
177
|
+
onShutdown: () => console.log('Shutting down...'),
|
|
178
|
+
onError: (err) => console.error('Worker error:', err),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
main().catch(console.error);
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Database Schema
|
|
186
|
+
|
|
187
|
+
Add the workflow tables to your Drizzle schema:
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
// schema.ts
|
|
191
|
+
import { workflows, workflowOperations, workflowLocks } from '@sprqvntrs/workflows/schema';
|
|
192
|
+
|
|
193
|
+
export { workflows, workflowOperations, workflowLocks };
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Then run your migrations to create the tables.
|
|
197
|
+
|
|
198
|
+
## API Reference
|
|
199
|
+
|
|
200
|
+
### `createWorkflowOrchestrator(config)`
|
|
201
|
+
|
|
202
|
+
Creates the main orchestrator instance.
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
const orchestrator = await createWorkflowOrchestrator({
|
|
206
|
+
// Required
|
|
207
|
+
connectionString: string, // PostgreSQL connection string
|
|
208
|
+
db: Database, // Drizzle database instance
|
|
209
|
+
queues: QueueDefinition[], // Queue configurations
|
|
210
|
+
|
|
211
|
+
// Optional
|
|
212
|
+
defaultTimeout?: number, // Default operation timeout (ms), default: 30000
|
|
213
|
+
defaultRetryLimit?: number, // Default retry attempts, default: 3
|
|
214
|
+
defaultRetryDelay?: number, // Default retry delay (s), default: 5
|
|
215
|
+
schema?: string, // pg-boss schema name, default: 'pgboss'
|
|
216
|
+
application?: string, // Application name for monitoring
|
|
217
|
+
debug?: boolean, // Enable debug logging
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Orchestrator Methods
|
|
222
|
+
|
|
223
|
+
#### Registration
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
// Register a single template
|
|
227
|
+
orchestrator.registerTemplate(template: WorkflowTemplate): void
|
|
228
|
+
|
|
229
|
+
// Register multiple templates
|
|
230
|
+
orchestrator.registerTemplates(templates: WorkflowTemplate[]): void
|
|
231
|
+
|
|
232
|
+
// Register a single operation handler
|
|
233
|
+
orchestrator.registerOperation(type: string, handler: OperationHandler): void
|
|
234
|
+
|
|
235
|
+
// Register multiple operation handlers
|
|
236
|
+
orchestrator.registerOperations(handlers: Record<string, OperationHandler>): void
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
#### Workflow Lifecycle
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
// Start a new workflow
|
|
243
|
+
const { workflowId, jobId } = await orchestrator.start({
|
|
244
|
+
type: string, // Workflow type (matches template)
|
|
245
|
+
context: WorkflowContext, // Initial context data
|
|
246
|
+
priority?: number, // Job priority (higher = more urgent)
|
|
247
|
+
startAfterSeconds?: number,// Delay before starting
|
|
248
|
+
singletonKey?: string, // Prevent duplicates
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Resume a paused workflow
|
|
252
|
+
const jobId = await orchestrator.resume(workflowId: string);
|
|
253
|
+
|
|
254
|
+
// Cancel a workflow
|
|
255
|
+
await orchestrator.cancel(workflowId: string);
|
|
256
|
+
|
|
257
|
+
// Retry a failed workflow
|
|
258
|
+
const jobId = await orchestrator.retry(workflowId: string);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
#### Scheduling
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
// Schedule a recurring workflow
|
|
265
|
+
await orchestrator.schedule({
|
|
266
|
+
name: string, // Unique schedule name
|
|
267
|
+
cron: string, // Cron expression
|
|
268
|
+
type: string, // Workflow type to start
|
|
269
|
+
context?: object, // Context for each instance
|
|
270
|
+
timezone?: string, // Timezone, default: 'UTC'
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Remove a schedule
|
|
274
|
+
await orchestrator.unschedule(name: string);
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
#### Queries
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
// Get workflow status with operations
|
|
281
|
+
const status = await orchestrator.getStatus(workflowId);
|
|
282
|
+
// Returns: { workflow, operations, progress, message }
|
|
283
|
+
|
|
284
|
+
// Get just the workflow record
|
|
285
|
+
const workflow = await orchestrator.getWorkflow(workflowId);
|
|
286
|
+
|
|
287
|
+
// List workflows with filtering
|
|
288
|
+
const workflows = await orchestrator.listWorkflows({
|
|
289
|
+
type?: string,
|
|
290
|
+
status?: string | string[],
|
|
291
|
+
limit?: number,
|
|
292
|
+
offset?: number,
|
|
293
|
+
});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
#### Worker Management
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
// Start processing jobs
|
|
300
|
+
await orchestrator.startWorker();
|
|
301
|
+
|
|
302
|
+
// Stop gracefully
|
|
303
|
+
await orchestrator.stopWorker();
|
|
304
|
+
|
|
305
|
+
// Validate all templates have handlers
|
|
306
|
+
orchestrator.validate(); // Throws if validation fails
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### pg-boss Introspection
|
|
310
|
+
|
|
311
|
+
Query pg-boss internal tables directly for debugging and monitoring:
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
import { createPgBossQueries } from '@sprqvntrs/workflows';
|
|
315
|
+
import { Pool } from 'pg';
|
|
316
|
+
|
|
317
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
318
|
+
const queries = createPgBossQueries(pool);
|
|
319
|
+
|
|
320
|
+
// Get all current jobs (id, name, state)
|
|
321
|
+
const jobs = await queries.getJobs();
|
|
322
|
+
|
|
323
|
+
// Get job statistics grouped by queue and state
|
|
324
|
+
const stats = await queries.getJobStats();
|
|
325
|
+
// Returns: [{ name: 'content-generation', state: 'active', count: 5 }, ...]
|
|
326
|
+
|
|
327
|
+
// Get all configured schedules
|
|
328
|
+
const schedules = await queries.getSchedules();
|
|
329
|
+
// Returns: [{ name: 'daily-sync', cron: '0 0 * * *', timezone: 'UTC', ... }, ...]
|
|
330
|
+
|
|
331
|
+
// Get job history for specific queues (from archive table)
|
|
332
|
+
const history = await queries.getJobHistory(['content-generation', 'data-sync']);
|
|
333
|
+
// Returns up to 50 most recent archived jobs
|
|
334
|
+
|
|
335
|
+
// Cancel and delete all jobs for a workflow
|
|
336
|
+
const result = await queries.deleteWorkflowJobs('workflow-123');
|
|
337
|
+
console.log(`Cancelled ${result.cancelledCount}, deleted ${result.deletedCount} jobs`);
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
#### Custom Schema
|
|
341
|
+
|
|
342
|
+
If you use a custom pg-boss schema:
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
const queries = createPgBossQueries(pool, { schema: 'my_pgboss' });
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Template Structure
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
interface WorkflowTemplate {
|
|
352
|
+
type: string; // Unique identifier
|
|
353
|
+
queue: string; // pg-boss queue name
|
|
354
|
+
version: string; // Semantic version
|
|
355
|
+
description?: string; // Human-readable description
|
|
356
|
+
estimatedDurationSeconds?: number;
|
|
357
|
+
|
|
358
|
+
stages: StageTemplate[]; // Ordered list of stages
|
|
359
|
+
checkpoints?: CheckpointTemplate[];
|
|
360
|
+
nextWorkflow?: string; // Chain to another workflow
|
|
361
|
+
coordination?: CoordinationConfig;
|
|
362
|
+
queueConfig?: QueueConfig;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
interface StageTemplate {
|
|
366
|
+
name: string; // Unique within workflow
|
|
367
|
+
description?: string;
|
|
368
|
+
operations: OperationTemplate[];
|
|
369
|
+
parallel?: boolean; // Run operations concurrently
|
|
370
|
+
fixOperations?: OperationTemplate[]; // For fix-verify loops
|
|
371
|
+
maxFixCycles?: number;
|
|
372
|
+
condition?: (context) => boolean; // Skip if returns false
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
interface OperationTemplate {
|
|
376
|
+
type: string; // Maps to handler
|
|
377
|
+
timeout?: number; // Milliseconds
|
|
378
|
+
maxAttempts?: number;
|
|
379
|
+
critical?: boolean; // Fail workflow if operation fails
|
|
380
|
+
condition?: (context) => boolean;
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Operation Handlers
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
type OperationHandler<TContext, TResult> = (
|
|
388
|
+
context: OperationContext<TContext>
|
|
389
|
+
) => Promise<OperationResult<TResult>>;
|
|
390
|
+
|
|
391
|
+
interface OperationContext<T> {
|
|
392
|
+
workflowId: string;
|
|
393
|
+
operationId: string;
|
|
394
|
+
operationType: string;
|
|
395
|
+
stageName: string;
|
|
396
|
+
attempt: number;
|
|
397
|
+
maxAttempts: number;
|
|
398
|
+
previousResults: T; // Accumulated from prior operations
|
|
399
|
+
initialContext: object; // Original workflow context
|
|
400
|
+
workflowType: string;
|
|
401
|
+
signal?: AbortSignal;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
interface OperationResult<T> {
|
|
405
|
+
status: 'completed' | 'failed';
|
|
406
|
+
data?: T; // Merged into workflow context
|
|
407
|
+
reason?: string; // Error message if failed
|
|
408
|
+
metadata?: object;
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Handler Utilities
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
import { withTimeout, withRetry, withErrorBoundary, compose } from '@sprqvntrs/workflows';
|
|
416
|
+
|
|
417
|
+
// Add timeout to a handler
|
|
418
|
+
const timedHandler = withTimeout(myHandler, 30000);
|
|
419
|
+
|
|
420
|
+
// Add retry logic
|
|
421
|
+
const retryHandler = withRetry(myHandler, {
|
|
422
|
+
maxAttempts: 3,
|
|
423
|
+
baseDelayMs: 1000,
|
|
424
|
+
backoffMultiplier: 2,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Catch exceptions and return failed result
|
|
428
|
+
const safeHandler = withErrorBoundary(myHandler);
|
|
429
|
+
|
|
430
|
+
// Combine wrappers
|
|
431
|
+
const robustHandler = compose(
|
|
432
|
+
myHandler,
|
|
433
|
+
(h) => withErrorBoundary(h),
|
|
434
|
+
(h) => withTimeout(h, 30000),
|
|
435
|
+
);
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Entity Locking
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
import { createLockManager, withLock } from '@sprqvntrs/workflows';
|
|
442
|
+
|
|
443
|
+
const lockManager = createLockManager(orchestrator.getDbState());
|
|
444
|
+
|
|
445
|
+
// Manual locking
|
|
446
|
+
const acquired = await lockManager.acquire('document', docId, workflowId, {
|
|
447
|
+
ttlMs: 600000, // 10 minute TTL
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
if (acquired) {
|
|
451
|
+
try {
|
|
452
|
+
await processDocument();
|
|
453
|
+
} finally {
|
|
454
|
+
await lockManager.release('document', docId);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Or use the helper
|
|
459
|
+
const result = await withLock(
|
|
460
|
+
lockManager,
|
|
461
|
+
'document',
|
|
462
|
+
docId,
|
|
463
|
+
workflowId,
|
|
464
|
+
async () => {
|
|
465
|
+
return await processDocument();
|
|
466
|
+
},
|
|
467
|
+
{ ttlMs: 600000 }
|
|
468
|
+
);
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
## Testing
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
import {
|
|
475
|
+
createMockContext,
|
|
476
|
+
createSuccessResult,
|
|
477
|
+
createFailureResult,
|
|
478
|
+
testHandler,
|
|
479
|
+
assertSuccess,
|
|
480
|
+
} from '@sprqvntrs/workflows/testing';
|
|
481
|
+
|
|
482
|
+
// Test a handler
|
|
483
|
+
const result = await testHandler(myHandler, {
|
|
484
|
+
previousResults: { url: 'https://example.com' },
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
assertSuccess(result);
|
|
488
|
+
expect(result.data?.fetchedData).toBeDefined();
|
|
489
|
+
|
|
490
|
+
// Create mock context manually
|
|
491
|
+
const context = createMockContext({
|
|
492
|
+
operationType: 'gather.scrape',
|
|
493
|
+
attempt: 2,
|
|
494
|
+
previousResults: { url: 'https://example.com' },
|
|
495
|
+
});
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
## Architecture
|
|
499
|
+
|
|
500
|
+
```
|
|
501
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
502
|
+
│ Application │
|
|
503
|
+
│ (API Routes, UI Actions) │
|
|
504
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
505
|
+
│
|
|
506
|
+
▼
|
|
507
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
508
|
+
│ WorkflowOrchestrator │
|
|
509
|
+
│ (Public API Facade) │
|
|
510
|
+
│ • start/resume/cancel/retry workflows │
|
|
511
|
+
│ • schedule recurring workflows │
|
|
512
|
+
│ • query workflow status │
|
|
513
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
514
|
+
│
|
|
515
|
+
▼
|
|
516
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
517
|
+
│ ExecutionEngine │
|
|
518
|
+
│ • Interprets workflow templates │
|
|
519
|
+
│ • Executes stages (sequential) and operations (parallel/seq) │
|
|
520
|
+
│ • Handles retries, timeouts, checkpoints │
|
|
521
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
522
|
+
│
|
|
523
|
+
┌─────────────────┼─────────────────┐
|
|
524
|
+
▼ ▼ ▼
|
|
525
|
+
┌───────────────────┐ ┌─────────────────┐ ┌───────────────────┐
|
|
526
|
+
│ pg-boss Queue │ │ PostgreSQL │ │ Operations │
|
|
527
|
+
│ │ │ │ │ │
|
|
528
|
+
│ • Job persistence │ │ • workflows │ │ • Handlers │
|
|
529
|
+
│ • Retry/timeout │ │ • operations │ │ • Registry │
|
|
530
|
+
│ • Scheduling │ │ • locks │ │ • Validation │
|
|
531
|
+
└───────────────────┘ └─────────────────┘ └───────────────────┘
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
## Best Practices
|
|
535
|
+
|
|
536
|
+
### 1. Keep Handlers Idempotent
|
|
537
|
+
|
|
538
|
+
Operations may be retried. Design handlers to be safely re-run:
|
|
539
|
+
|
|
540
|
+
```typescript
|
|
541
|
+
const processDocument: OperationHandler = async (ctx) => {
|
|
542
|
+
// Check if already processed
|
|
543
|
+
const existing = await db.query.results.findFirst({
|
|
544
|
+
where: eq(results.documentId, ctx.previousResults.documentId),
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
if (existing) {
|
|
548
|
+
return { status: 'completed', data: { result: existing } };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Process and store result
|
|
552
|
+
const result = await process();
|
|
553
|
+
await db.insert(results).values({ documentId, result });
|
|
554
|
+
|
|
555
|
+
return { status: 'completed', data: { result } };
|
|
556
|
+
};
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
### 2. Use Checkpoints for Long Workflows
|
|
560
|
+
|
|
561
|
+
Pause for human review at critical points:
|
|
562
|
+
|
|
563
|
+
```typescript
|
|
564
|
+
const template: WorkflowTemplate = {
|
|
565
|
+
// ...
|
|
566
|
+
checkpoints: [
|
|
567
|
+
{
|
|
568
|
+
after: 'gather',
|
|
569
|
+
status: 'data_ready',
|
|
570
|
+
condition: (ctx) => ctx.requiresReview,
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
};
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### 3. Use Entity Locking for Shared Resources
|
|
577
|
+
|
|
578
|
+
Prevent race conditions when multiple workflows access the same data:
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
581
|
+
const handler: OperationHandler = async (ctx) => {
|
|
582
|
+
const lockManager = createLockManager(dbState);
|
|
583
|
+
|
|
584
|
+
return withLock(
|
|
585
|
+
lockManager,
|
|
586
|
+
'account',
|
|
587
|
+
ctx.previousResults.accountId,
|
|
588
|
+
ctx.workflowId,
|
|
589
|
+
async () => {
|
|
590
|
+
// Safe to modify account
|
|
591
|
+
await updateAccount();
|
|
592
|
+
return { status: 'completed', data: {} };
|
|
593
|
+
}
|
|
594
|
+
);
|
|
595
|
+
};
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### 4. Validate at Startup
|
|
599
|
+
|
|
600
|
+
Catch configuration errors early:
|
|
601
|
+
|
|
602
|
+
```typescript
|
|
603
|
+
// In worker.ts
|
|
604
|
+
orchestrator.registerTemplates([...]);
|
|
605
|
+
orchestrator.registerOperations({...});
|
|
606
|
+
|
|
607
|
+
// Throws if any template references missing handlers
|
|
608
|
+
orchestrator.validate();
|
|
609
|
+
|
|
610
|
+
await orchestrator.startWorker();
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
### 5. Use Typed Handlers
|
|
614
|
+
|
|
615
|
+
Leverage TypeScript for better IDE support:
|
|
616
|
+
|
|
617
|
+
```typescript
|
|
618
|
+
interface GatherContext {
|
|
619
|
+
url: string;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
interface GatherResult {
|
|
623
|
+
scrapedData: ScrapedData;
|
|
624
|
+
fetchedAt: string;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const gatherHandler: OperationHandler<GatherContext, GatherResult> = async (ctx) => {
|
|
628
|
+
// ctx.previousResults.url is typed as string
|
|
629
|
+
const data = await scrape(ctx.previousResults.url);
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
status: 'completed',
|
|
633
|
+
data: {
|
|
634
|
+
scrapedData: data,
|
|
635
|
+
fetchedAt: new Date().toISOString(),
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
};
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
## Raw TypeScript
|
|
642
|
+
|
|
643
|
+
This package ships raw TypeScript (`main` and `types` point at `index.ts`), so a Vite
|
|
644
|
+
consumer (Vite, React Router, Remix) must add the scope to `ssr.noExternal`:
|
|
645
|
+
`ssr: { noExternal: [/^@sprqvntrs\//] }`.
|
|
646
|
+
|
|
647
|
+
## License
|
|
648
|
+
|
|
649
|
+
MIT
|