@dudousxd/nestjs-durable-core 0.64.0 → 0.65.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
@@ -981,6 +981,9 @@ function normalizeDescriptor(raw) {
981
981
  ],
982
982
  workflows: raw.workflows ?? [],
983
983
  steps: raw.steps ?? [],
984
+ ...raw.registrations !== void 0 ? {
985
+ registrations: raw.registrations
986
+ } : {},
984
987
  ...raw.partition !== void 0 ? {
985
988
  partition: raw.partition
986
989
  } : {},
@@ -991,6 +994,23 @@ function normalizeDescriptor(raw) {
991
994
  };
992
995
  }
993
996
  __name(normalizeDescriptor, "normalizeDescriptor");
997
+ function canonicalizeRegistrations(regs) {
998
+ const canonical = regs.map((r) => ({
999
+ name: r.name,
1000
+ version: r.version ?? null,
1001
+ group: r.group ?? null,
1002
+ requires: r.requires ? [
1003
+ ...new Set(r.requires)
1004
+ ].sort() : null,
1005
+ origin: r.origin ?? null
1006
+ }));
1007
+ const byJson = /* @__PURE__ */ new Map();
1008
+ for (const entry of canonical) byJson.set(JSON.stringify(entry), entry);
1009
+ return [
1010
+ ...byJson.keys()
1011
+ ].sort().map((k) => byJson.get(k));
1012
+ }
1013
+ __name(canonicalizeRegistrations, "canonicalizeRegistrations");
994
1014
  function canonicalizeForHash(d) {
995
1015
  const set = /* @__PURE__ */ __name((xs) => [
996
1016
  ...new Set(xs)
@@ -1012,6 +1032,9 @@ function canonicalizeForHash(d) {
1012
1032
  capabilities: set(d.capabilities),
1013
1033
  workflows: set(d.workflows),
1014
1034
  steps: set(d.steps),
1035
+ ...d.registrations !== void 0 ? {
1036
+ registrations: canonicalizeRegistrations(d.registrations)
1037
+ } : {},
1015
1038
  partition: d.partition ?? null,
1016
1039
  namespace: d.namespace ?? null,
1017
1040
  startedAt: d.startedAt
@@ -1044,6 +1067,144 @@ function heartbeatStatus(descriptor, opts = {}) {
1044
1067
  };
1045
1068
  }
1046
1069
  __name(heartbeatStatus, "heartbeatStatus");
1070
+ function describeWorker(opts) {
1071
+ const version = CURRENT_PROTOCOL_VERSION;
1072
+ const registrations = [
1073
+ ...opts.registrations ?? []
1074
+ ].sort((a, b) => `${a.name}@${a.version ?? ""}`.localeCompare(`${b.name}@${b.version ?? ""}`));
1075
+ return {
1076
+ instanceId: opts.instanceId,
1077
+ runtime: opts.runtime,
1078
+ sdk: {
1079
+ name: opts.sdk.name,
1080
+ version: opts.sdk.version
1081
+ },
1082
+ protocol: {
1083
+ version,
1084
+ range: [
1085
+ version,
1086
+ version
1087
+ ]
1088
+ },
1089
+ capabilities: [
1090
+ ...LEGACY_V1_CAPABILITIES
1091
+ ],
1092
+ workflows: [
1093
+ ...opts.workflows ?? []
1094
+ ].sort(),
1095
+ steps: [
1096
+ ...opts.steps
1097
+ ].sort(),
1098
+ ...registrations.length > 0 ? {
1099
+ registrations
1100
+ } : {},
1101
+ ...opts.partition ? {
1102
+ partition: opts.partition
1103
+ } : {},
1104
+ ...opts.namespace && opts.namespace !== "default" ? {
1105
+ namespace: opts.namespace
1106
+ } : {},
1107
+ startedAt: opts.startedAt
1108
+ };
1109
+ }
1110
+ __name(describeWorker, "describeWorker");
1111
+
1112
+ // src/handshake/announced.ts
1113
+ function announcementsOf(raw) {
1114
+ const d = normalizeDescriptor(raw);
1115
+ const identity = {
1116
+ instanceId: d.instanceId,
1117
+ runtime: d.runtime
1118
+ };
1119
+ const announcements = (d.registrations ?? []).map((reg) => ({
1120
+ ...reg,
1121
+ ...identity
1122
+ }));
1123
+ const covered = new Set(announcements.map((a) => a.name));
1124
+ for (const name of d.workflows) {
1125
+ if (!covered.has(name)) announcements.push({
1126
+ name,
1127
+ ...identity
1128
+ });
1129
+ }
1130
+ return announcements;
1131
+ }
1132
+ __name(announcementsOf, "announcementsOf");
1133
+ function announcementKey(a) {
1134
+ return a.version === void 0 ? a.name : `${a.name}@${a.version}`;
1135
+ }
1136
+ __name(announcementKey, "announcementKey");
1137
+ function stated(values) {
1138
+ return [
1139
+ ...new Set(values.filter((v) => v !== void 0))
1140
+ ].sort();
1141
+ }
1142
+ __name(stated, "stated");
1143
+ function aggregateAnnouncements(descriptors) {
1144
+ const byKey = /* @__PURE__ */ new Map();
1145
+ for (const descriptor of descriptors) {
1146
+ for (const announcement of announcementsOf(descriptor)) {
1147
+ const key = announcementKey(announcement);
1148
+ const bucket = byKey.get(key);
1149
+ if (bucket) bucket.push(announcement);
1150
+ else byKey.set(key, [
1151
+ announcement
1152
+ ]);
1153
+ }
1154
+ }
1155
+ const out = [];
1156
+ for (const [key, announcements] of byKey) {
1157
+ const groups = stated(announcements.map((a) => a.group));
1158
+ const origins = stated(announcements.map((a) => a.origin));
1159
+ const requiresByJson = /* @__PURE__ */ new Map();
1160
+ for (const a of announcements) {
1161
+ if (a.requires === void 0) continue;
1162
+ const canonical = [
1163
+ ...new Set(a.requires)
1164
+ ].sort();
1165
+ requiresByJson.set(JSON.stringify(canonical), canonical);
1166
+ }
1167
+ const requires = [
1168
+ ...requiresByJson.values()
1169
+ ].sort((x, y) => x.join(",").localeCompare(y.join(",")));
1170
+ const disagreements = [];
1171
+ if (groups.length > 1) disagreements.push({
1172
+ axis: "group",
1173
+ values: groups
1174
+ });
1175
+ if (origins.length > 1) disagreements.push({
1176
+ axis: "origin",
1177
+ values: origins
1178
+ });
1179
+ if (requires.length > 1) {
1180
+ disagreements.push({
1181
+ axis: "requires",
1182
+ values: requires.map((r) => r.join(","))
1183
+ });
1184
+ }
1185
+ const first = announcements[0];
1186
+ if (!first) continue;
1187
+ out.push({
1188
+ key,
1189
+ name: first.name,
1190
+ ...first.version !== void 0 ? {
1191
+ version: first.version
1192
+ } : {},
1193
+ groups,
1194
+ origins,
1195
+ requires,
1196
+ runtimes: [
1197
+ ...new Set(announcements.map((a) => a.runtime))
1198
+ ].sort(),
1199
+ instances: [
1200
+ ...new Set(announcements.map((a) => a.instanceId))
1201
+ ].sort(),
1202
+ disagreements
1203
+ });
1204
+ }
1205
+ return out.sort((a, b) => a.key.localeCompare(b.key));
1206
+ }
1207
+ __name(aggregateAnnouncements, "aggregateAnnouncements");
1047
1208
 
1048
1209
  // src/handshake/negotiate.ts
1049
1210
  var rangesOverlap = /* @__PURE__ */ __name((a, b) => Math.max(a[0], b[0]) <= Math.min(a[1], b[1]), "rangesOverlap");
@@ -1571,6 +1732,30 @@ var TransportPool = class {
1571
1732
  }
1572
1733
  return descriptors;
1573
1734
  }
1735
+ /**
1736
+ * Every live descriptor in the deployment, merged across every transport that can report them and
1737
+ * de-duplicated by `instanceId` — one worker publishes the same descriptor under each token it
1738
+ * consumes, and it must count once. Returns `[]` when no transport implements
1739
+ * {@link Transport.readAllWorkerDescriptors} (a pure in-process pool advertises nothing).
1740
+ *
1741
+ * First writer wins on a duplicate id: the copies are the same bytes by construction (one process,
1742
+ * one descriptor, published N times), so there is nothing to choose between. Two DIFFERENT
1743
+ * processes sharing an `instanceId` would collapse into one here — that is a misconfiguration the
1744
+ * fleet already suffers from (they also share heartbeat and descriptor keys, so they overwrite each
1745
+ * other's advertisement), not something this read can repair.
1746
+ */
1747
+ async readAllWorkerDescriptors() {
1748
+ const byInstance = /* @__PURE__ */ new Map();
1749
+ for (const { transport } of this.transports) {
1750
+ if (!transport.readAllWorkerDescriptors) continue;
1751
+ for (const descriptor of await transport.readAllWorkerDescriptors()) {
1752
+ if (!byInstance.has(descriptor.instanceId)) byInstance.set(descriptor.instanceId, descriptor);
1753
+ }
1754
+ }
1755
+ return [
1756
+ ...byInstance.values()
1757
+ ];
1758
+ }
1574
1759
  /** Distinct worker groups with a live heartbeat, merged across every transport that can report it. */
1575
1760
  async listWorkerGroups() {
1576
1761
  const groups = /* @__PURE__ */ new Set();
@@ -1971,7 +2156,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1971
2156
  return unwrapCompletion(await waitForSignal(`task:${runId}:${name}`), `task "${name}"`);
1972
2157
  }, "task");
1973
2158
  const child = /* @__PURE__ */ __name(async (workflow2, input, options) => {
1974
- const { childId, priority } = normalizeChildOptions(options);
2159
+ const { childId, priority, version } = normalizeChildOptions(options);
1975
2160
  const current = pos.next();
1976
2161
  const id = childId ?? `${runId}.child.${current}`;
1977
2162
  const existing = await readCheckpoint(current);
@@ -1999,7 +2184,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1999
2184
  }));
