Files
mantra-kmp/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChronicleManager.kt
Kgothatso Ngako 19d57ef3a5 Merge branch 'mantra' into claude/happy-gauss-dbe258
Brings in the Chronicle rename and the deprecation of the row rebuild, and
carries the supersession fix across into the new vocabulary.

Git followed every rename on its own -- `ArchiveManager` -> `ChronicleManager`,
the tests, the docs -- and auto-merged all three files my fix had touched. What
it could not do is rename identifiers inside the hunks it merged, so the fix
arrived speaking the old language: `ChronicleAssemblyJvmTest` still called
`ArchiveManager.assemble` and `ArchiveEvent.decodePage`, which does not compile,
and six doc comments in `ChronicleManager` and `GroupSignedEvent` still said
"archive" -- the exact ambiguity with archiving a chat that the rename exists to
remove.

One real conflict, in the design note, and it is the same sentence twice: my
correction of "a retranslated passage archives once" against the rename of the
uncorrected claim. Resolved to the correction, in the new vocabulary -- the
property still holds, it just stopped being free the moment the chronicle was
read from `GroupSignedEvent` rather than rebuilt from rows, and
`ChronicleManager.currentTranslationsOnly` is what holds it up.

`compileKotlinJvm` passes over a test file that does not compile, so it was no
evidence here; `compileTestKotlinJvm` is. And the filter was re-checked the way
it was written: removing it fails the same three tests, so the merge did not
quietly neuter them.

503 jvm tests and 297 android unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:37:12 +02:00

861 lines
38 KiB
Kotlin

