@dudousxd/nestjs-durable-core 0.53.0 → 0.54.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
@@ -783,10 +783,16 @@ var EventAccumulators = class {
783
783
  // src/events.ts
784
784
  var enc = /* @__PURE__ */ __name((s) => Buffer.from(s, "utf8").toString("base64"), "enc");
785
785
  var dec = /* @__PURE__ */ __name((s) => Buffer.from(s, "base64").toString("utf8"), "dec");
786
+ var EVENT_BUFFER_SCAN_LIMIT = 50;
787
+ var EVENT_TOKEN_PREFIX = "event:";
786
788
  function eventPrefix(name) {
787
- return `event:${enc(name)}:`;
789
+ return `${EVENT_TOKEN_PREFIX}${enc(name)}:`;
788
790
  }
789
791
  __name(eventPrefix, "eventPrefix");
792
+ function isEventToken(token) {
793
+ return token.startsWith(EVENT_TOKEN_PREFIX);
794
+ }
795
+ __name(isEventToken, "isEventToken");
790
796
  function eventToken(name, match, runId, seq) {
791
797
  return `${eventPrefix(name)}${enc(JSON.stringify(match ?? {}))}:${runId}#${seq}`;
792
798
  }
@@ -869,7 +875,7 @@ var RemoteWorkflowExecutor = class {
869
875
  var WEBHOOK_PREFIX = "wh:";
870
876
  var CHILD_PREFIX = "child:";
871
877
  var BREAKPOINT_PREFIX = "bp:";
872
- var EVENT_PREFIX = "event:";
878
+ var EVENT_PREFIX = EVENT_TOKEN_PREFIX;
873
879
  function classifyWaiterToken(token) {
874
880
  if (token.startsWith(WEBHOOK_PREFIX)) return {
875
881
  on: "webhook",
@@ -1335,6 +1341,29 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1335
1341
  value: buffered.payload
1336
1342
  };
1337
1343
  }, "consumeBuffered");
1344
+ const consumeBufferedEvent = /* @__PURE__ */ __name(async (name, match, token, seq) => {
1345
+ const candidates = await store.listBufferedEvents(name, EVENT_BUFFER_SCAN_LIMIT);
1346
+ for (const candidate of candidates) {
1347
+ if (!eventMatches(candidate.payload, match ?? {})) continue;
1348
+ if (!await store.removeBufferedEvent(candidate.id)) continue;
1349
+ await store.removeSignalWaiter({
1350
+ token,
1351
+ runId,
1352
+ seq
1353
+ });
1354
+ await writeCheckpoint(instantCheckpoint({
1355
+ runId,
1356
+ seq,
1357
+ name: `signal:${token}`,
1358
+ kind: "signal",
1359
+ output: candidate.payload
1360
+ }));
1361
+ return {
1362
+ value: candidate.payload
1363
+ };
1364
+ }
1365
+ return null;
1366
+ }, "consumeBufferedEvent");
1338
1367
  const stampDeadline = /* @__PURE__ */ __name(async (deadlineSeq, label, timeoutMs) => {
1339
1368
  const recorded = await readCheckpoint(deadlineSeq);
1340
1369
  const deadline = recorded?.wakeAt ?? host.clock() + timeoutMs;
@@ -1415,6 +1444,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1415
1444
  runId,
1416
1445
  seq: current
1417
1446
  });
1447
+ const bufferedHit2 = await consumeBufferedEvent(name, opts?.match, token2, current);
1448
+ if (bufferedHit2) return bufferedHit2.value;
1418
1449
  throw new WorkflowSuspended();
1419
1450
  }
1420
1451
  const timeoutMs = opts.timeoutMs;
@@ -1437,6 +1468,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1437
1468
  runId,
1438
1469
  seq: waitSeq
1439
1470
  });
1471
+ const bufferedHit = await consumeBufferedEvent(name, opts.match, token, waitSeq);
1472
+ if (bufferedHit) return bufferedHit.value;
1440
1473
  throw new WorkflowSuspended(deadline);
1441
1474
  }, "waitForEvent");
