@workflow/world-postgres 4.1.0-beta.29 → 4.1.0-beta.31

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,593 @@ 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
+ retryAfter: Schema.steps.retryAfter,
205
+ })
206
+ .from(Schema.steps)
207
+ .where(and(eq(Schema.steps.runId, sql.placeholder('runId')), eq(Schema.steps.stepId, sql.placeholder('stepId'))))
208
+ .limit(1)
209
+ .prepare('events_get_step_for_validation');
210
+ const getHookByToken = drizzle
211
+ .select({ hookId: Schema.hooks.hookId })
212
+ .from(Schema.hooks)
213
+ .where(eq(Schema.hooks.token, sql.placeholder('token')))
214
+ .limit(1)
215
+ .prepare('events_get_hook_by_token');
248
216
  return {
249
217
  async create(runId, data, params) {
250
218
  const eventId = `wevt_${ulid()}`;
219
+ // For run_created events, generate runId server-side if null or empty
220
+ let effectiveRunId;
221
+ if (data.eventType === 'run_created' && (!runId || runId === '')) {
222
+ effectiveRunId = `wrun_${ulid()}`;
223
+ }
224
+ else if (!runId) {
225
+ throw new Error('runId is required for non-run_created events');
226
+ }
227
+ else {
228
+ effectiveRunId = runId;
229
+ }
230
+ // specVersion is always sent by the runtime, but we provide a fallback for safety
231
+ const effectiveSpecVersion = data.specVersion ?? SPEC_VERSION_CURRENT;
232
+ // Track entity created/updated for EventResult
233
+ let run;
234
+ let step;
235
+ let hook;
236
+ const now = new Date();
237
+ // Helper to check if run is in terminal state
238
+ const isRunTerminal = (status) => ['completed', 'failed', 'cancelled'].includes(status);
239
+ // Helper to check if step is in terminal state
240
+ const isStepTerminal = (status) => ['completed', 'failed'].includes(status);
241
+ // ============================================================
242
+ // VALIDATION: Terminal state and event ordering checks
243
+ // ============================================================
244
+ // Get current run state for validation (if not creating a new run)
245
+ // Skip run validation for step_completed and step_retrying - they only operate
246
+ // on running steps, and running steps are always allowed to modify regardless
247
+ // of run state. This optimization saves database queries per step event.
248
+ let currentRun = null;
249
+ const skipRunValidationEvents = ['step_completed', 'step_retrying'];
250
+ if (data.eventType !== 'run_created' &&
251
+ !skipRunValidationEvents.includes(data.eventType)) {
252
+ // Use prepared statement for better performance
253
+ const [runValue] = await getRunForValidation.execute({
254
+ runId: effectiveRunId,
255
+ });
256
+ currentRun = runValue ?? null;
257
+ }
258
+ // ============================================================
259
+ // VERSION COMPATIBILITY: Check run spec version
260
+ // ============================================================
261
+ // For events that have fetched the run, check version compatibility.
262
+ // Skip for run_created (no existing run) and runtime events (step_completed, step_retrying).
263
+ if (currentRun) {
264
+ // Check if run requires a newer world version
265
+ if (requiresNewerWorld(currentRun.specVersion)) {
266
+ throw new RunNotSupportedError(currentRun.specVersion, SPEC_VERSION_CURRENT);
267
+ }
268
+ // Route to legacy handler for pre-event-sourcing runs
269
+ if (isLegacySpecVersion(currentRun.specVersion)) {
270
+ return handleLegacyEventPostgres(drizzle, effectiveRunId, eventId, data, currentRun, params);
271
+ }
272
+ }
273
+ // Run terminal state validation
274
+ if (currentRun && isRunTerminal(currentRun.status)) {
275
+ const runTerminalEvents = [
276
+ 'run_started',
277
+ 'run_completed',
278
+ 'run_failed',
279
+ ];
280
+ // Idempotent operation: run_cancelled on already cancelled run is allowed
281
+ if (data.eventType === 'run_cancelled' &&
282
+ currentRun.status === 'cancelled') {
283
+ // Get full run for return value
284
+ const [fullRun] = await drizzle
285
+ .select()
286
+ .from(Schema.runs)
287
+ .where(eq(Schema.runs.runId, effectiveRunId))
288
+ .limit(1);
289
+ // Create the event (still record it)
290
+ const [value] = await drizzle
291
+ .insert(Schema.events)
292
+ .values({
293
+ runId: effectiveRunId,
294
+ eventId,
295
+ correlationId: data.correlationId,
296
+ eventType: data.eventType,
297
+ eventData: 'eventData' in data ? data.eventData : undefined,
298
+ specVersion: effectiveSpecVersion,
299
+ })
300
+ .returning({ createdAt: Schema.events.createdAt });
301
+ const result = { ...data, ...value, runId: effectiveRunId, eventId };
302
+ const parsed = EventSchema.parse(result);
303
+ const resolveData = params?.resolveData ?? 'all';
304
+ return {
305
+ event: filterEventData(parsed, resolveData),
306
+ run: fullRun ? deserializeRunError(compact(fullRun)) : undefined,
307
+ };
308
+ }
309
+ // Run state transitions are not allowed on terminal runs
310
+ if (runTerminalEvents.includes(data.eventType) ||
311
+ data.eventType === 'run_cancelled') {
312
+ throw new WorkflowAPIError(`Cannot transition run from terminal state "${currentRun.status}"`, { status: 409 });
313
+ }
314
+ // Creating new entities on terminal runs is not allowed
315
+ if (data.eventType === 'step_created' ||
316
+ data.eventType === 'hook_created') {
317
+ throw new WorkflowAPIError(`Cannot create new entities on run in terminal state "${currentRun.status}"`, { status: 409 });
318
+ }
319
+ }
320
+ // Step-related event validation (ordering and terminal state)
321
+ // Fetch status + startedAt so we can reuse for step_started (avoid double read)
322
+ // Skip validation for step_completed/step_failed - use conditional UPDATE instead
323
+ let validatedStep = null;
324
+ const stepEventsNeedingValidation = ['step_started', 'step_retrying'];
325
+ if (stepEventsNeedingValidation.includes(data.eventType) &&
326
+ data.correlationId) {
327
+ // Use prepared statement for better performance
328
+ const [existingStep] = await getStepForValidation.execute({
329
+ runId: effectiveRunId,
330
+ stepId: data.correlationId,
331
+ });
332
+ validatedStep = existingStep ?? null;
333
+ // Event ordering: step must exist before these events
334
+ if (!validatedStep) {
335
+ throw new WorkflowAPIError(`Step "${data.correlationId}" not found`, {
336
+ status: 404,
337
+ });
338
+ }
339
+ // Step terminal state validation
340
+ if (isStepTerminal(validatedStep.status)) {
341
+ throw new WorkflowAPIError(`Cannot modify step in terminal state "${validatedStep.status}"`, { status: 409 });
342
+ }
343
+ // On terminal runs: only allow completing/failing in-progress steps
344
+ if (currentRun && isRunTerminal(currentRun.status)) {
345
+ if (validatedStep.status !== 'running') {
346
+ throw new WorkflowAPIError(`Cannot modify non-running step on run in terminal state "${currentRun.status}"`, { status: 410 });
347
+ }
348
+ }
349
+ }
350
+ // Hook-related event validation (ordering)
351
+ const hookEventsRequiringExistence = ['hook_disposed', 'hook_received'];
352
+ if (hookEventsRequiringExistence.includes(data.eventType) &&
353
+ data.correlationId) {
354
+ const [existingHook] = await drizzle
355
+ .select({ hookId: Schema.hooks.hookId })
356
+ .from(Schema.hooks)
357
+ .where(eq(Schema.hooks.hookId, data.correlationId))
358
+ .limit(1);
359
+ if (!existingHook) {
360
+ throw new WorkflowAPIError(`Hook "${data.correlationId}" not found`, {
361
+ status: 404,
362
+ });
363
+ }
364
+ }
365
+ // ============================================================
366
+ // Entity creation/updates based on event type
367
+ // ============================================================
368
+ // Handle run_created event: create the run entity atomically
369
+ if (data.eventType === 'run_created') {
370
+ const eventData = data.eventData;
371
+ const [runValue] = await drizzle
372
+ .insert(Schema.runs)
373
+ .values({
374
+ runId: effectiveRunId,
375
+ deploymentId: eventData.deploymentId,
376
+ workflowName: eventData.workflowName,
377
+ // Propagate specVersion from the event to the run entity
378
+ specVersion: effectiveSpecVersion,
379
+ input: eventData.input,
380
+ executionContext: eventData.executionContext,
381
+ status: 'pending',
382
+ })
383
+ .onConflictDoNothing()
384
+ .returning();
385
+ if (runValue) {
386
+ run = deserializeRunError(compact(runValue));
387
+ }
388
+ }
389
+ // Handle run_started event: update run status
390
+ if (data.eventType === 'run_started') {
391
+ const [runValue] = await drizzle
392
+ .update(Schema.runs)
393
+ .set({
394
+ status: 'running',
395
+ startedAt: now,
396
+ updatedAt: now,
397
+ })
398
+ .where(eq(Schema.runs.runId, effectiveRunId))
399
+ .returning();
400
+ if (runValue) {
401
+ run = deserializeRunError(compact(runValue));
402
+ }
403
+ }
404
+ // Handle run_completed event: update run status and cleanup hooks
405
+ if (data.eventType === 'run_completed') {
406
+ const eventData = data.eventData;
407
+ const [runValue] = await drizzle
408
+ .update(Schema.runs)
409
+ .set({
410
+ status: 'completed',
411
+ output: eventData.output,
412
+ completedAt: now,
413
+ updatedAt: now,
414
+ })
415
+ .where(eq(Schema.runs.runId, effectiveRunId))
416
+ .returning();
417
+ if (runValue) {
418
+ run = deserializeRunError(compact(runValue));
419
+ }
420
+ // Delete all hooks for this run to allow token reuse
421
+ await drizzle
422
+ .delete(Schema.hooks)
423
+ .where(eq(Schema.hooks.runId, effectiveRunId));
424
+ }
425
+ // Handle run_failed event: update run status and cleanup hooks
426
+ if (data.eventType === 'run_failed') {
427
+ const eventData = data.eventData;
428
+ const errorMessage = typeof eventData.error === 'string'
429
+ ? eventData.error
430
+ : (eventData.error?.message ?? 'Unknown error');
431
+ const [runValue] = await drizzle
432
+ .update(Schema.runs)
433
+ .set({
434
+ status: 'failed',
435
+ error: {
436
+ message: errorMessage,
437
+ stack: eventData.error?.stack,
438
+ code: eventData.errorCode,
439
+ },
440
+ completedAt: now,
441
+ updatedAt: now,
442
+ })
443
+ .where(eq(Schema.runs.runId, effectiveRunId))
444
+ .returning();
445
+ if (runValue) {
446
+ run = deserializeRunError(compact(runValue));
447
+ }
448
+ // Delete all hooks for this run to allow token reuse
449
+ await drizzle
450
+ .delete(Schema.hooks)
451
+ .where(eq(Schema.hooks.runId, effectiveRunId));
452
+ }
453
+ // Handle run_cancelled event: update run status and cleanup hooks
454
+ if (data.eventType === 'run_cancelled') {
455
+ const [runValue] = await drizzle
456
+ .update(Schema.runs)
457
+ .set({
458
+ status: 'cancelled',
459
+ completedAt: now,
460
+ updatedAt: now,
461
+ })
462
+ .where(eq(Schema.runs.runId, effectiveRunId))
463
+ .returning();
464
+ if (runValue) {
465
+ run = deserializeRunError(compact(runValue));
466
+ }
467
+ // Delete all hooks for this run to allow token reuse
468
+ await drizzle
469
+ .delete(Schema.hooks)
470
+ .where(eq(Schema.hooks.runId, effectiveRunId));
471
+ }
472
+ // Handle step_created event: create step entity
473
+ if (data.eventType === 'step_created') {
474
+ const eventData = data.eventData;
475
+ const [stepValue] = await drizzle
476
+ .insert(Schema.steps)
477
+ .values({
478
+ runId: effectiveRunId,
479
+ stepId: data.correlationId,
480
+ stepName: eventData.stepName,
481
+ input: eventData.input,
482
+ status: 'pending',
483
+ attempt: 0,
484
+ // Propagate specVersion from the event to the step entity
485
+ specVersion: effectiveSpecVersion,
486
+ })
487
+ .onConflictDoNothing()
488
+ .returning();
489
+ if (stepValue) {
490
+ step = deserializeStepError(compact(stepValue));
491
+ }
492
+ }
493
+ // Handle step_started event: increment attempt, set status to 'running'
494
+ // Sets startedAt (maps to startedAt) only on first start
495
+ // Reuse validatedStep from validation (already read above)
496
+ if (data.eventType === 'step_started') {
497
+ // Check if retryAfter timestamp hasn't been reached yet
498
+ if (validatedStep?.retryAfter &&
499
+ validatedStep.retryAfter.getTime() > Date.now()) {
500
+ const err = new WorkflowAPIError(`Cannot start step "${data.correlationId}": retryAfter timestamp has not been reached yet`, { status: 425 });
501
+ // Add meta for step-handler to extract retryAfter timestamp
502
+ err.meta = {
503
+ stepId: data.correlationId,
504
+ retryAfter: validatedStep.retryAfter.toISOString(),
505
+ };
506
+ throw err;
507
+ }
508
+ const isFirstStart = !validatedStep?.startedAt;
509
+ const hadRetryAfter = !!validatedStep?.retryAfter;
510
+ const [stepValue] = await drizzle
511
+ .update(Schema.steps)
512
+ .set({
513
+ status: 'running',
514
+ // Increment attempt counter using SQL
515
+ attempt: sql `${Schema.steps.attempt} + 1`,
516
+ // Only set startedAt on first start (not updated on retries)
517
+ ...(isFirstStart ? { startedAt: now } : {}),
518
+ // Clear retryAfter now that the step has started
519
+ ...(hadRetryAfter ? { retryAfter: null } : {}),
520
+ })
521
+ .where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId)))
522
+ .returning();
523
+ if (stepValue) {
524
+ step = deserializeStepError(compact(stepValue));
525
+ }
526
+ }
527
+ // Handle step_completed event: update step status
528
+ // Uses conditional UPDATE to skip validation query (performance optimization)
529
+ if (data.eventType === 'step_completed') {
530
+ const eventData = data.eventData;
531
+ const [stepValue] = await drizzle
532
+ .update(Schema.steps)
533
+ .set({
534
+ status: 'completed',
535
+ output: eventData.result,
536
+ completedAt: now,
537
+ })
538
+ .where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId),
539
+ // Only update if not already in terminal state (validation in WHERE clause)
540
+ notInArray(Schema.steps.status, ['completed', 'failed'])))
541
+ .returning();
542
+ if (stepValue) {
543
+ step = deserializeStepError(compact(stepValue));
544
+ }
545
+ else {
546
+ // Step not updated - check if it exists and why
547
+ const [existing] = await getStepForValidation.execute({
548
+ runId: effectiveRunId,
549
+ stepId: data.correlationId,
550
+ });
551
+ if (!existing) {
552
+ throw new WorkflowAPIError(`Step "${data.correlationId}" not found`, { status: 404 });
553
+ }
554
+ if (['completed', 'failed'].includes(existing.status)) {
555
+ throw new WorkflowAPIError(`Cannot modify step in terminal state "${existing.status}"`, { status: 409 });
556
+ }
557
+ }
558
+ }
559
+ // Handle step_failed event: terminal state with error
560
+ // Uses conditional UPDATE to skip validation query (performance optimization)
561
+ if (data.eventType === 'step_failed') {
562
+ const eventData = data.eventData;
563
+ const errorMessage = typeof eventData.error === 'string'
564
+ ? eventData.error
565
+ : (eventData.error?.message ?? 'Unknown error');
566
+ const [stepValue] = await drizzle
567
+ .update(Schema.steps)
568
+ .set({
569
+ status: 'failed',
570
+ error: {
571
+ message: errorMessage,
572
+ stack: eventData.stack,
573
+ },
574
+ completedAt: now,
575
+ })
576
+ .where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId),
577
+ // Only update if not already in terminal state (validation in WHERE clause)
578
+ notInArray(Schema.steps.status, ['completed', 'failed'])))
579
+ .returning();
580
+ if (stepValue) {
581
+ step = deserializeStepError(compact(stepValue));
582
+ }
583
+ else {
584
+ // Step not updated - check if it exists and why
585
+ const [existing] = await getStepForValidation.execute({
586
+ runId: effectiveRunId,
587
+ stepId: data.correlationId,
588
+ });
589
+ if (!existing) {
590
+ throw new WorkflowAPIError(`Step "${data.correlationId}" not found`, { status: 404 });
591
+ }
592
+ if (['completed', 'failed'].includes(existing.status)) {
593
+ throw new WorkflowAPIError(`Cannot modify step in terminal state "${existing.status}"`, { status: 409 });
594
+ }
595
+ }
596
+ }
597
+ // Handle step_retrying event: sets status back to 'pending', records error
598
+ if (data.eventType === 'step_retrying') {
599
+ const eventData = data.eventData;
600
+ const errorMessage = typeof eventData.error === 'string'
601
+ ? eventData.error
602
+ : (eventData.error?.message ?? 'Unknown error');
603
+ const [stepValue] = await drizzle
604
+ .update(Schema.steps)
605
+ .set({
606
+ status: 'pending',
607
+ error: {
608
+ message: errorMessage,
609
+ stack: eventData.stack,
610
+ },
611
+ retryAfter: eventData.retryAfter,
612
+ })
613
+ .where(and(eq(Schema.steps.runId, effectiveRunId), eq(Schema.steps.stepId, data.correlationId)))
614
+ .returning();
615
+ if (stepValue) {
616
+ step = deserializeStepError(compact(stepValue));
617
+ }
618
+ }
619
+ // Handle hook_created event: create hook entity
620
+ // Uses prepared statement for token uniqueness check (performance optimization)
621
+ if (data.eventType === 'hook_created') {
622
+ const eventData = data.eventData;
623
+ // Check for duplicate token using prepared statement
624
+ const [existingHook] = await getHookByToken.execute({
625
+ token: eventData.token,
626
+ });
627
+ if (existingHook) {
628
+ // Create hook_conflict event instead of throwing 409
629
+ // This allows the workflow to continue and fail gracefully when the hook is awaited
630
+ const conflictEventData = {
631
+ token: eventData.token,
632
+ };
633
+ const [conflictValue] = await drizzle
634
+ .insert(events)
635
+ .values({
636
+ runId: effectiveRunId,
637
+ eventId,
638
+ correlationId: data.correlationId,
639
+ eventType: 'hook_conflict',
640
+ eventData: conflictEventData,
641
+ specVersion: effectiveSpecVersion,
642
+ })
643
+ .returning({ createdAt: events.createdAt });
644
+ if (!conflictValue) {
645
+ throw new WorkflowAPIError(`Event ${eventId} could not be created`, { status: 409 });
646
+ }
647
+ const conflictResult = {
648
+ eventType: 'hook_conflict',
649
+ correlationId: data.correlationId,
650
+ eventData: conflictEventData,
651
+ ...conflictValue,
652
+ runId: effectiveRunId,
653
+ eventId,
654
+ };
655
+ const parsedConflict = EventSchema.parse(conflictResult);
656
+ const resolveData = params?.resolveData ?? 'all';
657
+ return {
658
+ event: filterEventData(parsedConflict, resolveData),
659
+ run,
660
+ step,
661
+ hook: undefined,
662
+ };
663
+ }
664
+ const [hookValue] = await drizzle
665
+ .insert(Schema.hooks)
666
+ .values({
667
+ runId: effectiveRunId,
668
+ hookId: data.correlationId,
669
+ token: eventData.token,
670
+ metadata: eventData.metadata,
671
+ ownerId: '', // TODO: get from context
672
+ projectId: '', // TODO: get from context
673
+ environment: '', // TODO: get from context
674
+ // Propagate specVersion from the event to the hook entity
675
+ specVersion: effectiveSpecVersion,
676
+ })
677
+ .onConflictDoNothing()
678
+ .returning();
679
+ if (hookValue) {
680
+ hookValue.metadata ||= hookValue.metadataJson;
681
+ hook = HookSchema.parse(compact(hookValue));
682
+ }
683
+ }
684
+ // Handle hook_disposed event: delete hook entity
685
+ if (data.eventType === 'hook_disposed' && data.correlationId) {
686
+ await drizzle
687
+ .delete(Schema.hooks)
688
+ .where(eq(Schema.hooks.hookId, data.correlationId));
689
+ }
251
690
  const [value] = await drizzle
