Skip to content

fix(chat): back chats up by their applied event cursor, not by message presence - #1308

Merged
bmc08gt merged 1 commit into
code/cashfrom
fix/chat-backfill-cursor
Aug 26, 2026
Merged

fix(chat): back chats up by their applied event cursor, not by message presence#1308
bmc08gt merged 1 commit into
code/cashfrom
fix/chat-backfill-cursor

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

A feed sync never backfilled a transcript. It asked hasMessages to decide whether a chat had ever been fetched, but the same sync writes each chat's last-message preview as a message row moments earlier — so the answer was always yes, and the newest page was never pulled. Chats opened to a single preview message until the user scrolled.

Underneath that sat a worse problem. latest_event_sequence was written by two parties meaning two different things: the event stream advanced it to the sequence the client had actually applied, while the feed sync overwrote it with the server's reported head via @Insert(REPLACE). Once a sync landed, the next GetDelta(after:) resumed from head and silently skipped every event in between — lost messages, not just a stale transcript. The same whole-row replace also zeroed analytics_counted_through, re-counting received messages that had already been counted.

The fix

The column now has one meaning — the cursor the client has applied.

  • ChatMetadataDao.upsert inserts, or updates only the server-owned columns in place. latest_event_sequence and analytics_counted_through are client-owned watermarks that no server payload carries, so a sync never touches them.
  • ChatEntityMapper stops round-tripping the server head in either direction. A row starts at 0, meaning "this transcript has never been fetched"; a chat rebuilt from the database reports 0 for the head — unknown, not "no events".
  • MessagingDelegate.loadMessages seats the cursor at the newest page's frontier, and only ever forward — a page older than the cursor must not rewind it.
  • FeedSyncDelegate's decision reads the cursor: unseated means load the newest page (never a delta, which would re-pull the whole history from sequence 0); behind the server head means delta-sync the missed window.

Tests

15 new tests across three modules:

  • FeedSyncBackfillTest — the five backfill decisions. The harness stubs hasMessages to true, which is what reality looks like once the preview row is written, so the never-fetched cases genuinely discriminate against the old condition.
  • MessagingLoadCursorTest — the cursor is seated from the page's highest event sequence, is not rewound by an older page, and stays unseated for an empty page.
  • ChatMetadataDaoTest — an upsert preserves both client-owned watermarks, refreshes the server-owned columns, and inserts an unseen chat.
  • ChatEntityMapperTest — the server head is never written as the applied cursor, and a chat rebuilt from the database reports an unknown head.

Parity

Mirrors the iOS fix in code-payments/code-ios-app#628. One shared, deliberate behaviour: a genuinely empty chat leaves its cursor at 0 and is re-queried on each feed sync — iOS does the same (if head > 0), so this is parity rather than drift.

ChatMessageDataSource.hasMessages now has no production caller. Left in place as a query helper rather than widen the diff.

@github-actions github-actions Bot added the type: fix Bug fix label Aug 22, 2026
…e presence

A feed sync never backfilled a transcript. It asked `hasMessages` to decide
whether a chat had ever been fetched, but the same sync writes each chat's
last-message preview as a message row moments earlier — so the answer was
always yes, and the newest page was never pulled. Chats opened to a single
preview message until the user scrolled.

Underneath that sat a worse problem: `latest_event_sequence` was written by
two parties meaning two different things. The event stream advanced it to the
sequence the client had actually applied; the feed sync overwrote it with the
server's reported head via `@Insert(REPLACE)`. Once a sync landed, the next
`GetDelta(after:)` resumed from head and silently skipped every event in
between. The same whole-row replace also zeroed `analytics_counted_through`,
re-counting received messages already counted.

The column now has one meaning — the cursor the client has applied:

- `ChatMetadataDao.upsert` inserts, or updates only the server-owned columns
  in place. The two client-owned watermarks are never touched by a sync.
- `ChatEntityMapper` stops round-tripping the server head. A row starts at 0,
  meaning "this transcript has never been fetched"; a chat rebuilt from the
  database reports 0 for the head — unknown, not "no events".
- `MessagingDelegate.loadMessages` seats the cursor at the newest page's
  frontier, and only ever forward.
- The sync's decision reads the cursor: unseated means load the newest page
  (never a delta, which would re-pull the whole history from sequence 0),
  behind the server head means delta-sync the missed window.

The pre-sync snapshot #1331 added comes out with it. That workaround read the
cursor and `hasMessages` *before* the sync wrote, and carried the old cursor on
`DeltaSyncNeeded`, because the write clobbered the row it was about to read.
Nothing clobbers it now: the sync reads the cursor after its own write, and
`performDeltaSync` reads it for itself again.

This matches the iOS fix in code-ios-app#628.
@bmc08gt
bmc08gt force-pushed the fix/chat-backfill-cursor branch from e3dfbaa to 3d0d096 Compare August 26, 2026 16:22
@bmc08gt bmc08gt self-assigned this Aug 26, 2026
@bmc08gt
bmc08gt merged commit 2b4c5e7 into code/cash Aug 26, 2026
@bmc08gt
bmc08gt deleted the fix/chat-backfill-cursor branch August 26, 2026 16:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant