Skip to content

fix(chat): rebuild chat history after logout and gate the tip milestone on it - #1331

Merged
bmc08gt merged 2 commits into
code/cashfrom
fix/chat-feed-sync-catch-up
Aug 26, 2026
Merged

fix(chat): rebuild chat history after logout and gate the tip milestone on it#1331
bmc08gt merged 2 commits into
code/cashfrom
fix/chat-feed-sync-catch-up

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

An account that had already sent a tip was shown the new-user tutorial again after signing back in, with "Scan a Tip Card" unticked while the activity feed right below it listed the tip. The milestone is a read of chat history — ChatMessageDao.hasEverTipped looks for an outgoing message with verb TIPPED — and two things conspired to leave that history empty.

Logout wiped the cache

RealSessionController routed AuthState.LoggedOut into ChatCoordinator.reset(), which deleted every chat metadata, message, and member row. Nothing needed it to: FlipcashDatabase.init names the database file from the account entropy, so two accounts are already separated by sitting in different files — PersistenceProvider calls that file swap the protection, which is why it must not short-circuit on isOpen(). The activity feed has never wiped itself on logout for the same reason.

The wipe was load-bearing in one place: account deletion reaches logout() through deleteAndLogout. So rather than dropping it, ChatCoordinator now splits in two:

  • teardown() — connections, jobs, in-memory state. What logout wants.
  • clearCache() — the persisted rows. Called only from deleteAndLogout.

logoutAndSwitchAccount also routes through logout(), so account switching stops wiping chat history as a side effect.

One consequence worth a decision: chat text now survives logout on disk, and the Room database is not SQLCipher-encrypted. That is the same exposure the activity feed already carries, but it is a product call rather than mine.

Login could not rebuild what logout removed

performFeedSync decides which chats need catching up by asking two questions — does this chat have cached messages, and does its stored latestEventSequence trail the server's. It asked both after upserting the server's sequence onto every metadata row and each chat's lastMessage into the message table, so the first read was always true and the second always equal. Both branches were unreachable.

That left each chat holding the single message the feed carried. In the reported case the newest message in the Jeffy DM was the tip received back at 2:39, not the tip sent at 2:38, so the rehydrated message was inbound and hasEverTipped stayed false permanently. The fix snapshots both values before the writes.

The delta path had the same shape one level down: performDeltaSync re-read chat_metadata for its starting sequence, which by then held the value the sync had just written — asking the server for everything after its own latest event, which is always nothing. Event.DeltaSyncNeeded now carries the pre-sync sequence. The live gap-fill caller has no write in front of it and keeps reading the row.

The milestone read the cache before it was complete

Even with the two fixes above, the wallet drew its verdict on a cold cache. WalletViewModel.isAwaitingActivity covered the activity feed's sync state; nothing covered the chat cache, so a cold start still flashed "Scan a Tip Card" as outstanding until the history landed.

The feed sync cannot be that signal — it reports Synced once the conversation list is written, before the backfill it schedules has run, and that backfill is exactly what surfaces a tip older than a chat's last message. FeedSyncDelegate now sends a terminal Event.CatchUpComplete after the catch-up loop. Its events are a FIFO channel drained by one sequential collector in RealChatCoordinator, so every catch-up item ahead of the marker has completed its suspend call by the time it is routed; routing it sets ChatState.historyHydration to Hydrated. A failed sync moves Unknown to Unavailable instead — an unreachable server ends the wait rather than extending it — and never downgrades a hydration that already succeeded.

hasEverTipped() returns Flow<Boolean?> and withholds an answer while hydration is Unknown. A cached tip still answers immediately, since a TIPPED message in the cache is proof whatever the hydration state, which keeps the warm-cache path — now the normal one — off the round-trip. It re-subscribes to the Room query on the hydration change rather than combining the two flows: a combine emits the pre-backfill answer alongside the "hydrated" flip, which is the same flash in a narrower window.

WalletViewModel emits nothing while the milestone is null, so onboardingItems stays null and the tab holds its loading state. A held balance deliberately does not short-circuit this the way it does the activity-feed clause — it is evidence about money, not about tipping. The cost is a funded, never-tipped user with a cold cache waiting one chat round-trip before the tab draws.

Tests

New FeedSyncCatchUpTest (6), ChatCoordinatorTeardownTest (2), ChatHistoryHydrationTest (4), and WalletMilestoneGatingTest (2), plus two cases in AuthManagerTest pinning that logout leaves the cache and deleteAndLogout clears it. Both new suites are written against the specific way this bug hides. ChatHistoryHydrationTest asserts the whole emitted sequence, since the defect is a transient false on the way to true and the settled value is correct either way. The feed-sync harness models Room's write-then-read behaviour instead of using relaxed mocks, which answer false/0 regardless of what the sync wrote — the exact coupling under test.

