From 98f766fcd320a7c7c455dfc82c078efc4fe06a0f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 18:16:26 +0200 Subject: [PATCH 1/2] fix: put the invite in the room, so a stuck one can be seen Inviting a member to a group that already had members put nothing whatsoever in the transcript. Not "put it in late" -- nothing, and nothing ever if the invite did not complete. So the one failure the user is best placed to notice, an invite that never reached the person it was made for, was the one the app kept to itself. The line existed. It was written by `MarmotOutboundDao.deliveryWelcome`, which is the wrong place for it, and the reason is the two paths through `inviteMember` that docs/marmot-membership.md already describes. A group that is still only its creator has nobody to inform, so its Welcome goes out immediately and `deliveryWelcome` runs inside the invite. A group that has members must broadcast a commit first, and its Welcome waits for a relay to acknowledge it -- `DatabaseNostrRepository.broadcastProcessed` picks the stored `MarmotCommitResult` back up and delivers then. Every invite after a group's first therefore wrote its transcript line a relay round trip away from the invite, if at all. **Four separate silences, not one.** Worth listing because only the first is about the deferral, and fixing that alone would have left the other three: 1. The deferred path wrote nothing until the ack, and nothing ever without one. 2. The write hung off `getMarmotKeyPackageById(...)?.let { getProfileByPublicKey(...)?.let { ... } }`. Those two lookups were there to *name* the invitee, and a miss on either cost the whole line rather than just the name. 3. `deliveryWelcome` wraps its body in `catch (e: Throwable) { logger.e(...) }` and returned Unit, so a Welcome that could not be built reached the log and no further. 4. `inviteMemberToChatRoom` is `@Transaction`. An invite that threw -- no MLS state for the room, a credential identity that does not match the peer -- rolled its line back with everything else, which is right, and left no account of the refusal anywhere durable. And the line it did write was `messageType = "message"`, `isUserMessage = true`, so it rendered as a chat bubble: "Invited Bob to chat", attributed to the inviter as something they said. **Three membership types, and the line moves to invite time.** `ChatMessage.MEMBERSHIP_TYPES` -- `memberInvited`, `memberInviteSent`, `memberInviteFailed` -- rendered by the transcript as system notices through `RitualNotice`, the way the ceremony, signing and chronicle lines already are. `memberInvited` is written by `inviteMember` and by `addMembersToChatRoom`'s batch path, *when the invite is made*, and deliberately **inside** the caller's transaction. Both halves of that matter and they pull opposite ways: written any later and an invite waiting on an ack that never comes shows nothing, which is the bug; written outside the transaction and an invite that does not survive `addMember` leaves the room claiming one was made. `memberInviteSent` is written by `DatabaseNostrRepository` alone. It is not written on the immediate path, and that is not an oversight: there the Welcome goes out in the same breath as the invite, so one line is the whole truth. It would also be a line the transcript could not order -- `MantraConverters` stores `Instant` as `epochSeconds`, the room query is `ORDER BY createdAt DESC`, and two rows written in the same second tie. Only the deferred path separates the two events in time, so only it owes a second line. `memberInviteFailed` carries the reason, because it is the only copy the user gets. `deliveryWelcome` now returns `Boolean` and files this line from its own catch before returning false -- its callers had no other way to see a failure it had already swallowed, and on the deferred path there is no invite screen left to fail back to. `addMembersToChatRoom` reads that answer instead of a `runCatching` that could never catch anything. **The refusal is written from outside the transaction that rolled it back.** `DatabaseChatRepository.inviteMember` catches, calls `announceInviteFailed`, and rethrows. The throw is what puts a message on the invite screen now; the line is what is still there tomorrow. Swallowing it instead would have popped the user back to the chat as though the invite had gone out, which is the bug the existing `runCatching` in `AddMemberToChatRoomConfirmationViewModel` was added to stop. **No schema change.** `messageType` is a free-form string column with a default, so new values need no migration -- unlike the chronicle rename, which had to rewrite the ones already stored. Nothing reindexes these either: they carry no `marmotGroupEventId`, so `getResolvedMarmotGroupEventIds` cannot see them and `UNRESOLVED_MARMOT_TYPES` does not name them. Six new tests. Four on the DAO: the immediate path leaves a line naming the invitee where the old code left none, the deferred path leaves one *and* claims no Welcome sent before any ack, everything an invite writes is a membership type rather than something the transcript would render as a bubble, and a refused invite leaves no claim that one was made. Two new ones on `DatabaseChatRepository`, which had no test file: a refused invite is written into the room, and the caller still gets the throw. Still open, and now said plainly in the doc rather than implied: a Participant row carries no state saying where its invite got to. The transcript narrates it; the `TODO: Update status of participant Invitation.PENDING -> Invitation.SENT` is untouched. Nor does an invitee with no published key package reach the room at all -- that fails in the view model, before there is an invite to write a line about. 806 tests pass -- 509 jvm, 297 android. Co-Authored-By: Claude Opus 5 --- .../compose/database/dao/MarmotOutboundDao.kt | 213 +++++++++++++++--- .../compose/database/model/ChatMessage.kt | 43 ++++ .../repository/DatabaseChatRepository.kt | 29 ++- .../repository/DatabaseNostrRepository.kt | 44 +++- .../ui/view/model/ChatMessageListViewModel.kt | 28 ++- .../database/dao/MarmotOutboundDaoJvmTest.kt | 143 ++++++++++-- .../DatabaseChatRepositoryJvmTest.kt | 126 +++++++++++ docs/marmot-membership.md | 40 +++- 8 files changed, 605 insertions(+), 61 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/DatabaseChatRepositoryJvmTest.kt 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 6b82cd43..2539c488 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 @@ -21,6 +21,7 @@ import press.mantra.compose.database.model.Participant import press.mantra.compose.exceptions.MarmotMissingChatGroupException import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.extensions.exporterSecret +import press.mantra.compose.extensions.shortened import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.MarmotInboundManager.EPOCH_RETENTION_WINDOW import press.mantra.compose.nostr.MarmotDelivery @@ -231,6 +232,12 @@ abstract class MarmotOutboundDao( }.onFailure { logger.e("Failed to invite $peerPublicKey to ${localChatRoom.chatRoom.id}", it) notAdded.add(peerPublicKey) + announceInviteFailed( + chatRoomId = localChatRoom.chatRoom.id, + userPublicKey = localChatRoom.chatRoom.userPublicKey, + peerPublicKey = peerPublicKey, + reason = it.message, + ) } } @@ -287,9 +294,30 @@ abstract class MarmotOutboundDao( ) ) + // One line each, as `inviteMember` writes for the invites it makes -- this + // path does its own commit and never goes through it. Written before the + // Welcomes rather than after, so a delivery that fails has an invite to be + // read against instead of a failure on its own. + peers.forEach { (peerPublicKey, _) -> + announceMembership( + chatRoomId = localChatRoom.chatRoom.id, + userPublicKey = localChatRoom.chatRoom.userPublicKey, + messageType = ChatMessage.TYPE_MEMBER_INVITED, + content = "Invited ${memberName(peerPublicKey)} to the group", + ) + } + val welcomeBytes = commitResult.welcomeBytes if (welcomeBytes == null) { logger.e("Batched commit for ${localChatRoom.chatRoom.id} produced no welcome") + peers.forEach { (peerPublicKey, _) -> + announceInviteFailed( + chatRoomId = localChatRoom.chatRoom.id, + userPublicKey = localChatRoom.chatRoom.userPublicKey, + peerPublicKey = peerPublicKey, + reason = "the group produced no invitation to send", + ) + } return peers.map { it.first } } @@ -300,17 +328,20 @@ abstract class MarmotOutboundDao( val notAdded = mutableListOf() peers.forEach { (peerPublicKey, peerKeyPackage) -> - runCatching { - deliveryWelcome( - nostrGroupId = localChatRoom.chatRoom.id, - userPublicKey = localChatRoom.chatRoom.userPublicKey, - welcomeBytes = welcomeBytes, - peerKeyPackageEventId = peerKeyPackage.id, - relays = relays, - createdAt = Clock.System.now() - ) - }.onFailure { - logger.e("Failed to deliver the welcome to $peerPublicKey", it) + // `deliveryWelcome` swallows what it catches and reports it as a + // transcript line instead, so its answer is what says whether this peer + // was reached -- a runCatching here would see nothing to catch. + val delivered = deliveryWelcome( + nostrGroupId = localChatRoom.chatRoom.id, + userPublicKey = localChatRoom.chatRoom.userPublicKey, + welcomeBytes = welcomeBytes, + peerKeyPackageEventId = peerKeyPackage.id, + relays = relays, + createdAt = Clock.System.now() + ) + + if (!delivered) { + logger.e("Failed to deliver the welcome to $peerPublicKey") notAdded.add(peerPublicKey) } } @@ -318,6 +349,100 @@ abstract class MarmotOutboundDao( return notAdded } + /** + * One line in the room's transcript, about a membership change. + * + * The same shape as `ChronicleManager.announce`, and for the same reason: this + * is the device saying what it just did, not an event anybody sent. Content is + * a whole sentence, so nothing prefixes a name to it -- see + * [ChatMessage.MEMBERSHIP_TYPES]. + */ + private suspend fun announceMembership( + chatRoomId: HexKey, + userPublicKey: HexKey, + messageType: String, + content: String, + ) { + database.chatMessageDao().upsert( + ChatMessage( + content = content, + messageType = messageType, + chatRoomId = chatRoomId, + senderPublicKey = userPublicKey, + isUserMessage = true, + giftWrapPayloadId = null, + marmotGroupEventId = null, + marmotInnerEventId = null, + ) + ) + } + + /** + * What to call an invitee on a membership line. + * + * Written into the content rather than resolved at render time, because these + * lines are about something that happened once and the AUTHORED-set machinery + * that follows a rename is for lines with an actor. A member being invited has + * a Profile row by construction -- Participant.participantPublicKey is a + * foreign key onto it -- so the fallback is for the delivery path, which runs + * long after the invite and reads its peer back out of a key package. + */ + private suspend fun memberName(publicKey: HexKey): String = + database.profileDao().getProfileByPublicKey(publicKey)?.humanReadableNameOrPubkey() + ?: publicKey.shortened() + + /** + * The Welcome for an invite made earlier has gone out. + * + * Written by the deferred delivery site only -- `DatabaseNostrRepository`, once + * a relay has acknowledged the commit. An invite into a group that was still + * just its creator sends its Welcome in the same breath as the invite, so its + * [ChatMessage.TYPE_MEMBER_INVITED] line already says this and a second one + * would only be the same second told twice. See [ChatMessage.MEMBERSHIP_TYPES]. + */ + suspend fun announceInviteSent( + chatRoomId: HexKey, + userPublicKey: HexKey, + peerKeyPackageEventId: HexKey, + ) { + val peerPublicKey = database.marmotKeyPackageDao() + .getMarmotKeyPackageById(peerKeyPackageEventId)?.publicKey + + announceMembership( + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + messageType = ChatMessage.TYPE_MEMBER_INVITE_SENT, + content = peerPublicKey?.let { "Sent ${memberName(it)} their invitation" } + ?: "Sent the invitation", + ) + } + + /** + * An invite did not make it, and nothing else will say so. + * + * The reason is put in the line because it is the only copy the user gets: the + * screen that asked for the invite is gone by the time the Welcome is + * delivered, and the failures that happen before it closes take the whole + * transaction -- and the invite line inside it -- down with them. See + * `DatabaseChatRepository.inviteMember`. + */ + suspend fun announceInviteFailed( + chatRoomId: HexKey, + userPublicKey: HexKey, + peerPublicKey: HexKey?, + reason: String?, + ) { + val who = peerPublicKey?.let { "${memberName(it)}'s invitation" } ?: "the invitation" + val why = reason?.takeIf { it.isNotBlank() }?.let { ": $it" } ?: "" + + announceMembership( + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + messageType = ChatMessage.TYPE_MEMBER_INVITE_FAILED, + content = "Couldn't send $who$why", + ) + } + private suspend fun inviteMember( nostrGroupId: HexKey, mlsGroup: MlsGroup, @@ -357,6 +482,20 @@ abstract class MarmotOutboundDao( "KeyPackage credential identity does not match memberPubKey" } + // Say so now, not when the Welcome eventually goes out. On the deferred path + // that is a relay round trip away and may never happen at all, and until this + // line existed the room showed nothing whatsoever in the meantime -- an invite + // that was queued, one that failed to reach the wire and one that was never + // made all looked identical from the transcript. Inside the caller's + // transaction, so an invite that does not survive `addMember` leaves no claim + // that it did. + announceMembership( + chatRoomId = nostrGroupId, + userPublicKey = userPublicKey, + messageType = ChatMessage.TYPE_MEMBER_INVITED, + content = "Invited ${memberName(peerPublicKey)} to the group", + ) + val retainedBefore = mlsGroup.retainedSecrets() val commitResult = mlsGroup.addMember( peerKeyPackage.tlsEncodedMarmotKeyPackage @@ -462,6 +601,16 @@ abstract class MarmotOutboundDao( } } + /** + * Put the invitee's Welcome on the outbound queue. + * + * Returns whether it got there. The caller cannot see that any other way -- the + * body swallows what it catches, deliberately, because on the deferred path this + * runs from a relay acknowledgement with no invite screen left to fail back to. + * What replaced reporting nothing at all is the [ChatMessage.TYPE_MEMBER_INVITE_FAILED] + * line written below, which outlives the screen and is the only account of a + * Welcome that never went out. + */ suspend fun deliveryWelcome( nostrGroupId: HexKey, userPublicKey: HexKey, @@ -469,7 +618,7 @@ abstract class MarmotOutboundDao( peerKeyPackageEventId: HexKey, relays: List, createdAt: Instant - ) { + ): Boolean { try { val welcomeBase64 = Base64.encode( source = welcomeBytes @@ -507,22 +656,14 @@ abstract class MarmotOutboundDao( ) ) + // The invite's own transcript line is not written here. It used to be, and + // that put nothing in the room for an invite into an established group -- + // where this runs a relay round trip later, if at all, and never at all if + // the ack does not come. It also hung off the two lookups below, so a + // missing key package or profile cost the line and not just the name. + // `inviteMember` writes it when the invite is made instead; what this + // function still owes the room is the failure below. database.marmotKeyPackageDao().getMarmotKeyPackageById(peerKeyPackageEventId)?.let { marmotKeyPackage -> - database.profileDao().getProfileByPublicKey(marmotKeyPackage.publicKey)?.let { profile -> - // Save chatMessage for the invite... - database.chatMessageDao().upsert( - ChatMessage( - content = "Invited ${profile.humanReadableNameOrPubkey() ?: "participant"} to chat", // use profile.humanReadable... - chatRoomId = nostrGroupId, - senderPublicKey = userPublicKey, - isUserMessage = true, // TODO: This is information message... - giftWrapPayloadId = welcomeEventId, - marmotGroupEventId = null, - marmotInnerEventId = null - ) - ) - } - // The group's signed work, offered to the member being invited. // Nothing else will ever show it to them: MLS gives a joiner no // history, and a group-signed event never travels -- every device @@ -550,8 +691,26 @@ abstract class MarmotOutboundDao( recipient = marmotKeyPackage.publicKey, ) } + + return true } catch (e: Throwable) { logger.e("Failed to deliver welcome:", e) + + // The room's only account of this. Its own runCatching because the + // failure being reported may well be the database, and a throw from + // here would be one the callers on the immediate path do not expect -- + // taking the invite's transaction down over a line about it. + runCatching { + announceInviteFailed( + chatRoomId = nostrGroupId, + userPublicKey = userPublicKey, + peerPublicKey = database.marmotKeyPackageDao() + .getMarmotKeyPackageById(peerKeyPackageEventId)?.publicKey, + reason = e.message, + ) + }.onFailure { logger.e("Failed to record the undelivered welcome:", it) } + + return false } } 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 e657249c..cab0c0d6 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 @@ -357,6 +357,49 @@ data class ChatMessage( TYPE_CHRONICLE_RECEIVED, ) + /** + * Adding a member to the group, as lines in the group's chat. + * + * An invite is two separate things, and in an established group they can be + * minutes apart: the membership change this device commits, and the Welcome + * that lets the invitee in going on the wire. The second waits on a relay + * acknowledging the commit -- see docs/marmot-membership.md -- and it is the + * one that can fail after the screen that asked for it has closed, which + * leaves the transcript as the only place able to say so. + * + * So [TYPE_MEMBER_INVITED] is written the moment the invite is made, and + * [TYPE_MEMBER_INVITE_SENT] only where the Welcome is delivered later than + * that. Where the two happen together -- a group that is still only its + * creator has nobody to inform, so its Welcome goes out immediately -- there + * is one line, because there was one event. + * + * Written by the inviter's device, for itself. None of these travels: what + * the group receives is the commit, and each member's transcript is written + * from what it made of that. A member watching from the side sees nothing + * here, 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. The invitee's name + * is written into the content the way the chronicle's is, which does not + * follow a rename and is the accepted cost for a line about a thing that + * happened once. + */ + const val TYPE_MEMBER_INVITED = "memberInvited" + const val TYPE_MEMBER_INVITE_SENT = "memberInviteSent" + const val TYPE_MEMBER_INVITE_FAILED = "memberInviteFailed" + + /** + * Every membership line, for the one check the transcript dispatches on. + * + * A type missing from here renders as a chat bubble -- silently, and looking + * exactly like the inviter having said "Invited Bob to the group". + */ + val MEMBERSHIP_TYPES = setOf( + TYPE_MEMBER_INVITED, + TYPE_MEMBER_INVITE_SENT, + TYPE_MEMBER_INVITE_FAILED, + ) + const val TYPE_UNDECRYPTABLE_OUTER_LAYER = "undecryptableOuterLayer" const val TYPE_PENDING_COMMIT = "pendingCommit" 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 80b04c00..51f0087d 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 @@ -168,11 +168,30 @@ class DatabaseChatRepository( peerPublicKey: HexKey, peerKeyPackage: MarmotKeyPackage ) { - database.marmotOutboundDao().inviteMemberToChatRoom( - localChatRoom = localChatRoom, - peerPublicKey = peerPublicKey, - peerKeyPackage = peerKeyPackage - ) + try { + database.marmotOutboundDao().inviteMemberToChatRoom( + localChatRoom = localChatRoom, + peerPublicKey = peerPublicKey, + peerKeyPackage = peerKeyPackage + ) + } catch (e: Throwable) { + // Outside the transaction that just went down, which is the point: the + // invite line `inviteMember` writes went with it, so an invite refused + // for want of MLS state or over a mismatched credential left the room + // with nothing at all to show. The caller still gets the throw and + // still puts its own message on the invite screen -- this is the copy + // that is still there tomorrow. + logger.e("Failed to invite $peerPublicKey to ${localChatRoom.chatRoom.id}", e) + runCatching { + database.marmotOutboundDao().announceInviteFailed( + chatRoomId = localChatRoom.chatRoom.id, + userPublicKey = localChatRoom.chatRoom.userPublicKey, + peerPublicKey = peerPublicKey, + reason = e.message, + ) + }.onFailure { logger.e("Failed to record the refused invite:", it) } + throw e + } } override suspend fun addMembers( localChatRoom: LocalChatRoom, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index 54ec9490..a827c7eb 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -439,15 +439,45 @@ class DatabaseNostrRepository( database.chatRoomDao().findChatRoomById(marmotCommitResult.chatRoomId)?.let { localChatRoom -> // TODO: Update status of participant Invitation.PENDING -> Invitation.SENT logger.d("localChatRoom: $localChatRoom") - marmotCommitResult.welcomeBytes?.let { welcomeBytes -> - logger.d("welcomeBytes: ${welcomeBytes.toHex()}") - database.marmotOutboundDao().deliveryWelcome( - nostrGroupId = marmotCommitResult.chatRoomId, + val welcomeBytes = marmotCommitResult.welcomeBytes + if (welcomeBytes == null) { + // The commit was acknowledged and there is nothing to let + // the invitee in with, so this invite is over. Nobody is + // waiting on the answer by now -- the screen that asked + // closed when the commit was made -- which is why it goes + // in the room rather than to a caller. + database.marmotOutboundDao().announceInviteFailed( + chatRoomId = marmotCommitResult.chatRoomId, + userPublicKey = marmotCommitResult.userPublicKey, + peerPublicKey = database.marmotKeyPackageDao() + .getMarmotKeyPackageById(marmotCommitResult.peerKeyPackageEventId) + ?.publicKey, + reason = "the commit carried no invitation", + ) + return@let + } + + logger.d("welcomeBytes: ${welcomeBytes.toHex()}") + val delivered = database.marmotOutboundDao().deliveryWelcome( + nostrGroupId = marmotCommitResult.chatRoomId, + userPublicKey = marmotCommitResult.userPublicKey, + welcomeBytes = welcomeBytes, + peerKeyPackageEventId = marmotCommitResult.peerKeyPackageEventId, + createdAt = marmotCommitResult.createdAt, + relays = Relays.DefaultDMRelayList.map { it.url } // TODO: Get localChatRoom relays... + ) + + // This is the deferred delivery site, and the only one where + // the Welcome goes out later than the invite that made it -- + // so it is the only one that owes the room a second line. An + // invite whose room was still just its creator sent its + // Welcome in the same breath and said so once already. A + // failure needs nothing here: `deliveryWelcome` files its own. + if (delivered) { + database.marmotOutboundDao().announceInviteSent( + chatRoomId = marmotCommitResult.chatRoomId, userPublicKey = marmotCommitResult.userPublicKey, - welcomeBytes = welcomeBytes, peerKeyPackageEventId = marmotCommitResult.peerKeyPackageEventId, - createdAt = marmotCommitResult.createdAt, - relays = Relays.DefaultDMRelayList.map { it.url } // TODO: Get localChatRoom relays... ) } } 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 66067b1b..4a72aabf 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 @@ -424,6 +424,24 @@ class ChatMessageListViewModel( return@items } + // Inviting somebody is nobody's words either, and + // for a while it was not in the room at all: the + // line was written when the Welcome went out, which + // on the deferred path is a relay round trip away + // and may never happen. Passed as answered and + // settled for the same reason the chronicle's are + // -- these report rather than ask, so they stay in + // the quiet tint and offer nothing to review. + if (localChatMessage.chatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES) { + RitualNotice( + localChatMessage = localChatMessage, + isAnswered = true, + isSettled = true, + onClick = {} + ) + return@items + } + // Signing lines are the same kind of thing and get // the same treatment -- nobody said them either -- // but they lead somewhere else, because what a @@ -745,6 +763,13 @@ private fun RitualNotice( ChatMessage.TYPE_CHRONICLE_SENT -> Icons.Default.Upload ChatMessage.TYPE_CHRONICLE_RECEIVED -> Icons.Default.Download + // An invite made and an invite sent are two separate steps on the deferred + // path, so they get separate icons -- the whole reason both lines exist is + // to be able to see that the first happened and the second did not. + ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd + ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload + ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline + else -> Icons.Default.PanTool } @@ -763,7 +788,8 @@ private fun RitualNotice( val tint = when { chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED || - chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED -> + chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED || + chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED -> MaterialTheme.colorScheme.error isRequest -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.onSurfaceVariant diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotOutboundDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotOutboundDaoJvmTest.kt index 37693973..f8698d79 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotOutboundDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotOutboundDaoJvmTest.kt @@ -6,6 +6,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import kotlinx.coroutines.runBlocking import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.database.model.NostrEvent @@ -51,25 +52,36 @@ class MarmotOutboundDaoJvmTest { private val user = KeyPair().pubKey.toHexKey() private val peer = KeyPair().pubKey.toHexKey() + private val secondPeer = KeyPair().pubKey.toHexKey() private val roomId = "b".repeat(64) /** - * A room with `mlsGroupState = null` -- exactly the shape a room restored from an inbound - * gift wrap has, which is the case the guard exists for. + * A member the database knows by name. Every invitee needs one -- + * Participant.participantPublicKey is a foreign key onto Profile -- and the membership + * lines read the name back out of it. */ - private suspend fun seedStatelessRoom(): LocalChatRoom { - val nostrEventId = "c".repeat(64) + private suspend fun seedProfile(publicKey: String, name: String, nostrEventId: String) { db.nostrEventDao().upsert( NostrEvent( id = nostrEventId, - pubKey = user, + pubKey = publicKey, kind = 0, tags = emptyArray(), content = "{}", sig = "0".repeat(128), ) ) - db.profileDao().upsert(Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId)) + db.profileDao().upsert( + Profile(publicKey = publicKey, userName = name, nostrEventId = nostrEventId) + ) + } + + /** + * A room with `mlsGroupState = null` -- exactly the shape a room restored from an inbound + * gift wrap has, which is the case the guard exists for. + */ + private suspend fun seedStatelessRoom(): LocalChatRoom { + seedProfile(user, "user", "c".repeat(64)) val chatRoom = ChatRoom( id = roomId, userPublicKey = user, @@ -100,18 +112,7 @@ class MarmotOutboundDaoJvmTest { */ private suspend fun seedMlsRoom(): LocalChatRoom { val stateless = seedStatelessRoom() - val peerEventId = "e".repeat(64) - db.nostrEventDao().upsert( - NostrEvent( - id = peerEventId, - pubKey = peer, - kind = 0, - tags = emptyArray(), - content = "{}", - sig = "0".repeat(128), - ) - ) - db.profileDao().upsert(Profile(publicKey = peer, userName = "peer", nostrEventId = peerEventId)) + seedProfile(peer, "peer", "e".repeat(64)) val mlsGroup = MlsGroup.create( identity = user.hexToByteArray(), initialExtensions = listOf( @@ -265,4 +266,110 @@ class MarmotOutboundDaoJvmTest { assertEquals(1, retained.size, "the pre-commit epoch was not retained") assertEquals(0L, retained.single().epoch, "a freshly created group is at epoch 0") } + + // ---- What the room is told about it ----------------------------------- + + private suspend fun transcript() = db.chatMessageDao().getChatMessagesByChatRoomId(roomId) + .map { it.chatMessage } + + /** + * The invite has to reach the transcript, and as a membership line rather than a chat + * bubble. It used to be written by `deliveryWelcome` -- which meant it depended on the + * invitee's key package and profile both being found, and neither is here, so the room + * was told nothing at all about an invite that succeeded. + */ + @Test + fun `inviting a member puts a line in the room`() = runBlocking { + val localChatRoom = seedMlsRoom() + + db.marmotOutboundDao().inviteMemberToChatRoom( + localChatRoom = localChatRoom, + peerPublicKey = peer, + peerKeyPackage = marmotKeyPackageFor(peer), + ) + + val line = transcript().singleOrNull { it.messageType == ChatMessage.TYPE_MEMBER_INVITED } + assertNotNull(line, "the invite left no line in the room") + assertTrue(line.content.contains("peer"), "the line does not name the invitee: ${line.content}") + } + + /** + * The case the line exists for. A group that already has members defers its Welcome until + * a relay acknowledges the commit -- see docs/marmot-membership.md -- so an invite made + * here is on nothing but a promise, and until the line moved to invite time the room said + * nothing whatsoever in the meantime and nothing ever if the ack never came. + */ + @Test + fun `an invite into an established group is in the room before its welcome goes out`() = runBlocking { + db.marmotOutboundDao().inviteMemberToChatRoom( + localChatRoom = seedMlsRoom(), + peerPublicKey = peer, + peerKeyPackage = marmotKeyPackageFor(peer), + ) + seedProfile(secondPeer, "second", "f".repeat(64)) + + // Re-read: the first invite advanced the epoch and persisted new state. + val established = assertNotNull(db.chatRoomDao().findChatRoomById(roomId)) + db.marmotOutboundDao().inviteMemberToChatRoom( + localChatRoom = established, + peerPublicKey = secondPeer, + peerKeyPackage = marmotKeyPackageFor(secondPeer), + ) + + val invited = transcript().filter { it.messageType == ChatMessage.TYPE_MEMBER_INVITED } + assertEquals(2, invited.size, "the deferred invite left no line in the room") + assertTrue( + invited.any { it.content.contains("second") }, + "the second invitee is not named: ${invited.map { it.content }}", + ) + + // Nothing has acknowledged the commit, so the Welcome has not gone out and nothing + // may claim it has. That absence is what makes a stuck invite visible. + assertTrue( + transcript().none { it.messageType == ChatMessage.TYPE_MEMBER_INVITE_SENT }, + "a welcome was reported sent before any relay acknowledged the commit", + ) + } + + /** + * Membership lines are not somebody's words, and the transcript decides that from the + * type alone. One missing from [ChatMessage.MEMBERSHIP_TYPES] renders as a chat bubble, + * looking exactly like the inviter having said "Invited peer to the group". + */ + @Test + fun `the invite line is a membership line and not a bubble`() = runBlocking { + db.marmotOutboundDao().inviteMemberToChatRoom( + localChatRoom = seedMlsRoom(), + peerPublicKey = peer, + peerKeyPackage = marmotKeyPackageFor(peer), + ) + + assertTrue( + transcript().all { it.messageType in ChatMessage.MEMBERSHIP_TYPES }, + "an invite wrote something the transcript would render as a bubble: " + + transcript().map { it.messageType }, + ) + } + + /** + * A refused invite must not leave the room claiming one was made. The line is written + * inside the caller's transaction precisely so that it goes down with everything else the + * refused invite touched -- what tells the user instead is + * `DatabaseChatRepository.inviteMember`, from outside it. + */ + @Test + fun `a refused invite leaves no claim that one was made`() = runBlocking { + runCatching { + db.marmotOutboundDao().inviteMemberToChatRoom( + localChatRoom = seedStatelessRoom(), + peerPublicKey = peer, + peerKeyPackage = keyPackage(), + ) + } + + assertTrue( + transcript().none { it.messageType == ChatMessage.TYPE_MEMBER_INVITED }, + "the room was told about an invite that was refused", + ) + } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/DatabaseChatRepositoryJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/DatabaseChatRepositoryJvmTest.kt new file mode 100644 index 00000000..4bfc224c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/DatabaseChatRepositoryJvmTest.kt @@ -0,0 +1,126 @@ +package press.mantra.compose.database.repository + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatMessage +import press.mantra.compose.database.model.ChatRoom +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.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.exceptions.MarmotMissingChatGroupException +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * What the room is told when an invite does not happen. + * + * `MarmotOutboundDao.inviteMemberToChatRoom` writes the invite's transcript line inside its + * own transaction, which is right: an invite that does not survive `addMember` must not leave + * the room claiming one was made. But it means a refusal takes the only account of itself + * down with it, and the screen that asked has closed by the time anybody looks -- so the + * repository is where a failure has to be written from, outside the transaction that rolled + * back. + */ +class DatabaseChatRepositoryJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val user = KeyPair().pubKey.toHexKey() + private val peer = KeyPair().pubKey.toHexKey() + private val roomId = "b".repeat(64) + + private val repository = DatabaseChatRepository( + database = db, + scope = CoroutineScope(Dispatchers.Unconfined), + ) + + private suspend fun seedProfile(publicKey: String, name: String, nostrEventId: String) { + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert( + Profile(publicKey = publicKey, userName = name, nostrEventId = nostrEventId) + ) + } + + /** A room restored from an inbound gift wrap: no MLS state, so nothing to invite into. */ + private suspend fun seedStatelessRoom(): LocalChatRoom { + seedProfile(user, "user", "c".repeat(64)) + seedProfile(peer, "peer", "e".repeat(64)) + val chatRoom = ChatRoom( + id = roomId, + userPublicKey = user, + subject = "a restored room", + description = null, + mlsGroupState = null, + ) + db.chatRoomDao().upsert(chatRoom) + return LocalChatRoom(chatRoom = chatRoom) + } + + private fun keyPackage() = MarmotKeyPackage( + id = "d".repeat(64), + publicKey = peer, + tlsEncodedMarmotKeyPackage = ByteArray(0), + ) + + @Test + fun `a refused invite is written into the room it was refused for`() = runBlocking { + val localChatRoom = seedStatelessRoom() + + assertFailsWith { + repository.inviteMember( + localChatRoom = localChatRoom, + peerPublicKey = peer, + peerKeyPackage = keyPackage(), + ) + } + + val line = db.chatMessageDao().getChatMessagesByChatRoomId(roomId) + .map { it.chatMessage } + .singleOrNull { it.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED } + + assertNotNull(line, "the refused invite left no line in the room") + assertTrue(line.content.contains("peer"), "the line does not name the invitee: ${line.content}") + } + + /** + * The caller still has to hear about it. The transcript line is the copy that is there + * tomorrow; the throw is what puts a message on the invite screen today, and swallowing it + * would pop the user back to the chat as though the invite had gone out. + */ + @Test + fun `a refused invite still reaches the caller`() = runBlocking { + val localChatRoom = seedStatelessRoom() + + assertFailsWith { + repository.inviteMember( + localChatRoom = localChatRoom, + peerPublicKey = peer, + peerKeyPackage = keyPackage(), + ) + } + } +} diff --git a/docs/marmot-membership.md b/docs/marmot-membership.md index c6feeb21..46a783b3 100644 --- a/docs/marmot-membership.md +++ b/docs/marmot-membership.md @@ -129,6 +129,37 @@ The group state is persisted after `commit()` and before any Welcome is delivere so a crash between the two leaves the group at the epoch the Welcomes describe rather than one behind it. +## What the room is told + +An invite writes three kinds of line into the room's transcript, none of which +travels — see `ChatMessage.MEMBERSHIP_TYPES`. They exist because the deferral +above is invisible from anywhere else: the screen that asks for an invite closes +the moment the commit is made, and everything that can still go wrong goes wrong +after that. + +| line | written by | says | +|-----------------------------|-----------------------------------------|---------------------------------------| +| `TYPE_MEMBER_INVITED` | `inviteMember`, `addMembersToChatRoom` | the membership change was made | +| `TYPE_MEMBER_INVITE_SENT` | `DatabaseNostrRepository` | the deferred Welcome went on the wire | +| `TYPE_MEMBER_INVITE_FAILED` | `deliveryWelcome`, `DatabaseChatRepository` | it did not, and why | + +The invite line is written **when the invite is made**, inside the caller's +transaction. Both halves of that matter: written any later and an invite waiting +on an ack that never comes leaves the room showing nothing, which is what this +looked like before; written outside the transaction and an invite that does not +survive `addMember` leaves the room claiming one was made. + +Only the immediate branch's Welcome goes out in the same breath as the invite, so +only the deferred branch owes a second line. Where the two are one event, one line +is the whole truth — and `createdAt` is stored to the second, so a second line +would be one the transcript could not reliably order after the first anyway. + +A refusal is the awkward case, because rolling the transaction back is right and +takes the account of it with it. `DatabaseChatRepository.inviteMember` catches, +writes the failed line from outside the transaction, and rethrows — the throw is +what puts a message on the invite screen now, the line is what is still there +tomorrow. + ## Other things that bite **Sequential invites each advance the epoch.** Where they still happen — the @@ -140,9 +171,12 @@ error. **A member with no published key package cannot be added.** A Marmot invite needs the invitee's `MarmotKeyPackage`. Both call sites look it up with a timeout and -collect the ones that failed. Today that only reaches the log — the -`TODO: Update status of participant Invitation.PENDING -> Invitation.SENT` at the -Welcome delivery site is the same gap seen from the other end. +collect the ones that failed. That is still only a log and a returned list — +nothing puts it in the room, because it happens before there is an invite to write +a line about. The `TODO: Update status of participant Invitation.PENDING -> +Invitation.SENT` at the Welcome delivery site is the same gap seen from the other +end: the transcript lines above say what happened, but a Participant row still +carries no state saying where its invite got to. **`deliveryWelcome` uses `Relays.DefaultDMRelayList`, not the room's relays.** There is a `TODO: Get localChatRoom relays...` on the ack-triggered call site. From 49c012bf8b45a4bf129fd193553cf1e17758a6e2 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 18:18:16 +0200 Subject: [PATCH 2/2] fix: a member is not shown the messages sent before they were in the room A joiner gets the MLS key schedule from their own epoch forward and nothing before it. The relay does not know that and hands them the whole room: negentropy syncs down every kind:445 the group ever published, `indexMarmotGroupEvent` read each one against the group, the outer layer refused, and every refusal wrote an `undecryptableOuterLayer` line. So the room a member had just been invited to opened on a screenful of "Undecryptable Message" above the conversation -- one per message the group had sent before they arrived, none of them ever readable, and the count only grows with how long the group had been talking. **The epoch of a kind:445 is inside the layer that will not decrypt**, so an event this device cannot read cannot be asked what epoch it is from. "From before we joined", "from an epoch we have not caught up to" and "from an epoch that fell out of the retention window" are indistinguishable from the outside, and only the first is permanent. What separates it is not the ciphertext but the clock: it was published before the group made the epoch we joined at. **`ChatRoom.joinedGroupAt` is that moment, written down.** The Welcome's `created_at`, which the inviter stamps as it mints the Welcome out of the Add commit that made us a member -- so it is the group's own account of when our epoch began, not this device's account of when it heard about it. A group this device created sets it to the room's creation; it was a member from epoch 0 and there is nothing behind it to hold back. Stored rather than read off `createdAt`, which today holds the same value in both paths. `createdAt` is row bookkeeping and this decides which of a group's messages a member is allowed to see at all; the two being equal is a coincidence of the current code, and hanging the second off the first makes a future change to when a room row is written into a change in what gets discarded. `memberSince` is `joinedGroupAt ?: createdAt`, so a room joined before the column existed gets the fix too -- and gets it from the value every path that sets the column would have written anyway. **`predatesMembership` draws the line strictly before**, and that is a judgement rather than a fact. Nostr stamps `created_at` in whole seconds, so the second the Welcome was minted holds both the commit that added us -- the last act of the epoch before ours, unreadable by construction -- and any message another member sent the instant they applied it. Only one of the two can be had. An unreadable event kept costs one refused decrypt; a readable event discarded is a message the member never sees. So the second is kept, and a room may still show a single placeholder for the commit that added its newest member. **Two places gate on it.** `indexMarmotGroupEvent` returns before touching the MLS group, so nothing is decrypted, no `MarmotGroupEvent` row is filed for ciphertext whose key this device never had, and no line is written. `reindexMarmotGroupEvents` partitions them out of the sweep entirely: a replay can say in advance that no pass will ever read them, so replaying them only spends a refused decrypt per sweep and reports every one as a failure on a room where nothing is wrong. `MarmotReindexSweep` is untouched apart from carrying the new count -- it decides how many times to go round, not what is worth going round for. **Schema v15, and the migration is the half that fixes devices already showing the bug.** Nothing rewrites a chat line that is already in the transcript, so fixing the write path alone would leave every member who joined a busy room opening it on the same run of placeholders forever. `MIGRATION_14_15` adds the column and deletes the lines: only the two types in `UNRESOLVED_MARMOT_TYPES`, and only where the group event behind them predates the room. Those lines say nothing by design -- they stand in for an event that was never read -- so removing one loses nothing, while every other line is the final word on its group event. The group events themselves stay; this is about what the room shows. The column is left null rather than backfilled from `createdAt`. Null already means "ask `createdAt`", and copying the value would turn a fallback into a claim this migration is in no position to make. It is manual rather than an `AutoMigration` only because of the delete: `ALTER TABLE ... ADD COLUMN` appends, which is where Room's own generated migration for a nullable addition puts one, and Room compares a table's columns by name rather than by position. **The reindex report stopped being true**, so it carries the number now. With the backlog held back, `unresolved` falls to zero and the screen said "Nothing to reindex - 30 event(s) all read" about a room where 27 of them were never this device's to read. `MarmotReindexReport.predatingMembership` is reported alongside `stored`, and the detail screen names it: "3 event(s) all read - 27 from before you joined". A member invited into an old room is the ordinary case, not an anomaly to bury in a total. Seventeen tests. `ChatRoomMembershipWindowTest` holds the boundary, including the same-second case and both directions of the `createdAt` fallback. `JoinedGroupAtMigrationJvmTest` runs the migration's own SQL against v14's three tables and covers what it must not take as carefully as what it must: a placeholder for an event from *after* the join is left to be recovered, a message that was read is left alone however old it is, a line with no group event behind it is out of reach of the rule, and two rooms joined at different times are each measured against their own join. `MarmotPreJoinIndexingJvmTest` drives the DAO against a room with no MLS state, which is what separates "left alone because it predates the join" from "tried and failed". 520 jvm tests and 302 android unit tests pass. Co-Authored-By: Claude Opus 5 --- .../15.json | 5642 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 10 +- .../builder/PlatformDatabaseBuilder.kt | 10 +- .../compose/database/dao/MarmotOutboundDao.kt | 9 +- .../mantra/compose/database/dao/NostrDao.kt | 50 +- .../migrations/JoinedGroupAtMigration.kt | 58 + .../mantra/compose/database/model/ChatRoom.kt | 55 + .../model/types/MarmotReindexReport.kt | 16 +- .../compose/managers/MarmotInboundManager.kt | 23 +- .../compose/managers/MarmotReindexSweep.kt | 7 + .../ui/composable/ChatRoomDetailScreen.kt | 22 +- .../model/ChatRoomMembershipWindowTest.kt | 103 + .../dao/MarmotPreJoinIndexingJvmTest.kt | 174 + .../JoinedGroupAtMigrationJvmTest.kt | 250 + 14 files changed, 6402 insertions(+), 27 deletions(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/15.json create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/JoinedGroupAtMigration.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ChatRoomMembershipWindowTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotPreJoinIndexingJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/JoinedGroupAtMigrationJvmTest.kt diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/15.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/15.json new file mode 100644 index 00000000..269a2baf --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/15.json @@ -0,0 +1,5642 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "513c8f6ec811352bd00aba1d34651d76", + "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, `joinedGroupAt` 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": "joinedGroupAt", + "columnName": "joinedGroupAt", + "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, '513c8f6ec811352bd00aba1d34651d76')" + ] + } +} \ 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 f731b7d3..b44a4da6 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 = 14, + version = 15, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -255,6 +255,14 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // 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. + // + // v15 adds the nullable ChatRoom.joinedGroupAt, which says when this + // device became a member and so which of the group's messages were never + // its to read. Adding a nullable column is a shape Room migrates itself; + // deleting the placeholder chat lines already written for those messages + // is not, and a member who joined a busy room is looking at a screenful of + // them. Manual for that half, so it too is passed to the builder rather + // than listed here. See MIGRATION_14_15. ] ) @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 f352664d..99ff7ece 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 @@ -5,6 +5,7 @@ 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 press.mantra.compose.database.migrations.MIGRATION_14_15 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -18,12 +19,13 @@ fun getRoomDatabase( builder: RoomDatabase.Builder ): press.mantra.compose.database.MantraDatabase { return builder - // Everything else Room generates itself. These three move data rather than + // Everything else Room generates itself. These four move data rather than // only changing shape, which an AutoMigration cannot express: 3->4 rewrites // 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) + // table before dropping them, 13->14 renames a column and rewrites the + // chat rows that named it the old way, and 14->15 adds a column and deletes + // the chat rows written for messages from before this device joined. + .addMigrations(MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14, MIGRATION_14_15) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() 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 6b82cd43..88d69097 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 @@ -81,12 +81,19 @@ abstract class MarmotOutboundDao( ) // Save Chat Room + // + // Member since the group existed, because this device is what created it: + // there is no epoch of this group that predates us, and so nothing in it + // for ChatRoom.predatesMembership to hold back. + val createdAt = Clock.System.now() val chatRoom = ChatRoom( id = nostrGroupId, userPublicKey = userPublicKey, mlsGroupState = mlsGroup.saveState().encodeTls().toHex(), subject = name, - description = description + description = description, + joinedGroupAt = createdAt, + createdAt = createdAt, ) database.chatRoomDao().upsert( chatRoom diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 11da2892..6bc6fde0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -706,6 +706,14 @@ abstract class NostrDao( description = group.currentMarmotData()?.description?.ifBlank { null }, initialGiftWrapPayloadId = decryptedGiftWrapPayload.id, createdAt = decryptedGiftWrapPayload.createdAt, + // The Welcome's own `created_at`, which the + // inviter stamps as it mints the Welcome out of + // the Add commit that made us a member. That + // commit is what created the epoch we are joining + // at, so it is the group's clock on when this + // room's history stops being ours to read. See + // ChatRoom.predatesMembership. + joinedGroupAt = decryptedGiftWrapPayload.createdAt, mlsGroupState = group.saveState().encodeTls().toHex(), ) ) @@ -1145,6 +1153,13 @@ abstract class NostrDao( * see [reindexMarmotGroupEvents]. Throws for a room this device cannot process * the event against at all, which the caller decides what to do about: a first * delivery lets it roll back its transaction, a replay logs it and moves on. + * + * An event from before this device joined is not read and not filed -- see + * [ChatRoom.predatesMembership]. Nothing about it is this device's: not the + * epoch key it was encrypted under, and so not the message either. Reading it + * anyway is how a room a member was invited to yesterday opened on a screenful + * of "Undecryptable Message" above the conversation, one line for every + * message the group had sent before they arrived. */ private suspend fun indexMarmotGroupEvent( groupEvent: GroupEvent, @@ -1154,6 +1169,11 @@ abstract class NostrDao( val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId) ?: throw MarmotMissingChatGroupException("Couldn't find chatRoom for ${groupEvent.id}") + if (localChatRoom.chatRoom.predatesMembership(Instant.fromEpochSeconds(groupEvent.createdAt))) { + logger.d("${groupEvent.id} predates this device joining $chatRoomId, nothing to index") + return + } + // Through the cache rather than rebuilt here, so the secret // tree's skipped-generation keys survive from one message to // the next. Two events published in the same instant arrive in @@ -1314,16 +1334,22 @@ abstract class NostrDao( * recovered by one pass can be what lets the next read the messages that were * waiting on it. * + * Events from before this device joined are left out of the sweep entirely -- + * see [ChatRoom.predatesMembership]. They are the one part of the backlog a + * replay can say something about in advance: no pass will ever read them, so + * replaying them only spends a refused decrypt each time and reports every one + * of them as a failure, on a room where nothing is wrong. + * * What this cannot do is recover a message whose key is gone: an application - * message the ratchet has already advanced past, or one from an epoch that - * predates this device joining. Those stay unreadable however often they are - * replayed. + * message the ratchet has already advanced past. Those stay unreadable however + * often they are replayed. */ open suspend fun reindexMarmotGroupEvents( chatRoomId: String, activeKeyPair: KeyPair, ): MarmotReindexReport { val userPublicKey = activeKeyPair.pubKey.toHex() + val chatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom // The query's LIKE only narrows; the h tag is what decides the room. Sorted // by CommitOrdering's own comparator rather than left in createdAt order, so @@ -1338,7 +1364,20 @@ abstract class NostrDao( } .sortedWith(CommitOrdering.comparator) - logger.i("Reindex $chatRoomId: ${groupEvents.size} stored group event(s)") + // Held back rather than dropped from the report: they are counted in + // `stored` and again on their own, so the screen can say a room is mostly + // older than the member reading it instead of calling those events read. + // + // A room with no row to ask keeps every event, so a replay against one + // does what it always did rather than quietly finding nothing to do. + val (readable, predatingMembership) = groupEvents.partition { groupEvent -> + chatRoom?.predatesMembership(Instant.fromEpochSeconds(groupEvent.createdAt)) != true + } + + logger.i( + "Reindex $chatRoomId: ${groupEvents.size} stored group event(s), " + + "${predatingMembership.size} from before this device joined" + ) // Held commits are what a replay is most often for, and they are only ever // cleared by one applying -- see MarmotInboundManager.forgetPendingCommits. @@ -1351,7 +1390,8 @@ abstract class NostrDao( return MarmotReindexSweep.run( stored = groupEvents.size, - unresolved = groupEvents.filterNot { it.id in resolved }, + predatingMembership = predatingMembership.size, + unresolved = readable.filterNot { it.id in resolved }, replay = { groupEvent -> indexMarmotGroupEvent( groupEvent = groupEvent, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/JoinedGroupAtMigration.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/JoinedGroupAtMigration.kt new file mode 100644 index 00000000..2d6e5e70 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/JoinedGroupAtMigration.kt @@ -0,0 +1,58 @@ +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 + +/** + * Records when this device joined each room, and clears the lines it wrote for + * messages it was never able to read. + * + * A member added to a group is given the key schedule from their own epoch + * forward and nothing before it. Every kind:445 the group published earlier is + * still on the relays, still syncs down, and is still unreadable -- so the room + * they opened for the first time led with a run of "Undecryptable Message", + * one per message sent before they arrived, above the conversation they were + * actually invited to. + * + * Two changes, one shape and one data, which is why this is a manual migration + * rather than an `AutoMigration` Room could generate: + * + * - `ChatRoom.joinedGroupAt` says when this device became a member, which is + * what `ChatRoom.predatesMembership` reads to leave those events alone. It + * is left null here rather than backfilled from `createdAt`: null already + * means "ask `createdAt`" -- see `ChatRoom.memberSince` -- and copying the + * value would turn a fallback into a claim this migration is in no position + * to make. + * - The placeholder lines already written for those events are deleted. Fixing + * the write path only stops the next one; nothing rewrites a line that is + * already in the transcript, so a member who joined last week would go on + * seeing their run of them forever. + * + * Only the two types in [ChatMessage.UNRESOLVED_MARMOT_TYPES] are deleted, and + * only where the group event behind them predates the room. Those lines say + * nothing by design -- they stand in for an event that was never read -- so + * removing one loses nothing, while every other line is the final word on its + * group event and is left alone. The group events themselves stay: this is + * about what the room shows, not about forgetting what arrived. + * + * `createdAt` is compared rather than `joinedGroupAt` because the column was + * added in this same migration and is null for every row in the database being + * migrated. It is the same comparison `memberSince` falls back to. + */ +val MIGRATION_14_15 = object : Migration(14, 15) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `ChatRoom` ADD COLUMN `joinedGroupAt` INTEGER") + + val placeholderTypes = ChatMessage.UNRESOLVED_MARMOT_TYPES.joinToString(", ") { "'$it'" } + + connection.execSQL( + "DELETE FROM `ChatMessage` WHERE `messageType` IN ($placeholderTypes) " + + "AND `marmotGroupEventId` IN (" + + "SELECT `MarmotGroupEvent`.`id` FROM `MarmotGroupEvent` " + + "JOIN `ChatRoom` ON `ChatRoom`.`id` = `MarmotGroupEvent`.`chatRoomId` " + + "WHERE `MarmotGroupEvent`.`createdAt` < `ChatRoom`.`createdAt`)" + ) + } +} 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 d998521e..8babc6ff 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 @@ -79,6 +79,24 @@ data class ChatRoom( val leftGroupAt: Instant? = null, + /** + * When this device became a member of the group, or null for a room that + * predates the column. + * + * The pair to [leftGroupAt], and the thing [memberSince] is really asking + * for. Written from the moment the group's own clock says our epoch began: + * the Welcome's `created_at`, which the inviter stamps as it mints the + * Welcome out of the Add commit that made us a member, or the room's own + * creation for a group this device started at epoch 0. + * + * Stored rather than read off [createdAt] because the two are only + * incidentally equal. [createdAt] is row bookkeeping -- when this device + * first wrote the row down -- and the question asked here decides which of + * the group's messages are ours to read at all. That is not a fact to leave + * hanging off a timestamp somebody could reasonably repurpose. + */ + val joinedGroupAt: Instant? = null, + /** * When this device last asked the group for its signed history, or null if * it never has or the answer has since arrived. @@ -144,6 +162,43 @@ data class ChatRoom( } + /** + * The moment this device joined, falling back to when it wrote the room down. + * + * A room joined before [joinedGroupAt] existed has no recorded answer, and + * [createdAt] is both the best one available and the one every path that + * writes [joinedGroupAt] would have written anyway: a joiner's row is created + * from the Welcome, and a creator's when it creates the group. + */ + val memberSince: Instant get() = joinedGroupAt ?: createdAt + + /** + * Whether something the group published at [publishedAt] belongs to an epoch + * this device was never in. + * + * MLS gives a joiner the key schedule from their own epoch forward and + * nothing before it, so a kind:445 older than [memberSince] cannot be read + * now and cannot be read later -- not by waiting, and not by replaying it. + * The point of asking is to leave those alone rather than file a line saying + * a message arrived that nobody can show. + * + * Time is the only thing to ask it of. The epoch a kind:445 was encrypted + * under is inside the outer layer, so an event this device cannot decrypt + * cannot be asked what epoch it is from, and "before we joined", "from an + * epoch we have not caught up to" and "from an epoch that fell out of the + * retention window" all look identical from the outside. What separates the + * first from the other two is that it was published before the group made + * the epoch we joined at. + * + * Strictly before, so an event stamped in the same second as our Welcome is + * still read. The error worth avoiding runs one way: an unreadable event + * costs a wasted decrypt, and a discarded readable one is a message the + * member never sees. The commit that added us sits exactly on that boundary + * and is unreadable by construction -- it is the last act of the epoch + * before ours -- so a room may still show one placeholder for it. + */ + fun predatesMembership(publishedAt: Instant): Boolean = publishedAt < memberSince + fun toMlsGroup(): MlsGroup? { return mlsGroupState?.let { return MlsGroup.restore( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/MarmotReindexReport.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/MarmotReindexReport.kt index 0933e6da..56b4e71f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/MarmotReindexReport.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/MarmotReindexReport.kt @@ -7,17 +7,23 @@ package press.mantra.compose.database.model.types * can say what happened rather than leaving the user to guess from the message * list whether anything moved. * - * @param stored every kind:445 held locally for the room. - * @param unresolved how many of those had nothing to show for them, or only a + * @param stored every kind:445 held locally for the room, [predatingMembership] + * included. They are held, so they are counted. + * @param predatingMembership how many of [stored] the group published before this + * device joined, which a replay does not touch -- see + * `ChatRoom.predatesMembership`. Reported rather than folded into [stored] + * silently, because "20 events, all read" is not true of a room where 15 of them + * were never this device's to read, and a member who was invited into an old + * room deserves the difference said out loud rather than left as a discrepancy. + * @param unresolved how many of the rest had nothing to show for them, or only a * placeholder line -- the ones a replay was allowed to touch. * @param recovered how many of [unresolved] came out with something to show: * a message, an applied commit, an entity added to the group's library. - * @param failed how many threw while being replayed. Expected to be non-zero - * on a room with events from before this device joined, whose epoch secrets it - * never held and never will. + * @param failed how many threw while being replayed. */ data class MarmotReindexReport( val stored: Int = 0, + val predatingMembership: Int = 0, val unresolved: Int = 0, val recovered: Int = 0, val failed: Int = 0, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt index 159ef231..d278c66a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt @@ -230,10 +230,17 @@ object MarmotInboundManager { ) if (mlsBytes == null) { - // Expected when this kind:445 was encrypted with an epoch - // key that predates our join (classical MLS forward - // secrecy), or when the sender's epoch has drifted. Not - // an error — callers should log at DEBUG. + // Expected when the sender's epoch has drifted, or when a + // key that predates our join is what this was encrypted + // with (classical MLS forward secrecy). Not an error — + // callers should log at DEBUG. + // + // The second of those is now rare rather than routine: + // `NostrDao.indexMarmotGroupEvent` holds back anything the + // group published before this device joined, so what + // reaches here from before our epoch is only what sits on + // the boundary — see ChatRoom.predatesMembership. A room + // full of these is a sign that gate is not being applied. GroupEventResult.UndecryptableOuterLayer( localChatRoom.chatRoom.id, retainedEpochCount = retainedExporterSecrets(localChatRoom.chatRoom.id).size, @@ -656,9 +663,11 @@ object MarmotInboundManager { * * Returns null when neither the current epoch key nor any retained key * decrypts. This happens normally for commits/application messages from - * epochs that predate our join (we never held those keys), so callers - * should treat null as an expected "nothing to do here" outcome and log - * at DEBUG, not as an error. + * epochs we never held the keys for, so callers should treat null as an + * expected "nothing to do here" outcome and log at DEBUG, not as an error. + * Most of that class of event no longer gets this far: what predates this + * device's join is held back before any of it is attempted -- see + * `ChatRoom.predatesMembership`. */ private fun tryDecryptOuterLayer( mlsGroup: MlsGroup, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotReindexSweep.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotReindexSweep.kt index 47071e5d..b1ac3a0d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotReindexSweep.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotReindexSweep.kt @@ -36,6 +36,11 @@ object MarmotReindexSweep { /** * @param stored how many group events the room holds in total, for the report. + * @param predatingMembership how many of [stored] the caller held back because + * they were published before this device joined, for the report. Carried + * through rather than worked out here for the same reason [stored] is: the + * sweep decides how many times to go round, not what is worth going round + * for. * @param unresolved those with nothing to show for them, in the order to replay * them. Anything already read must be left out: a replay is only ever allowed * to touch events it cannot make worse. @@ -48,6 +53,7 @@ object MarmotReindexSweep { */ suspend fun run( stored: Int, + predatingMembership: Int = 0, unresolved: List, maxPasses: Int = DEFAULT_MAX_PASSES, replay: suspend (T) -> Unit, @@ -85,6 +91,7 @@ object MarmotReindexSweep { return MarmotReindexReport( stored = stored, + predatingMembership = predatingMembership, unresolved = unresolved.size, recovered = recovered, failed = remaining.size, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 8cb0ed7c..88e9c849 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -632,13 +632,27 @@ private fun ReindexMarmotGroupEventsButton( when (reindexState) { is ChatRoomDetailViewModel.ReindexState.Done -> { val report = reindexState.report + // What was published before this member joined is named rather + // than counted as read. Their epoch keys were never on this + // device, so "all read" would be a claim about messages nobody + // here can open -- and a room that is mostly older than the + // member is the ordinary case for anyone invited into one. + val beforeJoining = + if (report.predatingMembership > 0) { + " · ${report.predatingMembership} from before you joined" + } else { + "" + } Text( text = when { - report.isNoOp -> "Nothing to reindex · ${report.stored} event(s) all read" + report.isNoOp -> + "Nothing to reindex · ${report.stored - report.predatingMembership} " + + "event(s) all read$beforeJoining" report.recovered > 0 && report.failed > 0 -> - "Recovered ${report.recovered} of ${report.unresolved} · ${report.failed} still unreadable" - report.recovered > 0 -> "Recovered ${report.recovered} of ${report.unresolved} event(s)" - else -> "${report.failed} event(s) still unreadable" + "Recovered ${report.recovered} of ${report.unresolved} · ${report.failed} still unreadable$beforeJoining" + report.recovered > 0 -> + "Recovered ${report.recovered} of ${report.unresolved} event(s)$beforeJoining" + else -> "${report.failed} event(s) still unreadable$beforeJoining" }, style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ChatRoomMembershipWindowTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ChatRoomMembershipWindowTest.kt new file mode 100644 index 00000000..1b50d443 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ChatRoomMembershipWindowTest.kt @@ -0,0 +1,103 @@ +package press.mantra.compose.database.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Which of a group's kind:445 events are this device's to read at all. + * + * MLS hands a joiner the key schedule from their own epoch forward and nothing + * before it, but the relay hands them the whole room. Everything the group + * published earlier still syncs down and is still permanently unreadable, so a + * member invited into a busy room opened it on a run of "Undecryptable Message" + * above the conversation they were invited to. + * + * The epoch an event was encrypted under is inside the layer that will not + * decrypt, so the only thing left to ask is when it was published. That makes + * the boundary a judgement call rather than a fact, and the direction it errs in + * is what these pin down: an unreadable event kept costs one refused decrypt, a + * readable event discarded is a message the member never sees. + */ +class ChatRoomMembershipWindowTest { + + private val roomId = "a".repeat(64) + private val user = "b".repeat(64) + + private fun room( + joinedGroupAt: Instant?, + createdAt: Instant = Instant.fromEpochSeconds(5_000), + ) = ChatRoom( + id = roomId, + userPublicKey = user, + subject = null, + description = null, + mlsGroupState = null, + joinedGroupAt = joinedGroupAt, + createdAt = createdAt, + ) + + @Test + fun `a message from before the welcome is not this devices to read`() { + val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000)) + + assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(1_999))) + } + + @Test + fun `a message from after the welcome is`() { + val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000)) + + assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(2_001))) + } + + /** + * The boundary, and the reason it is drawn strictly. + * + * Nostr stamps `created_at` in whole seconds, so the second the Welcome was + * minted holds both the commit that added us -- the last act of the epoch + * before ours, unreadable by construction -- and any message another member + * sent the instant they applied it. Only one of those two can be had, and a + * message is worth more than a spared decrypt. + */ + @Test + fun `a message from the very second of the welcome is kept`() { + val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000)) + + assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(2_000))) + } + + /** + * A room joined before the column existed. `createdAt` is when this device + * wrote the row down, which for a joiner is the Welcome it wrote it from -- + * the same answer every path that sets `joinedGroupAt` would have written. + */ + @Test + fun `a room with no recorded join falls back to when it was written down`() { + val chatRoom = room(joinedGroupAt = null, createdAt = Instant.fromEpochSeconds(5_000)) + + assertEquals(Instant.fromEpochSeconds(5_000), chatRoom.memberSince) + assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(4_999))) + assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(5_000))) + } + + /** + * A device that joined a room it had already heard of -- its own invite + * gift wrap arrived out of order, say, and the row was written before the + * Welcome was processed. The recorded join is the group's own account of + * when our epoch began and beats this device's account of when it started + * keeping notes. + */ + @Test + fun `a recorded join wins over when the row was written`() { + val chatRoom = room( + joinedGroupAt = Instant.fromEpochSeconds(9_000), + createdAt = Instant.fromEpochSeconds(5_000), + ) + + assertEquals(Instant.fromEpochSeconds(9_000), chatRoom.memberSince) + assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(6_000))) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotPreJoinIndexingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotPreJoinIndexingJvmTest.kt new file mode 100644 index 00000000..51c8e6db --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotPreJoinIndexingJvmTest.kt @@ -0,0 +1,174 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatMessage +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** + * What a room does with the kind:445 events the group published before this + * device was in it. + * + * They arrive whatever anyone wants: MLS gives a joiner the key schedule from + * their own epoch forward, the relay gives them the whole room, and negentropy + * syncs the lot down on first open. Every one of those events is permanently + * unreadable, and reading them anyway is what put a run of "Undecryptable + * Message" above the conversation a member had just been invited to. + * + * Asserted at the DAO because the decision is only worth anything where the + * backlog is: `indexMarmotGroupEvent` for the events as they land, and + * `reindexMarmotGroupEvents` for the replay that would otherwise spend a refused + * decrypt on each of them every time a member asks a room to try again. + * + * The room here has no MLS state, so nothing this replays can be read. That is + * the point: it separates events left alone because they predate the join from + * events tried and failed, which is the whole distinction under test. + */ +class MarmotPreJoinIndexingJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val keyPair = KeyPair() + private val user = keyPair.pubKey.toHexKey() + private val roomId = "a".repeat(64) + private val joinedAt = Instant.fromEpochSeconds(5_000) + + private suspend fun seedRoom(joinedGroupAt: Instant? = joinedAt) { + val profileEventId = "f".repeat(64) + db.nostrEventDao().upsert( + NostrEvent( + id = profileEventId, + pubKey = user, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert( + Profile(publicKey = user, userName = "member", nostrEventId = profileEventId) + ) + db.chatRoomDao().upsert( + ChatRoom( + id = roomId, + userPublicKey = user, + subject = null, + description = null, + mlsGroupState = null, + joinedGroupAt = joinedGroupAt, + createdAt = joinedGroupAt ?: joinedAt, + ) + ) + } + + /** One of the group's kind:445 events, stored the way a sync stores it. */ + private suspend fun seedGroupEvent(id: String, createdAt: Instant): String { + val eventId = id.padEnd(64, '0') + db.nostrEventDao().upsert( + NostrEvent( + id = eventId, + pubKey = "b".repeat(64), + kind = 445, + tags = arrayOf(arrayOf("h", roomId)), + content = "ciphertext", + sig = "0".repeat(128), + createdAt = createdAt, + ) + ) + return eventId + } + + private suspend fun lineFor(groupEventId: String): ChatMessage? = + db.chatMessageDao().getChatMessagesByMarmotGroupEventId(groupEventId) + + private suspend fun reindex() = db.nostrDao().reindexMarmotGroupEvents( + chatRoomId = roomId, + activeKeyPair = keyPair, + ) + + @Test + fun `a message from before the join leaves no line in the transcript`() = runBlocking { + seedRoom() + val before = seedGroupEvent("1", Instant.fromEpochSeconds(4_000)) + + reindex() + + assertNull( + lineFor(before), + "a message this device never held the epoch key for is not a message it can show", + ) + } + + /** + * The accounting. `stored` counts what the room holds, because it holds it, + * and `predatingMembership` says how much of that a replay was never going to + * touch; the rest counts only what it did touch. A sweep that reported the + * pre-join backlog as failures said a room was broken when it was working + * exactly as designed. + */ + @Test + fun `the pre-join backlog is counted apart from what was replayed`() = runBlocking { + seedRoom() + seedGroupEvent("1", Instant.fromEpochSeconds(3_000)) + seedGroupEvent("2", Instant.fromEpochSeconds(4_000)) + + val report = reindex() + + assertEquals(2, report.stored) + assertEquals(2, report.predatingMembership) + assertEquals(0, report.unresolved) + assertEquals(0, report.failed) + assertEquals(0, report.recovered) + } + + /** + * The other half, and the reason this is a cutoff rather than a blanket + * refusal to replay. An event from after the join that could not be read is a + * message waiting on a commit that has not landed, which is exactly what a + * replay exists to pick up. + */ + @Test + fun `an event from after the join is still replayed`() = runBlocking { + seedRoom() + seedGroupEvent("1", Instant.fromEpochSeconds(4_000)) + seedGroupEvent("2", Instant.fromEpochSeconds(6_000)) + + val report = reindex() + + assertEquals(2, report.stored) + assertEquals(1, report.predatingMembership) + assertEquals(1, report.unresolved, "only the event from after the join was replayed") + } + + /** + * A room joined before `joinedGroupAt` existed. `createdAt` is the same + * answer -- a joiner's row is written from the Welcome -- so the fix reaches + * rooms that were already on the device rather than only ones joined since. + */ + @Test + fun `a room with no recorded join still holds back its pre-join backlog`() = runBlocking { + seedRoom(joinedGroupAt = null) + val before = seedGroupEvent("1", Instant.fromEpochSeconds(4_000)) + + val report = reindex() + + assertNull(lineFor(before)) + assertEquals(0, report.unresolved) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/JoinedGroupAtMigrationJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/JoinedGroupAtMigrationJvmTest.kt new file mode 100644 index 00000000..90e04993 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/JoinedGroupAtMigrationJvmTest.kt @@ -0,0 +1,250 @@ +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 +import kotlin.test.assertTrue + +/** + * The v14 -> v15 migration, against a real database holding the lines it exists + * to clear out. + * + * Fixing the write path only stops the next one. Nothing rewrites a chat line + * that is already in the transcript, so a member who joined a busy room a week + * ago would go on opening it to the same run of "Undecryptable Message" -- one + * per message the group sent before they arrived -- however many versions later. + * That is the half of this migration worth asserting. + * + * What it must not do matters just as much. The delete is aimed at rows that say + * nothing by design, and everything else in the transcript is the final word on + * its group event: a message that was read, a commit that was applied, a line + * this device wrote for something it sent. A rule with a join and a comparison in + * it can take too much, and there is no undo. + * + * Run against the migration's own SQL on a bare connection rather than through + * Room, the way `ChronicleRenameMigrationJvmTest` is: Room's version wiring + * belongs to `PlatformDatabaseBuilder` and is the same for every migration in + * that list. + */ +class JoinedGroupAtMigrationJvmTest { + + private val connection: SQLiteConnection = BundledSQLiteDriver().open(":memory:") + + @AfterTest + fun close() = connection.close() + + /** v14's three tables, verbatim from `schemas/14.json`. */ + private fun createV14() { + 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, " + + "`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 )" + ) + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `MarmotGroupEvent` (`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 )" + ) + 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 this device wrote down at [createdAt], which is when it joined. */ + private fun insertRoom(id: String = "room", createdAt: Long) = connection.execSQL( + "INSERT INTO `ChatRoom` VALUES ('$id', 'user', NULL, NULL, NULL, NULL, NULL, NULL, " + + "$createdAt, $createdAt, $createdAt, NULL, NULL)" + ) + + private fun insertGroupEvent(id: String, createdAt: Long, room: String = "room") = + connection.execSQL( + "INSERT INTO `MarmotGroupEvent` (`id`, `userPublicKey`, `publicKey`, `chatRoomId`, " + + "`signature`, `encryptedContent`, `createdAt`, `updatedAt`, `savedAt`) " + + "VALUES ('$id', 'user', 'sender', '$room', 'sig', 'ciphertext', " + + "$createdAt, $createdAt, $createdAt)" + ) + + private fun insertLine( + groupEventId: String?, + messageType: String, + room: String = "room", + content: String = "line", + ) = connection.execSQL( + "INSERT INTO `ChatMessage` (`senderPublicKey`, `isUserMessage`, `marmotGroupEventId`, " + + "`chatRoomId`, `content`, `messageType`, `createdAt`, `updatedAt`, `savedAt`) " + + "VALUES ('user', 0, ${groupEventId?.let { "'$it'" } ?: "NULL"}, '$room', " + + "'$content', '$messageType', 1000, 1000, 1000)" + ) + + private fun lines(): List = + connection.prepare("SELECT `content` FROM `ChatMessage` ORDER BY `id`").use { statement -> + buildList { while (statement.step()) add(statement.getText(0)) } + } + + private fun columns(table: String): List = + connection.prepare("PRAGMA table_info(`$table`)").use { statement -> + buildList { while (statement.step()) add(statement.getText(1)) } + } + + @Test + fun `the column arrives, and every room reads as not having recorded a join`() = runBlocking { + createV14() + insertRoom(createdAt = 5_000) + + MIGRATION_14_15.migrate(connection) + + assertTrue("joinedGroupAt" in columns("ChatRoom")) + // Null on purpose. It already means "ask createdAt" -- see + // ChatRoom.memberSince -- and backfilling it would turn a fallback into + // a claim this migration is in no position to make. + connection.prepare("SELECT `joinedGroupAt` FROM `ChatRoom`").use { statement -> + assertTrue(statement.step() && statement.isNull(0)) + } + } + + /** + * `ALTER TABLE ... ADD COLUMN` appends, so the column lands last rather than + * where v15 declares it. Pinned because it looks like a mismatch and is not: + * Room compares a table's columns by name, and its own generated migration + * for a nullable addition appends in exactly this way. + */ + @Test + fun `the column is appended, which is where Room's own migrations put one`() = runBlocking { + createV14() + + MIGRATION_14_15.migrate(connection) + + assertEquals("joinedGroupAt", columns("ChatRoom").last()) + } + + @Test + fun `the placeholder lines for messages sent before this device joined are cleared`() = + runBlocking { + createV14() + insertRoom(createdAt = 5_000) + insertGroupEvent("before", createdAt = 4_000) + insertLine("before", ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, content = "gone") + + MIGRATION_14_15.migrate(connection) + + assertEquals(emptyList(), lines()) + } + + /** + * A commit held back because two competed for the same epoch reads the same + * way to the transcript, so it goes by the same rule. Both types are + * [ChatMessage.UNRESOLVED_MARMOT_TYPES] -- lines that stand in for an event + * that was never read -- and removing one loses nothing. + */ + @Test + fun `a pending commit line from before the join goes too`() = runBlocking { + createV14() + insertRoom(createdAt = 5_000) + insertGroupEvent("before", createdAt = 4_000) + insertLine("before", ChatMessage.TYPE_PENDING_COMMIT, content = "gone") + + MIGRATION_14_15.migrate(connection) + + assertEquals(emptyList(), lines()) + } + + /** + * The one this rule could get wrong. A placeholder for an event from after + * the join is a message still waiting on a commit that has not landed, and a + * replay is expected to recover it -- see `NostrDao.reindexMarmotGroupEvents`. + */ + @Test + fun `a placeholder for an event from after the join is left to be recovered`() = runBlocking { + createV14() + insertRoom(createdAt = 5_000) + insertGroupEvent("after", createdAt = 6_000) + insertLine("after", ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, content = "still waiting") + + MIGRATION_14_15.migrate(connection) + + assertEquals(listOf("still waiting"), lines()) + } + + @Test + fun `a message that was read is left alone however old it is`() = runBlocking { + createV14() + insertRoom(createdAt = 5_000) + insertGroupEvent("read", createdAt = 4_000) + insertLine("read", "message", content = "hello") + + MIGRATION_14_15.migrate(connection) + + assertEquals(listOf("hello"), lines()) + } + + /** + * Lines with no group event behind them: NIP-17 messages, and everything a + * session writes about itself. The delete reaches them through + * `marmotGroupEventId`, and a null one joins to nothing, but SQL nulls are + * quiet enough about it to be worth an assertion. + */ + @Test + fun `a line with no group event behind it is out of reach of the rule`() = runBlocking { + createV14() + insertRoom(createdAt = 5_000) + insertLine(null, ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, content = "not marmot") + + MIGRATION_14_15.migrate(connection) + + assertEquals(listOf("not marmot"), lines()) + } + + /** + * The comparison is per room. Two rooms joined at different times share one + * transcript table, and a run of placeholders in the older room says nothing + * about the newer one's. + */ + @Test + fun `each room is measured against its own join`() = runBlocking { + createV14() + insertRoom(id = "old", createdAt = 1_000) + insertRoom(id = "new", createdAt = 9_000) + insertGroupEvent("inOld", createdAt = 4_000, room = "old") + insertGroupEvent("inNew", createdAt = 4_000, room = "new") + insertLine("inOld", ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, room = "old", content = "kept") + insertLine("inNew", ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, room = "new", content = "gone") + + MIGRATION_14_15.migrate(connection) + + assertEquals(listOf("kept"), lines()) + } +}