@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.cjs CHANGED
@@ -60,6 +60,8 @@ __export(index_exports, {
60
60
  WorkflowBlocked: () => WorkflowBlocked,
61
61
  WorkflowEngine: () => WorkflowEngine,
62
62
  WorkflowSuspended: () => WorkflowSuspended,
63
+ aggregateAnnouncements: () => aggregateAnnouncements,
64
+ announcementsOf: () => announcementsOf,
63
65
  applyAttributeQuery: () => applyAttributeQuery,
64
66
  attributeColumnFor: () => attributeColumnFor,
65
67
  attributeOperand: () => attributeOperand,
@@ -75,6 +77,7 @@ __export(index_exports, {
75
77
  currentStep: () => currentStep,
76
78
  currentWorkflowCtx: () => currentWorkflowCtx,
77
79
  debug: () => debug,
80
+ describeWorker: () => describeWorker,
78
81
  descriptorHash: () => descriptorHash,
79
82
  error: () => error,
80
83
  eventMatchOf: () => eventMatchOf,
@@ -1103,6 +1106,9 @@ function normalizeDescriptor(raw) {
1103
1106
  ],
1104
1107
  workflows: raw.workflows ?? [],
1105
1108
  steps: raw.steps ?? [],
1109
+ ...raw.registrations !== void 0 ? {
1110
+ registrations: raw.registrations
1111
+ } : {},
1106
1112
  ...raw.partition !== void 0 ? {
1107
1113
  partition: raw.partition
1108
1114
  } : {},
@@ -1113,6 +1119,23 @@ function normalizeDescriptor(raw) {
1113
1119
  };
1114
1120
  }
1115
1121
  __name(normalizeDescriptor, "normalizeDescriptor");
1122
+ function canonicalizeRegistrations(regs) {
1123
+ const canonical = regs.map((r) => ({
1124
+ name: r.name,
1125
+ version: r.version ?? null,
1126
+ group: r.group ?? null,
1127
+ requires: r.requires ? [
1128
+ ...new Set(r.requires)
1129
+ ].sort() : null,
1130
+ origin: r.origin ?? null
1131
+ }));
1132
+ const byJson = /* @__PURE__ */ new Map();
1133
+ for (const entry of canonical) byJson.set(JSON.stringify(entry), entry);
1134
+ return [
1135
+ ...byJson.keys()
1136
+ ].sort().map((k) => byJson.get(k));
1137
+ }
1138
+ __name(canonicalizeRegistrations, "canonicalizeRegistrations");
1116
1139
  function canonicalizeForHash(d) {
1117
1140
  const set = /* @__PURE__ */ __name((xs) => [
1118
1141
  ...new Set(xs)
@@ -1134,6 +1157,9 @@ function canonicalizeForHash(d) {
1134
1157
  capabilities: set(d.capabilities),
1135
1158
  workflows: set(d.workflows),
1136
1159
  steps: set(d.steps),
1160
+ ...d.registrations !== void 0 ? {
1161
+ registrations: canonicalizeRegistrations(d.registrations)
1162
+ } : {},
1137
1163
  partition: d.partition ?? null,
1138
1164
  namespace: d.namespace ?? null,
1139
1165
  startedAt: d.startedAt
@@ -1166,6 +1192,144 @@ function heartbeatStatus(descriptor, opts = {}) {
1166
1192
  };
1167
1193
  }
1168
1194
  __name(heartbeatStatus, "heartbeatStatus");
1195
+ function describeWorker(opts) {
1196
+ const version = CURRENT_PROTOCOL_VERSION;
1197
+ const registrations = [
1198
+ ...opts.registrations ?? []
1199
+ ].sort((a, b) => `${a.name}@${a.version ?? ""}`.localeCompare(`${b.name}@${b.version ?? ""}`));
1200
+ return {
1201
+ instanceId: opts.instanceId,
1202
+ runtime: opts.runtime,
1203
+ sdk: {
1204
+ name: opts.sdk.name,
1205
+ version: opts.sdk.version
1206
+ },
1207
+ protocol: {
1208
+ version,
1209
+ range: [
1210
+ version,
1211
+ version
1212
+ ]
1213
+ },
1214
+ capabilities: [
1215
+ ...LEGACY_V1_CAPABILITIES
1216
+ ],
1217
+ workflows: [
1218
+ ...opts.workflows ?? []
1219
+ ].sort(),
1220
+ steps: [
1221
+ ...opts.steps
1222
+ ].sort(),
1223
+ ...registrations.length > 0 ? {
1224
+ registrations
1225
+ } : {},
1226
+ ...opts.partition ? {
1227
+ partition: opts.partition
1228
+ } : {},
1229
+ ...opts.namespace && opts.namespace !== "default" ? {
1230
+ namespace: opts.namespace
1231
+ } : {},
1232
+ startedAt: opts.startedAt
1233
+ };
1234
+ }
1235
+ __name(describeWorker, "describeWorker");
1236
+
1237
+ // src/handshake/announced.ts
1238
+ function announcementsOf(raw) {
1239
+ const d = normalizeDescriptor(raw);
1240
+ const identity = {
1241
+ instanceId: d.instanceId,
1242
+ runtime: d.runtime
1243
+ };
1244
+ const announcements = (d.registrations ?? []).map((reg) => ({
1245
+ ...reg,
1246
+ ...identity
1247
+ }));
1248
+ const covered = new Set(announcements.map((a) => a.name));
1249
+ for (const name of d.workflows) {
1250
+ if (!covered.has(name)) announcements.push({
1251
+ name,
1252
+ ...identity
1253
+ });
1254
+ }
1255
+ return announcements;
1256
+ }
1257
+ __name(announcementsOf, "announcementsOf");
1258
+ function announcementKey(a) {
1259
+ return a.version === void 0 ? a.name : `${a.name}@${a.version}`;
1260
+ }
1261
+ __name(announcementKey, "announcementKey");
1262
+ function stated(values) {
1263
+ return [
1264
+ ...new Set(values.filter((v) => v !== void 0))
1265
+ ].sort();
1266
+ }
1267
+ __name(stated, "stated");
1268
+ function aggregateAnnouncements(descriptors) {
1269
+ const byKey = /* @__PURE__ */ new Map();
1270
+ for (const descriptor of descriptors) {
1271
+ for (const announcement of announcementsOf(descriptor)) {
1272
+ const key = announcementKey(announcement);
1273
+ const bucket = byKey.get(key);
1274
+ if (bucket) bucket.push(announcement);
1275
+ else byKey.set(key, [
1276
+ announcement
1277
+ ]);
1278
+ }
1279
+ }
1280
+ const out = [];
1281
+ for (const [key, announcements] of byKey) {
1282
+ const groups = stated(announcements.map((a) => a.group));
1283
+ const origins = stated(announcements.map((a) => a.origin));
1284
+ const requiresByJson = /* @__PURE__ */ new Map();
1285
+ for (const a of announcements) {
1286
+ if (a.requires === void 0) continue;
1287
+ const canonical = [
1288
+ ...new Set(a.requires)
1289
+ ].sort();
1290
+ requiresByJson.set(JSON.stringify(canonical), canonical);
1291
+ }
1292
+ const requires = [
1293
+ ...requiresByJson.values()
1294
+ ].sort((x, y) => x.join(",").localeCompare(y.join(",")));
1295
+ const disagreements = [];
1296
+ if (groups.length > 1) disagreements.push({
1297
+ axis: "group",
1298
+ values: groups
1299
+ });
1300
+ if (origins.length > 1) disagreements.push({
1301
+ axis: "origin",
1302
+ values: origins
1303
+ });
1304
+ if (requires.length > 1) {
1305
+ disagreements.push({
1306
+ axis: "requires",
1307
+ values: requires.map((r) => r.join(","))
1308
+ });
1309
+ }
1310
+ const first = announcements[0];
1311
+ if (!first) continue;
1312
+ out.push({
1313
+ key,
1314
+ name: first.name,
1315
+ ...first.version !== void 0 ? {
1316
+ version: first.version
1317
+ } : {},
1318
+ groups,
1319
+ origins,
1320
+ requires,
1321
+ runtimes: [
1322
+ ...new Set(announcements.map((a) => a.runtime))
1323
+ ].sort(),
1324
+ instances: [
1325
+ ...new Set(announcements.map((a) => a.instanceId))
1326
+ ].sort(),
1327
+ disagreements
1328
+ });
1329
+ }
1330
+ return out.sort((a, b) => a.key.localeCompare(b.key));
1331
+ }
1332
+ __name(aggregateAnnouncements, "aggregateAnnouncements");
1169
1333
 
1170
1334
  // src/handshake/negotiate.ts
1171
1335
  var rangesOverlap = /* @__PURE__ */ __name((a, b) => Math.max(a[0], b[0]) <= Math.min(a[1], b[1]), "rangesOverlap");
@@ -1693,6 +1857,30 @@ var TransportPool = class {
1693
1857
  }
1694
1858
  return descriptors;
1695
1859
  }
1860
+ /**
1861
+ * Every live descriptor in the deployment, merged across every transport that can report them and
1862
+ * de-duplicated by `instanceId` — one worker publishes the same descriptor under each token it
1863
+ * consumes, and it must count once. Returns `[]` when no transport implements
1864
+ * {@link Transport.readAllWorkerDescriptors} (a pure in-process pool advertises nothing).
1865
+ *
1866
+ * First writer wins on a duplicate id: the copies are the same bytes by construction (one process,
1867
+ * one descriptor, published N times), so there is nothing to choose between. Two DIFFERENT
1868
+ * processes sharing an `instanceId` would collapse into one here — that is a misconfiguration the
1869
+ * fleet already suffers from (they also share heartbeat and descriptor keys, so they overwrite each
1870
+ * other's advertisement), not something this read can repair.
1871
+ */
1872
+ async readAllWorkerDescriptors() {
1873
+ const byInstance = /* @__PURE__ */ new Map();
1874
+ for (const { transport } of this.transports) {
1875
+ if (!transport.readAllWorkerDescriptors) continue;
1876
+ for (const descriptor of await transport.readAllWorkerDescriptors()) {
1877
+ if (!byInstance.has(descriptor.instanceId)) byInstance.set(descriptor.instanceId, descriptor);
1878
+ }
1879
+ }
1880
+ return [
1881
+ ...byInstance.values()
1882
+ ];
1883
+ }
1696
1884
  /** Distinct worker groups with a live heartbeat, merged across every transport that can report it. */
1697
1885
  async listWorkerGroups() {
1698
1886
  const groups = /* @__PURE__ */ new Set();
@@ -2093,7 +2281,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2093
2281
  return unwrapCompletion(await waitForSignal(`task:${runId}:${name}`), `task "${name}"`);
2094
2282
  }, "task");
2095
2283
  const child = /* @__PURE__ */ __name(async (workflow2, input, options) => {
2096
- const { childId, priority } = normalizeChildOptions(options);
2284
+ const { childId, priority, version } = normalizeChildOptions(options);
2097
2285
  const current = pos.next();
2098
2286
  const id = childId ?? `${runId}.child.${current}`;
2099
2287
  const existing = await readCheckpoint(current);
@@ -2121,7 +2309,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2121
2309
  }));
2122
2310
  return unwrapCompletion(buffered.payload, `child "${id}"`);
2123
2311
  }
2124
- if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
2312
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority, version);
2125
2313
  if (!existing) {
2126
2314
  await writeCheckpoint(instantCheckpoint({
2127
2315
  runId,
@@ -2204,12 +2392,12 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2204
2392
  return outputs;
2205
2393
  }, "all");
2206
2394
  const startChild = /* @__PURE__ */ __name(async (workflow2, input, options) => {
2207
- const { childId, priority } = normalizeChildOptions(options);
2395
+ const { childId, priority, version } = normalizeChildOptions(options);
2208
2396
  const current = pos.next();
2209
2397
  const id = childId ?? `${runId}.child.${current}`;
2210
2398
  const existing = await readCheckpoint(current);
2211
2399
  if (existing && existing.status === "completed") return existing.output;
2212
- if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
2400
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority, version);
2213
2401
  await writeCheckpoint(instantCheckpoint({
2214
2402
  runId,
2215
2403
  seq: current,
@@ -2769,6 +2957,15 @@ var WorkflowEngine = class {
2769
2957
  const reg = this.workflows.get(versionKey(name, version));
2770
2958
  return reg?.hasBody ? reg.fn : void 0;
2771
2959
  }
2960
+ /** The registered versions of `name`, for the pinned-start error — "2 is not registered" is a
2961
+ * guess-the-typo error without them, and the answer (which versions this deploy actually carries)
2962
+ * is right here. `none` when the name is unknown entirely. */
2963
+ registeredVersions(name) {
2964
+ const versions = [
2965
+ ...this.workflows.values()
2966
+ ].filter((w) => w.name === name).map((w) => w.version);
2967
+ return versions.length ? versions.join(", ") : "none";
2968
+ }
2772
2969
  /**
2773
2970
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
2774
2971
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
@@ -2918,12 +3115,13 @@ var WorkflowEngine = class {
2918
3115
  }
2919
3116
  async start(workflow, input, runId, opts) {
2920
3117
  const name = workflowName(workflow);
2921
- let registered = this.latest.get(name);
2922
- if (!registered) {
3118
+ const pinned = opts?.version;
3119
+ let registered = pinned === void 0 ? this.latest.get(name) : this.workflows.get(versionKey(name, pinned));
3120
+ if (!registered && pinned === void 0) {
2923
3121
  const ancestor = await this.findRemoteAncestor(runId);
2924
3122
  if (ancestor) registered = this.synthesizeRemoteChild(name, ancestor.version, ancestor);
2925
3123
  }
2926
- if (!registered) {
3124
+ if (!registered && pinned === void 0) {
2927
3125
  const conventionRun = {
2928
3126
  id: runId,
2929
3127
  workflow: name,
@@ -2940,7 +3138,9 @@ var WorkflowEngine = class {
2940
3138
  };
2941
3139
  registered = await this.resolveRemoteByConvention(conventionRun);
2942
3140
  }
2943
- if (!registered) throw new Error(`workflow ${name} is not registered`);
3141
+ if (!registered) {
3142
+ 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)})`);
3143
+ }
2944
3144
  await registered.validateInput?.(input);
2945
3145
  const prior = await this.store.getRun(runId);
2946
3146
  if (prior) {
@@ -3147,7 +3347,32 @@ var WorkflowEngine = class {
3147
3347
  /**
3148
3348
  * Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
3149
3349
  * timer poller alongside {@link resumeDueTimers}. A timed-out run is moved to `cancelled` with an
3150
- * `execution_timeout` error (terminal, so a late step result can't resurrect it).
3350
+ * `execution_timeout` error (terminal, so a late step result can't resurrect it), AND its children
3351
+ * are cascaded exactly as an explicit {@link cancel} cascades them.
3352
+ *
3353
+ * WHY THE TERMINAL WRITE IS DIRECT, not a call to {@link cancel}. Two reasons, both still standing:
3354
+ * the run must land with the `execution_timeout` error `cancel` knows nothing about (`cancel` writes
3355
+ * a plain `cancelled`, and the code is what a dashboard/alert distinguishes a timeout by), and this
3356
+ * is a SCAN — it walks every in-flight run of every timed workflow on every poller tick, so it must
3357
+ * not pay `cancel`'s per-run re-read and singleton/control-plane work on runs it isn't cancelling.
3358
+ * The cascade below is charged only to the runs that ACTUALLY timed out, which are rare by
3359
+ * construction; the scan itself is unchanged.
3360
+ *
3361
+ * The subtree, though, has no reason to differ, and before this it did: a timed-out parent's
3362
+ * children kept running with the parent gone and nothing pointing at them — a silent orphan you
3363
+ * could only find by reading the runs table by hand. {@link cancelChildren} is the SAME recursive
3364
+ * walk `cancel` uses, so children of children go too, and it inherits that path's two guards:
3365
+ * `cancel` returns early on a run that is already `completed`/`cancelled`/`dead` (a child that beat
3366
+ * the sweep to the finish is never clobbered, and a re-cancel is a no-op), and it writes the
3367
+ * parent's terminal status BEFORE recursing, so a cyclic parent→child graph terminates on the
3368
+ * second visit instead of looping. Two pollers sweeping the same run concurrently therefore write
3369
+ * the same terminal state twice and cascade into already-cancelled children — idempotent, not
3370
+ * corrupting. The one window it does NOT close is the pre-existing check-then-write race an
3371
+ * explicit `cancel` has always had (a child that completes between `cancel`'s read and its write);
3372
+ * closing that needs a compare-and-set in the store contract, not a change here.
3373
+ *
3374
+ * Cascade failures are swallowed per subtree for the same reason `cancelChildren` swallows them per
3375
+ * child: one unreachable child must not abort the sweep and leave every LATER timed-out run alive.
3151
3376
  */
3152
3377
  async sweepTimeouts(now = this.clock()) {
3153
3378
  for (const reg of new Set(this.latest.values())) {
@@ -3183,6 +3408,7 @@ var WorkflowEngine = class {
3183
3408
  namespace: run.namespace,
3184
3409
  error: error2
3185
3410
  });
3411
+ await this.cancelChildren(run.id).catch(() => void 0);
3186
3412
  }
3187
3413
  }
3188
3414
  }
@@ -3911,6 +4137,10 @@ var WorkflowEngine = class {
3911
4137
  * `child:<id>` waiter) and fire-and-forget (`ctx.startChild`, found via its `spawn:<id>`
3912
4138
  * checkpoint). Recursive, so a whole subtree is cancelled; the terminal guard in `cancel` stops it
3913
4139
  * at finished / already-cancelled runs (no loops, no re-cancel).
4140
+ *
4141
+ * Two callers, deliberately the same walk: an explicit {@link cancel}, and {@link sweepTimeouts}
4142
+ * when a parent hits its `executionTimeout` — a timed-out parent's subtree dies the same way an
4143
+ * explicitly cancelled one's does, or the children are orphans nobody is holding a reference to.
3914
4144
  */
3915
4145
  async cancelChildren(parentRunId, opts) {
3916
4146
  for (const id of await this.getRunChildren(parentRunId)) {
@@ -3990,6 +4220,29 @@ var WorkflowEngine = class {
3990
4220
  return out;
3991
4221
  }
3992
4222
  /**
4223
+ * What workflows the LIVE fleet announces it can execute — the deployment-wide registry a picker
4224
+ * ("call this existing workflow") needs, and the one question this engine's own registry cannot
4225
+ * answer. {@link workflowBody} answers only for this process, and a missing body is ambiguous: it
4226
+ * means "not registered here", but equally "registered via {@link registerRemote} against another
4227
+ * SDK" or "a group resolved by convention against a worker". An inferred answer would therefore
4228
+ * differ per replica. This one does not: every pod folds the same published announcements.
4229
+ *
4230
+ * Scoped like every other poll surface — an engine WITH a `namespace` sees only workers in it, and
4231
+ * an operator (namespace unset) sees the whole deployment ("ver tudo = ausência de namespace"). A
4232
+ * descriptor that states no namespace is `default`, matching how a single-tenant fleet publishes.
4233
+ *
4234
+ * Cost: one advertisement scan per CALL, on demand. Nothing is added to the poll loop and nothing
4235
+ * is retained — no pod holds a table of the fleet's registrations. Empty when no transport can
4236
+ * introspect the advertisement keyspace (a pure in-process transport advertises nothing).
4237
+ *
4238
+ * Workflows only, and deliberately: see the "steps are out of scope" note in `handshake/announced`.
4239
+ */
4240
+ async announcedWorkflows() {
4241
+ const descriptors = await this.pool.readAllWorkerDescriptors();
4242
+ const mine = this.namespace === void 0 ? descriptors : descriptors.filter((d) => (d.namespace ?? "default") === this.namespace);
4243
+ return aggregateAnnouncements(mine);
4244
+ }
4245
+ /**
3993
4246
  * The routing groups of every in-flight run's still-`pending` remote step — the queues where work is
3994
4247
  * dispatched and awaiting a worker. Folded into {@link workerHealth} so a step waiting on a group
3995
4248
  * that is NEITHER a registration NOR heartbeating (the exact shape of a run stuck on an offline
@@ -5068,10 +5321,13 @@ var WorkflowEngine = class {
5068
5321
  // namespace) is the one executing the parent. Otherwise an operator recovery-resuming a
5069
5322
  // `davi-local` pipeline would stamp its `processing` child `default` and it would leak off the
5070
5323
  // tenant's worker pool onto the shared/dev workers.
5071
- startChild: /* @__PURE__ */ __name((workflow, input, id, priority) => {
5324
+ startChild: /* @__PURE__ */ __name((workflow, input, id, priority, version) => {
5072
5325
  this.startChildDeferred(workflow, input, id, {
5073
5326
  priority,
5074
- namespace: parentNamespace
5327
+ namespace: parentNamespace,
5328
+ // Absent unless the body pinned one (`ctx.child(ref, input, { version })`) — an omitted
5329
+ // pin must stay omitted, so the child keeps resolving `latest` exactly as before.
5330
+ version
5075
5331
  });
5076
5332
  }, "startChild"),
5077
5333
  // Deferred for the same reentrancy reason as `startChild` above. `cancel()` is already
@@ -6480,6 +6736,8 @@ var InMemoryTransport = class {
6480
6736
  WorkflowBlocked,
6481
6737
  WorkflowEngine,
6482
6738
  WorkflowSuspended,
6739
+ aggregateAnnouncements,
6740
+ announcementsOf,
6483
6741
  applyAttributeQuery,
6484
6742
  attributeColumnFor,
6485
6743
  attributeOperand,
@@ -6495,6 +6753,7 @@ var InMemoryTransport = class {
6495
6753
  currentStep,
6496
6754
  currentWorkflowCtx,
6497
6755
  debug,
6756
+ describeWorker,
6498
6757
  descriptorHash,
6499
6758
  error,
6500
6759
  eventMatchOf,