From 76b4a78581225153ca0f85772e4032316c3739ac Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 13:13:50 +0200 Subject: [PATCH] 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 --- .../database/dao/MantraTranslationChunkDao.kt | 13 + .../compose/database/model/ChatMessage.kt | 37 ++- .../model/TranslationChunkApplyJvmTest.kt | 301 ++++++++++++++++++ 3 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/TranslationChunkApplyJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt index 52c1c7a3..8d110f83 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt @@ -15,6 +15,19 @@ interface MantraTranslationChunkDao { @Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId ORDER BY `index` ASC") suspend fun getTranslationChunksByTranslationChapterId(translationChapterId: String): List + /** + * 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 + @Query("DELETE FROM MantraTranslationChunk WHERE id = :id") suspend fun deleteById(id: String) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 7aba6e26..9217697c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -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 } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/TranslationChunkApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/TranslationChunkApplyJvmTest.kt new file mode 100644 index 00000000..a9123938 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/TranslationChunkApplyJvmTest.kt @@ -0,0 +1,301 @@ +package press.mantra.compose.database.model + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.nostr.nip30303.TranslationChunkEvent +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * What happens when a chunk is translated twice. + * + * A translation chunk is not edited. Retranslating a passage means proposing a + * new event carrying new words, and an event's id is a hash over its content -- + * so the second translation arrives as a row of its own rather than as an + * overwrite of the first. Left alone, the chapter would hold two answers to + * what one passage says, and the table pairing chunks with their translations + * would show whichever the query reached first. + * + * The rule this asserts is that the newest one wins, decided by the timestamp + * the group signed at rather than by arrival: 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. + */ +class TranslationChunkApplyJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val author = "a".repeat(64) + private val roomId = "b".repeat(64) + private val chunkId = "c".repeat(64) + private val translationChapterId = "d".repeat(64) + private val signature = "0".repeat(128) + + /** + * Everything a translation chunk hangs off, seeded as already signed: the + * room, the artifact it belongs to, the chapter and chunk it translates, and + * the translation chapter it goes into. None of it is what this test is + * about; they are the foreign keys in the way. + */ + private suspend fun seed() { + val nostrEventId = "e".repeat(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = author, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = signature, + ) + ) + db.profileDao().upsert(Profile(publicKey = author, nostrEventId = nostrEventId)) + db.chatRoomDao().upsert( + ChatRoom( + id = roomId, + userPublicKey = author, + subject = "a translation room", + description = null, + mlsGroupState = null, + ) + ) + + val dialectId = "1".repeat(64) + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Sesotho", + country = "ZA", + language = "st", + signature = signature, + chatRoomId = roomId, + ) + ) + + val artifactId = "2".repeat(64) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = "To Kill a Mockingbird", + url = "https://harper.com/2-kill-Bird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = signature, + ) + ) + + val artifactVersionId = "3".repeat(64) + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = artifactVersionId, + artifactId = artifactId, + publicKey = roomId, + versionLabel = "1.0", + signature = signature, + chatRoomId = roomId, + ) + ) + + val chapterId = "4".repeat(64) + db.mantraChapterDao().upsert( + MantraChapter( + id = chapterId, + artifactVersionId = artifactVersionId, + publicKey = roomId, + name = "Chapter 1", + originalText = "When he was nearly thirteen.", + index = 0, + wordCount = 5, + characterCount = 28, + signature = signature, + chatRoomId = roomId, + ) + ) + + db.mantraChunkDao().upsert( + MantraChunk( + id = chunkId, + chapterId = chapterId, + publicKey = roomId, + text = "When he was nearly thirteen.", + index = 0, + wordCount = 5, + characterCount = 28, + signature = signature, + chatRoomId = roomId, + ) + ) + + val translationId = "5".repeat(64) + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = translationId, + publicKey = roomId, + artifactVersionId = artifactVersionId, + dialectId = dialectId, + name = "Sesotho", + visibility = "private", + license = "cc", + signature = signature, + chatRoomId = roomId, + ) + ) + + db.mantraTranslationChapterDao().upsert( + MantraTranslationChapter( + id = translationChapterId, + publicKey = roomId, + chapterId = chapterId, + translationArtifactVersionId = translationId, + index = 0, + signature = signature, + chatRoomId = roomId, + ) + ) + } + + /** A translation of the seeded chunk, signed by the room as the group would. */ + private fun translation(text: String, createdAt: Long): Event { + val template = TranslationChunkEvent.build( + translationChapterId = translationChapterId, + chunkId = chunkId, + index = 0, + text = text, + createdAt = createdAt, + ) + + return Event( + id = EventHasher.hashId( + pubKey = roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = signature, + ) + } + + private suspend fun apply(event: Event) = ChatMessage.applyInnerEvent( + database = db, + groupId = roomId, + event = event, + marmotGroupEventId = null, + marmotInnerEventId = null, + senderPublicKey = roomId, + isUserMessage = false, + createdAt = Instant.fromEpochSeconds(event.createdAt), + ) + + private suspend fun translations() = db.mantraTranslationChunkDao() + .getTranslationChunksByTranslationChapterId(translationChapterId) + + @Test + fun `a retranslation replaces the translation it supersedes`() = runBlocking { + seed() + + val signedAt = Clock.System.now().epochSeconds + apply(translation("Ha a le hantle lilemo li leshome le metso e meraro.", signedAt)) + apply(translation("Ha a batla ho ba lilemo li leshome le metso e meraro.", signedAt + 60)) + + // Both are valid signatures of the group's over the same passage, and + // the chapter has room for one of them. + val kept = translations().single() + assertEquals("Ha a batla ho ba lilemo li leshome le metso e meraro.", kept.text) + } + + @Test + fun `a translation arriving after the one that superseded it is dropped`() = runBlocking { + seed() + + val signedAt = Clock.System.now().epochSeconds + val first = translation("Ha a le hantle lilemo li leshome le metso e meraro.", signedAt) + val second = translation("Ha a batla ho ba lilemo li leshome le metso e meraro.", signedAt + 60) + + // The order the relay happened to hand them over in, which is not the + // order the group signed them in. A device catching up has to reach the + // same chapter as one that saw them the other way round. + apply(second) + apply(first) + + val kept = translations().single() + assertEquals(second.id, kept.id) + } + + @Test + fun `two chunks of the same chapter each keep their translation`() = runBlocking { + seed() + + // The rule is one translation per source chunk, not one per chapter. + // Matching on the chapter alone would leave a chapter that can only ever + // hold its most recently translated passage. + val otherChunkId = "f".repeat(64) + db.mantraChunkDao().upsert( + MantraChunk( + id = otherChunkId, + chapterId = "4".repeat(64), + publicKey = roomId, + text = "My brother Jem got his arm badly broken.", + index = 1, + wordCount = 8, + characterCount = 40, + signature = signature, + chatRoomId = roomId, + ) + ) + + val signedAt = Clock.System.now().epochSeconds + apply(translation("Ha a le hantle lilemo li leshome le metso e meraro.", signedAt)) + + val otherTemplate = TranslationChunkEvent.build( + translationChapterId = translationChapterId, + chunkId = otherChunkId, + index = 1, + text = "Moholoane oa ka Jem o ile a robeha letsoho.", + createdAt = signedAt + 60, + ) + apply( + Event( + id = EventHasher.hashId( + pubKey = roomId, + createdAt = otherTemplate.createdAt, + kind = otherTemplate.kind, + tags = otherTemplate.tags, + content = otherTemplate.content, + ), + pubKey = roomId, + createdAt = otherTemplate.createdAt, + kind = otherTemplate.kind, + tags = otherTemplate.tags, + content = otherTemplate.content, + sig = signature, + ) + ) + + assertEquals( + listOf(chunkId, otherChunkId), + translations().map { it.chunkId }, + ) + } +}