@dudousxd/nestjs-durable-core 0.49.3 → 0.50.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
@@ -71,6 +71,7 @@ __export(index_exports, {
71
71
  normalizeAttributeRows: () => normalizeAttributeRows,
72
72
  parseDuration: () => parseDuration,
73
73
  prevCronFireMs: () => prevCronFireMs,
74
+ readSearchAttributes: () => readSearchAttributes,
74
75
  resolveRunWaiting: () => resolveRunWaiting,
75
76
  runInWorkflowCtx: () => runInWorkflowCtx,
76
77
  runSchedules: () => runSchedules,
@@ -1185,6 +1186,11 @@ function normalizeChildOptions(options) {
1185
1186
  } : options ?? {};
1186
1187
  }
1187
1188
  __name(normalizeChildOptions, "normalizeChildOptions");
1189
+ function formatIssuePath(path) {
1190
+ if (!path || path.length === 0) return "(root)";
1191
+ return path.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
1192
+ }
1193
+ __name(formatIssuePath, "formatIssuePath");
1188
1194
  function resolveStepPolicy(config, opts) {
1189
1195
  const policy = {};
1190
1196
  const retries = opts?.retries ?? config?.retries;
@@ -1215,7 +1221,7 @@ var Position = class Position2 {
1215
1221
  this.seq -= 1;
1216
1222
  }
1217
1223
  };
1218
- function createWorkflowCtx(host, runId, compensations, workflow = "") {
1224
+ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttributesSchema) {
1219
1225
  const { store, replay } = host;
1220
1226
  const pos = new Position();
1221
1227
  const readCheckpoint = /* @__PURE__ */ __name((seq) => {
@@ -1684,6 +1690,24 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1684
1690
  }
1685
1691
  return;
1686
1692
  }
1693
+ if (searchAttributesSchema) {
1694
+ const run = await store.getRun(runId);
1695
+ const merged = {
1696
+ ...run?.searchAttributes ?? {},
1697
+ ...attrs
1698
+ };
1699
+ const result = searchAttributesSchema["~standard"].validate(merged);
1700
+ if (result instanceof Promise) {
1701
+ throw new Error(`workflow "${workflow}": ctx.upsertSearchAttributes schema validation must be synchronous (the searchAttributes schema's \`~standard.validate\` returned a Promise) \u2014 replay-determinism requires a sync validator; drop any async refinement from the schema passed to @Workflow({ searchAttributes }).`);
1702
+ }
1703
+ if (result.issues) {
1704
+ const offendingKeys = [
1705
+ ...new Set(result.issues.map((issue) => formatIssuePath(issue.path)))
1706
+ ];
1707
+ const details = result.issues.map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`).join("; ");
1708
+ throw new Error(`workflow "${workflow}": ctx.upsertSearchAttributes rejected by its searchAttributes schema \u2014 offending key(s) ${offendingKeys.join(", ")} \u2014 ${details}`);
1709
+ }
1710
+ }
1687
1711
  await host.upsertSearchAttributes(runId, attrs);
1688
1712
  await writeCheckpoint(instantCheckpoint({
1689
1713
  runId,
@@ -1814,6 +1838,8 @@ var WorkflowEngine = class {
1814
1838
  maxRecoveryAttempts;
1815
1839
  remoteAdvanceSilenceMs;
1816
1840
  reconcileMs;
1841
+ remoteRedispatchMs;
1842
+ remoteRedispatchMax;
1817
1843
  webhookUrl;
1818
1844
  traceparent;
1819
1845
  context;
@@ -1885,6 +1911,8 @@ var WorkflowEngine = class {
1885
1911
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
1886
1912
  this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
1887
1913
  this.reconcileMs = deps.reconcileMs === void 0 ? 3e5 : deps.reconcileMs || void 0;
1914
+ this.remoteRedispatchMs = deps.remoteRedispatchMs || void 0;
1915
+ this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
1888
1916
  this.webhookUrl = deps.webhookUrl;
1889
1917
  this.traceparent = deps.traceparent;
1890
1918
  this.context = deps.context;
@@ -2035,6 +2063,7 @@ var WorkflowEngine = class {
2035
2063
  singleton: opts?.singleton,
2036
2064
  executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
2037
2065
  validateInput: opts?.validateInput,
2066
+ searchAttributesSchema: opts?.searchAttributesSchema,
2038
2067
  onEvent: opts?.onEvent,
2039
2068
  eventBatch: opts?.eventBatch,
2040
2069
  // A real, retained in-process body — so `workflowBody` can hand it to an in-app worker, and so
@@ -2596,6 +2625,49 @@ var WorkflowEngine = class {
2596
2625
  };
2597
2626
  }
2598
2627
  /**
2628
+ * Explicitly re-dispatch every remote step of `runId` stuck `pending` — the OPERATOR escape hatch for
2629
+ * a run whose dispatched step job was LOST (worker crashed with no result, or a transport that dropped
2630
+ * the job). No automatic recovery re-drives these: a reconcile re-drive re-suspends a still-pending
2631
+ * step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` only reclaims LEASED runs,
2632
+ * and `requeue` just replays back to the pending guard. This re-enqueues the same `stepId` so the
2633
+ * (idempotent) step re-runs and its result resumes the run. Safe to call on a healthy run — it re-runs
2634
+ * only in-flight `pending` remote steps, which are idempotent by the durable contract. Returns the
2635
+ * run's current status (with the count re-dispatched), or null if the run is unknown.
2636
+ */
2637
+ async redispatchPending(runId) {
2638
+ const run = await this.store.getRun(runId);
2639
+ if (!run) return null;
2640
+ const pending = (await this.store.listCheckpoints(runId)).filter((cp) => cp.kind === "remote" && cp.status === "pending");
2641
+ let redispatched = 0;
2642
+ for (const cp of pending) {
2643
+ if (!cp.stepId) continue;
2644
+ const reAttempt = cp.attempts + 1;
2645
+ const reEnqueuedAt = /* @__PURE__ */ new Date();
2646
+ await this.store.saveCheckpoint({
2647
+ ...cp,
2648
+ attempts: reAttempt,
2649
+ enqueuedAt: reEnqueuedAt,
2650
+ startedAt: reEnqueuedAt,
2651
+ finishedAt: reEnqueuedAt
2652
+ });
2653
+ await this.dispatchRemoteTask({
2654
+ runId,
2655
+ seq: cp.seq,
2656
+ name: cp.name,
2657
+ stepId: cp.stepId,
2658
+ group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
2659
+ input: cp.input,
2660
+ attempt: reAttempt
2661
+ });
2662
+ redispatched += 1;
2663
+ }
2664
+ return {
2665
+ runId,
2666
+ status: run.status,
2667
+ redispatched
2668
+ };
2669
+ }
2670
+ /**
2599
2671
  * **Fix-and-replay**: re-run a run (typically a `dead`/`failed` one) with a corrected `input`, as a
2600
2672
  * fresh run with clean history. It's a NEW run — `newRunId` defaults to `<runId>~retry~<uuid>` — so
2601
2673
  * the original stays inspectable. Returns the new run's id, or null if `runId` is unknown.
@@ -3691,7 +3763,7 @@ var WorkflowEngine = class {
3691
3763
  const snapshot = await this.store.listCheckpoints(run.id);
3692
3764
  const replay = /* @__PURE__ */ new Map();
3693
3765
  for (const cp of snapshot) replay.set(cp.seq, cp);
3694
- const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow);
3766
+ const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow, registered?.searchAttributesSchema);
3695
3767
  try {
3696
3768
  const output = await runInWorkflowCtx(ctx, () => fn(ctx, run.input));
3697
3769
  if (this.cancelRequested.has(run.id)) {
@@ -4083,7 +4155,47 @@ var WorkflowEngine = class {
4083
4155
  if (existing?.status === "completed") return existing.output;
4084
4156
  if (this.pool.size === 0) throw new Error("remote steps require a Transport");
4085
4157
  if (step.timeoutMs) return this.callRemoteInMemory(runId, seq, step, input, transport);
4086
- if (existing?.status === "pending") throw new WorkflowSuspended();
4158
+ if (existing?.status === "pending") {
4159
+ if (this.remoteRedispatchMs == null) throw new WorkflowSuspended();
4160
+ if (existing.wakeAt == null) {
4161
+ const wakeAt = this.clock() + this.remoteRedispatchMs;
4162
+ await this.store.saveCheckpoint({
4163
+ ...existing,
4164
+ wakeAt
4165
+ });
4166
+ throw new WorkflowSuspended(wakeAt);
4167
+ }
4168
+ if (this.clock() < existing.wakeAt) throw new WorkflowSuspended(existing.wakeAt);
4169
+ if (existing.attempts >= this.remoteRedispatchMax) {
4170
+ throw new RemoteStepError({
4171
+ message: `remote step "${step.name}" lost \u2014 no result after ${existing.attempts} re-dispatch(es)`,
4172
+ code: "remote_step_lost"
4173
+ });
4174
+ }
4175
+ const reAttempt = existing.attempts + 1;
4176
+ const nextDeadline = this.clock() + this.remoteRedispatchMs;
4177
+ const reEnqueuedAt = /* @__PURE__ */ new Date();
4178
+ await this.store.saveCheckpoint({
4179
+ ...existing,
4180
+ attempts: reAttempt,
4181
+ wakeAt: nextDeadline,
4182
+ enqueuedAt: reEnqueuedAt,
4183
+ startedAt: reEnqueuedAt,
4184
+ finishedAt: reEnqueuedAt
4185
+ });
4186
+ await this.dispatchRemoteTask({
4187
+ runId,
4188
+ seq,
4189
+ name: step.name,
4190
+ stepId: existing.stepId ?? stepId(runId, seq),
4191
+ group: existing.workerGroup ?? tenantGroup(sanitizeQueueToken(step.name), step.partition),
4192
+ input: existing.input,
4193
+ priority: admission?.priority,
4194
+ attempt: reAttempt,
4195
+ transport
4196
+ });
4197
+ throw new WorkflowSuspended(nextDeadline);
4198
+ }
4087
4199
  let attempt = 1;
4088
4200
  if (existing?.status === "failed") {
4089
4201
  const maxAttempts = Math.max(1, step.retries ?? 1);
@@ -4131,18 +4243,17 @@ var WorkflowEngine = class {
4131
4243
  startedAt: enqueuedAt,
4132
4244
  finishedAt: enqueuedAt
4133
4245
  });
4134
- await this.pool.dispatch({
4246
+ await this.dispatchRemoteTask({
4135
4247
  runId,
4136
4248
  seq,
4137
4249
  name: step.name,
4138
4250
  stepId: id,
4139
4251
  group: tenantGroup(sanitizeQueueToken(step.name), step.partition),
4140
4252
  input,
4141
- traceparent: this.traceparent?.(),
4142
- context: this.context?.(),
4143
4253
  priority: admission?.priority,
4144
- attempt
4145
- }, transport);
4254
+ attempt,
4255
+ transport
4256
+ });
4146
4257
  this.emit({
4147
4258
  type: "step.started",
4148
4259
  runId,
@@ -4152,6 +4263,23 @@ var WorkflowEngine = class {
4152
4263
  });
4153
4264
  throw new WorkflowSuspended();
4154
4265
  }
4266
+ /** Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
4267
+ * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
4268
+ * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable. */
4269
+ dispatchRemoteTask(task) {
4270
+ return this.pool.dispatch({
4271
+ runId: task.runId,
4272
+ seq: task.seq,
4273
+ name: task.name,
4274
+ stepId: task.stepId,
4275
+ group: task.group,
4276
+ input: task.input,
4277
+ traceparent: this.traceparent?.(),
4278
+ context: this.context?.(),
4279
+ priority: task.priority,
4280
+ attempt: task.attempt
4281
+ }, task.transport);
4282
+ }
4155
4283
  /**
4156
4284
  * Complete a durable remote step from its worker result and resume the run — runs on whichever
4157
4285
  * instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
@@ -4391,12 +4519,17 @@ var TERMINAL_RUN_STATUSES = [
4391
4519
  // src/run-waiting.ts
4392
4520
  var WEBHOOK_PREFIX = "wh:";
4393
4521
  var CHILD_PREFIX = "child:";
4522
+ var BREAKPOINT_PREFIX = "bp:";
4394
4523
  var EVENT_PREFIX = "event:";
4395
4524
  function classifyWaiterToken(token) {
4396
4525
  if (token.startsWith(WEBHOOK_PREFIX)) return {
4397
4526
  on: "webhook",
4398
4527
  name: token
4399
4528
  };
4529
+ if (token.startsWith(BREAKPOINT_PREFIX)) return {
4530
+ on: "breakpoint",
4531
+ name: "breakpoint"
4532
+ };
4400
4533
  if (token.startsWith(CHILD_PREFIX)) return {
4401
4534
  on: "child",
4402
4535
  name: token.slice(CHILD_PREFIX.length)
@@ -4817,6 +4950,19 @@ function attributeOperand(filter) {
4817
4950
  }
4818
4951
  __name(attributeOperand, "attributeOperand");
4819
4952
 
4953
+ // src/search-attributes-schema.ts
4954
+ function readSearchAttributes(schema, run) {
4955
+ const result = schema["~standard"].validate(run.searchAttributes ?? {});
4956
+ if (result instanceof Promise) {
4957
+ throw new Error("readSearchAttributes: schema validation must be synchronous (the searchAttributes schema's `~standard.validate` returned a Promise) \u2014 async Standard Schema validators are not supported.");
4958
+ }
4959
+ if (result.issues) {
4960
+ return {};
4961
+ }
4962
+ return result.value;
4963
+ }
4964
+ __name(readSearchAttributes, "readSearchAttributes");
4965
+
4820
4966
  // src/tokens.ts
4821
4967
  var STATE_STORE = Symbol.for("nestjs-durable:STATE_STORE");
4822
4968
  var TRANSPORT = Symbol.for("nestjs-durable:TRANSPORT");
@@ -5193,6 +5339,7 @@ var InMemoryTransport = class {
5193
5339
  normalizeAttributeRows,
5194
5340
  parseDuration,
5195
5341
  prevCronFireMs,
5342
+ readSearchAttributes,
5196
5343
  resolveRunWaiting,
5197
5344
  runInWorkflowCtx,
5198
5345
  runSchedules,