@flamingo-stack/openframe-frontend-core 0.0.750 → 0.0.751

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.
Files changed (60) hide show
  1. package/dist/{chunk-45YRDD7W.cjs → chunk-4WNBVNGP.cjs} +13 -13
  2. package/dist/{chunk-4O2KYY4K.cjs → chunk-53I4YHRT.cjs} +3 -3
  3. package/dist/{chunk-HOEVD6EN.cjs → chunk-5P7WBA2U.cjs} +4 -4
  4. package/dist/{chunk-CNUZLOVA.cjs → chunk-5WC535NJ.cjs} +17 -17
  5. package/dist/{chunk-BO6ZOAPB.cjs → chunk-77IKUBSH.cjs} +6 -6
  6. package/dist/{chunk-E3KFEOFA.cjs → chunk-CVBHB4OZ.cjs} +4 -4
  7. package/dist/{chunk-JPUTILTV.cjs → chunk-ESRA7NUI.cjs} +41 -41
  8. package/dist/{chunk-2J2OBP4N.cjs → chunk-H52GKJ36.cjs} +9 -9
  9. package/dist/{chunk-G7SBHMRR.cjs → chunk-KHKPLOU4.cjs} +69 -39
  10. package/dist/{chunk-6KEJQR6L.cjs → chunk-LIGDPFDM.cjs} +131 -131
  11. package/dist/{chunk-YL62VAHC.cjs → chunk-MJFTWHV6.cjs} +4 -4
  12. package/dist/{chunk-AW6GP3JK.cjs → chunk-MU7FFN6M.cjs} +34 -34
  13. package/dist/{chunk-7RCTOWBA.cjs → chunk-NOUROZYH.cjs} +13 -13
  14. package/dist/{chunk-KVSEWACW.cjs → chunk-QOONZMSQ.cjs} +9 -9
  15. package/dist/{chunk-FP6HAJQV.cjs → chunk-RVLQHSID.cjs} +70 -70
  16. package/dist/{chunk-GVETSV7H.cjs → chunk-SDOCZTST.cjs} +32 -32
  17. package/dist/{chunk-V3UJ6NDR.cjs → chunk-TPH4SRR2.cjs} +6 -6
  18. package/dist/{chunk-4FWURR3W.cjs → chunk-VUWPLQBY.cjs} +2 -2
  19. package/dist/{chunk-WZX26ODJ.cjs → chunk-X4LAMJHD.cjs} +54 -35
  20. package/dist/{cloud-tenants-E4JSG3GH.cjs → cloud-tenants-JDPC2DYF.cjs} +6 -6
  21. package/dist/components/case-studies/index.cjs +16 -16
  22. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts +7 -5
  23. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
  24. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.js +33 -14
  25. package/dist/components/chat/index.cjs +4 -4
  26. package/dist/components/chat/types/api.types.d.ts +27 -8
  27. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  28. package/dist/components/chat/types/network.types.d.ts +2 -1
  29. package/dist/components/chat/types/network.types.d.ts.map +1 -1
  30. package/dist/components/contact/index.cjs +7 -7
  31. package/dist/components/docs/index.cjs +10 -10
  32. package/dist/components/embeds/index.cjs +7 -7
  33. package/dist/components/faq/index.cjs +8 -8
  34. package/dist/components/features/index.cjs +6 -6
  35. package/dist/components/help-center-pages/index.cjs +67 -67
  36. package/dist/components/index.cjs +39 -39
  37. package/dist/components/meeting-scheduler/index.cjs +29 -29
  38. package/dist/components/navigation/index.cjs +9 -9
  39. package/dist/components/onboarding-guides/index.cjs +8 -8
  40. package/dist/components/product-demo/index.cjs +7 -7
  41. package/dist/components/tickets/index.cjs +11 -11
  42. package/dist/components/ui/index.cjs +6 -6
  43. package/dist/{devices-AVRQGB2N.cjs → devices-ADQWD37J.cjs} +7 -7
  44. package/dist/{embeddable-chat-SNYQSMS7.cjs → embeddable-chat-7YF2JMPS.cjs} +65 -65
  45. package/dist/hooks/index.cjs +3 -3
  46. package/dist/index.cjs +10 -10
  47. package/dist/{knowledge-S3CNISQH.cjs → knowledge-TNS64EGK.cjs} +7 -7
  48. package/dist/{logs-WDLOKDXT.cjs → logs-754MDDE4.cjs} +14 -14
  49. package/dist/nats/index.cjs +2 -2
  50. package/dist/nats/nats.d.ts +33 -7
  51. package/dist/nats/nats.d.ts.map +1 -1
  52. package/dist/nats/nats.js +41 -11
  53. package/dist/{policies-STML7NEZ.cjs → policies-Q23DPIH6.cjs} +10 -10
  54. package/dist/{scripts-HO53VYGX.cjs → scripts-DCYLK2RZ.cjs} +11 -11
  55. package/dist/{software-update-4VTK7LWQ.cjs → software-update-XCMGUIER.cjs} +9 -9
  56. package/package.json +1 -1
  57. package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +59 -23
  58. package/src/components/chat/types/api.types.ts +27 -8
  59. package/src/components/chat/types/network.types.ts +2 -1
  60. package/src/nats/nats.ts +91 -20