1442
1475
  const task = /* @__PURE__ */ __name(async (name, dispatch, options) => {
@@ -1830,6 +1863,7 @@ var WorkflowEngine = class {
1830
1863
  maxRecoveryAttempts;
1831
1864
  remoteAdvanceSilenceMs;
1832
1865
  reconcileMs;
1866
+ eventBufferTtlMs;
1833
1867
  remoteRedispatchMs;
1834
1868
  remoteRedispatchMax;
1835
1869
  webhookUrl;
@@ -1903,6 +1937,7 @@ var WorkflowEngine = class {
1903
1937
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
1904
1938
  this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
1905
1939
  this.reconcileMs = deps.reconcileMs === void 0 ? 3e5 : deps.reconcileMs || void 0;
1940
+ this.eventBufferTtlMs = deps.eventBufferTtlMs || void 0;
1906
1941
  this.remoteRedispatchMs = deps.remoteRedispatchMs || void 0;
1907
1942
  this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
1908
1943
  this.webhookUrl = deps.webhookUrl;
@@ -2576,14 +2611,16 @@ var WorkflowEngine = class {
2576
2611
  * Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
2577
2612
  * boot. A run still not due re-suspends cheaply without running new work.
2578
2613
  *
2579
- * Piggy-backs the signal-waiter reconcile safety net (see {@link reconcileSignalWaiter}): a crash
2580
- * between the waiter/buffer pairing ops on EITHER side (this run's own `putSignalWaiter` →
2581
- * `takeBufferedSignal`, or a signaler's `takeSignalWaiter` → `bufferSignal` → re-check — see
2582
- * `signal`'s interleaving proof) can leave both rows stranded with nothing left to pair them: the
2583
- * waiter's turn already suspended, the signaler that buffered it already returned. Event-wait
2584
- * suspends already carry a `reconcileMs` fallback `wakeAt` (see {@link reconcileWakeAt}) precisely so
2585
- * they show up HERE — that's what makes this due-timer pass the natural place for the sweep. One
2586
- * bulk waiter scan, filtered to just this tick's batch, keeps it cheap regardless of store size.
2614
+ * Piggy-backs the signal/event-waiter reconcile safety net (see {@link reconcileSignalWaiter}): a
2615
+ * crash between the waiter/buffer pairing ops on EITHER side (this run's own `putSignalWaiter` →
2616
+ * `takeBufferedSignal`/`listBufferedEvents`, or the other side's `takeSignalWaiter`/fan-out →
2617
+ * `bufferSignal`/`bufferEvent` → re-check — see `signal`'s interleaving proof, which `publishEvent`
2618
+ * mirrors for events) can leave both rows stranded with nothing left to pair them: the waiter's turn
2619
+ * already suspended, the other side that buffered it already returned. Event-wait suspends already
2620
+ * carry a `reconcileMs` fallback `wakeAt` (see {@link reconcileWakeAt}) precisely so they show up
2621
+ * HERE — that's what makes this due-timer pass the natural place for the sweep (and, for events, also
2622
+ * where an `eventBufferTtlMs` prune piggy-backs — see {@link reconcileBufferedEvent}). One bulk waiter
2623
+ * scan, filtered to just this tick's batch, keeps it cheap regardless of store size.
2587
2624
  */
2588
2625
  async resumeDueTimers(nowMs = this.clock()) {
2589
2626
  const due = await this.store.listDueTimers(nowMs, this.namespace);
@@ -2593,11 +2630,15 @@ var WorkflowEngine = class {
2593
2630
  }
2594
2631
  /**
2595
2632
  * The reconcile safety net's per-run check (see {@link resumeDueTimers}): if this suspended run has
2596
- * a registered signal waiter AND its token already has a buffered payload, pair and deliver them
2597
- * directly instead of falling through to a full replay. Event/child/breakpoint waiter tokens are
2598
- * never buffered (`publishEvent` doesn't buffer, and a child/breakpoint notifies via `signal` only
2599
- * once its counterpart is already registered) — so `takeBufferedSignal` on those is a guaranteed
2600
- * miss, and this is naturally signal-only without needing to classify the token.
2633
+ * a registered signal OR event waiter and a matching payload is already buffered, pair and deliver
2634
+ * them directly instead of falling through to a full replay. Child/breakpoint waiter tokens are
2635
+ * never buffered (a child/breakpoint notifies via `signal` only once its counterpart is already
2636
+ * registered) — `takeBufferedSignal` on those is a guaranteed miss, so this only ever does real work
2637
+ * for a plain signal or an event waiter (classified via {@link isEventToken}).
2638
+ *
2639
+ * Also opportunistically prunes expired buffered EVENTS for this waiter's name when
2640
+ * {@link eventBufferTtlMs} is set (see its doc — scoped to names a registered waiter references in
2641
+ * this tick's batch, piggy-backing the SAME scan rather than a separate pass), even on a miss.
2601
2642
  *
2602
2643
  * Returns `undefined` (not settled — `resumeLeased` proceeds with its normal `resume`) when there's
2603
2644
  * no waiter or no buffered hit. Deliberately never returns `null`: a delivered signal always resumes
@@ -2607,6 +2648,7 @@ var WorkflowEngine = class {
2607
2648
  */
2608
2649
  async reconcileSignalWaiter(run, waiter) {
2609
2650
  if (!waiter) return void 0;
2651
+ if (isEventToken(waiter.token)) return this.reconcileBufferedEvent(run, waiter);
2610
2652
  const buffered = await this.store.takeBufferedSignal(waiter.token);
2611
2653
  if (!buffered) return void 0;
2612
2654
  await this.store.removeSignalWaiter(waiter);
@@ -2617,6 +2659,34 @@ var WorkflowEngine = class {
2617
2659
  };
2618
2660
  }
2619
2661
  /**
2662
+ * The EVENT half of {@link reconcileSignalWaiter}: scan this waiter's name for a matching buffered
2663
+ * event (same list+evaluate+claim shape as `waitForEvent`'s own consumption — the match predicate
2664
+ * belongs to the waiter, never the store), pruning any expired candidates it encounters along the
2665
+ * way when {@link eventBufferTtlMs} is configured. A pruned-but-non-matching candidate does not stop
2666
+ * the scan; only a MATCHING hit (or exhausting the scan window) does.
2667
+ */
2668
+ async reconcileBufferedEvent(run, waiter) {
2669
+ const name = eventNameOf(waiter.token);
2670
+ const match = eventMatchOf(waiter.token);
2671
+ const candidates = await this.store.listBufferedEvents(name, EVENT_BUFFER_SCAN_LIMIT);
2672
+ const nowMs = this.clock();
2673
+ for (const candidate of candidates) {
2674
+ if (this.eventBufferTtlMs != null && nowMs - candidate.publishedAt > this.eventBufferTtlMs) {
2675
+ await this.store.removeBufferedEvent(candidate.id);
2676
+ continue;
2677
+ }
2678
+ if (!eventMatches(candidate.payload, match)) continue;
2679
+ if (!await this.store.removeBufferedEvent(candidate.id)) continue;
2680
+ await this.store.removeSignalWaiter(waiter);
2681
+ const settled = await this.deliverSignal(waiter, candidate.payload);
2682
+ return settled ?? {
2683
+ runId: run.id,
2684
+ status: run.status
2685
+ };
2686
+ }
2687
+ return void 0;
2688
+ }
2689
+ /**
2620
2690
  * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
2621
2691
  * exactly one instance runs it), then resumes the body. Returns the result, or null if another
2622
2692
  * instance holds the lease or the engine is draining. The default in-process dispatcher calls this;
@@ -2804,6 +2874,21 @@ var WorkflowEngine = class {
2804
2874
  * 2. **Starts** a fresh run of every workflow registered with `onEvent: [name]`, passing the
2805
2875
  * payload as input. Idempotent by `evt:<id>:<workflow>` — pass `opts.id` to dedupe redeliveries
2806
2876
  * of the same logical event (default: a fresh uuid, so each publish triggers once).
2877
+ *
2878
+ * Semantics (deliberate — mirrors `signalWithStart`'s reliability contract, so document it exactly):
2879
+ * a publish that resumes ≥1 live waiter, OR routes into an `eventBatch` accumulator / starts ≥1
2880
+ * subscriber, behaves exactly as above and is NOT buffered — fan-out stays live-only, same as
2881
+ * today. A publish that touches NEITHER — no live waiter matched, no subscriber exists/started —
2882
+ * buffers ONE copy (via {@link StateStore.bufferEvent}) unless `opts.buffer === false`, so it isn't
2883
+ * silently dropped just because nobody happened to be listening yet. That buffered copy is consumed
2884
+ * by the FIRST future `ctx.waitForEvent(name, { match })` whose match accepts its payload —
2885
+ * point-to-point on redelivery, by design (only one waiter ever gets it, even though the LIVE path
2886
+ * above is fan-out) — never by a second, later-registered onEvent subscriber (subscriber starts stay
2887
+ * live-only; `opts.id` dedupe applies to those only, unchanged). Right after buffering, this re-checks
2888
+ * `listSignalWaiters` ONCE — sandwich parity with `signal`'s own take → buffer → re-check — so a
2889
+ * waiter that registers in the sliver between the initial miss and the buffer write is still paired
2890
+ * instead of leaving both rows stranded (see `signal`'s interleaving proof; `waitForEvent`'s own
2891
+ * post-registration buffer scan is the mirror-image half of this same race).
2807
2892
  */
2808
2893
  async publishEvent(name, payload, opts) {
2809
2894
  let touched = 0;
@@ -2830,6 +2915,23 @@ var WorkflowEngine = class {
2830
2915
  }
2831
2916
  }
2832
2917
  }
2918
+ if (touched > 0 || opts?.buffer === false) return touched;
2919
+ const bufferedId = globalThis.crypto.randomUUID();
2920
+ await this.store.bufferEvent({
2921
+ name,
2922
+ payload,
2923
+ id: bufferedId,
2924
+ publishedAt: this.clock()
2925
+ });
2926
+ const lateWaiters = await this.store.listSignalWaiters(eventPrefix(name));
2927
+ const lateWaiter = lateWaiters.find((w) => eventMatches(payload, eventMatchOf(w.token)));
2928
+ if (!lateWaiter) return touched;
2929
+ if (!await this.store.removeBufferedEvent(bufferedId)) return touched;
2930
+ const waiter = await this.store.takeSignalWaiter(lateWaiter.token);
2931
+ if (waiter) {
2932
+ const settled = await this.deliverSignal(waiter, payload);
2933
+ if (settled) touched += 1;
2934
+ }
2833
2935
  return touched;
2834
2936
  }
2835
2937
  /**
@@ -4721,6 +4823,22 @@ var CodecStateStore = class {
4721
4823
  payload: this.dec(buffered.payload)
4722
4824
  };
4723
4825
  }
4826
+ bufferEvent(input) {
4827
+ return this.inner.bufferEvent({
4828
+ ...input,
4829
+ payload: this.enc(input.payload)
4830
+ });
4831
+ }
4832
+ async listBufferedEvents(name, limit) {
4833
+ const events = await this.inner.listBufferedEvents(name, limit);
4834
+ return events.map((e) => ({
4835
+ ...e,
4836
+ payload: this.dec(e.payload)
4837
+ }));
4838
+ }
4839
+ removeBufferedEvent(id) {
4840
+ return this.inner.removeBufferedEvent(id);
4841
+ }
4724
4842
  async listRuns(query) {
4725
4843
  return (await this.inner.listRuns(query)).map((r) => this.decRun(r));
4726
4844
  }
@@ -5193,6 +5311,28 @@ var InMemoryStateStore = class {
5193
5311
  payload
5194
5312
  };
5195
5313
  }
5314
+ /** `id -> row`, so {@link removeBufferedEvent} can delete an exact row regardless of iteration
5315
+ * order — mirrors the SQL stores' PK-by-id delete. */
5316
+ bufferedEvents = /* @__PURE__ */ new Map();
5317
+ async bufferEvent(input) {
5318
+ this.bufferedEvents.set(input.id, {
5319
+ name: input.name,
5320
+ payload: input.payload,
5321
+ publishedAt: input.publishedAt
5322
+ });
5323
+ }
5324
+ async listBufferedEvents(name, limit) {
5325
+ return [
5326
+ ...this.bufferedEvents.entries()
5327
+ ].filter(([, e]) => e.name === name).sort((a, b) => a[1].publishedAt - b[1].publishedAt).slice(0, limit).map(([id, e]) => ({
5328
+ id,
5329
+ payload: e.payload,
5330
+ publishedAt: e.publishedAt
5331
+ }));
5332
+ }
5333
+ async removeBufferedEvent(id) {
5334
+ return this.bufferedEvents.delete(id);
5335
+ }
5196
5336
  /**
5197
5337
  * Candidate run ids satisfying ONE attribute predicate, served from {@link attributeIndex} — the
5198
5338
  * pushdown step. Numeric predicates scan only `numValue` rows under the key; string/boolean ones
@@ -5351,6 +5491,8 @@ export {
5351
5491
  DURABLE_STEP_CONFIG,
5352
5492
  DURABLE_STEP_NAME,
5353
5493
  DurableWorkflow,
5494
+ EVENT_BUFFER_SCAN_LIMIT,
5495
+ EVENT_TOKEN_PREFIX,
5354
5496
  Entities,
5355
5497
  EventAccumulators,
5356
5498
  FatalError,
@@ -5389,6 +5531,7 @@ export {
5389
5531
  eventPrefix,
5390
5532
  eventToken,
5391
5533
  indexWaitersByRun,
5534
+ isEventToken,
5392
5535
  isWorkflowControlFlowSignal,
5393
5536
  matchesAttributes,
5394
5537
  normalizeAttributeRows,