…urvive

An account that had already sent a tip was shown the new-user tutorial again
after signing back in, with "Scan a Tip Card" unticked. The milestone is a read
of chat history — ChatMessageDao.hasEverTipped looks for an outgoing message
with verb TIPPED — and two things conspired to leave that history empty.

Logout wiped the cache. RealSessionController routed AuthState.LoggedOut into
ChatCoordinator.reset, which deleted every metadata, message, and member row.
Nothing needed it to: FlipcashDatabase names the file from the account entropy,
so accounts are already separated by sitting in different databases, and the
activity feed has never wiped itself on logout for exactly that reason. Split
the coordinator into teardown (connections, jobs, in-memory state) and
clearCache (the rows), point logout at teardown, and give deleteAndLogout the
explicit wipe — deletion is the one caller that wants the data gone. Account
switching goes through logout too, so it stops wiping as a side effect.

Login could not rebuild what logout removed. performFeedSync decides which
chats need catching up by asking whether a chat has cached messages and whether
its stored sequence trails the server's — but it asked after upserting the
server's sequence and each chat's lastMessage, so the first read was always
true and the second always equal. Both branches were unreachable, leaving each
chat holding the single message the feed carried. In the reported case that
message was the tip received back, not the tip sent, so hasEverTipped stayed
false for good. Snapshot both values before the writes.

The delta path had the same shape one level down: performDeltaSync re-read
chat_metadata for its starting sequence, which by then held the value the sync
had just written, so it asked the server for everything after the server's own
latest event. DeltaSyncNeeded now carries the pre-sync sequence. The live
gap-fill caller has no write in front of it and keeps reading the row.

One gap remains: WalletViewModel.isAwaitingActivity gates on the activity
feed's sync state, not the chat cache's, so a cold start can still flash the
milestone as incomplete before chat history lands. Closing that needs a
"chat history hydrated at least once" signal that doesn't exist yet.
@github-actions github-actions Bot added type: fix Bug fix area: auth Login, session, access keys, identity area: session and removed type: fix Bug fix labels Aug 26, 2026
@bmc08gt bmc08gt self-assigned this Aug 26, 2026
The wallet tab drew "Scan a Tip Card" as outstanding before the chat cache
had been reconciled, so an account that had already tipped saw the new-user
tutorial for as long as its history took to arrive. `isAwaitingActivity`
covered the activity feed's sync state; nothing covered the chat cache.

The feed sync cannot serve as that signal. It reports itself `Synced` once
the conversation list is written, which is before the per-chat backfill it
schedules has run — and that backfill is what surfaces a tip older than a
chat's last message. `FeedSyncDelegate` now sends a terminal
`Event.CatchUpComplete` after the catch-up loop; because the delegate's
events are a FIFO channel drained by one sequential collector in
`RealChatCoordinator`, every catch-up item ahead of the marker has finished
its suspend call by the time it is routed. Routing it sets
`ChatState.historyHydration` to `Hydrated`. A failed sync moves `Unknown` to
`Unavailable` instead, so an unreachable server ends the wait rather than
extending it; it never downgrades a hydration that already succeeded.

`hasEverTipped()` returns `Flow<Boolean?>`, withholding an answer while
hydration is `Unknown`. A cached tip still answers immediately — a TIPPED
message in the cache is proof whatever the hydration state — so the warm
cache path, now the normal one, does not wait on a round-trip. The flow
re-subscribes to the Room query on the hydration change rather than
combining the two: a combine would emit the pre-backfill answer alongside
the "hydrated" flip and reintroduce the flash it is meant to remove.

`WalletViewModel` emits nothing while the milestone is null, leaving
`onboardingItems` null and the tab on its loading state. A held balance
deliberately does not short-circuit this one, unlike the activity-feed
clause: it is evidence about money, not about tipping. The cost is that a
funded, never-tipped user with a cold cache now waits one chat round-trip
before the tab draws.
@github-actions github-actions Bot added the type: fix Bug fix label Aug 26, 2026
@bmc08gt bmc08gt changed the title fix(chat): keep chat history across logout so onboarding milestones survive fix(chat): rebuild chat history after logout and gate the tip milestone on it Aug 26, 2026
@bmc08gt
bmc08gt merged commit 90e685b into code/cash Aug 26, 2026
3 checks passed
bmc08gt added a commit that referenced this pull request Aug 26, 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 added a commit that referenced this pull request Aug 26, 2026
…e presence (#1308)

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 deleted the fix/chat-feed-sync-catch-up branch August 27, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: auth Login, session, access keys, identity area: session type: fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant