@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/index.ts ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * @sprqvntrs/workflows
3
+ *
4
+ * PostgreSQL-backed workflow orchestration with pg-boss job queuing.
5
+ *
6
+ * This package provides a declarative, template-driven approach to executing
7
+ * multi-stage background workflows with reliable persistence and job processing.
8
+ *
9
+ * ## Quick Start
10
+ *
11
+ * ```typescript
12
+ * import { createWorkflowOrchestrator } from '@sprqvntrs/workflows';
13
+ * import { drizzle } from 'drizzle-orm/node-postgres';
14
+ * import { Pool } from 'pg';
15
+ *
16
+ * // Set up database
17
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
18
+ * const db = drizzle(pool);
19
+ *
20
+ * // Create orchestrator
21
+ * const orchestrator = await createWorkflowOrchestrator({
22
+ * connectionString: process.env.DATABASE_URL,
23
+ * db,
24
+ * queues: [
25
+ * { name: 'default', workers: 5 },
26
+ * { name: 'sequential', workers: 1 },
27
+ * ],
28
+ * });
29
+ *
30
+ * // Register a workflow template
31
+ * orchestrator.registerTemplate({
32
+ * type: 'content-generation',
33
+ * queue: 'default',
34
+ * version: '1.0.0',
35
+ * stages: [
36
+ * { name: 'gather', operations: [{ type: 'gather.data' }] },
37
+ * { name: 'analyze', parallel: true, operations: [
38
+ * { type: 'analyze.content' },
39
+ * { type: 'analyze.market' },
40
+ * ]},
41
+ * { name: 'generate', operations: [{ type: 'generate.report' }] },
42
+ * ],
43
+ * });
44
+ *
45
+ * // Register operation handlers
46
+ * orchestrator.registerOperations({
47
+ * 'gather.data': async (ctx) => {
48
+ * const data = await fetchData(ctx.previousResults.url);
49
+ * return { status: 'completed', data: { fetchedData: data } };
50
+ * },
51
+ * // ... more handlers
52
+ * });
53
+ *
54
+ * // Start a workflow
55
+ * const { workflowId } = await orchestrator.start({
56
+ * type: 'content-generation',
57
+ * context: { url: 'https://example.com' },
58
+ * });
59
+ * ```
60
+ *
61
+ * ## Architecture
62
+ *
63
+ * The package follows a three-layer architecture:
64
+ *
65
+ * 1. **Orchestrator** - Public API for starting, managing, and querying workflows
66
+ * 2. **Execution Engine** - Interprets templates and executes stages/operations
67
+ * 3. **Infrastructure** - pg-boss job queue and Drizzle database persistence
68
+ *
69
+ * ## Features
70
+ *
71
+ * - **Declarative Templates** - Define workflows as configuration objects
72
+ * - **Parallel Execution** - Run operations concurrently within stages
73
+ * - **Checkpoints** - Pause workflows for manual intervention
74
+ * - **Workflow Chaining** - Trigger follow-up workflows on completion
75
+ * - **Fix-Verify Loops** - Automatic retry cycles for data integrity
76
+ * - **Entity Locking** - Prevent race conditions between workflows
77
+ * - **Type Safety** - Full TypeScript support with generics
78
+ *
79
+ * @packageDocumentation
80
+ */
81
+
82
+ // =============================================================================
83
+ // Main Orchestrator
84
+ // =============================================================================
85
+
86
+ export { createWorkflowOrchestrator, type WorkflowOrchestrator, type CreateOrchestratorConfig } from './src/orchestrator';
87
+
88
+ // =============================================================================
89
+ // Core Types
90
+ // =============================================================================
91
+
92
+ export type {
93
+ // Status types
94
+ WorkflowStatus,
95
+ OperationStatus,
96
+
97
+ // Template types
98
+ WorkflowTemplate,
99
+ StageTemplate,
100
+ OperationTemplate,
101
+ CheckpointTemplate,
102
+ CoordinationConfig,
103
+ QueueConfig,
104
+
105
+ // Operation types
106
+ OperationHandler,
107
+ OperationContext,
108
+ OperationResult,
109
+
110
+ // Record types
111
+ WorkflowRecord,
112
+ OperationRecord,
113
+ WorkflowContext,
114
+
115
+ // Configuration types
116
+ OrchestratorConfig,
117
+ QueueDefinition,
118
+
119
+ // API types
120
+ StartWorkflowOptions,
121
+ StartWorkflowResult,
122
+ ScheduleOptions,
123
+ WorkflowStatusDetails,
124
+ } from './src/types';
125
+
126
+ // Error types
127
+ export { WorkflowError, TemplateError, OperationError, TimeoutError, CoordinationError } from './src/types';
128
+
129
+ // =============================================================================
130
+ // Registries
131
+ // =============================================================================
132
+
133
+ export {
134
+ createTemplateRegistry,
135
+ validateTemplate,
136
+ getOperationTypes,
137
+ getQueueNames,
138
+ type TemplateRegistry,
139
+ type ValidationError,
140
+ } from './src/templates/registry';
141
+
142
+ export {
143
+ createOperationRegistry,
144
+ withTimeout,
145
+ withRetry,
146
+ withErrorBoundary,
147
+ compose,
148
+ defineHandler,
149
+ type OperationRegistry,
150
+ type TypedOperationHandler,
151
+ } from './src/operations/registry';
152
+
153
+ // =============================================================================
154
+ // Execution Engine
155
+ // =============================================================================
156
+
157
+ export {
158
+ createExecutionEngine,
159
+ type ExecutionEngine,
160
+ type ExecutionEngineConfig,
161
+ } from './src/engine/execution-engine';
162
+
163
+ // =============================================================================
164
+ // Infrastructure
165
+ // =============================================================================
166
+
167
+ // pg-boss utilities
168
+ export {
169
+ createBoss,
170
+ createSendOptions,
171
+ createWorkOptions,
172
+ registerWorker,
173
+ registerBatchWorker,
174
+ scheduleRecurring,
175
+ unschedule,
176
+ getJob,
177
+ cancelJob,
178
+ resumeJob,
179
+ setupGracefulShutdown,
180
+ setupEventListeners,
181
+ DEFAULT_QUEUE_CONFIG,
182
+ DEFAULT_PGBOSS_CONFIG,
183
+ type PgBossConfig,
184
+ type WorkflowJobData,
185
+ type JobHandler,
186
+ type BatchJobHandler,
187
+ type PgBossEvent,
188
+ } from './src/infrastructure/pg-boss';
189
+
190
+ // Database state
191
+ export {
192
+ createDbState,
193
+ type DbState,
194
+ type Database,
195
+ type CreateWorkflowData,
196
+ type CreateOperationData,
197
+ type ListWorkflowsOptions,
198
+ } from './src/infrastructure/db-state';
199
+
200
+ // pg-boss introspection queries
201
+ export {
202
+ createPgBossQueries,
203
+ type PgBossQueries,
204
+ type PgBossQueriesConfig,
205
+ type PgBossJobState,
206
+ type PgBossArchiveState,
207
+ type PgBossJobItem,
208
+ type PgBossJobStats,
209
+ type PgBossScheduleItem,
210
+ type PgBossArchiveItem,
211
+ type DeleteWorkflowJobsResult,
212
+ type PendingJobsResult,
213
+ } from './src/infrastructure/pgboss-queries';
214
+
215
+ // =============================================================================
216
+ // Coordination
217
+ // =============================================================================
218
+
219
+ export {
220
+ createLockManager,
221
+ withLock,
222
+ withLockWait,
223
+ type LockManager,
224
+ type LockOptions,
225
+ type WaitOptions,
226
+ type LockInfo,
227
+ } from './src/coordination/lock-manager';
228
+
229
+ // =============================================================================
230
+ // Re-exports for convenience
231
+ // =============================================================================
232
+
233
+ // Re-export PgBoss for advanced usage
234
+ export { default as PgBoss } from 'pg-boss';
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@sprqvntrs/workflows",
3
+ "version": "0.2.4",
4
+ "description": "PostgreSQL-backed workflow orchestration with pg-boss job queuing",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "types": "./index.ts",
8
+ "exports": {
9
+ ".": "./index.ts",
10
+ "./worker": "./src/worker/index.ts",
11
+ "./schema": "./src/infrastructure/schema.ts",
12
+ "./testing": "./src/testing/index.ts"
13
+ },
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/SPRQVNTRS/platform.git",
18
+ "directory": "packages/workflows"
19
+ },
20
+ "files": [
21
+ "src/**/*",
22
+ "index.ts",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "dependencies": {
31
+ "pg-boss": "^10.1.5"
32
+ },
33
+ "peerDependencies": {
34
+ "drizzle-orm": "^0.39.0",
35
+ "pg": "^8.11.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.0.0",
39
+ "@types/pg": "^8.11.0",
40
+ "drizzle-orm": "^0.39.0",
41
+ "pg": "^8.11.0",
42
+ "tsx": "^4.20.6",
43
+ "typescript": "^5.6.0",
44
+ "vitest": "^3.2.4"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "homepage": "https://github.com/SPRQVNTRS/platform/tree/main/packages/workflows#readme",
50
+ "bugs": {
51
+ "url": "https://github.com/SPRQVNTRS/platform/issues"
52
+ }
53
+ }
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Lock manager for workflow coordination.
3
+ *
4
+ * This module provides entity locking to prevent race conditions when
5
+ * multiple workflows operate on the same entity.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { createLockManager } from '@sprqvntrs/workflows';
10
+ *
11
+ * const lockManager = createLockManager(dbState);
12
+ *
13
+ * // Acquire lock before processing
14
+ * const acquired = await lockManager.acquire('document', '123', workflowId);
15
+ * if (!acquired) {
16
+ * throw new Error('Entity is locked by another workflow');
17
+ * }
18
+ *
19
+ * try {
20
+ * // Process entity...
21
+ * } finally {
22
+ * await lockManager.release('document', '123');
23
+ * }
24
+ * ```
25
+ */
26
+
27
+ import type { DbState } from '../infrastructure/db-state';
28
+ import { CoordinationError } from '../types';
29
+
30
+ // =============================================================================
31
+ // Types
32
+ // =============================================================================
33
+
34
+ /**
35
+ * Lock manager interface.
36
+ */
37
+ export interface LockManager {
38
+ /**
39
+ * Acquires a lock on an entity.
40
+ *
41
+ * @param entityType - Type of entity (e.g., 'document', 'user')
42
+ * @param entityId - Entity identifier
43
+ * @param workflowId - Workflow requesting the lock
44
+ * @param options - Lock options
45
+ * @returns True if lock was acquired
46
+ *
47
+ * @example
48
+ * ```typescript
49
+ * const acquired = await lockManager.acquire('document', '123', workflowId, {
50
+ * ttlMs: 600000, // 10 minute TTL
51
+ * });
52
+ * ```
53
+ */
54
+ acquire: (
55
+ entityType: string,
56
+ entityId: string,
57
+ workflowId: string,
58
+ options?: LockOptions,
59
+ ) => Promise<boolean>;
60
+
61
+ /**
62
+ * Releases a lock on an entity.
63
+ *
64
+ * @param entityType - Type of entity
65
+ * @param entityId - Entity identifier
66
+ * @returns True if lock was released
67
+ *
68
+ * @example
69
+ * ```typescript
70
+ * await lockManager.release('document', '123');
71
+ * ```
72
+ */
73
+ release: (entityType: string, entityId: string) => Promise<boolean>;
74
+
75
+ /**
76
+ * Releases all locks held by a workflow.
77
+ *
78
+ * @param workflowId - Workflow ID
79
+ * @returns Number of locks released
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * const count = await lockManager.releaseAll(workflowId);
84
+ * console.log(`Released ${count} locks`);
85
+ * ```
86
+ */
87
+ releaseAll: (workflowId: string) => Promise<number>;
88
+
89
+ /**
90
+ * Checks if an entity is locked.
91
+ *
92
+ * @param entityType - Type of entity
93
+ * @param entityId - Entity identifier
94
+ * @returns Lock info or null if not locked
95
+ *
96
+ * @example
97
+ * ```typescript
98
+ * const lock = await lockManager.check('document', '123');
99
+ * if (lock) {
100
+ * console.log(`Locked by workflow ${lock.workflowId}`);
101
+ * }
102
+ * ```
103
+ */
104
+ check: (entityType: string, entityId: string) => Promise<LockInfo | null>;
105
+
106
+ /**
107
+ * Waits for a lock to become available.
108
+ *
109
+ * @param entityType - Type of entity
110
+ * @param entityId - Entity identifier
111
+ * @param workflowId - Workflow requesting the lock
112
+ * @param options - Wait options
113
+ * @returns True if lock was acquired
114
+ *
115
+ * @example
116
+ * ```typescript
117
+ * const acquired = await lockManager.waitAndAcquire('document', '123', workflowId, {
118
+ * timeoutMs: 30000,
119
+ * pollIntervalMs: 1000,
120
+ * });
121
+ * ```
122
+ */
123
+ waitAndAcquire: (
124
+ entityType: string,
125
+ entityId: string,
126
+ workflowId: string,
127
+ options?: WaitOptions,
128
+ ) => Promise<boolean>;
129
+
130
+ /**
131
+ * Cleans up expired locks.
132
+ *
133
+ * Should be called periodically to release stale locks.
134
+ *
135
+ * @returns Number of locks cleaned up
136
+ */
137
+ cleanup: () => Promise<number>;
138
+ }
139
+
140
+ /**
141
+ * Options for acquiring a lock.
142
+ */
143
+ export interface LockOptions {
144
+ /**
145
+ * Time-to-live for the lock in milliseconds.
146
+ * If not provided, lock has no expiration.
147
+ */
148
+ ttlMs?: number;
149
+ }
150
+
151
+ /**
152
+ * Options for waiting for a lock.
153
+ */
154
+ export interface WaitOptions extends LockOptions {
155
+ /**
156
+ * Maximum time to wait in milliseconds.
157
+ * @default 60000 (1 minute)
158
+ */
159
+ timeoutMs?: number;
160
+
161
+ /**
162
+ * Interval between lock checks in milliseconds.
163
+ * @default 1000 (1 second)
164
+ */
165
+ pollIntervalMs?: number;
166
+ }
167
+
168
+ /**
169
+ * Information about a held lock.
170
+ */
171
+ export interface LockInfo {
172
+ /**
173
+ * Type of locked entity.
174
+ */
175
+ entityType: string;
176
+
177
+ /**
178
+ * ID of locked entity.
179
+ */
180
+ entityId: string;
181
+
182
+ /**
183
+ * Workflow holding the lock.
184
+ */
185
+ workflowId: string;
186
+
187
+ /**
188
+ * When lock was acquired.
189
+ */
190
+ acquiredAt: Date;
191
+
192
+ /**
193
+ * When lock expires (if TTL was set).
194
+ */
195
+ expiresAt: Date | null;
196
+ }
197
+
198
+ // =============================================================================
199
+ // Factory Function
200
+ // =============================================================================
201
+
202
+ /**
203
+ * Creates a lock manager instance.
204
+ *
205
+ * The lock manager provides entity-level locking to prevent race conditions
206
+ * when multiple workflows operate on the same entity.
207
+ *
208
+ * @param dbState - Database state manager
209
+ * @returns Lock manager instance
210
+ *
211
+ * @example
212
+ * ```typescript
213
+ * const lockManager = createLockManager(dbState);
214
+ *
215
+ * // In your workflow operation
216
+ * async function processDocument(context: OperationContext) {
217
+ * const { workflowId, previousResults } = context;
218
+ * const documentId = previousResults.documentId as string;
219
+ *
220
+ * // Acquire lock with TTL
221
+ * const acquired = await lockManager.acquire('document', documentId, workflowId, {
222
+ * ttlMs: 600000, // 10 minutes
223
+ * });
224
+ *
225
+ * if (!acquired) {
226
+ * return { status: 'failed', reason: 'Document is being processed by another workflow' };
227
+ * }
228
+ *
229
+ * try {
230
+ * // Process document...
231
+ * return { status: 'completed', data: { processed: true } };
232
+ * } finally {
233
+ * // Always release in finally block
234
+ * await lockManager.release('document', documentId);
235
+ * }
236
+ * }
237
+ * ```
238
+ */
239
+ export function createLockManager(dbState: DbState): LockManager {
240
+ return {
241
+ async acquire(
242
+ entityType: string,
243
+ entityId: string,
244
+ workflowId: string,
245
+ options?: LockOptions,
246
+ ): Promise<boolean> {
247
+ const expiresAt = options?.ttlMs ? new Date(Date.now() + options.ttlMs) : undefined;
248
+
249
+ return dbState.acquireLock(entityType, entityId, workflowId, expiresAt);
250
+ },
251
+
252
+ async release(entityType: string, entityId: string): Promise<boolean> {
253
+ return dbState.releaseLock(entityType, entityId);
254
+ },
255
+
256
+ async releaseAll(workflowId: string): Promise<number> {
257
+ return dbState.releaseLocksByWorkflow(workflowId);
258
+ },
259
+
260
+ async check(entityType: string, entityId: string): Promise<LockInfo | null> {
261
+ const lock = await dbState.getLock(entityType, entityId);
262
+ if (!lock) {
263
+ return null;
264
+ }
265
+
266
+ return {
267
+ entityType: lock.entityType,
268
+ entityId: lock.entityId,
269
+ workflowId: lock.workflowId,
270
+ acquiredAt: lock.acquiredAt,
271
+ expiresAt: lock.expiresAt,
272
+ };
273
+ },
274
+
275
+ async waitAndAcquire(
276
+ entityType: string,
277
+ entityId: string,
278
+ workflowId: string,
279
+ options?: WaitOptions,
280
+ ): Promise<boolean> {
281
+ const { timeoutMs = 60000, pollIntervalMs = 1000, ttlMs } = options ?? {};
282
+ const deadline = Date.now() + timeoutMs;
283
+
284
+ while (Date.now() < deadline) {
285
+ // Try to acquire
286
+ const expiresAt = ttlMs ? new Date(Date.now() + ttlMs) : undefined;
287
+ const acquired = await dbState.acquireLock(entityType, entityId, workflowId, expiresAt);
288
+
289
+ if (acquired) {
290
+ return true;
291
+ }
292
+
293
+ // Check if existing lock has expired
294
+ const existingLock = await dbState.getLock(entityType, entityId);
295
+ if (existingLock?.expiresAt && existingLock.expiresAt < new Date()) {
296
+ // Lock has expired, clean it up and try again
297
+ await dbState.releaseLock(entityType, entityId);
298
+ continue;
299
+ }
300
+
301
+ // Wait before next attempt
302
+ if (Date.now() + pollIntervalMs < deadline) {
303
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
304
+ } else {
305
+ break;
306
+ }
307
+ }
308
+
309
+ return false;
310
+ },
311
+
312
+ async cleanup(): Promise<number> {
313
+ return dbState.cleanupExpiredLocks();
314
+ },
315
+ };
316
+ }
317
+
318
+ // =============================================================================
319
+ // Helper Functions
320
+ // =============================================================================
321
+
322
+ /**
323
+ * Executes a function with a lock, automatically releasing on completion.
324
+ *
325
+ * This helper ensures the lock is always released, even if the function throws.
326
+ *
327
+ * @param lockManager - Lock manager instance
328
+ * @param entityType - Type of entity to lock
329
+ * @param entityId - Entity identifier
330
+ * @param workflowId - Workflow ID
331
+ * @param fn - Function to execute while holding the lock
332
+ * @param options - Lock options
333
+ * @returns Result of the function
334
+ * @throws CoordinationError if lock cannot be acquired
335
+ *
336
+ * @example
337
+ * ```typescript
338
+ * const result = await withLock(
339
+ * lockManager,
340
+ * 'document',
341
+ * '123',
342
+ * workflowId,
343
+ * async () => {
344
+ * // This runs while holding the lock
345
+ * return await processDocument();
346
+ * },
347
+ * { ttlMs: 300000 }
348
+ * );
349
+ * ```
350
+ */
351
+ export async function withLock<T>(
352
+ lockManager: LockManager,
353
+ entityType: string,
354
+ entityId: string,
355
+ workflowId: string,
356
+ fn: () => Promise<T>,
357
+ options?: LockOptions,
358
+ ): Promise<T> {
359
+ const acquired = await lockManager.acquire(entityType, entityId, workflowId, options);
360
+
361
+ if (!acquired) {
362
+ throw new CoordinationError(`Failed to acquire lock on ${entityType}:${entityId}`, {
363
+ entityType,
364
+ entityId,
365
+ workflowId,
366
+ });
367
+ }
368
+
369
+ try {
370
+ return await fn();
371
+ } finally {
372
+ await lockManager.release(entityType, entityId);
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Executes a function with a lock, waiting if necessary.
378
+ *
379
+ * Like `withLock`, but waits for the lock to become available.
380
+ *
381
+ * @param lockManager - Lock manager instance
382
+ * @param entityType - Type of entity to lock
383
+ * @param entityId - Entity identifier
384
+ * @param workflowId - Workflow ID
385
+ * @param fn - Function to execute while holding the lock
386
+ * @param options - Wait options
387
+ * @returns Result of the function
388
+ * @throws CoordinationError if lock cannot be acquired within timeout
389
+ *
390
+ * @example
391
+ * ```typescript
392
+ * const result = await withLockWait(
393
+ * lockManager,
394
+ * 'document',
395
+ * '123',
396
+ * workflowId,
397
+ * async () => {
398
+ * return await processDocument();
399
+ * },
400
+ * { timeoutMs: 60000, ttlMs: 300000 }
401
+ * );
402
+ * ```
403
+ */
404
+ export async function withLockWait<T>(
405
+ lockManager: LockManager,
406
+ entityType: string,
407
+ entityId: string,
408
+ workflowId: string,
409
+ fn: () => Promise<T>,
410
+ options?: WaitOptions,
411
+ ): Promise<T> {
412
+ const acquired = await lockManager.waitAndAcquire(entityType, entityId, workflowId, options);
413
+
414
+ if (!acquired) {
415
+ throw new CoordinationError(
416
+ `Timed out waiting for lock on ${entityType}:${entityId}`,
417
+ {
418
+ entityType,
419
+ entityId,
420
+ workflowId,
421
+ timeoutMs: options?.timeoutMs,
422
+ },
423
+ );
424
+ }
425
+
426
+ try {
427
+ return await fn();
428
+ } finally {
429
+ await lockManager.release(entityType, entityId);
430
+ }
431
+ }