@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.
@@ -0,0 +1,585 @@
1
+ /**
2
+ * pg-boss job queue infrastructure.
3
+ *
4
+ * This module provides the pg-boss setup and configuration for the workflow
5
+ * orchestrator. It handles initialization, queue management, and graceful shutdown.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { createBoss, getDefaultQueueConfig } from '@sprqvntrs/workflows';
10
+ *
11
+ * const boss = await createBoss({
12
+ * connectionString: process.env.DATABASE_URL,
13
+ * schema: 'pgboss',
14
+ * application: 'my-app',
15
+ * });
16
+ *
17
+ * await boss.start();
18
+ * ```
19
+ */
20
+
21
+ import PgBoss from 'pg-boss';
22
+ import type { QueueConfig, QueueDefinition } from '../types';
23
+
24
+ // =============================================================================
25
+ // Types
26
+ // =============================================================================
27
+
28
+ /**
29
+ * Configuration for pg-boss initialization.
30
+ */
31
+ export interface PgBossConfig {
32
+ /**
33
+ * PostgreSQL connection string.
34
+ */
35
+ connectionString: string;
36
+
37
+ /**
38
+ * Schema name for pg-boss tables.
39
+ * @default 'pgboss'
40
+ */
41
+ schema?: string;
42
+
43
+ /**
44
+ * Application name for PostgreSQL connection.
45
+ */
46
+ application?: string;
47
+
48
+ /**
49
+ * Whether to automatically create/migrate pg-boss schema.
50
+ * @default true
51
+ */
52
+ migrate?: boolean;
53
+
54
+ /**
55
+ * Whether to enable debug logging.
56
+ * @default false
57
+ */
58
+ debug?: boolean;
59
+
60
+ /**
61
+ * How long to retain completed jobs (in seconds).
62
+ * @default 604800 (7 days)
63
+ */
64
+ retentionSeconds?: number;
65
+
66
+ /**
67
+ * How long to retain archived jobs (in seconds).
68
+ * @default 604800 (7 days)
69
+ */
70
+ archiveSeconds?: number;
71
+
72
+ /**
73
+ * Interval for job maintenance (in seconds).
74
+ * @default 120 (2 minutes)
75
+ */
76
+ maintenanceIntervalSeconds?: number;
77
+ }
78
+
79
+ /**
80
+ * Job data structure for workflow jobs.
81
+ */
82
+ export interface WorkflowJobData {
83
+ /**
84
+ * The workflow ID to process.
85
+ */
86
+ workflowId: string;
87
+
88
+ /**
89
+ * Workflow type for logging/debugging.
90
+ */
91
+ workflowType: string;
92
+
93
+ /**
94
+ * When the job was created.
95
+ */
96
+ createdAt: string;
97
+ }
98
+
99
+ /**
100
+ * Job handler function signature.
101
+ */
102
+ export type JobHandler<T = WorkflowJobData> = (job: PgBoss.Job<T>) => Promise<void>;
103
+
104
+ /**
105
+ * Batch job handler function signature.
106
+ */
107
+ export type BatchJobHandler<T = WorkflowJobData> = (jobs: PgBoss.Job<T>[]) => Promise<void>;
108
+
109
+ // =============================================================================
110
+ // Default Configuration
111
+ // =============================================================================
112
+
113
+ /**
114
+ * Default queue configuration values.
115
+ *
116
+ * These defaults are used when no specific configuration is provided.
117
+ *
118
+ * @example
119
+ * ```typescript
120
+ * const config = {
121
+ * ...DEFAULT_QUEUE_CONFIG,
122
+ * retryLimit: 5, // Override retry limit
123
+ * };
124
+ * ```
125
+ */
126
+ export const DEFAULT_QUEUE_CONFIG: Required<QueueConfig> = {
127
+ retryLimit: 3,
128
+ retryDelay: 5,
129
+ retryBackoff: true,
130
+ expireInSeconds: 3600,
131
+ retentionSeconds: 86400,
132
+ };
133
+
134
+ /**
135
+ * Default pg-boss configuration values.
136
+ */
137
+ export const DEFAULT_PGBOSS_CONFIG: Required<Omit<PgBossConfig, 'connectionString'>> = {
138
+ schema: 'pgboss',
139
+ application: 'workflow-orchestrator',
140
+ migrate: true,
141
+ debug: false,
142
+ retentionSeconds: 604800,
143
+ archiveSeconds: 604800,
144
+ maintenanceIntervalSeconds: 120,
145
+ };
146
+
147
+ // =============================================================================
148
+ // Factory Functions
149
+ // =============================================================================
150
+
151
+ /**
152
+ * Creates and configures a pg-boss instance.
153
+ *
154
+ * This function creates a new pg-boss instance with the provided configuration.
155
+ * The instance must be started with `boss.start()` before use.
156
+ *
157
+ * @param config - pg-boss configuration
158
+ * @returns Configured pg-boss instance (not yet started)
159
+ *
160
+ * @example
161
+ * ```typescript
162
+ * const boss = createBoss({
163
+ * connectionString: 'postgresql://localhost/mydb',
164
+ * schema: 'pgboss',
165
+ * application: 'my-worker',
166
+ * });
167
+ *
168
+ * // Start the boss
169
+ * await boss.start();
170
+ *
171
+ * // Register workers...
172
+ *
173
+ * // Graceful shutdown
174
+ * process.on('SIGTERM', async () => {
175
+ * await boss.stop();
176
+ * });
177
+ * ```
178
+ */
179
+ export function createBoss(config: PgBossConfig): PgBoss {
180
+ const fullConfig = {
181
+ ...DEFAULT_PGBOSS_CONFIG,
182
+ ...config,
183
+ };
184
+
185
+ return new PgBoss({
186
+ connectionString: fullConfig.connectionString,
187
+ schema: fullConfig.schema,
188
+ application_name: fullConfig.application,
189
+ migrate: fullConfig.migrate,
190
+ // Retention settings
191
+ retentionMinutes: Math.floor(fullConfig.retentionSeconds / 60),
192
+ archiveCompletedAfterSeconds: fullConfig.archiveSeconds,
193
+ // Maintenance
194
+ maintenanceIntervalSeconds: fullConfig.maintenanceIntervalSeconds,
195
+ // Monitoring
196
+ ...(fullConfig.debug && { monitorStateIntervalSeconds: 30 }),
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Gets the queue configuration for a specific queue.
202
+ *
203
+ * Merges default configuration with queue-specific overrides.
204
+ *
205
+ * @param queueConfig - Optional queue-specific configuration
206
+ * @returns Complete queue configuration
207
+ *
208
+ * @example
209
+ * ```typescript
210
+ * const config = getQueueConfig({ retryLimit: 5 });
211
+ * // Returns: { retryLimit: 5, retryDelay: 5, retryBackoff: true, ... }
212
+ * ```
213
+ */
214
+ export function getQueueConfig(queueConfig?: QueueConfig): Required<QueueConfig> {
215
+ return {
216
+ ...DEFAULT_QUEUE_CONFIG,
217
+ ...queueConfig,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Creates pg-boss send options from queue configuration.
223
+ *
224
+ * Converts our QueueConfig to pg-boss SendOptions format.
225
+ *
226
+ * @param config - Queue configuration
227
+ * @param overrides - Additional send options
228
+ * @returns pg-boss SendOptions
229
+ *
230
+ * @example
231
+ * ```typescript
232
+ * const sendOptions = createSendOptions(
233
+ * { retryLimit: 3, retryDelay: 10 },
234
+ * { priority: 5 }
235
+ * );
236
+ * await boss.send('my-queue', data, sendOptions);
237
+ * ```
238
+ */
239
+ export function createSendOptions(
240
+ config: QueueConfig,
241
+ overrides?: Partial<PgBoss.SendOptions>,
242
+ ): PgBoss.SendOptions {
243
+ const fullConfig = getQueueConfig(config);
244
+
245
+ const baseOptions: PgBoss.SendOptions = {
246
+ retryLimit: fullConfig.retryLimit,
247
+ retryDelay: fullConfig.retryDelay,
248
+ retryBackoff: fullConfig.retryBackoff,
249
+ expireInSeconds: fullConfig.expireInSeconds,
250
+ retentionSeconds: fullConfig.retentionSeconds,
251
+ };
252
+
253
+ // Filter out undefined values from overrides to prevent pg-boss validation errors
254
+ // (e.g., "priority must be an integer" when priority is undefined)
255
+ if (overrides) {
256
+ for (const [key, value] of Object.entries(overrides)) {
257
+ if (value !== undefined) {
258
+ (baseOptions as Record<string, unknown>)[key] = value;
259
+ }
260
+ }
261
+ }
262
+
263
+ return baseOptions;
264
+ }
265
+
266
+ /**
267
+ * Creates pg-boss work options from queue definition.
268
+ *
269
+ * Converts our QueueDefinition to pg-boss WorkOptions format.
270
+ *
271
+ * @param queue - Queue definition
272
+ * @returns pg-boss WorkOptions
273
+ *
274
+ * @example
275
+ * ```typescript
276
+ * const workOptions = createWorkOptions({ name: 'my-queue', workers: 5 });
277
+ * await boss.work('my-queue', workOptions, handler);
278
+ * ```
279
+ */
280
+ export function createWorkOptions(queue: QueueDefinition): PgBoss.WorkOptions {
281
+ return {
282
+ batchSize: queue.batchSize ?? 1,
283
+ pollingIntervalSeconds: Math.floor((queue.pollingIntervalMs ?? 2000) / 1000),
284
+ };
285
+ }
286
+
287
+ // =============================================================================
288
+ // Utility Functions
289
+ // =============================================================================
290
+
291
+ /**
292
+ * Registers a worker for a specific queue.
293
+ *
294
+ * Convenience function that combines work options creation with registration.
295
+ *
296
+ * @param boss - pg-boss instance
297
+ * @param queue - Queue definition
298
+ * @param handler - Job handler function
299
+ * @returns Worker ID for unsubscribing
300
+ *
301
+ * @example
302
+ * ```typescript
303
+ * const workerId = await registerWorker(boss, { name: 'default', workers: 5 }, async (job) => {
304
+ * console.log('Processing job:', job.data);
305
+ * });
306
+ * ```
307
+ */
308
+ export async function registerWorker<T = WorkflowJobData>(
309
+ boss: PgBoss,
310
+ queue: QueueDefinition,
311
+ handler: JobHandler<T>,
312
+ ): Promise<string> {
313
+ const options = createWorkOptions(queue);
314
+ return boss.work<T>(queue.name, options, async ([job]) => {
315
+ if (job) {
316
+ await handler(job);
317
+ }
318
+ });
319
+ }
320
+
321
+ /**
322
+ * Registers a batch worker for a specific queue.
323
+ *
324
+ * For queues that benefit from processing multiple jobs at once.
325
+ *
326
+ * @param boss - pg-boss instance
327
+ * @param queue - Queue definition
328
+ * @param handler - Batch job handler function
329
+ * @returns Worker ID for unsubscribing
330
+ *
331
+ * @example
332
+ * ```typescript
333
+ * const workerId = await registerBatchWorker(boss, { name: 'bulk', batchSize: 10 }, async (jobs) => {
334
+ * console.log(`Processing ${jobs.length} jobs`);
335
+ * });
336
+ * ```
337
+ */
338
+ export async function registerBatchWorker<T = WorkflowJobData>(
339
+ boss: PgBoss,
340
+ queue: QueueDefinition,
341
+ handler: BatchJobHandler<T>,
342
+ ): Promise<string> {
343
+ const options = createWorkOptions(queue);
344
+ return boss.work<T>(queue.name, options, handler);
345
+ }
346
+
347
+ /**
348
+ * Schedules a recurring job using cron expression.
349
+ *
350
+ * Wrapper around pg-boss schedule that handles unscheduling and rescheduling.
351
+ *
352
+ * @param boss - pg-boss instance
353
+ * @param name - Unique schedule name
354
+ * @param cron - Cron expression
355
+ * @param queueName - Queue to send jobs to
356
+ * @param data - Data to include in each job
357
+ * @param options - Additional schedule options
358
+ *
359
+ * @example
360
+ * ```typescript
361
+ * // Schedule daily cleanup at 4 AM UTC
362
+ * await scheduleRecurring(boss, 'daily-cleanup', '0 4 * * *', 'cleanup', {
363
+ * scope: 'all',
364
+ * });
365
+ * ```
366
+ */
367
+ export async function scheduleRecurring<T extends object>(
368
+ boss: PgBoss,
369
+ name: string,
370
+ cron: string,
371
+ _queueName: string,
372
+ data?: T,
373
+ options?: PgBoss.ScheduleOptions,
374
+ ): Promise<void> {
375
+ // Unschedule first to ensure latest config is used
376
+ await boss.unschedule(name);
377
+ await boss.schedule(name, cron, data ?? {}, {
378
+ ...options,
379
+ tz: options?.tz ?? 'UTC',
380
+ });
381
+ }
382
+
383
+ /**
384
+ * Unschedules a recurring job.
385
+ *
386
+ * @param boss - pg-boss instance
387
+ * @param name - Schedule name to remove
388
+ *
389
+ * @example
390
+ * ```typescript
391
+ * await unschedule(boss, 'daily-cleanup');
392
+ * ```
393
+ */
394
+ export async function unschedule(boss: PgBoss, name: string): Promise<void> {
395
+ await boss.unschedule(name);
396
+ }
397
+
398
+ /**
399
+ * Gets information about a job by ID.
400
+ *
401
+ * @param boss - pg-boss instance
402
+ * @param jobId - Job ID to look up
403
+ * @returns Job information or null if not found
404
+ *
405
+ * @example
406
+ * ```typescript
407
+ * const job = await getJob(boss, 'some-job-id');
408
+ * if (job) {
409
+ * console.log('Job state:', job.state);
410
+ * }
411
+ * ```
412
+ */
413
+ export async function getJob<T = WorkflowJobData>(
414
+ boss: PgBoss,
415
+ queueName: string,
416
+ jobId: string,
417
+ ): Promise<PgBoss.JobWithMetadata<T> | null> {
418
+ return boss.getJobById<T>(queueName, jobId);
419
+ }
420
+
421
+ /**
422
+ * Cancels a job by ID.
423
+ *
424
+ * Only works for jobs that haven't started yet.
425
+ *
426
+ * @param boss - pg-boss instance
427
+ * @param jobId - Job ID to cancel
428
+ * @returns True if job was cancelled
429
+ *
430
+ * @example
431
+ * ```typescript
432
+ * const cancelled = await cancelJob(boss, 'some-job-id');
433
+ * if (cancelled) {
434
+ * console.log('Job cancelled successfully');
435
+ * }
436
+ * ```
437
+ */
438
+ export async function cancelJob(boss: PgBoss, queueName: string, jobId: string): Promise<void> {
439
+ await boss.cancel(queueName, jobId);
440
+ }
441
+
442
+ /**
443
+ * Resumes a job by ID.
444
+ *
445
+ * Only works for jobs that are in a cancelled or failed state.
446
+ *
447
+ * @param boss - pg-boss instance
448
+ * @param jobId - Job ID to resume
449
+ *
450
+ * @example
451
+ * ```typescript
452
+ * await resumeJob(boss, 'some-job-id');
453
+ * ```
454
+ */
455
+ export async function resumeJob(boss: PgBoss, queueName: string, jobId: string): Promise<void> {
456
+ await boss.resume(queueName, jobId);
457
+ }
458
+
459
+ // =============================================================================
460
+ // Lifecycle Helpers
461
+ // =============================================================================
462
+
463
+ /**
464
+ * Sets up graceful shutdown handlers.
465
+ *
466
+ * Registers SIGTERM and SIGINT handlers to stop pg-boss gracefully.
467
+ *
468
+ * @param boss - pg-boss instance
469
+ * @param options - Shutdown options
470
+ *
471
+ * @example
472
+ * ```typescript
473
+ * const boss = createBoss({ connectionString: '...' });
474
+ * await boss.start();
475
+ *
476
+ * setupGracefulShutdown(boss, {
477
+ * timeout: 30000,
478
+ * onShutdown: () => console.log('Shutting down...'),
479
+ * });
480
+ * ```
481
+ */
482
+ export function setupGracefulShutdown(
483
+ boss: PgBoss,
484
+ options?: {
485
+ /**
486
+ * Maximum time to wait for jobs to complete (ms).
487
+ * @default 30000
488
+ */
489
+ timeout?: number;
490
+ /**
491
+ * Callback when shutdown starts.
492
+ */
493
+ onShutdown?: () => void;
494
+ /**
495
+ * Callback when shutdown completes.
496
+ */
497
+ onComplete?: () => void;
498
+ },
499
+ ): void {
500
+ const { timeout = 30000, onShutdown, onComplete } = options ?? {};
501
+
502
+ let isShuttingDown = false;
503
+
504
+ const shutdown = async (_signal: string): Promise<void> => {
505
+ if (isShuttingDown) {
506
+ return;
507
+ }
508
+ isShuttingDown = true;
509
+
510
+ onShutdown?.();
511
+
512
+ // Set up timeout for forceful exit
513
+ const forceExitTimeout = setTimeout(() => {
514
+ console.error(`Shutdown timeout (${timeout}ms) exceeded, forcing exit`);
515
+ process.exit(1);
516
+ }, timeout);
517
+
518
+ try {
519
+ await boss.stop({ graceful: true, timeout });
520
+ clearTimeout(forceExitTimeout);
521
+ onComplete?.();
522
+ process.exit(0);
523
+ } catch (error) {
524
+ clearTimeout(forceExitTimeout);
525
+ console.error('Error during shutdown:', error);
526
+ process.exit(1);
527
+ }
528
+ };
529
+
530
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
531
+ process.on('SIGINT', () => shutdown('SIGINT'));
532
+ }
533
+
534
+ // =============================================================================
535
+ // Event Helpers
536
+ // =============================================================================
537
+
538
+ /**
539
+ * Event types emitted by pg-boss.
540
+ */
541
+ export type PgBossEvent = 'error' | 'monitor-states' | 'wip' | 'stopped';
542
+
543
+ /**
544
+ * Sets up event listeners for monitoring.
545
+ *
546
+ * @param boss - pg-boss instance
547
+ * @param handlers - Event handlers
548
+ *
549
+ * @example
550
+ * ```typescript
551
+ * setupEventListeners(boss, {
552
+ * onError: (error) => console.error('pg-boss error:', error),
553
+ * onMonitorStates: (states) => metrics.gauge('pgboss.active', states.active),
554
+ * });
555
+ * ```
556
+ */
557
+ export function setupEventListeners(
558
+ boss: PgBoss,
559
+ handlers: {
560
+ /**
561
+ * Called when an error occurs.
562
+ */
563
+ onError?: (error: Error) => void;
564
+ /**
565
+ * Called periodically with job state counts.
566
+ */
567
+ onMonitorStates?: (states: PgBoss.MonitorStates) => void;
568
+ /**
569
+ * Called when boss stops.
570
+ */
571
+ onStopped?: () => void;
572
+ },
573
+ ): void {
574
+ if (handlers.onError) {
575
+ boss.on('error', handlers.onError);
576
+ }
577
+
578
+ if (handlers.onMonitorStates) {
579
+ boss.on('monitor-states', handlers.onMonitorStates);
580
+ }
581
+
582
+ if (handlers.onStopped) {
583
+ boss.on('stopped', handlers.onStopped);
584
+ }
585
+ }