fix: hold a payload whose parent has not arrived instead of losing the event

A receiver hit `FOREIGN KEY constraint failed` on an artifact submission
and lost the whole group event. The artifact referenced a dialect the
receiver did not have, MantraArtifact.dialectId is a foreign key, and
SQLite answers a violated constraint by aborting -- which rolled back
the entire transaction the inbound pipeline runs in. Gone with it: the
NostrEvent, the MarmotGroupEvent, the submission's MarmotInnerEvent
holding the payload verbatim, and the transcript line. Nothing retries,
so the artifact stayed lost even once the dialect turned up.

Every nip30303 entity is a child of another and the schema enforces all
of it -- artifact→dialect, version→artifact, chapter→version,
chunk→chapter, translations→both of theirs -- so this was every branch,
not one.

And submissions make arriving before your parent ordinary rather than
exotic. That is the point of them: an admin submits a backlog in
whatever order they hold it, and a member who joined last week can be
sent what the group was told last month. Both produce payloads whose
parents are not here yet, and both were losing data.

So check the parents before inserting. A payload that arrives early is
held on the submission row -- awaitingEventId names what it waits for --
and applied when that arrives. Releasing one can release another, a
version freeing its chapters and those freeing their chunks, so it walks
outward until nothing more comes unstuck. A payload with a second parent
still missing is re-pointed at that one rather than retried on every
arrival.

Nothing is written to the transcript while a payload is held. Nobody has
said anything yet; the line appears when it is applied, in the position
its own timestamp gives it.

Two things fall out of the shape:

parentRefsOf is pure and separate from the lookups, because the mapping
is the part that can silently drift from the schema and there is no
database harness in commonTest to catch it. ParentRefsTest pins one case
per kind. Which table an id lives in is carried as the kind of event
that would have created it, so there is no second enum to keep in step.

applyInnerEvent takes ids rather than a GroupEvent, since replay happens
long after that object is gone. A released payload is recorded as not
ours: we hold the parents of anything we wrote, having written those too.

Also reconstructs a held bare nip30303 event from its own columns rather
than parsing its content as an event -- only submissions carry an event
there, and reading both that way would have stranded every bare one
permanently.

Verified: the v5→v6 migration runs clean on the receiver's real
populated database. The hold path itself still needs a fresh submission
from a sender to exercise end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 21:05:35 +02:00
parent 2d0fe6f5fc
commit d7aac49cf1
7 changed files with 5565 additions and 46 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -164,7 +164,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 5,
version = 6,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -183,7 +183,12 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// nip30303 event a SubmissionEvent rumor carries. Rumors queued before
// this come back null, which reads as "not a submission" -- correct,
// since none of them were.
AutoMigration(from = 4, to = 5)
AutoMigration(from = 4, to = 5),
// v6 adds the nullable MarmotInnerEvent.awaitingEventId, holding a
// submission that arrived before the event it references. Nothing
// queued before this was ever held, so null is the right answer for
// every existing row.
AutoMigration(from = 5, to = 6)
]
)
@ColumnTypeConverters(MantraConverters::class)

View File

@@ -15,6 +15,9 @@ interface MantraTranslationChunkDao {
@Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId ORDER BY `index` ASC")
suspend fun getTranslationChunksByTranslationChapterId(translationChapterId: String): List<MantraTranslationChunk>
@Query("SELECT * FROM MantraTranslationChunk WHERE id = :id")
suspend fun getTranslationChunkById(id: String): MantraTranslationChunk?
@Query("DELETE FROM MantraTranslationChunk WHERE id = :id")
suspend fun deleteById(id: String)
}

View File

@@ -26,4 +26,13 @@ interface MarmotInnerEventDao {
*/
@Query("DELETE FROM MarmotInnerEvent WHERE payloadEventId = :payloadEventId")
suspend fun deleteByPayloadEventId(payloadEventId: String)
/**
* The submissions held back waiting for [eventId], oldest first.
*
* Oldest first because a backlog usually arrives in the order it was
* written, so applying it that way unblocks the most in one pass.
*/
@Query("SELECT * FROM MarmotInnerEvent WHERE awaitingEventId = :eventId ORDER BY createdAt ASC")
suspend fun getSubmissionsAwaiting(eventId: String): List<MarmotInnerEvent>
}

View File

