@dudousxd/nestjs-durable-core 0.72.0 → 0.73.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
@@ -46,6 +46,7 @@ __export(index_exports, {
46
46
  InMemoryTransport: () => InMemoryTransport,
47
47
  LEGACY_V1_CAPABILITIES: () => LEGACY_V1_CAPABILITIES,
48
48
  LEGACY_V1_PROTOCOL: () => LEGACY_V1_PROTOCOL,
49
+ NestedWorkflowCallError: () => NestedWorkflowCallError,
49
50
  NonDeterminismError: () => NonDeterminismError,
50
51
  QueueController: () => QueueController,
51
52
  RUN_VALUE_FACET_LIMIT: () => RUN_VALUE_FACET_LIMIT,
@@ -55,6 +56,8 @@ __export(index_exports, {
55
56
  RemoteWorkflowExecutor: () => RemoteWorkflowExecutor,
56
57
  RemoteWorkflowTimeout: () => RemoteWorkflowTimeout,
57
58
  RunGateway: () => RunGateway,
59
+ SINGLETON_ACTIVE_STATUSES: () => SINGLETON_ACTIVE_STATUSES,
60
+ SINGLETON_ADMITTED_TAG: () => SINGLETON_ADMITTED_TAG,
58
61
  STATE_STORE: () => STATE_STORE,
59
62
  STATE_STORE_CANONICAL: () => STATE_STORE_CANONICAL,
60
63
  ScheduleAlreadyExistsError: () => ScheduleAlreadyExistsError,
@@ -85,6 +88,7 @@ __export(index_exports, {
85
88
  capableWorkers: () => capableWorkers,
86
89
  classifyWaiterToken: () => classifyWaiterToken,
87
90
  clearDurableExecutionWrappers: () => clearDurableExecutionWrappers,
91
+ clearTerminalSingletonAdmission: () => clearTerminalSingletonAdmission,
88
92
  collectMetrics: () => collectMetrics,
89
93
  concurrencyTag: () => concurrencyTag,
90
94
  controlPlaneDescriptor: () => controlPlaneDescriptor,
@@ -92,6 +96,7 @@ __export(index_exports, {
92
96
  coveredTokens: () => coveredTokens,
93
97
  createStepLogger: () => createStepLogger,
94
98
  currentStep: () => currentStep,
99
+ currentStepScope: () => currentStepScope,
95
100
  currentWorkflowCtx: () => currentWorkflowCtx,
96
101
  debug: () => debug,
97
102
  describeWorker: () => describeWorker,
@@ -133,12 +138,16 @@ __export(index_exports, {
133
138
  resolveRunWaiting: () => resolveRunWaiting,
134
139
  runDurableExecution: () => runDurableExecution,
135
140
  runInStepLogger: () => runInStepLogger,
141
+ runInStepScope: () => runInStepScope,
136
142
  runInWorkflowCtx: () => runInWorkflowCtx,
143
+ runOutsideWorkflowCtx: () => runOutsideWorkflowCtx,
137
144
  runSchedules: () => runSchedules,
138
145
  runStepHandler: () => runStepHandler,
139
146
  runValueFacetsFromRuns: () => runValueFacetsFromRuns,
140
147
  sanitizeQueueToken: () => sanitizeQueueToken,
141
148
  scheduledRunId: () => scheduledRunId,
149
+ singletonAdmissionDenialPatch: () => singletonAdmissionDenialPatch,
150
+ singletonAdmissionTags: () => singletonAdmissionTags,
142
151
  sqlComparator: () => sqlComparator,
143
152
  stepConfigOf: () => stepConfigOf,
144
153
  stepId: () => stepId,
@@ -324,6 +333,27 @@ function currentWorkflowCtx() {
324
333
  return storage.getStore();
325
334
  }
326
335
  __name(currentWorkflowCtx, "currentWorkflowCtx");
336
+ var STEP_SCOPE_KEY = Symbol.for("nestjs-durable:ambient-step-scope");
337
+ var stepScopeRef = globalThis;
338
+ if (!stepScopeRef[STEP_SCOPE_KEY]) {
339
+ stepScopeRef[STEP_SCOPE_KEY] = new import_node_async_hooks.AsyncLocalStorage();
340
+ }
341
+ var stepScopeStorage = stepScopeRef[STEP_SCOPE_KEY];
342
+ function runInStepScope(owner, step, fn) {
343
+ return stepScopeStorage.run({
344
+ owner,
345
+ step
346
+ }, fn);
347
+ }
348
+ __name(runInStepScope, "runInStepScope");
349
+ function currentStepScope() {
350
+ return stepScopeStorage.getStore();
351
+ }
352
+ __name(currentStepScope, "currentStepScope");
353
+ function runOutsideWorkflowCtx(fn) {
354
+ return storage.exit(() => stepScopeStorage.exit(fn));
355
+ }
356
+ __name(runOutsideWorkflowCtx, "runOutsideWorkflowCtx");
327
357
 
328
358
  // src/ambient-step.ts
329
359
  var import_node_async_hooks2 = require("async_hooks");
@@ -486,6 +516,21 @@ var NonDeterminismError = class extends Error {
486
516
  this.seq = seq;
487
517
  }
488
518
  };
519
+ var NestedWorkflowCallError = class extends FatalError {
520
+ static {
521
+ __name(this, "NestedWorkflowCallError");
522
+ }
523
+ runId;
524
+ primitive;
525
+ step;
526
+ constructor(runId, primitive, step) {
527
+ super(`ctx.${primitive}() was called inside the body of step "${step}" (run ${runId}). Workflow primitives (child/startChild/step/sleep/signals/sideEffect/\u2026, and MyWorkflow.start()/.execute() via the ambient ctx) must be called from the workflow body, never from inside a step body: the step's checkpoint replays without re-running it, so the nested call would shift every later journal position and corrupt the run on replay. Move the call out of the step.`, "nested_workflow_call");
528
+ this.name = "NestedWorkflowCallError";
529
+ this.runId = runId;
530
+ this.primitive = primitive;
531
+ this.step = step;
532
+ }
533
+ };
489
534
  var GatherError = class extends Error {
490
535
  static {
491
536
  __name(this, "GatherError");
@@ -842,14 +887,19 @@ async function runStepHandler(task, handler) {
842
887
  } : result, "withEvents");
843
888
  try {
844
889
  const logger = createStepLogger(events, Date.now);
845
- const output = await runDurableExecution({
846
- unit: "step",
847
- runId: task.runId,
848
- workflow: void 0,
849
- name: task.name,
850
- seq: task.seq,
851
- attempt: task.attempt
852
- }, async () => runInStepLogger(logger, () => handler(task.input, logger)));
890
+ const output = await runDurableExecution(
891
+ {
892
+ unit: "step",
893
+ runId: task.runId,
894
+ workflow: void 0,
895
+ name: task.name,
896
+ seq: task.seq,
897
+ attempt: task.attempt
898
+ },
899
+ // Outside any ambient workflow ctx: an in-process transport runs the handler on the parent
900
+ // body's async path, but a handler is not the body (see `runOutsideWorkflowCtx`).
901
+ async () => runOutsideWorkflowCtx(() => runInStepLogger(logger, () => handler(task.input, logger)))
902
+ );
853
903
  return withEvents({
854
904
  ...base,
855
905
  status: "completed",
@@ -2399,6 +2449,54 @@ function errorMessage(error2) {
2399
2449
  }
2400
2450
  __name(errorMessage, "errorMessage");
2401
2451
 
2452
+ // src/singleton-admission.ts
2453
+ var SINGLETON_ADMITTED_TAG = "durable:singleton:admitted";
2454
+ var SINGLETON_ACTIVE_STATUSES = [
2455
+ "pending",
2456
+ "running",
2457
+ "suspended",
2458
+ "blocked",
2459
+ "cancelling"
2460
+ ];
2461
+ function singletonAdmissionTags(runs, runId, tag, workflow, limit) {
2462
+ if (!Number.isInteger(limit) || limit < 1) throw new Error("singleton limit must be a positive integer");
2463
+ const active = runs.filter((run) => run.workflow === workflow && SINGLETON_ACTIVE_STATUSES.includes(run.status) && run.tags?.includes(tag));
2464
+ const candidate = active.find((run) => run.id === runId);
2465
+ if (!candidate) return null;
2466
+ if (candidate.tags?.includes(SINGLETON_ADMITTED_TAG)) return candidate.tags;
2467
+ if (candidate.status === "cancelling") return null;
2468
+ const holders = active.filter((run) => run.tags?.includes(SINGLETON_ADMITTED_TAG));
2469
+ const remaining = limit - holders.length;
2470
+ if (remaining <= 0) return null;
2471
+ const waiters = active.filter((run) => !run.tags?.includes(SINGLETON_ADMITTED_TAG)).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
2472
+ if (!waiters.slice(0, remaining).some((run) => run.id === runId)) return null;
2473
+ return [
2474
+ ...candidate.tags ?? [],
2475
+ SINGLETON_ADMITTED_TAG
2476
+ ];
2477
+ }
2478
+ __name(singletonAdmissionTags, "singletonAdmissionTags");
2479
+ async function clearTerminalSingletonAdmission(runId, patch, getRun) {
2480
+ if (!patch.status || !TERMINAL_RUN_STATUSES.includes(patch.status)) return patch;
2481
+ const tags = "tags" in patch ? patch.tags : (await getRun(runId))?.tags;
2482
+ return {
2483
+ ...patch,
2484
+ tags: tags?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
2485
+ };
2486
+ }
2487
+ __name(clearTerminalSingletonAdmission, "clearTerminalSingletonAdmission");
2488
+ function singletonAdmissionDenialPatch(runs, runId, tag, workflow, retryWakeAt) {
2489
+ if (retryWakeAt === void 0) return void 0;
2490
+ const candidate = runs.find((run) => run.id === runId && run.workflow === workflow && run.tags?.includes(tag) && SINGLETON_ACTIVE_STATUSES.includes(run.status));
2491
+ if (!candidate || candidate.status === "cancelling") return void 0;
2492
+ return {
2493
+ status: "suspended",
2494
+ wakeAt: retryWakeAt,
2495
+ updatedAt: /* @__PURE__ */ new Date()
2496
+ };
2497
+ }
2498
+ __name(singletonAdmissionDenialPatch, "singletonAdmissionDenialPatch");
2499
+
2402
2500
  // src/singleton-gate.ts
2403
2501
  var SINGLETON_RETRY_MS = 1e3;
2404
2502
  var SINGLETON_RETRY_JITTER_MS = 250;
@@ -2412,6 +2510,9 @@ var SingletonGate = class {
2412
2510
  }
2413
2511
  /** The tag a singleton run carries, so the gate can find others sharing its key. */
2414
2512
  tag(cfg, input) {
2513
+ if (!this.deps.store.tryAdmitSingleton) {
2514
+ throw new Error("Singleton workflows require StateStore.tryAdmitSingleton for atomic durable admission");
2515
+ }
2415
2516
  return `singleton:${cfg.key(input)}`;
2416
2517
  }
2417
2518
  /** Next wake time for a gated run: the base retry delay jittered to avoid a wakeup stampede. */
@@ -2420,7 +2521,7 @@ var SingletonGate = class {
2420
2521
  }
2421
2522
  /**
2422
2523
  * Reject a start that would grow the same-key backlog past `limit + maxQueueDepth` (counting
2423
- * `pending`/`running`/`suspended` runs sharing the key in one scan). No-op when no `maxQueueDepth`
2524
+ * `pending`/`running`/`suspended`/`blocked`/`cancelling` runs sharing the key in one scan). No-op when no `maxQueueDepth`
2424
2525
  * is configured.
2425
2526
  */
2426
2527
  async assertCapacity(workflow, cfg, input) {
@@ -2429,34 +2530,25 @@ var SingletonGate = class {
2429
2530
  const queued = await this.deps.store.listRuns({
2430
2531
  tag: this.tag(cfg, input),
2431
2532
  workflow,
2432
- statuses: [
2433
- "pending",
2434
- "running",
2435
- "suspended",
2436
- "cancelling"
2437
- ]
2533
+ statuses: SINGLETON_ACTIVE_STATUSES
2438
2534
  });
2439
2535
  if (queued.length >= cap) {
2440
2536
  throw new SingletonQueueFullError(workflow, cfg.key(input), cfg.maxQueueDepth);
2441
2537
  }
2442
2538
  }
2443
- /**
2444
- * Whether `run` may run now under its key: it's among the `limit` oldest in-flight (running or
2445
- * suspended) runs sharing the key, by `(createdAt, id)` order. A consistent store gives every
2446
- * instance the same ordering, so admission is race-free + FIFO.
2447
- */
2539
+ /** Preserve durable holders and atomically claim one of the remaining slots. */
2448
2540
  async admit(run, cfg) {
2449
- const inflight = (await this.deps.store.listRuns({
2450
- tag: this.tag(cfg, run.input),
2451
- workflow: run.workflow,
2452
- statuses: [
2453
- "running",
2454
- "suspended",
2455
- "cancelling"
2456
- ]
2457
- })).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
2458
- const idx = inflight.findIndex((r) => r.id === run.id);
2459
- return idx >= 0 && idx < (cfg.limit ?? 1);
2541
+ if (!this.deps.store.tryAdmitSingleton) {
2542
+ throw new Error("Singleton workflows require StateStore.tryAdmitSingleton for atomic durable admission");
2543
+ }
2544
+ const admitted = await this.deps.store.tryAdmitSingleton(run.id, this.tag(cfg, run.input), run.workflow, cfg.limit ?? 1, this.retryWakeAt());
2545
+ if (admitted && !run.tags?.includes(SINGLETON_ADMITTED_TAG)) {
2546
+ run.tags = [
2547
+ ...run.tags ?? [],
2548
+ SINGLETON_ADMITTED_TAG
2549
+ ];
2550
+ }
2551
+ return admitted;
2460
2552
  }
2461
2553
  /**
2462
2554
  * Notify-on-release: a singleton run settled (freeing a slot), so dispatch the oldest gated
@@ -2753,10 +2845,15 @@ var Position = class Position2 {
2753
2845
  rewind() {
2754
2846
  this.seq -= 1;
2755
2847
  }
2848
+ /** The position the NEXT primitive would claim, without claiming it. */
2849
+ peek() {
2850
+ return this.seq + 1;
2851
+ }
2756
2852
  };
2757
2853
  function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttributesSchema) {
2758
2854
  const { store, replay } = host;
2759
2855
  const pos = new Position();
2856
+ const scopeOwner = {};
2760
2857
  const readCheckpoint = /* @__PURE__ */ __name((seq) => {
2761
2858
  const hit = replay?.get(seq);
2762
2859
  if (hit !== void 0) return hit;
@@ -2802,7 +2899,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2802
2899
  };
2803
2900
  const body = /* @__PURE__ */ __name(() => {
2804
2901
  const logger = createStepLogger(events, host.clock);
2805
- return runInStepLogger(logger, () => fn(logger));
2902
+ return runInStepScope(scopeOwner, name, () => runInStepLogger(logger, () => fn(logger)));
2806
2903
  }, "body");
2807
2904
  const output = host.interceptStep ? await host.interceptStep(invocation, body) : await body();
2808
2905
  await host.completeStep({
@@ -2855,7 +2952,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2855
2952
  }
2856
2953
  if (existing && existing.status === "completed") return existing.output;
2857
2954
  return store.transaction(async (tx) => {
2858
- const output = await fn(tx.raw);
2955
+ const output = await runInStepScope(scopeOwner, name, () => fn(tx.raw));
2859
2956
  const cp = instantCheckpoint({
2860
2957
  runId,
2861
2958
  seq: current,
@@ -3364,30 +3461,49 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3364
3461
  });
3365
3462
  }
3366
3463
  __name(step, "step");
3464
+ const nestedStep = /* @__PURE__ */ __name(() => {
3465
+ const scope = currentStepScope();
3466
+ return scope !== void 0 && scope.owner === scopeOwner ? scope.step : void 0;
3467
+ }, "nestedStep");
3468
+ const guard = /* @__PURE__ */ __name((primitive, fn) => (...args) => {
3469
+ const stepName = nestedStep();
3470
+ if (stepName === void 0) return fn(...args);
3471
+ return (async () => {
3472
+ if (await readCheckpoint(pos.peek())) return fn(...args);
3473
+ throw new NestedWorkflowCallError(runId, primitive, stepName);
3474
+ })();
3475
+ }, "guard");
3476
+ const guardedWebhook = /* @__PURE__ */ __name(() => {
3477
+ const stepName = nestedStep();
3478
+ if (stepName !== void 0 && !replay?.get(pos.peek())) {
3479
+ throw new NestedWorkflowCallError(runId, "webhook", stepName);
3480
+ }
3481
+ return webhook();
3482
+ }, "guardedWebhook");
3367
3483
  return {
3368
3484
  runId,
3369
- step,
3370
- localStep,
3371
- upsertSearchAttributes,
3372
- transaction,
3373
- callEntity,
3374
- signalEntity,
3375
- sleep,
3376
- sleepUntil,
3485
+ step: guard("step", step),
3486
+ localStep: guard("localStep", localStep),
3487
+ upsertSearchAttributes: guard("upsertSearchAttributes", upsertSearchAttributes),
3488
+ transaction: guard("transaction", transaction),
3489
+ callEntity: guard("callEntity", callEntity),
3490
+ signalEntity: guard("signalEntity", signalEntity),
3491
+ sleep: guard("sleep", sleep),
3492
+ sleepUntil: guard("sleepUntil", sleepUntil),
3377
3493
  continueAsNew,
3378
- waitForSignal,
3379
- waitForEvent,
3380
- task,
3381
- child,
3382
- all,
3383
- startChild,
3384
- breakpoint,
3385
- webhook,
3386
- setEvent,
3387
- onUpdate,
3388
- patched,
3389
- now,
3390
- sideEffect
3494
+ waitForSignal: guard("waitForSignal", waitForSignal),
3495
+ waitForEvent: guard("waitForEvent", waitForEvent),
3496
+ task: guard("task", task),
3497
+ child: guard("child", child),
3498
+ all: guard("all", all),
3499
+ startChild: guard("startChild", startChild),
3500
+ breakpoint: guard("breakpoint", breakpoint),
3501
+ webhook: guardedWebhook,
3502
+ setEvent: guard("setEvent", setEvent),
3503
+ onUpdate: guard("onUpdate", onUpdate),
3504
+ patched: guard("patched", patched),
3505
+ now: guard("now", now),
3506
+ sideEffect: guard("sideEffect", sideEffect)
3391
3507
  };
3392
3508
  }
3393
3509
  __name(createWorkflowCtx, "createWorkflowCtx");
@@ -3949,7 +4065,7 @@ var WorkflowEngine = class {
3949
4065
  VERSION_UNDECLARED_TAG
3950
4066
  ] : []
3951
4067
  ];
3952
- const tags = mergeTags(registered.tags, stampedTags);
4068
+ const tags = mergeTags(registered.tags, stampedTags)?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG);
3953
4069
  if (registered.singleton) {
3954
4070
  await this.singletons.assertCapacity(name, registered.singleton, input);
3955
4071
  }
@@ -3989,10 +4105,30 @@ var WorkflowEngine = class {
3989
4105
  getRun(runId) {
3990
4106
  return this.store.getRun(runId);
3991
4107
  }
3992
- async resume(runId) {
4108
+ /**
4109
+ * Re-execute a run's body (replaying its checkpoints). This is the EXPLICIT entry point: besides
4110
+ * driving a live run, it re-drives a `failed` run — the manual retry (prefer {@link requeue}, which
4111
+ * also resets the failure state so replay re-attempts the failed parts). Completed / cancelled /
4112
+ * dead runs are never re-executed. The engine's own implicit wake-ups (signals, step results,
4113
+ * timers, redelivered dispatches) go through {@link resumeImplicit}, which treats `failed` as
4114
+ * terminal too.
4115
+ */
4116
+ resume(runId) {
4117
+ return this.resumeRun(runId, true);
4118
+ }
4119
+ /**
4120
+ * {@link resume} for every IMPLICIT wake-up — a delivered signal/child completion, a late step
4121
+ * result, a due timer, a redelivered dispatch, a queue-slot wake. A `failed` run is terminal to
4122
+ * these: only an explicit retry ({@link requeue} / {@link resume}) may re-drive it. Otherwise a
4123
+ * stale waiter or a late result left behind by a failed run silently re-executes its body.
4124
+ */
4125
+ resumeImplicit(runId) {
4126
+ return this.resumeRun(runId, false);
4127
+ }
4128
+ async resumeRun(runId, allowFailed) {
3993
4129
  const run = await this.store.getRun(runId);
3994
4130
  if (!run) throw new Error(`run ${runId} not found`);
3995
- if (run.status === "cancelled" || run.status === "completed" || run.status === "dead") {
4131
+ if (run.status === "cancelled" || run.status === "completed" || run.status === "dead" || run.status === "failed" && !allowFailed) {
3996
4132
  return {
3997
4133
  runId,
3998
4134
  status: run.status,
@@ -4004,6 +4140,12 @@ var WorkflowEngine = class {
4004
4140
  await this.store.releaseRunLock(runId);
4005
4141
  throw new NamespaceMismatch();
4006
4142
  }
4143
+ if (run.status === "failed" && run.tags?.includes(SINGLETON_ADMITTED_TAG)) {
4144
+ run.tags = run.tags.filter((tag) => tag !== SINGLETON_ADMITTED_TAG);
4145
+ await this.store.updateRun(run.id, {
4146
+ tags: run.tags
4147
+ });
4148
+ }
4007
4149
  const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run) ?? await this.resolveRemoteByConvention(run);
4008
4150
  if (!registered) {
4009
4151
  throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered \u2014 keep the prior version deployed so in-flight runs can drain (skew protection)`);
@@ -4153,7 +4295,10 @@ var WorkflowEngine = class {
4153
4295
  /** Track an in-flight execution so {@link drain} can wait for it. */
4154
4296
  track(p) {
4155
4297
  this.inflight.add(p);
4156
- void p.finally(() => this.inflight.delete(p));
4298
+ const done = /* @__PURE__ */ __name(() => {
4299
+ this.inflight.delete(p);
4300
+ }, "done");
4301
+ void p.then(done, done);
4157
4302
  return p;
4158
4303
  }
4159
4304
  /**
@@ -4441,7 +4586,7 @@ var WorkflowEngine = class {
4441
4586
  const nowMs = this.clock();
4442
4587
  const acquired = await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs);
4443
4588
  if (!acquired) return null;
4444
- return this.resume(runId).catch((err) => {
4589
+ return this.resumeImplicit(runId).catch((err) => {
4445
4590
  if (err instanceof NamespaceMismatch) return null;
4446
4591
  throw err;
4447
4592
  });
@@ -4504,7 +4649,8 @@ var WorkflowEngine = class {
4504
4649
  status: "pending",
4505
4650
  error: void 0,
4506
4651
  ...resurrecting ? {
4507
- recoveryAttempts: 0
4652
+ recoveryAttempts: 0,
4653
+ tags: run.tags?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
4508
4654
  } : {},
4509
4655
  updatedAt: /* @__PURE__ */ new Date()
4510
4656
  });
@@ -4623,10 +4769,19 @@ var WorkflowEngine = class {
4623
4769
  off();
4624
4770
  resolve(toResult(run));
4625
4771
  }, "finish");
4772
+ const fail = /* @__PURE__ */ __name((error2) => {
4773
+ if (done) return;
4774
+ done = true;
4775
+ if (timer) clearTimeout(timer);
4776
+ if (poller) clearInterval(poller);
4777
+ off();
4778
+ reject(error2);
4779
+ }, "fail");
4626
4780
  const check = /* @__PURE__ */ __name(() => {
4781
+ if (done) return;
4627
4782
  void this.store.getRun(runId).then((run) => {
4628
4783
  if (run && isSettled(run)) finish(run);
4629
- });
4784
+ }, fail);
4630
4785
  }, "check");
4631
4786
  const poller = opts?.until === "terminal" ? (() => {
4632
4787
  const interval = setInterval(check, 250);
@@ -4662,7 +4817,7 @@ var WorkflowEngine = class {
4662
4817
  if (!acquired) continue;
4663
4818
  try {
4664
4819
  const settled = onLocked ? await onLocked(run) : void 0;
4665
- results.push(settled ?? await this.resume(run.id));
4820
+ results.push(settled ?? await this.resumeImplicit(run.id));
4666
4821
  } catch (error2) {
4667
4822
  console.error(`[nestjs-durable] could not resume run ${run.id} (${run.workflow}@${run.workflowVersion}); leaving it for another instance:`, error2);
4668
4823
  }
@@ -4759,7 +4914,7 @@ var WorkflowEngine = class {
4759
4914
  // for an ordinary (non-fan) signal/child await.
4760
4915
  parallelGroup: waiter.parallelGroup
4761
4916
  }));
4762
- return this.resume(waiter.runId).catch((err) => {
4917
+ return this.resumeImplicit(waiter.runId).catch((err) => {
4763
4918
  if (err instanceof NamespaceMismatch) return null;
4764
4919
  throw err;
4765
4920
  });
@@ -4875,7 +5030,7 @@ var WorkflowEngine = class {
4875
5030
  async cancel(runId, opts) {
4876
5031
  const run = await this.store.getRun(runId);
4877
5032
  if (!run) return null;
4878
- if (run.status === "completed" || run.status === "cancelled" || run.status === "dead") {
5033
+ if (TERMINAL_RUN_STATUSES.includes(run.status)) {
4879
5034
  return {
4880
5035
  runId,
4881
5036
  status: run.status,
@@ -4904,7 +5059,7 @@ var WorkflowEngine = class {
4904
5059
  }).catch(() => void 0);
4905
5060
  }
4906
5061
  queueMicrotask(() => {
4907
- void this.resume(runId).then(() => this.notifyCancelled(runId)).catch(() => void 0);
5062
+ void this.resumeImplicit(runId).then(() => this.notifyCancelled(runId)).catch(() => void 0);
4908
5063
  });
4909
5064
  await this.cancelChildren(runId, opts);
4910
5065
  return {
@@ -5409,6 +5564,26 @@ var WorkflowEngine = class {
5409
5564
  durationMs: Date.now() - step.startedAt.getTime()
5410
5565
  });
5411
5566
  }
5567
+ /** Admission persists the wait atomically; never overwrite a cancellation after it returns. */
5568
+ async singletonWaitResult(run) {
5569
+ const current = await this.store.getRun(run.id);
5570
+ await this.store.releaseRunLock(run.id);
5571
+ if (!current) throw new Error(`run ${run.id} was deleted during singleton admission`);
5572
+ if (current.status === "suspended") {
5573
+ this.emit({
5574
+ type: "run.suspended",
5575
+ runId: current.id,
5576
+ workflow: current.workflow,
5577
+ namespace: current.namespace
5578
+ });
5579
+ }
5580
+ return {
5581
+ runId: current.id,
5582
+ status: current.status,
5583
+ output: current.output,
5584
+ error: current.error
5585
+ };
5586
+ }
5412
5587
  async execute(run, fn) {
5413
5588
  const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run) ?? await this.resolveRemoteByConvention(run);
5414
5589
  const lockNow = this.clock();
@@ -5578,23 +5753,7 @@ var WorkflowEngine = class {
5578
5753
  });
5579
5754
  }
5580
5755
  if (registered.singleton && !await this.singletons.admit(run, registered.singleton)) {
5581
- const wakeAt = this.singletons.retryWakeAt();
5582
- await this.store.updateRun(run.id, {
5583
- status: "suspended",
5584
- wakeAt,
5585
- updatedAt: /* @__PURE__ */ new Date()
5586
- });
5587
- this.emit({
5588
- type: "run.suspended",
5589
- runId: run.id,
5590
- workflow: run.workflow,
5591
- namespace: run.namespace
5592
- });
5593
- await this.store.releaseRunLock(run.id);
5594
- return {
5595
- runId: run.id,
5596
- status: "suspended"
5597
- };
5756
+ return this.singletonWaitResult(run);
5598
5757
  }
5599
5758
  const wfPlan = await this.planRoute(tenantGroup(sanitizeQueueToken(remote.group), void 0), registered.requires);
5600
5759
  if (wfPlan?.status === "blocked") return this.parkBlocked(run, wfPlan);
@@ -5739,7 +5898,7 @@ var WorkflowEngine = class {
5739
5898
  const checkpoint = await this.store.getCheckpoint(runId, cmd.seq);
5740
5899
  if (checkpoint && checkpoint.status !== "pending") {
5741
5900
  setTimeout(() => {
5742
- void this.resume(runId).catch(() => void 0);
5901
+ void this.resumeImplicit(runId).catch(() => void 0);
5743
5902
  }, 0).unref?.();
5744
5903
  return;
5745
5904
  }
@@ -6081,7 +6240,7 @@ var WorkflowEngine = class {
6081
6240
  kind: "signal",
6082
6241
  output: payload
6083
6242
  }));
6084
- setTimeout(() => void this.resume(run.id).catch(() => void 0), 0);
6243
+ setTimeout(() => void this.resumeImplicit(run.id).catch(() => void 0), 0);
6085
6244
  }, "deliverBuffered");
6086
6245
  const buffered = await this.store.takeBufferedSignal(cmd.signal);
6087
6246
  if (buffered) {
@@ -6208,23 +6367,7 @@ var WorkflowEngine = class {
6208
6367
  });
6209
6368
  }
6210
6369
  if (run.status !== "cancelling" && registered?.singleton && !await this.singletons.admit(run, registered.singleton)) {
6211
- const wakeAt = this.singletons.retryWakeAt();
6212
- await this.store.updateRun(run.id, {
6213
- status: "suspended",
6214
- wakeAt,
6215
- updatedAt: /* @__PURE__ */ new Date()
6216
- });
6217
- this.emit({
6218
- type: "run.suspended",
6219
- runId: run.id,
6220
- workflow: run.workflow,
6221
- namespace: run.namespace
6222
- });
6223
- await this.store.releaseRunLock(run.id);
6224
- return {
6225
- runId: run.id,
6226
- status: "suspended"
6227
- };
6370
+ return this.singletonWaitResult(run);
6228
6371
  }
6229
6372
  const compensations = [];
6230
6373
  const snapshot = await this.store.listCheckpoints(run.id);
@@ -6895,7 +7038,7 @@ var WorkflowEngine = class {
6895
7038
  queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
6896
7039
  durationMs: finishedAt.getTime() - startedAt.getTime()
6897
7040
  });
6898
- await this.resume(result.runId).catch((err) => {
7041
+ await this.resumeImplicit(result.runId).catch((err) => {
6899
7042
  if (err instanceof NamespaceMismatch) return null;
6900
7043
  throw err;
6901
7044
  });
@@ -6996,7 +7139,7 @@ var WorkflowEngine = class {
6996
7139
  ...waiters
6997
7140
  ];
6998
7141
  waiters.clear();
6999
- for (const runId of runIds) void this.resume(runId).catch(() => void 0);
7142
+ for (const runId of runIds) void this.resumeImplicit(runId).catch(() => void 0);
7000
7143
  }
7001
7144
  /** Release the flow-control slot a dispatched step held (if any), by its stepId. */
7002
7145
  async releaseQueueSlot(id) {
@@ -7243,6 +7386,8 @@ var CodecStateStore = class {
7243
7386
  }
7244
7387
  inner;
7245
7388
  codec;
7389
+ /** Atomic claims touch only searchable metadata and bypass payload encoding. */
7390
+ tryAdmitSingleton;
7246
7391
  /**
7247
7392
  * Forwarded verbatim — a facet cell counts `status`/`origin`, neither of which the codec touches
7248
7393
  * (it encodes payloads: input/output/error). Bound as a PROPERTY, and only when the inner store
@@ -7275,6 +7420,7 @@ var CodecStateStore = class {
7275
7420
  constructor(inner, codec) {
7276
7421
  this.inner = inner;
7277
7422
  this.codec = codec;
7423
+ if (inner.tryAdmitSingleton) this.tryAdmitSingleton = inner.tryAdmitSingleton.bind(inner);
7278
7424
  const deleteRuns = inner.deleteRuns;
7279
7425
  if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
7280
7426
  const prune = inner.pruneTerminalRuns;
@@ -7727,6 +7873,28 @@ var InMemoryStateStore = class {
7727
7873
  byRun.set(runId, row);
7728
7874
  }
7729
7875
  }
7876
+ async tryAdmitSingleton(runId, tag, workflow, limit, retryWakeAt) {
7877
+ const runs = [
7878
+ ...this.runs.values()
7879
+ ];
7880
+ const tags = singletonAdmissionTags(runs, runId, tag, workflow, limit);
7881
+ if (!tags) {
7882
+ const patch = singletonAdmissionDenialPatch(runs, runId, tag, workflow, retryWakeAt);
7883
+ const candidate = this.runs.get(runId);
7884
+ if (patch && candidate) this.runs.set(runId, {
7885
+ ...candidate,
7886
+ ...patch
7887
+ });
7888
+ return false;
7889
+ }
7890
+ const run = this.runs.get(runId);
7891
+ if (!run) return false;
7892
+ this.runs.set(runId, {
7893
+ ...run,
7894
+ tags
7895
+ });
7896
+ return true;
7897
+ }
7730
7898
  async createRun(run) {
7731
7899
  this.runs.set(run.id, {
7732
7900
  ...run,
@@ -7734,7 +7902,11 @@ var InMemoryStateStore = class {
7734
7902
  });
7735
7903
  this.reindexAttributes(run.id, run.searchAttributes);
7736
7904
  }
7737
- async updateRun(runId, patch) {
7905
+ async updateRun(runId, inputPatch) {
7906
+ const patch = inputPatch.status && TERMINAL_RUN_STATUSES.includes(inputPatch.status) ? {
7907
+ ...inputPatch,
7908
+ tags: ("tags" in inputPatch ? inputPatch.tags : this.runs.get(runId)?.tags)?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
7909
+ } : inputPatch;
7738
7910
  const existing = this.runs.get(runId);
7739
7911
  if (!existing) throw new Error(`run ${runId} not found`);
7740
7912
  const next = {
@@ -8149,6 +8321,7 @@ var InMemoryTransport = class {
8149
8321
  InMemoryTransport,
8150
8322
  LEGACY_V1_CAPABILITIES,
8151
8323
  LEGACY_V1_PROTOCOL,
8324
+ NestedWorkflowCallError,
8152
8325
  NonDeterminismError,
8153
8326
  QueueController,
8154
8327
  RUN_VALUE_FACET_LIMIT,
@@ -8158,6 +8331,8 @@ var InMemoryTransport = class {
8158
8331
  RemoteWorkflowExecutor,
8159
8332
  RemoteWorkflowTimeout,
8160
8333
  RunGateway,
8334
+ SINGLETON_ACTIVE_STATUSES,
8335
+ SINGLETON_ADMITTED_TAG,
8161
8336
  STATE_STORE,
8162
8337
  STATE_STORE_CANONICAL,
8163
8338
  ScheduleAlreadyExistsError,
@@ -8188,6 +8363,7 @@ var InMemoryTransport = class {
8188
8363
  capableWorkers,
8189
8364
  classifyWaiterToken,
8190
8365
  clearDurableExecutionWrappers,
8366
+ clearTerminalSingletonAdmission,
8191
8367
  collectMetrics,
8192
8368
  concurrencyTag,
8193
8369
  controlPlaneDescriptor,
@@ -8195,6 +8371,7 @@ var InMemoryTransport = class {
8195
8371
  coveredTokens,
8196
8372
  createStepLogger,
8197
8373
  currentStep,
8374
+ currentStepScope,
8198
8375
  currentWorkflowCtx,
8199
8376
  debug,
8200
8377
  describeWorker,
@@ -8236,12 +8413,16 @@ var InMemoryTransport = class {
8236
8413
  resolveRunWaiting,
8237
8414
  runDurableExecution,
8238
8415
  runInStepLogger,
8416
+ runInStepScope,
8239
8417
  runInWorkflowCtx,
8418
+ runOutsideWorkflowCtx,
8240
8419
  runSchedules,
8241
8420
  runStepHandler,
8242
8421
  runValueFacetsFromRuns,
8243
8422
  sanitizeQueueToken,
8244
8423
  scheduledRunId,
8424
+ singletonAdmissionDenialPatch,
8425
+ singletonAdmissionTags,
8245
8426
  sqlComparator,
8246
8427
  stepConfigOf,
8247
8428
  stepId,