@dudousxd/nestjs-durable-core 0.52.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.cjs CHANGED
@@ -30,6 +30,8 @@ __export(index_exports, {
30
30
  DURABLE_STEP_CONFIG: () => DURABLE_STEP_CONFIG,
31
31
  DURABLE_STEP_NAME: () => DURABLE_STEP_NAME,
32
32
  DurableWorkflow: () => DurableWorkflow,
33
+ EVENT_BUFFER_SCAN_LIMIT: () => EVENT_BUFFER_SCAN_LIMIT,
34
+ EVENT_TOKEN_PREFIX: () => EVENT_TOKEN_PREFIX,
33
35
  Entities: () => Entities,
34
36
  EventAccumulators: () => EventAccumulators,
35
37
  FatalError: () => FatalError,
@@ -68,6 +70,7 @@ __export(index_exports, {
68
70
  eventPrefix: () => eventPrefix,
69
71
  eventToken: () => eventToken,
70
72
  indexWaitersByRun: () => indexWaitersByRun,
73
+ isEventToken: () => isEventToken,
71
74
  isWorkflowControlFlowSignal: () => isWorkflowControlFlowSignal,
72
75
  matchesAttributes: () => matchesAttributes,
73
76
  normalizeAttributeRows: () => normalizeAttributeRows,
@@ -871,10 +874,16 @@ var EventAccumulators = class {
871
874
  // src/events.ts
872
875
  var enc = /* @__PURE__ */ __name((s) => Buffer.from(s, "utf8").toString("base64"), "enc");
873
876
  var dec = /* @__PURE__ */ __name((s) => Buffer.from(s, "base64").toString("utf8"), "dec");
877
+ var EVENT_BUFFER_SCAN_LIMIT = 50;
878
+ var EVENT_TOKEN_PREFIX = "event:";
874
879
  function eventPrefix(name) {
875
- return `event:${enc(name)}:`;
880
+ return `${EVENT_TOKEN_PREFIX}${enc(name)}:`;
876
881
  }
877
882
  __name(eventPrefix, "eventPrefix");
883
+ function isEventToken(token) {
884
+ return token.startsWith(EVENT_TOKEN_PREFIX);
885
+ }
886
+ __name(isEventToken, "isEventToken");
878
887
  function eventToken(name, match, runId, seq) {
879
888
  return `${eventPrefix(name)}${enc(JSON.stringify(match ?? {}))}:${runId}#${seq}`;
880
889
  }
@@ -953,6 +962,47 @@ var RemoteWorkflowExecutor = class {
953
962
  }
954
963
  };
955
964
 
965
+ // src/run-waiting.ts
966
+ var WEBHOOK_PREFIX = "wh:";
967
+ var CHILD_PREFIX = "child:";
968
+ var BREAKPOINT_PREFIX = "bp:";
969
+ var EVENT_PREFIX = EVENT_TOKEN_PREFIX;
970
+ function classifyWaiterToken(token) {
971
+ if (token.startsWith(WEBHOOK_PREFIX)) return {
972
+ on: "webhook",
973
+ name: token
974
+ };
975
+ if (token.startsWith(BREAKPOINT_PREFIX)) return {
976
+ on: "breakpoint",
977
+ name: "breakpoint"
978
+ };
979
+ if (token.startsWith(CHILD_PREFIX)) return {
980
+ on: "child",
981
+ name: token.slice(CHILD_PREFIX.length)
982
+ };
983
+ if (token.startsWith(EVENT_PREFIX)) return {
984
+ on: "signal",
985
+ name: eventNameOf(token)
986
+ };
987
+ return {
988
+ on: "signal",
989
+ name: token
990
+ };
991
+ }
992
+ __name(classifyWaiterToken, "classifyWaiterToken");
993
+ function resolveRunWaiting(run, waiterByRun) {
994
+ if (run.status !== "suspended") return void 0;
995
+ const waiter = waiterByRun.get(run.id);
996
+ return waiter ? classifyWaiterToken(waiter.token) : void 0;
997
+ }
998
+ __name(resolveRunWaiting, "resolveRunWaiting");
999
+ function indexWaitersByRun(waiters) {
1000
+ const byRun = /* @__PURE__ */ new Map();
1001
+ for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
1002
+ return byRun;
1003
+ }
1004
+ __name(indexWaitersByRun, "indexWaitersByRun");
1005
+
956
1006
  // src/singleton-gate.ts
957
1007
  var SINGLETON_RETRY_MS = 1e3;
958
1008
  var SINGLETON_RETRY_JITTER_MS = 250;
@@ -1382,6 +1432,29 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1382
1432
  value: buffered.payload
1383
1433
  };
1384
1434
  }, "consumeBuffered");
1435
+ const consumeBufferedEvent = /* @__PURE__ */ __name(async (name, match, token, seq) => {
1436
+ const candidates = await store.listBufferedEvents(name, EVENT_BUFFER_SCAN_LIMIT);
1437
+ for (const candidate of candidates) {
1438
+ if (!eventMatches(candidate.payload, match ?? {})) continue;
1439
+ if (!await store.removeBufferedEvent(candidate.id)) continue;
1440
+ await store.removeSignalWaiter({
1441
+ token,
1442
+ runId,
1443
+ seq
1444
+ });
1445
+ await writeCheckpoint(instantCheckpoint({
1446
+ runId,
1447
+ seq,
1448
+ name: `signal:${token}`,
1449
+ kind: "signal",
1450
+ output: candidate.payload
1451
+ }));
1452
+ return {
1453
+ value: candidate.payload
1454
+ };
1455
+ }
1456
+ return null;
1457
+ }, "consumeBufferedEvent");
1385
1458
  const stampDeadline = /* @__PURE__ */ __name(async (deadlineSeq, label, timeoutMs) => {
1386
1459
  const recorded = await readCheckpoint(deadlineSeq);
1387
1460
  const deadline = recorded?.wakeAt ?? host.clock() + timeoutMs;
@@ -1408,6 +1481,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1408
1481
  runId,
1409
1482
  seq: current
1410
1483
  });
1484
+ const lateBuffered2 = await consumeBuffered(token, current);
1485
+ if (lateBuffered2) {
1486
+ await store.removeSignalWaiter({
1487
+ token,
1488
+ runId,
1489
+ seq: current
1490
+ });
1491
+ return lateBuffered2.value;
1492
+ }
1411
1493
  throw new WorkflowSuspended();
1412
1494
  }
