@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.d.ts CHANGED
@@ -161,6 +161,74 @@ declare class InMemoryAdmissionBackend implements AdmissionBackend {
161
161
  release(queue: string, _slotId: string): Promise<void>;
162
162
  }
163
163
 
164
+ /**
165
+ * Vendored subset of the Standard Schema spec (https://standardschema.dev) — the `~standard`
166
+ * property protocol implemented by zod 3.24+, valibot, arktype, and others. Vendored (rather than
167
+ * taken as a dependency) so core stays dependency-free at runtime: this file is types only, erased
168
+ * entirely by the build. Source: https://github.com/standard-schema/standard-schema (MIT, Colin
169
+ * McDonnell) — trimmed to the `StandardSchemaV1` shape this package actually consumes (drops the
170
+ * sibling `StandardTypedV1`/`StandardJSONSchemaV1` specs the upstream package also exports).
171
+ *
172
+ * Used to type `@Workflow({ searchAttributes })` (see `WorkflowMeta.searchAttributes` in
173
+ * `@dudousxd/nestjs-durable` and `SearchAttributesSchema`/`InferSearchAttributes` in `interfaces.ts`)
174
+ * so a workflow can declare its search-attribute shape with whatever schema library it already uses,
175
+ * without core taking a hard dependency on any of them.
176
+ */
177
+ interface StandardSchemaV1<Input = unknown, Output = Input> {
178
+ /** The Standard Schema properties. */
179
+ readonly '~standard': StandardSchemaV1.Props<Input, Output>;
180
+ }
181
+ declare namespace StandardSchemaV1 {
182
+ /** The Standard Schema properties interface. */
183
+ interface Props<Input = unknown, Output = Input> {
184
+ /** The version number of the standard. */
185
+ readonly version: 1;
186
+ /** The vendor name of the schema library. */
187
+ readonly vendor: string;
188
+ /** Validates unknown input values. */
189
+ readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
190
+ /** Inferred types associated with the schema. */
191
+ readonly types?: Types<Input, Output> | undefined;
192
+ }
193
+ /** The result interface of the validate function. */
194
+ type Result<Output> = SuccessResult<Output> | FailureResult;
195
+ /** The result interface if validation succeeds. */
196
+ interface SuccessResult<Output> {
197
+ /** The typed output value. */
198
+ readonly value: Output;
199
+ /** A falsy value for `issues` indicates success. */
200
+ readonly issues?: undefined;
201
+ }
202
+ /** The result interface if validation fails. */
203
+ interface FailureResult {
204
+ /** The issues of failed validation. */
205
+ readonly issues: ReadonlyArray<Issue>;
206
+ }
207
+ /** The issue interface of the failure output. */
208
+ interface Issue {
209
+ /** The error message of the issue. */
210
+ readonly message: string;
211
+ /** The path of the issue, if any. */
212
+ readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
213
+ }
214
+ /** The path segment interface of the issue. */
215
+ interface PathSegment {
216
+ /** The key representing a path segment. */
217
+ readonly key: PropertyKey;
218
+ }
219
+ /** The Standard Schema types interface. */
220
+ interface Types<Input = unknown, Output = Input> {
221
+ /** The input type of the schema. */
222
+ readonly input: Input;
223
+ /** The output type of the schema. */
224
+ readonly output: Output;
225
+ }
226
+ /** Infers the input type of a Standard Schema. */
227
+ type InferInput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['input'];
228
+ /** Infers the output type of a Standard Schema. */
229
+ type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['output'];
230
+ }
231
+
164
232
  /**
165
233
  * Cross-package step-name stamp. The `@Step` decorator (`@dudousxd/nestjs-durable`) writes a
166
234
  * method's derived routing name under this key; core's `ctx.step` reads it (via {@link stepNameOf})
@@ -337,8 +405,10 @@ interface WorkflowRun {
337
405
  * store-backed gateway from the run's signal waiters (`listSignalWaiters`, ONE bulk scan), so the
338
406
  * dashboard can NAME the wait in a list row without fetching each run's timeline. `on` is derived from
339
407
  * the waiter token's prefix (see `classifyWaiterToken`): `wh:<runId>:<seq>` ⇒ `webhook`, `child:<id>`
340
- * ⇒ `child`, `event:<name>:…` ⇒ `signal` (name decoded), anything else ⇒ `signal` (the token IS the
341
- * signal name).
408
+ * ⇒ `child`, `bp:<runId>:<seq>` ⇒ `breakpoint` (a `ctx.breakpoint` DOES register a signal waiter —
409
+ * same machinery, `engine.continue` resumes it — so it's classified here, not left to fall through to
410
+ * the generic `signal` case with the raw token as its ugly display name), `event:<name>:…` ⇒ `signal`
411
+ * (name decoded), anything else ⇒ `signal` (the token IS the signal name).
342
412
  *
343
413
  * Deliberately EVENT-only (no timer/step): `wakeAt` alone can't tell a real `ctx.sleep` from the
344
414
  * reconcile-fallback `wakeAt` an event/step suspend now carries, and the store exposes no cheap bulk
@@ -347,8 +417,13 @@ interface WorkflowRun {
347
417
  * The detail view (which has the timeline) still distinguishes sleep vs in-flight step precisely.
348
418
  */
349
419
  interface RunWaiting {
350
- on: 'signal' | 'webhook' | 'child';
351
- /** The token/name the run is parked on — the signal name, webhook token, or awaited child id. */
420
+ on: 'signal' | 'webhook' | 'child' | 'breakpoint';
421
+ /**
422
+ * The token/name the run is parked on — the signal name, webhook token, or awaited child id. For a
423
+ * breakpoint this is always the literal `'breakpoint'`: the real label (if any) lives on the pending
424
+ * `breakpoint`/`breakpoint:<label>` checkpoint, which the detail view's timeline already shows —
425
+ * naming it here would need an extra per-run checkpoint read, defeating the bulk waiter scan.
426
+ */
352
427
  name: string;
353
428
  }
