@workflow/world-postgres 4.1.0-beta.4 → 4.1.0-beta.41
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.md +201 -21
- package/README.md +17 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +12 -12
- package/dist/cli.js.map +1 -1
- package/dist/drizzle/cbor.d.ts +38 -0
- package/dist/drizzle/cbor.d.ts.map +1 -0
- package/dist/drizzle/cbor.js +10 -0
- package/dist/drizzle/cbor.js.map +1 -0
- package/dist/drizzle/index.d.ts +3 -1
- package/dist/drizzle/index.d.ts.map +1 -1
- package/dist/drizzle/index.js.map +1 -1
- package/dist/drizzle/schema.d.ts +756 -59
- package/dist/drizzle/schema.d.ts.map +1 -1
- package/dist/drizzle/schema.js +68 -37
- package/dist/drizzle/schema.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -5
- package/dist/index.js.map +1 -1
- package/dist/{boss.d.ts → message.d.ts} +6 -6
- package/dist/message.d.ts.map +1 -0
- package/dist/{boss.js → message.js} +5 -5
- package/dist/message.js.map +1 -0
- package/dist/queue.d.ts +8 -6
- package/dist/queue.d.ts.map +1 -1
- package/dist/queue.js +129 -50
- package/dist/queue.js.map +1 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +854 -212
- package/dist/storage.js.map +1 -1
- package/dist/streamer.d.ts +5 -1
- package/dist/streamer.d.ts.map +1 -1
- package/dist/streamer.js +50 -9
- package/dist/streamer.js.map +1 -1
- package/package.json +21 -20
- package/src/drizzle/migrations/{0000_redundant_smasher.sql → 0000_cultured_the_anarchist.sql} +17 -13
- package/src/drizzle/migrations/0001_tricky_sersi.sql +11 -0
- package/src/drizzle/migrations/0002_add_expired_at.sql +2 -0
- package/src/drizzle/migrations/0003_add_stream_run_id.sql +3 -0
- package/src/drizzle/migrations/0004_remove_run_pause_status.sql +14 -0
- package/src/drizzle/migrations/0005_add_spec_version.sql +5 -0
- package/src/drizzle/migrations/0006_add_error_cbor.sql +5 -0
- package/src/drizzle/migrations/0007_add_waits_table.sql +13 -0
- package/src/drizzle/migrations/0008_migrate_pgboss_to_graphile.sql +20 -0
- package/src/drizzle/migrations/0009_add_is_webhook.sql +1 -0
- package/src/drizzle/migrations/meta/0000_snapshot.json +499 -0
- package/src/drizzle/migrations/meta/0001_snapshot.json +548 -0
- package/src/drizzle/migrations/meta/0002_snapshot.json +554 -0
- package/src/drizzle/migrations/meta/0003_snapshot.json +576 -0
- package/src/drizzle/migrations/meta/0005_snapshot.json +575 -0
- package/src/drizzle/migrations/meta/_journal.json +76 -0
- package/dist/boss.d.ts.map +0 -1
- package/dist/boss.js.map +0 -1
package/dist/storage.js
CHANGED
|
@@ -1,10 +1,67 @@
|
|
|
1
|
-
import { WorkflowAPIError } from '@workflow/errors';
|
|
2
|
-
import {
|
|
1
|
+
import { HookNotFoundError, RunNotSupportedError, WorkflowAPIError, } from '@workflow/errors';
|
|
2
|
+
import { EventSchema, HookSchema, isLegacySpecVersion, requiresNewerWorld, SPEC_VERSION_CURRENT, StepSchema, validateUlidTimestamp, WorkflowRunSchema, } from '@workflow/world';
|
|
3
|
+
import { and, asc, desc, eq, gt, lt, notInArray, sql } from 'drizzle-orm';
|
|
3
4
|
import { monotonicFactory } from 'ulid';
|
|
4
5
|
import { Schema } from './drizzle/index.js';
|
|
5
6
|
import { compact } from './util.js';
|
|
7
|
+
/**
|
|
8
|
+
* Parse legacy errorJson (text column with JSON-stringified StructuredError).
|
|
9
|
+
* Used for backwards compatibility when reading from deprecated error column.
|
|
10
|
+
*/
|
|
11
|
+
function parseErrorJson(errorJson) {
|
|
12
|
+
if (!errorJson)
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(errorJson);
|
|
16
|
+
if (typeof parsed === 'object' && parsed.message !== undefined) {
|
|
17
|
+
return {
|
|
18
|
+
message: parsed.message,
|
|
19
|
+
stack: parsed.stack,
|
|
20
|
+
code: parsed.code,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
// Not a structured error object, treat as plain string
|
|
24
|
+
return { message: String(parsed) };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Not JSON, treat as plain string error message
|
|
28
|
+
return { message: errorJson };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Deserialize run data, handling legacy error fields.
|
|
33
|
+
* The error field should already be deserialized from CBOR or fallback to errorJson.
|
|
34
|
+
* This function only handles very old legacy fields (errorStack, errorCode).
|
|
35
|
+
*/
|
|
36
|
+
function deserializeRunError(run) {
|
|
37
|
+
const { errorStack, errorCode, ...rest } = run;
|
|
38
|
+
// If no legacy fields, return as-is (error is already a StructuredError or undefined)
|
|
39
|
+
if (!errorStack && !errorCode) {
|
|
40
|
+
return rest;
|
|
41
|
+
}
|
|
42
|
+
// Very old legacy: separate errorStack/errorCode fields
|
|
43
|
+
const existingError = rest.error;
|
|
44
|
+
return {
|
|
45
|
+
...rest,
|
|
46
|
+
error: {
|
|
47
|
+
message: existingError?.message || '',
|
|
48
|
+
stack: existingError?.stack || errorStack,
|
|
49
|
+
code: existingError?.code || errorCode,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Deserialize step data, mapping DB columns to interface fields.
|
|
55
|
+
* The error field should already be deserialized from CBOR or fallback to errorJson.
|
|
56
|
+
*/
|
|
57
|
+
function deserializeStepError(step) {
|
|
58
|
+
const { startedAt, ...rest } = step;
|
|
59
|
+
return {
|
|
60
|
+
...rest,
|
|
61
|
+
startedAt,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
6
64
|
export function createRunsStorage(drizzle) {
|
|
7
|
-
const ulid = monotonicFactory();
|
|
8
65
|
const { runs } = Schema;
|
|
9
66
|
const get = drizzle
|
|
10
67
|
.select()
|
|
@@ -13,51 +70,21 @@ export function createRunsStorage(drizzle) {
|
|
|
13
70
|
.limit(1)
|
|
14
71
|
.prepare('workflow_runs_get');
|
|
15
72
|
return {
|
|
16
|
-
async
|
|
73
|
+
get: (async (id, params) => {
|
|
17
74
|
const [value] = await get.execute({ id });
|
|
18
75
|
if (!value) {
|
|
19
76
|
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
|
|
20
77
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
|
|
32
|
-
}
|
|
33
|
-
return compact(value);
|
|
34
|
-
},
|
|
35
|
-
async pause(id) {
|
|
36
|
-
// TODO: we might want to guard this for only specific statuses
|
|
37
|
-
const [value] = await drizzle
|
|
38
|
-
.update(Schema.runs)
|
|
39
|
-
.set({ status: 'paused' })
|
|
40
|
-
.where(eq(runs.runId, id))
|
|
41
|
-
.returning();
|
|
42
|
-
if (!value) {
|
|
43
|
-
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
|
|
44
|
-
}
|
|
45
|
-
return compact(value);
|
|
46
|
-
},
|
|
47
|
-
async resume(id) {
|
|
48
|
-
const [value] = await drizzle
|
|
49
|
-
.update(Schema.runs)
|
|
50
|
-
.set({ status: 'running' })
|
|
51
|
-
.where(and(eq(runs.runId, id), eq(runs.status, 'paused')))
|
|
52
|
-
.returning();
|
|
53
|
-
if (!value) {
|
|
54
|
-
throw new WorkflowAPIError(`Paused run not found: ${id}`, {
|
|
55
|
-
status: 404,
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
return compact(value);
|
|
59
|
-
},
|
|
60
|
-
async list(params) {
|
|
78
|
+
value.output ||= value.outputJson;
|
|
79
|
+
value.input ||= value.inputJson;
|
|
80
|
+
value.executionContext ||= value.executionContextJson;
|
|
81
|
+
value.error ||= parseErrorJson(value.errorJson);
|
|
82
|
+
const deserialized = deserializeRunError(compact(value));
|
|
83
|
+
const parsed = WorkflowRunSchema.parse(deserialized);
|
|
84
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
85
|
+
return filterRunData(parsed, resolveData);
|
|
86
|
+
}),
|
|
87
|
+
list: (async (params) => {
|
|
61
88
|
const limit = params?.pagination?.limit ?? 20;
|
|
62
89
|
const fromCursor = params?.pagination?.cursor;
|
|
63
90
|
const all = await drizzle
|
|
@@ -68,85 +95,711 @@ export function createRunsStorage(drizzle) {
|
|
|
68
95
|
.limit(limit + 1);
|
|
69
96
|
const values = all.slice(0, limit);
|
|
70
97
|
const hasMore = all.length > limit;
|
|
98
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
71
99
|
return {
|
|
72
|
-
data: values.map(
|
|
100
|
+
data: values.map((v) => {
|
|
101
|
+
v.output ||= v.outputJson;
|
|
102
|
+
v.input ||= v.inputJson;
|
|
103
|
+
v.executionContext ||= v.executionContextJson;
|
|
104
|
+
v.error ||= parseErrorJson(v.errorJson);
|
|
105
|
+
const deserialized = deserializeRunError(compact(v));
|
|
106
|
+
const parsed = WorkflowRunSchema.parse(deserialized);
|
|
107
|
+
return filterRunData(parsed, resolveData);
|
|
108
|
+
}),
|
|
73
109
|
hasMore,
|
|
74
110
|
cursor: values.at(-1)?.runId ?? null,
|
|
75
111
|
};
|
|
76
|
-
},
|
|
77
|
-
async create(data) {
|
|
78
|
-
const runId = `wrun_${ulid()}`;
|
|
79
|
-
const [value] = await drizzle
|
|
80
|
-
.insert(runs)
|
|
81
|
-
.values({
|
|
82
|
-
runId,
|
|
83
|
-
input: data.input,
|
|
84
|
-
executionContext: data.executionContext,
|
|
85
|
-
deploymentId: data.deploymentId,
|
|
86
|
-
status: 'pending',
|
|
87
|
-
workflowName: data.workflowName,
|
|
88
|
-
})
|
|
89
|
-
.onConflictDoNothing()
|
|
90
|
-
.returning();
|
|
91
|
-
if (!value) {
|
|
92
|
-
throw new WorkflowAPIError(`Run ${runId} already exists`, {
|
|
93
|
-
status: 409,
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
return compact(value);
|
|
97
|
-
},
|
|
98
|
-
async update(id, data) {
|
|
99
|
-
// Fetch current run to check if startedAt is already set
|
|
100
|
-
const [currentRun] = await drizzle
|
|
101
|
-
.select()
|
|
102
|
-
.from(runs)
|
|
103
|
-
.where(eq(runs.runId, id))
|
|
104
|
-
.limit(1);
|
|
105
|
-
if (!currentRun) {
|
|
106
|
-
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
|
|
107
|
-
}
|
|
108
|
-
const updates = {
|
|
109
|
-
...data,
|
|
110
|
-
output: data.output,
|
|
111
|
-
};
|
|
112
|
-
// Only set startedAt the first time transitioning to 'running'
|
|
113
|
-
if (data.status === 'running' && !currentRun.startedAt) {
|
|
114
|
-
updates.startedAt = new Date();
|
|
115
|
-
}
|
|
116
|
-
if (data.status === 'completed' ||
|
|
117
|
-
data.status === 'failed' ||
|
|
118
|
-
data.status === 'cancelled') {
|
|
119
|
-
updates.completedAt = new Date();
|
|
120
|
-
}
|
|
121
|
-
const [value] = await drizzle
|
|
122
|
-
.update(runs)
|
|
123
|
-
.set(updates)
|
|
124
|
-
.where(eq(runs.runId, id))
|
|
125
|
-
.returning();
|
|
126
|
-
if (!value) {
|
|
127
|
-
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
|
|
128
|
-
}
|
|
129
|
-
return compact(value);
|
|
130
|
-
},
|
|
112
|
+
}),
|
|
131
113
|
};
|
|
132
114
|
}
|
|
133
115
|
function map(obj, fn) {
|
|
134
116
|
return obj ? fn(obj) : undefined;
|
|
135
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Handle events for legacy runs (pre-event-sourcing, specVersion < 2).
|
|
120
|
+
* Legacy runs use different behavior:
|
|
121
|
+
* - run_cancelled: Skip event storage, directly update run
|
|
122
|
+
* - wait_completed: Store event only (no entity mutation)
|
|
123
|
+
* - hook_received: Store event only (hooks exist via old system, no entity mutation)
|
|
124
|
+
* - Other events: Throw error (not supported for legacy runs)
|
|
125
|
+
*/
|
|
126
|
+
async function handleLegacyEventPostgres(drizzle, runId, eventId, data, currentRun, params) {
|
|
127
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
128
|
+
switch (data.eventType) {
|
|
129
|
+
case 'run_cancelled': {
|
|
130
|
+
// Legacy: Skip event storage, directly update run to cancelled
|
|
131
|
+
const now = new Date();
|
|
132
|
+
// Update run status to cancelled
|
|
133
|
+
await drizzle
|
|
134
|
+
.update(Schema.runs)
|
|
135
|
+
.set({
|
|
136
|
+
status: 'cancelled',
|
|
137
|
+
completedAt: now,
|
|
138
|
+
updatedAt: now,
|
|
139
|
+
})
|
|
140
|
+
.where(eq(Schema.runs.runId, runId));
|
|
141
|
+
// Delete all hooks and waits for this run
|
|
142
|
+
await Promise.all([
|
|
143
|
+
drizzle.delete(Schema.hooks).where(eq(Schema.hooks.runId, runId)),
|
|
144
|
+
drizzle.delete(Schema.waits).where(eq(Schema.waits.runId, runId)),
|
|
145
|
+
]);
|
|
146
|
+
// Fetch updated run for return value
|
|
147
|
+
const [updatedRun] = await drizzle
|
|
148
|
+
.select()
|
|
149
|
+
.from(Schema.runs)
|
|
150
|
+
.where(eq(Schema.runs.runId, runId))
|
|
151
|
+
.limit(1);
|
|
152
|
+
// Return without event (legacy behavior skips event storage)
|
|
153
|
+
// Type assertion: EventResult expects WorkflowRun, filterRunData may return WorkflowRunWithoutData
|
|
154
|
+
return {
|
|
155
|
+
run: updatedRun
|
|
156
|
+
? filterRunData(deserializeRunError(compact(updatedRun)), resolveData)
|
|
157
|
+
: undefined,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
case 'wait_completed':
|
|
161
|
+
case 'hook_received': {
|
|
162
|
+
// Legacy: Store event only (no entity mutation)
|
|
163
|
+
// - wait_completed: for replay purposes
|
|
164
|
+
// - hook_received: hooks exist via old system, just record the event
|
|
165
|
+
const [insertedEvent] = await drizzle
|
|
166
|
+
.insert(Schema.events)
|
|
167
|
+
.values({
|
|
168
|
+
runId,
|
|
169
|
+
eventId,
|
|
170
|
+
correlationId: data.correlationId,
|
|
171
|
+
eventType: data.eventType,
|
|
172
|
+
eventData: 'eventData' in data ? data.eventData : undefined,
|
|
173
|
+
specVersion: SPEC_VERSION_CURRENT,
|
|
174
|
+
})
|
|
175
|
+
.returning({ createdAt: Schema.events.createdAt });
|
|
176
|
+
const event = EventSchema.parse({
|
|
177
|
+
...data,
|
|
178
|
+
...insertedEvent,
|
|
179
|
+
runId,
|
|
180
|
+
eventId,
|
|
181
|
+
});
|
|
182
|
+
return { event: filterEventData(event, resolveData) };
|
|
183
|
+
}
|
|
184
|
+
default:
|
|
185
|
+
throw new Error(`Event type '${data.eventType}' not supported for legacy runs ` +
|
|
186
|
+
`(specVersion: ${currentRun.specVersion || 'undefined'}). ` +
|
|
187
|
+
`Please upgrade @workflow packages.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
136
190
|
export function createEventsStorage(drizzle) {
|
|
137
191
|
const ulid = monotonicFactory();
|
|
138
192
|
const { events } = Schema;
|
|
193
|
+
// Prepared statements for validation queries (performance optimization)
|
|
194
|
+
const getRunForValidation = drizzle
|
|
195
|
+
.select({
|
|
196
|
+
status: Schema.runs.status,
|
|
197
|
+
specVersion: Schema.runs.specVersion,
|
|
198
|
+
})
|
|
199
|
+
.from(Schema.runs)
|
|
200
|
+
.where(eq(Schema.runs.runId, sql.placeholder('runId')))
|
|
201
|
+
.limit(1)
|
|
202
|
+
.prepare('events_get_run_for_validation');
|
|
203
|
+
const getStepForValidation = drizzle
|
|
204
|
+
.select({
|
|
205
|
+
status: Schema.steps.status,
|
|
206
|
+
startedAt: Schema.steps.startedAt,
|
|
207
|
+
retryAfter: Schema.steps.retryAfter,
|
|
208
|
+
})
|
|
209
|
+
.from(Schema.steps)
|
|
210
|
+
.where(and(eq(Schema.steps.runId, sql.placeholder('runId')), eq(Schema.steps.stepId, sql.placeholder('stepId'))))
|
|
211
|
+
.limit(1)
|
|
212
|
+
.prepare('events_get_step_for_validation');
|
|
213
|
+
const getHookByToken = drizzle
|
|
214
|
+
.select({ hookId: Schema.hooks.hookId })
|
|
215
|
+
.from(Schema.hooks)
|
|
216
|
+
.where(eq(Schema.hooks.token, sql.placeholder('token')))
|
|
217
|
+
.limit(1)
|
|
218
|
+
.prepare('events_get_hook_by_token');
|
|
219
|
+
const getWaitForValidation = drizzle
|
|
220
|
+
.select({
|
|
221
|
+
status: Schema.waits.status,
|
|
222
|
+
})
|
|
223
|
+
.from(Schema.waits)
|
|
224
|
+
.where(eq(Schema.waits.waitId, sql.placeholder('waitId')))
|
|
225
|
+
.limit(1)
|
|
226
|
+
.prepare('events_get_wait_for_validation');
|
|
139
227
|
return {
|
|
140
|
-
async create(runId, data) {
|
|
228
|
+
async create(runId, data, params) {
|
|
141
229
|
const eventId = `wevt_${ulid()}`;
|
|
230
|
+
// For run_created events, use client-provided runId or generate one server-side
|
|
231
|
+
let effectiveRunId;
|
|
232
|
+
if (data.eventType === 'run_created' && (!runId || runId === '')) {
|
|
233
|
+
effectiveRunId = `wrun_${ulid()}`;
|
|
234
|
+
}
|
|
235
|
+
else if (!runId) {
|
|
236
|
+
throw new Error('runId is required for non-run_created events');
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
effectiveRunId = runId;
|
|
240
|
+
}
|
|
241
|
+
// Validate client-provided runId timestamp is within acceptable threshold
|
|
242
|
+
if (data.eventType === 'run_created' && runId && runId !== '') {
|
|
243
|
+
const validationError = validateUlidTimestamp(effectiveRunId, 'wrun_');
|
|
244
|
+
if (validationError) {
|
|
245
|
+
throw new WorkflowAPIError(validationError, { status: 400 });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// specVersion is always sent by the runtime, but we provide a fallback for safety
|
|
249
|
+
const effectiveSpecVersion = data.specVersion ?? SPEC_VERSION_CURRENT;
|
|
250
|
+
// Track entity created/updated for EventResult
|
|
251
|
+
let run;
|
|
252
|
+
let step;
|
|
253
|
+
let hook;
|
|
254
|
+
let wait;
|
|
255
|
+
const now = new Date();
|
|
256
|
+
// Helper to check if run is in terminal state
|
|
257
|
+
const isRunTerminal = (status) => ['completed', 'failed', 'cancelled'].includes(status);
|
|
258
|
+
// Helper to check if step is in terminal state
|
|
259
|
+
const isStepTerminal = (status) => ['completed', 'failed'].includes(status);
|
|
260
|
+
// ============================================================
|
|
261
|
+
// VALIDATION: Terminal state and event ordering checks
|
|
262
|
+
// ============================================================
|
|
263
|
+
// Get current run state for validation (if not creating a new run)
|
|
264
|
+
// Skip run validation for step_completed and step_retrying - they only operate
|
|
265
|
+
// on running steps, and running steps are always allowed to modify regardless
|
|
266
|
+
// of run state. This optimization saves database queries per step event.
|
|
267
|
+
let currentRun = null;
|
|
268
|
+
const skipRunValidationEvents = ['step_completed', 'step_retrying'];
|
|
269
|
+
if (data.eventType !== 'run_created' &&
|
|
270
|
+
!skipRunValidationEvents.includes(data.eventType)) {
|
|
271
|
+
// Use prepared statement for better performance
|
|
272
|
+
const [runValue] = await getRunForValidation.execute({
|
|
273
|
+
runId: effectiveRunId,
|
|
274
|
+
});
|
|
275
|
+
currentRun = runValue ?? null;
|
|
276
|
+
}
|
|
277
|
+
// ============================================================
|
|
278
|
+
// VERSION COMPATIBILITY: Check run spec version
|
|
279
|
+
// ============================================================
|
|
280
|
+
// For events that have fetched the run, check version compatibility.
|
|
281
|
+
// Skip for run_created (no existing run) and runtime events (step_completed, step_retrying).
|
|
282
|
+
if (currentRun) {
|
|
283
|
+
// Check if run requires a newer world version
|
|
284
|
+
if (requiresNewerWorld(currentRun.specVersion)) {
|
|
285
|
+
throw new RunNotSupportedError(currentRun.specVersion, SPEC_VERSION_CURRENT);
|
|
286
|
+
}
|
|
287
|
+
// Route to legacy handler for pre-event-sourcing runs
|
|
288
|
+
if (isLegacySpecVersion(currentRun.specVersion)) {
|
|
289
|
+
return handleLegacyEventPostgres(drizzle, effectiveRunId, eventId, data, currentRun, params);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Run terminal state validation
|
|
293
|
+
if (currentRun && isRunTerminal(currentRun.status)) {
|
|
294
|
+
const runTerminalEvents = [
|
|
295
|
+
'run_started',
|
|
296
|
+
'run_completed',
|
|
297
|
+
'run_failed',
|
|
298
|
+
];
|
|
299
|
+
// Idempotent operation: run_cancelled on already cancelled run is allowed
|
|
300
|
+
if (data.eventType === 'run_cancelled' &&
|
|
301
|
+
currentRun.status === 'cancelled') {
|
|
302
|
+
// Get full run for return value
|
|
303
|
+
const [fullRun] = await drizzle
|
|
304
|
+
.select()
|
|
305
|
+
.from(Schema.runs)
|
|
306
|
+
.where(eq(Schema.runs.runId, effectiveRunId))
|
|
307
|
+
.limit(1);
|
|
308
|
+
// Create the event (still record it)
|
|
309
|
+
const [value] = await drizzle
|
|
310
|
+
.insert(Schema.events)
|
|
311
|
+
.values({
|
|
312
|
+
runId: effectiveRunId,
|
|
313
|
+
eventId,
|
|
314
|
+
correlationId: data.correlationId,
|
|
315
|
+
eventType: data.eventType,
|
|
316
|
+
eventData: 'eventData' in data ? data.eventData : undefined,
|
|
317
|
+
specVersion: effectiveSpecVersion,
|
|
318
|
+
})
|
|
319
|
+
.returning({ createdAt: Schema.events.createdAt });
|
|
320
|
+
const result = { ...data, ...value, runId: effectiveRunId, eventId };
|
|
321
|
+
const parsed = EventSchema.parse(result);
|
|
322
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
323
|
+
return {
|
|
324
|
+
event: filterEventData(parsed, resolveData),
|
|
325
|
+
run: fullRun ? deserializeRunError(compact(fullRun)) : undefined,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
// Run state transitions are not allowed on terminal runs
|
|
329
|
+
if (runTerminalEvents.includes(data.eventType) ||
|
|
330
|
+
data.eventType === 'run_cancelled') {
|
|
331
|
+
throw new WorkflowAPIError(`Cannot transition run from terminal state "${currentRun.status}"`, { status: 409 });
|
|
332
|
+
}
|
|
333
|
+
// Creating new entities on terminal runs is not allowed
|
|
334
|
+
if (data.eventType === 'step_created' ||
|
|
335
|
+
data.eventType === 'hook_created' ||
|
|
336
|
+
data.eventType === 'wait_created') {
|
|
337
|
+
throw new WorkflowAPIError(`Cannot create new entities on run in terminal state "${currentRun.status}"`, { status: 409 });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Step-related event validation (ordering and terminal state)
|
|
341
|
+
// Fetch status + startedAt so we can reuse for step_started (avoid double read)
|
|
342
|
+
// Skip validation for step_completed/step_failed - use conditional UPDATE instead
|
|
343
|
+
let validatedStep = null;
|
|
344
|
+
const stepEventsNeedingValidation = ['step_started', 'step_retrying'];
|
|
345
|
+
if (stepEventsNeedingValidation.includes(data.eventType) &&
|
|
346
|
+
data.correlationId) {
|
|
347
|
+
// Use prepared statement for better performance
|
|
348
|
+
const [existingStep] = await getStepForValidation.execute({
|
|
349
|
+
runId: effectiveRunId,
|
|
350
|
+
stepId: data.correlationId,
|
|
351
|
+
});
|
|
352
|
+
validatedStep = existingStep ?? null;
|
|
353
|
+
// Event ordering: step must exist before these events
|
|
354
|
+
if (!validatedStep) {
|
|
355
|
+
throw new WorkflowAPIError(`Step "${data.correlationId}" not found`, {
|
|
356
|
+
status: 404,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
// Step terminal state validation
|
|
360
|
+
if (isStepTerminal(validatedStep.status)) {
|
|
361
|
+
throw new WorkflowAPIError(`Cannot modify step in terminal state "${validatedStep.status}"`, { status: 409 });
|
|
362
|
+
}
|
|
363
|
+
// On terminal runs: only allow completing/failing in-progress steps
|
|
364
|
+
if (currentRun && isRunTerminal(currentRun.status)) {
|
|
365
|
+
if (validatedStep.status !== 'running') {
|
|
366
|
+
throw new WorkflowAPIError(`Cannot modify non-running step on run in terminal state "${currentRun.status}"`, { status: 410 });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Hook-related event validation (ordering)
|
|
371
|
+
const hookEventsRequiringExistence = ['hook_disposed', 'hook_received'];
|
|
372
|
+
if (hookEventsRequiringExistence.includes(data.eventType) &&
|
|
373
|
+
data.correlationId) {
|
|
374
|
+
const [existingHook] = await drizzle
|
|
375
|
+
.select({ hookId: Schema.hooks.hookId })
|
|
376
|
+
.from(Schema.hooks)
|
|
377
|
+
.where(eq(Schema.hooks.hookId, data.correlationId))
|
|
378
|
+
.limit(1);
|
|
379
|
+
if (!existingHook) {
|
|
380
|
+
throw new WorkflowAPIError(`Hook "${data.correlationId}" not found`, {
|
|
381
|
+
status: 404,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// ============================================================
|
|
386
|
+
// Entity creation/updates based on event type
|
|
387
|
+
// ============================================================
|
|
388
|
+
// Handle run_created event: create the run entity atomically
|
|
389
|
+
if (data.eventType === 'run_created') {
|
|
390
|
+
const eventData = data.eventData;
|
|
391
|
+
const [runValue] = await drizzle
|
|
392
|
+
.insert(Schema.runs)
|
|
393
|
+
.values({
|
|
394
|
+
runId: effectiveRunId,
|
|
395
|
+
deploymentId: eventData.deploymentId,
|
|
396
|
+
workflowName: eventData.workflowName,
|
|
397
|
+
// Propagate specVersion from the event to the run entity
|
|
398
|
+
specVersion: effectiveSpecVersion,
|
|
399
|
+
input: eventData.input,
|
|
400
|
+
executionContext: eventData.executionContext,
|
|
401
|
+
status: 'pending',
|
|
402
|
+
})
|
|
403
|
+
.onConflictDoNothing()
|
|
404
|
+
.returning();
|
|
405
|
+
if (runValue) {
|
|
406
|
+
run = deserializeRunError(compact(runValue));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Handle run_started event: update run status
|
|
410
|
+
if (data.eventType === 'run_started') {
|
|
411
|
+
const [runValue] = await drizzle
|
|
412
|
+
.update(Schema.runs)
|
|
413
|
+
.set({
|
|
414
|
+
status: 'running',
|
|
415
|
+
startedAt: now,
|
|
416
|
+
updatedAt: now,
|
|
417
|
+
})
|
|
418
|
+
.where(eq(Schema.runs.runId, effectiveRunId))
|
|
419
|
+
.returning();
|
|
420
|
+
if (runValue) {
|
|
421
|
+
run = deserializeRunError(compact(runValue));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// Handle run_completed event: update run status and cleanup hooks
|
|
425
|
+
if (data.eventType === 'run_completed') {
|
|
426
|
+
const eventData = data.eventData;
|
|
427
|
+
const [runValue] = await drizzle
|
|
428
|
+
.update(Schema.runs)
|
|
429
|
+
.set({
|
|
430
|
+
status: 'completed',
|
|
431
|
+
output: eventData.output,
|
|
432
|
+
completedAt: now,
|
|
433
|
+
updatedAt: now,
|
|
434
|
+
})
|
|
435
|
+
.where(eq(Schema.runs.runId, effectiveRunId))
|
|
436
|
+
.returning();
|
|
437
|
+
if (runValue) {
|
|
438
|
+
run = deserializeRunError(compact(runValue));
|
|
439
|
+
}
|
|
440
|
+
// Delete all hooks and waits for this run to allow token reuse
|
|
441
|
+
await Promise.all([
|
|
442
|
+
drizzle
|
|
443
|
+
.delete(Schema.hooks)
|
|
444
|
+
.where(eq(Schema.hooks.runId, effectiveRunId)),
|
|
445
|
+
drizzle
|
|
446
|
+
.delete(Schema.waits)
|
|
447
|
+
.where(eq(Schema.waits.runId, effectiveRunId)),
|
|
448
|
+
]);
|
|
449
|
+
}
|
|
450
|
+
// Handle run_failed event: update run status and cleanup hooks
|
|
451
|
+
if (data.eventType === 'run_failed') {
|
|
452
|
+
const eventData = data.eventData;
|
|
453
|
+
const errorMessage = typeof eventData.error === 'string'
|
|
454
|
+
? eventData.error
|
|
455
|
+
: (eventData.error?.message ?? 'Unknown error');
|
|
456
|
+
const [runValue] = await drizzle
|
|
457
|
+
.update(Schema.runs)
|
|
458
|
+
.set({
|
|
459
|
+
status: 'failed',
|
|
460
|
+
error: {
|
|
461
|
+
message: errorMessage,
|
|
462
|
+
stack: eventData.error?.stack,
|
|
463
|
+
code: eventData.errorCode,
|
|
464
|
+
},
|
|
465
|
+
completedAt: now,
|
|
466
|
+
updatedAt: now,
|
|
467
|
+
})
|
|
468
|
+
.where(eq(Schema.runs.runId, effectiveRunId))
|
|
469
|
+
.returning();
|
|
470
|
+
if (runValue) {
|
|
471
|
+
run = deserializeRunError(compact(runValue));
|
|
472
|
+
}
|
|
473
|
+
// Delete all hooks and waits for this run to allow token reuse
|
|
474
|
+
await Promise.all([
|
|
475
|
+
drizzle
|
|
476
|
+
.delete(Schema.hooks)
|
|
477
|
+
.where(eq(Schema.hooks.runId, effectiveRunId)),
|
|
478
|
+
drizzle
|
|
479
|
+
.delete(Schema.waits)
|
|
480
|
+
.where(eq(Schema.waits.runId, effectiveRunId)),
|
|
481
|
+
]);
|
|
482
|
+
}
|
|
483
|
+
// Handle run_cancelled event: update run status and cleanup hooks
|
|
484
|
+
if (data.eventType === 'run_cancelled') {
|
|
485
|
+
const [runValue] = await drizzle
|
|
486
|
+
.update(Schema.runs)
|
|
487
|
+
.set({
|
|
488
|
+
status: 'cancelled',
|
|
489
|
+
completedAt: now,
|
|
490
|
+
updatedAt: now,
|
|
491
|
+
})
|
|
492
|
+
.where(eq(Schema.runs.runId, effectiveRunId))
|
|
493
|
+
.returning();
|
|
494
|
+
if (runValue) {
|
|
495
|
+
run = deserializeRunError(compact(runValue));
|
|
496
|
+
}
|
|
497
|
+
// Delete all hooks and waits for this run to allow token reuse
|
|
498
|
+
await Promise.all([
|
|
499
|
+
drizzle
|
|
500
|
+
.delete(Schema.hooks)
|
|
501
|
+
.where(eq(Schema.hooks.runId, effectiveRunId)),
|
|
502
|
+
drizzle
|
|
503
|
+
.delete(Schema.waits)
|
|
504
|
+
.where(eq(Schema.waits.runId, effectiveRunId)),
|
|
505
|
+
]);
|
|
506
|
+
}
|
|
507
|
+
// Handle step_created event: create step entity
|
|
508
|
+
if (data.eventType === 'step_created') {
|
|
509
|
+
const eventData = data.eventData;
|
|
510
|
+
const [stepValue] = await drizzle
|
|
511
|
+
.insert(Schema.steps)
|
|
512
|
+
.values({
|
|
513
|
+
runId: effectiveRunId,
|
|
514
|
+
stepId: data.correlationId,
|
|
515
|
+
stepName: eventData.stepName,
|
|
516
|
+
input: eventData.input,
|
|
517
|
+
status: 'pending',
|
|
518
|
+
attempt: 0,
|
|
519
|
+
// Propagate specVersion from the event to the step entity
|
|
520
|
+
specVersion: effectiveSpecVersion,
|
|
521
|
+
})
|
|
522
|
+
.onConflictDoNothing()
|
|
523
|
+
.returning();
|
|
524
|
+
if (stepValue) {
|
|
525
|
+
step = deserializeStepError(compact(stepValue));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// Handle step_started event: increment attempt, set status to 'running'
|
|
529
|
+
// Sets startedAt (maps to startedAt) only on first start
|
|
530
|
+
// Reuse validatedStep from validation (already read above)
|
|
531
|
+
if (data.eventType === 'step_started') {
|
|
532
|
+
// Check if retryAfter timestamp hasn't been reached yet
|
|
533
|
+
if (validatedStep?.retryAfter &&
|
|
534
|
+
validatedStep.retryAfter.getTime() > Date.now()) {
|
|
535
|
+
const err = new WorkflowAPIError(`Cannot start step "${data.correlationId}": retryAfter timestamp has not been reached yet`, { status: 425 });
|
|
536
|
+
// Add meta for step-handler to extract retryAfter timestamp
|
|
537
|
+
err.meta = {
|
|
538
|
+
stepId: data.correlationId,
|
|
539
|
+
retryAfter: validatedStep.retryAfter.toISOString(),
|
|
540
|
+
};
|
|
541
|
+
throw err;
|
|
542
|
+
}
|
|
543
|
+
const isFirstStart = !validatedStep?.startedAt;
|
|
544
|
+
const hadRetryAfter = !!validatedStep?.retryAfter;
|
|
545
|
+
const [stepValue] = await drizzle
|
|
546
|
+
.update(Schema.steps)
|
|
547
|
+
.set({
|
|
548
|
+
status: 'running',
|
|
549
|
+
// Increment attempt counter using SQL
|
|
550
|
+
attempt: sql `${Schema.steps.attempt} + 1`,
|
|
551
|
+
// Only set startedAt on first start (not updated on retries)
|
|
552
|
+
...(isFirstStart ? { startedAt: now } : {}),
|
|
553
|
+
// Clear retryAfter now that the step has started
|
|
554
|
+
...(hadRetryAfter ? { retryAfter: null } : {}),
|
|
555
|
+
})
|
|
556
|
+
.where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId)))
|
|
557
|
+
.returning();
|
|
558
|
+
if (stepValue) {
|
|
559
|
+
step = deserializeStepError(compact(stepValue));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
// Handle step_completed event: update step status
|
|
563
|
+
// Uses conditional UPDATE to skip validation query (performance optimization)
|
|
564
|
+
if (data.eventType === 'step_completed') {
|
|
565
|
+
const eventData = data.eventData;
|
|
566
|
+
const [stepValue] = await drizzle
|
|
567
|
+
.update(Schema.steps)
|
|
568
|
+
.set({
|
|
569
|
+
status: 'completed',
|
|
570
|
+
output: eventData.result,
|
|
571
|
+
completedAt: now,
|
|
572
|
+
})
|
|
573
|
+
.where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId),
|
|
574
|
+
// Only update if not already in terminal state (validation in WHERE clause)
|
|
575
|
+
notInArray(Schema.steps.status, ['completed', 'failed'])))
|
|
576
|
+
.returning();
|
|
577
|
+
if (stepValue) {
|
|
578
|
+
step = deserializeStepError(compact(stepValue));
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
// Step not updated - check if it exists and why
|
|
582
|
+
const [existing] = await getStepForValidation.execute({
|
|
583
|
+
runId: effectiveRunId,
|
|
584
|
+
stepId: data.correlationId,
|
|
585
|
+
});
|
|
586
|
+
if (!existing) {
|
|
587
|
+
throw new WorkflowAPIError(`Step "${data.correlationId}" not found`, { status: 404 });
|
|
588
|
+
}
|
|
589
|
+
if (['completed', 'failed'].includes(existing.status)) {
|
|
590
|
+
throw new WorkflowAPIError(`Cannot modify step in terminal state "${existing.status}"`, { status: 409 });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// Handle step_failed event: terminal state with error
|
|
595
|
+
// Uses conditional UPDATE to skip validation query (performance optimization)
|
|
596
|
+
if (data.eventType === 'step_failed') {
|
|
597
|
+
const eventData = data.eventData;
|
|
598
|
+
const errorMessage = typeof eventData.error === 'string'
|
|
599
|
+
? eventData.error
|
|
600
|
+
: (eventData.error?.message ?? 'Unknown error');
|
|
601
|
+
const [stepValue] = await drizzle
|
|
602
|
+
.update(Schema.steps)
|
|
603
|
+
.set({
|
|
604
|
+
status: 'failed',
|
|
605
|
+
error: {
|
|
606
|
+
message: errorMessage,
|
|
607
|
+
stack: eventData.stack,
|
|
608
|
+
},
|
|
609
|
+
completedAt: now,
|
|
610
|
+
})
|
|
611
|
+
.where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId),
|
|
612
|
+
// Only update if not already in terminal state (validation in WHERE clause)
|
|
613
|
+
notInArray(Schema.steps.status, ['completed', 'failed'])))
|
|
614
|
+
.returning();
|
|
615
|
+
if (stepValue) {
|
|
616
|
+
step = deserializeStepError(compact(stepValue));
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
// Step not updated - check if it exists and why
|
|
620
|
+
const [existing] = await getStepForValidation.execute({
|
|
621
|
+
runId: effectiveRunId,
|
|
622
|
+
stepId: data.correlationId,
|
|
623
|
+
});
|
|
624
|
+
if (!existing) {
|
|
625
|
+
throw new WorkflowAPIError(`Step "${data.correlationId}" not found`, { status: 404 });
|
|
626
|
+
}
|
|
627
|
+
if (['completed', 'failed'].includes(existing.status)) {
|
|
628
|
+
throw new WorkflowAPIError(`Cannot modify step in terminal state "${existing.status}"`, { status: 409 });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// Handle step_retrying event: sets status back to 'pending', records error
|
|
633
|
+
if (data.eventType === 'step_retrying') {
|
|
634
|
+
const eventData = data.eventData;
|
|
635
|
+
const errorMessage = typeof eventData.error === 'string'
|
|
636
|
+
? eventData.error
|
|
637
|
+
: (eventData.error?.message ?? 'Unknown error');
|
|
638
|
+
const [stepValue] = await drizzle
|
|
639
|
+
.update(Schema.steps)
|
|
640
|
+
.set({
|
|
641
|
+
status: 'pending',
|
|
642
|
+
error: {
|
|
643
|
+
message: errorMessage,
|
|
644
|
+
stack: eventData.stack,
|
|
645
|
+
},
|
|
646
|
+
retryAfter: eventData.retryAfter,
|
|
647
|
+
})
|
|
648
|
+
.where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId)))
|
|
649
|
+
.returning();
|
|
650
|
+
if (stepValue) {
|
|
651
|
+
step = deserializeStepError(compact(stepValue));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// Handle hook_created event: create hook entity
|
|
655
|
+
// Uses prepared statement for token uniqueness check (performance optimization)
|
|
656
|
+
if (data.eventType === 'hook_created') {
|
|
657
|
+
const eventData = data.eventData;
|
|
658
|
+
// Check for duplicate token using prepared statement
|
|
659
|
+
const [existingHook] = await getHookByToken.execute({
|
|
660
|
+
token: eventData.token,
|
|
661
|
+
});
|
|
662
|
+
if (existingHook) {
|
|
663
|
+
// Create hook_conflict event instead of throwing 409
|
|
664
|
+
// This allows the workflow to continue and fail gracefully when the hook is awaited
|
|
665
|
+
const conflictEventData = {
|
|
666
|
+
token: eventData.token,
|
|
667
|
+
};
|
|
668
|
+
const [conflictValue] = await drizzle
|
|
669
|
+
.insert(events)
|
|
670
|
+
.values({
|
|
671
|
+
runId: effectiveRunId,
|
|
672
|
+
eventId,
|
|
673
|
+
correlationId: data.correlationId,
|
|
674
|
+
eventType: 'hook_conflict',
|
|
675
|
+
eventData: conflictEventData,
|
|
676
|
+
specVersion: effectiveSpecVersion,
|
|
677
|
+
})
|
|
678
|
+
.returning({ createdAt: events.createdAt });
|
|
679
|
+
if (!conflictValue) {
|
|
680
|
+
throw new WorkflowAPIError(`Event ${eventId} could not be created`, { status: 409 });
|
|
681
|
+
}
|
|
682
|
+
const conflictResult = {
|
|
683
|
+
eventType: 'hook_conflict',
|
|
684
|
+
correlationId: data.correlationId,
|
|
685
|
+
eventData: conflictEventData,
|
|
686
|
+
...conflictValue,
|
|
687
|
+
runId: effectiveRunId,
|
|
688
|
+
eventId,
|
|
689
|
+
};
|
|
690
|
+
const parsedConflict = EventSchema.parse(conflictResult);
|
|
691
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
692
|
+
return {
|
|
693
|
+
event: filterEventData(parsedConflict, resolveData),
|
|
694
|
+
run,
|
|
695
|
+
step,
|
|
696
|
+
hook: undefined,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const [hookValue] = await drizzle
|
|
700
|
+
.insert(Schema.hooks)
|
|
701
|
+
.values({
|
|
702
|
+
runId: effectiveRunId,
|
|
703
|
+
hookId: data.correlationId,
|
|
704
|
+
token: eventData.token,
|
|
705
|
+
metadata: eventData.metadata,
|
|
706
|
+
ownerId: '', // TODO: get from context
|
|
707
|
+
projectId: '', // TODO: get from context
|
|
708
|
+
environment: '', // TODO: get from context
|
|
709
|
+
// Propagate specVersion from the event to the hook entity
|
|
710
|
+
specVersion: effectiveSpecVersion,
|
|
711
|
+
isWebhook: eventData.isWebhook,
|
|
712
|
+
})
|
|
713
|
+
.onConflictDoNothing()
|
|
714
|
+
.returning();
|
|
715
|
+
if (hookValue) {
|
|
716
|
+
hookValue.metadata ||= hookValue.metadataJson;
|
|
717
|
+
hook = HookSchema.parse(compact(hookValue));
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// Handle hook_disposed event: delete hook entity
|
|
721
|
+
if (data.eventType === 'hook_disposed' && data.correlationId) {
|
|
722
|
+
await drizzle
|
|
723
|
+
.delete(Schema.hooks)
|
|
724
|
+
.where(eq(Schema.hooks.hookId, data.correlationId));
|
|
725
|
+
}
|
|
726
|
+
// Handle wait_created event: create wait entity
|
|
727
|
+
if (data.eventType === 'wait_created') {
|
|
728
|
+
const eventData = data.eventData;
|
|
729
|
+
const waitId = `${effectiveRunId}-${data.correlationId}`;
|
|
730
|
+
const [waitValue] = await drizzle
|
|
731
|
+
.insert(Schema.waits)
|
|
732
|
+
.values({
|
|
733
|
+
waitId,
|
|
734
|
+
runId: effectiveRunId,
|
|
735
|
+
status: 'waiting',
|
|
736
|
+
resumeAt: eventData.resumeAt,
|
|
737
|
+
specVersion: effectiveSpecVersion,
|
|
738
|
+
})
|
|
739
|
+
.onConflictDoNothing()
|
|
740
|
+
.returning();
|
|
741
|
+
if (waitValue) {
|
|
742
|
+
wait = {
|
|
743
|
+
waitId: waitValue.waitId,
|
|
744
|
+
runId: waitValue.runId,
|
|
745
|
+
status: waitValue.status,
|
|
746
|
+
resumeAt: waitValue.resumeAt ?? undefined,
|
|
747
|
+
completedAt: waitValue.completedAt ?? undefined,
|
|
748
|
+
createdAt: waitValue.createdAt,
|
|
749
|
+
updatedAt: waitValue.updatedAt,
|
|
750
|
+
specVersion: waitValue.specVersion ?? undefined,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
throw new WorkflowAPIError(`Wait "${data.correlationId}" already exists`, { status: 409 });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// Handle wait_completed event: transition wait to 'completed'
|
|
758
|
+
// Uses conditional UPDATE to reject duplicate completions (same pattern as step_completed)
|
|
759
|
+
if (data.eventType === 'wait_completed') {
|
|
760
|
+
const waitId = `${effectiveRunId}-${data.correlationId}`;
|
|
761
|
+
const [waitValue] = await drizzle
|
|
762
|
+
.update(Schema.waits)
|
|
763
|
+
.set({
|
|
764
|
+
status: 'completed',
|
|
765
|
+
completedAt: now,
|
|
766
|
+
})
|
|
767
|
+
.where(and(eq(Schema.waits.waitId, waitId), eq(Schema.waits.status, 'waiting')))
|
|
768
|
+
.returning();
|
|
769
|
+
if (waitValue) {
|
|
770
|
+
wait = {
|
|
771
|
+
waitId: waitValue.waitId,
|
|
772
|
+
runId: waitValue.runId,
|
|
773
|
+
status: waitValue.status,
|
|
774
|
+
resumeAt: waitValue.resumeAt ?? undefined,
|
|
775
|
+
completedAt: waitValue.completedAt ?? undefined,
|
|
776
|
+
createdAt: waitValue.createdAt,
|
|
777
|
+
updatedAt: waitValue.updatedAt,
|
|
778
|
+
specVersion: waitValue.specVersion ?? undefined,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
// Wait not updated - check if it exists and why
|
|
783
|
+
const [existing] = await getWaitForValidation.execute({
|
|
784
|
+
waitId,
|
|
785
|
+
});
|
|
786
|
+
if (!existing) {
|
|
787
|
+
throw new WorkflowAPIError(`Wait "${data.correlationId}" not found`, { status: 404 });
|
|
788
|
+
}
|
|
789
|
+
if (existing.status === 'completed') {
|
|
790
|
+
throw new WorkflowAPIError(`Wait "${data.correlationId}" already completed`, { status: 409 });
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
142
794
|
const [value] = await drizzle
|
|
143
795
|
.insert(events)
|
|
144
796
|
.values({
|
|
145
|
-
runId,
|
|
797
|
+
runId: effectiveRunId,
|
|
146
798
|
eventId,
|
|
147
799
|
correlationId: data.correlationId,
|
|
148
800
|
eventType: data.eventType,
|
|
149
801
|
eventData: 'eventData' in data ? data.eventData : undefined,
|
|
802
|
+
specVersion: effectiveSpecVersion,
|
|
150
803
|
})
|
|
151
804
|
.returning({ createdAt: events.createdAt });
|
|
152
805
|
if (!value) {
|
|
@@ -154,7 +807,16 @@ export function createEventsStorage(drizzle) {
|
|
|
154
807
|
status: 409,
|
|
155
808
|
});
|
|
156
809
|
}
|
|
157
|
-
|
|
810
|
+
const result = { ...data, ...value, runId: effectiveRunId, eventId };
|
|
811
|
+
const parsed = EventSchema.parse(result);
|
|
812
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
813
|
+
return {
|
|
814
|
+
event: filterEventData(parsed, resolveData),
|
|
815
|
+
run,
|
|
816
|
+
step,
|
|
817
|
+
hook,
|
|
818
|
+
wait,
|
|
819
|
+
};
|
|
158
820
|
},
|
|
159
821
|
async list(params) {
|
|
160
822
|
const limit = params?.pagination?.limit ?? 100;
|
|
@@ -169,8 +831,13 @@ export function createEventsStorage(drizzle) {
|
|
|
169
831
|
.orderBy(order.by)
|
|
170
832
|
.limit(limit + 1);
|
|
171
833
|
const values = all.slice(0, limit);
|
|
834
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
172
835
|
return {
|
|
173
|
-
data: values.map(
|
|
836
|
+
data: values.map((v) => {
|
|
837
|
+
v.eventData ||= v.eventDataJson;
|
|
838
|
+
const parsed = EventSchema.parse(compact(v));
|
|
839
|
+
return filterEventData(parsed, resolveData);
|
|
840
|
+
}),
|
|
174
841
|
cursor: values.at(-1)?.eventId ?? null,
|
|
175
842
|
hasMore: all.length > limit,
|
|
176
843
|
};
|
|
@@ -188,8 +855,13 @@ export function createEventsStorage(drizzle) {
|
|
|
188
855
|
.orderBy(order.by)
|
|
189
856
|
.limit(limit + 1);
|
|
190
857
|
const values = all.slice(0, limit);
|
|
858
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
191
859
|
return {
|
|
192
|
-
data: values.map(
|
|
860
|
+
data: values.map((v) => {
|
|
861
|
+
v.eventData ||= v.eventDataJson;
|
|
862
|
+
const parsed = EventSchema.parse(compact(v));
|
|
863
|
+
return filterEventData(parsed, resolveData);
|
|
864
|
+
}),
|
|
193
865
|
cursor: values.at(-1)?.eventId ?? null,
|
|
194
866
|
hasMore: all.length > limit,
|
|
195
867
|
};
|
|
@@ -205,149 +877,83 @@ export function createHooksStorage(drizzle) {
|
|
|
205
877
|
.limit(1)
|
|
206
878
|
.prepare('workflow_hooks_get_by_token');
|
|
207
879
|
return {
|
|
208
|
-
async get(hookId) {
|
|
880
|
+
async get(hookId, params) {
|
|
209
881
|
const [value] = await drizzle
|
|
210
882
|
.select()
|
|
211
883
|
.from(hooks)
|
|
212
884
|
.where(eq(hooks.hookId, hookId))
|
|
213
885
|
.limit(1);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
.values({
|
|
220
|
-
runId,
|
|
221
|
-
hookId: data.hookId,
|
|
222
|
-
token: data.token,
|
|
223
|
-
ownerId: '', // TODO: get from context
|
|
224
|
-
projectId: '', // TODO: get from context
|
|
225
|
-
environment: '', // TODO: get from context
|
|
226
|
-
})
|
|
227
|
-
.onConflictDoNothing()
|
|
228
|
-
.returning();
|
|
229
|
-
if (!value) {
|
|
230
|
-
throw new WorkflowAPIError(`Hook ${data.hookId} already exists`, {
|
|
231
|
-
status: 409,
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
return compact(value);
|
|
886
|
+
value.metadata ||= value.metadataJson;
|
|
887
|
+
const parsed = HookSchema.parse(compact(value));
|
|
888
|
+
parsed.isWebhook ??= true;
|
|
889
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
890
|
+
return filterHookData(parsed, resolveData);
|
|
235
891
|
},
|
|
236
|
-
async getByToken(token) {
|
|
892
|
+
async getByToken(token, params) {
|
|
237
893
|
const [value] = await getByToken.execute({ token });
|
|
238
894
|
if (!value) {
|
|
239
|
-
throw new
|
|
240
|
-
status: 404,
|
|
241
|
-
});
|
|
895
|
+
throw new HookNotFoundError(token);
|
|
242
896
|
}
|
|
243
|
-
|
|
897
|
+
value.metadata ||= value.metadataJson;
|
|
898
|
+
const parsed = HookSchema.parse(compact(value));
|
|
899
|
+
parsed.isWebhook ??= true;
|
|
900
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
901
|
+
return filterHookData(parsed, resolveData);
|
|
244
902
|
},
|
|
245
903
|
async list(params) {
|
|
246
904
|
const limit = params?.pagination?.limit ?? 100;
|
|
247
905
|
const fromCursor = params?.pagination?.cursor;
|
|
906
|
+
const sortOrder = params?.pagination?.sortOrder ?? 'asc';
|
|
907
|
+
const orderFn = sortOrder === 'asc' ? asc : desc;
|
|
908
|
+
const cursorFn = sortOrder === 'asc' ? gt : lt;
|
|
248
909
|
const all = await drizzle
|
|
249
910
|
.select()
|
|
250
911
|
.from(hooks)
|
|
251
|
-
.where(and(map(params.runId, (id) => eq(hooks.runId, id)), map(fromCursor, (c) =>
|
|
252
|
-
.orderBy(
|
|
912
|
+
.where(and(map(params.runId, (id) => eq(hooks.runId, id)), map(fromCursor, (c) => cursorFn(hooks.hookId, c))))
|
|
913
|
+
.orderBy(orderFn(hooks.hookId))
|
|
253
914
|
.limit(limit + 1);
|
|
254
915
|
const values = all.slice(0, limit);
|
|
255
916
|
const hasMore = all.length > limit;
|
|
917
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
256
918
|
return {
|
|
257
|
-
data: values.map(
|
|
919
|
+
data: values.map((v) => {
|
|
920
|
+
v.metadata ||= v.metadataJson;
|
|
921
|
+
const parsed = HookSchema.parse(compact(v));
|
|
922
|
+
return filterHookData(parsed, resolveData);
|
|
923
|
+
}),
|
|
258
924
|
cursor: values.at(-1)?.hookId ?? null,
|
|
259
925
|
hasMore,
|
|
260
926
|
};
|
|
261
927
|
},
|
|
262
|
-
async dispose(hookId) {
|
|
263
|
-
const [value] = await drizzle
|
|
264
|
-
.delete(hooks)
|
|
265
|
-
.where(eq(hooks.hookId, hookId))
|
|
266
|
-
.returning();
|
|
267
|
-
if (!value) {
|
|
268
|
-
throw new WorkflowAPIError(`Hook not found: ${hookId}`, {
|
|
269
|
-
status: 404,
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
return compact(value);
|
|
273
|
-
},
|
|
274
928
|
};
|
|
275
929
|
}
|
|
276
930
|
export function createStepsStorage(drizzle) {
|
|
277
931
|
const { steps } = Schema;
|
|
278
|
-
const get = drizzle
|
|
279
|
-
.select()
|
|
280
|
-
.from(steps)
|
|
281
|
-
.where(and(eq(steps.stepId, sql.placeholder('stepId')), eq(steps.runId, sql.placeholder('runId'))))
|
|
282
|
-
.limit(1)
|
|
283
|
-
.prepare('workflow_steps_get');
|
|
284
932
|
return {
|
|
285
|
-
async
|
|
933
|
+
get: (async (runId, stepId, params) => {
|
|
934
|
+
// If runId is not provided, query only by stepId
|
|
935
|
+
const whereClause = runId
|
|
936
|
+
? and(eq(steps.stepId, stepId), eq(steps.runId, runId))
|
|
937
|
+
: eq(steps.stepId, stepId);
|
|
286
938
|
const [value] = await drizzle
|
|
287
|
-
.insert(steps)
|
|
288
|
-
.values({
|
|
289
|
-
runId,
|
|
290
|
-
stepId: data.stepId,
|
|
291
|
-
stepName: data.stepName,
|
|
292
|
-
input: data.input,
|
|
293
|
-
status: 'pending',
|
|
294
|
-
attempt: 1,
|
|
295
|
-
})
|
|
296
|
-
.onConflictDoNothing()
|
|
297
|
-
.returning();
|
|
298
|
-
if (!value) {
|
|
299
|
-
throw new WorkflowAPIError(`Step ${data.stepId} already exists`, {
|
|
300
|
-
status: 409,
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
return compact(value);
|
|
304
|
-
},
|
|
305
|
-
async get(runId, stepId) {
|
|
306
|
-
const [value] = await get.execute({ stepId, runId });
|
|
307
|
-
if (!value) {
|
|
308
|
-
throw new WorkflowAPIError(`Step not found: ${stepId}`, {
|
|
309
|
-
status: 404,
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
return compact(value);
|
|
313
|
-
},
|
|
314
|
-
async update(runId, stepId, data) {
|
|
315
|
-
// Fetch current step to check if startedAt is already set
|
|
316
|
-
const [currentStep] = await drizzle
|
|
317
939
|
.select()
|
|
318
940
|
.from(steps)
|
|
319
|
-
.where(
|
|
941
|
+
.where(whereClause)
|
|
320
942
|
.limit(1);
|
|
321
|
-
if (!currentStep) {
|
|
322
|
-
throw new WorkflowAPIError(`Step not found: ${stepId}`, {
|
|
323
|
-
status: 404,
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
const updates = {
|
|
327
|
-
...data,
|
|
328
|
-
output: data.output,
|
|
329
|
-
};
|
|
330
|
-
const now = new Date();
|
|
331
|
-
// Only set startedAt the first time the step transitions to 'running'
|
|
332
|
-
if (data.status === 'running' && !currentStep.startedAt) {
|
|
333
|
-
updates.startedAt = now;
|
|
334
|
-
}
|
|
335
|
-
if (data.status === 'completed' || data.status === 'failed') {
|
|
336
|
-
updates.completedAt = now;
|
|
337
|
-
}
|
|
338
|
-
const [value] = await drizzle
|
|
339
|
-
.update(steps)
|
|
340
|
-
.set(updates)
|
|
341
|
-
.where(and(eq(steps.stepId, stepId), eq(steps.runId, runId)))
|
|
342
|
-
.returning();
|
|
343
943
|
if (!value) {
|
|
344
944
|
throw new WorkflowAPIError(`Step not found: ${stepId}`, {
|
|
345
945
|
status: 404,
|
|
346
946
|
});
|
|
347
947
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
948
|
+
value.output ||= value.outputJson;
|
|
949
|
+
value.input ||= value.inputJson;
|
|
950
|
+
value.error ||= parseErrorJson(value.errorJson);
|
|
951
|
+
const deserialized = deserializeStepError(compact(value));
|
|
952
|
+
const parsed = StepSchema.parse(deserialized);
|
|
953
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
954
|
+
return filterStepData(parsed, resolveData);
|
|
955
|
+
}),
|
|
956
|
+
list: (async (params) => {
|
|
351
957
|
const limit = params?.pagination?.limit ?? 20;
|
|
352
958
|
const fromCursor = params?.pagination?.cursor;
|
|
353
959
|
const all = await drizzle
|
|
@@ -358,12 +964,48 @@ export function createStepsStorage(drizzle) {
|
|
|
358
964
|
.limit(limit + 1);
|
|
359
965
|
const values = all.slice(0, limit);
|
|
360
966
|
const hasMore = all.length > limit;
|
|
967
|
+
const resolveData = params?.resolveData ?? 'all';
|
|
361
968
|
return {
|
|
362
|
-
data: values.map(
|
|
969
|
+
data: values.map((v) => {
|
|
970
|
+
v.output ||= v.outputJson;
|
|
971
|
+
v.input ||= v.inputJson;
|
|
972
|
+
v.error ||= parseErrorJson(v.errorJson);
|
|
973
|
+
const deserialized = deserializeStepError(compact(v));
|
|
974
|
+
const parsed = StepSchema.parse(deserialized);
|
|
975
|
+
return filterStepData(parsed, resolveData);
|
|
976
|
+
}),
|
|
363
977
|
hasMore,
|
|
364
978
|
cursor: values.at(-1)?.stepId ?? null,
|
|
365
979
|
};
|
|
366
|
-
},
|
|
980
|
+
}),
|
|
367
981
|
};
|
|
368
982
|
}
|
|
983
|
+
function filterStepData(step, resolveData) {
|
|
984
|
+
if (resolveData === 'none') {
|
|
985
|
+
const { input: _, output: __, ...rest } = step;
|
|
986
|
+
return { input: undefined, output: undefined, ...rest };
|
|
987
|
+
}
|
|
988
|
+
return step;
|
|
989
|
+
}
|
|
990
|
+
function filterRunData(run, resolveData) {
|
|
991
|
+
if (resolveData === 'none') {
|
|
992
|
+
const { input: _, output: __, ...rest } = run;
|
|
993
|
+
return { input: undefined, output: undefined, ...rest };
|
|
994
|
+
}
|
|
995
|
+
return run;
|
|
996
|
+
}
|
|
997
|
+
function filterHookData(hook, resolveData) {
|
|
998
|
+
if (resolveData === 'none' && 'metadata' in hook) {
|
|
999
|
+
const { metadata: _, ...rest } = hook;
|
|
1000
|
+
return { metadata: undefined, ...rest };
|
|
1001
|
+
}
|
|
1002
|
+
return hook;
|
|
1003
|
+
}
|
|
1004
|
+
function filterEventData(event, resolveData) {
|
|
1005
|
+
if (resolveData === 'none' && 'eventData' in event) {
|
|
1006
|
+
const { eventData: _, ...rest } = event;
|
|
1007
|
+
return rest;
|
|
1008
|
+
}
|
|
1009
|
+
return event;
|
|
1010
|
+
}
|
|
369
1011
|
//# sourceMappingURL=storage.js.map
|