@dudousxd/nestjs-durable 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -1,4 +1,5 @@
1
- import { StepError, WorkflowRef, SingletonConfig, StateStore, Transport, NamedTransport, ControlPlane, ScheduledWorkflow, RetentionPolicy, QueueConfig, AdmissionBackend, WorkflowClass, WorkflowInputOf, StartOptions, RunResult, RunGateway, WorkflowEngine, EntityHandler, DurableTopology, RunDetail, RunQuery, RunListItem, GroupHealth, EngineEvent, RunRequest, RunReply, StepInvocation, TenantEvent } from '@dudousxd/nestjs-durable-core';
1
+ import { StepError, WorkflowRef, SingletonConfig, SearchAttributesSchema, StateStore, Transport, NamedTransport, ControlPlane, ScheduledWorkflow, RetentionPolicy, QueueConfig, AdmissionBackend, WorkflowClass, WorkflowInputOf, StartOptions, RunResult, RunGateway, WorkflowEngine, EntityHandler, DurableTopology, RunDetail, RunQuery, RunListItem, GroupHealth, RunWaiting, EngineEvent, RunRequest, RunReply, StepInvocation, TenantEvent } from '@dudousxd/nestjs-durable-core';
2
+ export { AttributeFilter, EngineEvent, RunDetail, RunGateway, RunListItem, RunQuery, RunStatus, RunWaiting, SearchAttributes, StepCheckpoint, StepLogger, WorkflowCtx, WorkflowEngine, WorkflowRun } from '@dudousxd/nestjs-durable-core';
2
3
  import { z } from 'zod';
3
4
  import { ConcurrencyOption, StartRunDeps, RunRedisWorkerOptions, RunningWorker, DurableWorkerRuntime } from '@dudousxd/durable-worker';
4
5
  import { DynamicModule, InjectionToken, OnModuleInit, OnApplicationBootstrap, OnApplicationShutdown, Provider } from '@nestjs/common';