354
429
  type StepKind = 'local' | 'remote' | 'sleep' | 'signal';
@@ -618,6 +693,38 @@ interface RetentionPolicy {
618
693
  }
619
694
  /** Typed, queryable per-run data — exact values for `eq`/`ne`, numbers/strings for range ops. */
620
695
  type SearchAttributes = Record<string, string | number | boolean>;
696
+ /**
697
+ * A Standard Schema (https://standardschema.dev — implemented by zod 3.24+, valibot, arktype, …)
698
+ * whose validated OUTPUT is search-attribute-shaped: flat `string`/`number`/`boolean` values only.
699
+ * Declared via `@Workflow({ searchAttributes })`, this makes `ctx.upsertSearchAttributes` reject any
700
+ * write whose merged result doesn't conform. The `Output extends SearchAttributes` bound is enforced
701
+ * structurally at the declaration site — a schema whose inferred output has a nested object/array
702
+ * value (e.g. `z.object({ meta: z.object({ ... }) })`) fails to satisfy this type, so it's a
703
+ * compile-time error to declare it as a `searchAttributes` schema, not a runtime surprise.
704
+ *
705
+ * @example
706
+ * ```ts
707
+ * import { z } from 'zod';
708
+ *
709
+ * const orderAttrs = z.object({
710
+ * tier: z.enum(['free', 'pro']),
711
+ * amount: z.number(),
712
+ * rushOrder: z.boolean().optional(),
713
+ * });
714
+ *
715
+ * @Workflow({ name: 'checkout', searchAttributes: orderAttrs })
716
+ * class CheckoutWorkflow {
717
+ * async run(ctx: WorkflowCtx<InferSearchAttributes<typeof orderAttrs>>, input: CheckoutInput) {
718
+ * await ctx.upsertSearchAttributes({ tier: input.tier, amount: input.total });
719
+ * }
720
+ * }
721
+ * ```
722
+ */
723
+ type SearchAttributesSchema<A extends SearchAttributes = SearchAttributes> = StandardSchemaV1<unknown, A>;
724
+ /** Infer the validated OUTPUT shape of a {@link SearchAttributesSchema} — the typed attributes a
725
+ * workflow declares via `@Workflow({ searchAttributes })`. Feed it straight into `WorkflowCtx<A>`:
726
+ * `WorkflowCtx<InferSearchAttributes<typeof mySchema>>`. */
727
+ type InferSearchAttributes<S extends StandardSchemaV1<unknown, SearchAttributes>> = StandardSchemaV1.InferOutput<S>;
621
728
  type AttributeOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
622
729
  /** One predicate over a run's {@link SearchAttributes}; a {@link RunQuery} ANDs them all. */
