@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 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