@dudousxd/nestjs-durable-core 0.33.0 → 0.34.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/dist/index.d.cts CHANGED
@@ -577,7 +577,13 @@ interface StepResult {
577
577
  interface Heartbeat {
578
578
  runId: string;
579
579
  seq: number;
580
- stepId: string;
580
+ /**
581
+ * The in-flight step this beat keeps alive. ABSENT for a RUN-scoped (workflow-turn) heartbeat: a
582
+ * worker replaying a long workflow turn beats for its `runId` so the engine rearms the run's
583
+ * `advance` deadline (see `WorkflowEngineDeps.remoteAdvanceSilenceMs`). The engine then keys the
584
+ * liveness reset by `runId` instead of `stepId`.
585
+ */
586
+ stepId?: string | undefined;
581
587
  group: string;
582
588
  }
583
589
  /** engine → workflow worker: advance this run one turn by replaying the function against `history`. */
@@ -1291,6 +1297,18 @@ interface WorkflowEngineDeps {
1291
1297
  * Omit for unlimited (the default — recovery always retries).
1292
1298
  */
1293
1299
  maxRecoveryAttempts?: number | undefined;
1300
+ /**
1301
+ * Opt-in liveness deadline (ms) for a remote workflow `advance`. If the worker neither returns a
1302
+ * decision NOR sends a run-scoped {@link Heartbeat} within this window, the engine presumes it dead:
1303
+ * it rejects the advance with {@link RemoteWorkflowTimeout}, releases the lease, and lets recovery
1304
+ * re-drive (replaying completed steps from history). Each beat REARMS the window, so a worker still
1305
+ * legitimately replaying a long turn (it keeps beating) is never re-driven — closing the duplicate-
1306
+ * side-effect hazard a fixed {@link RemoteWorkflowExecutor} `timeoutMs` cannot avoid. Omit (default)
1307
+ * for the prior unbounded await — byte-identical behavior when unset. If a `RemoteWorkflowExecutor` is
1308
+ * ALSO constructed with its own `timeoutMs`, the shorter of the two deadlines wins; prefer setting only
1309
+ * one (this rearmed window for heartbeat-aware workers, or the fixed `timeoutMs` otherwise).
1310
+ */
1311
+ remoteAdvanceSilenceMs?: number | undefined;
1294
1312
  /**
1295
1313
  * Build the public callback URL for a `ctx.webhook()` token (e.g.
1296
1314
  * ``(t) => `https://api.example.com/durable/webhooks/${t}` ``). Populates
@@ -1364,6 +1382,7 @@ declare class WorkflowEngine {
1364
1382
  private readonly instanceId;
1365
1383
  private readonly leaseMs;
1366
1384
  private readonly maxRecoveryAttempts?;
1385
+ private readonly remoteAdvanceSilenceMs?;
1367
1386
  private readonly webhookUrl?;
1368
1387
  private readonly traceparent?;
1369
1388
  private readonly context?;
@@ -1388,7 +1407,9 @@ declare class WorkflowEngine {
1388
1407
  private readonly accumulators;
1389
1408
  /** In-flight remote steps awaiting a worker result, keyed by stepId. */
1390
1409
  private readonly pending;
1391
- /** Per-step "reset the liveness timer" callbacks, called when a heartbeat arrives. */
1410
+ /** "Reset the liveness timer" callbacks keyed by the beat's target — a stepId (in-flight remote step)
1411
+ * or a runId (in-flight remote workflow turn) — invoked when a matching heartbeat arrives. The two id
1412
+ * shapes don't collide in practice: a stepId is `${runId}:${seq}`, a run key is the bare runId. */
1392
1413
  private readonly heartbeatResets;
1393
1414
  private readonly listeners;
1394
1415
  /** Step interceptors (onion middleware around real local-step execution), first = outermost. */
@@ -1785,10 +1806,15 @@ declare class WorkflowEngine {
1785
1806
  /** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
1786
1807
  private callRemoteInMemory;
1787
1808
  /**
1788
- * Await a remote result, but reject with `RemoteStepTimeout` if neither the result nor a heartbeat
1789
- * arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`) rearms the
1790
- * window, so a worker that keeps beating stays alive past `timeoutMs`.
1809
+ * Await `resultPromise`, but reject with `onTimeout()` if neither it settles nor a heartbeat for `id`
1810
+ * arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`, looked up in
1811
+ * `heartbeatResets` by `id`) REARMS the window, so a worker that keeps beating stays alive past
1812
+ * `timeoutMs`. Shared by the remote-STEP liveness (keyed by stepId) and the remote-WORKFLOW advance
1813
+ * liveness (keyed by runId); the caller supplies the timeout error and any extra cleanup via `onTimeout`.
1791
1814
  */
1815
+ private awaitWithLivenessDeadline;
1816
+ /** Remote-STEP liveness: await a worker result, timing out (with the step's pending-map cleanup) to
1817
+ * `RemoteStepTimeout` if it neither lands nor beats within `timeoutMs`. See {@link awaitWithLivenessDeadline}. */
1792
1818
  private awaitWithHeartbeat;
1793
1819
  }
1794
1820
  /** Raised inside the workflow when a remote worker reports a step failure. */
@@ -2301,13 +2327,16 @@ declare class InMemoryStateStore implements StateStore {
2301
2327
  declare class InMemoryTransport implements Transport, ControlPlane {
2302
2328
  private readonly handlers;
2303
2329
  private resultHandler?;
2330
+ private heartbeatHandler?;
2304
2331
  private stepEventHandler?;
2305
2332
  private readonly controlHandlers;
2306
2333
  /** Register a fake worker handler for a step name. */
2307
2334
  handle(name: string, fn: StepHandler): void;
2308
2335
  dispatch(task: RemoteTask): Promise<void>;
2309
2336
  onResult(handler: (result: StepResult) => Promise<void>): void;
2310
- onHeartbeat(_handler: (beat: Heartbeat) => Promise<void>): void;
2337
+ onHeartbeat(handler: (beat: Heartbeat) => Promise<void>): void;
2338
+ /** Test hook: deliver a liveness heartbeat to the engine, as a real broker would on its channel. */
2339
+ emitHeartbeat(beat: Heartbeat): Promise<void>;
2311
2340
  /** Workflow step lifecycle: deliver to the engine's handler (async, mirroring a real broker). */
2312
2341
  dispatchStepEvent(event: WorkflowStepEvent): Promise<void>;
2313
2342
  onStepEvent(handler: (event: WorkflowStepEvent) => Promise<void>): void;
package/dist/index.d.ts CHANGED
@@ -577,7 +577,13 @@ interface StepResult {
577
577
  interface Heartbeat {
578
578
  runId: string;
579
579
  seq: number;
580
- stepId: string;
580
+ /**
581
+ * The in-flight step this beat keeps alive. ABSENT for a RUN-scoped (workflow-turn) heartbeat: a
582
+ * worker replaying a long workflow turn beats for its `runId` so the engine rearms the run's
583
+ * `advance` deadline (see `WorkflowEngineDeps.remoteAdvanceSilenceMs`). The engine then keys the
584
+ * liveness reset by `runId` instead of `stepId`.
585
+ */
586
+ stepId?: string | undefined;
581
587
  group: string;
582
588
  }
583
589
  /** engine → workflow worker: advance this run one turn by replaying the function against `history`. */
@@ -1291,6 +1297,18 @@ interface WorkflowEngineDeps {
1291
1297
  * Omit for unlimited (the default — recovery always retries).
1292
1298
  */
1293
1299
  maxRecoveryAttempts?: number | undefined;
1300
+ /**
1301
+ * Opt-in liveness deadline (ms) for a remote workflow `advance`. If the worker neither returns a
1302
+ * decision NOR sends a run-scoped {@link Heartbeat} within this window, the engine presumes it dead:
1303
+ * it rejects the advance with {@link RemoteWorkflowTimeout}, releases the lease, and lets recovery
1304
+ * re-drive (replaying completed steps from history). Each beat REARMS the window, so a worker still
1305
+ * legitimately replaying a long turn (it keeps beating) is never re-driven — closing the duplicate-
1306
+ * side-effect hazard a fixed {@link RemoteWorkflowExecutor} `timeoutMs` cannot avoid. Omit (default)
1307
+ * for the prior unbounded await — byte-identical behavior when unset. If a `RemoteWorkflowExecutor` is
1308
+ * ALSO constructed with its own `timeoutMs`, the shorter of the two deadlines wins; prefer setting only
1309
+ * one (this rearmed window for heartbeat-aware workers, or the fixed `timeoutMs` otherwise).
1310
+ */
1311
+ remoteAdvanceSilenceMs?: number | undefined;
1294
1312
  /**
1295
1313
  * Build the public callback URL for a `ctx.webhook()` token (e.g.
1296
1314
  * ``(t) => `https://api.example.com/durable/webhooks/${t}` ``). Populates
@@ -1364,6 +1382,7 @@ declare class WorkflowEngine {
1364
1382
  private readonly instanceId;
1365
1383
  private readonly leaseMs;
1366
1384
  private readonly maxRecoveryAttempts?;
1385
+ private readonly remoteAdvanceSilenceMs?;
1367
1386
  private readonly webhookUrl?;
1368
1387
  private readonly traceparent?;
1369
1388
  private readonly context?;
@@ -1388,7 +1407,9 @@ declare class WorkflowEngine {
1388
1407
  private readonly accumulators;
1389
1408
  /** In-flight remote steps awaiting a worker result, keyed by stepId. */
1390
1409
  private readonly pending;
1391
- /** Per-step "reset the liveness timer" callbacks, called when a heartbeat arrives. */
1410
+ /** "Reset the liveness timer" callbacks keyed by the beat's target — a stepId (in-flight remote step)
1411
+ * or a runId (in-flight remote workflow turn) — invoked when a matching heartbeat arrives. The two id
1412
+ * shapes don't collide in practice: a stepId is `${runId}:${seq}`, a run key is the bare runId. */
1392
1413
  private readonly heartbeatResets;
1393
1414
  private readonly listeners;
1394
1415
  /** Step interceptors (onion middleware around real local-step execution), first = outermost. */
@@ -1785,10 +1806,15 @@ declare class WorkflowEngine {
1785
1806
  /** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
1786
1807
  private callRemoteInMemory;
1787
1808
  /**
1788
- * Await a remote result, but reject with `RemoteStepTimeout` if neither the result nor a heartbeat
1789
- * arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`) rearms the
1790
- * window, so a worker that keeps beating stays alive past `timeoutMs`.
1809
+ * Await `resultPromise`, but reject with `onTimeout()` if neither it settles nor a heartbeat for `id`
1810
+ * arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`, looked up in
1811
+ * `heartbeatResets` by `id`) REARMS the window, so a worker that keeps beating stays alive past
1812
+ * `timeoutMs`. Shared by the remote-STEP liveness (keyed by stepId) and the remote-WORKFLOW advance
1813
+ * liveness (keyed by runId); the caller supplies the timeout error and any extra cleanup via `onTimeout`.
1791
1814
  */
1815
+ private awaitWithLivenessDeadline;
1816
+ /** Remote-STEP liveness: await a worker result, timing out (with the step's pending-map cleanup) to
1817
+ * `RemoteStepTimeout` if it neither lands nor beats within `timeoutMs`. See {@link awaitWithLivenessDeadline}. */
1792
1818
  private awaitWithHeartbeat;
1793
1819
  }
1794
1820
  /** Raised inside the workflow when a remote worker reports a step failure. */
@@ -2301,13 +2327,16 @@ declare class InMemoryStateStore implements StateStore {
2301
2327
  declare class InMemoryTransport implements Transport, ControlPlane {
2302
2328
  private readonly handlers;
2303
2329
  private resultHandler?;
2330
+ private heartbeatHandler?;
2304
2331
  private stepEventHandler?;
2305
2332
  private readonly controlHandlers;
2306
2333
  /** Register a fake worker handler for a step name. */
2307
2334
  handle(name: string, fn: StepHandler): void;
2308
2335
  dispatch(task: RemoteTask): Promise<void>;
2309
2336
  onResult(handler: (result: StepResult) => Promise<void>): void;
2310
- onHeartbeat(_handler: (beat: Heartbeat) => Promise<void>): void;
2337
+ onHeartbeat(handler: (beat: Heartbeat) => Promise<void>): void;
2338
+ /** Test hook: deliver a liveness heartbeat to the engine, as a real broker would on its channel. */
2339
+ emitHeartbeat(beat: Heartbeat): Promise<void>;
2311
2340
  /** Workflow step lifecycle: deliver to the engine's handler (async, mirroring a real broker). */
2312
2341
  dispatchStepEvent(event: WorkflowStepEvent): Promise<void>;
2313
2342
  onStepEvent(handler: (event: WorkflowStepEvent) => Promise<void>): void;
package/dist/index.js CHANGED
@@ -1431,6 +1431,7 @@ var WorkflowEngine = class {
1431
1431
  instanceId;
1432
1432
  leaseMs;
1433
1433
  maxRecoveryAttempts;
1434
+ remoteAdvanceSilenceMs;
1434
1435
  webhookUrl;
1435
1436
  traceparent;
1436
1437
  context;
@@ -1455,7 +1456,9 @@ var WorkflowEngine = class {
1455
1456
  accumulators;
1456
1457
  /** In-flight remote steps awaiting a worker result, keyed by stepId. */
1457
1458
  pending = /* @__PURE__ */ new Map();
1458
- /** Per-step "reset the liveness timer" callbacks, called when a heartbeat arrives. */
1459
+ /** "Reset the liveness timer" callbacks keyed by the beat's target — a stepId (in-flight remote step)
1460
+ * or a runId (in-flight remote workflow turn) — invoked when a matching heartbeat arrives. The two id
1461
+ * shapes don't collide in practice: a stepId is `${runId}:${seq}`, a run key is the bare runId. */
1459
1462
  heartbeatResets = /* @__PURE__ */ new Map();
1460
1463
  listeners = /* @__PURE__ */ new Set();
1461
1464
  /** Step interceptors (onion middleware around real local-step execution), first = outermost. */
@@ -1494,6 +1497,7 @@ var WorkflowEngine = class {
1494
1497
  this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
1495
1498
  this.leaseMs = deps.leaseMs ?? 3e4;
1496
1499
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
1500
+ this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
1497
1501
  this.webhookUrl = deps.webhookUrl;
1498
1502
  this.traceparent = deps.traceparent;
1499
1503
  this.context = deps.context;
@@ -1530,9 +1534,11 @@ var WorkflowEngine = class {
1530
1534
  }
1531
1535
  await this.completeRemoteResult(result);
1532
1536
  },
1533
- // A heartbeat for an in-flight long step resets its liveness window (see callRemote).
1537
+ // A heartbeat resets the liveness window for whatever it targets: an in-flight long STEP (keyed
1538
+ // by stepId, see callRemote) or — when stepId is absent — the in-flight workflow TURN (keyed by
1539
+ // runId, see runRemoteExecution's heartbeat-rearmed advance).
1534
1540
  async (beat) => {
1535
- this.heartbeatResets.get(beat.stepId)?.();
1541
+ this.heartbeatResets.get(beat.stepId ?? beat.runId)?.();
1536
1542
  },
1537
1543
  // A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
1538
1544
  // it's checkpointed live, not all-at-once when the long turn ends.
@@ -2624,7 +2630,9 @@ var WorkflowEngine = class {
2624
2630
  const history = await this.remoteHistory(run.id);
2625
2631
  let decision;
2626
2632
  try {
2627
- decision = await remote.executor.advance(run, history);
2633
+ const silenceMs = this.remoteAdvanceSilenceMs;
2634
+ const advance = remote.executor.advance(run, history);
2635
+ decision = silenceMs != null ? await this.awaitWithLivenessDeadline(run.id, advance, silenceMs, () => new RemoteWorkflowTimeout(run.id, silenceMs)) : await advance;
2628
2636
  } catch (err) {
2629
2637
  if (err instanceof RemoteWorkflowTimeout) {
2630
2638
  await this.store.releaseRunLock(run.id);
@@ -3285,11 +3293,13 @@ var WorkflowEngine = class {
3285
3293
  }
3286
3294
  }
3287
3295
  /**
3288
- * Await a remote result, but reject with `RemoteStepTimeout` if neither the result nor a heartbeat
3289
- * arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`) rearms the
3290
- * window, so a worker that keeps beating stays alive past `timeoutMs`.
3296
+ * Await `resultPromise`, but reject with `onTimeout()` if neither it settles nor a heartbeat for `id`
3297
+ * arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`, looked up in
3298
+ * `heartbeatResets` by `id`) REARMS the window, so a worker that keeps beating stays alive past
3299
+ * `timeoutMs`. Shared by the remote-STEP liveness (keyed by stepId) and the remote-WORKFLOW advance
3300
+ * liveness (keyed by runId); the caller supplies the timeout error and any extra cleanup via `onTimeout`.
3291
3301
  */
3292
- awaitWithHeartbeat(id, resultPromise, timeoutMs) {
3302
+ awaitWithLivenessDeadline(id, resultPromise, timeoutMs, onTimeout) {
3293
3303
  return new Promise((resolve, reject) => {
3294
3304
  let timer;
3295
3305
  const cleanup = /* @__PURE__ */ __name(() => {
@@ -3299,8 +3309,7 @@ var WorkflowEngine = class {
3299
3309
  const arm = /* @__PURE__ */ __name(() => {
3300
3310
  timer = setTimeout(() => {
3301
3311
  cleanup();
3302
- this.pending.delete(id);
3303
- reject(new RemoteStepTimeout(id, timeoutMs));
3312
+ reject(onTimeout());
3304
3313
  }, timeoutMs);
3305
3314
  timer.unref?.();
3306
3315
  }, "arm");
@@ -3318,6 +3327,14 @@ var WorkflowEngine = class {
3318
3327
  });
3319
3328
  });
3320
3329
  }
3330
+ /** Remote-STEP liveness: await a worker result, timing out (with the step's pending-map cleanup) to
3331
+ * `RemoteStepTimeout` if it neither lands nor beats within `timeoutMs`. See {@link awaitWithLivenessDeadline}. */
3332
+ awaitWithHeartbeat(id, resultPromise, timeoutMs) {
3333
+ return this.awaitWithLivenessDeadline(id, resultPromise, timeoutMs, () => {
3334
+ this.pending.delete(id);
3335
+ return new RemoteStepTimeout(id, timeoutMs);
3336
+ });
3337
+ }
3321
3338
  };
3322
3339
  var RemoteStepError = class extends Error {
3323
3340
  static {
@@ -4083,6 +4100,7 @@ var InMemoryTransport = class {
4083
4100
  }
4084
4101
  handlers = /* @__PURE__ */ new Map();
4085
4102
  resultHandler;
4103
+ heartbeatHandler;
4086
4104
  stepEventHandler;
4087
4105
  controlHandlers = /* @__PURE__ */ new Set();
4088
4106
  /** Register a fake worker handler for a step name. */
@@ -4097,7 +4115,12 @@ var InMemoryTransport = class {
4097
4115
  onResult(handler) {
4098
4116
  this.resultHandler = handler;
4099
4117
  }
4100
- onHeartbeat(_handler) {
4118
+ onHeartbeat(handler) {
4119
+ this.heartbeatHandler = handler;
4120
+ }
4121
+ /** Test hook: deliver a liveness heartbeat to the engine, as a real broker would on its channel. */
4122
+ async emitHeartbeat(beat) {
4123
+ await this.heartbeatHandler?.(beat);
4101
4124
  }
4102
4125
  /** Workflow step lifecycle: deliver to the engine's handler (async, mirroring a real broker). */
4103
4126
  async dispatchStepEvent(event) {