623
730
  interface AttributeFilter {
@@ -730,6 +837,12 @@ type RunRequestKind = {
730
837
  kind: 'retryWithInput';
731
838
  runId: string;
732
839
  input: unknown;
840
+ } | {
841
+ kind: 'redispatch';
842
+ runId: string;
843
+ } | {
844
+ kind: 'waitingFor';
845
+ runIds: string[];
733
846
  };
734
847
  /** The control plane's answer to a {@link RunRequest}, correlated by `requestId`. */
735
848
  interface RunReply {
@@ -1320,8 +1433,13 @@ type UndoOf<H> = H extends (input: infer I, ...rest: never[]) => infer R ? StepU
1320
1433
  /**
1321
1434
  * The context handed to a workflow function. Every interaction with the outside world goes
1322
1435
  * through it so the engine can checkpoint — the workflow body itself stays deterministic.
1436
+ *
1437
+ * Generic over `A`, the run's {@link SearchAttributes} shape — narrows {@link upsertSearchAttributes}
1438
+ * when a workflow declares a `@Workflow({ searchAttributes })` schema (pass
1439
+ * `WorkflowCtx<InferSearchAttributes<typeof mySchema>>` as the `run` method's ctx type). Defaults to
1440
+ * the untyped `SearchAttributes`, so every existing `WorkflowCtx` usage is unaffected.
1323
1441
  */
1324
- interface WorkflowCtx {
1442
+ interface WorkflowCtx<A extends SearchAttributes = SearchAttributes> {
1325
1443
  readonly runId: string;
1326
1444
  /**
1327
1445
  * Run a durable step — always dispatched, always engine-scheduled: the ONE step primitive (no
@@ -1528,8 +1646,16 @@ interface WorkflowCtx {
1528
1646
  * write, not one per turn. Use this instead of injecting the {@link StateStore} to mutate the run
1529
1647
  * you're executing (`@Inject(state-store)` + `store.updateRun(ctx.runId, …)` becomes
1530
1648
  * `ctx.upsertSearchAttributes(…)`).
1649
+ *
1650
+ * When the workflow declares a `@Workflow({ searchAttributes })` schema, the MERGED result (this
1651
+ * run's existing `searchAttributes` shallow-merged with `attrs`) is validated against it before the
1652
+ * write — an invalid merge throws, naming the workflow, the offending key(s), and the schema's
1653
+ * issues. Validation runs once, on the same first-run-only position as the write itself (skipped
1654
+ * entirely on replay, so it's never a source of nondeterminism); it must be synchronous — a schema
1655
+ * whose `validate` returns a `Promise` throws instead of being awaited. No schema declared ⇒
1656
+ * unchanged, unvalidated behavior.
1531
1657
  */
1532
- upsertSearchAttributes(attrs: SearchAttributes): Promise<void>;
1658
+ upsertSearchAttributes(attrs: Partial<A>): Promise<void>;
1533
1659
  /**
1534
1660
  * Run a checkpointed step **in-process** — the escape hatch from the always-dispatched {@link step}
1535
1661
  * for work that must run inline in the workflow body. Like `step`, its result is a durable checkpoint
@@ -1818,6 +1944,27 @@ interface WorkflowEngineDeps {
1818
1944
  * for a genuinely-orphaned run. Default 5min; set `0` to disable (the prior wake-forever behavior).
1819
1945
  */
1820
1946
  reconcileMs?: number | undefined;
1947
+ /**
1948
+ * Opt-in self-heal window (ms) for a remote step with NO `timeoutMs` whose dispatched job was LOST —
1949
+ * the worker crashed mid-step leaving no result, or the transport dropped the job (a Redis
1950
+ * flush/eviction, or BullMQ moving a stalled job to `failed` and removing it). The `pending`
1951
+ * checkpoint then has no result to resume on, and — BY DESIGN (see {@link reconcileMs}) — the
1952
+ * reconcile re-drive re-suspends a still-pending step rather than re-dispatching, so a slow-but-live
1953
+ * worker is never double-run. That safety means a genuinely-lost dispatch would otherwise hang
1954
+ * forever. When set, a reconcile re-drive that finds a remote step still `pending` PAST this window
1955
+ * re-dispatches it (bumping `attempts`, bounded by {@link remoteRedispatchMax}). Off by default:
1956
+ * re-dispatch can double-run a step whose original job is merely slow, so the window MUST exceed the
1957
+ * longest such step and the step MUST be idempotent. Prefer a per-step `timeoutMs` (tighter,
1958
+ * heartbeat-aware) where you can; this is the store-driven net for no-timeout steps that must
1959
+ * survive a lost dispatch. {@link WorkflowEngine.redispatchPending} is the manual counterpart.
1960
+ */
1961
+ remoteRedispatchMs?: number | undefined;
1962
+ /**
1963
+ * Max times {@link remoteRedispatchMs} re-dispatches one lost remote step before giving up and
1964
+ * failing it as a {@link RemoteStepError} (`code: 'remote_step_lost'`), so the run fails / dead-letters
1965
+ * instead of re-dispatching forever. Default 10. Ignored when `remoteRedispatchMs` is unset.
1966
+ */
1967
+ remoteRedispatchMax?: number | undefined;
1821
1968
  /**
1822
1969
  * Build the public callback URL for a `ctx.webhook()` token (e.g.
1823
1970
  * ``(t) => `https://api.example.com/durable/webhooks/${t}` ``). Populates
@@ -1894,6 +2041,8 @@ declare class WorkflowEngine {
1894
2041
  private readonly maxRecoveryAttempts?;
1895
2042
  private readonly remoteAdvanceSilenceMs?;
1896
2043
  private readonly reconcileMs;
2044
+ private readonly remoteRedispatchMs;
2045
+ private readonly remoteRedispatchMax;
1897
2046
  private readonly webhookUrl?;
1898
2047
  private readonly traceparent?;
1899
2048
  private readonly context?;
@@ -1983,6 +2132,8 @@ declare class WorkflowEngine {
1983
2132
  singleton?: SingletonConfig | undefined;
1984
2133
  executionTimeout?: string | number | undefined;
1985
2134
  validateInput?: ((input: unknown) => void | Promise<void>) | undefined;
2135
+ /** Typed/validated `searchAttributes` shape — see `RegisteredWorkflow.searchAttributesSchema`. */
2136
+ searchAttributesSchema?: SearchAttributesSchema | undefined;
1986
2137
  onEvent?: string[] | undefined;
1987
2138
  eventBatch?: EventBatchConfig | undefined;
1988
2139
  /** Worker group to DISPATCH this workflow's turns to (uniform dispatch). Requires `executor`.
@@ -2186,6 +2337,19 @@ declare class WorkflowEngine {
2186
2337
  * the HTTP request on workflow execution.
2187
2338
  */
2188
2339
  requeue(runId: string): Promise<RunResult | null>;
2340
+ /**
2341
+ * Explicitly re-dispatch every remote step of `runId` stuck `pending` — the OPERATOR escape hatch for
2342
+ * a run whose dispatched step job was LOST (worker crashed with no result, or a transport that dropped
2343
+ * the job). No automatic recovery re-drives these: a reconcile re-drive re-suspends a still-pending
2344
+ * step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` only reclaims LEASED runs,
2345
+ * and `requeue` just replays back to the pending guard. This re-enqueues the same `stepId` so the
2346
+ * (idempotent) step re-runs and its result resumes the run. Safe to call on a healthy run — it re-runs
2347
+ * only in-flight `pending` remote steps, which are idempotent by the durable contract. Returns the
2348
+ * run's current status (with the count re-dispatched), or null if the run is unknown.
2349
+ */
2350
+ redispatchPending(runId: string): Promise<(RunResult & {
2351
+ redispatched: number;
2352
+ }) | null>;
2189
2353
  /**
2190
2354
  * **Fix-and-replay**: re-run a run (typically a `dead`/`failed` one) with a corrected `input`, as a
2191
2355
  * fresh run with clean history. It's a NEW run — `newRunId` defaults to `<runId>~retry~<uuid>` — so
@@ -2488,6 +2652,10 @@ declare class WorkflowEngine {
2488
2652
  */
2489
2653
  update(runId: string, name: string, arg: unknown): Promise<UpdateResult>;
2490
2654
  private callRemote;
2655
+ /** Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
2656
+ * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
2657
+ * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable. */
2658
+ private dispatchRemoteTask;
2491
2659
  /**
2492
2660
  * Complete a durable remote step from its worker result and resume the run — runs on whichever
2493
2661
  * instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
@@ -2789,7 +2957,9 @@ interface DurableTopology {
2789
2957
  /** The tenant's isolation partition name; set only when `role` is 'tenant'. */
2790
2958
  tenant?: string;
2791
2959
  }
2792
- /** A run + its timeline + child ids — the detail view. Mirrors the dashboard's `RunDetail`. */
2960
+ /** A run + its timeline + child ids — the detail view. Single source of truth: the dashboard server
2961
+ * re-exports this instead of re-declaring it, and the dashboard client derives its wire mirror from
2962
+ * it via {@link WireDates} instead of hand-mirroring field by field. */
2793
2963
  interface RunDetail {
2794
2964
  run: WorkflowRun;
2795
2965
  /** Steps in execution order (local + remote). */
@@ -2797,6 +2967,17 @@ interface RunDetail {
2797
2967
  /** Ids of runs this run spawned (parent→children tree). */
2798
2968
  children: string[];
2799
2969
  }
2970
+ /**
2971
+ * Maps every `Date` (and `Date | undefined`) field of `T` to its ISO-string wire form — the shape a
2972
+ * JSON response actually carries once dates cross the wire (`JSON.stringify` already does this
2973
+ * coercion; this just types it). Homomorphic: each field keeps its own optional modifier, so a
2974
+ * required `Date` becomes a required `string` and an optional one stays optional. Lets a consumer
2975
+ * derive its own wire-facing client type from a core server type (e.g. the dashboard's SPA `WorkflowRun`/
2976
+ * `StepCheckpoint`/`RunDetail`) instead of hand-mirroring it field by field and drifting.
2977
+ */
2978
+ type WireDates<T> = {
2979
+ [K in keyof T]: T[K] extends Date ? string : T[K] extends Date | undefined ? string | undefined : T[K];
2980
+ };
2800
2981
  /**
2801
2982
  * The bounded read/control/stream surface a consumer (e.g. a controller) needs, satisfied by BOTH
2802
2983
  * topologies: the control plane binds a store-backed impl (reuses `DashboardService`); a tenant binds
@@ -2812,6 +2993,17 @@ interface RunGateway {
2812
2993
  * parked on). The store-backed gateway resolves `waiting`; a proxy relays whatever the control
2813
2994
  * plane sent. */
2814
2995
  listRuns(query: RunQuery): Promise<RunListItem[]>;
2996
+ /**
2997
+ * Bulk-resolve {@link RunWaiting} for an arbitrary set of run ids — for a consumer with its OWN
2998
+ * filtered/paginated run listing (so it isn't just `listRuns` under another name) that needs to know
2999
+ * which of ITS runs are parked on a breakpoint (or any other event wait) without re-deriving the bulk
3000
+ * waiter scan itself or reaching for raw SQL against `durable_step_checkpoints`. A `Record` (not a
3001
+ * `Map`) because `ProxyRunGateway` serialises the reply over the transport as plain JSON. Entries are
3002
+ * present ONLY for runs currently waiting — an unknown id, a non-suspended id, and a suspended id
3003
+ * parked on something non-event (a bare `ctx.sleep`, an in-flight step) are simply absent, same as a
3004
+ * `listRuns` row with no `waiting`.
3005
+ */
3006
+ waitingFor(runIds: string[]): Promise<Record<string, RunWaiting>>;
2815
3007
  /** Per-group worker health (queue backlog + live worker heartbeats). On the control plane this is
2816
3008
  * every group; over a tenant proxy the operator scopes it to the tenant's own groups. */
2817
3009
  workerHealth(): Promise<GroupHealth[]>;
@@ -2820,6 +3012,12 @@ interface RunGateway {
2820
3012
  }): Promise<RunResult | null>;
2821
3013
  retry(runId: string): Promise<RunResult | null>;
2822
3014
  continue(runId: string): Promise<RunResult | null>;
3015
+ /** Re-dispatch every remote step of a run stuck `pending` — the operator recovery for a LOST step
3016
+ * dispatch (crashed worker / dropped job) that no automatic path re-drives. Returns the run's status
3017
+ * plus how many steps were re-dispatched, or null if the run is unknown. */
3018
+ redispatchPending(runId: string): Promise<(RunResult & {
3019
+ redispatched: number;
3020
+ }) | null>;
2823
3021
  retryWithInput(runId: string, input: unknown): Promise<{
2824
3022
  runId: string;
2825
3023
  } | null>;
@@ -2832,6 +3030,11 @@ interface RunGateway {
2832
3030
  * The token prefix carries the kind — no extra store lookup and no new persisted field. Used by the
2833
3031
  * store-backed run gateway to stamp {@link RunWaiting} onto each listed run so the dashboard can name
2834
3032
  * the wait in a row without fetching that run's timeline.
3033
+ *
3034
+ * `ctx.breakpoint` reuses the signal-waiter machinery (`engine.continue` resumes it via `signal()`
3035
+ * like any other), so without this case it would fall through to the generic `signal` branch with the
3036
+ * raw `bp:<runId>:<seq>` token as its display name — a real bug. Named `'breakpoint'` here instead;
3037
+ * the token carries no separate label to recover cheaply (see {@link RunWaiting.name}).
2835
3038
  */
2836
3039
  declare function classifyWaiterToken(token: string): RunWaiting;
2837
3040
  /**
@@ -3079,6 +3282,35 @@ declare function attributeColumnFor(filter: AttributeFilter): 'numValue' | 'strV
3079
3282
  /** The literal a side-table predicate compares against (booleans → `"true"`/`"false"` strings). */
3080
3283
  declare function attributeOperand(filter: AttributeFilter): string | number;
3081
3284
 
3285
+ /**
3286
+ * Read a run's `searchAttributes` through a {@link SearchAttributesSchema}, typed to the schema's
3287
+ * inferred output. **Safe-parse semantics, deliberately lenient** — unlike
3288
+ * `ctx.upsertSearchAttributes` (which is strict: an invalid merge throws), a failed validation here
3289
+ * returns `{}` rather than throwing. This is intentional: `run.searchAttributes` may predate the
3290
+ * schema (an older deploy wrote it under a looser or absent schema, or a run started before this
3291
+ * workflow ever declared one), and a dashboard/read path crashing on old history is worse than it
3292
+ * quietly reporting no typed attributes for that run. Write-time enforcement is what keeps NEW writes
3293
+ * conformant; this is the read-side counterpart for consuming them.
3294
+ *
3295
+ * The schema's `validate` must be synchronous (matching the write-side constraint in
3296
+ * `ctx.upsertSearchAttributes`) — one returning a `Promise` throws with a clear error rather than
3297
+ * being silently awaited or silently treated as a failure.
3298
+ *
3299
+ * @example
3300
+ * ```ts
3301
+ * import { z } from 'zod';
3302
+ * import { readSearchAttributes } from '@dudousxd/nestjs-durable-core';
3303
+ *
3304
+ * const orderAttrs = z.object({ tier: z.enum(['free', 'pro']), amount: z.number() });
3305
+ *
3306
+ * const run = await store.getRun(runId);
3307
+ * const attrs = readSearchAttributes(orderAttrs, run ?? {}); // { tier: 'pro', amount: 42 } | {}
3308
+ * ```
3309
+ */
3310
+ declare function readSearchAttributes<S extends SearchAttributesSchema>(schema: S, run: {
3311
+ searchAttributes?: SearchAttributes | undefined;
3312
+ }): InferSearchAttributes<S>;
3313
+
3082
3314
  /**
3083
3315
  * Compute the worker group a tenant's run dispatches to. Mirrors the transport's
3084
3316
  * default-is-bare rule (see {@link TransportPool.useNamespace}'s `'default'` no-op): a
@@ -3226,4 +3458,4 @@ declare class InMemoryTransport implements Transport, ControlPlane {
3226
3458
  onControl(handler: (msg: ControlMessage) => void): void;
3227
3459
  }
3228
3460
 
3229
- export { type AccumulatorHost, type Admission, type AdmissionBackend, type AdmissionItem, type AttributeFilter, type AttributeOp, type BackoffStrategy, type ChildCallOptions, CodecStateStore, ContinueAsNew, type ControlMessage, type ControlPlane, DURABLE_OPTIONS, DURABLE_OPTIONS_CANONICAL, DURABLE_STEP_CONFIG, DURABLE_STEP_NAME, type DurableTopology, type DurableWebhook, DurableWorkflow, type EngineEvent, type EngineEventType, type EngineListener, Entities, type EntityConfig, type EntityHandler, type EntityHost, EventAccumulators, type EventBatchConfig, FatalError, GatherError, type GroupHealth, type Heartbeat, type HistoryEvent, InMemoryAdmissionBackend, InMemoryStateStore, InMemoryTransport, type MetricsCollector, type MetricsSnapshot, type NamedTransport, NonDeterminismError, type PayloadCodec, type QueueConfig, QueueController, RemoteStepError, RemoteStepTimeout, type RemoteTask, RemoteWorkflowExecutor, RemoteWorkflowTimeout, type RetentionPolicy, type RunAttributeRow, type RunDetail, type RunDispatcher, type RunGateway, type RunListItem, type RunQuery, type RunReply, type RunReplyResult, type RunRequest, type RunRequestKind, type RunResult, type RunSchedulesOptions, type RunStatus, type RunWaiting, STATE_STORE, STATE_STORE_CANONICAL, type ScheduledWorkflow, type SearchAttributes, SignalTimeoutError, type SignalWaiter, type SingletonConfig, SingletonQueueFullError, type StartOptions, type StartRunMessage, type StateStore, type StepCheckpoint, type StepConfig, type StepDef, type StepDispatchOpts, type StepError, type StepEvent, type StepHandler, type StepInterceptor, type StepInvocation, type StepKind, type StepLogger, type StepOptions, type StepRef, type StepResult, type StepUndo, type StoreTransaction, type SubProcessHandle, TERMINAL_RUN_STATUSES, TRANSPORT, TRANSPORT_CANONICAL, type TenantEvent, type Transport, type UndoOf, type UpdateResult, type UpdateValidator, WORKFLOW_NAME_KEY, type WorkerAdjust, type WorkerConcurrencyStatus, type WorkerHeartbeat, type WorkerStatus, type WorkflowClass, type WorkflowCommand, type WorkflowCtx, type WorkflowDecision, WorkflowEngine, type WorkflowEngineDeps, type WorkflowExecutor, type WorkflowInputOf, type WorkflowOutputOf, type WorkflowRef, type WorkflowRun, type WorkflowStartClient, type WorkflowStepEvent, WorkflowSuspended, type WorkflowTask, applyAttributeQuery, attributeColumnFor, attributeOperand, bindWorkflowClass, breakpointToken, classifyWaiterToken, collectMetrics, createStepLogger, currentWorkflowCtx, eventMatchOf, eventMatches, eventNameOf, eventPrefix, eventToken, indexWaitersByRun, matchesAttributes, normalizeAttributeRows, parseDuration, prevCronFireMs, resolveRunWaiting, runInWorkflowCtx, runSchedules, runStepHandler, sanitizeQueueToken, scheduledRunId, sqlComparator, stepConfigOf, stepId, stepNameOf, tenantGroup, workflowName };
3461
+ export { type AccumulatorHost, type Admission, type AdmissionBackend, type AdmissionItem, type AttributeFilter, type AttributeOp, type BackoffStrategy, type ChildCallOptions, CodecStateStore, ContinueAsNew, type ControlMessage, type ControlPlane, DURABLE_OPTIONS, DURABLE_OPTIONS_CANONICAL, DURABLE_STEP_CONFIG, DURABLE_STEP_NAME, type DurableTopology, type DurableWebhook, DurableWorkflow, type EngineEvent, type EngineEventType, type EngineListener, Entities, type EntityConfig, type EntityHandler, type EntityHost, EventAccumulators, type EventBatchConfig, FatalError, GatherError, type GroupHealth, type Heartbeat, type HistoryEvent, InMemoryAdmissionBackend, InMemoryStateStore, InMemoryTransport, type InferSearchAttributes, type MetricsCollector, type MetricsSnapshot, type NamedTransport, NonDeterminismError, type PayloadCodec, type QueueConfig, QueueController, RemoteStepError, RemoteStepTimeout, type RemoteTask, RemoteWorkflowExecutor, RemoteWorkflowTimeout, type RetentionPolicy, type RunAttributeRow, type RunDetail, type RunDispatcher, type RunGateway, type RunListItem, type RunQuery, type RunReply, type RunReplyResult, type RunRequest, type RunRequestKind, type RunResult, type RunSchedulesOptions, type RunStatus, type RunWaiting, STATE_STORE, STATE_STORE_CANONICAL, type ScheduledWorkflow, type SearchAttributes, type SearchAttributesSchema, SignalTimeoutError, type SignalWaiter, type SingletonConfig, SingletonQueueFullError, StandardSchemaV1, type StartOptions, type StartRunMessage, type StateStore, type StepCheckpoint, type StepConfig, type StepDef, type StepDispatchOpts, type StepError, type StepEvent, type StepHandler, type StepInterceptor, type StepInvocation, type StepKind, type StepLogger, type StepOptions, type StepRef, type StepResult, type StepUndo, type StoreTransaction, type SubProcessHandle, TERMINAL_RUN_STATUSES, TRANSPORT, TRANSPORT_CANONICAL, type TenantEvent, type Transport, type UndoOf, type UpdateResult, type UpdateValidator, WORKFLOW_NAME_KEY, type WireDates, type WorkerAdjust, type WorkerConcurrencyStatus, type WorkerHeartbeat, type WorkerStatus, type WorkflowClass, type WorkflowCommand, type WorkflowCtx, type WorkflowDecision, WorkflowEngine, type WorkflowEngineDeps, type WorkflowExecutor, type WorkflowInputOf, type WorkflowOutputOf, type WorkflowRef, type WorkflowRun, type WorkflowStartClient, type WorkflowStepEvent, WorkflowSuspended, type WorkflowTask, applyAttributeQuery, attributeColumnFor, attributeOperand, bindWorkflowClass, breakpointToken, classifyWaiterToken, collectMetrics, createStepLogger, currentWorkflowCtx, eventMatchOf, eventMatches, eventNameOf, eventPrefix, eventToken, indexWaitersByRun, matchesAttributes, normalizeAttributeRows, parseDuration, prevCronFireMs, readSearchAttributes, resolveRunWaiting, runInWorkflowCtx, runSchedules, runStepHandler, sanitizeQueueToken, scheduledRunId, sqlComparator, stepConfigOf, stepId, stepNameOf, tenantGroup, workflowName };
package/dist/index.js CHANGED
@@ -1100,6 +1100,11 @@ function normalizeChildOptions(options) {
1100
1100
  } : options ?? {};
1101
1101
  }
1102
1102
  __name(normalizeChildOptions, "normalizeChildOptions");
1103
+ function formatIssuePath(path) {
1104
+ if (!path || path.length === 0) return "(root)";
1105
+ return path.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
1106
+ }
1107
+ __name(formatIssuePath, "formatIssuePath");
1103
1108
  function resolveStepPolicy(config, opts) {
1104
1109
  const policy = {};
1105
1110
  const retries = opts?.retries ?? config?.retries;
@@ -1130,7 +1135,7 @@ var Position = class Position2 {
1130
1135
  this.seq -= 1;
1131
1136
  }
1132
1137
  };
1133
- function createWorkflowCtx(host, runId, compensations, workflow = "") {
1138
+ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttributesSchema) {
1134
1139
  const { store, replay } = host;
1135
1140
  const pos = new Position();
1136
1141
  const readCheckpoint = /* @__PURE__ */ __name((seq) => {
@@ -1599,6 +1604,24 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1599
1604
  }
1600
1605
  return;
1601
1606
  }
1607
+ if (searchAttributesSchema) {
1608
+ const run = await store.getRun(runId);
1609
+ const merged = {
1610
+ ...run?.searchAttributes ?? {},
1611
+ ...attrs
1612
+ };
1613
+ const result = searchAttributesSchema["~standard"].validate(merged);
1614
+ if (result instanceof Promise) {
1615
+ 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 }).`);
1616
+ }
1617
+ if (result.issues) {
1618
+ const offendingKeys = [
1619
+ ...new Set(result.issues.map((issue) => formatIssuePath(issue.path)))
1620
+ ];
1621
+ const details = result.issues.map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`).join("; ");
1622
+ throw new Error(`workflow "${workflow}": ctx.upsertSearchAttributes rejected by its searchAttributes schema \u2014 offending key(s) ${offendingKeys.join(", ")} \u2014 ${details}`);
1623
+ }
1624
+ }
1602
1625
  await host.upsertSearchAttributes(runId, attrs);
1603
1626
  await writeCheckpoint(instantCheckpoint({
1604
1627
  runId,
@@ -1729,6 +1752,8 @@ var WorkflowEngine = class {
1729
1752
  maxRecoveryAttempts;
1730
1753
  remoteAdvanceSilenceMs;
1731
1754
  reconcileMs;
1755
+ remoteRedispatchMs;
1756
+ remoteRedispatchMax;
1732
1757
  webhookUrl;
1733
1758
  traceparent;
1734
1759
  context;
@@ -1800,6 +1825,8 @@ var WorkflowEngine = class {
1800
1825
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
1801
1826
  this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
1802
1827
  this.reconcileMs = deps.reconcileMs === void 0 ? 3e5 : deps.reconcileMs || void 0;
1828
+ this.remoteRedispatchMs = deps.remoteRedispatchMs || void 0;
1829
+ this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
1803
1830
  this.webhookUrl = deps.webhookUrl;
1804
1831
  this.traceparent = deps.traceparent;
1805
1832
  this.context = deps.context;
@@ -1950,6 +1977,7 @@ var WorkflowEngine = class {
1950
1977
  singleton: opts?.singleton,
1951
1978
  executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
1952
1979
  validateInput: opts?.validateInput,
1980
+ searchAttributesSchema: opts?.searchAttributesSchema,
1953
1981
  onEvent: opts?.onEvent,
1954
1982
  eventBatch: opts?.eventBatch,
1955
1983
  // A real, retained in-process body — so `workflowBody` can hand it to an in-app worker, and so
@@ -2511,6 +2539,49 @@ var WorkflowEngine = class {
2511
2539
  };
2512
2540
  }
2513
2541
  /**
2542
+ * Explicitly re-dispatch every remote step of `runId` stuck `pending` — the OPERATOR escape hatch for
2543
+ * a run whose dispatched step job was LOST (worker crashed with no result, or a transport that dropped
2544
+ * the job). No automatic recovery re-drives these: a reconcile re-drive re-suspends a still-pending
2545
+ * step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` only reclaims LEASED runs,
2546
+ * and `requeue` just replays back to the pending guard. This re-enqueues the same `stepId` so the
2547
+ * (idempotent) step re-runs and its result resumes the run. Safe to call on a healthy run — it re-runs
2548
+ * only in-flight `pending` remote steps, which are idempotent by the durable contract. Returns the
2549
+ * run's current status (with the count re-dispatched), or null if the run is unknown.
2550
+ */
2551
+ async redispatchPending(runId) {
2552
+ const run = await this.store.getRun(runId);
2553
+ if (!run) return null;
2554
+ const pending = (await this.store.listCheckpoints(runId)).filter((cp) => cp.kind === "remote" && cp.status === "pending");
2555
+ let redispatched = 0;
2556
+ for (const cp of pending) {
2557
+ if (!cp.stepId) continue;
2558
+ const reAttempt = cp.attempts + 1;
2559
+ const reEnqueuedAt = /* @__PURE__ */ new Date();
2560
+ await this.store.saveCheckpoint({
2561
+ ...cp,
2562
+ attempts: reAttempt,
2563
+ enqueuedAt: reEnqueuedAt,
2564
+ startedAt: reEnqueuedAt,
2565
+ finishedAt: reEnqueuedAt
2566
+ });
2567
+ await this.dispatchRemoteTask({
2568
+ runId,
2569
+ seq: cp.seq,
2570
+ name: cp.name,
2571
+ stepId: cp.stepId,
2572
+ group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
2573
+ input: cp.input,
2574
+ attempt: reAttempt
2575
+ });
2576
+ redispatched += 1;
2577
+ }
2578
+ return {
2579
+ runId,
2580
+ status: run.status,
2581
+ redispatched
2582
+ };
2583
+ }
2584
+ /**
2514
2585
  * **Fix-and-replay**: re-run a run (typically a `dead`/`failed` one) with a corrected `input`, as a
2515
2586
  * fresh run with clean history. It's a NEW run — `newRunId` defaults to `<runId>~retry~<uuid>` — so
2516
2587
  * the original stays inspectable. Returns the new run's id, or null if `runId` is unknown.
@@ -3606,7 +3677,7 @@ var WorkflowEngine = class {
3606
3677
  const snapshot = await this.store.listCheckpoints(run.id);
3607
3678
  const replay = /* @__PURE__ */ new Map();
3608
3679
  for (const cp of snapshot) replay.set(cp.seq, cp);
3609
- const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow);
3680
+ const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow, registered?.searchAttributesSchema);
3610
3681
  try {
3611
3682
  const output = await runInWorkflowCtx(ctx, () => fn(ctx, run.input));
3612
3683
  if (this.cancelRequested.has(run.id)) {
@@ -3998,7 +4069,47 @@ var WorkflowEngine = class {
3998
4069
  if (existing?.status === "completed") return existing.output;
3999
4070
  if (this.pool.size === 0) throw new Error("remote steps require a Transport");
4000
4071
  if (step.timeoutMs) return this.callRemoteInMemory(runId, seq, step, input, transport);
4001
- if (existing?.status === "pending") throw new WorkflowSuspended();
4072
+ if (existing?.status === "pending") {
4073
+ if (this.remoteRedispatchMs == null) throw new WorkflowSuspended();
4074
+ if (existing.wakeAt == null) {
4075
+ const wakeAt = this.clock() + this.remoteRedispatchMs;
4076
+ await this.store.saveCheckpoint({
4077
+ ...existing,
4078
+ wakeAt
4079
+ });
4080
+ throw new WorkflowSuspended(wakeAt);
4081
+ }
4082
+ if (this.clock() < existing.wakeAt) throw new WorkflowSuspended(existing.wakeAt);
4083
+ if (existing.attempts >= this.remoteRedispatchMax) {
4084
+ throw new RemoteStepError({
4085
+ message: `remote step "${step.name}" lost \u2014 no result after ${existing.attempts} re-dispatch(es)`,
4086
+ code: "remote_step_lost"
4087
+ });
4088
+ }
4089
+ const reAttempt = existing.attempts + 1;
4090
+ const nextDeadline = this.clock() + this.remoteRedispatchMs;
4091
+ const reEnqueuedAt = /* @__PURE__ */ new Date();
4092
+ await this.store.saveCheckpoint({
4093
+ ...existing,
4094
+ attempts: reAttempt,
4095
+ wakeAt: nextDeadline,
4096
+ enqueuedAt: reEnqueuedAt,
4097
+ startedAt: reEnqueuedAt,
4098
+ finishedAt: reEnqueuedAt
4099
+ });
4100
+ await this.dispatchRemoteTask({
4101
+ runId,
4102
+ seq,
4103
+ name: step.name,
4104
+ stepId: existing.stepId ?? stepId(runId, seq),
4105
+ group: existing.workerGroup ?? tenantGroup(sanitizeQueueToken(step.name), step.partition),
4106
+ input: existing.input,
4107
+ priority: admission?.priority,
4108
+ attempt: reAttempt,
4109
+ transport
4110
+ });
4111
+ throw new WorkflowSuspended(nextDeadline);
4112
+ }
4002
4113
  let attempt = 1;
4003
4114
  if (existing?.status === "failed") {
4004
4115
  const maxAttempts = Math.max(1, step.retries ?? 1);
@@ -4046,18 +4157,17 @@ var WorkflowEngine = class {
4046
4157
  startedAt: enqueuedAt,
4047
4158
  finishedAt: enqueuedAt
4048
4159
  });
4049
- await this.pool.dispatch({
4160
+ await this.dispatchRemoteTask({
4050
4161
  runId,
4051
4162
  seq,
4052
4163
  name: step.name,
4053
4164
  stepId: id,
4054
4165
  group: tenantGroup(sanitizeQueueToken(step.name), step.partition),
4055
4166
  input,
4056
- traceparent: this.traceparent?.(),
4057
- context: this.context?.(),
4058
4167
  priority: admission?.priority,
4059
- attempt
4060
- }, transport);
4168
+ attempt,
4169
+ transport
4170
+ });
4061
4171
  this.emit({
4062
4172
  type: "step.started",
4063
4173
  runId,
@@ -4067,6 +4177,23 @@ var WorkflowEngine = class {
4067
4177
  });
4068
4178
  throw new WorkflowSuspended();
4069
4179
  }
4180
+ /** Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
4181
+ * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
4182
+ * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable. */
4183
+ dispatchRemoteTask(task) {
4184
+ return this.pool.dispatch({
4185
+ runId: task.runId,
4186
+ seq: task.seq,
4187
+ name: task.name,
4188
+ stepId: task.stepId,
4189
+ group: task.group,
4190
+ input: task.input,
4191
+ traceparent: this.traceparent?.(),
4192
+ context: this.context?.(),
4193
+ priority: task.priority,
4194
+ attempt: task.attempt
4195
+ }, task.transport);
4196
+ }
4070
4197
  /**
4071
4198
  * Complete a durable remote step from its worker result and resume the run — runs on whichever
4072
4199
  * instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.
@@ -4306,12 +4433,17 @@ var TERMINAL_RUN_STATUSES = [
4306
4433
  // src/run-waiting.ts
4307
4434
  var WEBHOOK_PREFIX = "wh:";
4308
4435
  var CHILD_PREFIX = "child:";
4436
+ var BREAKPOINT_PREFIX = "bp:";
4309
4437
  var EVENT_PREFIX = "event:";
4310
4438
  function classifyWaiterToken(token) {
4311
4439
  if (token.startsWith(WEBHOOK_PREFIX)) return {
4312
4440
  on: "webhook",
4313
4441
  name: token
4314
4442
  };
4443
+ if (token.startsWith(BREAKPOINT_PREFIX)) return {
4444
+ on: "breakpoint",
4445
+ name: "breakpoint"
4446
+ };
4315
4447
  if (token.startsWith(CHILD_PREFIX)) return {
4316
4448
  on: "child",
4317
4449
  name: token.slice(CHILD_PREFIX.length)
@@ -4732,6 +4864,19 @@ function attributeOperand(filter) {
4732
4864
  }
4733
4865
  __name(attributeOperand, "attributeOperand");
4734
4866
 
4867
+ // src/search-attributes-schema.ts
4868
+ function readSearchAttributes(schema, run) {
4869
+ const result = schema["~standard"].validate(run.searchAttributes ?? {});
4870
+ if (result instanceof Promise) {
4871
+ 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.");
4872
+ }
4873
+ if (result.issues) {
4874
+ return {};
4875
+ }
4876
+ return result.value;
4877
+ }
4878
+ __name(readSearchAttributes, "readSearchAttributes");
4879
+
4735
4880
  // src/tokens.ts
4736
4881
  var STATE_STORE = Symbol.for("nestjs-durable:STATE_STORE");
4737
4882
  var TRANSPORT = Symbol.for("nestjs-durable:TRANSPORT");
@@ -5107,6 +5252,7 @@ export {
5107
5252
  normalizeAttributeRows,
5108
5253
  parseDuration,
5109
5254
  prevCronFireMs,
5255
+ readSearchAttributes,
5110
5256
  resolveRunWaiting,
5111
5257
  runInWorkflowCtx,
5112
5258
  runSchedules,