@workflow/world-postgres 4.1.0-beta.28 → 4.1.0-beta.30

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/dist/storage.js CHANGED
@@ -1,121 +1,67 @@
1
- import { WorkflowAPIError } from '@workflow/errors';
2
- import { EventSchema, HookSchema, StepSchema, WorkflowRunSchema, } from '@workflow/world';
3
- import { and, desc, eq, gt, lt, sql } from 'drizzle-orm';
1
+ import { RunNotSupportedError, WorkflowAPIError } from '@workflow/errors';
2
+ import { EventSchema, HookSchema, isLegacySpecVersion, requiresNewerWorld, SPEC_VERSION_CURRENT, StepSchema, WorkflowRunSchema, } from '@workflow/world';
3
+ import { and, desc, eq, gt, lt, notInArray, sql } from 'drizzle-orm';
4
4
  import { monotonicFactory } from 'ulid';
5
5
  import { Schema } from './drizzle/index.js';
6
6
  import { compact } from './util.js';
7
7
  /**
8
- * Serialize a StructuredError object into a JSON string
8
+ * Parse legacy errorJson (text column with JSON-stringified StructuredError).
9
+ * Used for backwards compatibility when reading from deprecated error column.
9
10
  */
10
- function serializeRunError(data) {
11
- if (!data.error) {
12
- return data;
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 };
13
29
  }
14
- const { error, ...rest } = data;
15
- return {
16
- ...rest,
17
- error: JSON.stringify({
18
- message: error.message,
19
- stack: error.stack,
20
- code: error.code,
21
- }),
22
- };
23
30
  }
24
31
  /**
25
- * Deserialize error JSON string (or legacy flat fields) into a StructuredError object
26
- * Handles backwards compatibility:
27
- * - If error is a JSON string with {message, stack, code} → parse into StructuredError
28
- * - If error is a plain string → treat as error message
29
- * - If errorStack/errorCode exist (legacy) → combine into StructuredError
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).
30
35
  */
31
36
  function deserializeRunError(run) {
32
- const { error, errorStack, errorCode, ...rest } = run;
33
- if (!error && !errorStack && !errorCode) {
34
- return run;
35
- }
36
- // Try to parse as structured error JSON
37
- if (error) {
38
- try {
39
- const parsed = JSON.parse(error);
40
- if (typeof parsed === 'object' && parsed.message !== undefined) {
41
- return {
42
- ...rest,
43
- error: {
44
- message: parsed.message,
45
- stack: parsed.stack,
46
- code: parsed.code,
47
- },
48
- };
49
- }
50
- }
51
- catch {
52
- // Not JSON, treat as plain string
53
- }
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;
54
41
  }
55
- // Backwards compatibility: handle legacy separate fields or plain string error
42
+ // Very old legacy: separate errorStack/errorCode fields
43
+ const existingError = rest.error;
56
44
  return {
57
45
  ...rest,
58
46
  error: {
59
- message: error || '',
60
- stack: errorStack,
61
- code: errorCode,
47
+ message: existingError?.message || '',
48
+ stack: existingError?.stack || errorStack,
49
+ code: existingError?.code || errorCode,
62
50
  },
63
51
  };
64
52
  }
65
53
  /**
66
- * Serialize a StructuredError object into a JSON string for steps
67
- */
68
- function serializeStepError(data) {
69
- if (!data.error) {
70
- return data;
71
- }
72
- const { error, ...rest } = data;
73
- return {
74
- ...rest,
75
- error: JSON.stringify({
76
- message: error.message,
77
- stack: error.stack,
78
- code: error.code,
79
- }),
80
- };
81
- }
82
- /**
83
- * Deserialize error JSON string (or legacy flat fields) into a StructuredError object for steps
54
+ * Deserialize step data, mapping DB columns to interface fields.
55
+ * The error field should already be deserialized from CBOR or fallback to errorJson.
84
56
  */
85
57
  function deserializeStepError(step) {
86
- const { error, ...rest } = step;
87
- if (!error) {
88
- return step;
89
- }
90
- // Try to parse as structured error JSON
91
- if (error) {
92
- try {
93
- const parsed = JSON.parse(error);
94
- if (typeof parsed === 'object' && parsed.message !== undefined) {
95
- return {
96
- ...rest,
97
- error: {
98
- message: parsed.message,
99
- stack: parsed.stack,
100
- code: parsed.code,
101
- },
102
- };
103
- }
104
- }
105
- catch {
106
- // Not JSON, treat as plain string
107
- }
108
- }
109
- // Backwards compatibility: handle legacy separate fields or plain string error
58
+ const { startedAt, ...rest } = step;
110
59
  return {
111
60
  ...rest,
112
- error: {
113
- message: error || '',
114
- },
61
+ startedAt,
115
62
  };
116
63
  }