252
691
  .insert(events)
253
692
  .values({
254
- runId,
693
+ runId: effectiveRunId,
255
694
  eventId,
256
695
  correlationId: data.correlationId,
257
696
  eventType: data.eventType,
258
697
  eventData: 'eventData' in data ? data.eventData : undefined,
698
+ specVersion: effectiveSpecVersion,
259
699
  })
260
700
  .returning({ createdAt: events.createdAt });
261
701
  if (!value) {
@@ -263,10 +703,10 @@ export function createEventsStorage(drizzle) {
263
703
  status: 409,
264
704
  });
265
705
  }
266
- const result = { ...data, ...value, runId, eventId };
706
+ const result = { ...data, ...value, runId: effectiveRunId, eventId };
267
707
  const parsed = EventSchema.parse(result);
268
708
  const resolveData = params?.resolveData ?? 'all';
269
- return filterEventData(parsed, resolveData);
709
+ return { event: filterEventData(parsed, resolveData), run, step, hook };
270
710
  },
271
711
  async list(params) {
272
712
  const limit = params?.pagination?.limit ?? 100;
@@ -338,30 +778,6 @@ export function createHooksStorage(drizzle) {
338
778
  const resolveData = params?.resolveData ?? 'all';
339
779
  return filterHookData(parsed, resolveData);
340
780
  },
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
781
  async getByToken(token, params) {
366
782
  const [value] = await getByToken.execute({ token });
367
783
  if (!value) {
@@ -396,46 +812,12 @@ export function createHooksStorage(drizzle) {
396
812
  hasMore,
397
813
  };
398
814
  },
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
815
  };
414
816
  }
