@dudousxd/nestjs-durable-core 0.38.0 → 0.40.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.js CHANGED
@@ -1440,6 +1440,14 @@ function isNewerVersion(a, b) {
1440
1440
  }) > 0;
1441
1441
  }
1442
1442
  __name(isNewerVersion, "isNewerVersion");
1443
+ var NamespaceMismatch = class NamespaceMismatch2 extends Error {
1444
+ static {
1445
+ __name(this, "NamespaceMismatch");
1446
+ }
1447
+ constructor() {
1448
+ super("namespace-mismatch");
1449
+ }
1450
+ };
1443
1451
  var WorkflowEngine = class {
1444
1452
  static {
1445
1453
  __name(this, "WorkflowEngine");
@@ -1450,6 +1458,7 @@ var WorkflowEngine = class {
1450
1458
  controlPlane;
1451
1459
  clock;
1452
1460
  instanceId;
1461
+ namespace;
1453
1462
  leaseMs;
1454
1463
  maxRecoveryAttempts;
1455
1464
  remoteAdvanceSilenceMs;
@@ -1516,6 +1525,7 @@ var WorkflowEngine = class {
1516
1525
  this.admission = deps.admission ?? new InMemoryAdmissionBackend(this.clock);
1517
1526
  this.admission.onFreed?.((queue) => this.wakeQueueWaiters(queue));
1518
1527
  this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
1528
+ this.namespace = deps.namespace ?? "default";
1519
1529
  this.leaseMs = deps.leaseMs ?? 3e4;
1520
1530
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
1521
1531
  this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
@@ -1626,8 +1636,21 @@ var WorkflowEngine = class {
1626
1636
  * Register a workflow version. Register multiple versions of the same name to keep in-flight
1627
1637
  * runs working across a breaking change: old runs resume on the version they started on, new
1628
1638
  * runs start on the newest registered version.
1639
+ *
1640
+ * GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
1641
+ * this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
1642
+ * inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
1643
+ * body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
1644
+ * ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
1645
+ * engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
1646
+ * engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
1647
+ * inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
1648
+ * step toward routing every run through a group with no local/remote flag (Phase 3).
1629
1649
  */
1630
1650
  register(name, version, fn, opts) {
1651
+ if (opts?.group == null !== (opts?.executor == null)) {
1652
+ throw new Error(`workflow ${name}@${version}: register({ group, executor }) needs BOTH group and executor (or neither)`);
1653
+ }
1631
1654
  const registered = {
1632
1655
  name,
1633
1656
  version,
@@ -1637,8 +1660,17 @@ var WorkflowEngine = class {
1637
1660
  executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
1638
1661
  validateInput: opts?.validateInput,
1639
1662
  onEvent: opts?.onEvent,
1640
- eventBatch: opts?.eventBatch
1663
+ eventBatch: opts?.eventBatch,
1664
+ // A real, retained in-process body — so `workflowBody` can hand it to an in-app worker, and so
1665
+ // it is NOT confused with the throwing stub `registerRemote` installs for a body-less remote.
1666
+ hasBody: true
1641
1667
  };
1668
+ if (opts?.group != null && opts?.executor != null) {
1669
+ registered.remote = {
1670
+ group: opts.group,
1671
+ executor: opts.executor
1672
+ };
1673
+ }
1642
1674
  this.workflows.set(versionKey(name, version), registered);
1643
1675
  const current = this.latest.get(name);
1644
1676
  if (!current || isNewerVersion(version, current.version)) this.latest.set(name, registered);
@@ -1649,6 +1681,17 @@ var WorkflowEngine = class {
1649
1681
  }
1650
1682
  }
1651
1683
  /**
1684
+ * The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
1685
+ * own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
1686
+ * the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
1687
+ * it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
1688
+ * remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
1689
+ */
1690
+ workflowBody(name, version) {
1691
+ const reg = this.workflows.get(versionKey(name, version));
1692
+ return reg?.hasBody ? reg.fn : void 0;
1693
+ }
1694
+ /**
1652
1695
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
1653
1696
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
1654
1697
  * the run's history to `executor` (which dispatches a {@link WorkflowTask} to the worker) and applying
@@ -1774,7 +1817,11 @@ var WorkflowEngine = class {
1774
1817
  }
1775
1818
  async start(workflow, input, runId, opts) {
1776
1819
  const name = workflowName(workflow);
1777
- const registered = this.latest.get(name);
1820
+ let registered = this.latest.get(name);
1821
+ if (!registered) {
1822
+ const ancestor = await this.findRemoteAncestor(runId);
1823
+ if (ancestor) registered = this.synthesizeRemoteChild(name, ancestor.version, ancestor);
1824
+ }
1778
1825
  if (!registered) throw new Error(`workflow ${name} is not registered`);
1779
1826
  await registered.validateInput?.(input);
1780
1827
  const prior = await this.store.getRun(runId);
@@ -1799,6 +1846,7 @@ var WorkflowEngine = class {
1799
1846
  workflow: name,
1800
1847
  workflowVersion: registered.version,
1801
1848
  status: "pending",
1849
+ namespace: this.namespace,
1802
1850
  input,
1803
1851
  tags,
1804
1852
  searchAttributes: opts?.searchAttributes,
@@ -1835,12 +1883,91 @@ var WorkflowEngine = class {
1835
1883
  error: run.error
1836
1884
  };
1837
1885
  }
1838
- const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
1886
+ if (run.namespace !== void 0 && run.namespace !== this.namespace) {
1887
+ await this.store.releaseRunLock(runId);
1888
+ throw new NamespaceMismatch();
1889
+ }
1890
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
1839
1891
  if (!registered) {
1840
1892
  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
1893
  }
1842
1894
  return this.track(this.execute(run, registered.fn));
1843
1895
  }
1896
+ /**
1897
+ * The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
1898
+ * `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
1899
+ * flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
1900
+ * never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
1901
+ * Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
1902
+ * is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
1903
+ * no redundant registration per child name.
1904
+ *
1905
+ * RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
1906
+ * registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
1907
+ * registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
1908
+ * new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
1909
+ */
1910
+ async findInheritedRegistration(run) {
1911
+ const ancestor = await this.findRemoteAncestor(run.id);
1912
+ if (!ancestor) return void 0;
1913
+ return this.synthesizeRemoteChild(run.workflow, run.workflowVersion, ancestor);
1914
+ }
1915
+ /**
1916
+ * A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
1917
+ * group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
1918
+ * {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
1919
+ * is correct AND required — a second executor on the same group would race the worker for results.
1920
+ * Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
1921
+ * its identity stays its own.
1922
+ */
1923
+ synthesizeRemoteChild(name, version, ancestor) {
1924
+ const remote = ancestor.remote;
1925
+ if (!remote) throw new Error(`workflow ${ancestor.name} is not a remote workflow`);
1926
+ return {
1927
+ name,
1928
+ version,
1929
+ fn: /* @__PURE__ */ __name(() => {
1930
+ throw new Error(`workflow ${name} is remote \u2014 it has no in-process body`);
1931
+ }, "fn"),
1932
+ remote
1933
+ };
1934
+ }
1935
+ /**
1936
+ * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
1937
+ * registration (or `undefined` if there is none). The parent of an awaited child is found via its
1938
+ * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
1939
+ * child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
1940
+ * cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
1941
+ * unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
1942
+ * unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
1943
+ * `visited` guards against a pathological cyclic id graph.
1944
+ */
1945
+ async findRemoteAncestor(childRunId, visited = /* @__PURE__ */ new Set()) {
1946
+ if (visited.has(childRunId)) return void 0;
1947
+ visited.add(childRunId);
1948
+ const parentRunId = await this.findParentRunId(childRunId);
1949
+ if (!parentRunId) return void 0;
1950
+ const parent = await this.store.getRun(parentRunId);
1951
+ if (!parent) return void 0;
1952
+ const parentReg = this.workflows.get(versionKey(parent.workflow, parent.workflowVersion));
1953
+ if (parentReg?.remote) return parentReg;
1954
+ if (parentReg) return void 0;
1955
+ return this.findRemoteAncestor(parent.id, visited);
1956
+ }
1957
+ /**
1958
+ * The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
1959
+ * child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
1960
+ * and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
1961
+ * registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
1962
+ * waiter is consumed), but by then the child is terminal and no longer resolved.
1963
+ */
1964
+ async findParentRunId(childRunId) {
1965
+ const token = `child:${childRunId}`;
1966
+ for (const waiter of await this.store.listSignalWaiters("child:")) {
1967
+ if (waiter.token === token) return waiter.runId;
1968
+ }
1969
+ return void 0;
1970
+ }
1844
1971
  /** Track an in-flight execution so {@link drain} can wait for it. */
1845
1972
  track(p) {
1846
1973
  this.inflight.add(p);
@@ -1878,11 +2005,13 @@ var WorkflowEngine = class {
1878
2005
  const inflight = [
1879
2006
  ...await this.store.listRuns({
1880
2007
  workflow: reg.name,
1881
- status: "running"
2008
+ status: "running",
2009
+ namespace: this.namespace
1882
2010
  }),
1883
2011
  ...await this.store.listRuns({
1884
2012
  workflow: reg.name,
1885
- status: "suspended"
2013
+ status: "suspended",
2014
+ namespace: this.namespace
1886
2015
  })
1887
2016
  ];
1888
2017
  for (const run of inflight) {
@@ -1912,7 +2041,7 @@ var WorkflowEngine = class {
1912
2041
  async recoverIncomplete(nowMs = this.clock()) {
1913
2042
  if (this.draining) return [];
1914
2043
  const results = [];
1915
- for (const run of await this.store.listIncompleteRuns()) {
2044
+ for (const run of await this.store.listIncompleteRuns(this.namespace)) {
1916
2045
  const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
1917
2046
  if (!acquired) continue;
1918
2047
  const settled = await this.countRecovery(run);
@@ -1982,7 +2111,7 @@ var WorkflowEngine = class {
1982
2111
  * boot. A run still not due re-suspends cheaply without running new work.
1983
2112
  */
1984
2113
  async resumeDueTimers(nowMs = this.clock()) {
1985
- return this.resumeLeased(await this.store.listDueTimers(nowMs), nowMs);
2114
+ return this.resumeLeased(await this.store.listDueTimers(nowMs, this.namespace), nowMs);
1986
2115
  }
1987
2116
  /**
1988
2117
  * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
@@ -1995,7 +2124,10 @@ var WorkflowEngine = class {
1995
2124
  const nowMs = this.clock();
1996
2125
  const acquired = await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs);
1997
2126
  if (!acquired) return null;
1998
- return this.resume(runId);
2127
+ return this.resume(runId).catch((err) => {
2128
+ if (err instanceof NamespaceMismatch) return null;
2129
+ throw err;
2130
+ });
1999
2131
  }
2000
2132
  /**
2001
2133
  * Re-enqueue a run for a worker to (re-)execute — the dispatch-model **retry**. Sets it back to
@@ -2041,7 +2173,7 @@ var WorkflowEngine = class {
2041
2173
  * {@link resumeDueTimers}.
2042
2174
  */
2043
2175
  async runPending(nowMs = this.clock()) {
2044
- return this.resumeLeased(await this.store.listPendingRuns(100), nowMs);
2176
+ return this.resumeLeased(await this.store.listPendingRuns(100, this.namespace), nowMs);
2045
2177
  }
2046
2178
  /**
2047
2179
  * Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
@@ -2153,7 +2285,10 @@ var WorkflowEngine = class {
2153
2285
  kind: "signal",
2154
2286
  output: payload
2155
2287
  }));
2156
- return this.resume(waiter.runId);
2288
+ return this.resume(waiter.runId).catch((err) => {
2289
+ if (err instanceof NamespaceMismatch) return null;
2290
+ throw err;
2291
+ });
2157
2292
  }
2158
2293
  /**
2159
2294
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
@@ -2530,7 +2665,7 @@ var WorkflowEngine = class {
2530
2665
  });
2531
2666
  }
2532
2667
  async execute(run, fn) {
2533
- const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
2668
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
2534
2669
  const lockNow = this.clock();
2535
2670
  if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
2536
2671
  if (!await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow)) {
@@ -3307,7 +3442,10 @@ var WorkflowEngine = class {
3307
3442
  queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
3308
3443
  durationMs: finishedAt.getTime() - startedAt.getTime()
3309
3444
  });
3310
- await this.resume(result.runId);
3445
+ await this.resume(result.runId).catch((err) => {
3446
+ if (err instanceof NamespaceMismatch) return null;
3447
+ throw err;
3448
+ });
3311
3449
  }
3312
3450
  /**
3313
3451
  * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
@@ -3605,14 +3743,14 @@ var CodecStateStore = class {
3605
3743
  saveCheckpoint: /* @__PURE__ */ __name((cp) => tx.saveCheckpoint(this.encCp(cp)), "saveCheckpoint")
3606
3744
  }));
3607
3745
  }
3608
- async listIncompleteRuns() {
3609
- return (await this.inner.listIncompleteRuns()).map((r) => this.decRun(r));
3746
+ async listIncompleteRuns(namespace) {
3747
+ return (await this.inner.listIncompleteRuns(namespace)).map((r) => this.decRun(r));
3610
3748
  }
3611
- async listPendingRuns(limit) {
3612
- return (await this.inner.listPendingRuns(limit)).map((r) => this.decRun(r));
3749
+ async listPendingRuns(limit, namespace) {
3750
+ return (await this.inner.listPendingRuns(limit, namespace)).map((r) => this.decRun(r));
3613
3751
  }
3614
- async listDueTimers(nowMs) {
3615
- return (await this.inner.listDueTimers(nowMs)).map((r) => this.decRun(r));
3752
+ async listDueTimers(nowMs, namespace) {
3753
+ return (await this.inner.listDueTimers(nowMs, namespace)).map((r) => this.decRun(r));
3616
3754
  }
3617
3755
  tryLockRun(runId, owner, leaseUntilMs, nowMs) {
3618
3756
  return this.inner.tryLockRun(runId, owner, leaseUntilMs, nowMs);
@@ -3967,7 +4105,8 @@ var InMemoryStateStore = class {
3967
4105
  }
3968
4106
  async createRun(run) {
3969
4107
  this.runs.set(run.id, {
3970
- ...run
4108
+ ...run,
4109
+ namespace: run.namespace ?? "default"
3971
4110
  });
3972
4111
  this.reindexAttributes(run.id, run.searchAttributes);
3973
4112
  }
@@ -4008,24 +4147,24 @@ var InMemoryStateStore = class {
4008
4147
  ...checkpoint
4009
4148
  });
4010
4149
  }
4011
- async listIncompleteRuns() {
4150
+ async listIncompleteRuns(namespace) {
4012
4151
  return [
4013
4152
  ...this.runs.values()
4014
- ].filter((r) => r.status === "running" || r.status === "cancelling").map((r) => ({
4153
+ ].filter((r) => (r.status === "running" || r.status === "cancelling") && (namespace === void 0 || r.namespace === namespace)).map((r) => ({
4015
4154
  ...r
4016
4155
  }));
4017
4156
  }
4018
- async listPendingRuns(limit) {
4157
+ async listPendingRuns(limit, namespace) {
4019
4158
  return [
4020
4159
  ...this.runs.values()
4021
- ].filter((r) => r.status === "pending").sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id)).slice(0, limit).map((r) => ({
4160
+ ].filter((r) => r.status === "pending" && (namespace === void 0 || r.namespace === namespace)).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id)).slice(0, limit).map((r) => ({
4022
4161
  ...r
4023
4162
  }));
4024
4163
  }
4025
- async listDueTimers(nowMs) {
4164
+ async listDueTimers(nowMs, namespace) {
4026
4165
  return [
4027
4166
  ...this.runs.values()
4028
- ].filter((r) => r.status === "suspended" && r.wakeAt !== void 0 && r.wakeAt <= nowMs).map((r) => ({
4167
+ ].filter((r) => r.status === "suspended" && r.wakeAt !== void 0 && r.wakeAt <= nowMs && (namespace === void 0 || r.namespace === namespace)).map((r) => ({
4029
4168
  ...r
4030
4169
  }));
4031
4170
  }
@@ -4140,6 +4279,7 @@ var InMemoryStateStore = class {
4140
4279
  if (query.status) runs = runs.filter((r) => r.status === query.status);
4141
4280
  if (query.statuses) runs = runs.filter((r) => query.statuses?.includes(r.status));
4142
4281
  if (query.tag) runs = runs.filter((r) => r.tags?.includes(query.tag));
4282
+ if (query.namespace !== void 0) runs = runs.filter((r) => r.namespace === query.namespace);
4143
4283
  if (query.attributes?.length) {
4144
4284
  let candidates = null;
4145
4285
  for (const f of query.attributes) {