From c66f085681a241e8c03a7a526995b3ac5c47d969 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 16:06:17 +0200 Subject: [PATCH 1/2] refactor: deprecate the row rebuild, and write down what goes with it `assemble` reads `GroupSignedEvent` now and rebuilds from `Mantra*` rows only what that table does not hold, which is work signed before it existed. The rebuild is therefore on its way out rather than merely second in line, and this says so where a reader will actually meet it -- at the call site, from the compiler -- instead of only in a paragraph they have to find first. **Nine `@Deprecated` markers, and they are load-bearing as documentation.** The eight `toXEvent()` methods and `ArchiveManager.rebuiltEventsOf`, each carrying the same sentence: this is the fallback for pre-v13 work, read the event off the table instead, and it goes when the last such install does. That raises nine warnings in `commonMain` today, all of them inside the walk itself, so the deprecation is visible in every build without anything failing over it. The level is `WARNING` deliberately -- the code is still called, still correct, and still the only thing standing between an older room and an empty archive. **The checklist is a new section in docs/member-archive.md**, because the interesting part of this removal is not the eight methods, it is everything around them that is easy to take out by association or leave behind by accident. *What goes*: the walk and the version-label recovery inside it, the union in `signedEventsOf`, the eight rebuilds, and `ArchiveRoundTripTest` entire -- all ten cases, which exist to hold the rebuild up and cover nothing else. Its own header still opened with "signed events are not stored as events", which stopped being true two commits ago, so it now says what it is: the gate on a deprecated fallback, deleted with what it guards. *Two already-dead cousins to sweep at the same time*, named because they will look like part of the rebuild to whoever does the removal and are not: `MantraTranslation.toTranslationEvent`, which nothing has ever called, and `MantraTranslationChunkProposal.toTranslationChunkEvent`, on a model that is not even a `@Database` entity. *The tests that seed without recording*: in `ArchiveAssemblyJvmTest` the `apply`-only seeding **is** the rebuild path, and two of its cases are about the union specifically and mean nothing without it. `ArchiveApplyJvmTest` seeds its sender the same way but is testing delivery rather than assembly, so it needs the recording call *added* -- otherwise it quietly starts asserting against an empty archive, which is the same silent-success failure this whole feature is about. **What only looks like it goes, which is the half worth writing down.** The `isArchivable` filter in `signedEventsOf` is not part of the rebuild and becomes the only thing standing. It is there *because* of the record: the walk could only ever produce document kinds, so nothing needed filtering while it was the source, and the table holds every kind the group has signed -- starting with the `GroupKeyStateEvent` every room signs as its first act. Dropping it with the walk turns every room's archive into an `IllegalArgumentException` from `ArchiveEvent.build`. Two cases fail with exactly that if it goes, which is the guard against removing it by association rather than by decision. The verify filter in `assemble` stays too. With the rebuild gone it checks events that were verified before they were recorded, so it cannot fail in practice -- which is the argument for keeping it, not against. "Cannot happen" is the state it exists to preserve. `Mantra*.signature` and `Mantra*.publicKey` are explicitly *not* on the list. They were what made a row rebuildable, and since v13 `groupSignedEventId` says whether the group signed a row and points at the proof -- so they are arguably redundant. But four test files assert on them and `MantraTranslationContributor` builds a contributor list out of one, and it is a twelve-table migration with its own tests to rewrite. It should be decided on its own merits, not ride along. **The precondition cannot be checked, and the section says so plainly.** No query answers "does any install still hold pre-v13 work" -- a device that upgraded is indistinguishable from one that never had any, and the rows that need rebuilding are on other people's devices. What is observable is the `signedEventsOf` log line, which fires only when the rebuild actually contributed something; fleet-wide silence is evidence and not proof. The cost of getting it wrong is named as well, because it is not loud: the member keeps their own rows and reads the room normally, and only loses the ability to *answer* a request with the older half of the group's work -- so a newer member asks, is answered, and receives an archive that is quietly short. No behaviour change. 495 jvm tests and 297 android unit tests pass. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/MantraArtifact.kt | 5 + .../database/model/MantraArtifactVersion.kt | 5 + .../compose/database/model/MantraChapter.kt | 5 + .../compose/database/model/MantraChunk.kt | 5 + .../compose/database/model/MantraDialect.kt | 5 + .../model/MantraTranslationArtifactVersion.kt | 5 + .../model/MantraTranslationChapter.kt | 5 + .../database/model/MantraTranslationChunk.kt | 5 + .../mantra/compose/managers/ArchiveManager.kt | 17 +++- .../nostr/archive/ArchiveRoundTripTest.kt | 30 +++--- docs/member-archive.md | 96 +++++++++++++++++++ 11 files changed, 170 insertions(+), 13 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt index 38842414..77f31015 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt @@ -87,6 +87,11 @@ data class MantraArtifact( * wrong here until an archive needed to rebuild an artifact and nothing had * ever called this. */ + @Deprecated( + "Archive fallback for work signed before the GroupSignedEvent table. Read the " + + "event off that table instead; this rebuild goes when the last pre-v13 install " + + "does -- see the removal checklist in docs/member-archive.md." + ) fun toArtifactEvent(versionLabel: String): ArtifactEvent { return ArtifactEvent( id = id, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt index 46c2abe8..3ad97588 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt @@ -66,6 +66,11 @@ data class MantraArtifactVersion( override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt ): OptionalNostrEventEntity, TimestampedEntity { + @Deprecated( + "Archive fallback for work signed before the GroupSignedEvent table. Read the " + + "event off that table instead; this rebuild goes when the last pre-v13 install " + + "does -- see the removal checklist in docs/member-archive.md." + ) fun toArtifactVersionEvent(): ArtifactVersionEvent { return ArtifactVersionEvent( id = id, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChapter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChapter.kt index a12f015e..ca9f5920 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChapter.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChapter.kt @@ -71,6 +71,11 @@ data class MantraChapter( override val updatedAt: Instant = createdAt ): OptionalNostrEventEntity, TimestampedEntity { + @Deprecated( + "Archive fallback for work signed before the GroupSignedEvent table. Read the " + + "event off that table instead; this rebuild goes when the last pre-v13 install " + + "does -- see the removal checklist in docs/member-archive.md." + ) fun toChapterEvent(): ChapterEvent { return ChapterEvent( id = id, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt index ab7439cf..fee9571a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt @@ -67,6 +67,11 @@ data class MantraChunk( override val updatedAt: Instant = createdAt ): OptionalNostrEventEntity, TimestampedEntity { + @Deprecated( + "Archive fallback for work signed before the GroupSignedEvent table. Read the " + + "event off that table instead; this rebuild goes when the last pre-v13 install " + + "does -- see the removal checklist in docs/member-archive.md." + ) fun toChunkEvent(): ChunkEvent { return ChunkEvent( id = id, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt index a7fef77a..359b9d3a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt @@ -56,6 +56,11 @@ data class MantraDialect( override val updatedAt: Instant = createdAt ): OptionalNostrEventEntity, TimestampedEntity { + @Deprecated( + "Archive fallback for work signed before the GroupSignedEvent table. Read the " + + "event off that table instead; this rebuild goes when the last pre-v13 install " + + "does -- see the removal checklist in docs/member-archive.md." + ) fun toDialectEvent(): DialectEvent { return DialectEvent( id = id, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt index 9b9be98c..109225a1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt @@ -75,6 +75,11 @@ data class MantraTranslationArtifactVersion( override val updatedAt: Instant = createdAt ): OptionalNostrEventEntity, TimestampedEntity { + @Deprecated( + "Archive fallback for work signed before the GroupSignedEvent table. Read the " + + "event off that table instead; this rebuild goes when the last pre-v13 install " + + "does -- see the removal checklist in docs/member-archive.md." + ) fun toTranslationArtifactVersionEvent(): TranslationArtifactVersionEvent { return TranslationArtifactVersionEvent( id = id, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChapter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChapter.kt index 0cd2df48..41f5eee8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChapter.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChapter.kt @@ -70,6 +70,11 @@ data class MantraTranslationChapter( override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt ): OptionalNostrEventEntity, TimestampedEntity { + @Deprecated( + "Archive fallback for work signed before the GroupSignedEvent table. Read the " + + "event off that table instead; this rebuild goes when the last pre-v13 install " + + "does -- see the removal checklist in docs/member-archive.md." + ) fun toTranslationChapterEvent(): TranslationChapterEvent { return TranslationChapterEvent( id = id, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChunk.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChunk.kt index b74380b5..7ed2b2a8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChunk.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChunk.kt @@ -72,6 +72,11 @@ data class MantraTranslationChunk( override val updatedAt: Instant = createdAt ): OptionalNostrEventEntity, TimestampedEntity { + @Deprecated( + "Archive fallback for work signed before the GroupSignedEvent table. Read the " + + "event off that table instead; this rebuild goes when the last pre-v13 install " + + "does -- see the removal checklist in docs/member-archive.md." + ) fun toTranslationChunkEvent(): TranslationChunkEvent { return TranslationChunkEvent( id = id, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt index a3659d44..9203a751 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt @@ -42,8 +42,10 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent * signed; `ArchiveRoundTripTest` 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. The fallback can go once no install still holds - * pre-v13 work. + * 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-archive.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 @@ -662,7 +664,18 @@ object ArchiveManager { * * 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-archive.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( + "Archive fallback for work signed before the GroupSignedEvent table. " + + "Goes when the last pre-v13 install does -- see the removal checklist " + + "in docs/member-archive.md." + ) private suspend fun rebuiltEventsOf( database: MantraDatabase, chatRoomId: String, diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt index d6f274b3..0ef6cdca 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt @@ -39,22 +39,30 @@ import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent /** - * The assumption the whole archive rests on: a row can be turned back into the - * event the group signed. + * The assumption the archive's *fallback* rests on: a row can be turned back + * into the event the group signed. * - * Signed events are not stored as events. `FrostSigningManager.complete` applies - * them and what survives is a `Mantra*` row, so an archive has to rebuild each - * one with `toXEvent()` and hope it comes out byte-identical. If it does not, - * the id changes, the signature no longer covers it, and every receiver drops - * the payload as a forgery -- silently, one kind at a time. + * Signed events used to be stored only as rows. `FrostSigningManager.complete` + * applied one and what survived was a `Mantra*` row, so an archive had to + * rebuild each payload with `toXEvent()` and hope it came out byte-identical -- + * and if it did not, the id changed, the signature no longer covered it, and + * every receiver dropped the payload as a forgery, silently, one kind at a time. * * That is what the round-trip note on each `toXEvent` claims and what nothing - * asserted until now. Every kind in `ArchiveEvent.ARCHIVABLE_KINDS` is checked - * here against a real FROST quorum, because a signature that verifies is the - * only evidence that the rebuild is faithful -- comparing fields would only + * asserted until this ran. Every kind in `ArchiveEvent.ARCHIVABLE_KINDS` is + * checked here against a real FROST quorum, because a signature that verifies is + * the only evidence that the rebuild is faithful -- comparing fields would only * prove the test and the code agree about which fields matter. * - * A kind that fails here cannot be archived at all, whatever the allowlist says. + * ### Deprecated, along with what it guards + * + * `GroupSignedEvent` keeps the events now, and `ArchiveManager.assemble` reads + * them; the rebuild survives only for work signed before that table existed, and + * so does this. Nothing else covers the `toXEvent()` methods, so **this whole + * file goes when they do** -- see "Retiring the rebuild" in + * docs/member-archive.md. Until then a kind that fails here cannot be archived + * *from a row*, whatever the allowlist says, which for a pre-v13 room is the + * same thing as not at all. */ class ArchiveRoundTripTest { private val participants = 3 diff --git a/docs/member-archive.md b/docs/member-archive.md index 5eb50732..0551394b 100644 --- a/docs/member-archive.md +++ b/docs/member-archive.md @@ -717,6 +717,102 @@ content. It buys graceful degradation and costs everything listed under --- +## Retiring the rebuild + +`assemble` reads `GroupSignedEvent` and rebuilds from `Mantra*` rows only what +that table does not hold, which by now is work signed before the table existed. +Everything on the rebuild side is marked `@Deprecated` so the compiler names it +at every call site, and it comes out in one piece rather than a method at a time +-- a half-removed rebuild is a rebuild that covers some kinds and silently drops +others. + +### The one precondition + +**No install still holds work signed before schema v13.** Nothing in the app can +check this, and no query answers it: a device that upgraded is indistinguishable +from one that never had pre-v13 work, and the rows that need rebuilding are on +*other people's* devices. It is a judgement about the installed base, not a +condition to test for. What can be checked, on any given device, is that the +rebuild is contributing nothing: + +``` +Archiving : N event(s) as the group signed them, M rebuilt from rows that predate the record +``` + +`ArchiveManager.signedEventsOf` logs that line only when `M > 0`. Silence across +the fleet is the evidence; it is not proof. + +A member whose device still needs it and does not get it is not broken loudly. +They keep their own rows and read the room normally. What they lose is the +ability to *answer* an archive request with the older half of the group's work, +so a newer member asks, is answered, and receives an archive that is quietly +short. That is the failure mode to weigh -- it looks like success on both ends. + +### What goes + +| what | where | +|---|---| +| `rebuiltEventsOf` | [ArchiveManager.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt) -- the tree walk, and the version-label recovery inside it | +| the union in `signedEventsOf` | same file -- it collapses to the `GroupSignedEvent` read plus the `isArchivable` filter, which **stays**: see below | +| `MantraDialect.toDialectEvent` | [MantraDialect.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt) | +| `MantraArtifact.toArtifactEvent` | [MantraArtifact.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt) -- and with it the `versionLabel` parameter that exists only because the label is not on the row | +| `MantraArtifactVersion.toArtifactVersionEvent` | [MantraArtifactVersion.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt) | +| `MantraChapter.toChapterEvent` | [MantraChapter.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChapter.kt) | +| `MantraChunk.toChunkEvent` | [MantraChunk.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt) | +| `MantraTranslationArtifactVersion.toTranslationArtifactVersionEvent` | [MantraTranslationArtifactVersion.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt) | +| `MantraTranslationChapter.toTranslationChapterEvent` | [MantraTranslationChapter.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChapter.kt) | +| `MantraTranslationChunk.toTranslationChunkEvent` | [MantraTranslationChunk.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChunk.kt) | +| `ArchiveRoundTripTest`, all ten cases | [ArchiveRoundTripTest.kt](../composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt) -- it exists to hold the rebuild up and covers nothing else | +| the "Where the events come from" reasoning above | this file | + +**Two already-dead cousins to sweep at the same time**, neither of which is part +of the rebuild and both of which will look like it to whoever does the removal: +`MantraTranslation.toTranslationEvent` (nothing has ever called it -- 30311 is +not archivable and nothing builds one) and +`MantraTranslationChunkProposal.toTranslationChunkEvent` (on a model that is not +even a `@Database` entity). See [dead-code.md](./dead-code.md) for the house +style on writing those down rather than deleting them blind. + +**The tests that seed without recording go too**, or they go on proving a path +that no longer exists. In `ArchiveAssemblyJvmTest` the `apply`-only seeding is +the rebuild path and `recordEverythingApplied` is the real one; the cases named +*work held both ways travels exactly once* and *an artifact the rebuild has to +leave out still archives from the record* are about the union specifically and +have no meaning without it. `ArchiveApplyJvmTest` seeds the sender the same way, +so it needs the recording call added rather than removed -- it is testing +delivery, not assembly, and would otherwise start asserting against an empty +archive. + +### What only looks like it goes + +**The `isArchivable` filter in `signedEventsOf` stays, and becomes the only +thing standing.** It is not part of the rebuild; it is there *because* of the +record. The rebuild could only ever produce document kinds, so nothing needed +filtering while it was the source. The table holds every kind the group has +signed, and every room signs a `GroupKeyStateEvent` as its first act -- so +removing that filter along with the walk turns every room's archive into an +`IllegalArgumentException` from `ArchiveEvent.build`. Two cases in +`ArchiveAssemblyJvmTest` fail with exactly that if it is dropped, which is the +guard against removing it by association. + +**The verify filter in `assemble` stays.** With the rebuild gone it is checking +events that were verified before they were recorded, so it can never fail in +practice -- which is an argument for keeping it, not for dropping it. It is one +signature check standing between a corrupted row and a payload every receiver +reads as a forgery, and "cannot happen" is the state it is meant to preserve. + +**`Mantra*.signature` and `Mantra*.publicKey` are not obviously removable, and +are a separate decision.** They were what made a row rebuildable, but they are +also what `SignedArtifactTest`, `SignedChapterTest`, `SignedGroupKeyStateTest` +and `ArchiveApplyJvmTest.rowFingerprints` assert on, and +`MantraTranslationContributor` builds a contributor list out of one. Since v13, +`groupSignedEventId` says whether the group signed a row and points at the proof, +so the columns are arguably redundant -- but that is a schema migration across +twelve tables with its own tests to rewrite, and it should not ride along with +this. + +--- + ## What this does not do Each of these will be reported as a bug. None of them is. From ea11e8b233f57e51960a51a4108f976a98e3d9d0 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 16:27:36 +0200 Subject: [PATCH 2/2] refactor: call it a chronicle, and keep "archive" for what a user does to a chat Archiving a chat is an ordinary thing a user will want to do to a conversation, and it is not this. This is the group's signed record, handed to a member who joined after the work was done so their room stops being empty. Two unrelated meanings of one word in one app is a bug waiting to be written, and `ChatRoom.archiveRequestedAt` is exactly where they would have met: a column on the chat row, named for the thing that is not the chat. So the whole feature is Chronicle now -- `press.mantra.compose.nostr.chronicle`, `ChronicleEvent` (30327), `ChronicleRequestEvent` (30328), the three tags, `ChronicleManager`, `docs/member-chronicle.md`. The kind numbers do not move; only the words do. **The wire tags move too**, `archiveId` -> `chronicleId` and `archivePage` -> `chroniclePage`, which is free exactly once. Both kinds are new and there is no old build to stay compatible with -- the design note says so in as many words -- so the alternative was carrying the old spelling on the wire forever to save a rename that costs nothing today. The recipient tag stays `p`; it was never ours. **Schema v14, because two things had the old word written into stored data.** `ChatRoom.archiveRequestedAt` becomes `chronicleRequestedAt`, renamed rather than dropped and re-added: while it is set it is the only record that a device with an empty room has already asked the group for its history, and a device that lost it mid-flight would ask again on its next launch, and the one after that. The three `ChatMessage.messageType` strings become their `chronicle*` spellings, rewritten rather than left to a legacy constant the way `dkgApprovalNeeded` was. These lines cannot be regenerated -- a chronicle is announced once, when it is requested, sent and applied -- and an unrecognised type is not skipped by the transcript. It renders as an ordinary chat bubble, so "Caught up on 12 items" would come back attributed to a member as something they said. `MIGRATION_13_14` does both, because Room can rename a column and cannot rewrite rows in the same breath. `ALTER TABLE ... RENAME COLUMN` needs SQLite 3.25, which `getRoomDatabase` guarantees by pinning `BundledSQLiteDriver`, and the column is in no index, no foreign key, and there is not a view or trigger in the database -- so nothing has to move with it. Five tests hold the two halves apart: the value survives, the column keeps its position, a room that never asked still reads as never having asked, the three types are rewritten, and every other type is left alone. **`isArchivable` is `isChroniclable`**, on the "recyclable" pattern, and it keeps its job unchanged: the allowlist that stands between a replayed `GroupKeyStateEvent` and the apply path. No behaviour change beyond the rename. 797 tests pass. Co-Authored-By: Claude Opus 5 --- .../14.json | 5637 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 24 +- .../builder/PlatformDatabaseBuilder.kt | 10 +- .../database/dao/GroupSignedEventDao.kt | 4 +- .../compose/database/dao/MarmotOutboundDao.kt | 8 +- .../migrations/ChronicleRenameMigration.kt | 60 + .../compose/database/model/ChatMessage.kt | 44 +- .../mantra/compose/database/model/ChatRoom.kt | 8 +- .../database/model/GroupSignedEvent.kt | 8 +- .../compose/database/model/MantraArtifact.kt | 4 +- .../database/model/MantraArtifactVersion.kt | 2 +- .../repository/DatabaseChatRepository.kt | 4 +- ...{ArchiveManager.kt => ChronicleManager.kt} | 150 +- .../nostr/archive/tags/ArchiveIdTag.kt | 40 - .../ChronicleEvent.kt} | 90 +- .../ChronicleRequestEvent.kt} | 16 +- .../nostr/chronicle/tags/ChronicleIdTag.kt | 40 + .../tags/ChroniclePageTag.kt} | 26 +- .../tags/ChronicleRecipientTag.kt} | 20 +- .../compose/nostr/frost/GroupKeyStateEvent.kt | 2 +- .../compose/nostr/nip30303/SubmissionEvent.kt | 2 +- .../compose/repository/ChatRepository.kt | 2 +- .../ui/view/model/ChatMessageListViewModel.kt | 10 +- .../compose/managers/GroupKeyStateTest.kt | 10 +- .../ChronicleEventTest.kt} | 198 +- .../ChronicleRoundTripTest.kt} | 22 +- .../dao/GroupSignedEventDaoJvmTest.kt | 4 +- .../ChronicleRenameMigrationJvmTest.kt | 193 + ...plyJvmTest.kt => ChronicleApplyJvmTest.kt} | 190 +- ...JvmTest.kt => ChronicleAssemblyJvmTest.kt} | 100 +- .../managers/SignedGroupKeyStateTest.kt | 2 +- docs/README.md | 4 +- ...{member-archive.md => member-chronicle.md} | 178 +- 33 files changed, 6506 insertions(+), 606 deletions(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/14.json create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/ChronicleRenameMigration.kt rename composeApp/src/commonMain/kotlin/press/mantra/compose/managers/{ArchiveManager.kt => ChronicleManager.kt} (85%) delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/tags/ArchiveIdTag.kt rename composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/{archive/ArchiveEvent.kt => chronicle/ChronicleEvent.kt} (79%) rename composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/{archive/ArchiveRequestEvent.kt => chronicle/ChronicleRequestEvent.kt} (83%) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/tags/ChronicleIdTag.kt rename composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/{archive/tags/ArchivePageTag.kt => chronicle/tags/ChroniclePageTag.kt} (63%) rename composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/{archive/tags/ArchiveRecipientTag.kt => chronicle/tags/ChronicleRecipientTag.kt} (68%) rename composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/{archive/ArchiveEventTest.kt => chronicle/ChronicleEventTest.kt} (64%) rename composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/{archive/ArchiveRoundTripTest.kt => chronicle/ChronicleRoundTripTest.kt} (95%) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/ChronicleRenameMigrationJvmTest.kt rename composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/{ArchiveApplyJvmTest.kt => ChronicleApplyJvmTest.kt} (84%) rename composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/{ArchiveAssemblyJvmTest.kt => ChronicleAssemblyJvmTest.kt} (85%) rename docs/{member-archive.md => member-chronicle.md} (83%) diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/14.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/14.json new file mode 100644 index 00000000..b97093c1 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/14.json @@ -0,0 +1,5637 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "c85793cb0e21bc5af13025944a61658f", + "entities": [ + { + "tableName": "BroadcastNostrEventReceipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAccepted", + "columnName": "isAccepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSync", + "columnName": "isSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messages", + "columnName": "messages", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventReceipt_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventReceipt_isAccepted", + "unique": false, + "columnNames": [ + "isAccepted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "BroadcastNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourcePublicKey", + "columnName": "sourcePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationPublicKey", + "columnName": "destinationPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourcePublicKey", + "destinationPublicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourcePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "destinationPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "ChatMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `directMessageRecipientPublicKey` TEXT, `frostSigningSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUserMessage", + "columnName": "isUserMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftWrapPayloadId", + "columnName": "giftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotInnerEventId", + "columnName": "marmotInnerEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotedMessageId", + "columnName": "quotedMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotInnerEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotInnerEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventRequestRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventRequestId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventRequestId`) REFERENCES `BroadcastNostrEventRequest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventRequestId", + "columnName": "broadcastNostrEventRequestId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventRequest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventReceiptRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventReceiptId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventReceiptId`) REFERENCES `BroadcastNostrEventReceipt`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventReceiptId", + "columnName": "broadcastNostrEventReceiptId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventReceipt", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventReceiptId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageNostrEventRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatRoom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mlsGroupState", + "columnName": "mlsGroupState", + "affinity": "TEXT" + }, + { + "fieldPath": "initialGiftWrapPayloadId", + "columnName": "initialGiftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "leftGroupAt", + "columnName": "leftGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "chronicleRequestedAt", + "columnName": "chronicleRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "initialGiftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgParticipantMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `DkgSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostPublicKey", + "columnName": "hostPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round1Random", + "columnName": "round1Random", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round2AuxRandom", + "columnName": "round2AuxRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorRound1", + "columnName": "coordinatorRound1", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT" + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "secretShare", + "columnName": "secretShare", + "affinity": "TEXT" + }, + { + "fieldPath": "publicShares", + "columnName": "publicShares", + "affinity": "TEXT" + }, + { + "fieldPath": "recoveryData", + "columnName": "recoveryData", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hostKeyApprovedAt", + "columnName": "hostKeyApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round1ApprovedAt", + "columnName": "round1ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round2ApprovedAt", + "columnName": "round2ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedThrough", + "columnName": "approvalRequestedThrough", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DkgSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSignerMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `signerPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `signerPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerPublicKey", + "columnName": "signerPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "signerPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_FrostSignerMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSignerMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `itemIndex` INTEGER NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signature` TEXT, PRIMARY KEY(`sessionId`, `itemIndex`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemIndex", + "columnName": "itemIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unsignedEventJson", + "columnName": "unsignedEventJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nonceRandom", + "columnName": "nonceRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aggregatedNonce", + "columnName": "aggregatedNonce", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "itemIndex" + ] + }, + "indices": [ + { + "name": "index_FrostSigningItem_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, `signerIds` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, `approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signerId", + "columnName": "signerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "signApprovedAt", + "columnName": "signApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedAt", + "columnName": "approvalRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_FrostSigningSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_FrostSigningSession_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `receiverPublicKey` TEXT NOT NULL, `receiverRelayHit` TEXT, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverRelayHit", + "columnName": "receiverRelayHit", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapSeal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `giftWrapMessageId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapMessageId`) REFERENCES `GiftWrapMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "giftWrapMessageId", + "columnName": "giftWrapMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapMessageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapPayload", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "giftWrapSealId", + "columnName": "giftWrapSealId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapSeal", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapSealId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyState", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedBy", + "columnName": "announcedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedAt", + "columnName": "announcedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyState_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupSignedEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `derivationPath` TEXT, `frostSigningSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_GroupSignedEvent_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_GroupSignedEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_GroupSignedEvent_frostSigningSessionId", + "unique": false, + "columnNames": [ + "frostSigningSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_frostSigningSessionId` ON `${TABLE_NAME}` (`frostSigningSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "InReplyToRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "replyingNostrEventId", + "columnName": "replyingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToProfilePublicKey", + "columnName": "inReplyToProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootProfilePublicKey", + "columnName": "inReplyToRootProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "replyingNostrEventId", + "inReplyToNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionLabel", + "columnName": "versionLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifact", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraDialect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChunkId", + "columnName": "translationChunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersionContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "editorPublicKey", + "columnName": "editorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersionContributor", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapterContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkId", + "columnName": "chunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dTag", + "columnName": "dTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationId", + "columnName": "translationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslation", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotCommitResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerKeyPackageEventId", + "columnName": "peerKeyPackageEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOneMemberInitialGroupCreation", + "columnName": "isOneMemberInitialGroupCreation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commitBytes", + "columnName": "commitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "welcomeBytes", + "columnName": "welcomeBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "groupInfoBytes", + "columnName": "groupInfoBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "framedCommitBytes", + "columnName": "framedCommitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "preCommitExporterSecret", + "columnName": "preCommitExporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "MarmotGroupEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encryptedContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotInnerEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `payloadEventId` TEXT, `marmotGroupEventId` TEXT, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadEventId", + "columnName": "payloadEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilter", + "columnName": "synchronizationFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NegentropySynchronizeRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_NegentropySynchronizeRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "negentropySynchronizeRequestId", + "columnName": "negentropySynchronizeRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "SynchronizeNostrEventResult", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "negentropySynchronizeRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedAuthorPublicKey", + "columnName": "quotedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToAuthorPublicKey", + "columnName": "inReplyToAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootAuthorPublicKey", + "columnName": "inReplyToRootAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedAuthorPublicKey", + "columnName": "repostedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_NostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToRootNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToRootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)" + } + ] + }, + { + "tableName": "NostrEventRelay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "relayURL" + ] + }, + "indices": [ + { + "name": "index_NostrEventRelay_relayURL", + "unique": false, + "columnNames": [ + "relayURL" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Participant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Post", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT" + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Post_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Post_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Post_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + }, + { + "name": "index_Post_repostId", + "unique": false, + "columnNames": [ + "repostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT" + }, + { + "fieldPath": "banner", + "columnName": "banner", + "affinity": "TEXT" + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "nip05", + "columnName": "nip05", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "lud06", + "columnName": "lud06", + "affinity": "TEXT" + }, + { + "fieldPath": "lud16", + "columnName": "lud16", + "affinity": "TEXT" + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey" + ] + }, + "indices": [ + { + "name": "index_Profile_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "QuotedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "quotingNostrEventId", + "columnName": "quotingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedProfilePublicKey", + "columnName": "quotedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "quotingNostrEventId", + "quotedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Reaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Reaction_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Reaction_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Reaction_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey", + "type", + "url" + ] + } + }, + { + "tableName": "RepostedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repostingNostrEventId", + "columnName": "repostingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedProfilePublicKey", + "columnName": "repostedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostingNostrEventId", + "repostedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_SynchronizeNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventResult_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UnsignedNostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateTags", + "columnName": "privateTags", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedAt", + "columnName": "signedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UnsignedNostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_UnsignedNostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "Zap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT" + }, + { + "fieldPath": "amountInMillisatoshis", + "columnName": "amountInMillisatoshis", + "affinity": "INTEGER" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Zap_senderPublicKey", + "unique": false, + "columnNames": [ + "senderPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)" + }, + { + "name": "index_Zap_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Zap_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "senderPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "receiverPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "postId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c85793cb0e21bc5af13025944a61658f')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt index 3623e7b5..f731b7d3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 13, + version = 14, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -232,12 +232,13 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // a room that could only propose one thing at a time cannot have written // two sessions' lines into the same stretch of transcript. AutoMigration(from = 10, to = 11), - // v12 adds the nullable ChatRoom.archiveRequestedAt, which stops a device - // with no signed work in a room asking the group for its history on every - // launch while an answer is in flight. Rooms written before it read back - // null, meaning "never asked" -- true of all of them, and harmless: the - // request only goes out for a room that holds no signed work, and a room - // that holds some will not ask. + // v12 adds the nullable ChatRoom.archiveRequestedAt -- renamed to + // chronicleRequestedAt in v14 -- which stops a device with no signed work + // in a room asking the group for its history on every launch while an + // answer is in flight. Rooms written before it read back null, meaning + // "never asked" -- true of all of them, and harmless: the request only + // goes out for a room that holds no signed work, and a room that holds + // some will not ask. AutoMigration(from = 11, to = 12), // v13 adds the GroupSignedEvent table and the nullable // `groupSignedEventId` on every row a signed event turns into. A new @@ -246,7 +247,14 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // hold the event behind this row" -- true of all of them, since nothing // kept it. They are not backfilled: the events are gone, and inventing // an id for one would point a row at a signature nobody can produce. - AutoMigration(from = 12, to = 13) + AutoMigration(from = 12, to = 13), + // v14 changes one column name and three stored strings, all of which say + // "archive" about the group's signed record. The word was wanted back for + // the ordinary thing a user does to a chat, and a column called + // `archiveRequestedAt` sitting on ChatRoom is exactly where the two + // meanings would have met. Room can rename a column and cannot rewrite the + // rows in the same breath, so this is a manual migration passed to the + // builder rather than an entry here. See MIGRATION_13_14. ] ) @ColumnTypeConverters(MantraConverters::class) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt index 92b31069..f352664d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt @@ -4,6 +4,7 @@ import androidx.room3.RoomDatabase import androidx.sqlite.driver.bundled.BundledSQLiteDriver import press.mantra.compose.database.migrations.MIGRATION_3_4 import press.mantra.compose.database.migrations.MIGRATION_9_10 +import press.mantra.compose.database.migrations.MIGRATION_13_14 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -17,11 +18,12 @@ fun getRoomDatabase( builder: RoomDatabase.Builder ): press.mantra.compose.database.MantraDatabase { return builder - // Everything else Room generates itself. These two move data rather than + // Everything else Room generates itself. These three move data rather than // only changing shape, which an AutoMigration cannot express: 3->4 rewrites - // chat rows, and 9->10 copies a session's per-event columns onto the items - // table before dropping them. - .addMigrations(MIGRATION_3_4, MIGRATION_9_10) + // chat rows, 9->10 copies a session's per-event columns onto the items + // table before dropping them, and 13->14 renames a column and rewrites the + // chat rows that named it the old way. + .addMigrations(MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupSignedEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupSignedEventDao.kt index 98c3a4a5..da6e856c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupSignedEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupSignedEventDao.kt @@ -42,9 +42,9 @@ abstract class GroupSignedEventDao { * Files a signed event, keeping what is already known about it. * * The same event reaches a device more than once by design: it is applied - * when the session that made it completes, and again from any archive page + * when the session that made it completes, and again from any chronicle page * carrying it. A plain upsert would let the second arrival overwrite the - * first, and the second arrival is the poorer one -- an archive knows no + * first, and the second arrival is the poorer one -- a chronicle knows no * session and, on a member who joined after the ceremony, no derivation * path. So the incoming row fills gaps and never empties them. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt index 2675d539..6b82cd43 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt @@ -3,7 +3,7 @@ package press.mantra.compose.database.dao import androidx.room3.Dao import androidx.room3.Transaction import press.mantra.compose.database.MantraDatabase -import press.mantra.compose.managers.ArchiveManager +import press.mantra.compose.managers.ChronicleManager import press.mantra.compose.database.model.BroadcastNostrEventRequest import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatMessageBroadcastNostrEventRequestRelation @@ -527,7 +527,7 @@ abstract class MarmotOutboundDao( // Nothing else will ever show it to them: MLS gives a joiner no // history, and a group-signed event never travels -- every device // derives it from a session it took part in, or does without. See - // docs/member-archive.md. + // docs/member-chronicle.md. // // **Queued behind the Welcome is not delivered after it.** These // are different transports -- a relay-borne gift wrap and a @@ -536,14 +536,14 @@ abstract class MarmotOutboundDao( // an epoch ahead of theirs and is dropped outright rather than // deferred. So this is a latency optimisation and not the // mechanism: what recovers it is the invitee asking for - // themselves once they are in, which `ArchiveManager.requestIfEmpty` + // themselves once they are in, which `ChronicleManager.requestIfEmpty` // does on their first open of the room. // // A push that does not land is therefore the ordinary case rather // than an error, and nothing here reports one to the inviter. It // is inside this function's catch for that reason, alongside the // Welcome it rides behind. - ArchiveManager.sendTo( + ChronicleManager.sendTo( database = database, chatRoomId = nostrGroupId, userPublicKey = userPublicKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/ChronicleRenameMigration.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/ChronicleRenameMigration.kt new file mode 100644 index 00000000..80ca9331 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/ChronicleRenameMigration.kt @@ -0,0 +1,60 @@ +package press.mantra.compose.database.migrations + +import androidx.room3.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL +import press.mantra.compose.database.model.ChatMessage + +/** + * Renames the group's signed record from "archive" to "chronicle", everywhere the + * old word had been written down. + * + * The word was needed back. Archiving a chat is an ordinary thing a user does to + * a conversation, and the group's signed record is not that -- it is what a + * member who joined after the work was done is handed so their room stops being + * empty. Two unrelated meanings of one word in one app is a bug waiting to be + * written, and `ChatRoom.archiveRequestedAt` is precisely where they would have + * met: a column on the chat row, named for the thing that is not the chat. + * + * Two changes, one shape and one data, which is why this is a manual migration + * rather than a `@RenameColumn` spec Room could generate: + * + * - `ChatRoom.archiveRequestedAt` becomes `chronicleRequestedAt`. Renamed rather + * than dropped and re-added, because the value is load-bearing while it is + * set: it is the only thing stopping a device with an empty room asking the + * group for its history on every launch. A device that dropped it mid-flight + * would ask again on the next start, and again after that. + * - The three `ChatMessage.messageType` strings become their `chronicle*` + * spellings. Rewritten rather than left to a legacy constant, because these + * lines cannot be regenerated -- a chronicle is announced once, when it is + * requested, sent or applied -- and an unrecognised type does not render as + * nothing. It renders as a chat bubble, so "Caught up on 12 items" would come + * back attributed to a member as something they said. + * + * Matched on the exact old strings rather than a `LIKE`, because this app wrote + * all three of them one version ago and knows what they were. Anything else in + * the column is left alone. + * + * `ALTER TABLE ... RENAME COLUMN` needs SQLite 3.25, which `getRoomDatabase` + * guarantees by pinning `BundledSQLiteDriver` on every platform. The column is in + * no index and no foreign key -- see the v13 schema -- so nothing else in the + * table has to move with it. + */ +val MIGRATION_13_14 = object : Migration(13, 14) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "ALTER TABLE `ChatRoom` RENAME COLUMN `archiveRequestedAt` TO `chronicleRequestedAt`" + ) + + mapOf( + "archiveRequested" to ChatMessage.TYPE_CHRONICLE_REQUESTED, + "archiveSent" to ChatMessage.TYPE_CHRONICLE_SENT, + "archiveReceived" to ChatMessage.TYPE_CHRONICLE_RECEIVED, + ).forEach { (oldType, newType) -> + connection.execSQL( + "UPDATE `ChatMessage` SET `messageType` = '$newType' " + + "WHERE `messageType` = '$oldType'" + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 71e691cd..e657249c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -10,7 +10,7 @@ import press.mantra.compose.database.model.traits.SoftDeletableEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.database.model.traits.UserViewableEntity import press.mantra.compose.exceptions.MarmotUnprocessableInnerEventException -import press.mantra.compose.managers.ArchiveManager +import press.mantra.compose.managers.ChronicleManager import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.extensions.toHex import com.vitorpamplona.quartz.marmot.GroupEventResult @@ -26,8 +26,8 @@ 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.archive.ArchiveEvent -import press.mantra.compose.nostr.archive.ArchiveRequestEvent +import press.mantra.compose.nostr.chronicle.ChronicleEvent +import press.mantra.compose.nostr.chronicle.ChronicleRequestEvent import press.mantra.compose.nostr.nip30303.SubmissionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributorListEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent @@ -325,11 +325,11 @@ data class ChatMessage( /** * Handing a member the group's signed record, as lines in the chat. * - * One line per archive rather than per page. A member who joins a working + * One line per chronicle rather than per page. A member who joins a working * group has a room that fills itself in without explanation, and the * alternative to saying so once is either silence -- which looks like a * group that has done nothing -- or a line per applied payload, which is - * the transcript the archive deliberately does not write. + * the transcript the chronicle deliberately does not write. * * Content is self-contained: these are not predicates for a name to be * read in front of, so they stay out of the AUTHORED sets. A recipient's @@ -337,24 +337,24 @@ data class ChatMessage( * which does not follow a rename and is the accepted cost for a line * about a thing that happened once. * - * The received line names no sender on purpose. An archive can be + * The received line names no sender on purpose. A chronicle can be * assembled out of pages from more than one member, so attributing the * catch-up to one of them would be a guess dressed as a fact. */ - const val TYPE_ARCHIVE_REQUESTED = "archiveRequested" - const val TYPE_ARCHIVE_SENT = "archiveSent" - const val TYPE_ARCHIVE_RECEIVED = "archiveReceived" + const val TYPE_CHRONICLE_REQUESTED = "chronicleRequested" + const val TYPE_CHRONICLE_SENT = "chronicleSent" + const val TYPE_CHRONICLE_RECEIVED = "chronicleReceived" /** - * Every archive line, for the one check the transcript dispatches on. + * Every chronicle line, for the one check the transcript dispatches on. * * A type missing from here renders as a chat bubble -- silently, and * looking exactly like a member having said "Caught up on 12 items". */ - val ARCHIVE_TYPES = setOf( - TYPE_ARCHIVE_REQUESTED, - TYPE_ARCHIVE_SENT, - TYPE_ARCHIVE_RECEIVED, + val CHRONICLE_TYPES = setOf( + TYPE_CHRONICLE_REQUESTED, + TYPE_CHRONICLE_SENT, + TYPE_CHRONICLE_RECEIVED, ) const val TYPE_UNDECRYPTABLE_OUTER_LAYER = "undecryptableOuterLayer" @@ -651,14 +651,14 @@ data class ChatMessage( ) } - // An archive is neither a document nor a submission: it is a + // A chronicle is neither a document nor a submission: it is a // bundle of documents addressed to one member who is missing // them. Intercepted here rather than in applyInnerEvent for // the same reason the gift wrap above is -- deciding whether // to act needs the active key, which applyInnerEvent has no // business knowing. - if (event.kind == ArchiveEvent.KIND) { - ArchiveManager.receive( + if (event.kind == ChronicleEvent.KIND) { + ChronicleManager.receive( database = database, chatRoomId = groupEventResult.groupId, userPublicKey = activeKeyPair.pubKey.toHex(), @@ -666,8 +666,8 @@ data class ChatMessage( ) // No chat line, and not for want of one worth writing. - // One line per archive is right; one per page is not, and - // the pages of an archive are not distinguishable from + // One line per chronicle is right; one per page is not, and + // the pages of a chronicle are not distinguishable from // each other here. That is Phase 7's, and until then a // silent catch-up beats a transcript full of envelopes. return null @@ -679,8 +679,8 @@ data class ChatMessage( // pages are idempotent and everyone but the recipient ignores // them. A member with nothing signed answers nothing, which // is the honest reply from one still catching up themselves. - if (event.kind == ArchiveRequestEvent.KIND) { - ArchiveManager.sendTo( + if (event.kind == ChronicleRequestEvent.KIND) { + ChronicleManager.sendTo( database = database, chatRoomId = groupEventResult.groupId, userPublicKey = activeKeyPair.pubKey.toHex(), @@ -818,7 +818,7 @@ data class ChatMessage( * [groupSignedEventId] is the third way in, and the one with no * envelope at all: a `GroupSignedEvent` the group put its own signature * to, applied by `FrostSigningManager` when a session completes or by - * `ArchiveManager` when a page arrives. Both Marmot ids are null for + * `ChronicleManager` when a page arrives. Both Marmot ids are null for * those -- there is no group event and no inner event behind them -- * which is why they need an id of their own, and why every row this * writes carries it. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt index 4e2aa1b0..d998521e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt @@ -83,16 +83,16 @@ data class ChatRoom( * When this device last asked the group for its signed history, or null if * it never has or the answer has since arrived. * - * A device with no signed work in a room asks for an archive -- see - * `ArchiveManager.requestIfEmpty` and docs/member-archive.md. This is the + * A device with no signed work in a room asks for a chronicle -- see + * `ChronicleManager.requestIfEmpty` and docs/member-chronicle.md. This is the * only thing stopping it asking again on every launch while an answer is in - * flight, and it is cleared as soon as an archive applies anything, so a + * flight, and it is cleared as soon as a chronicle applies anything, so a * partial answer is followed by another request rather than by silence. * * Not a claim that anybody replied. Nothing acknowledges a request, and the * room having work in it is the only evidence that ever arrives. */ - val archiveRequestedAt: Instant? = null, + val chronicleRequestedAt: Instant? = null, override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt index 35adb275..aeb1ec84 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt @@ -32,7 +32,7 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent * as itself -- the outbound pipeline re-authors rumors as their sender and would * strip the signature off -- so it is not a `NostrEvent`, and it is not a * `MarmotInnerEvent` either, since no member sent it. Every device derives it - * from a signing session it took part in, or is handed it in an archive. The + * from a signing session it took part in, or is handed it in a chronicle. The * columns are `NostrEvent`'s so that what is stored is an event rather than a * summary of one, which is what makes [verifies] answerable from the row alone. * @@ -96,9 +96,9 @@ data class GroupSignedEvent( * The session that produced the signature, when this device ran it. * * Not a foreign key, and null on a device that was handed the event rather - * than signing it -- an archive recipient holds the group's work without + * than signing it -- a chronicle recipient holds the group's work without * ever having held a session for it, which is the whole point of - * `docs/member-archive.md`. A session is also allowed to be swept away + * `docs/member-chronicle.md`. A session is also allowed to be swept away * without taking the group's signed work with it. */ val frostSigningSessionId: String? = null, @@ -140,7 +140,7 @@ data class GroupSignedEvent( * The row for a signed [event], as the group made it. * * Not verified here. Both callers have already checked the signature -- - * `FrostSigningManager` before it applies a batch, `ArchiveManager` + * `FrostSigningManager` before it applies a batch, `ChronicleManager` * before it applies a payload -- and a check that runs twice tends to * become a check nobody performs. [verifies] is for readers. */ diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt index 38842414..7620de16 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt @@ -83,8 +83,8 @@ data class MantraArtifact( * from the same event. * * Tag order matches [ArtifactEvent.build] exactly -- alt first -- for the - * same reason, and `ArchiveRoundTripTest` is what says so. Both of those were - * wrong here until an archive needed to rebuild an artifact and nothing had + * same reason, and `ChronicleRoundTripTest` is what says so. Both of those were + * wrong here until a chronicle needed to rebuild an artifact and nothing had * ever called this. */ fun toArtifactEvent(versionLabel: String): ArtifactEvent { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt index 46c2abe8..e645ebf3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt @@ -74,7 +74,7 @@ data class MantraArtifactVersion( // Tag order matches ArtifactVersionEvent.initialVersionOf, which puts // the alt first because `build` does, or the event id does not // round-trip. It was the other way round here for as long as nothing - // called this -- see ArchiveRoundTripTest, which is what calls it now. + // called this -- see ChronicleRoundTripTest, which is what calls it now. tags = TagArrayBuilder() .addUnique( AltTag.assemble(ArtifactVersionEvent.ALT_DESCRIPTION) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index efef82bd..80b04c00 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -2,7 +2,7 @@ package press.mantra.compose.database.repository import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.MantraDatabase -import press.mantra.compose.managers.ArchiveManager +import press.mantra.compose.managers.ChronicleManager import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GiftWrapPayload @@ -55,7 +55,7 @@ class DatabaseChatRepository( override suspend fun requestGroupHistoryIfMissing( chatRoomId: String, userPublicKey: HexKey - ): Boolean = ArchiveManager.requestIfEmpty( + ): Boolean = ChronicleManager.requestIfEmpty( database = database, chatRoomId = chatRoomId, userPublicKey = userPublicKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChronicleManager.kt similarity index 85% rename from composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt rename to composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChronicleManager.kt index a3659d44..03c4a873 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChronicleManager.kt @@ -15,19 +15,19 @@ 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.archive.ArchiveEvent -import press.mantra.compose.nostr.archive.ArchiveRequestEvent +import press.mantra.compose.nostr.chronicle.ChronicleEvent +import press.mantra.compose.nostr.chronicle.ChronicleRequestEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent /** * Building the group's signed record into pages a member who lacks it can apply. * - * See docs/member-archive.md. The short of it: a member added after the work was + * 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 archive, and the rows are the fallback + * ### 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 @@ -39,7 +39,7 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent * 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; `ArchiveRoundTripTest` is what says it is, per kind, against a real + * 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. The fallback can go once no install still holds @@ -48,22 +48,22 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent * **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 [ArchiveEvent.isArchivable] + * 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 `ArchiveEvent.build` would refuse the page, and a room's whole - * archive would fail on the one event every room has. + * 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 - * archive honest about its own size: a row that came from a member's rumor - * cannot be archived, and dropping it here rather than letting the recipient + * 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 ArchiveManager { - private const val TAG = "ArchiveManager" +object ChronicleManager { + private const val TAG = "ChronicleManager" private val logger = Logger.withTag(TAG) @@ -81,9 +81,9 @@ object ArchiveManager { database: MantraDatabase, chatRoomId: String, recipient: HexKey, - archiveId: String = RandomInstance.bytes(32).toHex(), + chronicleId: String = RandomInstance.bytes(32).toHex(), createdAt: Long = TimeUtils.now(), - ): List> { + ): List> { val held = signedEventsOf(database, chatRoomId) // One rule over both sources: nothing leaves that the recipient could @@ -97,25 +97,25 @@ object ArchiveManager { if (verified.size != held.size) { logger.d( "Leaving ${held.size - verified.size} of ${held.size} event(s) out of " + - "$chatRoomId's archive: nothing verifiably signed by the room" + "$chatRoomId's chronicle: nothing verifiably signed by the room" ) } - val pages = paginate(ArchiveEvent.inApplyOrder(verified)) + val pages = paginate(ChronicleEvent.inApplyOrder(verified)) if (pages.isEmpty()) { - logger.i("Nothing signed to archive for room $chatRoomId") + logger.i("Nothing signed to chronicle for room $chatRoomId") return emptyList() } logger.i( - "Archiving ${verified.size} event(s) for $chatRoomId as $archiveId, " + + "Chronicling ${verified.size} event(s) for $chatRoomId as $chronicleId, " + "${pages.size} page(s) for ${recipient.take(8)}" ) return pages.mapIndexed { index, payloads -> - ArchiveEvent.build( + ChronicleEvent.build( payloads = payloads, - archiveId = archiveId, + chronicleId = chronicleId, index = index, count = pages.size, recipient = recipient, @@ -154,7 +154,7 @@ object ArchiveManager { userPublicKey: HexKey, ): Boolean { val chatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom ?: return false - if (chatRoom.archiveRequestedAt != null) return false + if (chatRoom.chronicleRequestedAt != null) return false val holdsWork = database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId).isNotEmpty() || database.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId).isNotEmpty() @@ -165,18 +165,18 @@ object ArchiveManager { database = database, chatRoomId = chatRoomId, userPublicKey = userPublicKey, - kind = ArchiveRequestEvent.KIND, - tags = ArchiveRequestEvent.build().tags, + kind = ChronicleRequestEvent.KIND, + tags = ChronicleRequestEvent.build().tags, content = "", ) - database.chatRoomDao().upsert(chatRoom.copy(archiveRequestedAt = Clock.System.now())) + database.chatRoomDao().upsert(chatRoom.copy(chronicleRequestedAt = Clock.System.now())) announce( database = database, chatRoomId = chatRoomId, userPublicKey = userPublicKey, - messageType = ChatMessage.TYPE_ARCHIVE_REQUESTED, + messageType = ChatMessage.TYPE_CHRONICLE_REQUESTED, content = "Asked this group for its signed work", ) @@ -188,7 +188,7 @@ object ArchiveManager { /** * Send [recipient] everything this device can prove about [chatRoomId]. * - * Both ways an archive goes out are this one call: answering a request, and + * 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. * @@ -199,7 +199,7 @@ object ArchiveManager { * 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 archive + * from a member who is themselves still catching up, and an empty chronicle * would look like an answer. */ suspend fun sendTo( @@ -236,11 +236,11 @@ object ArchiveManager { database = database, chatRoomId = chatRoomId, userPublicKey = userPublicKey, - messageType = ChatMessage.TYPE_ARCHIVE_SENT, + messageType = ChatMessage.TYPE_CHRONICLE_SENT, content = "Sent this group's signed work to $name", ) - logger.i("Sending ${recipient.take(8)} ${pages.size} archive page(s) for $chatRoomId") + logger.i("Sending ${recipient.take(8)} ${pages.size} chronicle page(s) for $chatRoomId") return pages.size } @@ -249,12 +249,12 @@ object ArchiveManager { * One line in the room's transcript. * * Written by the device it happened on, for itself. None of these travels -- - * an archive is not an event in the group's life, it is one member being + * 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.ARCHIVE_TYPES]. + * [ChatMessage.CHRONICLE_TYPES]. */ private suspend fun announce( database: MantraDatabase, @@ -287,10 +287,10 @@ object ArchiveManager { * * **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 an archive that + * 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 archive is right, and it is not writable from here, because the - * pages of an archive are indistinguishable from each other at this point. + * 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, @@ -343,7 +343,7 @@ object ArchiveManager { } /** - * Take delivery of an archive page for [userPublicKey]. + * 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 @@ -354,7 +354,7 @@ object ArchiveManager { * **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 an archive page rather than at the event that introduced it. That + * 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. */ @@ -364,7 +364,7 @@ object ArchiveManager { userPublicKey: HexKey, page: Event, ): Outcome { - val recipient = ArchiveEvent( + val recipient = ChronicleEvent( id = page.id, pubKey = page.pubKey, createdAt = page.createdAt, @@ -374,7 +374,7 @@ object ArchiveManager { ).recipient() if (!recipient.equals(userPublicKey, ignoreCase = true)) { - logger.d("Archive page ${page.id.take(8)} is for ${recipient?.take(8)}; not applying") + logger.d("Chronicle page ${page.id.take(8)} is for ${recipient?.take(8)}; not applying") return Outcome() } @@ -382,7 +382,7 @@ object ArchiveManager { } /** - * Apply every archive page this room holds for [userPublicKey], repeatedly, + * 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 @@ -398,8 +398,8 @@ object ArchiveManager { * 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 archive gets, and the rest is a hole to be filled by another - * page or another archive. + * 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 @@ -413,7 +413,7 @@ object ArchiveManager { userPublicKey: HexKey, ): Outcome { val pages = database.marmotInnerEventDao() - .getByChatRoomAndKinds(chatRoomId, listOf(ArchiveEvent.KIND)) + .getByChatRoomAndKinds(chatRoomId, listOf(ChronicleEvent.KIND)) .filter { addressedTo(it, userPublicKey) } if (pages.isEmpty()) return Outcome() @@ -439,22 +439,22 @@ object ArchiveManager { } logger.i( - "Swept ${pages.size} archive page(s) for $chatRoomId: " + + "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 archive + // 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.archiveRequestedAt != null) { - database.chatRoomDao().upsert(chatRoom.copy(archiveRequestedAt = null)) + if (chatRoom.chronicleRequestedAt != null) { + database.chatRoomDao().upsert(chatRoom.copy(chronicleRequestedAt = null)) // One line per answered request, which is as close to one per - // archive as this can get: the pages of an archive are not + // 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. // @@ -466,7 +466,7 @@ object ArchiveManager { database = database, chatRoomId = chatRoomId, userPublicKey = userPublicKey, - messageType = ChatMessage.TYPE_ARCHIVE_RECEIVED, + messageType = ChatMessage.TYPE_CHRONICLE_RECEIVED, content = "Caught up on ${pass.applied} item(s) of this group's signed work", ) } @@ -477,7 +477,7 @@ object ArchiveManager { } private fun addressedTo(stored: MarmotInnerEvent, userPublicKey: HexKey): Boolean = - ArchiveEvent( + ChronicleEvent( id = stored.id, pubKey = stored.publicKey, createdAt = stored.createdAt.epochSeconds, @@ -494,42 +494,42 @@ object ArchiveManager { * 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 - * archive. + * chronicle. * * The page's own framing is still all-or-nothing; see - * [ArchiveEvent.decodePage] for why those two are not in tension. + * [ChronicleEvent.decodePage] for why those two are not in tension. */ private suspend fun applyPage( database: MantraDatabase, chatRoomId: String, stored: MarmotInnerEvent, ): Outcome { - val payloads = ArchiveEvent.decodePage(stored.content) + val payloads = ChronicleEvent.decodePage(stored.content) if (payloads == null) { - logger.w("Archive page ${stored.id.take(8)} does not read as a page; dropping it") + 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 - // archive was signed by the room it is arriving in, so they all share + // 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 an archive exists for, and why + // 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() - ArchiveEvent.inApplyOrder(payloads).forEach { payload -> + 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 (!ArchiveEvent.isArchivable(payload.kind)) { + if (!ChronicleEvent.isChroniclable(payload.kind)) { logger.w( - "Archive page ${stored.id.take(8)} carries kind ${payload.kind}, " + - "which an archive may not deliver; dropping ${payload.id.take(8)}" + "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 @@ -537,7 +537,7 @@ object ArchiveManager { if (!GroupKeyStateEvent.isSignedByRoom(payload, chatRoomId)) { logger.w( - "Archive page ${stored.id.take(8)} carries ${payload.id.take(8)}, " + + "Chronicle page ${stored.id.take(8)} carries ${payload.id.take(8)}, " + "which room $chatRoomId did not sign; dropping it" ) outcome += Outcome(skipped = 1) @@ -550,7 +550,7 @@ object ArchiveManager { // 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 an archive of their + // none of the events, and could never build a chronicle of their // own to hand on. database.groupSignedEventDao().record( GroupSignedEvent.fromEvent( @@ -564,7 +564,7 @@ object ArchiveManager { // 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 archive + // and another one on every pass of the sweep. The chronicle // restores the work; the conversation is forward secret and stays // gone. ChatMessage.applyInnerEvent( @@ -584,7 +584,7 @@ object ArchiveManager { // 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 archive page " + + "Could not apply ${payload.id.take(8)} from chronicle page " + "${stored.id.take(8)} yet: ${error.message}" ) Outcome(failed = 1) @@ -595,7 +595,7 @@ object ArchiveManager { } /** - * Every archivable event this device holds for the room: the ones the group + * 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 @@ -610,7 +610,7 @@ object ArchiveManager { * 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; [ArchiveEvent.inApplyOrder] settles it + * 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 @@ -627,7 +627,7 @@ object ArchiveManager { // 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 { ArchiveEvent.isArchivable(it.kind) } + .filter { ChronicleEvent.isChroniclable(it.kind) } .map { it.toEvent() } val onFile = recorded.mapTo(mutableSetOf()) { it.id } @@ -635,7 +635,7 @@ object ArchiveManager { if (rebuilt.isNotEmpty()) { logger.d( - "Archiving $chatRoomId: ${recorded.size} event(s) as the group signed them, " + + "Chronicling $chatRoomId: ${recorded.size} event(s) as the group signed them, " + "${rebuilt.size} rebuilt from rows that predate the record" ) } @@ -652,7 +652,7 @@ object ArchiveManager { * artifact row -- see `MantraArtifact.toArtifactEvent` -- and the version it * went into is one step away here. * - * Still walked on every archive, and by now it contributes nothing in most + * 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, @@ -680,7 +680,7 @@ object ArchiveManager { // 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 archive rather than a reason to + // cannot rebuild, which is a gap in the chronicle rather than a reason to // abandon it. val versionLabel = versions .firstOrNull { it.createdAt == artifact.createdAt } @@ -719,7 +719,7 @@ object ArchiveManager { // 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 - // an archive carries a group's current answer to + // 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() @@ -735,12 +735,12 @@ object ArchiveManager { * [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 archives -- a room of one-line dialects + * 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 - * archive: a chapter nobody can archive is a hole, and a member who gets + * 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): List> { @@ -753,16 +753,16 @@ object ArchiveManager { // Plus the comma this event needs if it is not first on its page. val size = event.toJson().encodeToByteArray().size + 1 - if (2 + size > ArchiveEvent.MAX_PAGE_BYTES) { + if (2 + size > ChronicleEvent.MAX_PAGE_BYTES) { logger.w( "Event ${event.id} is $size bytes and will not fit a " + - "${ArchiveEvent.MAX_PAGE_BYTES}-byte page; leaving it out of the archive" + "${ChronicleEvent.MAX_PAGE_BYTES}-byte page; leaving it out of the chronicle" ) return@forEach } val full = page.isNotEmpty() && - (bytes + size > ArchiveEvent.MAX_PAGE_BYTES || page.size >= ArchiveEvent.MAX_PAGE_EVENTS) + (bytes + size > ChronicleEvent.MAX_PAGE_BYTES || page.size >= ChronicleEvent.MAX_PAGE_EVENTS) if (full) { pages.add(page) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/tags/ArchiveIdTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/tags/ArchiveIdTag.kt deleted file mode 100644 index 497d3fff..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/tags/ArchiveIdTag.kt +++ /dev/null @@ -1,40 +0,0 @@ -package press.mantra.compose.nostr.archive.tags - -import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.ensure - -/** - * Ties the pages of one archive together. - * - * An archive is more than one event and its pages arrive in no particular - * order, so a receiver has to be able to tell page 2 of this archive from page 2 - * of somebody else's. Two members answering the same request is the ordinary - * case rather than the odd one -- nothing stops them, and nothing should, since - * an incomplete answer is exactly what a second answer fixes -- and without an - * id their pages would interleave into one sequence that is neither. - * - * Fresh random bytes per archive, not derived from anything. Two members - * assembling the same rows must not collide on an id, because their page counts - * will differ whenever their databases do. - */ -class ArchiveIdTag( - val archiveId: String, -) { - fun toTagArray() = assemble(archiveId = archiveId) - - companion object { - const val TAG_NAME = "archiveId" - - fun parse(tag: Array): ArchiveIdTag? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotBlank()) { return null } - - return ArchiveIdTag(archiveId = tag[1]) - } - - fun assemble(archiveId: String): Array = arrayOf(TAG_NAME, archiveId) - - fun assemble(archiveIdTag: ArchiveIdTag) = assemble(archiveId = archiveIdTag.archiveId) - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/ArchiveEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt similarity index 79% rename from composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/ArchiveEvent.kt rename to composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt index c3ae8347..c6a96f5d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/ArchiveEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt @@ -1,4 +1,4 @@ -package press.mantra.compose.nostr.archive +package press.mantra.compose.nostr.chronicle import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -11,9 +11,9 @@ import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.json.jsonArray import press.mantra.compose.network.serialization.CommonJson -import press.mantra.compose.nostr.archive.tags.ArchiveIdTag -import press.mantra.compose.nostr.archive.tags.ArchivePageTag -import press.mantra.compose.nostr.archive.tags.ArchiveRecipientTag +import press.mantra.compose.nostr.chronicle.tags.ChronicleIdTag +import press.mantra.compose.nostr.chronicle.tags.ChroniclePageTag +import press.mantra.compose.nostr.chronicle.tags.ChronicleRecipientTag import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent @@ -38,26 +38,26 @@ import press.mantra.compose.nostr.nip30303.TranslationChunkEvent * * Each payload travels whole, keeping its own id, author and signature, so the * receiver checks it rather than believing it -- see [isSignedByRoom] on - * `GroupKeyStateEvent`. The sender of an archive is therefore not trusted, which + * `GroupKeyStateEvent`. The sender of a chronicle is therefore not trusted, which * is what lets any member answer. * - * See docs/member-archive.md for the whole design, including the two things this + * See docs/member-chronicle.md for the whole design, including the two things this * deliberately does not do: it never carries the chat, and it cannot make its * recipient able to *sign* anything. * * ### Why not a [press.mantra.compose.nostr.nip30303.SubmissionEvent] each * * The envelope fits and the meaning does not. A submission is an *act* -- this - * member is putting this event in front of this group -- and an archive asserts + * member is putting this event in front of this group -- and a chronicle asserts * nothing; it re-delivers what the group already agreed. On one kind a * four-hundred-event backfill is indistinguishable from four hundred new * submissions and every device has to guess which it is looking at. It would * also be one inner event and one kind:445 per payload, where a page is one, and * the submission arm of `ChatMessage.applyInnerEvent` files a chat line per - * payload, which an archive must not. + * payload, which a chronicle must not. */ @Immutable -class ArchiveEvent( +class ChronicleEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -71,19 +71,19 @@ class ArchiveEvent( * readable page. * * Nothing here is verified yet. Parsing says the framing is intact; - * `ArchiveManager` is what asks whether the group signed each one, and it + * `ChronicleManager` is what asks whether the group signed each one, and it * asks per payload rather than per page. */ fun payloads(): List? = decodePage(content) - /** Which archive this page belongs to, or null if it names none. */ - fun archiveId(): String? = tags.firstNotNullOfOrNull(ArchiveIdTag::parse)?.archiveId + /** Which chronicle this page belongs to, or null if it names none. */ + fun chronicleId(): String? = tags.firstNotNullOfOrNull(ChronicleIdTag::parse)?.chronicleId - /** Where this page sits in that archive, or null if it does not say. */ - fun page(): ArchivePageTag? = tags.firstNotNullOfOrNull(ArchivePageTag::parse) + /** Where this page sits in that chronicle, or null if it does not say. */ + fun page(): ChroniclePageTag? = tags.firstNotNullOfOrNull(ChroniclePageTag::parse) - /** Who should act on this page. A hint -- see [ArchiveRecipientTag]. */ - fun recipient(): HexKey? = tags.firstNotNullOfOrNull(ArchiveRecipientTag::parse)?.pubKey + /** Who should act on this page. A hint -- see [ChronicleRecipientTag]. */ + fun recipient(): HexKey? = tags.firstNotNullOfOrNull(ChronicleRecipientTag::parse)?.pubKey companion object { /** @@ -97,13 +97,13 @@ class ArchiveEvent( * instead, which is "an accident of routing rather than a decision, and * the next family added should not rely on it." This is that next family. * - * It is also the right neighbourhood on the merits. An archive is not a + * It is also the right neighbourhood on the merits. A chronicle is not a * document kind; it is a statement about the record, which is what a key * state is too. */ const val KIND: Kind = 30327 - const val ALT_DESCRIPTION = "Group archive" + const val ALT_DESCRIPTION = "Group chronicle" /** * The most JSON one page may carry. @@ -139,18 +139,18 @@ class ArchiveEvent( * * Larger than `FrostSigningManager.MAX_BATCH_SIZE`, deliberately: a batch * item costs nonce generation, a FROST session and a native sign, where - * an archive payload costs a signature verify and an upsert. + * a chronicle payload costs a signature verify and an upsert. */ const val MAX_PAGE_EVENTS = 128 /** - * The kinds an archive may carry, in the order they have to be applied. + * The kinds a chronicle may carry, in the order they have to be applied. * * One list doing both jobs, because a separate allowlist is one more * thing that can disagree with the order it is applied in. * * **The order is Room's, not nostr's.** Every one of these has a foreign - * key on the one before it, so an archive applied out of order is a + * key on the one before it, so a chronicle applied out of order is a * constraint violation rather than a wrong answer. Kind order is not * dependency order and never was -- a translation chapter (30308) hangs * off a translation artifact version (30306) which hangs off an artifact @@ -160,13 +160,13 @@ class ArchiveEvent( * 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` is group-signed and passes verification perfectly, - * so an archive carrying an old one is a validly signed statement about + * so a chronicle carrying an old one is a validly signed statement about * what the room signs with, replayed by whoever kept a copy. Nothing but * this list stops it. * * ### What is missing from it, and why * - * **Only kinds the group actually signs can be here**, because an archive + * **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 * thirteen nip30303 kinds reach a signing session; the rest travel as * rumors -- empty signature, member author, vouched for by the MLS frame @@ -177,7 +177,7 @@ class ArchiveEvent( * - `TranslationEvent` (30311). Nothing builds one; the inbound arm * exists and no producer does. * - The contributor lists (30305, 30307, 30310). `applyInnerEvent` has - * no arm that writes a row for any of them, so archiving them would + * no arm that writes a row for any of them, so chronicling them would * cost bytes and restore nothing. * * Two kinds were on that list and are not any more, because the app @@ -186,7 +186,7 @@ class ArchiveEvent( * of the batch that carries it. `TranslationChunkEvent` (30309) -- the * translated text itself -- used to be submitted as its author's rumor, * and is now proposed to the group like everything else. Both are - * therefore checkable, and an archive 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. */ private val APPLY_ORDER: List = listOf( @@ -200,23 +200,23 @@ class ArchiveEvent( TranslationChunkEvent.KIND, ) - /** Every kind an archive may carry. */ - val ARCHIVABLE_KINDS: Set = APPLY_ORDER.toSet() + /** Every kind a chronicle may carry. */ + val CHRONICLABLE_KINDS: Set = APPLY_ORDER.toSet() /** - * Where [kind] sits in dependency order, or null if an archive may not + * Where [kind] sits in dependency order, or null if a chronicle may not * carry it at all. */ fun applyRank(kind: Kind): Int? = APPLY_ORDER.indexOf(kind).takeIf { it >= 0 } - fun isArchivable(kind: Kind): Boolean = kind in ARCHIVABLE_KINDS + fun isChroniclable(kind: Kind): Boolean = kind in CHRONICLABLE_KINDS /** * [payloads] in dependency order, so a page applies front to back. * * Stable within a rank: two chapters of one version have no order between * them and keeping the caller's is one less thing that varies between two - * members assembling the same archive. + * members assembling the same chronicle. */ fun inApplyOrder(payloads: List): List = payloads.sortedBy { applyRank(it.kind) ?: Int.MAX_VALUE } @@ -239,14 +239,14 @@ class ArchiveEvent( * payloads one at a time.** They are different questions. A page that * will not parse has lost its framing, so nothing in it can be trusted to * be what the sender wrote -- and a page silently shortened by one - * element would leave a receiver believing it holds a complete archive + * element would leave a receiver believing it holds a complete chronicle * when the page count says so and the contents do not. A payload whose * signature does not verify is a well-framed page containing one bad * event, and costing its honest neighbours would let one forged payload - * deny an entire archive. + * deny an entire chronicle. * * Both caps are checked here rather than at the call site, because this - * is the boundary a remote party's page crosses. An archive is the second + * is the boundary a remote party's page crosses. A chronicle is the second * place in this protocol where somebody else decides how much work this * device does; the first grew a cap on the way in for the same reason. */ @@ -263,7 +263,7 @@ class ArchiveEvent( } /** - * A page of [payloads], page [index] of [count] in archive [archiveId], + * A page of [payloads], page [index] of [count] in chronicle [chronicleId], * for [recipient]. * * The caller sorts and pages; this only writes what it is handed. Both @@ -272,31 +272,31 @@ class ArchiveEvent( */ fun build( payloads: List, - archiveId: String, + chronicleId: String, index: Int, count: Int, recipient: HexKey, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ): EventTemplate { - require(payloads.isNotEmpty()) { "An archive page carries at least one event" } + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + require(payloads.isNotEmpty()) { "A chronicle page carries at least one event" } require(payloads.size <= MAX_PAGE_EVENTS) { - "An archive page carries at most $MAX_PAGE_EVENTS events, not ${payloads.size}" + "A chronicle page carries at most $MAX_PAGE_EVENTS events, not ${payloads.size}" } payloads.forEach { - require(isArchivable(it.kind)) { "An archive may not carry kind ${it.kind}" } + require(isChroniclable(it.kind)) { "A chronicle may not carry kind ${it.kind}" } } val content = encodePage(payloads) require(content.encodeToByteArray().size <= MAX_PAGE_BYTES) { - "An archive page carries at most $MAX_PAGE_BYTES bytes" + "A chronicle page carries at most $MAX_PAGE_BYTES bytes" } - return eventTemplate(KIND, content, createdAt) { + return eventTemplate(KIND, content, createdAt) { alt(ALT_DESCRIPTION) - addUnique(ArchiveIdTag.assemble(archiveId)) - addUnique(ArchivePageTag.assemble(index, count)) - addUnique(ArchiveRecipientTag.assemble(recipient)) + addUnique(ChronicleIdTag.assemble(chronicleId)) + addUnique(ChroniclePageTag.assemble(index, count)) + addUnique(ChronicleRecipientTag.assemble(recipient)) initializer() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/ArchiveRequestEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRequestEvent.kt similarity index 83% rename from composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/ArchiveRequestEvent.kt rename to composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRequestEvent.kt index 89ee815d..752bd927 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/ArchiveRequestEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRequestEvent.kt @@ -1,4 +1,4 @@ -package press.mantra.compose.nostr.archive +package press.mantra.compose.nostr.chronicle import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -16,7 +16,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils * "I hold none of this group's work. Send it." * * The reliable half of handing a new member the group's history, and the reason - * [ArchiveEvent] is not simply pushed at them when they are invited. + * [ChronicleEvent] is not simply pushed at them when they are invited. * * A push from the inviter is an application message in the epoch the add * created. If it reaches the invitee before their Welcome does -- different @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils * * - a reinstall, whose rows are gone and whose invite is long past; * - a second device, which was never invited at all; - * - an archive that was sent and lost. + * - a chronicle that was sent and lost. * * Carries nothing. The room is the envelope, the asker is the MLS sender, and * what they are missing is "all of it" -- a cursor would have to be a position @@ -43,7 +43,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils * the first version. */ @Immutable -class ArchiveRequestEvent( +class ChronicleRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -53,15 +53,15 @@ class ArchiveRequestEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { companion object { - /** Beside [ArchiveEvent] at 30327 -- see the note on its own kind. */ + /** Beside [ChronicleEvent] at 30327 -- see the note on its own kind. */ const val KIND: Kind = 30328 - const val ALT_DESCRIPTION = "Group archive request" + const val ALT_DESCRIPTION = "Group chronicle request" fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ): EventTemplate = + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate = eventTemplate(KIND, "", createdAt) { alt(ALT_DESCRIPTION) initializer() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/tags/ChronicleIdTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/tags/ChronicleIdTag.kt new file mode 100644 index 00000000..0828d8e7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/tags/ChronicleIdTag.kt @@ -0,0 +1,40 @@ +package press.mantra.compose.nostr.chronicle.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Ties the pages of one chronicle together. + * + * A chronicle is more than one event and its pages arrive in no particular + * order, so a receiver has to be able to tell page 2 of this chronicle from page 2 + * of somebody else's. Two members answering the same request is the ordinary + * case rather than the odd one -- nothing stops them, and nothing should, since + * an incomplete answer is exactly what a second answer fixes -- and without an + * id their pages would interleave into one sequence that is neither. + * + * Fresh random bytes per chronicle, not derived from anything. Two members + * assembling the same rows must not collide on an id, because their page counts + * will differ whenever their databases do. + */ +class ChronicleIdTag( + val chronicleId: String, +) { + fun toTagArray() = assemble(chronicleId = chronicleId) + + companion object { + const val TAG_NAME = "chronicleId" + + fun parse(tag: Array): ChronicleIdTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotBlank()) { return null } + + return ChronicleIdTag(chronicleId = tag[1]) + } + + fun assemble(chronicleId: String): Array = arrayOf(TAG_NAME, chronicleId) + + fun assemble(chronicleIdTag: ChronicleIdTag) = assemble(chronicleId = chronicleIdTag.chronicleId) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/tags/ArchivePageTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/tags/ChroniclePageTag.kt similarity index 63% rename from composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/tags/ArchivePageTag.kt rename to composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/tags/ChroniclePageTag.kt index 501c6ccf..c015c74c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/tags/ArchivePageTag.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/tags/ChroniclePageTag.kt @@ -1,15 +1,15 @@ -package press.mantra.compose.nostr.archive.tags +package press.mantra.compose.nostr.chronicle.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure /** - * Where this page sits in its archive, and how many there are. + * Where this page sits in its chronicle, and how many there are. * - * The count is what lets a receiver say whether it holds a whole archive, which + * The count is what lets a receiver say whether it holds a whole chronicle, which * is the only question it can answer about completeness on its own. It cannot * tell whether the *sender* left anything out -- see the note on omission in - * docs/member-archive.md -- so this is a claim about the transfer, not about the + * docs/member-chronicle.md -- so this is a claim about the transfer, not about the * group's record. * * The index is not an ordering instruction. Pages apply in whatever order they @@ -17,38 +17,38 @@ import com.vitorpamplona.quartz.utils.ensure * give no ordering guarantee and a design that needed one would be wrong on the * wire rather than merely slow. */ -class ArchivePageTag( +class ChroniclePageTag( val index: Int, val count: Int, ) { - /** Whether this page claims to be the whole archive on its own. */ + /** Whether this page claims to be the whole chronicle on its own. */ fun isOnlyPage(): Boolean = count == 1 fun toTagArray() = assemble(index = index, count = count) companion object { - const val TAG_NAME = "archivePage" + const val TAG_NAME = "chroniclePage" - fun parse(tag: Array): ArchivePageTag? { + fun parse(tag: Array): ChroniclePageTag? { ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } val index = tag[1].toIntOrNull() ?: return null val count = tag[2].toIntOrNull() ?: return null - // A page outside its own archive is not a page. Refused rather than + // A page outside its own chronicle is not a page. Refused rather than // clamped: the pair is how a receiver decides it has everything, and - // a repaired one would let a truncated archive read as complete. + // a repaired one would let a truncated chronicle read as complete. ensure(count >= 1) { return null } ensure(index in 0 until count) { return null } - return ArchivePageTag(index = index, count = count) + return ChroniclePageTag(index = index, count = count) } fun assemble(index: Int, count: Int): Array = arrayOf(TAG_NAME, index.toString(), count.toString()) - fun assemble(archivePageTag: ArchivePageTag) = - assemble(index = archivePageTag.index, count = archivePageTag.count) + fun assemble(chroniclePageTag: ChroniclePageTag) = + assemble(index = chroniclePageTag.index, count = chroniclePageTag.count) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/tags/ArchiveRecipientTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/tags/ChronicleRecipientTag.kt similarity index 68% rename from composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/tags/ArchiveRecipientTag.kt rename to composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/tags/ChronicleRecipientTag.kt index 099a38b7..8ecf410b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/archive/tags/ArchiveRecipientTag.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/tags/ChronicleRecipientTag.kt @@ -1,25 +1,25 @@ -package press.mantra.compose.nostr.archive.tags +package press.mantra.compose.nostr.chronicle.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure /** - * Who an archive is for. + * Who a chronicle is for. * * **A hint, not access control.** The page is an ordinary group message and * every member can read it -- which is right, because it is their own history * going back to them. What this decides is who *acts*: a device that is not * named stores the page and applies nothing, since it already holds the work and - * re-applying would rewrite every one of its rows to point at an archive page + * re-applying would rewrite every one of its rows to point at a chronicle page * rather than at the event that actually introduced it. * * So this is not a secret being kept from the group, and nothing downstream may - * treat it as one. Encrypting an archive to one member was considered and - * rejected in docs/member-archive.md: it protects nothing and costs a "sent a + * treat it as one. Encrypting a chronicle to one member was considered and + * rejected in docs/member-chronicle.md: it protects nothing and costs a "sent a * private message" line per page in everyone's transcript. */ -class ArchiveRecipientTag( +class ChronicleRecipientTag( val pubKey: HexKey, ) { fun toTagArray() = assemble(pubKey = pubKey) @@ -27,17 +27,17 @@ class ArchiveRecipientTag( companion object { const val TAG_NAME = "p" - fun parse(tag: Array): ArchiveRecipientTag? { + fun parse(tag: Array): ChronicleRecipientTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ArchiveRecipientTag(pubKey = tag[1]) + return ChronicleRecipientTag(pubKey = tag[1]) } fun assemble(pubKey: HexKey): Array = arrayOf(TAG_NAME, pubKey) - fun assemble(archiveRecipientTag: ArchiveRecipientTag) = - assemble(pubKey = archiveRecipientTag.pubKey) + fun assemble(chronicleRecipientTag: ChronicleRecipientTag) = + assemble(pubKey = chronicleRecipientTag.pubKey) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt index b68446e2..135973e4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt @@ -144,7 +144,7 @@ object GroupKeyStateEvent { * That is what makes a group's signature checkable by a member holding * nothing else. No `GroupKeyState` row, no threshold key, no derivation path, * no ceremony -- which is exactly the position a member added after the - * ceremony is in, and the reason `docs/member-archive.md` can hand them + * ceremony is in, and the reason `docs/member-chronicle.md` can hand them * history without asking them to trust whoever sent it. * * Everything is caught, because every input is off the wire: a pubkey that diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEvent.kt index 3a2abf79..4f4a078f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEvent.kt @@ -25,7 +25,7 @@ import press.mantra.compose.nostr.nip30303.tags.PayloadKindTag * That buys two things: * * - A group can take in work written by somebody who is not in it. A - * translation lifted from a public archive, a chapter transcribed by an + * translation lifted from a public chronicle, a chapter transcribed by an * outside contributor, an artifact somebody published years ago -- an admin * submits it and the group applies it, with the original author still named * on the row. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 33b39c0f..57592dd4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -27,7 +27,7 @@ interface ChatRepository { * * True when a request went out. Safe to call on every open: it is a no-op * for a room that already holds work and for one still waiting on an answer. - * See docs/member-archive.md for why the joiner asks rather than the inviter + * See docs/member-chronicle.md for why the joiner asks rather than the inviter * pushing. */ suspend fun requestGroupHistoryIfMissing(chatRoomId: String, userPublicKey: HexKey): Boolean diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 4c7cca5b..66067b1b 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -165,7 +165,7 @@ class ChatMessageListViewModel( * * Opening the room is the trigger because it is the first moment this device * is demonstrably in the group's current epoch -- see - * docs/member-archive.md. A member added after the work was done has no way + * docs/member-chronicle.md. A member added after the work was done has no way * to see any of it otherwise: MLS gives them no history, and a group-signed * event never travels, so every device derives it or does without. * @@ -414,7 +414,7 @@ class ChatMessageListViewModel( // Passed as answered and settled because those // are about requests and this asks nothing -- // which is what keeps it in the quiet tint. - if (localChatMessage.chatMessage.messageType in ChatMessage.ARCHIVE_TYPES) { + if (localChatMessage.chatMessage.messageType in ChatMessage.CHRONICLE_TYPES) { RitualNotice( localChatMessage = localChatMessage, isAnswered = true, @@ -741,9 +741,9 @@ private fun RitualNotice( ChatMessage.TYPE_FROST_FAILED -> Icons.Default.ErrorOutline ChatMessage.TYPE_FROST_APPROVAL_NEEDED -> Icons.Default.Draw - ChatMessage.TYPE_ARCHIVE_REQUESTED -> Icons.Default.History - ChatMessage.TYPE_ARCHIVE_SENT -> Icons.Default.Upload - ChatMessage.TYPE_ARCHIVE_RECEIVED -> Icons.Default.Download + ChatMessage.TYPE_CHRONICLE_REQUESTED -> Icons.Default.History + ChatMessage.TYPE_CHRONICLE_SENT -> Icons.Default.Upload + ChatMessage.TYPE_CHRONICLE_RECEIVED -> Icons.Default.Download else -> Icons.Default.PanTool } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt index 12217a1f..ec09b9ab 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -520,7 +520,7 @@ class GroupKeyStateTest { // `isSignedByGroup` is the caller that knows a threshold key and a path. // `isSignedByRoom` is the same three checks with the walk already done, and // it is what a member holding neither has to work from -- see - // docs/member-archive.md. What these pin down is that a room id alone is + // docs/member-chronicle.md. What these pin down is that a room id alone is // enough, and that it is enough for any kind rather than only a key state. /** An event of [kind] authored and signed by [author]'s room at [path]. */ @@ -575,11 +575,11 @@ class GroupKeyStateTest { @Test fun `the verifier does not care what kind it is looking at`() { - // The point of splitting it out: an archive carries documents rather than + // The point of splitting it out: a chronicle carries documents rather than // key states, and none of the three checks knows the difference. // // Note the last one. A GroupKeyStateEvent signed by the room passes here - // exactly as a dialect does, which is why the archive needs an allowlist + // exactly as a dialect does, which is why the chronicle needs an allowlist // of kinds on top of this and cannot treat "the group signed it" as // permission to apply it. listOf( @@ -629,7 +629,7 @@ class GroupKeyStateTest { @Test fun `claiming the room as author proves nothing without the signature`() { - // The forgery an archive would otherwise carry: write the room's id into + // The forgery a chronicle would otherwise carry: write the room's id into // pubKey -- every member knows it, it is in the h tag of every kind:445 // the group has ever sent -- and put anything at all in sig. The author // check and the id check both pass. The signature is the whole feature. @@ -677,7 +677,7 @@ class GroupKeyStateTest { @Test fun `malformed input is a no rather than a throw`() { - // Everything here arrives off the wire inside somebody else's archive, so + // Everything here arrives off the wire inside somebody else's chronicle, so // the failure mode has to be a verdict. A throw would take down the whole // inbound transaction the page is being applied in. val signed = signedByRoom() diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt similarity index 64% rename from composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveEventTest.kt rename to composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt index 75345782..2f4f4996 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt @@ -1,4 +1,4 @@ -package press.mantra.compose.nostr.archive +package press.mantra.compose.nostr.chronicle import com.vitorpamplona.quartz.nip01Core.core.Event import kotlin.test.Test @@ -8,9 +8,9 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -import press.mantra.compose.nostr.archive.tags.ArchiveIdTag -import press.mantra.compose.nostr.archive.tags.ArchivePageTag -import press.mantra.compose.nostr.archive.tags.ArchiveRecipientTag +import press.mantra.compose.nostr.chronicle.tags.ChronicleIdTag +import press.mantra.compose.nostr.chronicle.tags.ChroniclePageTag +import press.mantra.compose.nostr.chronicle.tags.ChronicleRecipientTag import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent @@ -28,16 +28,16 @@ import press.mantra.compose.nostr.nip30303.TranslationContributorListEvent import press.mantra.compose.nostr.nip30303.TranslationEvent /** - * The envelope an archive travels in: what it carries, what it refuses, and the + * The envelope a chronicle travels in: what it carries, what it refuses, and the * order it puts things in. * * Nothing here verifies a signature -- that is `GroupKeyStateTest`'s half, and - * `ArchiveManager`'s at apply time. What these cover is the framing, and the two + * `ChronicleManager`'s at apply time. What these cover is the framing, and the two * bounds a page has to hold whoever sent it. */ -class ArchiveEventTest { +class ChronicleEventTest { private val recipient = "d".repeat(64) - private val archiveId = "e".repeat(64) + private val chronicleId = "e".repeat(64) private fun payload( kind: Int, @@ -62,7 +62,7 @@ class ArchiveEventTest { payload(ArtifactEvent.KIND, "an artifact"), ) - val decoded = ArchiveEvent.decodePage(ArchiveEvent.encodePage(events)) + val decoded = ChronicleEvent.decodePage(ChronicleEvent.encodePage(events)) assertNotNull(decoded) assertEquals(events.map { it.content }, decoded.map { it.content }) @@ -78,26 +78,26 @@ class ArchiveEventTest { fun `a page of one is still an array`() { // The contrast with FrostSigningEvents.encodeProposal, which keeps a bare // object for a batch of one so that builds predating batching can read - // it. Both archive kinds are new, so there is no such build and no reason + // it. Both chronicle kinds are new, so there is no such build and no reason // to carry the second shape. - val encoded = ArchiveEvent.encodePage(listOf(payload(DialectEvent.KIND))) + val encoded = ChronicleEvent.encodePage(listOf(payload(DialectEvent.KIND))) assertTrue(encoded.startsWith("["), "a page is always an array") assertTrue(encoded.endsWith("]")) - assertEquals(1, ArchiveEvent.decodePage(encoded)?.size) + assertEquals(1, ChronicleEvent.decodePage(encoded)?.size) } @Test fun `a page that is not an array is not a page`() { - assertNull(ArchiveEvent.decodePage(payload(DialectEvent.KIND).toJson())) - assertNull(ArchiveEvent.decodePage("")) - assertNull(ArchiveEvent.decodePage("not json")) - assertNull(ArchiveEvent.decodePage("[unclosed")) + assertNull(ChronicleEvent.decodePage(payload(DialectEvent.KIND).toJson())) + assertNull(ChronicleEvent.decodePage("")) + assertNull(ChronicleEvent.decodePage("not json")) + assertNull(ChronicleEvent.decodePage("[unclosed")) } @Test - fun `an empty page is refused rather than read as an empty archive`() { - assertNull(ArchiveEvent.decodePage("[]")) + fun `an empty page is refused rather than read as an empty chronicle`() { + assertNull(ChronicleEvent.decodePage("[]")) } @Test @@ -105,29 +105,29 @@ class ArchiveEventTest { // All-or-nothing here, and per-payload at verify time. They answer // different questions: a page that will not parse has lost its framing, // so a page quietly shortened by one element would report a complete - // archive on its page count while holding less than it says. A payload + // chronicle on its page count while holding less than it says. A payload // whose signature does not verify is a well-framed page with one bad // event in it, and dropping its honest neighbours would let one forgery - // deny an entire archive. + // deny an entire chronicle. val good = payload(DialectEvent.KIND) - assertNull(ArchiveEvent.decodePage("[${good.toJson()},\"not an event\"]")) - assertNull(ArchiveEvent.decodePage("[\"not an event\",${good.toJson()}]")) + assertNull(ChronicleEvent.decodePage("[${good.toJson()},\"not an event\"]")) + assertNull(ChronicleEvent.decodePage("[\"not an event\",${good.toJson()}]")) } // ---- The two caps ---------------------------------------------------- @Test fun `a page over the event cap is refused on the way in`() { - val overCap = (0..ArchiveEvent.MAX_PAGE_EVENTS).map { + val overCap = (0..ChronicleEvent.MAX_PAGE_EVENTS).map { payload(DialectEvent.KIND, id = it.toString().padStart(64, '0')) } - assertEquals(ArchiveEvent.MAX_PAGE_EVENTS + 1, overCap.size) + assertEquals(ChronicleEvent.MAX_PAGE_EVENTS + 1, overCap.size) - assertNull(ArchiveEvent.decodePage(ArchiveEvent.encodePage(overCap))) + assertNull(ChronicleEvent.decodePage(ChronicleEvent.encodePage(overCap))) // And exactly at the cap is fine, so the bound is not off by one. - assertNotNull(ArchiveEvent.decodePage(ArchiveEvent.encodePage(overCap.dropLast(1)))) + assertNotNull(ChronicleEvent.decodePage(ChronicleEvent.encodePage(overCap.dropLast(1)))) } @Test @@ -137,30 +137,30 @@ class ArchiveEventTest { // count cap set above what MAX_PAGE_BYTES can hold is a check that never // runs, and the page-over-the-cap test above passes for the wrong reason. // Both caps have to be re-sized together; this is what says so. - val atCap = (0 until ArchiveEvent.MAX_PAGE_EVENTS).map { + val atCap = (0 until ChronicleEvent.MAX_PAGE_EVENTS).map { payload(DialectEvent.KIND, id = it.toString().padStart(64, '0')) } - val page = ArchiveEvent.encodePage(atCap) + val page = ChronicleEvent.encodePage(atCap) assertTrue( - page.encodeToByteArray().size <= ArchiveEvent.MAX_PAGE_BYTES, + page.encodeToByteArray().size <= ChronicleEvent.MAX_PAGE_BYTES, "MAX_PAGE_EVENTS payloads take ${page.encodeToByteArray().size} bytes, " + - "over the ${ArchiveEvent.MAX_PAGE_BYTES}-byte cap: the event cap can never fire" + "over the ${ChronicleEvent.MAX_PAGE_BYTES}-byte cap: the event cap can never fire" ) - assertEquals(ArchiveEvent.MAX_PAGE_EVENTS, ArchiveEvent.decodePage(page)?.size) + assertEquals(ChronicleEvent.MAX_PAGE_EVENTS, ChronicleEvent.decodePage(page)?.size) } @Test fun `a page over the byte cap is refused on the way in`() { // Checked before the JSON is parsed, because the cap exists to bound the - // work a remote party can ask this device to do. An archive is the second + // work a remote party can ask this device to do. A chronicle is the second // place in this protocol where somebody else sets that size. - val huge = ArchiveEvent.encodePage( - listOf(payload(DialectEvent.KIND, content = "x".repeat(ArchiveEvent.MAX_PAGE_BYTES))) + val huge = ChronicleEvent.encodePage( + listOf(payload(DialectEvent.KIND, content = "x".repeat(ChronicleEvent.MAX_PAGE_BYTES))) ) - assertTrue(huge.encodeToByteArray().size > ArchiveEvent.MAX_PAGE_BYTES) + assertTrue(huge.encodeToByteArray().size > ChronicleEvent.MAX_PAGE_BYTES) - assertNull(ArchiveEvent.decodePage(huge)) + assertNull(ChronicleEvent.decodePage(huge)) } @Test @@ -168,15 +168,15 @@ class ArchiveEventTest { // A multi-byte payload that fits as characters and does not as bytes. If // this were measured in characters the cap would let through up to four // times what it says. - val wide = "世".repeat(ArchiveEvent.MAX_PAGE_BYTES / 2) - val page = ArchiveEvent.encodePage(listOf(payload(DialectEvent.KIND, content = wide))) + val wide = "世".repeat(ChronicleEvent.MAX_PAGE_BYTES / 2) + val page = ChronicleEvent.encodePage(listOf(payload(DialectEvent.KIND, content = wide))) - assertTrue(page.length < ArchiveEvent.MAX_PAGE_BYTES) - assertTrue(page.encodeToByteArray().size > ArchiveEvent.MAX_PAGE_BYTES) - assertNull(ArchiveEvent.decodePage(page)) + assertTrue(page.length < ChronicleEvent.MAX_PAGE_BYTES) + assertTrue(page.encodeToByteArray().size > ChronicleEvent.MAX_PAGE_BYTES) + assertNull(ChronicleEvent.decodePage(page)) } - // ---- What an archive may carry --------------------------------------- + // ---- What a chronicle may carry --------------------------------------- @Test fun `the allowlist is every kind the group signs, and only those`() { @@ -189,72 +189,72 @@ class ArchiveEventTest { TranslationArtifactVersionEvent.KIND, TranslationChapterEvent.KIND, TranslationChunkEvent.KIND, - ).forEach { assertTrue(ArchiveEvent.isArchivable(it), "kind $it should be archivable") } + ).forEach { assertTrue(ChronicleEvent.isChroniclable(it), "kind $it should be chroniclable") } - assertEquals(8, ArchiveEvent.ARCHIVABLE_KINDS.size) + assertEquals(8, ChronicleEvent.CHRONICLABLE_KINDS.size) } @Test - fun `the translated text is archivable, now that the group signs it`() { + fun `the translated text is chroniclable, now that the group signs it`() { // Worth its own case because it was the feature's headline limitation for // as long as `saveTranslation` wrote the row and queued the member's own - // rumor. An archive can only carry what the receiver can check, so a + // rumor. A chronicle can only carry what the receiver can check, so a // translation nobody signed could not travel, and a new member got the // whole structure and none of the prose. - assertTrue(ArchiveEvent.isArchivable(TranslationChunkEvent.KIND)) + assertTrue(ChronicleEvent.isChroniclable(TranslationChunkEvent.KIND)) // Same shape, same fix: the version an artifact starts life with used to // be derived on arrival, so it was authored by the group with no // signature to show for it. It is the second item of the artifact's batch // now. - assertTrue(ArchiveEvent.isArchivable(ArtifactVersionEvent.KIND)) + assertTrue(ChronicleEvent.isChroniclable(ArtifactVersionEvent.KIND)) } @Test fun `the kinds nobody signs are still left out`() { // Nothing builds one of these at all; the inbound arm exists and no // producer does. - assertFalse(ArchiveEvent.isArchivable(TranslationEvent.KIND)) + assertFalse(ChronicleEvent.isChroniclable(TranslationEvent.KIND)) } @Test - fun `an archive may not carry a key state, however well signed`() { + fun `a chronicle may not carry a key state, however well signed`() { // The attack the allowlist exists for, and the reason it is an allowlist // rather than a denylist. A GroupKeyStateEvent is signed by the room and - // passes verification perfectly, so an archive carrying an old one is a + // passes verification perfectly, so a chronicle carrying an old one is a // validly signed statement about what the room signs with, replayed by // whoever kept a copy. Nothing but this stops it. - assertFalse(ArchiveEvent.isArchivable(GroupKeyStateEvent.KIND)) - assertNull(ArchiveEvent.applyRank(GroupKeyStateEvent.KIND)) + assertFalse(ChronicleEvent.isChroniclable(GroupKeyStateEvent.KIND)) + assertNull(ChronicleEvent.applyRank(GroupKeyStateEvent.KIND)) } @Test - fun `an archive carries documents, not envelopes or protocol`() { - assertFalse(ArchiveEvent.isArchivable(SubmissionEvent.KIND)) - assertFalse(ArchiveEvent.isArchivable(ArchiveEvent.KIND)) - assertFalse(ArchiveEvent.isArchivable(ArchiveRequestEvent.KIND)) + fun `a chronicle carries documents, not envelopes or protocol`() { + assertFalse(ChronicleEvent.isChroniclable(SubmissionEvent.KIND)) + assertFalse(ChronicleEvent.isChroniclable(ChronicleEvent.KIND)) + assertFalse(ChronicleEvent.isChroniclable(ChronicleRequestEvent.KIND)) FrostSigningEvents.ALL.forEach { - assertFalse(ArchiveEvent.isArchivable(it), "frost kind $it") + assertFalse(ChronicleEvent.isChroniclable(it), "frost kind $it") } } @Test fun `the contributor lists are left out while nothing applies them`() { // Not an oversight. ChatMessage.applyInnerEvent has no arm that writes a - // row for any of these, so archiving them would cost bytes and restore + // row for any of these, so chronicling them would cost bytes and restore // nothing. They belong in the list on the day that changes. - assertFalse(ArchiveEvent.isArchivable(TranslationArtifactVersionContributorListEvent.KIND)) - assertFalse(ArchiveEvent.isArchivable(TranslationChapterContributorListEvent.KIND)) - assertFalse(ArchiveEvent.isArchivable(TranslationContributorListEvent.KIND)) + assertFalse(ChronicleEvent.isChroniclable(TranslationArtifactVersionContributorListEvent.KIND)) + assertFalse(ChronicleEvent.isChroniclable(TranslationChapterContributorListEvent.KIND)) + assertFalse(ChronicleEvent.isChroniclable(TranslationContributorListEvent.KIND)) } // ---- Dependency order ------------------------------------------------ @Test fun `the order is the foreign keys, not the kind numbers`() { - fun rank(kind: Int) = assertNotNull(ArchiveEvent.applyRank(kind), "kind $kind has no rank") + fun rank(kind: Int) = assertNotNull(ChronicleEvent.applyRank(kind), "kind $kind has no rank") - // Every one of these is a foreign key in Room, so an archive applied the + // Every one of these is a foreign key in Room, so a chronicle applied the // other way round is a constraint violation rather than a wrong answer. assertTrue(rank(DialectEvent.KIND) < rank(ArtifactEvent.KIND)) assertTrue(rank(ArtifactEvent.KIND) < rank(ArtifactVersionEvent.KIND)) @@ -300,7 +300,7 @@ class ArchiveEventTest { TranslationChapterEvent.KIND, TranslationChunkEvent.KIND, ), - ArchiveEvent.inApplyOrder(jumbled).map { it.kind } + ChronicleEvent.inApplyOrder(jumbled).map { it.kind } ) } @@ -308,7 +308,7 @@ class ArchiveEventTest { fun `two of a kind keep the order they were handed in`() { // Nothing orders two chapters of one version, and keeping the caller's // order is one less thing that differs between two members assembling the - // same archive. + // same chronicle. val chapters = listOf( payload(ChapterEvent.KIND, "one", id = "1".repeat(64)), payload(ChapterEvent.KIND, "two", id = "2".repeat(64)), @@ -317,24 +317,24 @@ class ArchiveEventTest { assertEquals( listOf("one", "two", "three"), - ArchiveEvent.inApplyOrder(chapters).map { it.content } + ChronicleEvent.inApplyOrder(chapters).map { it.content } ) } // ---- The tags -------------------------------------------------------- @Test - fun `a page says which archive it is, where in it, and who for`() { - val template = ArchiveEvent.build( + fun `a page says which chronicle it is, where in it, and who for`() { + val template = ChronicleEvent.build( payloads = listOf(payload(DialectEvent.KIND)), - archiveId = archiveId, + chronicleId = chronicleId, index = 2, count = 5, recipient = recipient, createdAt = 1_700_000_100L ) - val readBack = ArchiveEvent( + val readBack = ChronicleEvent( id = "f".repeat(64), pubKey = "b".repeat(64), createdAt = template.createdAt, @@ -343,7 +343,7 @@ class ArchiveEventTest { sig = "" ) - assertEquals(archiveId, readBack.archiveId()) + assertEquals(chronicleId, readBack.chronicleId()) assertEquals(2, readBack.page()?.index) assertEquals(5, readBack.page()?.count) assertEquals(recipient, readBack.recipient()) @@ -351,31 +351,31 @@ class ArchiveEventTest { } @Test - fun `a page outside its own archive is not a page`() { + fun `a page outside its own chronicle is not a page`() { // Refused rather than clamped: the pair is how a receiver decides it has - // everything, so a repaired one would let a truncated archive read as + // everything, so a repaired one would let a truncated chronicle read as // complete. - assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "5", "3"))) - assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "-1", "3"))) - assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "0", "0"))) - assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "0"))) - assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "one", "3"))) + assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "5", "3"))) + assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "-1", "3"))) + assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "0", "0"))) + assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "0"))) + assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "one", "3"))) - assertEquals(0, ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "0", "1"))?.index) - assertTrue(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "0", "1"))!!.isOnlyPage()) + assertEquals(0, ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "0", "1"))?.index) + assertTrue(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "0", "1"))!!.isOnlyPage()) } @Test - fun `an archive id and a recipient have to be there to be read`() { - assertNull(ArchiveIdTag.parse(arrayOf(ArchiveIdTag.TAG_NAME))) - assertNull(ArchiveIdTag.parse(arrayOf(ArchiveIdTag.TAG_NAME, " "))) - assertNull(ArchiveIdTag.parse(arrayOf("other", archiveId))) - assertEquals(archiveId, ArchiveIdTag.parse(arrayOf(ArchiveIdTag.TAG_NAME, archiveId))?.archiveId) + fun `a chronicle id and a recipient have to be there to be read`() { + assertNull(ChronicleIdTag.parse(arrayOf(ChronicleIdTag.TAG_NAME))) + assertNull(ChronicleIdTag.parse(arrayOf(ChronicleIdTag.TAG_NAME, " "))) + assertNull(ChronicleIdTag.parse(arrayOf("other", chronicleId))) + assertEquals(chronicleId, ChronicleIdTag.parse(arrayOf(ChronicleIdTag.TAG_NAME, chronicleId))?.chronicleId) - assertNull(ArchiveRecipientTag.parse(arrayOf(ArchiveRecipientTag.TAG_NAME, "short"))) + assertNull(ChronicleRecipientTag.parse(arrayOf(ChronicleRecipientTag.TAG_NAME, "short"))) assertEquals( recipient, - ArchiveRecipientTag.parse(arrayOf(ArchiveRecipientTag.TAG_NAME, recipient))?.pubKey + ChronicleRecipientTag.parse(arrayOf(ChronicleRecipientTag.TAG_NAME, recipient))?.pubKey ) } @@ -387,34 +387,34 @@ class ArchiveEventTest { // security -- the inbound one is the boundary -- but a page nobody can // read back is better failed where it was made. assertFailsWith { - ArchiveEvent.build(emptyList(), archiveId, 0, 1, recipient) + ChronicleEvent.build(emptyList(), chronicleId, 0, 1, recipient) } assertFailsWith { - ArchiveEvent.build( - payloads = (0..ArchiveEvent.MAX_PAGE_EVENTS).map { + ChronicleEvent.build( + payloads = (0..ChronicleEvent.MAX_PAGE_EVENTS).map { payload(DialectEvent.KIND, id = it.toString().padStart(64, '0')) }, - archiveId = archiveId, + chronicleId = chronicleId, index = 0, count = 1, recipient = recipient ) } assertFailsWith { - ArchiveEvent.build( + ChronicleEvent.build( payloads = listOf(payload(GroupKeyStateEvent.KIND)), - archiveId = archiveId, + chronicleId = chronicleId, index = 0, count = 1, recipient = recipient ) } assertFailsWith { - ArchiveEvent.build( + ChronicleEvent.build( payloads = listOf( - payload(DialectEvent.KIND, content = "x".repeat(ArchiveEvent.MAX_PAGE_BYTES)) + payload(DialectEvent.KIND, content = "x".repeat(ChronicleEvent.MAX_PAGE_BYTES)) ), - archiveId = archiveId, + chronicleId = chronicleId, index = 0, count = 1, recipient = recipient @@ -424,9 +424,9 @@ class ArchiveEventTest { @Test fun `a request carries nothing but what it is`() { - val template = ArchiveRequestEvent.build(createdAt = 1_700_000_100L) + val template = ChronicleRequestEvent.build(createdAt = 1_700_000_100L) - assertEquals(ArchiveRequestEvent.KIND, template.kind) + assertEquals(ChronicleRequestEvent.KIND, template.kind) assertEquals("", template.content) // The room is the envelope and the asker is the MLS sender, so there is // nothing left for the event itself to say. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt similarity index 95% rename from composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt rename to composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt index d6f274b3..c6d13145 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt @@ -1,4 +1,4 @@ -package press.mantra.compose.nostr.archive +package press.mantra.compose.nostr.chronicle import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -39,24 +39,24 @@ import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent /** - * The assumption the whole archive rests on: a row can be turned back into the + * The assumption the whole chronicle rests on: a row can be turned back into the * event the group signed. * * Signed events are not stored as events. `FrostSigningManager.complete` applies - * them and what survives is a `Mantra*` row, so an archive has to rebuild each + * them and what survives is a `Mantra*` row, so a chronicle has to rebuild each * one with `toXEvent()` and hope it comes out byte-identical. If it does not, * the id changes, the signature no longer covers it, and every receiver drops * the payload as a forgery -- silently, one kind at a time. * * That is what the round-trip note on each `toXEvent` claims and what nothing - * asserted until now. Every kind in `ArchiveEvent.ARCHIVABLE_KINDS` is checked + * asserted until now. Every kind in `ChronicleEvent.CHRONICLABLE_KINDS` is checked * here against a real FROST quorum, because a signature that verifies is the * only evidence that the rebuild is faithful -- comparing fields would only * prove the test and the code agree about which fields matter. * - * A kind that fails here cannot be archived at all, whatever the allowlist says. + * A kind that fails here cannot be chronicled at all, whatever the allowlist says. */ -class ArchiveRoundTripTest { +class ChronicleRoundTripTest { private val participants = 3 private val threshold = 2 @@ -267,7 +267,7 @@ class ArchiveRoundTripTest { @Test fun `an artifact version survives the trip through a row`() { // The second fault of the same shape, found the moment this kind became - // archivable: `toArtifactVersionEvent` emitted [artifactId, alt] where + // chroniclable: `toArtifactVersionEvent` emitted [artifactId, alt] where // `build` emits [alt, artifactId]. It had been that way for as long as // nothing called it, which is what makes an unused rebuild dangerous // rather than merely dead -- the claim in its comment was never checked. @@ -300,7 +300,7 @@ class ArchiveRoundTripTest { @Test fun `a translated chunk survives the trip through a row`() { - // The translated text. Archivable only because the app stopped saving it + // The translated text. Chroniclable only because the app stopped saving it // as its author's rumor and started asking the group to sign it -- until // then a new member got the whole structure and none of the prose. assertRoundTrips( @@ -401,14 +401,14 @@ class ArchiveRoundTripTest { } @Test - fun `every archivable 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 // 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. assertEquals( 8, - ArchiveEvent.ARCHIVABLE_KINDS.size, - "an archivable kind was added or removed; add or remove its round-trip case" + ChronicleEvent.CHRONICLABLE_KINDS.size, + "a chroniclable kind was added or removed; add or remove its round-trip case" ) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/GroupSignedEventDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/GroupSignedEventDaoJvmTest.kt index 636a339e..f58bf006 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/GroupSignedEventDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/GroupSignedEventDaoJvmTest.kt @@ -40,7 +40,7 @@ import press.mantra.compose.nostr.nip30303.DialectEvent * * A fake signature would pass every column assertion here and prove nothing, so * the events are signed by an actual t-of-n key derived at the room's own path - * -- the same construction `ArchiveApplyJvmTest` uses. That is what makes + * -- the same construction `ChronicleApplyJvmTest` uses. That is what makes * [GroupSignedEvent.verifies] worth asserting: it is answered from the row * alone, and it is the only assertion that would notice a column quietly * failing to round-trip. @@ -237,7 +237,7 @@ class GroupSignedEventDaoJvmTest { } @Test - fun `an archive arrival does not empty what the session knew`() = runBlocking { + fun `a chronicle arrival does not empty what the session knew`() = runBlocking { seedRoom() db.groupSignedEventDao().record(rowFor()) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/ChronicleRenameMigrationJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/ChronicleRenameMigrationJvmTest.kt new file mode 100644 index 00000000..6c53b396 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/ChronicleRenameMigrationJvmTest.kt @@ -0,0 +1,193 @@ +package press.mantra.compose.database.migrations + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.model.ChatMessage +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The v13 -> v14 rename, against a real database holding rows written under the + * old word. + * + * A rename looks like the safest migration there is, which is why it is worth + * asserting. Both halves of it carry something that cannot be recovered if it is + * dropped instead of carried across: + * + * - `chronicleRequestedAt` is the only record that a device with an empty room + * has already asked the group for its history. Lose it and the device asks + * again on its next launch, and on the one after that. + * - The three chat lines are written once each -- when a chronicle is requested, + * sent and applied -- and nothing regenerates them. A type the transcript does + * not recognise is not skipped; it renders as an ordinary chat bubble, so + * "Caught up on 12 items" would come back attributed to a member as something + * they said. + * + * Run against the migration's own SQL on a bare connection rather than through + * Room, the way `FrostSigningItemMigrationJvmTest` is: it covers the rename and + * the rewrite, not Room's version wiring, which belongs to + * `PlatformDatabaseBuilder` and is the same for every migration in that list. + */ +class ChronicleRenameMigrationJvmTest { + + private val connection: SQLiteConnection = BundledSQLiteDriver().open(":memory:") + + @AfterTest + fun close() = connection.close() + + /** v13's `ChatRoom` and `ChatMessage`, verbatim from `schemas/13.json`. */ + private fun createV13() { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `ChatRoom` (`id` TEXT NOT NULL, " + + "`userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, " + + "`mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, " + + "`archiveRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, " + + "`updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, " + + "`deletedAt` INTEGER, PRIMARY KEY(`id`), " + + "FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE , " + + "FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `ChatMessage` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, " + + "`giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, " + + "`marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, " + + "`replyToMessageId` INTEGER, `quotedMessageId` INTEGER, " + + "`content` TEXT NOT NULL, `messageType` TEXT NOT NULL, " + + "`directMessageRecipientPublicKey` TEXT, `frostSigningSessionId` TEXT, " + + "`createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, " + + "`savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, " + + "FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE , " + + "FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE , " + + "FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE , " + + "FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + } + + /** A room that asked for the group's history at [requestedAt] and is still waiting. */ + private fun insertV13Room(id: String = "room", requestedAt: Long?) = connection.execSQL( + "INSERT INTO `ChatRoom` VALUES ('$id', 'user', NULL, NULL, NULL, NULL, NULL, " + + "${requestedAt ?: "NULL"}, 1000, 1000, 1000, NULL, NULL)" + ) + + private fun insertV13Message(messageType: String, content: String = "line") = + connection.execSQL( + "INSERT INTO `ChatMessage` (`senderPublicKey`, `isUserMessage`, `chatRoomId`, " + + "`content`, `messageType`, `createdAt`, `updatedAt`, `savedAt`) " + + "VALUES ('user', 0, 'room', '$content', '$messageType', 1000, 1000, 1000)" + ) + + /** Column names in declaration order, which is what Room checks the table against. */ + private fun columns(table: String): List = + connection.prepare("PRAGMA table_info(`$table`)").use { statement -> + buildList { while (statement.step()) add(statement.getText(1)) } + } + + /** One column of the one row, as text, or null when the column is null. */ + private fun read(table: String, column: String): String? = + connection.prepare("SELECT `$column` FROM `$table`").use { statement -> + if (statement.step() && !statement.isNull(0)) statement.getText(0) else null + } + + private fun messageTypes(): List = + connection.prepare("SELECT `messageType` FROM `ChatMessage` ORDER BY `id`").use { s -> + buildList { while (s.step()) add(s.getText(0)) } + } + + @Test + fun `a room still waiting on an answer keeps the moment it asked`() = runBlocking { + createV13() + insertV13Room(requestedAt = 1_700_000_000) + + MIGRATION_13_14.migrate(connection) + + // Renamed, not dropped and re-added: a null here is a device that asks + // the group for its history again on every launch. + assertEquals("1700000000", read("ChatRoom", "chronicleRequestedAt")) + } + + @Test + fun `the column keeps its place, so Room reads the table it expects`() = runBlocking { + createV13() + + MIGRATION_13_14.migrate(connection) + + assertEquals( + listOf( + "id", + "userPublicKey", + "subject", + "description", + "mlsGroupState", + "initialGiftWrapPayloadId", + "leftGroupAt", + "chronicleRequestedAt", + "createdAt", + "updatedAt", + "savedAt", + "viewedAt", + "deletedAt", + ), + columns("ChatRoom"), + "v14's ChatRoom columns, in the order schemas/14.json declares them" + ) + } + + @Test + fun `a room that never asked still reads as never having asked`() = runBlocking { + createV13() + insertV13Room(requestedAt = null) + + MIGRATION_13_14.migrate(connection) + + assertEquals(null, read("ChatRoom", "chronicleRequestedAt")) + } + + @Test + fun `the three lines about the group's record are rewritten`() = runBlocking { + createV13() + insertV13Message("archiveRequested") + insertV13Message("archiveSent") + insertV13Message("archiveReceived") + + MIGRATION_13_14.migrate(connection) + + assertEquals( + listOf( + ChatMessage.TYPE_CHRONICLE_REQUESTED, + ChatMessage.TYPE_CHRONICLE_SENT, + ChatMessage.TYPE_CHRONICLE_RECEIVED, + ), + messageTypes() + ) + } + + @Test + fun `every other line is left alone`() = runBlocking { + createV13() + insertV13Message("message") + insertV13Message(ChatMessage.TYPE_FROST_COMPLETE) + insertV13Message(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_LEGACY) + + MIGRATION_13_14.migrate(connection) + + assertEquals( + listOf( + "message", + ChatMessage.TYPE_FROST_COMPLETE, + ChatMessage.TYPE_DKG_APPROVAL_NEEDED_LEGACY, + ), + messageTypes() + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt similarity index 84% rename from composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt rename to composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt index 3103eff1..b98ac0e7 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt @@ -32,11 +32,11 @@ import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Profile import press.mantra.compose.extensions.toHex -import press.mantra.compose.nostr.archive.ArchiveEvent -import press.mantra.compose.nostr.archive.ArchiveRequestEvent -import press.mantra.compose.nostr.archive.tags.ArchiveIdTag -import press.mantra.compose.nostr.archive.tags.ArchivePageTag -import press.mantra.compose.nostr.archive.tags.ArchiveRecipientTag +import press.mantra.compose.nostr.chronicle.ChronicleEvent +import press.mantra.compose.nostr.chronicle.ChronicleRequestEvent +import press.mantra.compose.nostr.chronicle.tags.ChronicleIdTag +import press.mantra.compose.nostr.chronicle.tags.ChroniclePageTag +import press.mantra.compose.nostr.chronicle.tags.ChronicleRecipientTag import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent @@ -58,12 +58,12 @@ import press.mantra.compose.nostr.nip30303.TranslationChunkEvent * member who sent them. * * The negative cases matter more than the positive one. Verification is what - * makes an archive safe to accept from anybody, so what has to be proved is that + * makes a chronicle safe to accept from anybody, so what has to be proved is that * it refuses -- a forged payload beside honest ones, and a genuine key state * replayed out of its epoch, which passes every signature check there is and is * stopped by nothing but the allowlist. */ -class ArchiveApplyJvmTest { +class ChronicleApplyJvmTest { /** The member who did the work. */ private val sender: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) @@ -232,7 +232,7 @@ class ArchiveApplyJvmTest { ).also { apply(sender, it) } // The second item of the artifact's own signing batch, not a row derived - // on arrival -- which is what makes it archivable at all. + // on arrival -- which is what makes it chroniclable at all. val version = signed(ArtifactVersionEvent.initialVersionOf(artifact).single()) .also { apply(sender, it) } @@ -293,7 +293,7 @@ class ArchiveApplyJvmTest { ).also { apply(sender, it) } // The translated text. Group-signed now, so it can travel and be checked - // -- until it was, an archive handed a new member the whole structure and + // -- until it was, a chronicle handed a new member the whole structure and // none of the prose. apply( sender, @@ -310,15 +310,15 @@ class ArchiveApplyJvmTest { } /** - * An archive page as it reaches a device: stored as an inner event first, + * A chronicle page as it reaches a device: stored as an inner event first, * exactly as the inbound path stores every payload it decrypts before * dispatching on kind, then handed to the manager. */ private suspend fun deliver( - template: EventTemplate, + template: EventTemplate, to: String = newMember, from: String = oldMember, - ): ArchiveManager.Outcome { + ): ChronicleManager.Outcome { val page = Event( id = EventHasher.hashId( pubKey = from, @@ -331,7 +331,7 @@ class ArchiveApplyJvmTest { createdAt = template.createdAt, kind = template.kind, tags = template.tags, - // An archive page is a member's own rumor. What carries the group's + // A chronicle page is a member's own rumor. What carries the group's // word is each payload's signature, not the envelope's. content = template.content, sig = "" @@ -349,10 +349,10 @@ class ArchiveApplyJvmTest { ) ) - return ArchiveManager.receive(receiver, chatRoomId, to, page) + return ChronicleManager.receive(receiver, chatRoomId, to, page) } - private suspend fun senderArchive() = ArchiveManager.assemble(sender, chatRoomId, newMember) + private suspend fun senderChronicle() = ChronicleManager.assemble(sender, chatRoomId, newMember) private suspend fun rowCounts(db: MantraDatabase): Map { val dialects = db.mantraDialectDao().getDialectsByChatRoomId(chatRoomId) @@ -386,12 +386,12 @@ class ArchiveApplyJvmTest { } /** - * Every archived row as (id, author, signature), sorted. + * Every chronicled row as (id, author, signature), sorted. * * Counting is not the claim. Two databases can hold the same number of * artifacts and disagree about every one of them, and a rebuild that lost the * group's signature -- or re-authored a row as whoever sent it -- would pass a - * count and fail the only thing the archive is for. + * count and fail the only thing the chronicle is for. */ private suspend fun rowFingerprints(db: MantraDatabase): List> { val artifacts = db.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId) @@ -438,12 +438,12 @@ class ArchiveApplyJvmTest { "the new member starts with nothing, which is the whole problem" ) - senderArchive().forEach { deliver(it) } + senderChronicle().forEach { deliver(it) } assertEquals(rowCounts(sender), rowCounts(receiver)) // Not merely the same shape: the same rows, each with the group's own - // signature on it. An archive that produced look-alike rows authored by + // signature on it. A chronicle that produced look-alike rows authored by // the member who sent them would pass a count and fail the only thing // this is for. val fingerprints = rowFingerprints(receiver) @@ -452,7 +452,7 @@ class ArchiveApplyJvmTest { // And every one of them is the room's own work, with no exception left. // The artifact version used to be one -- derived on arrival, so authored // by the group and carrying no signature to show for it -- and is signed - // in the artifact's own batch now, which is what let it into the archive. + // in the artifact's own batch now, which is what let it into the chronicle. fingerprints.forEach { (id, author, signature) -> assertEquals(chatRoomId, author, "row $id is not authored by the room") assertTrue(signature.isNotBlank(), "row $id came across without a signature") @@ -463,23 +463,23 @@ class ArchiveApplyJvmTest { * The member who was handed history can hand it on. * * Rows are a reading of what the group signed; the events are the thing - * itself, and until they are kept the recipient of an archive is a dead end + * itself, and until they are kept the recipient of a chronicle is a dead end * -- holding the work, unable to prove any of it, and unable to build a page * for the next member to arrive. The path is the room's, read from the key * state when the recipient has one. */ @Test - fun `the archive leaves the receiver holding the events, not only the rows`() = runBlocking { + fun `the chronicle leaves the receiver holding the events, not only the rows`() = runBlocking { seedSenderWork() seedRoom(receiver, newMember) - senderArchive().forEach { deliver(it) } + senderChronicle().forEach { deliver(it) } val signed = receiver.groupSignedEventDao().getByChatRoomId(chatRoomId) assertEquals( - senderArchive() - .flatMap { assertNotNull(ArchiveEvent.decodePage(it.content)) } + senderChronicle() + .flatMap { assertNotNull(ChronicleEvent.decodePage(it.content)) } .map { it.id } .toSet(), signed.map { it.id }.toSet(), @@ -502,18 +502,18 @@ class ArchiveApplyJvmTest { } @Test - fun `an archive files no chat lines`() = runBlocking { + fun `a chronicle files no chat lines`() = runBlocking { seedSenderWork() seedRoom(receiver, newMember) - senderArchive().forEach { deliver(it) } + senderChronicle().forEach { deliver(it) } assertEquals( emptyList(), receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId) .map { it.chatMessage.messageType } - .filterNot { it in ChatMessage.ARCHIVE_TYPES }, - "the archive restores the work; the conversation is forward secret and stays gone" + .filterNot { it in ChatMessage.CHRONICLE_TYPES }, + "the chronicle restores the work; the conversation is forward secret and stays gone" ) } @@ -526,15 +526,15 @@ class ArchiveApplyJvmTest { // before the chapter it names, chunks before their chapter, the artifact // last. Relays give no ordering guarantee, so this is the ordinary case // rather than the adversarial one. - val payloads = senderArchive() - .flatMap { assertNotNull(ArchiveEvent.decodePage(it.content)) } - val archiveId = "e".repeat(64) + val payloads = senderChronicle() + .flatMap { assertNotNull(ChronicleEvent.decodePage(it.content)) } + val chronicleId = "e".repeat(64) val outcomes = payloads.reversed().mapIndexed { index, payload -> deliver( - ArchiveEvent.build( + ChronicleEvent.build( payloads = listOf(payload), - archiveId = archiveId, + chronicleId = chronicleId, index = index, count = payloads.size, recipient = newMember, @@ -553,7 +553,7 @@ class ArchiveApplyJvmTest { assertEquals( 0, outcomes.last().failed, - "the last page completes the archive, so the pass it triggers should leave nothing" + "the last page completes the chronicle, so the pass it triggers should leave nothing" ) assertEquals(rowCounts(sender), rowCounts(receiver)) @@ -607,9 +607,9 @@ class ArchiveApplyJvmTest { ) val outcome = deliver( - ArchiveEvent.build( + ChronicleEvent.build( payloads = forgeries + honest, - archiveId = "f".repeat(64), + chronicleId = "f".repeat(64), index = 0, count = 1, recipient = newMember, @@ -625,7 +625,7 @@ class ArchiveApplyJvmTest { assertEquals(honest.id, dialects.single().id) // And nothing forged was kept as an event either. The record is what a - // later archive is built from, so a forgery filed here would be one this + // later chronicle is built from, so a forgery filed here would be one this // member goes on to hand to everybody else. assertEquals( listOf(honest.id), @@ -634,7 +634,7 @@ class ArchiveApplyJvmTest { } @Test - fun `a key state the room really signed cannot be replayed through an archive`() = runBlocking { + fun `a key state the room really signed cannot be replayed through a chronicle`() = runBlocking { seedSenderWork() seedRoom(receiver, newMember) @@ -657,18 +657,18 @@ class ArchiveApplyJvmTest { "the point of this test is that verification passes" ) - // Built by hand: ArchiveEvent.build refuses this kind, which is the + // Built by hand: ChronicleEvent.build refuses this kind, which is the // outbound half of the same rule. Only a hand-rolled page gets this far. - val page = EventTemplate( + val page = EventTemplate( createdAt = 1_700_000_100L, - kind = ArchiveEvent.KIND, + kind = ChronicleEvent.KIND, tags = arrayOf( - arrayOf("alt", ArchiveEvent.ALT_DESCRIPTION), - ArchiveIdTag.assemble("a".repeat(64)), - ArchivePageTag.assemble(0, 1), - ArchiveRecipientTag.assemble(newMember), + arrayOf("alt", ChronicleEvent.ALT_DESCRIPTION), + ChronicleIdTag.assemble("a".repeat(64)), + ChroniclePageTag.assemble(0, 1), + ChronicleRecipientTag.assemble(newMember), ), - content = ArchiveEvent.encodePage(listOf(keyState)), + content = ChronicleEvent.encodePage(listOf(keyState)), ) val outcome = deliver(page) @@ -679,11 +679,11 @@ class ArchiveApplyJvmTest { } @Test - fun `a member the archive is not addressed to applies none of it`() = runBlocking { + fun `a member the chronicle is not addressed to applies none of it`() = runBlocking { seedSenderWork() seedRoom(receiver, newMember) - val outcome = senderArchive().map { deliver(it, to = "7".repeat(64)) } + val outcome = senderChronicle().map { deliver(it, to = "7".repeat(64)) } assertEquals(0, outcome.sumOf { it.applied }) assertEquals( @@ -698,22 +698,22 @@ class ArchiveApplyJvmTest { } @Test - fun `applying the same archive twice changes nothing the second time`() = runBlocking { + fun `applying the same chronicle twice changes nothing the second time`() = runBlocking { seedSenderWork() seedRoom(receiver, newMember) - val archive = senderArchive() - archive.forEach { deliver(it) } + val chronicle = senderChronicle() + chronicle.forEach { deliver(it) } val afterFirst = rowCounts(receiver) - archive.forEach { deliver(it) } + chronicle.forEach { deliver(it) } assertEquals(afterFirst, rowCounts(receiver)) assertEquals( emptyList(), receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId) .map { it.chatMessage.messageType } - .filterNot { it in ChatMessage.ARCHIVE_TYPES }, + .filterNot { it in ChatMessage.CHRONICLE_TYPES }, ) } @@ -727,17 +727,17 @@ class ArchiveApplyJvmTest { fun `a device holding none of the room's work asks for it`() = runBlocking { seedRoom(receiver, newMember) - assertTrue(ArchiveManager.requestIfEmpty(receiver, chatRoomId, newMember)) + assertTrue(ChronicleManager.requestIfEmpty(receiver, chatRoomId, newMember)) // Queued with no chat line, the way a signing message travels. Broadcast // does not depend on one, and a row of envelopes in the transcript is not - // what an archive should leave behind. - assertEquals(1, queued(receiver, ArchiveRequestEvent.KIND).size) + // what a chronicle should leave behind. + assertEquals(1, queued(receiver, ChronicleRequestEvent.KIND).size) // One line saying so, and nothing else. Asking is a thing this device did // and the room would otherwise sit empty with no explanation. assertEquals( - listOf(ChatMessage.TYPE_ARCHIVE_REQUESTED), + listOf(ChatMessage.TYPE_CHRONICLE_REQUESTED), receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId) .map { it.chatMessage.messageType } ) @@ -747,18 +747,18 @@ class ArchiveApplyJvmTest { fun `a device does not ask twice while an answer is in flight`() = runBlocking { seedRoom(receiver, newMember) - assertTrue(ArchiveManager.requestIfEmpty(receiver, chatRoomId, newMember)) - assertFalse(ArchiveManager.requestIfEmpty(receiver, chatRoomId, newMember)) + assertTrue(ChronicleManager.requestIfEmpty(receiver, chatRoomId, newMember)) + assertFalse(ChronicleManager.requestIfEmpty(receiver, chatRoomId, newMember)) - assertEquals(1, queued(receiver, ArchiveRequestEvent.KIND).size) + assertEquals(1, queued(receiver, ChronicleRequestEvent.KIND).size) } @Test fun `a device holding the work does not ask for it`() = runBlocking { seedSenderWork() - assertFalse(ArchiveManager.requestIfEmpty(sender, chatRoomId, oldMember)) - assertEquals(0, queued(sender, ArchiveRequestEvent.KIND).size) + assertFalse(ChronicleManager.requestIfEmpty(sender, chatRoomId, oldMember)) + assertEquals(0, queued(sender, ChronicleRequestEvent.KIND).size) } @Test @@ -766,30 +766,30 @@ class ArchiveApplyJvmTest { seedSenderWork() seedRoom(receiver, newMember) - ArchiveManager.requestIfEmpty(receiver, chatRoomId, newMember) - assertNotNull(receiver.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.archiveRequestedAt) + ChronicleManager.requestIfEmpty(receiver, chatRoomId, newMember) + assertNotNull(receiver.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.chronicleRequestedAt) - senderArchive().forEach { deliver(it) } + senderChronicle().forEach { deliver(it) } // Cleared on anything applied rather than on the sender claiming the - // archive was complete. A page count is the sender's word about the + // chronicle was complete. A page count is the sender's word about the // transfer, not about the group's record, so a member who left work out // must not get the last word on whether to ask again. - assertNull(receiver.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.archiveRequestedAt) + assertNull(receiver.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.chronicleRequestedAt) } @Test - fun `answering a request queues the archive for whoever asked`() = runBlocking { + fun `answering a request queues the chronicle for whoever asked`() = runBlocking { seedSenderWork() - val pages = ArchiveManager.sendTo(sender, chatRoomId, oldMember, recipient = newMember) + val pages = ChronicleManager.sendTo(sender, chatRoomId, oldMember, recipient = newMember) assertEquals(1, pages) - val queuedPage = queued(sender, ArchiveEvent.KIND).single() + val queuedPage = queued(sender, ChronicleEvent.KIND).single() assertEquals(oldMember, queuedPage.publicKey, "the page is the answering member's own rumor") - val page = ArchiveEvent( + val page = ChronicleEvent( id = queuedPage.id, pubKey = queuedPage.publicKey, createdAt = queuedPage.createdAt.epochSeconds, @@ -798,30 +798,30 @@ class ArchiveApplyJvmTest { sig = "", ) assertEquals(newMember, page.recipient()) - assertEquals(ArchiveEvent.ARCHIVABLE_KINDS, page.payloads()?.map { it.kind }?.toSet()) + assertEquals(ChronicleEvent.CHRONICLABLE_KINDS, page.payloads()?.map { it.kind }?.toSet()) - // One line about the archive, and no line per page. + // One line about the chronicle, and no line per page. assertEquals( - listOf(ChatMessage.TYPE_ARCHIVE_SENT), + listOf(ChatMessage.TYPE_CHRONICLE_SENT), sender.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId) .map { it.chatMessage.messageType } ) } @Test - fun `a member with nothing signed answers nothing rather than an empty archive`() = runBlocking { + fun `a member with nothing signed answers nothing rather than an empty chronicle`() = runBlocking { seedRoom(receiver, newMember) - assertEquals(0, ArchiveManager.sendTo(receiver, chatRoomId, newMember, recipient = oldMember)) - assertEquals(0, queued(receiver, ArchiveEvent.KIND).size) + assertEquals(0, ChronicleManager.sendTo(receiver, chatRoomId, newMember, recipient = oldMember)) + assertEquals(0, queued(receiver, ChronicleEvent.KIND).size) } @Test fun `a member does not answer their own request`() = runBlocking { seedSenderWork() - assertEquals(0, ArchiveManager.sendTo(sender, chatRoomId, oldMember, recipient = oldMember)) - assertEquals(0, queued(sender, ArchiveEvent.KIND).size) + assertEquals(0, ChronicleManager.sendTo(sender, chatRoomId, oldMember, recipient = oldMember)) + assertEquals(0, queued(sender, ChronicleEvent.KIND).size) } @Test @@ -830,13 +830,13 @@ class ArchiveApplyJvmTest { seedRoom(receiver, newMember) // The joiner asks. - assertTrue(ArchiveManager.requestIfEmpty(receiver, chatRoomId, newMember)) + assertTrue(ChronicleManager.requestIfEmpty(receiver, chatRoomId, newMember)) // A member holding the work answers it, addressed to whoever asked. - ArchiveManager.sendTo(sender, chatRoomId, oldMember, recipient = newMember) + ChronicleManager.sendTo(sender, chatRoomId, oldMember, recipient = newMember) // The pages reach the joiner, who applies them. - queued(sender, ArchiveEvent.KIND).forEach { queuedPage -> + queued(sender, ChronicleEvent.KIND).forEach { queuedPage -> deliver( EventTemplate( createdAt = queuedPage.createdAt.epochSeconds, @@ -848,7 +848,7 @@ class ArchiveApplyJvmTest { } assertEquals(rowCounts(sender), rowCounts(receiver)) - assertNull(receiver.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.archiveRequestedAt) + assertNull(receiver.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.chronicleRequestedAt) } // ---- The push behind a Welcome --------------------------------------- @@ -898,11 +898,11 @@ class ArchiveApplyJvmTest { createdAt = Clock.System.now(), ) - val page = queued(sender, ArchiveEvent.KIND).single() + val page = queued(sender, ChronicleEvent.KIND).single() assertEquals( newMember, - ArchiveEvent( + ChronicleEvent( id = page.id, pubKey = page.publicKey, createdAt = page.createdAt.epochSeconds, @@ -926,9 +926,9 @@ class ArchiveApplyJvmTest { createdAt = Clock.System.now(), ) - assertEquals(0, queued(sender, ArchiveEvent.KIND).size) + assertEquals(0, queued(sender, ChronicleEvent.KIND).size) // The Welcome itself still went out. A push that has nothing to say is - // not a failed invite, and nothing about the archive may report one: + // not a failed invite, and nothing about the chronicle may report one: // the invitee asks for themselves on their first open regardless. assertEquals( 1, @@ -943,18 +943,18 @@ class ArchiveApplyJvmTest { seedSenderWork() seedRoom(receiver, newMember) - ArchiveManager.requestIfEmpty(receiver, chatRoomId, newMember) + ChronicleManager.requestIfEmpty(receiver, chatRoomId, newMember) // Delivered a page at a time and out of order, so the sweep runs several // times over several pages. The transcript is not a log of that. - val payloads = senderArchive() - .flatMap { assertNotNull(ArchiveEvent.decodePage(it.content)) } + val payloads = senderChronicle() + .flatMap { assertNotNull(ChronicleEvent.decodePage(it.content)) } payloads.reversed().forEachIndexed { index, payload -> deliver( - ArchiveEvent.build( + ChronicleEvent.build( payloads = listOf(payload), - archiveId = "e".repeat(64), + chronicleId = "e".repeat(64), index = index, count = payloads.size, recipient = newMember, @@ -964,7 +964,7 @@ class ArchiveApplyJvmTest { } assertEquals( - listOf(ChatMessage.TYPE_ARCHIVE_REQUESTED, ChatMessage.TYPE_ARCHIVE_RECEIVED), + listOf(ChatMessage.TYPE_CHRONICLE_REQUESTED, ChatMessage.TYPE_CHRONICLE_RECEIVED), receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId) .map { it.chatMessage.messageType } ) @@ -977,7 +977,7 @@ class ArchiveApplyJvmTest { // The invite-time push lands before the member has opened the room, so // "caught up on work you have not seen yet" is a line about nothing. - senderArchive().forEach { deliver(it) } + senderChronicle().forEach { deliver(it) } assertEquals( emptyList(), diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveAssemblyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt similarity index 85% rename from composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveAssemblyJvmTest.kt rename to composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt index 77e8e103..b8886c4a 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveAssemblyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt @@ -29,7 +29,7 @@ import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Profile import press.mantra.compose.extensions.toHex -import press.mantra.compose.nostr.archive.ArchiveEvent +import press.mantra.compose.nostr.chronicle.ChronicleEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.nip30303.SubmissionEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent @@ -42,15 +42,15 @@ import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent /** - * Assembling a room's archive out of the rows a device actually holds. + * Assembling a room's chronicle out of the rows a device actually holds. * * The seed is the real inbound path -- `ChatMessage.applyInnerEvent`, the same - * call a finished signing session makes -- so what is archived here is what a + * call a finished signing session makes -- so what is chronicled here is what a * member's database really contains rather than rows built to suit the test. * That matters because the whole risk in this direction is a rebuild that * differs from what was signed, and rows assembled by hand would hide it. */ -class ArchiveAssemblyJvmTest { +class ChronicleAssemblyJvmTest { private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) @AfterTest @@ -229,7 +229,7 @@ class ArchiveAssemblyJvmTest { ) // The second item of the artifact's own batch, built the way the batch - // builds it. It used to be derived on arrival, which is why the archive + // builds it. It used to be derived on arrival, which is why the chronicle // once could not carry it: a derived row has no signature to check. val version = apply(ArtifactVersionEvent.initialVersionOf(artifact).single()) @@ -299,30 +299,30 @@ class ArchiveAssemblyJvmTest { ) } - private suspend fun archivedPayloads(): List = - ArchiveManager.assemble(db, chatRoomId, newMember) - .flatMap { assertNotNull(ArchiveEvent.decodePage(it.content)) } + private suspend fun chronicledPayloads(): List = + ChronicleManager.assemble(db, chatRoomId, newMember) + .flatMap { assertNotNull(ChronicleEvent.decodePage(it.content)) } @Test - fun `every payload in an archive is one the room signed`() = runBlocking { + fun `every payload in a chronicle is one the room signed`() = runBlocking { seedSignedWork() - val payloads = archivedPayloads() + val payloads = chronicledPayloads() - assertTrue(payloads.isNotEmpty(), "a room with signed work archives something") + assertTrue(payloads.isNotEmpty(), "a room with signed work chronicles something") payloads.forEach { assertTrue( GroupKeyStateEvent.isSignedByRoom(it, chatRoomId), - "kind ${it.kind} (${it.id.take(8)}) was archived without a signature the room can be checked for" + "kind ${it.kind} (${it.id.take(8)}) was chronicled without a signature the room can be checked for" ) } } @Test - fun `an archive 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() - val byKind = archivedPayloads().groupBy { it.kind } + val byKind = chronicledPayloads().groupBy { it.kind } assertEquals(1, byKind[DialectEvent.KIND]?.size) assertEquals(1, byKind[ArtifactEvent.KIND]?.size) @@ -333,16 +333,16 @@ class ArchiveAssemblyJvmTest { assertEquals(1, byKind[TranslationChapterEvent.KIND]?.size) assertEquals(1, byKind[TranslationChunkEvent.KIND]?.size) - assertEquals(ArchiveEvent.ARCHIVABLE_KINDS, byKind.keys) + assertEquals(ChronicleEvent.CHRONICLABLE_KINDS, byKind.keys) } @Test - fun `an archive is in dependency order across its whole length`() = runBlocking { + fun `a chronicle is in dependency order across its whole length`() = runBlocking { seedSignedWork() - val ranks = archivedPayloads().map { assertNotNull(ArchiveEvent.applyRank(it.kind)) } + val ranks = chronicledPayloads().map { assertNotNull(ChronicleEvent.applyRank(it.kind)) } - assertEquals(ranks.sorted(), ranks, "an archive out of order is a foreign key violation") + assertEquals(ranks.sorted(), ranks, "a chronicle out of order is a foreign key violation") } @Test @@ -372,27 +372,27 @@ class ArchiveAssemblyJvmTest { ) assertEquals( 1, - archivedPayloads().count { it.kind == DialectEvent.KIND }, - "the unsigned dialect should not have been archived" + chronicledPayloads().count { it.kind == DialectEvent.KIND }, + "the unsigned dialect should not have been chronicled" ) } @Test - fun `a room with nothing signed archives nothing, and says so rather than failing`() = runBlocking { + fun `a room with nothing signed chronicles nothing, and says so rather than failing`() = runBlocking { seedRoom() - assertEquals(emptyList(), ArchiveManager.assemble(db, chatRoomId, newMember)) + assertEquals(emptyList(), ChronicleManager.assemble(db, chatRoomId, newMember)) } @Test - fun `an archive of one page still says it is one of one`() = runBlocking { + fun `a chronicle of one page still says it is one of one`() = runBlocking { seedSignedWork() - val pages = ArchiveManager.assemble(db, chatRoomId, newMember) + val pages = ChronicleManager.assemble(db, chatRoomId, newMember) assertEquals(1, pages.size, "this much work fits one page") - val page = ArchiveEvent( + val page = ChronicleEvent( id = "d".repeat(64), pubKey = chatRoomId, createdAt = pages.single().createdAt, @@ -404,7 +404,7 @@ class ArchiveAssemblyJvmTest { assertEquals(0, page.page()?.index) assertEquals(1, page.page()?.count) assertEquals(newMember, page.recipient()) - assertNotNull(page.archiveId()) + assertNotNull(page.chronicleId()) } // ---- Reading from the record rather than rebuilding ------------------- @@ -418,16 +418,16 @@ class ArchiveAssemblyJvmTest { * row could have failed to keep. */ @Test - fun `an archive carries the group's events as the group signed them`() = runBlocking { + fun `a chronicle carries the group's events as the group signed them`() = runBlocking { seedSignedWork() recordEverythingApplied() - val payloads = archivedPayloads().associateBy { it.id } + val payloads = chronicledPayloads().associateBy { it.id } assertEquals( applied.map { it.id }.toSet(), payloads.keys, - "everything the group signed here should be in the archive" + "everything the group signed here should be in the chronicle" ) applied.forEach { event -> assertEquals( @@ -439,10 +439,10 @@ class ArchiveAssemblyJvmTest { } /** - * Both sources, one archive, and nothing counted twice. + * Both sources, one chronicle, and nothing counted twice. * * The state a device upgrading mid-life is in: older work only the rows - * remember, newer work on file as events. Neither half is the archive on its + * remember, newer work on file as events. Neither half is the chronicle on its * own, and an event held both ways is still one event. */ @Test @@ -453,7 +453,7 @@ class ArchiveAssemblyJvmTest { // a room that was upgraded partway through its life looks like. record(*applied.take(applied.size / 2).toTypedArray()) - val payloads = archivedPayloads() + val payloads = chronicledPayloads() assertEquals( applied.map { it.id }.toSet(), @@ -474,11 +474,11 @@ class ArchiveAssemblyJvmTest { * one as its first act -- so it is on file in every room that has ever * signed anything. The rebuild could not reach one because no `Mantra*` row * holds it; the record holds every kind. Without the filter on the way out - * `ArchiveEvent.build` refuses the page and the room's whole archive fails + * `ChronicleEvent.build` refuses the page and the room's whole chronicle fails * on the one event every room has. */ @Test - fun `a key state the room really signed is never archived`() = runBlocking { + fun `a key state the room really signed is never chronicled`() = runBlocking { seedSignedWork() recordEverythingApplied() @@ -499,20 +499,20 @@ class ArchiveAssemblyJvmTest { "the point of this test is that verification passes" ) - val payloads = archivedPayloads() + val payloads = chronicledPayloads() - assertTrue(payloads.isNotEmpty(), "the rest of the room's work still archives") + assertTrue(payloads.isNotEmpty(), "the rest of the room's work still chronicles") assertEquals( - ArchiveEvent.ARCHIVABLE_KINDS, + ChronicleEvent.CHRONICLABLE_KINDS, payloads.map { it.kind }.toSet(), - "an archive carries the document kinds and nothing else" + "a chronicle carries the document kinds and nothing else" ) assertTrue(payloads.none { it.id == keyState.id }) } /** The same rule, for a kind that is merely not on the list rather than dangerous. */ @Test - fun `a signed kind the archive has no arm for is left behind`() = runBlocking { + fun `a signed kind the chronicle has no arm for is left behind`() = runBlocking { seedSignedWork() recordEverythingApplied() @@ -528,8 +528,8 @@ class ArchiveAssemblyJvmTest { ) assertEquals( - ArchiveEvent.ARCHIVABLE_KINDS, - archivedPayloads().map { it.kind }.toSet() + ChronicleEvent.CHRONICLABLE_KINDS, + chronicledPayloads().map { it.kind }.toSet() ) } @@ -544,7 +544,7 @@ class ArchiveAssemblyJvmTest { * find: the label never left. */ @Test - fun `an artifact the rebuild has to leave out still archives from the record`() = runBlocking { + fun `an artifact the rebuild has to leave out still chronicles from the record`() = runBlocking { seedRoom() val dialect = apply( @@ -575,37 +575,37 @@ class ArchiveAssemblyJvmTest { // Rows only: the rebuild reaches the dialect and gives up on the artifact. assertEquals( listOf(dialect.id), - archivedPayloads().map { it.id }, + chronicledPayloads().map { it.id }, ) record(dialect, artifact) assertEquals( setOf(dialect.id, artifact.id), - archivedPayloads().map { it.id }.toSet(), + chronicledPayloads().map { it.id }.toSet(), "the record holds the whole event, version label and all" ) } @Test - fun `two archives of the same rows do not share an id`() = runBlocking { + fun `two chronicles of the same rows do not share an id`() = runBlocking { seedSignedWork() - fun idOf(template: EventTemplate) = ArchiveEvent( + fun idOf(template: EventTemplate) = ChronicleEvent( id = "d".repeat(64), pubKey = chatRoomId, createdAt = template.createdAt, tags = template.tags, content = template.content, sig = "" - ).archiveId() + ).chronicleId() // Two members answering one request is the ordinary case, and their page // counts differ whenever their databases do. Sharing an id would let one - // archive's pages be counted towards the other's total. + // chronicle's pages be counted towards the other's total. assertTrue( - idOf(ArchiveManager.assemble(db, chatRoomId, newMember).single()) != - idOf(ArchiveManager.assemble(db, chatRoomId, newMember).single()) + idOf(ChronicleManager.assemble(db, chatRoomId, newMember).single()) != + idOf(ChronicleManager.assemble(db, chatRoomId, newMember).single()) ) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index 437f1bc3..0112d3c6 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -516,7 +516,7 @@ class SignedGroupKeyStateTest { * only on a device that ran the session and are swept away with it. This * reads the record that outlives it -- and reads it back through * `verifies()`, so a column that failed to round-trip fails here rather than - * years later when somebody tries to hand the batch on in an archive. + * years later when somebody tries to hand the batch on in a chronicle. * * The path is the part no device is told. Each resolves it from the room it * is standing in, so both arriving at `m/9420/0/0` is two independent diff --git a/docs/README.md b/docs/README.md index 4f6a69c6..905be8b1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ silent, or a decision that looked arbitrary and was not. | [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken | | [frost-batch-signing.md](./frost-batch-signing.md) | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it | | [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite | -| [member-archive.md](./member-archive.md) | handing a member added after the work was done the group's signed record — why the events are not on the wire at all, and why the room's id is enough to verify them | +| [member-chronicle.md](./member-chronicle.md) | handing a member added after the work was done the group's signed record — why the events are not on the wire at all, and why the room's id is enough to verify them | | [marmot-direct-messages.md](./marmot-direct-messages.md) | a one-to-one message inside a group as a stock NIP-59 gift wrap — what its MIP-03 carve-out costs, why the sender cannot read their own, and the one query that would broadcast it | | [mls-skipped-keys.md](./mls-skipped-keys.md) | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app | | [long-running-sync.md](./long-running-sync.md) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list | @@ -23,7 +23,7 @@ report — it is silent, and it looks like every other kind of delivery failure. sync note stands alone, and the dead-code inventory reads as a follow-up to it. The batch-signing note is a phased plan that has been built: read it after the derivation note, whose one rule is the same one it is built around. The member -archive note is a phased plan that has not been built, and reads as the +chronicle note is a phased plan that has not been built, and reads as the membership note's unanswered half: what a member who joins late can be given, and the one thing they cannot. The jvm-target note is unrelated to all of them: it is a build and packaging story. diff --git a/docs/member-archive.md b/docs/member-chronicle.md similarity index 83% rename from docs/member-archive.md rename to docs/member-chronicle.md index 5eb50732..2c8f146e 100644 --- a/docs/member-archive.md +++ b/docs/member-chronicle.md @@ -2,7 +2,7 @@ A member added after the work was done sees none of it, and no amount of waiting fixes that. This is how to send them the group's signed record, why the sending -member cannot be trusted and does not need to be, and the one thing an archive +member cannot be trusted and does not need to be, and the one thing a chronicle cannot give them. Read [shared-key-derivation.md](./shared-key-derivation.md) first. The property @@ -17,7 +17,7 @@ times worth reading: | what the plan said | what it turned out to be | |---|---| -| nine archivable kinds | six at first, eight now. The three that were unsigned were fixed in the app rather than worked around here -- see [Phase 3](#what-is-actually-archivable) | +| nine chroniclable kinds | six at first, eight now. The three that were unsigned were fixed in the app rather than worked around here -- see [Phase 3](#what-is-actually-chroniclable) | | `MAX_PAGE_EVENTS = 256` | 128. At 256 the byte cap always binds first and the count cap can never fire | | "assemble, order, pack and queue" | assemble only; queueing moved to Phase 5, next to the thing that decides when | | "re-read the room between the invite and the assembly" | unnecessary; that rule is about the MLS snapshot a commit is built on | @@ -52,10 +52,10 @@ not follow from the first and is not fixed by fixing it: even a member who could decrypt the entire back-transcript would still hold nothing an artifact, chapter or chunk could be built from. -Which makes an archive not a convenience but the only path, and fixes the line +Which makes a chronicle not a convenience but the only path, and fixes the line the design has to hold: -> **An archive carries what the group signed. Never the chat.** +> **A chronicle carries what the group signed. Never the chat.** Two reasons, and the second is the load-bearing one. Restoring the chat would undo forward secrecy on purpose. And a signed event is the only thing a new @@ -93,17 +93,17 @@ what follows: | question | answer, and why | |---|---| -| Who may send an archive? | Anyone in the room. The receiver checks every payload, so a hostile sender can inject nothing. | +| Who may send a chronicle? | Anyone in the room. The receiver checks every payload, so a hostile sender can inject nothing. | | Does it need encrypting to the recipient? | No. It is the group's own history going back to the group. | | Does the new member need the key state first? | No. That was the ordering problem this removes. | -| What can a hostile archive do? | Omit. Not forge. See [What this does not do](#what-this-does-not-do). | +| What can a hostile chronicle do? | Omit. Not forge. See [What this does not do](#what-this-does-not-do). | ### The guard that is not optional Nothing on the inbound nip30303 path verifies a signature today. `ChatMessage.applyInnerEvent` parses and upserts, and that is *correct* as things stand: rumors carry `sig = ""` and are authenticated by the MLS frame, so nothing -on the wire has ever claimed group authorship. An archive is the first thing that +on the wire has ever claimed group authorship. A chronicle is the first thing that does. So `isSignedByRoom` is not hardening. It is the feature's entire security, and @@ -118,10 +118,10 @@ signature. That makes **every kind the group has ever signed replayable by any member at any time**, which is a larger door than it first looks. `GroupKeyStateEvent` (30326) is group-signed and would pass `isSignedByRoom` -perfectly. An archive carrying an old one is a validly signed statement about +perfectly. A chronicle carrying an old one is a validly signed statement about which key the room signs with, replayed by whoever kept a copy. -> **An archive carries an allowlist of document kinds, never everything that +> **A chronicle carries an allowlist of document kinds, never everything that > verifies.** The list is the nip30303 kinds `applyInnerEvent` dispatches, and > the rule is checked on the way out *and independently on the way in*. @@ -132,10 +132,10 @@ boundary. ## Push and pull -The obvious trigger is the invite: send the archive right after the Welcome. That +The obvious trigger is the invite: send the chronicle right after the Welcome. That works, and on its own it is unreliable in the way [marmot-membership.md](./marmot-membership.md#why-this-fails-silently) describes. -An archive is an application message in the epoch the add created. If it reaches +A chronicle is an application message in the epoch the add created. If it reaches the invitee before their Welcome does -- different transports, no ordering guarantee -- it is **dropped, not deferred**, and the sender sees nothing wrong. @@ -144,9 +144,9 @@ The fix is not to make the push more careful. It is to let the joiner ask: - A request is proof of arrival. A device that can send an application message in the room has processed its Welcome; the race has nothing left to lose. - It covers what a push never can. A reinstall, a second device, a member whose - inviter has since left, an archive that was lost -- none of those has an invite + inviter has since left, a chronicle that was lost -- none of those has an invite to hang off. -- It converges. Requests repeat, archives are idempotent, and any member can +- It converges. Requests repeat, chronicles are idempotent, and any member can answer. So both, on the same two events: **the pull is the mechanism and the push is a @@ -168,7 +168,7 @@ split the existing check in two and keep the existing one as a caller: * * The room's id is the group's signing key -- see shared-key-derivation.md -- * so this needs nothing but an id the caller already has. That is what makes - * an archive checkable by a member who holds no key state and no share. + * a chronicle checkable by a member who holds no key state and no share. */ fun isSignedByRoom(event: Event, chatRoomId: HexKey): Boolean = runCatching { if (!event.pubKey.equals(chatRoomId, ignoreCase = true)) return false @@ -197,12 +197,12 @@ is even reached. **A day.** -A new package, `press.mantra.compose.nostr.archive`, with `ArchiveEvents.kt` +A new package, `press.mantra.compose.nostr.chronicle`, with `ChronicleEvents.kt` holding the kinds -- mirroring `FrostSigningEvents`. ``` - holder --[ 30327 archive ]-> one member a page of signed events - joiner --[ 30328 archive request ]-> everyone "I have none of this" + holder --[ 30327 chronicle ]-> one member a page of signed events + joiner --[ 30328 chronicle request ]-> everyone "I have none of this" ``` **Why 3032x and not 30313.** The nip30303 family runs 30300 to `SubmissionEvent` @@ -213,7 +213,7 @@ rather than a decision, and the next family added should not rely on it." This i that next family, so it does not. 30327 and 30328 sit past `GroupKeyStateEvent` at 30326 and clash with nothing on either transport. -It is also the right neighbourhood on the merits. An archive is not a document +It is also the right neighbourhood on the merits. A chronicle is not a document kind; it is a statement about the record, which is what `GroupKeyStateEvent` is too. @@ -224,11 +224,11 @@ id, author and signature", and its header even names the case. It is still the wrong kind here, for three reasons: - **A submission is an act** -- *this member is putting this event in front of - this group*. An archive asserts nothing; it re-delivers what the group already + this group*. A chronicle asserts nothing; it re-delivers what the group already agreed. On one kind, a 400-event backfill is indistinguishable from 400 new submissions, and every device has to guess which it is looking at. - **N submissions are N inner events and N kind:445s.** A page is one. -- **The submission arm files a `ChatMessage` per payload.** An archive must not +- **The submission arm files a `ChatMessage` per payload.** A chronicle must not -- see Phase 4. ### Shape @@ -242,8 +242,8 @@ Tags, one value each, per the house convention: | tag | holds | why | |---|---|---| -| `ArchiveIdTag` | 32-byte hex | Ties pages of one archive together, so two members answering the same request do not interleave into one nonsense sequence. | -| `ArchivePageTag` | index, total | The receiver can say whether it holds a whole archive. | +| `ChronicleIdTag` | 32-byte hex | Ties pages of one chronicle together, so two members answering the same request do not interleave into one nonsense sequence. | +| `ChroniclePageTag` | index, total | The receiver can say whether it holds a whole chronicle. | | `p` | recipient pubkey | **A hint, not access control** -- see Phase 4. | ### Two caps, both enforced on receive @@ -268,7 +268,7 @@ exactly the cap still decodes, which is the assertion that fails when somebody raises one number without the other. Both are checked independently on the way in, for the reason the batch cap is: -an archive is the second place in this protocol where a remote party decides how +a chronicle is the second place in this protocol where a remote party decides how much work everyone else does. Measure the 64 KB against a finished kind:445 rather than trusting it -- MLS framing and NIP-44 expansion both sit outside it. @@ -277,11 +277,11 @@ array containing a non-event is refused whole. --- -## Phase 3 -- assembling an archive +## Phase 3 -- assembling a chronicle **A day.** -`ArchiveManager.assemble(database, chatRoomId, recipient): List>` +`ChronicleManager.assemble(database, chatRoomId, recipient): List>` Read every group-signed event this device holds for the room, order it, and pack it into pages. @@ -295,7 +295,7 @@ keeps the decision about transcript lines next to the decision about triggers. > **Since the `GroupSignedEvent` table landed**, a signed event *is* stored as an > event -- `FrostSigningManager` files one per batch it completes, and -> `ArchiveManager.applyPage` files one per payload it accepts, each with the +> `ChronicleManager.applyPage` files one per payload it accepts, each with the > derivation path its author was reached at. `assemble` reads that table first > and rebuilds only what it does not hold, which is work signed before the table > existed. So the rest of this section describes the *fallback*: the round-trip @@ -307,14 +307,14 @@ keeps the decision about transcript lines next to the decision about triggers. > - **The allowlist now does real work on the way out.** The rebuild could only > ever produce document kinds; the table holds everything the group has signed, > and every room signs a `GroupKeyStateEvent` as its first act. `assemble` -> filters on `ArchiveEvent.isArchivable` before anything else -- without it -> `ArchiveEvent.build` refuses the page and a room's whole archive fails on the +> filters on `ChronicleEvent.isChroniclable` before anything else -- without it +> `ChronicleEvent.build` refuses the page and a room's whole chronicle fails on the > one event every room has. -> - **An artifact whose initial version row is missing now archives.** The +> - **An artifact whose initial version row is missing now chronicles.** The > rebuild has to recover the version label from that row and logs and gives up > without it; on file as an event, the label never left. -Signed events are not stored as events; they are stored as rows. So the archive +Signed events are not stored as events; they are stored as rows. So the chronicle is rebuilt from `Mantra*` rows via each entity's `toXEvent()`, which is exactly what the round-trip convention exists for: `toXEvent` emits tags in the same order as `build`, so the id round-trips, and the row carries `signature` and @@ -322,7 +322,7 @@ order as `build`, so the id round-trips, and the row carries `signature` and **This is the assumption to test first, before writing anything else in this phase.** If any entity's `toXEvent` does not round-trip to an id whose signature -still verifies, that entity cannot be archived at all, and it is better to find +still verifies, that entity cannot be chronicled at all, and it is better to find out in an afternoon than in Phase 8. A round-trip test per kind, over rows produced by a real signing session, is the gate on the rest of this work. @@ -342,14 +342,14 @@ Neither fault would have shown up as an error. Both produce a well-formed artifact whose id no longer matches its fields, which every receiver drops as a forgery, silently, one kind at a time. -### What is actually archivable +### What is actually chroniclable -An archive can only carry what its receiver can check, so the list is exactly the +A chronicle can only carry what its receiver can check, so the list is exactly the kinds a signing session produces. Eight of the thirteen nip30303 kinds do. | kind | | why | |---|---|---| -| 30304 Dialect, 30300 Artifact, 30301 ArtifactVersion, 30302 Chapter, 30303 Chunk, 30306 TranslationArtifactVersion, 30308 TranslationChapter, 30309 TranslationChunk | archivable | proposed through `proposeSigning` / `proposeSigningBatch` | +| 30304 Dialect, 30300 Artifact, 30301 ArtifactVersion, 30302 Chapter, 30303 Chunk, 30306 TranslationArtifactVersion, 30308 TranslationChapter, 30309 TranslationChunk | chroniclable | proposed through `proposeSigning` / `proposeSigningBatch` | | 30311 Translation | no | nothing builds one; the inbound arm exists and no producer does | | 30305, 30307, 30310 contributor lists | no | `applyInnerEvent` has no arm that writes a row for any of them | @@ -358,7 +358,7 @@ that mattered.** An artifact version was derived from the signed artifact on arrival -- a row naming the group as its author with no signature to show for it -- and a translated chunk was submitted as its author's rumor by `MantraDao.saveTranslation`. Neither could be put in front of somebody with no -way to check it, so an archive restored everything a translation hangs on and not +way to check it, so a chronicle restored everything a translation hangs on and not the translation. Both were fixed in the app rather than worked around here, in parallel with this @@ -373,14 +373,14 @@ end: a version sits between its artifact and the chapters hanging off it, and a translated chunk hangs off both a source chunk and a translation chapter, so it really is last. -**A retranslated passage archives once.** The arm that applies a translation +**A retranslated passage chronicles once.** The arm that applies a translation chunk drops the one it supersedes -- newest by the timestamp the group signed at, id breaking a tie -- so a sender holds a group's current answer to each passage rather than its drafts, and that is what travels. ### Ordering -Room enforces the shape, so an archive out of order is a foreign key violation +Room enforces the shape, so a chronicle out of order is a foreign key violation rather than a wrong answer. The rank: | # | kind | event | depends on | @@ -407,15 +407,15 @@ kind 30309; they have identical dependencies, so one rank covers both, and The same rule the batch signing work landed on -- *the thing being referenced is signed first* -- and the same reason. -**Pages preserve the rank across the whole archive**, not within each page. Page +**Pages preserve the rank across the whole chronicle**, not within each page. Page boundaries fall wherever the byte cap lands. ### Packing Greedy: serialise, accumulate, cut when the next event would cross either cap. -An event that alone exceeds `MAX_PAGE_BYTES` cannot be archived; log it by id and -carry on rather than failing the archive. That is a real hole and should be -visible -- but a chapter nobody can archive is better than a member who gets +An event that alone exceeds `MAX_PAGE_BYTES` cannot be chronicled; log it by id and +carry on rather than failing the chronicle. That is a real hole and should be +visible -- but a chapter nobody can chronicle is better than a member who gets nothing. --- @@ -429,7 +429,7 @@ nothing. A page names its recipient in a `p` tag, and **a device that is not the named recipient stores the inner event and does nothing else.** It already holds the work; re-applying would rewrite `marmotGroupEventId` on every one of its rows to -point at an archive page rather than at the event that actually introduced it, +point at a chronicle page rather than at the event that actually introduced it, which is provenance loss for no gain. So the `p` tag is an addressing hint and not a secret. Say so where it is @@ -439,7 +439,7 @@ history. What the tag decides is who *acts*. ### Applying ```kotlin -ArchiveManager.apply(database, chatRoomId, page: MarmotInnerEvent) +ChronicleManager.apply(database, chatRoomId, page: MarmotInnerEvent) ``` 1. Parse the content array. A page that will not parse is dropped whole. @@ -457,8 +457,8 @@ transaction and one bad event should not take the room down with it. **Discard the chat lines.** `ChatMessage` has an `autoGenerate` primary key, so every applied payload mints a *new* row -- there is no id to dedupe on. An -archive that filed them would give the new member a synthetic transcript dated -now, and give them a second one on every re-run of the sweep. The archive +chronicle that filed them would give the new member a synthetic transcript dated +now, and give them a second one on every re-run of the sweep. The chronicle restores the work; the conversation is forward secret and stays gone. `applyInnerEvent` already does its entity upserts internally and merely *returns* @@ -478,12 +478,12 @@ shape: ```kotlin database.marmotInnerEventDao() - .getByChatRoomAndKinds(chatRoomId, listOf(ArchiveEvents.ARCHIVE)) + .getByChatRoomAndKinds(chatRoomId, listOf(ChronicleEvents.CHRONICLE)) ``` Re-apply every stored page for the room, oldest first, after each new page arrives. Everything in it is an `upsert` keyed on the event id, so a re-run is -free and a converged archive costs one no-op pass. +free and a converged chronicle costs one no-op pass. **Progress is falling failures, not rows written.** "Repeat while a pass applies something new" is the obvious loop condition and it does not terminate: an upsert @@ -495,12 +495,12 @@ last one learned something; a pass that does not is as far as these pages get. payload once per pass it survived and reports failures that a later pass went on to fix, so `failed > 0` stops meaning "still missing" -- which is exactly the question the caller is asking. Found by asserting that the page completing an -out-of-order archive leaves nothing behind, which failed against the sum. +out-of-order chronicle leaves nothing behind, which failed against the sum. Only the recipient sweeps, which is what bounds it: the members who skip apply never build the list. -**Test:** an archive delivered in reverse page order converges to the same rows +**Test:** a chronicle delivered in reverse page order converges to the same rows as one delivered in order; a page whose payloads are all already applied changes nothing; a page containing one forged payload applies the rest. @@ -510,7 +510,7 @@ nothing; a page containing one forged payload applies the rest. **A day, including one schema change.** -`ArchiveRequestEvent` (30328), sent into the room, content empty. +`ChronicleRequestEvent` (30328), sent into the room, content empty. **When a device sends one.** On entering a room it holds no signed work for -- no `MantraArtifact` and no `MantraDialect` rows -- having processed its Welcome. @@ -520,7 +520,7 @@ because all three look identical from inside the database, which is the point. **Who answers.** Any member holding the work. Answering costs bandwidth and nothing else -- pages are idempotent and non-recipients skip them -- so a duplicate answer is waste, not damage. A random 0-30 s stand-down, skipped if -another member's archive for that request id is already on the wire, is worth +another member's chronicle for that request id is already on the wire, is worth adding and is worth adding *last*: it is an optimisation, and shipping it with the correctness would make it look like part of it. @@ -529,7 +529,7 @@ the correctness would make it look like part of it. One nullable column, so Room generates it: ```kotlin -val archiveRequestedAt: Instant? = null // on ChatRoom +val chronicleRequestedAt: Instant? = null // on ChatRoom AutoMigration(from = 11, to = 12) ``` @@ -538,7 +538,7 @@ Rooms written before it read back null, meaning "never asked" -- true of all of them, and harmless: the request is only sent for a room with no work in it, and a room that has work will not ask. -Clear it when an archive for the room applies anything, so a partial answer is +Clear it when a chronicle for the room applies anything, so a partial answer is followed by another request rather than by silence. --- @@ -552,7 +552,7 @@ written as one. `MarmotOutboundDao.deliveryWelcome` is the seam -- both branches of `inviteMember` reach it, the immediate one and the ack-triggered one in -`DatabaseNostrRepository`. Assemble an archive for the invitee there and queue +`DatabaseNostrRepository`. Assemble a chronicle for the invitee there and queue its pages behind the Welcome. One thing to be honest about at that call site, in a comment: **queued behind the @@ -574,7 +574,7 @@ land is not an error; it is the ordinary case the pull exists for. It sits insid **One call, two occasions.** Answering a request and pushing behind a Welcome are the same operation and differ only in who decided, so they are one function named -for what it does -- `ArchiveManager.sendTo` -- rather than two named for their +for what it does -- `ChronicleManager.sendTo` -- rather than two named for their occasions. --- @@ -583,22 +583,22 @@ occasions. **A day.** -**The transcript gets one line per archive**, not one per event. Three types -- -`TYPE_ARCHIVE_REQUESTED`, `TYPE_ARCHIVE_SENT`, `TYPE_ARCHIVE_RECEIVED` -- in -`ARCHIVE_TYPES`, with an arm in the transcript that renders them as notices. A +**The transcript gets one line per chronicle**, not one per event. Three types -- +`TYPE_CHRONICLE_REQUESTED`, `TYPE_CHRONICLE_SENT`, `TYPE_CHRONICLE_RECEIVED` -- in +`CHRONICLE_TYPES`, with an arm in the transcript that renders them as notices. A type missing from that set renders as a chat bubble, silently, looking exactly like a member having said *"Caught up on 12 items"*. Three decisions inside that: - **The received line is written when the request stamp is cleared**, which is as - close to one-per-archive as this can get: an archive's pages are not + close to one-per-chronicle as this can get: a chronicle's pages are not distinguishable from each other at apply time, and clearing the stamp is exactly the moment a catch-up stops being pending. - **A push behind a Welcome writes no line at all**, because the room was never asked. It lands before the member has opened the room, and *"caught up on work you have not seen yet"* is a line about nothing. -- **The received line names no sender.** An archive can be assembled from pages +- **The received line names no sender.** A chronicle can be assembled from pages sent by more than one member, so attributing the catch-up to one of them would be a guess dressed as a fact. @@ -615,7 +615,7 @@ left out rather than written blind: and it wants a screen to live on. **Say what the new member cannot do.** Still unwritten, and now down to one -thing rather than two: an archive hands its recipient the group's whole signed +thing rather than two: a chronicle hands its recipient the group's whole signed record, prose included, and does not make them able to *sign* anything. That is the sentence a member wants the first time they open a room they were added to late, and the first thing this will be reported as a bug for. @@ -631,21 +631,21 @@ batch-signing note's did, so this section is the index rather than the work. Every claim below is asserted somewhere; what is here is which claim and where. **The whole thing, end to end** -- -[ArchiveApplyJvmTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt), +[ChronicleApplyJvmTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt), over two real databases with the pages carried by hand. The sender's room is -seeded through `ChatMessage.applyInnerEvent` itself, so what is archived is what +seeded through `ChatMessage.applyInnerEvent` itself, so what is chronicled is what a member's device really holds rather than rows built to suit the test. The receiver holds no share, no `DkgSession`, no `FrostSigningSession` and no `GroupKeyState`, and ends with the sender's rows. Compared as `(id, author, signature)` per row rather than by count, and then -asserted that every archived row is authored by the room and carries a signature. +asserted that every chronicled row is authored by the room and carries a signature. Counting is not the claim: two databases can hold the same number of artifacts and disagree about all of them, and a rebuild that lost the group's signature -- or re-authored a row as whoever sent it -- would pass a count and fail the only thing this is for. The artifact version is the one exception and has to be: nobody signs it, it is derived from the signed artifact on arrival, which is why -it is not archived and why a chapter's foreign key survives anyway. +it is not chronicled and why a chapter's foreign key survives anyway. **The negative one that matters** -- four ways to be a dishonest member in one page beside one honest dialect: the room's id as author with a made-up signature, @@ -658,17 +658,17 @@ this does. **The replay that must not work** -- a genuine, still-verifying `GroupKeyStateEvent` in a hand-rolled page. It passes every signature check there is; the allowlist is the only thing that stops it, and the page has to be -hand-rolled because `ArchiveEvent.build` refuses the kind, which is the outbound +hand-rolled because `ChronicleEvent.build` refuses the kind, which is the outbound half of the same rule. -**The `toXEvent` round trip per archived kind** -- -[ArchiveRoundTripTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/archive/ArchiveRoundTripTest.kt), +**The `toXEvent` round trip per chronicled kind** -- +[ChronicleRoundTripTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt), against real FROST with no database. This is the one that earned its place: it found that `MantraArtifact.toArtifactEvent` had the alt tag in the wrong position *and* omitted the version metadata entirely, either of which produces payloads every receiver silently drops as forgeries. It also holds the negative -- a rebuild with the wrong version label fails as a forgery rather than as a mistake --- and a guard that the case list and `ARCHIVABLE_KINDS` move together. +-- and a guard that the case list and `CHRONICLABLE_KINDS` move together. **And three that were not in the first draft**, each written because a test passed for the wrong reason or a bound could not fire: @@ -677,9 +677,9 @@ passed for the wrong reason or a bound could not fire: page-over-the-cap test passes while the count cap is unreachable behind the byte cap, which is how it was first written. - *Pages delivered backwards really did fail first.* Otherwise "out of order - converges" would pass on an archive that was never out of order, and the sweep + converges" would pass on a chronicle that was never out of order, and the sweep -- the only reason it converges -- would be untested. -- *The page that completes an archive leaves nothing behind.* This is what caught +- *The page that completes a chronicle leaves nothing behind.* This is what caught the sweep returning the sum of its passes rather than the settled one, which made `failed > 0` stop meaning "still missing". @@ -690,18 +690,18 @@ passed for the wrong reason or a bound could not fire: **No code, and one constraint that is sharper than the first draft said.** The receiving half is safe to ship on its own, and phases 1-4 are exactly that: -nothing sends an archive until Phase 5 asks for one. That is the half to have in +nothing sends a chronicle until Phase 5 asks for one. That is the half to have in the field first. **Sending into a group with an old build is not free.** The first draft said an old build "files it as unsupported, exactly as it does today for anything it does not know", which is true and reads better than it lives. The unsupported row's content is `event.toJson()`, and it renders as an ordinary chat bubble -- so -every member on an old build sees each archive page as a raw-JSON bubble of up to +every member on an old build sees each chronicle page as a raw-JSON bubble of up to `MAX_PAGE_BYTES`, in a transcript, once per page. Nothing breaks and nothing is lost. But a group mid-upgrade gets a genuinely -unpleasant transcript, and that is worth knowing before the first archive goes +unpleasant transcript, and that is worth knowing before the first chronicle goes out rather than after. The rule: > Confirm every member is on a build that understands kind 30327 before any @@ -713,7 +713,7 @@ The mitigation, if that ever proves unacceptable, is the one the appendix reject for other reasons: carrying pages as Marmot direct messages, where an old build sees a gift wrap it cannot open and renders *"sent a private message"* with no content. It buys graceful degradation and costs everything listed under -[Carrying the archive as a Marmot direct message](#appendix--what-was-considered-and-rejected). +[Carrying the chronicle as a Marmot direct message](#appendix--what-was-considered-and-rejected). --- @@ -721,7 +721,7 @@ content. It buys graceful degradation and costs everything listed under Each of these will be reported as a bug. None of them is. -**A new member still cannot sign, and an archive cannot change that.** This is +**A new member still cannot sign, and a chronicle cannot change that.** This is the big one. `proposeSigningBatch` resolves a `DkgSession` with a non-null `secretShare` and then `signerIdOf`, or throws *"This device is not a participant in ceremony ..."*. `GroupKeyState` states it plainly: *"A member can be in the @@ -730,7 +730,7 @@ reinstalled -- and the state is still worth keeping: it says what the room signs with, which is what tells them they cannot."* Re-running the ceremony is not an escape either: *"a group that re-runs its -ceremony derives a different room rather than re-keying this one."* A post-archive +ceremony derives a different room rather than re-keying this one."* A post-chronicle member can read everything and can still submit what needs no quorum -- `saveTranslation` and `addArtifactVersion` go through `MantraDao.submitToGroup` with no share -- but cannot add a dialect, artifact, chapter or translation @@ -741,7 +741,7 @@ share to a new participant without changing the public key it derives from. It is a real protocol, it is a great deal more work than this document, and it is the thing to build after this one. -**An archive can omit.** Verification stops forgery and does nothing about +**A chronicle can omit.** Verification stops forgery and does nothing about silence: a sender can leave things out, and the receiver has no way to know. Any member can send one and they merge idempotently, so asking a second member is the practical answer, and a group that suspects one member is not the threat model @@ -751,35 +751,35 @@ general answer for the reason [frost-batch-signing.md](./frost-batch-signing.md#appendix--what-was-considered-and-rejected) gives for manifests. Worth revisiting once anything depends on completeness. -**Nothing unsigned is archived, and that is the whole list.** For a while it read +**Nothing unsigned is chronicled, and that is the whole list.** For a while it read larger: the translated text was its author's rumor and an artifact's first version was derived rather than signed, so neither could travel and a new member got the structure and none of the prose. Both are signed now. What is left out is `TranslationEvent`, which nothing builds, and the contributor lists, which nothing applies -- so the rule and the list have stopped diverging, and the thing to watch is that they do not drift apart again. The guard is -`ArchiveRoundTripTest`, which fails when a kind is added to the allowlist without +`ChronicleRoundTripTest`, which fails when a kind is added to the allowlist without a case proving it can be rebuilt. **The chat is gone and stays gone.** By design, restated here because it is the -first thing a new member will notice and the archive is what makes them expect +first thing a new member will notice and the chronicle is what makes them expect otherwise. -**A room with no shared key gets an empty archive.** An ordinary Marmot room's id +**A room with no shared key gets an empty chronicle.** An ordinary Marmot room's id is `RandomInstance.bytes(32)`, not a derived key, so nothing can be signed by it -and there is nothing to archive. Correct, and worth a log line rather than a +and there is nothing to chronicle. Correct, and worth a log line rather than a silent empty result. -**An oversized single event cannot be archived.** A chapter whose text exceeds +**An oversized single event cannot be chronicled.** A chapter whose text exceeds `MAX_PAGE_BYTES` on its own is skipped with a log. Splitting a page mid-event means a reassembly protocol, and that is not worth building before something hits the limit. -**Nothing expires.** An archive grows with the group forever, and a member +**Nothing expires.** A chronicle grows with the group forever, and a member joining a five-year-old room downloads five years. A cursor -- *everything since event X* -- is the obvious next thing and is deliberately not in v1, because "since" is a partial order over a dependency graph, not a timestamp, and getting -it wrong means an archive that references rows the receiver does not have. +it wrong means a chronicle that references rows the receiver does not have. --- @@ -800,10 +800,10 @@ id is in the `h` tag of every kind:445 the group has ever sent. A separate, deliberate publication step for work a group *chooses* to publish is a good feature; making it the backfill mechanism is a leak. -**One `SubmissionEvent` per archived event.** Covered in Phase 2. The envelope +**One `SubmissionEvent` per chronicled event.** Covered in Phase 2. The envelope fits and the meaning does not. -**Carrying the archive as a Marmot direct message.** The natural reading of "send +**Carrying the chronicle as a Marmot direct message.** The natural reading of "send it to the new member" -- an NIP-59 wrap inside the group, per [marmot-direct-messages.md](./marmot-direct-messages.md). Rejected: it encrypts the group's own history to one member, which protects nothing; it costs a *"sent @@ -812,7 +812,7 @@ are not forward secret, so it would be the weakest-protected copy of the group's record on any device holding it. The `p` tag as a hint gets the addressing without any of that. -**A dedicated table for unapplied archive payloads.** Phase 4's sweep reads +**A dedicated table for unapplied chronicle payloads.** Phase 4's sweep reads `MarmotInnerEvent`, which already holds every page. A second copy is a second thing that can disagree with the first.