@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,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;
|