@dudousxd/nestjs-durable-core 0.38.0 → 0.39.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
@@ -1503,6 +1503,16 @@ declare class WorkflowEngine {
1503
1503
  * Register a workflow version. Register multiple versions of the same name to keep in-flight
1504
1504
  * runs working across a breaking change: old runs resume on the version they started on, new
1505
1505
  * runs start on the newest registered version.
1506
+ *
1507
+ * GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
1508
+ * this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
1509
+ * inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
1510
+ * body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
1511
+ * ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
1512
+ * engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
1513
+ * engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
1514
+ * inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
1515
+ * step toward routing every run through a group with no local/remote flag (Phase 3).
1506
1516
  */
1507
1517
  register(name: string, version: string, fn: WorkflowFn, opts?: {
1508
1518
  tags?: string[] | undefined;
@@ -1511,7 +1521,22 @@ declare class WorkflowEngine {
1511
1521
  validateInput?: ((input: unknown) => void | Promise<void>) | undefined;
1512
1522
  onEvent?: string[] | undefined;
1513
1523
  eventBatch?: EventBatchConfig | undefined;
1524
+ /** Worker group to DISPATCH this workflow's turns to (uniform dispatch). Requires `executor`.
1525
+ * Omit for the default inline fast path. See the method doc. */
1526
+ group?: string | undefined;
1527
+ /** Advances a turn by dispatching a {@link WorkflowTask} to `group`. Requires `group`. For an
1528
+ * in-app worker, pair a transport-backed {@link RemoteWorkflowExecutor} (group consumed by a
1529
+ * worker that calls {@link workflowBody}) so the retained `fn` runs through the transport hop. */
1530
+ executor?: WorkflowExecutor | undefined;
1514
1531
  }): void;
1532
+ /**
1533
+ * The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
1534
+ * own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
1535
+ * the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
1536
+ * it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
1537
+ * remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
1538
+ */
1539
+ workflowBody(name: string, version: string): WorkflowFn | undefined;
1515
1540
  /**
1516
1541
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
1517
1542
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
@@ -1575,6 +1600,49 @@ declare class WorkflowEngine {
1575
1600
  /** Read a run's current persisted state (or null if unknown). A thin pass-through to the store. */
1576
1601
  getRun(runId: string): Promise<WorkflowRun | null>;
1577
1602
  resume(runId: string): Promise<RunResult>;
1603
+ /**
1604
+ * The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
1605
+ * `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
1606
+ * flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
1607
+ * never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
1608
+ * Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
1609
+ * is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
1610
+ * no redundant registration per child name.
1611
+ *
1612
+ * RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
1613
+ * registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
1614
+ * registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
1615
+ * new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
1616
+ */
1617
+ private findInheritedRegistration;
1618
+ /**
1619
+ * A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
1620
+ * group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
1621
+ * {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
1622
+ * is correct AND required — a second executor on the same group would race the worker for results.
1623
+ * Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
1624
+ * its identity stays its own.
1625
+ */
1626
+ private synthesizeRemoteChild;
1627
+ /**
1628
+ * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
1629
+ * registration (or `undefined` if there is none). The parent of an awaited child is found via its
1630
+ * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
1631
+ * child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
1632
+ * cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
1633
+ * unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
1634
+ * unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
1635
+ * `visited` guards against a pathological cyclic id graph.
1636
+ */
1637
+ private findRemoteAncestor;
1638
+ /**
1639
+ * The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
1640
+ * child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
1641
+ * and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
1642
+ * registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
1643
+ * waiter is consumed), but by then the child is terminal and no longer resolved.
1644
+ */
1645
+ private findParentRunId;
1578
1646
  /** Track an in-flight execution so {@link drain} can wait for it. */
1579
1647
  private track;
1580
1648
  /**
package/dist/index.d.ts CHANGED
@@ -1503,6 +1503,16 @@ declare class WorkflowEngine {
1503
1503
  * Register a workflow version. Register multiple versions of the same name to keep in-flight
1504
1504
  * runs working across a breaking change: old runs resume on the version they started on, new
1505
1505
  * runs start on the newest registered version.
1506
+ *
1507
+ * GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
1508
+ * this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
1509
+ * inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
1510
+ * body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
1511
+ * ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
1512
+ * engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
1513
+ * engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
1514
+ * inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
1515
+ * step toward routing every run through a group with no local/remote flag (Phase 3).
1506
1516
  */
1507
1517
  register(name: string, version: string, fn: WorkflowFn, opts?: {
1508
1518
  tags?: string[] | undefined;
@@ -1511,7 +1521,22 @@ declare class WorkflowEngine {
1511
1521
  validateInput?: ((input: unknown) => void | Promise<void>) | undefined;
1512
1522
  onEvent?: string[] | undefined;
1513
1523
  eventBatch?: EventBatchConfig | undefined;
1524
+ /** Worker group to DISPATCH this workflow's turns to (uniform dispatch). Requires `executor`.
1525
+ * Omit for the default inline fast path. See the method doc. */
1526
+ group?: string | undefined;
1527
+ /** Advances a turn by dispatching a {@link WorkflowTask} to `group`. Requires `group`. For an
1528
+ * in-app worker, pair a transport-backed {@link RemoteWorkflowExecutor} (group consumed by a
1529
+ * worker that calls {@link workflowBody}) so the retained `fn` runs through the transport hop. */
1530
+ executor?: WorkflowExecutor | undefined;
1514
1531
  }): void;
1532
+ /**
1533
+ * The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
1534
+ * own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
1535
+ * the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
1536
+ * it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
1537
+ * remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
1538
+ */
1539
+ workflowBody(name: string, version: string): WorkflowFn | undefined;
1515
1540
  /**
1516
1541
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
1517
1542
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
@@ -1575,6 +1600,49 @@ declare class WorkflowEngine {
1575
1600
  /** Read a run's current persisted state (or null if unknown). A thin pass-through to the store. */
1576
1601
  getRun(runId: string): Promise<WorkflowRun | null>;
1577
1602
  resume(runId: string): Promise<RunResult>;
1603
+ /**
1604
+ * The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
1605
+ * `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
1606
+ * flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
1607
+ * never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
1608
+ * Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
1609
+ * is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
1610
+ * no redundant registration per child name.
1611
+ *
1612
+ * RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
1613
+ * registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
1614
+ * registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
1615
+ * new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
1616
+ */
1617
+ private findInheritedRegistration;
1618
+ /**
1619
+ * A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
1620
+ * group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
1621
+ * {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
1622
+ * is correct AND required — a second executor on the same group would race the worker for results.
1623
+ * Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
1624
+ * its identity stays its own.
1625
+ */
1626
+ private synthesizeRemoteChild;
1627
+ /**
1628
+ * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
1629
+ * registration (or `undefined` if there is none). The parent of an awaited child is found via its
1630
+ * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
1631
+ * child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
1632
+ * cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
1633
+ * unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
1634
+ * unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
1635
+ * `visited` guards against a pathological cyclic id graph.
1636
+ */
1637
+ private findRemoteAncestor;
1638
+ /**
1639
+ * The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
1640
+ * child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
1641
+ * and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
1642
+ * registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
1643
+ * waiter is consumed), but by then the child is terminal and no longer resolved.
1644
+ */
1645
+ private findParentRunId;
1578
1646
  /** Track an in-flight execution so {@link drain} can wait for it. */
1579
1647
  private track;
1580
1648
  /**
package/dist/index.js CHANGED
@@ -1626,8 +1626,21 @@ var WorkflowEngine = class {
1626
1626
  * Register a workflow version. Register multiple versions of the same name to keep in-flight
1627
1627
  * runs working across a breaking change: old runs resume on the version they started on, new
1628
1628
  * runs start on the newest registered version.
1629
+ *
1630
+ * GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
1631
+ * this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
1632
+ * inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
1633
+ * body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
1634
+ * ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
1635
+ * engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
1636
+ * engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
1637
+ * inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
1638
+ * step toward routing every run through a group with no local/remote flag (Phase 3).
1629
1639
  */
1630
1640
  register(name, version, fn, opts) {
1641
+ if (opts?.group == null !== (opts?.executor == null)) {
1642
+ throw new Error(`workflow ${name}@${version}: register({ group, executor }) needs BOTH group and executor (or neither)`);
1643
+ }
1631
1644
  const registered = {
1632
1645
  name,
1633
1646
  version,
@@ -1637,8 +1650,17 @@ var WorkflowEngine = class {
1637
1650
  executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
1638
1651
  validateInput: opts?.validateInput,
1639
1652
  onEvent: opts?.onEvent,
1640
- eventBatch: opts?.eventBatch
1653
+ eventBatch: opts?.eventBatch,
1654
+ // A real, retained in-process body — so `workflowBody` can hand it to an in-app worker, and so
1655
+ // it is NOT confused with the throwing stub `registerRemote` installs for a body-less remote.
1656
+ hasBody: true
1641
1657
  };
1658
+ if (opts?.group != null && opts?.executor != null) {
1659
+ registered.remote = {
1660
+ group: opts.group,
1661
+ executor: opts.executor
1662
+ };
1663
+ }
1642
1664
  this.workflows.set(versionKey(name, version), registered);
1643
1665
  const current = this.latest.get(name);
1644
1666
  if (!current || isNewerVersion(version, current.version)) this.latest.set(name, registered);
@@ -1649,6 +1671,17 @@ var WorkflowEngine = class {
1649
1671
  }
1650
1672
  }
1651
1673
  /**
1674
+ * The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
1675
+ * own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
1676
+ * the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
1677
+ * it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
1678
+ * remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
1679
+ */
1680
+ workflowBody(name, version) {
1681
+ const reg = this.workflows.get(versionKey(name, version));
1682
+ return reg?.hasBody ? reg.fn : void 0;
1683
+ }
1684
+ /**
1652
1685
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
1653
1686
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
1654
1687
  * the run's history to `executor` (which dispatches a {@link WorkflowTask} to the worker) and applying
@@ -1774,7 +1807,11 @@ var WorkflowEngine = class {
1774
1807
  }
1775
1808
  async start(workflow, input, runId, opts) {
1776
1809
  const name = workflowName(workflow);
1777
- const registered = this.latest.get(name);
1810
+ let registered = this.latest.get(name);
1811
+ if (!registered) {
1812
+ const ancestor = await this.findRemoteAncestor(runId);
1813
+ if (ancestor) registered = this.synthesizeRemoteChild(name, ancestor.version, ancestor);
1814
+ }
1778
1815
  if (!registered) throw new Error(`workflow ${name} is not registered`);
1779
1816
  await registered.validateInput?.(input);
1780
1817
  const prior = await this.store.getRun(runId);
@@ -1835,12 +1872,87 @@ var WorkflowEngine = class {
1835
1872
  error: run.error
1836
1873
  };
1837
1874
  }
1838
- const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
1875
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
1839
1876
  if (!registered) {
1840
1877
  throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered \u2014 keep the prior version deployed so in-flight runs can drain (skew protection)`);
1841
1878
  }
1842
1879
  return this.track(this.execute(run, registered.fn));
1843
1880
  }
1881
+ /**
1882
+ * The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
1883
+ * `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
1884
+ * flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
1885
+ * never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
1886
+ * Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
1887
+ * is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
1888
+ * no redundant registration per child name.
1889
+ *
1890
+ * RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
1891
+ * registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
1892
+ * registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
1893
+ * new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
1894
+ */
1895
+ async findInheritedRegistration(run) {
1896
+ const ancestor = await this.findRemoteAncestor(run.id);
1897
+ if (!ancestor) return void 0;
1898
+ return this.synthesizeRemoteChild(run.workflow, run.workflowVersion, ancestor);
1899
+ }
1900
+ /**
1901
+ * A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
1902
+ * group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
1903
+ * {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
1904
+ * is correct AND required — a second executor on the same group would race the worker for results.
1905
+ * Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
1906
+ * its identity stays its own.
1907
+ */
1908
+ synthesizeRemoteChild(name, version, ancestor) {
1909
+ const remote = ancestor.remote;
1910
+ if (!remote) throw new Error(`workflow ${ancestor.name} is not a remote workflow`);
1911
+ return {
1912
+ name,
1913
+ version,
1914
+ fn: /* @__PURE__ */ __name(() => {
1915
+ throw new Error(`workflow ${name} is remote \u2014 it has no in-process body`);
1916
+ }, "fn"),
1917
+ remote
1918
+ };
1919
+ }
1920
+ /**
1921
+ * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
1922
+ * registration (or `undefined` if there is none). The parent of an awaited child is found via its
1923
+ * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
1924
+ * child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
1925
+ * cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
1926
+ * unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
1927
+ * unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
1928
+ * `visited` guards against a pathological cyclic id graph.
1929
+ */
1930
+ async findRemoteAncestor(childRunId, visited = /* @__PURE__ */ new Set()) {
1931
+ if (visited.has(childRunId)) return void 0;
1932
+ visited.add(childRunId);
1933
+ const parentRunId = await this.findParentRunId(childRunId);
1934
+ if (!parentRunId) return void 0;
1935
+ const parent = await this.store.getRun(parentRunId);
1936
+ if (!parent) return void 0;
1937
+ const parentReg = this.workflows.get(versionKey(parent.workflow, parent.workflowVersion));
1938
+ if (parentReg?.remote) return parentReg;
1939
+ if (parentReg) return void 0;
1940
+ return this.findRemoteAncestor(parent.id, visited);
1941
+ }
1942
+ /**
1943
+ * The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
1944
+ * child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
1945
+ * and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
1946
+ * registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
1947
+ * waiter is consumed), but by then the child is terminal and no longer resolved.
1948
+ */
1949
+ async findParentRunId(childRunId) {
1950
+ const token = `child:${childRunId}`;
1951
+ for (const waiter of await this.store.listSignalWaiters("child:")) {
1952
+ if (waiter.token === token) return waiter.runId;
1953
+ }
1954
+ return void 0;
1955
+ }
1844
1956
  /** Track an in-flight execution so {@link drain} can wait for it. */
1845
1957
  track(p) {
1846
1958
  this.inflight.add(p);
@@ -2530,7 +2642,7 @@ var WorkflowEngine = class {
2530
2642
  });
2531
2643
  }
2532
2644
  async execute(run, fn) {
2533
- const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
2645
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
2534
2646
  const lockNow = this.clock();
2535
2647
  if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
2536
2648
  if (!await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow)) {