@@ -49,14 +49,16 @@ const RECOVERY_REPORT_FLOOR_MS = 5_000;
49
49
  * - Subject: `chat.{dialogId}.{topic}`
50
50
  * - When `optStartSeq` is a number, the consumer resumes at `optStartSeq + 1`
51
51
  * (`DeliverPolicy.ByStartSequence`). When null/undefined, it live-tails
52
- * (`DeliverPolicy.New`).
52
+ * (`DeliverPolicy.New`) from whenever the server creates the consumer.
53
53
  * - On reconnect, the consumer is recreated starting from the highest stream
54
54
  * sequence we've already observed + 1, so no chunk is replayed or skipped.
55
- * - Consumer is ephemeral with `AckPolicy.None` and the client's default
56
- * inactivity threshold (overridable via `inactiveThresholdMs`).
55
+ * - Consumer is ephemeral with `AckPolicy.None` and nats.ws's default
56
+ * inactivity threshold (`inactiveThresholdMs` only reaches a live-tail
57
+ * consumer — see the option).
57
58
  * - `reconnectionCount` also counts consumer recreations and post-absence
58
- * resyncs, not just NATS reconnects — see the counter's docs. Callers refetch
59
- * persisted history whenever it moves.
59
+ * resyncs, not just NATS reconnects — see the counter's docs.
60
+ * - `caughtUpCount` is when callers refetch persisted history after a gap —
61
+ * not `reconnectionCount` (see its docs).
60
62
  */
