@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.cjs CHANGED
@@ -1512,6 +1512,14 @@ function isNewerVersion(a, b) {
1512
1512
  }) > 0;
1513
1513
  }
1514
1514
  __name(isNewerVersion, "isNewerVersion");
1515
+ var NamespaceMismatch = class NamespaceMismatch2 extends Error {
1516
+ static {
1517
+ __name(this, "NamespaceMismatch");
1518
+ }
1519
+ constructor() {
1520
+ super("namespace-mismatch");
1521
+ }
1522
+ };
1515
1523
  var WorkflowEngine = class {
1516
1524
  static {
1517
1525
  __name(this, "WorkflowEngine");
@@ -1522,6 +1530,7 @@ var WorkflowEngine = class {
1522
1530
  controlPlane;
1523
1531
  clock;
1524
1532
  instanceId;
1533
+ namespace;
1525
1534
  leaseMs;
1526
1535
  maxRecoveryAttempts;
1527
1536
  remoteAdvanceSilenceMs;
@@ -1588,6 +1597,7 @@ var WorkflowEngine = class {
1588
1597
  this.admission = deps.admission ?? new InMemoryAdmissionBackend(this.clock);
1589
1598
  this.admission.onFreed?.((queue) => this.wakeQueueWaiters(queue));
1590
1599
  this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
1600
+ this.namespace = deps.namespace ?? "default";
1591
1601
  this.leaseMs = deps.leaseMs ?? 3e4;
1592
1602
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
1593
1603
  this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
@@ -1698,8 +1708,21 @@ var WorkflowEngine = class {
1698
1708
  * Register a workflow version. Register multiple versions of the same name to keep in-flight
1699
1709
  * runs working across a breaking change: old runs resume on the version they started on, new
1700
1710
  * runs start on the newest registered version.
1711
+ *
1712
+ * GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
1713
+ * this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
1714
+ * inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
1715
+ * body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
1716
+ * ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
1717
+ * engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
1718
+ * engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
1719
+ * inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
1720
+ * step toward routing every run through a group with no local/remote flag (Phase 3).
1701
1721
  */
1702
1722
  register(name, version, fn, opts) {
1723
+ if (opts?.group == null !== (opts?.executor == null)) {
1724
+ throw new Error(`workflow ${name}@${version}: register({ group, executor }) needs BOTH group and executor (or neither)`);
1725
+ }
1703
1726
  const registered = {
1704
1727
  name,
1705
1728
  version,
@@ -1709,8 +1732,17 @@ var WorkflowEngine = class {
1709
1732
  executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
1710
1733
  validateInput: opts?.validateInput,
1711
1734
  onEvent: opts?.onEvent,
1712
- eventBatch: opts?.eventBatch
1735
+ eventBatch: opts?.eventBatch,
1736
+ // A real, retained in-process body — so `workflowBody` can hand it to an in-app worker, and so
1737
+ // it is NOT confused with the throwing stub `registerRemote` installs for a body-less remote.
1738
+ hasBody: true
1713
1739
  };
1740
+ if (opts?.group != null && opts?.executor != null) {
1741
+ registered.remote = {
1742
+ group: opts.group,
1743
+ executor: opts.executor
1744
+ };
1745
+ }
1714
1746
  this.workflows.set(versionKey(name, version), registered);
1715
1747
  const current = this.latest.get(name);
1716
1748
  if (!current || isNewerVersion(version, current.version)) this.latest.set(name, registered);
@@ -1721,6 +1753,17 @@ var WorkflowEngine = class {
1721
1753
  }
1722
1754
  }
1723
1755
  /**
1756
+ * The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
1757
+ * own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
1758
+ * the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
1759
+ * it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
1760
+ * remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
1761
+ */
1762
+ workflowBody(name, version) {
1763
+ const reg = this.workflows.get(versionKey(name, version));
1764
+ return reg?.hasBody ? reg.fn : void 0;
1765
+ }
1766
+ /**
1724
1767
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
1725
1768
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
1726
1769
  * the run's history to `executor` (which dispatches a {@link WorkflowTask} to the worker) and applying
@@ -1846,7 +1889,11 @@ var WorkflowEngine = class {
1846
1889
  }
1847
1890
  async start(workflow, input, runId, opts) {
1848
1891
  const name = workflowName(workflow);
1849
- const registered = this.latest.get(name);
1892
+ let registered = this.latest.get(name);
1893
+ if (!registered) {
1894
+ const ancestor = await this.findRemoteAncestor(runId);
1895
+ if (ancestor) registered = this.synthesizeRemoteChild(name, ancestor.version, ancestor);
1896
+ }
1850
1897
  if (!registered) throw new Error(`workflow ${name} is not registered`);
1851
1898
  await registered.validateInput?.(input);
1852
1899
  const prior = await this.store.getRun(runId);
@@ -1871,6 +1918,7 @@ var WorkflowEngine = class {
1871
1918
  workflow: name,
1872
1919
  workflowVersion: registered.version,
1873
1920
  status: "pending",
1921
+ namespace: this.namespace,
1874
1922
  input,
1875
1923
  tags,
1876
1924
  searchAttributes: opts?.searchAttributes,
@@ -1907,12 +1955,91 @@ var WorkflowEngine = class {
1907
1955
  error: run.error
1908
1956
  };
1909
1957
  }
1910
- const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
1958
+ if (run.namespace !== void 0 && run.namespace !== this.namespace) {
1959
+ await this.store.releaseRunLock(runId);
1960
+ throw new NamespaceMismatch();
1961
+ }
1962
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
1911
1963
  if (!registered) {
1912
1964
  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)`);
1913
1965
  }
1914
1966
  return this.track(this.execute(run, registered.fn));
1915
1967
  }
1968
+ /**
1969
+ * The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
1970
+ * `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
1971
+ * flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
1972
+ * never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
1973
+ * Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
1974
+ * is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
1975
+ * no redundant registration per child name.
1976
+ *
1977
+ * RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
1978
+ * registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
1979
+ * registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
1980
+ * new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
1981
+ */
1982
+ async findInheritedRegistration(run) {
1983
+ const ancestor = await this.findRemoteAncestor(run.id);
1984
+ if (!ancestor) return void 0;
1985
+ return this.synthesizeRemoteChild(run.workflow, run.workflowVersion, ancestor);
1986
+ }
1987
+ /**
1988
+ * A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
1989
+ * group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
1990
+ * {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
1991
+ * is correct AND required — a second executor on the same group would race the worker for results.
1992
+ * Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
1993
+ * its identity stays its own.
1994
+ */
1995
+ synthesizeRemoteChild(name, version, ancestor) {
1996
+ const remote = ancestor.remote;
1997
+ if (!remote) throw new Error(`workflow ${ancestor.name} is not a remote workflow`);
1998
+ return {
1999
+ name,
2000
+ version,
2001
+ fn: /* @__PURE__ */ __name(() => {
2002
+ throw new Error(`workflow ${name} is remote \u2014 it has no in-process body`);
2003
+ }, "fn"),
2004
+ remote
2005
+ };
2006
+ }
2007
+ /**
2008
+ * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
2009
+ * registration (or `undefined` if there is none). The parent of an awaited child is found via its
2010
+ * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
2011
+ * child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
2012
+ * cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
2013
+ * unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
2014
+ * unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
2015
+ * `visited` guards against a pathological cyclic id graph.
2016
+ */
2017
+ async findRemoteAncestor(childRunId, visited = /* @__PURE__ */ new Set()) {
2018
+ if (visited.has(childRunId)) return void 0;
2019
+ visited.add(childRunId);
2020
+ const parentRunId = await this.findParentRunId(childRunId);
2021
+ if (!parentRunId) return void 0;
2022
+ const parent = await this.store.getRun(parentRunId);
2023
+ if (!parent) return void 0;
2024
+ const parentReg = this.workflows.get(versionKey(parent.workflow, parent.workflowVersion));
2025
+ if (parentReg?.remote) return parentReg;
2026
+ if (parentReg) return void 0;
2027
+ return this.findRemoteAncestor(parent.id, visited);
2028
+ }
2029
+ /**
2030
+ * The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
2031
+ * child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
2032
+ * and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
2033
+ * registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
2034
+ * waiter is consumed), but by then the child is terminal and no longer resolved.
2035
+ */
2036
+ async findParentRunId(childRunId) {
2037
+ const token = `child:${childRunId}`;
2038
+ for (const waiter of await this.store.listSignalWaiters("child:")) {
2039
+ if (waiter.token === token) return waiter.runId;
2040
+ }
2041
+ return void 0;
2042
+ }
1916
2043
  /** Track an in-flight execution so {@link drain} can wait for it. */
1917
2044
  track(p) {
1918
2045
  this.inflight.add(p);
@@ -1950,11 +2077,13 @@ var WorkflowEngine = class {
1950
2077
  const inflight = [
1951
2078
  ...await this.store.listRuns({
1952
2079
  workflow: reg.name,
1953
- status: "running"
2080
+ status: "running",
2081
+ namespace: this.namespace
1954
2082
  }),
1955
2083
  ...await this.store.listRuns({
1956
2084
  workflow: reg.name,
1957
- status: "suspended"
2085
+ status: "suspended",
2086
+ namespace: this.namespace
1958
2087
  })
1959
2088
  ];
1960
2089
  for (const run of inflight) {
@@ -1984,7 +2113,7 @@ var WorkflowEngine = class {
1984
2113
  async recoverIncomplete(nowMs = this.clock()) {
1985
2114
  if (this.draining) return [];
1986
2115
  const results = [];
1987
- for (const run of await this.store.listIncompleteRuns()) {
2116
+ for (const run of await this.store.listIncompleteRuns(this.namespace)) {
1988
2117
  const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
1989
2118
  if (!acquired) continue;
1990
2119
  const settled = await this.countRecovery(run);
@@ -2054,7 +2183,7 @@ var WorkflowEngine = class {
2054
2183
  * boot. A run still not due re-suspends cheaply without running new work.
2055
2184
  */
2056
2185
  async resumeDueTimers(nowMs = this.clock()) {
2057
- return this.resumeLeased(await this.store.listDueTimers(nowMs), nowMs);
2186
+ return this.resumeLeased(await this.store.listDueTimers(nowMs, this.namespace), nowMs);
2058
2187
  }
2059
2188
  /**
2060
2189
  * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
@@ -2067,7 +2196,10 @@ var WorkflowEngine = class {
2067
2196
  const nowMs = this.clock();
2068
2197
  const acquired = await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs);
2069
2198
  if (!acquired) return null;
2070
- return this.resume(runId);
2199
+ return this.resume(runId).catch((err) => {
2200
+ if (err instanceof NamespaceMismatch) return null;
2201
+ throw err;
2202
+ });
2071
2203
  }
2072
2204
  /**
2073
2205
  * Re-enqueue a run for a worker to (re-)execute — the dispatch-model **retry**. Sets it back to
@@ -2113,7 +2245,7 @@ var WorkflowEngine = class {
2113
2245
  * {@link resumeDueTimers}.
2114
2246
  */
2115
2247
  async runPending(nowMs = this.clock()) {
2116
- return this.resumeLeased(await this.store.listPendingRuns(100), nowMs);
2248
+ return this.resumeLeased(await this.store.listPendingRuns(100, this.namespace), nowMs);
2117
2249
  }
2118
2250
  /**
2119
2251
  * Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
@@ -2225,7 +2357,10 @@ var WorkflowEngine = class {
2225
2357
  kind: "signal",
2226
2358
  output: payload
2227
2359
  }));
2228
- return this.resume(waiter.runId);
2360
+ return this.resume(waiter.runId).catch((err) => {
2361
+ if (err instanceof NamespaceMismatch) return null;
2362
+ throw err;
2363
+ });
2229
2364
  }
2230
2365
  /**
2231
2366
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
@@ -2602,7 +2737,7 @@ var WorkflowEngine = class {
2602
2737
  });
2603
2738
  }
2604
2739
  async execute(run, fn) {
2605
- const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
2740
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
2606
2741
  const lockNow = this.clock();
2607
2742
  if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
2608
2743
  if (!await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow)) {
@@ -3379,7 +3514,10 @@ var WorkflowEngine = class {
3379
3514
  queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
3380
3515
  durationMs: finishedAt.getTime() - startedAt.getTime()
3381
3516
  });
3382
- await this.resume(result.runId);
3517
+ await this.resume(result.runId).catch((err) => {
3518
+ if (err instanceof NamespaceMismatch) return null;
3519
+ throw err;
3520
+ });
3383
3521
  }
3384
3522
  /**
3385
3523
  * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
@@ -3677,14 +3815,14 @@ var CodecStateStore = class {
3677
3815
  saveCheckpoint: /* @__PURE__ */ __name((cp) => tx.saveCheckpoint(this.encCp(cp)), "saveCheckpoint")
3678
3816
  }));
3679
3817
  }
3680
- async listIncompleteRuns() {
3681
- return (await this.inner.listIncompleteRuns()).map((r) => this.decRun(r));
3818
+ async listIncompleteRuns(namespace) {
3819
+ return (await this.inner.listIncompleteRuns(namespace)).map((r) => this.decRun(r));
3682
3820
  }
3683
- async listPendingRuns(limit) {
3684
- return (await this.inner.listPendingRuns(limit)).map((r) => this.decRun(r));
3821
+ async listPendingRuns(limit, namespace) {
3822
+ return (await this.inner.listPendingRuns(limit, namespace)).map((r) => this.decRun(r));
3685
3823
  }
3686
- async listDueTimers(nowMs) {
3687
- return (await this.inner.listDueTimers(nowMs)).map((r) => this.decRun(r));
3824
+ async listDueTimers(nowMs, namespace) {
3825
+ return (await this.inner.listDueTimers(nowMs, namespace)).map((r) => this.decRun(r));
3688
3826
  }
3689
3827
  tryLockRun(runId, owner, leaseUntilMs, nowMs) {
3690
3828
  return this.inner.tryLockRun(runId, owner, leaseUntilMs, nowMs);
@@ -4039,7 +4177,8 @@ var InMemoryStateStore = class {
4039
4177
  }
4040
4178
  async createRun(run) {
4041
4179
  this.runs.set(run.id, {
4042
- ...run
4180
+ ...run,
4181
+ namespace: run.namespace ?? "default"
4043
4182
  });
4044
4183
  this.reindexAttributes(run.id, run.searchAttributes);
4045
4184
  }
@@ -4080,24 +4219,24 @@ var InMemoryStateStore = class {
4080
4219
  ...checkpoint
4081
4220
  });
4082
4221
  }
4083
- async listIncompleteRuns() {
4222
+ async listIncompleteRuns(namespace) {
4084
4223
  return [
4085
4224
  ...this.runs.values()
4086
- ].filter((r) => r.status === "running" || r.status === "cancelling").map((r) => ({
4225
+ ].filter((r) => (r.status === "running" || r.status === "cancelling") && (namespace === void 0 || r.namespace === namespace)).map((r) => ({
4087
4226
  ...r
4088
4227
  }));
4089
4228
  }
4090
- async listPendingRuns(limit) {
4229
+ async listPendingRuns(limit, namespace) {
4091
4230
  return [
4092
4231
  ...this.runs.values()
4093
- ].filter((r) => r.status === "pending").sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id)).slice(0, limit).map((r) => ({
4232
+ ].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) => ({
4094
4233
  ...r
4095
4234
  }));
4096
4235
  }
4097
- async listDueTimers(nowMs) {
4236
+ async listDueTimers(nowMs, namespace) {
4098
4237
  return [
4099
4238
  ...this.runs.values()
4100
- ].filter((r) => r.status === "suspended" && r.wakeAt !== void 0 && r.wakeAt <= nowMs).map((r) => ({
4239
+ ].filter((r) => r.status === "suspended" && r.wakeAt !== void 0 && r.wakeAt <= nowMs && (namespace === void 0 || r.namespace === namespace)).map((r) => ({
4101
4240
  ...r
4102
4241
  }));
4103
4242
  }
@@ -4212,6 +4351,7 @@ var InMemoryStateStore = class {
4212
4351
  if (query.status) runs = runs.filter((r) => r.status === query.status);
4213
4352
  if (query.statuses) runs = runs.filter((r) => query.statuses?.includes(r.status));
4214
4353
  if (query.tag) runs = runs.filter((r) => r.tags?.includes(query.tag));
4354
+ if (query.namespace !== void 0) runs = runs.filter((r) => r.namespace === query.namespace);
4215
4355
  if (query.attributes?.length) {
4216
4356
  let candidates = null;
4217
4357
  for (const f of query.attributes) {