package press.mantra.compose.managers
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.extensions.toHex
import kotlin.time.Clock
import kotlin.time.Instant
import press.mantra.compose.nostr.chronicle.ChronicleEvent
import press.mantra.compose.nostr.chronicle.ChronicleRequestEvent
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
/**
* Building the group's signed record into pages a member who lacks it can apply.
*
* See docs/member-chronicle.md. The short of it: a member added after the work was
* done has none of it and never will, because a group-signed event is applied
* locally by each device that took part and never goes on the wire. This is how
* it gets to them.
*
* ### The events are the chronicle, and the rows are the fallback
*
* `GroupSignedEvent` holds what the group signed, as it signed it, so
* [signedEventsOf] reads it first: no rebuild, no round-trip risk, and nothing
* that depends on a row having kept every field of the event it came from. The
* artifact's version label is the standing example -- it is not on the artifact
* row at all, and [rebuiltEventsOf] has to go and find it on the initial version
* or leave the artifact out.
*
* A room whose work predates that table has no events on file, so the rebuild
* stays as the fallback for exactly what the table is missing, keyed by id.
* Every payload it produces stands or falls on being byte-identical to what was
* signed; `ChronicleRoundTripTest` is what says it is, per kind, against a real
* quorum, and it found two faults in the artifact's rebuild the first time it
* ran -- both of which would have shipped payloads that every receiver drops as
* forgeries without a word. Both it and everything that exists only to hold it
* up are marked `@Deprecated`; the fallback can go once no install still holds
* pre-v13 work, and docs/member-chronicle.md's "Retiring the rebuild" is the list
* of what goes with it.
*
* **The allowlist does real work on the way out now.** The rebuild could only
* ever produce document kinds; the table holds everything the group has ever
* signed, `GroupKeyStateEvent` included -- and every room signs one of those as
* its first act. So [signedEventsOf] filters on [ChronicleEvent.isChroniclable]
* before anything else, which is the same rule [applyPage] applies on the way
* in. Without it `ChronicleEvent.build` would refuse the page, and a room's whole
* chronicle would fail on the one event every room has.
*
* ### Nothing unverifiable leaves
*
* Every rebuilt event is checked with [GroupKeyStateEvent.isSignedByRoom] before
* it is packed, against the same room id the recipient will check it with. That
* is not politeness towards the receiver, who checks anyway. It is what keeps an
* chronicle honest about its own size: a row that came from a member's rumor
* cannot be chronicled, and dropping it here rather than letting the recipient
* drop it means the page count says what will actually arrive.
*/
object ChronicleManager {
private const val TAG = "ChronicleManager"
private val logger = Logger.withTag(TAG)
/**
* Every group-signed event this device holds for [chatRoomId], paged and
* addressed to [recipient].
*
* Empty when there is nothing to send -- a room with no shared key, a room
* whose work is all member rumors, or a device that is itself behind. An
* empty result is not an error and the caller should not report one: the
* honest answer to "send them the history" in a room with no signed history
* is nothing.
*/
suspend fun assemble(
database: MantraDatabase,
chatRoomId: String,
recipient: HexKey,
chronicleId: String = RandomInstance.bytes(32).toHex(),
createdAt: Long = TimeUtils.now(),
): List<EventTemplate<ChronicleEvent>> {
val held = signedEventsOf(database, chatRoomId)
// One rule over both sources: nothing leaves that the recipient could
// not check for themselves. What a drop means depends on where it came
// from, and the two are worth telling apart when reading this log --
// from the rebuild it is the ordinary case of a member's own rumor
// sitting in the same table as the group's work, and from the record it
// is a row that has drifted from the event it recorded, which nothing
// in this app does on purpose.
val verified = held.filter { GroupKeyStateEvent.isSignedByRoom(it, chatRoomId) }
if (verified.size != held.size) {
logger.d(
"Leaving ${held.size - verified.size} of ${held.size} event(s) out of " +
"$chatRoomId's chronicle: nothing verifiably signed by the room"
)
}
val pages = paginate(ChronicleEvent.inApplyOrder(verified))
if (pages.isEmpty()) {
logger.i("Nothing signed to chronicle for room $chatRoomId")
return emptyList()
}
logger.i(
"Chronicling ${verified.size} event(s) for $chatRoomId as $chronicleId, " +
"${pages.size} page(s) for ${recipient.take(8)}"
)
return pages.mapIndexed { index, payloads ->
ChronicleEvent.build(
payloads = payloads,
chronicleId = chronicleId,
index = index,
count = pages.size,
recipient = recipient,
createdAt = createdAt,
)
}
}
// ---- Asking, and answering -------------------------------------------
/**
* Ask the group for its signed history, if this device holds none of it.
*
* Called on entering a room. The condition is deliberately crude -- no
* dialects and no artifacts -- because the three cases it has to catch look
* identical from inside the database and should not be told apart:
*
* - a member added after the work was done, whose invite may be recent;
* - a reinstall, whose invite is long past;
* - a second device, which was never invited at all.
*
* Returns whether a request went out.
*
* ### Why a device asks rather than being sent one
*
* A push from the inviter is an application message in the epoch the add
* created, and one that overtakes the Welcome is dropped and not deferred --
* silently, while the inviter sees a success. See docs/marmot-membership.md.
* Sending a request cannot lose that race, because being able to send it is
* the proof the race was won: a device that can put an application message
* into the room has processed its Welcome.
*/
suspend fun requestIfEmpty(
database: MantraDatabase,
chatRoomId: String,
userPublicKey: HexKey,
): Boolean {
val chatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom ?: return false
if (chatRoom.chronicleRequestedAt != null) return false
val holdsWork = database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId).isNotEmpty() ||
database.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId).isNotEmpty()
if (holdsWork) return false
queue(
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
kind = ChronicleRequestEvent.KIND,
tags = ChronicleRequestEvent.build().tags,
content = "",
)
database.chatRoomDao().upsert(chatRoom.copy(chronicleRequestedAt = Clock.System.now()))
announce(
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
messageType = ChatMessage.TYPE_CHRONICLE_REQUESTED,
content = "Asked this group for its signed work",
)
logger.i("Asked $chatRoomId for its signed history")
return true
}
/**
* Send [recipient] everything this device can prove about [chatRoomId].
*
* Both ways a chronicle goes out are this one call: answering a request, and
* the push behind a Welcome. Named for what it does rather than for either
* occasion, because the two differ only in who decided.
*
* Any member may send one and none is elected to. A duplicate costs bandwidth
* and nothing else -- pages are idempotent and every member who is not the
* recipient ignores them -- so it is waste rather than damage, and the
* stand-down that would avoid it is an optimisation to add on top rather than
* a correctness gap to close first.
*
* A device with nothing signed sends nothing. Silence is the honest reply
* from a member who is themselves still catching up, and an empty chronicle
* would look like an answer.
*/
suspend fun sendTo(
database: MantraDatabase,
chatRoomId: String,
userPublicKey: HexKey,
recipient: HexKey,
): Int {
if (recipient.equals(userPublicKey, ignoreCase = true)) return 0
val pages = assemble(database, chatRoomId, recipient)
if (pages.isEmpty()) {
logger.i("Nothing signed here to send ${recipient.take(8)} for $chatRoomId")
return 0
}
pages.forEach { page ->
queue(
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
kind = page.kind,
tags = page.tags,
content = page.content,
createdAt = Instant.fromEpochSeconds(page.createdAt),
)
}
val name = database.profileDao().getProfileByPublicKey(recipient)
?.humanReadableNameOrPubkey()
?: recipient.take(8)
announce(
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
messageType = ChatMessage.TYPE_CHRONICLE_SENT,
content = "Sent this group's signed work to $name",
)
logger.i("Sending ${recipient.take(8)} ${pages.size} chronicle page(s) for $chatRoomId")
return pages.size
}
/**
* One line in the room's transcript.
*
* Written by the device it happened on, for itself. None of these travels --
* a chronicle is not an event in the group's life, it is one member being
* caught up -- so a member watching from the side sees nothing, correctly.
*
* Content is a whole sentence rather than a predicate, so these stay out of
* the AUTHORED sets and nothing prefixes a name to them. See
* [ChatMessage.CHRONICLE_TYPES].
*/
private suspend fun announce(
database: MantraDatabase,
chatRoomId: String,
userPublicKey: HexKey,
messageType: String,
content: String,
) {
database.chatMessageDao().upsert(
ChatMessage(
content = content,
messageType = messageType,
chatRoomId = chatRoomId,
senderPublicKey = userPublicKey,
isUserMessage = true,
giftWrapPayloadId = null,
marmotGroupEventId = null,
marmotInnerEventId = null,
)
)
}
/**
* Put one event on the room's outbound queue.
*
* The same shape `FrostSigningManager.broadcast` uses: a `MarmotInnerEvent`
* with no group event yet, which the notary picks up and encrypts into a
* kind:445 for the room. The id is the rumor id the outbound pipeline
* recomputes from these fields when it assembles the event to encrypt.
*
* **No `ChatMessage`.** Broadcast does not depend on one -- it is
* unconditional in `encryptAndSendMarmotInnerEvent`, which is what let the
* signing sessions travel with no transcript line -- and a chronicle that
* filed one per page would put a row of envelopes in the room's history. One
* line per chronicle is right, and it is not writable from here, because the
* pages of a chronicle are indistinguishable from each other at this point.
*/
private suspend fun queue(
database: MantraDatabase,
chatRoomId: String,
userPublicKey: HexKey,
kind: Int,
tags: TagArray,
content: String,
createdAt: Instant = Clock.System.now(),
) {
database.marmotInnerEventDao().upsert(
MarmotInnerEvent(
id = EventHasher.hashId(
pubKey = userPublicKey,
createdAt = createdAt.epochSeconds,
tags = tags,
content = content,
kind = kind
),
publicKey = userPublicKey,
kind = kind,
createdAt = createdAt,
tags = tags,
content = content,
chatRoomId = chatRoomId,
)
)
}
// ---- Receiving ------------------------------------------------------
/**
* What one pass over a room's stored pages did.
*
* [skipped] and [failed] are kept apart because only one of them is worth
* trying again. A payload the allowlist or the signature refused will be
* refused identically forever; a payload that threw was probably missing a
* row it depends on, and the next page may bring it.
*/
data class Outcome(
val applied: Int = 0,
val skipped: Int = 0,
val failed: Int = 0,
) {
operator fun plus(other: Outcome) = Outcome(
applied = applied + other.applied,
skipped = skipped + other.skipped,
failed = failed + other.failed,
)
}
/**
* Take delivery of a chronicle page for [userPublicKey].
*
* The page is already on disk by the time this runs -- the inbound path
* stores every inner event it decrypts before dispatching on kind -- so this
* does not apply the arriving page as such. It sweeps every page the room
* has, which covers the new one and any that arrived before the rows they
* depend on.
*
* **A device that is not the named recipient does nothing.** The page is an
* ordinary group message and it can read it; it has no reason to. It already
* holds the work, and re-applying would rewrite every one of its rows to
* point at a chronicle page rather than at the event that introduced it. That
* is also what keeps the sweep bounded: only the member being caught up ever
* builds the list.
*/
suspend fun receive(
database: MantraDatabase,
chatRoomId: String,
userPublicKey: HexKey,
page: Event,
): Outcome {
val recipient = ChronicleEvent(
id = page.id,
pubKey = page.pubKey,
createdAt = page.createdAt,
tags = page.tags,
content = page.content,
sig = page.sig,
).recipient()
if (!recipient.equals(userPublicKey, ignoreCase = true)) {
logger.d("Chronicle page ${page.id.take(8)} is for ${recipient?.take(8)}; not applying")
return Outcome()
}
return sweep(database, chatRoomId, userPublicKey)
}
/**
* Apply every chronicle page this room holds for [userPublicKey], repeatedly,
* until a pass stops making progress.
*
* Pages arrive over relays in no order, so page 3 can land before page 2 and
* its chunks have no chapter to hang off yet. Those payloads throw a foreign
* key violation and would be lost -- unless something runs them again.
*
* **No table is needed for that**, because the inbound path already stores
* every inner event it decrypts. This is the same shape
* `FrostSigningManager.replayStoredMessages` has, and for the same reason:
* nothing was actually lost, it just had nowhere to go at the time.
*
* **Progress is measured by failures falling, not by rows written.** Every
* write here is an upsert keyed on the event id, so "applied something" is
* true on every pass forever and would not terminate. A pass that fails fewer
* payloads than the last one learned something; a pass that does not is as
* far as this chronicle gets, and the rest is a hole to be filled by another
* page or another chronicle.
*
* **The answer is the last pass, not the sum of them.** Accumulating would
* count a payload once per pass it survived and report failures the next pass
* went on to fix, so `failed > 0` would stop meaning "still missing". The
* final pass is the settled state: what these pages can apply, what they will
* never apply, and what is still waiting on something that has not arrived.
*/
suspend fun sweep(
database: MantraDatabase,
chatRoomId: String,
userPublicKey: HexKey,
): Outcome {
val pages = database.marmotInnerEventDao()
.getByChatRoomAndKinds(chatRoomId, listOf(ChronicleEvent.KIND))
.filter { addressedTo(it, userPublicKey) }
if (pages.isEmpty()) return Outcome()
var pass = Outcome()
var previousFailures = Int.MAX_VALUE
while (true) {
pass = Outcome()
pages.forEach { pass += applyPage(database, chatRoomId, it) }
if (pass.failed == 0 || pass.failed >= previousFailures) {
if (pass.failed > 0) {
logger.w(
"Stopped sweeping $chatRoomId with ${pass.failed} payload(s) still " +
"unapplied: nothing in the pages it holds satisfies them"
)
}
break
}
previousFailures = pass.failed
}
logger.i(
"Swept ${pages.size} chronicle page(s) for $chatRoomId: " +
"${pass.applied} applied, ${pass.skipped} skipped, ${pass.failed} left"
)
// An answer arrived, so the room may ask again if it turns out to be a
// partial one. Cleared on anything applied rather than on the chronicle
// reporting itself complete: a page count is the sender's claim about the
// transfer, not about the group's record, and a member who left work out
// would otherwise have the last word.
if (pass.applied > 0) {
database.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.let { chatRoom ->
if (chatRoom.chronicleRequestedAt != null) {
database.chatRoomDao().upsert(chatRoom.copy(chronicleRequestedAt = null))
// One line per answered request, which is as close to one per
// chronicle as this can get: the pages of a chronicle are not
// distinguishable from each other here, and clearing the stamp
// is exactly the moment a catch-up stops being pending.
//
// A push behind a Welcome writes none, because the room was
// never asked. That is right: it lands before the member has
// opened the room, and "caught up on work you have not seen
// yet" is a line about nothing.
announce(
database = database,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
messageType = ChatMessage.TYPE_CHRONICLE_RECEIVED,
content = "Caught up on ${pass.applied} item(s) of this group's signed work",
)
}
}
}
return pass
}
private fun addressedTo(stored: MarmotInnerEvent, userPublicKey: HexKey): Boolean =
ChronicleEvent(
id = stored.id,
pubKey = stored.publicKey,
createdAt = stored.createdAt.epochSeconds,
tags = stored.tags,
content = stored.content,
sig = "",
).recipient().equals(userPublicKey, ignoreCase = true)
/**
* One page, checked payload by payload and applied in dependency order.
*
* **Per payload, not per page.** A forged payload sitting beside honest ones
* costs itself and nothing else -- the same rule `MarmotInboundManager` uses
* for a forged direct message, and for the same reason: this runs inside the
* inbound transaction, and one bad event must not take the room down with it.
* Refusing the whole page would also let a single forgery deny an entire
* chronicle.
*
* The page's own framing is still all-or-nothing; see
* [ChronicleEvent.decodePage] for why those two are not in tension.
*/
private suspend fun applyPage(
database: MantraDatabase,
chatRoomId: String,
stored: MarmotInnerEvent,
): Outcome {
val payloads = ChronicleEvent.decodePage(stored.content)
if (payloads == null) {
logger.w("Chronicle page ${stored.id.take(8)} does not read as a page; dropping it")
return Outcome()
}
// Read once for the page rather than per payload: every event in an
// chronicle was signed by the room it is arriving in, so they all share
// its path. Null when the recipient has no key state yet -- which is
// the normal case for the member a chronicle exists for, and why
// `GroupSignedEvent.derivationPath` is nullable and `record` fills it
// in later rather than overwriting it with null.
val derivationPath = GroupKeyStateManager.keyStateFor(database, chatRoomId)?.derivationPath
var outcome = Outcome()
ChronicleEvent.inApplyOrder(payloads).forEach { payload ->
// The allowlist first, and it is not a formality. Verification admits
// an event to the apply path on the strength of the group's
// signature, which makes every kind the group has ever signed
// replayable by any member at any time -- a GroupKeyStateEvent from
// an earlier epoch passes the signature check perfectly.
if (!ChronicleEvent.isChroniclable(payload.kind)) {
logger.w(
"Chronicle page ${stored.id.take(8)} carries kind ${payload.kind}, " +
"which a chronicle may not deliver; dropping ${payload.id.take(8)}"
)
outcome += Outcome(skipped = 1)
return@forEach
}
if (!GroupKeyStateEvent.isSignedByRoom(payload, chatRoomId)) {
logger.w(
"Chronicle page ${stored.id.take(8)} carries ${payload.id.take(8)}, " +
"which room $chatRoomId did not sign; dropping it"
)
outcome += Outcome(skipped = 1)
return@forEach
}
outcome += try {
// Kept as the group signed it, before it is turned into rows.
// The check above is what earns it that: a payload reaching here
// is one the room signed, which is the same standard
// `FrostSigningManager` records its own batches on. Without this
// a member who was handed their history would hold the rows and
// none of the events, and could never build a chronicle of their
// own to hand on.
database.groupSignedEventDao().record(
GroupSignedEvent.fromEvent(
event = payload,
chatRoomId = chatRoomId,
derivationPath = derivationPath,
)
)
// The chat line this returns is deliberately dropped rather than
// filed. ChatMessage has an autoGenerate primary key, so there is
// no id to dedupe on and every applied payload would mint a new
// row -- giving the recipient a synthetic transcript dated now,
// and another one on every pass of the sweep. The chronicle
// restores the work; the conversation is forward secret and stays
// gone.
ChatMessage.applyInnerEvent(
database = database,
groupId = chatRoomId,
event = payload,
marmotGroupEventId = stored.marmotGroupEventId,
marmotInnerEventId = stored.id,
senderPublicKey = payload.pubKey,
isUserMessage = false,
createdAt = stored.createdAt,
groupSignedEventId = payload.id,
)
Outcome(applied = 1)
} catch (error: Throwable) {
// Almost always a foreign key: this payload names a row that is
// in a page which has not arrived yet. Retryable, which is what
// the sweep is for, so it is counted rather than logged loudly.
logger.d(
"Could not apply ${payload.id.take(8)} from chronicle page " +
"${stored.id.take(8)} yet: ${error.message}"
)
Outcome(failed = 1)
}
}
return outcome
}
/**
* Every chroniclable event this device holds for the room: the ones the group
* signed here or sent here, plus anything only the rows still remember.
*
* The record comes first because it is the event rather than a reconstruction
* of one, and the rebuild fills the gap behind it -- keyed by id, so an event
* held both ways travels once. A room that upgraded mid-life has both, and
* neither half is complete on its own.
*
* The two disagree only in one direction worth naming. An id the rebuild
* produces that the record does not hold is either work from before the
* table existed, which is the point of the fallback, or a rebuild that has
* gone wrong -- and a wrong rebuild hashes to an id whose signature does not
* verify, so the filter in [assemble] drops it either way rather than
* shipping a payload every receiver reads as a forgery.
*
* Order does not matter at this point; [ChronicleEvent.inApplyOrder] settles it
* afterwards. It is stable within a rank, so a room holding some of its work
* both ways can order two chapters differently from a member holding one way
* only. That costs nothing: pages are idempotent and applied payload by
* payload, and two members already differed by the order their rows were
* written in.
*/
private suspend fun signedEventsOf(
database: MantraDatabase,
chatRoomId: String,
): List<Event> {
val recorded = database.groupSignedEventDao()
.getByChatRoomId(chatRoomId)
// The allowlist, applied to the source that can actually trip it --
// see the class comment. A key state on file is the room's own, and
// sending it would be handing every member a validly signed
// statement about what the room signs with, replayable forever.
.filter { ChronicleEvent.isChroniclable(it.kind) }
.map { it.toEvent() }
val onFile = recorded.mapTo(mutableSetOf()) { it.id }
val rebuilt = rebuiltEventsOf(database, chatRoomId).filterNot { it.id in onFile }
if (rebuilt.isNotEmpty()) {
logger.d(
"Chronicling $chatRoomId: ${recorded.size} event(s) as the group signed them, " +
"${rebuilt.size} rebuilt from rows that predate the record"
)
}
return currentTranslationsOnly(recorded + rebuilt)
}
/**
* Drops every translation of a passage but the one that stands.
*
* The record keeps every event the group ever signed, deliberately -- a
* signature is the group's statement and discarding one is not this table's
* business. The rows do not: `ChatMessage.applyInnerEvent` supersedes a
* translation chunk, deleting the one it replaces, so a passage translated
* three times leaves one row and three events.
*
* That difference reaches the chronicle the moment it is read from the record
* rather than rebuilt from rows, and it compounds: every draft a group ever
* signed would travel in every chronicle it ever sends, for as long as the room
* exists. A chronicle exists to catch a member up on where the group has got
* to, not to hand them its drafting history.
*
* **The rule is the applying arm's, restated rather than approximated**:
* newest by the timestamp the group signed at, id breaking a tie. It has to
* be, or the chronicle would ship one translation as current and the recipient
* would settle on another -- and since both are validly signed, nothing
* downstream would notice the disagreement.
*
* Dropping the drafts is safe precisely because the recipient applies the
* same rule: it is not what keeps them correct, only what stops them being
* sent work they would immediately discard.
*
* A translation naming no passage is left alone rather than grouped with
* others: it is unappliable either way, and letting one stand in for a whole
* passage would let a malformed event suppress a good one.
*/
private fun currentTranslationsOnly(events: List<Event>): List<Event> {
val translations = events.filter { it.kind == TranslationChunkEvent.KIND }
if (translations.size < 2) return events
val current = translations
.groupBy { event ->
val translation = TranslationChunkEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig,
)
val chapterId = translation.translationChapterId()
val chunkId = translation.chunkId()
if (chapterId == null || chunkId == null) event.id else "$chapterId/$chunkId"
}
.values
.mapNotNull { candidates ->
candidates.maxWithOrNull(compareBy({ it.createdAt }, { it.id }))
}
.mapTo(mutableSetOf()) { it.id }
if (current.size != translations.size) {
logger.d(
"Leaving ${translations.size - current.size} superseded translation(s) " +
"out of the chronicle"
)
}
return events.filter { it.kind != TranslationChunkEvent.KIND || it.id in current }
}
/**
* The room's rows, rebuilt into the events they came from.
*
* Walked down the tree rather than queried per kind, because only dialects
* and artifacts have a by-room query and the rest hang off a parent. The walk
* is also what makes an artifact's version label reachable: it is not on the
* artifact row -- see `MantraArtifact.toArtifactEvent` -- and the version it
* went into is one step away here.
*
* Still walked on every chronicle, and by now it contributes nothing in most
* rooms: everything signed or applied since `GroupSignedEvent` existed is on
* file as an event, and [signedEventsOf] discards whatever this rebuilds of
* it. The walk is a handful of indexed queries against a room's own rows,
* and it is what makes a half-upgraded room whole, so it runs rather than
* being skipped when the record looks complete -- there is no way to tell a
* complete record from a partial one without doing it.
*
* It returns everything it can rebuild and lets [signedEventsOf] and the
* verify filter decide what can travel.
*
* Deprecated rather than merely legacy: it is a whole mechanism kept alive
* for a shrinking set of rows, and it takes eight `toXEvent()` methods and a
* ten-case round-trip suite with it. **docs/member-chronicle.md, "Retiring the
* rebuild", is the checklist** -- what goes, what only looks like it goes,
* and the one thing that has to be true before any of it can.
*/
@Deprecated(
"Chronicle fallback for work signed before the GroupSignedEvent table. " +
"Goes when the last pre-v13 install does -- see the removal checklist " +
"in docs/member-chronicle.md."
)
private suspend fun rebuiltEventsOf(
database: MantraDatabase,
chatRoomId: String,
): List<Event> = buildList {
database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId).forEach {
add(it.toDialectEvent())
}
database.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId).forEach { artifact ->
val versions = database.mantraArtifactVersionDao()
.getArtifactVersionsByArtifactId(artifact.id)
// The version an artifact starts life with carries the label the
// artifact was signed with, and `ArtifactVersionEvent.initialVersionOf`
// gives it the artifact's own timestamp. The label is still not on the
// artifact row -- `fromArtifactEvent` drops it -- so this is where it
// comes back from. An artifact with no such version is one this device
// cannot rebuild, which is a gap in the chronicle rather than a reason to
// abandon it.
val versionLabel = versions
.firstOrNull { it.createdAt == artifact.createdAt }
?.versionLabel
if (versionLabel == null) {
logger.w("Artifact ${artifact.id} has no initial version; leaving it out")
} else {
add(artifact.toArtifactEvent(versionLabel = versionLabel))
}
versions.forEach { version ->
add(version.toArtifactVersionEvent())
database.mantraChapterDao()
.getChaptersByArtifactVersionId(version.id)
.forEach { chapter ->
add(chapter.toChapterEvent())
database.mantraChunkDao()
.getChunksByChapterId(chapter.id)
.forEach { add(it.toChunkEvent()) }
}
database.mantraTranslationArtifactVersionDao()
.getTranslationsByArtifactVersionId(version.id)
.forEach { translationVersion ->
add(translationVersion.toTranslationArtifactVersionEvent())
database.mantraTranslationChapterDao()
.getTranslationChaptersByTranslationArtifactVersionId(translationVersion.id)
.forEach { translationChapter ->
add(translationChapter.toTranslationChapterEvent())
// Only the translation that survived supersession
// is here to be found: retranslating a passage
// changes the text and so the event id, and the
// arm that applies one drops what it replaces. So
// a chronicle carries a group's current answer to
// each passage rather than its drafts, which is
// the same thing every other member holds.
database.mantraTranslationChunkDao()
.getTranslationChunksByTranslationChapterId(translationChapter.id)
.forEach { add(it.toTranslationChunkEvent()) }
}
}
}
}
}
/**
* [events] cut into pages that fit, keeping the order they arrive in.
*
* Greedy: fill a page until the next event would cross either cap. Both are
* checked because they bind different chronicles -- a room of one-line dialects
* hits the count first and a room of chapters hits the bytes.
*
* An event too large to share a page with anything is given one of its own.
* One too large for even that is dropped with a log rather than failing the
* chronicle: a chapter nobody can chronicle is a hole, and a member who gets
* nothing at all is a bigger one.
*/
private fun paginate(events: List<Event>): List<List<Event>> {
val pages = mutableListOf<List<Event>>()
var page = mutableListOf<Event>()
// The brackets an empty page already costs.
var bytes = 2
events.forEach { event ->
// Plus the comma this event needs if it is not first on its page.
val size = event.toJson().encodeToByteArray().size + 1
if (2 + size > ChronicleEvent.MAX_PAGE_BYTES) {
logger.w(
"Event ${event.id} is $size bytes and will not fit a " +
"${ChronicleEvent.MAX_PAGE_BYTES}-byte page; leaving it out of the chronicle"
)
return@forEach
}
val full = page.isNotEmpty() &&
(bytes + size > ChronicleEvent.MAX_PAGE_BYTES || page.size >= ChronicleEvent.MAX_PAGE_EVENTS)
if (full) {
pages.add(page)
page = mutableListOf()
bytes = 2
}
page.add(event)
bytes += size
}
if (page.isNotEmpty()) pages.add(page)
return pages
}
}