@ttt-productions/chat-react 0.12.12 → 0.12.14

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.
@@ -17,6 +17,7 @@
17
17
  import { createReconnectController } from '@ttt-productions/realtime-core';
18
18
  import { CLIENT_FRAME, CHAT_CLOSE_CODES, buildFrame, parseFrame, isInboxSnapshot, ChatSendRejectedPayloadSchema, } from './wire.js';
19
19
  import { wireRowToMessage, optimisticMessage, seqToMessageId, applyModerationOverlay, overlayFromRow, } from './map.js';
20
+ import { createDiagnosticsEmitter, safeDiagnosticLabel, CHAT_CLIENT_DIAGNOSTIC_EVENTS as DIAG, } from './diagnostics.js';
20
21
  import { defaultTimers, isChatAccessDeniedError, HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX, MAX_PENDING_SEND_ATTEMPTS, PENDING_SEND_MAX_AGE_MS, SERVER_RETRYABLE_MAX_AGE_MS, SEND_RETRY_MIN_DELAY_MS, SEND_RETRY_MAX_DELAY_MS, } from './shared.js';
21
22
  const INITIAL = {
22
23
  status: 'idle',
@@ -82,10 +83,23 @@ export class ChannelClient {
82
83
  * send that exceeds the attempt/age cap is flipped to a visible failed state
83
84
  * (retry affordance) instead of staying a ghost. */
84
85
  pendingSends = new Map();
86
+ /**
87
+ * The structured diagnostics emitter, or null when diagnostics are off (the
88
+ * default). Every call site uses `this.diag?.(...)` so the off path constructs
89
+ * no payload at all.
90
+ */
91
+ diag;
92
+ /** Monotonic socket-open attempt counter (diagnostics correlation only). */
93
+ connectAttempt = 0;
94
+ /** Why the CURRENT connect attempt is happening (diagnostics only). */
95
+ reconnectCause = 'initial';
96
+ /** The last close code observed, carried into the next attempt's cause line. */
97
+ lastCloseCode = null;
85
98
  constructor(config) {
86
99
  this.config = config;
87
100
  this.timers = config.timers ?? defaultTimers;
88
101
  this.controller = createReconnectController(config.reconnect ?? {});
102
+ this.diag = createDiagnosticsEmitter(config.diagnostics);
89
103
  }
90
104
  // ---- observable store ----
91
105
  getState() {
@@ -111,11 +125,20 @@ export class ChannelClient {
111
125
  if (this.state.status !== 'idle' && this.state.status !== 'closed')
112
126
  return;
113
127
  this.closedByUs = false;
128
+ this.reconnectCause = 'initial';
114
129
  this.controller.start();
115
130
  this.setState({ status: 'connecting' });
116
131
  await this.openSocket();
117
132
  }
118
133
  async openSocket() {
134
+ this.connectAttempt += 1;
135
+ this.diag?.(DIAG.CONNECT_ATTEMPT, {
136
+ attempt: this.connectAttempt,
137
+ cause: this.reconnectCause,
138
+ closeCode: this.lastCloseCode,
139
+ resumeSeq: this.resumeSeq,
140
+ pendingSends: this.pendingSends.size,
141
+ });
119
142
  let grantToken;
120
143
  try {
121
144
  grantToken = await this.config.grantProvider();
@@ -127,8 +150,11 @@ export class ChannelClient {
127
150
  // on the opening loader forever. Every other mint failure (network, transient
128
151
  // 'unavailable'/'internal', post-login 'unauthenticated') stays retryable and
129
152
  // backs off exactly as before.
130
- if (isChatAccessDeniedError(err))
153
+ const terminal = isChatAccessDeniedError(err);
154
+ this.diag?.(DIAG.GRANT_FAILED, { attempt: this.connectAttempt, terminal });
155
+ if (terminal)
131
156
  return this.denyAccessTerminally();
157
+ this.reconnectCause = 'grant-error';
132
158
  return this.scheduleReconnect();
133
159
  }
134
160
  if (this.closedByUs)
@@ -149,10 +175,18 @@ export class ChannelClient {
149
175
  this.controller.onOpen();
150
176
  this.reauthAttempted = false;
151
177
  this.setState({ status: 'open' });
178
+ this.diag?.(DIAG.SOCKET_OPEN, {
179
+ attempt: this.connectAttempt,
180
+ cause: this.reconnectCause,
181
+ resumeSeq: this.resumeSeq,
182
+ pendingSends: this.pendingSends.size,
183
+ presenceSubscribed: this.presenceSubscribed,
184
+ });
152
185
  // Resume: ask the DO for the authoritative snapshot, then live deltas flow. The
153
186
  // field name MUST be `afterSeq` — the Channel DO reads `payload.afterSeq`; any other
154
187
  // name reads as cursorless and forces a full resync every reconnect.
155
188
  this.sendFrame(CLIENT_FRAME.RESUME, { afterSeq: this.resumeSeq });
189
+ this.diag?.(DIAG.RESUME_REQUEST, { afterSeq: this.resumeSeq, attempt: this.connectAttempt });
156
190
  // If the UI had presence open, re-subscribe after a reconnect.
157
191
  if (this.presenceSubscribed)
158
192
  this.sendFrame(CLIENT_FRAME.PRESENCE_SUBSCRIBE, {});
@@ -164,7 +198,7 @@ export class ChannelClient {
164
198
  // that lost its socket before the ack is not silently dropped. The DO dedups
165
199
  // (re-acks, no re-broadcast, no flood-token) — this is the client half of the
166
200
  // Contract C send-idempotency the server side was built for.
167
- this.resendPendingSends();
201
+ this.resendPendingSends('reconnect');
168
202
  this.startHeartbeat();
169
203
  }
170
204
  /**
@@ -173,7 +207,7 @@ export class ChannelClient {
173
207
  * visible failed state instead of being resent forever — the ghost becomes a
174
208
  * retryable failure the UI can surface.
175
209
  */
176
- resendPendingSends() {
210
+ resendPendingSends(reason) {
177
211
  if (this.pendingSends.size === 0)
178
212
  return;
179
213
  const now = this.timers.now();
@@ -213,6 +247,14 @@ export class ChannelClient {
213
247
  });
214
248
  if (sent)
215
249
  pending.attempts += 1;
250
+ this.diag?.(DIAG.SEND_RETRY, {
251
+ clientMessageId: pending.clientMessageId,
252
+ reason,
253
+ attempts: pending.attempts,
254
+ serverRetryable: pending.serverRetryable,
255
+ ageMs: now - pending.sentAt,
256
+ sent,
257
+ });
216
258
  }
217
259
  }
218
260
  /**
@@ -234,6 +276,14 @@ export class ChannelClient {
234
276
  pending.failed = true;
235
277
  // A terminal/exhausted send never retries on a timer — cancel any scheduled one.
236
278
  this.clearSendRetryTimer(pending);
279
+ this.diag?.(DIAG.SEND_FAILED, {
280
+ clientMessageId,
281
+ code: code ?? 'send-failed',
282
+ retryable: retryable ?? null,
283
+ attempts: pending.attempts,
284
+ serverRetryable: pending.serverRetryable,
285
+ ageMs: this.timers.now() - pending.sentAt,
286
+ });
237
287
  const next = this.state.messages.map((m) => m.meta?.optimistic && m.meta?.clientMessageId === clientMessageId
238
288
  ? {
239
289
  ...m,
@@ -273,7 +323,7 @@ export class ChannelClient {
273
323
  this.membershipRetryTimer = null;
274
324
  if (this.closedByUs)
275
325
  return;
276
- this.resendPendingSends();
326
+ this.resendPendingSends('membership-pending');
277
327
  }, delayMs);
278
328
  }
279
329
  /**
@@ -301,20 +351,27 @@ export class ChannelClient {
301
351
  const rawId = payload.clientMessageId;
302
352
  if (typeof rawId === 'string' && rawId.length > 0 && rawId.length <= 200) {
303
353
  const orphaned = this.pendingSends.get(rawId);
304
- if (orphaned && !orphaned.failed)
354
+ if (orphaned && !orphaned.failed) {
355
+ this.diag?.(DIAG.SEND_REJECTED, { clientMessageId: rawId, outcome: 'unparseable-fail-closed' });
305
356
  this.failPendingSend(rawId);
357
+ return;
358
+ }
306
359
  }
360
+ this.diag?.(DIAG.SEND_REJECTED, { clientMessageId: null, outcome: 'unparseable-dropped' });
307
361
  return;
308
362
  }
309
363
  const { clientMessageId, code, retryable, retryAfterMs } = parsed.data;
310
364
  const pending = this.pendingSends.get(clientMessageId);
311
365
  // No matching pending send — already acked/reconciled, or never ours. A late
312
366
  // ack/echo (which may have already landed) always wins; nothing to correlate.
313
- if (!pending || pending.failed)
367
+ if (!pending || pending.failed) {
368
+ this.diag?.(DIAG.SEND_REJECTED, { clientMessageId, code, retryable, outcome: 'no-pending-send' });
314
369
  return;
370
+ }
315
371
  if (!retryable) {
316
372
  // Terminal: an unchanged resend cannot succeed (blocked-word / archived /
317
373
  // deleted). Fail ONLY this bubble, recording the reason + terminal class.
374
+ this.diag?.(DIAG.SEND_REJECTED, { clientMessageId, code, retryable: false, outcome: 'terminal' });
318
375
  this.failPendingSend(clientMessageId, code, false);
319
376
  return;
320
377
  }
@@ -326,10 +383,13 @@ export class ChannelClient {
326
383
  // If the budget already elapsed (a long stream of retryable rejections), fail
327
384
  // now rather than scheduling yet another doomed resend.
328
385
  if (this.timers.now() - pending.sentAt >= SERVER_RETRYABLE_MAX_AGE_MS) {
386
+ this.diag?.(DIAG.SEND_REJECTED, { clientMessageId, code, retryable: true, outcome: 'budget-exhausted' });
329
387
  this.failPendingSend(clientMessageId, code, true);
330
388
  return;
331
389
  }
332
- this.scheduleSendRetry(clientMessageId, this.clampRetryDelay(retryAfterMs));
390
+ const delayMs = this.clampRetryDelay(retryAfterMs);
391
+ this.diag?.(DIAG.SEND_REJECTED, { clientMessageId, code, retryable: true, outcome: 'retry-scheduled', delayMs });
392
+ this.scheduleSendRetry(clientMessageId, delayMs);
333
393
  }
334
394
  /**
335
395
  * Schedule EXACTLY ONE per-send resend of a correlated retryable rejection (never
@@ -355,11 +415,19 @@ export class ChannelClient {
355
415
  }
356
416
  // A server-retryable resend does NOT count toward the attempt cap (that cap is
357
417
  // for blind reconnect resends only) — leave `attempts` untouched.
358
- this.sendFrame(CLIENT_FRAME.SEND, {
418
+ const sent = this.sendFrame(CLIENT_FRAME.SEND, {
359
419
  clientMessageId: current.clientMessageId,
360
420
  text: current.text,
361
421
  replyTo: current.replyTo,
362
422
  });
423
+ this.diag?.(DIAG.SEND_RETRY, {
424
+ clientMessageId: current.clientMessageId,
425
+ reason: 'scheduled',
426
+ attempts: current.attempts,
427
+ serverRetryable: current.serverRetryable,
428
+ ageMs: this.timers.now() - current.sentAt,
429
+ sent,
430
+ });
363
431
  // Budget backstop: the contract says every send gets an ack or a rejection,
364
432
  // and either one replaces/clears this timer. If the server goes silent (a
365
433
  // contract violation), NOTHING else would ever re-check the budget on a
@@ -422,6 +490,14 @@ export class ChannelClient {
422
490
  replyTo: pending.replyTo,
423
491
  });
424
492
  pending.attempts = sent ? 1 : 0;
493
+ this.diag?.(DIAG.SEND_RETRY, {
494
+ clientMessageId,
495
+ reason: 'manual',
496
+ attempts: pending.attempts,
497
+ serverRetryable: false,
498
+ ageMs: 0,
499
+ sent,
500
+ });
425
501
  const next = this.state.messages.map((m) => {
426
502
  if (!(m.meta?.optimistic && m.meta?.clientMessageId === clientMessageId && m.meta?.sendFailed)) {
427
503
  return m;
@@ -439,13 +515,17 @@ export class ChannelClient {
439
515
  onClose(code, _reason) {
440
516
  this.stopHeartbeat();
441
517
  this.socket = null;
518
+ this.lastCloseCode = code;
442
519
  if (this.closedByUs) {
520
+ this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: true, outcome: 'closed', resumeSeq: this.resumeSeq });
443
521
  this.setState({ status: 'closed' });
444
522
  return;
445
523
  }
446
524
  // 4401 AUTH_EXPIRED: re-mint a grant ONCE and reconnect immediately, no backoff.
447
525
  if (code === CHAT_CLOSE_CODES.AUTH_EXPIRED && !this.reauthAttempted) {
448
526
  this.reauthAttempted = true;
527
+ this.reconnectCause = 'auth-expired';
528
+ this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: false, outcome: 're-grant', resumeSeq: this.resumeSeq });
449
529
  this.setState({ status: 'reconnecting' });
450
530
  void this.openSocket();
451
531
  return;
@@ -457,14 +537,23 @@ export class ChannelClient {
457
537
  if (code === CHAT_CLOSE_CODES.REVOKED) {
458
538
  this.closedByUs = true;
459
539
  this.controller.close();
540
+ this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: false, outcome: 'revoked', resumeSeq: this.resumeSeq });
460
541
  this.failAllPendingSends();
461
542
  this.setState({ status: 'closed', lastErrorCode: 'revoked' });
462
543
  return;
463
544
  }
545
+ this.reconnectCause = 'transient-close';
546
+ this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: false, outcome: 'reconnect', resumeSeq: this.resumeSeq });
464
547
  this.scheduleReconnect();
465
548
  }
466
549
  scheduleReconnect() {
467
550
  const delay = this.controller.onClose();
551
+ this.diag?.(DIAG.RECONNECT_SCHEDULED, {
552
+ delayMs: delay,
553
+ cause: this.reconnectCause,
554
+ attempt: this.connectAttempt,
555
+ resumeSeq: this.resumeSeq,
556
+ });
468
557
  if (delay == null) {
469
558
  // Reconnect give-up: terminal, same as REVOKED — pending sends must not stay
470
559
  // an eternal "Sending…"; flip them to the visible failed/retry state.
@@ -498,12 +587,14 @@ export class ChannelClient {
498
587
  // ---- inbound frames ----
499
588
  onMessage(data) {
500
589
  const frame = parseFrame(data);
501
- if (!frame || !frame.type)
590
+ if (!frame || !frame.type) {
591
+ this.diag?.(DIAG.FRAME_DROPPED, { kind: 'unparseable', reason: 'bad-envelope' });
502
592
  return;
593
+ }
503
594
  const payload = frame.payload ?? {};
504
595
  switch (frame.type) {
505
596
  case 'message':
506
- return this.applyMessage(payload.message);
597
+ return this.applyMessage(payload.message, 'live');
507
598
  case 'ack':
508
599
  return this.applyAck(payload);
509
600
  case 'history-page':
@@ -528,6 +619,12 @@ export class ChannelClient {
528
619
  if (code === MEMBERSHIP_PENDING_CODE) {
529
620
  const raw = payload.retryAfterMs;
530
621
  const delay = this.clampRetryDelay(typeof raw === 'number' ? raw : undefined);
622
+ this.diag?.(DIAG.ERROR_FRAME, {
623
+ code: MEMBERSHIP_PENDING_CODE,
624
+ correlated: false,
625
+ delayMs: delay,
626
+ pendingSends: this.pendingSends.size,
627
+ });
531
628
  for (const pending of this.pendingSends.values()) {
532
629
  if (!pending.failed) {
533
630
  pending.serverRetryable = true;
@@ -536,12 +633,15 @@ export class ChannelClient {
536
633
  }
537
634
  return this.scheduleMembershipPendingRetry(delay);
538
635
  }
636
+ this.diag?.(DIAG.ERROR_FRAME, { code: safeDiagnosticLabel(code), correlated: false });
539
637
  return this.setState({ lastErrorCode: code });
540
638
  }
541
639
  case 'revision':
542
640
  return this.applyRevisionFrame(payload);
543
641
  default:
544
- return; // unknown/optional types tolerated (forward compat)
642
+ // unknown/optional types tolerated (forward compat)
643
+ this.diag?.(DIAG.FRAME_DROPPED, { kind: safeDiagnosticLabel(frame.type), reason: 'unknown-type' });
644
+ return;
545
645
  }
546
646
  }
547
647
  /**
@@ -555,18 +655,36 @@ export class ChannelClient {
555
655
  const seq = payload.messageSeq;
556
656
  const kind = payload.kind;
557
657
  const messageRevision = payload.messageRevision;
558
- if (typeof seq !== 'number' || kind == null || typeof messageRevision !== 'number')
658
+ if (typeof seq !== 'number' || kind == null || typeof messageRevision !== 'number') {
659
+ this.diag?.(DIAG.FRAME_DROPPED, { kind: 'revision', reason: 'malformed-payload' });
559
660
  return;
661
+ }
560
662
  const prev = this.overlays.get(seq);
561
663
  // Max-revision merge: a higher messageRevision supersedes; an older one is dropped.
562
- if (prev && prev.messageRevision >= messageRevision)
664
+ if (prev && prev.messageRevision >= messageRevision) {
665
+ this.diag?.(DIAG.FRAME_DROPPED, {
666
+ kind: 'revision',
667
+ reason: 'stale-revision',
668
+ seq,
669
+ messageRevision,
670
+ heldRevision: prev.messageRevision,
671
+ });
563
672
  return;
673
+ }
564
674
  this.overlays.set(seq, { kind, messageRevision });
565
675
  // Re-render the matching message in place if it is already present. Re-derive
566
676
  // from the ORIGINAL row so a `restore` reverts a previously-blanked text.
567
677
  const messageId = seqToMessageId(seq);
568
678
  const original = this.originalRows.get(seq);
569
- if (original && this.state.messages.some((m) => m.messageId === messageId)) {
679
+ const rendered = Boolean(original) && this.state.messages.some((m) => m.messageId === messageId);
680
+ this.diag?.(DIAG.REVISION_APPLIED, {
681
+ seq,
682
+ kind,
683
+ messageRevision,
684
+ // false = overlay retained for a row not loaded yet (re-applied when it renders).
685
+ rendered,
686
+ });
687
+ if (rendered && original) {
570
688
  const overlaid = applyModerationOverlay(original, this.effectiveOverlay(seq, null));
571
689
  const next = this.state.messages.map((m) => (m.messageId === messageId ? overlaid : m));
572
690
  this.setState({ messages: next });
@@ -613,21 +731,34 @@ export class ChannelClient {
613
731
  */
614
732
  applyChannelSnapshot(snap) {
615
733
  // Guard: an inbox snapshot must never reach a channel client.
616
- if (isInboxSnapshot(snap))
734
+ if (isInboxSnapshot(snap)) {
735
+ this.diag?.(DIAG.FRAME_DROPPED, { kind: 'snapshot', reason: 'inbox-snapshot-on-channel' });
617
736
  return;
618
- if (typeof snap.lastMessageSeq !== 'number')
737
+ }
738
+ if (typeof snap.lastMessageSeq !== 'number') {
739
+ this.diag?.(DIAG.FRAME_DROPPED, { kind: 'snapshot', reason: 'missing-last-message-seq' });
619
740
  return;
741
+ }
620
742
  // Absent `resync` (legacy/pre-contract DO) is treated as a resync directive.
621
743
  const resync = snap.resync !== false;
744
+ const cursorBefore = this.resumeSeq;
745
+ const delta = Array.isArray(snap.delta) ? snap.delta : [];
622
746
  if (!resync) {
623
747
  // Apply the contiguous delta oldest-first, exactly like live messages, then
624
748
  // advance the cursor to the authoritative tail so the NEXT resume tells the DO
625
749
  // we're caught up only after we actually applied the backlog.
626
- const delta = Array.isArray(snap.delta) ? snap.delta : [];
627
750
  for (const row of delta)
628
- this.applyMessage(row);
751
+ this.applyMessage(row, 'delta');
629
752
  if (snap.lastMessageSeq > this.resumeSeq)
630
753
  this.resumeSeq = snap.lastMessageSeq;
754
+ this.diag?.(DIAG.RESUME_RESULT, {
755
+ lastMessageSeq: snap.lastMessageSeq,
756
+ resync: false,
757
+ deltaCount: delta.length,
758
+ cursorBefore,
759
+ cursorAfter: this.resumeSeq,
760
+ readSeq: typeof snap.readSeq === 'number' ? snap.readSeq : null,
761
+ });
631
762
  // A non-resync snapshot IS the first authoritative data (even an empty delta =
632
763
  // caught up) — end initial loading. A resync snapshot instead defers to the
633
764
  // re-paged first history page below, so it must NOT end it here.
@@ -643,17 +774,91 @@ export class ChannelClient {
643
774
  this.overlays.clear();
644
775
  this.originalRows.clear();
645
776
  this.resumeSeq = 0;
777
+ const priorCount = this.state.messages.length;
646
778
  const keptOptimistic = this.state.messages.filter((m) => m.meta?.optimistic);
779
+ this.diag?.(DIAG.RESUME_RESULT, {
780
+ lastMessageSeq: snap.lastMessageSeq,
781
+ resync: true,
782
+ deltaCount: delta.length,
783
+ cursorBefore,
784
+ cursorAfter: 0,
785
+ readSeq: typeof snap.readSeq === 'number' ? snap.readSeq : null,
786
+ });
787
+ this.diag?.(DIAG.RESYNC_DROPPED_TAIL, {
788
+ // The client REMOVED rows from the UI here — the counterpart of an upsert.
789
+ removedCount: priorCount - keptOptimistic.length,
790
+ keptOptimistic: keptOptimistic.length,
791
+ });
647
792
  this.setState({ messages: keptOptimistic, hasOlder: true, isFetchingOlder: false });
648
793
  this.requestHistory(null);
649
794
  }
650
- applyMessage(row) {
651
- if (!row || typeof row.seq !== 'number')
795
+ /**
796
+ * Apply one server row (a live `message` frame or a resume-delta row) and record
797
+ * the decision the client made about it.
798
+ *
799
+ * Diagnostics bounding: a LIVE frame always emits one `chat_client_frame_applied`
800
+ * line; a DELTA row emits one only when it is materially interesting (carries an
801
+ * attachment state, carries a moderation kind, or reconciles one of our optimistic
802
+ * sends) — a 500-row backlog is otherwise summarized by `chat_client_resume_result`
803
+ * rather than spamming 500 lines. Attachment transitions ALWAYS emit, from either
804
+ * source, because an in-place attachment mutation is the exact event this
805
+ * instrumentation exists to prove.
806
+ */
807
+ applyMessage(row, source) {
808
+ if (!row || typeof row.seq !== 'number') {
809
+ this.diag?.(DIAG.FRAME_DROPPED, { kind: 'message', source, reason: 'missing-seq' });
652
810
  return;
811
+ }
812
+ // Read the pre-merge view ONLY when diagnostics are on (these are O(n) scans).
813
+ const prior = this.diag ? this.priorRowFacts(row) : null;
814
+ const cursorBefore = this.resumeSeq;
653
815
  const mapped = this.renderRow(row);
654
816
  this.mergeMessage(mapped, row);
655
817
  if (row.seq > this.resumeSeq)
656
818
  this.resumeSeq = row.seq;
819
+ if (!this.diag || !prior)
820
+ return;
821
+ const attachmentState = typeof row.attachmentState === 'string' ? row.attachmentState : null;
822
+ // Emit a transition for a real change only: first arrival of an attachment row
823
+ // (null -> pending) and every later in-place mutation (pending -> ready/failed).
824
+ if (attachmentState !== prior.attachmentState && (attachmentState != null || prior.hadRow)) {
825
+ this.diag(DIAG.ATTACHMENT_TRANSITION, {
826
+ seq: row.seq,
827
+ from: prior.attachmentState,
828
+ to: attachmentState,
829
+ source,
830
+ });
831
+ }
832
+ const notable = attachmentState != null || row.moderationKind != null || prior.reconciledOptimistic;
833
+ if (source === 'delta' && !notable)
834
+ return;
835
+ this.diag(DIAG.FRAME_APPLIED, {
836
+ kind: 'message',
837
+ source,
838
+ seq: row.seq,
839
+ hasClientMessageId: Boolean(row.clientMessageId),
840
+ attachmentState,
841
+ moderationKind: row.moderationKind ?? null,
842
+ op: prior.reconciledOptimistic ? 'optimistic-reconcile' : prior.hadRow ? 'replace-by-seq' : 'insert',
843
+ cursorBefore,
844
+ cursorAfter: this.resumeSeq,
845
+ });
846
+ }
847
+ /**
848
+ * The pre-merge view of a row's seq: whether we already hold that seq, its current
849
+ * attachment state, and whether this row reconciles a live optimistic send.
850
+ * Diagnostics-only — never called when diagnostics are off.
851
+ */
852
+ priorRowFacts(row) {
853
+ const messageId = seqToMessageId(row.seq);
854
+ const existing = this.state.messages.find((m) => m.messageId === messageId);
855
+ const raw = existing?.meta?.attachmentState;
856
+ return {
857
+ hadRow: existing != null,
858
+ attachmentState: typeof raw === 'string' ? raw : null,
859
+ reconciledOptimistic: Boolean(row.clientMessageId &&
860
+ this.state.messages.some((m) => m.meta?.optimistic && m.meta?.clientMessageId === row.clientMessageId)),
861
+ };
657
862
  }
658
863
  /**
659
864
  * Merge a server message: reconcile an optimistic echo by clientMessageId (swap
@@ -682,6 +887,13 @@ export class ChannelClient {
682
887
  // ack guarantees the seq even if the broadcast is dropped/reordered.
683
888
  const seq = payload.seq;
684
889
  const cmid = payload.clientMessageId;
890
+ this.diag?.(DIAG.SEND_ACK, {
891
+ clientMessageId: cmid,
892
+ seq,
893
+ cursorBefore: this.resumeSeq,
894
+ cursorAfter: seq > this.resumeSeq ? seq : this.resumeSeq,
895
+ wasPending: this.pendingSends.has(cmid),
896
+ });
685
897
  // The send is acked — stop tracking it for reconnect resend (and cancel any
686
898
  // per-send retry timer so a scheduled resend never fires after it landed).
687
899
  this.clearPendingSend(cmid);
@@ -700,6 +912,12 @@ export class ChannelClient {
700
912
  this.setState({ messages: next });
701
913
  }
702
914
  applyHistoryPage(rows) {
915
+ const cursorBefore = this.resumeSeq;
916
+ // Pre-page attachment states for the seqs we already hold — a history page that
917
+ // carries a DIFFERENT state for a row we already rendered is an in-place mutation
918
+ // the client learned about by re-fetching, which is exactly the reveal path under
919
+ // investigation. Diagnostics-only, so only built when they are on.
920
+ const priorAttachment = this.diag ? this.snapshotAttachmentStates() : null;
703
921
  const mapped = rows.map((r) => this.renderRow(r));
704
922
  // Mirror mergeMessage's optimistic reconciliation: a history row carrying a
705
923
  // clientMessageId we still hold as an optimistic placeholder IS that send (the
@@ -735,6 +953,47 @@ export class ChannelClient {
735
953
  // which is a harmless no-op since the flag never flips back to false.)
736
954
  hasLoadedInitialData: true,
737
955
  });
956
+ if (!this.diag)
957
+ return;
958
+ if (priorAttachment) {
959
+ for (const r of rows) {
960
+ if (typeof r.seq !== 'number')
961
+ continue;
962
+ const before = priorAttachment.get(r.seq);
963
+ if (before === undefined)
964
+ continue; // a row we did not hold — not a transition
965
+ const after = typeof r.attachmentState === 'string' ? r.attachmentState : null;
966
+ if (before !== after) {
967
+ this.diag(DIAG.ATTACHMENT_TRANSITION, { seq: r.seq, from: before, to: after, source: 'history' });
968
+ }
969
+ }
970
+ }
971
+ const seqs = rows.map((r) => r.seq).filter((s) => typeof s === 'number');
972
+ this.diag(DIAG.HISTORY_PAGE, {
973
+ count: rows.length,
974
+ minSeq: seqs.length > 0 ? Math.min(...seqs) : null,
975
+ maxSeq: seqs.length > 0 ? Math.max(...seqs) : null,
976
+ cursorBefore,
977
+ cursorAfter: this.resumeSeq,
978
+ reconciledOptimistic: incomingCmids.size,
979
+ hasOlder: rows.length >= HISTORY_PAGE_MAX,
980
+ total: merged.length,
981
+ });
982
+ }
983
+ /**
984
+ * A seq -> attachmentState map of the rows currently held (diagnostics-only, so a
985
+ * later in-place mutation can be reported as an explicit `old -> new` transition).
986
+ */
987
+ snapshotAttachmentStates() {
988
+ const out = new Map();
989
+ for (const m of this.state.messages) {
990
+ const seq = m.meta?.seq;
991
+ if (typeof seq !== 'number')
992
+ continue;
993
+ const raw = m.meta?.attachmentState;
994
+ out.set(seq, typeof raw === 'string' ? raw : null);
995
+ }
996
+ return out;
738
997
  }
739
998
  applyTyping(uid) {
740
999
  if (!uid || uid === this.config.currentUserId)
@@ -771,6 +1030,13 @@ export class ChannelClient {
771
1030
  replyTo: args.replyTo ?? null,
772
1031
  });
773
1032
  if (!sent) {
1033
+ this.diag?.(DIAG.SEND_FAILED, {
1034
+ clientMessageId: args.clientMessageId,
1035
+ code: 'send-failed',
1036
+ retryable: null,
1037
+ attempts: 0,
1038
+ reason: 'socket-not-open',
1039
+ });
774
1040
  this.setState({ lastErrorCode: 'send-failed' });
775
1041
  return false;
776
1042
  }
@@ -797,6 +1063,12 @@ export class ChannelClient {
797
1063
  });
798
1064
  const next = [...this.state.messages, echo].sort(sortBySeq);
799
1065
  this.setState({ messages: next, lastErrorCode: null });
1066
+ this.diag?.(DIAG.SEND_OPTIMISTIC, {
1067
+ clientMessageId: args.clientMessageId,
1068
+ hasReplyTo: Boolean(args.replyTo),
1069
+ replyToSeq: args.replyTo?.messageSeq ?? null,
1070
+ pendingSends: this.pendingSends.size,
1071
+ });
800
1072
  return true;
801
1073
  }
802
1074
  /**
@@ -836,7 +1108,8 @@ export class ChannelClient {
836
1108
  this.requestHistory(beforeSeq);
837
1109
  }
838
1110
  requestHistory(beforeSeq) {
839
- this.sendFrame(CLIENT_FRAME.HISTORY, { beforeSeq, limit: HISTORY_PAGE_MAX });
1111
+ const sent = this.sendFrame(CLIENT_FRAME.HISTORY, { beforeSeq, limit: HISTORY_PAGE_MAX });
1112
+ this.diag?.(DIAG.HISTORY_REQUEST, { beforeSeq, limit: HISTORY_PAGE_MAX, sent });
840
1113
  }
841
1114
  // ---- heartbeat (20s) ----
842
1115
  startHeartbeat() {