@@ -219,19 +219,18 @@ data class ChatMessage(
}
val payload = submission?.payload()
database.marmotInnerEventDao().upsert(
MarmotInnerEvent(
id = event.id,
publicKey = event.pubKey,
marmotGroupEventId = groupEvent.id,
tags = event.tags,
content = event.content,
chatRoomId = groupEventResult.groupId,
kind = event.kind,
payloadEventId = payload?.id,
createdAt = Instant.fromEpochSeconds(event.createdAt)
)
val innerEvent = MarmotInnerEvent(
id = event.id,
publicKey = event.pubKey,
marmotGroupEventId = groupEvent.id,
tags = event.tags,
content = event.content,
chatRoomId = groupEventResult.groupId,
kind = event.kind,
payloadEventId = payload?.id,
createdAt = Instant.fromEpochSeconds(event.createdAt)
)
database.marmotInnerEventDao().upsert(innerEvent)
// A submission whose payload will not parse, or which carries
// another submission, is kept but not applied: there is nothing
@@ -249,15 +248,13 @@ data class ChatMessage(
content = event.content,
)
} else {
applyInnerEvent(
applyOrHold(
database = database,
activeKeyPair = activeKeyPair,
groupEvent = groupEvent,
groupId = groupEventResult.groupId,
innerEvent = innerEvent,
event = payload ?: event,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
createdAt = Instant.fromEpochSeconds(event.createdAt),
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
)
}
}
@@ -355,14 +352,283 @@ data class ChatMessage(
* from [event], so the chat line says who added it and the row says who
* wrote it.
*/
private suspend fun applyInnerEvent(
/**
* Apply [event] now, or hold it until what it references turns up.
*
* Foreign keys mean a payload cannot become a row before its parent
* does, and inserting one anyway does not fail politely: SQLite aborts
* the statement, which rolls back the whole transaction the inbound
* pipeline runs in -- losing the nostr event, the group event, this
* submission and the transcript line with it, none of which is retried.
*
* So the parent is checked first. A payload that arrives early is kept
* against the id it waits for and applied later, which is what makes
* order stop mattering: an admin submitting a backlog can send it in
* whatever order they hold it, and a member who joined last week can be
* sent what the group was told last month.
*
* A held payload writes no chat line. Nobody said anything yet -- the
* line appears when it is applied, in the transcript position its own
* timestamp gives it.
*/
private suspend fun applyOrHold(
database: MantraDatabase,
activeKeyPair: KeyPair,
groupEvent: GroupEvent,
groupId: String,
innerEvent: MarmotInnerEvent,
event: Event,
isUserMessage: Boolean,
): ChatMessage? {
missingParentOf(database, event)?.let { missingParent ->
database.marmotInnerEventDao().upsert(
innerEvent.copy(awaitingEventId = missingParent)
)
return null
}
val chatMessage = applyInnerEvent(
database = database,
groupId = groupId,
event = event,
marmotGroupEventId = innerEvent.marmotGroupEventId,
marmotInnerEventId = innerEvent.id,
senderPublicKey = innerEvent.publicKey,
isUserMessage = isUserMessage,
createdAt = innerEvent.createdAt,
)
// This may be the parent something else was held for.
releaseAwaiting(database, activeKeyPair, groupId, event.id)
return chatMessage
}
/**
* Apply whatever was waiting on [arrivedEventId], now that it is here.
*
* Releasing one can release another -- a version unblocks its chapters,
* a chapter unblocks its chunks -- so this keeps going until nothing
* more comes unstuck. A payload with a second parent still missing is
* re-pointed at that one rather than applied, so it waits for the right
* thing instead of being retried on every arrival.
*/
private suspend fun releaseAwaiting(
database: MantraDatabase,
activeKeyPair: KeyPair,
groupId: String,
arrivedEventId: HexKey,
) {
val arrived = ArrayDeque(listOf(arrivedEventId))
while (arrived.isNotEmpty()) {
val parentId = arrived.removeFirst()
database.marmotInnerEventDao().getSubmissionsAwaiting(parentId).forEach { held ->
// A submission's content is its payload verbatim; anything
// else held is a bare nip30303 event, where the row is the
// event and its own columns rebuild it. Reading both out of
// the content would strand every bare one here forever.
val payload = if (held.kind == SubmissionEvent.KIND) {
Event.fromJsonOrNull(held.content)
} else {
Event(
id = held.id,
pubKey = held.publicKey,
createdAt = held.createdAt.epochSeconds,
kind = held.kind,
tags = held.tags,
content = held.content,
sig = "",
)
} ?: return@forEach
missingParentOf(database, payload)?.let { stillMissing ->
database.marmotInnerEventDao().upsert(
held.copy(awaitingEventId = stillMissing)
)
return@forEach
}
database.marmotInnerEventDao().upsert(held.copy(awaitingEventId = null))
applyInnerEvent(
database = database,
groupId = groupId,
event = payload,
marmotGroupEventId = held.marmotGroupEventId,
marmotInnerEventId = held.id,
senderPublicKey = held.publicKey,
// Held payloads are never ours: we hold the parents of
// anything we wrote, having written those too.
isUserMessage = false,
createdAt = held.createdAt,
)?.let { database.chatMessageDao().upsert(it) }
arrived.addLast(payload.id)
}
}
}
/**
* The event [event] references but which is not on file yet, or null
* when everything it needs is already here.
*/
private suspend fun missingParentOf(
database: MantraDatabase,
event: Event,
): HexKey? = parentRefsOf(event).firstOrNull { !it.exists(database) }?.id
/**
* A row some event references, named by id and by the kind of event
* that would have created it.
*/
internal data class ParentRef(
val id: HexKey,
val kind: Int,
) {
suspend fun exists(database: MantraDatabase): Boolean = when (kind) {
DialectEvent.KIND ->
database.mantraDialectDao().getDialectById(id) != null
ArtifactEvent.KIND ->
database.mantraArtifactDao().getArtifactById(id) != null
ArtifactVersionEvent.KIND ->
database.mantraArtifactVersionDao().getArtifactVersionById(id) != null
ChapterEvent.KIND ->
database.mantraChapterDao().getChapterById(id) != null
ChunkEvent.KIND ->
database.mantraChunkDao().getChunkById(id) != null
TranslationArtifactVersionEvent.KIND ->
database.mantraTranslationArtifactVersionDao().getTranslationById(id) != null
TranslationChapterEvent.KIND ->
database.mantraTranslationChapterDao().getTranslationChapterById(id) != null
TranslationChunkEvent.KIND ->
database.mantraTranslationChunkDao().getTranslationChunkById(id) != null
// Not a parent anything waits on.
else -> true
}
}
/**
* Every row [event] references and the database will insist exists.
*
* This is the foreign keys on the Mantra* entities, read off the event
* instead of the schema. The two have to agree: a parent listed here
* that the schema does not enforce just delays a payload for no reason,
* and one the schema enforces but is missing here is a payload that
* takes the whole inbound transaction down with it.
*
* The chat room is deliberately not among them. It is a foreign key,
* but a payload for a room we are not in never reaches this far.
*/
internal fun parentRefsOf(event: Event): List<ParentRef> = when (event.kind) {
ArtifactEvent.KIND ->
event.asArtifactEvent().let {
listOfNotNull(it.dialectId()?.let { id -> ParentRef(id, DialectEvent.KIND) })
}
ArtifactVersionEvent.KIND ->
event.asArtifactVersionEvent().let {
listOfNotNull(it.artifactId()?.let { id -> ParentRef(id, ArtifactEvent.KIND) })
}
ChapterEvent.KIND ->
event.asChapterEvent().let {
listOfNotNull(
it.artifactVersionId()?.let { id -> ParentRef(id, ArtifactVersionEvent.KIND) }
)
}
ChunkEvent.KIND ->
event.asChunkEvent().let {
listOfNotNull(it.chapterId()?.let { id -> ParentRef(id, ChapterEvent.KIND) })
}
TranslationArtifactVersionEvent.KIND ->
event.asTranslationArtifactVersionEvent().let {
listOfNotNull(
it.artifactVersionId()?.let { id -> ParentRef(id, ArtifactVersionEvent.KIND) },
it.dialectId()?.let { id -> ParentRef(id, DialectEvent.KIND) },
)
}
TranslationChapterEvent.KIND ->
event.asTranslationChapterEvent().let {
listOfNotNull(
it.translationArtifactVersionId()?.let { id ->
ParentRef(id, TranslationArtifactVersionEvent.KIND)
},
it.chapterId()?.let { id -> ParentRef(id, ChapterEvent.KIND) },
)
}
TranslationChunkEvent.KIND ->
event.asTranslationChunkEvent().let {
listOfNotNull(
it.translationChapterId()?.let { id ->
ParentRef(id, TranslationChapterEvent.KIND)
},
it.chunkId()?.let { id -> ParentRef(id, ChunkEvent.KIND) },
)
}
TranslationEvent.KIND ->
event.asTranslationEvent().let {
listOfNotNull(
it.translationChunkId()?.let { id ->
ParentRef(id, TranslationChunkEvent.KIND)
},
it.translationArtifactVersionId()?.let { id ->
ParentRef(id, TranslationArtifactVersionEvent.KIND)
},
)
}
// Kinds with no parent to wait for.
else -> emptyList()
}
// A submission's payload arrives as a base Event; these read it back as
// the kind it says it is, so its tag accessors can be used.
private fun Event.asArtifactEvent() =
ArtifactEvent(id, pubKey, createdAt, tags, content, sig)
private fun Event.asArtifactVersionEvent() =
ArtifactVersionEvent(id, pubKey, createdAt, tags, content, sig)
private fun Event.asChapterEvent() =
ChapterEvent(id, pubKey, createdAt, tags, content, sig)
private fun Event.asChunkEvent() =
ChunkEvent(id, pubKey, createdAt, tags, content, sig)
private fun Event.asTranslationArtifactVersionEvent() =
TranslationArtifactVersionEvent(id, pubKey, createdAt, tags, content, sig)
private fun Event.asTranslationChapterEvent() =
TranslationChapterEvent(id, pubKey, createdAt, tags, content, sig)
private fun Event.asTranslationChunkEvent() =
TranslationChunkEvent(id, pubKey, createdAt, tags, content, sig)
private fun Event.asTranslationEvent() =
TranslationEvent(id, pubKey, createdAt, tags, content, sig)
private suspend fun applyInnerEvent(
database: MantraDatabase,
groupId: String,
event: Event,
marmotGroupEventId: HexKey?,
marmotInnerEventId: HexKey,
senderPublicKey: HexKey,
isUserMessage: Boolean,
createdAt: Instant,
): ChatMessage? {
return when (event.kind) {
@@ -370,10 +636,10 @@ data class ChatMessage(
ChatMessage(
giftWrapPayloadId = null,
messageType = "message",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = event.content, // TODO: Figure out what to do here...
@@ -393,17 +659,17 @@ data class ChatMessage(
)?.let { mantraArtifact ->
database.mantraArtifactDao().upsert(
mantraArtifact.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifact",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraArtifact.name} to artifacts"
@@ -424,17 +690,17 @@ data class ChatMessage(
)?.let { mantraArtifactVersion ->
database.mantraArtifactVersionDao().upsert(
mantraArtifactVersion.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifactVersion",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name...
@@ -455,17 +721,17 @@ data class ChatMessage(
)?.let { mantraChapter ->
database.mantraChapterDao().upsert(
mantraChapter.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "chapter",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name...
@@ -487,7 +753,7 @@ data class ChatMessage(
)?.let { mantraChunk ->
database.mantraChunkDao().upsert(
mantraChunk.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
@@ -509,7 +775,7 @@ data class ChatMessage(
)?.let { mantraDialect ->
database.mantraDialectDao().upsert(
mantraDialect.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
@@ -517,10 +783,10 @@ data class ChatMessage(
ChatMessage(
giftWrapPayloadId = null,
messageType = "dialect",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name...
@@ -541,17 +807,17 @@ data class ChatMessage(
)?.let { mantraTranslationArtifactVersion ->
database.mantraTranslationArtifactVersionDao().upsert(
mantraTranslationArtifactVersion.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "translationArtifactVersion",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name...
@@ -576,7 +842,7 @@ data class ChatMessage(
)?.let { mantraTranslationChapter ->
database.mantraTranslationChapterDao().upsert(
mantraTranslationChapter.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
// TODO: translation chapter might be too noisy for chat updates
@@ -597,7 +863,7 @@ data class ChatMessage(
)?.let { mantraTranslationChunk ->
database.mantraTranslationChunkDao().upsert(
mantraTranslationChunk.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
@@ -623,16 +889,16 @@ data class ChatMessage(
)?.let { mantraTranslation ->
database.mantraTranslationDao().upsert(
mantraTranslation.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "translation",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text
@@ -643,10 +909,10 @@ data class ChatMessage(
ChatMessage(
giftWrapPayloadId = null,
messageType = "unsupported",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = event.toJson(),

View File

@@ -73,6 +73,17 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload...
*/
val payloadEventId: HexKey? = null,
/**
* The event this one references but which has not arrived yet, or null
* when there is nothing left to wait for.
*
* Every nip30303 entity is a child of another and the database enforces
* that, so a payload cannot become a row before its parent does. Rather
* than drop one that arrives early, it is held here against the id it is
* waiting on and applied when that turns up.
*/
val awaitingEventId: HexKey? = null,
/**
* Associated MarmotGroupEvent
*/
@@ -127,6 +138,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload...
if (content != other.content) return false
if (quotedEventId != other.quotedEventId) return false
if (payloadEventId != other.payloadEventId) return false
if (awaitingEventId != other.awaitingEventId) return false
if (marmotGroupEventId != other.marmotGroupEventId) return false
if (createdAt != other.createdAt) return false
if (updatedAt != other.updatedAt) return false
@@ -147,6 +159,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload...
result = 31 * result + content.hashCode()
result = 31 * result + (quotedEventId?.hashCode() ?: 0)
result = 31 * result + (payloadEventId?.hashCode() ?: 0)
result = 31 * result + (awaitingEventId?.hashCode() ?: 0)
result = 31 * result + (marmotGroupEventId?.hashCode() ?: 0)
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()

View File

@@ -0,0 +1,167 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
/**
* What the inbound side must wait for before it writes a row.
*
* These have to match the foreign keys on the Mantra* entities exactly. A
* parent claimed here that the schema does not enforce holds a payload back
* for nothing; one the schema enforces but that is missing here is an insert
* that violates a constraint, and SQLite answers that by rolling back the
* whole inbound transaction -- losing the group event, the submission and the
* transcript line, none of which is ever retried. So the mapping is asserted
* rather than trusted to stay in step.
*/
class ParentRefsTest {
private val author = "a".repeat(64)
private val dialect = "d".repeat(64)
private val artifact = "1".repeat(64)
private val version = "2".repeat(64)
private val chapter = "3".repeat(64)
private val chunk = "4".repeat(64)
private val translationVersion = "5".repeat(64)
private val translationChapter = "6".repeat(64)
private fun eventOf(template: EventTemplate<*>) = Event(
id = "f".repeat(64),
pubKey = author,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = "",
)
private fun refsOf(template: EventTemplate<*>) =
ChatMessage.parentRefsOf(eventOf(template)).map { it.id to it.kind }
@Test
fun `an artifact waits for its dialect`() {
val refs = refsOf(
ArtifactEvent.build(
name = "In Detention",
url = "example.com",
visibility = "private",
license = "cc",
dialectId = dialect,
)
)
assertEquals(listOf(dialect to DialectEvent.KIND), refs)
}
@Test
fun `a version waits for its artifact`() {
val refs = refsOf(
ArtifactVersionEvent.build(content = "1.0") {
addUnique(ArtifactIdTag.assemble(artifact))
}
)
assertEquals(listOf(artifact to ArtifactEvent.KIND), refs)
}
@Test
fun `a chapter waits for its version and a chunk for its chapter`() {
val chapterRefs = refsOf(
ChapterEvent.build(
artifactVersionId = version,
name = "Chapter 1",
originalText = "text",
index = 0,
wordCount = 1,
characterCount = 4,
)
)
val chunkRefs = refsOf(
ChunkEvent.build(
chapterId = chapter,
text = "text",
index = 0,
wordCount = 1,
characterCount = 4,
)
)
assertEquals(listOf(version to ArtifactVersionEvent.KIND), chapterRefs)
assertEquals(listOf(chapter to ChapterEvent.KIND), chunkRefs)
}
@Test
fun `a translation waits for both of its parents`() {
val refs = refsOf(
TranslationArtifactVersionEvent.build(
artifactVersionId = version,
dialectId = dialect,
name = "Sesotho",
visibility = "private",
license = "cc",
)
)
assertEquals(
listOf(
version to ArtifactVersionEvent.KIND,
dialect to DialectEvent.KIND,
),
refs
)
}
@Test
fun `a translated chapter and chunk each wait for both of their parents`() {
val chapterRefs = refsOf(
TranslationChapterEvent.build(
translationArtifactVersionId = translationVersion,
chapterId = chapter,
index = 0,
)
)
val chunkRefs = refsOf(
TranslationChunkEvent.build(
translationChapterId = translationChapter,
chunkId = chunk,
index = 0,
text = "translated",
)
)
assertEquals(
listOf(
translationVersion to TranslationArtifactVersionEvent.KIND,
chapter to ChapterEvent.KIND,
),
chapterRefs
)
assertEquals(
listOf(
translationChapter to TranslationChapterEvent.KIND,
chunk to ChunkEvent.KIND,
),
chunkRefs
)
}
@Test
fun `a dialect waits for nothing, so it can always start a group off`() {
val refs = refsOf(
DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
)
assertTrue(refs.isEmpty())
}
}