@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 +21 -0
- package/README.md +649 -0
- package/index.ts +234 -0
- package/package.json +53 -0
- package/src/coordination/lock-manager.ts +431 -0
- package/src/engine/execution-engine.ts +715 -0
- package/src/infrastructure/db-state.ts +703 -0
- package/src/infrastructure/pg-boss.ts +585 -0
- package/src/infrastructure/pgboss-queries.ts +320 -0
- package/src/infrastructure/schema.ts +342 -0
- package/src/operations/registry.ts +409 -0
- package/src/orchestrator.ts +1056 -0
- package/src/templates/registry.ts +591 -0
- package/src/testing/index.ts +476 -0
- package/src/types.ts +946 -0
- package/src/worker/index.ts +325 -0
|
@@ -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
|
+
}
|