1413
1495
  const timeoutMs = opts.timeoutMs;
@@ -1419,7 +1501,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1419
1501
  const buffered = await consumeBuffered(token, waitSeq);
1420
1502
  if (buffered) return buffered.value;
1421
1503
  if (host.clock() >= deadline) {
1422
- await store.takeSignalWaiter(token).catch(() => void 0);
1504
+ await store.removeSignalWaiter({
1505
+ token,
1506
+ runId,
1507
+ seq: waitSeq
1508
+ }).catch(() => void 0);
1423
1509
  throw new SignalTimeoutError(token, timeoutMs);
1424
1510
  }
1425
1511
  await store.putSignalWaiter({
@@ -1427,6 +1513,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1427
1513
  runId,
1428
1514
  seq: waitSeq
1429
1515
  });
1516
+ const lateBuffered = await consumeBuffered(token, waitSeq);
1517
+ if (lateBuffered) {
1518
+ await store.removeSignalWaiter({
1519
+ token,
1520
+ runId,
1521
+ seq: waitSeq
1522
+ });
1523
+ return lateBuffered.value;
1524
+ }
1430
1525
  throw new WorkflowSuspended(deadline);
1431
1526
  }, "waitForSignal");
1432
1527
  const waitForEvent = /* @__PURE__ */ __name(async (name, opts) => {
@@ -1440,6 +1535,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1440
1535
  runId,
1441
1536
  seq: current
1442
1537
  });
