pg-workflows 0.5.0 → 0.6.1

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/README.md CHANGED
@@ -441,6 +441,41 @@ await engine.resumeWorkflow({
441
441
  });
442
442
  ```
443
443
 
444
+ ### Fast-Forward
445
+
446
+ Skip the current waiting step and immediately resume execution. `fastForwardWorkflow` inspects the paused step and dispatches the right internal action — `triggerEvent` for `waitFor`, timeout triggers for `delay`/`waitUntil`, resume for `pause`, and direct output writes for `poll`. If the workflow is not paused, it's a no-op.
447
+
448
+ This is useful for testing, debugging, or manually advancing workflows past long waits.
449
+
450
+ ```typescript
451
+ // Fast-forward a waitFor step, providing mock event data
452
+ await engine.fastForwardWorkflow({
453
+ runId: run.id,
454
+ resourceId: 'user-123',
455
+ data: { approved: true, reviewer: 'admin' },
456
+ });
457
+
458
+ // Fast-forward a delay/waitUntil step (no data needed)
459
+ await engine.fastForwardWorkflow({
460
+ runId: run.id,
461
+ resourceId: 'user-123',
462
+ });
463
+
464
+ // Fast-forward a poll step with mock result data
465
+ await engine.fastForwardWorkflow({
466
+ runId: run.id,
467
+ resourceId: 'user-123',
468
+ data: { paymentId: 'pay_123', status: 'completed' },
469
+ });
470
+ ```
471
+
472
+ | Paused step type | Behavior |
473
+ |------------------|----------|
474
+ | `step.waitFor()` | Triggers the event with `data` (defaults to `{}`) |
475
+ | `step.delay()` / `step.waitUntil()` | Triggers the timeout event to skip the wait |
476
+ | `step.poll()` | Writes `data` as the poll result and triggers resolution |
477
+ | `step.pause()` | Delegates to `resumeWorkflow()` |
478
+
444
479
  ---
445
480
 
446
481
  ## Examples
@@ -588,6 +623,7 @@ When `boss` is omitted, pg-boss is created automatically with an isolated schema
588
623
  | `resumeWorkflow({ runId, resourceId?, options? })` | Resume a paused workflow |
589
624
  | `cancelWorkflow({ runId, resourceId? })` | Cancel a workflow |
590
625
  | `triggerEvent({ runId, resourceId?, eventName, data?, options? })` | Send an event to a workflow |
626
+ | `fastForwardWorkflow({ runId, resourceId?, data? })` | Skip the current waiting step and resume execution |
591
627
  | `getRun({ runId, resourceId? })` | Get workflow run details |
592
628
  | `checkProgress({ runId, resourceId? })` | Get workflow progress |
593
629
  | `getRuns(filters)` | List workflow runs with pagination |
package/dist/index.cjs CHANGED
@@ -271,15 +271,23 @@ async function runMigrations(db) {
271
271
  job_id varchar(256)
272
272
  );
273
273
  `, []);
274
- await db.executeSql(`
275
- CREATE INDEX workflow_runs_workflow_id_idx ON workflow_runs USING btree (workflow_id);
276
- `, []);
277
274
  await db.executeSql(`
278
275
  CREATE INDEX workflow_runs_created_at_idx ON workflow_runs USING btree (created_at);
276
+ CREATE INDEX workflow_runs_resource_id_created_at_idx ON workflow_runs USING btree (resource_id, created_at DESC);
277
+ CREATE INDEX workflow_runs_status_created_at_idx ON workflow_runs USING btree (status, created_at DESC);
278
+ CREATE INDEX workflow_runs_workflow_id_created_at_idx ON workflow_runs USING btree (workflow_id, created_at DESC);
279
+ CREATE INDEX workflow_runs_resource_id_workflow_id_created_at_idx ON workflow_runs USING btree (resource_id, workflow_id, created_at DESC);
279
280
  `, []);