@@ -51,6 +52,8 @@ interface WorkflowMeta {
51
52
  inputSchema?: (new (...args: any[]) => object) | undefined;
52
53
  /** Custom input validator (throws on invalid). See `WorkflowOptions`. */
53
54
  validateInput?: ((input: unknown) => void | Promise<void>) | undefined;
55
+ /** Typed/validated `searchAttributes` shape. See `WorkflowOptions`. */
56
+ searchAttributes?: SearchAttributesSchema | undefined;
54
57
  /** Event names that start a fresh run of this workflow. See `WorkflowOptions`. */
55
58
  onEvent?: string[] | undefined;
56
59
  /** Debounce `onEvent` triggers — fire once it's quiet for this long. See `WorkflowOptions`. */
@@ -102,6 +105,32 @@ interface WorkflowOptions {
102
105
  * `inputSchema`. Takes precedence over `inputSchema` if both are set.
103
106
  */
104
107
  validateInput?: (input: unknown) => void | Promise<void>;
108
+ /**
109
+ * Validate this workflow's {@link WorkflowCtx.upsertSearchAttributes} writes against a **Standard
110
+ * Schema** (https://standardschema.dev — zod 3.24+, valibot, arktype, …). `ctx.upsertSearchAttributes`
111
+ * validates the MERGED result (existing attributes shallow-merged with the patch) on every call —
112
+ * an invalid merge throws, naming this workflow, the offending key(s), and the schema's issues. The
113
+ * schema's inferred output must be search-attribute-shaped (flat `string`/`number`/`boolean` values
114
+ * only) — a schema whose output has a nested object/array is a compile-time error here. Omit for
115
+ * the prior unvalidated behavior.
116
+ *
117
+ * ```ts
118
+ * import { z } from 'zod';
119
+ *
120
+ * const orderAttrs = z.object({
121
+ * tier: z.enum(['free', 'pro']),
122
+ * amount: z.number(),
123
+ * });
124
+ *
125
+ * @Workflow({ name: 'checkout', searchAttributes: orderAttrs })
126
+ * class CheckoutWorkflow {
127
+ * async run(ctx: WorkflowCtx<InferSearchAttributes<typeof orderAttrs>>, input: CheckoutInput) {
128
+ * await ctx.upsertSearchAttributes({ tier: input.tier, amount: input.total });
129
+ * }
130
+ * }
131
+ * ```
132
+ */
133
+ searchAttributes?: SearchAttributesSchema;
105
134
  /**
106
135
  * Start a fresh run of this workflow whenever any of these events is published via
107
136
  * `publishEvent(name, payload)` — the payload becomes the run's input. e.g.
@@ -289,6 +318,10 @@ interface DurableRetentionOptions {
289
318
  *
290
319
  * `forRoot` throws when neither `store` nor `connection` is set, and when `store` is set without a
291
320
  * `transport`.
321
+ *
322
+ * Set {@link DurableModuleOptions.topology} to name the role explicitly instead of relying on this
323
+ * inference — it additionally VALIDATES the axes above (`namespace` vs `partition`) that this prose
324
+ * is otherwise the only source of truth for. Omitting it changes nothing.
292
325
  */
293
326
  interface DurableModuleOptions {
294
327
  /** State store — set this to play the **operator** role (see the interface doc for the full role
@@ -478,6 +511,47 @@ interface DurableModuleOptions {
478
511
  /** Timeout for a thin worker's `RunGateway` round-trip over `transport` before it rejects. Defaults
479
512
  * to 10_000ms inside `ProxyRunGateway`. Ignored for an operator (bound to `StoreRunGateway`). */
480
513
  runGatewayTimeoutMs?: number;
514
+ /**
515
+ * An explicit preset that NAMES the deployment role instead of leaving it to `store`/`connection`
516
+ * inference, and VALIDATES the axes that are otherwise only prose (see the interface doc's role
517
+ * matrix, and {@link namespace} vs {@link partition}). Omit to keep the existing inference —
518
+ * `topology` is entirely additive and changes nothing when unset.
519
+ *
520
+ * **`namespace` vs `partition` — the two axes this preset locks down:**
521
+ * - `namespace` is the OPERATOR's poll-scoping axis: which runs a control-plane instance
522
+ * drives/recovers/resumes (`runPending`/`recoverIncomplete`/`resumeDueTimers`/`sweepTimeouts`).
523
+ * Unset makes the instance see (drive) EVERY namespace.
524
+ * `{ role: 'control-plane' }` is the only preset that allows it.
525
+ * - `partition` is the WORKER's queue-routing suffix: which queue a thin/co-located worker's
526
+ * consumer subscribes to (`<name>@<partition>`), matching the queue an operator's convention
527
+ * dispatch routes a run's `namespace` to. `{ role: 'tenant' }` sets this FOR you from `tenant` —
528
+ * you never set `partition` directly under `topology`.
529
+ *
530
+ * ```ts
531
+ * // Control plane: owns the store, dispatches over the transport, prunes old runs.
532
+ * DurableModule.forRoot({
533
+ * topology: { role: 'control-plane' },
534
+ * store,
535
+ * transport,
536
+ * retention: { policies: [{ statuses: ['completed', 'cancelled'], maxAge: '14d' }] },
537
+ * });
538
+ *
539
+ * // Tenant: store-less worker scoped to its own partition — `tenant` maps to `partition` for you.
540
+ * DurableModule.forRoot({
541
+ * topology: { role: 'tenant', tenant: 'acme-corp' },
542
+ * connection: process.env.REDIS_URL,
543
+ * });
544
+ * ```
545
+ *
546
+ * Validated at `forRoot`/`forRootAsync` resolution time — see {@link DurableModule} for the exact
547
+ * error messages, which double as the axis primer above.
548
+ */
549
+ topology?: {
550
+ role: 'control-plane';
551
+ } | {
552
+ role: 'tenant';
553
+ tenant: string;
554
+ };
481
555
  }
482
556
  interface DurableModuleAsyncOptions {
483
557
  useFactory: (...args: never[]) => DurableModuleOptions | Promise<DurableModuleOptions>;
@@ -486,6 +560,14 @@ interface DurableModuleAsyncOptions {
486
560
  declare class DurableModule {
487
561
  static forRoot(options: DurableModuleOptions): DynamicModule;
488
562
  static forRootAsync(options: DurableModuleAsyncOptions): DynamicModule;
563
+ /**
564
+ * Validates role/axis constraints, in resolution order. When {@link DurableModuleOptions.topology}
565
+ * is set, it OWNS validation — {@link assertValidTopology}'s own store/transport/connection checks
566
+ * are strictly stronger than {@link assertValidRole}'s, so the topology-specific, axis-teaching
567
+ * message is what a `topology`-opted-in consumer sees (not the older generic one). Falls back to
568
+ * {@link assertValidRole} unchanged when `topology` is absent — zero behavior change.
569
+ */
570
+ private static assertValid;
489
571
  private static build;
490
572
  }
491
573
 
@@ -722,6 +804,9 @@ declare class ProxyRunGateway implements RunGateway {
722
804
  listRuns(query: RunQuery): Promise<RunListItem[]>;
723
805
  /** Round-trips to the operator, which scopes the result to this tenant's own `@<tenant>` groups. */
724
806
  workerHealth(): Promise<GroupHealth[]>;
807
+ /** Bulk, one request for the whole id list (like `listRuns`, not one request per id). The operator
808
+ * filters the reply to runs this tenant actually owns (see `RunRequestResponder`). */
809
+ waitingFor(runIds: string[]): Promise<Record<string, RunWaiting>>;
725
810
  cancel(runId: string, opts?: {
726
811
  compensate?: boolean;
727
812
  }): Promise<RunResult | null>;
@@ -730,6 +815,9 @@ declare class ProxyRunGateway implements RunGateway {
730
815
  retryWithInput(runId: string, input: unknown): Promise<{
731
816
  runId: string;
732
817
  } | null>;
818
+ redispatchPending(runId: string): Promise<(RunResult & {
819
+ redispatched: number;
820
+ }) | null>;
733
821
  subscribe(runId: string, onEvent: (event: EngineEvent) => void): () => void;
734
822
  }
735
823
 
@@ -807,6 +895,17 @@ declare class StoreRunGateway implements RunGateway {
807
895
  topology(): DurableTopology;
808
896
  getRunDetail(runId: string): Promise<RunDetail | null>;
809
897
  listRuns(query: RunQuery): Promise<RunListItem[]>;
898
+ /**
899
+ * Bulk-resolve what each of `runIds` is currently parked on — for a consumer with its own filtered/
900
+ * paginated run listing (e.g. "which of MY suspended runs are stuck at a breakpoint") without
901
+ * re-deriving `listRuns`' waiter scan or querying `durable_step_checkpoints` directly. Mirrors
902
+ * `listRuns`' waiting computation (the SAME bulk signal-waiter scan + `resolveRunWaiting`), but ALSO
903
+ * bulk-fetches the currently-suspended runs to check real status: `engine.cancel` (the non-compensate
904
+ * path) doesn't clear a run's signal waiter row, so a cancelled run can leave an ORPHANED waiter
905
+ * behind — trusting waiter presence alone would wrongly report a terminal run as still waiting.
906
+ * Two bulk scans total (never one query per requested id), same as `listRuns`.
907
+ */
908
+ waitingFor(runIds: string[]): Promise<Record<string, RunWaiting>>;
810
909
  /** Every group the engine knows about — unscoped. A tenant proxy's request is scoped by the
811
910
  * `RunRequestResponder` (to the requester's `@<tenant>` groups); the operator's own UI sees all. */
812
911
  workerHealth(): Promise<GroupHealth[]>;
@@ -819,6 +918,9 @@ declare class StoreRunGateway implements RunGateway {
819
918
  retryWithInput(runId: string, input: unknown): Promise<{
820
919
  runId: string;
821
920
  } | null>;
921
+ redispatchPending(runId: string): Promise<(RunResult & {
922
+ redispatched: number;
923
+ }) | null>;
822
924
  subscribe(runId: string, onEvent: (event: EngineEvent) => void): () => void;
823
925
  }
824
926
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { StepError, WorkflowRef, SingletonConfig, StateStore, Transport, NamedTransport, ControlPlane, ScheduledWorkflow, RetentionPolicy, QueueConfig, AdmissionBackend, WorkflowClass, WorkflowInputOf, StartOptions, RunResult, RunGateway, WorkflowEngine, EntityHandler, DurableTopology, RunDetail, RunQuery, RunListItem, GroupHealth, EngineEvent, RunRequest, RunReply, StepInvocation, TenantEvent } from '@dudousxd/nestjs-durable-core';
1
+ import { StepError, WorkflowRef, SingletonConfig, SearchAttributesSchema, StateStore, Transport, NamedTransport, ControlPlane, ScheduledWorkflow, RetentionPolicy, QueueConfig, AdmissionBackend, WorkflowClass, WorkflowInputOf, StartOptions, RunResult, RunGateway, WorkflowEngine, EntityHandler, DurableTopology, RunDetail, RunQuery, RunListItem, GroupHealth, RunWaiting, EngineEvent, RunRequest, RunReply, StepInvocation, TenantEvent } from '@dudousxd/nestjs-durable-core';
2
+ export { AttributeFilter, EngineEvent, RunDetail, RunGateway, RunListItem, RunQuery, RunStatus, RunWaiting, SearchAttributes, StepCheckpoint, StepLogger, WorkflowCtx, WorkflowEngine, WorkflowRun } from '@dudousxd/nestjs-durable-core';
2
3
  import { z } from 'zod';
3
4
  import { ConcurrencyOption, StartRunDeps, RunRedisWorkerOptions, RunningWorker, DurableWorkerRuntime } from '@dudousxd/durable-worker';
4
5
  import { DynamicModule, InjectionToken, OnModuleInit, OnApplicationBootstrap, OnApplicationShutdown, Provider } from '@nestjs/common';
@@ -51,6 +52,8 @@ interface WorkflowMeta {
51
52
  inputSchema?: (new (...args: any[]) => object) | undefined;
52
53
  /** Custom input validator (throws on invalid). See `WorkflowOptions`. */
53
54
  validateInput?: ((input: unknown) => void | Promise<void>) | undefined;
55
+ /** Typed/validated `searchAttributes` shape. See `WorkflowOptions`. */
56
+ searchAttributes?: SearchAttributesSchema | undefined;
54
57
  /** Event names that start a fresh run of this workflow. See `WorkflowOptions`. */
55
58
  onEvent?: string[] | undefined;
56
59
  /** Debounce `onEvent` triggers — fire once it's quiet for this long. See `WorkflowOptions`. */
@@ -102,6 +105,32 @@ interface WorkflowOptions {
102
105
  * `inputSchema`. Takes precedence over `inputSchema` if both are set.
103
106
  */
104
107
  validateInput?: (input: unknown) => void | Promise<void>;
108
+ /**
109
+ * Validate this workflow's {@link WorkflowCtx.upsertSearchAttributes} writes against a **Standard
110
+ * Schema** (https://standardschema.dev — zod 3.24+, valibot, arktype, …). `ctx.upsertSearchAttributes`
111
+ * validates the MERGED result (existing attributes shallow-merged with the patch) on every call —
112
+ * an invalid merge throws, naming this workflow, the offending key(s), and the schema's issues. The
113
+ * schema's inferred output must be search-attribute-shaped (flat `string`/`number`/`boolean` values
114
+ * only) — a schema whose output has a nested object/array is a compile-time error here. Omit for
115
+ * the prior unvalidated behavior.
116
+ *
117
+ * ```ts
118
+ * import { z } from 'zod';
119
+ *
120
+ * const orderAttrs = z.object({
121
+ * tier: z.enum(['free', 'pro']),
122
+ * amount: z.number(),
123
+ * });
124
+ *
125
+ * @Workflow({ name: 'checkout', searchAttributes: orderAttrs })
126
+ * class CheckoutWorkflow {
127
+ * async run(ctx: WorkflowCtx<InferSearchAttributes<typeof orderAttrs>>, input: CheckoutInput) {
128
+ * await ctx.upsertSearchAttributes({ tier: input.tier, amount: input.total });
129
+ * }
130
+ * }
131
+ * ```
132
+ */
133
+ searchAttributes?: SearchAttributesSchema;
105
134
  /**
106
135
  * Start a fresh run of this workflow whenever any of these events is published via
107
136
  * `publishEvent(name, payload)` — the payload becomes the run's input. e.g.
@@ -289,6 +318,10 @@ interface DurableRetentionOptions {
289
318
  *
290
319
  * `forRoot` throws when neither `store` nor `connection` is set, and when `store` is set without a
291
320
  * `transport`.
321
+ *
322
+ * Set {@link DurableModuleOptions.topology} to name the role explicitly instead of relying on this
323
+ * inference — it additionally VALIDATES the axes above (`namespace` vs `partition`) that this prose
324
+ * is otherwise the only source of truth for. Omitting it changes nothing.
292
325
  */
293
326
  interface DurableModuleOptions {
294
327
  /** State store — set this to play the **operator** role (see the interface doc for the full role
@@ -478,6 +511,47 @@ interface DurableModuleOptions {
478
511
  /** Timeout for a thin worker's `RunGateway` round-trip over `transport` before it rejects. Defaults
479
512
  * to 10_000ms inside `ProxyRunGateway`. Ignored for an operator (bound to `StoreRunGateway`). */
480
513
  runGatewayTimeoutMs?: number;
514
+ /**
515
+ * An explicit preset that NAMES the deployment role instead of leaving it to `store`/`connection`
516
+ * inference, and VALIDATES the axes that are otherwise only prose (see the interface doc's role
517
+ * matrix, and {@link namespace} vs {@link partition}). Omit to keep the existing inference —
518
+ * `topology` is entirely additive and changes nothing when unset.
519
+ *
520
+ * **`namespace` vs `partition` — the two axes this preset locks down:**
521
+ * - `namespace` is the OPERATOR's poll-scoping axis: which runs a control-plane instance
522
+ * drives/recovers/resumes (`runPending`/`recoverIncomplete`/`resumeDueTimers`/`sweepTimeouts`).
523
+ * Unset makes the instance see (drive) EVERY namespace.
524
+ * `{ role: 'control-plane' }` is the only preset that allows it.
525
+ * - `partition` is the WORKER's queue-routing suffix: which queue a thin/co-located worker's
526
+ * consumer subscribes to (`<name>@<partition>`), matching the queue an operator's convention
527
+ * dispatch routes a run's `namespace` to. `{ role: 'tenant' }` sets this FOR you from `tenant` —
528
+ * you never set `partition` directly under `topology`.
529
+ *
530
+ * ```ts
531
+ * // Control plane: owns the store, dispatches over the transport, prunes old runs.
532
+ * DurableModule.forRoot({
533
+ * topology: { role: 'control-plane' },
534
+ * store,
535
+ * transport,
536
+ * retention: { policies: [{ statuses: ['completed', 'cancelled'], maxAge: '14d' }] },
537
+ * });
538
+ *
539
+ * // Tenant: store-less worker scoped to its own partition — `tenant` maps to `partition` for you.
540
+ * DurableModule.forRoot({
541
+ * topology: { role: 'tenant', tenant: 'acme-corp' },
542
+ * connection: process.env.REDIS_URL,
543
+ * });
544
+ * ```
545
+ *
546
+ * Validated at `forRoot`/`forRootAsync` resolution time — see {@link DurableModule} for the exact
547
+ * error messages, which double as the axis primer above.
548
+ */
549
+ topology?: {
550
+ role: 'control-plane';
551
+ } | {
552
+ role: 'tenant';
553
+ tenant: string;
554
+ };
481
555
  }
482
556
  interface DurableModuleAsyncOptions {
483
557
  useFactory: (...args: never[]) => DurableModuleOptions | Promise<DurableModuleOptions>;
@@ -486,6 +560,14 @@ interface DurableModuleAsyncOptions {
486
560
  declare class DurableModule {
487
561
  static forRoot(options: DurableModuleOptions): DynamicModule;
488
562
  static forRootAsync(options: DurableModuleAsyncOptions): DynamicModule;
563
+ /**
564
+ * Validates role/axis constraints, in resolution order. When {@link DurableModuleOptions.topology}
565
+ * is set, it OWNS validation — {@link assertValidTopology}'s own store/transport/connection checks
566
+ * are strictly stronger than {@link assertValidRole}'s, so the topology-specific, axis-teaching
567
+ * message is what a `topology`-opted-in consumer sees (not the older generic one). Falls back to
568
+ * {@link assertValidRole} unchanged when `topology` is absent — zero behavior change.
569
+ */
570
+ private static assertValid;
489
571
  private static build;
490
572
  }
491
573
 
@@ -722,6 +804,9 @@ declare class ProxyRunGateway implements RunGateway {
722
804
  listRuns(query: RunQuery): Promise<RunListItem[]>;
723
805
  /** Round-trips to the operator, which scopes the result to this tenant's own `@<tenant>` groups. */
724
806
  workerHealth(): Promise<GroupHealth[]>;
807
+ /** Bulk, one request for the whole id list (like `listRuns`, not one request per id). The operator
808
+ * filters the reply to runs this tenant actually owns (see `RunRequestResponder`). */
809
+ waitingFor(runIds: string[]): Promise<Record<string, RunWaiting>>;
725
810
  cancel(runId: string, opts?: {
726
811
  compensate?: boolean;
727
812
  }): Promise<RunResult | null>;
@@ -730,6 +815,9 @@ declare class ProxyRunGateway implements RunGateway {
730
815
  retryWithInput(runId: string, input: unknown): Promise<{
731
816
  runId: string;
732
817
  } | null>;
818
+ redispatchPending(runId: string): Promise<(RunResult & {
819
+ redispatched: number;
820
+ }) | null>;
733
821
  subscribe(runId: string, onEvent: (event: EngineEvent) => void): () => void;
734
822
  }
735
823
 
@@ -807,6 +895,17 @@ declare class StoreRunGateway implements RunGateway {
807
895
  topology(): DurableTopology;
808
896
  getRunDetail(runId: string): Promise<RunDetail | null>;
809
897
  listRuns(query: RunQuery): Promise<RunListItem[]>;
898
+ /**
899
+ * Bulk-resolve what each of `runIds` is currently parked on — for a consumer with its own filtered/
900
+ * paginated run listing (e.g. "which of MY suspended runs are stuck at a breakpoint") without
901
+ * re-deriving `listRuns`' waiter scan or querying `durable_step_checkpoints` directly. Mirrors
902
+ * `listRuns`' waiting computation (the SAME bulk signal-waiter scan + `resolveRunWaiting`), but ALSO
903
+ * bulk-fetches the currently-suspended runs to check real status: `engine.cancel` (the non-compensate
904
+ * path) doesn't clear a run's signal waiter row, so a cancelled run can leave an ORPHANED waiter
905
+ * behind — trusting waiter presence alone would wrongly report a terminal run as still waiting.
906
+ * Two bulk scans total (never one query per requested id), same as `listRuns`.
907
+ */
908
+ waitingFor(runIds: string[]): Promise<Record<string, RunWaiting>>;
810
909
  /** Every group the engine knows about — unscoped. A tenant proxy's request is scoped by the
811
910
  * `RunRequestResponder` (to the requester's `@<tenant>` groups); the operator's own UI sees all. */
812
911
  workerHealth(): Promise<GroupHealth[]>;
@@ -819,6 +918,9 @@ declare class StoreRunGateway implements RunGateway {
819
918
  retryWithInput(runId: string, input: unknown): Promise<{
820
919
  runId: string;
821
920
  } | null>;
921
+ redispatchPending(runId: string): Promise<(RunResult & {
922
+ redispatched: number;
923
+ }) | null>;
822
924
  subscribe(runId: string, onEvent: (event: EngineEvent) => void): () => void;
823
925
  }
824
926
 
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ function Workflow(options) {
22
22
  executionTimeout: options.executionTimeout,
23
23
  inputSchema: options.inputSchema,
24
24
  validateInput: options.validateInput,
25
+ searchAttributes: options.searchAttributes,
25
26
  onEvent: options.onEvent,
26
27
  debounce: options.debounce,
27
28
  batch: options.batch
@@ -436,6 +437,9 @@ function unavailableRunGateway() {
436
437
  listRuns(_query) {
437
438
  return tenantGatewayUnavailable("listRuns");
438
439
  },
440
+ waitingFor(_runIds) {
441
+ return tenantGatewayUnavailable("waitingFor");
442
+ },
439
443
  workerHealth() {
440
444
  return tenantGatewayUnavailable("workerHealth");
441
445
  },
@@ -451,6 +455,9 @@ function unavailableRunGateway() {
451
455
  retryWithInput(_runId, _input) {
452
456
  return tenantGatewayUnavailable("retryWithInput");
453
457
  },
458
+ redispatchPending(_runId) {
459
+ return tenantGatewayUnavailable("redispatchPending");
460
+ },
454
461
  subscribe(_runId, _onEvent) {
455
462
  throw new Error("RunGateway.subscribe() is not available \u2014 pass `transport` to use RunGateway.");
456
463
  }
@@ -850,6 +857,14 @@ var ProxyRunGateway = class {
850
857
  kind: "workerHealth"
851
858
  });
852
859
  }
860
+ /** Bulk, one request for the whole id list (like `listRuns`, not one request per id). The operator
861
+ * filters the reply to runs this tenant actually owns (see `RunRequestResponder`). */
862
+ waitingFor(runIds) {
863
+ return this.request({
864
+ kind: "waitingFor",
865
+ runIds
866
+ });
867
+ }
853
868
  cancel(runId, opts) {
854
869
  return this.request(opts === void 0 ? {
855
870
  kind: "cancel",
@@ -879,6 +894,12 @@ var ProxyRunGateway = class {
879
894
  input
880
895
  });
881
896
  }
897
+ redispatchPending(runId) {
898
+ return this.request({
899
+ kind: "redispatch",
900
+ runId
901
+ });
902
+ }
882
903
  subscribe(runId, onEvent) {
883
904
  const unsubscribe = this.transport.onTenantEvent?.(this.tenant, (evt) => {
884
905
  if (evt.event.runId === runId) onEvent(evt.event);
@@ -1057,6 +1078,27 @@ var RunRequestResponder = class {
1057
1078
  }
1058
1079
  };
1059
1080
  }
1081
+ if (body.kind === "waitingFor") {
1082
+ const all = await this.gateway.waitingFor(body.runIds);
1083
+ const owned = await Promise.all(Object.entries(all).map(async ([runId, waiting]) => {
1084
+ const runDetail = await this.gateway.getRunDetail(runId);
1085
+ return runDetail && runDetail.run.namespace === msg.tenant ? [
1086
+ runId,
1087
+ waiting
1088
+ ] : void 0;
1089
+ }));
1090
+ const data = {};
1091
+ for (const entry of owned) {
1092
+ if (entry) data[entry[0]] = entry[1];
1093
+ }
1094
+ return {
1095
+ requestId: msg.requestId,
1096
+ result: {
1097
+ ok: true,
1098
+ data
1099
+ }
1100
+ };
1101
+ }
1060
1102
  const detail = await this.gateway.getRunDetail(body.runId);
1061
1103
  if (detail && detail.run.namespace !== msg.tenant) {
1062
1104
  return {
@@ -1110,6 +1152,8 @@ var RunRequestResponder = class {
1110
1152
  return this.gateway.continue(body.runId);
1111
1153
  case "retryWithInput":
1112
1154
  return this.gateway.retryWithInput(body.runId, body.input);
1155
+ case "redispatch":
1156
+ return this.gateway.redispatchPending(body.runId);
1113
1157
  }
1114
1158
  }
1115
1159
  };
@@ -1173,6 +1217,36 @@ var StoreRunGateway = class {
1173
1217
  } : run;
1174
1218
  });
1175
1219
  }
1220
+ /**
1221
+ * Bulk-resolve what each of `runIds` is currently parked on — for a consumer with its own filtered/
1222
+ * paginated run listing (e.g. "which of MY suspended runs are stuck at a breakpoint") without
1223
+ * re-deriving `listRuns`' waiter scan or querying `durable_step_checkpoints` directly. Mirrors
1224
+ * `listRuns`' waiting computation (the SAME bulk signal-waiter scan + `resolveRunWaiting`), but ALSO
1225
+ * bulk-fetches the currently-suspended runs to check real status: `engine.cancel` (the non-compensate
1226
+ * path) doesn't clear a run's signal waiter row, so a cancelled run can leave an ORPHANED waiter
1227
+ * behind — trusting waiter presence alone would wrongly report a terminal run as still waiting.
1228
+ * Two bulk scans total (never one query per requested id), same as `listRuns`.
1229
+ */
1230
+ async waitingFor(runIds) {
1231
+ if (runIds.length === 0) return {};
1232
+ const idSet = new Set(runIds);
1233
+ const [suspended, waiters] = await Promise.all([
1234
+ this.store.listRuns({
1235
+ statuses: [
1236
+ "suspended"
1237
+ ]
1238
+ }),
1239
+ this.store.listSignalWaiters("")
1240
+ ]);
1241
+ const waiterByRun = indexWaitersByRun(waiters);
1242
+ const result = {};
1243
+ for (const run of suspended) {
1244
+ if (!idSet.has(run.id)) continue;
1245
+ const waiting = resolveRunWaiting(run, waiterByRun);
1246
+ if (waiting) result[run.id] = waiting;
1247
+ }
1248
+ return result;
1249
+ }
1176
1250
  /** Every group the engine knows about — unscoped. A tenant proxy's request is scoped by the
1177
1251
  * `RunRequestResponder` (to the requester's `@<tenant>` groups); the operator's own UI sees all. */
1178
1252
  workerHealth() {
@@ -1191,6 +1265,9 @@ var StoreRunGateway = class {
1191
1265
  retryWithInput(runId, input) {
1192
1266
  return this.engine.retryWithInput(runId, input);
1193
1267
  }
1268
+ redispatchPending(runId) {
1269
+ return this.engine.redispatchPending(runId);
1270
+ }
1194
1271
  subscribe(runId, onEvent) {
1195
1272
  return this.engine.subscribe((event) => {
1196
1273
  if (event.runId === runId) onEvent(event);
@@ -1461,6 +1538,7 @@ var WorkflowRegistrar = class {
1461
1538
  singleton: meta.singleton,
1462
1539
  executionTimeout: meta.executionTimeout,
1463
1540
  validateInput,
1541
+ searchAttributesSchema: meta.searchAttributes,
1464
1542
  onEvent: getOnEvents(meta, workflowCtor),
1465
1543
  eventBatch,
1466
1544
  // Uniform dispatch (opt-in): when an in-app worker is configured, register the body GROUP-SERVED
@@ -1693,6 +1771,47 @@ function assertValidRole(options) {
1693
1771
  }
1694
1772
  }
1695
1773
  __name(assertValidRole, "assertValidRole");
1774
+ function assertValidTopology(options) {
1775
+ const topology = options.topology;
1776
+ if (topology === void 0) return;
1777
+ if (topology.role === "control-plane") {
1778
+ if (options.store === void 0 || options.transport === void 0 && options.transports === void 0) {
1779
+ throw new Error("topology: { role: 'control-plane' } needs `store` AND (`transport` or `transports`).\nA control-plane node is the durable operator: it owns the state store and dispatches runs to workers over a transport \u2014 without both, there is nothing for it to operate.");
1780
+ }
1781
+ if (options.partition !== void 0) {
1782
+ throw new Error("topology: { role: 'control-plane' } forbids `partition`.\npartition is the WORKER queue-routing suffix \u2014 on a control-plane node, runs are dispatched to tenant partitions via each run's namespace, not via this option. Set `partition` (or `topology: { role: 'tenant', tenant }`) on the WORKER node instead.");
1783
+ }
1784
+ if ("tenant" in topology) {
1785
+ throw new Error("topology: { role: 'control-plane' } forbids a `tenant` field.\ntenant names a WORKER's isolation partition \u2014 a control-plane node serves every tenant's runs (via their namespace), it isn't scoped to one itself.");
1786
+ }
1787
+ return;
1788
+ }
1789
+ if (options.connection === void 0) {
1790
+ throw new Error(`topology: { role: 'tenant', tenant: '${topology.tenant}' } needs \`connection\`.
1791
+ A tenant is a store-less worker: it connects to the broker directly to pull its own partition of tasks instead of polling a store it does not have.`);
1792
+ }
1793
+ if (options.store !== void 0) {
1794
+ throw new Error("topology: { role: 'tenant' } forbids `store`.\nA tenant is store-less BY DEFINITION \u2014 a `store` present means you actually wanted `topology: { role: 'control-plane' }` (the store-owning role).");
1795
+ }
1796
+ if (options.namespace !== void 0) {
1797
+ throw new Error("topology: { role: 'tenant' } forbids `namespace`.\nnamespace is the OPERATOR's poll-scoping axis (which runs a control-plane instance drives/recovers/resumes) \u2014 a tenant worker has no poll loop for it to scope. Use this preset's `tenant` field for the worker's own routing axis instead.");
1798
+ }
1799
+ if (options.partition !== void 0 && options.partition !== topology.tenant) {
1800
+ throw new Error(`topology: { role: 'tenant', tenant: '${topology.tenant}' } conflicts with \`partition: '${options.partition}'\`.
1801
+ tenant maps 1:1 onto partition (the worker queue-routing suffix) \u2014 set only \`tenant\`, or set \`partition\` to the same value.`);
1802
+ }
1803
+ }
1804
+ __name(assertValidTopology, "assertValidTopology");
1805
+ function resolveTopology(options) {
1806
+ const topology = options.topology;
1807
+ if (topology === void 0 || topology.role !== "tenant") return options;
1808
+ if (options.partition !== void 0) return options;
1809
+ return {
1810
+ ...options,
1811
+ partition: topology.tenant
1812
+ };
1813
+ }
1814
+ __name(resolveTopology, "resolveTopology");
1696
1815
  var RunGatewayBootstrap = class RunGatewayBootstrap2 {
1697
1816
  static {
1698
1817
  __name(this, "RunGatewayBootstrap");
@@ -1753,10 +1872,10 @@ var DurableModule = class _DurableModule {
1753
1872
  __name(this, "DurableModule");
1754
1873
  }
1755
1874
  static forRoot(options) {
1756
- assertValidRole(options);
1875
+ _DurableModule.assertValid(options);
1757
1876
  return _DurableModule.build({
1758
1877
  provide: DURABLE_OPTIONS_CANONICAL7,
1759
- useValue: options
1878
+ useValue: resolveTopology(options)
1760
1879
  });
1761
1880
  }
1762
1881
  static forRootAsync(options) {
@@ -1764,12 +1883,26 @@ var DurableModule = class _DurableModule {
1764
1883
  provide: DURABLE_OPTIONS_CANONICAL7,
1765
1884
  useFactory: /* @__PURE__ */ __name(async (...args) => {
1766
1885
  const resolved = await options.useFactory(...args);
1767
- assertValidRole(resolved);
1768
- return resolved;
1886
+ _DurableModule.assertValid(resolved);
1887
+ return resolveTopology(resolved);
1769
1888
  }, "useFactory"),
1770
1889
  inject: options.inject ?? []
1771
1890
  });
1772
1891
  }
1892
+ /**
1893
+ * Validates role/axis constraints, in resolution order. When {@link DurableModuleOptions.topology}
1894
+ * is set, it OWNS validation — {@link assertValidTopology}'s own store/transport/connection checks
1895
+ * are strictly stronger than {@link assertValidRole}'s, so the topology-specific, axis-teaching
1896
+ * message is what a `topology`-opted-in consumer sees (not the older generic one). Falls back to
1897
+ * {@link assertValidRole} unchanged when `topology` is absent — zero behavior change.
1898
+ */
1899
+ static assertValid(options) {
1900
+ if (options.topology !== void 0) {
1901
+ assertValidTopology(options);
1902
+ return;
1903
+ }
1904
+ assertValidRole(options);
1905
+ }
1773
1906
  static build(optionsProvider) {
1774
1907
  return {
1775
1908
  module: _DurableModule,
@@ -1911,6 +2044,9 @@ var DurableModule = class _DurableModule {
1911
2044
  DurableModule = _ts_decorate12([
1912
2045
  Module({})
1913
2046
  ], DurableModule);
2047
+
2048
+ // src/index.ts
2049
+ import { WorkflowEngine as WorkflowEngine7 } from "@dudousxd/nestjs-durable-core";
1914
2050
  export {
1915
2051
  CONTEXT_ACCESSOR,
1916
2052
  DEAD_LETTER_METADATA,
@@ -1947,6 +2083,7 @@ export {
1947
2083
  ThinWorkflowRegistrar,
1948
2084
  WORKFLOW_METADATA,
1949
2085
  Workflow,
2086
+ WorkflowEngine7 as WorkflowEngine,
1950
2087
  WorkflowService,
1951
2088
  entityConfigFor,
1952
2089
  getDurableStepMeta,