1538
+ const bufferedHit2 = await consumeBufferedEvent(name, opts?.match, token2, current);
1539
+ if (bufferedHit2) return bufferedHit2.value;
1443
1540
  throw new WorkflowSuspended();
1444
1541
  }
1445
1542
  const timeoutMs = opts.timeoutMs;
@@ -1450,7 +1547,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1450
1547
  const waited = await readCheckpoint(waitSeq);
1451
1548
  if (waited && waited.status === "completed") return waited.output;
1452
1549
  if (host.clock() >= deadline) {
1453
- await store.takeSignalWaiter(token).catch(() => void 0);
1550
+ await store.removeSignalWaiter({
1551
+ token,
1552
+ runId,
1553
+ seq: waitSeq
1554
+ }).catch(() => void 0);
1454
1555
  throw new SignalTimeoutError(`event:${name}`, timeoutMs);
1455
1556
  }
1456
1557
  await store.putSignalWaiter({
@@ -1458,6 +1559,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1458
1559
  runId,
1459
1560
  seq: waitSeq
1460
1561
  });
1562
+ const bufferedHit = await consumeBufferedEvent(name, opts.match, token, waitSeq);
1563
+ if (bufferedHit) return bufferedHit.value;
1461
1564
  throw new WorkflowSuspended(deadline);
1462
1565
  }, "waitForEvent");
