From 6ff9fd2d3869047088d70fbd5ea9ff653b087c44 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 20:20:07 +0200 Subject: [PATCH 01/13] feat: let a group define the dialects it translates into MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dialects existed but had nowhere to come from. The only way to create one was the "New dialect" branch buried inside the add-artifact form, which meant a dialect could only be born as a side effect of adding the first artifact written in it. A group that wanted to line up the languages it works in before any source material arrived had no way to say so, and a dialect created that way was invisible afterwards -- there was no screen anywhere that listed what the group had defined. Give the group detail screen a Dialects section between Library and Projects: the dialects defined in this room, each showing its name over " · ", and an Add Dialect button. The cards do not navigate -- there is no dialect detail screen to open, and a card that goes nowhere is worse than one that plainly does not. The add screen is the add-artifact form with the artifact half removed: the same chat-room title bar, the same bottom bar with an extended FAB, the same three fields (name, country, language) styled the same way. On success it returns to the group with popUpTo {inclusive = true}, replacing the stale detail screen beneath it so the new dialect is actually in the list when you land -- these lists load once, in the view model's initiate(). One deliberate difference from AddArtifactViewModel: it wraps its whole body in `localChatRoom.chatRoom.toMlsGroup()?.let { ... }` and so does nothing at all, silently, in a NIP-17 room. Nothing under MantraRepository.addDialect needs an MLS group, so the gate is left out rather than copied into a new screen as a button that does nothing. Co-Authored-By: Claude Opus 5 --- .../compose/ui/composable/AddDialectScreen.kt | 309 ++++++++++++++++++ .../ui/composable/ChatRoomDetailScreen.kt | 68 ++++ .../ui/composable/navigation/MantraNavHost.kt | 30 ++ .../navigation/routes/AddDialectRoute.kt | 10 + .../ui/view/model/AddDialectViewModel.kt | 134 ++++++++ .../ui/view/model/ChatRoomDetailViewModel.kt | 1 + .../ui/view/state/AddDialectUIState.kt | 15 + .../ui/view/state/ChatRoomDetailUIState.kt | 2 + 8 files changed, 569 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddDialectRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt new file mode 100644 index 00000000..b02663ca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt @@ -0,0 +1,309 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Title +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.view.model.AddDialectViewModel +import press.mantra.compose.ui.view.state.AddDialectUIState + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AddDialectScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateToRoute: (Route) -> Unit, +) { + val addDialectViewModel: AddDialectViewModel = viewModel( + factory = AddDialectViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddDialectUIState = initialAddDialectUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + activeUserPublicKey = activeUserPublicKey, + mantraRepository = mantraRepository + ) + ) + + when (val addDialectUIState = addDialectViewModel.addDialectUIState) { + is AddDialectUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(50.dp) + ) + Text( + text = addDialectUIState.message, + ) + } + } + is AddDialectUIState.Loaded -> { + val nameFieldState = rememberTextFieldState() + val countryFieldState = rememberTextFieldState() + val languageFieldState = rememberTextFieldState() + + Scaffold( + topBar = { + TopAppBar( + title = { + addDialectUIState.localChatRoom.RenderChatRoomTitleText() + }, + actions = { + + } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + addDialectViewModel.addDialect( + localChatRoom = addDialectUIState.localChatRoom, + nameField = nameFieldState, + countryField = countryFieldState, + languageField = languageFieldState, + onSuccess = { + // Back to the group, reloaded so the new + // dialect shows up in the list. + onNavigateToRouteAndPopUpInclusive.invoke( + ChatRoomDetailRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed Dialect") + ) + } + ) + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Add dialect" + ) + Text("Add Dialect") + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).fillMaxSize() + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Add a dialect the group can translate into") + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = nameFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Title, + contentDescription = "Name of the dialect" + ) + }, + label = { + Text( + text = "Dialect Name" + ) + }, + placeholder = { + Text( + text = "eg. Sesotho" + ) + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = countryFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Public, + contentDescription = "Country of the dialect" + ) + }, + label = { + Text( + text = "Country" + ) + }, + placeholder = { + Text( + text = "eg. Lesotho" + ) + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = languageFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Translate, + contentDescription = "Language of the dialect" + ) + }, + label = { + Text( + text = "Language" + ) + }, + placeholder = { + Text( + text = "eg. st" + ) + }, + ) + } + } + } + } + AddDialectUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + 20.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = "Add Dialect", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator( + fillScreen = false + ) + + Spacer( + modifier = Modifier.weight(2f) + ) + } + } + } + + LaunchedEffect(true) { + if (initialAddDialectUIState == AddDialectUIState.Loading) { + addDialectViewModel.initiateAddDialect() + } + } +} + +@Preview +@Composable +private fun AddDialectScreenPreview() { + TorchTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + AddDialectScreen( + activeUserPublicKey = "", + chatRoomId = "publicKey", + relayHint = null, + initialAddDialectUIState = AddDialectUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "", + userPublicKey = "", + subject = "Message title", + description = "See something. Say somethin", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = null + ), + ) + ), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateToRoute = {} + ) + } + } +} 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 1bce2c76..ba444544 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 @@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.LibraryBooks import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Schema +import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Unsubscribe import androidx.compose.material.icons.filled.WaterfallChart import androidx.compose.material3.ButtonDefaults @@ -60,6 +61,7 @@ import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute +import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -217,6 +219,72 @@ fun ChatRoomDetailScreen( HorizontalDivider() } + item { + // Dialects + Text( + text = "Dialects", + style = MaterialTheme.typography.labelMedium + ) + } + + if (chatRoomDetailUIState.dialects.isEmpty()) { + item { + Text("No dialects have been defined in this group.") + } + } else { + items( + items = chatRoomDetailUIState.dialects, + key = { dialect -> dialect.id } + ) { dialect -> + Card { + ListItem( + leadingContent = { + Icon( + Icons.Default.Translate, + contentDescription = "Dialect" + ) + }, + headlineContent = { + Text(text = dialect.name) + }, + supportingContent = { + Text(text = "${dialect.language} \u00b7 ${dialect.country}") + } + ) + } + } + } + + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + AddDialectRoute( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint + ) + ) + } + ) { + + Icon( + Icons.Default.Translate, + contentDescription = "Add new dialect" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text("Add Dialect") + } + } + + item { + HorizontalDivider() + } + // TODO: Add projects... item { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 9f9d612e..5dad096d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -103,6 +103,7 @@ import kotlinx.coroutines.launch import press.mantra.compose.database.repository.DatabaseMantraRepository import press.mantra.compose.ui.composable.AddArtifactScreen import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute +import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute @@ -112,6 +113,7 @@ import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRo import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.ArtifactDetailScreen import press.mantra.compose.ui.composable.AddChapterScreen +import press.mantra.compose.ui.composable.AddDialectScreen import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen import press.mantra.compose.ui.composable.ChapterDetailScreen import press.mantra.compose.ui.composable.TranslateChunkScreen @@ -814,6 +816,34 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddDialectScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + mantraRepository = databaseMantraRepository, + onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute -> + // Replace both this add screen and the stale chat room detail + // beneath it so we land on a freshly-loaded detail screen. + navController.navigate( + route = chatRoomDetailRoute + ) { + popUpTo { + inclusive = true + } + } + }, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddDialectRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddDialectRoute.kt new file mode 100644 index 00000000..b66a8ee4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddDialectRoute.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class AddDialectRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt new file mode 100644 index 00000000..b4a3abfb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt @@ -0,0 +1,134 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.AddDialectUIState + +class AddDialectViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialAddDialectUIState: AddDialectUIState, + val nostrRepository: NostrRepository, + val chatRepository: ChatRepository, + val mantraRepository: MantraRepository, +): ViewModel() { + + var addDialectUIState: AddDialectUIState by mutableStateOf(initialAddDialectUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateAddDialect() { + logger.d("compressed (most likely chat room): $chatRoomId") + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + addDialectUIState = if (localChatRoom == null) { + AddDialectUIState.Error("Couldn't find the chat room") + } else { + AddDialectUIState.Loaded( + localChatRoom = localChatRoom, + ) + } + } + } + + fun addDialect( + localChatRoom: LocalChatRoom, + nameField: TextFieldState, + countryField: TextFieldState, + languageField: TextFieldState, + onSuccess: (dialectId: String) -> Unit, + onFailure: () -> Unit + ) { + val name = nameField.text.toString() + val country = countryField.text.toString() + val language = languageField.text.toString() + + if (name.isBlank() || country.isBlank() || language.isBlank()) { + onFailure.invoke() + return + } + + // Guard against double submits from repeated FAB taps. + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val dialectInnerEvent = runCatching { + mantraRepository.addDialect( + localChatRoom = localChatRoom, + name = name, + country = country, + language = language, + userPublicKey = activeUserPublicKey, + ) + }.onFailure { error -> + logger.e("Failed to add dialect", error) + }.getOrNull() + + if (dialectInnerEvent != null) { + nameField.clearText() + countryField.clearText() + languageField.clearText() + + viewModelScope.launch(Dispatchers.Main) { + onSuccess.invoke(dialectInnerEvent.id) + } + } else { + viewModelScope.launch(Dispatchers.Main) { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "AddDialectViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + mantraRepository: MantraRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AddDialectViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddDialectUIState = initialAddDialectUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + mantraRepository = mantraRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 89535ec0..9e1d7068 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -55,6 +55,7 @@ class ChatRoomDetailViewModel( ChatRoomDetailUIState.Loaded( localChatRoom = localChatRoom, artifacts = mantraRepository.getArtifacts(chatRoomId), + dialects = mantraRepository.getDialects(chatRoomId), ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt new file mode 100644 index 00000000..3f66e18b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt @@ -0,0 +1,15 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface AddDialectUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + ): AddDialectUIState + + data class Error( + val message: String + ): AddDialectUIState + + data object Loading: AddDialectUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index cb464925..7d710357 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -1,12 +1,14 @@ package press.mantra.compose.ui.view.state import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom sealed interface ChatRoomDetailUIState { data class Loaded( val localChatRoom: LocalChatRoom, val artifacts: List = emptyList(), + val dialects: List = emptyList(), ): ChatRoomDetailUIState data class Error( From e14be2d187128803af563ed426af1838c1e6a0e3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 20:20:35 +0200 Subject: [PATCH 02/13] refactor: pick an artifact's dialect, do not invent one while adding it Adding an artifact offered a "New dialect" chip that swapped in three more fields -- name, country, language -- and minted a dialect on the way to creating the artifact. Now that a group defines its dialects on its own screen, that path is a second, worse way to do the same thing: it creates a dialect as a side effect of an unrelated action, in a form where the fields belong to neither entity clearly, and with no sight of what the group has already defined beyond a row of chips. Drop it. The chip row is now exactly the dialects that exist, and selectedDialectId changes meaning from "null = create a new one" to "null = nothing picked yet" -- which the FAB rejects alongside the other required fields, rather than falling through to creating something. A group with no dialects yet gets a line saying so and pointing at the group screen, instead of a lone chip that opens a form. addArtifact loses the three TextFieldStates and existingDialectId for a single dialectId, and with them the branch that called addDialect and threaded its id back in. Validation is now one condition rather than one per path. Co-Authored-By: Claude Opus 5 --- .../ui/composable/AddArtifactScreen.kt | 137 +++--------------- .../ui/view/model/AddArtifactViewModel.kt | 34 +---- 2 files changed, 24 insertions(+), 147 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index 38d68b94..34e7932f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt @@ -18,9 +18,6 @@ import androidx.compose.material.icons.filled.Label import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.LocalOffer import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Title -import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBarDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -108,12 +105,9 @@ fun AddArtifactScreen( val nameFieldState = rememberTextFieldState() val urlFieldState = rememberTextFieldState() val versionLabelFieldState = rememberTextFieldState("1.0") - val dialectNameFieldState = rememberTextFieldState() - val dialectCountryFieldState = rememberTextFieldState() - val dialectLanguageFieldState = rememberTextFieldState() - // null = "New dialect" (show the create fields); otherwise the id of - // an existing dialect to reuse. + // The id of the group dialect this artifact is written in. Null until + // one is picked; dialects are defined from the group detail screen. var selectedDialectId: String? by remember { mutableStateOf(null) } Scaffold( @@ -138,10 +132,7 @@ fun AddArtifactScreen( nameField = nameFieldState, urlField = urlFieldState, versionLabelField = versionLabelFieldState, - existingDialectId = selectedDialectId, - dialectNameField = dialectNameFieldState, - dialectCountryField = dialectCountryFieldState, - dialectLanguageField = dialectLanguageFieldState, + dialectId = selectedDialectId, onSuccess = { artifactId -> // Open the newly created artifact, removing this // add screen from the back stack. @@ -266,112 +257,24 @@ fun AddArtifactScreen( Text("Source dialect") - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - addArtifactUIState.dialects.forEach { dialect -> - FilterChip( - selected = selectedDialectId == dialect.id, - onClick = { selectedDialectId = dialect.id }, - label = { Text(dialect.name) } - ) + if (addArtifactUIState.dialects.isEmpty()) { + Text( + text = "No dialects have been defined in this group yet. Add one from the group's detail screen first.", + style = MaterialTheme.typography.bodySmall + ) + } else { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + addArtifactUIState.dialects.forEach { dialect -> + FilterChip( + selected = selectedDialectId == dialect.id, + onClick = { selectedDialectId = dialect.id }, + label = { Text(dialect.name) } + ) + } } - FilterChip( - selected = selectedDialectId == null, - onClick = { selectedDialectId = null }, - leadingIcon = { - Icon( - Icons.Default.Add, - contentDescription = "Create a new dialect" - ) - }, - label = { Text("New dialect") } - ) - } - - // Only collect new-dialect details when not reusing an existing one. - if (selectedDialectId == null) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = dialectNameFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.Title, - contentDescription = "Name of the dialect" - ) - }, - label = { - Text( - text = "Dialect Name" - ) - }, - placeholder = { - Text( - text = "eg. Sesotho" - ) - }, - ) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = dialectCountryFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.Public, - contentDescription = "Country of the dialect" - ) - }, - label = { - Text( - text = "Country" - ) - }, - placeholder = { - Text( - text = "eg. Lesotho" - ) - }, - ) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = dialectLanguageFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.Translate, - contentDescription = "Language of the dialect" - ) - }, - label = { - Text( - text = "Language" - ) - }, - placeholder = { - Text( - text = "eg. st" - ) - }, - ) } // TODO: Add Visibility diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt index a84ec5a8..cc90e468 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt @@ -62,10 +62,7 @@ class AddArtifactViewModel( nameField: TextFieldState, urlField: TextFieldState, versionLabelField: TextFieldState, - existingDialectId: HexKey?, - dialectNameField: TextFieldState, - dialectCountryField: TextFieldState, - dialectLanguageField: TextFieldState, + dialectId: HexKey?, visibility: String = MantraRepository.DEFAULT_VISIBILITY, license: String = MantraRepository.DEFAULT_LICENSE, onSuccess: (artifactId: String) -> Unit, @@ -75,16 +72,10 @@ class AddArtifactViewModel( val name = nameField.text.toString() val url = urlField.text.toString() val versionLabel = versionLabelField.text.toString() - val dialectName = dialectNameField.text.toString() - val dialectCountry = dialectCountryField.text.toString() - val dialectLanguage = dialectLanguageField.text.toString() - // When no existing dialect is selected, the new-dialect fields are required. - val creatingNewDialect = existingDialectId.isNullOrBlank() - val newDialectIncomplete = dialectName.isBlank() || dialectCountry.isBlank() || dialectLanguage.isBlank() - if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || - (creatingNewDialect && newDialectIncomplete) - ) { + // addArtifact requires an existing source dialect; they are defined + // from the group detail screen, not here. + if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) { onFailure.invoke() return } @@ -95,20 +86,6 @@ class AddArtifactViewModel( viewModelScope.launch(Dispatchers.IO) { val artifactInnerEvent = runCatching { - // Reuse the selected dialect, or create a new source dialect and - // reference it by id. addArtifact requires a valid dialectId. - val dialectId = if (creatingNewDialect) { - mantraRepository.addDialect( - localChatRoom = localChatRoom, - name = dialectName, - country = dialectCountry, - language = dialectLanguage, - userPublicKey = activeUserPublicKey, - )?.id ?: return@runCatching null - } else { - existingDialectId - } - mantraRepository.addArtifact( localChatRoom = localChatRoom, name = name, @@ -127,9 +104,6 @@ class AddArtifactViewModel( nameField.clearText() urlField.clearText() versionLabelField.clearText() - dialectNameField.clearText() - dialectCountryField.clearText() - dialectLanguageField.clearText() viewModelScope.launch(Dispatchers.Main) { onSuccess.invoke(artifactInnerEvent.id) From fa380e94e15a7d5f3dfb49308025b8d63c70f13d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 20:21:00 +0200 Subject: [PATCH 03/13] feat: add a SubmissionEvent that carries a nip30303 event as its payload Every nip30303 kind so far describes a thing: an artifact, a dialect, a chapter, a translated chunk. None of them describes the act of putting one in front of a group, and until now nothing needed to -- a group event's sender was the author of the event inside it, so the two questions had one answer by construction. That construction is also the limit. It means a group can only ever hold work written by its own members under their own keys. A translation lifted from a public archive, a chapter transcribed by an outside contributor, an artifact somebody published years ago: none of it can go in without a member re-authoring it and taking the byline. Kind 30312 is the envelope that separates them. Its content is the payload event's JSON, whole -- same id, same pubKey, same signature, nothing rewritten to look like the submitter's work. The submitter signs for the envelope; the author still signs for the event. Two tags name what is inside so a client can decide whether it can apply a submission without parsing the content first: payloadKind the payload's kind payloadId the payload's id, with the author slot carrying the payload's author -- who, unusually for an id tag in this package, is often not the event's sender Kinds 30300-30311 are taken (30305 and 30307 by contributor lists), so 30312 is the next free one. A submission is not an endorsement and grants nothing. Who may submit is the group's business; this only makes the question expressible. The test covers the property the whole thing rests on: an event written by an outsider goes into an envelope, comes out of a JSON round trip with its id, author and signature intact, and does not acquire the submitter as its author. It also pins payload() returning null rather than something empty when the content will not parse -- which needed android.util.Log stubbing, since quartz logs on that path and unmocked Log methods throw, failing the test on the log line rather than on what it came to check. Nothing sends or reads one yet. Co-Authored-By: Claude Opus 5 --- composeApp/build.gradle.kts | 9 ++ .../compose/nostr/nip30303/SubmissionEvent.kt | 81 +++++++++++ .../nostr/nip30303/tags/PayloadIdTag.kt | 47 +++++++ .../nostr/nip30303/tags/PayloadKindTag.kt | 43 ++++++ .../nostr/nip30303/SubmissionEventTest.kt | 126 ++++++++++++++++++ 5 files changed, 306 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadIdTag.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadKindTag.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEventTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 7d1eb406..71501a81 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -169,6 +169,15 @@ android { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } + testOptions { + unitTests { + // Quartz logs through android.util.Log on the error paths -- parsing a + // malformed event, for one. Unmocked, those methods throw, so a test + // covering such a path fails on the log line rather than on what it + // came to check. Default values let the code under test carry on. + isReturnDefaultValues = true + } + } } dependencies { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEvent.kt new file mode 100644 index 00000000..3a2abf79 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEvent.kt @@ -0,0 +1,81 @@ +package press.mantra.compose.nostr.nip30303 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.PayloadIdTag +import press.mantra.compose.nostr.nip30303.tags.PayloadKindTag + +/** + * 30312 + * + * An envelope that carries one nip30303 event into a group. + * + * Every other nip30303 kind describes a thing -- an artifact, a dialect, a + * chapter, a translated chunk. A submission describes an act: *this member is + * putting this event in front of this group*. The two are separate on purpose, + * because they answer different questions and often have different answers. + * + * The payload travels whole, in [content], keeping its own id, author and + * signature. Nothing is rewritten to make it look like the submitter's work. + * That buys two things: + * + * - A group can take in work written by somebody who is not in it. A + * translation lifted from a public archive, a chapter transcribed by an + * outside contributor, an artifact somebody published years ago -- an admin + * submits it and the group applies it, with the original author still named + * on the row. + * - Authorship stops being a claim the transport makes. Before this, being + * the sender of a group message *was* being the author of the event inside + * it, so the only events a group could hold were ones its own members had + * written under their own keys. + * + * A submission is not an endorsement and grants nothing: a payload's author is + * whoever signed it, and who may submit is the group's business. + */ +@Immutable +class SubmissionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + /** + * The submitted event, parsed out of [content]. + * + * Null when the content is not an event at all -- treat that as a + * submission that cannot be applied, not as an empty one. + */ + fun payload(): Event? = Event.fromJsonOrNull(content) + + fun payloadKind() = tags.firstNotNullOfOrNull(PayloadKindTag::parse)?.kind + + fun payloadIdReference() = tags.firstNotNullOfOrNull(PayloadIdTag::parse)?.ref + fun payloadId() = payloadIdReference()?.eventId + + /** Who wrote the payload, which is not who sent this submission. */ + fun payloadAuthor() = payloadIdReference()?.author + + companion object { + const val KIND = 30312 + const val ALT_DESCRIPTION = "Submission" + + fun build( + payload: Event, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, payload.toJson(), createdAt) { + alt(ALT_DESCRIPTION) + addUnique(PayloadKindTag.assemble(payload.kind)) + addUnique(PayloadIdTag.assemble(eventId = payload.id, pubkey = payload.pubKey)) + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadIdTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadIdTag.kt new file mode 100644 index 00000000..8cb1bdab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadIdTag.kt @@ -0,0 +1,47 @@ +package press.mantra.compose.nostr.nip30303.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** + * The event a submission carries, named on the submission itself. + * + * The payload is already in the submission's content, so this tag is not how a + * reader gets at it -- it is how they filter for it. The author slot matters + * more than usual here: on a submission it names whoever wrote the payload, + * who is not necessarily the member who submitted it. + */ +data class PayloadIdTag( + val ref: EventReference, +) { + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this( + EventReference(eventId, pubkey, relayHint) + ) + + companion object { + const val TAG_NAME = "payloadId" + + fun parse(tag: Array): PayloadIdTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return PayloadIdTag(tag[1], relayHint, tag.getOrNull(3)) + } + + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl? = null, + pubkey: String? = null, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey) + + fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadKindTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadKindTag.kt new file mode 100644 index 00000000..4634b251 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadKindTag.kt @@ -0,0 +1,43 @@ +package press.mantra.compose.nostr.nip30303.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The kind of the event a submission carries. + * + * Lets a client decide whether it can apply a submission without parsing the + * payload JSON out of the content first. + */ +class PayloadKindTag( + val kind: Int, +) { + fun toTagArray() = assemble( + kind = kind, + ) + + companion object { + const val TAG_NAME = "payloadKind" + + fun parse(tag: Array): PayloadKindTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + val kind = tag[1].toIntOrNull() ?: return null + return PayloadKindTag( + kind = kind, + ) + } + + fun assemble( + kind: Int, + ): Array = arrayOf( + TAG_NAME, + kind.toString() + ) + + fun assemble(payloadKindTag: PayloadKindTag) = assemble( + kind = payloadKindTag.kind, + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEventTest.kt new file mode 100644 index 00000000..90b36aa6 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEventTest.kt @@ -0,0 +1,126 @@ +package press.mantra.compose.nostr.nip30303 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +/** + * What a submission has to survive: the trip through a group. + * + * The envelope is only worth having if the event inside it comes out the other + * side unchanged -- same id, same author, same signature. The moment any of + * those is rewritten in transit, a group can no longer hold work by anyone but + * its own members, which is the whole reason submissions exist. + */ +class SubmissionEventTest { + private val submitter = "a".repeat(64) + private val outsider = "b".repeat(64) + + /** A dialect written by somebody who is not in the group. */ + private fun outsiderDialect(): Event { + val template = DialectEvent.build( + name = "Sesotho", + country = "Lesotho", + language = "st", + createdAt = 1_700_000_000L, + ) + return Event( + id = EventHasher.hashId( + pubKey = outsider, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = outsider, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "c".repeat(128), + ) + } + + /** Send a submission template and read it back the way inbound does. */ + private fun roundTrip(payload: Event): SubmissionEvent { + val template = SubmissionEvent.build(payload = payload, createdAt = 1_700_000_100L) + val onTheWire = Event.fromJson( + Event( + id = EventHasher.hashId( + pubKey = submitter, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = submitter, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "", + ).toJson() + ) + + return SubmissionEvent( + id = onTheWire.id, + pubKey = onTheWire.pubKey, + createdAt = onTheWire.createdAt, + tags = onTheWire.tags, + content = onTheWire.content, + sig = onTheWire.sig, + ) + } + + @Test + fun `the payload comes back as the event that went in`() { + val dialect = outsiderDialect() + + val payload = roundTrip(dialect).payload() + + assertEquals(dialect.id, payload?.id) + assertEquals(dialect.pubKey, payload?.pubKey) + assertEquals(dialect.kind, payload?.kind) + assertEquals(dialect.content, payload?.content) + assertEquals(dialect.sig, payload?.sig) + } + + @Test + fun `submitting does not make the submitter the author`() { + val dialect = outsiderDialect() + + val submission = roundTrip(dialect) + + assertEquals(submitter, submission.pubKey) + assertEquals(outsider, submission.payload()?.pubKey) + assertNotEquals(submission.pubKey, submission.payload()?.pubKey) + } + + @Test + fun `the envelope names what it carries without being opened`() { + val dialect = outsiderDialect() + + val submission = roundTrip(dialect) + + assertEquals(DialectEvent.KIND, submission.payloadKind()) + assertEquals(dialect.id, submission.payloadId()) + assertEquals(outsider, submission.payloadAuthor()) + } + + @Test + fun `a payload the group cannot read is null rather than empty`() { + val submission = SubmissionEvent( + id = "d".repeat(64), + pubKey = submitter, + createdAt = 1_700_000_100L, + tags = arrayOf(), + content = "not an event", + sig = "", + ) + + assertNull(submission.payload()) + } +} From ce77b772409d622029b0d10b0ca5ab010a51a05d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 20:21:25 +0200 Subject: [PATCH 04/13] feat: apply the nip30303 event a submission carries, keeping its author Teach the receiving side to open an envelope before anything starts sending one. In that order a client that has this can already handle submissions from a client that does not yet send them; the reverse would turn every artifact, dialect and chapter into an "unsupported" row for anyone who had not updated. Despite the name, MarmotInboundManager does not dispatch on inner-event kinds -- it decrypts MLS and hands back a GroupEventResult. The kind dispatch has always lived in ChatMessage.fromGroupEventResult, so that is where support for a new kind goes. The `when (event.kind)` body becomes applyInnerEvent, which takes the event to apply separately from how it arrived: event the nip30303 event, written by whoever wrote it -- possibly nobody in this group marmotInnerEventId the row the group actually delivered senderPublicKey the member who delivered it createdAt when they did For a plain nip30303 event those all come from the one event, which is exactly the old behaviour. For a submission they come from the envelope while `event` is the payload. Entity rows take their author from the payload via fromXEvent, so the chat line says who added something and the row says who wrote it -- the point of the envelope, made real at the only place it can be. createdAt deliberately follows the envelope rather than the payload: a submitted archive translation can be years old, and sorting the group's transcript by when the source was written would file "X added a translation" somewhere nobody will scroll to. The stored MarmotInnerEvent stays the outer event -- that is what the group sent -- and gains payloadEventId naming what it carries. The payload is not given a row of its own: it is recoverable from the submission's content, and a second row with a null marmotGroupEventId would look to the outbound pipeline like something waiting to be sent. Nullable column, so AutoMigration(4, 5) is all it needs; rumors queued before this read back null, which is correct, since none of them were submissions. Two submissions are stored but not applied, because there is nothing in them to make a row from: one whose payload will not parse, and one carrying another submission. Both surface as "unsupported" rather than disappearing. The unsupported fallback also stops attributing to groupEvent.pubKey, which is the ephemeral key every kind:445 is signed with and so names nobody. Co-Authored-By: Claude Opus 5 --- .../5.json | 5051 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 10 +- .../compose/database/model/ChatMessage.kt | 645 ++- .../database/model/MarmotInnerEvent.kt | 11 + 4 files changed, 5429 insertions(+), 288 deletions(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/5.json diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/5.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/5.json new file mode 100644 index 00000000..503babc9 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/5.json @@ -0,0 +1,5051 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "aa31bf911f296dcdac70918a4879c5d7", + "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, `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": "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, `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": "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, `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": "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": "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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, 'aa31bf911f296dcdac70918a4879c5d7')" + ] + } +} \ 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 f2c9c27b..e3c146ec 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -164,7 +164,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 4, + version = 5, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -173,11 +173,17 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // additions need no default and drop no data, so Room generates this one // too. Rituals already in flight come back with all three null, which reads // as "not approved yet" and simply asks the member for each step. - AutoMigration(from = 2, to = 3) + AutoMigration(from = 2, to = 3), // v4 changes no schema at all -- it rewrites `dkgApprovalNeeded` chat rows // into one type per ritual step. Data, not shape, so it is a manual // migration passed to the builder rather than an entry here. See // MIGRATION_3_4. + // + // v5 adds the nullable MarmotInnerEvent.payloadEventId, which names the + // nip30303 event a SubmissionEvent rumor carries. Rumors queued before + // this come back null, which reads as "not a submission" -- correct, + // since none of them were. + AutoMigration(from = 4, to = 5) ] ) @ColumnTypeConverters(MantraConverters::class) 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 7fe1264f..28e2d0f8 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 @@ -22,6 +22,7 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.SubmissionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributorListEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent @@ -199,6 +200,25 @@ data class ChatMessage( val event = Event.fromJsonOrNull(groupEventResult.innerEventJson) ?: throw MarmotUnprocessableInnerEventException("Can't process ${groupEventResult.innerEventJson}") + // A submission is an envelope: the nip30303 event it delivers + // is in its content, written by whoever wrote it. Everything + // else on the wire is the nip30303 event itself. Either way the + // row on disk is the outer event -- that is what the group sent + // and what the chat line is attributed to. + val submission = if (event.kind == SubmissionEvent.KIND) { + SubmissionEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig, + ) + } else { + null + } + val payload = submission?.payload() + database.marmotInnerEventDao().upsert( MarmotInnerEvent( id = event.id, @@ -208,296 +228,37 @@ data class ChatMessage( content = event.content, chatRoomId = groupEventResult.groupId, kind = event.kind, + payloadEventId = payload?.id, createdAt = Instant.fromEpochSeconds(event.createdAt) ) ) - when (event.kind) { - ChatEvent.KIND -> { - ChatMessage( - giftWrapPayloadId = null, - messageType = "message", - marmotGroupEventId = groupEvent.id, - marmotInnerEventId = event.id, - senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, - chatRoomId = groupEventResult.groupId, - createdAt = Instant.fromEpochSeconds(event.createdAt), - content = event.content, // TODO: Figure out what to do here... - ) - } - ArtifactEvent.KIND -> { - MantraArtifact.fromArtifactEvent( - artifactEvent = ArtifactEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig, - ), - chatRoomId = groupEventResult.groupId, - )?.let { mantraArtifact -> - database.mantraArtifactDao().upsert( - mantraArtifact.copy( - marmotGroupEventId = groupEvent.id, - ) - ) - ChatMessage( - giftWrapPayloadId = null, - messageType = "artifact", - marmotGroupEventId = groupEvent.id, - marmotInnerEventId = event.id, - senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, - chatRoomId = groupEventResult.groupId, - createdAt = Instant.fromEpochSeconds(event.createdAt), - content = "Added ${mantraArtifact.name} to artifacts" - ) - } - } - ArtifactVersionEvent.KIND -> { - MantraArtifactVersion.fromArtifactVersionEvent( - artifactVersionEvent = ArtifactVersionEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig - ), - chatRoomId = groupEventResult.groupId, - )?.let { mantraArtifactVersion -> - database.mantraArtifactVersionDao().upsert( - mantraArtifactVersion.copy( - marmotGroupEventId = groupEvent.id, - ) - ) - - ChatMessage( - giftWrapPayloadId = null, - messageType = "artifactVersion", - marmotGroupEventId = groupEvent.id, - marmotInnerEventId = event.id, - senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, - chatRoomId = groupEventResult.groupId, - createdAt = Instant.fromEpochSeconds(event.createdAt), - content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name... - ) - } - } - ChapterEvent.KIND -> { - MantraChapter.fromChapterEvent( - chapterEvent = ChapterEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig - ), - chatRoomId = groupEventResult.groupId, - )?.let { mantraChapter -> - database.mantraChapterDao().upsert( - mantraChapter.copy( - marmotGroupEventId = groupEvent.id, - ) - ) - - ChatMessage( - giftWrapPayloadId = null, - messageType = "chapter", - marmotGroupEventId = groupEvent.id, - marmotInnerEventId = event.id, - senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, - chatRoomId = groupEventResult.groupId, - createdAt = Instant.fromEpochSeconds(event.createdAt), - content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name... - ) - } - } - - ChunkEvent.KIND -> { - MantraChunk.fromChunkEvent( - chunkEvent = ChunkEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig - ), - chatRoomId = groupEventResult.groupId, - )?.let { mantraChunk -> - database.mantraChunkDao().upsert( - mantraChunk.copy( - marmotGroupEventId = groupEvent.id, - ) - ) - - // TODO: Chunks might be too noisy to show in chat... - } - null - } - DialectEvent.KIND -> { - MantraDialect.fromDialectEvent( - dialectEvent = DialectEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig - ), - chatRoomId = groupEventResult.groupId, - )?.let { mantraDialect -> - database.mantraDialectDao().upsert( - mantraDialect.copy( - marmotGroupEventId = groupEvent.id, - ) - ) - - - ChatMessage( - giftWrapPayloadId = null, - messageType = "dialect", - marmotGroupEventId = groupEvent.id, - marmotInnerEventId = event.id, - senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, - chatRoomId = groupEventResult.groupId, - createdAt = Instant.fromEpochSeconds(event.createdAt), - content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name... - ) - } - } - TranslationArtifactVersionEvent.KIND -> { - MantraTranslationArtifactVersion.fromTranslationArtifactVersionEvent( - translationArtifactVersionEvent = TranslationArtifactVersionEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig - ), - chatRoomId = groupEventResult.groupId, - )?.let { mantraTranslationArtifactVersion -> - database.mantraTranslationArtifactVersionDao().upsert( - mantraTranslationArtifactVersion.copy( - marmotGroupEventId = groupEvent.id, - ) - ) - - ChatMessage( - giftWrapPayloadId = null, - messageType = "translationArtifactVersion", - marmotGroupEventId = groupEvent.id, - marmotInnerEventId = event.id, - senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, - chatRoomId = groupEventResult.groupId, - createdAt = Instant.fromEpochSeconds(event.createdAt), - content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name... - ) - } - } - TranslationArtifactVersionContributorListEvent.KIND -> { - // TODO: Consume contributors... - null - } - TranslationChapterEvent.KIND -> { - MantraTranslationChapter.fromTranslationChapterEvent( - translationChapterEvent = TranslationChapterEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig - ), - chatRoomId = groupEventResult.groupId, - )?.let { mantraTranslationChapter -> - database.mantraTranslationChapterDao().upsert( - mantraTranslationChapter.copy( - marmotGroupEventId = groupEvent.id, - ) - ) - // TODO: translation chapter might be too noisy for chat updates - } - null - } - TranslationChunkEvent.KIND -> { - MantraTranslationChunk.fromTranslationChunkEvent( - translationChunkEvent = TranslationChunkEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig - ), - chatRoomId = groupEventResult.groupId, - )?.let { mantraTranslationChunk -> - database.mantraTranslationChunkDao().upsert( - mantraTranslationChunk.copy( - marmotGroupEventId = groupEvent.id, - ) - ) - - - } - null - } - TranslationContributorListEvent.KIND -> { - // TODO: Consume contributors... - null - } - TranslationEvent.KIND -> { - MantraTranslation.fromTranslationEvent( - translationEvent = TranslationEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig - ), - chatRoomId = groupEventResult.groupId, - )?.let { mantraTranslation -> - database.mantraTranslationDao().upsert( - mantraTranslation.copy( - marmotGroupEventId = groupEvent.id, - ) - ) - ChatMessage( - giftWrapPayloadId = null, - messageType = "translation", - marmotGroupEventId = groupEvent.id, - marmotInnerEventId = event.id, - senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, - chatRoomId = groupEventResult.groupId, - createdAt = Instant.fromEpochSeconds(event.createdAt), - content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text - ) - } - } - else -> { - ChatMessage( - giftWrapPayloadId = null, - messageType = "unsupported", - marmotGroupEventId = groupEvent.id, - marmotInnerEventId = event.id, - senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, - chatRoomId = groupEventResult.groupId, - createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), - content = groupEventResult.innerEventJson, // TODO: Figure out what to do here... - ) - } + // A submission whose payload will not parse, or which carries + // another submission, is kept but not applied: there is nothing + // here we can turn into a row. + if (submission != null && (payload == null || payload.kind == SubmissionEvent.KIND)) { + ChatMessage( + giftWrapPayloadId = null, + messageType = "unsupported", + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = event.id, + senderPublicKey = event.pubKey, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + chatRoomId = groupEventResult.groupId, + createdAt = Instant.fromEpochSeconds(event.createdAt), + content = event.content, + ) + } else { + applyInnerEvent( + database = database, + activeKeyPair = activeKeyPair, + groupEvent = groupEvent, + groupId = groupEventResult.groupId, + event = payload ?: event, + marmotInnerEventId = event.id, + senderPublicKey = event.pubKey, + createdAt = Instant.fromEpochSeconds(event.createdAt), + ) } } is GroupEventResult.CommitPending -> { @@ -581,5 +342,317 @@ data class ChatMessage( } } } + + /** + * Apply one nip30303 [event] the group delivered, and describe it for + * the transcript. + * + * [event] is what is being applied; the other parameters are how it + * arrived. They come apart for submissions: the payload is written by + * whoever wrote it -- possibly nobody in this group -- while + * [marmotInnerEventId], [senderPublicKey] and [createdAt] all belong to + * the envelope a member actually sent. Entity rows take their author + * from [event], so the chat line says who added it and the row says who + * wrote it. + */ + private suspend fun applyInnerEvent( + database: MantraDatabase, + activeKeyPair: KeyPair, + groupEvent: GroupEvent, + groupId: String, + event: Event, + marmotInnerEventId: HexKey, + senderPublicKey: HexKey, + createdAt: Instant, + ): ChatMessage? { + return when (event.kind) { + ChatEvent.KIND -> { + ChatMessage( + giftWrapPayloadId = null, + messageType = "message", + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + chatRoomId = groupId, + createdAt = createdAt, + content = event.content, // TODO: Figure out what to do here... + ) + } + ArtifactEvent.KIND -> { + MantraArtifact.fromArtifactEvent( + artifactEvent = ArtifactEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig, + ), + chatRoomId = groupId, + )?.let { mantraArtifact -> + database.mantraArtifactDao().upsert( + mantraArtifact.copy( + marmotGroupEventId = groupEvent.id, + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = "artifact", + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + chatRoomId = groupId, + createdAt = createdAt, + content = "Added ${mantraArtifact.name} to artifacts" + ) + } + } + ArtifactVersionEvent.KIND -> { + MantraArtifactVersion.fromArtifactVersionEvent( + artifactVersionEvent = ArtifactVersionEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { mantraArtifactVersion -> + database.mantraArtifactVersionDao().upsert( + mantraArtifactVersion.copy( + marmotGroupEventId = groupEvent.id, + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = "artifactVersion", + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + chatRoomId = groupId, + createdAt = createdAt, + content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name... + ) + } + } + ChapterEvent.KIND -> { + MantraChapter.fromChapterEvent( + chapterEvent = ChapterEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { mantraChapter -> + database.mantraChapterDao().upsert( + mantraChapter.copy( + marmotGroupEventId = groupEvent.id, + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = "chapter", + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + chatRoomId = groupId, + createdAt = createdAt, + content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name... + ) + } + } + + ChunkEvent.KIND -> { + MantraChunk.fromChunkEvent( + chunkEvent = ChunkEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { mantraChunk -> + database.mantraChunkDao().upsert( + mantraChunk.copy( + marmotGroupEventId = groupEvent.id, + ) + ) + + // TODO: Chunks might be too noisy to show in chat... + } + null + } + DialectEvent.KIND -> { + MantraDialect.fromDialectEvent( + dialectEvent = DialectEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { mantraDialect -> + database.mantraDialectDao().upsert( + mantraDialect.copy( + marmotGroupEventId = groupEvent.id, + ) + ) + + + ChatMessage( + giftWrapPayloadId = null, + messageType = "dialect", + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + chatRoomId = groupId, + createdAt = createdAt, + content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name... + ) + } + } + TranslationArtifactVersionEvent.KIND -> { + MantraTranslationArtifactVersion.fromTranslationArtifactVersionEvent( + translationArtifactVersionEvent = TranslationArtifactVersionEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { mantraTranslationArtifactVersion -> + database.mantraTranslationArtifactVersionDao().upsert( + mantraTranslationArtifactVersion.copy( + marmotGroupEventId = groupEvent.id, + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = "translationArtifactVersion", + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + chatRoomId = groupId, + createdAt = createdAt, + content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name... + ) + } + } + TranslationArtifactVersionContributorListEvent.KIND -> { + // TODO: Consume contributors... + null + } + TranslationChapterEvent.KIND -> { + MantraTranslationChapter.fromTranslationChapterEvent( + translationChapterEvent = TranslationChapterEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { mantraTranslationChapter -> + database.mantraTranslationChapterDao().upsert( + mantraTranslationChapter.copy( + marmotGroupEventId = groupEvent.id, + ) + ) + // TODO: translation chapter might be too noisy for chat updates + } + null + } + TranslationChunkEvent.KIND -> { + MantraTranslationChunk.fromTranslationChunkEvent( + translationChunkEvent = TranslationChunkEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { mantraTranslationChunk -> + database.mantraTranslationChunkDao().upsert( + mantraTranslationChunk.copy( + marmotGroupEventId = groupEvent.id, + ) + ) + + + } + null + } + TranslationContributorListEvent.KIND -> { + // TODO: Consume contributors... + null + } + TranslationEvent.KIND -> { + MantraTranslation.fromTranslationEvent( + translationEvent = TranslationEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { mantraTranslation -> + database.mantraTranslationDao().upsert( + mantraTranslation.copy( + marmotGroupEventId = groupEvent.id, + ) + ) + ChatMessage( + giftWrapPayloadId = null, + messageType = "translation", + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + chatRoomId = groupId, + createdAt = createdAt, + content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text + ) + } + } + else -> { + ChatMessage( + giftWrapPayloadId = null, + messageType = "unsupported", + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + chatRoomId = groupId, + createdAt = createdAt, + content = event.toJson(), + ) + } + } + } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt index 75f3c387..a93087e9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt @@ -64,6 +64,15 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... val content: String, val quotedEventId: String? = null, + /** + * For a SubmissionEvent rumor, the id of the nip30303 event it carries. + * + * The submission's own id is derived from the envelope, so it is the only + * handle the queue has on what is actually being submitted. Null for every + * other kind, where the row *is* the event. + */ + val payloadEventId: HexKey? = null, + /** * Associated MarmotGroupEvent */ @@ -117,6 +126,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... if (!tags.contentDeepEquals(other.tags)) return false if (content != other.content) return false if (quotedEventId != other.quotedEventId) return false + if (payloadEventId != other.payloadEventId) return false if (marmotGroupEventId != other.marmotGroupEventId) return false if (createdAt != other.createdAt) return false if (updatedAt != other.updatedAt) return false @@ -136,6 +146,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... result = 31 * result + tags.contentDeepHashCode() result = 31 * result + content.hashCode() result = 31 * result + (quotedEventId?.hashCode() ?: 0) + result = 31 * result + (payloadEventId?.hashCode() ?: 0) result = 31 * result + (marmotGroupEventId?.hashCode() ?: 0) result = 31 * result + createdAt.hashCode() result = 31 * result + updatedAt.hashCode() From 6c63027912eb233557b772435205598f537cff5b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 20:21:52 +0200 Subject: [PATCH 05/13] feat: submit nip30303 events to the group instead of authoring them into it With the receiving side able to open an envelope, start sending one. Every nip30303 event now leaves as a SubmissionEvent payload and none leaves on its own: addDialect, addArtifact, addArtifactVersion, addChapter and each of its chunks, addTranslationArtifactVersion and each of its translation chapters, and saveTranslation. Because all four add screens reach the wire through MantraDao, none of them needed touching. Two helpers carry it: rumorOf(template, publicKey) the unsigned event a template describes. Its id is computed exactly as the matching Mantra* entity computes its own, so the row on disk and the payload on the wire are one event rather than two copies of one. submitToGroup(...) wraps a payload, queues the submission as an unprocessed rumor, and writes the chat line. Two things this drags in, neither optional: The queued MarmotInnerEvent is now the envelope, so its id is the envelope's and no longer the nip30303 event's. saveTranslation replaces a chunk whenever its text changes -- the id is derived from the content, so an edit is a new row -- and un-queued the superseded one by deleteById(stale.id). That silently stops matching anything once the row is a submission, leaving the stale translation to be sent anyway. It now also deletes by what the submission carries, via deleteByPayloadEventId. The add* methods return the entity rather than the queued rumor. This is a correctness fix, not tidying: AddArtifactViewModel navigates to ArtifactDetailRoute on that id, and AddTranslationArtifactVersionViewModel feeds addDialect's id straight back in as a dialectId. Both used to be handed a MarmotInnerEvent whose id happened to equal the entity's, and both would now have been handed a submission id -- one navigating to an artifact that does not exist, the other tagging a translation with a dialect that does not. Returning MantraArtifact/MantraDialect/etc. makes .id mean the entity everywhere and matches saveTranslationChunk, which already returned its entity. The sendMarmotInnerEvent overload taking a LocalChatRoom loses its last caller; submitToGroup names the submitter explicitly, which is the thing that matters now that it is not necessarily the author. Outbound still only ever submits payloads authored by the submitter -- nothing in the app originates a foreign event yet. submitToGroup is where that would attach. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/database/dao/MantraDao.kt | 265 +++++++++--------- .../database/dao/MarmotInnerEventDao.kt | 9 + .../repository/DatabaseMantraRepository.kt | 10 +- .../compose/repository/MantraRepository.kt | 21 +- .../ui/view/model/AddArtifactViewModel.kt | 6 +- .../ui/view/model/AddDialectViewModel.kt | 6 +- 6 files changed, 157 insertions(+), 160 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt index ba331ccb..ac55e82e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt @@ -3,7 +3,10 @@ package press.mantra.compose.database.dao import androidx.room3.Dao import androidx.room3.Transaction import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.MantraArtifact @@ -21,6 +24,7 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.SubmissionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -28,6 +32,7 @@ import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_LICENSE import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_VISIBILITY import press.mantra.compose.text.Markdown +import kotlin.time.Instant @Dao abstract class MantraDao( @@ -35,6 +40,82 @@ abstract class MantraDao( ) { val logger = Logger.withTag("NostrDao") + /** + * The unsigned nip30303 event [template] describes, authored by [publicKey]. + * + * Its id is computed the same way the matching Mantra* entity computes + * its own, so the row on disk and the payload on the wire are the same + * event rather than two copies of one. + */ + private fun rumorOf( + template: EventTemplate, + publicKey: HexKey, + ): Event = Event( + id = EventHasher.hashId( + pubKey = publicKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = publicKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + // A rumor. What signs for this reaching the group is the kind:445 the + // outbound pipeline wraps it in, not the payload itself. + sig = "", + ) + + /** + * Queue [payload] for the group inside a [SubmissionEvent] that + * [submitterPublicKey] authors. + * + * nip30303 events are never sent on their own. Wrapping them means the + * payload keeps whoever wrote it as its author while the group still knows + * which member put it there -- see [SubmissionEvent] for why the two are + * worth telling apart. + * + * The submission is stored as an unprocessed rumor (null marmotGroupEventId), + * which is what the outbound pipeline picks up and encrypts into a kind:445 + * group event for the chat room. + */ + private suspend fun submitToGroup( + chatRoomId: String, + submitterPublicKey: HexKey, + payload: Event, + text: String, + ): MarmotInnerEvent { + val submissionTemplate = SubmissionEvent.build(payload = payload) + + val submissionInnerEvent = MarmotInnerEvent( + id = EventHasher.hashId( + pubKey = submitterPublicKey, + createdAt = submissionTemplate.createdAt, + kind = submissionTemplate.kind, + tags = submissionTemplate.tags, + content = submissionTemplate.content, + ), + publicKey = submitterPublicKey, + kind = SubmissionEvent.KIND, + createdAt = Instant.fromEpochSeconds(submissionTemplate.createdAt), + tags = submissionTemplate.tags, + content = submissionTemplate.content, + payloadEventId = payload.id, + chatRoomId = chatRoomId, + ) + + sendMarmotInnerEvent( + chatRoomId = chatRoomId, + userPublicKey = submitterPublicKey, + text = text, + marmotInnerEvent = submissionInnerEvent, + ) + + return submissionInnerEvent + } + @Transaction open suspend fun addDialect( localChatRoom: LocalChatRoom, @@ -42,7 +123,7 @@ abstract class MantraDao( country: String, language: String, userPublicKey: HexKey, - ): MarmotInnerEvent? { + ): MantraDialect? { logger.d("addDialect: $name") val dialectEventTemplate = DialectEvent.build( @@ -57,26 +138,17 @@ abstract class MantraDao( userPublicKey = userPublicKey, ) ?: return null - val dialectInnerEvent = MarmotInnerEvent( - id = mantraDialect.id, - publicKey = mantraDialect.publicKey, - kind = DialectEvent.KIND, - createdAt = mantraDialect.createdAt, - tags = dialectEventTemplate.tags, - content = dialectEventTemplate.content, - chatRoomId = mantraDialect.chatRoomId, - ) - return try { database.mantraDialectDao().upsert(mantraDialect) - database.marmotInnerEventDao().upsert(dialectInnerEvent) - sendMarmotInnerEvent( - localChatRoom = localChatRoom, + submitToGroup( + chatRoomId = mantraDialect.chatRoomId, + submitterPublicKey = userPublicKey, + payload = rumorOf(dialectEventTemplate, userPublicKey), text = "Added $name as a dialect", - marmotInnerEvent = dialectInnerEvent ) - dialectInnerEvent + + mantraDialect } catch (error: Throwable) { logger.e("Failed to add dialect \"$name\" to chat room ${localChatRoom.chatRoom.id}", error) null @@ -94,7 +166,7 @@ abstract class MantraDao( userPublicKey: HexKey, visibility: String = DEFAULT_VISIBILITY, license: String = DEFAULT_LICENSE, - ): MarmotInnerEvent? { + ): MantraArtifact? { // TODO: Verify the active user is an admin of the chat room before allowing this. val artifactEventTemplate = ArtifactEvent.build( name = name, @@ -110,32 +182,19 @@ abstract class MantraDao( userPublicKey = userPublicKey, ) ?: return null - // Persist the artifact together with an unprocessed marmot inner event - // (a rumor). Inner events with a null marmotGroupEventId are later - // picked up by the outbound pipeline and encrypted into a kind:445 - // group event for the chat room. + // Persist the artifact locally and submit it to the group. // // This covers the "private" visibility case. Permissioned artifacts // (published as a PublicMessage) and public artifacts (published as a // plain nostr event) are not implemented yet. - val artifactInnerEvent = MarmotInnerEvent( - id = mantraArtifact.id, - publicKey = mantraArtifact.publicKey, - kind = ArtifactEvent.KIND, - createdAt = mantraArtifact.createdAt, - tags = artifactEventTemplate.tags, - content = artifactEventTemplate.content, - chatRoomId = mantraArtifact.chatRoomId, - ) - return try { database.mantraArtifactDao().upsert(mantraArtifact) - database.marmotInnerEventDao().upsert(artifactInnerEvent) - sendMarmotInnerEvent( - localChatRoom = localChatRoom, + submitToGroup( + chatRoomId = mantraArtifact.chatRoomId, + submitterPublicKey = userPublicKey, + payload = rumorOf(artifactEventTemplate, userPublicKey), text = "Added $name to artifacts", - marmotInnerEvent = artifactInnerEvent ) // Every artifact starts with an initial version. @@ -146,7 +205,7 @@ abstract class MantraDao( userPublicKey = userPublicKey, ) - artifactInnerEvent + mantraArtifact } catch (error: Throwable) { logger.e("Failed to add artifact \"$name\" to chat room ${localChatRoom.chatRoom.id}", error) null @@ -159,7 +218,7 @@ abstract class MantraDao( artifactId: HexKey, versionLabel: String, userPublicKey: HexKey, - ): MarmotInnerEvent? { + ): MantraArtifactVersion? { return addArtifactVersionInternal( localChatRoom = localChatRoom, artifactId = artifactId, @@ -173,7 +232,7 @@ abstract class MantraDao( artifactId: HexKey, versionLabel: String, userPublicKey: HexKey, - ): MarmotInnerEvent? { + ): MantraArtifactVersion? { // The version label is carried in the event content (see // MantraArtifactVersion.fromArtifactVersionEvent). val artifactVersionEventTemplate = ArtifactVersionEvent.build( @@ -188,25 +247,16 @@ abstract class MantraDao( userPublicKey = userPublicKey, ) ?: return null - val versionInnerEvent = MarmotInnerEvent( - id = mantraArtifactVersion.id, - publicKey = mantraArtifactVersion.publicKey, - kind = ArtifactVersionEvent.KIND, - createdAt = mantraArtifactVersion.createdAt, - tags = artifactVersionEventTemplate.tags, - content = artifactVersionEventTemplate.content, - chatRoomId = mantraArtifactVersion.chatRoomId, - ) - database.mantraArtifactVersionDao().upsert(mantraArtifactVersion) - database.marmotInnerEventDao().upsert(versionInnerEvent) - sendMarmotInnerEvent( - localChatRoom = localChatRoom, + submitToGroup( + chatRoomId = mantraArtifactVersion.chatRoomId, + submitterPublicKey = userPublicKey, + payload = rumorOf(artifactVersionEventTemplate, userPublicKey), text = "Add the ${artifactVersionEventTemplate.content} version", - marmotInnerEvent = versionInnerEvent ) - return versionInnerEvent + + return mantraArtifactVersion } @Transaction @@ -216,7 +266,7 @@ abstract class MantraDao( originalText: String, chatRoomId: String, userPublicKey: HexKey, - ): MarmotInnerEvent? { + ): MantraChapter? { // Chapters attach to an artifact version; use the latest one. val version = database.mantraArtifactVersionDao() @@ -244,24 +294,12 @@ abstract class MantraDao( return try { database.mantraChapterDao().upsert(chapter) - val chapterInnerEvent = MarmotInnerEvent( - id = chapter.id, - publicKey = chapter.publicKey, - kind = ChapterEvent.KIND, - createdAt = chapter.createdAt, - tags = chapterEventTemplate.tags, - content = chapterEventTemplate.content, - chatRoomId = chapter.chatRoomId, - ) - database.marmotInnerEventDao().upsert( - chapterInnerEvent - ) - sendMarmotInnerEvent( + submitToGroup( chatRoomId = chatRoomId, - userPublicKey = userPublicKey, + submitterPublicKey = userPublicKey, + payload = rumorOf(chapterEventTemplate, userPublicKey), text = "Added chapter to artifact", // TODO: Get artifact to use in text... - marmotInnerEvent = chapterInnerEvent ) // Split the markdown into paragraph chunks. @@ -279,30 +317,17 @@ abstract class MantraDao( userPublicKey = userPublicKey, )?.let { chunk -> database.mantraChunkDao().upsert(chunk) - val chunkInnerEvent = MarmotInnerEvent( - id = chunk.id, - publicKey = chunk.publicKey, - kind = ChunkEvent.KIND, - createdAt = chunk.createdAt, - tags = chunkEventTemplate.tags, - content = chunkEventTemplate.content, - chatRoomId = chunk.chatRoomId, - ) - database.marmotInnerEventDao().upsert( - chunkInnerEvent - ) - - sendMarmotInnerEvent( + submitToGroup( chatRoomId = chatRoomId, - userPublicKey = userPublicKey, + submitterPublicKey = userPublicKey, + payload = rumorOf(chunkEventTemplate, userPublicKey), text = "${chapter.name} added chunk $chunkIndex", // TODO: Use a portion of the actual chunked text... - marmotInnerEvent = chunkInnerEvent ) } } - chapterInnerEvent + chapter } catch (error: Throwable) { logger.e("Failed to add chapter \"$name\" to artifact $artifactId", error) null @@ -315,7 +340,7 @@ abstract class MantraDao( dialectId: String, chatRoomId: String, userPublicKey: HexKey, - ): MarmotInnerEvent? { + ): MantraTranslationArtifactVersion? { val artifact = database.mantraArtifactDao().getArtifactById(artifactId) ?: return null val version = database.mantraArtifactVersionDao() .getArtifactVersionsByArtifactId(artifactId) @@ -339,21 +364,12 @@ abstract class MantraDao( return try { database.mantraTranslationArtifactVersionDao().upsert(translationVersion) - val translationArtifactVersionInnerEvent = MarmotInnerEvent( - id = translationVersion.id, - publicKey = translationVersion.publicKey, - kind = TranslationArtifactVersionEvent.KIND, - createdAt = translationVersion.createdAt, - tags = translationVersionTemplate.tags, - content = translationVersionTemplate.content, - chatRoomId = translationVersion.chatRoomId, - ) - sendMarmotInnerEvent( + submitToGroup( chatRoomId = chatRoomId, - userPublicKey = userPublicKey, + submitterPublicKey = userPublicKey, + payload = rumorOf(translationVersionTemplate, userPublicKey), text = "Added ${dialect.name} translation", - marmotInnerEvent = translationArtifactVersionInnerEvent ) // Mirror the source structure: a translation chapter per chapter and @@ -372,21 +388,12 @@ abstract class MantraDao( ) ?: return@forEach database.mantraTranslationChapterDao().upsert(translationChapter) - val translationChapterInnerEvent = MarmotInnerEvent( - id = translationChapter.id, - publicKey = translationChapter.publicKey, - kind = TranslationChapterEvent.KIND, - createdAt = translationChapter.createdAt, - tags = translationChapterTemplate.tags, - content = translationChapterTemplate.content, - chatRoomId = translationChapter.chatRoomId, - ) - sendMarmotInnerEvent( + submitToGroup( chatRoomId = chatRoomId, - userPublicKey = userPublicKey, + submitterPublicKey = userPublicKey, + payload = rumorOf(translationChapterTemplate, userPublicKey), text = "Prepared ${dialect.name} translation of the chapter ${chapter.name}", - marmotInnerEvent = translationChapterInnerEvent ) // TODO: Figure out if we seriously need the scaffolding @@ -427,7 +434,7 @@ abstract class MantraDao( // } } - translationArtifactVersionInnerEvent + translationVersion } catch (error: Throwable) { logger.e("Failed to add translation for artifact $artifactId", error) null @@ -460,30 +467,25 @@ abstract class MantraDao( return try { // Replace any existing translation chunk for this source chunk. Its id // is derived from the (now changed) content, so it becomes a new row — - // drop the old one (and its rumor) to keep one per source chunk. + // drop the old one (and the submission carrying it) to keep one per + // source chunk. The submission is found by what it carries, since its + // own id is the envelope's rather than the chunk's. database.mantraTranslationChunkDao() .getTranslationChunksByTranslationChapterId(translationChapterId) .filter { it.chunkId == chunkId && it.id != translationChunk.id } .forEach { stale -> database.mantraTranslationChunkDao().deleteById(stale.id) database.marmotInnerEventDao().deleteById(stale.id) + database.marmotInnerEventDao().deleteByPayloadEventId(stale.id) } database.mantraTranslationChunkDao().upsert(translationChunk) - val translationChunkInnerEvent = MarmotInnerEvent( - id = translationChunk.id, - publicKey = translationChunk.publicKey, - kind = TranslationChunkEvent.KIND, - createdAt = translationChunk.createdAt, - tags = translationChunkTemplate.tags, - content = translationChunkTemplate.content, - chatRoomId = translationChunk.chatRoomId, - ) - sendMarmotInnerEvent( + + submitToGroup( chatRoomId = chatRoomId, - userPublicKey = userPublicKey, + submitterPublicKey = userPublicKey, + payload = rumorOf(translationChunkTemplate, userPublicKey), text = "Translated chunk ${translationChunk.index}", // TODO: Use a portion of the translation and name the language - marmotInnerEvent = translationChunkInnerEvent ) translationChunk @@ -493,19 +495,6 @@ abstract class MantraDao( } } - private suspend fun sendMarmotInnerEvent( - localChatRoom: LocalChatRoom, - text: String, - marmotInnerEvent: MarmotInnerEvent - ) { - sendMarmotInnerEvent( - chatRoomId = localChatRoom.chatRoom.id, - userPublicKey = localChatRoom.chatRoom.userPublicKey, - text = text, - marmotInnerEvent = marmotInnerEvent - ) - } - private suspend fun sendMarmotInnerEvent( chatRoomId: String, userPublicKey: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt index 159e1dfb..016e060b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt @@ -17,4 +17,13 @@ interface MarmotInnerEventDao { @Query("DELETE FROM MarmotInnerEvent WHERE id = :id") suspend fun deleteById(id: String) + + /** + * Drop the submissions carrying [payloadEventId]. + * + * A submission's id is the envelope's, not the payload's, so a superseded + * nip30303 event cannot be un-queued by its own id. + */ + @Query("DELETE FROM MarmotInnerEvent WHERE payloadEventId = :payloadEventId") + suspend fun deleteByPayloadEventId(payloadEventId: String) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt index 5a5bcc6c..eb573bcf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt @@ -44,7 +44,7 @@ class DatabaseMantraRepository( dialectId: String, chatRoomId: String, userPublicKey: HexKey, - ): MarmotInnerEvent? { + ): MantraTranslationArtifactVersion? { return database.mantraDao().addTranslationArtifactVersion( artifactId = artifactId, dialectId = dialectId, @@ -99,7 +99,7 @@ class DatabaseMantraRepository( originalText: String, chatRoomId: String, userPublicKey: HexKey, - ): MarmotInnerEvent? { + ): MantraChapter? { return database.mantraDao().addChapter( artifactId = artifactId, name = name, @@ -121,7 +121,7 @@ class DatabaseMantraRepository( country: String, language: String, userPublicKey: HexKey, - ): MarmotInnerEvent? { + ): MantraDialect? { return database.mantraDao().addDialect( localChatRoom = localChatRoom, name = name, @@ -140,7 +140,7 @@ class DatabaseMantraRepository( userPublicKey: HexKey, visibility: String, license: String, - ): MarmotInnerEvent? { + ): MantraArtifact? { return database.mantraDao().addArtifact( localChatRoom = localChatRoom, name = name, @@ -158,7 +158,7 @@ class DatabaseMantraRepository( artifactId: HexKey, versionLabel: String, userPublicKey: HexKey, - ): MarmotInnerEvent? { + ): MantraArtifactVersion? { return database.mantraDao().addArtifactVersion( localChatRoom = localChatRoom, artifactId = artifactId, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt index 3ec50ddb..9d0b5d88 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -9,7 +9,6 @@ import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.database.model.MantraTranslationChapter import press.mantra.compose.database.model.MantraTranslationChunk -import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom interface MantraRepository { @@ -65,7 +64,7 @@ interface MantraRepository { dialectId: String, chatRoomId: String, userPublicKey: HexKey, - ): MarmotInnerEvent? + ): MantraTranslationArtifactVersion? /** * Add a chapter (markdown [originalText]) to the artifact's latest version. @@ -79,7 +78,7 @@ interface MantraRepository { originalText: String, chatRoomId: String, userPublicKey: HexKey, - ): MarmotInnerEvent? + ): MantraChapter? suspend fun getDialects(chatRoomId: String): List @@ -91,7 +90,7 @@ interface MantraRepository { country: String, language: String, userPublicKey: HexKey, - ): MarmotInnerEvent? + ): MantraDialect? suspend fun addArtifact( localChatRoom: LocalChatRoom, @@ -102,14 +101,14 @@ interface MantraRepository { userPublicKey: HexKey, visibility: String = DEFAULT_VISIBILITY, license: String = DEFAULT_LICENSE, - ): MarmotInnerEvent? + ): MantraArtifact? suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, versionLabel: String, userPublicKey: HexKey, - ): MarmotInnerEvent? + ): MantraArtifactVersion? companion object { const val DEFAULT_VISIBILITY = "private" @@ -155,7 +154,7 @@ interface MantraRepository { dialectId: String, chatRoomId: String, userPublicKey: HexKey, - ): MarmotInnerEvent? = null + ): MantraTranslationArtifactVersion? = null override suspend fun addChapter( artifactId: String, @@ -163,7 +162,7 @@ interface MantraRepository { originalText: String, chatRoomId: String, userPublicKey: HexKey, - ): MarmotInnerEvent? = null + ): MantraChapter? = null override suspend fun getDialects(chatRoomId: String): List = emptyList() @@ -175,7 +174,7 @@ interface MantraRepository { country: String, language: String, userPublicKey: HexKey, - ): MarmotInnerEvent? = null + ): MantraDialect? = null override suspend fun addArtifact( localChatRoom: LocalChatRoom, @@ -186,14 +185,14 @@ interface MantraRepository { userPublicKey: HexKey, visibility: String, license: String, - ): MarmotInnerEvent? = null + ): MantraArtifact? = null override suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, versionLabel: String, userPublicKey: HexKey, - ): MarmotInnerEvent? = null + ): MantraArtifactVersion? = null } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt index cc90e468..98a02974 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt @@ -85,7 +85,7 @@ class AddArtifactViewModel( isActionPending.value = true viewModelScope.launch(Dispatchers.IO) { - val artifactInnerEvent = runCatching { + val artifact = runCatching { mantraRepository.addArtifact( localChatRoom = localChatRoom, name = name, @@ -100,13 +100,13 @@ class AddArtifactViewModel( logger.e("Failed to add artifact", error) }.getOrNull() - if (artifactInnerEvent != null) { + if (artifact != null) { nameField.clearText() urlField.clearText() versionLabelField.clearText() viewModelScope.launch(Dispatchers.Main) { - onSuccess.invoke(artifactInnerEvent.id) + onSuccess.invoke(artifact.id) } } else { viewModelScope.launch(Dispatchers.Main) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt index b4a3abfb..f0f655e7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt @@ -76,7 +76,7 @@ class AddDialectViewModel( isActionPending.value = true viewModelScope.launch(Dispatchers.IO) { - val dialectInnerEvent = runCatching { + val dialect = runCatching { mantraRepository.addDialect( localChatRoom = localChatRoom, name = name, @@ -88,13 +88,13 @@ class AddDialectViewModel( logger.e("Failed to add dialect", error) }.getOrNull() - if (dialectInnerEvent != null) { + if (dialect != null) { nameField.clearText() countryField.clearText() languageField.clearText() viewModelScope.launch(Dispatchers.Main) { - onSuccess.invoke(dialectInnerEvent.id) + onSuccess.invoke(dialect.id) } } else { viewModelScope.launch(Dispatchers.Main) { From 2d0fe6f5fc529aa473b756fd7205e695948c2537 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 20:38:03 +0200 Subject: [PATCH 06/13] fix: disable Add Artifact until a dialect is picked Submitting without a dialect was rejected in the view model, which called onFailure, which navigated to ImplementationPendingRoute("Failed Artifact") -- a whole screen away from the form, saying nothing about which field was wrong, and leaving the way back to the only sensible fix as the back button. That is a bad way to report any missing field, but the dialect is the one where it is unrecoverable in place. A blank name or url is answered by typing; a dialect has to already exist, and since dialects moved to the group screen there is nothing on this form that can conjure one. So an unpicked dialect is not a mistake to report after the fact, it is a state the button should not be pressable in. Material 3 gives ExtendedFloatingActionButton no `enabled` parameter, so this paints the disabled colours from ButtonDefaults.buttonColors() -- the same ones every other disabled button in the app resolves from the theme, rather than an alpha invented here -- and returns early from onClick. Also marks it disabled to accessibility services. Colours alone leave a screen reader announcing a button it is happy to press, and pressing it does nothing, which is worse than a button that says it is unavailable. A group with no dialects at all is covered by the same condition, since there is then nothing to select. Co-Authored-By: Claude Opus 5 --- .../ui/composable/AddArtifactScreen.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index 34e7932f..5d451f8d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt @@ -20,10 +20,12 @@ import androidx.compose.material.icons.filled.LocalOffer import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -33,6 +35,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -42,6 +45,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -110,6 +115,16 @@ fun AddArtifactScreen( // one is picked; dialects are defined from the group detail screen. var selectedDialectId: String? by remember { mutableStateOf(null) } + // Of the required fields this is the only one the screen cannot ask + // for again: a dialect has to already exist, and nothing here can + // create one. So an unpicked dialect is a dead end rather than + // something to submit and be told about, and the button says so. + val canAddArtifact = selectedDialectId != null + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + Scaffold( topBar = { TopAppBar( @@ -126,7 +141,27 @@ fun AddArtifactScreen( actions = {}, floatingActionButton = { ExtendedFloatingActionButton( + modifier = if (canAddArtifact) { + Modifier + } else { + // Looking unavailable is not being unavailable: + // without this a screen reader still announces + // a button it is happy to press. + Modifier.semantics { disabled() } + }, + containerColor = if (canAddArtifact) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canAddArtifact) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, onClick = { + if (!canAddArtifact) return@ExtendedFloatingActionButton + addArtifactViewModel.addArtifact( localChatRoom = addArtifactUIState.localChatRoom, nameField = nameFieldState, From d7aac49cf1695301b3d59f90c40dbcdb5e421078 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 21:05:35 +0200 Subject: [PATCH 07/13] fix: hold a payload whose parent has not arrived instead of losing the event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A receiver hit `FOREIGN KEY constraint failed` on an artifact submission and lost the whole group event. The artifact referenced a dialect the receiver did not have, MantraArtifact.dialectId is a foreign key, and SQLite answers a violated constraint by aborting -- which rolled back the entire transaction the inbound pipeline runs in. Gone with it: the NostrEvent, the MarmotGroupEvent, the submission's MarmotInnerEvent holding the payload verbatim, and the transcript line. Nothing retries, so the artifact stayed lost even once the dialect turned up. Every nip30303 entity is a child of another and the schema enforces all of it -- artifact→dialect, version→artifact, chapter→version, chunk→chapter, translations→both of theirs -- so this was every branch, not one. And submissions make arriving before your parent ordinary rather than exotic. That is the point of them: an admin submits a backlog in whatever order they hold it, and a member who joined last week can be sent what the group was told last month. Both produce payloads whose parents are not here yet, and both were losing data. So check the parents before inserting. A payload that arrives early is held on the submission row -- awaitingEventId names what it waits for -- and applied when that arrives. Releasing one can release another, a version freeing its chapters and those freeing their chunks, so it walks outward until nothing more comes unstuck. A payload with a second parent still missing is re-pointed at that one rather than retried on every arrival. Nothing is written to the transcript while a payload is held. Nobody has said anything yet; the line appears when it is applied, in the position its own timestamp gives it. Two things fall out of the shape: parentRefsOf is pure and separate from the lookups, because the mapping is the part that can silently drift from the schema and there is no database harness in commonTest to catch it. ParentRefsTest pins one case per kind. Which table an id lives in is carried as the kind of event that would have created it, so there is no second enum to keep in step. applyInnerEvent takes ids rather than a GroupEvent, since replay happens long after that object is gone. A released payload is recorded as not ours: we hold the parents of anything we wrote, having written those too. Also reconstructs a held bare nip30303 event from its own columns rather than parsing its content as an event -- only submissions carry an event there, and reading both that way would have stranded every bare one permanently. Verified: the v5→v6 migration runs clean on the receiver's real populated database. The hold path itself still needs a fresh submission from a sender to exercise end to end. Co-Authored-By: Claude Opus 5 --- .../6.json | 5056 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 9 +- .../database/dao/MantraTranslationChunkDao.kt | 3 + .../database/dao/MarmotInnerEventDao.kt | 9 + .../compose/database/model/ChatMessage.kt | 354 +- .../database/model/MarmotInnerEvent.kt | 13 + .../compose/database/model/ParentRefsTest.kt | 167 + 7 files changed, 5565 insertions(+), 46 deletions(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ParentRefsTest.kt diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json new file mode 100644 index 00000000..0bc3bee3 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json @@ -0,0 +1,5056 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "1e573d88b491b8326cd3866f0cd0118b", + "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, `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": "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, `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": "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, `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": "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": "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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `awaitingEventId` TEXT, `marmotGroupEventId` 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": "awaitingEventId", + "columnName": "awaitingEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "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, '1e573d88b491b8326cd3866f0cd0118b')" + ] + } +} \ 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 e3c146ec..70cc9c1d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -164,7 +164,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 5, + version = 6, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -183,7 +183,12 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // nip30303 event a SubmissionEvent rumor carries. Rumors queued before // this come back null, which reads as "not a submission" -- correct, // since none of them were. - AutoMigration(from = 4, to = 5) + AutoMigration(from = 4, to = 5), + // v6 adds the nullable MarmotInnerEvent.awaitingEventId, holding a + // submission that arrived before the event it references. Nothing + // queued before this was ever held, so null is the right answer for + // every existing row. + AutoMigration(from = 5, to = 6) ] ) @ColumnTypeConverters(MantraConverters::class) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt index 52c1c7a3..be7f42bf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt @@ -15,6 +15,9 @@ interface MantraTranslationChunkDao { @Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId ORDER BY `index` ASC") suspend fun getTranslationChunksByTranslationChapterId(translationChapterId: String): List + @Query("SELECT * FROM MantraTranslationChunk WHERE id = :id") + suspend fun getTranslationChunkById(id: String): MantraTranslationChunk? + @Query("DELETE FROM MantraTranslationChunk WHERE id = :id") suspend fun deleteById(id: String) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt index 016e060b..fe8d8711 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt @@ -26,4 +26,13 @@ interface MarmotInnerEventDao { */ @Query("DELETE FROM MarmotInnerEvent WHERE payloadEventId = :payloadEventId") suspend fun deleteByPayloadEventId(payloadEventId: String) + + /** + * The submissions held back waiting for [eventId], oldest first. + * + * Oldest first because a backlog usually arrives in the order it was + * written, so applying it that way unblocks the most in one pass. + */ + @Query("SELECT * FROM MarmotInnerEvent WHERE awaitingEventId = :eventId ORDER BY createdAt ASC") + suspend fun getSubmissionsAwaiting(eventId: String): List } \ No newline at end of file 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 28e2d0f8..edd93fc5 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 @@ -219,19 +219,18 @@ data class ChatMessage( } val payload = submission?.payload() - database.marmotInnerEventDao().upsert( - MarmotInnerEvent( - id = event.id, - publicKey = event.pubKey, - marmotGroupEventId = groupEvent.id, - tags = event.tags, - content = event.content, - chatRoomId = groupEventResult.groupId, - kind = event.kind, - payloadEventId = payload?.id, - createdAt = Instant.fromEpochSeconds(event.createdAt) - ) + val innerEvent = MarmotInnerEvent( + id = event.id, + publicKey = event.pubKey, + marmotGroupEventId = groupEvent.id, + tags = event.tags, + content = event.content, + chatRoomId = groupEventResult.groupId, + kind = event.kind, + payloadEventId = payload?.id, + createdAt = Instant.fromEpochSeconds(event.createdAt) ) + database.marmotInnerEventDao().upsert(innerEvent) // A submission whose payload will not parse, or which carries // another submission, is kept but not applied: there is nothing @@ -249,15 +248,13 @@ data class ChatMessage( content = event.content, ) } else { - applyInnerEvent( + applyOrHold( database = database, activeKeyPair = activeKeyPair, - groupEvent = groupEvent, groupId = groupEventResult.groupId, + innerEvent = innerEvent, event = payload ?: event, - marmotInnerEventId = event.id, - senderPublicKey = event.pubKey, - createdAt = Instant.fromEpochSeconds(event.createdAt), + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, ) } } @@ -355,14 +352,283 @@ data class ChatMessage( * from [event], so the chat line says who added it and the row says who * wrote it. */ - private suspend fun applyInnerEvent( + /** + * Apply [event] now, or hold it until what it references turns up. + * + * Foreign keys mean a payload cannot become a row before its parent + * does, and inserting one anyway does not fail politely: SQLite aborts + * the statement, which rolls back the whole transaction the inbound + * pipeline runs in -- losing the nostr event, the group event, this + * submission and the transcript line with it, none of which is retried. + * + * So the parent is checked first. A payload that arrives early is kept + * against the id it waits for and applied later, which is what makes + * order stop mattering: an admin submitting a backlog can send it in + * whatever order they hold it, and a member who joined last week can be + * sent what the group was told last month. + * + * A held payload writes no chat line. Nobody said anything yet -- the + * line appears when it is applied, in the transcript position its own + * timestamp gives it. + */ + private suspend fun applyOrHold( database: MantraDatabase, activeKeyPair: KeyPair, - groupEvent: GroupEvent, + groupId: String, + innerEvent: MarmotInnerEvent, + event: Event, + isUserMessage: Boolean, + ): ChatMessage? { + missingParentOf(database, event)?.let { missingParent -> + database.marmotInnerEventDao().upsert( + innerEvent.copy(awaitingEventId = missingParent) + ) + return null + } + + val chatMessage = applyInnerEvent( + database = database, + groupId = groupId, + event = event, + marmotGroupEventId = innerEvent.marmotGroupEventId, + marmotInnerEventId = innerEvent.id, + senderPublicKey = innerEvent.publicKey, + isUserMessage = isUserMessage, + createdAt = innerEvent.createdAt, + ) + + // This may be the parent something else was held for. + releaseAwaiting(database, activeKeyPair, groupId, event.id) + + return chatMessage + } + + /** + * Apply whatever was waiting on [arrivedEventId], now that it is here. + * + * Releasing one can release another -- a version unblocks its chapters, + * a chapter unblocks its chunks -- so this keeps going until nothing + * more comes unstuck. A payload with a second parent still missing is + * re-pointed at that one rather than applied, so it waits for the right + * thing instead of being retried on every arrival. + */ + private suspend fun releaseAwaiting( + database: MantraDatabase, + activeKeyPair: KeyPair, + groupId: String, + arrivedEventId: HexKey, + ) { + val arrived = ArrayDeque(listOf(arrivedEventId)) + + while (arrived.isNotEmpty()) { + val parentId = arrived.removeFirst() + + database.marmotInnerEventDao().getSubmissionsAwaiting(parentId).forEach { held -> + // A submission's content is its payload verbatim; anything + // else held is a bare nip30303 event, where the row is the + // event and its own columns rebuild it. Reading both out of + // the content would strand every bare one here forever. + val payload = if (held.kind == SubmissionEvent.KIND) { + Event.fromJsonOrNull(held.content) + } else { + Event( + id = held.id, + pubKey = held.publicKey, + createdAt = held.createdAt.epochSeconds, + kind = held.kind, + tags = held.tags, + content = held.content, + sig = "", + ) + } ?: return@forEach + + missingParentOf(database, payload)?.let { stillMissing -> + database.marmotInnerEventDao().upsert( + held.copy(awaitingEventId = stillMissing) + ) + return@forEach + } + + database.marmotInnerEventDao().upsert(held.copy(awaitingEventId = null)) + + applyInnerEvent( + database = database, + groupId = groupId, + event = payload, + marmotGroupEventId = held.marmotGroupEventId, + marmotInnerEventId = held.id, + senderPublicKey = held.publicKey, + // Held payloads are never ours: we hold the parents of + // anything we wrote, having written those too. + isUserMessage = false, + createdAt = held.createdAt, + )?.let { database.chatMessageDao().upsert(it) } + + arrived.addLast(payload.id) + } + } + } + + /** + * The event [event] references but which is not on file yet, or null + * when everything it needs is already here. + */ + private suspend fun missingParentOf( + database: MantraDatabase, + event: Event, + ): HexKey? = parentRefsOf(event).firstOrNull { !it.exists(database) }?.id + + /** + * A row some event references, named by id and by the kind of event + * that would have created it. + */ + internal data class ParentRef( + val id: HexKey, + val kind: Int, + ) { + suspend fun exists(database: MantraDatabase): Boolean = when (kind) { + DialectEvent.KIND -> + database.mantraDialectDao().getDialectById(id) != null + + ArtifactEvent.KIND -> + database.mantraArtifactDao().getArtifactById(id) != null + + ArtifactVersionEvent.KIND -> + database.mantraArtifactVersionDao().getArtifactVersionById(id) != null + + ChapterEvent.KIND -> + database.mantraChapterDao().getChapterById(id) != null + + ChunkEvent.KIND -> + database.mantraChunkDao().getChunkById(id) != null + + TranslationArtifactVersionEvent.KIND -> + database.mantraTranslationArtifactVersionDao().getTranslationById(id) != null + + TranslationChapterEvent.KIND -> + database.mantraTranslationChapterDao().getTranslationChapterById(id) != null + + TranslationChunkEvent.KIND -> + database.mantraTranslationChunkDao().getTranslationChunkById(id) != null + + // Not a parent anything waits on. + else -> true + } + } + + /** + * Every row [event] references and the database will insist exists. + * + * This is the foreign keys on the Mantra* entities, read off the event + * instead of the schema. The two have to agree: a parent listed here + * that the schema does not enforce just delays a payload for no reason, + * and one the schema enforces but is missing here is a payload that + * takes the whole inbound transaction down with it. + * + * The chat room is deliberately not among them. It is a foreign key, + * but a payload for a room we are not in never reaches this far. + */ + internal fun parentRefsOf(event: Event): List = when (event.kind) { + ArtifactEvent.KIND -> + event.asArtifactEvent().let { + listOfNotNull(it.dialectId()?.let { id -> ParentRef(id, DialectEvent.KIND) }) + } + + ArtifactVersionEvent.KIND -> + event.asArtifactVersionEvent().let { + listOfNotNull(it.artifactId()?.let { id -> ParentRef(id, ArtifactEvent.KIND) }) + } + + ChapterEvent.KIND -> + event.asChapterEvent().let { + listOfNotNull( + it.artifactVersionId()?.let { id -> ParentRef(id, ArtifactVersionEvent.KIND) } + ) + } + + ChunkEvent.KIND -> + event.asChunkEvent().let { + listOfNotNull(it.chapterId()?.let { id -> ParentRef(id, ChapterEvent.KIND) }) + } + + TranslationArtifactVersionEvent.KIND -> + event.asTranslationArtifactVersionEvent().let { + listOfNotNull( + it.artifactVersionId()?.let { id -> ParentRef(id, ArtifactVersionEvent.KIND) }, + it.dialectId()?.let { id -> ParentRef(id, DialectEvent.KIND) }, + ) + } + + TranslationChapterEvent.KIND -> + event.asTranslationChapterEvent().let { + listOfNotNull( + it.translationArtifactVersionId()?.let { id -> + ParentRef(id, TranslationArtifactVersionEvent.KIND) + }, + it.chapterId()?.let { id -> ParentRef(id, ChapterEvent.KIND) }, + ) + } + + TranslationChunkEvent.KIND -> + event.asTranslationChunkEvent().let { + listOfNotNull( + it.translationChapterId()?.let { id -> + ParentRef(id, TranslationChapterEvent.KIND) + }, + it.chunkId()?.let { id -> ParentRef(id, ChunkEvent.KIND) }, + ) + } + + TranslationEvent.KIND -> + event.asTranslationEvent().let { + listOfNotNull( + it.translationChunkId()?.let { id -> + ParentRef(id, TranslationChunkEvent.KIND) + }, + it.translationArtifactVersionId()?.let { id -> + ParentRef(id, TranslationArtifactVersionEvent.KIND) + }, + ) + } + + // Kinds with no parent to wait for. + else -> emptyList() + } + + // A submission's payload arrives as a base Event; these read it back as + // the kind it says it is, so its tag accessors can be used. + private fun Event.asArtifactEvent() = + ArtifactEvent(id, pubKey, createdAt, tags, content, sig) + + private fun Event.asArtifactVersionEvent() = + ArtifactVersionEvent(id, pubKey, createdAt, tags, content, sig) + + private fun Event.asChapterEvent() = + ChapterEvent(id, pubKey, createdAt, tags, content, sig) + + private fun Event.asChunkEvent() = + ChunkEvent(id, pubKey, createdAt, tags, content, sig) + + private fun Event.asTranslationArtifactVersionEvent() = + TranslationArtifactVersionEvent(id, pubKey, createdAt, tags, content, sig) + + private fun Event.asTranslationChapterEvent() = + TranslationChapterEvent(id, pubKey, createdAt, tags, content, sig) + + private fun Event.asTranslationChunkEvent() = + TranslationChunkEvent(id, pubKey, createdAt, tags, content, sig) + + private fun Event.asTranslationEvent() = + TranslationEvent(id, pubKey, createdAt, tags, content, sig) + + private suspend fun applyInnerEvent( + database: MantraDatabase, groupId: String, event: Event, + marmotGroupEventId: HexKey?, marmotInnerEventId: HexKey, senderPublicKey: HexKey, + isUserMessage: Boolean, createdAt: Instant, ): ChatMessage? { return when (event.kind) { @@ -370,10 +636,10 @@ data class ChatMessage( ChatMessage( giftWrapPayloadId = null, messageType = "message", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = event.content, // TODO: Figure out what to do here... @@ -393,17 +659,17 @@ data class ChatMessage( )?.let { mantraArtifact -> database.mantraArtifactDao().upsert( mantraArtifact.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "artifact", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraArtifact.name} to artifacts" @@ -424,17 +690,17 @@ data class ChatMessage( )?.let { mantraArtifactVersion -> database.mantraArtifactVersionDao().upsert( mantraArtifactVersion.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "artifactVersion", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name... @@ -455,17 +721,17 @@ data class ChatMessage( )?.let { mantraChapter -> database.mantraChapterDao().upsert( mantraChapter.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "chapter", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name... @@ -487,7 +753,7 @@ data class ChatMessage( )?.let { mantraChunk -> database.mantraChunkDao().upsert( mantraChunk.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) @@ -509,7 +775,7 @@ data class ChatMessage( )?.let { mantraDialect -> database.mantraDialectDao().upsert( mantraDialect.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) @@ -517,10 +783,10 @@ data class ChatMessage( ChatMessage( giftWrapPayloadId = null, messageType = "dialect", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name... @@ -541,17 +807,17 @@ data class ChatMessage( )?.let { mantraTranslationArtifactVersion -> database.mantraTranslationArtifactVersionDao().upsert( mantraTranslationArtifactVersion.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "translationArtifactVersion", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name... @@ -576,7 +842,7 @@ data class ChatMessage( )?.let { mantraTranslationChapter -> database.mantraTranslationChapterDao().upsert( mantraTranslationChapter.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) // TODO: translation chapter might be too noisy for chat updates @@ -597,7 +863,7 @@ data class ChatMessage( )?.let { mantraTranslationChunk -> database.mantraTranslationChunkDao().upsert( mantraTranslationChunk.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) @@ -623,16 +889,16 @@ data class ChatMessage( )?.let { mantraTranslation -> database.mantraTranslationDao().upsert( mantraTranslation.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "translation", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text @@ -643,10 +909,10 @@ data class ChatMessage( ChatMessage( giftWrapPayloadId = null, messageType = "unsupported", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = event.toJson(), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt index a93087e9..eab23cd3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt @@ -73,6 +73,17 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... */ val payloadEventId: HexKey? = null, + /** + * The event this one references but which has not arrived yet, or null + * when there is nothing left to wait for. + * + * Every nip30303 entity is a child of another and the database enforces + * that, so a payload cannot become a row before its parent does. Rather + * than drop one that arrives early, it is held here against the id it is + * waiting on and applied when that turns up. + */ + val awaitingEventId: HexKey? = null, + /** * Associated MarmotGroupEvent */ @@ -127,6 +138,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... if (content != other.content) return false if (quotedEventId != other.quotedEventId) return false if (payloadEventId != other.payloadEventId) return false + if (awaitingEventId != other.awaitingEventId) return false if (marmotGroupEventId != other.marmotGroupEventId) return false if (createdAt != other.createdAt) return false if (updatedAt != other.updatedAt) return false @@ -147,6 +159,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... result = 31 * result + content.hashCode() result = 31 * result + (quotedEventId?.hashCode() ?: 0) result = 31 * result + (payloadEventId?.hashCode() ?: 0) + result = 31 * result + (awaitingEventId?.hashCode() ?: 0) result = 31 * result + (marmotGroupEventId?.hashCode() ?: 0) result = 31 * result + createdAt.hashCode() result = 31 * result + updatedAt.hashCode() diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ParentRefsTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ParentRefsTest.kt new file mode 100644 index 00000000..c19e650c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ParentRefsTest.kt @@ -0,0 +1,167 @@ +package press.mantra.compose.database.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent +import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.TranslationChapterEvent +import press.mantra.compose.nostr.nip30303.TranslationChunkEvent +import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag + +/** + * What the inbound side must wait for before it writes a row. + * + * These have to match the foreign keys on the Mantra* entities exactly. A + * parent claimed here that the schema does not enforce holds a payload back + * for nothing; one the schema enforces but that is missing here is an insert + * that violates a constraint, and SQLite answers that by rolling back the + * whole inbound transaction -- losing the group event, the submission and the + * transcript line, none of which is ever retried. So the mapping is asserted + * rather than trusted to stay in step. + */ +class ParentRefsTest { + private val author = "a".repeat(64) + private val dialect = "d".repeat(64) + private val artifact = "1".repeat(64) + private val version = "2".repeat(64) + private val chapter = "3".repeat(64) + private val chunk = "4".repeat(64) + private val translationVersion = "5".repeat(64) + private val translationChapter = "6".repeat(64) + + private fun eventOf(template: EventTemplate<*>) = Event( + id = "f".repeat(64), + pubKey = author, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "", + ) + + private fun refsOf(template: EventTemplate<*>) = + ChatMessage.parentRefsOf(eventOf(template)).map { it.id to it.kind } + + @Test + fun `an artifact waits for its dialect`() { + val refs = refsOf( + ArtifactEvent.build( + name = "In Detention", + url = "example.com", + visibility = "private", + license = "cc", + dialectId = dialect, + ) + ) + + assertEquals(listOf(dialect to DialectEvent.KIND), refs) + } + + @Test + fun `a version waits for its artifact`() { + val refs = refsOf( + ArtifactVersionEvent.build(content = "1.0") { + addUnique(ArtifactIdTag.assemble(artifact)) + } + ) + + assertEquals(listOf(artifact to ArtifactEvent.KIND), refs) + } + + @Test + fun `a chapter waits for its version and a chunk for its chapter`() { + val chapterRefs = refsOf( + ChapterEvent.build( + artifactVersionId = version, + name = "Chapter 1", + originalText = "text", + index = 0, + wordCount = 1, + characterCount = 4, + ) + ) + val chunkRefs = refsOf( + ChunkEvent.build( + chapterId = chapter, + text = "text", + index = 0, + wordCount = 1, + characterCount = 4, + ) + ) + + assertEquals(listOf(version to ArtifactVersionEvent.KIND), chapterRefs) + assertEquals(listOf(chapter to ChapterEvent.KIND), chunkRefs) + } + + @Test + fun `a translation waits for both of its parents`() { + val refs = refsOf( + TranslationArtifactVersionEvent.build( + artifactVersionId = version, + dialectId = dialect, + name = "Sesotho", + visibility = "private", + license = "cc", + ) + ) + + assertEquals( + listOf( + version to ArtifactVersionEvent.KIND, + dialect to DialectEvent.KIND, + ), + refs + ) + } + + @Test + fun `a translated chapter and chunk each wait for both of their parents`() { + val chapterRefs = refsOf( + TranslationChapterEvent.build( + translationArtifactVersionId = translationVersion, + chapterId = chapter, + index = 0, + ) + ) + val chunkRefs = refsOf( + TranslationChunkEvent.build( + translationChapterId = translationChapter, + chunkId = chunk, + index = 0, + text = "translated", + ) + ) + + assertEquals( + listOf( + translationVersion to TranslationArtifactVersionEvent.KIND, + chapter to ChapterEvent.KIND, + ), + chapterRefs + ) + assertEquals( + listOf( + translationChapter to TranslationChapterEvent.KIND, + chunk to ChunkEvent.KIND, + ), + chunkRefs + ) + } + + @Test + fun `a dialect waits for nothing, so it can always start a group off`() { + val refs = refsOf( + DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st") + ) + + assertTrue(refs.isEmpty()) + } +} From fcc28de9311353bd037612677e216faa3cbf6cb9 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 21:12:50 +0200 Subject: [PATCH 08/13] Revert "fix: hold a payload whose parent has not arrived instead of losing the event" This reverts commit d7aac49. Reverting restores the defect it addressed: a payload referencing a row the receiver does not have violates a foreign key, and SQLite aborts, rolling back the whole inbound transaction -- the nostr event, the group event, the submission and the transcript line, none of them retried. That is what produced the observed `FOREIGN KEY constraint failed` on an artifact whose dialect had not arrived. Also drops the schema back to v5. Any device already migrated to v6 will refuse to open its database, since the builder sets no destructive fallback on downgrade; clear that app's data before installing a build from this commit. Co-Authored-By: Claude Opus 5 --- .../6.json | 5056 ----------------- .../mantra/compose/database/MantraDatabase.kt | 9 +- .../database/dao/MantraTranslationChunkDao.kt | 3 - .../database/dao/MarmotInnerEventDao.kt | 9 - .../compose/database/model/ChatMessage.kt | 354 +- .../database/model/MarmotInnerEvent.kt | 13 - .../compose/database/model/ParentRefsTest.kt | 167 - 7 files changed, 46 insertions(+), 5565 deletions(-) delete mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json delete mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ParentRefsTest.kt diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json deleted file mode 100644 index 0bc3bee3..00000000 --- a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json +++ /dev/null @@ -1,5056 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 6, - "identityHash": "1e573d88b491b8326cd3866f0cd0118b", - "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, `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": "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, `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": "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, `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": "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": "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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `awaitingEventId` TEXT, `marmotGroupEventId` 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": "awaitingEventId", - "columnName": "awaitingEventId", - "affinity": "TEXT" - }, - { - "fieldPath": "marmotGroupEventId", - "columnName": "marmotGroupEventId", - "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, '1e573d88b491b8326cd3866f0cd0118b')" - ] - } -} \ 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 70cc9c1d..e3c146ec 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -164,7 +164,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 6, + version = 5, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -183,12 +183,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // nip30303 event a SubmissionEvent rumor carries. Rumors queued before // this come back null, which reads as "not a submission" -- correct, // since none of them were. - AutoMigration(from = 4, to = 5), - // v6 adds the nullable MarmotInnerEvent.awaitingEventId, holding a - // submission that arrived before the event it references. Nothing - // queued before this was ever held, so null is the right answer for - // every existing row. - AutoMigration(from = 5, to = 6) + AutoMigration(from = 4, to = 5) ] ) @ColumnTypeConverters(MantraConverters::class) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt index be7f42bf..52c1c7a3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt @@ -15,9 +15,6 @@ interface MantraTranslationChunkDao { @Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId ORDER BY `index` ASC") suspend fun getTranslationChunksByTranslationChapterId(translationChapterId: String): List - @Query("SELECT * FROM MantraTranslationChunk WHERE id = :id") - suspend fun getTranslationChunkById(id: String): MantraTranslationChunk? - @Query("DELETE FROM MantraTranslationChunk WHERE id = :id") suspend fun deleteById(id: String) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt index fe8d8711..016e060b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt @@ -26,13 +26,4 @@ interface MarmotInnerEventDao { */ @Query("DELETE FROM MarmotInnerEvent WHERE payloadEventId = :payloadEventId") suspend fun deleteByPayloadEventId(payloadEventId: String) - - /** - * The submissions held back waiting for [eventId], oldest first. - * - * Oldest first because a backlog usually arrives in the order it was - * written, so applying it that way unblocks the most in one pass. - */ - @Query("SELECT * FROM MarmotInnerEvent WHERE awaitingEventId = :eventId ORDER BY createdAt ASC") - suspend fun getSubmissionsAwaiting(eventId: String): List } \ No newline at end of file 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 edd93fc5..28e2d0f8 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 @@ -219,18 +219,19 @@ data class ChatMessage( } val payload = submission?.payload() - val innerEvent = MarmotInnerEvent( - id = event.id, - publicKey = event.pubKey, - marmotGroupEventId = groupEvent.id, - tags = event.tags, - content = event.content, - chatRoomId = groupEventResult.groupId, - kind = event.kind, - payloadEventId = payload?.id, - createdAt = Instant.fromEpochSeconds(event.createdAt) + database.marmotInnerEventDao().upsert( + MarmotInnerEvent( + id = event.id, + publicKey = event.pubKey, + marmotGroupEventId = groupEvent.id, + tags = event.tags, + content = event.content, + chatRoomId = groupEventResult.groupId, + kind = event.kind, + payloadEventId = payload?.id, + createdAt = Instant.fromEpochSeconds(event.createdAt) + ) ) - database.marmotInnerEventDao().upsert(innerEvent) // A submission whose payload will not parse, or which carries // another submission, is kept but not applied: there is nothing @@ -248,13 +249,15 @@ data class ChatMessage( content = event.content, ) } else { - applyOrHold( + applyInnerEvent( database = database, activeKeyPair = activeKeyPair, + groupEvent = groupEvent, groupId = groupEventResult.groupId, - innerEvent = innerEvent, event = payload ?: event, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + marmotInnerEventId = event.id, + senderPublicKey = event.pubKey, + createdAt = Instant.fromEpochSeconds(event.createdAt), ) } } @@ -352,283 +355,14 @@ data class ChatMessage( * from [event], so the chat line says who added it and the row says who * wrote it. */ - /** - * Apply [event] now, or hold it until what it references turns up. - * - * Foreign keys mean a payload cannot become a row before its parent - * does, and inserting one anyway does not fail politely: SQLite aborts - * the statement, which rolls back the whole transaction the inbound - * pipeline runs in -- losing the nostr event, the group event, this - * submission and the transcript line with it, none of which is retried. - * - * So the parent is checked first. A payload that arrives early is kept - * against the id it waits for and applied later, which is what makes - * order stop mattering: an admin submitting a backlog can send it in - * whatever order they hold it, and a member who joined last week can be - * sent what the group was told last month. - * - * A held payload writes no chat line. Nobody said anything yet -- the - * line appears when it is applied, in the transcript position its own - * timestamp gives it. - */ - private suspend fun applyOrHold( - database: MantraDatabase, - activeKeyPair: KeyPair, - groupId: String, - innerEvent: MarmotInnerEvent, - event: Event, - isUserMessage: Boolean, - ): ChatMessage? { - missingParentOf(database, event)?.let { missingParent -> - database.marmotInnerEventDao().upsert( - innerEvent.copy(awaitingEventId = missingParent) - ) - return null - } - - val chatMessage = applyInnerEvent( - database = database, - groupId = groupId, - event = event, - marmotGroupEventId = innerEvent.marmotGroupEventId, - marmotInnerEventId = innerEvent.id, - senderPublicKey = innerEvent.publicKey, - isUserMessage = isUserMessage, - createdAt = innerEvent.createdAt, - ) - - // This may be the parent something else was held for. - releaseAwaiting(database, activeKeyPair, groupId, event.id) - - return chatMessage - } - - /** - * Apply whatever was waiting on [arrivedEventId], now that it is here. - * - * Releasing one can release another -- a version unblocks its chapters, - * a chapter unblocks its chunks -- so this keeps going until nothing - * more comes unstuck. A payload with a second parent still missing is - * re-pointed at that one rather than applied, so it waits for the right - * thing instead of being retried on every arrival. - */ - private suspend fun releaseAwaiting( - database: MantraDatabase, - activeKeyPair: KeyPair, - groupId: String, - arrivedEventId: HexKey, - ) { - val arrived = ArrayDeque(listOf(arrivedEventId)) - - while (arrived.isNotEmpty()) { - val parentId = arrived.removeFirst() - - database.marmotInnerEventDao().getSubmissionsAwaiting(parentId).forEach { held -> - // A submission's content is its payload verbatim; anything - // else held is a bare nip30303 event, where the row is the - // event and its own columns rebuild it. Reading both out of - // the content would strand every bare one here forever. - val payload = if (held.kind == SubmissionEvent.KIND) { - Event.fromJsonOrNull(held.content) - } else { - Event( - id = held.id, - pubKey = held.publicKey, - createdAt = held.createdAt.epochSeconds, - kind = held.kind, - tags = held.tags, - content = held.content, - sig = "", - ) - } ?: return@forEach - - missingParentOf(database, payload)?.let { stillMissing -> - database.marmotInnerEventDao().upsert( - held.copy(awaitingEventId = stillMissing) - ) - return@forEach - } - - database.marmotInnerEventDao().upsert(held.copy(awaitingEventId = null)) - - applyInnerEvent( - database = database, - groupId = groupId, - event = payload, - marmotGroupEventId = held.marmotGroupEventId, - marmotInnerEventId = held.id, - senderPublicKey = held.publicKey, - // Held payloads are never ours: we hold the parents of - // anything we wrote, having written those too. - isUserMessage = false, - createdAt = held.createdAt, - )?.let { database.chatMessageDao().upsert(it) } - - arrived.addLast(payload.id) - } - } - } - - /** - * The event [event] references but which is not on file yet, or null - * when everything it needs is already here. - */ - private suspend fun missingParentOf( - database: MantraDatabase, - event: Event, - ): HexKey? = parentRefsOf(event).firstOrNull { !it.exists(database) }?.id - - /** - * A row some event references, named by id and by the kind of event - * that would have created it. - */ - internal data class ParentRef( - val id: HexKey, - val kind: Int, - ) { - suspend fun exists(database: MantraDatabase): Boolean = when (kind) { - DialectEvent.KIND -> - database.mantraDialectDao().getDialectById(id) != null - - ArtifactEvent.KIND -> - database.mantraArtifactDao().getArtifactById(id) != null - - ArtifactVersionEvent.KIND -> - database.mantraArtifactVersionDao().getArtifactVersionById(id) != null - - ChapterEvent.KIND -> - database.mantraChapterDao().getChapterById(id) != null - - ChunkEvent.KIND -> - database.mantraChunkDao().getChunkById(id) != null - - TranslationArtifactVersionEvent.KIND -> - database.mantraTranslationArtifactVersionDao().getTranslationById(id) != null - - TranslationChapterEvent.KIND -> - database.mantraTranslationChapterDao().getTranslationChapterById(id) != null - - TranslationChunkEvent.KIND -> - database.mantraTranslationChunkDao().getTranslationChunkById(id) != null - - // Not a parent anything waits on. - else -> true - } - } - - /** - * Every row [event] references and the database will insist exists. - * - * This is the foreign keys on the Mantra* entities, read off the event - * instead of the schema. The two have to agree: a parent listed here - * that the schema does not enforce just delays a payload for no reason, - * and one the schema enforces but is missing here is a payload that - * takes the whole inbound transaction down with it. - * - * The chat room is deliberately not among them. It is a foreign key, - * but a payload for a room we are not in never reaches this far. - */ - internal fun parentRefsOf(event: Event): List = when (event.kind) { - ArtifactEvent.KIND -> - event.asArtifactEvent().let { - listOfNotNull(it.dialectId()?.let { id -> ParentRef(id, DialectEvent.KIND) }) - } - - ArtifactVersionEvent.KIND -> - event.asArtifactVersionEvent().let { - listOfNotNull(it.artifactId()?.let { id -> ParentRef(id, ArtifactEvent.KIND) }) - } - - ChapterEvent.KIND -> - event.asChapterEvent().let { - listOfNotNull( - it.artifactVersionId()?.let { id -> ParentRef(id, ArtifactVersionEvent.KIND) } - ) - } - - ChunkEvent.KIND -> - event.asChunkEvent().let { - listOfNotNull(it.chapterId()?.let { id -> ParentRef(id, ChapterEvent.KIND) }) - } - - TranslationArtifactVersionEvent.KIND -> - event.asTranslationArtifactVersionEvent().let { - listOfNotNull( - it.artifactVersionId()?.let { id -> ParentRef(id, ArtifactVersionEvent.KIND) }, - it.dialectId()?.let { id -> ParentRef(id, DialectEvent.KIND) }, - ) - } - - TranslationChapterEvent.KIND -> - event.asTranslationChapterEvent().let { - listOfNotNull( - it.translationArtifactVersionId()?.let { id -> - ParentRef(id, TranslationArtifactVersionEvent.KIND) - }, - it.chapterId()?.let { id -> ParentRef(id, ChapterEvent.KIND) }, - ) - } - - TranslationChunkEvent.KIND -> - event.asTranslationChunkEvent().let { - listOfNotNull( - it.translationChapterId()?.let { id -> - ParentRef(id, TranslationChapterEvent.KIND) - }, - it.chunkId()?.let { id -> ParentRef(id, ChunkEvent.KIND) }, - ) - } - - TranslationEvent.KIND -> - event.asTranslationEvent().let { - listOfNotNull( - it.translationChunkId()?.let { id -> - ParentRef(id, TranslationChunkEvent.KIND) - }, - it.translationArtifactVersionId()?.let { id -> - ParentRef(id, TranslationArtifactVersionEvent.KIND) - }, - ) - } - - // Kinds with no parent to wait for. - else -> emptyList() - } - - // A submission's payload arrives as a base Event; these read it back as - // the kind it says it is, so its tag accessors can be used. - private fun Event.asArtifactEvent() = - ArtifactEvent(id, pubKey, createdAt, tags, content, sig) - - private fun Event.asArtifactVersionEvent() = - ArtifactVersionEvent(id, pubKey, createdAt, tags, content, sig) - - private fun Event.asChapterEvent() = - ChapterEvent(id, pubKey, createdAt, tags, content, sig) - - private fun Event.asChunkEvent() = - ChunkEvent(id, pubKey, createdAt, tags, content, sig) - - private fun Event.asTranslationArtifactVersionEvent() = - TranslationArtifactVersionEvent(id, pubKey, createdAt, tags, content, sig) - - private fun Event.asTranslationChapterEvent() = - TranslationChapterEvent(id, pubKey, createdAt, tags, content, sig) - - private fun Event.asTranslationChunkEvent() = - TranslationChunkEvent(id, pubKey, createdAt, tags, content, sig) - - private fun Event.asTranslationEvent() = - TranslationEvent(id, pubKey, createdAt, tags, content, sig) - private suspend fun applyInnerEvent( database: MantraDatabase, + activeKeyPair: KeyPair, + groupEvent: GroupEvent, groupId: String, event: Event, - marmotGroupEventId: HexKey?, marmotInnerEventId: HexKey, senderPublicKey: HexKey, - isUserMessage: Boolean, createdAt: Instant, ): ChatMessage? { return when (event.kind) { @@ -636,10 +370,10 @@ data class ChatMessage( ChatMessage( giftWrapPayloadId = null, messageType = "message", - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, chatRoomId = groupId, createdAt = createdAt, content = event.content, // TODO: Figure out what to do here... @@ -659,17 +393,17 @@ data class ChatMessage( )?.let { mantraArtifact -> database.mantraArtifactDao().upsert( mantraArtifact.copy( - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "artifact", - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraArtifact.name} to artifacts" @@ -690,17 +424,17 @@ data class ChatMessage( )?.let { mantraArtifactVersion -> database.mantraArtifactVersionDao().upsert( mantraArtifactVersion.copy( - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "artifactVersion", - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name... @@ -721,17 +455,17 @@ data class ChatMessage( )?.let { mantraChapter -> database.mantraChapterDao().upsert( mantraChapter.copy( - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "chapter", - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name... @@ -753,7 +487,7 @@ data class ChatMessage( )?.let { mantraChunk -> database.mantraChunkDao().upsert( mantraChunk.copy( - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, ) ) @@ -775,7 +509,7 @@ data class ChatMessage( )?.let { mantraDialect -> database.mantraDialectDao().upsert( mantraDialect.copy( - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, ) ) @@ -783,10 +517,10 @@ data class ChatMessage( ChatMessage( giftWrapPayloadId = null, messageType = "dialect", - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name... @@ -807,17 +541,17 @@ data class ChatMessage( )?.let { mantraTranslationArtifactVersion -> database.mantraTranslationArtifactVersionDao().upsert( mantraTranslationArtifactVersion.copy( - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "translationArtifactVersion", - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name... @@ -842,7 +576,7 @@ data class ChatMessage( )?.let { mantraTranslationChapter -> database.mantraTranslationChapterDao().upsert( mantraTranslationChapter.copy( - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, ) ) // TODO: translation chapter might be too noisy for chat updates @@ -863,7 +597,7 @@ data class ChatMessage( )?.let { mantraTranslationChunk -> database.mantraTranslationChunkDao().upsert( mantraTranslationChunk.copy( - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, ) ) @@ -889,16 +623,16 @@ data class ChatMessage( )?.let { mantraTranslation -> database.mantraTranslationDao().upsert( mantraTranslation.copy( - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "translation", - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text @@ -909,10 +643,10 @@ data class ChatMessage( ChatMessage( giftWrapPayloadId = null, messageType = "unsupported", - marmotGroupEventId = marmotGroupEventId, + marmotGroupEventId = groupEvent.id, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, chatRoomId = groupId, createdAt = createdAt, content = event.toJson(), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt index eab23cd3..a93087e9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt @@ -73,17 +73,6 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... */ val payloadEventId: HexKey? = null, - /** - * The event this one references but which has not arrived yet, or null - * when there is nothing left to wait for. - * - * Every nip30303 entity is a child of another and the database enforces - * that, so a payload cannot become a row before its parent does. Rather - * than drop one that arrives early, it is held here against the id it is - * waiting on and applied when that turns up. - */ - val awaitingEventId: HexKey? = null, - /** * Associated MarmotGroupEvent */ @@ -138,7 +127,6 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... if (content != other.content) return false if (quotedEventId != other.quotedEventId) return false if (payloadEventId != other.payloadEventId) return false - if (awaitingEventId != other.awaitingEventId) return false if (marmotGroupEventId != other.marmotGroupEventId) return false if (createdAt != other.createdAt) return false if (updatedAt != other.updatedAt) return false @@ -159,7 +147,6 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... result = 31 * result + content.hashCode() result = 31 * result + (quotedEventId?.hashCode() ?: 0) result = 31 * result + (payloadEventId?.hashCode() ?: 0) - result = 31 * result + (awaitingEventId?.hashCode() ?: 0) result = 31 * result + (marmotGroupEventId?.hashCode() ?: 0) result = 31 * result + createdAt.hashCode() result = 31 * result + updatedAt.hashCode() diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ParentRefsTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ParentRefsTest.kt deleted file mode 100644 index c19e650c..00000000 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ParentRefsTest.kt +++ /dev/null @@ -1,167 +0,0 @@ -package press.mantra.compose.database.model - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import press.mantra.compose.nostr.nip30303.ArtifactEvent -import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent -import press.mantra.compose.nostr.nip30303.ChapterEvent -import press.mantra.compose.nostr.nip30303.ChunkEvent -import press.mantra.compose.nostr.nip30303.DialectEvent -import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent -import press.mantra.compose.nostr.nip30303.TranslationChapterEvent -import press.mantra.compose.nostr.nip30303.TranslationChunkEvent -import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag - -/** - * What the inbound side must wait for before it writes a row. - * - * These have to match the foreign keys on the Mantra* entities exactly. A - * parent claimed here that the schema does not enforce holds a payload back - * for nothing; one the schema enforces but that is missing here is an insert - * that violates a constraint, and SQLite answers that by rolling back the - * whole inbound transaction -- losing the group event, the submission and the - * transcript line, none of which is ever retried. So the mapping is asserted - * rather than trusted to stay in step. - */ -class ParentRefsTest { - private val author = "a".repeat(64) - private val dialect = "d".repeat(64) - private val artifact = "1".repeat(64) - private val version = "2".repeat(64) - private val chapter = "3".repeat(64) - private val chunk = "4".repeat(64) - private val translationVersion = "5".repeat(64) - private val translationChapter = "6".repeat(64) - - private fun eventOf(template: EventTemplate<*>) = Event( - id = "f".repeat(64), - pubKey = author, - createdAt = template.createdAt, - kind = template.kind, - tags = template.tags, - content = template.content, - sig = "", - ) - - private fun refsOf(template: EventTemplate<*>) = - ChatMessage.parentRefsOf(eventOf(template)).map { it.id to it.kind } - - @Test - fun `an artifact waits for its dialect`() { - val refs = refsOf( - ArtifactEvent.build( - name = "In Detention", - url = "example.com", - visibility = "private", - license = "cc", - dialectId = dialect, - ) - ) - - assertEquals(listOf(dialect to DialectEvent.KIND), refs) - } - - @Test - fun `a version waits for its artifact`() { - val refs = refsOf( - ArtifactVersionEvent.build(content = "1.0") { - addUnique(ArtifactIdTag.assemble(artifact)) - } - ) - - assertEquals(listOf(artifact to ArtifactEvent.KIND), refs) - } - - @Test - fun `a chapter waits for its version and a chunk for its chapter`() { - val chapterRefs = refsOf( - ChapterEvent.build( - artifactVersionId = version, - name = "Chapter 1", - originalText = "text", - index = 0, - wordCount = 1, - characterCount = 4, - ) - ) - val chunkRefs = refsOf( - ChunkEvent.build( - chapterId = chapter, - text = "text", - index = 0, - wordCount = 1, - characterCount = 4, - ) - ) - - assertEquals(listOf(version to ArtifactVersionEvent.KIND), chapterRefs) - assertEquals(listOf(chapter to ChapterEvent.KIND), chunkRefs) - } - - @Test - fun `a translation waits for both of its parents`() { - val refs = refsOf( - TranslationArtifactVersionEvent.build( - artifactVersionId = version, - dialectId = dialect, - name = "Sesotho", - visibility = "private", - license = "cc", - ) - ) - - assertEquals( - listOf( - version to ArtifactVersionEvent.KIND, - dialect to DialectEvent.KIND, - ), - refs - ) - } - - @Test - fun `a translated chapter and chunk each wait for both of their parents`() { - val chapterRefs = refsOf( - TranslationChapterEvent.build( - translationArtifactVersionId = translationVersion, - chapterId = chapter, - index = 0, - ) - ) - val chunkRefs = refsOf( - TranslationChunkEvent.build( - translationChapterId = translationChapter, - chunkId = chunk, - index = 0, - text = "translated", - ) - ) - - assertEquals( - listOf( - translationVersion to TranslationArtifactVersionEvent.KIND, - chapter to ChapterEvent.KIND, - ), - chapterRefs - ) - assertEquals( - listOf( - translationChapter to TranslationChapterEvent.KIND, - chunk to ChunkEvent.KIND, - ), - chunkRefs - ) - } - - @Test - fun `a dialect waits for nothing, so it can always start a group off`() { - val refs = refsOf( - DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st") - ) - - assertTrue(refs.isEmpty()) - } -} From b4ac65f5c9f4d03a28372bc3d5818fd7fe58690d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 21:38:23 +0200 Subject: [PATCH 09/13] feat: sign a nostr event with the group's shared key A ceremony leaves every member holding a share of a t-of-n key and no way to use it. This is the other half: a session that turns an unsigned nostr event into one signed by the group. The shape is ChillDkgRitualManager's, deliberately. The member who proposes coordinates, protocol messages travel as gift-wrapped rumors on the same NIP-17 pipeline chat messages use, each inbound message is persisted and then the session is asked whether it can move, and every step is recomputed from stored inputs so a device killed mid-round resumes on the next message. Anyone who has read that manager can read this one. proposer --[ 30320 proposal ]-> everyone the unsigned event signer --[ 30321 nonce ]-> everyone this device's public nonce proposer --[ 30322 signer set ]-> everyone who signs, and their aggregated nonce signer --[ 30323 partial ]-> everyone this device's partial signature proposer --[ 30324 signature ]-> everyone the finished 64-byte signature anyone --[ 30325 failure ]-> everyone abandon + blame Three things are genuinely different, and each is why this is a separate manager rather than another branch of that one. **It does not need everybody.** A DKG cannot finish until every member takes part; that is what makes the key. Signing needs t, and waiting for n would throw away the property the group ran a ceremony to get. So the coordinator waits for the threshold to be reachable, picks a set and says who is in it. Members left out do nothing and stall nothing. **Restart-safety is forced rather than chosen.** SecretNonce cannot be serialised and refuses to be used twice, so storing the randomness it derives from and regenerating on demand is the only way a session survives the app closing. That is safe for exactly one reason: a session signs one message and cannot be made to sign another. Two rules hold it in place and both are load-bearing rather than tidy: - the event id is written at creation, and a proposal that disagrees with it is refused rather than applied; - the aggregated nonce and signer set are write-once. A coordinator that sends a second, different set is ignored. Obeying it would mean two partial signatures over one secret nonce against two challenges, which is precisely how a secret share is extracted. The session stalls; the share does not. **One approval, not three.** A DKG asks three times because each step publishes something different and commits the member to something different. Here every step serves one decision -- sign this event or do not -- and the event is fixed before the member is asked, so a second prompt would be the same question twice. Declining is broadcast rather than silent: a t-of-n group can sign without you, but only if it knows. Two things are checked rather than trusted, both because the coordinator is untrusted by construction: the event id is recomputed from the proposal's own fields, so a proposer cannot have the group sign one thing while showing them another; and the finished signature is verified before the session is called complete, so a bad aggregate is a failure here rather than a rejection at every relay it reaches. Signer ids are derived, not stored: a member's FROST id is their index in the bytewise sort of the ceremony's host keys, the same ordering ChillDKG hashed into the session identity and the same one the public shares are in. Deriving means signing cannot disagree with the ceremony that made the key. DkgSession gains publicShares, kept because FROST validates each signer's secret share against its public one. A ceremony finished before this column reads back null and signing runs without that check rather than refusing. The tests run the same calls in the same order against real FROST and assert the aggregate verifies as a nostr signature. That path was written from reading the library rather than from a working example, so it is the part most likely to be subtly wrong -- and wired up wrong it fails silently, on every device. Kinds start at 30320 with a gap. The DKG runs 30310-30316 and the nip30303 document kinds run 30300 up; those two already collide at 30310 and 30311, and SubmissionEvent sits on 30312, which is also the DKG's round-1 kind. They are kept apart today only by riding different transports, which is luck. Signing shares a transport and rooms with the DKG, so it starts clear of both. No UI yet: this is the session logic, reachable through proposeSigning, approve and decline. Co-Authored-By: Claude Opus 5 --- .../6.json | 5289 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 17 +- .../database/dao/FrostSigningSessionDao.kt | 47 + .../mantra/compose/database/dao/NostrDao.kt | 25 + .../compose/database/model/ChatMessage.kt | 45 + .../compose/database/model/DkgSession.kt | 21 + .../database/model/FrostSignerMessage.kt | 52 + .../database/model/FrostSigningSession.kt | 139 + .../database/model/types/FrostSigningStage.kt | 25 + .../compose/managers/ChillDkgRitualManager.kt | 6 + .../compose/managers/FrostSigningManager.kt | 1135 ++++ .../compose/nostr/frost/FrostSigningEvents.kt | 99 + .../compose/nostr/frost/tags/FrostKeyTag.kt | 36 + .../nostr/frost/tags/FrostSessionIdTag.kt | 36 + .../nostr/frost/tags/FrostSignerIdsTag.kt | 42 + .../compose/managers/FrostSigningRoundTest.kt | 278 + 16 files changed, 7290 insertions(+), 2 deletions(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSignerMessage.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/FrostSigningStage.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostKeyTag.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSessionIdTag.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSignerIdsTag.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json new file mode 100644 index 00000000..8c68b8c0 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json @@ -0,0 +1,5289 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "bce93202d97cf766e17536af771fcffd", + "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, `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": "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, `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": "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": "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, `stage` TEXT NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signerIds` TEXT, `signature` 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": "stage", + "columnName": "stage", + "affinity": "TEXT", + "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": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, 'bce93202d97cf766e17536af771fcffd')" + ] + } +} \ 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 e3c146ec..29cd80e7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -16,6 +16,7 @@ import press.mantra.compose.database.dao.ChatMessageNostrEventRelationDao import press.mantra.compose.database.dao.ChatRoomDao import press.mantra.compose.database.dao.ConnectionDao import press.mantra.compose.database.dao.DkgSessionDao +import press.mantra.compose.database.dao.FrostSigningSessionDao import press.mantra.compose.database.dao.GiftWrapMessageDao import press.mantra.compose.database.dao.GiftWrapPayloadDao import press.mantra.compose.database.dao.GiftWrapSealDao @@ -102,6 +103,8 @@ import press.mantra.compose.database.model.Reaction import press.mantra.compose.database.model.RecentSearch import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.Relay import press.mantra.compose.database.model.RepostedRelation import press.mantra.compose.database.model.SynchronizeNostrEventRequest @@ -124,6 +127,8 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) ChatRoom::class, DkgParticipantMessage::class, DkgSession::class, + FrostSignerMessage::class, + FrostSigningSession::class, GiftWrapMessage::class, GiftWrapSeal::class, GiftWrapPayload::class, @@ -164,7 +169,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 5, + version = 6, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -183,7 +188,13 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // nip30303 event a SubmissionEvent rumor carries. Rumors queued before // this come back null, which reads as "not a submission" -- correct, // since none of them were. - AutoMigration(from = 4, to = 5) + AutoMigration(from = 4, to = 5), + // v6 adds the FrostSigningSession/FrostSignerMessage tables and the + // nullable DkgSession.publicShares. New tables and a nullable column are + // both shapes Room can migrate itself. A ceremony that completed before + // this reads back null, and signing falls back to not cross-checking + // shares rather than refusing to run. + AutoMigration(from = 5, to = 6) ] ) @ColumnTypeConverters(MantraConverters::class) @@ -203,6 +214,8 @@ abstract class MantraDatabase: RoomDatabase() { abstract fun dkgSessionDao(): DkgSessionDao + abstract fun frostSigningSessionDao(): FrostSigningSessionDao + abstract fun connectionDao(): ConnectionDao abstract fun giftWrapMessageDao(): GiftWrapMessageDao diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt new file mode 100644 index 00000000..78d04c63 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt @@ -0,0 +1,47 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import kotlinx.coroutines.flow.Flow +import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningSession + +@Dao +interface FrostSigningSessionDao { + @Query("SELECT * FROM FrostSigningSession WHERE id = :sessionId") + suspend fun getSessionById(sessionId: String): FrostSigningSession? + + @Query("SELECT * FROM FrostSigningSession WHERE id = :sessionId") + fun observeSessionById(sessionId: String): Flow + + /** + * A room's signing sessions, newest first. Unlike a DKG a group signs + * repeatedly, so there is no single "current" one to observe. + */ + @Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC") + fun observeSessionsForChatRoom(chatRoomId: String): Flow> + + @Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1") + suspend fun getLatestSessionForChatRoom(chatRoomId: String): FrostSigningSession? + + @Upsert + suspend fun upsert(frostSigningSession: FrostSigningSession) + + @Upsert + suspend fun upsert(frostSignerMessage: FrostSignerMessage) + + @Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY createdAt ASC") + suspend fun getMessagesByKind(sessionId: String, kind: Kind): List + + @Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId ORDER BY createdAt ASC") + fun observeMessages(sessionId: String): Flow> + + @Query("SELECT COUNT(*) FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind") + suspend fun countMessagesByKind(sessionId: String, kind: Kind): Int + + @Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind AND signerPublicKey = :signerPublicKey") + suspend fun getMessage(sessionId: String, kind: Kind, signerPublicKey: HexKey): FrostSignerMessage? +} 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 304a048c..3beb8e67 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 @@ -31,6 +31,8 @@ import press.mantra.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventI import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.frost.FrostSigningEvents +import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.managers.MarmotInboundManager import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException @@ -960,6 +962,29 @@ abstract class NostrDao( nostrPrivateKey = activeKeyPair.privKey!! ) } + } else if (FrostSigningEvents.isFrostSigningKind(decryptedGiftWrapPayload.kind)) { + // A FROST signing session message for one of our + // NIP-17 groups. Same reasoning as the ritual above: + // the manager is idempotent, and the room is created + // on demand because membership is the payload's + // p-tags either way. + val localChatRoom = getOrCreateNip17ChatRoom( + decryptedGiftWrapPayload = decryptedGiftWrapPayload, + activeKeyPair = activeKeyPair, + nostrEventId = nostrEvent.id, + relayURL = relayURL + ) + + if (localChatRoom == null) { + logger.w("FROST payload for unknown chat room ${decryptedGiftWrapPayload.chatRoomId}") + } else { + FrostSigningManager.processSigningPayload( + database = database, + localChatRoom = localChatRoom, + giftWrapPayload = decryptedGiftWrapPayload, + userPublicKey = activeKeyPair.pubKey.toHex() + ) + } } else { logger.w("Unsupported event: $decryptedGiftWrapPayload") } 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 28e2d0f8..59fca17f 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 @@ -179,6 +179,51 @@ data class ChatMessage( * A finished ceremony is the exception, and has no actor: the group ends up * with a key, nobody hands it to them. */ + /** + * A FROST signing session, as lines in the group's chat. + * + * The same shape as the ceremony's, and for the same reason: signing with + * the group's key is otherwise a black box, and a session stalled on one + * member gives no way to see whose door to knock on. + */ + const val TYPE_FROST_STARTED = "frostStarted" + const val TYPE_FROST_NONCE = "frostNonce" + const val TYPE_FROST_SIGNER_SET = "frostSignerSet" + const val TYPE_FROST_PARTIAL_SIGNATURE = "frostPartialSignature" + const val TYPE_FROST_SIGNATURE = "frostSignature" + const val TYPE_FROST_COMPLETE = "frostComplete" + const val TYPE_FROST_FAILED = "frostFailed" + + /** Addressed to the reader rather than said by anyone -- see [DKG_REQUEST_TYPES]. */ + const val TYPE_FROST_APPROVAL_NEEDED = "frostApprovalNeeded" + + /** Every signing line, for rendering them as system lines rather than bubbles. */ + val FROST_TYPES = setOf( + TYPE_FROST_STARTED, + TYPE_FROST_NONCE, + TYPE_FROST_SIGNER_SET, + TYPE_FROST_PARTIAL_SIGNATURE, + TYPE_FROST_SIGNATURE, + TYPE_FROST_COMPLETE, + TYPE_FROST_FAILED, + TYPE_FROST_APPROVAL_NEEDED, + ) + + /** + * The signing lines somebody did, as opposed to ones that simply happened. + * Their content is written as a predicate for the actor's name to be read + * in front of. A finished signature has no actor: the group ends up with + * one, nobody hands it to them. + */ + val FROST_AUTHORED_TYPES = setOf( + TYPE_FROST_STARTED, + TYPE_FROST_NONCE, + TYPE_FROST_SIGNER_SET, + TYPE_FROST_PARTIAL_SIGNATURE, + TYPE_FROST_SIGNATURE, + TYPE_FROST_FAILED, + ) + val DKG_AUTHORED_TYPES = setOf( TYPE_DKG_STARTED, TYPE_DKG_HOST_KEY, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt index 24ecc091..8a44166f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt @@ -9,6 +9,9 @@ import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.database.model.types.DkgRitualStage import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.PublicKey import kotlin.time.Clock import kotlin.time.Instant @@ -82,6 +85,17 @@ data class DkgSession( /** Result, secret: this device's FROST secret share, hex. */ val secretShare: HexKey? = null, + /** + * Result: every participant's public share, comma-separated hex, in + * participant order. + * + * Not secret, and not needed to finish the ceremony -- kept because signing + * is what the key is for and FROST validates each signer's secret share + * against its public one. Null on a ceremony that completed before this + * column existed; signing still works there, without that check. + */ + val publicShares: String? = null, + /** Result: recovery data, to be backed up alongside the host key. */ val recoveryData: HexKey? = null, @@ -125,4 +139,11 @@ data class DkgSession( override val savedAt: Instant = createdAt, ): TimestampedEntity, LocalStoreEntity { fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey + + /** The participants' public shares in participant order, or null if unrecorded. */ + fun publicShareList(): List? = publicShares + ?.split(",") + ?.mapNotNull { hex -> hex.trim().takeIf { it.isNotEmpty() } } + ?.map { PublicKey(ByteVector(it.hexToByteArray())) } + ?.takeIf { it.isNotEmpty() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSignerMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSignerMessage.kt new file mode 100644 index 00000000..bfaa1f65 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSignerMessage.kt @@ -0,0 +1,52 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * One signing message from one member, keyed so a duplicate delivery overwrites + * rather than accumulates — relays redeliver, and feeding the same nonce in + * twice would make the coordinator's signer set the wrong length. + * + * Covers the nonce and the partial signature; the coordinator's own broadcasts + * live on [FrostSigningSession] because there is only ever one of each. + * + * Keyed on `(sessionId, signerPublicKey, kind)`, which also means a member + * cannot replace their own nonce once the coordinator has aggregated it — a + * second nonce from the same signer overwrites the first, and the aggregate + * built from it simply stops matching. The session's own write-once rule on the + * aggregate is what makes that a stalled session rather than a leaked share. + */ +@Entity( + primaryKeys = ["sessionId", "signerPublicKey", "kind"], + foreignKeys = [ + ForeignKey( + entity = FrostSigningSession::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + Index("sessionId"), + ], +) +data class FrostSignerMessage( + val sessionId: String, + + /** The member's nostr public key — who sent it. */ + val signerPublicKey: HexKey, + + /** One of the `FrostSigningEvents` kinds. */ + val kind: Kind, + + /** Hex of the protocol bytes: a public nonce, or a partial signature. */ + val payload: HexKey, + + val createdAt: Instant = Clock.System.now(), +) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt new file mode 100644 index 00000000..e15db909 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt @@ -0,0 +1,139 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.time.Clock +import kotlin.time.Instant +import press.mantra.compose.database.model.traits.LocalStoreEntity +import press.mantra.compose.database.model.traits.TimestampedEntity +import press.mantra.compose.database.model.types.FrostSigningStage + +/** + * One FROST signing session, as this device sees it. + * + * Like [DkgSession] this stores *inputs* rather than protocol state, so a + * session survives the app being killed halfway through: every step is + * recomputed from what is on the row. Unlike a DKG that choice is not merely + * convenient here, it is forced -- `fr.acinq.bitcoin.crypto.frost.SecretNonce` + * cannot be serialised and refuses to be used twice, by design. + * + * ### The nonce, and why one session means one message + * + * [nonceRandom] is secret, and regenerating this device's nonce from it is safe + * for exactly one reason: a session signs one message and can never be made to + * sign another. `SecretNonce.generate` mixes the message in, so the same + * randomness under a different message would be a different nonce -- but the + * same randomness under the same message with two *different* aggregated nonces + * would produce two partial signatures over one secret nonce, which is how a + * secret share is extracted. + * + * Two rules keep that impossible, and both are load-bearing: + * + * - [eventId] is written when the session is created and a proposal that + * disagrees with it is rejected rather than applied. + * - [aggregatedNonce] and [signerIds] are written once. A second, different + * signer set for the same session is ignored, not honoured. + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = ChatRoom::class, + parentColumns = ["id"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + Index("chatRoomId"), + Index("dkgSessionId"), + ], +) +data class FrostSigningSession( + /** Minted by the proposer, carried on every message as `frost_session`. */ + @PrimaryKey + val id: String, + + val chatRoomId: String, + + /** The member who proposed the signature, who also aggregates for it. */ + val coordinatorPublicKey: HexKey, + + /** Whose device this row belongs to, for multi-account support. */ + val userPublicKey: HexKey, + + /** The ceremony whose key this signs with — a group may hold more than one. */ + val dkgSessionId: String, + + /** The `t` of the t-of-n: how many partial signatures make a signature. */ + val threshold: Int, + + /** The `n` the key was generated for. FROST needs it to place signer ids. */ + val participantCount: Int, + + /** This device's FROST id: its index in the ceremony's participant order. */ + val signerId: Int, + + val stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + + /** + * The unsigned event, as JSON. Kept whole so a member can be shown what + * they are being asked to sign rather than a hash of it. + */ + val unsignedEventJson: String, + + /** + * The event id, which is the 32 bytes actually signed. + * + * Recomputed from the event's own fields on arrival, never taken from the + * proposal. It pins the session to one message -- see the class note on why + * that is what makes reusing [nonceRandom] safe. + */ + val eventId: HexKey, + + /** Secret. 32 bytes of fresh randomness, the seed for this device's nonce. */ + val nonceRandom: HexKey, + + /** The coordinator's `AggregatedNonce` once it arrives, hex. Written once. */ + val aggregatedNonce: HexKey? = null, + + /** The chosen signers' FROST ids in aggregation order, comma separated. Written once. */ + val signerIds: String? = null, + + /** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */ + val signature: HexKey? = null, + + val failureReason: String? = null, + + /** + * When this device's owner agreed to sign, and with it to everything the + * session does on their behalf. Null until they do, and nothing of theirs + * goes out before it is set. + * + * One gate rather than the DKG's three. What a signer is consenting to is + * the event, and the event is fixed before they are asked: the second round + * puts no new question to them, so asking again would be asking the same + * question twice about a decision already made. + */ + val signApprovedAt: Instant? = null, + + /** Whether the chat line asking for that approval has been written. */ + val approvalRequestedAt: Instant? = null, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt, + override val savedAt: Instant = createdAt, +): TimestampedEntity, LocalStoreEntity { + fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey + + /** The chosen signers, or null while the coordinator has yet to choose. */ + fun signerIdList(): List? = signerIds + ?.split(",") + ?.mapNotNull { it.trim().toIntOrNull() } + ?.takeIf { it.isNotEmpty() } + + /** Whether this device was picked to sign. A t-of-n key does not need everyone. */ + fun isSigner(): Boolean = signerIdList()?.contains(signerId) ?: false +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/FrostSigningStage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/FrostSigningStage.kt new file mode 100644 index 00000000..9331e3a7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/FrostSigningStage.kt @@ -0,0 +1,25 @@ +package press.mantra.compose.database.model.types + +/** + * How far a FROST signing session has got, from the point of view of the device + * holding the row. Coordinator and signers move through the same ladder; the + * coordinator simply has extra work to do at [COLLECTING_NONCES] and + * [COLLECTING_PARTIAL_SIGNATURES]. + * + * Declaration order is the ladder: `FrostSigningManager` compares ordinals to + * keep the label moving forwards when messages arrive out of order, so the + * collecting stages must stay in the order the session runs them. + */ +enum class FrostSigningStage { + /** Proposal seen; waiting for enough signers to offer a nonce. */ + COLLECTING_NONCES, + + /** The signer set is fixed; waiting on their partial signatures. */ + COLLECTING_PARTIAL_SIGNATURES, + + /** Aggregated and verified. The event is signed. */ + COMPLETE, + + /** Abandoned. See `FrostSigningSession.failureReason`. */ + FAILED +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index 79bb84a8..cf20fd29 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -574,6 +574,12 @@ object ChillDkgRitualManager { stage = DkgRitualStage.COMPLETE, thresholdPublicKey = output.thresholdPublicKey?.value?.toHex(), secretShare = output.secretShare?.value?.toHex(), + // Kept for signing, which needs every participant's public + // share to place and check the signers. In participant order, + // the same order the ids are derived from. + publicShares = output.publicShares + .joinToString(",") { share -> share.value.toHex() } + .takeIf { output.publicShares.isNotEmpty() }, recoveryData = output.recovery?.toHex() ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt new file mode 100644 index 00000000..b059106b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -0,0 +1,1135 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.utils.RandomInstance +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.crypto.frost.AggregatedNonce +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import fr.acinq.bitcoin.crypto.frost.TweakCache +import fr.acinq.bitcoin.utils.Either +import kotlin.time.Clock +import kotlin.time.Instant +import kotlinx.coroutines.CancellationException +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.ChatMessage +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.GiftWrapPayload +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.database.model.types.FrostSigningStage +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.frost.FrostSigningEvents + +/** + * Signs a nostr event with a group's FROST threshold key, over a NIP-17 group. + * + * The shape is [ChillDkgRitualManager]'s, deliberately: the member who proposes + * a signature coordinates it, every protocol message travels as a gift-wrapped + * rumor on the kinds in [FrostSigningEvents], each inbound message is persisted + * and then the session is asked whether it can move, and every step is + * recomputed from stored inputs so a device killed mid-round resumes on the + * next message. What that manager's own notes say about being message-driven + * applies here unchanged. + * + * Three things are genuinely different, and each of them is why this is a + * separate manager rather than another branch of that one. + * + * ### It does not need everybody + * + * A DKG cannot finish until every member takes part; that is what makes the key. + * Signing with a t-of-n key needs t, and waiting for n would throw away the + * property the group ran a ceremony to get. So the coordinator waits for the + * threshold to be reachable, picks a signer set, and says who is in it. Members + * outside the set do nothing and are not stalling anything. + * + * ### Restart-safety is forced, not chosen + * + * `SecretNonce` cannot be serialised and refuses to be used twice. Storing the + * randomness it is derived from and regenerating on demand is the only way a + * signing session can survive the app closing -- and it is safe only because a + * session signs one message and cannot be made to sign another. See + * [FrostSigningSession] for the two rules that hold that in place; both are + * enforced here, in [acceptProposal] and in [record]. + * + * ### One approval, not three + * + * A DKG asks three times because each step publishes something different and + * commits the member to something different. Here every step serves one + * decision -- sign this event or do not -- and the event is fixed before the + * member is asked. A second prompt would be the same question again. + */ +object FrostSigningManager { + private const val TAG = "FrostSigningManager" + + private val logger = Logger.withTag(TAG) + + /** + * Opens a signing session, making this device the coordinator. + * + * [unsignedEvent] is signed as it stands apart from its `pubkey`, which is + * replaced with the group's key: a signature over an event claiming + * somebody else's author would verify against nothing. + */ + suspend fun proposeSigning( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + kind: Kind, + tags: Array>, + content: String, + createdAt: Long = Clock.System.now().epochSeconds + ): FrostSigningSession { + val key = completedKey(database, localChatRoom.chatRoom.id) + ?: throw IllegalStateException("This group has no shared key to sign with") + + val signerId = signerIdOf(database, key, userPublicKey) + ?: throw IllegalStateException("This device is not a participant in ceremony ${key.id}") + + val unsignedEvent = unsignedEventOf(key, kind, tags, content, createdAt) + val sessionId = RandomInstance.bytes(32).toHex() + + val session = FrostSigningSession( + id = sessionId, + chatRoomId = localChatRoom.chatRoom.id, + coordinatorPublicKey = userPublicKey, + userPublicKey = userPublicKey, + dkgSessionId = key.id, + threshold = key.threshold, + participantCount = key.participantCount, + signerId = signerId, + unsignedEventJson = unsignedEvent.toJson(), + eventId = unsignedEvent.id, + // Fresh per session, and never reused: this is the seed the device's + // secret nonce is regenerated from for the life of the session. + nonceRandom = RandomInstance.bytes(32).toHex(), + // Proposing a signature is already the act of agreeing to it. + signApprovedAt = Clock.System.now() + ) + database.frostSigningSessionDao().upsert(session) + announceStarted(database, session) + + logger.i("Proposing signature $sessionId over event ${unsignedEvent.id} with key ${key.id}") + + broadcast( + database = database, + localChatRoom = localChatRoom, + session = session, + kind = FrostSigningEvents.PROPOSAL, + content = session.unsignedEventJson, + includeKey = true + ) + + advance(database, localChatRoom, sessionId) + + return session + } + + /** + * Feeds one inbound signing message in and advances the session as far as it + * will go. Safe to call twice with the same message: every write is keyed + * and every step recomputed from stored inputs. + */ + suspend fun processSigningPayload( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + giftWrapPayload: GiftWrapPayload, + userPublicKey: HexKey + ) { + val sessionId = FrostSigningEvents.parseSessionId(giftWrapPayload.tags) + if (sessionId == null) { + logger.w("FROST payload ${giftWrapPayload.id} has no session tag; dropping") + return + } + + val session = if (giftWrapPayload.kind == FrostSigningEvents.PROPOSAL) { + acceptProposal( + database = database, + localChatRoom = localChatRoom, + giftWrapPayload = giftWrapPayload, + sessionId = sessionId, + userPublicKey = userPublicKey + ) + } else { + // Not knowing the session is normal: gift wraps carry a randomised + // created_at and relays hand them back in no particular order, so a + // nonce routinely lands before the proposal that asks for it. The + // payload is already stored, and [acceptProposal] replays it once the + // proposal turns up. + database.frostSigningSessionDao().getSessionById(sessionId) + } + + if (session == null) { + logger.i("No signing session $sessionId for kind ${giftWrapPayload.kind}; leaving it stored") + return + } + + if (session.stage == FrostSigningStage.FAILED) { + logger.i("Session $sessionId already failed; ignoring kind ${giftWrapPayload.kind}") + return + } + + if (!record(database, session, giftWrapPayload)) return + + advance(database, localChatRoom, session.id) + } + + /** + * Records a proposal. Returns the session, whether it was just created or + * already known. + * + * Publishes nothing: a signature is the one thing a group's key exists to + * produce, so a relay delivering an event to a phone in a pocket must not be + * enough to make it happen. + */ + private suspend fun acceptProposal( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + giftWrapPayload: GiftWrapPayload, + sessionId: String, + userPublicKey: HexKey + ): FrostSigningSession? { + database.frostSigningSessionDao().getSessionById(sessionId)?.let { existing -> + // The message a session signs is fixed at creation. A second proposal + // under the same id carrying a different event is either a mistake or + // an attempt to get two signatures out of one secret nonce, which is + // how a share is extracted -- so it is refused, not applied. + val proposed = Event.fromJsonOrNull(giftWrapPayload.content) + if (proposed != null && proposed.id != existing.eventId) { + logger.w( + "Session $sessionId re-proposed with event ${proposed.id}, " + + "but it is already signing ${existing.eventId}; ignoring" + ) + } + return existing + } + + val dkgSessionId = FrostSigningEvents.parseKey(giftWrapPayload.tags) + if (dkgSessionId == null) { + logger.w("Signing proposal $sessionId names no key; dropping") + return null + } + + val key = database.dkgSessionDao().getSessionById(dkgSessionId) + if (key == null || key.stage != DkgRitualStage.COMPLETE) { + logger.w("Signing proposal $sessionId names key $dkgSessionId, which this device does not hold; dropping") + return null + } + + val signerId = signerIdOf(database, key, userPublicKey) + if (signerId == null) { + logger.w("Signing proposal $sessionId is for a ceremony this device did not take part in; dropping") + return null + } + + val proposed = Event.fromJsonOrNull(giftWrapPayload.content) + if (proposed == null) { + logger.w("Signing proposal $sessionId does not carry an event; dropping") + return null + } + + // Rebuilt from the event's own fields rather than trusted. The id is the + // 32 bytes every signer puts their share behind, so taking the proposer's + // word for it would let them have the group sign one thing while being + // shown another. + val unsignedEvent = unsignedEventOf( + key = key, + kind = proposed.kind, + tags = proposed.tags, + content = proposed.content, + createdAt = proposed.createdAt + ) + if (unsignedEvent.id != proposed.id) { + logger.w( + "Signing proposal $sessionId carries id ${proposed.id} but its fields hash " + + "to ${unsignedEvent.id}; dropping" + ) + return null + } + + val session = FrostSigningSession( + id = sessionId, + chatRoomId = localChatRoom.chatRoom.id, + // Whoever proposes coordinates. Aggregating nonces and partial + // signatures gives no power over the outcome -- a wrong aggregate + // produces a signature that does not verify, not a forged one. + coordinatorPublicKey = giftWrapPayload.publicKey, + userPublicKey = userPublicKey, + dkgSessionId = key.id, + threshold = key.threshold, + participantCount = key.participantCount, + signerId = signerId, + unsignedEventJson = unsignedEvent.toJson(), + eventId = unsignedEvent.id, + nonceRandom = RandomInstance.bytes(32).toHex() + ) + database.frostSigningSessionDao().upsert(session) + announceStarted(database, session) + + logger.i("Recorded signing session $sessionId over ${unsignedEvent.id}; awaiting approval") + + announceApprovalNeeded(database, session) + replayStoredMessages(database, session) + + return session + } + + /** + * Feeds in every message of this session that arrived before the proposal did. + * + * They were dropped at the time for want of a session to file them under, but + * the inbound path stores every payload it decrypts before dispatching on + * kind, so nothing was actually lost — this reads them back out. + */ + private suspend fun replayStoredMessages( + database: MantraDatabase, + session: FrostSigningSession + ) { + val stored = database.giftWrapPayloadDao().getByChatRoomAndKinds( + chatRoomId = session.chatRoomId, + kinds = FrostSigningEvents.ALL.toList() + ).filter { payload -> + payload.kind != FrostSigningEvents.PROPOSAL && + FrostSigningEvents.parseSessionId(payload.tags) == session.id + } + + if (stored.isEmpty()) return + + logger.i("Replaying ${stored.size} stored message(s) for signing session ${session.id}") + + stored.forEach { payload -> + if (!record(database, session, payload)) return + } + } + + /** + * Files one signing message. Returns false when the message ends the session, + * so the caller stops rather than trying to advance a dead one. + */ + private suspend fun record( + database: MantraDatabase, + session: FrostSigningSession, + giftWrapPayload: GiftWrapPayload + ): Boolean { + when (giftWrapPayload.kind) { + FrostSigningEvents.PROPOSAL -> Unit // handled by acceptProposal + + FrostSigningEvents.NONCE, + FrostSigningEvents.PARTIAL_SIGNATURE -> { + val known = database.frostSigningSessionDao() + .getMessage(session.id, giftWrapPayload.kind, giftWrapPayload.publicKey) != null + + database.frostSigningSessionDao().upsert( + FrostSignerMessage( + sessionId = session.id, + signerPublicKey = giftWrapPayload.publicKey, + kind = giftWrapPayload.kind, + payload = giftWrapPayload.content + ) + ) + + if (!known) { + announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + } + } + + FrostSigningEvents.SIGNER_SET -> { + if (!isFromCoordinator(session, giftWrapPayload)) return true + + val signerIds = FrostSigningEvents.parseSignerIds(giftWrapPayload.tags) + if (signerIds == null) { + logger.w("Session ${session.id}: signer set carries no ids; ignoring") + return true + } + + val known = current(database, session).aggregatedNonce != null + + // Write-once, and this is the load-bearing one. Signing the same + // message twice under one secret nonce against two different + // aggregated nonces is exactly how a secret share is extracted, so + // a coordinator that sends a second, different signer set is + // ignored rather than obeyed. The session stalls; the share does + // not leak. + update(database, session) { current -> + if (current.aggregatedNonce == null) { + current.copy( + aggregatedNonce = giftWrapPayload.content, + signerIds = signerIds.joinToString(",") + ) + } else { + current + } + } + + if (!known) { + announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + } + } + + FrostSigningEvents.SIGNATURE -> { + if (!isFromCoordinator(session, giftWrapPayload)) return true + + val known = current(database, session).signature != null + + update(database, session) { current -> + if (current.signature == null) { + current.copy(signature = giftWrapPayload.content) + } else { + current + } + } + + if (!known) { + announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + } + } + + FrostSigningEvents.FAILURE -> { + fail( + database = database, + session = session, + reason = "Abandoned by ${giftWrapPayload.publicKey.take(8)}: ${giftWrapPayload.content}", + culprit = giftWrapPayload.publicKey + ) + return false + } + } + + return true + } + + private fun isFromCoordinator( + session: FrostSigningSession, + giftWrapPayload: GiftWrapPayload + ): Boolean { + if (giftWrapPayload.publicKey == session.coordinatorPublicKey) return true + + logger.w( + "Session ${session.id}: kind ${giftWrapPayload.kind} from " + + "${giftWrapPayload.publicKey.take(8)}, who is not the coordinator; ignoring" + ) + return false + } + + /** + * Takes every step the stored messages now allow, in order, stopping at the + * first one still waiting on somebody. + * + * Each step asks whether its output already exists rather than whether the + * stage says it has run, so re-entering after a crash — or after the same + * message is delivered twice — repeats no work and skips none. + */ + private suspend fun advance( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + sessionId: String + ) { + var session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return + if (session.stage == FrostSigningStage.COMPLETE || session.stage == FrostSigningStage.FAILED) return + + val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return + val secretShare = key.secretShare?.let { PrivateKey(ByteVector32(it)) } ?: return + val thresholdPublicKey = key.thresholdPublicKey?.let { PublicKey(ByteVector(it.hexToByteArray())) } + ?: return + + try { + // Nothing of this device's own goes out before its owner has said so. + // Returns rather than throws: the session is not failing, it is waiting + // on a person, and everything received stays stored so it resumes the + // moment they approve. + if (session.signApprovedAt == null) { + announceApprovalNeeded(database, session) + return + } + + val tweakCache = TweakCache.create(thresholdPublicKey) + val message = ByteVector(session.eventId.hexToByteArray()) + val publicShares = key.publicShareList() + + // Regenerated rather than stored -- SecretNonce refuses both. Safe + // because the session's message can never change; see the notes on + // FrostSigningSession. + val (secretNonce, publicNonce) = SecretNonce.generate( + sessionRandom = ByteVector32(session.nonceRandom), + secretShare = secretShare, + publicShare = publicShares?.getOrNull(session.signerId), + tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, + message = message, + extraInput = null + ) + + if (ownMessage(database, session, FrostSigningEvents.NONCE) == null) { + publishOwn( + database, + localChatRoom, + session, + FrostSigningEvents.NONCE, + publicNonce.data.toHex() + ) + } + + if (session.isCoordinator() && session.aggregatedNonce == null) { + val offered = orderedNonces(database, session) ?: return + val chosen = offered.take(session.threshold) + + val aggregated = IndividualNonce.aggregate(chosen.map { it.second }) + .orThrow("aggregating nonces") + + session = update(database, session) { + it.copy( + aggregatedNonce = aggregated.toByteArray().toHex(), + signerIds = chosen.joinToString(",") { (id, _) -> id.toString() } + ) + } + broadcast( + database = database, + localChatRoom = localChatRoom, + session = session, + kind = FrostSigningEvents.SIGNER_SET, + content = aggregated.toByteArray().toHex(), + signerIds = chosen.map { (id, _) -> id } + ) + announceStep( + database, + session, + FrostSigningEvents.SIGNER_SET, + session.userPublicKey + ) + } + + val aggregatedNonce = session.aggregatedNonce ?: return + val signerIds = session.signerIdList() ?: return + session = moveTo(database, session, FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES) + + val signingSession = Session.create( + aggregatedNonce = AggregatedNonce(aggregatedNonce.hexToByteArray()), + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = publicShares?.let { shares -> + signerIds.mapNotNull { shares.getOrNull(it) }.takeIf { it.size == signerIds.size } + }, + nParticipants = session.participantCount, + threshold = session.threshold, + tweakCache = tweakCache, + message = message + ) + + // A member outside the chosen set has nothing to contribute and is not + // holding anybody up. They stay in the session to receive the finished + // signature like everybody else. + if (session.isSigner() && ownMessage(database, session, FrostSigningEvents.PARTIAL_SIGNATURE) == null) { + val partialSignature = signingSession + .sign(secretNonce, secretShare, session.signerId.toUInt()) + .orThrow("signing") + + publishOwn( + database, + localChatRoom, + session, + FrostSigningEvents.PARTIAL_SIGNATURE, + partialSignature.toHex() + ) + } + + if (session.isCoordinator() && session.signature == null) { + val partials = orderedPartialSignatures(database, session, signerIds) ?: return + + val signature = signingSession + .aggregateSigs(partials.map { ByteVector32(it) }) + .orThrow("aggregating partial signatures") + .toHex() + + session = update(database, session) { it.copy(signature = signature) } + broadcast( + database = database, + localChatRoom = localChatRoom, + session = session, + kind = FrostSigningEvents.SIGNATURE, + content = signature + ) + announceStep(database, session, FrostSigningEvents.SIGNATURE, session.userPublicKey) + } + + val signature = session.signature ?: return + + // The payoff: a signature that verifies is one the group made, whoever + // relayed it. Checking rather than trusting is what keeps a faulty or + // dishonest coordinator from passing off something that will be + // rejected by every relay it reaches. + val signedEvent = signedEvent(session, signature) + val verified = Nip01Crypto.verify( + signature = signature.hexToByteArray(), + hash = session.eventId.hexToByteArray(), + pubKey = signedEvent.pubKey.hexToByteArray() + ) + if (!verified) { + throw IllegalStateException("The aggregated signature does not verify against ${session.eventId}") + } + + update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) } + announce( + database = database, + session = session, + messageType = ChatMessage.TYPE_FROST_COMPLETE, + content = "The group signed the event. It took ${session.threshold} of " + + "${session.participantCount} members.", + actor = session.coordinatorPublicKey + ) + + logger.i("Signing session $sessionId complete") + } catch (e: CancellationException) { + // The sync was torn down mid-step, which says nothing about the session. + throw e + } catch (e: Throwable) { + logger.e("Signing session $sessionId failed", e) + fail(database, session, e.message ?: e::class.simpleName ?: "Unknown error") + broadcast( + database = database, + localChatRoom = localChatRoom, + session = session, + kind = FrostSigningEvents.FAILURE, + content = e.message ?: "Signing failed" + ) + } + } + + /** + * The event this session produces, with the signature on it. + * + * Public so a caller can take the finished event and do whatever it was + * signing it for — the session's job ends at a valid signature. + */ + fun signedEvent(session: FrostSigningSession, signature: HexKey): Event { + val unsigned = Event.fromJson(session.unsignedEventJson) + + return Event( + id = unsigned.id, + pubKey = unsigned.pubKey, + createdAt = unsigned.createdAt, + kind = unsigned.kind, + tags = unsigned.tags, + content = unsigned.content, + sig = signature + ) + } + + /** The finished event, or null while the session is still running. */ + fun signedEvent(session: FrostSigningSession): Event? = + session.signature?.let { signedEvent(session, it) } + + /** + * The nonces on offer, as (signer id, nonce), ordered by signer id — or null + * while fewer than the threshold have arrived. + * + * Ordered so that every device that recomputes the aggregate from the same + * set arrives at the same bytes. FROST binds the signer set into the + * challenge, so an order nobody else derives is a signature nobody can + * aggregate. + */ + private suspend fun orderedNonces( + database: MantraDatabase, + session: FrostSigningSession + ): List>? { + val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null + val idByMember = signerIds(database, key) + + val offered = database.frostSigningSessionDao() + .getMessagesByKind(session.id, FrostSigningEvents.NONCE) + .mapNotNull { message -> + idByMember[message.signerPublicKey]?.let { id -> + id to IndividualNonce(message.payload.hexToByteArray()) + } + } + .sortedBy { (id, _) -> id } + + if (offered.size < session.threshold) { + logger.d("Session ${session.id}: ${offered.size}/${session.threshold} nonces") + return null + } + + return offered + } + + /** + * The chosen signers' partial signatures in signer-set order, or null while + * any are missing. Aggregation needs exactly one per signer, positionally + * matched to the set the session was created with. + */ + private suspend fun orderedPartialSignatures( + database: MantraDatabase, + session: FrostSigningSession, + signerIds: List + ): List? { + val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null + val memberById = signerIds(database, key).entries.associate { (member, id) -> id to member } + + val payloadByMember = database.frostSigningSessionDao() + .getMessagesByKind(session.id, FrostSigningEvents.PARTIAL_SIGNATURE) + .associate { it.signerPublicKey to it.payload } + + val partials = signerIds.mapNotNull { id -> + memberById[id]?.let { payloadByMember[it] } + } + + if (partials.size < signerIds.size) { + logger.d("Session ${session.id}: ${partials.size}/${signerIds.size} partial signatures") + return null + } + + return partials.map { it.hexToByteArray() } + } + + /** + * Every ceremony participant's FROST id, keyed by their nostr public key. + * + * The id is the member's index in the ceremony's participant order, which is + * the bytewise sort of the host public keys — the same ordering ChillDKG + * hashed into the session identity, and so the same one the public shares + * are in. Deriving it rather than storing it means signing cannot disagree + * with the ceremony that made the key. + */ + private suspend fun signerIds( + database: MantraDatabase, + key: DkgSession + ): Map { + val hostKeys = database.dkgSessionDao() + .getMessagesByKind(key.id, DkgRitualEvents.HOST_KEY) + .associate { it.participantPublicKey to it.payload.lowercase() } + + val order = hostKeys.values.sorted() + + return hostKeys.mapNotNull { (member, hostKey) -> + order.indexOf(hostKey).takeIf { it >= 0 }?.let { member to it } + }.toMap() + } + + private suspend fun signerIdOf( + database: MantraDatabase, + key: DkgSession, + member: HexKey + ): Int? = signerIds(database, key)[member] + + /** + * The group's usable key, or null when it has none. + * + * A group can have run more than one ceremony; the newest completed one is + * the live key, matching what the shared-key screen shows. + */ + suspend fun completedKey(database: MantraDatabase, chatRoomId: String): DkgSession? = + database.dkgSessionDao() + .getLatestSessionForChatRoom(chatRoomId) + ?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null } + + /** Whether this group can sign at all, read by the UI so it offers nothing that would fail. */ + suspend fun canSign(database: MantraDatabase, chatRoomId: String): Boolean = + completedKey(database, chatRoomId) != null + + /** + * The unsigned event a session signs: the caller's fields under the group's + * key, with the id computed from them. + */ + private fun unsignedEventOf( + key: DkgSession, + kind: Kind, + tags: Array>, + content: String, + createdAt: Long + ): Event { + // Nostr identifies an author by the x-only key a BIP-340 signature + // verifies against, which for a FROST key is the tweaked threshold key + // rather than the 33-byte value the ceremony reports. + val thresholdPublicKey = PublicKey(ByteVector(key.thresholdPublicKey!!.hexToByteArray())) + val groupPubKey = TweakCache.create(thresholdPublicKey).tweakedPublicKey.value.toHex() + + return Event( + id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content + ), + pubKey = groupPubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "" + ) + } + + /** + * Turns a failed FROST step into an exception so it joins [advance]'s single + * failure path. + * + * The library reports these as `Either`, because a partial signature that + * will not aggregate is a normal outcome of collecting them from other + * people rather than a bug. This session has one response to all of them — + * there is no signature, so it dies and the group is told — and what is + * worth keeping is which step failed. + */ + private fun Either.orThrow(step: String): T = when (this) { + is Either.Right -> value + is Either.Left -> throw IllegalStateException("FROST $step failed: ${value.message}", value) + } + + /** This device's own message of [kind], if it has published one. */ + private suspend fun ownMessage( + database: MantraDatabase, + session: FrostSigningSession, + kind: Kind + ): FrostSignerMessage? = database.frostSigningSessionDao().getMessage( + sessionId = session.id, + kind = kind, + signerPublicKey = session.userPublicKey + ) + + /** + * Applies [transform] to the *stored* session and returns what was written. + * + * Callers hold a session across several steps, each of which may write; going + * back to the row means a later write cannot silently undo an earlier one by + * copying from a stale snapshot. + */ + private suspend fun update( + database: MantraDatabase, + session: FrostSigningSession, + transform: (FrostSigningSession) -> FrostSigningSession + ): FrostSigningSession { + val current = database.frostSigningSessionDao().getSessionById(session.id) ?: session + val updated = transform(current) + + if (updated == current) return current + + val stamped = updated.copy(updatedAt = Clock.System.now()) + database.frostSigningSessionDao().upsert(stamped) + + return stamped + } + + /** + * Moves the progress label forward, never back. Stages are declared in the + * order the session runs them, so a message arriving out of order cannot + * walk the UI back down the ladder. + */ + private suspend fun moveTo( + database: MantraDatabase, + session: FrostSigningSession, + stage: FrostSigningStage + ): FrostSigningSession = update(database, session) { current -> + val settled = + current.stage == FrostSigningStage.COMPLETE || current.stage == FrostSigningStage.FAILED + + if (settled || current.stage.ordinal >= stage.ordinal) current else current.copy(stage = stage) + } + + private suspend fun fail( + database: MantraDatabase, + session: FrostSigningSession, + reason: String, + culprit: HexKey = session.userPublicKey + ) { + // Read before writing so the notice goes out once. A session can be failed + // from two directions -- a FAILURE message from a member, and a fault + // raised locally -- and the group does not need to be told twice. + val current = database.frostSigningSessionDao().getSessionById(session.id) ?: session + if (current.stage == FrostSigningStage.FAILED) return + + update(database, current) { + it.copy(stage = FrostSigningStage.FAILED, failureReason = reason) + } + announce( + database = database, + session = current, + messageType = ChatMessage.TYPE_FROST_FAILED, + content = "abandoned the signature. Nothing was signed, and it is safe to ask again.", + actor = culprit + ) + } + + /** The stored session, for reading a column back before overwriting it. */ + private suspend fun current( + database: MantraDatabase, + session: FrostSigningSession + ): FrostSigningSession = + database.frostSigningSessionDao().getSessionById(session.id) ?: session + + /** + * Whether this session is waiting on its owner to agree to sign. + * + * Mirrors the gate in [advance]: pending exactly when [advance] would stop at + * it. The two must agree, or the screen offers an approval that does nothing, + * or none while the session sits still. + */ + fun isAwaitingApproval(session: FrostSigningSession): Boolean { + if (session.stage == FrostSigningStage.COMPLETE || session.stage == FrostSigningStage.FAILED) { + return false + } + + return session.signApprovedAt == null + } + + /** + * Records that this device's owner agreed to sign, then lets the session run + * as far as the next thing it is waiting on. + * + * Approving a session not waiting on it is a no-op rather than an error: a + * stale screen left open across a redelivery should not publish anything. + */ + suspend fun approve( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + sessionId: String + ) { + val session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return + + if (!isAwaitingApproval(session)) { + logger.i("Session $sessionId is not waiting on an approval; ignoring it") + return + } + + update(database, session) { it.copy(signApprovedAt = Clock.System.now()) } + + logger.i("Session $sessionId: signing approved") + + advance(database, localChatRoom, sessionId) + } + + /** + * Refuses to sign, and tells the group so the coordinator can pick somebody + * else rather than wait. + * + * A t-of-n group can sign without this member, so declining is a normal + * outcome and not a failure of the session — but only if it is said out loud. + * Silence is indistinguishable from a phone in a pocket. + */ + suspend fun decline( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + sessionId: String + ) { + val session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return + + if (!isAwaitingApproval(session)) return + + fail( + database = database, + session = session, + reason = "Declined on this device", + culprit = session.userPublicKey + ) + broadcast( + database = database, + localChatRoom = localChatRoom, + session = session, + kind = FrostSigningEvents.FAILURE, + content = "Declined to sign" + ) + } + + private suspend fun announceStarted(database: MantraDatabase, session: FrostSigningSession) = + announce( + database = database, + session = session, + messageType = ChatMessage.TYPE_FROST_STARTED, + content = "asked the group to sign something with its shared key. It takes " + + "${session.threshold} of ${session.participantCount} members to do it.", + actor = session.coordinatorPublicKey + ) + + /** + * Tells the group's chat that this session is waiting on the reader. + * + * Written once, guarded by [FrostSigningSession.approvalRequestedAt], because + * [advance] runs on every arriving message and would otherwise ask again on + * each one. + */ + private suspend fun announceApprovalNeeded( + database: MantraDatabase, + session: FrostSigningSession + ) { + if (current(database, session).approvalRequestedAt != null) return + + update(database, session) { it.copy(approvalRequestedAt = Clock.System.now()) } + + announce( + database = database, + session = session, + messageType = ChatMessage.TYPE_FROST_APPROVAL_NEEDED, + content = "Your approval is needed to sign with the group's shared key. Nothing " + + "has been published from this device yet.", + actor = session.userPublicKey + ) + } + + /** + * Puts one protocol message in the group's chat, as a line naming who sent it. + * + * Same reasoning as the ceremony's transcript: these are things a particular + * member's device did, and a signature made with the group's key is worth + * being able to watch. The wording describes what a step accomplishes rather + * than what it is called. + */ + private suspend fun announceStep( + database: MantraDatabase, + session: FrostSigningSession, + kind: Kind, + actor: HexKey + ) { + val (messageType, content) = when (kind) { + FrostSigningEvents.NONCE -> ChatMessage.TYPE_FROST_NONCE to + "offered to help sign, sending the one-time value their signature needs." + + FrostSigningEvents.SIGNER_SET -> ChatMessage.TYPE_FROST_SIGNER_SET to + "chose who is signing and combined their one-time values." + + FrostSigningEvents.PARTIAL_SIGNATURE -> ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE to + "signed their part. On its own it proves nothing; combined with the " + + "others it is the group's signature." + + FrostSigningEvents.SIGNATURE -> ChatMessage.TYPE_FROST_SIGNATURE to + "combined the parts into the group's signature." + + // PROPOSAL and FAILURE are announced by the code that acts on them -- + // both say more than the message itself carries. + else -> return + } + + announce( + database = database, + session = session, + messageType = messageType, + content = content, + actor = actor + ) + } + + /** + * Puts a signing milestone in the group's chat. + * + * Each device writes its own row from the messages it has already received, so + * this costs no traffic and cannot disagree with the session it describes. + * Written once by construction rather than de-duplication: every caller checks + * before it writes, because [ChatMessage] has no key to make a second insert a + * no-op. + */ + private suspend fun announce( + database: MantraDatabase, + session: FrostSigningSession, + messageType: String, + content: String, + actor: HexKey + ) { + database.chatMessageDao().upsert( + ChatMessage( + senderPublicKey = actor, + isUserMessage = actor == session.userPublicKey, + giftWrapPayloadId = null, + marmotGroupEventId = null, + marmotInnerEventId = null, + chatRoomId = session.chatRoomId, + content = content, + messageType = messageType + ) + ) + } + + /** + * Broadcasts one of this device's own protocol messages AND records it + * locally. The local copy matters: the coordinator signs too, and its own + * message has to be in the aggregation alongside everyone else's. + * + * Queued before it is recorded, because [advance] republishes any message it + * has no local copy of. A crash between the two therefore costs a duplicate + * broadcast — which every receiver folds away — rather than a message the + * group waits on forever. + */ + private suspend fun publishOwn( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + session: FrostSigningSession, + kind: Kind, + content: String + ) { + broadcast(database, localChatRoom, session, kind, content) + + val known = ownMessage(database, session, kind) != null + + database.frostSigningSessionDao().upsert( + FrostSignerMessage( + sessionId = session.id, + signerPublicKey = session.userPublicKey, + kind = kind, + payload = content + ) + ) + + if (!known) announceStep(database, session, kind, session.userPublicKey) + } + + /** + * Queues a signing message as a gift-wrap payload. `NotaryViewModel` picks it + * up, seals a copy per participant and broadcasts — the same path chat + * messages take, which is why this needs no transport of its own. + */ + private suspend fun broadcast( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + session: FrostSigningSession, + kind: Kind, + content: String, + includeKey: Boolean = false, + signerIds: List? = null + ) { + val receiverTags = localChatRoom.localParticipants + .distinctBy { it.participant.participantPublicKey } + .filter { it.participant.participantPublicKey != session.userPublicKey } + .map { localParticipant -> + PTag.assemble( + localParticipant.participant.participantPublicKey, + localParticipant.participant.relayHint?.let { NormalizedRelayUrl(it) } + ) + } + + val tags = receiverTags.toTypedArray() + FrostSigningEvents.assembleTags( + sessionId = session.id, + dkgSessionId = if (includeKey) session.dkgSessionId else null, + signerIds = signerIds + ) + + val createdAt = Clock.System.now().epochSeconds + val giftWrapPayloadId = EventHasher.hashId( + pubKey = session.userPublicKey, + createdAt = createdAt, + tags = tags, + content = content, + kind = kind + ) + + database.giftWrapPayloadDao().upsert( + GiftWrapPayload( + id = giftWrapPayloadId, + kind = kind, + tags = tags, + createdAt = Instant.fromEpochSeconds(createdAt), + content = content, + chatRoomId = localChatRoom.chatRoom.id, + publicKey = session.userPublicKey + ) + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt new file mode 100644 index 00000000..29ea90ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt @@ -0,0 +1,99 @@ +package press.mantra.compose.nostr.frost + +import com.vitorpamplona.quartz.nip01Core.core.Kind +import press.mantra.compose.nostr.frost.tags.FrostKeyTag +import press.mantra.compose.nostr.frost.tags.FrostSessionIdTag +import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag + +/** + * The nostr kinds a FROST signing session is carried on. + * + * Like the ChillDKG kinds these are **rumor** kinds: they only ever exist + * inside a NIP-17 gift wrap addressed to the group, so no relay sees them + * unencrypted and the replaceable semantics normally implied by the 3xxxx range + * never apply. + * + * Who talks to whom, in order: + * + * ``` + * proposer --[ 30320 proposal ]-> everyone the unsigned event to sign + * signer --[ 30321 nonce ]-> everyone this device's public nonce + * proposer --[ 30322 signer set ]-> everyone who is signing, and their aggregated nonce + * signer --[ 30323 partial ]-> everyone this device's partial signature + * proposer --[ 30324 signature ]-> everyone the finished 64-byte signature + * anyone --[ 30325 failure ]-> everyone abandon + blame + * ``` + * + * ### Why 3032x and not 3031x + * + * The DKG kinds run 30310-30316 and the nip30303 document kinds run 30300 + * upwards; those two have already met at 30310 and 30311, and + * [press.mantra.compose.nostr.nip30303.SubmissionEvent] sits on 30312, which is + * also the DKG's round-1 kind. They are kept apart today only by travelling on + * different transports -- documents inside Marmot group events, rituals inside + * NIP-17 wraps -- which is luck rather than design. + * + * Signing runs on the same transport as the DKG and in the same rooms, so it + * starts at 30320 with a deliberate gap. Anything added to either family has + * room to grow without a second accident. + */ +object FrostSigningEvents { + /** + * Opens a session. Content is the unsigned nostr event, as JSON; the key to + * sign with is named in [FrostKeyTag]. + */ + val PROPOSAL: Kind = 30320 + + /** A signer's `IndividualNonce`, hex encoded. */ + val NONCE: Kind = 30321 + + /** + * The coordinator's chosen signers, in [FrostSignerIdsTag], with their + * `AggregatedNonce` as the content, hex encoded. + */ + val SIGNER_SET: Kind = 30322 + + /** A signer's 32-byte partial signature, hex encoded. */ + val PARTIAL_SIGNATURE: Kind = 30323 + + /** The finished 64-byte BIP-340 signature over the event id, hex encoded. */ + val SIGNATURE: Kind = 30324 + + /** Session abandoned. Content is the reason, for showing to the group. */ + val FAILURE: Kind = 30325 + + /** Every kind above, for filtering inbound payloads in one check. */ + val ALL: Set = setOf( + PROPOSAL, + NONCE, + SIGNER_SET, + PARTIAL_SIGNATURE, + SIGNATURE, + FAILURE + ) + + fun isFrostSigningKind(kind: Kind): Boolean = kind in ALL + + /** + * Tags for a signing message. The session id is on every kind so a message + * from an abandoned attempt can be dropped rather than mixed in. + */ + fun assembleTags( + sessionId: String, + dkgSessionId: String? = null, + signerIds: List? = null + ): Array> = buildList { + add(FrostSessionIdTag.assemble(sessionId)) + dkgSessionId?.let { add(FrostKeyTag.assemble(it)) } + signerIds?.let { add(FrostSignerIdsTag.assemble(it)) } + }.toTypedArray() + + fun parseSessionId(tags: Array>): String? = + tags.firstNotNullOfOrNull(FrostSessionIdTag::parse)?.sessionId + + fun parseKey(tags: Array>): String? = + tags.firstNotNullOfOrNull(FrostKeyTag::parse)?.dkgSessionId + + fun parseSignerIds(tags: Array>): List? = + tags.firstNotNullOfOrNull(FrostSignerIdsTag::parse)?.signerIds +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostKeyTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostKeyTag.kt new file mode 100644 index 00000000..4f777718 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostKeyTag.kt @@ -0,0 +1,36 @@ +package press.mantra.compose.nostr.frost.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Which key the group is being asked to sign with, named by the ceremony that + * created it. + * + * A group can hold more than one shared key -- a ceremony is re-runnable, and + * a member leaving is a reason to run another -- and a signer holds a + * different secret share under each. Signing with the share from the wrong + * ceremony produces a partial signature that cannot aggregate, so the session + * says which one from the start rather than leaving each device to guess at + * its most recent. + */ +class FrostKeyTag( + val dkgSessionId: String, +) { + fun toTagArray() = assemble(dkgSessionId = dkgSessionId) + + companion object { + const val TAG_NAME = "frost_key" + + fun parse(tag: Array): FrostKeyTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + return FrostKeyTag(dkgSessionId = tag[1]) + } + + fun assemble(dkgSessionId: String): Array = arrayOf(TAG_NAME, dkgSessionId) + + fun assemble(frostKeyTag: FrostKeyTag) = assemble(dkgSessionId = frostKeyTag.dkgSessionId) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSessionIdTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSessionIdTag.kt new file mode 100644 index 00000000..91222d44 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSessionIdTag.kt @@ -0,0 +1,36 @@ +package press.mantra.compose.nostr.frost.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Binds a signing message to one session. + * + * A group signs more than once with the same key, and an abandoned attempt at + * signing one event must never have its messages fed into a live attempt at + * another. That is stricter here than it is for a DKG: every signer's secret + * nonce is derived per session, so two sessions sharing an id would be two + * different messages signed under one nonce -- which is how a secret share + * leaks. + */ +class FrostSessionIdTag( + val sessionId: String, +) { + fun toTagArray() = assemble(sessionId = sessionId) + + companion object { + const val TAG_NAME = "frost_session" + + fun parse(tag: Array): FrostSessionIdTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + return FrostSessionIdTag(sessionId = tag[1]) + } + + fun assemble(sessionId: String): Array = arrayOf(TAG_NAME, sessionId) + + fun assemble(frostSessionIdTag: FrostSessionIdTag) = + assemble(sessionId = frostSessionIdTag.sessionId) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSignerIdsTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSignerIdsTag.kt new file mode 100644 index 00000000..06af36ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSignerIdsTag.kt @@ -0,0 +1,42 @@ +package press.mantra.compose.nostr.frost.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The participants the coordinator chose to sign with, as their FROST ids, in + * the order their nonces were aggregated. + * + * A t-of-n key does not need everyone, so somebody has to pick which t, and + * only the coordinator sees every nonce. Every signer then has to build the + * same session from the same set in the same order -- FROST binds the set into + * the challenge, so a device that disagrees about who is signing produces a + * partial signature that will not aggregate. + */ +class FrostSignerIdsTag( + val signerIds: List, +) { + fun toTagArray() = assemble(signerIds = signerIds) + + companion object { + const val TAG_NAME = "frost_signers" + + fun parse(tag: Array): FrostSignerIdsTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + // Order is meaningful, so a single unparseable entry invalidates the + // whole list rather than quietly shortening it. + val ids = tag[1].split(",").map { it.trim().toIntOrNull() ?: return null } + if (ids.isEmpty()) return null + + return FrostSignerIdsTag(signerIds = ids) + } + + fun assemble(signerIds: List): Array = + arrayOf(TAG_NAME, signerIds.joinToString(",")) + + fun assemble(frostSignerIdsTag: FrostSignerIdsTag) = + assemble(signerIds = frostSignerIdsTag.signerIds) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt new file mode 100644 index 00000000..0d4fc4c5 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt @@ -0,0 +1,278 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import fr.acinq.bitcoin.crypto.frost.TweakCache +import fr.acinq.secp256k1.Hex +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.frost.FrostSigningEvents + +/** + * The two rounds a signing session runs, against real FROST. + * + * `FrostSigningManager` spreads these steps across arriving messages, several + * devices and a database, none of which a unit test can stand up. What it can + * do is run the same calls in the same order with the same arguments and check + * that what comes out is a signature nostr will accept — which is the part + * that was written from reading the library rather than from a working example, + * and so the part most likely to be subtly wrong. + * + * A signature that verifies is the whole contract: if these calls are wired up + * incorrectly the aggregate simply fails to verify, silently, on every device. + */ +class FrostSigningRoundTest { + private val participants = 3 + private val threshold = 2 + + /** Stands in for a completed ceremony. A trusted dealer is fine here: the test is about signing. */ + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey) + + /** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */ + private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex() + + /** The 32 bytes actually signed — a nostr event id, exactly as the manager computes it. */ + private fun eventId(content: String): String = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = 1_700_000_000L, + kind = 1, + tags = arrayOf(), + content = content + ) + + /** + * One signer's half of the protocol, in the manager's order: regenerate the + * nonce from stored randomness, then sign once the set is known. + */ + private fun nonceOf(signerId: Int, message: ByteVector, random: String): Pair = + SecretNonce.generate( + sessionRandom = ByteVector32(random), + secretShare = keyMaterial.secretShares[signerId], + publicShare = keyMaterial.publicShares[signerId], + tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, + message = message, + extraInput = null + ) + + private fun sessionFor(signerIds: List, nonces: List, message: ByteVector): Session { + val aggregated = IndividualNonce.aggregate(nonces).right!! + + return Session.create( + aggregatedNonce = aggregated, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { keyMaterial.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = tweakCache, + message = message + ) + } + + @Test + fun `a threshold of signers produces a signature nostr accepts`() { + val id = eventId("the group agrees") + val message = ByteVector(id.hexToByteArray()) + + // Two of the three sign, which is the point of a 2-of-3 key. + val signerIds = listOf(0, 1) + val nonces = signerIds.map { nonceOf(it, message, "a".repeat(63) + "${it + 1}") } + + val session = sessionFor(signerIds, nonces.map { it.second }, message) + + val partials = signerIds.mapIndexed { position, signerId -> + session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!! + } + + val signature = session.aggregateSigs(partials).right!! + + assertTrue( + Nip01Crypto.verify( + signature = signature.toByteArray(), + hash = id.hexToByteArray(), + pubKey = groupPubKey.hexToByteArray() + ), + "the aggregated signature must verify against the group's x-only key" + ) + } + + @Test + fun `a different pair of signers signs the same event just as well`() { + val id = eventId("the group agrees") + val message = ByteVector(id.hexToByteArray()) + + // Whoever happens to be available. The coordinator picks; the signature + // that comes out must not depend on which t it picked. + val signerIds = listOf(1, 2) + val nonces = signerIds.map { nonceOf(it, message, "b".repeat(63) + "${it + 1}") } + + val session = sessionFor(signerIds, nonces.map { it.second }, message) + val partials = signerIds.mapIndexed { position, signerId -> + session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!! + } + + val signature = session.aggregateSigs(partials).right!! + + assertTrue( + Nip01Crypto.verify( + signature = signature.toByteArray(), + hash = id.hexToByteArray(), + pubKey = groupPubKey.hexToByteArray() + ) + ) + } + + @Test + fun `a signature over one event does not verify against another`() { + val id = eventId("the group agrees") + val message = ByteVector(id.hexToByteArray()) + + val signerIds = listOf(0, 1) + val nonces = signerIds.map { nonceOf(it, message, "c".repeat(63) + "${it + 1}") } + val session = sessionFor(signerIds, nonces.map { it.second }, message) + val partials = signerIds.mapIndexed { position, signerId -> + session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!! + } + val signature = session.aggregateSigs(partials).right!! + + assertFalse( + Nip01Crypto.verify( + signature = signature.toByteArray(), + hash = eventId("the group agrees to something else").hexToByteArray(), + pubKey = groupPubKey.hexToByteArray() + ), + "a signature is over one event id and must not carry to another" + ) + } + + @Test + fun `regenerating a nonce from the same seed and message gives the same nonce`() { + val id = eventId("the group agrees") + val message = ByteVector(id.hexToByteArray()) + val random = "d".repeat(63) + "1" + + // What makes a signing session restart-safe: SecretNonce cannot be stored, + // so the manager keeps its seed and derives again. If that were not + // reproducible a device that restarted mid-session would publish a partial + // signature against a nonce nobody aggregated. + val first = nonceOf(0, message, random).second + val second = nonceOf(0, message, random).second + + assertEquals(first.data.toHex(), second.data.toHex()) + } + + @Test + fun `the same seed under a different message gives a different nonce`() { + val random = "e".repeat(63) + "1" + + // The safety property behind reusing the seed at all: one session signs one + // message. Were the nonce independent of the message, a session that could + // be re-pointed at another event would sign twice under one nonce, which + // hands over the secret share. + val first = nonceOf(0, ByteVector(eventId("one thing").hexToByteArray()), random).second + val second = nonceOf(0, ByteVector(eventId("another thing").hexToByteArray()), random).second + + assertFalse(first.data.toHex() == second.data.toHex()) + } +} + +/** + * The pure bits of a signing session's bookkeeping: who is signing, and with + * which key. + */ +class FrostSigningSessionTest { + private fun session(signerId: Int, signerIds: String?) = FrostSigningSession( + id = "s".repeat(64), + chatRoomId = "room", + coordinatorPublicKey = "c".repeat(64), + userPublicKey = "u".repeat(64), + dkgSessionId = "k".repeat(64), + threshold = 2, + participantCount = 3, + signerId = signerId, + unsignedEventJson = "{}", + eventId = "e".repeat(64), + nonceRandom = "f".repeat(64), + signerIds = signerIds + ) + + @Test + fun `a member left out of the signer set is not a signer`() { + assertTrue(session(signerId = 1, signerIds = "0,1").isSigner()) + assertFalse(session(signerId = 2, signerIds = "0,1").isSigner()) + } + + @Test + fun `nobody is a signer until the coordinator has chosen`() { + assertFalse(session(signerId = 0, signerIds = null).isSigner()) + } + + @Test + fun `the signer set keeps the order it was aggregated in`() { + // FROST binds the set into the challenge, so this list is not a set of ids + // but a sequence positionally matched to the aggregated nonce. + assertEquals(listOf(2, 0, 1), session(signerId = 0, signerIds = "2,0,1").signerIdList()) + } + + @Test + fun `a signer set tag survives the trip through a tag array`() { + val tags = FrostSigningEvents.assembleTags( + sessionId = "session", + dkgSessionId = "ceremony", + signerIds = listOf(2, 0, 1) + ) + + assertEquals("session", FrostSigningEvents.parseSessionId(tags)) + assertEquals("ceremony", FrostSigningEvents.parseKey(tags)) + assertEquals(listOf(2, 0, 1), FrostSigningEvents.parseSignerIds(tags)) + } + + @Test + fun `a ceremony that recorded no public shares reads back null rather than empty`() { + // Ceremonies completed before the column existed. Signing falls back to not + // cross-checking shares, which the FROST API allows, rather than refusing. + val ceremony = DkgSession( + id = "k".repeat(64), + chatRoomId = "room", + coordinatorPublicKey = "c".repeat(64), + userPublicKey = "u".repeat(64), + threshold = 2, + participantCount = 3, + hostPublicKey = "h".repeat(66), + round1Random = "1".repeat(64), + round2AuxRandom = "2".repeat(64) + ) + + assertEquals(null, ceremony.publicShareList()) + assertEquals( + 2, + ceremony.copy( + publicShares = listOf( + Hex.encode(ByteArray(33) { 2 }), + Hex.encode(ByteArray(33) { 3 }) + ).joinToString(",") + ).publicShareList()?.size + ) + } +} From 63c1879acedfe21f3bc9f569a3b6c6bfd11960b1 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 21:55:10 +0200 Subject: [PATCH 10/13] refactor: carry signing on marmot inner events, not gift wraps A signing message is now an ordinary Marmot inner event: queued with a null marmotGroupEventId, picked up by the outbound pipeline, MLS-encrypted and broadcast as one kind:445 for the room. Inbound it arrives through ChatMessage.fromGroupEventResult like every other inner event, and is dispatched from NostrDao rather than from the gift-wrap branch. The ceremony keeps NIP-17 because it has no choice: its participants are not yet a Marmot group, and its purpose is to produce the key one would be keyed on. Signing has that solved for it, so it was paying for addressing it does not need -- a gift wrap is sealed once per recipient, so every message cost one wrap per member, and every message had to name the whole group in p-tags. A group event is encrypted to the group once. That also removes a small dishonesty. The signer set is supposed to come from the ceremony; carrying p-tags meant each message also asserted a membership list, and two sources for one fact is one too many. Now who can read a message is the MLS tree's business and who may sign is the ceremony's. Which room follows from the transport. A ceremony runs in a NIP-17 room -- every member an equal admin, no MLS tree to be outside of -- and a group event needs an MLS one, so signing cannot happen where the ceremony did. It happens in the #admins room, which is the right venue anyway: it already exists after a ceremony, its membership is exactly the share holders, and its id *is* the key, derived by SharedKeyDerivation.marmotGroupId. So completedKey rederives rather than reading a column: a room cannot be pointed at a key it was not derived from. Receivers were already independent of this, naming their key in the proposal's frost_key tag and looking it up locally. Mechanical consequences: - processSigningPayload, acceptProposal, record and isFromCoordinator take the decrypted Event instead of a GiftWrapPayload. - replayStoredMessages reads MarmotInnerEvent rows, via a new getByChatRoomAndKinds, and rebuilds the rumor from the row's own columns. - applyInnerEvent returns null for the signing kinds. They are the manager's, and it writes transcript lines naming who did what, so an "unsupported" row would be a second and worse account of the same thing. - DkgSessionDao gains getKeyHoldingSessions for the derivation match. The kind comment is rewritten rather than kept. 3032x was chosen to clear the DKG, which now shares no transport with signing and cannot clash with it; what it actually has to clear is the nip30303 document kinds, which run 30300-30312 and are dispatched by the same inbound path. It still does. The DKG's own overlap with those numbers is noted there as the routing accident it is, so nothing added later leans on it. No schema change: both tables and the columns landed in v6 with the previous commit. Co-Authored-By: Claude Opus 5 --- .../compose/database/dao/DkgSessionDao.kt | 10 + .../database/dao/MarmotInnerEventDao.kt | 10 + .../mantra/compose/database/dao/NostrDao.kt | 43 ++-- .../compose/database/model/ChatMessage.kt | 7 + .../compose/managers/FrostSigningManager.kt | 204 ++++++++++-------- .../compose/nostr/frost/FrostSigningEvents.kt | 31 +-- 6 files changed, 182 insertions(+), 123 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt index dd69c320..6640b078 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt @@ -27,6 +27,16 @@ interface DkgSessionDao { @Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1") suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? + /** + * Every ceremony this device came out of holding a share, newest first. + * + * Signing happens in the #admins room, whose id is derived from the key + * rather than from the room the ceremony ran in, so the key is found by + * matching that derivation rather than by a stored room id. + */ + @Query("SELECT * FROM DkgSession WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL ORDER BY createdAt DESC") + suspend fun getKeyHoldingSessions(): List + @Upsert suspend fun upsert(dkgSession: DkgSession) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt index 016e060b..37b43f32 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt @@ -15,6 +15,16 @@ interface MarmotInnerEventDao { @Upsert suspend fun upsert(marmotInnerEvent: MarmotInnerEvent) + /** + * A room's inner events of the given kinds, oldest first. + * + * Used to replay a protocol backlog: a session's messages are stored as they + * decrypt, but one that arrives before the proposal opening its session has + * nowhere to be filed at the time. + */ + @Query("SELECT * FROM MarmotInnerEvent WHERE chatRoomId = :chatRoomId AND kind IN (:kinds) ORDER BY createdAt ASC") + suspend fun getByChatRoomAndKinds(chatRoomId: String, kinds: List): List + @Query("DELETE FROM MarmotInnerEvent WHERE id = :id") suspend fun deleteById(id: String) 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 3beb8e67..18685171 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 @@ -45,6 +45,7 @@ import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.marmot.mls.tree.Credential +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -453,6 +454,25 @@ abstract class NostrDao( chatMessage ) } + + // A FROST signing message for this group. Driven from + // here rather than from ChatMessage because the + // manager needs the room to publish its own replies + // into, and because it writes its transcript lines + // itself. The manager is idempotent, so a redelivered + // message re-runs a step it has already taken. + if (groupEventResult is GroupEventResult.ApplicationMessage) { + Event.fromJsonOrNull(groupEventResult.innerEventJson) + ?.takeIf { FrostSigningEvents.isFrostSigningKind(it.kind) } + ?.let { innerEvent -> + FrostSigningManager.processSigningPayload( + database = database, + localChatRoom = localChatRoom, + innerEvent = innerEvent, + userPublicKey = activeKeyPair.pubKey.toHex() + ) + } + } } } else { throw MarmotNotMemberOfChatGroupException("We are not a member of the chat room ${localChatRoom.chatRoom.id}") @@ -962,29 +982,6 @@ abstract class NostrDao( nostrPrivateKey = activeKeyPair.privKey!! ) } - } else if (FrostSigningEvents.isFrostSigningKind(decryptedGiftWrapPayload.kind)) { - // A FROST signing session message for one of our - // NIP-17 groups. Same reasoning as the ritual above: - // the manager is idempotent, and the room is created - // on demand because membership is the payload's - // p-tags either way. - val localChatRoom = getOrCreateNip17ChatRoom( - decryptedGiftWrapPayload = decryptedGiftWrapPayload, - activeKeyPair = activeKeyPair, - nostrEventId = nostrEvent.id, - relayURL = relayURL - ) - - if (localChatRoom == null) { - logger.w("FROST payload for unknown chat room ${decryptedGiftWrapPayload.chatRoomId}") - } else { - FrostSigningManager.processSigningPayload( - database = database, - localChatRoom = localChatRoom, - giftWrapPayload = decryptedGiftWrapPayload, - userPublicKey = activeKeyPair.pubKey.toHex() - ) - } } else { logger.w("Unsupported event: $decryptedGiftWrapPayload") } 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 59fca17f..435a1833 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 @@ -17,6 +17,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent @@ -684,6 +685,12 @@ data class ChatMessage( ) } } + // A signing session's own protocol messages. FrostSigningManager + // applies them and writes its own transcript lines naming who did + // what, so an "unsupported" row here would be a second, worse + // account of the same thing. + in FrostSigningEvents.ALL -> null + else -> { ChatMessage( giftWrapPayloadId = null, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index b059106b..47355af6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -7,8 +7,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.utils.RandomInstance import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 @@ -28,7 +26,7 @@ import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSignerMessage import press.mantra.compose.database.model.FrostSigningSession -import press.mantra.compose.database.model.GiftWrapPayload +import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.database.model.types.FrostSigningStage @@ -37,15 +35,23 @@ import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.frost.FrostSigningEvents /** - * Signs a nostr event with a group's FROST threshold key, over a NIP-17 group. + * Signs a nostr event with a group's FROST threshold key, in its #admins room. * * The shape is [ChillDkgRitualManager]'s, deliberately: the member who proposes - * a signature coordinates it, every protocol message travels as a gift-wrapped - * rumor on the kinds in [FrostSigningEvents], each inbound message is persisted - * and then the session is asked whether it can move, and every step is - * recomputed from stored inputs so a device killed mid-round resumes on the - * next message. What that manager's own notes say about being message-driven - * applies here unchanged. + * a signature coordinates it, protocol messages travel on the kinds in + * [FrostSigningEvents], each inbound message is persisted and then the session + * is asked whether it can move, and every step is recomputed from stored inputs + * so a device killed mid-round resumes on the next message. What that manager's + * own notes say about being message-driven applies here unchanged. + * + * The transport is not the same one. A ceremony runs over NIP-17 because it has + * to: its participants are not yet a Marmot group, and its whole purpose is to + * produce the key one would be keyed on. Signing has the opposite problem + * solved for it -- the #admins room already exists, its membership is exactly + * the share holders, and its id is derived from the key -- so a signing message + * is an ordinary Marmot inner event and needs no addressing of its own. One + * encrypted group event reaches everyone, rather than one sealed wrap per + * member per message. * * Three things are genuinely different, and each of them is why this is a * separate manager rather than another branch of that one. @@ -148,43 +154,42 @@ object FrostSigningManager { suspend fun processSigningPayload( database: MantraDatabase, localChatRoom: LocalChatRoom, - giftWrapPayload: GiftWrapPayload, + innerEvent: Event, userPublicKey: HexKey ) { - val sessionId = FrostSigningEvents.parseSessionId(giftWrapPayload.tags) + val sessionId = FrostSigningEvents.parseSessionId(innerEvent.tags) if (sessionId == null) { - logger.w("FROST payload ${giftWrapPayload.id} has no session tag; dropping") + logger.w("FROST payload ${innerEvent.id} has no session tag; dropping") return } - val session = if (giftWrapPayload.kind == FrostSigningEvents.PROPOSAL) { + val session = if (innerEvent.kind == FrostSigningEvents.PROPOSAL) { acceptProposal( database = database, localChatRoom = localChatRoom, - giftWrapPayload = giftWrapPayload, + innerEvent = innerEvent, sessionId = sessionId, userPublicKey = userPublicKey ) } else { - // Not knowing the session is normal: gift wraps carry a randomised - // created_at and relays hand them back in no particular order, so a - // nonce routinely lands before the proposal that asks for it. The - // payload is already stored, and [acceptProposal] replays it once the - // proposal turns up. + // Not knowing the session is normal: a member catching up applies a + // group's backlog in whatever order the epochs decrypt, so a nonce can + // land before the proposal that asks for it. The inner event is already + // stored, and [acceptProposal] replays it once the proposal turns up. database.frostSigningSessionDao().getSessionById(sessionId) } if (session == null) { - logger.i("No signing session $sessionId for kind ${giftWrapPayload.kind}; leaving it stored") + logger.i("No signing session $sessionId for kind ${innerEvent.kind}; leaving it stored") return } if (session.stage == FrostSigningStage.FAILED) { - logger.i("Session $sessionId already failed; ignoring kind ${giftWrapPayload.kind}") + logger.i("Session $sessionId already failed; ignoring kind ${innerEvent.kind}") return } - if (!record(database, session, giftWrapPayload)) return + if (!record(database, session, innerEvent)) return advance(database, localChatRoom, session.id) } @@ -200,7 +205,7 @@ object FrostSigningManager { private suspend fun acceptProposal( database: MantraDatabase, localChatRoom: LocalChatRoom, - giftWrapPayload: GiftWrapPayload, + innerEvent: Event, sessionId: String, userPublicKey: HexKey ): FrostSigningSession? { @@ -209,7 +214,7 @@ object FrostSigningManager { // under the same id carrying a different event is either a mistake or // an attempt to get two signatures out of one secret nonce, which is // how a share is extracted -- so it is refused, not applied. - val proposed = Event.fromJsonOrNull(giftWrapPayload.content) + val proposed = Event.fromJsonOrNull(innerEvent.content) if (proposed != null && proposed.id != existing.eventId) { logger.w( "Session $sessionId re-proposed with event ${proposed.id}, " + @@ -219,7 +224,7 @@ object FrostSigningManager { return existing } - val dkgSessionId = FrostSigningEvents.parseKey(giftWrapPayload.tags) + val dkgSessionId = FrostSigningEvents.parseKey(innerEvent.tags) if (dkgSessionId == null) { logger.w("Signing proposal $sessionId names no key; dropping") return null @@ -237,7 +242,7 @@ object FrostSigningManager { return null } - val proposed = Event.fromJsonOrNull(giftWrapPayload.content) + val proposed = Event.fromJsonOrNull(innerEvent.content) if (proposed == null) { logger.w("Signing proposal $sessionId does not carry an event; dropping") return null @@ -268,7 +273,7 @@ object FrostSigningManager { // Whoever proposes coordinates. Aggregating nonces and partial // signatures gives no power over the outcome -- a wrong aggregate // produces a signature that does not verify, not a forged one. - coordinatorPublicKey = giftWrapPayload.publicKey, + coordinatorPublicKey = innerEvent.pubKey, userPublicKey = userPublicKey, dkgSessionId = key.id, threshold = key.threshold, @@ -300,20 +305,30 @@ object FrostSigningManager { database: MantraDatabase, session: FrostSigningSession ) { - val stored = database.giftWrapPayloadDao().getByChatRoomAndKinds( + val stored = database.marmotInnerEventDao().getByChatRoomAndKinds( chatRoomId = session.chatRoomId, kinds = FrostSigningEvents.ALL.toList() - ).filter { payload -> - payload.kind != FrostSigningEvents.PROPOSAL && - FrostSigningEvents.parseSessionId(payload.tags) == session.id + ).filter { stored -> + stored.kind != FrostSigningEvents.PROPOSAL && + FrostSigningEvents.parseSessionId(stored.tags) == session.id + }.map { stored -> + Event( + id = stored.id, + pubKey = stored.publicKey, + createdAt = stored.createdAt.epochSeconds, + kind = stored.kind, + tags = stored.tags, + content = stored.content, + sig = "" + ) } if (stored.isEmpty()) return logger.i("Replaying ${stored.size} stored message(s) for signing session ${session.id}") - stored.forEach { payload -> - if (!record(database, session, payload)) return + stored.forEach { innerEvent -> + if (!record(database, session, innerEvent)) return } } @@ -324,34 +339,34 @@ object FrostSigningManager { private suspend fun record( database: MantraDatabase, session: FrostSigningSession, - giftWrapPayload: GiftWrapPayload + innerEvent: Event ): Boolean { - when (giftWrapPayload.kind) { + when (innerEvent.kind) { FrostSigningEvents.PROPOSAL -> Unit // handled by acceptProposal FrostSigningEvents.NONCE, FrostSigningEvents.PARTIAL_SIGNATURE -> { val known = database.frostSigningSessionDao() - .getMessage(session.id, giftWrapPayload.kind, giftWrapPayload.publicKey) != null + .getMessage(session.id, innerEvent.kind, innerEvent.pubKey) != null database.frostSigningSessionDao().upsert( FrostSignerMessage( sessionId = session.id, - signerPublicKey = giftWrapPayload.publicKey, - kind = giftWrapPayload.kind, - payload = giftWrapPayload.content + signerPublicKey = innerEvent.pubKey, + kind = innerEvent.kind, + payload = innerEvent.content ) ) if (!known) { - announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } FrostSigningEvents.SIGNER_SET -> { - if (!isFromCoordinator(session, giftWrapPayload)) return true + if (!isFromCoordinator(session, innerEvent)) return true - val signerIds = FrostSigningEvents.parseSignerIds(giftWrapPayload.tags) + val signerIds = FrostSigningEvents.parseSignerIds(innerEvent.tags) if (signerIds == null) { logger.w("Session ${session.id}: signer set carries no ids; ignoring") return true @@ -368,7 +383,7 @@ object FrostSigningManager { update(database, session) { current -> if (current.aggregatedNonce == null) { current.copy( - aggregatedNonce = giftWrapPayload.content, + aggregatedNonce = innerEvent.content, signerIds = signerIds.joinToString(",") ) } else { @@ -377,25 +392,25 @@ object FrostSigningManager { } if (!known) { - announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } FrostSigningEvents.SIGNATURE -> { - if (!isFromCoordinator(session, giftWrapPayload)) return true + if (!isFromCoordinator(session, innerEvent)) return true val known = current(database, session).signature != null update(database, session) { current -> if (current.signature == null) { - current.copy(signature = giftWrapPayload.content) + current.copy(signature = innerEvent.content) } else { current } } if (!known) { - announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } @@ -403,8 +418,8 @@ object FrostSigningManager { fail( database = database, session = session, - reason = "Abandoned by ${giftWrapPayload.publicKey.take(8)}: ${giftWrapPayload.content}", - culprit = giftWrapPayload.publicKey + reason = "Abandoned by ${innerEvent.pubKey.take(8)}: ${innerEvent.content}", + culprit = innerEvent.pubKey ) return false } @@ -415,13 +430,13 @@ object FrostSigningManager { private fun isFromCoordinator( session: FrostSigningSession, - giftWrapPayload: GiftWrapPayload + innerEvent: Event ): Boolean { - if (giftWrapPayload.publicKey == session.coordinatorPublicKey) return true + if (innerEvent.pubKey == session.coordinatorPublicKey) return true logger.w( - "Session ${session.id}: kind ${giftWrapPayload.kind} from " + - "${giftWrapPayload.publicKey.take(8)}, who is not the coordinator; ignoring" + "Session ${session.id}: kind ${innerEvent.kind} from " + + "${innerEvent.pubKey.take(8)}, who is not the coordinator; ignoring" ) return false } @@ -724,15 +739,34 @@ object FrostSigningManager { ): Int? = signerIds(database, key)[member] /** - * The group's usable key, or null when it has none. + * The key a room signs with, or null when it has none. * - * A group can have run more than one ceremony; the newest completed one is - * the live key, matching what the shared-key screen shows. + * Signing runs in the #admins room, which is not where the ceremony ran. A + * ceremony needs a NIP-17 group -- every member an equal admin, no MLS tree + * to be outside of -- while a group event needs an MLS one, so the two + * cannot be the same room. + * + * They are still bound together, and by construction rather than by a + * column: the #admins room's id *is* the key, derived from it by + * [SharedKeyDerivation.marmotGroupId]. Rederiving is what finds the key + * here, which means a room cannot be pointed at a key it was not derived + * from. + * + * Falls back to a ceremony held in this very room, which is not how the app + * wires things today but costs one lookup to keep honest. */ - suspend fun completedKey(database: MantraDatabase, chatRoomId: String): DkgSession? = - database.dkgSessionDao() + suspend fun completedKey(database: MantraDatabase, chatRoomId: String): DkgSession? { + database.dkgSessionDao().getKeyHoldingSessions().firstOrNull { session -> + session.stage == DkgRitualStage.COMPLETE && + session.thresholdPublicKey?.let { + SharedKeyDerivation.marmotGroupId(it) == chatRoomId + } == true + }?.let { return it } + + return database.dkgSessionDao() .getLatestSessionForChatRoom(chatRoomId) ?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null } + } /** Whether this group can sign at all, read by the UI so it offers nothing that would fail. */ suspend fun canSign(database: MantraDatabase, chatRoomId: String): Boolean = @@ -1082,9 +1116,16 @@ object FrostSigningManager { } /** - * Queues a signing message as a gift-wrap payload. `NotaryViewModel` picks it - * up, seals a copy per participant and broadcasts — the same path chat - * messages take, which is why this needs no transport of its own. + * Queues a signing message as an unprocessed marmot inner event. + * `NotaryViewModel` picks it up, MLS-encrypts it and broadcasts it as a + * kind:445 for the room — the same path every other event in a Marmot group + * takes, which is why this needs no transport of its own. + * + * No p-tags. A gift wrap is addressed and sealed once per recipient, so the + * ceremony has to name everybody on every message; a group event is + * encrypted to the group, and who is in it is the MLS tree's business rather + * than the message's. That also means the signer set genuinely comes from + * the ceremony rather than from whoever happened to be tagged. */ private suspend fun broadcast( database: MantraDatabase, @@ -1095,40 +1136,31 @@ object FrostSigningManager { includeKey: Boolean = false, signerIds: List? = null ) { - val receiverTags = localChatRoom.localParticipants - .distinctBy { it.participant.participantPublicKey } - .filter { it.participant.participantPublicKey != session.userPublicKey } - .map { localParticipant -> - PTag.assemble( - localParticipant.participant.participantPublicKey, - localParticipant.participant.relayHint?.let { NormalizedRelayUrl(it) } - ) - } - - val tags = receiverTags.toTypedArray() + FrostSigningEvents.assembleTags( + val tags = FrostSigningEvents.assembleTags( sessionId = session.id, dkgSessionId = if (includeKey) session.dkgSessionId else null, signerIds = signerIds ) val createdAt = Clock.System.now().epochSeconds - val giftWrapPayloadId = EventHasher.hashId( - pubKey = session.userPublicKey, - createdAt = createdAt, - tags = tags, - content = content, - kind = kind - ) - database.giftWrapPayloadDao().upsert( - GiftWrapPayload( - id = giftWrapPayloadId, + database.marmotInnerEventDao().upsert( + MarmotInnerEvent( + // The rumor id the outbound pipeline will recompute from these + // same fields when it assembles the event to encrypt. + id = EventHasher.hashId( + pubKey = session.userPublicKey, + createdAt = createdAt, + tags = tags, + content = content, + kind = kind + ), + publicKey = session.userPublicKey, kind = kind, - tags = tags, createdAt = Instant.fromEpochSeconds(createdAt), + tags = tags, content = content, - chatRoomId = localChatRoom.chatRoom.id, - publicKey = session.userPublicKey + chatRoomId = localChatRoom.chatRoom.id ) ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt index 29ea90ee..c61a5a71 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt @@ -8,10 +8,10 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag /** * The nostr kinds a FROST signing session is carried on. * - * Like the ChillDKG kinds these are **rumor** kinds: they only ever exist - * inside a NIP-17 gift wrap addressed to the group, so no relay sees them - * unencrypted and the replaceable semantics normally implied by the 3xxxx range - * never apply. + * These are **rumor** kinds: they only ever exist inside a Marmot group event, + * MLS-encrypted to the group and then wrapped again under the group's exporter + * secret, so no relay sees them and the replaceable semantics normally implied + * by the 3xxxx range never apply. * * Who talks to whom, in order: * @@ -24,18 +24,21 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag * anyone --[ 30325 failure ]-> everyone abandon + blame * ``` * - * ### Why 3032x and not 3031x + * ### Why 3032x * - * The DKG kinds run 30310-30316 and the nip30303 document kinds run 30300 - * upwards; those two have already met at 30310 and 30311, and - * [press.mantra.compose.nostr.nip30303.SubmissionEvent] sits on 30312, which is - * also the DKG's round-1 kind. They are kept apart today only by travelling on - * different transports -- documents inside Marmot group events, rituals inside - * NIP-17 wraps -- which is luck rather than design. + * These share the inner-event space with the nip30303 document kinds, which run + * 30300 up to [press.mantra.compose.nostr.nip30303.SubmissionEvent] at 30312 -- + * the same space, because both are Marmot inner events and both are dispatched + * on kind by the same inbound path. Starting at 30320 leaves that family room to + * grow into. * - * Signing runs on the same transport as the DKG and in the same rooms, so it - * starts at 30320 with a deliberate gap. Anything added to either family has - * room to grow without a second accident. + * The DKG's 30310-30316 look like a clash and are not: those exist only inside + * NIP-17 gift wraps, and nothing reads a kind across both transports. It is + * worth knowing that the numbers already overlap there -- the DKG's proposal and + * host-key kinds sit on 30310 and 30311 alongside two nip30303 kinds, and its + * round-1 kind is 30312, alongside SubmissionEvent -- because that separation is + * an accident of routing rather than a decision, and the next family added + * should not rely on it. */ object FrostSigningEvents { /** From 3e4166f13df207e2a459aaea224b9c56ba00e95a Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 22:09:47 +0200 Subject: [PATCH 11/13] feat: sign a dialect into existence instead of submitting one Adding a dialect no longer creates one. It opens a signing session over a DialectEvent, and the dialect appears -- on every member's device at once, authored by the group's shared key rather than by whoever typed it -- when enough members have signed. That is the difference between the two envelopes. A submission says "I am putting this in front of the group"; the group's only recourse afterwards is social, and the row records the submitter as its author. A signature is the group saying it, it takes a quorum to say, and the author on the row is the group's key. For something as load-bearing as the set of dialects a group translates into, the second is the honest one. **Where the signed event becomes a row.** Every device has the event and the signature once the session completes, so each applies the result itself rather than waiting to be sent something it can already build -- the same reasoning the transcript lines are written on. Nothing goes on the wire for it, and nothing could: the outbound pipeline re-authors rumors as their sender, so a group-signed event pushed through it would come out stripped of the signature and attributed to whoever sent it. Applying reuses the inbound path's dispatch rather than repeating it. applyInnerEvent takes plain ids now instead of a GroupEvent, and both are null here, because there is no group event and no inner event behind a row a device derived for itself. A failure there is logged and the session still completes: the signature is made and valid, and failing the session would tell the group to abandon something that succeeded. **The screen.** One, not three. A ceremony asks three different questions so it gets three approval screens; signing asks one -- sign this or do not -- so a single screen has to carry the whole case: what is being signed, who else has agreed, and what the group is still waiting on. The event is shown as the thing it is, a dialect with its name and country and language, because a member deciding whether to sign is deciding about a dialect and "kind 30304" answers a question nobody asked. Anything unrecognised falls back to the raw kind, which is better than describing it wrongly. The member ladder names people rather than counting them, for the same reason the ceremony's does: "1 of 2" does not tell anyone whose door to knock on. It stays useful after the decision, since a member who has already signed is exactly who needs to see who has not. **Getting there.** Signing lines render in the transcript as system notices like ritual lines -- nobody said them either -- but they lead to the session rather than to the key. A chat row carries no session id and adding a column to the table every message uses would be a poor trade for a lookup, so FrostSigningRoute takes a nullable id and the screen resolves the room's live session. Approving is recorded as answered by the nonce line rather than the partial signature: agreeing is agreeing to take part, and the coordinator may then pick a quorum without you, which should not leave you looking like you never replied. **Proposing needs a key.** The FAB is disabled, and says why, when the room has none -- proposeSigning throws there, and it is not reachable outside the #admins room in the first place. AddDialectViewModel drops MantraRepository, which it no longer uses for anything. Co-Authored-By: Claude Opus 5 --- .../database/dao/FrostSigningSessionDao.kt | 3 + .../compose/database/model/ChatMessage.kt | 76 ++-- .../DatabaseFrostSigningRepository.kt | 101 +++++ .../compose/managers/FrostSigningManager.kt | 43 ++ .../repository/FrostSigningRepository.kt | 94 +++++ .../compose/ui/composable/AddDialectScreen.kt | 63 ++- .../ui/composable/ChatRoomMessagingScreen.kt | 11 + .../ui/composable/FrostSigningScreen.kt | 373 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 38 +- .../navigation/routes/FrostSigningRoute.kt | 27 ++ .../ui/view/model/AddDialectViewModel.kt | 54 ++- .../ui/view/model/ChatMessageListViewModel.kt | 46 ++- .../ui/view/model/FrostSigningViewModel.kt | 156 ++++++++ .../ui/view/state/AddDialectUIState.kt | 7 + .../ui/view/state/FrostSigningUIState.kt | 26 ++ 15 files changed, 1048 insertions(+), 70 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/FrostSigningRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt index 78d04c63..0b68a2b2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt @@ -27,6 +27,9 @@ interface FrostSigningSessionDao { @Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1") suspend fun getLatestSessionForChatRoom(chatRoomId: String): FrostSigningSession? + @Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC") + suspend fun getSessionsForChatRoom(chatRoomId: String): List + @Upsert suspend fun upsert(frostSigningSession: FrostSigningSession) 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 435a1833..feba4e8c 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 @@ -198,6 +198,20 @@ data class ChatMessage( /** Addressed to the reader rather than said by anyone -- see [DKG_REQUEST_TYPES]. */ const val TYPE_FROST_APPROVAL_NEEDED = "frostApprovalNeeded" + /** + * Answering the request publishes this device's nonce, not its signature: + * approving is agreeing to take part, and the coordinator may then pick a + * quorum that does not include this member. Keying the answer on the + * partial signature would leave a member who agreed, and was not needed, + * looking like they never replied. + */ + val FROST_REQUEST_FULFILMENTS = mapOf( + TYPE_FROST_APPROVAL_NEEDED to TYPE_FROST_NONCE, + ) + + /** The signing lines that ask rather than report. */ + val FROST_REQUEST_TYPES = setOf(TYPE_FROST_APPROVAL_NEEDED) + /** Every signing line, for rendering them as system lines rather than bubbles. */ val FROST_TYPES = setOf( TYPE_FROST_STARTED, @@ -297,12 +311,12 @@ data class ChatMessage( } else { applyInnerEvent( database = database, - activeKeyPair = activeKeyPair, - groupEvent = groupEvent, groupId = groupEventResult.groupId, event = payload ?: event, + marmotGroupEventId = groupEvent.id, marmotInnerEventId = event.id, senderPublicKey = event.pubKey, + isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, createdAt = Instant.fromEpochSeconds(event.createdAt), ) } @@ -401,14 +415,14 @@ data class ChatMessage( * from [event], so the chat line says who added it and the row says who * wrote it. */ - private suspend fun applyInnerEvent( + internal suspend fun applyInnerEvent( database: MantraDatabase, - activeKeyPair: KeyPair, - groupEvent: GroupEvent, groupId: String, event: Event, - marmotInnerEventId: HexKey, + marmotGroupEventId: HexKey?, + marmotInnerEventId: HexKey?, senderPublicKey: HexKey, + isUserMessage: Boolean, createdAt: Instant, ): ChatMessage? { return when (event.kind) { @@ -416,10 +430,10 @@ data class ChatMessage( ChatMessage( giftWrapPayloadId = null, messageType = "message", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = event.content, // TODO: Figure out what to do here... @@ -439,17 +453,17 @@ data class ChatMessage( )?.let { mantraArtifact -> database.mantraArtifactDao().upsert( mantraArtifact.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "artifact", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraArtifact.name} to artifacts" @@ -470,17 +484,17 @@ data class ChatMessage( )?.let { mantraArtifactVersion -> database.mantraArtifactVersionDao().upsert( mantraArtifactVersion.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "artifactVersion", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name... @@ -501,17 +515,17 @@ data class ChatMessage( )?.let { mantraChapter -> database.mantraChapterDao().upsert( mantraChapter.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "chapter", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name... @@ -533,7 +547,7 @@ data class ChatMessage( )?.let { mantraChunk -> database.mantraChunkDao().upsert( mantraChunk.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) @@ -555,7 +569,7 @@ data class ChatMessage( )?.let { mantraDialect -> database.mantraDialectDao().upsert( mantraDialect.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) @@ -563,10 +577,10 @@ data class ChatMessage( ChatMessage( giftWrapPayloadId = null, messageType = "dialect", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name... @@ -587,17 +601,17 @@ data class ChatMessage( )?.let { mantraTranslationArtifactVersion -> database.mantraTranslationArtifactVersionDao().upsert( mantraTranslationArtifactVersion.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "translationArtifactVersion", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name... @@ -622,7 +636,7 @@ data class ChatMessage( )?.let { mantraTranslationChapter -> database.mantraTranslationChapterDao().upsert( mantraTranslationChapter.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) // TODO: translation chapter might be too noisy for chat updates @@ -643,7 +657,7 @@ data class ChatMessage( )?.let { mantraTranslationChunk -> database.mantraTranslationChunkDao().upsert( mantraTranslationChunk.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) @@ -669,16 +683,16 @@ data class ChatMessage( )?.let { mantraTranslation -> database.mantraTranslationDao().upsert( mantraTranslation.copy( - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, ) ) ChatMessage( giftWrapPayloadId = null, messageType = "translation", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text @@ -695,10 +709,10 @@ data class ChatMessage( ChatMessage( giftWrapPayloadId = null, messageType = "unsupported", - marmotGroupEventId = groupEvent.id, + marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, content = event.toJson(), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt new file mode 100644 index 00000000..abd6ed08 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt @@ -0,0 +1,101 @@ +package press.mantra.compose.database.repository + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.FrostSigningStage +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.repository.FrostSigningRepository + +class DatabaseFrostSigningRepository( + private val database: MantraDatabase, + private val scope: CoroutineScope +): FrostSigningRepository { + private val logger = Logger.withTag(TAG) + + override fun observeSessionById(sessionId: String): Flow = + database.frostSigningSessionDao().observeSessionById(sessionId) + + override fun observeSessionsForChatRoom(chatRoomId: String): Flow> = + database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId) + + override fun observeMessages(sessionId: String): Flow> = + database.frostSigningSessionDao().observeMessages(sessionId) + + override suspend fun getSessionById(sessionId: String): FrostSigningSession? = + database.frostSigningSessionDao().getSessionById(sessionId) + + override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? { + val sessions = database.frostSigningSessionDao().getSessionsForChatRoom(chatRoomId) + + return sessions.firstOrNull { + it.stage != FrostSigningStage.COMPLETE && it.stage != FrostSigningStage.FAILED + } ?: sessions.firstOrNull() + } + + override suspend fun canSign(chatRoomId: String): Boolean = + FrostSigningManager.canSign(database, chatRoomId) + + override suspend fun proposeSigning( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + kind: Kind, + tags: Array>, + content: String + ): FrostSigningSession? = try { + FrostSigningManager.proposeSigning( + database = database, + localChatRoom = localChatRoom, + userPublicKey = userPublicKey, + kind = kind, + tags = tags, + content = content + ) + } catch (e: Throwable) { + // Proposing throws when the group has no key or this device was not in the + // ceremony. Both are states the UI is supposed to have checked for, so this + // is a null the caller reports rather than a crash. + logger.e("Error proposing a signature in ${localChatRoom.chatRoom.id}", e) + null + } + + override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) { + try { + FrostSigningManager.approve( + database = database, + localChatRoom = localChatRoom, + sessionId = sessionId + ) + } catch (e: Throwable) { + // The session fails itself and tells the group; swallowing here keeps a + // protocol fault from taking the screen down with it. + logger.e("Error approving signing session $sessionId", e) + } + } + + override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) { + try { + FrostSigningManager.decline( + database = database, + localChatRoom = localChatRoom, + sessionId = sessionId + ) + } catch (e: Throwable) { + logger.e("Error declining signing session $sessionId", e) + } + } + + override fun signedEvent(session: FrostSigningSession): Event? = + FrostSigningManager.signedEvent(session) + + companion object { + private const val TAG = "DatabaseFrostSigningRepository" + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 47355af6..2bca8145 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -596,6 +596,16 @@ object FrostSigningManager { } update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) } + + // A signature exists to be used. Every device has the event and the + // signature by now, so each applies the result itself rather than + // waiting to be sent something it can already build -- the same + // reasoning the transcript lines are written on. Nothing goes on the + // wire: a signed event authored by the threshold key cannot travel as + // an inner event anyway, because the outbound pipeline re-authors + // rumors as their sender and would strip the group's signature off. + applySignedEvent(database, session, signedEvent) + announce( database = database, session = session, @@ -622,6 +632,39 @@ object FrostSigningManager { } } + /** + * Turns the signed event into whatever it is: a dialect, an artifact, a + * chapter. + * + * Reuses the inbound path's dispatch rather than repeating it, with no group + * event and no inner event behind the row -- there is neither, and both + * columns are nullable for exactly this kind of locally-derived record. + * + * A failure here is not the session's: the signature is made and valid, and + * saying otherwise would tell the group to abandon a ceremony that + * succeeded. It is logged and the session still completes. + */ + private suspend fun applySignedEvent( + database: MantraDatabase, + session: FrostSigningSession, + signedEvent: Event + ) { + try { + ChatMessage.applyInnerEvent( + database = database, + groupId = session.chatRoomId, + event = signedEvent, + marmotGroupEventId = null, + marmotInnerEventId = null, + senderPublicKey = session.coordinatorPublicKey, + isUserMessage = session.isCoordinator(), + createdAt = Clock.System.now() + )?.let { database.chatMessageDao().upsert(it) } + } catch (e: Throwable) { + logger.e("Signed ${signedEvent.id} but could not apply it locally", e) + } + } + /** * The event this session produces, with the signature on it. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt new file mode 100644 index 00000000..f3412414 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt @@ -0,0 +1,94 @@ +package press.mantra.compose.repository + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +/** + * Reads, opens and answers FROST signing sessions. + * + * Advancing one is the inbound path's job, with one exception: a session waiting + * on this device's owner does not advance until [approve] is called, because it + * will not sign on their behalf until they say so. + */ +interface FrostSigningRepository { + fun observeSessionById(sessionId: String): Flow + + /** Every session the room has run, newest first. Unlike a ceremony, signing recurs. */ + fun observeSessionsForChatRoom(chatRoomId: String): Flow> + + fun observeMessages(sessionId: String): Flow> + + suspend fun getSessionById(sessionId: String): FrostSigningSession? + + /** + * The session a room is currently running, or its most recent one if none is. + * + * For callers that mean "the signing going on here" without holding an id -- + * a transcript line, mostly. Prefers a live session because that is the one + * anybody tapping through wants to act on; a finished one is only what is + * left to show when there is nothing live. + */ + suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? + + /** Whether this room holds a key it can sign with, so the UI offers nothing that would fail. */ + suspend fun canSign(chatRoomId: String): Boolean + + /** + * Opens a session asking the group to sign an event with these fields. The + * author is the group's key, not the proposer's, and is filled in here. + */ + suspend fun proposeSigning( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + kind: Kind, + tags: Array>, + content: String + ): FrostSigningSession? + + /** Agrees to sign, letting the session publish this device's part and run on. */ + suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) + + /** Refuses, and says so, since a t-of-n group can proceed without this member. */ + suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) + + /** The finished event, or null while the session is still running. */ + fun signedEvent(session: FrostSigningSession): Event? + + companion object { + val NO_OP_FROST_SIGNING_REPOSITORY: FrostSigningRepository = object : FrostSigningRepository { + override fun observeSessionById(sessionId: String): Flow = flowOf(null) + + override fun observeSessionsForChatRoom(chatRoomId: String): Flow> = + flowOf(emptyList()) + + override fun observeMessages(sessionId: String): Flow> = + flowOf(emptyList()) + + override suspend fun getSessionById(sessionId: String): FrostSigningSession? = null + + override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? = null + + override suspend fun canSign(chatRoomId: String): Boolean = false + + override suspend fun proposeSigning( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + kind: Kind, + tags: Array>, + content: String + ): FrostSigningSession? = null + + override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) = Unit + + override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit + + override fun signedEvent(session: FrostSigningSession): Event? = null + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt index b02663ca..40b8cf69 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt @@ -16,9 +16,11 @@ import androidx.compose.material.icons.filled.Title import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -27,11 +29,14 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -40,9 +45,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.NostrRepository -import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator @@ -59,7 +64,7 @@ fun AddDialectScreen( initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading, nostrRepository: NostrRepository, chatRepository: ChatRepository, - mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, ) { @@ -71,7 +76,7 @@ fun AddDialectScreen( nostrRepository = nostrRepository, chatRepository = chatRepository, activeUserPublicKey = activeUserPublicKey, - mantraRepository = mantraRepository + frostSigningRepository = frostSigningRepository ) ) @@ -94,6 +99,10 @@ fun AddDialectScreen( val countryFieldState = rememberTextFieldState() val languageFieldState = rememberTextFieldState() + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + Scaffold( topBar = { TopAppBar( @@ -110,20 +119,41 @@ fun AddDialectScreen( actions = {}, floatingActionButton = { ExtendedFloatingActionButton( + modifier = if (addDialectUIState.canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (addDialectUIState.canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (addDialectUIState.canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, onClick = { + if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton + addDialectViewModel.addDialect( localChatRoom = addDialectUIState.localChatRoom, nameField = nameFieldState, countryField = countryFieldState, languageField = languageFieldState, - onSuccess = { - // Back to the group, reloaded so the new - // dialect shows up in the list. + onSuccess = { sessionId -> + // Onto the session rather than back to + // the group. Nothing has been created + // yet -- the dialect appears when enough + // members sign -- so landing on the list + // it is not in would read as a failure. onNavigateToRouteAndPopUpInclusive.invoke( - ChatRoomDetailRoute( + FrostSigningRoute( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, - relayHint = relayHint + sessionId = sessionId ) ) }, @@ -137,9 +167,9 @@ fun AddDialectScreen( ) { Icon( Icons.Default.Add, - contentDescription = "Add dialect" + contentDescription = "Propose dialect" ) - Text("Add Dialect") + Text("Propose Dialect") } } ) @@ -155,6 +185,15 @@ fun AddDialectScreen( ) { Text("Add a dialect the group can translate into") + if (!addDialectUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "dialect into existence. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + OutlinedTextField( modifier = Modifier.fillMaxWidth() .background(BottomAppBarDefaults.containerColor), @@ -300,7 +339,7 @@ private fun AddDialectScreenPreview() { ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, onNavigateToRouteAndPopUpInclusive = {}, onNavigateToRoute = {} ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index 3cd87d96..c77fa7ef 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -43,6 +43,7 @@ import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme @@ -141,6 +142,16 @@ fun ChatRoomMessagingScreen( chatRoomId = chatRoomId ) ) + }, + onOpenSigning = { + // No session id: a chat row carries none, and the + // screen resolves the room's live one. + onNavigateToRoute.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + ) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt new file mode 100644 index 00000000..38282f75 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -0,0 +1,373 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.FrostSigningStage +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar +import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.view.model.FrostSigningViewModel +import press.mantra.compose.ui.view.state.FrostSigningUIState + +/** + * One signing session, and the member's decision about it. + * + * A ceremony gets three approval screens because it asks three different + * questions. Signing asks one — sign this or do not — so there is one screen, + * and it has to carry the whole case for the answer: what is being signed, who + * else has agreed, and what the group is still waiting on. + * + * It stays useful after the decision. A session cannot finish until enough + * members take part, so a member who has already signed still needs to see + * whose door to knock on, and the ladder is the only place that says. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FrostSigningScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + sessionId: String?, + initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateBack: () -> Unit, +) { + val frostSigningViewModel: FrostSigningViewModel = viewModel( + factory = FrostSigningViewModel.factory( + chatRoomId = chatRoomId, + sessionId = sessionId, + activeUserPublicKey = activeUserPublicKey, + initialFrostSigningUIState = initialFrostSigningUIState, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + // Nothing loads the room or starts watching the session until this runs. + LaunchedEffect(true) { + if (initialFrostSigningUIState == FrostSigningUIState.Loading) { + frostSigningViewModel.initiate() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Sign with the group's key", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + when (val state = frostSigningViewModel.frostSigningUIState) { + is FrostSigningUIState.Loading -> Loading(padding) + + is FrostSigningUIState.Error -> Column( + modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(50.dp)) + Text(text = state.message, textAlign = TextAlign.Center) + } + + is FrostSigningUIState.Loaded -> { + // Loaded means the room is loaded, not the session: initiate() sets + // this state and only then starts collecting, so the first emission + // always has a null session. Reading that as "no such session" would + // flash an error on the way in. + val session = state.session ?: return@Scaffold Loading(padding) + + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(15.dp) + ) { + WhatIsBeingSigned(frostSigningViewModel.proposedEvent(session)) + + HorizontalDivider() + + Text( + text = statusOf(session), + style = MaterialTheme.typography.bodyMedium + ) + + session.failureReason?.let { reason -> + Text( + text = reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + HorizontalDivider() + + Text( + text = "Members", + style = MaterialTheme.typography.labelMedium + ) + + // Named rather than counted, for the same reason the ceremony's + // ladder names people: "1 of 2" does not tell anyone whose door + // to knock on, and a session stalls until somebody knocks. + state.localChatRoom.localParticipants + .distinctBy { it.participant.participantPublicKey } + .forEach { localParticipant -> + val member = localParticipant.participant.participantPublicKey + + Card { + ListItem( + leadingContent = { + ProfileAvatar( + publicKey = member, + profile = localParticipant.profile + ) + }, + trailingContent = { + when { + member in state.signed -> Icon( + Icons.Default.CheckCircle, + contentDescription = "Signed", + tint = MaterialTheme.colorScheme.primary + ) + + member in state.offeredNonce -> Icon( + Icons.Default.HourglassEmpty, + contentDescription = "Ready to sign" + ) + + else -> Icon( + Icons.Default.RadioButtonUnchecked, + contentDescription = "Not yet", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + headlineContent = { + Text( + text = localParticipant.profile + ?.humanReadableNameOrPubkey() + ?: member + ) + }, + supportingContent = { + Text( + text = when { + member in state.signed -> "Signed their part" + member in state.offeredNonce -> "Ready to sign" + else -> "Has not taken part yet" + } + ) + } + ) + } + } + + if (session.signApprovedAt == null && + session.stage != FrostSigningStage.COMPLETE && + session.stage != FrostSigningStage.FAILED + ) { + HorizontalDivider() + + Text( + text = "Nothing has been published from this device yet. Signing " + + "puts your share behind this event; it cannot be taken back.", + style = MaterialTheme.typography.bodySmall + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + enabled = !frostSigningViewModel.isActionPending.value, + onClick = { frostSigningViewModel.approve(onNavigateBack) } + ) { + Icon(Icons.Default.Draw, contentDescription = null) + Spacer(modifier = Modifier.width(10.dp)) + Text("Sign") + } + + TextButton( + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + enabled = !frostSigningViewModel.isActionPending.value, + onClick = { frostSigningViewModel.decline(onNavigateBack) } + ) { + Text("Don't sign") + } + } + } + } + } + } + } +} + +@Composable +private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) { + Column( + modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(50.dp)) + CircularProgressIndicator() + } +} + +/** + * What the group is being asked to put its name to. + * + * Shown as the thing rather than as an event: a member deciding whether to sign + * is deciding about a dialect or an artifact, and "kind 30304" answers a + * question nobody asked. The raw kind stays for anything not recognised, since + * refusing to describe an event is better than describing it wrongly. + */ +@Composable +private fun WhatIsBeingSigned(event: Event?) { + if (event == null) { + Text( + text = "This session's event could not be read, so there is nothing to check " + + "before signing. Don't sign it.", + color = MaterialTheme.colorScheme.error + ) + return + } + + val (label, detail) = when (event.kind) { + DialectEvent.KIND -> "New dialect" to DialectEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { dialect -> + listOfNotNull(dialect.name(), dialect.country(), dialect.language()) + .joinToString(" · ") + } + + ArtifactEvent.KIND -> "New artifact" to event.content + + ChapterEvent.KIND -> "New chapter" to ChapterEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).name().orEmpty() + + else -> "Event of kind ${event.kind}" to event.content + } + + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text(text = label, style = MaterialTheme.typography.labelMedium) + + Text(text = detail, style = MaterialTheme.typography.titleMedium) + + Text( + text = "Signed by the group, not by you. Once enough members sign, this is " + + "published under the group's shared key.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +private fun statusOf(session: FrostSigningSession): String = when (session.stage) { + FrostSigningStage.COLLECTING_NONCES -> + "Waiting for ${session.threshold} of ${session.participantCount} members to take part." + + FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES -> + if (session.isSigner()) { + "You are one of the signers. Waiting on the rest of them." + } else { + "Enough members took part without needing you. Waiting on them to sign." + } + + FrostSigningStage.COMPLETE -> "Signed." + + FrostSigningStage.FAILED -> "Abandoned. Nothing was signed, and it is safe to ask again." +} + +@Preview +@Composable +private fun FrostSigningScreenPreview() { + TorchTheme { + Surface(modifier = Modifier.fillMaxSize()) { + FrostSigningScreen( + activeUserPublicKey = "", + chatRoomId = "chatRoomId", + sessionId = "sessionId", + initialFrostSigningUIState = FrostSigningUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "chatRoomId", + userPublicKey = "", + subject = "Group (#admins)", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = null + ) + ) + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateBack = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 5dad096d..354ffafa 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -16,6 +16,7 @@ import androidx.navigation.toRoute import press.mantra.compose.MantraGlobal import press.mantra.compose.database.repository.DatabaseChatRepository import press.mantra.compose.database.repository.DatabaseDkgRepository +import press.mantra.compose.database.repository.DatabaseFrostSigningRepository import press.mantra.compose.database.repository.DatabaseMarmotRepository import press.mantra.compose.database.repository.DatabaseNostrRepository import press.mantra.compose.database.repository.DatabaseSearchRepository @@ -104,6 +105,7 @@ import press.mantra.compose.database.repository.DatabaseMantraRepository import press.mantra.compose.ui.composable.AddArtifactScreen import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute @@ -114,6 +116,7 @@ import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactV import press.mantra.compose.ui.composable.ArtifactDetailScreen import press.mantra.compose.ui.composable.AddChapterScreen import press.mantra.compose.ui.composable.AddDialectScreen +import press.mantra.compose.ui.composable.FrostSigningScreen import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen import press.mantra.compose.ui.composable.ChapterDetailScreen import press.mantra.compose.ui.composable.TranslateChunkScreen @@ -164,6 +167,13 @@ fun MantraNavHost( ) } + val databaseFrostSigningRepository = remember { + DatabaseFrostSigningRepository( + database = auxDatabaseManager.auxDatabase, + applicationIOScope + ) + } + val databaseMarmotRepository = remember { DatabaseMarmotRepository( database = auxDatabaseManager.auxDatabase, @@ -825,14 +835,12 @@ fun MantraNavHost( relayHint = route.relayHint, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, - mantraRepository = databaseMantraRepository, - onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute -> - // Replace both this add screen and the stale chat room detail - // beneath it so we land on a freshly-loaded detail screen. - navController.navigate( - route = chatRoomDetailRoute - ) { - popUpTo { + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this add screen so back returns to the group rather + // than to a form whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { inclusive = true } } @@ -844,6 +852,20 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + FrostSigningScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + sessionId = route.sessionId, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/FrostSigningRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/FrostSigningRoute.kt new file mode 100644 index 00000000..3d7575f7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/FrostSigningRoute.kt @@ -0,0 +1,27 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * One signing session. + * + * Carries a session id rather than only a room, because a group signs + * repeatedly and can have more than one session open at a time -- unlike a + * ceremony, where "the room's ritual" identifies it. + */ +@Serializable +data class FrostSigningRoute( + val activeUserPublicKey: String, + val chatRoomId: String, + + /** + * Null when the caller does not know which session it means. + * + * A transcript line is the main case: chat rows carry no session, and adding + * a column for one feature to a table every message uses is a poor trade for + * a lookup the screen can do. It resolves to the room's live session, which + * is the one a line is talking about in every case but a group running two + * at once. + */ + val sessionId: String? = null +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt index f0f655e7..a77d4593 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt @@ -18,8 +18,9 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.launch import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.nostr.nip30303.DialectEvent import press.mantra.compose.ui.view.state.AddDialectUIState class AddDialectViewModel( @@ -29,7 +30,7 @@ class AddDialectViewModel( initialAddDialectUIState: AddDialectUIState, val nostrRepository: NostrRepository, val chatRepository: ChatRepository, - val mantraRepository: MantraRepository, + val frostSigningRepository: FrostSigningRepository, ): ViewModel() { var addDialectUIState: AddDialectUIState by mutableStateOf(initialAddDialectUIState) @@ -39,6 +40,14 @@ class AddDialectViewModel( val isActionPending: MutableState = mutableStateOf(false) + /** + * Whether this group holds a key it can sign with. + * + * Read before the form is offered: a group without one cannot make a dialect + * this way at all, and a button that always fails is worse than no button. + */ + suspend fun canSign(): Boolean = frostSigningRepository.canSign(chatRoomId) + fun initiateAddDialect() { logger.d("compressed (most likely chat room): $chatRoomId") viewModelScope.launch(Dispatchers.IO) { @@ -49,17 +58,30 @@ class AddDialectViewModel( } else { AddDialectUIState.Loaded( localChatRoom = localChatRoom, + canSign = frostSigningRepository.canSign(chatRoomId), ) } } } + /** + * Asks the group to sign a new dialect into existence. + * + * The dialect is not created here and does not exist yet. What goes out is a + * proposal to sign it, and the dialect appears -- on every member's device at + * once, authored by the group's shared key rather than by whoever typed it -- + * when enough members have signed. + * + * That is the difference from submitting one. A submission says "I am putting + * this in front of the group" and the group's only recourse afterwards is + * social. A signature is the group saying it, and it takes a quorum to say. + */ fun addDialect( localChatRoom: LocalChatRoom, nameField: TextFieldState, countryField: TextFieldState, languageField: TextFieldState, - onSuccess: (dialectId: String) -> Unit, + onSuccess: (sessionId: String) -> Unit, onFailure: () -> Unit ) { val name = nameField.text.toString() @@ -76,25 +98,31 @@ class AddDialectViewModel( isActionPending.value = true viewModelScope.launch(Dispatchers.IO) { - val dialect = runCatching { - mantraRepository.addDialect( + val dialectEventTemplate = DialectEvent.build( + name = name, + country = country, + language = language, + ) + + val session = runCatching { + frostSigningRepository.proposeSigning( localChatRoom = localChatRoom, - name = name, - country = country, - language = language, userPublicKey = activeUserPublicKey, + kind = dialectEventTemplate.kind, + tags = dialectEventTemplate.tags, + content = dialectEventTemplate.content, ) }.onFailure { error -> - logger.e("Failed to add dialect", error) + logger.e("Failed to propose a dialect for signing", error) }.getOrNull() - if (dialect != null) { + if (session != null) { nameField.clearText() countryField.clearText() languageField.clearText() viewModelScope.launch(Dispatchers.Main) { - onSuccess.invoke(dialect.id) + onSuccess.invoke(session.id) } } else { viewModelScope.launch(Dispatchers.Main) { @@ -116,7 +144,7 @@ class AddDialectViewModel( initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading, nostrRepository: NostrRepository, chatRepository: ChatRepository, - mantraRepository: MantraRepository + frostSigningRepository: FrostSigningRepository ): ViewModelProvider.Factory = viewModelFactory { initializer { AddDialectViewModel( @@ -126,7 +154,7 @@ class AddDialectViewModel( initialAddDialectUIState = initialAddDialectUIState, nostrRepository = nostrRepository, chatRepository = chatRepository, - mantraRepository = mantraRepository + frostSigningRepository = frostSigningRepository ) } } 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 27d40b07..2d2a20d6 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 @@ -24,6 +24,8 @@ import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.CallMerge import androidx.compose.material.icons.filled.FactCheck +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.PanTool import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Upload @@ -215,7 +217,7 @@ class ChatMessageListViewModel( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable - fun RenderMessages(onOpenSharedKey: () -> Unit) { + fun RenderMessages(onOpenSharedKey: () -> Unit, onOpenSigning: () -> Unit) { Column( modifier = Modifier.fillMaxWidth(), @@ -270,8 +272,10 @@ class ChatMessageListViewModel( val answeredRequests = chatRoomDetailMessageListUIState .chatMessageList .mapNotNull { request -> - val published = ChatMessage - .DKG_REQUEST_FULFILMENTS[request.chatMessage.messageType] + val published = ( + ChatMessage.DKG_REQUEST_FULFILMENTS + + ChatMessage.FROST_REQUEST_FULFILMENTS + )[request.chatMessage.messageType] ?: return@mapNotNull null val done = chatRoomDetailMessageListUIState.chatMessageList.any { @@ -347,6 +351,20 @@ class ChatMessageListViewModel( 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 + // reader needs from one is the event being signed + // rather than the state of the key. + if (localChatMessage.chatMessage.messageType in ChatMessage.FROST_TYPES) { + RitualNotice( + localChatMessage = localChatMessage, + isAnswered = localChatMessage.chatMessage.id in answeredRequests, + onClick = onOpenSigning + ) + return@items + } + BoxWithConstraints( modifier = Modifier.fillMaxWidth() ) { @@ -537,6 +555,15 @@ private fun RitualNotice( ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1 -> Icons.Default.Upload ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2 -> Icons.Default.FactCheck + ChatMessage.TYPE_FROST_STARTED -> Icons.Default.Draw + ChatMessage.TYPE_FROST_NONCE -> Icons.Default.Upload + ChatMessage.TYPE_FROST_SIGNER_SET -> Icons.Default.Groups + ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE -> Icons.Default.Draw + ChatMessage.TYPE_FROST_SIGNATURE -> Icons.Default.WorkspacePremium + ChatMessage.TYPE_FROST_COMPLETE -> Icons.Default.CheckCircle + ChatMessage.TYPE_FROST_FAILED -> Icons.Default.ErrorOutline + ChatMessage.TYPE_FROST_APPROVAL_NEEDED -> Icons.Default.Draw + else -> Icons.Default.PanTool } @@ -545,10 +572,15 @@ private fun RitualNotice( // quiet; these are not. // An answered request is history, not a summons: it keeps its stage's icon so // the step is still recognisable, but drops the colour and the call to action. - val isRequest = chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES && !isAnswered + val isRequest = ( + chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES || + chatMessage.messageType in ChatMessage.FROST_REQUEST_TYPES + ) && !isAnswered val tint = when { - chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED -> MaterialTheme.colorScheme.error + chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED || + chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED -> + MaterialTheme.colorScheme.error isRequest -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.onSurfaceVariant } @@ -578,7 +610,9 @@ private fun RitualNotice( // from the joined profile rather than written into the content, // so it follows a rename and is not stuck on the "LOADING..." // placeholder a member is given the moment they are first seen. - if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES) { + if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES || + chatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES + ) { withStyle( SpanStyle(color = ProfileColor.fromPublicKey(chatMessage.senderPublicKey)) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt new file mode 100644 index 00000000..b2942491 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt @@ -0,0 +1,156 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.nostr.frost.FrostSigningEvents +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.view.state.FrostSigningUIState + +class FrostSigningViewModel( + val chatRoomId: String, + val sessionId: String?, + val activeUserPublicKey: HexKey, + initialFrostSigningUIState: FrostSigningUIState, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var frostSigningUIState: FrostSigningUIState by mutableStateOf(initialFrostSigningUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** + * Loads the room, then watches the session for as long as the screen lives. + * + * A session moves on messages arriving from other members, so a screen that + * read it once would sit still while the rest of the group signed around it. + */ + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + if (localChatRoom == null) { + frostSigningUIState = FrostSigningUIState.Error("Couldn't find the group") + return@launch + } + + // A transcript line knows its room but not its session, so resolve one + // before watching anything. + val id = sessionId + ?: frostSigningRepository.liveSessionForChatRoom(chatRoomId)?.id + if (id == null) { + frostSigningUIState = + FrostSigningUIState.Error("This group is not signing anything right now.") + return@launch + } + + frostSigningUIState = FrostSigningUIState.Loaded(localChatRoom = localChatRoom) + + combine( + frostSigningRepository.observeSessionById(id), + frostSigningRepository.observeMessages(id) + ) { session, messages -> session to messages } + .collect { (session, messages) -> + frostSigningUIState = FrostSigningUIState.Loaded( + localChatRoom = localChatRoom, + session = session, + offeredNonce = messages + .filter { it.kind == FrostSigningEvents.NONCE } + .map { it.signerPublicKey } + .toSet(), + signed = messages + .filter { it.kind == FrostSigningEvents.PARTIAL_SIGNATURE } + .map { it.signerPublicKey } + .toSet() + ) + } + } + } + + /** The event the group is being asked to sign, for showing it before they agree. */ + fun proposedEvent(session: FrostSigningSession): Event? = + Event.fromJsonOrNull(session.unsignedEventJson) + + fun signedEvent(session: FrostSigningSession): Event? = + frostSigningRepository.signedEvent(session) + + fun approve(onDone: () -> Unit) { + val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return + val sessionId = state.session?.id ?: return + + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + frostSigningRepository.approve( + localChatRoom = state.localChatRoom, + sessionId = sessionId + ) + + isActionPending.value = false + + viewModelScope.launch(Dispatchers.Main) { onDone.invoke() } + } + } + + fun decline(onDone: () -> Unit) { + val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return + val sessionId = state.session?.id ?: return + + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + frostSigningRepository.decline( + localChatRoom = state.localChatRoom, + sessionId = sessionId + ) + + isActionPending.value = false + + viewModelScope.launch(Dispatchers.Main) { onDone.invoke() } + } + } + + companion object { + private const val TAG = "FrostSigningViewModel" + + fun factory( + chatRoomId: String, + sessionId: String?, + activeUserPublicKey: HexKey, + initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + FrostSigningViewModel( + chatRoomId = chatRoomId, + sessionId = sessionId, + activeUserPublicKey = activeUserPublicKey, + initialFrostSigningUIState = initialFrostSigningUIState, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt index 3f66e18b..8f41867d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt @@ -5,6 +5,13 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom sealed interface AddDialectUIState { data class Loaded( val localChatRoom: LocalChatRoom, + + /** + * Whether the group holds a shared key. A dialect is signed into + * existence now rather than submitted, so a group without one cannot + * make one here at all. + */ + val canSign: Boolean = false, ): AddDialectUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt new file mode 100644 index 00000000..ccf8cdcb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt @@ -0,0 +1,26 @@ +package press.mantra.compose.ui.view.state + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface FrostSigningUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + + /** Null on the first emission, before the session has been collected. */ + val session: FrostSigningSession? = null, + + /** Who has offered a nonce, so the screen can name who it is waiting on. */ + val offeredNonce: Set = emptySet(), + + /** Who has signed their part. */ + val signed: Set = emptySet(), + ): FrostSigningUIState + + data class Error( + val message: String + ): FrostSigningUIState + + data object Loading: FrostSigningUIState +} From d110737f9add7148ab0954ae9a7848239c326660 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 23:09:53 +0200 Subject: [PATCH 12/13] fix: keep a room's MlsGroup alive so a late message can still be read Two events published in the same second reliably lose one of them. The receiver stores the kind:445 and produces nothing from it -- no inner event, no chat line, no error anybody sees, because MarmotGroupEvent is written before the message is decrypted and so survives while everything downstream silently does not. Observed as a FROST signing session that never started on the receiver: proposeSigning publishes the proposal and then the proposer's own nonce, the relay handed them back in the other order, and the proposal was dropped. The nonce is still sitting there filed against a session that will never exist. The same bug ate a dialect earlier, which then took out the artifact referencing it via a foreign key. MLS is specified to tolerate this. RFC 9420 says a receiver that gets generation N+1 before N keeps the intermediate keys so the older message can still be read, and quartz's SecretTree does exactly that, in a private skippedKeys map. What it does not do is persist it: exportSenderStates() returns the ratchet positions only, so saveState() drops the cache. NostrDao rebuilt the group from stored state for every inbound event, so the cache was empty every single time, and generation N arriving after N+1 failed `require(generation >= applicationGeneration)` and was swallowed. Terminal -- the key is derived from a ratchet that has moved past it, and nothing asks the sender to resend. This keeps the instance alive instead. MlsGroupCache holds one MlsGroup per room, and the inbound path goes through it, so skippedKeys survives from one message to the next. That covers the case that actually bites -- a burst arriving in one sync, decrypted one after another against the same tree -- which is what every bursty flow needs: proposeRitual sends two, addArtifact sends two, and addChapter sends one per paragraph plus one, of which only the ones arriving in ascending generation order survived. Reuse is conditional on the stored state still being exactly what the cache last wrote. Sending a message advances the sender ratchet and saves; so does adding a member. When that happens the cache rebuilds rather than carrying on from a group that has been overtaken -- which is what keeps this from being worse than no cache at all: the fallback is always the old behaviour, never a diverged ratchet. One lock per room, not one overall, because the group is mutable and decryption advances it: two events for the same room decrypted at once would corrupt the tree, and a busy room should not hold up a quiet one. **This is a mitigation, not the fix.** It does not survive a restart, and it does not survive another writer, so a long enough reorder still loses the message. The fix belongs in quartz -- carry skippedKeys through saveState/restore -- and quartz is a mavenCentral binary, not a fork, so it cannot be made here. docs/mls-skipped-keys.md has the analysis, the patch, the migration constraint on the persisted state format, and the three ways to actually land it. Not verified end to end: the proposal that exposed this cannot be recovered, since its generation is already past, so confirming the fix needs a fresh burst. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/database/dao/NostrDao.kt | 31 ++- .../mantra/compose/managers/MlsGroupCache.kt | 121 +++++++++++ docs/README.md | 3 + docs/mls-skipped-keys.md | 194 ++++++++++++++++++ 4 files changed, 339 insertions(+), 10 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MlsGroupCache.kt create mode 100644 docs/mls-skipped-keys.md 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 18685171..0aaba9be 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 @@ -33,6 +33,7 @@ import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.managers.MlsGroupCache import press.mantra.compose.managers.MarmotInboundManager import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException @@ -388,9 +389,21 @@ abstract class NostrDao( val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId) if (localChatRoom != null) { - val mlsGroup = localChatRoom.chatRoom.toMlsGroup() - - if (mlsGroup != null) { + // 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 + // whatever order the relay feels like, and rebuilding between + // them loses the earlier one for good -- see MlsGroupCache. + val handled = MlsGroupCache.withGroup( + chatRoomId = chatRoomId, + storedStateHex = localChatRoom.chatRoom.mlsGroupState, + build = { localChatRoom.chatRoom.toMlsGroup() }, + save = { stateHex -> + database.chatRoomDao().upsert( + localChatRoom.chatRoom.copy(mlsGroupState = stateHex) + ) + } + ) { mlsGroup -> val memberPubkeys = mlsGroup.members().mapNotNull { (leafIndex, leafNode) -> val pubkey = when (val cred = leafNode.credential) { @@ -437,12 +450,6 @@ abstract class NostrDao( } } - // Save the mls chatRoom state... - database.chatRoomDao().upsert( - localChatRoom.chatRoom.copy( - mlsGroupState = mlsGroup.saveState().encodeTls().toHex() - ) - ) ChatMessage.fromGroupEventResult( database = database, activeKeyPair = activeKeyPair, @@ -477,7 +484,11 @@ abstract class NostrDao( } else { throw MarmotNotMemberOfChatGroupException("We are not a member of the chat room ${localChatRoom.chatRoom.id}") } - } else { + } + + // Null means the room has no usable group state, which is what + // a failed toMlsGroup() meant before the cache existed. + if (handled == null) { throw MarmotMissingNostrGroupDataExtension("Couldn't find chatRoom for $nostrEvent") } } else { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MlsGroupCache.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MlsGroupCache.kt new file mode 100644 index 00000000..6429fe16 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MlsGroupCache.kt @@ -0,0 +1,121 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import press.mantra.compose.extensions.toHex +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Keeps a room's [MlsGroup] alive between messages instead of rebuilding it + * from the stored state every time. + * + * ### The bug this exists for + * + * MLS is specified to tolerate out-of-order delivery within an epoch: a + * receiver that gets generation N+1 before N derives and caches the key for N + * so the older message can still be read when it turns up. Quartz's + * `SecretTree` does exactly that, in a private `skippedKeys` map. + * + * `SecretTree.exportSenderStates()` does not include that map, so + * `MlsGroup.saveState()` does not carry it. Rebuilding the group from stored + * state therefore throws the skipped keys away, and a message for a generation + * the ratchet has already passed fails + * `require(generation >= state.applicationGeneration)` and is dropped. There is + * no recovering it afterwards: the key is gone and the sender will not resend. + * + * Nostr relays offer no ordering whatsoever, so this is not an edge case. Two + * events published in the same second race, and exactly one survives — which is + * how a signing session's proposal was lost while the nonce sent immediately + * behind it arrived fine. + * + * ### What this fixes, and what it does not + * + * Holding the instance means `skippedKeys` survives for as long as the process + * does and nothing else writes the room's state. That covers the case that + * actually bites — a burst of messages arriving in one sync — because they are + * decrypted one after another against the same tree. + * + * It does not survive a restart, and it does not survive another writer, so + * reordering across app launches still loses messages. The real fix is for + * `exportSenderStates` to carry the skipped keys; see + * `docs/mls-skipped-keys.md`. + * + * ### Staleness + * + * The group is only reused when the stored state is still exactly what this + * cache last wrote. Anything else that saves a room's state — sending a message + * advances the sender ratchet and saves, so does adding a member — changes the + * hex, and the next read rebuilds rather than carrying on from a group that has + * been overtaken. Losing the skipped keys there is the same behaviour as + * before this existed, so the fallback is never worse than not caching. + */ +object MlsGroupCache { + private const val TAG = "MlsGroupCache" + + private val logger = Logger.withTag(TAG) + + private class Entry( + val group: MlsGroup, + /** The state hex this cache last wrote, for spotting another writer. */ + var stateHex: String, + ) + + private val entries = mutableMapOf() + + /** + * Serialises use of one room's group. + * + * The group is mutable and decryption advances it, so two events for the + * same room being decrypted at once would corrupt the ratchet. One lock per + * room rather than one overall, so a busy room cannot hold up a quiet one. + * + * Held across database work, which is safe here because a caller only ever + * takes this lock while it is already running -- it never waits on a + * resource the holder is waiting for. + */ + private val locks = mutableMapOf() + private val locksGuard = Mutex() + + private suspend fun lockFor(chatRoomId: String): Mutex = + locksGuard.withLock { locks.getOrPut(chatRoomId) { Mutex() } } + + /** + * Runs [block] against the room's live group, then stores whatever state it + * left behind. + * + * [storedStateHex] is the room's state as the database currently has it, and + * [build] turns it into a group. [save] is handed the state to persist; it + * runs inside the lock so the stored state and the cached instance cannot + * disagree. + * + * Returns null without calling [block] when the room has no usable group + * state, which is the same thing a failed `toMlsGroup()` meant before. + */ + suspend fun withGroup( + chatRoomId: String, + storedStateHex: String?, + build: () -> MlsGroup?, + save: suspend (String) -> Unit, + block: suspend (MlsGroup) -> T, + ): T? = lockFor(chatRoomId).withLock { + val cached = entries[chatRoomId] + + val group = if (cached != null && cached.stateHex == storedStateHex) { + cached.group + } else { + if (cached != null) { + logger.d("Room $chatRoomId was written elsewhere; rebuilding its group") + } + build() ?: return@withLock null + } + + val result = block(group) + + val stateHex = group.saveState().encodeTls().toHex() + save(stateHex) + entries[chatRoomId] = Entry(group = group, stateHex = stateHex) + + result + } +} diff --git a/docs/README.md b/docs/README.md index 892a9ba8..683c112f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,5 +9,8 @@ silent, or a decision that looked arbitrary and was not. | [shared-key-ceremony.md](./shared-key-ceremony.md) | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering | | [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken | | [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite | +| [mls-skipped-keys.md](./mls-skipped-keys.md) | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app | Start with the ceremony if you are new to this area; the other two both assume it. +Read the skipped-keys note before debugging any "the other device never got it" +report — it is silent, and it looks like every other kind of delivery failure. diff --git a/docs/mls-skipped-keys.md b/docs/mls-skipped-keys.md new file mode 100644 index 00000000..fbf61175 --- /dev/null +++ b/docs/mls-skipped-keys.md @@ -0,0 +1,194 @@ +# Messages are lost when two arrive out of order + +A group event that a relay hands back a moment late is dropped and cannot be +recovered. Two messages published in the same second reliably lose one of them. + +This is a conformance gap in quartz's MLS implementation, not in this app. What +this app can do about it from outside the library is partial, and is described +at the end. + +## Symptom + +The receiver stores the kind:445 group event and produces nothing from it. No +inner event, no chat line, no error the user sees. `MarmotGroupEvent` is written +*before* the message is decrypted, so the row survives while everything +downstream of it silently does not: + +``` +receiver, room 6d8ec3ad ("Frosty (#admins)") + + 20:36:55 kind 9 chat message decrypted, applied + 20:37:34 kind 30321 nonce decrypted, applied + 20:37:34 kind 30320 proposal group event stored, no inner event +``` + +Both 20:37:34 events were published by the same sender in the same +`proposeSigning` call. Every message that arrived on its own decrypted fine; the +back-to-back pair lost exactly one. + +Downstream the failure reads as something else entirely. In the case above a +FROST signing session never started on the receiver, because the proposal that +opens one never arrived — leaving a nonce filed against a session that will +never exist. An earlier instance of the same bug dropped a dialect, and the +artifact referencing it then failed a foreign key and rolled back its whole +transaction. + +## Cause + +MLS is specified to tolerate out-of-order delivery inside an epoch. RFC 9420 +§9.1: a receiver that gets generation `N+1` before `N` derives the intermediate +keys and keeps them, so the older message can still be read when it turns up. + +Quartz does implement this. `SecretTree` caches them: + +```kotlin +// SecretTree.kt +private val skippedKeys = mutableMapOf, KeyNonceGeneration>() + +fun applicationKeyNonceForGeneration(leafIndex: Int, generation: Int): KeyNonceGeneration { + val cachedKey = skippedKeys.remove(Pair(leafIndex, generation)) + if (cachedKey != null) { /* ...replay check... */ return cachedKey } + + val state = getOrInitSender(leafIndex) + require(generation >= state.applicationGeneration) { + "Generation $generation already consumed (current: ${state.applicationGeneration})" + } + ... +} +``` + +The gap is that the cache is never persisted: + +```kotlin +// SecretTree.kt +fun exportSenderStates(): Map = senderState.toMap() + +fun importSenderStates(states: Map) { + senderState.putAll(states) +} +``` + +`exportSenderStates()` returns the ratchet *positions* only. `MlsGroup.saveState()` +calls it (`senderRatchetStates = secretTree.exportSenderStates()`) and +`MlsGroup.restore()` calls `importSenderStates`. So `skippedKeys` exists only in +one `SecretTree` instance's memory. + +That would be harmless if the group instance outlived the messages. It does not: +`NostrDao` rebuilds it from stored state for every inbound event and saves it +back afterwards. So the sequence is + +1. generation 1 arrives, ratchet advances 0 → 2, generation 0's key goes into + `skippedKeys` +2. `saveState()` — `skippedKeys` is dropped on the floor +3. generation 0 arrives, a fresh tree is restored with + `applicationGeneration = 2`, the cache is empty, `require` fails +4. the exception is swallowed, the event yields no `ApplicationMessage` + +Step 3 is terminal. The key is derived from a ratchet that has moved past it and +cannot be recovered, and nothing asks the sender to resend. + +Verified against the published artifact rather than a checkout: +`quartz-1.14.0-sources.jar`, `commonMain/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt`. + +## Why it is not an edge case here + +Nostr relays make no ordering guarantee at all, and negentropy reconciliation +hands back a room's backlog in whatever order it likes. Any two messages close +enough together can swap. + +Several flows publish in bursts, and each of them is a reliable trigger: + +| flow | messages in one pass | +|---|---| +| `FrostSigningManager.proposeSigning` | proposal, then the proposer's nonce | +| `ChillDkgRitualManager.proposeRitual` | proposal, then the host key | +| `MantraDao.addArtifact` | the artifact, then its first version | +| `MantraDao.addChapter` | the chapter, then one per paragraph chunk | + +`addChapter` is the worst of these: a chapter with twenty paragraphs publishes +twenty-one events at once, and only the ones that happen to arrive in ascending +generation order survive. + +## The fix, in quartz + +Carry the skipped keys through `saveState`/`restore` alongside the ratchet +positions. + +**1. Export and import them.** In `SecretTree`: + +```kotlin +fun exportSkippedKeys(): Map, KeyNonceGeneration> = skippedKeys.toMap() + +fun importSkippedKeys(keys: Map, KeyNonceGeneration>) { + skippedKeys.putAll(keys) +} +``` + +`MAX_SKIPPED_KEYS` already bounds the map, so the serialised size is bounded by +the same constant and needs no separate cap. + +**2. Put them in the group state.** `MlsGroup.saveState()` already writes +`senderRatchetStates = secretTree.exportSenderStates()`; add a sibling field, and +have `restore()` call `importSkippedKeys` next to its existing +`importSenderStates`. + +**3. Keep old state readable.** The persisted state is a TLS-encoded struct that +existing installs already hold, so the new field has to be optional: absent means +an empty map, which is exactly the behaviour today. Without that, every device +with a stored group is broken by the upgrade. + +**4. Consumed-generation replay protection.** `consumedGenerations` guards +against a replayed message re-using a cached key. It is in-memory too, so it +should travel with the skipped keys or the guard weakens across restarts. Worth +deciding deliberately rather than by omission. + +A test worth having with it: save and restore a group between the two messages +of an out-of-order pair, and assert the older one still decrypts. That is the +property, and it is invisible to any test that keeps one instance alive. + +### Getting the change into this build + +Quartz is **not** a local fork. It is `com.vitorpamplona.quartz:quartz`, pinned +in `gradle/libs.versions.toml` and resolved from mavenCentral; +`settings.gradle.kts` only `includeBuild`s `lightning-kmp-app`. Nothing in this +repository can change it. + +There is a full amethyst clone at `~/Documents/development/nostr/amethyst` whose +`SecretTree.kt` was byte-identical to published 1.14.0 when this was written, so +the patch itself is a small delta against a known-good base. Landing it means one +of: + +- **Upstream it.** It is a genuine RFC 9420 conformance gap and affects any + client that reloads group state per message, which is the ordinary shape for a + mobile app. Slowest, and the only option that leaves this repo's build + reproducible. +- **Patch the clone and publish to mavenLocal**, then add `mavenLocal()` here and + pin the patched version. Fast, but the build then depends on a patched crypto + library built from one machine's filesystem. +- **Wire quartz as a composite build**, the way `lightning-kmp-app` is. Same + coupling to a path outside the repo, but the source is at least visible. + +## What this app does in the meantime + +`MlsGroupCache` keeps a room's `MlsGroup` instance alive between messages instead +of rebuilding it from stored state each time, so `skippedKeys` survives for as +long as the process does. The inbound path in `NostrDao` goes through it. + +This covers the case that actually bites — a burst arriving in one sync, decrypted +one after another against the same tree — and it is what makes the flows in the +table above work. + +It is not the fix, and it is worth being precise about what it leaves broken: + +- **A restart loses the cache.** Messages skipped before the app closed cannot be + read after it reopens. +- **Another writer invalidates it.** Sending a message advances the sender ratchet + and saves the room's state; adding a member does too. The cache reuses its + instance only while the stored state is still exactly what it last wrote, and + rebuilds otherwise — dropping the skipped keys at that point, exactly as before. +- **Nothing helps a long reorder.** A message the relay holds back until after a + restart or an outbound send is gone. + +The staleness check is what keeps the cache from being *worse* than no cache: a +group that has been overtaken by another writer is never carried on with, so the +fallback is always the old behaviour rather than a diverged ratchet. From 42dd38cfc400dff40199849ac8735ea0cd3f890f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 23:18:54 +0200 Subject: [PATCH 13/13] test: pin the two invariants this session left unguarded Both are silent when broken, which is why they are worth asserting rather than reasoning about. **The cache's reuse decision.** MlsGroupCache exists because quartz drops a secret tree's skipped-generation keys on save, so rebuilding a group between two messages loses any that arrives late. Its safety argument is one comparison: reuse while the stored state is still what the cache last wrote, rebuild when it is not. Get that wrong in either direction and nothing complains -- reuse too eagerly and a group carries on from a ratchet another writer already moved, which corrupts decryption rather than failing it; reuse too rarely and the cache does nothing and the original bug is back with no symptom. That decision is now a generic LiveInstanceCache with MlsGroupCache as a typed facade over it, so it can be tested without standing up an MLS group. Splitting it also made two behaviours explicit that were previously incidental: a failed build no longer leaves the old instance behind, and an instance whose use threw is deliberately not cached -- it is half-advanced and never persisted, so the next caller has to start from disk. **Rumor and row ids agreeing.** MantraDao writes an entity whose id comes from fromXEventTemplate and separately builds the rumor it submits with rumorOf, which hashes the template itself. Both are meant to produce one id and nothing checked it. Diverging would mean submissions naming an event nobody has, deleteByPayloadEventId silently un-queuing nothing so superseded translations go out anyway, and every receiver creating a second row instead of converging on the sender's -- all of it invisible, since the ids are opaque hex either way. Asserted per kind, plus the whole chain out through the submission envelope. Both suites were mutation-checked rather than trusted: inverting the staleness comparison fails one cache test, recording the pre-block state fails another, and hashing the rumor under a different author fails all six id tests. Still uncovered, and not cheaply fixable: FrostSigningManager's and MantraDao's state machines both need a Room harness, and commonTest has none. The FROST crypto path is covered by FrostSigningRoundTest; the message-driven parts around it are not. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/managers/MlsGroupCache.kt | 111 ++++++---- .../database/model/RumorIdAgreementTest.kt | 174 +++++++++++++++ .../compose/managers/LiveInstanceCacheTest.kt | 201 ++++++++++++++++++ 3 files changed, 446 insertions(+), 40 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveInstanceCacheTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MlsGroupCache.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MlsGroupCache.kt index 6429fe16..efe838a1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MlsGroupCache.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MlsGroupCache.kt @@ -51,43 +51,14 @@ import kotlinx.coroutines.sync.withLock * before this existed, so the fallback is never worse than not caching. */ object MlsGroupCache { - private const val TAG = "MlsGroupCache" - - private val logger = Logger.withTag(TAG) - - private class Entry( - val group: MlsGroup, - /** The state hex this cache last wrote, for spotting another writer. */ - var stateHex: String, - ) - - private val entries = mutableMapOf() - - /** - * Serialises use of one room's group. - * - * The group is mutable and decryption advances it, so two events for the - * same room being decrypted at once would corrupt the ratchet. One lock per - * room rather than one overall, so a busy room cannot hold up a quiet one. - * - * Held across database work, which is safe here because a caller only ever - * takes this lock while it is already running -- it never waits on a - * resource the holder is waiting for. - */ - private val locks = mutableMapOf() - private val locksGuard = Mutex() - - private suspend fun lockFor(chatRoomId: String): Mutex = - locksGuard.withLock { locks.getOrPut(chatRoomId) { Mutex() } } + private val cache = LiveInstanceCache { it.saveState().encodeTls().toHex() } /** * Runs [block] against the room's live group, then stores whatever state it * left behind. * * [storedStateHex] is the room's state as the database currently has it, and - * [build] turns it into a group. [save] is handed the state to persist; it - * runs inside the lock so the stored state and the cached instance cannot - * disagree. + * [build] turns it into a group. [save] is handed the state to persist. * * Returns null without calling [block] when the room has no usable group * state, which is the same thing a failed `toMlsGroup()` meant before. @@ -98,24 +69,84 @@ object MlsGroupCache { build: () -> MlsGroup?, save: suspend (String) -> Unit, block: suspend (MlsGroup) -> T, - ): T? = lockFor(chatRoomId).withLock { - val cached = entries[chatRoomId] + ): T? = cache.withInstance( + key = chatRoomId, + storedState = storedStateHex, + build = build, + save = save, + block = block + ) +} - val group = if (cached != null && cached.stateHex == storedStateHex) { - cached.group +/** + * One live instance per key, reused only while the stored state is still the one + * this cache last wrote. + * + * Split out from [MlsGroupCache] so the decision it makes can be tested without + * standing up an MLS group. That decision is the whole safety argument: reuse + * when nothing else has written, rebuild when something has, and never carry on + * with an instance whose last use failed part-way through. + */ +internal class LiveInstanceCache( + /** The persisted form of an instance, for spotting another writer. */ + private val stateOf: (T) -> String, +) { + private val logger = Logger.withTag("LiveInstanceCache") + + private class Entry(val instance: T, val state: String) + + private val entries = mutableMapOf>() + + /** + * Serialises use of one key's instance. + * + * The instance is mutable and [block] advances it, so two callers running at + * once would corrupt it. One lock per key rather than one overall, so a busy + * key cannot hold up a quiet one. + * + * Held across [block], which may touch the database. Safe here because a + * caller only ever takes this lock while it is already running -- it never + * waits on a resource the holder is itself waiting for. + */ + private val locks = mutableMapOf() + private val locksGuard = Mutex() + + private suspend fun lockFor(key: String): Mutex = + locksGuard.withLock { locks.getOrPut(key) { Mutex() } } + + suspend fun withInstance( + key: String, + storedState: String?, + build: () -> T?, + save: suspend (String) -> Unit, + block: suspend (T) -> R, + ): R? = lockFor(key).withLock { + val cached = entries[key] + + val instance = if (cached != null && cached.state == storedState) { + cached.instance } else { if (cached != null) { - logger.d("Room $chatRoomId was written elsewhere; rebuilding its group") + logger.d("$key was written elsewhere; rebuilding") } + // Dropped before the block runs, so a build that fails does not leave + // the old instance behind to be picked up by the next caller. + entries.remove(key) build() ?: return@withLock null } - val result = block(group) + // Deliberately not in a finally: an instance whose use threw part-way is + // in an unknown state, and the next caller should rebuild from whatever + // was last persisted rather than carry on with it. + val result = block(instance) - val stateHex = group.saveState().encodeTls().toHex() - save(stateHex) - entries[chatRoomId] = Entry(group = group, stateHex = stateHex) + val state = stateOf(instance) + save(state) + entries[key] = Entry(instance = instance, state = state) result } + + /** How many instances are held. For tests. */ + internal fun size(): Int = entries.size } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt new file mode 100644 index 00000000..ea0437ab --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt @@ -0,0 +1,174 @@ +package press.mantra.compose.database.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.test.Test +import kotlin.test.assertEquals +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent +import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.SubmissionEvent +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag + +/** + * The row on disk and the payload on the wire have to be the same event. + * + * `MantraDao` writes an entity whose id comes from `MantraX.fromXEventTemplate`, + * and separately builds the rumor it submits with `rumorOf`, which hashes the + * template itself. Both are supposed to produce one id. Nothing checks that they + * do, and nothing would notice if they stopped: + * + * - the submission would carry a `payloadId` naming an event nobody has, + * - `MarmotInnerEvent.payloadEventId` would stop matching the row it carries, + * so `deleteByPayloadEventId` would silently un-queue nothing and superseded + * translations would go out anyway, + * - and every receiver would create a *second* row rather than converging on + * the sender's, because entity ids are content hashes and the two sides would + * be hashing different things. + * + * All of that is silent. The ids are opaque hex either way. + */ +class RumorIdAgreementTest { + private val author = "a".repeat(64) + private val chatRoomId = "room" + private val other = "b".repeat(64) + + /** Exactly what `MantraDao.rumorOf` does, and it must stay exactly that. */ + private fun rumorIdOf(template: EventTemplate<*>): String = EventHasher.hashId( + pubKey = author, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ) + + @Test + fun `a dialect's row and its rumor agree`() { + val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st") + + val entity = MantraDialect.fromDialectEventTemplate( + dialectEventTemplate = template, + chatRoomId = chatRoomId, + userPublicKey = author + ) + + assertEquals(rumorIdOf(template), entity?.id) + } + + @Test + fun `an artifact's row and its rumor agree`() { + val template = ArtifactEvent.build( + name = "In Detention", + url = "example.com", + visibility = "private", + license = "cc", + dialectId = other + ) + + val entity = MantraArtifact.fromArtifactEventTemplate( + artifactEventTemplate = template, + chatRoomId = chatRoomId, + userPublicKey = author + ) + + assertEquals(rumorIdOf(template), entity?.id) + } + + @Test + fun `an artifact version's row and its rumor agree`() { + val template = ArtifactVersionEvent.build(content = "1.0") { + addUnique(ArtifactIdTag.assemble(other)) + } + + val entity = MantraArtifactVersion.fromArtifactVersionEventTemplate( + artifactVersionEventTemplate = template, + chatRoomId = chatRoomId, + userPublicKey = author + ) + + assertEquals(rumorIdOf(template), entity?.id) + } + + @Test + fun `a chapter's and a chunk's rows agree with their rumors`() { + val chapter = ChapterEvent.build( + artifactVersionId = other, + name = "Chapter 1", + originalText = "some text", + index = 0, + wordCount = 2, + characterCount = 9 + ) + val chunk = ChunkEvent.build( + chapterId = other, + text = "some text", + index = 0, + wordCount = 2, + characterCount = 9 + ) + + assertEquals( + rumorIdOf(chapter), + MantraChapter.fromChapterEventTemplate(chapter, chatRoomId, author)?.id + ) + assertEquals( + rumorIdOf(chunk), + MantraChunk.fromChunkEventTemplate(chunk, chatRoomId, author)?.id + ) + } + + @Test + fun `a translation version's row and its rumor agree`() { + val template = TranslationArtifactVersionEvent.build( + artifactVersionId = other, + dialectId = other, + name = "Sesotho", + visibility = "private", + license = "cc" + ) + + val entity = MantraTranslationArtifactVersion.fromTranslationArtifactVersionEventTemplate( + translationArtifactVersionEventTemplate = template, + chatRoomId = chatRoomId, + userPublicKey = author + ) + + assertEquals(rumorIdOf(template), entity?.id) + } + + @Test + fun `the submission names the id the row was written under`() { + // The end of the chain the rest of this file checks a link of: what a + // receiver reads out of the envelope has to be the id the sender stored. + val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st") + val entity = MantraDialect.fromDialectEventTemplate(template, chatRoomId, author) + + val payload = Event( + id = rumorIdOf(template), + pubKey = author, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "" + ) + val submission = SubmissionEvent.build(payload = payload) + + val readBack = SubmissionEvent( + id = "f".repeat(64), + pubKey = author, + createdAt = submission.createdAt, + tags = submission.tags, + content = submission.content, + sig = "" + ) + + assertEquals(entity?.id, readBack.payloadId()) + assertEquals(entity?.id, readBack.payload()?.id) + assertEquals(DialectEvent.KIND, readBack.payloadKind()) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveInstanceCacheTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveInstanceCacheTest.kt new file mode 100644 index 00000000..c2931f4d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveInstanceCacheTest.kt @@ -0,0 +1,201 @@ +package press.mantra.compose.managers + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlinx.coroutines.runBlocking + +/** + * The decision behind keeping an MLS group alive between messages. + * + * This cache exists because quartz drops a secret tree's skipped-generation keys + * on save, so rebuilding a group between two messages loses any message that + * arrives late -- permanently, and silently. See `docs/mls-skipped-keys.md`. + * + * Every one of these failures is invisible at runtime. Reuse too eagerly and a + * group carries on from a ratchet another writer has already moved, which + * corrupts decryption rather than failing it. Reuse too rarely and the cache + * does nothing at all, and the bug it was written for comes straight back with + * no symptom to notice. So the rule is asserted rather than reasoned about. + */ +class LiveInstanceCacheTest { + /** Stands in for an MlsGroup: mutable, and its persisted form is its content. */ + private class Group(var state: String) { + /** How many times this particular instance was handed to a caller. */ + var uses: Int = 0 + } + + private fun cache() = LiveInstanceCache { it.state } + + @Test + fun `reuses the instance while nothing else has written`() = runBlocking { + val cache = cache() + var stored: String? = "start" + var built = 0 + + val first = cache.withInstance( + key = "room", + storedState = stored, + build = { built++; Group("start") }, + save = { stored = it }, + block = { it.uses++; it } + ) + + val second = cache.withInstance( + key = "room", + storedState = stored, + build = { built++; Group("start") }, + save = { stored = it }, + block = { it.uses++; it } + ) + + // The same object, not merely an equal one: what has to survive is the + // in-memory skipped-key map, which no amount of rebuilding recovers. + assertSame(first, second) + assertEquals(1, built) + assertEquals(2, second?.uses) + } + + @Test + fun `rebuilds when something else wrote the stored state`() = runBlocking { + val cache = cache() + var stored: String? = "start" + var built = 0 + + val first = cache.withInstance( + key = "room", + storedState = stored, + build = { built++; Group("start") }, + save = { stored = it }, + block = { it } + ) + + // Sending a message advances the sender ratchet and saves; adding a + // member does too. Carrying on from an instance that has been overtaken + // would diverge the ratchet, which is worse than not caching at all. + stored = "written by someone else" + + val second = cache.withInstance( + key = "room", + storedState = stored, + build = { built++; Group("written by someone else") }, + save = { stored = it }, + block = { it } + ) + + assertEquals(2, built) + assertEquals(false, first === second) + } + + @Test + fun `persists whatever the block left behind`() = runBlocking { + val cache = cache() + var stored: String? = "start" + + cache.withInstance( + key = "room", + storedState = stored, + build = { Group("start") }, + save = { stored = it }, + block = { it.state = "advanced" } + ) + + assertEquals("advanced", stored) + } + + @Test + fun `the state it records is the one it compares against next time`() = runBlocking { + val cache = cache() + var stored: String? = "start" + var built = 0 + + repeat(3) { + cache.withInstance( + key = "room", + storedState = stored, + build = { built++; Group("start") }, + save = { stored = it }, + // Every use moves the instance on, as decrypting a message does. + block = { group -> group.state = "advanced ${group.uses++}" } + ) + } + + // Recording the pre-block state instead would make every call look like + // somebody else had written, quietly turning the cache off. + assertEquals(1, built) + } + + @Test + fun `does not run the block, or cache anything, when there is nothing to build`() = runBlocking { + val cache = cache() + var ran = false + + val result = cache.withInstance( + key = "room", + storedState = null, + build = { null }, + save = { }, + block = { ran = true } + ) + + assertNull(result) + assertEquals(false, ran) + assertEquals(0, cache.size()) + } + + @Test + fun `an instance whose use threw is not handed to the next caller`() = runBlocking { + val cache = cache() + var stored: String? = "start" + var built = 0 + + assertFailsWith { + cache.withInstance( + key = "room", + storedState = stored, + build = { built++; Group("start") }, + save = { stored = it }, + block = { error("decryption blew up half way") } + ) + } + + cache.withInstance( + key = "room", + storedState = stored, + build = { built++; Group("start") }, + save = { stored = it }, + block = { it } + ) + + // Half-advanced and never persisted: the next caller has to start from + // what is actually on disk, not from whatever the failure left in memory. + assertEquals(2, built) + } + + @Test + fun `rooms are cached independently`() = runBlocking { + val cache = cache() + var storedA: String? = "a" + var storedB: String? = "b" + + val a = cache.withInstance( + key = "roomA", + storedState = storedA, + build = { Group("a") }, + save = { storedA = it }, + block = { it } + ) + val b = cache.withInstance( + key = "roomB", + storedState = storedB, + build = { Group("b") }, + save = { storedB = it }, + block = { it } + ) + + assertEquals(2, cache.size()) + assertEquals(false, a === b) + } +}