415
817
  export function createStepsStorage(drizzle) {
416
818
  const { steps } = Schema;
417
819
  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) {
820
+ get: (async (runId, stepId, params) => {
439
821
  // If runId is not provided, query only by stepId
440
822
  const whereClause = runId
441
823
  ? and(eq(steps.stepId, stepId), eq(steps.runId, runId))
@@ -451,50 +833,14 @@ export function createStepsStorage(drizzle) {
451
833
  });
452
834
  }
453
835
  value.output ||= value.outputJson;
836
+ value.input ||= value.inputJson;
837
+ value.error ||= parseErrorJson(value.errorJson);
454
838
  const deserialized = deserializeStepError(compact(value));
455
839
  const parsed = StepSchema.parse(deserialized);
456
840
  const resolveData = params?.resolveData ?? 'all';
457
841
  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) {
842
+ }),
843
+ list: (async (params) => {
498
844
  const limit = params?.pagination?.limit ?? 20;
499
845
  const fromCursor = params?.pagination?.cursor;
500
846
  const all = await drizzle
@@ -508,6 +854,9 @@ export function createStepsStorage(drizzle) {
508
854
  const resolveData = params?.resolveData ?? 'all';
509
855
  return {
510
856
  data: values.map((v) => {
857
+ v.output ||= v.outputJson;
858
+ v.input ||= v.inputJson;
859
+ v.error ||= parseErrorJson(v.errorJson);
511
860
  const deserialized = deserializeStepError(compact(v));
512
861
  const parsed = StepSchema.parse(deserialized);
513
862
  return filterStepData(parsed, resolveData);
@@ -515,20 +864,20 @@ export function createStepsStorage(drizzle) {
515
864
  hasMore,
516
865
  cursor: values.at(-1)?.stepId ?? null,
517
866
  };
518
- },
867
+ }),
519
868
  };
520
869
  }
521
870
  function filterStepData(step, resolveData) {
522
871
  if (resolveData === 'none') {
523
872
  const { input: _, output: __, ...rest } = step;
524
- return { input: [], output: undefined, ...rest };
873
+ return { input: undefined, output: undefined, ...rest };
525
874
  }
526
875
  return step;
527
876
  }
528
877
  function filterRunData(run, resolveData) {
529
878
  if (resolveData === 'none') {
530
879
  const { input: _, output: __, ...rest } = run;
531
- return { input: [], output: undefined, ...rest };
880
+ return { input: undefined, output: undefined, ...rest };
532
881
  }
533
882
  return run;
534
883
  }