fix: keep one translation per source chunk, however it arrives
A translation chunk is never edited. Retranslating a passage means a new event
carrying new words, and an event's id is a hash over its content -- so the
second translation is a row of its own rather than an overwrite of the first.
`MantraDao.saveTranslation` knew that and dropped the row it superseded, but it
only ever saw this device's own retranslations. Everything arriving from the
group went through `ChatMessage.applyInnerEvent`, which upserted and nothing
else. A member retranslating a passage somebody else had already translated left
two rows behind, and `TranslationChapterViewModel` pairs chunks with their
translations by `associateBy { it.chunkId }` -- one of the two wins, and which
one is whatever order SQLite happened to return them in for a query ordered on a
column they share.
So the rule moves to where the row is actually made, and now covers the group's
signatures and the relay's deliveries alike.
**Newest wins by the timestamp the group signed at, not by arrival.** Two
devices catching up read the same events in whatever order their relays hand
them over, and they have to end up holding the same translation either way. An
older translation arriving after the one that superseded it is dropped rather
than allowed to overwrite it. Ties break on the event id -- arbitrary, but the
same arbitrary on every device, which is the whole requirement.
**Matched on the source chunk, not on the chapter.** A chapter holds one
translation per passage, not one translation; matching on the chapter alone
would leave a chapter that could only ever show its most recently translated
paragraph. `getTranslationChunksByChunkId` is the query that says so.
**Tests.** `TranslationChunkApplyJvmTest` covers the three cases against a real
database: a retranslation replaces what it supersedes, a translation arriving
after the one that superseded it is dropped, and two chunks of one chapter each
keep their own. The first two fail against the plain upsert this replaces; the
third is what stops the fix from over-deleting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,19 @@ interface MantraTranslationChunkDao {
|
||||
@Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId ORDER BY `index` ASC")
|
||||
suspend fun getTranslationChunksByTranslationChapterId(translationChapterId: String): List<MantraTranslationChunk>
|
||||
|
||||
/**
|
||||
* Every translation this chapter holds of one source chunk.
|
||||
*
|
||||
* There should only ever be one, and this is how that is kept true: a
|
||||
* retranslation is a new event with a new id rather than an edit, so the
|
||||
* row it replaces has to be found and dropped.
|
||||
*/
|
||||
@Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId AND chunkId = :chunkId")
|
||||
suspend fun getTranslationChunksByChunkId(
|
||||
translationChapterId: String,
|
||||
chunkId: String
|
||||
): List<MantraTranslationChunk>
|
||||
|
||||
@Query("DELETE FROM MantraTranslationChunk WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
}
|
||||
@@ -998,13 +998,42 @@ data class ChatMessage(
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraTranslationChunk ->
|
||||
database.mantraTranslationChunkDao().upsert(
|
||||
mantraTranslationChunk.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
// One translation per source chunk. Retranslating changes
|
||||
// the text and so the event's id, which makes it a new
|
||||
// event rather than an edit of the old one -- so the one
|
||||
// it supersedes is dropped here, or a passage would have
|
||||
// two answers to what it says and the reader would be
|
||||
// shown whichever the query happened to reach first.
|
||||
//
|
||||
// Newest wins by the timestamp the group signed at, not by
|
||||
// when it arrived, with the id breaking a tie. Two devices
|
||||
// catching up read the same events in whatever order the
|
||||
// relay hands them over, and they have to end up holding
|
||||
// the same translation either way.
|
||||
val existing = database.mantraTranslationChunkDao()
|
||||
.getTranslationChunksByChunkId(
|
||||
translationChapterId = mantraTranslationChunk.translationChapterId,
|
||||
chunkId = mantraTranslationChunk.chunkId,
|
||||
)
|
||||
)
|
||||
.filterNot { it.id == mantraTranslationChunk.id }
|
||||
|
||||
val isNewest = existing.none {
|
||||
it.createdAt > mantraTranslationChunk.createdAt ||
|
||||
(it.createdAt == mantraTranslationChunk.createdAt &&
|
||||
it.id > mantraTranslationChunk.id)
|
||||
}
|
||||
|
||||
if (isNewest) {
|
||||
existing.forEach {
|
||||
database.mantraTranslationChunkDao().deleteById(it.id)
|
||||
}
|
||||
|
||||
database.mantraTranslationChunkDao().upsert(
|
||||
mantraTranslationChunk.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user