fix(chapters): number a chapter past the ones already proposed

Phase 3 of docs/chapter-uploads.md, and the second fix to something wrong
today.

A chapter's index is signed into the event and cannot be recounted on
arrival: a device applying one cannot recount from a version other members
may have added to in a different order. So the number has to be right when
the chapter is proposed, and `getChaptersForArtifactVersion(...).size` is not
it -- a proposed chapter is not a row until a quorum signs it, so two
proposals a second apart read the same table and are signed at the same
index. At one chapter at a time that is a race nobody hits. An upload of
twelve files hits it eleven times out of eleven.

The information was already published. A session's items carry the unsigned
events it is signing, and an unsigned event's fields are settled at proposal
time -- which is the whole reason FrostSigningRepository.unsignedEvents
exists rather than only signedEvents. So ChapterIndexing.nextIndex reads the
table and the room's proposals, and returns one past the highest rather than
a count: a count is wrong the moment the sequence has a gap, and a chapter
that failed to reach a quorum leaves one.

Three things that are easy to get wrong and are tested. Every session counts
whatever stage it is at, because filtering would trade a harmless gap for a
collision with a stalled session that still completes. Items are filtered by
kind, because a chunk carries an index too -- its position in the chapter --
and reading those as chapter indices puts the second chapter of a
two-paragraph first chapter at 2. And proposals are scoped by the
artifactVersionId tag, because the row query is scoped by version while
observeSessionsForChatRoom returns the whole room.

This fixes one device, not the group: two members proposing at the same
moment still collide, and no amount of local reading will change that. That
is tolerable where this was not -- an index orders reading, two chapters at
index 4 sort arbitrarily between themselves, and nothing else breaks.

951 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 18:46:09 +02:00
parent dbc7940618
commit 83bd91f46c
4 changed files with 464 additions and 21 deletions

View File

@@ -0,0 +1,114 @@
package press.mantra.compose.managers
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.first
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.MantraRepository
/**
* Where the next chapter of a version goes.
*
* The index is signed into the chapter and cannot be recounted on arrival -- a
* device applying one cannot recount from a version other members may have
* added to in a different order -- so the number has to be right at proposal
* time. "How many chapters are in the table" is not it.
*
* **A proposed chapter is not a row.** It becomes one when a quorum signs,
* which takes as long as it takes, and until then nothing in
* `MantraChapter` knows about it. So reading the table gives the same answer to
* two proposals made a second apart, and both get signed at the same index.
* At one chapter at a time that is a race nobody hits; an upload of twelve
* files hits it eleven times out of eleven.
*
* The information needed is already published. A session's items carry the
* unsigned events it is signing, and an unsigned event's id and fields are
* settled at proposal time -- that is the whole reason
* [FrostSigningRepository.unsignedEvents] exists rather than only
* `signedEvents`. So the answer is the table *and* the room's open proposals.
*/
object ChapterIndexing {
private const val TAG = "ChapterIndexing"
/**
* The index for the next chapter of [artifactVersionId], counting chapters
* this room has proposed but not yet signed.
*
* **One past the highest, not a count.** A count is wrong the moment the
* sequence has a gap, and gaps are expected here: a chapter that fails to
* reach a quorum never becomes a row, and its index is spent -- a
* re-proposed chapter is a different event. Ordering survives a gap, so
* leaving one is the cheap answer and renumbering would be the expensive
* wrong one.
*
* **Every session counts, whatever stage it is at.** A failed session's
* index is wasted, which costs a gap; a stalled session's chapter may still
* arrive, and stepping over it costs nothing. Filtering by stage would
* trade a harmless gap for a possible collision.
*
* **This fixes one device, not the group.** Two members proposing at the
* same moment still collide: neither sees the other's proposal until it
* arrives, and no amount of local reading will change that. That is
* tolerable where this is not -- an index orders reading, two chapters at
* index 4 sort arbitrarily between themselves, and nothing else breaks. The
* local case is worth fixing because an upload makes it certain rather than
* unlikely.
*
* Failing soft on purpose: if the proposals cannot be read, the table's
* answer is still better than nothing, and refusing to propose a chapter
* because its number could not be checked would be the wrong trade.
*/
suspend fun nextIndex(
mantraRepository: MantraRepository,
frostSigningRepository: FrostSigningRepository,
chatRoomId: String,
artifactVersionId: String,
): Int {
val signed = mantraRepository
.getChaptersForArtifactVersion(artifactVersionId)
.maxOfOrNull { it.index }
val proposed = runCatching {
proposedIndices(frostSigningRepository, chatRoomId, artifactVersionId).maxOrNull()
}.onFailure { error ->
Logger.withTag(TAG).e(
"Could not read this room's proposals; numbering the next chapter from the " +
"table alone, which may collide with a chapter already proposed",
error,
)
}.getOrNull()
val highest = listOfNotNull(signed, proposed).maxOrNull() ?: return 0
return highest + 1
}
/**
* The indices of chapters this room has proposed for [artifactVersionId].
*
* Read off the unsigned events rather than from anything stored about them,
* because there is nothing stored about them: a proposal's items are its
* only record until it is signed. Non-chapter items -- a dialect, a
* translation, the chunks beside a chapter -- are ignored by kind, and a
* chapter belonging to another version of the same artifact by its
* `artifactVersionId` tag.
*/
private suspend fun proposedIndices(
frostSigningRepository: FrostSigningRepository,
chatRoomId: String,
artifactVersionId: String,
): List<Int> =
frostSigningRepository.observeSessionsForChatRoom(chatRoomId).first()
.flatMap { session -> frostSigningRepository.unsignedEvents(session.items) }
.filter { event -> event.kind == ChapterEvent.KIND }
.mapNotNull { event ->
ChapterEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig,
).takeIf { it.artifactVersionId() == artifactVersionId }?.index()
}
}

View File

@@ -18,6 +18,7 @@ import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.managers.ChapterIndexing
import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.managers.TranslationScaffold
import press.mantra.compose.nostr.nip30303.ChapterEvent
@@ -131,7 +132,17 @@ class AddChapterViewModel(
// may have added to in a different order. The scaffolding proposed
// below is placed at the same one, so a translation chapter agrees
// with the chapter it stands for.
val chapterIndex = mantraRepository.getChaptersForArtifactVersion(artifactVersion.id).size
//
// Counting the room's open proposals as well as its rows, because a
// chapter proposed a second ago is not a row yet and reading the
// table alone would hand it this chapter's number -- see
// [ChapterIndexing.nextIndex].
val chapterIndex = ChapterIndexing.nextIndex(
mantraRepository = mantraRepository,
frostSigningRepository = frostSigningRepository,
chatRoomId = chatRoomId,
artifactVersionId = artifactVersion.id,
)
val chapterEventTemplate = ChapterEvent.build(
artifactVersionId = artifactVersion.id,