workerflow 0.1.0 → 0.2.0

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/src/runtime.ts CHANGED
@@ -117,19 +117,61 @@ export abstract class WorkflowRuntime<
117
117
  * @returns An array containing the formatted steps for all steps in the
118
118
  * workflow.
119
119
  */
120
- getSteps_experimental(): Step[] {
120
+ getSteps_experimental(): Array<(RunStep & { attempts: RunStepAttempt[] }) | SleepStep | WaitStep> {
121
121
  const steps = this.sql.exec<Step_Row>("SELECT * FROM steps ORDER BY created_at ASC").toArray();
122
- return steps.map((step) => formatStep(step));
122
+ return steps.map((step) => {
123
+ if (step.type === "run") {
124
+ const attempts = this.sql
125
+ .exec<RunStepAttempt_Row>(
126
+ `SELECT * FROM run_step_attempts WHERE step_id = ? ORDER BY started_at ASC, id ASC`,
127
+ step.id
128
+ )
129
+ .toArray();
130
+ return {
131
+ ...formatRunStep(step),
132
+ attempts: attempts.map((attempt) => formatRunStepAttempt(attempt))
133
+ };
134
+ }
135
+ if (step.type === "sleep") {
136
+ return formatSleepStep(step);
137
+ } else if (step.type === "wait") {
138
+ if (step.state === "satisfied") {
139
+ return formatSatisfiedWaitStep(step, this.getInboundEventForWaitStep(step.id).payload);
140
+ } else if (step.state === "timed_out") {
141
+ return formatTimedOutWaitStep(step);
142
+ } else {
143
+ return formatWaitingWaitStep(step);
144
+ }
145
+ } else {
146
+ throw new Error("Unexpected step type. Expected 'run', 'sleep', or 'wait'.");
147
+ }
148
+ });
123
149
  }
124
150
 
125
151
  /**
126
- * Retrieves all durable step events in the workflow, ordered by recording time.
152
+ * Loads the `inbound_events` row whose `claimed_by` is this wait step (`waitStepId`).
127
153
  *
128
- * @returns Formatted `step_events` rows for the workflow instance.
154
+ * @throws If no such row exists (storage invariant broken or the step is not in a satisfied state with a claim).
129
155
  */
130
- getStepEvents_experimental(): StepEvent[] {
131
- const events = this.sql.exec<StepEventRow>("SELECT * FROM step_events ORDER BY recorded_at ASC");
132
- return events.toArray().map((row) => formatStepEvent(row));
156
+ private getInboundEventForWaitStep<T extends Json | undefined = Json | undefined>(
157
+ stepId: WaitStepId
158
+ ): InboundEvent<T> {
159
+ const [row] = this.sql
160
+ .exec<ClaimedInboundEvent_Row>(`SELECT * FROM inbound_events WHERE claimed_by = ? LIMIT 1`, stepId)
161
+ .toArray();
162
+ if (row === undefined) {
163
+ throw new Error(
164
+ `Wait step '${stepId}' is satisfied in durable state but no inbound_events row is claimed by this step.`
165
+ );
166
+ }
167
+ return {
168
+ id: row.id,
169
+ eventName: row.event_name,
170
+ payload: (row.payload === null ? undefined : JSON.parse(row.payload)) as T,
171
+ createdAt: new Date(row.created_at),
172
+ claimedBy: row.claimed_by,
173
+ claimedAt: new Date(row.claimed_at)
174
+ };
133
175
  }
134
176
 
135
177
  /**
@@ -157,7 +199,9 @@ export abstract class WorkflowRuntime<
157
199
  return;
158
200
  }
159
201
 
160
- const serializedPayload = payload !== undefined ? JSON.stringify(payload) : null;
202
+ // SQL NULL encodes `undefined` (no payload); raw JSON.stringify for everything else
203
+ // (including JSON null, which becomes the TEXT literal 'null').
204
+ const serializedPayload = payload === undefined ? null : JSON.stringify(payload);
161
205
 
162
206
  // If the workflow is paused, queue the event but do not satisfy any wait step or call run().
163
207
  // The event will be picked up when the workflow is resumed and execution hits getOrCreateWaitStep.
@@ -184,34 +228,24 @@ export abstract class WorkflowRuntime<
184
228
  .toArray();
185
229
 
186
230
  if (step !== undefined) {
187
- this.sql.exec(
188
- `UPDATE steps
231
+ this.ctx.storage.transactionSync(() => {
232
+ this.sql.exec(
233
+ `INSERT INTO inbound_events (event_name, payload, claimed_by, claimed_at)
234
+ VALUES (?, ?, ?, CAST(unixepoch('subsecond') * 1000 AS INTEGER))`,
235
+ event,
236
+ serializedPayload,
237
+ step.id
238
+ );
239
+ this.sql.exec(
240
+ `UPDATE steps
189
241
  SET state = 'satisfied',
190
- payload = ?,
191
- resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER),
192
- timeout_at = NULL
242
+ resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER)
193
243
  WHERE id = ?
194
244
  AND type = 'wait'
195
245
  AND state = 'waiting'`,
196
- serializedPayload,
197
- step.id
198
- );
199
-
200
- this.sql.exec(
201
- `INSERT INTO step_events (step_id, type, payload)
202
- VALUES (?, ?, ?)`,
203
- step.id,
204
- "wait_satisfied",
205
- serializedPayload
206
- );
207
-
208
- this.sql.exec(
209
- `INSERT INTO inbound_events (event_name, payload, claimed_by, claimed_at)
210
- VALUES (?, ?, ?, CAST(unixepoch('subsecond') * 1000 AS INTEGER))`,
211
- event,
212
- serializedPayload,
213
- step.id
214
- );
246
+ step.id
247
+ );
248
+ });
215
249
 
216
250
  await this.run();
217
251
  } else {
@@ -280,7 +314,7 @@ export abstract class WorkflowRuntime<
280
314
 
281
315
  // Schedule another safety alarm if the run loop is still active.
282
316
  if (this.#isRunLoopActive) {
283
- await this.ctx.storage.setAlarm(Date.now() + 30_000 * 60); // 30 minutes
317
+ await this.ctx.storage.setAlarm(Date.now() + 30 * 60 * 1000); // 30 minutes
284
318
  } else {
285
319
  await this.run();
286
320
  }
@@ -342,7 +376,6 @@ export abstract class WorkflowRuntime<
342
376
 
343
377
  if (this.#status !== "running") {
344
378
  this.#setStatus({ type: "running" });
345
- this.#status = "running";
346
379
 
347
380
  if (this.onStatusChange_experimental !== undefined) {
348
381
  await this.onStatusChange_experimental("running");
@@ -352,7 +385,7 @@ export abstract class WorkflowRuntime<
352
385
  if (this.#isRunLoopActive) return;
353
386
 
354
387
  const requestId = crypto.randomUUID();
355
- const context = new WorkflowRuntimeContext(this.ctx.storage, { requestId });
388
+ const context = new WorkflowRuntimeContext(this.ctx.storage);
356
389
 
357
390
  this.#isRunLoopActive = true;
358
391
 
@@ -381,7 +414,7 @@ export abstract class WorkflowRuntime<
381
414
  const version = this.#definitionVersion;
382
415
  if (version === undefined) {
383
416
  throw new Error(
384
- "Workflow definition version has not been initialized. Call 'start()' before running the workflow."
417
+ "Workflow definition version has not been initialized. Call 'create()' before running the workflow."
385
418
  );
386
419
  }
387
420
 
@@ -428,7 +461,12 @@ export abstract class WorkflowRuntime<
428
461
  if (result.resume.type === "immediate") continue;
429
462
 
430
463
  // A 'suspended' resume hint indicates that the workflow should suspend itself and wait for the next alarm or inbound event to resume.
431
- if (result.resume.type === "suspended") break;
464
+ if (result.resume.type === "suspended") {
465
+ if (result.resume.wakeAt !== undefined) {
466
+ await this.ctx.storage.setAlarm(result.resume.wakeAt);
467
+ }
468
+ break;
469
+ }
432
470
 
433
471
  break;
434
472
  } catch (error) {
@@ -476,201 +514,130 @@ export abstract class WorkflowRuntime<
476
514
  export class WorkflowRuntimeContext extends RpcTarget {
477
515
  private readonly storage: DurableObjectStorage;
478
516
  private readonly sql: SqlStorage;
479
- private readonly requestId?: string;
480
517
  private static readonly BACKOFF_DELAYS = [250, 500, 1_000, 2_000, 4_000, 8_000, 10_000] as const;
481
518
 
482
519
  private static readonly DEFAULT_MAX_ATTEMPTS = 3;
483
520
 
484
- constructor(storage: DurableObjectStorage, options?: { requestId?: string }) {
521
+ constructor(storage: DurableObjectStorage) {
485
522
  super();
486
523
  this.storage = storage;
487
524
  this.sql = storage.sql;
488
- this.requestId = options?.requestId;
489
- }
490
-
491
- public async getOrCreateStep(
492
- id: RunStepId,
493
- options: { type: "run"; maxAttempts?: number | null; parentStepId: RunStepId | null }
494
- ): Promise<RunStep>;
495
- public async getOrCreateStep(
496
- id: SleepStepId,
497
- options: { type: "sleep"; wakeAt: Date; parentStepId: RunStepId | null }
498
- ): Promise<SleepStep>;
499
- public async getOrCreateStep(
500
- id: WaitStepId,
501
- options: { type: "wait"; eventName: string; timeoutAt?: Date; parentStepId: RunStepId | null }
502
- ): Promise<WaitStep>;
503
- public async getOrCreateStep(
504
- id: RunStepId | SleepStepId | WaitStepId,
505
- options:
506
- | { type: "run"; maxAttempts?: number | null; parentStepId: RunStepId | null }
507
- | { type: "sleep"; wakeAt: Date; parentStepId: RunStepId | null }
508
- | { type: "wait"; eventName: string; timeoutAt?: Date; parentStepId: RunStepId | null }
509
- ): Promise<Step> {
510
- try {
511
- if (options.type === "run") {
512
- return await this.getOrCreateRunStep(id as RunStepId, {
513
- maxAttempts: options?.maxAttempts,
514
- parentStepId: options.parentStepId
515
- });
516
- } else if (options.type === "sleep") {
517
- return await this.getOrCreateSleepStep(id as SleepStepId, {
518
- wakeAt: options.wakeAt,
519
- parentStepId: options.parentStepId
520
- });
521
- } else {
522
- return await this.getOrCreateWaitStep(id as WaitStepId, {
523
- eventName: options.eventName,
524
- timeoutAt: options.timeoutAt,
525
- parentStepId: options.parentStepId
526
- });
527
- }
528
- } catch (error) {
529
- console.error(error instanceof Error ? error : new Error(String(error)), { requestId: this.requestId });
530
- if (error instanceof Error && isSqliteInvariantViolation(error.message)) {
531
- throw new WorkflowInvariantError(error.message);
532
- }
533
-
534
- // All other errors are considered to be infrastructure/critical errors and may cause the DO to be reset
535
- // The following are examples of such errors:
536
- // 'SQLITE_FULL', // Database or disk is full
537
- // 'SQLITE_IOERR', // I/O error
538
- // 'SQLITE_BUSY', // Database is locked
539
- // 'SQLITE_NOMEM', // Out of memory
540
- // 'SQLITE_INTERRUPT', // Operation interrupted
541
- // 'SQLITE_CORRUPT', // Database file is corrupted
542
- // 'SQLITE_CANTOPEN', // Cannot open database file
543
- throw error;
544
- }
545
525
  }
546
526
 
547
527
  /**
548
- * True if this run step has at least one **direct** child that still **explains** a parent left in `running`:
549
- * typically `run` in `running` or `pending`, `sleep`/`wait` in `waiting`, or a successful-but-not-failed child row
550
- * (`succeeded` run, `satisfied` wait, `elapsed` sleep) while the parent has not yet recorded its own outcome.
528
+ * Loads the `inbound_events` row whose `claimed_by` is this wait step (`waitStepId`).
551
529
  *
552
- * Excludes only terminal **failure** child states (`failed`, `timed_out`).
530
+ * @throws If no such row exists (storage invariant broken or the step is not in a satisfied state with a claim).
553
531
  */
554
- public async hasRunningOrWaitingChildSteps(stepId: RunStepId): Promise<boolean> {
555
- const rows = this.sql
556
- .exec<{ x: number }>(
557
- `SELECT 1 AS x FROM steps
558
- WHERE parent_step_id = ?
559
- AND state NOT IN ('failed', 'timed_out')
560
- LIMIT 1`,
561
- stepId
562
- )
532
+ private getInboundEventForWaitStep<T extends Json | undefined>(stepId: WaitStepId): InboundEvent<T> {
533
+ const [row] = this.sql
534
+ .exec<ClaimedInboundEvent_Row>(`SELECT * FROM inbound_events WHERE claimed_by = ? LIMIT 1`, stepId)
563
535
  .toArray();
564
- return rows.length > 0;
536
+ if (row === undefined) {
537
+ throw new Error(
538
+ `Wait step '${stepId}' is satisfied in durable state but no inbound_events row is claimed by this step.`
539
+ );
540
+ }
541
+ return {
542
+ id: row.id,
543
+ eventName: row.event_name,
544
+ payload: (row.payload === null ? undefined : JSON.parse(row.payload)) as T,
545
+ createdAt: new Date(row.created_at),
546
+ claimedBy: row.claimed_by,
547
+ claimedAt: new Date(row.claimed_at)
548
+ };
565
549
  }
566
550
 
567
- private async getOrCreateRunStep(
551
+ getOrCreateRunStep(
568
552
  id: RunStepId,
569
- options: { maxAttempts?: number | null; parentStepId: RunStepId | null }
570
- ): Promise<RunStep> {
571
- const maxAttempts =
572
- options.maxAttempts === undefined ? WorkflowRuntimeContext.DEFAULT_MAX_ATTEMPTS : options.maxAttempts;
573
- return await this.storage.transaction(async (transaction) => {
574
- const [existing] = this.sql.exec<RunStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'run'", id).toArray();
575
- // If the step does not exist, we create it and mark the attempt as 'pending'.
576
- if (existing === undefined) {
577
- const inserted = this.sql
578
- .exec<RunStep_Row>(
579
- `INSERT INTO steps (id, type, state, attempt_count, max_attempts, next_attempt_at, parent_step_id) VALUES (?, 'run', 'pending', 0, ?, CAST(unixepoch('subsecond') * 1000 AS INTEGER), ?) RETURNING *`,
580
- id,
581
- maxAttempts,
582
- options.parentStepId
583
- )
584
- .one();
585
-
586
- return formatStep(inserted);
587
- } else {
588
- // If the step exists and is in 'pending' state, we update the alarm to wake at the correct time.
589
- if (existing.state === "pending" && Date.now() < existing.next_attempt_at) {
590
- await transaction.setAlarm(existing.next_attempt_at);
591
- }
592
- return formatStep(existing);
593
- }
594
- });
595
- }
596
-
597
- private async getOrCreateSleepStep(
598
- id: SleepStepId,
599
- options: { wakeAt: Date; parentStepId: RunStepId | null }
600
- ): Promise<SleepStep> {
601
- return await this.storage.transaction(async (transaction) => {
602
- const [existing] = this.sql
603
- .exec<SleepStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'sleep'", id)
604
- .toArray();
605
- if (existing !== undefined) {
606
- // If the step exists and is in 'waiting' state, we update the alarm to wake at the correct time.
607
- if (existing.state === "waiting" && Date.now() < existing.wake_at) {
608
- await transaction.setAlarm(existing.wake_at);
609
- }
610
- return formatStep(existing);
611
- }
612
-
613
- // If the step does not exist, we create it, set it to 'waiting' state and set the alarm to wake at the correct time.
614
- const wakeAt = options.wakeAt.getTime();
553
+ options: {
554
+ maxAttempts?: number | null;
555
+ parentStepId: RunStepId | null;
556
+ }
557
+ ): RunStep & { attempts: RunStepAttempt[] } {
558
+ const [existing] = this.sql.exec<RunStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'run'", id).toArray();
559
+ if (existing === undefined) {
615
560
  const inserted = this.sql
616
- .exec<SleepStep_Row>(
617
- `INSERT INTO steps (id, type, state, wake_at, parent_step_id) VALUES (?, 'sleep', 'waiting', ?, ?) RETURNING *`,
561
+ .exec<RunStep_Row>(
562
+ `INSERT INTO steps (id, type, parent_step_id, max_attempts) VALUES (?, 'run', ?, ?) RETURNING *`,
618
563
  id,
619
- wakeAt,
620
- options.parentStepId
564
+ options.parentStepId,
565
+ options.maxAttempts ?? WorkflowRuntimeContext.DEFAULT_MAX_ATTEMPTS
621
566
  )
622
567
  .one();
623
- this.sql.exec("INSERT INTO step_events (step_id, type, wake_at) VALUES (?, ?, ?)", id, "sleep_waiting", wakeAt);
624
- await transaction.setAlarm(wakeAt);
625
- return formatStep(inserted);
626
- });
568
+ return { ...formatRunStep(inserted), attempts: [] };
569
+ } else {
570
+ const attempts = this.sql
571
+ .exec<RunStepAttempt_Row>(
572
+ `SELECT * FROM run_step_attempts WHERE step_id = ? ORDER BY started_at ASC, id ASC`,
573
+ id
574
+ )
575
+ .toArray();
576
+
577
+ return {
578
+ ...formatRunStep(existing),
579
+ attempts: attempts.map((attempt) => formatRunStepAttempt(attempt))
580
+ };
581
+ }
627
582
  }
628
583
 
629
- private async getOrCreateWaitStep(
584
+ getOrCreateSleepStep(id: SleepStepId, options: { wakeAt: Date; parentStepId: RunStepId | null }): SleepStep {
585
+ const [existing] = this.sql
586
+ .exec<SleepStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'sleep'", id)
587
+ .toArray();
588
+ if (existing !== undefined) {
589
+ return formatSleepStep(existing);
590
+ }
591
+
592
+ const wakeAt = options.wakeAt.getTime();
593
+ const inserted = this.sql
594
+ .exec<SleepStep_Row>(
595
+ `INSERT INTO steps (id, type, state, target_wake_at, parent_step_id) VALUES (?, 'sleep', 'waiting', ?, ?) RETURNING *`,
596
+ id,
597
+ wakeAt,
598
+ options.parentStepId
599
+ )
600
+ .one();
601
+ return formatSleepStep(inserted);
602
+ }
603
+
604
+ getOrCreateWaitStep<T extends Json | undefined>(
630
605
  id: WaitStepId,
631
606
  options: { eventName: string; timeoutAt?: Date; parentStepId: RunStepId | null }
632
- ): Promise<WaitStep> {
633
- return await this.storage.transaction(async (transaction) => {
634
- const [existing] = this.sql
635
- .exec<WaitStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'wait'", id)
636
- .toArray();
637
- // If the step exists and isn't in 'waiting' state (i.e. in terminal state of 'satisfied' or 'timed_out'), we return the step as is as no further action is needed.
638
- if (existing !== undefined && existing.state !== "waiting") {
639
- return formatStep(existing);
607
+ ): WaitStep<T> {
608
+ const [existing] = this.sql.exec<WaitStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'wait'", id).toArray();
609
+ // If the step exists and isn't in 'waiting' state (i.e. in terminal state of 'satisfied' or 'timed_out'), we return the step as is as no further action is needed.
610
+ if (existing !== undefined && existing.state !== "waiting") {
611
+ if (existing.state === "satisfied") {
612
+ return formatSatisfiedWaitStep<T>(existing, this.getInboundEventForWaitStep<T>(existing.id).payload);
613
+ } else if (existing.state === "timed_out") {
614
+ return formatTimedOutWaitStep(existing);
640
615
  }
616
+ }
641
617
 
642
- let waiting: Extract<WaitStep_Row, { state: "waiting" }>;
643
- if (existing !== undefined) {
644
- waiting = existing;
645
- } else {
646
- waiting = this.sql
647
- .exec<Extract<WaitStep_Row, { state: "waiting" }>>(
648
- `
618
+ let waiting: Extract<WaitStep_Row, { state: "waiting" }>;
619
+ if (existing !== undefined) {
620
+ waiting = existing;
621
+ } else {
622
+ waiting = this.sql
623
+ .exec<Extract<WaitStep_Row, { state: "waiting" }>>(
624
+ `
649
625
  INSERT INTO steps (id, type, state, event_name, timeout_at, parent_step_id)
650
626
  VALUES (?, 'wait', 'waiting', ?, ?, ?)
651
627
  RETURNING *
652
628
  `,
653
- id,
654
- options.eventName,
655
- options.timeoutAt !== undefined ? options.timeoutAt.getTime() : null,
656
- options.parentStepId
657
- )
658
- .one();
659
- this.sql.exec(
660
- `INSERT INTO step_events (step_id, type, event_name, timeout_at) VALUES (?, ?, ?, ?)`,
661
629
  id,
662
- "wait_waiting",
663
630
  options.eventName,
664
- options.timeoutAt !== undefined ? options.timeoutAt.getTime() : null
665
- );
666
- }
667
-
668
- const timeoutAt = waiting.timeout_at;
631
+ options.timeoutAt !== undefined ? options.timeoutAt.getTime() : null,
632
+ options.parentStepId
633
+ )
634
+ .one();
635
+ }
669
636
 
670
- // Attempt to claim any inbound event that is not claimed yet for the given event name.
671
- const [event] = this.sql
672
- .exec<{ id: string; payload: string }>(
673
- `
637
+ // Attempt to claim any inbound event that is not claimed yet for the given event name.
638
+ const [claimed] = this.sql
639
+ .exec<{ id: string; payload: string | null }>(
640
+ `
674
641
  UPDATE inbound_events
675
642
  SET claimed_by = ?,
676
643
  claimed_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER)
@@ -685,280 +652,228 @@ export class WorkflowRuntimeContext extends RpcTarget {
685
652
  AND claimed_by IS NULL
686
653
  RETURNING id, payload
687
654
  `,
688
- id,
689
- options.eventName
690
- )
691
- .toArray();
655
+ id,
656
+ options.eventName
657
+ )
658
+ .toArray();
692
659
 
693
- // If a queued inbound event was found, we mark the step as 'satisfied' and return the satisfied step.
694
- if (event !== undefined) {
695
- const satisfied = this.sql
696
- .exec<WaitStep_Row>(
697
- `
660
+ if (claimed !== undefined) {
661
+ const satisfied = this.sql
662
+ .exec<SatisfiedWaitStep_Row>(
663
+ `
698
664
  UPDATE steps
699
665
  SET state = 'satisfied',
700
- payload = ?,
701
- resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER),
702
- timeout_at = NULL
666
+ resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER)
703
667
  WHERE id = ?
704
668
  AND type = 'wait'
705
669
  AND state = 'waiting'
706
670
  RETURNING *
707
671
  `,
708
- event.payload,
709
- id
710
- )
711
- .one();
712
-
713
- this.sql.exec(
714
- `
715
- INSERT INTO step_events (step_id, type, payload)
716
- VALUES (?, ?, ?)
717
- `,
718
- id,
719
- "wait_satisfied",
720
- event.payload
721
- );
672
+ id
673
+ )
674
+ .one();
675
+ return formatSatisfiedWaitStep<T>(satisfied, claimed.payload === null ? undefined : JSON.parse(claimed.payload));
676
+ }
722
677
 
723
- return formatStep(satisfied);
724
- }
725
- // If no queued inbound event was found, we return the step as is.
726
- else {
727
- if (timeoutAt !== null && Date.now() < timeoutAt) {
728
- await transaction.setAlarm(timeoutAt);
729
- } else {
730
- await transaction.deleteAlarm();
731
- }
732
- return formatStep(waiting);
733
- }
734
- });
678
+ return formatWaitingWaitStep(waiting);
735
679
  }
736
- async handleRunAttemptEvent(
737
- id: RunStepId,
738
- event:
739
- | { type: "running"; attemptCount: number }
740
- | { type: "succeeded"; attemptCount: number; result: string }
741
- | {
742
- type: "failed";
743
- attemptCount: number;
744
- errorMessage: string;
745
- errorName?: string;
746
- isNonRetryableStepError?: boolean;
747
- }
748
- ): Promise<void> {
749
- try {
750
- await this.storage.transaction(async (transaction) => {
751
- const [existing] = this.sql
752
- .exec<RunStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'run'", id)
753
- .toArray();
754
- if (existing === undefined) {
755
- throw new WorkflowInvariantError(`Step '${id}' of type 'run' not found.`);
756
- }
757
680
 
758
- const attemptCount = event.attemptCount;
681
+ /**
682
+ * True if this run step has at least one **direct** child that is still in progress.
683
+ */
684
+ hasInProgressChildSteps(stepId: RunStepId): boolean {
685
+ const rows = this.sql
686
+ .exec<{ x: number }>(
687
+ `SELECT 1 AS x FROM steps c
688
+ WHERE c.parent_step_id = ?
689
+ AND (
690
+ (c.type = 'sleep' AND c.state IN ('waiting', 'elapsed'))
691
+ OR (c.type = 'wait' AND c.state IN ('waiting', 'satisfied'))
692
+ OR (
693
+ c.type = 'run'
694
+ AND NOT EXISTS (
695
+ SELECT 1
696
+ FROM run_step_attempts a
697
+ WHERE a.step_id = c.id
698
+ AND a.state = 'failed'
699
+ AND a.next_attempt_at IS NULL
700
+ AND a.id = (
701
+ SELECT a2.id
702
+ FROM run_step_attempts a2
703
+ WHERE a2.step_id = c.id
704
+ ORDER BY a2.started_at DESC, a2.id DESC
705
+ LIMIT 1
706
+ )
707
+ )
708
+ )
709
+ )
710
+ LIMIT 1`,
711
+ stepId
712
+ )
713
+ .toArray();
714
+ return rows.length > 0;
715
+ }
759
716
 
760
- if (event.type === "running") {
761
- if (existing.state !== "pending") {
762
- throw new WorkflowInvariantError(
763
- `Unexpected state for run step '${id}'. Expected 'pending' but got ${existing.state}.`
764
- );
765
- }
766
- if (existing.next_attempt_at !== null && existing.next_attempt_at > Date.now()) {
767
- throw new WorkflowInvariantError(
768
- `Unexpected next attempt at for run step '${id}'. Expected a NULL value or a value that is in the past but got ${new Date(existing.next_attempt_at).toISOString()}.`
769
- );
770
- }
771
- if (existing.attempt_count !== attemptCount - 1) {
772
- throw new WorkflowInvariantError(
773
- `Unexpected attempt count for run step '${id}'. Expected ${attemptCount - 1} but got ${existing.attempt_count}.`
774
- );
775
- }
776
- // Update the step to the 'running' state and insert an `attempt_started` step_events row.
777
- this.sql.exec(
778
- `UPDATE steps SET state = 'running', attempt_count = ?, next_attempt_at = NULL WHERE id = ?`,
779
- attemptCount,
780
- id
781
- );
782
- this.sql.exec(
783
- "INSERT INTO step_events (step_id, type, attempt_number) VALUES (?, ?, ?)",
784
- id,
785
- "attempt_started",
786
- attemptCount
787
- );
788
- } else if (event.type === "succeeded") {
789
- if (existing.state !== "running") {
790
- throw new WorkflowInvariantError(
791
- `Unexpected state for run step '${id}'. Expected 'running' but got ${existing.state}.`
792
- );
793
- }
794
- if (existing.attempt_count !== attemptCount) {
795
- throw new WorkflowInvariantError(
796
- `Unexpected attempt count for run step '${id}'. Expected ${attemptCount} but got ${existing.attempt_count}.`
797
- );
798
- }
799
- // Update the step to the 'succeeded' state and insert an `attempt_succeeded` step_events row.
800
- this.sql.exec(
801
- `UPDATE steps SET state = 'succeeded', result = ?, resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER) WHERE id = ?`,
802
- event.result,
803
- id
804
- );
805
- this.sql.exec(
806
- "INSERT INTO step_events (step_id, type, attempt_number, result) VALUES (?, ?, ?, ?)",
807
- id,
808
- "attempt_succeeded",
809
- attemptCount,
810
- event.result
811
- );
812
- } else if (event.type === "failed") {
813
- if (existing.state !== "running") {
814
- throw new WorkflowInvariantError(
815
- `Unexpected state for run step '${id}'. Expected 'running' but got ${existing.state}.`
816
- );
817
- }
717
+ handleRunAttemptStarted(stepId: RunStepId): StartedRunStepAttempt {
718
+ // If a run step with the given id does not exist, we throw a 'WorkflowInvariantError' indicating that the step was not found.
719
+ const [existing] = this.sql
720
+ .exec<RunStep_Row>(`SELECT * FROM steps WHERE id = ? AND type = 'run'`, stepId)
721
+ .toArray();
722
+ if (existing === undefined) {
723
+ throw new Error(`Run step '${stepId}' not found.`);
724
+ }
818
725
 
819
- if (existing.attempt_count !== attemptCount) {
820
- throw new WorkflowInvariantError(
821
- `Unexpected attempt count for run step '${id}'. Expected ${attemptCount} but got ${existing.attempt_count}.`
822
- );
823
- }
726
+ // Get the last attempt for the step.
727
+ const [lastAttempt] = this.sql
728
+ .exec<RunStepAttempt_Row>(
729
+ "SELECT * FROM run_step_attempts WHERE step_id = ? ORDER BY started_at DESC, id DESC",
730
+ stepId
731
+ )
732
+ .toArray();
824
733
 
825
- // If the step has reached the maximum number of attempts, we mark the step as 'failed'
826
- if (
827
- (existing.max_attempts != null && existing.attempt_count >= existing.max_attempts) ||
828
- event.isNonRetryableStepError
829
- ) {
830
- this.sql.exec(
831
- `UPDATE steps SET state = 'failed', error_message = ?, error_name = ?, resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER) WHERE id = ?`,
832
- event.errorMessage,
833
- event.errorName ?? null,
834
- id
835
- );
836
-
837
- // Insert an `attempt_failed` step_events row
838
- this.sql.exec(
839
- "INSERT INTO step_events (step_id, type, attempt_number, error_message, error_name) VALUES (?, ?, ?, ?, ?)",
840
- id,
841
- "attempt_failed",
842
- attemptCount,
843
- event.errorMessage,
844
- event.errorName ?? null
845
- );
846
- }
847
- // Otherwise (if the step hasn't reached the maximum number of attempts), we mark the step as 'pending'
848
- // and update 'next_attempt_at' to the next backoff time and set the alarm to wake up at the same time.
849
- else {
850
- const backoff =
851
- WorkflowRuntimeContext.BACKOFF_DELAYS[attemptCount - 1] ??
852
- (WorkflowRuntimeContext.BACKOFF_DELAYS[WorkflowRuntimeContext.BACKOFF_DELAYS.length - 1] as number);
853
- const nextAttemptAt = Date.now() + backoff;
854
- this.sql.exec(`UPDATE steps SET state = 'pending', next_attempt_at = ? WHERE id = ?`, nextAttemptAt, id);
855
- this.sql.exec(
856
- "INSERT INTO step_events (step_id, type, attempt_number, error_message, error_name, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?)",
857
- id,
858
- "attempt_failed",
859
- attemptCount,
860
- event.errorMessage,
861
- event.errorName ?? null,
862
- nextAttemptAt
863
- );
864
- await transaction.setAlarm(nextAttemptAt);
865
- }
866
- }
867
- });
868
- } catch (error) {
869
- console.error(error instanceof Error ? error : new Error(String(error)), { requestId: this.requestId });
870
- if (error instanceof Error && isSqliteInvariantViolation(error.message)) {
871
- throw new WorkflowInvariantError(error.message);
872
- }
873
- throw error;
734
+ // If the last attempt has been started, we throw a 'WorkflowInvariantError' indicating that the attempt is already in progress.
735
+ if (lastAttempt !== undefined && lastAttempt.state === "started") {
736
+ throw new Error(`Attempt '${lastAttempt.id}' for run step '${stepId}' is already in progress.`);
874
737
  }
738
+
739
+ // Insert a new attempt for the step and return the new attempt
740
+ const attempt = this.sql
741
+ .exec<StartedRunStepAttempt_Row>(
742
+ `INSERT INTO run_step_attempts (step_id, state) VALUES (?, 'started') RETURNING *`,
743
+ stepId
744
+ )
745
+ .one();
746
+ return formatRunStepAttempt(attempt);
875
747
  }
876
748
 
877
- handleSleepStepEvent(id: SleepStepId, event: { type: "elapsed" }): void {
878
- try {
879
- const [existing] = this.sql
880
- .exec<SleepStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'sleep'", id)
881
- .toArray();
882
- if (existing === undefined) {
883
- throw new WorkflowInvariantError(`Step '${id}' of type 'sleep' not found.`);
884
- }
749
+ handleRunAttemptFailed(
750
+ stepId: RunStepId,
751
+ result: {
752
+ errorMessage: string;
753
+ errorName?: string;
754
+ isNonRetryableStepError?: boolean;
755
+ }
756
+ ): FailedRunStepAttempt {
757
+ // If a run step with the given id does not exist, we throw a 'WorkflowInvariantError' indicating that the step was not found.
758
+ const [existing] = this.sql
759
+ .exec<RunStep_Row>(`SELECT * FROM steps WHERE id = ? AND type = 'run'`, stepId)
760
+ .toArray();
761
+ if (existing === undefined) {
762
+ throw new Error(`Run step '${stepId}' not found.`);
763
+ }
885
764
 
886
- if (event.type === "elapsed") {
887
- // Update the step to the 'elapsed' state and insert a `sleep_elapsed` step_events row.
888
- if (existing.state !== "waiting") {
889
- throw new WorkflowInvariantError(
890
- `Unexpected state for sleep step '${id}'. Expected 'waiting' but got ${existing.state}.`
891
- );
892
- }
893
- this.sql.exec(
894
- `UPDATE steps SET state = 'elapsed', resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER), wake_at = NULL WHERE id = ?`,
895
- id
896
- );
897
- this.sql.exec("INSERT INTO step_events (step_id, type) VALUES (?, ?)", id, "sleep_elapsed");
898
- }
899
- } catch (error) {
900
- console.error(error instanceof Error ? error : new Error(String(error)), { requestId: this.requestId });
901
- if (error instanceof Error && isSqliteInvariantViolation(error.message)) {
902
- throw new WorkflowInvariantError(error.message);
903
- }
904
- throw error;
765
+ // Get the last attempt for the step.
766
+ const attempts = this.sql
767
+ .exec<RunStepAttempt_Row>("SELECT * FROM run_step_attempts WHERE step_id = ? ORDER BY started_at ASC", stepId)
768
+ .toArray();
769
+
770
+ const lastAttempt = attempts[attempts.length - 1];
771
+ if (lastAttempt === undefined) {
772
+ throw new Error(`No attempt in progress for run step '${stepId}'.`);
773
+ }
774
+
775
+ if (result.isNonRetryableStepError || attempts.length === existing.max_attempts) {
776
+ const updated = this.sql
777
+ .exec<Extract<RunStepAttempt_Row, { state: "failed" }>>(
778
+ `UPDATE run_step_attempts SET state = 'failed', ended_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER), error_message = ?, error_name = ?, next_attempt_at = NULL WHERE id = ? AND state = 'started' RETURNING *`,
779
+ result.errorMessage,
780
+ result.errorName ?? null,
781
+ lastAttempt.id
782
+ )
783
+ .one();
784
+ return formatRunStepAttempt(updated);
785
+ } else {
786
+ const backoff =
787
+ WorkflowRuntimeContext.BACKOFF_DELAYS[attempts.length - 1] ??
788
+ (WorkflowRuntimeContext.BACKOFF_DELAYS[WorkflowRuntimeContext.BACKOFF_DELAYS.length - 1] as number);
789
+ const nextAttemptAt = Date.now() + backoff;
790
+
791
+ const updated = this.sql
792
+ .exec<Extract<RunStepAttempt_Row, { state: "failed" }>>(
793
+ `UPDATE run_step_attempts SET state = 'failed', ended_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER), error_message = ?, error_name = ?, next_attempt_at = ? WHERE id = ? AND state = 'started' RETURNING *`,
794
+ result.errorMessage,
795
+ result.errorName ?? null,
796
+ nextAttemptAt,
797
+ lastAttempt.id
798
+ )
799
+ .one();
800
+ return formatRunStepAttempt(updated);
905
801
  }
906
802
  }
907
803
 
908
- handleWaitStepEvent(id: WaitStepId, event: { type: "timed_out" }): void {
909
- try {
910
- this.storage.transactionSync(() => {
911
- const [existing] = this.sql
912
- .exec<WaitStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'wait'", id)
913
- .toArray();
914
- if (existing === undefined) {
915
- throw new WorkflowInvariantError(`Step '${id}' of type 'wait' not found.`);
916
- }
917
- if (existing.state !== "waiting") {
918
- throw new WorkflowInvariantError(
919
- `Unexpected state for wait step '${id}'. Expected 'waiting' but got ${existing.state}.`
920
- );
921
- }
922
- // If the step has a timeout and the timeout has not been reached, we throw an error to explain the state mismatch.
923
- if (existing.timeout_at !== null && existing.timeout_at > Date.now()) {
924
- throw new WorkflowInvariantError(
925
- `Unexpected timeout at for wait step '${id}'. Expected a NULL value or a value that is in the past but got ${new Date(existing.timeout_at).toISOString()}.`
926
- );
927
- }
928
- // If the step has timed out, we mark the step as 'timed_out' and insert a `wait_timed_out` step_events row.
929
- if (event.type === "timed_out") {
930
- this.sql.exec(
931
- "UPDATE steps SET state = 'timed_out', resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER), timeout_at = NULL WHERE id = ?",
932
- id
933
- );
934
- this.sql.exec("INSERT INTO step_events (step_id, type) VALUES (?, ?)", id, "wait_timed_out");
935
- }
936
- });
937
- } catch (error) {
938
- console.error(error instanceof Error ? error : new Error(String(error)), { requestId: this.requestId });
939
- if (error instanceof Error && isSqliteInvariantViolation(error.message)) {
940
- throw new WorkflowInvariantError(error.message);
941
- }
942
- throw error;
804
+ /**
805
+ * Marks the in-flight attempt as succeeded.
806
+ *
807
+ * @param resultJson - Raw JSON string for the result value (`null` when the callback returned `undefined`). The
808
+ * `result_type` discriminator is derived: `null` `'none'`, non-null `'json'`.
809
+ */
810
+ handleRunAttemptSucceeded(stepId: RunStepId, resultJson: string | null): SucceededRunStepAttempt {
811
+ const [existing] = this.sql
812
+ .exec<RunStep_Row>(`SELECT * FROM steps WHERE id = ? AND type = 'run'`, stepId)
813
+ .toArray();
814
+ if (existing === undefined) {
815
+ throw new Error(`Run step '${stepId}' not found.`);
816
+ }
817
+
818
+ const attempts = this.sql
819
+ .exec<RunStepAttempt_Row>("SELECT * FROM run_step_attempts WHERE step_id = ? ORDER BY started_at ASC", stepId)
820
+ .toArray();
821
+
822
+ const lastAttempt = attempts[attempts.length - 1];
823
+ if (lastAttempt === undefined || lastAttempt?.state !== "started") {
824
+ throw new Error(`No attempt in progress for run step '${stepId}'.`);
943
825
  }
826
+
827
+ const resultType = resultJson === null ? "none" : "json";
828
+ const updated = this.sql
829
+ .exec<SucceededRunStepAttempt_Row>(
830
+ `UPDATE run_step_attempts SET state = 'succeeded', ended_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER), result_type = ?, result_json = ? WHERE id = ? AND state = 'started' RETURNING *`,
831
+ resultType,
832
+ resultJson,
833
+ lastAttempt.id
834
+ )
835
+ .one();
836
+ return formatRunStepAttempt(updated);
944
837
  }
945
- }
946
838
 
947
- function isSqliteInvariantViolation(message: string): boolean {
948
- return (
949
- message.includes("SQLITE_CONSTRAINT") || // Constraint violation (FK, UNIQUE, CHECK, NOT NULL)
950
- message.includes("SQLITE_MISMATCH") || // Data type mismatch
951
- message.includes("SQLITE_ERROR") || // Generic SQL error (syntax, etc.)
952
- message.includes("SQLITE_RANGE") || // Parameter index out of range
953
- message.includes("SQLITE_AUTH") || // Authorization denied (e.g., accessing _cf_ tables)
954
- message.includes("SQLITE_TOOBIG") // String or BLOB too large
955
- );
956
- }
839
+ handleSleepStepElapsed(id: SleepStepId): void {
840
+ const [existing] = this.sql
841
+ .exec<SleepStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'sleep'", id)
842
+ .toArray();
843
+ if (existing === undefined) {
844
+ throw new Error(`Step '${id}' of type 'sleep' not found.`);
845
+ }
957
846
 
958
- class WorkflowInvariantError extends Error {
959
- constructor(message: string) {
960
- super(message);
961
- this.name = "WorkflowInvariantError";
847
+ if (existing.state !== "waiting") {
848
+ throw new Error(`Unexpected state for sleep step '${id}'. Expected 'waiting' but got ${existing.state}.`);
849
+ }
850
+ this.sql.exec(
851
+ `UPDATE steps SET state = 'elapsed', resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER) WHERE id = ?`,
852
+ id
853
+ );
854
+ }
855
+
856
+ handleWaitStepTimedOut(id: WaitStepId): void {
857
+ this.storage.transactionSync(() => {
858
+ const [existing] = this.sql
859
+ .exec<WaitStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'wait'", id)
860
+ .toArray();
861
+ if (existing === undefined) {
862
+ throw new Error(`Step '${id}' of type 'wait' not found.`);
863
+ }
864
+ if (existing.state !== "waiting") {
865
+ throw new Error(`Unexpected state for wait step '${id}'. Expected 'waiting' but got ${existing.state}.`);
866
+ }
867
+ if (existing.timeout_at !== null && existing.timeout_at > Date.now()) {
868
+ throw new Error(
869
+ `Unexpected timeout at for wait step '${id}'. Expected a NULL value or a value that is in the past but got ${new Date(existing.timeout_at).toISOString()}.`
870
+ );
871
+ }
872
+ this.sql.exec(
873
+ "UPDATE steps SET state = 'timed_out', resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER) WHERE id = ?",
874
+ id
875
+ );
876
+ });
962
877
  }
963
878
  }
964
879
 
@@ -966,33 +881,30 @@ export type RunStepId = Brand<string, "RunStepId">;
966
881
  export type SleepStepId = Brand<string, "SleepStepId">;
967
882
  export type WaitStepId = Brand<string, "WaitStepId">;
968
883
 
969
- type RunStep = {
884
+ export type RunStepAttempt = {
885
+ id: string;
886
+ stepId: RunStepId;
887
+ startedAt: Date;
888
+ } & (
889
+ | { state: "started" }
890
+ | ({ state: "succeeded"; endedAt: Date } & (
891
+ | { resultType: "json"; resultJson: string }
892
+ | { resultType: "none" }
893
+ ))
894
+ | { state: "failed"; errorMessage: string; errorName?: string; endedAt: Date; nextAttemptAt?: Date }
895
+ );
896
+
897
+ export type StartedRunStepAttempt = Extract<RunStepAttempt, { state: "started" }>;
898
+ export type SucceededRunStepAttempt = Extract<RunStepAttempt, { state: "succeeded" }>;
899
+ export type FailedRunStepAttempt = Extract<RunStepAttempt, { state: "failed" }>;
900
+
901
+ export type RunStep = {
970
902
  type: "run";
971
903
  id: RunStepId;
972
904
  createdAt: Date;
973
- attemptCount: number;
974
905
  maxAttempts: number | null;
975
906
  parentStepId: RunStepId | null;
976
- } & (
977
- | {
978
- state: "pending";
979
- nextAttemptAt: Date;
980
- }
981
- | {
982
- state: "running";
983
- }
984
- | {
985
- state: "succeeded";
986
- result: string;
987
- resolvedAt: Date;
988
- }
989
- | {
990
- state: "failed";
991
- errorMessage: string;
992
- errorName?: string;
993
- resolvedAt: Date;
994
- }
995
- );
907
+ };
996
908
 
997
909
  type SleepStep = {
998
910
  type: "sleep";
@@ -1010,7 +922,7 @@ type SleepStep = {
1010
922
  }
1011
923
  );
1012
924
 
1013
- type WaitStep = {
925
+ type WaitStep<T extends Json | undefined = Json | undefined> = {
1014
926
  type: "wait";
1015
927
  id: WaitStepId;
1016
928
  createdAt: Date;
@@ -1023,256 +935,171 @@ type WaitStep = {
1023
935
  }
1024
936
  | {
1025
937
  state: "satisfied";
1026
- payload: string;
938
+ payload: T;
1027
939
  resolvedAt: Date;
940
+ timeoutAt?: Date;
1028
941
  }
1029
942
  | {
1030
943
  state: "timed_out";
1031
944
  resolvedAt: Date;
945
+ timeoutAt: Date;
1032
946
  }
1033
947
  );
1034
948
 
1035
- type Step = RunStep | SleepStep | WaitStep;
1036
-
1037
- function formatStep(step: RunStep_Row): RunStep;
1038
- function formatStep(step: SleepStep_Row): SleepStep;
1039
- function formatStep(step: WaitStep_Row): WaitStep;
1040
- function formatStep(step: Step_Row): Step;
1041
- function formatStep(step: Step_Row): Step {
1042
- switch (step.type) {
1043
- case "run": {
1044
- switch (step.state) {
1045
- case "pending":
1046
- return {
1047
- type: "run",
1048
- id: step.id,
1049
- state: "pending",
1050
- nextAttemptAt: new Date(step.next_attempt_at),
1051
- createdAt: new Date(step.created_at),
1052
- attemptCount: step.attempt_count,
1053
- maxAttempts: step.max_attempts,
1054
- parentStepId: step.parent_step_id
1055
- } satisfies RunStep;
1056
- case "running":
1057
- return {
1058
- type: "run",
1059
- id: step.id,
1060
- state: "running",
1061
- createdAt: new Date(step.created_at),
1062
- attemptCount: step.attempt_count,
1063
- maxAttempts: step.max_attempts,
1064
- parentStepId: step.parent_step_id
1065
- } satisfies RunStep;
1066
- case "succeeded":
1067
- return {
1068
- type: "run",
1069
- id: step.id,
1070
- state: "succeeded",
1071
- result: step.result,
1072
- resolvedAt: new Date(step.resolved_at),
1073
- createdAt: new Date(step.created_at),
1074
- attemptCount: step.attempt_count,
1075
- maxAttempts: step.max_attempts,
1076
- parentStepId: step.parent_step_id
1077
- } satisfies RunStep;
1078
- case "failed":
1079
- return {
1080
- type: "run",
1081
- id: step.id,
1082
- state: "failed",
1083
- errorMessage: step.error_message,
1084
- errorName: step.error_name ?? undefined,
1085
- resolvedAt: new Date(step.resolved_at),
1086
- createdAt: new Date(step.created_at),
1087
- attemptCount: step.attempt_count,
1088
- maxAttempts: step.max_attempts,
1089
- parentStepId: step.parent_step_id
1090
- } satisfies RunStep;
1091
- default:
1092
- throw new Error("Unexpected step state");
1093
- }
1094
- }
1095
- case "sleep":
1096
- switch (step.state) {
1097
- case "waiting":
1098
- return {
1099
- type: "sleep",
1100
- id: step.id,
1101
- state: "waiting",
1102
- wakeAt: new Date(step.wake_at),
1103
- createdAt: new Date(step.created_at),
1104
- parentStepId: step.parent_step_id
1105
- } satisfies SleepStep;
1106
- case "elapsed":
1107
- return {
1108
- type: "sleep",
1109
- id: step.id,
1110
- state: "elapsed",
1111
- resolvedAt: new Date(step.resolved_at),
1112
- createdAt: new Date(step.created_at),
1113
- parentStepId: step.parent_step_id
1114
- } satisfies SleepStep;
1115
- default:
1116
- throw new Error("Unexpected step state");
1117
- }
1118
- case "wait":
1119
- switch (step.state) {
1120
- case "waiting":
1121
- return {
1122
- type: "wait",
1123
- id: step.id,
1124
- state: "waiting",
1125
- eventName: step.event_name,
1126
- timeoutAt: step.timeout_at ? new Date(step.timeout_at) : undefined,
1127
- createdAt: new Date(step.created_at),
1128
- parentStepId: step.parent_step_id
1129
- } satisfies WaitStep;
1130
- case "satisfied":
1131
- return {
1132
- type: "wait",
1133
- id: step.id,
1134
- state: "satisfied",
1135
- payload: step.payload,
1136
- createdAt: new Date(step.created_at),
1137
- eventName: step.event_name,
1138
- resolvedAt: new Date(step.resolved_at),
1139
- parentStepId: step.parent_step_id
1140
- } satisfies WaitStep;
1141
- case "timed_out":
1142
- return {
1143
- type: "wait",
1144
- id: step.id,
1145
- state: "timed_out",
1146
- eventName: step.event_name,
1147
- resolvedAt: new Date(step.resolved_at),
1148
- createdAt: new Date(step.created_at),
1149
- parentStepId: step.parent_step_id
1150
- } satisfies WaitStep;
1151
- default:
1152
- throw new Error("Unexpected step state");
1153
- }
1154
- default:
1155
- throw new Error("Unexpected step type");
1156
- }
1157
- }
949
+ type WaitingWaitStep = Extract<WaitStep, { state: "waiting" }>;
950
+ type SatisfiedWaitStep<T extends Json | undefined> = Extract<WaitStep<T>, { state: "satisfied" }>;
951
+ type TimedOutWaitStep = Extract<WaitStep, { state: "timed_out" }>;
1158
952
 
1159
953
  /**
1160
- * Formatted `step_events` row for application use.
954
+ * SQLite row shape for `run_step_attempts`.
955
+ *
956
+ * Succeeded attempts use a `result_type` discriminator:
957
+ *
958
+ * - `'json'` → `result_json` holds the raw JSON value (never NULL)
959
+ * - `'none'` → callback returned `undefined`/`void`; no result data
1161
960
  */
1162
- type StepEvent = {
961
+ type RunStepAttempt_Row = {
1163
962
  id: string;
1164
- stepId: string;
1165
- recordedAt: Date;
963
+ step_id: RunStepId;
964
+ started_at: number;
1166
965
  } & (
1167
966
  | {
1168
- type: "attempt_started";
1169
- attemptNumber: number;
1170
- }
1171
- | {
1172
- type: "attempt_succeeded";
1173
- attemptNumber: number;
1174
- result?: string;
1175
- }
1176
- | {
1177
- type: "attempt_failed";
1178
- attemptNumber: number;
1179
- errorMessage: string;
1180
- errorName?: string;
1181
- nextAttemptAt?: Date;
1182
- }
1183
- | {
1184
- type: "sleep_waiting";
1185
- wakeAt: Date;
1186
- }
1187
- | {
1188
- type: "sleep_elapsed";
1189
- }
1190
- | {
1191
- type: "wait_waiting";
1192
- eventName: string;
1193
- timeoutAt?: Date;
1194
- }
1195
- | {
1196
- type: "wait_satisfied";
1197
- payload?: string;
967
+ state: "started";
1198
968
  }
969
+ | ({
970
+ state: "succeeded";
971
+ ended_at: number;
972
+ } & ({ result_type: "json"; result_json: string } | { result_type: "none" }))
1199
973
  | {
1200
- type: "wait_timed_out";
974
+ state: "failed";
975
+ error_message: string;
976
+ error_name: string | null;
977
+ ended_at: number;
978
+ next_attempt_at: number | null;
1201
979
  }
1202
980
  );
1203
981
 
1204
- function formatStepEvent(row: StepEventRow): StepEvent {
1205
- switch (row.type) {
1206
- case "attempt_started":
1207
- return {
1208
- id: row.id,
1209
- type: "attempt_started",
1210
- attemptNumber: row.attempt_number,
1211
- stepId: row.step_id,
1212
- recordedAt: new Date(row.recorded_at)
1213
- };
1214
- case "attempt_succeeded":
1215
- return {
1216
- id: row.id,
1217
- type: "attempt_succeeded",
1218
- attemptNumber: row.attempt_number,
1219
- result: row.result,
1220
- stepId: row.step_id,
1221
- recordedAt: new Date(row.recorded_at)
1222
- };
1223
- case "attempt_failed":
1224
- return {
1225
- id: row.id,
1226
- type: "attempt_failed",
1227
- attemptNumber: row.attempt_number,
1228
- errorMessage: row.error_message,
1229
- errorName: row.error_name ?? undefined,
1230
- nextAttemptAt: row.next_attempt_at ? new Date(row.next_attempt_at) : undefined,
1231
- stepId: row.step_id,
1232
- recordedAt: new Date(row.recorded_at)
1233
- };
1234
- case "sleep_waiting":
982
+ type StartedRunStepAttempt_Row = Extract<RunStepAttempt_Row, { state: "started" }>;
983
+ type SucceededRunStepAttempt_Row = Extract<RunStepAttempt_Row, { state: "succeeded" }>;
984
+ type FailedRunStepAttempt_Row = Extract<RunStepAttempt_Row, { state: "failed" }>;
985
+
986
+ function formatRunStepAttempt(attempt: StartedRunStepAttempt_Row): StartedRunStepAttempt;
987
+ function formatRunStepAttempt(attempt: SucceededRunStepAttempt_Row): SucceededRunStepAttempt;
988
+ function formatRunStepAttempt(attempt: FailedRunStepAttempt_Row): FailedRunStepAttempt;
989
+ function formatRunStepAttempt(attempt: RunStepAttempt_Row): RunStepAttempt;
990
+ function formatRunStepAttempt(attempt: RunStepAttempt_Row): RunStepAttempt {
991
+ switch (attempt.state) {
992
+ case "started":
1235
993
  return {
1236
- id: row.id,
1237
- type: "sleep_waiting",
1238
- wakeAt: new Date(row.wake_at),
1239
- stepId: row.step_id,
1240
- recordedAt: new Date(row.recorded_at)
994
+ id: attempt.id,
995
+ stepId: attempt.step_id,
996
+ startedAt: new Date(attempt.started_at),
997
+ state: "started"
1241
998
  };
1242
- case "sleep_elapsed":
1243
- return {
1244
- id: row.id,
1245
- type: "sleep_elapsed",
1246
- stepId: row.step_id,
1247
- recordedAt: new Date(row.recorded_at)
999
+ case "succeeded": {
1000
+ const base = {
1001
+ id: attempt.id,
1002
+ stepId: attempt.step_id,
1003
+ startedAt: new Date(attempt.started_at),
1004
+ state: "succeeded" as const,
1005
+ endedAt: new Date(attempt.ended_at)
1248
1006
  };
1249
- case "wait_waiting":
1007
+ if (attempt.result_type === "json") {
1008
+ return { ...base, resultType: "json" as const, resultJson: attempt.result_json };
1009
+ }
1010
+ return { ...base, resultType: attempt.result_type };
1011
+ }
1012
+ case "failed":
1250
1013
  return {
1251
- id: row.id,
1252
- type: "wait_waiting",
1253
- eventName: row.event_name,
1254
- timeoutAt: row.timeout_at ? new Date(row.timeout_at) : undefined,
1255
- stepId: row.step_id,
1256
- recordedAt: new Date(row.recorded_at)
1014
+ id: attempt.id,
1015
+ stepId: attempt.step_id,
1016
+ startedAt: new Date(attempt.started_at),
1017
+ state: "failed",
1018
+ errorMessage: attempt.error_message,
1019
+ errorName: attempt.error_name ?? undefined,
1020
+ endedAt: new Date(attempt.ended_at),
1021
+ nextAttemptAt: attempt.next_attempt_at != null ? new Date(attempt.next_attempt_at) : undefined
1257
1022
  };
1258
- case "wait_satisfied":
1023
+ }
1024
+ }
1025
+
1026
+ function formatRunStep(step: RunStep_Row): RunStep {
1027
+ return {
1028
+ type: "run",
1029
+ id: step.id,
1030
+ createdAt: new Date(step.created_at),
1031
+ maxAttempts: step.max_attempts,
1032
+ parentStepId: step.parent_step_id
1033
+ };
1034
+ }
1035
+
1036
+ function formatSleepStep(step: SleepStep_Row): SleepStep {
1037
+ switch (step.state) {
1038
+ case "waiting":
1259
1039
  return {
1260
- id: row.id,
1261
- type: "wait_satisfied",
1262
- payload: row.payload,
1263
- stepId: row.step_id,
1264
- recordedAt: new Date(row.recorded_at)
1265
- };
1266
- case "wait_timed_out":
1040
+ type: "sleep",
1041
+ id: step.id,
1042
+ state: "waiting",
1043
+ wakeAt: new Date(step.target_wake_at),
1044
+ createdAt: new Date(step.created_at),
1045
+ parentStepId: step.parent_step_id
1046
+ } satisfies SleepStep;
1047
+ case "elapsed":
1267
1048
  return {
1268
- id: row.id,
1269
- type: "wait_timed_out",
1270
- stepId: row.step_id,
1271
- recordedAt: new Date(row.recorded_at)
1272
- };
1049
+ type: "sleep",
1050
+ id: step.id,
1051
+ state: "elapsed",
1052
+ resolvedAt: new Date(step.resolved_at),
1053
+ createdAt: new Date(step.created_at),
1054
+ parentStepId: step.parent_step_id
1055
+ } satisfies SleepStep;
1056
+ default:
1057
+ throw new Error("Unexpected sleep step state");
1273
1058
  }
1274
1059
  }
1275
1060
 
1061
+ function formatWaitingWaitStep(step: WaitingWaitStep_Row): WaitingWaitStep {
1062
+ return {
1063
+ type: "wait",
1064
+ id: step.id,
1065
+ state: "waiting",
1066
+ eventName: step.event_name,
1067
+ timeoutAt: step.timeout_at != null ? new Date(step.timeout_at) : undefined,
1068
+ createdAt: new Date(step.created_at),
1069
+ parentStepId: step.parent_step_id
1070
+ };
1071
+ }
1072
+
1073
+ function formatTimedOutWaitStep(step: TimedOutWaitStep_Row): TimedOutWaitStep {
1074
+ return {
1075
+ type: "wait",
1076
+ id: step.id,
1077
+ state: "timed_out",
1078
+ eventName: step.event_name,
1079
+ resolvedAt: new Date(step.resolved_at),
1080
+ createdAt: new Date(step.created_at),
1081
+ parentStepId: step.parent_step_id,
1082
+ timeoutAt: new Date(step.timeout_at)
1083
+ };
1084
+ }
1085
+
1086
+ function formatSatisfiedWaitStep<T extends Json | undefined>(
1087
+ step: SatisfiedWaitStep_Row,
1088
+ payload: T
1089
+ ): SatisfiedWaitStep<T> {
1090
+ return {
1091
+ type: "wait",
1092
+ id: step.id,
1093
+ state: "satisfied",
1094
+ payload: payload,
1095
+ createdAt: new Date(step.created_at),
1096
+ eventName: step.event_name,
1097
+ resolvedAt: new Date(step.resolved_at),
1098
+ parentStepId: step.parent_step_id,
1099
+ timeoutAt: step.timeout_at != null ? new Date(step.timeout_at) : undefined
1100
+ };
1101
+ }
1102
+
1276
1103
  /**
1277
1104
  * SQLite row shape for `workflow_events` (append-only workflow status transitions).
1278
1105
  */
@@ -1332,128 +1159,56 @@ type RunStep_Row = {
1332
1159
  * Enclosing run step id when this run step was created inside that run's callback; otherwise null.
1333
1160
  */
1334
1161
  parent_step_id: RunStepId | null;
1335
-
1336
- /**
1337
- * Number of attempts that have been durably started for this step.
1338
- *
1339
- * Invariants:
1340
- *
1341
- * - `0` before the first attempt starts
1342
- * - Incremented exactly when an attempt transitions into `running`
1343
- * - Never decremented
1344
- * - Unchanged while the step is pending between retries/backoff
1345
- *
1346
- * When greater than `0`, this is also the 1-based number of the most recently started attempt. The next attempt, if
1347
- * one is started, will have number `attempts_started + 1`.
1348
- */
1349
- attempt_count: number;
1350
1162
  /**
1351
1163
  * Maximum number of attempts that can be made for this step. If not present, the step can be retried indefinitely. If
1352
1164
  * present, the step can be retried up to this number of times. If the step has reached the maximum number of
1353
1165
  * attempts, it will transition to the `failed` state.
1354
1166
  */
1355
1167
  max_attempts: number | null;
1356
- } & (
1357
- | {
1358
- /**
1359
- * The step has been reached but is not yet resolved, and no attempt is currently in progress.
1360
- *
1361
- * This includes steps that: 1. have been reached but have not yet started their first attempt 2. are waiting
1362
- * until a retry/backoff time 3. are eligible to run immediately.
1363
- */
1364
- state: "pending";
1365
-
1366
- /**
1367
- * Earliest time at which the next attempt may start.
1368
- *
1369
- * Semantics: - On first creation, this is typically set to "now", meaning the step is immediately runnable. -
1370
- * After a failed attempt with backoff, this is set to the retry time. - While the current time is before this
1371
- * value, the step remains `pending` and no new attempt may start. - Once the current time reaches or passes this
1372
- * value, the step becomes eligible to transition from `pending` to `running`.
1373
- */
1374
- next_attempt_at: number;
1375
- }
1376
- | {
1377
- /**
1378
- * An attempt was durably started, but its outcome has not yet been durably recorded.
1379
- *
1380
- * This does not guarantee that code is actively executing at this instant. After a restart or interruption,
1381
- * `running` means only that a start was recorded and no durable success/failure was recorded afterward.
1382
- */
1383
- state: "running";
1384
- }
1385
- | {
1386
- /**
1387
- * The step completed successfully and its result was durably recorded.
1388
- */
1389
- state: "succeeded";
1390
-
1391
- /**
1392
- * Serialized successful result for the step.
1393
- */
1394
- result: string;
1395
-
1396
- /**
1397
- * Time at which the step became terminal by succeeding.
1398
- */
1399
- resolved_at: number;
1400
- }
1401
- | {
1402
- /**
1403
- * The step failed terminally and no further attempts will be made.
1404
- */
1405
- state: "failed";
1406
-
1407
- /**
1408
- * Serialized failure message for the terminal failure.
1409
- */
1410
- error_message: string;
1411
-
1412
- /**
1413
- * Optional serialized error class/name for the terminal failure.
1414
- */
1415
- error_name: string | null;
1416
-
1417
- /**
1418
- * Time at which the step became terminal by failing.
1419
- */
1420
- resolved_at: number;
1421
- }
1422
- );
1168
+ };
1423
1169
 
1424
1170
  type SleepStep_Row = {
1425
1171
  id: SleepStepId;
1426
1172
  type: "sleep";
1427
1173
  created_at: number;
1428
1174
  /**
1429
- * Enclosing run step id when this sleep step was created inside that run's callback; otherwise null.
1175
+ * Id of the enclosing run step if this sleep step was created from within that run step's callback; otherwise null.
1430
1176
  */
1431
1177
  parent_step_id: RunStepId | null;
1432
1178
  } & (
1433
1179
  | {
1434
1180
  /**
1435
- * The sleep step has been reached and is currently in effect.
1181
+ * The sleep step has started and is not yet terminal.
1436
1182
  *
1437
- * `waiting` here means "started but not yet resolved", not "not yet started". The step remains waiting until its
1438
- * wake time is reached.
1183
+ * In this state, the step is considered active until its target wake time is reached and that transition is
1184
+ * durably recorded.
1439
1185
  */
1440
1186
  state: "waiting";
1441
1187
 
1442
1188
  /**
1443
- * Earliest time at which the sleep condition becomes satisfied.
1189
+ * Target time at or after which this sleep step may transition to `elapsed`.
1444
1190
  *
1445
- * Before this moment, the step remains waiting. At or after this moment, the step may be marked elapsed.
1191
+ * This is part of the step's configured behavior, not an indicator that the step is still pending.
1446
1192
  */
1447
- wake_at: number;
1193
+ target_wake_at: number;
1448
1194
  }
1449
1195
  | {
1450
1196
  /**
1451
- * The sleep interval elapsed and that fact was durably recorded.
1197
+ * The sleep step has completed because its target wake time was reached and that outcome was durably recorded.
1452
1198
  */
1453
1199
  state: "elapsed";
1454
1200
 
1455
1201
  /**
1456
- * Time at which the sleep step became terminal by completing.
1202
+ * Target time at or after which this sleep step became eligible to transition to `elapsed`.
1203
+ *
1204
+ * Retained on terminal rows so the completed step still carries its original timing configuration.
1205
+ */
1206
+ target_wake_at: number;
1207
+
1208
+ /**
1209
+ * Time at which the transition to `elapsed` was durably recorded.
1210
+ *
1211
+ * This may be equal to or later than `target_wake_at`.
1457
1212
  */
1458
1213
  resolved_at: number;
1459
1214
  }
@@ -1464,177 +1219,80 @@ type WaitStep_Row = {
1464
1219
  type: "wait";
1465
1220
  created_at: number;
1466
1221
  /**
1467
- * Enclosing run step id when this wait step was created inside that run's callback; otherwise null.
1222
+ * Id of the enclosing run step if this wait step was created from within that run step's callback; otherwise null.
1468
1223
  */
1469
1224
  parent_step_id: RunStepId | null;
1470
1225
 
1471
1226
  /**
1472
- * Name of the inbound event that can satisfy this step.
1227
+ * Name of the inbound event that can satisfy this wait step.
1473
1228
  */
1474
1229
  event_name: string;
1475
1230
  } & (
1476
1231
  | {
1477
1232
  /**
1478
- * The step has been reached, but the expected event has not yet been durably received, and no timeout failure has
1479
- * been durably recorded.
1233
+ * The wait step has started and is not yet terminal.
1234
+ *
1235
+ * In this state, the expected event has not yet been durably matched to the step, and no timeout outcome has been
1236
+ * durably recorded.
1480
1237
  */
1481
1238
  state: "waiting";
1482
1239
 
1483
1240
  /**
1484
- * Optional deadline after which the wait step may fail.
1241
+ * Optional time at or after which this wait step may transition to `timed_out`.
1485
1242
  *
1486
- * - If omitted, the step may wait indefinitely.
1487
- * - If present, reaching or passing this time allows the step to transition to `failed`.
1243
+ * - If null, the step may wait indefinitely.
1244
+ * - If set, reaching or passing this time makes the step eligible to time out.
1488
1245
  */
1489
1246
  timeout_at: number | null;
1490
1247
  }
1491
1248
  | {
1492
1249
  /**
1493
- * The expected event was received and its payload was durably recorded.
1250
+ * The wait step has completed because a matching event was durably received and recorded.
1494
1251
  */
1495
1252
  state: "satisfied";
1496
1253
 
1497
1254
  /**
1498
- * Serialized payload of the event that satisfied the wait.
1255
+ * Optional timeout that applied while this step was active.
1256
+ *
1257
+ * Retained on terminal rows so the completed step still carries its original timing configuration.
1499
1258
  */
1500
- payload: string;
1259
+ timeout_at: number | null;
1501
1260
 
1502
1261
  /**
1503
- * Time at which the wait step became terminal by receiving the expected event.
1262
+ * Time at which the transition to `satisfied` was durably recorded.
1504
1263
  */
1505
1264
  resolved_at: number;
1506
1265
  }
1507
1266
  | {
1508
1267
  /**
1509
- * The step failed because its timeout was reached before the expected event was durably recorded.
1268
+ * The wait step has completed by timing out before a matching event was durably received.
1510
1269
  */
1511
1270
  state: "timed_out";
1512
1271
 
1513
1272
  /**
1514
- * Time at which the wait step became terminal by timing out.
1273
+ * Time at or after which this step became eligible to transition to `timed_out`.
1274
+ */
1275
+ timeout_at: number;
1276
+
1277
+ /**
1278
+ * Time at which the transition to `timed_out` was durably recorded.
1279
+ *
1280
+ * This may be equal to or later than `timeout_at`.
1515
1281
  */
1516
1282
  resolved_at: number;
1517
1283
  }
1518
1284
  );
1519
1285
 
1286
+ type WaitingWaitStep_Row = Extract<WaitStep_Row, { state: "waiting" }>;
1287
+ type SatisfiedWaitStep_Row = Extract<WaitStep_Row, { state: "satisfied" }>;
1288
+ type TimedOutWaitStep_Row = Extract<WaitStep_Row, { state: "timed_out" }>;
1289
+
1520
1290
  /**
1521
1291
  * A step row is created once the workflow reaches that step. From that point on, `status` describes the step's durable
1522
1292
  * lifecycle state.
1523
1293
  */
1524
1294
  type Step_Row = RunStep_Row | SleepStep_Row | WaitStep_Row;
1525
1295
 
1526
- /**
1527
- * SQLite row shape for `step_events`: append-only durable transitions for steps.
1528
- *
1529
- * The `steps` table holds current state; `step_events` records how that state evolved.
1530
- */
1531
- type StepEventRow = {
1532
- id: string;
1533
-
1534
- /**
1535
- * `steps.id` this event applies to.
1536
- */
1537
- step_id: RunStepId | SleepStepId | WaitStepId;
1538
-
1539
- /**
1540
- * When this event row was persisted (`unixepoch` ms).
1541
- */
1542
- recorded_at: number;
1543
- } & (
1544
- | {
1545
- /**
1546
- * A run step attempt was durably started.
1547
- *
1548
- * This corresponds to the step transitioning from `pending` → `running`.
1549
- */
1550
- type: "attempt_started";
1551
-
1552
- /**
1553
- * 1-based attempt number.
1554
- *
1555
- * Equals the value of `attempts_started` after the transition.
1556
- */
1557
- attempt_number: number;
1558
- }
1559
- | {
1560
- /**
1561
- * A run step attempt completed successfully.
1562
- *
1563
- * The step transitioned from `running` to `succeeded`.
1564
- */
1565
- type: "attempt_succeeded";
1566
-
1567
- attempt_number: number;
1568
-
1569
- /**
1570
- * Serialized result produced by the step.
1571
- */
1572
- result: string;
1573
- }
1574
- | {
1575
- /**
1576
- * A run step attempt failed.
1577
- *
1578
- * The step either: - scheduled a next attempt, or - transitioned to terminal failure.
1579
- */
1580
- type: "attempt_failed";
1581
-
1582
- attempt_number: number;
1583
-
1584
- error_message: string;
1585
- error_name: string | null;
1586
-
1587
- /**
1588
- * If present, the next attempt time.
1589
- *
1590
- * Absence indicates this failure was terminal.
1591
- */
1592
- next_attempt_at?: number;
1593
- }
1594
- | {
1595
- /**
1596
- * A sleep step began waiting.
1597
- *
1598
- * Corresponds to the step entering `waiting`.
1599
- */
1600
- type: "sleep_waiting";
1601
-
1602
- /**
1603
- * Wake time for the sleep step.
1604
- */
1605
- wake_at: number;
1606
- }
1607
- | {
1608
- /**
1609
- * A sleep step completed because the wake condition became satisfied.
1610
- */
1611
- type: "sleep_elapsed";
1612
- }
1613
- | {
1614
- /**
1615
- * A wait step began waiting for the expected event.
1616
- */
1617
- type: "wait_waiting";
1618
-
1619
- event_name: string;
1620
- timeout_at: number | null;
1621
- }
1622
- | {
1623
- /**
1624
- * A wait step was satisfied by receiving the expected event.
1625
- */
1626
- type: "wait_satisfied";
1627
-
1628
- payload: string;
1629
- }
1630
- | {
1631
- /**
1632
- * A wait step failed because its timeout deadline was reached.
1633
- */
1634
- type: "wait_timed_out";
1635
- }
1636
- );
1637
-
1638
1296
  export type WorkflowStatus =
1639
1297
  | "pending" // The workflow has been created but 'run' hasn't been called yet
1640
1298
  | "running" // The workflow is currently executing; steps are being created/processed
@@ -1651,55 +1309,56 @@ type WorkflowMetadata_Row<TVersion extends string> = {
1651
1309
  definition_input: string | null;
1652
1310
  };
1653
1311
 
1654
- type WorkflowMetadata<TVersion extends string> = {
1655
- createdAt: Date;
1656
- updatedAt: Date;
1657
- status: WorkflowStatus;
1658
- definitionVersion?: TVersion;
1659
- definitionInput?: Json;
1660
- };
1661
-
1662
- export function formatWorkflowMetadata<TVersion extends string>(
1663
- metadata: WorkflowMetadata_Row<TVersion>
1664
- ): WorkflowMetadata<TVersion> {
1665
- return {
1666
- createdAt: new Date(metadata.created_at),
1667
- updatedAt: new Date(metadata.updated_at),
1668
- status: metadata.status,
1669
- definitionVersion: metadata.definition_version ?? undefined,
1670
- definitionInput: metadata.definition_input ? JSON.parse(metadata.definition_input) : undefined
1671
- };
1672
- }
1673
1312
  /**
1674
1313
  * Durable record of inbound events (`inbound_events`) that may satisfy wait steps.
1675
1314
  *
1676
1315
  * Persists delivered signals across restarts so step resolution is based on durable state rather than in-memory state.
1677
1316
  */
1678
- export type InboundEventRow = {
1679
- /**
1680
- * Unique identifier for the event.
1681
- */
1317
+ type InboundEvent_Row = {
1682
1318
  id: string;
1683
- /**
1684
- * Name of the event.
1685
- *
1686
- * Used by wait steps to determine whether this event can satisfy them.
1687
- */
1688
1319
  event_name: string;
1689
1320
  /**
1690
- * Serialized payload delivered with the event.
1691
- */
1692
- payload: string;
1693
- /**
1694
- * Time the event was durably recorded.
1321
+ * Raw JSON value, or SQL NULL for undefined (no payload).
1695
1322
  */
1323
+ payload: string | null;
1696
1324
  created_at: number;
1697
- /**
1698
- * Step that claimed the event.
1699
- */
1700
- claimed_by?: RunStepId | SleepStepId | WaitStepId;
1701
- /**
1702
- * Time the event was claimed.
1703
- */
1704
- claimed_at?: number;
1705
- };
1325
+ } & (
1326
+ | {
1327
+ /**
1328
+ * Step that claimed the event.
1329
+ */
1330
+ claimed_by: WaitStepId;
1331
+ /**
1332
+ * Time the event was claimed.
1333
+ */
1334
+ claimed_at: number;
1335
+ }
1336
+ | {
1337
+ claimed_by: null;
1338
+ claimed_at: null;
1339
+ }
1340
+ );
1341
+
1342
+ type ClaimedInboundEvent_Row = Extract<
1343
+ InboundEvent_Row,
1344
+ {
1345
+ claimed_by: WaitStepId;
1346
+ claimed_at: number;
1347
+ }
1348
+ >;
1349
+
1350
+ type InboundEvent<T extends Json | undefined = Json | undefined> = {
1351
+ id: string;
1352
+ eventName: string;
1353
+ payload: T;
1354
+ createdAt: Date;
1355
+ } & (
1356
+ | {
1357
+ claimedBy: WaitStepId;
1358
+ claimedAt: Date;
1359
+ }
1360
+ | {
1361
+ claimedBy?: never;
1362
+ claimedAt?: never;
1363
+ }
1364
+ );