61
63
  export function useJetStreamDialogSubscription({
62
64
  enabled,
@@ -84,6 +86,11 @@ export function useJetStreamDialogSubscription({
84
86
  // churn — and churn that can feed itself, since each new consumer can report
85
87
  // its own recoveries. Callers see the two summed (see the return).
86
88
  const [recoveryCount, setRecoveryCount] = useState(0);
89
+ const [caughtUpCount, setCaughtUpCount] = useState(0);
90
+ // The dialog this hook last opened a tail for, and the reconnection count it
91
+ // opened it at: a consumer created for the same dialog at a higher count is
92
+ // one coming back after a gap, which owes the caller a history refetch.
93
+ const tailOpenedAtRef = useRef<{ dialogId: string; count: number } | null>(null);
87
94
  // The highest sequence seen, TAGGED with the dialog it belongs to. The tag is
88
95
  // what makes the per-dialog reset implicit — a sequence from another
89
96
  // conversation simply stops matching — so the dialog-change effect below no
@@ -381,11 +388,28 @@ export function useJetStreamDialogSubscription({
381
388
  const initialOptStart = optStartSeqRef.current;
382
389
  const startSeq = resumeSeq != null ? resumeSeq + 1 : initialOptStart != null ? initialOptStart + 1 : undefined;
383
390
 
391
+ // A first consumer reports nothing: the caller has just loaded the history
392
+ // it resumes after. A reconnect or resync recreates it at a higher count,
393
+ // and from then on the caller's history may predate a gap.
394
+ const opened = tailOpenedAtRef.current;
395
+ let owesCatchUp = opened !== null && opened.dialogId === dialogId && reconnectionCount > opened.count;
396
+ if (!owesCatchUp) tailOpenedAtRef.current = { dialogId, count: reconnectionCount };
397
+
384
398
  let cancelled = false;
399
+ // Settles an owed catch-up once the replay is in, or once there can be none
400
+ // (a failed resume, below) — never as the tail comes back: a refetch that
401
+ // lands first merges without the turns the replay is still delivering, and
402
+ // they then render twice.
403
+ const settleCatchUp = () => {
404
+ if (cancelled || !owesCatchUp) return;
405
+ owesCatchUp = false;
406
+ setCaughtUpCount(c => c + 1);
407
+ };
385
408
 
386
409
  void (async () => {
410
+ let handle: JetStreamSubscriptionHandle;
387
411
  try {
388
- const handle = await client.subscribeJetStreamOrdered(
412
+ handle = await client.subscribeJetStreamOrdered(
389
413
  msg => {
390
414
  if (cancelled) return;
391
415
  const streamSeq = msg.info.streamSequence;
@@ -424,27 +448,39 @@ export function useJetStreamDialogSubscription({
424
448
  const now = Date.now();
425
449
  if (now - lastRecoveryReportRef.current < RECOVERY_REPORT_FLOOR_MS) return;
426
450
  lastRecoveryReportRef.current = now;
451
+ owesCatchUp = true;
427
452
  setRecoveryCount(c => c + 1);
428
453
  },
454
+ onCaughtUp: settleCatchUp,
429
455
  },
430
456
  );
457
+ } catch {
458
+ if (cancelled) return;
459
+ setIsSubscribed(false);
460
+ // With no consumer the refetch cannot wait for a replay. The next resume
461
+ // still owes one (`tailOpenedAtRef` did not move), and its own catch-up
462
+ // refetch clears what its replay re-renders meanwhile.
463
+ settleCatchUp();
464
+ return;
465
+ }
431
466
 
432
- if (cancelled) {
433
- try {
434
- handle.unsubscribe();
435
- } catch {
436
- // ignore
437
- }
438
- return;
467
+ if (cancelled) {
468
+ try {
469
+ handle.unsubscribe();
470
+ } catch {
471
+ // ignore
439
472
  }
473
+ return;
474
+ }
440
475
 
441
- subscriptionRef.current = handle;
442
- setIsSubscribed(true);
476
+ subscriptionRef.current = handle;
477
+ setIsSubscribed(true);
478
+ // A host callback, outside the subscribe's catch: a throw there must not
479
+ // read as a failed subscribe and settle the catch-up before the replay.
480
+ try {
443
481
  onSubscribedRef.current?.();
444
- } catch {
445
- if (!cancelled) {
446
- setIsSubscribed(false);
447
- }
482
+ } catch (e) {
483
+ console.warn('[JetStream] onSubscribed threw:', e);
448
484
  }
449
485
  })();
450
486
 
@@ -464,14 +500,14 @@ export function useJetStreamDialogSubscription({
464
500
  }, [enabled, dialogId, isConnected, streamName, topic, reconnectionCount]);
465
501
 
466
502
  // One counter for every way the tail comes back — reconnect, consumer
467
- // recreation, post-absence resync — because callers do the same thing for all
468
- // of them: refetch persisted history, since the gap may predate what the
469
- // stream still holds. The last two are the quiet ones; no connection event
470
- // fires for either.
503
+ // recreation, post-absence resync. The last two are the quiet ones; no
504
+ // connection event fires for either. `caughtUpCount` counts the same events,
505
+ // each once its replay is in or once it could not be resumed (see its docs).
471
506
  return {
472
507
  isConnected,
473
508
  isSubscribed,
474
509
  reconnectionCount: reconnectionCount + recoveryCount,
510
+ caughtUpCount,
475
511
  currentStreamSeq,
476
512
  };
477
513
  }
@@ -103,8 +103,9 @@ export interface UseJetStreamDialogSubscriptionOptions {
103
103
  /** Single topic to subscribe to. */
104
104
  topic: NatsMessageType;
105
105
  /**
106
- * Resume from this JetStream sequence + 1. When null/undefined, the consumer starts with
107
- * DeliverPolicy.New (live tail only).
106
+ * Resume from this JetStream sequence + 1 (0 replays everything the stream retains for the
107
+ * dialog). When null/undefined, the consumer starts with DeliverPolicy.New: live tail only,
108
+ * from whenever the server creates the consumer — after `onSubscribed` has fired.
108
109
  */
109
110
  optStartSeq?: number | null;
110
111
  onEvent?: (payload: unknown, messageType: NatsMessageType) => void;
@@ -127,8 +128,10 @@ export interface UseJetStreamDialogSubscriptionOptions {
127
128
  maxDelayMs?: number;
128
129
  multiplier?: number;
129
130
  };
130
- /** Consumer inactivity threshold in ms before NATS auto-cleans it. Defaults to the
131
- * client's, and applies only to the first consumer — recreations use nats.ws's. */
131
+ /** Consumer inactivity threshold in ms before NATS auto-cleans it, passed to the client
132
+ * (see `JetStreamOrderedSubscribeOptions.inactiveThresholdMs`). It takes effect only
133
+ * while the hook live-tails (`optStartSeq` null, nothing seen yet): every other consumer
134
+ * it opens starts from a sequence ≥ 1, which nats.ws gives its own default. */
132
135
  inactiveThresholdMs?: number;
133
136
  /**
134
137
  * A counter the host bumps when it knows, from outside the page, that the tail
@@ -140,7 +143,8 @@ export interface UseJetStreamDialogSubscriptionOptions {
140
143
  * either kind of knowledge bumps this instead of hoping the page noticed.
141
144
  *
142
145
  * Treated exactly like a reconnect: the consumer is rebuilt from the highest
143
- * sequence already seen, and `reconnectionCount` moves so callers refetch
146
+ * sequence already seen, `reconnectionCount` moves, and `caughtUpCount` moves
147
+ * once the rebuilt consumer has replayed what it missed, so callers refetch
144
148
  * persisted history. Any increase counts; the value itself means nothing, and
145
149
  * an increase arriving alongside a `visibilitychange` costs one rebuild, not
146
150
  * two.
@@ -156,11 +160,26 @@ export interface UseJetStreamDialogSubscriptionReturn {
156
160
  * JetStream ordered-consumer recreation, the page returning to view after
157
161
  * being hidden long enough to have missed something, or a host reporting the
158
162
  * same via `resyncSignal`. All of them mean the same thing to the caller —
159
- * the tail may have skipped messages, so persisted history has to be
160
- * refetched. One absence counts once even when two sources report it.
161
- * Starts at 0.
163
+ * the tail may have skipped messages. One absence counts once even when two
164
+ * sources report it. Starts at 0.
165
+ *
166
+ * Not the moment to refetch persisted history: the replay is still arriving
167
+ * when this moves. See `caughtUpCount`.
162
168
  */
163
169
  reconnectionCount: number;
170
+ /**
171
+ * Incremented once a tail that came back — any of the events
172
+ * `reconnectionCount` counts, recoveries past its report floor — has
173
+ * delivered everything the stream still held for the dialog. Refetch
174
+ * persisted history on this: the gap may predate what the stream retains,
175
+ * and only a refetch that lands after the replay merges against the turns it
176
+ * re-delivered. One that lands first merges without them, and they then
177
+ * render a second time. Also moves when such a tail could not be resumed at
178
+ * all, so history is not left at its pre-gap snapshot: the next resume then
179
+ * replays the gap over that refetch, and its own increment's refetch clears
180
+ * the copies. Never moves for the first consumer of a dialog. Starts at 0.
181
+ */
182
+ caughtUpCount: number;
164
183
  /** Highest JetStream stream sequence observed so far (null before first delivery). */
165
184
  currentStreamSeq: number | null;
166
185
  }
@@ -36,7 +36,8 @@ export interface ChunkData {
36
36
  sequenceId?: number;
37
37
  /** JetStream stream sequence, populated from JsMsg.info.streamSequence when delivered via JetStream. */
38
38
  streamSeq?: number;
39
- /** Authoritative dialog streaming state at the time this chunk was emitted; consumers should mirror it into their dialog cache. */
39
+ /** @deprecated Not set by the ai-agent: its chunks carry no stream state, which exists only
40
+ * as the GraphQL `Dialog.streamState` field (resolved from the dialog lock). Read that instead. */
40
41
  streamState?: 'IDLE' | 'STREAMING';
41
42
  type: string;
42
43
  text?: string;
package/src/nats/nats.ts CHANGED
@@ -19,14 +19,25 @@ export type JetStreamDeliverPolicy = 'new' | 'byStartSequence';
19
19
  export interface JetStreamOrderedSubscribeOptions {
20
20
  streamName: string;
21
21
  filterSubject: string;
22
+ /**
23
+ * `'byStartSequence'` delivers every retained message from `optStartSeq` on.
24
+ * `'new'` delivers only what is published after the server creates the
25
+ * consumer, which happens after the returned promise resolves: a caller that
26
+ * acts once subscribed and must see everything that follows uses a start
27
+ * sequence instead.
28
+ */
22
29
  deliverPolicy: JetStreamDeliverPolicy;
23
- /** Required when deliverPolicy === 'byStartSequence'. */
30
+ /**
31
+ * First stream sequence to deliver, for `'byStartSequence'`; ignored for
32
+ * `'new'`. Missing or 0 starts at the first message the stream retains.
33
+ */
24
34
  optStartSeq?: number;
25
35
  /**
26
36
  * Auto-cleanup the ephemeral consumer after this idle time. Defaults to
27
- * nats.ws's own. Applies to the consumer created first: nats.ws only reads it
28
- * when the start sequence is the one it was built with, so every consumer it
29
- * recreates afterwards reverts to the library default.
37
+ * nats.ws's own. nats.ws reads it only while it builds the consumer at the
38
+ * start it was asked for — `'new'`, or `optStartSeq` 0/unset — and has
39
+ * delivered nothing yet. A consumer created from `optStartSeq` ≥ 1, and any
40
+ * recreation after a delivery, gets the library default.
30
41
  */
31
42
  inactiveThresholdMs?: number;
32
43
  /** AbortSignal to tear down the consumer. */
@@ -39,10 +50,25 @@ export interface JetStreamOrderedSubscribeOptions {
39
50
  * but everything that aged out of the stream while it was gone is
40
51
  * unrecoverable from the tail, and no connection event announces it (the
41
52
  * WebSocket never dropped). Callers that keep persisted history alongside
42
- * the live tail must refetch it here, or they keep showing a snapshot from
43
- * before the gap.
53
+ * the live tail must refetch it, or they keep showing a snapshot from before
54
+ * the gap — once the recreated consumer has caught up (`onCaughtUp`), not
55
+ * here.
44
56
  */
45
57
  onRecovered?: () => void;
58
+ /**
59
+ * The consumer has delivered everything the stream held for the subject when
60
+ * it was created. Fires once for the first consumer and once more for each
61
+ * one nats.ws recreates (`onRecovered`): after `onMessage` has handled the
62
+ * last message of that backlog, straight away when it was empty, or when
63
+ * consuming failed and nothing more will arrive. In that last case a later
64
+ * consumer replays the gap over a refetch made in the meantime, and the
65
+ * refetch on ITS `onCaughtUp` clears the copies.
66
+ *
67
+ * This is the moment to refetch persisted history after a gap. A refetch
68
+ * that lands while the backlog is still arriving merges without the turns it
69
+ * is about to replay, and the replay then renders them a second time.
70
+ */
71
+ onCaughtUp?: () => void;
46
72
  }
47
73
 
48
74
  export interface JetStreamSubscriptionHandle {
@@ -187,7 +213,7 @@ export interface NatsClient {
187
213
  /**
188
214
  * Subscribe to a JetStream subject via an ephemeral OrderedConsumer (no acks).
189
215
  * Use `optStartSeq` with `deliverPolicy: 'byStartSequence'` to resume from a known offset,
190
- * or `deliverPolicy: 'new'` to live-tail.
216
+ * or `deliverPolicy: 'new'` to live-tail (see that option for where it starts).
191
217
  */
192
218
  subscribeJetStreamOrdered(
193
219
  onMessage: (msg: JsMsg) => void | Promise<void>,
@@ -355,21 +381,14 @@ function mapNatsTypeToStatus(
355
381
  function watchForRecovery(
356
382
  iter: ConsumerMessages,
357
383
  recreatedEvent: ConsumerEvents.OrderedConsumerRecreated,
358
- onRecovered?: () => void,
384
+ onRecreated: (consumerName: string) => void,
359
385
  ): void {
360
- if (!onRecovered) return;
361
386
  void (async () => {
362
387
  try {
363
388
  const status = await iter.status();
364
389
  for await (const event of status) {
365
390
  if (event.type !== recreatedEvent) continue;
366
- try {
367
- onRecovered();
368
- } catch (e) {
369
- // A caller that throws must not take the watch down with it — this
370
- // loop is the only thing reporting recoveries for this subscription.
371
- console.warn('[nats] onRecovered threw:', e);
372
- }
391
+ onRecreated(String(event.data));
373
392
  }
374
393
  } catch {
375
394
  // Status ends with the subscription — nothing left to report.
@@ -377,6 +396,16 @@ function watchForRecovery(
377
396
  })();
378
397
  }
379
398
 
399
+ /** A caller that throws must not take down the loop reporting to it — that
400
+ * loop is the only thing reporting these events for its subscription. */
401
+ function invokeCallback(name: string, callback: (() => void) | undefined): void {
402
+ try {
403
+ callback?.();
404
+ } catch (e) {
405
+ console.warn(`[nats] ${name} threw:`, e);
406
+ }
407
+ }
408
+
380
409
  export function createNatsClient(options: NatsClientOptions): NatsClient {
381
410
  let nc: NatsConnection | null = null;
382
411
  let statusLoopAbort: AbortController | null = null;
@@ -632,8 +661,11 @@ export function createNatsClient(options: NatsClientOptions): NatsClient {
632
661
  const js = conn.jetstream();
633
662
  const consumer: Consumer = await js.consumers.get(opts.streamName, {
634
663
  filterSubjects: opts.filterSubject,
635
- deliver_policy: nats.DeliverPolicy.StartSequence,
636
- opt_start_seq: opts.optStartSeq ?? 0,
664
+ // nats.ws turns a start sequence of 0 into 1, so a 'new' that fell back to
665
+ // StartSequence replayed everything the stream retains for the subject.
666
+ ...(opts.deliverPolicy === 'new'
667
+ ? { deliver_policy: nats.DeliverPolicy.New }
668
+ : { deliver_policy: nats.DeliverPolicy.StartSequence, opt_start_seq: opts.optStartSeq ?? 0 }),
637
669
  // Milliseconds, NOT nanoseconds: nats.ws runs this through its own
638
670
  // `nanos()` before it reaches the server. Pre-converting here multiplied
639
671
  // it by 1e6 twice, putting the threshold ~9.5 years out — so the ephemeral
@@ -673,6 +705,25 @@ export function createNatsClient(options: NatsClientOptions): NatsClient {
673
705
  return { unsubscribe() {} };
674
706
  }
675
707
 
708
+ // Whether the consumer delivering now still owes `onCaughtUp` its backlog.
709
+ let backlogOwed = true;
710
+ const reportCaughtUp = () => {
711
+ if (!backlogOwed || closed) return;
712
+ backlogOwed = false;
713
+ invokeCallback('onCaughtUp', opts.onCaughtUp);
714
+ };
715
+ // An empty backlog has no message to say so: it shows only in the answer to
716
+ // CONSUMER.CREATE, which `info(true)` returns without asking the server again.
717
+ const reportIfNothingPending = async (consumerName?: string) => {
718
+ try {
719
+ const info = await consumer.info(true);
720
+ if (consumerName !== undefined && info.name !== consumerName) return;
721
+ if (info.num_pending === 0) reportCaughtUp();
722
+ } catch {
723
+ // A failed lookup reports nothing: the next message with `pending === 0` does instead.
724
+ }
725
+ };
726
+
676
727
  (async () => {
677
728
  try {
678
729
  const iter = await consumer.consume();
@@ -685,7 +736,19 @@ export function createNatsClient(options: NatsClientOptions): NatsClient {
685
736
  return;
686
737
  }
687
738
  iterRef.current = iter;
688
- watchForRecovery(iter, nats.ConsumerEvents.OrderedConsumerRecreated, opts.onRecovered);
739
+ if (opts.onRecovered || opts.onCaughtUp) {
740
+ watchForRecovery(iter, nats.ConsumerEvents.OrderedConsumerRecreated, consumerName => {
741
+ backlogOwed = true;
742
+ invokeCallback('onRecovered', opts.onRecovered);
743
+ // nats.ws announces the recreated consumer before it records it as
744
+ // the current one (a promise continuation does that), so its answer
745
+ // is read once those have run — and only if it is the one announced.
746
+ setTimeout(() => {
747
+ void reportIfNothingPending(consumerName);
748
+ }, 0);
749
+ });
750
+ }
751
+ if (opts.onCaughtUp) await reportIfNothingPending();
689
752
  for await (const msg of iter) {
690
753
  if (closed) break;
691
754
  try {
@@ -693,9 +756,17 @@ export function createNatsClient(options: NatsClientOptions): NatsClient {
693
756
  } catch (e) {
694
757
  emitStatus({ status: 'error', data: e });
695
758
  }
759
+ // `pending` is what the stream still held for this consumer when it
760
+ // sent this message: 0 means the backlog ends here.
761
+ if (msg.info.pending === 0) reportCaughtUp();
696
762
  }
697
763
  } catch (e) {
698
- if (!closed) emitStatus({ status: 'error', data: e });
764
+ if (!closed) {
765
+ emitStatus({ status: 'error', data: e });
766
+ // This consumer delivers nothing more: settle what it owed rather than
767
+ // leave the caller's history at its pre-gap snapshot (see `onCaughtUp`).
768
+ reportCaughtUp();
769
+ }
699
770
  }
700
771
  })().catch((e: unknown) => emitStatus({ status: 'error', data: e }));
701
772