117
64
  export function createRunsStorage(drizzle) {
118
- const ulid = monotonicFactory();
119
65
  const { runs } = Schema;
120
66
  const get = drizzle
121
67
  .select()
@@ -124,7 +70,7 @@ export function createRunsStorage(drizzle) {
124
70
  .limit(1)
125
71
  .prepare('workflow_runs_get');
126
72
  return {
127
- async get(id, params) {
73
+ get: (async (id, params) => {
128
74
  const [value] = await get.execute({ id });
129
75
  if (!value) {
130
76
  throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
@@ -132,29 +78,13 @@ export function createRunsStorage(drizzle) {
132
78
  value.output ||= value.outputJson;
133
79
  value.input ||= value.inputJson;
134
80
  value.executionContext ||= value.executionContextJson;
81
+ value.error ||= parseErrorJson(value.errorJson);
135
82
  const deserialized = deserializeRunError(compact(value));
136
83
  const parsed = WorkflowRunSchema.parse(deserialized);
137
84
  const resolveData = params?.resolveData ?? 'all';
138
85
  return filterRunData(parsed, resolveData);
139
- },
140
- async cancel(id, params) {
141
- // TODO: we might want to guard this for only specific statuses
142
- const [value] = await drizzle
143
- .update(Schema.runs)
144
- .set({ status: 'cancelled', completedAt: sql `now()` })
145
- .where(eq(runs.runId, id))
146
- .returning();
147
- if (!value) {
148
- throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
149
- }
150
- // Clean up all hooks for this run when cancelling
151
- await drizzle.delete(Schema.hooks).where(eq(Schema.hooks.runId, id));
152
- const deserialized = deserializeRunError(compact(value));
153
- const parsed = WorkflowRunSchema.parse(deserialized);
154
- const resolveData = params?.resolveData ?? 'all';
155
- return filterRunData(parsed, resolveData);
156
- },
157
- async list(params) {
86
+ }),
87
+ list: (async (params) => {
158
88
  const limit = params?.pagination?.limit ?? 20;
159
89
  const fromCursor = params?.pagination?.cursor;
160
90
  const all = await drizzle
@@ -168,6 +98,10 @@ export function createRunsStorage(drizzle) {
168
98
  const resolveData = params?.resolveData ?? 'all';
169
99
  return {
170
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);
171
105
  const deserialized = deserializeRunError(compact(v));
172
106
  const parsed = WorkflowRunSchema.parse(deserialized);
173
107
  return filterRunData(parsed, resolveData);
@@ -175,87 +109,578 @@ export function createRunsStorage(drizzle) {
175
109
  hasMore,
176
110
  cursor: values.at(-1)?.runId ?? null,
177
111
  };
178
- },
179
- async create(data) {
180
- const runId = `wrun_${ulid()}`;
181
- const [value] = await drizzle
182
- .insert(runs)
183
- .values({
184
- runId,
185
- input: data.input,
186
- executionContext: data.executionContext,
187
- deploymentId: data.deploymentId,
188
- status: 'pending',
189
- workflowName: data.workflowName,
190
- })
191
- .onConflictDoNothing()
192
- .returning();
193
- if (!value) {
194
- throw new WorkflowAPIError(`Run ${runId} already exists`, {
195
- status: 409,
196
- });
197
- }
198
- return deserializeRunError(compact(value));
199
- },
200
- async update(id, data) {
201
- // Fetch current run to check if startedAt is already set
202
- const [currentRun] = await drizzle
203
- .select()
204
- .from(runs)
205
- .where(eq(runs.runId, id))
206
- .limit(1);
207
- if (!currentRun) {
208
- throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
209
- }
210
- // Serialize the error field if present
211
- const serialized = serializeRunError(data);
212
- const updates = {
213
- ...serialized,
214
- output: data.output,
215
- };
216
- // Only set startedAt the first time transitioning to 'running'
217
- if (data.status === 'running' && !currentRun.startedAt) {
218
- updates.startedAt = new Date();
219
- }
220
- const isBecomingTerminal = data.status === 'completed' ||
221
- data.status === 'failed' ||
222
- data.status === 'cancelled';
223
- if (isBecomingTerminal) {
224
- updates.completedAt = new Date();
225
- }
226
- const [value] = await drizzle
227
- .update(runs)
228
- .set(updates)
229
- .where(eq(runs.runId, id))
230
- .returning();
231
- if (!value) {
232
- throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
233
- }
234
- // If transitioning to a terminal status, clean up all hooks for this run
235
- if (isBecomingTerminal) {
236
- await drizzle.delete(Schema.hooks).where(eq(Schema.hooks.runId, id));
237
- }
238
- return deserializeRunError(compact(value));
239
- },
112
+ }),
240
113
  };
241
114
  }
242
115
  function map(obj, fn) {
243
116
  return obj ? fn(obj) : undefined;
244
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 for this run
142
+ await drizzle.delete(Schema.hooks).where(eq(Schema.hooks.runId, runId));
143
+ // Fetch updated run for return value
144
+ const [updatedRun] = await drizzle
145
+ .select()
146
+ .from(Schema.runs)
147
+ .where(eq(Schema.runs.runId, runId))
148
+ .limit(1);
149
+ // Return without event (legacy behavior skips event storage)
150
+ // Type assertion: EventResult expects WorkflowRun, filterRunData may return WorkflowRunWithoutData
151
+ return {
152
+ run: updatedRun
153
+ ? filterRunData(deserializeRunError(compact(updatedRun)), resolveData)
154
+ : undefined,
155
+ };
156
+ }
157
+ case 'wait_completed':
158
+ case 'hook_received': {
159
+ // Legacy: Store event only (no entity mutation)
160
+ // - wait_completed: for replay purposes
161
+ // - hook_received: hooks exist via old system, just record the event
162
+ const [insertedEvent] = await drizzle
163
+ .insert(Schema.events)
164
+ .values({
165
+ runId,
166
+ eventId,
167
+ correlationId: data.correlationId,
168
+ eventType: data.eventType,
169
+ eventData: 'eventData' in data ? data.eventData : undefined,
170
+ specVersion: SPEC_VERSION_CURRENT,
171
+ })
172
+ .returning({ createdAt: Schema.events.createdAt });
173
+ const event = EventSchema.parse({
174
+ ...data,
175
+ ...insertedEvent,
176
+ runId,
177
+ eventId,
178
+ });
179
+ return { event: filterEventData(event, resolveData) };
180
+ }
181
+ default:
182
+ throw new Error(`Event type '${data.eventType}' not supported for legacy runs ` +
183
+ `(specVersion: ${currentRun.specVersion || 'undefined'}). ` +
184
+ `Please upgrade @workflow packages.`);
185
+ }
186
+ }
245
187
  export function createEventsStorage(drizzle) {
246
188
  const ulid = monotonicFactory();
247
189
  const { events } = Schema;
190
+ // Prepared statements for validation queries (performance optimization)
191
+ const getRunForValidation = drizzle
192
+ .select({
193
+ status: Schema.runs.status,
194
+ specVersion: Schema.runs.specVersion,
195
+ })
196
+ .from(Schema.runs)
197
+ .where(eq(Schema.runs.runId, sql.placeholder('runId')))
198
+ .limit(1)
199
+ .prepare('events_get_run_for_validation');
200
+ const getStepForValidation = drizzle
201
+ .select({
202
+ status: Schema.steps.status,
203
+ startedAt: Schema.steps.startedAt,
204
+ })
205
+ .from(Schema.steps)
206
+ .where(and(eq(Schema.steps.runId, sql.placeholder('runId')), eq(Schema.steps.stepId, sql.placeholder('stepId'))))
207
+ .limit(1)
208
+ .prepare('events_get_step_for_validation');
209
+ const getHookByToken = drizzle
210
+ .select({ hookId: Schema.hooks.hookId })
211
+ .from(Schema.hooks)
212
+ .where(eq(Schema.hooks.token, sql.placeholder('token')))
213
+ .limit(1)
214
+ .prepare('events_get_hook_by_token');
248
215
  return {
249
216
  async create(runId, data, params) {
250
217
  const eventId = `wevt_${ulid()}`;
218
+ // For run_created events, generate runId server-side if null or empty
219
+ let effectiveRunId;
220
+ if (data.eventType === 'run_created' && (!runId || runId === '')) {
221
+ effectiveRunId = `wrun_${ulid()}`;
222
+ }
223
+ else if (!runId) {
224
+ throw new Error('runId is required for non-run_created events');
225
+ }
226
+ else {
227
+ effectiveRunId = runId;
228
+ }
229
+ // specVersion is always sent by the runtime, but we provide a fallback for safety
230
+ const effectiveSpecVersion = data.specVersion ?? SPEC_VERSION_CURRENT;
231
+ // Track entity created/updated for EventResult
232
+ let run;
233
+ let step;
234
+ let hook;
235
+ const now = new Date();
236
+ // Helper to check if run is in terminal state
237
+ const isRunTerminal = (status) => ['completed', 'failed', 'cancelled'].includes(status);
238
+ // Helper to check if step is in terminal state
239
+ const isStepTerminal = (status) => ['completed', 'failed'].includes(status);
240
+ // ============================================================
241
+ // VALIDATION: Terminal state and event ordering checks
242
+ // ============================================================
243
+ // Get current run state for validation (if not creating a new run)
244
+ // Skip run validation for step_completed and step_retrying - they only operate
245
+ // on running steps, and running steps are always allowed to modify regardless
246
+ // of run state. This optimization saves database queries per step event.
247
+ let currentRun = null;
248
+ const skipRunValidationEvents = ['step_completed', 'step_retrying'];
249
+ if (data.eventType !== 'run_created' &&
250
+ !skipRunValidationEvents.includes(data.eventType)) {
251
+ // Use prepared statement for better performance
252
+ const [runValue] = await getRunForValidation.execute({
253
+ runId: effectiveRunId,
254
+ });
255
+ currentRun = runValue ?? null;
256
+ }
257
+ // ============================================================
258
+ // VERSION COMPATIBILITY: Check run spec version
259
+ // ============================================================
260
+ // For events that have fetched the run, check version compatibility.
261
+ // Skip for run_created (no existing run) and runtime events (step_completed, step_retrying).
262
+ if (currentRun) {
263
+ // Check if run requires a newer world version
264
+ if (requiresNewerWorld(currentRun.specVersion)) {
265
+ throw new RunNotSupportedError(currentRun.specVersion, SPEC_VERSION_CURRENT);
266
+ }
267
+ // Route to legacy handler for pre-event-sourcing runs
268
+ if (isLegacySpecVersion(currentRun.specVersion)) {
269
+ return handleLegacyEventPostgres(drizzle, effectiveRunId, eventId, data, currentRun, params);
270
+ }
271
+ }
272
+ // Run terminal state validation
273
+ if (currentRun && isRunTerminal(currentRun.status)) {
274
+ const runTerminalEvents = [
275
+ 'run_started',
276
+ 'run_completed',
277
+ 'run_failed',
278
+ ];
279
+ // Idempotent operation: run_cancelled on already cancelled run is allowed
280
+ if (data.eventType === 'run_cancelled' &&
281
+ currentRun.status === 'cancelled') {
282
+ // Get full run for return value
283
+ const [fullRun] = await drizzle
284
+ .select()
285
+ .from(Schema.runs)
286
+ .where(eq(Schema.runs.runId, effectiveRunId))
287
+ .limit(1);
288
+ // Create the event (still record it)
289
+ const [value] = await drizzle
290
+ .insert(Schema.events)
291
+ .values({
292
+ runId: effectiveRunId,
293
+ eventId,
294
+ correlationId: data.correlationId,
295
+ eventType: data.eventType,
296
+ eventData: 'eventData' in data ? data.eventData : undefined,
297
+ specVersion: effectiveSpecVersion,
298
+ })
299
+ .returning({ createdAt: Schema.events.createdAt });
300
+ const result = { ...data, ...value, runId: effectiveRunId, eventId };
301
+ const parsed = EventSchema.parse(result);
302
+ const resolveData = params?.resolveData ?? 'all';
303
+ return {
304
+ event: filterEventData(parsed, resolveData),
305
+ run: fullRun ? deserializeRunError(compact(fullRun)) : undefined,
306
+ };
307
+ }
308
+ // Run state transitions are not allowed on terminal runs
309
+ if (runTerminalEvents.includes(data.eventType) ||
310
+ data.eventType === 'run_cancelled') {
311
+ throw new WorkflowAPIError(`Cannot transition run from terminal state "${currentRun.status}"`, { status: 410 });
312
+ }
313
+ // Creating new entities on terminal runs is not allowed
314
+ if (data.eventType === 'step_created' ||
315
+ data.eventType === 'hook_created') {
316
+ throw new WorkflowAPIError(`Cannot create new entities on run in terminal state "${currentRun.status}"`, { status: 410 });
317
+ }
318
+ }
319
+ // Step-related event validation (ordering and terminal state)
320
+ // Fetch status + startedAt so we can reuse for step_started (avoid double read)
321
+ // Skip validation for step_completed/step_failed - use conditional UPDATE instead
322
+ let validatedStep = null;
323
+ const stepEventsNeedingValidation = ['step_started', 'step_retrying'];
324
+ if (stepEventsNeedingValidation.includes(data.eventType) &&
325
+ data.correlationId) {
326
+ // Use prepared statement for better performance
327
+ const [existingStep] = await getStepForValidation.execute({
328
+ runId: effectiveRunId,
329
+ stepId: data.correlationId,
330
+ });
331
+ validatedStep = existingStep ?? null;
332
+ // Event ordering: step must exist before these events
333
+ if (!validatedStep) {
334
+ throw new WorkflowAPIError(`Step "${data.correlationId}" not found`, {
335
+ status: 404,
336
+ });
337
+ }
338
+ // Step terminal state validation
339
+ if (isStepTerminal(validatedStep.status)) {
340
+ throw new WorkflowAPIError(`Cannot modify step in terminal state "${validatedStep.status}"`, { status: 410 });
341
+ }
342
+ // On terminal runs: only allow completing/failing in-progress steps
343
+ if (currentRun && isRunTerminal(currentRun.status)) {
344
+ if (validatedStep.status !== 'running') {
345
+ throw new WorkflowAPIError(`Cannot modify non-running step on run in terminal state "${currentRun.status}"`, { status: 410 });
346
+ }
347
+ }
348
+ }
349
+ // Hook-related event validation (ordering)
350
+ const hookEventsRequiringExistence = ['hook_disposed', 'hook_received'];
351
+ if (hookEventsRequiringExistence.includes(data.eventType) &&
352
+ data.correlationId) {
353
+ const [existingHook] = await drizzle
354
+ .select({ hookId: Schema.hooks.hookId })
355
+ .from(Schema.hooks)
356
+ .where(eq(Schema.hooks.hookId, data.correlationId))
357
+ .limit(1);
358
+ if (!existingHook) {
359
+ throw new WorkflowAPIError(`Hook "${data.correlationId}" not found`, {
360
+ status: 404,
361
+ });
362
+ }
363
+ }
364
+ // ============================================================
365
+ // Entity creation/updates based on event type
366
+ // ============================================================
367
+ // Handle run_created event: create the run entity atomically
368
+ if (data.eventType === 'run_created') {
369
+ const eventData = data.eventData;
370
+ const [runValue] = await drizzle
371
+ .insert(Schema.runs)
372
+ .values({
373
+ runId: effectiveRunId,
374
+ deploymentId: eventData.deploymentId,
375
+ workflowName: eventData.workflowName,
376
+ // Propagate specVersion from the event to the run entity
377
+ specVersion: effectiveSpecVersion,
378
+ input: eventData.input,
379
+ executionContext: eventData.executionContext,
380
+ status: 'pending',
381
+ })
382
+ .onConflictDoNothing()
383
+ .returning();
384
+ if (runValue) {
385
+ run = deserializeRunError(compact(runValue));
386
+ }
387
+ }
388
+ // Handle run_started event: update run status
389
+ if (data.eventType === 'run_started') {
390
+ const [runValue] = await drizzle
391
+ .update(Schema.runs)
392
+ .set({
393
+ status: 'running',
394
+ startedAt: now,
395
+ updatedAt: now,
396
+ })
397
+ .where(eq(Schema.runs.runId, effectiveRunId))
398
+ .returning();
399
+ if (runValue) {
400
+ run = deserializeRunError(compact(runValue));
401
+ }
402
+ }
403
+ // Handle run_completed event: update run status and cleanup hooks
404
+ if (data.eventType === 'run_completed') {
405
+ const eventData = data.eventData;
406
+ const [runValue] = await drizzle
407
+ .update(Schema.runs)
408
+ .set({
409
+ status: 'completed',
410
+ output: eventData.output,
411
+ completedAt: now,
412
+ updatedAt: now,
413
+ })
414
+ .where(eq(Schema.runs.runId, effectiveRunId))
415
+ .returning();
416
+ if (runValue) {
417
+ run = deserializeRunError(compact(runValue));
418
+ }
419
+ // Delete all hooks for this run to allow token reuse
420
+ await drizzle
421
+ .delete(Schema.hooks)
422
+ .where(eq(Schema.hooks.runId, effectiveRunId));
423
+ }
424
+ // Handle run_failed event: update run status and cleanup hooks
425
+ if (data.eventType === 'run_failed') {
426
+ const eventData = data.eventData;
427
+ const errorMessage = typeof eventData.error === 'string'
428
+ ? eventData.error
429
+ : (eventData.error?.message ?? 'Unknown error');
430
+ const [runValue] = await drizzle
431
+ .update(Schema.runs)
432
+ .set({
433
+ status: 'failed',
434
+ error: {
435
+ message: errorMessage,
436
+ stack: eventData.error?.stack,
437
+ code: eventData.errorCode,
438
+ },
439
+ completedAt: now,
440
+ updatedAt: now,
441
+ })
442
+ .where(eq(Schema.runs.runId, effectiveRunId))
443
+ .returning();
444
+ if (runValue) {
445
+ run = deserializeRunError(compact(runValue));
446
+ }
447
+ // Delete all hooks for this run to allow token reuse
448
+ await drizzle
449
+ .delete(Schema.hooks)
450
+ .where(eq(Schema.hooks.runId, effectiveRunId));
451
+ }
452
+ // Handle run_cancelled event: update run status and cleanup hooks
453
+ if (data.eventType === 'run_cancelled') {
454
+ const [runValue] = await drizzle
455
+ .update(Schema.runs)
456
+ .set({
457
+ status: 'cancelled',
458
+ completedAt: now,
459
+ updatedAt: now,
460
+ })
461
+ .where(eq(Schema.runs.runId, effectiveRunId))
462
+ .returning();
463
+ if (runValue) {
464
+ run = deserializeRunError(compact(runValue));
465
+ }
466
+ // Delete all hooks for this run to allow token reuse
467
+ await drizzle
468
+ .delete(Schema.hooks)
469
+ .where(eq(Schema.hooks.runId, effectiveRunId));
470
+ }
471
+ // Handle step_created event: create step entity
472
+ if (data.eventType === 'step_created') {
473
+ const eventData = data.eventData;
474
+ const [stepValue] = await drizzle
475
+ .insert(Schema.steps)
476
+ .values({
477
+ runId: effectiveRunId,
478
+ stepId: data.correlationId,
479
+ stepName: eventData.stepName,
480
+ input: eventData.input,
481
+ status: 'pending',
482
+ attempt: 0,
483
+ // Propagate specVersion from the event to the step entity
484
+ specVersion: effectiveSpecVersion,
485
+ })
486
+ .onConflictDoNothing()
487
+ .returning();
488
+ if (stepValue) {
489
+ step = deserializeStepError(compact(stepValue));
490
+ }
491
+ }
492
+ // Handle step_started event: increment attempt, set status to 'running'
493
+ // Sets startedAt (maps to startedAt) only on first start
494
+ // Reuse validatedStep from validation (already read above)
495
+ if (data.eventType === 'step_started') {
496
+ const isFirstStart = !validatedStep?.startedAt;
497
+ const [stepValue] = await drizzle
498
+ .update(Schema.steps)
499
+ .set({
500
+ status: 'running',
501
+ // Increment attempt counter using SQL
502
+ attempt: sql `${Schema.steps.attempt} + 1`,
503
+ // Only set startedAt on first start (not updated on retries)
504
+ ...(isFirstStart ? { startedAt: now } : {}),
505
+ })
506
+ .where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId)))
507
+ .returning();
508
+ if (stepValue) {
509
+ step = deserializeStepError(compact(stepValue));
510
+ }
511
+ }
512
+ // Handle step_completed event: update step status
513
+ // Uses conditional UPDATE to skip validation query (performance optimization)
514
+ if (data.eventType === 'step_completed') {
515
+ const eventData = data.eventData;
516
+ const [stepValue] = await drizzle
517
+ .update(Schema.steps)
518
+ .set({
519
+ status: 'completed',
520
+ output: eventData.result,
521
+ completedAt: now,
522
+ })
523
+ .where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId),
524
+ // Only update if not already in terminal state (validation in WHERE clause)
525
+ notInArray(Schema.steps.status, ['completed', 'failed'])))
526
+ .returning();
527
+ if (stepValue) {
528
+ step = deserializeStepError(compact(stepValue));
529
+ }
530
+ else {
531
+ // Step not updated - check if it exists and why
532
+ const [existing] = await getStepForValidation.execute({
533
+ runId: effectiveRunId,
534
+ stepId: data.correlationId,
535
+ });
536
+ if (!existing) {
537
+ throw new WorkflowAPIError(`Step "${data.correlationId}" not found`, { status: 404 });
538
+ }
539
+ if (['completed', 'failed'].includes(existing.status)) {
540
+ throw new WorkflowAPIError(`Cannot modify step in terminal state "${existing.status}"`, { status: 410 });
541
+ }
542
+ }
543
+ }
544
+ // Handle step_failed event: terminal state with error
545
+ // Uses conditional UPDATE to skip validation query (performance optimization)
546
+ if (data.eventType === 'step_failed') {
547
+ const eventData = data.eventData;
548
+ const errorMessage = typeof eventData.error === 'string'
549
+ ? eventData.error
550
+ : (eventData.error?.message ?? 'Unknown error');
551
+ const [stepValue] = await drizzle
552
+ .update(Schema.steps)
553
+ .set({
554
+ status: 'failed',
555
+ error: {
556
+ message: errorMessage,
557
+ stack: eventData.stack,
558
+ },
559
+ completedAt: now,
560
+ })
561
+ .where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId),
562
+ // Only update if not already in terminal state (validation in WHERE clause)
563
+ notInArray(Schema.steps.status, ['completed', 'failed'])))
564
+ .returning();
565
+ if (stepValue) {
566
+ step = deserializeStepError(compact(stepValue));
567
+ }
568
+ else {
569
+ // Step not updated - check if it exists and why
570
+ const [existing] = await getStepForValidation.execute({
571
+ runId: effectiveRunId,
572
+ stepId: data.correlationId,
573
+ });
574
+ if (!existing) {
575
+ throw new WorkflowAPIError(`Step "${data.correlationId}" not found`, { status: 404 });
576
+ }
577
+ if (['completed', 'failed'].includes(existing.status)) {
578
+ throw new WorkflowAPIError(`Cannot modify step in terminal state "${existing.status}"`, { status: 410 });
579
+ }
580
+ }
581
+ }
582
+ // Handle step_retrying event: sets status back to 'pending', records error
583
+ if (data.eventType === 'step_retrying') {
584
+ const eventData = data.eventData;
585
+ const errorMessage = typeof eventData.error === 'string'
586
+ ? eventData.error
587
+ : (eventData.error?.message ?? 'Unknown error');
588
+ const [stepValue] = await drizzle
589
+ .update(Schema.steps)
590
+ .set({
591
+ status: 'pending',
592
+ error: {
593
+ message: errorMessage,
594
+ stack: eventData.stack,
595
+ },
596
+ retryAfter: eventData.retryAfter,
597
+ })
598
+ .where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId)))
599
+ .returning();
600
+ if (stepValue) {
601
+ step = deserializeStepError(compact(stepValue));
602
+ }
603
+ }
604
+ // Handle hook_created event: create hook entity
605
+ // Uses prepared statement for token uniqueness check (performance optimization)
606
+ if (data.eventType === 'hook_created') {
607
+ const eventData = data.eventData;
608
+ // Check for duplicate token using prepared statement
609
+ const [existingHook] = await getHookByToken.execute({
610
+ token: eventData.token,
611
+ });
612
+ if (existingHook) {
613
+ // Create hook_conflict event instead of throwing 409
614
+ // This allows the workflow to continue and fail gracefully when the hook is awaited
615
+ const conflictEventData = {
616
+ token: eventData.token,
617
+ };
618
+ const [conflictValue] = await drizzle
619
+ .insert(events)
620
+ .values({
621
+ runId: effectiveRunId,
622
+ eventId,
623
+ correlationId: data.correlationId,
624
+ eventType: 'hook_conflict',
625
+ eventData: conflictEventData,
626
+ specVersion: effectiveSpecVersion,
627
+ })
628
+ .returning({ createdAt: events.createdAt });
629
+ if (!conflictValue) {
630
+ throw new WorkflowAPIError(`Event ${eventId} could not be created`, { status: 409 });
631
+ }
632
+ const conflictResult = {
633
+ eventType: 'hook_conflict',
634
+ correlationId: data.correlationId,
635
+ eventData: conflictEventData,
636
+ ...conflictValue,
637
+ runId: effectiveRunId,
638
+ eventId,
639
+ };
640
+ const parsedConflict = EventSchema.parse(conflictResult);
641
+ const resolveData = params?.resolveData ?? 'all';
642
+ return {
643
+ event: filterEventData(parsedConflict, resolveData),
644
+ run,
645
+ step,
646
+ hook: undefined,
647
+ };
648
+ }
649
+ const [hookValue] = await drizzle
650
+ .insert(Schema.hooks)
651
+ .values({
652
+ runId: effectiveRunId,
653
+ hookId: data.correlationId,
654
+ token: eventData.token,
655
+ metadata: eventData.metadata,
656
+ ownerId: '', // TODO: get from context
657
+ projectId: '', // TODO: get from context
658
+ environment: '', // TODO: get from context
659
+ // Propagate specVersion from the event to the hook entity
660
+ specVersion: effectiveSpecVersion,
661
+ })
662
+ .onConflictDoNothing()
663
+ .returning();
664
+ if (hookValue) {
665
+ hookValue.metadata ||= hookValue.metadataJson;
666
+ hook = HookSchema.parse(compact(hookValue));
667
+ }
668
+ }
669
+ // Handle hook_disposed event: delete hook entity
670
+ if (data.eventType === 'hook_disposed' && data.correlationId) {
671
+ await drizzle
672
+ .delete(Schema.hooks)
673
+ .where(eq(Schema.hooks.hookId, data.correlationId));
674
+ }
251
675
  const [value] = await drizzle
