@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,320 @@
1
+ /**
2
+ * pg-boss introspection queries.
3
+ *
4
+ * This module provides utility functions for querying pg-boss internal tables
5
+ * directly, useful for debugging, monitoring, and administration without
6
+ * needing a separate database client.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { createPgBossQueries } from '@sprqvntrs/workflows';
11
+ * import { Pool } from 'pg';
12
+ *
13
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
14
+ * const queries = createPgBossQueries(pool);
15
+ *
16
+ * // Get job statistics
17
+ * const stats = await queries.getJobStats();
18
+ * console.log(stats);
19
+ *
20
+ * // Get all schedules
21
+ * const schedules = await queries.getSchedules();
22
+ *
23
+ * // Get recent job history for specific queues
24
+ * const history = await queries.getJobHistory(['content-generation', 'data-sync']);
25
+ * ```
26
+ */
27
+
28
+ import type { Pool, PoolClient } from 'pg';
29
+
30
+ // =============================================================================
31
+ // Types
32
+ // =============================================================================
33
+
34
+ /**
35
+ * pg-boss job states.
36
+ */
37
+ export type PgBossJobState = 'created' | 'active' | 'retry' | 'completed' | 'failed' | 'cancelled';
38
+
39
+ /**
40
+ * pg-boss archive job states (terminal states).
41
+ */
42
+ export type PgBossArchiveState = 'completed' | 'failed' | 'expired' | 'cancelled';
43
+
44
+ /**
45
+ * Simplified job item from pgboss.job table.
46
+ */
47
+ export interface PgBossJobItem {
48
+ id: string;
49
+ name: string;
50
+ state: PgBossJobState;
51
+ }
52
+
53
+ /**
54
+ * Job statistics grouped by queue name and state.
55
+ */
56
+ export interface PgBossJobStats {
57
+ name: string;
58
+ state: PgBossJobState;
59
+ count: number;
60
+ }
61
+
62
+ /**
63
+ * Schedule item from pgboss.schedule table.
64
+ */
65
+ export interface PgBossScheduleItem {
66
+ name: string;
67
+ cron: string;
68
+ timezone: string | null;
69
+ data: Record<string, unknown> | null;
70
+ created_on: Date;
71
+ updated_on: Date;
72
+ }
73
+
74
+ /**
75
+ * Archive item from pgboss.archive table.
76
+ */
77
+ export interface PgBossArchiveItem {
78
+ id: string;
79
+ name: string;
80
+ state: PgBossArchiveState;
81
+ output: Record<string, unknown> | null;
82
+ completed_on: Date;
83
+ }
84
+
85
+ /**
86
+ * Result of deleting workflow jobs.
87
+ */
88
+ export interface DeleteWorkflowJobsResult {
89
+ cancelledCount: number;
90
+ deletedCount: number;
91
+ }
92
+
93
+ /**
94
+ * Result of deleting or cancelling pending jobs.
95
+ */
96
+ export interface PendingJobsResult {
97
+ count: number;
98
+ }
99
+
100
+ /**
101
+ * pg-boss query interface.
102
+ */
103
+ export interface PgBossQueries {
104
+ /**
105
+ * Gets all jobs from pgboss.job table.
106
+ * Returns simplified job items with id, name, and state.
107
+ */
108
+ getJobs: () => Promise<PgBossJobItem[]>;
109
+
110
+ /**
111
+ * Gets job statistics grouped by queue name and state.
112
+ * Useful for monitoring job distribution across queues.
113
+ */
114
+ getJobStats: () => Promise<PgBossJobStats[]>;
115
+
116
+ /**
117
+ * Gets all schedules from pgboss.schedule table.
118
+ * Returns schedules sorted by creation time (newest first).
119
+ */
120
+ getSchedules: () => Promise<PgBossScheduleItem[]>;
121
+
122
+ /**
123
+ * Gets job history from pgboss.archive for given queue names.
124
+ * Returns up to 50 most recent archived jobs.
125
+ *
126
+ * @param names - Queue names to filter by
127
+ */
128
+ getJobHistory: (names: string[]) => Promise<PgBossArchiveItem[]>;
129
+
130
+ /**
131
+ * Cancels and deletes all jobs associated with a workflow ID.
132
+ * Searches jobs where data.workflowId matches the given ID.
133
+ *
134
+ * @param workflowId - Workflow ID to cancel/delete jobs for
135
+ */
136
+ deleteWorkflowJobs: (workflowId: string) => Promise<DeleteWorkflowJobsResult>;
137
+
138
+ /**
139
+ * Deletes all pending jobs (state = 'created').
140
+ * Optionally filter by queue name.
141
+ *
142
+ * @param queueName - Optional queue name to filter by
143
+ */
144
+ deletePendingJobs: (queueName?: string) => Promise<PendingJobsResult>;
145
+
146
+ /**
147
+ * Cancels all pending jobs (state = 'created') by setting state to 'cancelled'.
148
+ * Jobs remain in the database but won't be processed.
149
+ * Optionally filter by queue name.
150
+ *
151
+ * @param queueName - Optional queue name to filter by
152
+ */
153
+ cancelPendingJobs: (queueName?: string) => Promise<PendingJobsResult>;
154
+
155
+ /**
156
+ * Purges all jobs in a specific state.
157
+ * WARNING: This permanently deletes jobs.
158
+ *
159
+ * @param state - Job state to purge
160
+ * @param queueName - Optional queue name to filter by
161
+ */
162
+ purgeJobsByState: (state: PgBossJobState, queueName?: string) => Promise<PendingJobsResult>;
163
+ }
164
+
165
+ /**
166
+ * Configuration for pg-boss queries.
167
+ */
168
+ export interface PgBossQueriesConfig {
169
+ /**
170
+ * pg-boss schema name.
171
+ * @default 'pgboss'
172
+ */
173
+ schema?: string;
174
+ }
175
+
176
+ // =============================================================================
177
+ // Factory Function
178
+ // =============================================================================
179
+
180
+ /**
181
+ * Creates a pg-boss queries instance.
182
+ *
183
+ * Provides utility functions for introspecting pg-boss internal tables
184
+ * directly via raw SQL queries. Useful for debugging and monitoring.
185
+ *
186
+ * @param pool - PostgreSQL connection pool
187
+ * @param config - Optional configuration
188
+ * @returns pg-boss queries interface
189
+ *
190
+ * @example
191
+ * ```typescript
192
+ * import { createPgBossQueries } from '@sprqvntrs/workflows';
193
+ * import { Pool } from 'pg';
194
+ *
195
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
196
+ * const queries = createPgBossQueries(pool);
197
+ *
198
+ * // Monitor active jobs
199
+ * const stats = await queries.getJobStats();
200
+ * const activeJobs = stats.filter(s => s.state === 'active');
201
+ * console.log('Active jobs by queue:', activeJobs);
202
+ *
203
+ * // Check schedules
204
+ * const schedules = await queries.getSchedules();
205
+ * console.log('Configured schedules:', schedules.map(s => s.name));
206
+ *
207
+ * // View recent failures for a queue
208
+ * const history = await queries.getJobHistory(['my-workflow-queue']);
209
+ * const failures = history.filter(h => h.state === 'failed');
210
+ * console.log('Recent failures:', failures);
211
+ *
212
+ * // Clean up jobs for a cancelled workflow
213
+ * const result = await queries.deleteWorkflowJobs('workflow-123');
214
+ * console.log(`Cancelled ${result.cancelledCount}, deleted ${result.deletedCount} jobs`);
215
+ * ```
216
+ */
217
+ export function createPgBossQueries(
218
+ pool: Pool | PoolClient,
219
+ config?: PgBossQueriesConfig,
220
+ ): PgBossQueries {
221
+ const schema = config?.schema ?? 'pgboss';
222
+
223
+ return {
224
+ async getJobs(): Promise<PgBossJobItem[]> {
225
+ const result = await pool.query<PgBossJobItem>(
226
+ `SELECT id, name, state FROM ${schema}.job ORDER BY createdon DESC`,
227
+ );
228
+ return result.rows;
229
+ },
230
+
231
+ async getJobStats(): Promise<PgBossJobStats[]> {
232
+ const result = await pool.query<PgBossJobStats>(
233
+ `SELECT name, state, COUNT(*)::int as count
234
+ FROM ${schema}.job
235
+ GROUP BY name, state
236
+ ORDER BY name, state`,
237
+ );
238
+ return result.rows;
239
+ },
240
+
241
+ async getSchedules(): Promise<PgBossScheduleItem[]> {
242
+ const result = await pool.query<PgBossScheduleItem>(
243
+ `SELECT name, cron, timezone, data, created_on, updated_on
244
+ FROM ${schema}.schedule
245
+ ORDER BY created_on DESC`,
246
+ );
247
+ return result.rows;
248
+ },
249
+
250
+ async getJobHistory(names: string[]): Promise<PgBossArchiveItem[]> {
251
+ if (names.length === 0) {
252
+ return [];
253
+ }
254
+
255
+ const result = await pool.query<PgBossArchiveItem>(
256
+ `SELECT id, name, state, output, completedon as completed_on
257
+ FROM ${schema}.archive
258
+ WHERE name = ANY($1::text[])
259
+ ORDER BY completedon DESC
260
+ LIMIT 50`,
261
+ [names],
262
+ );
263
+ return result.rows;
264
+ },
265
+
266
+ async deleteWorkflowJobs(workflowId: string): Promise<DeleteWorkflowJobsResult> {
267
+ // Cancel active/pending jobs for this workflow
268
+ const cancelResult = await pool.query(
269
+ `UPDATE ${schema}.job
270
+ SET state = 'cancelled'
271
+ WHERE data::jsonb ? 'workflowId'
272
+ AND (data::jsonb->>'workflowId') = $1
273
+ AND state NOT IN ('completed', 'failed', 'cancelled')
274
+ RETURNING id`,
275
+ [workflowId],
276
+ );
277
+
278
+ // Delete all jobs for this workflow (including completed/failed ones)
279
+ const deleteResult = await pool.query(
280
+ `DELETE FROM ${schema}.job
281
+ WHERE data::jsonb ? 'workflowId'
282
+ AND (data::jsonb->>'workflowId') = $1
283
+ RETURNING id`,
284
+ [workflowId],
285
+ );
286
+
287
+ return {
288
+ cancelledCount: cancelResult.rowCount ?? 0,
289
+ deletedCount: deleteResult.rowCount ?? 0,
290
+ };
291
+ },
292
+
293
+ async deletePendingJobs(queueName?: string): Promise<PendingJobsResult> {
294
+ const query = queueName
295
+ ? `DELETE FROM ${schema}.job WHERE state = 'created' AND name = $1 RETURNING id`
296
+ : `DELETE FROM ${schema}.job WHERE state = 'created' RETURNING id`;
297
+
298
+ const result = await pool.query(query, queueName ? [queueName] : []);
299
+ return { count: result.rowCount ?? 0 };
300
+ },
301
+
302
+ async cancelPendingJobs(queueName?: string): Promise<PendingJobsResult> {
303
+ const query = queueName
304
+ ? `UPDATE ${schema}.job SET state = 'cancelled' WHERE state = 'created' AND name = $1 RETURNING id`
305
+ : `UPDATE ${schema}.job SET state = 'cancelled' WHERE state = 'created' RETURNING id`;
306
+
307
+ const result = await pool.query(query, queueName ? [queueName] : []);
308
+ return { count: result.rowCount ?? 0 };
309
+ },
310
+
311
+ async purgeJobsByState(state: PgBossJobState, queueName?: string): Promise<PendingJobsResult> {
312
+ const query = queueName
313
+ ? `DELETE FROM ${schema}.job WHERE state = $1 AND name = $2 RETURNING id`
314
+ : `DELETE FROM ${schema}.job WHERE state = $1 RETURNING id`;
315
+
316
+ const result = await pool.query(query, queueName ? [state, queueName] : [state]);
317
+ return { count: result.rowCount ?? 0 };
318
+ },
319
+ };
320
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Drizzle ORM schema for workflow orchestration.
3
+ *
4
+ * This module exports the database schema required for workflow persistence.
5
+ * Import this schema into your application's Drizzle configuration.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * // In your drizzle schema file
10
+ * import { workflows, workflowOperations, workflowLocks } from '@sprqvntrs/workflows/schema';
11
+ *
12
+ * export { workflows, workflowOperations, workflowLocks };
13
+ * ```
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * // Running migrations
18
+ * // Add these tables to your Drizzle migrations
19
+ * ```
20
+ */
21
+
22
+ import { pgTable, uuid, text, timestamp, jsonb, integer, unique, index } from 'drizzle-orm/pg-core';
23
+
24
+ // =============================================================================
25
+ // Workflows Table
26
+ // =============================================================================
27
+
28
+ /**
29
+ * Main workflows table storing workflow instances.
30
+ *
31
+ * Each row represents a single workflow execution with its current state
32
+ * and accumulated context from completed operations.
33
+ *
34
+ * @remarks
35
+ * The `context` column stores a JSONB object that grows as operations complete.
36
+ * Consider monitoring context size for workflows with many operations.
37
+ */
38
+ export const workflows = pgTable(
39
+ 'workflows',
40
+ {
41
+ /**
42
+ * Unique identifier for the workflow instance.
43
+ */
44
+ id: uuid('id').primaryKey().defaultRandom(),
45
+
46
+ /**
47
+ * Workflow type matching a registered template.
48
+ * @example 'content-generation', 'data-processing'
49
+ */
50
+ type: text('type').notNull(),
51
+
52
+ /**
53
+ * Current workflow status.
54
+ * @see WorkflowStatus type for possible values
55
+ */
56
+ status: text('status').notNull().default('pending'),
57
+
58
+ /**
59
+ * Accumulated workflow context (initial + operation results).
60
+ * This JSONB column stores all data flowing through the workflow.
61
+ */
62
+ context: jsonb('context').notNull().default({}),
63
+
64
+ /**
65
+ * Currently executing stage name.
66
+ * Null when workflow hasn't started or is completed.
67
+ */
68
+ currentStage: text('current_stage'),
69
+
70
+ /**
71
+ * Checkpoint status when workflow is paused.
72
+ * Used by UI to display appropriate actions.
73
+ * @example 'data_ready', 'content_review', 'approval_pending'
74
+ */
75
+ checkpointStatus: text('checkpoint_status'),
76
+
77
+ /**
78
+ * Error message if workflow failed.
79
+ * Contains the error that caused terminal failure.
80
+ */
81
+ errorMessage: text('error_message'),
82
+
83
+ /**
84
+ * Template version used for this workflow.
85
+ * Useful for debugging and migration.
86
+ */
87
+ templateVersion: text('template_version').notNull(),
88
+
89
+ /**
90
+ * When the workflow record was created.
91
+ */
92
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
93
+
94
+ /**
95
+ * When workflow execution actually started.
96
+ * Set when worker picks up the job.
97
+ */
98
+ startedAt: timestamp('started_at', { withTimezone: true }),
99
+
100
+ /**
101
+ * When workflow completed (success, failure, or cancellation).
102
+ */
103
+ completedAt: timestamp('completed_at', { withTimezone: true }),
104
+
105
+ /**
106
+ * pg-boss job ID for this workflow.
107
+ * Used to correlate with job queue.
108
+ */
109
+ jobId: text('job_id'),
110
+ },
111
+ (table) => [
112
+ /**
113
+ * Index for querying workflows by status.
114
+ * Common query: find all active/pending workflows.
115
+ */
116
+ index('workflows_status_idx').on(table.status),
117
+
118
+ /**
119
+ * Index for querying workflows by type.
120
+ * Common query: find all workflows of a specific type.
121
+ */
122
+ index('workflows_type_idx').on(table.type),
123
+
124
+ /**
125
+ * Index for querying workflows by creation time.
126
+ * Common query: find recent workflows.
127
+ */
128
+ index('workflows_created_at_idx').on(table.createdAt),
129
+
130
+ /**
131
+ * Composite index for filtering by type and status.
132
+ * Common query: find active workflows of a specific type.
133
+ */
134
+ index('workflows_type_status_idx').on(table.type, table.status),
135
+ ],
136
+ );
137
+
138
+ // =============================================================================
139
+ // Workflow Operations Table
140
+ // =============================================================================
141
+
142
+ /**
143
+ * Operations table storing individual operation executions.
144
+ *
145
+ * Each row represents a single operation within a workflow stage.
146
+ * Operations track their own status, results, and retry attempts.
147
+ */
148
+ export const workflowOperations = pgTable(
149
+ 'workflow_operations',
150
+ {
151
+ /**
152
+ * Unique identifier for the operation instance.
153
+ */
154
+ id: uuid('id').primaryKey().defaultRandom(),
155
+
156
+ /**
157
+ * Parent workflow ID.
158
+ * References the workflows table.
159
+ */
160
+ workflowId: uuid('workflow_id')
161
+ .notNull()
162
+ .references(() => workflows.id, { onDelete: 'cascade' }),
163
+
164
+ /**
165
+ * Operation type matching a registered handler.
166
+ * @example 'gather.data', 'analyze.content'
167
+ */
168
+ type: text('type').notNull(),
169
+
170
+ /**
171
+ * Stage name this operation belongs to.
172
+ */
173
+ stage: text('stage').notNull(),
174
+
175
+ /**
176
+ * Current operation status.
177
+ * @see OperationStatus type for possible values
178
+ */
179
+ status: text('status').notNull().default('pending'),
180
+
181
+ /**
182
+ * Operation result data (on success).
183
+ * This data gets merged into the workflow context.
184
+ */
185
+ result: jsonb('result'),
186
+
187
+ /**
188
+ * Error message if operation failed.
189
+ */
190
+ errorMessage: text('error_message'),
191
+
192
+ /**
193
+ * Current attempt count (1-indexed).
194
+ */
195
+ attempts: integer('attempts').notNull().default(0),
196
+
197
+ /**
198
+ * Maximum allowed attempts.
199
+ */
200
+ maxAttempts: integer('max_attempts').notNull().default(3),
201
+
202
+ /**
203
+ * When the operation record was created.
204
+ */
205
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
206
+
207
+ /**
208
+ * When operation execution started.
209
+ */
210
+ startedAt: timestamp('started_at', { withTimezone: true }),
211
+
212
+ /**
213
+ * When operation completed (success or failure).
214
+ */
215
+ completedAt: timestamp('completed_at', { withTimezone: true }),
216
+ },
217
+ (table) => [
218
+ /**
219
+ * Index for querying operations by workflow.
220
+ * Common query: get all operations for a workflow.
221
+ */
222
+ index('workflow_operations_workflow_id_idx').on(table.workflowId),
223
+
224
+ /**
225
+ * Index for querying operations by status.
226
+ * Common query: find pending operations.
227
+ */
228
+ index('workflow_operations_status_idx').on(table.status),
229
+
230
+ /**
231
+ * Composite index for finding operations by workflow and stage.
232
+ * Common query: get operations for a specific stage.
233
+ */
234
+ index('workflow_operations_workflow_stage_idx').on(table.workflowId, table.stage),
235
+ ],
236
+ );
237
+
238
+ // =============================================================================
239
+ // Workflow Locks Table
240
+ // =============================================================================
241
+
242
+ /**
243
+ * Locks table for coordination and race condition prevention.
244
+ *
245
+ * Uses database unique constraints to ensure only one workflow
246
+ * can hold a lock on a given entity at a time.
247
+ *
248
+ * @remarks
249
+ * Lock acquisition is done via INSERT. If insert fails due to unique
250
+ * constraint violation, another workflow holds the lock.
251
+ */
252
+ export const workflowLocks = pgTable(
253
+ 'workflow_locks',
254
+ {
255
+ /**
256
+ * Unique identifier for the lock.
257
+ */
258
+ id: uuid('id').primaryKey().defaultRandom(),
259
+
260
+ /**
261
+ * Type of entity being locked.
262
+ * @example 'document', 'user', 'order'
263
+ */
264
+ entityType: text('entity_type').notNull(),
265
+
266
+ /**
267
+ * ID of the entity being locked.
268
+ */
269
+ entityId: text('entity_id').notNull(),
270
+
271
+ /**
272
+ * Workflow holding this lock.
273
+ */
274
+ workflowId: uuid('workflow_id')
275
+ .notNull()
276
+ .references(() => workflows.id, { onDelete: 'cascade' }),
277
+
278
+ /**
279
+ * When the lock was acquired.
280
+ */
281
+ acquiredAt: timestamp('acquired_at', { withTimezone: true }).notNull().defaultNow(),
282
+
283
+ /**
284
+ * Optional lock expiration time.
285
+ * Allows stale locks to be cleaned up.
286
+ */
287
+ expiresAt: timestamp('expires_at', { withTimezone: true }),
288
+ },
289
+ (table) => [
290
+ /**
291
+ * Unique constraint ensuring only one lock per entity.
292
+ * This is the core mechanism for mutual exclusion.
293
+ */
294
+ unique('workflow_locks_entity_unique').on(table.entityType, table.entityId),
295
+
296
+ /**
297
+ * Index for finding locks by workflow.
298
+ * Used during workflow cleanup.
299
+ */
300
+ index('workflow_locks_workflow_id_idx').on(table.workflowId),
301
+
302
+ /**
303
+ * Index for finding expired locks.
304
+ * Used by lock cleanup job.
305
+ */
306
+ index('workflow_locks_expires_at_idx').on(table.expiresAt),
307
+ ],
308
+ );
309
+
310
+ // =============================================================================
311
+ // Type Exports for Drizzle
312
+ // =============================================================================
313
+
314
+ /**
315
+ * TypeScript type for workflow table row.
316
+ */
317
+ export type Workflow = typeof workflows.$inferSelect;
318
+
319
+ /**
320
+ * TypeScript type for workflow insert.
321
+ */
322
+ export type NewWorkflow = typeof workflows.$inferInsert;
323
+
324
+ /**
325
+ * TypeScript type for operation table row.
326
+ */
327
+ export type WorkflowOperation = typeof workflowOperations.$inferSelect;
328
+
329
+ /**
330
+ * TypeScript type for operation insert.
331
+ */
332
+ export type NewWorkflowOperation = typeof workflowOperations.$inferInsert;
333
+
334
+ /**
335
+ * TypeScript type for lock table row.
336
+ */
337
+ export type WorkflowLock = typeof workflowLocks.$inferSelect;
338
+
339
+ /**
340
+ * TypeScript type for lock insert.
341
+ */
342
+ export type NewWorkflowLock = typeof workflowLocks.$inferInsert;