Skip to content

Commit a37210e

Browse files
committed
fix(chat): create the member row when recording a read pointer
`ChatMemberDao.updatePointers` was a bare `UPDATE ... WHERE chat_id_hex = ? AND user_id_hex = ?`, which matches nothing when the member row is absent, so the pointer was dropped without a trace. Both callers can reach it in that state: a read is written the moment a message is on screen, which can beat the feed sync that writes the membership, and the event stream applies pointer updates for members the feed has not written yet. It is now `advancePointer`, which inserts the row when it is missing and otherwise carries the member's other pointers over. The read-merge-write moved into the DAO with it, so a feed sync landing between the read and the write can no longer be lost.
1 parent 62249fc commit a37210e

3 files changed

Lines changed: 56 additions & 18 deletions

File tree

‎apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,31 @@ interface ChatMemberDao {
7272
)
7373
suspend fun getChatIdForMember(userIdHex: String, chatType: String): String?
7474

75-
@Query("UPDATE chat_members SET pointers_json = :pointersJson WHERE chat_id_hex = :chatIdHex AND user_id_hex = :userIdHex")
76-
suspend fun updatePointers(chatIdHex: String, userIdHex: String, pointersJson: String)
75+
/**
76+
* Records [pointer] for [userIdHex] in [chatIdHex], creating the member row if it is not
77+
* there yet.
78+
*
79+
* The row is not a given at this point. A read is written the moment a message is on screen,
80+
* and a pointer update can arrive off the event stream for a member the feed has not written
81+
* yet — the bare `UPDATE` this replaces matched nothing in either case and dropped the
82+
* pointer silently. Only the member's pointer of the same type is displaced; the rest carry
83+
* over.
84+
*/
85+
@Transaction
86+
suspend fun advancePointer(
87+
chatIdHex: String,
88+
userIdHex: String,
89+
pointer: MessagePointerSerialized,
90+
) {
91+
val existing = getMember(chatIdHex, userIdHex)?.pointersJson.orEmpty()
92+
insertOrReplace(
93+
ChatMemberEntity(
94+
chatIdHex = chatIdHex,
95+
userIdHex = userIdHex,
96+
pointersJson = existing.filterNot { it.type == pointer.type } + pointer,
97+
)
98+
)
99+
}
77100

78101
@Query("DELETE FROM chat_members WHERE chat_id_hex = :chatIdHex")
79102
suspend fun deleteForChat(chatIdHex: String)

‎apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMemberDaoTest.kt‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,7 @@ class ChatMemberDaoTest {
5353
@Test
5454
fun `upsert does not rewind a locally advanced read pointer`() = runTest {
5555
dao.upsert(member(readPointer(1)))
56-
dao.updatePointers(
57-
CHAT_HEX,
58-
SELF_HEX,
59-
"""[{"type":"READ","userIdHex":"$SELF_HEX","value":9,"timestampEpochSeconds":0}]""",
60-
)
56+
dao.advancePointer(CHAT_HEX, SELF_HEX, readPointer(9))
6157

6258
// A feed sync issued before the advance reached the server carries the old pointer.
6359
dao.upsert(member(readPointer(1)))
@@ -87,6 +83,25 @@ class ChatMemberDaoTest {
8783
assertEquals(7L, pointers.single { it.userIdHex == OTHER_HEX }.value)
8884
}
8985

86+
@Test
87+
fun `advancePointer records a read for a member that has not synced yet`() = runTest {
88+
// The chat is open and a message is on screen before the feed writes the membership.
89+
dao.advancePointer(CHAT_HEX, SELF_HEX, readPointer(4))
90+
91+
assertEquals(4L, dao.getMember(CHAT_HEX, SELF_HEX)?.pointersJson?.single()?.value)
92+
}
93+
94+
@Test
95+
fun `advancePointer leaves the member's other pointers alone`() = runTest {
96+
dao.upsert(member(readPointer(2), MessagePointerSerialized("DELIVERED", SELF_HEX, 5)))
97+
98+
dao.advancePointer(CHAT_HEX, SELF_HEX, readPointer(6))
99+
100+
val pointers = dao.getMember(CHAT_HEX, SELF_HEX)?.pointersJson.orEmpty()
101+
assertEquals(6L, pointers.single { it.type == "READ" }.value)
102+
assertEquals(5L, pointers.single { it.type == "DELIVERED" }.value)
103+
}
104+
90105
@Test
91106
fun `deleteMembersNotIn drops departed members and leaves the rest intact`() = runTest {
92107
dao.upsert(member(readPointer(9)))

‎apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,19 +69,19 @@ class ChatMemberDataSource @Inject constructor(
6969
}
7070
}
7171

72+
/**
73+
* Records [pointer] for its member in [chatId], whether or not that member has synced yet.
74+
*
75+
* The read-merge-write runs inside the DAO so a feed sync landing between the two halves
76+
* cannot be lost.
77+
*/
7278
suspend fun updatePointers(chatId: ChatId, pointer: MessagePointer) {
7379
val dao = db?.chatMemberDao() ?: return
74-
val chatIdHex = mapper.chatIdHex(chatId)
75-
val userIdHex = mapper.userIdHex(pointer.userId)
76-
77-
val existing = dao.getMember(chatIdHex, userIdHex)
78-
val existingPointers = existing?.pointersJson ?: emptyList()
79-
80-
val merged = existingPointers
81-
.filter { it.type != pointer.type.name }
82-
.plus(mapper.pointerSerialized(pointer))
83-
84-
dao.updatePointers(chatIdHex, userIdHex, mapper.pointersToJson(merged))
80+
dao.advancePointer(
81+
chatIdHex = mapper.chatIdHex(chatId),
82+
userIdHex = mapper.userIdHex(pointer.userId),
83+
pointer = mapper.pointerSerialized(pointer),
84+
)
8585
}
8686

8787
/**

0 commit comments

Comments
 (0)