@dudousxd/nestjs-durable-core 0.46.1 → 0.47.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.cjs CHANGED
@@ -28,6 +28,7 @@ __export(index_exports, {
28
28
  DURABLE_OPTIONS_CANONICAL: () => DURABLE_OPTIONS_CANONICAL,
29
29
  DURABLE_STEP_CONFIG: () => DURABLE_STEP_CONFIG,
30
30
  DURABLE_STEP_NAME: () => DURABLE_STEP_NAME,
31
+ DurableWorkflow: () => DurableWorkflow,
31
32
  Entities: () => Entities,
32
33
  EventAccumulators: () => EventAccumulators,
33
34
  FatalError: () => FatalError,
@@ -54,9 +55,11 @@ __export(index_exports, {
54
55
  applyAttributeQuery: () => applyAttributeQuery,
55
56
  attributeColumnFor: () => attributeColumnFor,
56
57
  attributeOperand: () => attributeOperand,
58
+ bindWorkflowClass: () => bindWorkflowClass,
57
59
  breakpointToken: () => breakpointToken,
58
60
  collectMetrics: () => collectMetrics,
59
61
  createStepLogger: () => createStepLogger,
62
+ currentWorkflowCtx: () => currentWorkflowCtx,
60
63
  eventMatchOf: () => eventMatchOf,
61
64
  eventMatches: () => eventMatches,
62
65
  eventPrefix: () => eventPrefix,
@@ -65,6 +68,7 @@ __export(index_exports, {
65
68
  normalizeAttributeRows: () => normalizeAttributeRows,
66
69
  parseDuration: () => parseDuration,
67
70
  prevCronFireMs: () => prevCronFireMs,
71
+ runInWorkflowCtx: () => runInWorkflowCtx,
68
72
  runSchedules: () => runSchedules,
69
73
  runStepHandler: () => runStepHandler,
70
74
  sanitizeQueueToken: () => sanitizeQueueToken,
@@ -233,6 +237,218 @@ var InMemoryAdmissionBackend = class {
233
237
  }
234
238
  };
235
239
 
240
+ // src/ambient-ctx.ts
241
+ var import_node_async_hooks = require("async_hooks");
242
+ var STORAGE_KEY = Symbol.for("nestjs-durable:ambient-workflow-ctx");
243
+ var globalRef = globalThis;
244
+ if (!globalRef[STORAGE_KEY]) {
245
+ globalRef[STORAGE_KEY] = new import_node_async_hooks.AsyncLocalStorage();
246
+ }
247
+ var storage = globalRef[STORAGE_KEY];
248
+ function runInWorkflowCtx(ctx, fn) {
249
+ return storage.run(ctx, fn);
250
+ }
251
+ __name(runInWorkflowCtx, "runInWorkflowCtx");
252
+ function currentWorkflowCtx() {
253
+ return storage.getStore();
254
+ }
255
+ __name(currentWorkflowCtx, "currentWorkflowCtx");
256
+
257
+ // src/errors.ts
258
+ var FatalError = class extends Error {
259
+ static {
260
+ __name(this, "FatalError");
261
+ }
262
+ code;
263
+ constructor(message, code) {
264
+ super(message);
265
+ this.name = "FatalError";
266
+ this.code = code;
267
+ }
268
+ };
269
+ var SingletonQueueFullError = class extends Error {
270
+ static {
271
+ __name(this, "SingletonQueueFullError");
272
+ }
273
+ workflow;
274
+ key;
275
+ maxQueueDepth;
276
+ constructor(workflow, key, maxQueueDepth) {
277
+ super(`singleton queue for ${workflow} key "${key}" is full (maxQueueDepth=${maxQueueDepth}); retry later`);
278
+ this.name = "SingletonQueueFullError";
279
+ this.workflow = workflow;
280
+ this.key = key;
281
+ this.maxQueueDepth = maxQueueDepth;
282
+ }
283
+ };
284
+ var SignalTimeoutError = class extends Error {
285
+ static {
286
+ __name(this, "SignalTimeoutError");
287
+ }
288
+ token;
289
+ timeoutMs;
290
+ constructor(token, timeoutMs) {
291
+ super(`timed out after ${timeoutMs}ms waiting for signal "${token}"`);
292
+ this.name = "SignalTimeoutError";
293
+ this.token = token;
294
+ this.timeoutMs = timeoutMs;
295
+ }
296
+ };
297
+ var RemoteStepTimeout = class extends Error {
298
+ static {
299
+ __name(this, "RemoteStepTimeout");
300
+ }
301
+ stepId;
302
+ timeoutMs;
303
+ constructor(stepId2, timeoutMs) {
304
+ super(`remote step ${stepId2} produced no result/heartbeat within ${timeoutMs}ms`);
305
+ this.name = "RemoteStepTimeout";
306
+ this.stepId = stepId2;
307
+ this.timeoutMs = timeoutMs;
308
+ }
309
+ };
310
+ var RemoteWorkflowTimeout = class extends Error {
311
+ static {
312
+ __name(this, "RemoteWorkflowTimeout");
313
+ }
314
+ taskId;
315
+ timeoutMs;
316
+ constructor(taskId, timeoutMs) {
317
+ super(`remote workflow task ${taskId} produced no decision within ${timeoutMs}ms \u2014 presumed dropped; re-driving via recovery`);
318
+ this.name = "RemoteWorkflowTimeout";
319
+ this.taskId = taskId;
320
+ this.timeoutMs = timeoutMs;
321
+ }
322
+ };
323
+ var NonDeterminismError = class extends Error {
324
+ static {
325
+ __name(this, "NonDeterminismError");
326
+ }
327
+ runId;
328
+ seq;
329
+ constructor(runId, seq, expected, recorded) {
330
+ super(`non-determinism at ${runId}#${seq}: code expects "${expected}" but history recorded "${recorded}". The workflow changed under an in-flight run \u2014 register a new @Workflow version.`);
331
+ this.name = "NonDeterminismError";
332
+ this.runId = runId;
333
+ this.seq = seq;
334
+ }
335
+ };
336
+ var GatherError = class extends Error {
337
+ static {
338
+ __name(this, "GatherError");
339
+ }
340
+ failures;
341
+ constructor(failures, total) {
342
+ const ids = failures.map((f) => f.id).join(", ");
343
+ const denom = total ?? failures.length;
344
+ super(`ctx.all: ${failures.length} of ${denom} child(ren) failed: ${ids}`);
345
+ this.name = "GatherError";
346
+ this.failures = failures;
347
+ }
348
+ };
349
+ var WorkflowSuspended = class extends Error {
350
+ static {
351
+ __name(this, "WorkflowSuspended");
352
+ }
353
+ /** Epoch ms to auto-resume (durable sleep), or undefined when waiting on an external signal. */
354
+ wakeAt;
355
+ constructor(wakeAt) {
356
+ super("workflow suspended");
357
+ this.name = "WorkflowSuspended";
358
+ this.wakeAt = wakeAt;
359
+ }
360
+ };
361
+ var ContinueAsNew = class extends Error {
362
+ static {
363
+ __name(this, "ContinueAsNew");
364
+ }
365
+ input;
366
+ constructor(input) {
367
+ super("workflow continued as new"), this.input = input;
368
+ this.name = "ContinueAsNew";
369
+ }
370
+ };
371
+
372
+ // src/workflow-ref.ts
373
+ var WORKFLOW_NAME_KEY = Symbol.for("nestjs-durable:workflow-name");
374
+ function workflowName(ref) {
375
+ if (typeof ref === "string") return ref;
376
+ const name = ref[WORKFLOW_NAME_KEY];
377
+ if (!name) {
378
+ throw new Error(`workflow class ${ref.name} has no registered name \u2014 is it decorated with @Workflow({ name })?`);
379
+ }
380
+ return name;
381
+ }
382
+ __name(workflowName, "workflowName");
383
+
384
+ // src/durable-workflow.ts
385
+ var BINDINGS_KEY = Symbol.for("nestjs-durable:workflow-class-bindings");
386
+ var globalRef2 = globalThis;
387
+ if (!globalRef2[BINDINGS_KEY]) {
388
+ globalRef2[BINDINGS_KEY] = /* @__PURE__ */ new WeakMap();
389
+ }
390
+ var bindings = globalRef2[BINDINGS_KEY];
391
+ function bindWorkflowClass(ctor, client) {
392
+ bindings.set(ctor, client);
393
+ }
394
+ __name(bindWorkflowClass, "bindWorkflowClass");
395
+ function clientOf(ctor) {
396
+ const client = bindings.get(ctor);
397
+ if (!client) {
398
+ throw new Error(`${workflowName(ctor)} is not bound to a durable engine \u2014 the class-first statics only work after the module that registers this workflow has booted (or, in a store-less tenant worker, use the injected WorkflowService/engine facade instead).`);
399
+ }
400
+ return client;
401
+ }
402
+ __name(clientOf, "clientOf");
403
+ var DurableWorkflow = class {
404
+ static {
405
+ __name(this, "DurableWorkflow");
406
+ }
407
+ /**
408
+ * Fire-and-forget start — `engine.start` outside a workflow, `ctx.startChild` (parent-linked)
409
+ * inside one. `opts.id` pins the run id (outside) / child id (inside) for idempotent starts.
410
+ */
411
+ static async start(input, opts) {
412
+ const cls = this;
413
+ const ctx = currentWorkflowCtx();
414
+ if (ctx) {
415
+ const runId2 = await ctx.startChild(cls, input, opts?.id);
416
+ return {
417
+ runId: runId2,
418
+ status: "pending"
419
+ };
420
+ }
421
+ const runId = opts?.id ?? globalThis.crypto.randomUUID();
422
+ return clientOf(cls).start(workflowName(cls), input, runId, opts);
423
+ }
424
+ /**
425
+ * Run-and-await the typed output — `ctx.child` inside a workflow (suspends the parent, zero
426
+ * compute); outside, starts the run and waits until it settles terminally. Throws `FatalError`
427
+ * when the run does not complete. `opts.timeoutMs` bounds the OUTSIDE wait only (inside, a
428
+ * suspended parent costs nothing — bound the child itself with `executionTimeout` instead).
429
+ */
430
+ static async execute(input, opts) {
431
+ const cls = this;
432
+ const ctx = currentWorkflowCtx();
433
+ if (ctx) return ctx.child(cls, input, opts?.id);
434
+ const client = clientOf(cls);
435
+ if (!client.waitForRun) {
436
+ throw new FatalError(`${workflowName(cls)}.execute() outside a workflow needs a store-backed engine to await the result \u2014 this class is bound to a store-less start facade (tenant worker). Use ${workflowName(cls)}.start() and observe the run instead.`, "execute_unsupported");
437
+ }
438
+ const runId = opts?.id ?? globalThis.crypto.randomUUID();
439
+ await client.start(workflowName(cls), input, runId, opts);
440
+ const settled = await client.waitForRun(runId, {
441
+ ...opts?.timeoutMs != null ? {
442
+ timeoutMs: opts.timeoutMs
443
+ } : {},
444
+ until: "terminal"
445
+ });
446
+ if (settled.status === "completed") return settled.output;
447
+ const detail = settled.error ? `: ${settled.error.message}` : "";
448
+ throw new FatalError(`${workflowName(cls)} run ${runId} settled ${settled.status}${detail}`, settled.error?.code ?? "execute_failed");
449
+ }
450
+ };
451
+
236
452
  // src/duration.ts
237
453
  var UNIT_MS = {
238
454
  ms: 1,
@@ -489,121 +705,6 @@ function instantCheckpoint(p) {
489
705
  }
490
706
  __name(instantCheckpoint, "instantCheckpoint");
491
707
 
492
- // src/errors.ts
493
- var FatalError = class extends Error {
494
- static {
495
- __name(this, "FatalError");
496
- }
497
- code;
498
- constructor(message, code) {
499
- super(message);
500
- this.name = "FatalError";
501
- this.code = code;
502
- }
503
- };
504
- var SingletonQueueFullError = class extends Error {
505
- static {
506
- __name(this, "SingletonQueueFullError");
507
- }
508
- workflow;
509
- key;
510
- maxQueueDepth;
511
- constructor(workflow, key, maxQueueDepth) {
512
- super(`singleton queue for ${workflow} key "${key}" is full (maxQueueDepth=${maxQueueDepth}); retry later`);
513
- this.name = "SingletonQueueFullError";
514
- this.workflow = workflow;
515
- this.key = key;
516
- this.maxQueueDepth = maxQueueDepth;
517
- }
518
- };
519
- var SignalTimeoutError = class extends Error {
520
- static {
521
- __name(this, "SignalTimeoutError");
522
- }
523
- token;
524
- timeoutMs;
525
- constructor(token, timeoutMs) {
526
- super(`timed out after ${timeoutMs}ms waiting for signal "${token}"`);
527
- this.name = "SignalTimeoutError";
528
- this.token = token;
529
- this.timeoutMs = timeoutMs;
530
- }
531
- };
532
- var RemoteStepTimeout = class extends Error {
533
- static {
534
- __name(this, "RemoteStepTimeout");
535
- }
536
- stepId;
537
- timeoutMs;
538
- constructor(stepId2, timeoutMs) {
539
- super(`remote step ${stepId2} produced no result/heartbeat within ${timeoutMs}ms`);
540
- this.name = "RemoteStepTimeout";
541
- this.stepId = stepId2;
542
- this.timeoutMs = timeoutMs;
543
- }
544
- };
545
- var RemoteWorkflowTimeout = class extends Error {
546
- static {
547
- __name(this, "RemoteWorkflowTimeout");
548
- }
549
- taskId;
550
- timeoutMs;
551
- constructor(taskId, timeoutMs) {
552
- super(`remote workflow task ${taskId} produced no decision within ${timeoutMs}ms \u2014 presumed dropped; re-driving via recovery`);
553
- this.name = "RemoteWorkflowTimeout";
554
- this.taskId = taskId;
555
- this.timeoutMs = timeoutMs;
556
- }
557
- };
558
- var NonDeterminismError = class extends Error {
559
- static {
560
- __name(this, "NonDeterminismError");
561
- }
562
- runId;
563
- seq;
564
- constructor(runId, seq, expected, recorded) {
565
- super(`non-determinism at ${runId}#${seq}: code expects "${expected}" but history recorded "${recorded}". The workflow changed under an in-flight run \u2014 register a new @Workflow version.`);
566
- this.name = "NonDeterminismError";
567
- this.runId = runId;
568
- this.seq = seq;
569
- }
570
- };
571
- var GatherError = class extends Error {
572
- static {
573
- __name(this, "GatherError");
574
- }
575
- failures;
576
- constructor(failures, total) {
577
- const ids = failures.map((f) => f.id).join(", ");
578
- const denom = total ?? failures.length;
579
- super(`ctx.all: ${failures.length} of ${denom} child(ren) failed: ${ids}`);
580
- this.name = "GatherError";
581
- this.failures = failures;
582
- }
583
- };
584
- var WorkflowSuspended = class extends Error {
585
- static {
586
- __name(this, "WorkflowSuspended");
587
- }
588
- /** Epoch ms to auto-resume (durable sleep), or undefined when waiting on an external signal. */
589
- wakeAt;
590
- constructor(wakeAt) {
591
- super("workflow suspended");
592
- this.name = "WorkflowSuspended";
593
- this.wakeAt = wakeAt;
594
- }
595
- };
596
- var ContinueAsNew = class extends Error {
597
- static {
598
- __name(this, "ContinueAsNew");
599
- }
600
- input;
601
- constructor(input) {
602
- super("workflow continued as new"), this.input = input;
603
- this.name = "ContinueAsNew";
604
- }
605
- };
606
-
607
708
  // src/entities.ts
608
709
  var ENTITY_WORKFLOW = "__entity";
609
710
  var entityRunId = /* @__PURE__ */ __name((name, key) => `entity:${name}:${key}`, "entityRunId");
@@ -1065,18 +1166,6 @@ function stepConfigOf(ref) {
1065
1166
  }
1066
1167
  __name(stepConfigOf, "stepConfigOf");
1067
1168
 
1068
- // src/workflow-ref.ts
1069
- var WORKFLOW_NAME_KEY = Symbol.for("nestjs-durable:workflow-name");
1070
- function workflowName(ref) {
1071
- if (typeof ref === "string") return ref;
1072
- const name = ref[WORKFLOW_NAME_KEY];
1073
- if (!name) {
1074
- throw new Error(`workflow class ${ref.name} has no registered name \u2014 is it decorated with @Workflow({ name })?`);
1075
- }
1076
- return name;
1077
- }
1078
- __name(workflowName, "workflowName");
1079
-
1080
1169
  // src/workflow-ctx.ts
1081
1170
  function normalizeChildOptions(options) {
1082
1171
  return typeof options === "string" ? {
@@ -1262,6 +1351,20 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1262
1351
  value: buffered.payload
1263
1352
  };
1264
1353
  }, "consumeBuffered");
1354
+ const stampDeadline = /* @__PURE__ */ __name(async (deadlineSeq, label, timeoutMs) => {
1355
+ const recorded = await readCheckpoint(deadlineSeq);
1356
+ const deadline = recorded?.wakeAt ?? host.clock() + timeoutMs;
1357
+ if (!recorded) {
1358
+ await writeCheckpoint(instantCheckpoint({
1359
+ runId,
1360
+ seq: deadlineSeq,
1361
+ name: `timeout:${label}`,
1362
+ kind: "sleep",
1363
+ wakeAt: deadline
1364
+ }));
1365
+ }
1366
+ return deadline;
1367
+ }, "stampDeadline");
1265
1368
  const waitForSignal = /* @__PURE__ */ __name(async (token, opts) => {
1266
1369
  if (opts?.timeoutMs == null) {
1267
1370
  const current = pos.next();
@@ -1279,17 +1382,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1279
1382
  const timeoutMs = opts.timeoutMs;
1280
1383
  const deadlineSeq = pos.next();
1281
1384
  const waitSeq = pos.next();
1282
- const recorded = await readCheckpoint(deadlineSeq);
1283
- const deadline = recorded?.wakeAt ?? host.clock() + timeoutMs;
1284
- if (!recorded) {
1285
- await writeCheckpoint(instantCheckpoint({
1286
- runId,
1287
- seq: deadlineSeq,
1288
- name: `timeout:${token}`,
1289
- kind: "sleep",
1290
- wakeAt: deadline
1291
- }));
1292
- }
1385
+ const deadline = await stampDeadline(deadlineSeq, token, timeoutMs);
1293
1386
  const waited = await readCheckpoint(waitSeq);
1294
1387
  if (waited && waited.status === "completed") return waited.output;
1295
1388
  const buffered = await consumeBuffered(token, waitSeq);
@@ -1322,17 +1415,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1322
1415
  const deadlineSeq = pos.next();
1323
1416
  const waitSeq = pos.next();
1324
1417
  const token = eventToken(name, opts.match, runId, waitSeq);
1325
- const recorded = await readCheckpoint(deadlineSeq);
1326
- const deadline = recorded?.wakeAt ?? host.clock() + timeoutMs;
1327
- if (!recorded) {
1328
- await writeCheckpoint(instantCheckpoint({
1329
- runId,
1330
- seq: deadlineSeq,
1331
- name: `timeout:event:${name}`,
1332
- kind: "sleep",
1333
- wakeAt: deadline
1334
- }));
1335
- }
1418
+ const deadline = await stampDeadline(deadlineSeq, `event:${name}`, timeoutMs);
1336
1419
  const waited = await readCheckpoint(waitSeq);
1337
1420
  if (waited && waited.status === "completed") return waited.output;
1338
1421
  if (host.clock() >= deadline) {
@@ -1388,6 +1471,9 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1388
1471
  const cp = existing[i];
1389
1472
  const c = cp?.status === "completed" ? cp.output : null;
1390
1473
  if (c && c.ok === false) {
1474
+ for (let j = 0; j < inputs.length; j += 1) {
1475
+ if (j !== i && existing[j]?.status !== "completed") host.cancelChild?.(id(j));
1476
+ }
1391
1477
  throw new GatherError([
1392
1478
  {
1393
1479
  index: i,
@@ -1544,15 +1630,30 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1544
1630
  const webhook = /* @__PURE__ */ __name(() => {
1545
1631
  const current = pos.next();
1546
1632
  const token = `wh:${runId}:${current}`;
1547
- const wait = /* @__PURE__ */ __name(async () => {
1633
+ const wait = /* @__PURE__ */ __name(async (opts) => {
1634
+ const timeoutMs = opts?.timeoutMs;
1635
+ const deadlineSeq = timeoutMs == null ? -1 : pos.next();
1548
1636
  const existing = await readCheckpoint(current);
1549
1637
  if (existing && existing.status === "completed") return existing.output;
1638
+ if (timeoutMs == null) {
1639
+ await store.putSignalWaiter({
1640
+ token,
1641
+ runId,
1642
+ seq: current
1643
+ });
1644
+ throw new WorkflowSuspended();
1645
+ }
1646
+ const deadline = await stampDeadline(deadlineSeq, token, timeoutMs);
1647
+ if (host.clock() >= deadline) {
1648
+ await store.takeSignalWaiter(token).catch(() => void 0);
1649
+ throw new SignalTimeoutError(token, timeoutMs);
1650
+ }
1550
1651
  await store.putSignalWaiter({
1551
1652
  token,
1552
1653
  runId,
1553
1654
  seq: current
1554
1655
  });
1555
- throw new WorkflowSuspended();
1656
+ throw new WorkflowSuspended(deadline);
1556
1657
  }, "wait");
1557
1658
  return {
1558
1659
  token,
@@ -1580,6 +1681,18 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1580
1681
  output: attrs
1581
1682
  }));
1582
1683
  }, "upsertSearchAttributes");
1684
+ function resolveCompensateDef(compensate) {
1685
+ const name = typeof compensate === "string" ? compensate : stepNameOf(compensate);
1686
+ if (name === void 0) {
1687
+ throw new Error("ctx.step: compensate handler is not a @Step reference (no stamped step name) \u2014 pass a @Step-decorated method or a step name string");
1688
+ }
1689
+ const config = typeof compensate === "string" ? void 0 : stepConfigOf(compensate);
1690
+ return {
1691
+ name,
1692
+ ...resolveStepPolicy(config, void 0)
1693
+ };
1694
+ }
1695
+ __name(resolveCompensateDef, "resolveCompensateDef");
1583
1696
  function step(handlerOrName, input, opts) {
1584
1697
  const name = typeof handlerOrName === "string" ? handlerOrName : stepNameOf(handlerOrName);
1585
1698
  if (name === void 0) {
@@ -1593,6 +1706,20 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1593
1706
  return host.callRemote(runId, pos.next(), def, input, opts?.queue, opts?.transport, {
1594
1707
  priority: opts?.priority,
1595
1708
  fairnessKey: opts?.fairnessKey
1709
+ }).then((output) => {
1710
+ if (opts?.compensate) {
1711
+ compensations.push({
1712
+ name,
1713
+ dispatch: {
1714
+ def: resolveCompensateDef(opts.compensate),
1715
+ args: {
1716
+ input,
1717
+ output
1718
+ }
1719
+ }
1720
+ });
1721
+ }
1722
+ return output;
1596
1723
  });
1597
1724
  }
1598
1725
  __name(step, "step");
@@ -2486,7 +2613,13 @@ var WorkflowEngine = class {
2486
2613
  * `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
2487
2614
  */
2488
2615
  waitForRun(runId, opts) {
2489
- const isSettled = /* @__PURE__ */ __name((run) => run.status !== "pending" && run.status !== "running" && run.status !== "cancelling" && !(run.status === "suspended" && run.awaitingDecisionTaskId !== void 0), "isSettled");
2616
+ const TERMINAL = /* @__PURE__ */ new Set([
2617
+ "completed",
2618
+ "failed",
2619
+ "cancelled",
2620
+ "dead"
2621
+ ]);
2622
+ const isSettled = /* @__PURE__ */ __name((run) => opts?.until === "terminal" ? TERMINAL.has(run.status) : run.status !== "pending" && run.status !== "running" && run.status !== "cancelling" && !(run.status === "suspended" && run.awaitingDecisionTaskId !== void 0), "isSettled");
2490
2623
  const toResult = /* @__PURE__ */ __name((run) => ({
2491
2624
  runId,
2492
2625
  status: run.status,
@@ -2500,6 +2633,7 @@ var WorkflowEngine = class {
2500
2633
  const timer = opts?.timeoutMs != null ? setTimeout(() => {
2501
2634
  if (done) return;
2502
2635
  done = true;
2636
+ if (poller) clearInterval(poller);
2503
2637
  off();
2504
2638
  reject(new Error(`waitForRun(${runId}) timed out after ${opts.timeoutMs}ms`));
2505
2639
  }, opts.timeoutMs) : void 0;
@@ -2507,6 +2641,7 @@ var WorkflowEngine = class {
2507
2641
  if (done) return;
2508
2642
  done = true;
2509
2643
  if (timer) clearTimeout(timer);
2644
+ if (poller) clearInterval(poller);
2510
2645
  off();
2511
2646
  resolve(toResult(run));
2512
2647
  }, "finish");
@@ -2515,6 +2650,11 @@ var WorkflowEngine = class {
2515
2650
  if (run && isSettled(run)) finish(run);
2516
2651
  });
2517
2652
  }, "check");
2653
+ const poller = opts?.until === "terminal" ? (() => {
2654
+ const interval = setInterval(check, 250);
2655
+ interval.unref?.();
2656
+ return interval;
2657
+ })() : void 0;
2518
2658
  off = this.subscribe((ev) => {
2519
2659
  if (ev.runId === runId && (ev.type === "run.completed" || ev.type === "run.failed" || ev.type === "run.suspended")) {
2520
2660
  check();
@@ -3438,14 +3578,24 @@ var WorkflowEngine = class {
3438
3578
  * Undo the registered saga compensations in reverse and settle the run `cancelled`. Shared by EVERY
3439
3579
  * exit path of a run with a compensating cancel in flight — whether the resumed replay returned,
3440
3580
  * suspended, hit continue-as-new, or threw — so a cancel is never lost just because the body didn't
3441
- * stop at a suspension point. Clears the in-memory cancel flag.
3581
+ * stop at a suspension point.
3582
+ *
3583
+ * A dispatched compensation still in flight (or backing off a retry) needs the run to keep waiting:
3584
+ * unlike the failure path (which is fine going `suspended` — a resumable state no different from an
3585
+ * ordinary in-flight `ctx.step`), THIS run must stay `cancelling` — `engine.cancel`'s idempotent
3586
+ * re-check, `waitForRun`'s non-terminal treatment, and crash recovery (`recoverIncomplete` only
3587
+ * re-drives `running`/`cancelling` runs — a `cancelling` one with a free lease, whatever it's
3588
+ * internally waiting on) all key off that status. So on a pending unwind this returns WITHOUT
3589
+ * touching `status` (already `cancelling`, untouched since `engine.cancel` set it) — the in-memory
3590
+ * cancel flag stays too, re-derived from the status on any later re-drive regardless.
3442
3591
  */
3443
- async compensateAndCancel(run, compensations) {
3592
+ async compensateAndCancel(run, compensations, replay) {
3593
+ const pending = await this.unwindCompensations(run, compensations, replay);
3594
+ if (pending) return {
3595
+ runId: run.id,
3596
+ status: "cancelling"
3597
+ };
3444
3598
  this.cancelRequested.delete(run.id);
3445
- for (let i = compensations.length - 1; i >= 0; i -= 1) {
3446
- const comp = compensations[i];
3447
- if (comp) await this.runCompensation(run, comp);
3448
- }
3449
3599
  const error = {
3450
3600
  message: "cancelled"
3451
3601
  };
@@ -3508,15 +3658,19 @@ var WorkflowEngine = class {
3508
3658
  for (const cp of snapshot) replay.set(cp.seq, cp);
3509
3659
  const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow);
3510
3660
  try {
3511
- const output = await fn(ctx, run.input);
3512
- if (this.cancelRequested.has(run.id)) return this.compensateAndCancel(run, compensations);
3661
+ const output = await runInWorkflowCtx(ctx, () => fn(ctx, run.input));
3662
+ if (this.cancelRequested.has(run.id)) {
3663
+ return this.compensateAndCancel(run, compensations, replay);
3664
+ }
3513
3665
  return this.settleRun(run, {
3514
3666
  kind: "completed",
3515
3667
  output
3516
3668
  });
3517
3669
  } catch (err) {
3518
3670
  if (err instanceof ContinueAsNew) {
3519
- if (this.cancelRequested.has(run.id)) return this.compensateAndCancel(run, compensations);
3671
+ if (this.cancelRequested.has(run.id)) {
3672
+ return this.compensateAndCancel(run, compensations, replay);
3673
+ }
3520
3674
  await this.store.updateRun(run.id, {
3521
3675
  status: "completed",
3522
3676
  output: void 0,
@@ -3541,22 +3695,26 @@ var WorkflowEngine = class {
3541
3695
  };
3542
3696
  }
3543
3697
  if (err instanceof WorkflowSuspended) {
3544
- if (this.cancelRequested.has(run.id)) return this.compensateAndCancel(run, compensations);
3698
+ if (this.cancelRequested.has(run.id)) {
3699
+ return this.compensateAndCancel(run, compensations, replay);
3700
+ }
3545
3701
  return this.settleRun(run, {
3546
3702
  kind: "suspended",
3547
3703
  wakeAt: err.wakeAt
3548
3704
  });
3549
3705
  }
3550
- if (this.cancelRequested.has(run.id)) return this.compensateAndCancel(run, compensations);
3706
+ if (this.cancelRequested.has(run.id)) {
3707
+ return this.compensateAndCancel(run, compensations, replay);
3708
+ }
3551
3709
  const error = {
3552
3710
  message: err instanceof Error ? err.message : String(err),
3553
3711
  stack: err instanceof Error ? err.stack : void 0
3554
3712
  };
3555
- for (let i = compensations.length - 1; i >= 0; i -= 1) {
3556
- const comp = compensations[i];
3557
- if (!comp) continue;
3558
- await this.runCompensation(run, comp);
3559
- }
3713
+ const pending = await this.unwindCompensations(run, compensations, replay);
3714
+ if (pending) return this.settleRun(run, {
3715
+ kind: "suspended",
3716
+ wakeAt: pending.wakeAt
3717
+ });
3560
3718
  return this.settleRun(run, {
3561
3719
  kind: "failed",
3562
3720
  error
@@ -3566,14 +3724,62 @@ var WorkflowEngine = class {
3566
3724
  }
3567
3725
  }
3568
3726
  /**
3569
- * Run one saga compensation, retried up to `compensationRetries`, emitting a `compensate:<step>`
3570
- * step event for its outcome so a stranded undo is visible. Never throws — a permanently-failing
3571
- * compensation is skipped so it can't mask the original failure.
3727
+ * Undo every registered saga compensation in strict reverse order — the shared unwind both the
3728
+ * run-failure path (`runExecution`'s catch) and the compensating-cancel path
3729
+ * (`compensateAndCancel`) drive. Every entry, LOCAL or DISPATCHED, gets a NEGATIVE checkpoint seq
3730
+ * `-(k + 1)` for its position `k` (0, 1, 2… walking the stack in reverse) — reserved space that can
3731
+ * never collide with a body position (those start at 0), so a saga's undo trail composes onto the
3732
+ * run's ordinary checkpoint history with no store-schema change, and shows up in run detail exactly
3733
+ * like any other step. Deterministic because `compensations` is rebuilt identically on every replay
3734
+ * (see `workflow-ctx.ts`), so the same comp always lands on the same negative seq across a crash +
3735
+ * re-drive — the crash-safety this whole scheme rests on.
3736
+ *
3737
+ * A LOCAL entry runs in-process (`runCompensation`), which checkpoints its own outcome — completed,
3738
+ * or failed once `compensationRetries` are exhausted — so a crash mid-unwind SKIPS an
3739
+ * already-resolved one on the next pass instead of re-running it (and re-spending its retry budget)
3740
+ * from attempt 1. A DISPATCHED entry is an ordinary remote-step dispatch under the hood
3741
+ * (`dispatchCompensation`): when it needs the run to WAIT — freshly dispatched, still in flight, or
3742
+ * backing off a durable retry — this function returns that wait signal immediately (skipping every
3743
+ * comp after it in this pass); the CALLER decides how to represent "still unwinding" (see
3744
+ * `compensateAndCancel`'s doc comment for why that differs between the two callers). Either way, the
3745
+ * run resumes later exactly like any other dispatched step: `completeRemoteResult` completes the
3746
+ * negative-seq checkpoint and re-drives, the body replays and deterministically re-throws the same
3747
+ * exit it took before (the original failure, or the suspension `compensateAndCancel` resumed from),
3748
+ * this function runs again, the now-resolved comp is skipped, and the unwind continues from there.
3749
+ *
3750
+ * Returns `undefined` once every compensation has resolved — the caller then finalizes the run
3751
+ * (`failed` with the ORIGINAL body error, never masked by a comp outcome; or `cancelled`).
3752
+ */
3753
+ async unwindCompensations(run, compensations, replay) {
3754
+ for (let i = compensations.length - 1; i >= 0; i -= 1) {
3755
+ const comp = compensations[i];
3756
+ if (!comp) continue;
3757
+ const seq = -(compensations.length - i);
3758
+ if ("fn" in comp) {
3759
+ const existing = replay?.get(seq) ?? await this.store.getCheckpoint(run.id, seq);
3760
+ if (existing) continue;
3761
+ await this.runCompensation(run, comp, seq, replay);
3762
+ continue;
3763
+ }
3764
+ const pending = await this.dispatchCompensation(run, comp, seq, replay);
3765
+ if (pending) return pending;
3766
+ }
3767
+ return void 0;
3768
+ }
3769
+ /**
3770
+ * Run one LOCAL saga compensation, retried up to `compensationRetries`, emitting a
3771
+ * `compensate:<step>` step event for its outcome so a stranded undo is visible. Never throws — a
3772
+ * permanently-failing compensation is skipped so it can't mask the original failure. Checkpoints
3773
+ * its outcome at `seq` (a negative unwind position reserved by {@link unwindCompensations}) so a
3774
+ * crash mid-unwind skips an already-resolved undo on a later pass instead of re-running it (and
3775
+ * re-spending its retry budget) from attempt 1 — the checkpoint is durability/visibility only; the
3776
+ * runtime retry-in-process-back-to-back semantics of `comp.fn()` are unchanged.
3572
3777
  */
3573
- async runCompensation(run, comp) {
3778
+ async runCompensation(run, comp, seq, replay) {
3574
3779
  const name = `compensate:${comp.name}`;
3780
+ const enqueuedAt = /* @__PURE__ */ new Date();
3575
3781
  for (let attempt = 1; attempt <= this.compensationRetries; attempt += 1) {
3576
- const startedAt = Date.now();
3782
+ const startedAt = /* @__PURE__ */ new Date();
3577
3783
  try {
3578
3784
  await comp.fn();
3579
3785
  this.emit({
@@ -3582,26 +3788,143 @@ var WorkflowEngine = class {
3582
3788
  workflow: run.workflow,
3583
3789
  name,
3584
3790
  kind: "local",
3585
- durationMs: Date.now() - startedAt
3791
+ durationMs: Date.now() - startedAt.getTime()
3586
3792
  });
3793
+ const cp = stepCheckpoint({
3794
+ runId: run.id,
3795
+ seq,
3796
+ name,
3797
+ kind: "local",
3798
+ status: "completed",
3799
+ attempts: attempt,
3800
+ enqueuedAt,
3801
+ startedAt,
3802
+ finishedAt: /* @__PURE__ */ new Date()
3803
+ });
3804
+ await this.store.saveCheckpoint(cp);
3805
+ replay?.set(seq, cp);
3587
3806
  return;
3588
3807
  } catch (err) {
3589
3808
  if (attempt >= this.compensationRetries) {
3809
+ const error = {
3810
+ message: err instanceof Error ? err.message : String(err)
3811
+ };
3590
3812
  this.emit({
3591
3813
  type: "step.failed",
3592
3814
  runId: run.id,
3593
3815
  workflow: run.workflow,
3594
3816
  name,
3595
3817
  kind: "local",
3596
- error: {
3597
- message: err instanceof Error ? err.message : String(err)
3598
- },
3599
- durationMs: Date.now() - startedAt
3818
+ error,
3819
+ durationMs: Date.now() - startedAt.getTime()
3600
3820
  });
3821
+ const cp = stepCheckpoint({
3822
+ runId: run.id,
3823
+ seq,
3824
+ name,
3825
+ kind: "local",
3826
+ status: "failed",
3827
+ attempts: attempt,
3828
+ error,
3829
+ enqueuedAt,
3830
+ startedAt,
3831
+ finishedAt: /* @__PURE__ */ new Date()
3832
+ });
3833
+ await this.store.saveCheckpoint(cp);
3834
+ replay?.set(seq, cp);
3601
3835
  }
3602
3836
  }
3603
3837
  }
3604
3838
  }
3839
+ /**
3840
+ * Resolve (or advance) ONE dispatched saga compensation at its reserved negative `seq` — the undo
3841
+ * handler is an ordinary remote `@Step`, so this mirrors `callRemote`'s pending/failed/absent
3842
+ * branches (engine.ts's `callRemote`), minus the queue/admission and in-memory-`timeoutMs` paths
3843
+ * (V1: dispatch direct by name, always durable). Returns the wait signal when the run must pause for
3844
+ * this compensation (freshly dispatched, still in flight, or backing off before a durable retry);
3845
+ * `undefined` once it's resolved — completed (crash-safe skip), or retries exhausted/non-retryable
3846
+ * (already visible: `completeRemoteResult` emitted `step.failed` for `compensate:<name>` when that
3847
+ * checkpoint landed — never re-emitted here, and never allowed to block the rest of the unwind or
3848
+ * mask the run's original failure).
3849
+ */
3850
+ async dispatchCompensation(run, comp, seq, replay) {
3851
+ const { def, args } = comp.dispatch;
3852
+ const name = `compensate:${comp.name}`;
3853
+ const existing = replay?.get(seq) ?? await this.store.getCheckpoint(run.id, seq);
3854
+ if (existing?.status === "completed") return void 0;
3855
+ if (existing?.status === "pending") return {};
3856
+ if (existing?.status === "failed") {
3857
+ const maxAttempts = Math.max(1, def.retries ?? 1);
3858
+ const retryable = existing.error?.retryable !== false;
3859
+ if (!retryable || existing.attempts >= maxAttempts) return void 0;
3860
+ if (existing.wakeAt == null) {
3861
+ const wakeAt = this.clock() + backoffDelay(existing.attempts, def);
3862
+ const withWakeAt = {
3863
+ ...existing,
3864
+ wakeAt
3865
+ };
3866
+ await this.store.saveCheckpoint(withWakeAt);
3867
+ replay?.set(seq, withWakeAt);
3868
+ return {
3869
+ wakeAt
3870
+ };
3871
+ }
3872
+ if (this.clock() < existing.wakeAt) return {
3873
+ wakeAt: existing.wakeAt
3874
+ };
3875
+ await this.dispatchCompensationAttempt(run, seq, name, def, args, existing.attempts + 1, replay);
3876
+ return {};
3877
+ }
3878
+ await this.dispatchCompensationAttempt(run, seq, name, def, args, 1, replay);
3879
+ return {};
3880
+ }
3881
+ /**
3882
+ * Dispatch (or re-dispatch, on a durable retry) one compensation's undo handler — mirrors
3883
+ * `callRemote`'s dispatch block (persist the `pending` checkpoint BEFORE handing off to the
3884
+ * transport, so a fast result always finds it to complete). Two DIFFERENT names are in play: the
3885
+ * checkpoint's `name` is `compensate:<originalStepName>` (what makes this undo show up on the run's
3886
+ * timeline, and what `completeRemoteResult` emits `step.completed`/`step.failed` under); the WIRE
3887
+ * task's `name` is the undo handler's OWN routing name (`def.name`) — a worker dispatches to the
3888
+ * `@Step` it registered, not to this saga-visibility label.
3889
+ */
3890
+ async dispatchCompensationAttempt(run, seq, name, def, args, attempt, replay) {
3891
+ if (this.pool.size === 0) throw new Error("remote steps require a Transport");
3892
+ const group = tenantGroup(sanitizeQueueToken(def.name), def.partition);
3893
+ const enqueuedAt = /* @__PURE__ */ new Date();
3894
+ const cp = stepCheckpoint({
3895
+ runId: run.id,
3896
+ seq,
3897
+ name,
3898
+ kind: "remote",
3899
+ status: "pending",
3900
+ input: args,
3901
+ attempts: attempt,
3902
+ workerGroup: group,
3903
+ enqueuedAt,
3904
+ startedAt: enqueuedAt,
3905
+ finishedAt: enqueuedAt
3906
+ });
3907
+ await this.store.saveCheckpoint(cp);
3908
+ replay?.set(seq, cp);
3909
+ await this.pool.dispatch({
3910
+ runId: run.id,
3911
+ seq,
3912
+ name: def.name,
3913
+ stepId: cp.stepId,
3914
+ group,
3915
+ input: args,
3916
+ traceparent: this.traceparent?.(),
3917
+ context: this.context?.(),
3918
+ attempt
3919
+ });
3920
+ this.emit({
3921
+ type: "step.started",
3922
+ runId: run.id,
3923
+ seq,
3924
+ name,
3925
+ kind: "remote"
3926
+ });
3927
+ }
3605
3928
  /** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
3606
3929
  * (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
3607
3930
  * module and the engine stays the orchestrator. */
@@ -3627,6 +3950,12 @@ var WorkflowEngine = class {
3627
3950
  namespace: parentNamespace
3628
3951
  }).catch(() => void 0));
3629
3952
  }, "startChild"),
3953
+ // Deferred for the same reentrancy reason as `startChild` above. `cancel()` is already
3954
+ // idempotent on a terminal/cancelled run (returns its existing status without side effects), so
3955
+ // no extra guard is needed here for the failFast replay case (re-issuing the same cancel calls).
3956
+ cancelChild: /* @__PURE__ */ __name((childId) => {
3957
+ queueMicrotask(() => void this.cancel(childId).catch(() => void 0));
3958
+ }, "cancelChild"),
3630
3959
  // Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
3631
3960
  upsertSearchAttributes: /* @__PURE__ */ __name(async (runId, attrs) => {
3632
3961
  const run = await this.store.getRun(runId);
@@ -4750,6 +5079,7 @@ var InMemoryTransport = class {
4750
5079
  DURABLE_OPTIONS_CANONICAL,
4751
5080
  DURABLE_STEP_CONFIG,
4752
5081
  DURABLE_STEP_NAME,
5082
+ DurableWorkflow,
4753
5083
  Entities,
4754
5084
  EventAccumulators,
4755
5085
  FatalError,
@@ -4776,9 +5106,11 @@ var InMemoryTransport = class {
4776
5106
  applyAttributeQuery,
4777
5107
  attributeColumnFor,
4778
5108
  attributeOperand,
5109
+ bindWorkflowClass,
4779
5110
  breakpointToken,
4780
5111
  collectMetrics,
4781
5112
  createStepLogger,
5113
+ currentWorkflowCtx,
4782
5114
  eventMatchOf,
4783
5115
  eventMatches,
4784
5116
  eventPrefix,
@@ -4787,6 +5119,7 @@ var InMemoryTransport = class {
4787
5119
  normalizeAttributeRows,
4788
5120
  parseDuration,
4789
5121
  prevCronFireMs,
5122
+ runInWorkflowCtx,
4790
5123
  runSchedules,
4791
5124
  runStepHandler,
4792
5125
  sanitizeQueueToken,