@dudousxd/nestjs-durable 0.30.2 → 0.32.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,56 @@ 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
+ * **One `tenant` word, two axes — the preset maps it to the right one per role:** on a
531
+ * `control-plane` an optional `tenant` scopes the OPERATOR to its own runs (maps to `namespace` —
532
+ * a self-contained local stack sharing a broker with a deployed cluster names itself so neither
533
+ * drives the other's runs; leave it unset on a deployed operator to drive every tenant). On a
534
+ * `tenant` role it is required and maps to `partition` (the worker's queue suffix).
535
+ *
536
+ * ```ts
537
+ * // Control plane: owns the store, dispatches over the transport, prunes old runs. The optional
538
+ * // `tenant` (e.g. from an env var) scopes this operator to its own runs — undefined on a
539
+ * // deployed operator (drives everything), set on a local stack sharing the broker.
540
+ * DurableModule.forRoot({
541
+ * topology: { role: 'control-plane', tenant: process.env.DURABLE_TENANT },
542
+ * store,
543
+ * transport,
544
+ * retention: { policies: [{ statuses: ['completed', 'cancelled'], maxAge: '14d' }] },
545
+ * });
546
+ *
547
+ * // Tenant: store-less worker scoped to its own partition — `tenant` maps to `partition` for you.
548
+ * DurableModule.forRoot({
549
+ * topology: { role: 'tenant', tenant: 'acme-corp' },
550
+ * connection: process.env.REDIS_URL,
551
+ * });
552
+ * ```
553
+ *
554
+ * Validated at `forRoot`/`forRootAsync` resolution time — see {@link DurableModule} for the exact
555
+ * error messages, which double as the axis primer above.
556
+ */
557
+ topology?: {
558
+ role: 'control-plane';
559
+ tenant?: string;
560
+ } | {
561
+ role: 'tenant';
562
+ tenant: string;
563
+ };
481
564
  }
482
565
  interface DurableModuleAsyncOptions {
483
566
  useFactory: (...args: never[]) => DurableModuleOptions | Promise<DurableModuleOptions>;
@@ -486,6 +569,14 @@ interface DurableModuleAsyncOptions {
486
569
  declare class DurableModule {
487
570
  static forRoot(options: DurableModuleOptions): DynamicModule;
488
571
  static forRootAsync(options: DurableModuleAsyncOptions): DynamicModule;
572
+ /**
573
+ * Validates role/axis constraints, in resolution order. When {@link DurableModuleOptions.topology}
574
+ * is set, it OWNS validation — {@link assertValidTopology}'s own store/transport/connection checks
575
+ * are strictly stronger than {@link assertValidRole}'s, so the topology-specific, axis-teaching
576
+ * message is what a `topology`-opted-in consumer sees (not the older generic one). Falls back to
577
+ * {@link assertValidRole} unchanged when `topology` is absent — zero behavior change.
578
+ */
579
+ private static assertValid;
489
580
  private static build;
490
581
  }
491
582
 
@@ -722,6 +813,9 @@ declare class ProxyRunGateway implements RunGateway {
722
813
  listRuns(query: RunQuery): Promise<RunListItem[]>;
723
814
  /** Round-trips to the operator, which scopes the result to this tenant's own `@<tenant>` groups. */
724
815
  workerHealth(): Promise<GroupHealth[]>;
816
+ /** Bulk, one request for the whole id list (like `listRuns`, not one request per id). The operator
817
+ * filters the reply to runs this tenant actually owns (see `RunRequestResponder`). */
818
+ waitingFor(runIds: string[]): Promise<Record<string, RunWaiting>>;
725
819
  cancel(runId: string, opts?: {
726
820
  compensate?: boolean;
727
821
  }): Promise<RunResult | null>;
@@ -810,6 +904,17 @@ declare class StoreRunGateway implements RunGateway {
810
904
  topology(): DurableTopology;
811
905
  getRunDetail(runId: string): Promise<RunDetail | null>;
812
906
  listRuns(query: RunQuery): Promise<RunListItem[]>;
907
+ /**
908
+ * Bulk-resolve what each of `runIds` is currently parked on — for a consumer with its own filtered/
909
+ * paginated run listing (e.g. "which of MY suspended runs are stuck at a breakpoint") without
910
+ * re-deriving `listRuns`' waiter scan or querying `durable_step_checkpoints` directly. Mirrors
911
+ * `listRuns`' waiting computation (the SAME bulk signal-waiter scan + `resolveRunWaiting`), but ALSO
912
+ * bulk-fetches the currently-suspended runs to check real status: `engine.cancel` (the non-compensate
913
+ * path) doesn't clear a run's signal waiter row, so a cancelled run can leave an ORPHANED waiter
914
+ * behind — trusting waiter presence alone would wrongly report a terminal run as still waiting.
915
+ * Two bulk scans total (never one query per requested id), same as `listRuns`.
916
+ */
917
+ waitingFor(runIds: string[]): Promise<Record<string, RunWaiting>>;
813
918
  /** Every group the engine knows about — unscoped. A tenant proxy's request is scoped by the
814
919
  * `RunRequestResponder` (to the requester's `@<tenant>` groups); the operator's own UI sees all. */
815
920
  workerHealth(): Promise<GroupHealth[]>;
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,56 @@ 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
+ * **One `tenant` word, two axes — the preset maps it to the right one per role:** on a
531
+ * `control-plane` an optional `tenant` scopes the OPERATOR to its own runs (maps to `namespace` —
532
+ * a self-contained local stack sharing a broker with a deployed cluster names itself so neither
533
+ * drives the other's runs; leave it unset on a deployed operator to drive every tenant). On a
534
+ * `tenant` role it is required and maps to `partition` (the worker's queue suffix).
535
+ *
536
+ * ```ts
537
+ * // Control plane: owns the store, dispatches over the transport, prunes old runs. The optional
538
+ * // `tenant` (e.g. from an env var) scopes this operator to its own runs — undefined on a
539
+ * // deployed operator (drives everything), set on a local stack sharing the broker.
540
+ * DurableModule.forRoot({
541
+ * topology: { role: 'control-plane', tenant: process.env.DURABLE_TENANT },
542
+ * store,
543
+ * transport,
544
+ * retention: { policies: [{ statuses: ['completed', 'cancelled'], maxAge: '14d' }] },
545
+ * });
546
+ *
547
+ * // Tenant: store-less worker scoped to its own partition — `tenant` maps to `partition` for you.
548
+ * DurableModule.forRoot({
549
+ * topology: { role: 'tenant', tenant: 'acme-corp' },
550
+ * connection: process.env.REDIS_URL,
551
+ * });
552
+ * ```
553
+ *
554
+ * Validated at `forRoot`/`forRootAsync` resolution time — see {@link DurableModule} for the exact
555
+ * error messages, which double as the axis primer above.
556
+ */
557
+ topology?: {
558
+ role: 'control-plane';
559
+ tenant?: string;
560
+ } | {
561
+ role: 'tenant';
562
+ tenant: string;
563
+ };
481
564
  }
482
565
  interface DurableModuleAsyncOptions {
483
566
  useFactory: (...args: never[]) => DurableModuleOptions | Promise<DurableModuleOptions>;
@@ -486,6 +569,14 @@ interface DurableModuleAsyncOptions {
486
569
  declare class DurableModule {
487
570
  static forRoot(options: DurableModuleOptions): DynamicModule;
488
571
  static forRootAsync(options: DurableModuleAsyncOptions): DynamicModule;
572
+ /**
573
+ * Validates role/axis constraints, in resolution order. When {@link DurableModuleOptions.topology}
574
+ * is set, it OWNS validation — {@link assertValidTopology}'s own store/transport/connection checks
575
+ * are strictly stronger than {@link assertValidRole}'s, so the topology-specific, axis-teaching
576
+ * message is what a `topology`-opted-in consumer sees (not the older generic one). Falls back to
577
+ * {@link assertValidRole} unchanged when `topology` is absent — zero behavior change.
578
+ */
579
+ private static assertValid;
489
580
  private static build;
490
581
  }
491
582
 
@@ -722,6 +813,9 @@ declare class ProxyRunGateway implements RunGateway {
722
813
  listRuns(query: RunQuery): Promise<RunListItem[]>;
723
814
  /** Round-trips to the operator, which scopes the result to this tenant's own `@<tenant>` groups. */
724
815
  workerHealth(): Promise<GroupHealth[]>;
816
+ /** Bulk, one request for the whole id list (like `listRuns`, not one request per id). The operator
817
+ * filters the reply to runs this tenant actually owns (see `RunRequestResponder`). */
818
+ waitingFor(runIds: string[]): Promise<Record<string, RunWaiting>>;
725
819
  cancel(runId: string, opts?: {
726
820
  compensate?: boolean;
727
821
  }): Promise<RunResult | null>;
@@ -810,6 +904,17 @@ declare class StoreRunGateway implements RunGateway {
810
904
  topology(): DurableTopology;
811
905
  getRunDetail(runId: string): Promise<RunDetail | null>;
812
906
  listRuns(query: RunQuery): Promise<RunListItem[]>;
907
+ /**
908
+ * Bulk-resolve what each of `runIds` is currently parked on — for a consumer with its own filtered/
909
+ * paginated run listing (e.g. "which of MY suspended runs are stuck at a breakpoint") without
910
+ * re-deriving `listRuns`' waiter scan or querying `durable_step_checkpoints` directly. Mirrors
911
+ * `listRuns`' waiting computation (the SAME bulk signal-waiter scan + `resolveRunWaiting`), but ALSO
912
+ * bulk-fetches the currently-suspended runs to check real status: `engine.cancel` (the non-compensate
913
+ * path) doesn't clear a run's signal waiter row, so a cancelled run can leave an ORPHANED waiter
914
+ * behind — trusting waiter presence alone would wrongly report a terminal run as still waiting.
915
+ * Two bulk scans total (never one query per requested id), same as `listRuns`.
916
+ */
917
+ waitingFor(runIds: string[]): Promise<Record<string, RunWaiting>>;
813
918
  /** Every group the engine knows about — unscoped. A tenant proxy's request is scoped by the
814
919
  * `RunRequestResponder` (to the requester's `@<tenant>` groups); the operator's own UI sees all. */
815
920
  workerHealth(): Promise<GroupHealth[]>;
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
  },
@@ -853,6 +857,14 @@ var ProxyRunGateway = class {
853
857
  kind: "workerHealth"
854
858
  });
855
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
+ }
856
868
  cancel(runId, opts) {
857
869
  return this.request(opts === void 0 ? {
858
870
  kind: "cancel",
@@ -1066,6 +1078,27 @@ var RunRequestResponder = class {
1066
1078
  }
1067
1079
  };
1068
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
+ }
1069
1102
  const detail = await this.gateway.getRunDetail(body.runId);
1070
1103
  if (detail && detail.run.namespace !== msg.tenant) {
1071
1104
  return {
@@ -1184,6 +1217,36 @@ var StoreRunGateway = class {
1184
1217
  } : run;
1185
1218
  });
1186
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
+ }
1187
1250
  /** Every group the engine knows about — unscoped. A tenant proxy's request is scoped by the
1188
1251
  * `RunRequestResponder` (to the requester's `@<tenant>` groups); the operator's own UI sees all. */
1189
1252
  workerHealth() {
@@ -1475,6 +1538,7 @@ var WorkflowRegistrar = class {
1475
1538
  singleton: meta.singleton,
1476
1539
  executionTimeout: meta.executionTimeout,
1477
1540
  validateInput,
1541
+ searchAttributesSchema: meta.searchAttributes,
1478
1542
  onEvent: getOnEvents(meta, workflowCtor),
1479
1543
  eventBatch,
1480
1544
  // Uniform dispatch (opt-in): when an in-app worker is configured, register the body GROUP-SERVED
@@ -1707,6 +1771,55 @@ function assertValidRole(options) {
1707
1771
  }
1708
1772
  }
1709
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 (topology.tenant !== void 0 && options.namespace !== void 0 && options.namespace !== topology.tenant) {
1785
+ throw new Error(`topology: { role: 'control-plane', tenant: '${topology.tenant}' } conflicts with \`namespace: '${options.namespace}'\`.
1786
+ tenant maps 1:1 onto namespace (the operator's poll-scoping axis) \u2014 set only \`tenant\`, or set \`namespace\` to the same value.`);
1787
+ }
1788
+ return;
1789
+ }
1790
+ if (options.connection === void 0) {
1791
+ throw new Error(`topology: { role: 'tenant', tenant: '${topology.tenant}' } needs \`connection\`.
1792
+ 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.`);
1793
+ }
1794
+ if (options.store !== void 0) {
1795
+ 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).");
1796
+ }
1797
+ if (options.namespace !== void 0) {
1798
+ 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.");
1799
+ }
1800
+ if (options.partition !== void 0 && options.partition !== topology.tenant) {
1801
+ throw new Error(`topology: { role: 'tenant', tenant: '${topology.tenant}' } conflicts with \`partition: '${options.partition}'\`.
1802
+ tenant maps 1:1 onto partition (the worker queue-routing suffix) \u2014 set only \`tenant\`, or set \`partition\` to the same value.`);
1803
+ }
1804
+ }
1805
+ __name(assertValidTopology, "assertValidTopology");
1806
+ function resolveTopology(options) {
1807
+ const topology = options.topology;
1808
+ if (topology === void 0) return options;
1809
+ if (topology.role === "control-plane") {
1810
+ if (topology.tenant === void 0 || options.namespace !== void 0) return options;
1811
+ return {
1812
+ ...options,
1813
+ namespace: topology.tenant
1814
+ };
1815
+ }
1816
+ if (options.partition !== void 0) return options;
1817
+ return {
1818
+ ...options,
1819
+ partition: topology.tenant
1820
+ };
1821
+ }
1822
+ __name(resolveTopology, "resolveTopology");
1710
1823
  var RunGatewayBootstrap = class RunGatewayBootstrap2 {
1711
1824
  static {
1712
1825
  __name(this, "RunGatewayBootstrap");
@@ -1767,10 +1880,10 @@ var DurableModule = class _DurableModule {
1767
1880
  __name(this, "DurableModule");
1768
1881
  }
1769
1882
  static forRoot(options) {
1770
- assertValidRole(options);
1883
+ _DurableModule.assertValid(options);
1771
1884
  return _DurableModule.build({
1772
1885
  provide: DURABLE_OPTIONS_CANONICAL7,
1773
- useValue: options
1886
+ useValue: resolveTopology(options)
1774
1887
  });
1775
1888
  }
1776
1889
  static forRootAsync(options) {
@@ -1778,12 +1891,26 @@ var DurableModule = class _DurableModule {
1778
1891
  provide: DURABLE_OPTIONS_CANONICAL7,
1779
1892
  useFactory: /* @__PURE__ */ __name(async (...args) => {
1780
1893
  const resolved = await options.useFactory(...args);
1781
- assertValidRole(resolved);
1782
- return resolved;
1894
+ _DurableModule.assertValid(resolved);
1895
+ return resolveTopology(resolved);
1783
1896
  }, "useFactory"),
1784
1897
  inject: options.inject ?? []
1785
1898
  });
1786
1899
  }
1900
+ /**
1901
+ * Validates role/axis constraints, in resolution order. When {@link DurableModuleOptions.topology}
1902
+ * is set, it OWNS validation — {@link assertValidTopology}'s own store/transport/connection checks
1903
+ * are strictly stronger than {@link assertValidRole}'s, so the topology-specific, axis-teaching
1904
+ * message is what a `topology`-opted-in consumer sees (not the older generic one). Falls back to
1905
+ * {@link assertValidRole} unchanged when `topology` is absent — zero behavior change.
1906
+ */
1907
+ static assertValid(options) {
1908
+ if (options.topology !== void 0) {
1909
+ assertValidTopology(options);
1910
+ return;
1911
+ }
1912
+ assertValidRole(options);
1913
+ }
1787
1914
  static build(optionsProvider) {
1788
1915
  return {
1789
1916
  module: _DurableModule,
@@ -1925,6 +2052,9 @@ var DurableModule = class _DurableModule {
1925
2052
  DurableModule = _ts_decorate12([
1926
2053
  Module({})
1927
2054
  ], DurableModule);
2055
+
2056
+ // src/index.ts
2057
+ import { WorkflowEngine as WorkflowEngine7 } from "@dudousxd/nestjs-durable-core";
1928
2058
  export {
1929
2059
  CONTEXT_ACCESSOR,
1930
2060
  DEAD_LETTER_METADATA,
@@ -1961,6 +2091,7 @@ export {
1961
2091
  ThinWorkflowRegistrar,
1962
2092
  WORKFLOW_METADATA,
1963
2093
  Workflow,
2094
+ WorkflowEngine7 as WorkflowEngine,
1964
2095
  WorkflowService,
1965
2096
  entityConfigFor,
1966
2097
  getDurableStepMeta,