@dudousxd/nestjs-durable-core 0.37.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.cjs CHANGED
@@ -1434,9 +1434,28 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1434
1434
  const now = /* @__PURE__ */ __name(() => step("now", async () => host.clock()), "now");
1435
1435
  const random = /* @__PURE__ */ __name(() => step("random", async () => Math.random()), "random");
1436
1436
  const uuid = /* @__PURE__ */ __name(() => step("uuid", async () => globalThis.crypto.randomUUID()), "uuid");
1437
+ const upsertSearchAttributes = /* @__PURE__ */ __name(async (attrs) => {
1438
+ const current = pos.next();
1439
+ const existing = await readCheckpoint(current);
1440
+ if (existing) {
1441
+ if (existing.name !== "searchAttributes") {
1442
+ throw new NonDeterminismError(runId, current, "searchAttributes", existing.name);
1443
+ }
1444
+ return;
1445
+ }
1446
+ await host.upsertSearchAttributes(runId, attrs);
1447
+ await writeCheckpoint(instantCheckpoint({
1448
+ runId,
1449
+ seq: current,
1450
+ name: "searchAttributes",
1451
+ kind: "local",
1452
+ output: attrs
1453
+ }));
1454
+ }, "upsertSearchAttributes");
1437
1455
  return {
1438
1456
  runId,
1439
1457
  step,
1458
+ upsertSearchAttributes,
1440
1459
  transaction,
1441
1460
  callEntity,
1442
1461
  signalEntity,
@@ -1679,8 +1698,21 @@ var WorkflowEngine = class {
1679
1698
  * Register a workflow version. Register multiple versions of the same name to keep in-flight
1680
1699
  * runs working across a breaking change: old runs resume on the version they started on, new
1681
1700
  * runs start on the newest registered version.
1701
+ *
1702
+ * GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
1703
+ * this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
1704
+ * inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
1705
+ * body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
1706
+ * ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
1707
+ * engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
1708
+ * engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
1709
+ * inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
1710
+ * step toward routing every run through a group with no local/remote flag (Phase 3).
1682
1711
  */
1683
1712
  register(name, version, fn, opts) {
1713
+ if (opts?.group == null !== (opts?.executor == null)) {
1714
+ throw new Error(`workflow ${name}@${version}: register({ group, executor }) needs BOTH group and executor (or neither)`);
1715
+ }
1684
1716
  const registered = {
1685
1717
  name,
1686
1718
  version,
@@ -1690,8 +1722,17 @@ var WorkflowEngine = class {
1690
1722
  executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
1691
1723
  validateInput: opts?.validateInput,
1692
1724
  onEvent: opts?.onEvent,
1693
- eventBatch: opts?.eventBatch
1725
+ eventBatch: opts?.eventBatch,
1726
+ // A real, retained in-process body — so `workflowBody` can hand it to an in-app worker, and so
1727
+ // it is NOT confused with the throwing stub `registerRemote` installs for a body-less remote.
1728
+ hasBody: true
1694
1729
  };
1730
+ if (opts?.group != null && opts?.executor != null) {
1731
+ registered.remote = {
1732
+ group: opts.group,
1733
+ executor: opts.executor
1734
+ };
1735
+ }
1695
1736
  this.workflows.set(versionKey(name, version), registered);
1696
1737
  const current = this.latest.get(name);
1697
1738
  if (!current || isNewerVersion(version, current.version)) this.latest.set(name, registered);
@@ -1702,6 +1743,17 @@ var WorkflowEngine = class {
1702
1743
  }
1703
1744
  }
1704
1745
  /**
1746
+ * The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
1747
+ * own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
1748
+ * the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
1749
+ * it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
1750
+ * remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
1751
+ */
1752
+ workflowBody(name, version) {
1753
+ const reg = this.workflows.get(versionKey(name, version));
1754
+ return reg?.hasBody ? reg.fn : void 0;
1755
+ }
1756
+ /**
1705
1757
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
1706
1758
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
1707
1759
  * the run's history to `executor` (which dispatches a {@link WorkflowTask} to the worker) and applying
@@ -1827,7 +1879,11 @@ var WorkflowEngine = class {
1827
1879
  }
1828
1880
  async start(workflow, input, runId, opts) {
1829
1881
  const name = workflowName(workflow);
1830
- const registered = this.latest.get(name);
1882
+ let registered = this.latest.get(name);
1883
+ if (!registered) {
1884
+ const ancestor = await this.findRemoteAncestor(runId);
1885
+ if (ancestor) registered = this.synthesizeRemoteChild(name, ancestor.version, ancestor);
1886
+ }
1831
1887
  if (!registered) throw new Error(`workflow ${name} is not registered`);
1832
1888
  await registered.validateInput?.(input);
1833
1889
  const prior = await this.store.getRun(runId);
@@ -1888,12 +1944,87 @@ var WorkflowEngine = class {
1888
1944
  error: run.error
1889
1945
  };
1890
1946
  }
1891
- const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
1947
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
1892
1948
  if (!registered) {
1893
1949
  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)`);
1894
1950
  }
1895
1951
  return this.track(this.execute(run, registered.fn));
1896
1952
  }
1953
+ /**
1954
+ * The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
1955
+ * `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
1956
+ * flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
1957
+ * never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
1958
+ * Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
1959
+ * is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
1960
+ * no redundant registration per child name.
1961
+ *
1962
+ * RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
1963
+ * registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
1964
+ * registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
1965
+ * new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
1966
+ */
1967
+ async findInheritedRegistration(run) {
1968
+ const ancestor = await this.findRemoteAncestor(run.id);
1969
+ if (!ancestor) return void 0;
1970
+ return this.synthesizeRemoteChild(run.workflow, run.workflowVersion, ancestor);
1971
+ }
1972
+ /**
1973
+ * A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
1974
+ * group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
1975
+ * {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
1976
+ * is correct AND required — a second executor on the same group would race the worker for results.
1977
+ * Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
1978
+ * its identity stays its own.
1979
+ */
1980
+ synthesizeRemoteChild(name, version, ancestor) {
1981
+ const remote = ancestor.remote;
1982
+ if (!remote) throw new Error(`workflow ${ancestor.name} is not a remote workflow`);
1983
+ return {
1984
+ name,
1985
+ version,
1986
+ fn: /* @__PURE__ */ __name(() => {
1987
+ throw new Error(`workflow ${name} is remote \u2014 it has no in-process body`);
1988
+ }, "fn"),
1989
+ remote
1990
+ };
1991
+ }
1992
+ /**
1993
+ * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
1994
+ * registration (or `undefined` if there is none). The parent of an awaited child is found via its
1995
+ * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
1996
+ * child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
1997
+ * cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
1998
+ * unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
1999
+ * unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
2000
+ * `visited` guards against a pathological cyclic id graph.
2001
+ */
2002
+ async findRemoteAncestor(childRunId, visited = /* @__PURE__ */ new Set()) {
2003
+ if (visited.has(childRunId)) return void 0;
2004
+ visited.add(childRunId);
2005
+ const parentRunId = await this.findParentRunId(childRunId);
2006
+ if (!parentRunId) return void 0;
2007
+ const parent = await this.store.getRun(parentRunId);
2008
+ if (!parent) return void 0;
2009
+ const parentReg = this.workflows.get(versionKey(parent.workflow, parent.workflowVersion));
2010
+ if (parentReg?.remote) return parentReg;
2011
+ if (parentReg) return void 0;
2012
+ return this.findRemoteAncestor(parent.id, visited);
2013
+ }
2014
+ /**
2015
+ * The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
2016
+ * child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
2017
+ * and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
2018
+ * registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
2019
+ * waiter is consumed), but by then the child is terminal and no longer resolved.
2020
+ */
2021
+ async findParentRunId(childRunId) {
2022
+ const token = `child:${childRunId}`;
2023
+ for (const waiter of await this.store.listSignalWaiters("child:")) {
2024
+ if (waiter.token === token) return waiter.runId;
2025
+ }
2026
+ return void 0;
2027
+ }
1897
2028
  /** Track an in-flight execution so {@link drain} can wait for it. */
1898
2029
  track(p) {
1899
2030
  this.inflight.add(p);
@@ -2583,7 +2714,7 @@ var WorkflowEngine = class {
2583
2714
  });
2584
2715
  }
2585
2716
  async execute(run, fn) {
2586
- const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
2717
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
2587
2718
  const lockNow = this.clock();
2588
2719
  if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
2589
2720
  if (!await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow)) {
@@ -3165,6 +3296,17 @@ var WorkflowEngine = class {
3165
3296
  priority
3166
3297
  }).catch(() => void 0));
3167
3298
  }, "startChild"),
3299
+ // Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
3300
+ upsertSearchAttributes: /* @__PURE__ */ __name(async (runId, attrs) => {
3301
+ const run = await this.store.getRun(runId);
3302
+ await this.store.updateRun(runId, {
3303
+ searchAttributes: {
3304
+ ...run?.searchAttributes ?? {},
3305
+ ...attrs
3306
+ },
3307
+ updatedAt: /* @__PURE__ */ new Date()
3308
+ });
3309
+ }, "upsertSearchAttributes"),
3168
3310
  signalEntity: /* @__PURE__ */ __name((name, key, op, arg, reply) => {
3169
3311
  queueMicrotask(() => void this.entities.dispatch(name, key, op, arg, reply).catch(() => void 0));
3170
3312
  }, "signalEntity"),