1463
1566
  const task = /* @__PURE__ */ __name(async (name, dispatch, options) => {
@@ -1851,6 +1954,7 @@ var WorkflowEngine = class {
1851
1954
  maxRecoveryAttempts;
1852
1955
  remoteAdvanceSilenceMs;
1853
1956
  reconcileMs;
1957
+ eventBufferTtlMs;
1854
1958
  remoteRedispatchMs;
1855
1959
  remoteRedispatchMax;
1856
1960
  webhookUrl;
@@ -1924,6 +2028,7 @@ var WorkflowEngine = class {
1924
2028
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
1925
2029
  this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
1926
2030
  this.reconcileMs = deps.reconcileMs === void 0 ? 3e5 : deps.reconcileMs || void 0;
2031
+ this.eventBufferTtlMs = deps.eventBufferTtlMs || void 0;
1927
2032
  this.remoteRedispatchMs = deps.remoteRedispatchMs || void 0;
1928
2033
  this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
1929
2034
  this.webhookUrl = deps.webhookUrl;
@@ -2596,9 +2701,81 @@ var WorkflowEngine = class {
2596
2701
  /**
2597
2702
  * Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
2598
2703
  * boot. A run still not due re-suspends cheaply without running new work.
2704
+ *
2705
+ * Piggy-backs the signal/event-waiter reconcile safety net (see {@link reconcileSignalWaiter}): a
2706
+ * crash between the waiter/buffer pairing ops on EITHER side (this run's own `putSignalWaiter` →
2707
+ * `takeBufferedSignal`/`listBufferedEvents`, or the other side's `takeSignalWaiter`/fan-out →
2708
+ * `bufferSignal`/`bufferEvent` → re-check — see `signal`'s interleaving proof, which `publishEvent`
2709
+ * mirrors for events) can leave both rows stranded with nothing left to pair them: the waiter's turn
2710
+ * already suspended, the other side that buffered it already returned. Event-wait suspends already
2711
+ * carry a `reconcileMs` fallback `wakeAt` (see {@link reconcileWakeAt}) precisely so they show up
2712
+ * HERE — that's what makes this due-timer pass the natural place for the sweep (and, for events, also
2713
+ * where an `eventBufferTtlMs` prune piggy-backs — see {@link reconcileBufferedEvent}). One bulk waiter
2714
+ * scan, filtered to just this tick's batch, keeps it cheap regardless of store size.
2599
2715
  */
2600
2716
  async resumeDueTimers(nowMs = this.clock()) {
2601
- return this.resumeLeased(await this.store.listDueTimers(nowMs, this.namespace), nowMs);
2717
+ const due = await this.store.listDueTimers(nowMs, this.namespace);
2718
+ if (due.length === 0) return [];
2719
+ const waiterByRun = indexWaitersByRun(await this.store.listSignalWaiters(""));
2720
+ return this.resumeLeased(due, nowMs, (run) => this.reconcileSignalWaiter(run, waiterByRun.get(run.id)));
2721
+ }
2722
+ /**
2723
+ * The reconcile safety net's per-run check (see {@link resumeDueTimers}): if this suspended run has
2724
+ * a registered signal OR event waiter and a matching payload is already buffered, pair and deliver
2725
+ * them directly instead of falling through to a full replay. Child/breakpoint waiter tokens are
2726
+ * never buffered (a child/breakpoint notifies via `signal` only once its counterpart is already
2727
+ * registered) — `takeBufferedSignal` on those is a guaranteed miss, so this only ever does real work
2728
+ * for a plain signal or an event waiter (classified via {@link isEventToken}).
2729
+ *
2730
+ * Also opportunistically prunes expired buffered EVENTS for this waiter's name when
2731
+ * {@link eventBufferTtlMs} is set (see its doc — scoped to names a registered waiter references in
2732
+ * this tick's batch, piggy-backing the SAME scan rather than a separate pass), even on a miss.
2733
+ *
2734
+ * Returns `undefined` (not settled — `resumeLeased` proceeds with its normal `resume`) when there's
2735
+ * no waiter or no buffered hit. Deliberately never returns `null`: a delivered signal always resumes
2736
+ * via {@link deliverSignal}, and even a `NamespaceMismatch` there is reported as a real (non-nullish)
2737
+ * result — returning `null` would read as "not settled" to `resumeLeased`'s `??` fallback and
2738
+ * trigger a second, redundant `resume` call on the same run.
2739
+ */
2740
+ async reconcileSignalWaiter(run, waiter) {
2741
+ if (!waiter) return void 0;
2742
+ if (isEventToken(waiter.token)) return this.reconcileBufferedEvent(run, waiter);
2743
+ const buffered = await this.store.takeBufferedSignal(waiter.token);
2744
+ if (!buffered) return void 0;
2745
+ await this.store.removeSignalWaiter(waiter);
2746
+ const settled = await this.deliverSignal(waiter, buffered.payload);
2747
+ return settled ?? {
2748
+ runId: run.id,
2749
+ status: run.status
2750
+ };
2751
+ }
2752
+ /**
2753
+ * The EVENT half of {@link reconcileSignalWaiter}: scan this waiter's name for a matching buffered
2754
+ * event (same list+evaluate+claim shape as `waitForEvent`'s own consumption — the match predicate
2755
+ * belongs to the waiter, never the store), pruning any expired candidates it encounters along the
2756
+ * way when {@link eventBufferTtlMs} is configured. A pruned-but-non-matching candidate does not stop
2757
+ * the scan; only a MATCHING hit (or exhausting the scan window) does.
2758
+ */
2759
+ async reconcileBufferedEvent(run, waiter) {
2760
+ const name = eventNameOf(waiter.token);
2761
+ const match = eventMatchOf(waiter.token);
2762
+ const candidates = await this.store.listBufferedEvents(name, EVENT_BUFFER_SCAN_LIMIT);
2763
+ const nowMs = this.clock();
2764
+ for (const candidate of candidates) {
2765
+ if (this.eventBufferTtlMs != null && nowMs - candidate.publishedAt > this.eventBufferTtlMs) {
2766
+ await this.store.removeBufferedEvent(candidate.id);
2767
+ continue;
2768
+ }
2769
+ if (!eventMatches(candidate.payload, match)) continue;
2770
+ if (!await this.store.removeBufferedEvent(candidate.id)) continue;
2771
+ await this.store.removeSignalWaiter(waiter);
2772
+ const settled = await this.deliverSignal(waiter, candidate.payload);
2773
+ return settled ?? {
2774
+ runId: run.id,
2775
+ status: run.status
2776
+ };
2777
+ }
2778
+ return void 0;
2602
2779
  }
2603
2780
  /**
2604
2781
  * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
@@ -2788,6 +2965,21 @@ var WorkflowEngine = class {
2788
2965
  * 2. **Starts** a fresh run of every workflow registered with `onEvent: [name]`, passing the
2789
2966
  * payload as input. Idempotent by `evt:<id>:<workflow>` — pass `opts.id` to dedupe redeliveries
2790
2967
  * of the same logical event (default: a fresh uuid, so each publish triggers once).
2968
+ *
2969
+ * Semantics (deliberate — mirrors `signalWithStart`'s reliability contract, so document it exactly):
2970
+ * a publish that resumes ≥1 live waiter, OR routes into an `eventBatch` accumulator / starts ≥1
2971
+ * subscriber, behaves exactly as above and is NOT buffered — fan-out stays live-only, same as
2972
+ * today. A publish that touches NEITHER — no live waiter matched, no subscriber exists/started —
2973
+ * buffers ONE copy (via {@link StateStore.bufferEvent}) unless `opts.buffer === false`, so it isn't
2974
+ * silently dropped just because nobody happened to be listening yet. That buffered copy is consumed
2975
+ * by the FIRST future `ctx.waitForEvent(name, { match })` whose match accepts its payload —
2976
+ * point-to-point on redelivery, by design (only one waiter ever gets it, even though the LIVE path
2977
+ * above is fan-out) — never by a second, later-registered onEvent subscriber (subscriber starts stay
2978
+ * live-only; `opts.id` dedupe applies to those only, unchanged). Right after buffering, this re-checks
2979
+ * `listSignalWaiters` ONCE — sandwich parity with `signal`'s own take → buffer → re-check — so a
2980
+ * waiter that registers in the sliver between the initial miss and the buffer write is still paired
2981
+ * instead of leaving both rows stranded (see `signal`'s interleaving proof; `waitForEvent`'s own
2982
+ * post-registration buffer scan is the mirror-image half of this same race).
2791
2983
  */
2792
2984
  async publishEvent(name, payload, opts) {
2793
2985
  let touched = 0;
@@ -2814,18 +3006,36 @@ var WorkflowEngine = class {
2814
3006
  }
2815
3007
  }
2816
3008
  }
3009
+ if (touched > 0 || opts?.buffer === false) return touched;
3010
+ const bufferedId = globalThis.crypto.randomUUID();
3011
+ await this.store.bufferEvent({
3012
+ name,
3013
+ payload,
3014
+ id: bufferedId,
3015
+ publishedAt: this.clock()
3016
+ });
3017
+ const lateWaiters = await this.store.listSignalWaiters(eventPrefix(name));
3018
+ const lateWaiter = lateWaiters.find((w) => eventMatches(payload, eventMatchOf(w.token)));
3019
+ if (!lateWaiter) return touched;
3020
+ if (!await this.store.removeBufferedEvent(bufferedId)) return touched;
3021
+ const waiter = await this.store.takeSignalWaiter(lateWaiter.token);
3022
+ if (waiter) {
3023
+ const settled = await this.deliverSignal(waiter, payload);
3024
+ if (settled) touched += 1;
3025
+ }
2817
3026
  return touched;
2818
3027
  }
2819
- async signal(token, payload) {
2820
- const waiter = await this.store.takeSignalWaiter(token);
2821
- if (!waiter) {
2822
- await this.store.bufferSignal(token, payload);
2823
- return null;
2824
- }
3028
+ /**
3029
+ * Deliver a signal to an already-known waiter: write its resolving `signal:<token>` checkpoint and
3030
+ * resume the run. Shared by {@link signal}'s direct hit, its post-buffer re-check reclaim, and the
3031
+ * due-timer reconcile sweep ({@link reconcileSignalWaiter}) — all three resolve a waiter the exact
3032
+ * same way.
3033
+ */
3034
+ async deliverSignal(waiter, payload) {
2825
3035
  await this.store.saveCheckpoint(instantCheckpoint({
2826
3036
  runId: waiter.runId,
2827
3037
  seq: waiter.seq,
2828
- name: `signal:${token}`,
3038
+ name: `signal:${waiter.token}`,
2829
3039
  kind: "signal",
2830
3040
  output: payload,
2831
3041
  // Carry the awaiting command's fan group (set when a `ctx.gather_children`/`ctx.all` fan-out
@@ -2840,6 +3050,42 @@ var WorkflowEngine = class {
2840
3050
  });
2841
3051
  }
2842
3052
  /**
3053
+ * Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
3054
+ * the run result — or null if no run is (or ends up) waiting for it.
3055
+ *
3056
+ * Race with the waiter side (`waitForSignal`'s check → `putSignalWaiter` → re-check, same shape in
3057
+ * the remote `waitSignal` command): a waiter can register in the sliver between this method's
3058
+ * initial `takeSignalWaiter` miss and its `bufferSignal` write, and the naive old flow (buffer and
3059
+ * return) would then leave BOTH a buffered payload and a registered waiter sitting in the store with
3060
+ * nothing left to pair them — a lost wake, forever suspended.
3061
+ *
3062
+ * The interleaving proof: the waiter side does check → put → re-check, this side does take → buffer
3063
+ * → re-check. Whichever side's SECOND look runs last is guaranteed to observe the other side's
3064
+ * write, because it always follows that write in real time:
3065
+ * - If the waiter's `put` lands before this method's first `take`, the take just finds it directly
3066
+ * (the pre-existing, non-racy path).
3067
+ * - If the waiter's `put` lands after this method's `bufferSignal`, our re-check below either sees
3068
+ * it (if `put` also lands before the re-check) or the waiter's OWN post-put re-check sees our
3069
+ * buffer (if `put` lands after our re-check but the waiter hasn't re-checked yet either — its
3070
+ * re-check strictly follows its own put).
3071
+ * - If the waiter's `put` lands between this method's `take` and its re-`take`, the re-check below
3072
+ * catches it.
3073
+ * Either way, the destructive `take*` ops (on the waiter row, on the buffer) arbitrate: whichever
3074
+ * side actually removes the row is the one that delivers, so a signal is never delivered twice.
3075
+ */
3076
+ async signal(token, payload) {
3077
+ const waiter = await this.store.takeSignalWaiter(token);
3078
+ if (waiter) return this.deliverSignal(waiter, payload);
3079
+ await this.store.bufferSignal(token, payload);
3080
+ const lateWaiter = await this.store.takeSignalWaiter(token);
3081
+ if (!lateWaiter) return null;
3082
+ const reclaimed = await this.store.takeBufferedSignal(token);
3083
+ if (!reclaimed) {
3084
+ return null;
3085
+ }
3086
+ return this.deliverSignal(lateWaiter, reclaimed.payload);
3087
+ }
3088
+ /**
2843
3089
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
2844
3090
  * signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
2845
3091
  * consumed when it reaches `waitForSignal(token)`. The canonical **durable-entity / accumulator**
@@ -3312,6 +3558,14 @@ var WorkflowEngine = class {
3312
3558
  error: outcome.error
3313
3559
  };
3314
3560
  }
3561
+ const latest = await this.store.getRun(run.id);
3562
+ if (latest?.status === "cancelled") {
3563
+ return {
3564
+ runId: run.id,
3565
+ status: "cancelled",
3566
+ error: latest.error
3567
+ };
3568
+ }
3315
3569
  await this.store.updateRun(run.id, {
3316
3570
  status: "suspended",
3317
3571
  wakeAt: this.reconcileWakeAt(outcome.wakeAt),
@@ -3654,22 +3908,34 @@ var WorkflowEngine = class {
3654
3908
  }));
3655
3909
  wakeAt = wakeAt == null ? deadline : Math.min(wakeAt, deadline);
3656
3910
  } else if (cmd.kind === "waitSignal") {
3657
- const buffered = await this.store.takeBufferedSignal(cmd.signal);
3658
- if (buffered) {
3911
+ const deliverBuffered = /* @__PURE__ */ __name(async (payload) => {
3659
3912
  await this.store.saveCheckpoint(instantCheckpoint({
3660
3913
  runId: run.id,
3661
3914
  seq: cmd.seq,
3662
3915
  name: `signal:${cmd.signal}`,
3663
3916
  kind: "signal",
3664
- output: buffered.payload
3917
+ output: payload
3665
3918
  }));
3666
3919
  setTimeout(() => void this.resume(run.id).catch(() => void 0), 0);
3920
+ }, "deliverBuffered");
3921
+ const buffered = await this.store.takeBufferedSignal(cmd.signal);
3922
+ if (buffered) {
3923
+ await deliverBuffered(buffered.payload);
3667
3924
  } else {
3668
3925
  await this.store.putSignalWaiter({
3669
3926
  token: cmd.signal,
3670
3927
  runId: run.id,
3671
3928
  seq: cmd.seq
3672
3929
  });
3930
+ const lateBuffered = await this.store.takeBufferedSignal(cmd.signal);
3931
+ if (lateBuffered) {
3932
+ await this.store.removeSignalWaiter({
3933
+ token: cmd.signal,
3934
+ runId: run.id,
3935
+ seq: cmd.seq
3936
+ });
3937
+ await deliverBuffered(lateBuffered.payload);
3938
+ }
3673
3939
  }
3674
3940
  } else if (cmd.kind === "startChild") {
3675
3941
  const childId = `${run.id}.child.${cmd.seq}`;
@@ -4529,47 +4795,6 @@ var TERMINAL_RUN_STATUSES = [
4529
4795
  "dead"
4530
4796
  ];
4531
4797
 
4532
- // src/run-waiting.ts
4533
- var WEBHOOK_PREFIX = "wh:";
4534
- var CHILD_PREFIX = "child:";
4535
- var BREAKPOINT_PREFIX = "bp:";
4536
- var EVENT_PREFIX = "event:";
4537
- function classifyWaiterToken(token) {
4538
- if (token.startsWith(WEBHOOK_PREFIX)) return {
4539
- on: "webhook",
4540
- name: token
4541
- };
4542
- if (token.startsWith(BREAKPOINT_PREFIX)) return {
4543
- on: "breakpoint",
4544
- name: "breakpoint"
4545
- };
4546
- if (token.startsWith(CHILD_PREFIX)) return {
4547
- on: "child",
4548
- name: token.slice(CHILD_PREFIX.length)
4549
- };
4550
- if (token.startsWith(EVENT_PREFIX)) return {
4551
- on: "signal",
4552
- name: eventNameOf(token)
4553
- };
4554
- return {
4555
- on: "signal",
4556
- name: token
4557
- };
4558
- }
4559
- __name(classifyWaiterToken, "classifyWaiterToken");
4560
- function resolveRunWaiting(run, waiterByRun) {
4561
- if (run.status !== "suspended") return void 0;
4562
- const waiter = waiterByRun.get(run.id);
4563
- return waiter ? classifyWaiterToken(waiter.token) : void 0;
4564
- }
4565
- __name(resolveRunWaiting, "resolveRunWaiting");
4566
- function indexWaitersByRun(waiters) {
4567
- const byRun = /* @__PURE__ */ new Map();
4568
- for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
4569
- return byRun;
4570
- }
4571
- __name(indexWaitersByRun, "indexWaitersByRun");
4572
-
4573
4798
  // src/codec-state-store.ts
4574
4799
  var CodecStateStore = class {
4575
4800
  static {
@@ -4677,6 +4902,9 @@ var CodecStateStore = class {
4677
4902
  listSignalWaiters(prefix) {
4678
4903
  return this.inner.listSignalWaiters(prefix);
4679
4904
  }
4905
+ removeSignalWaiter(waiter) {
4906
+ return this.inner.removeSignalWaiter(waiter);
4907
+ }
4680
4908
  bufferSignal(token, payload) {
4681
4909
  return this.inner.bufferSignal(token, this.enc(payload));
4682
4910
  }
@@ -4686,6 +4914,22 @@ var CodecStateStore = class {
4686
4914
  payload: this.dec(buffered.payload)
4687
4915
  };
4688
4916
  }
4917
+ bufferEvent(input) {
4918
+ return this.inner.bufferEvent({
4919
+ ...input,
4920
+ payload: this.enc(input.payload)
4921
+ });
4922
+ }
4923
+ async listBufferedEvents(name, limit) {
4924
+ const events = await this.inner.listBufferedEvents(name, limit);
4925
+ return events.map((e) => ({
4926
+ ...e,
4927
+ payload: this.dec(e.payload)
4928
+ }));
4929
+ }
4930
+ removeBufferedEvent(id) {
4931
+ return this.inner.removeBufferedEvent(id);
4932
+ }
4689
4933
  async listRuns(query) {
4690
4934
  return (await this.inner.listRuns(query)).map((r) => this.decRun(r));
4691
4935
  }
@@ -5129,6 +5373,12 @@ var InMemoryStateStore = class {
5129
5373
  ...w
5130
5374
  }));
5131
5375
  }
5376
+ async removeSignalWaiter(waiter) {
5377
+ const current = this.signalWaiters.get(waiter.token);
5378
+ if (current && current.runId === waiter.runId && current.seq === waiter.seq) {
5379
+ this.signalWaiters.delete(waiter.token);
5380
+ }
5381
+ }
5132
5382
  // No real DB transaction (there's no DB) — run the work and save the checkpoint. Exactly-once
5133
5383
  // across a crash needs a SQL store; this keeps `ctx.transaction` usable in tests / local dev.
5134
5384
  async transaction(work) {
@@ -5152,6 +5402,28 @@ var InMemoryStateStore = class {
5152
5402
  payload
5153
5403
  };
5154
5404
  }
5405
+ /** `id -> row`, so {@link removeBufferedEvent} can delete an exact row regardless of iteration
5406
+ * order — mirrors the SQL stores' PK-by-id delete. */
5407
+ bufferedEvents = /* @__PURE__ */ new Map();
5408
+ async bufferEvent(input) {
5409
+ this.bufferedEvents.set(input.id, {
5410
+ name: input.name,
5411
+ payload: input.payload,
5412
+ publishedAt: input.publishedAt
5413
+ });
5414
+ }
5415
+ async listBufferedEvents(name, limit) {
5416
+ return [
5417
+ ...this.bufferedEvents.entries()
5418
+ ].filter(([, e]) => e.name === name).sort((a, b) => a[1].publishedAt - b[1].publishedAt).slice(0, limit).map(([id, e]) => ({
5419
+ id,
5420
+ payload: e.payload,
5421
+ publishedAt: e.publishedAt
5422
+ }));
5423
+ }
5424
+ async removeBufferedEvent(id) {
5425
+ return this.bufferedEvents.delete(id);
5426
+ }
5155
5427
  /**
5156
5428
  * Candidate run ids satisfying ONE attribute predicate, served from {@link attributeIndex} — the
5157
5429
  * pushdown step. Numeric predicates scan only `numValue` rows under the key; string/boolean ones
@@ -5311,6 +5583,8 @@ var InMemoryTransport = class {
5311
5583
  DURABLE_STEP_CONFIG,
5312
5584
  DURABLE_STEP_NAME,
5313
5585
  DurableWorkflow,
5586
+ EVENT_BUFFER_SCAN_LIMIT,
5587
+ EVENT_TOKEN_PREFIX,
5314
5588
  Entities,
5315
5589
  EventAccumulators,
5316
5590
  FatalError,
@@ -5349,6 +5623,7 @@ var InMemoryTransport = class {
5349
5623
  eventPrefix,
5350
5624
  eventToken,
5351
5625
  indexWaitersByRun,
5626
+ isEventToken,
5352
5627
  isWorkflowControlFlowSignal,
5353
5628
  matchesAttributes,
5354
5629
  normalizeAttributeRows,