281
+ } else {
280
282
  await db.executeSql(`
281
- CREATE INDEX workflow_runs_resource_id_idx ON workflow_runs USING btree (resource_id);
282
- `, []);
283
+ DROP INDEX IF EXISTS workflow_runs_workflow_id_idx;
284
+ DROP INDEX IF EXISTS workflow_runs_resource_id_idx;
285
+ CREATE INDEX IF NOT EXISTS workflow_runs_created_at_idx ON workflow_runs USING btree (created_at);
286
+ CREATE INDEX IF NOT EXISTS workflow_runs_resource_id_created_at_idx ON workflow_runs USING btree (resource_id, created_at DESC);
287
+ CREATE INDEX IF NOT EXISTS workflow_runs_status_created_at_idx ON workflow_runs USING btree (status, created_at DESC);
288
+ CREATE INDEX IF NOT EXISTS workflow_runs_workflow_id_created_at_idx ON workflow_runs USING btree (workflow_id, created_at DESC);
289
+ CREATE INDEX IF NOT EXISTS workflow_runs_resource_id_workflow_id_created_at_idx ON workflow_runs USING btree (resource_id, workflow_id, created_at DESC);
290
+ `, []);
283
291
  }
284
292
  }
285
293
 
@@ -322,13 +330,13 @@ async function insertWorkflowRun({
322
330
  const runId = generateKSUID("run");
323
331
  const now = new Date;
324
332
  const result = await db.executeSql(`INSERT INTO workflow_runs (
325
- id,
326
- resource_id,
327
- workflow_id,
328
- current_step_id,
329
- status,
330
- input,
331
- max_retries,
333
+ id,
334
+ resource_id,
335
+ workflow_id,
336
+ current_step_id,
337
+ status,
338
+ input,
339
+ max_retries,
332
340
  timeout_at,
333
341
  created_at,
334
342
  updated_at,
@@ -375,7 +383,8 @@ async function getWorkflowRun({
375
383
  async function updateWorkflowRun({
376
384
  runId,
377
385
  resourceId,
378
- data
386
+ data,
387
+ expectedStatuses
379
388
  }, db) {
380
389
  const now = new Date;
381
390
  const updates = ["updated_at = $1"];
@@ -431,10 +440,20 @@ async function updateWorkflowRun({
431
440
  values.push(data.jobId);
432
441
  paramIndex++;
433
442
  }
434
- const whereClause = resourceId ? `WHERE id = $${paramIndex} AND resource_id = $${paramIndex + 1}` : `WHERE id = $${paramIndex}`;
435
443
  values.push(runId);
444
+ const idParam = paramIndex;
445
+ paramIndex++;
436
446
  if (resourceId) {
437
447
  values.push(resourceId);
448
+ paramIndex++;
449
+ }
450
+ if (expectedStatuses && expectedStatuses.length > 0) {
451
+ values.push(expectedStatuses);
452
+ paramIndex++;
453
+ }
454
+ let whereClause = resourceId ? `WHERE id = $${idParam} AND resource_id = $${idParam + 1}` : `WHERE id = $${idParam}`;
455
+ if (expectedStatuses && expectedStatuses.length > 0) {
456
+ whereClause += ` AND status = ANY($${paramIndex - 1})`;
438
457
  }
439
458
  const query = `
440
459
  UPDATE workflow_runs
@@ -475,37 +494,50 @@ async function getWorkflowRuns({
475
494
  values.push(workflowId);
476
495
  paramIndex++;
477
496
  }
478
- if (startingAfter) {
479
- const cursorResult = await db.executeSql("SELECT created_at FROM workflow_runs WHERE id = $1 LIMIT 1", [startingAfter]);
480
- if (cursorResult.rows[0]?.created_at) {
481
- conditions.push(`created_at < $${paramIndex}`);
482
- values.push(typeof cursorResult.rows[0].created_at === "string" ? new Date(cursorResult.rows[0].created_at) : cursorResult.rows[0].created_at);
483
- paramIndex++;
497
+ const cursorIds = [startingAfter, endingBefore].filter(Boolean);
498
+ if (cursorIds.length > 0) {
499
+ const cursorResult = await db.executeSql("SELECT id, created_at FROM workflow_runs WHERE id = ANY($1)", [cursorIds]);
500
+ const cursorMap = new Map;
501
+ for (const row of cursorResult.rows) {
502
+ cursorMap.set(row.id, typeof row.created_at === "string" ? new Date(row.created_at) : row.created_at);
484
503
  }
485
- }
486
- if (endingBefore) {
487
- const cursorResult = await db.executeSql("SELECT created_at FROM workflow_runs WHERE id = $1 LIMIT 1", [endingBefore]);
488
- if (cursorResult.rows[0]?.created_at) {
489
- conditions.push(`created_at > $${paramIndex}`);
490
- values.push(typeof cursorResult.rows[0].created_at === "string" ? new Date(cursorResult.rows[0].created_at) : cursorResult.rows[0].created_at);
491
- paramIndex++;
504
+ if (startingAfter) {
505
+ const cursor = cursorMap.get(startingAfter);
506
+ if (cursor) {
507
+ conditions.push(`created_at < $${paramIndex}`);
508
+ values.push(cursor);
509
+ paramIndex++;
510
+ }
511
+ }
512
+ if (endingBefore) {
513
+ const cursor = cursorMap.get(endingBefore);
514
+ if (cursor) {
515
+ conditions.push(`created_at > $${paramIndex}`);
516
+ values.push(cursor);
517
+ paramIndex++;
518
+ }
492
519
  }
493
520
  }
494
521
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
495
522
  const actualLimit = Math.min(Math.max(limit, 1), 100) + 1;
523
+ const isBackward = !!endingBefore && !startingAfter;
496
524
  const query = `
497
525
  SELECT * FROM workflow_runs
498
526
  ${whereClause}
499
- ORDER BY created_at DESC
527
+ ORDER BY created_at ${isBackward ? "ASC" : "DESC"}
500
528
  LIMIT $${paramIndex}
501
529
  `;
502
530
  values.push(actualLimit);
503
531
  const result = await db.executeSql(query, values);
504
532
  const rows = result.rows;
505
- const hasMore = rows.length > (limit ?? 20);
506
- const rawItems = hasMore ? rows.slice(0, limit) : rows;
533
+ const hasExtraRow = rows.length > (limit ?? 20);
534
+ const rawItems = hasExtraRow ? rows.slice(0, limit) : rows;
535
+ if (isBackward) {
536
+ rawItems.reverse();
537
+ }
507
538
  const items = rawItems.map((row) => mapRowToWorkflowRun(row));
508
- const hasPrev = !!endingBefore;
539
+ const hasMore = isBackward ? items.length > 0 : hasExtraRow;
540
+ const hasPrev = isBackward ? hasExtraRow : !!startingAfter && items.length > 0;
509
541
  const nextCursor = hasMore && items.length > 0 ? items[items.length - 1]?.id ?? null : null;
510
542
  const prevCursor = hasPrev && items.length > 0 ? items[0]?.id ?? null : null;
511
543
  return { items, nextCursor, prevCursor, hasMore, hasPrev };
@@ -711,7 +743,8 @@ class WorkflowEngine {
711
743
  data: {
712
744
  status: "paused" /* PAUSED */,
713
745
  pausedAt: new Date
714
- }
746
+ },
747
+ expectedStatuses: ["running" /* RUNNING */, "pending" /* PENDING */]
715
748
  });
716
749
  this.logger.log("Paused workflow run", {
717
750
  runId,
@@ -725,6 +758,10 @@ class WorkflowEngine {
725
758
  options
726
759
  }) {
727
760
  await this.checkIfHasStarted();
761
+ const current = await this.getRun({ runId, resourceId });
762
+ if (current.status !== "paused" /* PAUSED */) {
763
+ throw new WorkflowEngineError(`Cannot resume workflow run in '${current.status}' status, must be 'paused'`, current.workflowId, runId);
764
+ }
728
765
  return this.triggerEvent({
729
766
  runId,
730
767
  resourceId,
@@ -733,6 +770,51 @@ class WorkflowEngine {
733
770
  options
734
771
  });
735
772
  }
773
+ async fastForwardWorkflow({
774
+ runId,
775
+ resourceId,
776
+ data
777
+ }) {
778
+ await this.checkIfHasStarted();
779
+ const run = await this.getRun({ runId, resourceId });
780
+ if (run.status !== "paused" /* PAUSED */) {
781
+ return run;
782
+ }
783
+ const stepId = run.currentStepId;
784
+ const waitForStep = this.getWaitForStepEntry(run.timeline, stepId);
785
+ if (!waitForStep) {
786
+ return run;
787
+ }
788
+ const { eventName, timeoutEvent, skipOutput } = waitForStep.waitFor;
789
+ if (eventName === PAUSE_EVENT_NAME) {
790
+ return this.resumeWorkflow({ runId, resourceId });
791
+ }
792
+ if (skipOutput && timeoutEvent) {
793
+ await withPostgresTransaction(this.db, async (db) => {
794
+ const freshRun = await this.getRun({ runId, resourceId }, { exclusiveLock: true, db });
795
+ return this.updateRun({
796
+ runId,
797
+ resourceId,
798
+ data: {
799
+ timeline: import_es_toolkit.merge(freshRun.timeline, {
800
+ [stepId]: {
801
+ output: data ?? {},
802
+ timestamp: new Date
803
+ }
804
+ })
805
+ }
806
+ }, { db });
807
+ }, this.pool);
808
+ return this.triggerEvent({ runId, resourceId, eventName: timeoutEvent });
809
+ }
810
+ if (eventName) {
811
+ return this.triggerEvent({ runId, resourceId, eventName, data: data ?? {} });
812
+ }
813
+ if (timeoutEvent) {
814
+ return this.triggerEvent({ runId, resourceId, eventName: timeoutEvent, data: data ?? {} });
815
+ }
816
+ return run;
817
+ }
736
818
  async cancelWorkflow({
737
819
  runId,
738
820
  resourceId
@@ -743,7 +825,8 @@ class WorkflowEngine {
743
825
  resourceId,
744
826
  data: {
745
827
  status: "cancelled" /* CANCELLED */
746
- }
828
+ },
829
+ expectedStatuses: ["pending" /* PENDING */, "running" /* RUNNING */, "paused" /* PAUSED */]
747
830
  });
748
831
  this.logger.log(`cancelled workflow run with id ${runId}`);
749
832
  return run;
@@ -768,7 +851,7 @@ class WorkflowEngine {
768
851
  data
769
852
  }
770
853
  };
771
- this.boss.send(WORKFLOW_RUN_QUEUE_NAME, job, {
854
+ await this.boss.send(WORKFLOW_RUN_QUEUE_NAME, job, {
772
855
  expireInSeconds: options?.expireInSeconds ?? defaultExpireInSeconds
773
856
  });
774
857
  this.logger.log(`event ${eventName} sent for workflow run with id ${runId}`);
@@ -784,10 +867,17 @@ class WorkflowEngine {
784
867
  async updateRun({
785
868
  runId,
786
869
  resourceId,
787
- data
870
+ data,
871
+ expectedStatuses
788
872
  }, { db } = {}) {
789
- const run = await updateWorkflowRun({ runId, resourceId, data }, db ?? this.db);
873
+ const run = await updateWorkflowRun({ runId, resourceId, data, expectedStatuses }, db ?? this.db);
790
874
  if (!run) {
875
+ if (expectedStatuses) {
876
+ const current = await getWorkflowRun({ runId, resourceId }, { db: db ?? this.db });
877
+ if (current) {
878
+ throw new WorkflowEngineError(`Cannot update workflow run in '${current.status}' status, expected: ${expectedStatuses.join(", ")}`, current.workflowId, runId);
879
+ }
880
+ }
791
881
  throw new WorkflowRunNotFoundError(runId);
792
882
  }
793
883
  return run;
@@ -870,36 +960,40 @@ class WorkflowEngine {
870
960
  throw new WorkflowEngineError("Missing current step id", workflowId, runId);
871
961
  }
872
962
  if (run.status === "paused" /* PAUSED */) {
873
- const waitForStepEntry = run.timeline[`${run.currentStepId}-wait-for`];
874
- const waitForStep = waitForStepEntry && typeof waitForStepEntry === "object" && "waitFor" in waitForStepEntry ? waitForStepEntry : null;
875
- const currentStep = this.getCachedStepEntry(run.timeline, run.currentStepId);
876
- const waitFor = waitForStep?.waitFor;
877
- const hasCurrentStepOutput = currentStep?.output !== undefined;
878
- const eventMatches = waitFor && event?.name && (event.name === waitFor.eventName || event.name === waitFor.timeoutEvent) && !hasCurrentStepOutput;
879
- if (eventMatches) {
880
- const isTimeout = event?.name === waitFor?.timeoutEvent;
881
- const skipOutput = waitFor?.skipOutput;
882
- run = await this.updateRun({
883
- runId,
884
- resourceId: scopedResourceId,
885
- data: {
886
- status: "running" /* RUNNING */,
887
- pausedAt: null,
888
- resumedAt: new Date,
889
- jobId: job?.id,
890
- ...skipOutput ? {} : {
891
- timeline: import_es_toolkit.merge(run.timeline, {
892
- [run.currentStepId]: {
893
- output: event?.data ?? {},
894
- ...isTimeout ? { timedOut: true } : {},
895
- timestamp: new Date
896
- }
897
- })
963
+ run = await withPostgresTransaction(this.db, async (db) => {
964
+ const lockedRun = await this.getRun({ runId, resourceId: scopedResourceId }, { exclusiveLock: true, db });
965
+ if (lockedRun.status !== "paused" /* PAUSED */) {
966
+ return lockedRun;
967
+ }
968
+ const waitForStep = this.getWaitForStepEntry(lockedRun.timeline, lockedRun.currentStepId);
969
+ const currentStep = this.getCachedStepEntry(lockedRun.timeline, lockedRun.currentStepId);
970
+ const waitFor = waitForStep?.waitFor;
971
+ const hasCurrentStepOutput = currentStep?.output !== undefined;
972
+ const eventMatches = waitFor && event?.name && (event.name === waitFor.eventName || event.name === waitFor.timeoutEvent) && !hasCurrentStepOutput;
973
+ if (eventMatches) {
974
+ const isTimeout = event?.name === waitFor?.timeoutEvent;
975
+ const skipOutput = waitFor?.skipOutput;
976
+ return this.updateRun({
977
+ runId,
978
+ resourceId: scopedResourceId,
979
+ data: {
980
+ status: "running" /* RUNNING */,
981
+ pausedAt: null,
982
+ resumedAt: new Date,
983
+ jobId: job?.id,
984
+ ...skipOutput ? {} : {
985
+ timeline: import_es_toolkit.merge(lockedRun.timeline, {
986
+ [lockedRun.currentStepId]: {
987
+ output: event?.data ?? {},
988
+ ...isTimeout ? { timedOut: true } : {},
989
+ timestamp: new Date
990
+ }
991
+ })
992
+ }
898
993
  }
899
- }
900
- });
901
- } else {
902
- run = await this.updateRun({
994
+ }, { db });
995
+ }
996
+ return this.updateRun({
903
997
  runId,
904
998
  resourceId: scopedResourceId,
905
999
  data: {
@@ -908,8 +1002,8 @@ class WorkflowEngine {
908
1002
  resumedAt: new Date,
909
1003
  jobId: job?.id
910
1004
  }
911
- });
912
- }
1005
+ }, { db });
1006
+ }, this.pool);
913
1007
  }
914
1008
  const baseStep = {
915
1009
  run: async (stepId, handler) => {
@@ -1039,6 +1133,10 @@ class WorkflowEngine {
1039
1133
  const stepEntry = timeline[stepId];
1040
1134
  return stepEntry && typeof stepEntry === "object" && "output" in stepEntry ? stepEntry : null;
1041
1135
  }
1136
+ getWaitForStepEntry(timeline, stepId) {
1137
+ const entry = timeline[`${stepId}-wait-for`];
1138
+ return entry && typeof entry === "object" && "waitFor" in entry ? entry : null;
1139
+ }
1042
1140
  async runStep({
1043
1141
  stepId,
1044
1142
  run,
@@ -1080,7 +1178,7 @@ class WorkflowEngine {
1080
1178
  runId: run.id,
1081
1179
  resourceId: run.resourceId ?? undefined,
1082
1180
  data: {
1083
- timeline: import_es_toolkit.merge(run.timeline, {
1181
+ timeline: import_es_toolkit.merge(persistedRun.timeline, {
1084
1182
  [stepId]: {
1085
1183
  output,
1086
1184
  timestamp: new Date
@@ -1125,33 +1223,45 @@ ${error.stack}` : String(error)
1125
1223
  return cached.timedOut ? undefined : cached.output;
1126
1224
  }
1127
1225
  const timeoutEvent = timeoutDate ? `__timeout_${stepId}` : undefined;
1128
- await this.updateRun({
1129
- runId: run.id,
1130
- resourceId: run.resourceId ?? undefined,
1131
- data: {
1132
- status: "paused" /* PAUSED */,
1133
- currentStepId: stepId,
1134
- pausedAt: new Date,
1135
- timeline: import_es_toolkit.merge(run.timeline, {
1136
- [`${stepId}-wait-for`]: {
1137
- waitFor: { eventName, timeoutEvent },
1138
- timestamp: new Date
1139
- }
1140
- })
1141
- }
1142
- });
1143
- if (timeoutDate && timeoutEvent) {
1144
- const job = {
1226
+ await withPostgresTransaction(this.db, async (db) => {
1227
+ const freshRun = await this.getRun({ runId: run.id, resourceId: run.resourceId ?? undefined }, { exclusiveLock: true, db });
1228
+ return this.updateRun({
1145
1229
  runId: run.id,
1146
1230
  resourceId: run.resourceId ?? undefined,
1147
- workflowId: run.workflowId,
1148
- input: run.input,
1149
- event: { name: timeoutEvent, data: { date: timeoutDate.toISOString() } }
1150
- };
1151
- await this.boss.send(WORKFLOW_RUN_QUEUE_NAME, job, {
1152
- startAfter: timeoutDate.getTime() <= Date.now() ? new Date : timeoutDate,
1153
- expireInSeconds: defaultExpireInSeconds
1154
- });
1231
+ data: {
1232
+ status: "paused" /* PAUSED */,
1233
+ currentStepId: stepId,
1234
+ pausedAt: new Date,
1235
+ timeline: import_es_toolkit.merge(freshRun.timeline, {
1236
+ [`${stepId}-wait-for`]: {
1237
+ waitFor: { eventName, timeoutEvent },
1238
+ timestamp: new Date
1239
+ }
1240
+ })
1241
+ }
1242
+ }, { db });
1243
+ }, this.pool);
1244
+ if (timeoutDate && timeoutEvent) {
1245
+ try {
1246
+ const job = {
1247
+ runId: run.id,
1248
+ resourceId: run.resourceId ?? undefined,
1249
+ workflowId: run.workflowId,
1250
+ input: run.input,
1251
+ event: { name: timeoutEvent, data: { date: timeoutDate.toISOString() } }
1252
+ };
1253
+ await this.boss.send(WORKFLOW_RUN_QUEUE_NAME, job, {
1254
+ startAfter: timeoutDate.getTime() <= Date.now() ? new Date : timeoutDate,
1255
+ expireInSeconds: defaultExpireInSeconds
1256
+ });
1257
+ } catch (error) {
1258
+ await this.updateRun({
1259
+ runId: run.id,
1260
+ resourceId: run.resourceId ?? undefined,
1261
+ data: { status: "running" /* RUNNING */, pausedAt: null }
1262
+ });
1263
+ throw error;
1264
+ }
1155
1265
  }
1156
1266
  this.logger.log(`Step ${stepId} waiting${eventName ? ` for event "${eventName}"` : ""}${timeoutDate ? ` until ${timeoutDate.toISOString()}` : ""}`, { runId: run.id, workflowId: run.workflowId });
1157
1267
  }
@@ -1176,59 +1286,99 @@ ${error.stack}` : String(error)
1176
1286
  const pollStateEntry = persistedRun.timeline[`${stepId}-poll`];
1177
1287
  const startedAt = pollStateEntry && typeof pollStateEntry === "object" && "startedAt" in pollStateEntry ? new Date(pollStateEntry.startedAt) : new Date;
1178
1288
  if (timeoutMs !== undefined && Date.now() >= startedAt.getTime() + timeoutMs) {
1179
- await this.updateRun({
1289
+ await withPostgresTransaction(this.db, async (db) => {
1290
+ const freshRun = await this.getRun({ runId: run.id, resourceId: run.resourceId ?? undefined }, { exclusiveLock: true, db });
1291
+ return this.updateRun({
1292
+ runId: run.id,
1293
+ resourceId: run.resourceId ?? undefined,
1294
+ data: {
1295
+ currentStepId: stepId,
1296
+ timeline: import_es_toolkit.merge(freshRun.timeline, {
1297
+ [stepId]: { output: {}, timedOut: true, timestamp: new Date }
1298
+ })
1299
+ }
1300
+ }, { db });
1301
+ }, this.pool);
1302
+ return { timedOut: true };
1303
+ }
1304
+ let result;
1305
+ try {
1306
+ result = await conditionFn();
1307
+ } catch (error) {
1308
+ this.logger.error(`Poll conditionFn for step ${stepId} threw an error, treating as non-match and continuing to poll`, error, { runId: run.id, workflowId: run.workflowId });
1309
+ if (timeoutMs !== undefined && Date.now() >= startedAt.getTime() + timeoutMs) {
1310
+ await withPostgresTransaction(this.db, async (db) => {
1311
+ const freshRun = await this.getRun({ runId: run.id, resourceId: run.resourceId ?? undefined }, { exclusiveLock: true, db });
1312
+ return this.updateRun({
1313
+ runId: run.id,
1314
+ resourceId: run.resourceId ?? undefined,
1315
+ data: {
1316
+ currentStepId: stepId,
1317
+ timeline: import_es_toolkit.merge(freshRun.timeline, {
1318
+ [stepId]: { output: {}, timedOut: true, timestamp: new Date }
1319
+ })
1320
+ }
1321
+ }, { db });
1322
+ }, this.pool);
1323
+ return { timedOut: true };
1324
+ }
1325
+ result = false;
1326
+ }
1327
+ if (result !== false) {
1328
+ await withPostgresTransaction(this.db, async (db) => {
1329
+ const freshRun = await this.getRun({ runId: run.id, resourceId: run.resourceId ?? undefined }, { exclusiveLock: true, db });
1330
+ return this.updateRun({
1331
+ runId: run.id,
1332
+ resourceId: run.resourceId ?? undefined,
1333
+ data: {
1334
+ currentStepId: stepId,
1335
+ timeline: import_es_toolkit.merge(freshRun.timeline, {
1336
+ [stepId]: { output: result, timestamp: new Date }
1337
+ })
1338
+ }
1339
+ }, { db });
1340
+ }, this.pool);
1341
+ return { timedOut: false, data: result };
1342
+ }
1343
+ const pollEvent = `__poll_${stepId}`;
1344
+ await withPostgresTransaction(this.db, async (db) => {
1345
+ const freshRun = await this.getRun({ runId: run.id, resourceId: run.resourceId ?? undefined }, { exclusiveLock: true, db });
1346
+ return this.updateRun({
1180
1347
  runId: run.id,
1181
1348
  resourceId: run.resourceId ?? undefined,
1182
1349
  data: {
1350
+ status: "paused" /* PAUSED */,
1183
1351
  currentStepId: stepId,
1184
- timeline: import_es_toolkit.merge(persistedRun.timeline, {
1185
- [stepId]: { output: {}, timedOut: true, timestamp: new Date }
1352
+ pausedAt: new Date,
1353
+ timeline: import_es_toolkit.merge(freshRun.timeline, {
1354
+ [`${stepId}-poll`]: { startedAt: startedAt.toISOString() },
1355
+ [`${stepId}-wait-for`]: {
1356
+ waitFor: { timeoutEvent: pollEvent, skipOutput: true },
1357
+ timestamp: new Date
1358
+ }
1186
1359
  })
1187
1360
  }
1361
+ }, { db });
1362
+ }, this.pool);
1363
+ try {
1364
+ await this.boss.send(WORKFLOW_RUN_QUEUE_NAME, {
1365
+ runId: run.id,
1366
+ resourceId: run.resourceId ?? undefined,
1367
+ workflowId: run.workflowId,
1368
+ input: run.input,
1369
+ event: { name: pollEvent, data: {} }
1370
+ }, {
1371
+ startAfter: new Date(Date.now() + intervalMs),
1372
+ expireInSeconds: defaultExpireInSeconds
1188
1373
  });
1189
- return { timedOut: true };
1190
- }
1191
- const result = await conditionFn();
1192
- if (result !== false) {
1374
+ } catch (error) {
1193
1375
  await this.updateRun({
1194
1376
  runId: run.id,
1195
1377
  resourceId: run.resourceId ?? undefined,
1196
- data: {
1197
- currentStepId: stepId,
1198
- timeline: import_es_toolkit.merge(persistedRun.timeline, {
1199
- [stepId]: { output: result, timestamp: new Date }
1200
- })
1201
- }
1378
+ data: { status: "running" /* RUNNING */, pausedAt: null }
1202
1379
  });
1203
- return { timedOut: false, data: result };
1380
+ throw error;
1204
1381
  }
1205
- const pollEvent = `__poll_${stepId}`;
1206
- await this.updateRun({
1207
- runId: run.id,
1208
- resourceId: run.resourceId ?? undefined,
1209
- data: {
1210
- status: "paused" /* PAUSED */,
1211
- currentStepId: stepId,
1212
- pausedAt: new Date,
1213
- timeline: import_es_toolkit.merge(persistedRun.timeline, {
1214
- [`${stepId}-poll`]: { startedAt: startedAt.toISOString() },
1215
- [`${stepId}-wait-for`]: {
1216
- waitFor: { timeoutEvent: pollEvent, skipOutput: true },
1217
- timestamp: new Date
1218
- }
1219
- })
1220
- }
1221
- });
1222
- await this.boss.send(WORKFLOW_RUN_QUEUE_NAME, {
1223
- runId: run.id,
1224
- resourceId: run.resourceId ?? undefined,
1225
- workflowId: run.workflowId,
1226
- input: run.input,
1227
- event: { name: pollEvent, data: {} }
1228
- }, {
1229
- startAfter: new Date(Date.now() + intervalMs),
1230
- expireInSeconds: defaultExpireInSeconds
1231
- });
1232
1382
  this.logger.log(`Step ${stepId} polling every ${intervalMs}ms...`, {
1233
1383
  runId: run.id,
1234
1384
  workflowId: run.workflowId
@@ -1273,5 +1423,5 @@ ${error.stack}` : String(error)
1273
1423
  }
1274
1424
  }
1275
1425
 
1276
- //# debugId=CB5A27C81F2BF17864756E2164756E21
1426
+ //# debugId=12BE08AB4C2E4D0564756E2164756E21
1277
1427
  //# sourceMappingURL=index.js.map
package/dist/index.d.cts CHANGED
@@ -211,6 +211,11 @@ declare class WorkflowEngine {
211
211
  expireInSeconds?: number;
212
212
  };
213
213
  }): Promise<WorkflowRun>;
214
+ fastForwardWorkflow({ runId, resourceId, data }: {
215
+ runId: string;
216
+ resourceId?: string;
217
+ data?: Record<string, unknown>;
218
+ }): Promise<WorkflowRun>;
214
219
  cancelWorkflow({ runId, resourceId }: {
215
220
  runId: string;
216
221
  resourceId?: string;
@@ -231,10 +236,11 @@ declare class WorkflowEngine {
231
236
  exclusiveLock?: boolean;
232
237
  db?: Db;
233
238
  }): Promise<WorkflowRun>;
234
- updateRun({ runId, resourceId, data }: {
239
+ updateRun({ runId, resourceId, data, expectedStatuses }: {
235
240
  runId: string;
236
241
  resourceId?: string;
237
242
  data: Partial<WorkflowRun>;
243
+ expectedStatuses?: string[];
238
244
  }, { db }?: {
239
245
  db?: Db;
240
246
  }): Promise<WorkflowRun>;
@@ -251,6 +257,7 @@ declare class WorkflowEngine {
251
257
  private resolveScopedResourceId;
252
258
  private handleWorkflowRun;
253
259
  private getCachedStepEntry;
260
+ private getWaitForStepEntry;
254
261
  private runStep;
255
262
  private waitStep;
256
263
  private pollStep;