252
676
  .insert(events)
253
677
  .values({
254
- runId,
678
+ runId: effectiveRunId,
255
679
  eventId,
256
680
  correlationId: data.correlationId,
257
681
  eventType: data.eventType,
258
682
  eventData: 'eventData' in data ? data.eventData : undefined,
683
+ specVersion: effectiveSpecVersion,
259
684
  })
260
685
  .returning({ createdAt: events.createdAt });
261
686
  if (!value) {
@@ -263,10 +688,10 @@ export function createEventsStorage(drizzle) {
263
688
  status: 409,
264
689
  });
265
690
  }
266
- const result = { ...data, ...value, runId, eventId };
691
+ const result = { ...data, ...value, runId: effectiveRunId, eventId };
267
692
  const parsed = EventSchema.parse(result);
268
693
  const resolveData = params?.resolveData ?? 'all';
269
- return filterEventData(parsed, resolveData);
694
+ return { event: filterEventData(parsed, resolveData), run, step, hook };
270
695
  },
271
696
  async list(params) {
272
697
  const limit = params?.pagination?.limit ?? 100;
@@ -338,30 +763,6 @@ export function createHooksStorage(drizzle) {
338
763
  const resolveData = params?.resolveData ?? 'all';
339
764
  return filterHookData(parsed, resolveData);
340
765
  },
341
- async create(runId, data, params) {
342
- const [value] = await drizzle
343
- .insert(hooks)
344
- .values({
345
- runId,
346
- hookId: data.hookId,
347
- token: data.token,
348
- metadata: data.metadata,
349
- ownerId: '', // TODO: get from context
350
- projectId: '', // TODO: get from context
351
- environment: '', // TODO: get from context
352
- })
353
- .onConflictDoNothing()
354
- .returning();
355
- if (!value) {
356
- throw new WorkflowAPIError(`Hook ${data.hookId} already exists`, {
357
- status: 409,
358
- });
359
- }
360
- value.metadata ||= value.metadataJson;
361
- const parsed = HookSchema.parse(compact(value));
362
- const resolveData = params?.resolveData ?? 'all';
363
- return filterHookData(parsed, resolveData);
364
- },
365
766
  async getByToken(token, params) {
366
767
  const [value] = await getByToken.execute({ token });
367
768
  if (!value) {
@@ -396,46 +797,12 @@ export function createHooksStorage(drizzle) {
396
797
  hasMore,
397
798
  };
398
799
  },
399
- async dispose(hookId, params) {
400
- const [value] = await drizzle
401
- .delete(hooks)
402
- .where(eq(hooks.hookId, hookId))
403
- .returning();
404
- if (!value) {
405
- throw new WorkflowAPIError(`Hook not found: ${hookId}`, {
406
- status: 404,
407
- });
408
- }
409
- const parsed = HookSchema.parse(compact(value));
410
- const resolveData = params?.resolveData ?? 'all';
411
- return filterHookData(parsed, resolveData);
412
- },
413
800
  };
414
801
  }