2000
2185
  return unwrapCompletion(buffered.payload, `child "${id}"`);
2001
2186
  }
2002
- if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
2187
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority, version);
2003
2188
  if (!existing) {
2004
2189
  await writeCheckpoint(instantCheckpoint({
2005
2190
  runId,
@@ -2082,12 +2267,12 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2082
2267
  return outputs;
2083
2268
  }, "all");
2084
2269
  const startChild = /* @__PURE__ */ __name(async (workflow2, input, options) => {
2085
- const { childId, priority } = normalizeChildOptions(options);
2270
+ const { childId, priority, version } = normalizeChildOptions(options);
2086
2271
  const current = pos.next();
2087
2272
  const id = childId ?? `${runId}.child.${current}`;
2088
2273
  const existing = await readCheckpoint(current);
2089
2274
  if (existing && existing.status === "completed") return existing.output;
2090
- if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
2275
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority, version);
2091
2276
  await writeCheckpoint(instantCheckpoint({
2092
2277
  runId,
2093
2278
  seq: current,
@@ -2647,6 +2832,15 @@ var WorkflowEngine = class {
2647
2832
  const reg = this.workflows.get(versionKey(name, version));
2648
2833
  return reg?.hasBody ? reg.fn : void 0;
2649
2834
  }
2835
+ /** The registered versions of `name`, for the pinned-start error — "2 is not registered" is a
2836
+ * guess-the-typo error without them, and the answer (which versions this deploy actually carries)
2837
+ * is right here. `none` when the name is unknown entirely. */
2838
+ registeredVersions(name) {
2839
+ const versions = [
2840
+ ...this.workflows.values()
2841
+ ].filter((w) => w.name === name).map((w) => w.version);
2842
+ return versions.length ? versions.join(", ") : "none";
2843
+ }
2650
2844
  /**
2651
2845
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
2652
2846
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
@@ -2796,12 +2990,13 @@ var WorkflowEngine = class {
2796
2990
  }
2797
2991
  async start(workflow, input, runId, opts) {
2798
2992
  const name = workflowName(workflow);
2799
- let registered = this.latest.get(name);
2800
- if (!registered) {
2993
+ const pinned = opts?.version;
2994
+ let registered = pinned === void 0 ? this.latest.get(name) : this.workflows.get(versionKey(name, pinned));
2995
+ if (!registered && pinned === void 0) {
2801
2996
  const ancestor = await this.findRemoteAncestor(runId);
2802
2997
  if (ancestor) registered = this.synthesizeRemoteChild(name, ancestor.version, ancestor);
2803
2998
  }
2804
- if (!registered) {
2999
+ if (!registered && pinned === void 0) {
2805
3000
  const conventionRun = {
2806
3001
  id: runId,
2807
3002
  workflow: name,
@@ -2818,7 +3013,9 @@ var WorkflowEngine = class {
2818
3013
  };
2819
3014
  registered = await this.resolveRemoteByConvention(conventionRun);
2820
3015
  }
2821
- if (!registered) throw new Error(`workflow ${name} is not registered`);
3016
+ if (!registered) {
3017
+ throw new Error(pinned === void 0 ? `workflow ${name} is not registered` : `workflow ${versionKey(name, pinned)} is not registered \u2014 a pinned start never falls back to the newest version (registered: ${this.registeredVersions(name)})`);
3018
+ }
2822
3019
  await registered.validateInput?.(input);
2823
3020
  const prior = await this.store.getRun(runId);
2824
3021
  if (prior) {
@@ -3025,7 +3222,32 @@ var WorkflowEngine = class {
3025
3222
  /**
3026
3223
  * Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
3027
3224
  * timer poller alongside {@link resumeDueTimers}. A timed-out run is moved to `cancelled` with an
3028
- * `execution_timeout` error (terminal, so a late step result can't resurrect it).
3225
+ * `execution_timeout` error (terminal, so a late step result can't resurrect it), AND its children
3226
+ * are cascaded exactly as an explicit {@link cancel} cascades them.
3227
+ *
3228
+ * WHY THE TERMINAL WRITE IS DIRECT, not a call to {@link cancel}. Two reasons, both still standing:
3229
+ * the run must land with the `execution_timeout` error `cancel` knows nothing about (`cancel` writes
3230
+ * a plain `cancelled`, and the code is what a dashboard/alert distinguishes a timeout by), and this
3231
+ * is a SCAN — it walks every in-flight run of every timed workflow on every poller tick, so it must
3232
+ * not pay `cancel`'s per-run re-read and singleton/control-plane work on runs it isn't cancelling.
3233
+ * The cascade below is charged only to the runs that ACTUALLY timed out, which are rare by
3234
+ * construction; the scan itself is unchanged.
3235
+ *
3236
+ * The subtree, though, has no reason to differ, and before this it did: a timed-out parent's
3237
+ * children kept running with the parent gone and nothing pointing at them — a silent orphan you
3238
+ * could only find by reading the runs table by hand. {@link cancelChildren} is the SAME recursive
3239
+ * walk `cancel` uses, so children of children go too, and it inherits that path's two guards:
3240
+ * `cancel` returns early on a run that is already `completed`/`cancelled`/`dead` (a child that beat
3241
+ * the sweep to the finish is never clobbered, and a re-cancel is a no-op), and it writes the
3242
+ * parent's terminal status BEFORE recursing, so a cyclic parent→child graph terminates on the
3243
+ * second visit instead of looping. Two pollers sweeping the same run concurrently therefore write
3244
+ * the same terminal state twice and cascade into already-cancelled children — idempotent, not
3245
+ * corrupting. The one window it does NOT close is the pre-existing check-then-write race an
3246
+ * explicit `cancel` has always had (a child that completes between `cancel`'s read and its write);
3247
+ * closing that needs a compare-and-set in the store contract, not a change here.
3248
+ *
3249
+ * Cascade failures are swallowed per subtree for the same reason `cancelChildren` swallows them per
3250
+ * child: one unreachable child must not abort the sweep and leave every LATER timed-out run alive.
3029
3251
  */
3030
3252
  async sweepTimeouts(now = this.clock()) {
3031
3253
  for (const reg of new Set(this.latest.values())) {
@@ -3061,6 +3283,7 @@ var WorkflowEngine = class {
3061
3283
  namespace: run.namespace,
3062
3284
  error: error2
3063
3285
  });
3286
+ await this.cancelChildren(run.id).catch(() => void 0);
3064
3287
  }
3065
3288
  }
3066
3289
  }
@@ -3789,6 +4012,10 @@ var WorkflowEngine = class {
3789
4012
  * `child:<id>` waiter) and fire-and-forget (`ctx.startChild`, found via its `spawn:<id>`
3790
4013
  * checkpoint). Recursive, so a whole subtree is cancelled; the terminal guard in `cancel` stops it
3791
4014
  * at finished / already-cancelled runs (no loops, no re-cancel).
4015
+ *
4016
+ * Two callers, deliberately the same walk: an explicit {@link cancel}, and {@link sweepTimeouts}
4017
+ * when a parent hits its `executionTimeout` — a timed-out parent's subtree dies the same way an
4018
+ * explicitly cancelled one's does, or the children are orphans nobody is holding a reference to.
3792
4019
  */
3793
4020
  async cancelChildren(parentRunId, opts) {
3794
4021
  for (const id of await this.getRunChildren(parentRunId)) {
@@ -3868,6 +4095,29 @@ var WorkflowEngine = class {
3868
4095
  return out;
3869
4096
  }
3870
4097
  /**
4098
+ * What workflows the LIVE fleet announces it can execute — the deployment-wide registry a picker
4099
+ * ("call this existing workflow") needs, and the one question this engine's own registry cannot
4100
+ * answer. {@link workflowBody} answers only for this process, and a missing body is ambiguous: it
4101
+ * means "not registered here", but equally "registered via {@link registerRemote} against another
4102
+ * SDK" or "a group resolved by convention against a worker". An inferred answer would therefore
4103
+ * differ per replica. This one does not: every pod folds the same published announcements.
4104
+ *
4105
+ * Scoped like every other poll surface — an engine WITH a `namespace` sees only workers in it, and
4106
+ * an operator (namespace unset) sees the whole deployment ("ver tudo = ausência de namespace"). A
4107
+ * descriptor that states no namespace is `default`, matching how a single-tenant fleet publishes.
4108
+ *
4109
+ * Cost: one advertisement scan per CALL, on demand. Nothing is added to the poll loop and nothing
4110
+ * is retained — no pod holds a table of the fleet's registrations. Empty when no transport can
4111
+ * introspect the advertisement keyspace (a pure in-process transport advertises nothing).
4112
+ *
4113
+ * Workflows only, and deliberately: see the "steps are out of scope" note in `handshake/announced`.
4114
+ */
4115
+ async announcedWorkflows() {
4116
+ const descriptors = await this.pool.readAllWorkerDescriptors();
4117
+ const mine = this.namespace === void 0 ? descriptors : descriptors.filter((d) => (d.namespace ?? "default") === this.namespace);
4118
+ return aggregateAnnouncements(mine);
4119
+ }
4120
+ /**
3871
4121
  * The routing groups of every in-flight run's still-`pending` remote step — the queues where work is
3872
4122
  * dispatched and awaiting a worker. Folded into {@link workerHealth} so a step waiting on a group
3873
4123
  * that is NEITHER a registration NOR heartbeating (the exact shape of a run stuck on an offline
@@ -4946,10 +5196,13 @@ var WorkflowEngine = class {
4946
5196
  // namespace) is the one executing the parent. Otherwise an operator recovery-resuming a
4947
5197
  // `davi-local` pipeline would stamp its `processing` child `default` and it would leak off the
4948
5198
  // tenant's worker pool onto the shared/dev workers.
4949
- startChild: /* @__PURE__ */ __name((workflow, input, id, priority) => {
5199
+ startChild: /* @__PURE__ */ __name((workflow, input, id, priority, version) => {
4950
5200
  this.startChildDeferred(workflow, input, id, {
4951
5201
  priority,
4952
- namespace: parentNamespace
5202
+ namespace: parentNamespace,
5203
+ // Absent unless the body pinned one (`ctx.child(ref, input, { version })`) — an omitted
5204
+ // pin must stay omitted, so the child keeps resolving `latest` exactly as before.
5205
+ version
4953
5206
  });
4954
5207
  }, "startChild"),
4955
5208
  // Deferred for the same reentrancy reason as `startChild` above. `cancel()` is already
@@ -6357,6 +6610,8 @@ export {
6357
6610
  WorkflowBlocked,
6358
6611
  WorkflowEngine,
6359
6612
  WorkflowSuspended,
6613
+ aggregateAnnouncements,
6614
+ announcementsOf,
6360
6615
  applyAttributeQuery,
6361
6616
  attributeColumnFor,
6362
6617
  attributeOperand,
@@ -6372,6 +6627,7 @@ export {
6372
6627
  currentStep,
6373
6628
  currentWorkflowCtx,
6374
6629
  debug,
6630
+ describeWorker,
6375
6631
  descriptorHash,
6376
6632
  error,
6377
6633
  eventMatchOf,