Files

2106 lines
76 KiB
TypeScript

import {
EntityConflictError,
HookNotFoundError,
RunExpiredError,
RunNotSupportedError,
TooEarlyError,
WorkflowRunNotFoundError,
WorkflowWorldError,
} from '@workflow/errors';
import type {
AttributeChange,
Event,
EventResult,
ExperimentalSetAttributesResult,
GetEventParams,
Hook,
ListEventsParams,
ListHooksParams,
PaginatedResponse,
ResolveData,
SerializedData,
Step,
StepWithoutData,
Storage,
Wait,
WorkflowRun,
WorkflowRunWithoutData,
} from '@workflow/world';
import {
ATTRIBUTE_MAX_PER_RUN,
AttributeValidationError,
EventSchema,
HookSchema,
isChildEntityCreationEvent,
isChildEntityCreationEventType,
isHookEventRequiringExistence,
isLegacySpecVersion,
isTerminalRunEventType,
isTerminalStepStatus,
isTerminalWorkflowRunStatus,
requiresNewerWorld,
SPEC_VERSION_CURRENT,
StepSchema,
stripEventDataRefs,
TERMINAL_STEP_STATUSES,
TERMINAL_WORKFLOW_RUN_STATUSES,
validateAttributeChanges,
validateUlidTimestamp,
WorkflowRunSchema,
} from '@workflow/world';
import {
and,
asc,
desc,
eq,
gt,
inArray,
lt,
notInArray,
sql,
} from 'drizzle-orm';
import { monotonicFactory } from 'ulid';
import { type Drizzle, Schema } from './drizzle/index.js';
import type { SerializedContent } from './drizzle/schema.js';
import { compact } from './util.js';
/**
* Read helper for the deprecated `error` text column (legacy: JSON-stringified
* `StructuredError`). In the current event-sourced model, the `error` field on
* entities is `SerializedData` (Uint8Array) produced by the new error
* serialization pipeline; legacy text-column records pre-date that pipeline
* and cannot be hydrated back into the original thrown value.
*
* Returns `null` unconditionally so downstream consumers treat legacy errors
* as absent rather than receiving a shape that `hydrateStepError` /
* `hydrateRunError` can't process. Callers that need to inspect the raw
* legacy payload should read the `errorJson` column directly.
*/
function parseErrorJson(_errorJson: string | null): SerializedData | null {
return null;
}
/**
* Pass-through helper kept for backwards compatibility with the run read path.
* In the current event-sourced model, `error` is already `SerializedData`
* (Uint8Array) on the entity, and any legacy `errorStack` / `errorCode`
* fields are no longer populated by the current write path.
*/
function deserializeRunError(run: any): WorkflowRun {
// Drop any stale legacy-only fields we might still encounter on read.
const { errorStack: _errorStack, ...rest } = run;
return rest as WorkflowRun;
}
/**
* Deserialize step data, mapping DB columns to interface fields.
* The error field should already be deserialized from CBOR or fallback to errorJson.
*/
function deserializeStepError(step: any): Step {
const { startedAt, ...rest } = step;
return {
...rest,
startedAt,
} as Step;
}
export function createRunsStorage(drizzle: Drizzle): Storage['runs'] {
const { runs } = Schema;
const get = drizzle
.select()
.from(runs)
.where(eq(runs.runId, sql.placeholder('id')))
.limit(1)
.prepare('workflow_runs_get');
return {
get: (async (id, params) => {
const [value] = await get.execute({ id });
if (!value) {
throw new WorkflowRunNotFoundError(id);
}
value.output ||= value.outputJson;
value.input ||= value.inputJson;
value.executionContext ||= value.executionContextJson;
value.error ||= parseErrorJson(value.errorJson);
const deserialized = deserializeRunError(compact(value));
const parsed = WorkflowRunSchema.parse(deserialized);
const resolveData = params?.resolveData ?? 'all';
return filterRunData(parsed, resolveData);
}) as Storage['runs']['get'],
getMany: (async (ids, params) => {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) {
return [];
}
const values = await drizzle
.select()
.from(runs)
.where(inArray(runs.runId, uniqueIds));
const resolveData = params?.resolveData ?? 'all';
const runsById = new Map(
values.map((value) => {
value.output ||= value.outputJson;
value.input ||= value.inputJson;
value.executionContext ||= value.executionContextJson;
value.error ||= parseErrorJson(value.errorJson);
const parsed = WorkflowRunSchema.parse(
deserializeRunError(compact(value))
);
return [value.runId, filterRunData(parsed, resolveData)] as const;
})
);
return ids.map((id) => runsById.get(id) ?? null);
}) as NonNullable<Storage['runs']['getMany']>,
list: (async (params) => {
const limit = params?.pagination?.limit ?? 20;
const fromCursor = params?.pagination?.cursor;
const all = await drizzle
.select()
.from(runs)
.where(
and(
map(fromCursor, (c) => lt(runs.runId, c)),
map(params?.workflowName, (wf) => eq(runs.workflowName, wf)),
map(params?.status, (wf) => eq(runs.status, wf))
)
)
.orderBy(desc(runs.runId))
.limit(limit + 1);
const values = all.slice(0, limit);
const hasMore = all.length > limit;
const resolveData = params?.resolveData ?? 'all';
return {
data: values.map((v) => {
v.output ||= v.outputJson;
v.input ||= v.inputJson;
v.executionContext ||= v.executionContextJson;
v.error ||= parseErrorJson(v.errorJson);
const deserialized = deserializeRunError(compact(v));
const parsed = WorkflowRunSchema.parse(deserialized);
return filterRunData(parsed, resolveData);
}),
hasMore,
cursor: values.at(-1)?.runId ?? null,
};
}) as Storage['runs']['list'],
experimentalSetAttributes: async (
runId: string,
changes: AttributeChange[],
options?: { allowReservedAttributes?: boolean }
): Promise<ExperimentalSetAttributesResult> => {
// Load existing attributes so the SDK-shape validator can produce
// a precise error message (cap, duplicate keys, reserved prefix,
// byte length). The authoritative cap enforcement happens inside
// the UPDATE statement below — see the `WHERE` clause — so the
// race between this read and the UPDATE cannot push the row past
// the per-run cap.
const [existing] = await drizzle
.select({ attributes: runs.attributes })
.from(runs)
.where(eq(runs.runId, runId))
.limit(1);
if (!existing) {
throw new WorkflowRunNotFoundError(runId);
}
try {
validateAttributeChanges(changes, {
existingKeys: Object.keys(existing.attributes ?? {}),
allowReservedAttributes: options?.allowReservedAttributes,
});
} catch (err) {
if (err instanceof AttributeValidationError) throw err;
throw err;
}
// Build a single SQL expression that applies all changes
// atomically. Sets fold into nested `jsonb_set` calls; removes
// fold into chained `-` (delete) operators.
let expr = sql`COALESCE(${runs.attributes}, '{}'::jsonb)`;
for (const { key, value } of changes) {
if (value === null) {
expr = sql`${expr} - ${key}`;
} else {
expr = sql`jsonb_set(${expr}, ARRAY[${key}]::text[], to_jsonb(${value}::text), true)`;
}
}
// Atomic cap enforcement: only commit the UPDATE if the
// post-merge key count fits the per-run cap. Computed against
// the *current* row state, so two concurrent writers adding
// disjoint keys at the cap boundary cannot both succeed.
// Drizzle re-renders `expr` twice in the SQL (`SET attributes =
// ...` + the count check); `jsonb_set` is cheap so the
// duplication is harmless.
const [updated] = await drizzle
.update(runs)
.set({
attributes: expr as any,
updatedAt: new Date(),
})
.where(
and(
eq(runs.runId, runId),
sql`(SELECT COUNT(*) FROM jsonb_object_keys(${expr})) <= ${ATTRIBUTE_MAX_PER_RUN}`
)
)
.returning({ attributes: runs.attributes });
if (!updated) {
// Either the run vanished mid-call, or the cap-check WHERE
// clause rejected the UPDATE. Re-read to disambiguate.
const [stillThere] = await drizzle
.select({ attributes: runs.attributes })
.from(runs)
.where(eq(runs.runId, runId))
.limit(1);
if (!stillThere) {
throw new WorkflowRunNotFoundError(runId);
}
throw new AttributeValidationError(
`Run attribute count would exceed limit ${ATTRIBUTE_MAX_PER_RUN} after concurrent write`
);
}
return { attributes: updated.attributes ?? {} };
},
};
}
function map<T, R>(obj: T | null | undefined, fn: (v: T) => R): undefined | R {
return obj ? fn(obj) : undefined;
}
/**
* Handle events for legacy runs (pre-event-sourcing, specVersion < 2).
* Legacy runs use different behavior:
* - run_cancelled: Skip event storage, directly update run
* - wait_completed: Store event only (no entity mutation)
* - hook_received: Store event only (hooks exist via old system, no entity mutation)
* - Other events: Throw error (not supported for legacy runs)
*/
async function handleLegacyEventPostgres(
drizzle: Drizzle,
runId: string,
eventId: string,
data: any,
currentRun: { status: string; specVersion: number | null },
params?: { resolveData?: ResolveData }
): Promise<EventResult> {
const resolveData = params?.resolveData ?? 'all';
switch (data.eventType) {
case 'run_cancelled': {
// Legacy: Skip event storage, directly update run to cancelled
const now = new Date();
// Update run status to cancelled
await drizzle
.update(Schema.runs)
.set({
status: 'cancelled',
completedAt: now,
updatedAt: now,
})
.where(eq(Schema.runs.runId, runId));
// Delete all hooks and waits for this run
await Promise.all([
drizzle.delete(Schema.hooks).where(eq(Schema.hooks.runId, runId)),
drizzle.delete(Schema.waits).where(eq(Schema.waits.runId, runId)),
]);
// Fetch updated run for return value
const [updatedRun] = await drizzle
.select()
.from(Schema.runs)
.where(eq(Schema.runs.runId, runId))
.limit(1);
// Return without event (legacy behavior skips event storage)
// Type assertion: EventResult expects WorkflowRun, filterRunData may return WorkflowRunWithoutData
return {
run: updatedRun
? (filterRunData(
deserializeRunError(compact(updatedRun)),
resolveData
) as WorkflowRun)
: undefined,
};
}
case 'wait_completed':
case 'hook_received': {
// Legacy: Store event only (no entity mutation)
// - wait_completed: for replay purposes
// - hook_received: hooks exist via old system, just record the event
//
// hook_received additionally guards against a concurrent (or already
// committed) terminal transition, mirroring the current-spec
// hook_received transaction below: `FOR UPDATE` takes the run row
// lock, blocking until any in-flight terminal UPDATE (including the
// legacy run_cancelled path above) commits, then observes the
// post-commit status.
const insertLegacyEvent = (tx: Pick<Drizzle, 'insert'>) =>
tx
.insert(Schema.events)
.values({
runId,
eventId,
correlationId: data.correlationId,
eventType: data.eventType,
eventData: 'eventData' in data ? data.eventData : undefined,
specVersion: SPEC_VERSION_CURRENT,
})
.returning({ createdAt: Schema.events.createdAt });
const [insertedEvent] =
data.eventType === 'hook_received'
? await drizzle.transaction(async (tx) => {
const [runRow] = await tx
.select({ status: Schema.runs.status })
.from(Schema.runs)
.where(eq(Schema.runs.runId, runId))
.for('update')
.limit(1);
if (!runRow) {
throw new WorkflowRunNotFoundError(runId);
}
if (isTerminalWorkflowRunStatus(runRow.status)) {
throw new RunExpiredError(
`Workflow run "${runId}" is already in terminal state "${runRow.status}"`
);
}
return insertLegacyEvent(tx);
})
: await insertLegacyEvent(drizzle);
const event = EventSchema.parse({
...data,
...insertedEvent,
runId,
eventId,
});
return { event: stripEventDataRefs(event, resolveData) };
}
default:
throw new Error(
`Event type '${data.eventType}' not supported for legacy runs ` +
`(specVersion: ${currentRun.specVersion || 'undefined'}). ` +
`Please upgrade @workflow packages.`
);
}
}
export function createEventsStorage(drizzle: Drizzle): Storage['events'] {
const ulid = monotonicFactory();
const { events } = Schema;
// Prepared statements for validation queries (performance optimization)
const getRunForValidation = drizzle
.select({
status: Schema.runs.status,
specVersion: Schema.runs.specVersion,
})
.from(Schema.runs)
.where(eq(Schema.runs.runId, sql.placeholder('runId')))
.limit(1)
.prepare('events_get_run_for_validation');
const getStepForValidation = drizzle
.select({
status: Schema.steps.status,
startedAt: Schema.steps.startedAt,
retryAfter: Schema.steps.retryAfter,
})
.from(Schema.steps)
.where(
and(
eq(Schema.steps.runId, sql.placeholder('runId')),
eq(Schema.steps.stepId, sql.placeholder('stepId'))
)
)
.limit(1)
.prepare('events_get_step_for_validation');
const getHookByToken = drizzle
.select({ hookId: Schema.hooks.hookId, runId: Schema.hooks.runId })
.from(Schema.hooks)
.where(eq(Schema.hooks.token, sql.placeholder('token')))
.limit(1)
.prepare('events_get_hook_by_token');
// Used to distinguish a real same-hook duplicate from an orphaned
// hook row left behind by a process / database interruption between
// the hook INSERT and the events INSERT below (see the recovery
// logic in the hook_created branch).
const getHookCreatedEvent = drizzle
.select({ eventId: events.eventId })
.from(events)
.where(
and(
eq(events.runId, sql.placeholder('runId')),
eq(events.correlationId, sql.placeholder('correlationId')),
eq(events.eventType, sql.placeholder('eventType'))
)
)
.limit(1)
.prepare('events_get_hook_created_for_run_correlation');
const getWaitForValidation = drizzle
.select({
status: Schema.waits.status,
})
.from(Schema.waits)
.where(eq(Schema.waits.waitId, sql.placeholder('waitId')))
.limit(1)
.prepare('events_get_wait_for_validation');
return {
async create(runId, data, params): Promise<EventResult> {
let eventId: string | undefined;
const getEventId = () => (eventId ??= `wevt_${ulid()}`);
// For run_created events, use client-provided runId or generate one server-side
let effectiveRunId: string;
if (data.eventType === 'run_created' && (!runId || runId === '')) {
effectiveRunId = `wrun_${ulid()}`;
} else if (!runId) {
throw new Error('runId is required for non-run_created events');
} else {
effectiveRunId = runId;
}
// Validate client-provided runId timestamp is within acceptable threshold
if (data.eventType === 'run_created' && runId && runId !== '') {
const validationError = validateUlidTimestamp(effectiveRunId, 'wrun_');
if (validationError) {
throw new WorkflowWorldError(validationError);
}
}
// specVersion is always sent by the runtime, but we provide a fallback for safety
const effectiveSpecVersion = data.specVersion ?? SPEC_VERSION_CURRENT;
// Track entity created/updated for EventResult
let run: WorkflowRun | undefined;
let step: Step | undefined;
let hook: Hook | undefined;
let wait: Wait | undefined;
// Lazy step start: set true when this step_started atomically created
// the step (the caller won the create-claim). Surfaced on EventResult
// as the runtime's exactly-once ownership signal.
let stepCreatedLazily = false;
const now = new Date();
// Terminal step statuses for use in SQL WHERE clauses (atomic guard).
// Must match the Vercel world's conditional expressions:
// ne(status, 'completed') AND ne(status, 'failed') AND ne(status, 'cancelled')
const terminalStepStatuses: (typeof Schema.steps.status.enumValues)[number][] =
[...TERMINAL_STEP_STATUSES];
// ============================================================
// VALIDATION: Terminal state and event ordering checks
// ============================================================
// Get current run state for validation (if not creating a new run)
// Skip run validation for step_completed and step_retrying - they only operate
// on running steps, and running steps are always allowed to modify regardless
// of run state. This optimization saves database queries per step event.
let currentRun: { status: string; specVersion: number | null } | null =
null;
const skipRunValidationEvents = ['step_completed', 'step_retrying'];
if (
data.eventType !== 'run_created' &&
!skipRunValidationEvents.includes(data.eventType)
) {
// Use prepared statement for better performance
const [runValue] = await getRunForValidation.execute({
runId: effectiveRunId,
});
currentRun = runValue ?? null;
// Resilient start: run_started on non-existent run with eventData
// creates the run first, so the queue can bootstrap a run that
// failed to create during start().
if (
data.eventType === 'run_started' &&
!currentRun &&
'eventData' in data &&
data.eventData
) {
const runInputData = (data as any).eventData as {
deploymentId?: string;
workflowName?: string;
input?: any;
executionContext?: Record<string, any>;
attributes?: Record<string, string>;
allowReservedAttributes?: true;
};
if (
runInputData.deploymentId &&
runInputData.workflowName &&
runInputData.input !== undefined
) {
validateAttributeChanges(
Object.entries(runInputData.attributes ?? {}).map(
([key, value]) => ({ key, value })
),
{
allowReservedAttributes:
runInputData.allowReservedAttributes === true,
}
);
// Create run + run_created event atomically. The
// transaction ensures we never have an orphaned run
// without its run_created event.
const [inserted] = await drizzle
.insert(Schema.runs)
.values({
runId: effectiveRunId,
deploymentId: runInputData.deploymentId,
workflowName: runInputData.workflowName,
specVersion: effectiveSpecVersion,
input: runInputData.input as SerializedContent,
executionContext: runInputData.executionContext as
| SerializedContent
| undefined,
attributes: runInputData.attributes,
status: 'pending',
})
.onConflictDoNothing()
.returning();
if (inserted) {
const runCreatedEventId = `wevt_${ulid()}`;
await drizzle.insert(events).values({
runId: effectiveRunId,
eventId: runCreatedEventId,
eventType: 'run_created',
eventData: {
deploymentId: runInputData.deploymentId,
workflowName: runInputData.workflowName,
input: runInputData.input,
executionContext: runInputData.executionContext,
attributes: runInputData.attributes,
allowReservedAttributes: runInputData.allowReservedAttributes,
},
specVersion: effectiveSpecVersion,
});
}
const createdRun = inserted;
if (createdRun) {
currentRun = {
status: 'pending',
specVersion: effectiveSpecVersion,
};
} else {
// Run already exists (concurrent run_created won the
// race). Re-read so downstream logic sees the real state.
const [runValue] = await getRunForValidation.execute({
runId: effectiveRunId,
});
currentRun = runValue ?? null;
}
}
}
}
// ============================================================
// VERSION COMPATIBILITY: Check run spec version
// ============================================================
// For events that have fetched the run, check version compatibility.
// Skip for run_created (no existing run) and runtime events (step_completed, step_retrying).
if (currentRun) {
// Check if run requires a newer world version
if (requiresNewerWorld(currentRun.specVersion)) {
throw new RunNotSupportedError(
currentRun.specVersion!,
SPEC_VERSION_CURRENT
);
}
// Route to legacy handler for pre-event-sourcing runs
if (isLegacySpecVersion(currentRun.specVersion)) {
return handleLegacyEventPostgres(
drizzle,
effectiveRunId,
getEventId(),
data,
currentRun,
params
);
}
}
if (data.eventType === 'attr_set' && !currentRun) {
throw new WorkflowRunNotFoundError(effectiveRunId);
}
// Lazy step start: a step_started carrying step-creation data
// (stepName + input) may arrive with no prior step_created — it creates
// the step on the fly (see the materialization block below). This
// mirrors the resilient run_started path. Detect it here so the
// entity-creation terminal-run guard treats it like a creation and the
// "step must exist" ordering guard below doesn't reject it.
const createsChildEntity = isChildEntityCreationEvent(data);
const lazyStepStart =
createsChildEntity && data.eventType === 'step_started';
// Run terminal state validation
if (currentRun && isTerminalWorkflowRunStatus(currentRun.status)) {
// Idempotent operation: run_cancelled on already cancelled run is allowed
if (
data.eventType === 'run_cancelled' &&
currentRun.status === 'cancelled'
) {
// Get full run for return value
const [fullRun] = await drizzle
.select()
.from(Schema.runs)
.where(eq(Schema.runs.runId, effectiveRunId))
.limit(1);
// Create the event (still record it)
const [value] = await drizzle
.insert(Schema.events)
.values({
runId: effectiveRunId,
eventId: getEventId(),
correlationId: data.correlationId,
eventType: data.eventType,
eventData: 'eventData' in data ? data.eventData : undefined,
specVersion: effectiveSpecVersion,
})
.returning({ createdAt: Schema.events.createdAt });
const result = {
...data,
...value,
runId: effectiveRunId,
eventId: getEventId(),
};
const parsed = EventSchema.parse(result);
const resolveData = params?.resolveData ?? 'all';
return {
event: stripEventDataRefs(parsed, resolveData),
run: fullRun ? deserializeRunError(compact(fullRun)) : undefined,
};
}
// For run_started on terminal runs, use RunExpiredError so the
// runtime knows to exit without retrying.
if (data.eventType === 'run_started') {
throw new RunExpiredError(
`Workflow run "${effectiveRunId}" is already in terminal state "${currentRun.status}"`
);
}
// Other run state transitions are not allowed on terminal runs
if (isTerminalRunEventType(data.eventType)) {
throw new EntityConflictError(
`Cannot transition run from terminal state "${currentRun.status}"`
);
}
// Creating new entities on terminal runs is not allowed. A lazy
// step_started creates a step, so it is rejected here too — a bare
// (non-lazy) step_started falls through to the step-validation block
// below, which uses RunExpiredError for terminal runs.
if (createsChildEntity) {
throw new EntityConflictError(
`Cannot create new entities on run in terminal state "${currentRun.status}"`
);
}
if (data.eventType === 'attr_set') {
throw new EntityConflictError(
`Cannot set attributes on run in terminal state "${currentRun.status}"`
);
}
}
// Step-related event validation (ordering and terminal state)
// Fetch status + startedAt so we can reuse for step_started (avoid double read)
// Skip validation for step_completed/step_failed - use conditional UPDATE instead
let validatedStep: {
status: string;
startedAt: Date | null;
retryAfter: Date | null;
} | null = null;
const stepEventsNeedingValidation = ['step_started', 'step_retrying'];
if (
stepEventsNeedingValidation.includes(data.eventType) &&
data.correlationId
) {
// Use prepared statement for better performance
const [existingStep] = await getStepForValidation.execute({
runId: effectiveRunId,
stepId: data.correlationId,
});
validatedStep = existingStep ?? null;
// Event ordering: step must exist before these events — except on the
// lazy-start path, where step_started creates the step itself.
if (!validatedStep && !lazyStepStart) {
throw new WorkflowWorldError(
`Step "${data.correlationId}" not found`
);
}
// Lazy start exactly-once gate: a lazy step_started always CREATES the
// step (the owned-inline path only sends one for a step whose
// step_created it deferred). If the step already exists, a concurrent
// handler won the create — this caller is a loser and must not start or
// run the step. Throw EntityConflictError so the runtime's executeStep
// maps it to `skipped`. Critical: the start UPDATE below permits
// re-starting a non-terminal step (retries rely on that), so without
// this gate a loser would re-start a running step and run the body a
// second time. (A concurrent create that lands after this read is also
// caught by the onConflictDoNothing()+returning() claim below.)
if (lazyStepStart && validatedStep) {
throw new EntityConflictError(
`Step "${data.correlationId}" already created`
);
}
// Terminal-state checks only apply when the step already exists.
// validatedStep is null only on the lazy-start path (no step yet),
// where there is nothing terminal to guard against.
if (validatedStep) {
// Step terminal state validation
if (isTerminalStepStatus(validatedStep.status)) {
throw new EntityConflictError(
`Cannot modify step in terminal state "${validatedStep.status}"`
);
}
// On terminal runs: only allow completing/failing in-progress steps
if (currentRun && isTerminalWorkflowRunStatus(currentRun.status)) {
if (validatedStep.status !== 'running') {
throw new RunExpiredError(
`Cannot modify non-running step on run in terminal state "${currentRun.status}"`
);
}
}
}
}
// Hook-related event validation (ordering)
if (isHookEventRequiringExistence(data.eventType) && data.correlationId) {
const [existingHook] = await drizzle
.select({ hookId: Schema.hooks.hookId })
.from(Schema.hooks)
.where(eq(Schema.hooks.hookId, data.correlationId))
.limit(1);
if (!existingHook) {
throw new HookNotFoundError(data.correlationId);
}
}
// ============================================================
// Entity creation/updates based on event type
// ============================================================
// Handle run_created event: create the run entity atomically
if (data.eventType === 'run_created') {
const eventData = (data as any).eventData as {
deploymentId: string;
workflowName: string;
input: any[];
executionContext?: Record<string, any>;
attributes?: Record<string, string>;
allowReservedAttributes?: true;
};
validateAttributeChanges(
Object.entries(eventData.attributes ?? {}).map(([key, value]) => ({
key,
value,
})),
{
allowReservedAttributes: eventData.allowReservedAttributes === true,
}
);
const [runValue] = await drizzle
.insert(Schema.runs)
.values({
runId: effectiveRunId,
deploymentId: eventData.deploymentId,
workflowName: eventData.workflowName,
// Propagate specVersion from the event to the run entity
specVersion: effectiveSpecVersion,
input: eventData.input as SerializedContent,
executionContext: eventData.executionContext as
| SerializedContent
| undefined,
attributes: eventData.attributes,
status: 'pending',
})
.onConflictDoNothing()
.returning();
// No row back means the run already exists: the resilient start path
// (run_started on a non-existent run) won a TOCTOU race and created
// it. Surface the conflict rather than returning `{ run: undefined }`
// — start() already treats EntityConflictError as benign, and falling
// through would append a duplicate run_created event to the log.
if (!runValue) {
throw new EntityConflictError(
`Workflow run "${effectiveRunId}" already exists`
);
}
run = deserializeRunError(compact(runValue));
}
// Handle run_started event: update run status
if (data.eventType === 'run_started') {
// If the run is already running, return it without inserting a
// duplicate run_started event. This makes run_started idempotent
// for concurrent invocations: replay is deterministic, so letting
// multiple callers proceed with the same run is safe. We skip
// preloaded events here because this is a rare race-condition path
// — the runtime falls back to loadWorkflowRunEvents().
if (currentRun?.status === 'running') {
const [fullRun] = await drizzle
.select()
.from(Schema.runs)
.where(eq(Schema.runs.runId, effectiveRunId))
.limit(1);
if (fullRun) {
return { run: deserializeRunError(compact(fullRun)) };
}
}
const [runValue] = await drizzle
.update(Schema.runs)
.set({
status: 'running',
startedAt: now,
updatedAt: now,
})
.where(eq(Schema.runs.runId, effectiveRunId))
.returning();
if (runValue) {
run = deserializeRunError(compact(runValue));
}
}
// Terminal run statuses for use in SQL WHERE clauses (atomic guard).
// Must match the Vercel world's conditional expressions:
// ne(status, 'completed') AND ne(status, 'failed') AND ne(status, 'cancelled')
const terminalRunStatuses: (typeof Schema.runs.status.enumValues)[number][] =
[...TERMINAL_WORKFLOW_RUN_STATUSES];
// Handle run_completed event: update run status and cleanup hooks
// Uses conditional UPDATE to prevent completing an already-terminal run.
if (data.eventType === 'run_completed') {
const eventData = (data as any).eventData as { output?: any };
const [runValue] = await drizzle
.update(Schema.runs)
.set({
status: 'completed',
output: eventData.output as SerializedContent | undefined,
completedAt: now,
updatedAt: now,
})
.where(
and(
eq(Schema.runs.runId, effectiveRunId),
notInArray(Schema.runs.status, terminalRunStatuses)
)
)
.returning();
if (runValue) {
run = deserializeRunError(compact(runValue));
} else {
const [existing] = await getRunForValidation.execute({
runId: effectiveRunId,
});
if (!existing) {
throw new WorkflowRunNotFoundError(effectiveRunId);
}
if (isTerminalWorkflowRunStatus(existing.status)) {
throw new EntityConflictError(
`Cannot transition run from terminal state "${existing.status}"`
);
}
}
// Delete all hooks and waits for this run to allow token reuse
await Promise.all([
drizzle
.delete(Schema.hooks)
.where(eq(Schema.hooks.runId, effectiveRunId)),
drizzle
.delete(Schema.waits)
.where(eq(Schema.waits.runId, effectiveRunId)),
]);
}
// Handle run_failed event: update run status and cleanup hooks
// Uses conditional UPDATE to prevent failing an already-terminal run.
if (data.eventType === 'run_failed') {
const eventData = (data as any).eventData as {
error: unknown;
errorCode?: string;
};
// The error field is SerializedData (Uint8Array) produced by
// dehydrateRunError. We store it verbatim in the error_cbor column;
// consumers hydrate via hydrateRunError.
const [runValue] = await drizzle
.update(Schema.runs)
.set({
status: 'failed',
error: eventData.error as SerializedData,
errorCode: eventData.errorCode,
completedAt: now,
updatedAt: now,
})
.where(
and(
eq(Schema.runs.runId, effectiveRunId),
notInArray(Schema.runs.status, terminalRunStatuses)
)
)
.returning();
if (runValue) {
run = deserializeRunError(compact(runValue));
} else {
const [existing] = await getRunForValidation.execute({
runId: effectiveRunId,
});
if (!existing) {
throw new WorkflowRunNotFoundError(effectiveRunId);
}
if (isTerminalWorkflowRunStatus(existing.status)) {
throw new EntityConflictError(
`Cannot transition run from terminal state "${existing.status}"`
);
}
}
// Delete all hooks and waits for this run to allow token reuse
await Promise.all([
drizzle
.delete(Schema.hooks)
.where(eq(Schema.hooks.runId, effectiveRunId)),
drizzle
.delete(Schema.waits)
.where(eq(Schema.waits.runId, effectiveRunId)),
]);
}
// Handle run_cancelled event: update run status and cleanup hooks
// Uses conditional UPDATE to prevent cancelling an already-terminal run.
// Note: idempotent run_cancelled on already-cancelled runs is handled
// earlier in the pre-validation block (creates event and returns early).
if (data.eventType === 'run_cancelled') {
const [runValue] = await drizzle
.update(Schema.runs)
.set({
status: 'cancelled',
completedAt: now,
updatedAt: now,
})
.where(
and(
eq(Schema.runs.runId, effectiveRunId),
notInArray(Schema.runs.status, terminalRunStatuses)
)
)
.returning();
if (runValue) {
run = deserializeRunError(compact(runValue));
} else {
const [existing] = await getRunForValidation.execute({
runId: effectiveRunId,
});
if (!existing) {
throw new WorkflowRunNotFoundError(effectiveRunId);
}
if (isTerminalWorkflowRunStatus(existing.status)) {
throw new EntityConflictError(
`Cannot transition run from terminal state "${existing.status}"`
);
}
}
// Delete all hooks and waits for this run to allow token reuse
await Promise.all([
drizzle
.delete(Schema.hooks)
.where(eq(Schema.hooks.runId, effectiveRunId)),
drizzle
.delete(Schema.waits)
.where(eq(Schema.waits.runId, effectiveRunId)),
]);
}
if (data.eventType === 'attr_set') {
const { changes, allowReservedAttributes } = data.eventData;
// Dedup pre-check for correlated workflow writes: if the event is
// already in the log (a redelivered/replayed duplicate), reject
// BEFORE materializing onto the run. Without this, a duplicate —
// including a pathological one carrying different changes for the
// same correlationId — would mutate `run.attributes` and then fail
// the event insert, leaving the snapshot out of sync with the
// event log. The unique index on the insert below still guards the
// truly-concurrent race; both writers of that race carry identical
// changes (deterministic replay), so the double-applied update is
// idempotent there.
if (data.correlationId && data.eventData.writer.type === 'workflow') {
const [duplicate] = await drizzle
.select({ eventId: events.eventId })
.from(events)
.where(
and(
eq(events.runId, effectiveRunId),
eq(events.correlationId, data.correlationId),
eq(events.eventType, 'attr_set')
)
)
.limit(1);
if (duplicate) {
throw new EntityConflictError(
`attr_set for correlationId "${data.correlationId}" already exists in run "${effectiveRunId}"`
);
}
}
const [existing] = await drizzle
.select({ attributes: Schema.runs.attributes })
.from(Schema.runs)
.where(eq(Schema.runs.runId, effectiveRunId))
.limit(1);
if (!existing) {
throw new WorkflowRunNotFoundError(effectiveRunId);
}
validateAttributeChanges(changes, {
existingKeys: Object.keys(existing.attributes ?? {}),
allowReservedAttributes: allowReservedAttributes === true,
});
let expr = sql`COALESCE(${Schema.runs.attributes}, '{}'::jsonb)`;
for (const { key, value } of changes) {
if (value === null) {
expr = sql`${expr} - ${key}`;
} else {
expr = sql`jsonb_set(${expr}, ARRAY[${key}]::text[], to_jsonb(${value}::text), true)`;
}
}
const [runValue] = await drizzle
.update(Schema.runs)
.set({
attributes: expr as any,
updatedAt: now,
})
.where(
and(
eq(Schema.runs.runId, effectiveRunId),
sql`(SELECT COUNT(*) FROM jsonb_object_keys(${expr})) <= ${ATTRIBUTE_MAX_PER_RUN}`
)
)
.returning();
if (!runValue) {
// The guarded update matches zero rows either because the cap
// condition failed or because the run row disappeared between the
// existence check above and this update — distinguish the two so
// the error is not misattributed.
const [stillExists] = await drizzle
.select({ runId: Schema.runs.runId })
.from(Schema.runs)
.where(eq(Schema.runs.runId, effectiveRunId))
.limit(1);
if (!stillExists) {
throw new WorkflowRunNotFoundError(effectiveRunId);
}
throw new AttributeValidationError(
`Run attribute count would exceed limit ${ATTRIBUTE_MAX_PER_RUN}`
);
}
run = deserializeRunError(compact(runValue));
}
// Strip eventData from run_started — it belongs on run_created only.
// For step_started on the lazy-start path, strip only the step `input`
// (it belongs on the synthetic step_created written below); `stepName`
// is preserved for the client replay consumer's step-name divergence
// check.
let storedEventData: unknown;
if (data.eventType === 'run_started') {
storedEventData = undefined;
} else if ('eventData' in data && data.eventData) {
if (
data.eventType === 'step_started' &&
'input' in (data.eventData as Record<string, unknown>)
) {
const { input: _strippedInput, ...rest } = data.eventData as {
input?: unknown;
} & Record<string, unknown>;
storedEventData = rest;
} else {
storedEventData = data.eventData;
}
} else {
storedEventData = undefined;
}
// Handle step_created event: create step entity
if (data.eventType === 'step_created') {
const eventData = (data as any).eventData as {
stepName: string;
input: any;
};
const [stepValue] = await drizzle
.insert(Schema.steps)
.values({
runId: effectiveRunId,
stepId: data.correlationId!,
stepName: eventData.stepName,
input: eventData.input as SerializedContent,
status: 'pending',
attempt: 0,
// Propagate specVersion from the event to the step entity
specVersion: effectiveSpecVersion,
})
.onConflictDoNothing()
.returning();
if (stepValue) {
step = deserializeStepError(compact(stepValue));
}
}
let value: { createdAt: Date } | undefined;
// Handle step_started event: increment attempt and set the step to
// running, then write the matching event log entry in the same
// transaction. The guarded UPDATE takes the step row lock; keeping the
// event INSERT behind that lock prevents a late step_started from being
// ordered after a concurrent terminal event that already won the row.
if (data.eventType === 'step_started') {
value = await drizzle.transaction(async (tx) => {
// Lazy step start: no prior step_created exists, but this
// step_started carries the step-creation data. The step INSERT is
// the ownership claim: only the caller that inserts the row gets to
// run the step body inline.
if (lazyStepStart && !validatedStep) {
const lazyData = data.eventData;
const [inserted] = await tx
.insert(Schema.steps)
.values({
runId: effectiveRunId,
stepId: data.correlationId,
stepName: lazyData.stepName,
input: lazyData.input as SerializedContent,
status: 'pending',
attempt: 0,
specVersion: effectiveSpecVersion,
})
.onConflictDoNothing()
.returning({ stepId: Schema.steps.stepId });
if (!inserted) {
throw new EntityConflictError(
`Step "${data.correlationId}" already created`
);
}
// Replay still needs to observe step_created before
// step_started. Because this synthetic event is in the same
// transaction as the lazy step row and step_started event, we
// cannot leave behind only one side of that materialization.
const stepCreatedEventId = `wevt_${ulid()}`;
await tx
.insert(events)
.values({
runId: effectiveRunId,
eventId: stepCreatedEventId,
correlationId: data.correlationId,
eventType: 'step_created',
eventData: {
stepName: lazyData.stepName,
input: lazyData.input,
},
specVersion: effectiveSpecVersion,
})
.onConflictDoNothing();
stepCreatedLazily = true;
}
// Retried steps may be scheduled for later. Keep this check inside
// the transaction so the step_started write cannot slip past it.
if (
validatedStep?.retryAfter &&
validatedStep.retryAfter.getTime() > Date.now()
) {
throw new TooEarlyError(
`Cannot start step "${data.correlationId}": retryAfter timestamp has not been reached yet`,
{
retryAfter: Math.ceil(
(validatedStep.retryAfter.getTime() - Date.now()) / 1000
),
}
);
}
// The terminal-state guard is part of the UPDATE, not just the
// earlier validation read. That closes the race where another
// writer completes/fails the step between validation and start.
const [stepValue] = await tx
.update(Schema.steps)
.set({
status: 'running',
attempt: sql`${Schema.steps.attempt} + 1`,
// Preserve the original first-start timestamp across retries or
// overlapping starts.
startedAt: sql`COALESCE(${Schema.steps.startedAt}, ${now.toISOString()})`,
retryAfter: null,
})
.where(
and(
eq(Schema.steps.runId, effectiveRunId),
eq(Schema.steps.stepId, data.correlationId!),
notInArray(Schema.steps.status, terminalStepStatuses)
)
)
.returning();
if (stepValue) {
step = deserializeStepError(compact(stepValue));
} else {
const [existing] = await tx
.select({ status: Schema.steps.status })
.from(Schema.steps)
.where(
and(
eq(Schema.steps.runId, effectiveRunId),
eq(Schema.steps.stepId, data.correlationId!)
)
)
.limit(1);
if (!existing) {
throw new WorkflowWorldError(
`Step "${data.correlationId}" not found`
);
}
if (isTerminalStepStatus(existing.status)) {
throw new EntityConflictError(
`Cannot modify step in terminal state "${existing.status}"`
);
}
}
// Allocate the step_started ULID only after the guarded step UPDATE
// has acquired and passed the row lock. Without a sequence, this is
// the local ordering guarantee we can provide: a writer blocked on
// the step row will not carry an older event id into a later insert.
const stepStartedEventId = `wevt_${ulid()}`;
eventId = stepStartedEventId;
const [eventValue] = await tx
.insert(events)
.values({
runId: effectiveRunId,
eventId: stepStartedEventId,
correlationId: data.correlationId,
eventType: data.eventType,
eventData: storedEventData,
specVersion: effectiveSpecVersion,
})
.returning({ createdAt: events.createdAt });
if (!eventValue) {
throw new EntityConflictError(
`Event ${stepStartedEventId} could not be created`
);
}
return eventValue;
});
}
// Handle step_completed event: update step status
// Uses conditional UPDATE to prevent completing an already-terminal step.
if (data.eventType === 'step_completed') {
const eventData = (data as any).eventData as { result?: any };
const [stepValue] = await drizzle
.update(Schema.steps)
.set({
status: 'completed',
output: eventData.result as SerializedContent | undefined,
completedAt: now,
})
.where(
and(
eq(Schema.steps.runId, effectiveRunId),
eq(Schema.steps.stepId, data.correlationId!),
notInArray(Schema.steps.status, terminalStepStatuses)
)
)
.returning();
if (stepValue) {
step = deserializeStepError(compact(stepValue));
} else {
// Step not updated - check if it exists and why
const [existing] = await getStepForValidation.execute({
runId: effectiveRunId,
stepId: data.correlationId!,
});
if (!existing) {
throw new WorkflowWorldError(
`Step "${data.correlationId}" not found`
);
}
if (isTerminalStepStatus(existing.status)) {
throw new EntityConflictError(
`Cannot modify step in terminal state "${existing.status}"`
);
}
}
}
// Handle step_failed event: terminal state with error
// Uses conditional UPDATE to prevent failing an already-terminal step.
if (data.eventType === 'step_failed') {
const eventData = (data as any).eventData as {
error?: unknown;
};
// The error field is SerializedData (Uint8Array) produced by
// dehydrateStepError. We store it verbatim in the error_cbor column;
// consumers hydrate via hydrateStepError.
const [stepValue] = await drizzle
.update(Schema.steps)
.set({
status: 'failed',
error: eventData.error as SerializedData,
completedAt: now,
})
.where(
and(
eq(Schema.steps.runId, effectiveRunId),
eq(Schema.steps.stepId, data.correlationId!),
notInArray(Schema.steps.status, terminalStepStatuses)
)
)
.returning();
if (stepValue) {
step = deserializeStepError(compact(stepValue));
} else {
// Step not updated - check if it exists and why
const [existing] = await getStepForValidation.execute({
runId: effectiveRunId,
stepId: data.correlationId!,
});
if (!existing) {
throw new WorkflowWorldError(
`Step "${data.correlationId}" not found`
);
}
if (isTerminalStepStatus(existing.status)) {
throw new EntityConflictError(
`Cannot modify step in terminal state "${existing.status}"`
);
}
}
}
// Handle step_retrying event: sets status back to 'pending', records error
// Uses conditional UPDATE to prevent retrying an already-terminal step.
if (data.eventType === 'step_retrying') {
const eventData = (data as any).eventData as {
error?: unknown;
retryAfter?: Date;
};
const [stepValue] = await drizzle
.update(Schema.steps)
.set({
status: 'pending',
error: eventData.error as SerializedData,
retryAfter: eventData.retryAfter,
})
.where(
and(
eq(Schema.steps.runId, effectiveRunId),
eq(Schema.steps.stepId, data.correlationId!),
notInArray(Schema.steps.status, terminalStepStatuses)
)
)
.returning();
if (stepValue) {
step = deserializeStepError(compact(stepValue));
} else {
// Step not updated - check if it exists and why
const [existing] = await getStepForValidation.execute({
runId: effectiveRunId,
stepId: data.correlationId!,
});
if (!existing) {
throw new WorkflowWorldError(
`Step "${data.correlationId}" not found`
);
}
if (isTerminalStepStatus(existing.status)) {
throw new EntityConflictError(
`Cannot modify step in terminal state "${existing.status}"`
);
}
}
}
// Handle hook_created event: create hook entity
// Uses prepared statement for token uniqueness check (performance optimization)
if (data.eventType === 'hook_created') {
const eventData = (data as any).eventData as {
token: string;
metadata?: any;
isWebhook?: boolean;
isSystem?: boolean;
};
// Check for duplicate token using prepared statement
const [existingHook] = await getHookByToken.execute({
token: eventData.token,
});
if (existingHook) {
// Idempotency: if the existing hook is the *same* (runId, hookId)
// we are trying to create, this is either a duplicate / replayed
// processing of the same hook_created (not a real conflict), or
// an orphaned hook row from a prior crashed attempt (the hook
// INSERT below landed but the events INSERT below didn't —
// these writes are not in one transaction). Distinguish by
// checking whether the `hook_created` event actually exists in
// the event log:
// - exists → real duplicate: throw EntityConflictError so the
// runtime's concurrent-replay catch path (matching the
// step_created path) swallows it, instead of producing a
// self-conflict in the event log that would later replay
// as HookConflictError.
// See https://github.com/vercel/workflow/issues/2283.
// - missing → orphaned hook row (crash between hook INSERT
// and events INSERT): skip the hook insert (the existing
// row already has the desired state) and fall through to
// the events INSERT below, completing the partial write.
if (
existingHook.runId === effectiveRunId &&
existingHook.hookId === data.correlationId
) {
const [existingEvent] = await getHookCreatedEvent.execute({
runId: effectiveRunId,
correlationId: data.correlationId,
eventType: 'hook_created',
});
if (existingEvent) {
throw new EntityConflictError(
`Hook "${data.correlationId}" already created`
);
}
// Orphaned hook row: hook row exists but no hook_created
// event in the log. Skip the hook insert below (the row
// already exists with our (runId, hookId)) and let the
// outer code path emit the hook_created event, completing
// the partial write. We also re-fetch the existing hook
// row so the EventResult carries the actual persisted
// entity rather than `undefined`.
const [recoveredHookValue] = await drizzle
.select()
.from(Schema.hooks)
.where(eq(Schema.hooks.hookId, data.correlationId!))
.limit(1);
if (recoveredHookValue) {
recoveredHookValue.metadata ||= recoveredHookValue.metadataJson;
hook = HookSchema.parse(compact(recoveredHookValue));
}
} else {
// Cross-hook / cross-run conflict: a different
// (runId, hookId) holds this token. Create a hook_conflict
// event instead of throwing 409 — this lets the workflow
// continue and fail gracefully when the hook is awaited.
const conflictEventData = {
token: eventData.token,
conflictingRunId: existingHook.runId,
};
const conflictEventId = getEventId();
const [conflictValue] = await drizzle
.insert(events)
.values({
runId: effectiveRunId,
eventId: conflictEventId,
correlationId: data.correlationId,
eventType: 'hook_conflict',
eventData: conflictEventData,
specVersion: effectiveSpecVersion,
})
.returning({ createdAt: events.createdAt });
if (!conflictValue) {
throw new EntityConflictError(
`Event ${conflictEventId} could not be created`
);
}
const conflictResult = {
eventType: 'hook_conflict' as const,
correlationId: data.correlationId,
eventData: conflictEventData,
...conflictValue,
runId: effectiveRunId,
eventId: conflictEventId,
};
const parsedConflict = EventSchema.parse(conflictResult);
const resolveData = params?.resolveData ?? 'all';
return {
event: stripEventDataRefs(parsedConflict, resolveData),
run,
step,
hook: undefined,
};
}
} else {
const [hookValue] = await drizzle
.insert(Schema.hooks)
.values({
runId: effectiveRunId,
hookId: data.correlationId!,
token: eventData.token,
metadata: eventData.metadata as SerializedContent,
ownerId: '', // TODO: get from context
projectId: '', // TODO: get from context
environment: '', // TODO: get from context
// Propagate specVersion from the event to the hook entity
specVersion: effectiveSpecVersion,
isWebhook: eventData.isWebhook,
isSystem: eventData.isSystem ?? false,
})
.onConflictDoNothing()
.returning();
if (hookValue) {
hookValue.metadata ||= hookValue.metadataJson;
hook = HookSchema.parse(compact(hookValue));
}
}
}
// Handle hook_disposed event: delete hook entity atomically.
// Uses DELETE ... RETURNING to ensure only one concurrent caller
// succeeds — if no rows are returned, the hook was already disposed.
if (data.eventType === 'hook_disposed' && data.correlationId) {
const [deleted] = await drizzle
.delete(Schema.hooks)
.where(eq(Schema.hooks.hookId, data.correlationId))
.returning({ hookId: Schema.hooks.hookId });
if (!deleted) {
throw new EntityConflictError(
`Hook "${data.correlationId}" already disposed`
);
}
}
// Handle hook_received event: append the event only if the run has
// not reached a terminal state. hook_received has no branch in the
// terminal-run guard above (it doesn't transition the run or create
// an entity), so without this, the generic INSERT further below
// could append a hook_received event after a concurrent
// run_completed / run_failed / run_cancelled has already committed.
// `FOR UPDATE` takes the run row lock inside this transaction: it
// blocks until any in-flight terminal transition — whose own
// conditional UPDATE takes the same row lock — commits, then
// observes the post-commit status. That linearizes this insert
// against the run's terminal transition the same way step_started's
// guarded UPDATE linearizes against a concurrent terminal step
// event.
if (data.eventType === 'hook_received') {
value = await drizzle.transaction(async (tx) => {
const [runRow] = await tx
.select({ status: Schema.runs.status })
.from(Schema.runs)
.where(eq(Schema.runs.runId, effectiveRunId))
.for('update')
.limit(1);
if (!runRow) {
throw new WorkflowRunNotFoundError(effectiveRunId);
}
if (isTerminalWorkflowRunStatus(runRow.status)) {
throw new RunExpiredError(
`Workflow run "${effectiveRunId}" is already in terminal state "${runRow.status}"`
);
}
// Allocate the ULID only after the row lock is acquired,
// matching step_started's ordering guarantee: a writer blocked
// on the run row must not carry an older event id into a later
// insert.
const hookReceivedEventId = `wevt_${ulid()}`;
eventId = hookReceivedEventId;
const [eventValue] = await tx
.insert(events)
.values({
runId: effectiveRunId,
eventId: hookReceivedEventId,
correlationId: data.correlationId,
eventType: data.eventType,
eventData: storedEventData,
specVersion: effectiveSpecVersion,
})
.returning({ createdAt: events.createdAt });
if (!eventValue) {
throw new EntityConflictError(
`Event ${hookReceivedEventId} could not be created`
);
}
return eventValue;
});
}
// Handle wait_created event: create wait entity
if (data.eventType === 'wait_created') {
const eventData = (data as any).eventData as {
resumeAt?: Date;
};
const waitId = `${effectiveRunId}-${data.correlationId}`;
const [waitValue] = await drizzle
.insert(Schema.waits)
.values({
waitId,
runId: effectiveRunId,
status: 'waiting',
resumeAt: eventData.resumeAt,
specVersion: effectiveSpecVersion,
})
.onConflictDoNothing()
.returning();
if (waitValue) {
wait = {
waitId: waitValue.waitId,
runId: waitValue.runId,
status: waitValue.status,
resumeAt: waitValue.resumeAt ?? undefined,
completedAt: waitValue.completedAt ?? undefined,
createdAt: waitValue.createdAt,
updatedAt: waitValue.updatedAt,
specVersion: waitValue.specVersion ?? undefined,
};
} else {
throw new EntityConflictError(
`Wait "${data.correlationId}" already exists`
);
}
}
// Handle wait_completed event: transition wait to 'completed'
// Uses conditional UPDATE to reject duplicate completions (same pattern as step_completed)
if (data.eventType === 'wait_completed') {
const waitId = `${effectiveRunId}-${data.correlationId}`;
const [waitValue] = await drizzle
.update(Schema.waits)
.set({
status: 'completed',
completedAt: now,
})
.where(
and(
eq(Schema.waits.waitId, waitId),
eq(Schema.waits.status, 'waiting')
)
)
.returning();
if (waitValue) {
wait = {
waitId: waitValue.waitId,
runId: waitValue.runId,
status: waitValue.status,
resumeAt: waitValue.resumeAt ?? undefined,
completedAt: waitValue.completedAt ?? undefined,
createdAt: waitValue.createdAt,
updatedAt: waitValue.updatedAt,
specVersion: waitValue.specVersion ?? undefined,
};
} else {
// Wait not updated - check if it exists and why
const [existing] = await getWaitForValidation.execute({
waitId,
});
if (!existing) {
throw new WorkflowWorldError(
`Wait "${data.correlationId}" not found`
);
}
if (existing.status === 'completed') {
throw new EntityConflictError(
`Wait "${data.correlationId}" already completed`
);
}
}
}
try {
if (!value) {
[value] = await drizzle
.insert(events)
.values({
runId: effectiveRunId,
eventId: getEventId(),
correlationId: data.correlationId,
eventType: data.eventType,
eventData: storedEventData,
specVersion: effectiveSpecVersion,
})
.returning({ createdAt: events.createdAt });
}
} catch (err) {
// Translate unique-violation on the correlated-event partial index
// (workflow_events_entity_creation_unique) into EntityConflictError
// so the runtime's existing dedup catch path can handle it. Without
// this, two concurrent invocations producing identical
// correlationIds (e.g. snapshot runtime deterministic ULIDs) would
// surface as unhandled DB errors instead of dedup signals.
// Drizzle wraps the underlying pg error in DrizzleQueryError; the
// pg error (with .code === '23505') lives on .cause. We additionally
// gate on the violated constraint name so other 23505 violations on
// these event types (e.g. the events primary key, or any future
// unique constraint we might add) don't get misclassified as a
// correlationId conflict.
const isDeduplicatedCorrelatedEvent =
isChildEntityCreationEventType(data.eventType) ||
(data.eventType === 'attr_set' &&
data.eventData.writer.type === 'workflow');
const pgErr = (err as { code?: string; constraint?: string }).code
? (err as { code?: string; constraint?: string })
: ((err as { cause?: { code?: string; constraint?: string } })
.cause ?? {});
const pgCode = pgErr.code;
const pgConstraint = pgErr.constraint;
if (
isDeduplicatedCorrelatedEvent &&
pgCode === '23505' &&
pgConstraint === 'workflow_events_entity_creation_unique'
) {
throw new EntityConflictError(
`${data.eventType} for correlationId "${data.correlationId}" already exists in run "${effectiveRunId}"`
);
}
throw err;
}
if (!value) {
throw new EntityConflictError(
`Event ${getEventId()} could not be created`
);
}
const result = {
...data,
...value,
runId: effectiveRunId,
eventId: getEventId(),
...(storedEventData !== undefined
? { eventData: storedEventData }
: {}),
};
// Strip eventData leaked by ...data spread for run_started events.
// The eventData (run input for resilient start) belongs on
// run_created only; storedEventData is already undefined above.
if (data.eventType === 'run_started') {
delete (result as any).eventData;
}
const parsed = EventSchema.parse(result);
const resolveData = params?.resolveData ?? 'all';
// For run_started: include all events so the runtime can skip
// the initial events.list call and reduce TTFB.
let allEvents: Event[] | undefined;
let cursor: string | null | undefined;
let hasMore: boolean | undefined;
if (data.eventType === 'run_started' && run) {
const eventRows = await drizzle
.select()
.from(Schema.events)
.where(eq(Schema.events.runId, effectiveRunId))
.orderBy(Schema.events.eventId);
allEvents = eventRows.map((e) => {
e.eventData ||= e.eventDataJson;
const parsed = EventSchema.parse(compact(e));
return stripEventDataRefs(parsed, resolveData);
});
cursor = allEvents.at(-1)?.eventId ?? null;
hasMore = false;
}
return {
event: stripEventDataRefs(parsed, resolveData),
run,
step,
hook,
wait,
events: allEvents,
cursor,
hasMore,
...(stepCreatedLazily ? { stepCreated: true } : {}),
};
},
async get(
runId: string,
eventId: string,
params?: GetEventParams
): Promise<Event> {
const [value] = await drizzle
.select()
.from(events)
.where(and(eq(events.runId, runId), eq(events.eventId, eventId)))
.limit(1);
if (!value) {
throw new WorkflowWorldError(`Event not found: ${eventId}`);
}
value.eventData ||= value.eventDataJson;
const parsed = EventSchema.parse(compact(value));
const resolveData = params?.resolveData ?? 'all';
return stripEventDataRefs(parsed, resolveData);
},
async list(params: ListEventsParams): Promise<PaginatedResponse<Event>> {
const limit = params?.pagination?.limit ?? 100;
const sortOrder = params.pagination?.sortOrder || 'asc';
const order =
sortOrder === 'desc'
? { by: desc(events.eventId), compare: lt }
: { by: events.eventId, compare: gt };
const all = await drizzle
.select()
.from(events)
.where(
and(
eq(events.runId, params.runId),
map(params.pagination?.cursor, (c) =>
order.compare(events.eventId, c)
)
)
)
.orderBy(order.by)
.limit(limit + 1);
const values = all.slice(0, limit);
const resolveData = params?.resolveData ?? 'all';
return {
data: values.map((v) => {
v.eventData ||= v.eventDataJson;
const parsed = EventSchema.parse(compact(v));
return stripEventDataRefs(parsed, resolveData);
}),
cursor: values.at(-1)?.eventId ?? null,
hasMore: all.length > limit,
};
},
async listByCorrelationId(params) {
const limit = params?.pagination?.limit ?? 100;
const sortOrder = params.pagination?.sortOrder || 'asc';
const order =
sortOrder === 'desc'
? { by: desc(events.eventId), compare: lt }
: { by: events.eventId, compare: gt };
const all = await drizzle
.select()
.from(events)
.where(
and(
eq(events.correlationId, params.correlationId),
map(params.pagination?.cursor, (c) =>
order.compare(events.eventId, c)
)
)
)
.orderBy(order.by)
.limit(limit + 1);
const values = all.slice(0, limit);
const resolveData = params?.resolveData ?? 'all';
return {
data: values.map((v) => {
v.eventData ||= v.eventDataJson;
const parsed = EventSchema.parse(compact(v));
return stripEventDataRefs(parsed, resolveData);
}),
cursor: values.at(-1)?.eventId ?? null,
hasMore: all.length > limit,
};
},
};
}
export function createHooksStorage(drizzle: Drizzle): Storage['hooks'] {
const { hooks } = Schema;
const getByToken = drizzle
.select()
.from(hooks)
.where(eq(hooks.token, sql.placeholder('token')))
.limit(1)
.prepare('workflow_hooks_get_by_token');
return {
async get(hookId, params) {
const [value] = await drizzle
.select()
.from(hooks)
.where(eq(hooks.hookId, hookId))
.limit(1);
value.metadata ||= value.metadataJson;
const parsed = HookSchema.parse(compact(value));
parsed.isWebhook ??= true;
const resolveData = params?.resolveData ?? 'all';
return filterHookData(parsed, resolveData);
},
async getByToken(token, params) {
const [value] = await getByToken.execute({ token });
if (!value) {
throw new HookNotFoundError(token);
}
value.metadata ||= value.metadataJson;
const parsed = HookSchema.parse(compact(value));
parsed.isWebhook ??= true;
const resolveData = params?.resolveData ?? 'all';
return filterHookData(parsed, resolveData);
},
async list(params: ListHooksParams) {
const limit = params?.pagination?.limit ?? 100;
const fromCursor = params?.pagination?.cursor;
const sortOrder = params?.pagination?.sortOrder ?? 'asc';
const orderFn = sortOrder === 'asc' ? asc : desc;
const cursorFn = sortOrder === 'asc' ? gt : lt;
const all = await drizzle
.select()
.from(hooks)
.where(
and(
map(params.runId, (id) => eq(hooks.runId, id)),
map(fromCursor, (c) => cursorFn(hooks.hookId, c))
)
)
.orderBy(orderFn(hooks.hookId))
.limit(limit + 1);
const values = all.slice(0, limit);
const hasMore = all.length > limit;
const resolveData = params?.resolveData ?? 'all';
return {
data: values.map((v) => {
v.metadata ||= v.metadataJson;
const parsed = HookSchema.parse(compact(v));
return filterHookData(parsed, resolveData);
}),
cursor: values.at(-1)?.hookId ?? null,
hasMore,
};
},
};
}
export function createStepsStorage(drizzle: Drizzle): Storage['steps'] {
const { steps } = Schema;
return {
get: (async (runId, stepId, params) => {
const [value] = await drizzle
.select()
.from(steps)
.where(and(eq(steps.runId, runId), eq(steps.stepId, stepId)))
.limit(1);
if (!value) {
throw new WorkflowWorldError(`Step not found: ${stepId}`);
}
value.output ||= value.outputJson;
value.input ||= value.inputJson;
value.error ||= parseErrorJson(value.errorJson);
const deserialized = deserializeStepError(compact(value));
const parsed = StepSchema.parse(deserialized);
const resolveData = params?.resolveData ?? 'all';
return filterStepData(parsed, resolveData);
}) as Storage['steps']['get'],
list: (async (params) => {
const limit = params?.pagination?.limit ?? 20;
const fromCursor = params?.pagination?.cursor;
const all = await drizzle
.select()
.from(steps)
.where(
and(
eq(steps.runId, params.runId),
map(fromCursor, (c) => lt(steps.stepId, c))
)
)
.orderBy(desc(steps.stepId))
.limit(limit + 1);
const values = all.slice(0, limit);
const hasMore = all.length > limit;
const resolveData = params?.resolveData ?? 'all';
return {
data: values.map((v) => {
v.output ||= v.outputJson;
v.input ||= v.inputJson;
v.error ||= parseErrorJson(v.errorJson);
const deserialized = deserializeStepError(compact(v));
const parsed = StepSchema.parse(deserialized);
return filterStepData(parsed, resolveData);
}),
hasMore,
cursor: values.at(-1)?.stepId ?? null,
};
}) as Storage['steps']['list'],
};
}
function filterStepData(step: Step, resolveData: 'none'): StepWithoutData;
function filterStepData(step: Step, resolveData: 'all'): Step;
function filterStepData(
step: Step,
resolveData: ResolveData
): Step | StepWithoutData;
function filterStepData(
step: Step,
resolveData: ResolveData
): Step | StepWithoutData {
if (resolveData === 'none') {
const { input: _, output: __, ...rest } = step;
return { input: undefined, output: undefined, ...rest };
}
return step;
}
function filterRunData(
run: WorkflowRun,
resolveData: 'none'
): WorkflowRunWithoutData;
function filterRunData(run: WorkflowRun, resolveData: 'all'): WorkflowRun;
function filterRunData(
run: WorkflowRun,
resolveData: ResolveData
): WorkflowRun | WorkflowRunWithoutData;
function filterRunData(
run: WorkflowRun,
resolveData: ResolveData
): WorkflowRun | WorkflowRunWithoutData {
if (resolveData === 'none') {
const { input: _, output: __, ...rest } = run;
return { input: undefined, output: undefined, ...rest };
}
return run;
}
function filterHookData(hook: Hook, resolveData: ResolveData): Hook {
if (resolveData === 'none' && 'metadata' in hook) {
const { metadata: _, ...rest } = hook;
return { metadata: undefined, ...rest };
}
return hook;
}