415
802
  export function createStepsStorage(drizzle) {
416
803
  const { steps } = Schema;
417
804
  return {
418
- async create(runId, data) {
419
- const [value] = await drizzle
420
- .insert(steps)
421
- .values({
422
- runId,
423
- stepId: data.stepId,
424
- stepName: data.stepName,
425
- input: data.input,
426
- status: 'pending',
427
- attempt: 0,
428
- })
429
- .onConflictDoNothing()
430
- .returning();
431
- if (!value) {
432
- throw new WorkflowAPIError(`Step ${data.stepId} already exists`, {
433
- status: 409,
434
- });
435
- }
436
- return deserializeStepError(compact(value));
437
- },
438
- async get(runId, stepId, params) {
805
+ get: (async (runId, stepId, params) => {
439
806
  // If runId is not provided, query only by stepId
440
807
  const whereClause = runId
441
808
  ? and(eq(steps.stepId, stepId), eq(steps.runId, runId))
@@ -451,50 +818,14 @@ export function createStepsStorage(drizzle) {
451
818
  });
452
819
  }
453
820
  value.output ||= value.outputJson;
821
+ value.input ||= value.inputJson;
822
+ value.error ||= parseErrorJson(value.errorJson);
454
823
  const deserialized = deserializeStepError(compact(value));
455
824
  const parsed = StepSchema.parse(deserialized);
456
825
  const resolveData = params?.resolveData ?? 'all';
457
826
  return filterStepData(parsed, resolveData);
458
- },
459
- async update(runId, stepId, data) {
460
- // Fetch current step to check if startedAt is already set
461
- const [currentStep] = await drizzle
462
- .select()
463
- .from(steps)
464
- .where(and(eq(steps.stepId, stepId), eq(steps.runId, runId)))
465
- .limit(1);
466
- if (!currentStep) {
467
- throw new WorkflowAPIError(`Step not found: ${stepId}`, {
468
- status: 404,
469
- });
470
- }
471
- // Serialize the error field if present
472
- const serialized = serializeStepError(data);
473
- const updates = {
474
- ...serialized,
475
- output: data.output,
476
- };
477
- const now = new Date();
478
- // Only set startedAt the first time the step transitions to 'running'
479
- if (data.status === 'running' && !currentStep.startedAt) {
480
- updates.startedAt = now;
481
- }
482
- if (data.status === 'completed' || data.status === 'failed') {
483
- updates.completedAt = now;
484
- }
485
- const [value] = await drizzle
486
- .update(steps)
487
- .set(updates)
488
- .where(and(eq(steps.stepId, stepId), eq(steps.runId, runId)))
489
- .returning();
490
- if (!value) {
491
- throw new WorkflowAPIError(`Step not found: ${stepId}`, {
492
- status: 404,
493
- });
494
- }
495
- return deserializeStepError(compact(value));
496
- },
497
- async list(params) {
827
+ }),
828
+ list: (async (params) => {
498
829
  const limit = params?.pagination?.limit ?? 20;
499
830
  const fromCursor = params?.pagination?.cursor;
500
831
  const all = await drizzle
@@ -508,6 +839,9 @@ export function createStepsStorage(drizzle) {
508
839
  const resolveData = params?.resolveData ?? 'all';
509
840
  return {
510
841
  data: values.map((v) => {
842
+ v.output ||= v.outputJson;
843
+ v.input ||= v.inputJson;
844
+ v.error ||= parseErrorJson(v.errorJson);
511
845
  const deserialized = deserializeStepError(compact(v));
512
846
  const parsed = StepSchema.parse(deserialized);
513
847
  return filterStepData(parsed, resolveData);
@@ -515,20 +849,20 @@ export function createStepsStorage(drizzle) {
515
849
  hasMore,
516
850
  cursor: values.at(-1)?.stepId ?? null,
517
851
  };
518
- },
852
+ }),
519
853
  };
520
854
  }
521
855
  function filterStepData(step, resolveData) {
522
856
  if (resolveData === 'none') {
523
857
  const { input: _, output: __, ...rest } = step;
524
- return { input: [], output: undefined, ...rest };
858
+ return { input: undefined, output: undefined, ...rest };
525
859
  }
526
860
  return step;
527
861
  }
528
862
  function filterRunData(run, resolveData) {
529
863
  if (resolveData === 'none') {
530
864
  const { input: _, output: __, ...rest } = run;
531
- return { input: [], output: undefined, ...rest };
865
+ return { input: undefined, output: undefined, ...rest };
532
866
  }
533
867
  return run;
534
868
  }