@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,703 @@
1
+ /**
2
+ * Database state management for workflows.
3
+ *
4
+ * This module provides functions for persisting and querying workflow state
5
+ * in PostgreSQL using Drizzle ORM. It handles all CRUD operations for
6
+ * workflows, operations, and locks.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { createDbState } from '@sprqvntrs/workflows';
11
+ *
12
+ * const dbState = createDbState(drizzleDb);
13
+ *
14
+ * const workflow = await dbState.createWorkflow({
15
+ * type: 'my-workflow',
16
+ * context: { userId: '123' },
17
+ * templateVersion: '1.0.0',
18
+ * });
19
+ * ```
20
+ */
21
+
22
+ import { eq, and, isNotNull, lt, inArray, sql, desc } from 'drizzle-orm';
23
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
24
+ import {
25
+ workflows,
26
+ workflowOperations,
27
+ workflowLocks,
28
+ type Workflow,
29
+ type NewWorkflow,
30
+ type WorkflowOperation,
31
+ type NewWorkflowOperation,
32
+ type WorkflowLock,
33
+ } from './schema';
34
+ import type { WorkflowStatus, OperationStatus, WorkflowContext } from '../types';
35
+
36
+ // =============================================================================
37
+ // Types
38
+ // =============================================================================
39
+
40
+ /**
41
+ * Database instance type.
42
+ * Supports any Drizzle PostgreSQL database instance using node-postgres (pg).
43
+ */
44
+ export type Database = NodePgDatabase<Record<string, unknown>>;
45
+
46
+ /**
47
+ * Database state manager interface.
48
+ *
49
+ * Provides all database operations needed by the workflow orchestrator.
50
+ */
51
+ export interface DbState {
52
+ // Workflow operations
53
+ createWorkflow: (data: CreateWorkflowData) => Promise<Workflow>;
54
+ getWorkflow: (id: string) => Promise<Workflow | null>;
55
+ getWorkflowByJobId: (jobId: string) => Promise<Workflow | null>;
56
+ updateWorkflowStatus: (id: string, status: WorkflowStatus, error?: string) => Promise<Workflow | null>;
57
+ updateWorkflowStage: (id: string, stage: string) => Promise<Workflow | null>;
58
+ updateWorkflowContext: (id: string, context: WorkflowContext) => Promise<Workflow | null>;
59
+ updateWorkflowCheckpoint: (id: string, checkpointStatus: string | null) => Promise<Workflow | null>;
60
+ setWorkflowJobId: (id: string, jobId: string) => Promise<Workflow | null>;
61
+ listWorkflows: (options?: ListWorkflowsOptions) => Promise<Workflow[]>;
62
+
63
+ // Operation operations
64
+ createOperation: (data: CreateOperationData) => Promise<WorkflowOperation>;
65
+ createOperations: (data: CreateOperationData[]) => Promise<WorkflowOperation[]>;
66
+ getOperation: (id: string) => Promise<WorkflowOperation | null>;
67
+ getOperationsByWorkflow: (workflowId: string) => Promise<WorkflowOperation[]>;
68
+ getOperationsByStage: (workflowId: string, stage: string) => Promise<WorkflowOperation[]>;
69
+ updateOperationStatus: (id: string, status: OperationStatus, error?: string) => Promise<WorkflowOperation | null>;
70
+ updateOperationResult: (id: string, result: Record<string, unknown>) => Promise<WorkflowOperation | null>;
71
+ incrementOperationAttempts: (id: string) => Promise<WorkflowOperation | null>;
72
+ deleteOperationsByWorkflow: (workflowId: string) => Promise<number>;
73
+
74
+ // Lock operations
75
+ acquireLock: (entityType: string, entityId: string, workflowId: string, expiresAt?: Date) => Promise<boolean>;
76
+ releaseLock: (entityType: string, entityId: string) => Promise<boolean>;
77
+ releaseLocksByWorkflow: (workflowId: string) => Promise<number>;
78
+ getLock: (entityType: string, entityId: string) => Promise<WorkflowLock | null>;
79
+ cleanupExpiredLocks: () => Promise<number>;
80
+ }
81
+
82
+ /**
83
+ * Data for creating a new workflow.
84
+ */
85
+ export interface CreateWorkflowData {
86
+ /**
87
+ * Workflow type matching a registered template.
88
+ */
89
+ type: string;
90
+
91
+ /**
92
+ * Initial workflow context.
93
+ */
94
+ context: WorkflowContext;
95
+
96
+ /**
97
+ * Template version being used.
98
+ */
99
+ templateVersion: string;
100
+
101
+ /**
102
+ * Optional job ID if already known.
103
+ */
104
+ jobId?: string;
105
+ }
106
+
107
+ /**
108
+ * Data for creating a new operation.
109
+ */
110
+ export interface CreateOperationData {
111
+ /**
112
+ * Parent workflow ID.
113
+ */
114
+ workflowId: string;
115
+
116
+ /**
117
+ * Operation type.
118
+ */
119
+ type: string;
120
+
121
+ /**
122
+ * Stage name.
123
+ */
124
+ stage: string;
125
+
126
+ /**
127
+ * Maximum retry attempts.
128
+ */
129
+ maxAttempts: number;
130
+ }
131
+
132
+ /**
133
+ * Options for listing workflows.
134
+ */
135
+ export interface ListWorkflowsOptions {
136
+ /**
137
+ * Filter by workflow type.
138
+ */
139
+ type?: string;
140
+
141
+ /**
142
+ * Filter by status.
143
+ */
144
+ status?: WorkflowStatus | WorkflowStatus[];
145
+
146
+ /**
147
+ * Maximum number of results.
148
+ * @default 100
149
+ */
150
+ limit?: number;
151
+
152
+ /**
153
+ * Offset for pagination.
154
+ * @default 0
155
+ */
156
+ offset?: number;
157
+
158
+ /**
159
+ * Order by creation time.
160
+ * @default 'desc'
161
+ */
162
+ order?: 'asc' | 'desc';
163
+ }
164
+
165
+ // =============================================================================
166
+ // Factory Function
167
+ // =============================================================================
168
+
169
+ /**
170
+ * Creates a database state manager.
171
+ *
172
+ * The database state manager provides all CRUD operations for workflows,
173
+ * operations, and locks. It uses Drizzle ORM for type-safe queries.
174
+ *
175
+ * @param db - Drizzle database instance
176
+ * @returns Database state manager
177
+ *
178
+ * @example
179
+ * ```typescript
180
+ * import { drizzle } from 'drizzle-orm/node-postgres';
181
+ * import { Pool } from 'pg';
182
+ * import { createDbState } from '@sprqvntrs/workflows';
183
+ *
184
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
185
+ * const db = drizzle(pool);
186
+ * const dbState = createDbState(db);
187
+ *
188
+ * // Create a workflow
189
+ * const workflow = await dbState.createWorkflow({
190
+ * type: 'my-workflow',
191
+ * context: { documentId: '123' },
192
+ * templateVersion: '1.0.0',
193
+ * });
194
+ *
195
+ * // Update status
196
+ * await dbState.updateWorkflowStatus(workflow.id, 'active');
197
+ *
198
+ * // Create operations
199
+ * await dbState.createOperations([
200
+ * { workflowId: workflow.id, type: 'gather.data', stage: 'gather', maxAttempts: 3 },
201
+ * { workflowId: workflow.id, type: 'process.data', stage: 'process', maxAttempts: 3 },
202
+ * ]);
203
+ * ```
204
+ */
205
+ export function createDbState(db: Database): DbState {
206
+ return {
207
+ // =========================================================================
208
+ // Workflow Operations
209
+ // =========================================================================
210
+
211
+ /**
212
+ * Creates a new workflow record.
213
+ *
214
+ * @param data - Workflow creation data
215
+ * @returns Created workflow record
216
+ */
217
+ async createWorkflow(data: CreateWorkflowData): Promise<Workflow> {
218
+ const [workflow] = await db
219
+ .insert(workflows)
220
+ .values({
221
+ type: data.type,
222
+ context: data.context,
223
+ templateVersion: data.templateVersion,
224
+ jobId: data.jobId,
225
+ status: 'pending',
226
+ })
227
+ .returning();
228
+
229
+ if (!workflow) {
230
+ throw new Error('Failed to create workflow');
231
+ }
232
+
233
+ return workflow;
234
+ },
235
+
236
+ /**
237
+ * Gets a workflow by ID.
238
+ *
239
+ * @param id - Workflow ID
240
+ * @returns Workflow record or null
241
+ */
242
+ async getWorkflow(id: string): Promise<Workflow | null> {
243
+ const [workflow] = await db.select().from(workflows).where(eq(workflows.id, id)).limit(1);
244
+
245
+ return workflow ?? null;
246
+ },
247
+
248
+ /**
249
+ * Gets a workflow by pg-boss job ID.
250
+ *
251
+ * @param jobId - pg-boss job ID
252
+ * @returns Workflow record or null
253
+ */
254
+ async getWorkflowByJobId(jobId: string): Promise<Workflow | null> {
255
+ const [workflow] = await db.select().from(workflows).where(eq(workflows.jobId, jobId)).limit(1);
256
+
257
+ return workflow ?? null;
258
+ },
259
+
260
+ /**
261
+ * Updates workflow status.
262
+ *
263
+ * Also sets startedAt/completedAt timestamps as appropriate.
264
+ *
265
+ * @param id - Workflow ID
266
+ * @param status - New status
267
+ * @param error - Optional error message (for failed status)
268
+ * @returns Updated workflow or null
269
+ */
270
+ async updateWorkflowStatus(
271
+ id: string,
272
+ status: WorkflowStatus,
273
+ error?: string,
274
+ ): Promise<Workflow | null> {
275
+ const updates: Partial<NewWorkflow> = { status };
276
+
277
+ if (status === 'active') {
278
+ updates.startedAt = new Date();
279
+ }
280
+
281
+ if (status === 'completed' || status === 'failed' || status === 'cancelled') {
282
+ updates.completedAt = new Date();
283
+ }
284
+
285
+ if (error) {
286
+ updates.errorMessage = error;
287
+ }
288
+
289
+ const [workflow] = await db.update(workflows).set(updates).where(eq(workflows.id, id)).returning();
290
+
291
+ return workflow ?? null;
292
+ },
293
+
294
+ /**
295
+ * Updates the current stage of a workflow.
296
+ *
297
+ * @param id - Workflow ID
298
+ * @param stage - Stage name
299
+ * @returns Updated workflow or null
300
+ */
301
+ async updateWorkflowStage(id: string, stage: string): Promise<Workflow | null> {
302
+ const [workflow] = await db
303
+ .update(workflows)
304
+ .set({ currentStage: stage })
305
+ .where(eq(workflows.id, id))
306
+ .returning();
307
+
308
+ return workflow ?? null;
309
+ },
310
+
311
+ /**
312
+ * Updates workflow context with merged data.
313
+ *
314
+ * Uses JSONB concatenation to merge new data with existing context.
315
+ *
316
+ * @param id - Workflow ID
317
+ * @param context - Context data to merge
318
+ * @returns Updated workflow or null
319
+ */
320
+ async updateWorkflowContext(id: string, context: WorkflowContext): Promise<Workflow | null> {
321
+ const [workflow] = await db
322
+ .update(workflows)
323
+ .set({
324
+ context: sql`${workflows.context} || ${JSON.stringify(context)}::jsonb`,
325
+ })
326
+ .where(eq(workflows.id, id))
327
+ .returning();
328
+
329
+ return workflow ?? null;
330
+ },
331
+
332
+ /**
333
+ * Updates workflow checkpoint status.
334
+ *
335
+ * @param id - Workflow ID
336
+ * @param checkpointStatus - Checkpoint status or null to clear
337
+ * @returns Updated workflow or null
338
+ */
339
+ async updateWorkflowCheckpoint(
340
+ id: string,
341
+ checkpointStatus: string | null,
342
+ ): Promise<Workflow | null> {
343
+ const updates: Partial<NewWorkflow> = { checkpointStatus };
344
+
345
+ if (checkpointStatus) {
346
+ updates.status = 'paused';
347
+ }
348
+
349
+ const [workflow] = await db.update(workflows).set(updates).where(eq(workflows.id, id)).returning();
350
+
351
+ return workflow ?? null;
352
+ },
353
+
354
+ /**
355
+ * Sets the pg-boss job ID for a workflow.
356
+ *
357
+ * @param id - Workflow ID
358
+ * @param jobId - pg-boss job ID
359
+ * @returns Updated workflow or null
360
+ */
361
+ async setWorkflowJobId(id: string, jobId: string): Promise<Workflow | null> {
362
+ const [workflow] = await db
363
+ .update(workflows)
364
+ .set({ jobId })
365
+ .where(eq(workflows.id, id))
366
+ .returning();
367
+
368
+ return workflow ?? null;
369
+ },
370
+
371
+ /**
372
+ * Lists workflows with optional filtering.
373
+ *
374
+ * @param options - List options
375
+ * @returns Array of workflows
376
+ */
377
+ async listWorkflows(options?: ListWorkflowsOptions): Promise<Workflow[]> {
378
+ const { type, status, limit = 100, offset = 0, order = 'desc' } = options ?? {};
379
+
380
+ let query = db.select().from(workflows);
381
+
382
+ const conditions = [];
383
+
384
+ if (type) {
385
+ conditions.push(eq(workflows.type, type));
386
+ }
387
+
388
+ if (status) {
389
+ if (Array.isArray(status)) {
390
+ conditions.push(inArray(workflows.status, status));
391
+ } else {
392
+ conditions.push(eq(workflows.status, status));
393
+ }
394
+ }
395
+
396
+ if (conditions.length > 0) {
397
+ query = query.where(and(...conditions)) as typeof query;
398
+ }
399
+
400
+ query = query.orderBy(order === 'desc' ? desc(workflows.createdAt) : workflows.createdAt) as typeof query;
401
+ query = query.limit(limit).offset(offset) as typeof query;
402
+
403
+ return query;
404
+ },
405
+
406
+ // =========================================================================
407
+ // Operation Operations
408
+ // =========================================================================
409
+
410
+ /**
411
+ * Creates a new operation record.
412
+ *
413
+ * @param data - Operation creation data
414
+ * @returns Created operation record
415
+ */
416
+ async createOperation(data: CreateOperationData): Promise<WorkflowOperation> {
417
+ const [operation] = await db
418
+ .insert(workflowOperations)
419
+ .values({
420
+ workflowId: data.workflowId,
421
+ type: data.type,
422
+ stage: data.stage,
423
+ maxAttempts: data.maxAttempts,
424
+ status: 'pending',
425
+ })
426
+ .returning();
427
+
428
+ if (!operation) {
429
+ throw new Error('Failed to create operation');
430
+ }
431
+
432
+ return operation;
433
+ },
434
+
435
+ /**
436
+ * Creates multiple operation records in a batch.
437
+ *
438
+ * @param data - Array of operation creation data
439
+ * @returns Created operation records
440
+ */
441
+ async createOperations(data: CreateOperationData[]): Promise<WorkflowOperation[]> {
442
+ if (data.length === 0) {
443
+ return [];
444
+ }
445
+
446
+ const operations = await db
447
+ .insert(workflowOperations)
448
+ .values(
449
+ data.map((d) => ({
450
+ workflowId: d.workflowId,
451
+ type: d.type,
452
+ stage: d.stage,
453
+ maxAttempts: d.maxAttempts,
454
+ status: 'pending' as const,
455
+ })),
456
+ )
457
+ .returning();
458
+
459
+ return operations;
460
+ },
461
+
462
+ /**
463
+ * Gets an operation by ID.
464
+ *
465
+ * @param id - Operation ID
466
+ * @returns Operation record or null
467
+ */
468
+ async getOperation(id: string): Promise<WorkflowOperation | null> {
469
+ const [operation] = await db
470
+ .select()
471
+ .from(workflowOperations)
472
+ .where(eq(workflowOperations.id, id))
473
+ .limit(1);
474
+
475
+ return operation ?? null;
476
+ },
477
+
478
+ /**
479
+ * Gets all operations for a workflow.
480
+ *
481
+ * @param workflowId - Workflow ID
482
+ * @returns Array of operations
483
+ */
484
+ async getOperationsByWorkflow(workflowId: string): Promise<WorkflowOperation[]> {
485
+ return db
486
+ .select()
487
+ .from(workflowOperations)
488
+ .where(eq(workflowOperations.workflowId, workflowId))
489
+ .orderBy(workflowOperations.createdAt);
490
+ },
491
+
492
+ /**
493
+ * Gets operations for a specific stage.
494
+ *
495
+ * @param workflowId - Workflow ID
496
+ * @param stage - Stage name
497
+ * @returns Array of operations
498
+ */
499
+ async getOperationsByStage(workflowId: string, stage: string): Promise<WorkflowOperation[]> {
500
+ return db
501
+ .select()
502
+ .from(workflowOperations)
503
+ .where(and(eq(workflowOperations.workflowId, workflowId), eq(workflowOperations.stage, stage)))
504
+ .orderBy(workflowOperations.createdAt);
505
+ },
506
+
507
+ /**
508
+ * Updates operation status.
509
+ *
510
+ * Also sets startedAt/completedAt timestamps as appropriate.
511
+ *
512
+ * @param id - Operation ID
513
+ * @param status - New status
514
+ * @param error - Optional error message
515
+ * @returns Updated operation or null
516
+ */
517
+ async updateOperationStatus(
518
+ id: string,
519
+ status: OperationStatus,
520
+ error?: string,
521
+ ): Promise<WorkflowOperation | null> {
522
+ const updates: Partial<NewWorkflowOperation> = { status };
523
+
524
+ if (status === 'active') {
525
+ updates.startedAt = new Date();
526
+ }
527
+
528
+ if (status === 'completed' || status === 'failed' || status === 'skipped') {
529
+ updates.completedAt = new Date();
530
+ }
531
+
532
+ if (error) {
533
+ updates.errorMessage = error;
534
+ }
535
+
536
+ const [operation] = await db
537
+ .update(workflowOperations)
538
+ .set(updates)
539
+ .where(eq(workflowOperations.id, id))
540
+ .returning();
541
+
542
+ return operation ?? null;
543
+ },
544
+
545
+ /**
546
+ * Updates operation result data.
547
+ *
548
+ * @param id - Operation ID
549
+ * @param result - Result data
550
+ * @returns Updated operation or null
551
+ */
552
+ async updateOperationResult(
553
+ id: string,
554
+ result: Record<string, unknown>,
555
+ ): Promise<WorkflowOperation | null> {
556
+ const [operation] = await db
557
+ .update(workflowOperations)
558
+ .set({ result, status: 'completed', completedAt: new Date() })
559
+ .where(eq(workflowOperations.id, id))
560
+ .returning();
561
+
562
+ return operation ?? null;
563
+ },
564
+
565
+ /**
566
+ * Increments the attempt counter for an operation.
567
+ *
568
+ * @param id - Operation ID
569
+ * @returns Updated operation or null
570
+ */
571
+ async incrementOperationAttempts(id: string): Promise<WorkflowOperation | null> {
572
+ const [operation] = await db
573
+ .update(workflowOperations)
574
+ .set({
575
+ attempts: sql`${workflowOperations.attempts} + 1`,
576
+ })
577
+ .where(eq(workflowOperations.id, id))
578
+ .returning();
579
+
580
+ return operation ?? null;
581
+ },
582
+
583
+ /**
584
+ * Deletes all operations for a workflow.
585
+ *
586
+ * Used during workflow retry to clean up previous attempt.
587
+ *
588
+ * @param workflowId - Workflow ID
589
+ * @returns Number of deleted operations
590
+ */
591
+ async deleteOperationsByWorkflow(workflowId: string): Promise<number> {
592
+ const result = await db
593
+ .delete(workflowOperations)
594
+ .where(eq(workflowOperations.workflowId, workflowId))
595
+ .returning();
596
+
597
+ return result.length;
598
+ },
599
+
600
+ // =========================================================================
601
+ // Lock Operations
602
+ // =========================================================================
603
+
604
+ /**
605
+ * Attempts to acquire a lock on an entity.
606
+ *
607
+ * Uses database unique constraint to ensure mutual exclusion.
608
+ *
609
+ * @param entityType - Type of entity
610
+ * @param entityId - Entity ID
611
+ * @param workflowId - Workflow requesting the lock
612
+ * @param expiresAt - Optional expiration time
613
+ * @returns True if lock was acquired
614
+ */
615
+ async acquireLock(
616
+ entityType: string,
617
+ entityId: string,
618
+ workflowId: string,
619
+ expiresAt?: Date,
620
+ ): Promise<boolean> {
621
+ try {
622
+ await db.insert(workflowLocks).values({
623
+ entityType,
624
+ entityId,
625
+ workflowId,
626
+ expiresAt,
627
+ });
628
+ return true;
629
+ } catch (error) {
630
+ // Unique constraint violation means lock is held by another workflow
631
+ if (error instanceof Error && error.message.includes('unique')) {
632
+ return false;
633
+ }
634
+ throw error;
635
+ }
636
+ },
637
+
638
+ /**
639
+ * Releases a lock on an entity.
640
+ *
641
+ * @param entityType - Type of entity
642
+ * @param entityId - Entity ID
643
+ * @returns True if lock was released
644
+ */
645
+ async releaseLock(entityType: string, entityId: string): Promise<boolean> {
646
+ const result = await db
647
+ .delete(workflowLocks)
648
+ .where(and(eq(workflowLocks.entityType, entityType), eq(workflowLocks.entityId, entityId)))
649
+ .returning();
650
+
651
+ return result.length > 0;
652
+ },
653
+
654
+ /**
655
+ * Releases all locks held by a workflow.
656
+ *
657
+ * @param workflowId - Workflow ID
658
+ * @returns Number of released locks
659
+ */
660
+ async releaseLocksByWorkflow(workflowId: string): Promise<number> {
661
+ const result = await db
662
+ .delete(workflowLocks)
663
+ .where(eq(workflowLocks.workflowId, workflowId))
664
+ .returning();
665
+
666
+ return result.length;
667
+ },
668
+
669
+ /**
670
+ * Gets lock information for an entity.
671
+ *
672
+ * @param entityType - Type of entity
673
+ * @param entityId - Entity ID
674
+ * @returns Lock record or null
675
+ */
676
+ async getLock(entityType: string, entityId: string): Promise<WorkflowLock | null> {
677
+ const [lock] = await db
678
+ .select()
679
+ .from(workflowLocks)
680
+ .where(and(eq(workflowLocks.entityType, entityType), eq(workflowLocks.entityId, entityId)))
681
+ .limit(1);
682
+
683
+ return lock ?? null;
684
+ },
685
+
686
+ /**
687
+ * Cleans up expired locks.
688
+ *
689
+ * Should be called periodically to release stale locks.
690
+ *
691
+ * @returns Number of cleaned up locks
692
+ */
693
+ async cleanupExpiredLocks(): Promise<number> {
694
+ const now = new Date();
695
+ const result = await db
696
+ .delete(workflowLocks)
697
+ .where(and(isNotNull(workflowLocks.expiresAt), lt(workflowLocks.expiresAt, now)))
698
+ .returning();
699
+
700
+ return result.length;
701
+ },
702
+ };
703
+ }