feat(projects): a late member is told what the room's work is for
Phase 5 of docs/project-feature.md. Three kinds appended to `ChronicleEvent.APPLY_ORDER`, which is all the mechanism needs: `CHRONICLABLE_KINDS`, `applyRank`, `inApplyOrder` and `isChroniclable` all derive from that one list, and `signedEventsOf` already filters on it. Appending is not arbitrary. A membership is a foreign key into both a project and a version, so it has to apply after everything it can name, and putting the three on the end gets that right by construction -- everything a project groups is already above it. A page in the wrong order is a refused insert on the one device with nothing to fall back on. Then four existing tests failed, and each was telling the truth. Three of them compare what a seeded room chronicles against CHRONICLABLE_KINDS itself, so a kind added to the set and not to the seed fails them. That is the point of writing them that way, so the project went into the canonical seed in both chronicle suites rather than into a helper beside them. The fourth is the one worth reading. `every chroniclable kind has a round-trip test above` asserts that every kind a chronicle carries can also be rebuilt from its row -- and projects deliberately cannot, because the rebuild exists for rooms older than schema v13 and projects arrive at v21. So the meta-test now excludes them explicitly, and the gap is pinned where it actually bites: `a project is only ever chronicled off the record` holds a project whose `GroupSignedEvent` is missing and shows the row is there and the chronicle is not. That is reachable -- `recordSignedEvents` catches its own failure so a ceremony that succeeded is not reported as failed -- and it is worth knowing is possible rather than worth preventing. `the rebuild alone reaches everything but the projects` pins the other side, so anyone adding a `toProjectEvent` fails a test that tells them where the comment explaining why is. Also corrected a stale count in the allowlist's own comment: it said six of thirteen nip30303 kinds reach a signing session while the list below it had eight. Eleven of sixteen now, with `SubmissionEvent` named among the exclusions since it is the envelope rather than the work. 1403 tests, no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9430396e5a
commit
5fac2fc7b7
5 files changed
+327
-5
No files matched your search
+25
-2
@@ -19,6 +19,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
|||||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||||
@@ -167,8 +170,8 @@ class ChronicleEvent(
|
|||||||
* ### What is missing from it, and why
|
* ### What is missing from it, and why
|
||||||
*
|
*
|
||||||
* **Only kinds the group actually signs can be here**, because a chronicle
|
* **Only kinds the group actually signs can be here**, because a chronicle
|
||||||
* that cannot be verified is one that has to be believed. Six of the
|
* that cannot be verified is one that has to be believed. Eleven of the
|
||||||
* thirteen nip30303 kinds reach a signing session; the rest travel as
|
* sixteen nip30303 kinds reach a signing session; the rest travel as
|
||||||
* rumors -- empty signature, member author, vouched for by the MLS frame
|
* rumors -- empty signature, member author, vouched for by the MLS frame
|
||||||
* they arrived in and by nothing that survives leaving it.
|
* they arrived in and by nothing that survives leaving it.
|
||||||
*
|
*
|
||||||
@@ -179,6 +182,8 @@ class ChronicleEvent(
|
|||||||
* - The contributor lists (30305, 30307, 30310). `applyInnerEvent` has
|
* - The contributor lists (30305, 30307, 30310). `applyInnerEvent` has
|
||||||
* no arm that writes a row for any of them, so chronicling them would
|
* no arm that writes a row for any of them, so chronicling them would
|
||||||
* cost bytes and restore nothing.
|
* cost bytes and restore nothing.
|
||||||
|
* - `SubmissionEvent` (30312), which is the envelope rather than the
|
||||||
|
* work: a chronicle carries payloads.
|
||||||
*
|
*
|
||||||
* Two kinds were on that list and are not any more, because the app
|
* Two kinds were on that list and are not any more, because the app
|
||||||
* stopped leaving them unsigned. `ArtifactVersionEvent` (30301) used to be
|
* stopped leaving them unsigned. `ArtifactVersionEvent` (30301) used to be
|
||||||
@@ -188,6 +193,10 @@ class ChronicleEvent(
|
|||||||
* and is now proposed to the group like everything else. Both are
|
* and is now proposed to the group like everything else. Both are
|
||||||
* therefore checkable, and a chronicle that left them out would hand a new
|
* therefore checkable, and a chronicle that left them out would hand a new
|
||||||
* member the whole structure and none of the prose.
|
* member the whole structure and none of the prose.
|
||||||
|
*
|
||||||
|
* The three project kinds (30330-30332) were signed from the day they
|
||||||
|
* existed, so they have no such story: they are here because a group's
|
||||||
|
* work includes what the work is for.
|
||||||
*/
|
*/
|
||||||
private val APPLY_ORDER: List<Kind> = listOf(
|
private val APPLY_ORDER: List<Kind> = listOf(
|
||||||
DialectEvent.KIND,
|
DialectEvent.KIND,
|
||||||
@@ -198,6 +207,20 @@ class ChronicleEvent(
|
|||||||
TranslationArtifactVersionEvent.KIND,
|
TranslationArtifactVersionEvent.KIND,
|
||||||
TranslationChapterEvent.KIND,
|
TranslationChapterEvent.KIND,
|
||||||
TranslationChunkEvent.KIND,
|
TranslationChunkEvent.KIND,
|
||||||
|
// Projects are last, and that is the whole of what this list says
|
||||||
|
// about them. A membership is a foreign key into both a project and
|
||||||
|
// a version, so it has to apply after everything it can name -- and
|
||||||
|
// appending gets that right by construction, since everything a
|
||||||
|
// project groups is already above it.
|
||||||
|
//
|
||||||
|
// Chroniclable where the contributor lists are not, for the opposite
|
||||||
|
// of their reason: these have an arm that writes a row, and they are
|
||||||
|
// signed rather than submitted, so a receiver can check them. A late
|
||||||
|
// member handed the whole library and no account of what any of it
|
||||||
|
// is for has been handed the smaller half.
|
||||||
|
ProjectEvent.KIND,
|
||||||
|
ProjectArtifactVersionEvent.KIND,
|
||||||
|
ProjectTranslationArtifactVersionEvent.KIND,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Every kind a chronicle may carry. */
|
/** Every kind a chronicle may carry. */
|
||||||
|
|||||||
+51
-1
@@ -19,6 +19,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
|||||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent
|
||||||
import press.mantra.compose.nostr.nip30303.SubmissionEvent
|
import press.mantra.compose.nostr.nip30303.SubmissionEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributorListEvent
|
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributorListEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||||
@@ -190,9 +193,56 @@ class ChronicleEventTest {
|
|||||||
TranslationArtifactVersionEvent.KIND,
|
TranslationArtifactVersionEvent.KIND,
|
||||||
TranslationChapterEvent.KIND,
|
TranslationChapterEvent.KIND,
|
||||||
TranslationChunkEvent.KIND,
|
TranslationChunkEvent.KIND,
|
||||||
|
ProjectEvent.KIND,
|
||||||
|
ProjectArtifactVersionEvent.KIND,
|
||||||
|
ProjectTranslationArtifactVersionEvent.KIND,
|
||||||
).forEach { assertTrue(ChronicleEvent.isChroniclable(it), "kind $it should be chroniclable") }
|
).forEach { assertTrue(ChronicleEvent.isChroniclable(it), "kind $it should be chroniclable") }
|
||||||
|
|
||||||
assertEquals(8, ChronicleEvent.CHRONICLABLE_KINDS.size)
|
assertEquals(11, ChronicleEvent.CHRONICLABLE_KINDS.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a membership applies after the project and the version it names`() {
|
||||||
|
// The only thing APPLY_ORDER has to get right about projects. A
|
||||||
|
// membership is a foreign key into both, so a page that applied one
|
||||||
|
// first would have the insert refused and the membership lost -- on the
|
||||||
|
// one device that most needs it, since a chronicle is what a member who
|
||||||
|
// joined late is given instead of having been there.
|
||||||
|
val project = ChronicleEvent.applyRank(ProjectEvent.KIND)!!
|
||||||
|
val source = ChronicleEvent.applyRank(ProjectArtifactVersionEvent.KIND)!!
|
||||||
|
val translation = ChronicleEvent.applyRank(ProjectTranslationArtifactVersionEvent.KIND)!!
|
||||||
|
|
||||||
|
assertTrue(source > project, "a source membership must apply after its project")
|
||||||
|
assertTrue(translation > project, "a translation membership must apply after its project")
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
source > ChronicleEvent.applyRank(ArtifactVersionEvent.KIND)!!,
|
||||||
|
"a source membership must apply after the version it names"
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
translation > ChronicleEvent.applyRank(TranslationArtifactVersionEvent.KIND)!!,
|
||||||
|
"a translation membership must apply after the translation it names"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a page of a whole project sorts into an order that will apply`() {
|
||||||
|
val shuffled = listOf(
|
||||||
|
payload(ProjectArtifactVersionEvent.KIND),
|
||||||
|
payload(ProjectEvent.KIND),
|
||||||
|
payload(ArtifactVersionEvent.KIND),
|
||||||
|
payload(ArtifactEvent.KIND),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
ArtifactEvent.KIND,
|
||||||
|
ArtifactVersionEvent.KIND,
|
||||||
|
ProjectEvent.KIND,
|
||||||
|
ProjectArtifactVersionEvent.KIND,
|
||||||
|
),
|
||||||
|
ChronicleEvent.inApplyOrder(shuffled).map { it.kind }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+34
-1
@@ -34,6 +34,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
|||||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||||
@@ -408,15 +411,45 @@ class ChronicleRoundTripTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kinds that reach a chronicle only off the record, never by rebuild.
|
||||||
|
*
|
||||||
|
* Everything else here has two sources, because a room whose work predates
|
||||||
|
* `GroupSignedEvent` at schema v13 has rows and no events, and the rebuild is
|
||||||
|
* what makes such a room whole. Projects arrive at v21, so no project row
|
||||||
|
* anywhere is in that position and a `toProjectEvent` would be new code for
|
||||||
|
* a mechanism with a removal checklist already written against it -- see
|
||||||
|
* `MantraProject` and docs/member-chronicle.md.
|
||||||
|
*
|
||||||
|
* The cost is real and is asserted rather than assumed:
|
||||||
|
* `ChronicleAssemblyJvmTest.a project is only ever chronicled off the record`
|
||||||
|
* is what happens when the record is missing.
|
||||||
|
*/
|
||||||
|
private val notRebuildable = setOf(
|
||||||
|
ProjectEvent.KIND,
|
||||||
|
ProjectArtifactVersionEvent.KIND,
|
||||||
|
ProjectTranslationArtifactVersionEvent.KIND,
|
||||||
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `every chroniclable kind has a round-trip test above`() {
|
fun `every chroniclable kind has a round-trip test above`() {
|
||||||
// The list and this file have to move together. A kind added to the
|
// The list and this file have to move together. A kind added to the
|
||||||
// allowlist without a case here is one nobody has checked can be rebuilt,
|
// allowlist without a case here is one nobody has checked can be rebuilt,
|
||||||
// and the failure is silent: the receiver drops it as a forgery.
|
// and the failure is silent: the receiver drops it as a forgery.
|
||||||
|
//
|
||||||
|
// Unless it is a kind with no rebuild at all, which is a decision rather
|
||||||
|
// than an omission -- see [notRebuildable]. Those are excluded here and
|
||||||
|
// pinned in `ChronicleAssemblyJvmTest` instead, so adding one is still a
|
||||||
|
// thing somebody has to write down.
|
||||||
assertEquals(
|
assertEquals(
|
||||||
8,
|
8,
|
||||||
ChronicleEvent.CHRONICLABLE_KINDS.size,
|
(ChronicleEvent.CHRONICLABLE_KINDS - notRebuildable).size,
|
||||||
"a chroniclable kind was added or removed; add or remove its round-trip case"
|
"a chroniclable kind was added or removed; add or remove its round-trip case"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
notRebuildable.all { it in ChronicleEvent.CHRONICLABLE_KINDS },
|
||||||
|
"a kind cannot be exempted from the rebuild unless it is chroniclable at all"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,7 @@ import press.mantra.compose.database.MantraDatabase
|
|||||||
import press.mantra.compose.database.builder.getRoomDatabase
|
import press.mantra.compose.database.builder.getRoomDatabase
|
||||||
import press.mantra.compose.database.model.ChatMessage
|
import press.mantra.compose.database.model.ChatMessage
|
||||||
import press.mantra.compose.database.model.ChatRoom
|
import press.mantra.compose.database.model.ChatRoom
|
||||||
|
import press.mantra.compose.database.model.GroupSignedEvent
|
||||||
import press.mantra.compose.database.model.MarmotInnerEvent
|
import press.mantra.compose.database.model.MarmotInnerEvent
|
||||||
import press.mantra.compose.database.model.MarmotKeyPackage
|
import press.mantra.compose.database.model.MarmotKeyPackage
|
||||||
import press.mantra.compose.database.model.NostrEvent
|
import press.mantra.compose.database.model.NostrEvent
|
||||||
@@ -44,6 +45,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
|||||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||||
@@ -307,6 +311,43 @@ class ChronicleApplyJvmTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// What the work is for. Unlike everything above it, a project has to be
|
||||||
|
// *recorded* as well as applied to reach a chronicle: there is no
|
||||||
|
// `toProjectEvent`, so the deprecated rebuild cannot produce one, and
|
||||||
|
// the record is its only source. That is deliberate -- the rebuild
|
||||||
|
// serves rooms older than schema v13 and projects arrive at v21 -- and
|
||||||
|
// it is why `record` appears here and nowhere else in this seed.
|
||||||
|
val project = signed(
|
||||||
|
ProjectEvent.build(
|
||||||
|
name = "In Detention into isiZulu",
|
||||||
|
description = "The first chapter, with its back-translation.",
|
||||||
|
creatorPublicKey = oldMember,
|
||||||
|
ownerPublicKeys = setOf(chatRoomId),
|
||||||
|
createdAt = 1_700_000_070L,
|
||||||
|
)
|
||||||
|
).also { apply(sender, it) }
|
||||||
|
|
||||||
|
val sourceMembership = signed(
|
||||||
|
ProjectArtifactVersionEvent.membershipsOf(project, listOf(version.id)).single()
|
||||||
|
).also { apply(sender, it) }
|
||||||
|
|
||||||
|
val translationMembership = signed(
|
||||||
|
ProjectTranslationArtifactVersionEvent
|
||||||
|
.membershipsOf(project, listOf(translationVersion.id))
|
||||||
|
.single()
|
||||||
|
).also { apply(sender, it) }
|
||||||
|
|
||||||
|
sender.groupSignedEventDao().recordAll(
|
||||||
|
listOf(project, sourceMembership, translationMembership).map {
|
||||||
|
GroupSignedEvent.fromEvent(
|
||||||
|
event = it,
|
||||||
|
chatRoomId = chatRoomId,
|
||||||
|
derivationPath = SharedKeyDerivation.formatPath(),
|
||||||
|
frostSigningSessionId = "session-1",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+176
-1
@@ -37,6 +37,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
|||||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectEvent
|
||||||
|
import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||||
@@ -297,8 +300,40 @@ class ChronicleAssemblyJvmTest {
|
|||||||
createdAt = 1_700_000_060L
|
createdAt = 1_700_000_060L
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// What the work is for. Seeded here rather than in the tests that are
|
||||||
|
// about projects, because `a chronicle holds every kind the group
|
||||||
|
// signed` compares what this room produces against CHRONICLABLE_KINDS
|
||||||
|
// itself -- so a kind added to that set and not to this seed fails three
|
||||||
|
// tests at once, which is the point of writing it that way.
|
||||||
|
//
|
||||||
|
// Proposed the way the form will propose it: the project leading, its
|
||||||
|
// memberships taking the id and the timestamp it comes back with.
|
||||||
|
seededProject = apply(
|
||||||
|
ProjectEvent.build(
|
||||||
|
name = "In Detention into isiZulu",
|
||||||
|
description = "The first chapter, with its back-translation.",
|
||||||
|
creatorPublicKey = newMember,
|
||||||
|
ownerPublicKeys = setOf(chatRoomId),
|
||||||
|
createdAt = 1_700_000_070L,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
apply(
|
||||||
|
ProjectArtifactVersionEvent
|
||||||
|
.membershipsOf(seededProject!!, listOf(version.id))
|
||||||
|
.single()
|
||||||
|
)
|
||||||
|
apply(
|
||||||
|
ProjectTranslationArtifactVersionEvent
|
||||||
|
.membershipsOf(seededProject!!, listOf(translationVersion.id))
|
||||||
|
.single()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The project [seedSignedWork] signed, for the tests that are about it. */
|
||||||
|
private var seededProject: Event? = null
|
||||||
|
|
||||||
private suspend fun chronicledPayloads(): List<Event> =
|
private suspend fun chronicledPayloads(): List<Event> =
|
||||||
ChronicleManager.assemble(db, chatRoomId, newMember)
|
ChronicleManager.assemble(db, chatRoomId, newMember)
|
||||||
.flatMap { assertNotNull(ChronicleEvent.decodePage(it.content)) }
|
.flatMap { assertNotNull(ChronicleEvent.decodePage(it.content)) }
|
||||||
@@ -321,6 +356,12 @@ class ChronicleAssemblyJvmTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `a chronicle holds every kind the group signed, and each of them once`() = runBlocking {
|
fun `a chronicle holds every kind the group signed, and each of them once`() = runBlocking {
|
||||||
seedSignedWork()
|
seedSignedWork()
|
||||||
|
// On file, which is what a room signing work today looks like. Without
|
||||||
|
// this the chronicle is assembled entirely by the deprecated rebuild,
|
||||||
|
// which reaches eight of the eleven kinds -- it has no `toProjectEvent`
|
||||||
|
// and deliberately never will. That path is still covered, by
|
||||||
|
// `work held both ways travels exactly once` and the two below it.
|
||||||
|
recordEverythingApplied()
|
||||||
|
|
||||||
val byKind = chronicledPayloads().groupBy { it.kind }
|
val byKind = chronicledPayloads().groupBy { it.kind }
|
||||||
|
|
||||||
@@ -332,10 +373,33 @@ class ChronicleAssemblyJvmTest {
|
|||||||
assertEquals(1, byKind[TranslationArtifactVersionEvent.KIND]?.size)
|
assertEquals(1, byKind[TranslationArtifactVersionEvent.KIND]?.size)
|
||||||
assertEquals(1, byKind[TranslationChapterEvent.KIND]?.size)
|
assertEquals(1, byKind[TranslationChapterEvent.KIND]?.size)
|
||||||
assertEquals(1, byKind[TranslationChunkEvent.KIND]?.size)
|
assertEquals(1, byKind[TranslationChunkEvent.KIND]?.size)
|
||||||
|
assertEquals(1, byKind[ProjectEvent.KIND]?.size)
|
||||||
|
assertEquals(1, byKind[ProjectArtifactVersionEvent.KIND]?.size)
|
||||||
|
assertEquals(1, byKind[ProjectTranslationArtifactVersionEvent.KIND]?.size)
|
||||||
|
|
||||||
assertEquals(ChronicleEvent.CHRONICLABLE_KINDS, byKind.keys)
|
assertEquals(ChronicleEvent.CHRONICLABLE_KINDS, byKind.keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the rebuild alone reaches everything but the projects`() = runBlocking {
|
||||||
|
// The same room with nothing on file, which is the deprecated path in
|
||||||
|
// isolation. Pinned rather than left implicit: if somebody adds a
|
||||||
|
// `toProjectEvent` this fails, and the comment on `MantraProject` says
|
||||||
|
// why they should not.
|
||||||
|
seedSignedWork()
|
||||||
|
|
||||||
|
val kinds = chronicledPayloads().map { it.kind }.toSet()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ChronicleEvent.CHRONICLABLE_KINDS - setOf(
|
||||||
|
ProjectEvent.KIND,
|
||||||
|
ProjectArtifactVersionEvent.KIND,
|
||||||
|
ProjectTranslationArtifactVersionEvent.KIND,
|
||||||
|
),
|
||||||
|
kinds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a chronicle is in dependency order across its whole length`() = runBlocking {
|
fun `a chronicle is in dependency order across its whole length`() = runBlocking {
|
||||||
seedSignedWork()
|
seedSignedWork()
|
||||||
@@ -451,7 +515,24 @@ class ChronicleAssemblyJvmTest {
|
|||||||
|
|
||||||
// Half the batch on file, the other half only as rows -- which is what
|
// Half the batch on file, the other half only as rows -- which is what
|
||||||
// a room that was upgraded partway through its life looks like.
|
// a room that was upgraded partway through its life looks like.
|
||||||
record(*applied.take(applied.size / 2).toTypedArray())
|
//
|
||||||
|
// The projects are on file whichever half they fall in, because the
|
||||||
|
// rebuild cannot reach them: there is no `toProjectEvent`, deliberately.
|
||||||
|
// That is not a gap in this scenario, it is the scenario being
|
||||||
|
// impossible for them -- the rebuild serves rooms whose work predates
|
||||||
|
// `GroupSignedEvent` at v13, and projects arrive at v21. See
|
||||||
|
// `a project is only ever chronicled off the record` for the edge that
|
||||||
|
// does follow from it.
|
||||||
|
val projectKinds = setOf(
|
||||||
|
ProjectEvent.KIND,
|
||||||
|
ProjectArtifactVersionEvent.KIND,
|
||||||
|
ProjectTranslationArtifactVersionEvent.KIND,
|
||||||
|
)
|
||||||
|
val rebuildable = applied.filterNot { it.kind in projectKinds }
|
||||||
|
record(
|
||||||
|
*(rebuildable.take(rebuildable.size / 2) +
|
||||||
|
applied.filter { it.kind in projectKinds }).toTypedArray()
|
||||||
|
)
|
||||||
|
|
||||||
val payloads = chronicledPayloads()
|
val payloads = chronicledPayloads()
|
||||||
|
|
||||||
@@ -749,4 +830,98 @@ class ChronicleAssemblyJvmTest {
|
|||||||
chronicledTranslations().map { it.id }.toSet()
|
chronicledTranslations().map { it.id }.toSet()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Projects ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a room's projects travel in its chronicle`() = runBlocking {
|
||||||
|
seedSignedWork()
|
||||||
|
recordEverythingApplied()
|
||||||
|
val project = assertNotNull(seededProject)
|
||||||
|
|
||||||
|
val payloads = chronicledPayloads()
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
payloads.any { it.kind == ProjectEvent.KIND && it.id == project.id },
|
||||||
|
"a late member should be told what the room's work is for"
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
payloads.count { it.kind == ProjectArtifactVersionEvent.KIND },
|
||||||
|
"the project's source version should travel with it"
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
payloads.count { it.kind == ProjectTranslationArtifactVersionEvent.KIND },
|
||||||
|
"the project's translation should travel with it"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a chronicled project arrives in an order that applies`() = runBlocking {
|
||||||
|
seedSignedWork()
|
||||||
|
recordEverythingApplied()
|
||||||
|
|
||||||
|
val kinds = chronicledPayloads().map { it.kind }
|
||||||
|
|
||||||
|
// The assertion that matters, and the reason projects went on the end of
|
||||||
|
// APPLY_ORDER rather than anywhere tidier: both memberships are foreign
|
||||||
|
// keys into the project and into a version, so either arriving first
|
||||||
|
// would be refused -- silently, on the one device that has nothing else
|
||||||
|
// to fall back on.
|
||||||
|
val project = kinds.indexOf(ProjectEvent.KIND)
|
||||||
|
assertTrue(project >= 0, "the project is in the chronicle")
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
kinds.indexOf(ProjectArtifactVersionEvent.KIND) > project &&
|
||||||
|
kinds.indexOf(ProjectTranslationArtifactVersionEvent.KIND) > project,
|
||||||
|
"memberships must come after their project"
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
kinds.indexOf(ArtifactVersionEvent.KIND) <
|
||||||
|
kinds.indexOf(ProjectArtifactVersionEvent.KIND),
|
||||||
|
"a membership must come after the version it names"
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
kinds.indexOf(TranslationArtifactVersionEvent.KIND) <
|
||||||
|
kinds.indexOf(ProjectTranslationArtifactVersionEvent.KIND),
|
||||||
|
"a membership must come after the translation it names"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one thing "no `toProjectEvent`" costs, stated rather than discovered.
|
||||||
|
*
|
||||||
|
* Every other kind in a chronicle has two sources: the event as the group
|
||||||
|
* signed it, and a rebuild from the row when the signed event predates the
|
||||||
|
* table that keeps them. Projects have only the first, because that rebuild
|
||||||
|
* exists for rooms older than schema v13 and projects arrive at v21 -- there
|
||||||
|
* is no project row anywhere whose event was never recorded.
|
||||||
|
*
|
||||||
|
* Except by accident: `recordSignedEvents` catches its own failure and lets
|
||||||
|
* the session complete, correctly, since the signature is valid whether or
|
||||||
|
* not this device could write it down. A project applied through that gap is
|
||||||
|
* a row this device can show and cannot chronicle, and it will not come
|
||||||
|
* back -- which is worth knowing is possible rather than worth preventing,
|
||||||
|
* since the alternative is failing a ceremony that succeeded.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a project is only ever chronicled off the record`() = runBlocking {
|
||||||
|
seedSignedWork()
|
||||||
|
|
||||||
|
// Everything on file except the project, which is the shape that gap
|
||||||
|
// leaves behind.
|
||||||
|
record(*applied.filterNot { it.kind == ProjectEvent.KIND }.toTypedArray())
|
||||||
|
|
||||||
|
val project = assertNotNull(seededProject)
|
||||||
|
|
||||||
|
assertNotNull(
|
||||||
|
db.mantraProjectDao().getProjectById(project.id),
|
||||||
|
"the row is here either way"
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
chronicledPayloads().none { it.id == project.id },
|
||||||
|
"and without its signed event there is nothing to chronicle"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user