feat(dialects): read the dialects an open session is already signing
DialectScaffold.pendingIn, the half of the identity rule that rows cannot answer. Without it the feature has a race it would hit on first use: two admins open the picker, both see the same forty missing, both propose, and the room gets eighty dialects -- forty of them duplicates nothing will ever merge. Readable because FrostSigningRepository.unsignedEvents gives a batch's events at proposal time, before any signature, which is exactly when this has to be known. The lambda is passed in rather than imported, as ProposedEvent.summarize takes a resolver: it keeps the function pure, testable without a database and callable from a composable. COMPLETE and FAILED sessions are both excluded, for opposite reasons. A complete session's dialects are rows by now, so counting them would hide them behind "waiting for the group" rather than "already yours". A failed one left nothing behind and has to be proposable again -- the signing screen says as much, and a retried batch must be a fresh one anyway. A session whose items will not parse contributes nothing rather than throwing, and the trade is named where it is made: its dialects look selectable and may be proposed twice, which is visible and recoverable, against a picker that will not open at all, which leaves an admin no way to act. unsignedEventJson is written by this device from a template it built, so that branch means a damaged database rather than an odd proposal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
import press.mantra.compose.language.AfricanLanguage
|
||||
import press.mantra.compose.language.AfricanLanguages
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
@@ -73,6 +77,73 @@ object DialectScaffold {
|
||||
return catalogue.filterNot { keyOf(it) in held }
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalogue entries an open session is already signing.
|
||||
*
|
||||
* Without this the feature has a race it will hit on its first real use: two
|
||||
* admins open the picker, both see the same forty missing, both propose, and
|
||||
* the room gets eighty dialects -- forty of them duplicates that nothing will
|
||||
* ever merge, because nothing makes a dialect unique.
|
||||
*
|
||||
* Readable because [unsignedEvents] gives a batch's events *at proposal
|
||||
* time*, before any signature, which is exactly when this has to be known.
|
||||
* It is passed in rather than imported for the reason
|
||||
* `ProposedEvent.summarize` takes a resolver: it keeps this testable without
|
||||
* a database and callable from a composable.
|
||||
*
|
||||
* [FrostSigningStage.COMPLETE] and [FrostSigningStage.FAILED] are both
|
||||
* excluded, for opposite reasons. A complete session's dialects are rows by
|
||||
* now, so counting them here would hide them behind the wrong explanation --
|
||||
* "waiting for the group" rather than "already yours". A failed session left
|
||||
* nothing behind and must be proposable again; the signing screen tells a
|
||||
* member as much, and a batch retried has to be a fresh one anyway.
|
||||
*
|
||||
* A session whose items cannot be read contributes nothing rather than
|
||||
* throwing. That is a trade and worth naming: the cost is that its dialects
|
||||
* look selectable and may be proposed twice, and the alternative is a picker
|
||||
* that will not open at all. A duplicate dialect is visible and recoverable;
|
||||
* a dead screen leaves an admin with no way to act. `unsignedEventJson` is
|
||||
* written by this device from a template it built, so reaching this branch
|
||||
* means the database is damaged rather than the proposal being odd.
|
||||
*/
|
||||
fun pendingIn(
|
||||
sessions: List<LocalFrostSigningSession>,
|
||||
unsignedEvents: (List<FrostSigningItem>) -> List<Event>,
|
||||
catalogue: List<AfricanLanguage> = AfricanLanguages.ALL,
|
||||
): List<AfricanLanguage> {
|
||||
val inFlight = sessions
|
||||
.filter { it.session.stage in OPEN_STAGES }
|
||||
.flatMap { session ->
|
||||
runCatching { unsignedEvents(session.items) }.getOrDefault(emptyList())
|
||||
}
|
||||
.mapNotNullTo(mutableSetOf(), ::keyOfDialectEvent)
|
||||
|
||||
return catalogue.filter { keyOf(it) in inFlight }
|
||||
}
|
||||
|
||||
/** The stages where a session is still going, and so is still a claim on a dialect. */
|
||||
private val OPEN_STAGES = setOf(
|
||||
FrostSigningStage.COLLECTING_NONCES,
|
||||
FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES,
|
||||
)
|
||||
|
||||
/**
|
||||
* [event] as a dialect's [Key], or null when it is not a dialect or is
|
||||
* missing one of the two tags the identity is made of.
|
||||
*/
|
||||
private fun keyOfDialectEvent(event: Event): Key? {
|
||||
if (event.kind != DialectEvent.KIND) return null
|
||||
|
||||
val dialect = DialectEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
)
|
||||
|
||||
val code = dialect.language() ?: return null
|
||||
val country = dialect.country() ?: return null
|
||||
|
||||
return keyOf(code, country)
|
||||
}
|
||||
|
||||
/**
|
||||
* One dialect event per entry, in the order given.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
import press.mantra.compose.language.AfricanLanguage
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
|
||||
/**
|
||||
* The dialects an open signing session is already signing.
|
||||
*
|
||||
* This is the half of the identity rule that rows cannot answer. Two admins open
|
||||
* the picker, both see the same forty missing and both propose: without reading
|
||||
* what is in flight the room gets eighty dialects, forty of them duplicates that
|
||||
* nothing will ever merge. `FrostSigningRepository.unsignedEvents` makes a
|
||||
* batch readable at proposal time, which is exactly when it has to be.
|
||||
*/
|
||||
class DialectsInFlightTest {
|
||||
|
||||
private val groupKey = "a".repeat(64)
|
||||
private val chatRoomId = "r".repeat(64)
|
||||
|
||||
private val sesotho = AfricanLanguage("Sesotho", "st", "Lesotho")
|
||||
private val swahiliKenya = AfricanLanguage("Kiswahili", "sw", "Kenya")
|
||||
private val swahiliTanzania = AfricanLanguage("Kiswahili", "sw", "Tanzania")
|
||||
|
||||
private val catalogue = listOf(sesotho, swahiliKenya, swahiliTanzania)
|
||||
|
||||
private fun unsigned(template: EventTemplate<*>) = Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
),
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "",
|
||||
)
|
||||
|
||||
/** What the repository does, so the lambda under test is the real one. */
|
||||
private val unsignedEvents: (List<FrostSigningItem>) -> List<Event> =
|
||||
{ items -> items.map { Event.fromJson(it.unsignedEventJson) } }
|
||||
|
||||
private fun session(
|
||||
id: String,
|
||||
stage: FrostSigningStage,
|
||||
json: List<String>,
|
||||
) = LocalFrostSigningSession(
|
||||
session = FrostSigningSession(
|
||||
id = id,
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = "c".repeat(64),
|
||||
userPublicKey = "u".repeat(64),
|
||||
dkgSessionId = "d".repeat(64),
|
||||
threshold = 2,
|
||||
participantCount = 3,
|
||||
signerId = 1,
|
||||
stage = stage,
|
||||
),
|
||||
unorderedItems = json.mapIndexed { index, eventJson ->
|
||||
FrostSigningItem(
|
||||
sessionId = id,
|
||||
itemIndex = index,
|
||||
unsignedEventJson = eventJson,
|
||||
eventId = "e$index".padEnd(64, 'f').take(64),
|
||||
nonceRandom = "n$index".padEnd(64, 'f').take(64),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private fun dialectJson(language: AfricanLanguage) = unsigned(
|
||||
DialectScaffold.dialectsOf(listOf(language), createdAt = 1_700_000_000L).single()
|
||||
).toJson()
|
||||
|
||||
private fun waveFor(
|
||||
languages: List<AfricanLanguage>,
|
||||
stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
|
||||
id: String = "s".repeat(64),
|
||||
) = session(id = id, stage = stage, json = languages.map(::dialectJson))
|
||||
|
||||
@Test
|
||||
fun anOpenSessionsDialectsArePending() {
|
||||
assertEquals(
|
||||
listOf(sesotho, swahiliKenya),
|
||||
DialectScaffold.pendingIn(
|
||||
sessions = listOf(waveFor(listOf(sesotho, swahiliKenya))),
|
||||
unsignedEvents = unsignedEvents,
|
||||
catalogue = catalogue,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSessionCollectingSignaturesIsStillOpen() {
|
||||
assertEquals(
|
||||
listOf(sesotho),
|
||||
DialectScaffold.pendingIn(
|
||||
sessions = listOf(
|
||||
waveFor(
|
||||
listOf(sesotho),
|
||||
stage = FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES
|
||||
)
|
||||
),
|
||||
unsignedEvents = unsignedEvents,
|
||||
catalogue = catalogue,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** It left nothing behind, so it has to be proposable again. */
|
||||
@Test
|
||||
fun aFailedSessionsDialectsAreNotPending() {
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
DialectScaffold.pendingIn(
|
||||
sessions = listOf(waveFor(listOf(sesotho), stage = FrostSigningStage.FAILED)),
|
||||
unsignedEvents = unsignedEvents,
|
||||
catalogue = catalogue,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Its dialects are rows by now, so counting them here would hide them behind
|
||||
* "waiting for the group" rather than "already yours".
|
||||
*/
|
||||
@Test
|
||||
fun aCompleteSessionsDialectsAreNotPending() {
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
DialectScaffold.pendingIn(
|
||||
sessions = listOf(waveFor(listOf(sesotho), stage = FrostSigningStage.COMPLETE)),
|
||||
unsignedEvents = unsignedEvents,
|
||||
catalogue = catalogue,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun twoOpenWavesAreReadTogether() {
|
||||
assertEquals(
|
||||
listOf(sesotho, swahiliTanzania),
|
||||
DialectScaffold.pendingIn(
|
||||
sessions = listOf(
|
||||
waveFor(listOf(sesotho), id = "1".repeat(64)),
|
||||
waveFor(listOf(swahiliTanzania), id = "2".repeat(64)),
|
||||
),
|
||||
unsignedEvents = unsignedEvents,
|
||||
catalogue = catalogue,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSessionSigningSomethingElseContributesNothing() {
|
||||
val chapter = unsigned(
|
||||
ChapterEvent.build(
|
||||
artifactVersionId = "b".repeat(64),
|
||||
name = "Chapter 1",
|
||||
originalText = "Paragraph.",
|
||||
index = 0,
|
||||
wordCount = 1,
|
||||
characterCount = 10,
|
||||
)
|
||||
).toJson()
|
||||
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
DialectScaffold.pendingIn(
|
||||
sessions = listOf(
|
||||
session(
|
||||
id = "s".repeat(64),
|
||||
stage = FrostSigningStage.COLLECTING_NONCES,
|
||||
json = listOf(chapter),
|
||||
)
|
||||
),
|
||||
unsignedEvents = unsignedEvents,
|
||||
catalogue = catalogue,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDialectTheCatalogueDoesNotListIsNotReported() {
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
DialectScaffold.pendingIn(
|
||||
sessions = listOf(waveFor(listOf(AfricanLanguage("Nheengatu", "yrl", "Brazil")))),
|
||||
unsignedEvents = unsignedEvents,
|
||||
catalogue = catalogue,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A damaged session must not take the picker down with it, and the other
|
||||
* session's dialects still have to be read. See the trade named on
|
||||
* `pendingIn`: its own dialects look selectable again, which is recoverable,
|
||||
* and a screen that will not open is not.
|
||||
*/
|
||||
@Test
|
||||
fun anUnreadableSessionIsSkippedRatherThanThrown() {
|
||||
assertEquals(
|
||||
listOf(swahiliKenya),
|
||||
DialectScaffold.pendingIn(
|
||||
sessions = listOf(
|
||||
session(
|
||||
id = "1".repeat(64),
|
||||
stage = FrostSigningStage.COLLECTING_NONCES,
|
||||
json = listOf("not an event"),
|
||||
),
|
||||
waveFor(listOf(swahiliKenya), id = "2".repeat(64)),
|
||||
),
|
||||
unsignedEvents = unsignedEvents,
|
||||
catalogue = catalogue,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noSessionsMeansNothingPending() {
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
DialectScaffold.pendingIn(
|
||||
sessions = emptyList(),
|
||||
unsignedEvents = unsignedEvents,
|
||||
catalogue = catalogue,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** The two halves together, which is how the picker reads them. */
|
||||
@Test
|
||||
fun missingExcludesBothWhatIsHeldAndWhatIsInFlight() {
|
||||
val pending = DialectScaffold.pendingIn(
|
||||
sessions = listOf(waveFor(listOf(swahiliKenya))),
|
||||
unsignedEvents = unsignedEvents,
|
||||
catalogue = catalogue,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(swahiliTanzania),
|
||||
DialectScaffold.missingFrom(
|
||||
existing = listOf(
|
||||
MantraDialect(
|
||||
id = "d".repeat(64),
|
||||
publicKey = groupKey,
|
||||
name = "Sesotho",
|
||||
country = "Lesotho",
|
||||
language = "st",
|
||||
signature = "",
|
||||
chatRoomId = chatRoomId,
|
||||
)
|
||||
),
|
||||
pending = pending,
|
||||
catalogue = catalogue,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user