diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 69a2d9c8..a951896c 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -121,8 +121,9 @@ kotlin { } commonTest.dependencies { implementation(libs.kotlin.test) - // runTest: the relay pool's bookkeeping is all suspend functions, and there is no - // runBlocking in a common source set. + // runTest: the DAO, model and relay layers are all suspending, and there is no + // runBlocking in a common source set — so anything worth asserting about them + // needs a coroutine, and a scheduler, to assert it in. implementation(libs.kotlinx.coroutinesTest) } jvmMain.dependencies { @@ -172,6 +173,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/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/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/schemas/press.mantra.compose.database.MantraDatabase/7.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/7.json new file mode 100644 index 00000000..02a05a59 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/7.json @@ -0,0 +1,5379 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "4b5d394b56639e56d8de1e8f9a7f6faf", + "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": "GroupKeyState", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedBy", + "columnName": "announcedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedAt", + "columnName": "announcedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyState_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "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, '4b5d394b56639e56d8de1e8f9a7f6faf')" + ] + } +} \ No newline at end of file diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/8.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/8.json new file mode 100644 index 00000000..9e9fc250 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/8.json @@ -0,0 +1,5389 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "1502cdf3274007ce762825646ef3c955", + "entities": [ + { + "tableName": "BroadcastNostrEventReceipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAccepted", + "columnName": "isAccepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSync", + "columnName": "isSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messages", + "columnName": "messages", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventReceipt_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventReceipt_isAccepted", + "unique": false, + "columnNames": [ + "isAccepted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "BroadcastNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourcePublicKey", + "columnName": "sourcePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationPublicKey", + "columnName": "destinationPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourcePublicKey", + "destinationPublicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourcePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "destinationPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "ChatMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUserMessage", + "columnName": "isUserMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftWrapPayloadId", + "columnName": "giftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotInnerEventId", + "columnName": "marmotInnerEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotedMessageId", + "columnName": "quotedMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "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": "GroupKeyState", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedBy", + "columnName": "announcedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedAt", + "columnName": "announcedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyState_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "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, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadEventId", + "columnName": "payloadEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilter", + "columnName": "synchronizationFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NegentropySynchronizeRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_NegentropySynchronizeRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "negentropySynchronizeRequestId", + "columnName": "negentropySynchronizeRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "SynchronizeNostrEventResult", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "negentropySynchronizeRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedAuthorPublicKey", + "columnName": "quotedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToAuthorPublicKey", + "columnName": "inReplyToAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootAuthorPublicKey", + "columnName": "inReplyToRootAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedAuthorPublicKey", + "columnName": "repostedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_NostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToRootNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToRootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)" + } + ] + }, + { + "tableName": "NostrEventRelay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "relayURL" + ] + }, + "indices": [ + { + "name": "index_NostrEventRelay_relayURL", + "unique": false, + "columnNames": [ + "relayURL" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Participant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Post", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT" + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Post_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Post_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Post_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + }, + { + "name": "index_Post_repostId", + "unique": false, + "columnNames": [ + "repostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT" + }, + { + "fieldPath": "banner", + "columnName": "banner", + "affinity": "TEXT" + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "nip05", + "columnName": "nip05", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "lud06", + "columnName": "lud06", + "affinity": "TEXT" + }, + { + "fieldPath": "lud16", + "columnName": "lud16", + "affinity": "TEXT" + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey" + ] + }, + "indices": [ + { + "name": "index_Profile_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "QuotedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "quotingNostrEventId", + "columnName": "quotingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedProfilePublicKey", + "columnName": "quotedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "quotingNostrEventId", + "quotedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Reaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Reaction_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Reaction_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Reaction_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey", + "type", + "url" + ] + } + }, + { + "tableName": "RepostedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repostingNostrEventId", + "columnName": "repostingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedProfilePublicKey", + "columnName": "repostedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostingNostrEventId", + "repostedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_SynchronizeNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventResult_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UnsignedNostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateTags", + "columnName": "privateTags", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedAt", + "columnName": "signedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UnsignedNostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_UnsignedNostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "Zap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT" + }, + { + "fieldPath": "amountInMillisatoshis", + "columnName": "amountInMillisatoshis", + "affinity": "INTEGER" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Zap_senderPublicKey", + "unique": false, + "columnNames": [ + "senderPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)" + }, + { + "name": "index_Zap_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Zap_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "senderPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "receiverPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "postId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1502cdf3274007ce762825646ef3c955')" + ] + } +} \ 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..939c4af6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -16,9 +16,11 @@ 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 +import press.mantra.compose.database.dao.GroupKeyStateDao import press.mantra.compose.database.dao.InReplyToRelationDao import press.mantra.compose.database.dao.MantraArtifactDao import press.mantra.compose.database.dao.MantraArtifactVersionDao @@ -70,6 +72,7 @@ import press.mantra.compose.database.model.Connection import press.mantra.compose.database.model.GiftWrapMessage import press.mantra.compose.database.model.GiftWrapPayload import press.mantra.compose.database.model.GiftWrapSeal +import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.InReplyToRelation import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion @@ -102,6 +105,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,9 +129,12 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) ChatRoom::class, DkgParticipantMessage::class, DkgSession::class, + FrostSignerMessage::class, + FrostSigningSession::class, GiftWrapMessage::class, GiftWrapSeal::class, GiftWrapPayload::class, + GroupKeyState::class, InReplyToRelation::class, MantraArtifact::class, MantraArtifactVersion::class, @@ -164,7 +172,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 4, + version = 8, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -173,11 +181,34 @@ 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), + // 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), + // v7 adds the GroupKeyState table, which records what shared key a room + // signs with instead of leaving it to be rederived. A new table is a + // shape Room migrates itself. Rooms created before this have no row and + // fall back to the rederivation scan in FrostSigningManager.completedKey, + // which is why that scan stays. + AutoMigration(from = 6, to = 7), + // v8 adds a nullable direct message recipient to MarmotInnerEvent and + // ChatMessage. Nullable additions need no default and drop no data, so Room + // generates this one. Rows written before it come back null, which reads as + // "not a direct message" -- the only answer that is true of all of them. + AutoMigration(from = 7, to = 8) ] ) @ColumnTypeConverters(MantraConverters::class) @@ -197,6 +228,10 @@ abstract class MantraDatabase: RoomDatabase() { abstract fun dkgSessionDao(): DkgSessionDao + abstract fun frostSigningSessionDao(): FrostSigningSessionDao + + abstract fun groupKeyStateDao(): GroupKeyStateDao + abstract fun connectionDao(): ConnectionDao abstract fun giftWrapMessageDao(): GiftWrapMessageDao 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/FrostSigningSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt new file mode 100644 index 00000000..0b68a2b2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt @@ -0,0 +1,50 @@ +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? + + @Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC") + suspend fun getSessionsForChatRoom(chatRoomId: String): List + + @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/GroupKeyStateDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt new file mode 100644 index 00000000..05c43121 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt @@ -0,0 +1,56 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Transaction +import androidx.room3.Upsert +import kotlinx.coroutines.flow.Flow +import press.mantra.compose.database.model.GroupKeyState + +@Dao +abstract class GroupKeyStateDao { + @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") + abstract suspend fun getByChatRoomId(chatRoomId: String): GroupKeyState? + + @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") + abstract fun observeByChatRoomId(chatRoomId: String): Flow + + /** Every room that signs with one ceremony's key. One, today. */ + @Query("SELECT * FROM GroupKeyState WHERE dkgSessionId = :dkgSessionId") + abstract suspend fun getByDkgSessionId(dkgSessionId: String): List + + @Upsert + abstract suspend fun upsert(groupKeyState: GroupKeyState) + + /** + * Files a state, keeping the newest announcement per room. + * + * This is where the event's replaceable semantics actually happen. Relays + * never see a `GroupKeyStateEvent` -- it is a rumor inside a Marmot group + * event -- so nothing upstream applies the 3xxxx replacement rule, and an + * announcement that arrives twice would otherwise be two rows racing for + * one primary key. + * + * Older announcements are dropped rather than applied, so a redelivery from + * a relay backfill cannot walk the room back to a state it has already + * moved past. An announcement at the same instant is kept as a no-op: two + * members announcing the same true thing agree by construction, since both + * derived it from the room they are standing in. + * + * Returns the state now on file. + */ + @Transaction + open suspend fun replace(groupKeyState: GroupKeyState): GroupKeyState { + val known = getByChatRoomId(groupKeyState.chatRoomId) + + if (known != null && known.announcedAt >= groupKeyState.announcedAt) return known + + val stamped = groupKeyState.copy( + createdAt = known?.createdAt ?: groupKeyState.createdAt, + updatedAt = groupKeyState.createdAt + ) + upsert(stamped) + + return stamped + } +} 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..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,25 @@ 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) + + /** + * 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/dao/MarmotOutboundDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt index ca3413e3..653d12ee 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt @@ -22,6 +22,7 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.extensions.exporterSecret import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.MarmotInboundManager.EPOCH_RETENTION_WINDOW +import press.mantra.compose.nostr.MarmotDirectMessage import press.mantra.compose.nostr.Relays import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag @@ -33,6 +34,7 @@ import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup 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.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher @@ -435,17 +437,13 @@ abstract class MarmotOutboundDao( // Save commitResult... in case we need to broadcast welcomeEvent after relay acknowledgement... database.marmotCommitResultDao().upsert( - MarmotCommitResult( - id = commitEvent.id, - isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation, + MarmotCommitResult.from( + commitEventId = commitEvent.id, + commitResult = commitResult, chatRoomId = nostrGroupId, - commitBytes = commitResult.commitBytes, - preCommitExporterSecret = commitResult.preCommitExporterSecret, - welcomeBytes = commitResult.welcomeBytes, - framedCommitBytes = commitResult.preCommitExporterSecret, - groupInfoBytes = commitResult.groupInfoBytes, userPublicKey = userPublicKey, peerKeyPackageEventId = peerKeyPackage.id, + isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation, createdAt = Instant.fromEpochSeconds(commitEvent.createdAt) ) ) @@ -545,7 +543,20 @@ abstract class MarmotOutboundDao( marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync ): GroupEvent { - val innerEvent = RumorAssembler.assembleRumor( + // A direct message is gift wrapped for one member before it goes into MLS, so + // what the group carries is a kind:1059 whose id is not this row's. Everything + // downstream -- MLS encrypt, outer ChaCha20, kind:445, persistence, broadcast -- + // is the same either way. See MarmotDirectMessage. + val innerEvent: Event = marmotInnerEvent.directMessageRecipientPublicKey?.let { recipient -> + MarmotDirectMessage.wrap( + signer = nostrSignerSync, + recipientPublicKey = recipient, + kind = marmotInnerEvent.kind, + createdAt = marmotInnerEvent.createdAt.epochSeconds, + tags = marmotInnerEvent.tags, + content = marmotInnerEvent.content + ) + } ?: RumorAssembler.assembleRumor( pubKey = nostrSignerSync.pubKey, ev = EventTemplate( createdAt = marmotInnerEvent.createdAt.epochSeconds, @@ -628,11 +639,25 @@ abstract class MarmotOutboundDao( database.marmotInnerEventDao().upsert( marmotInnerEvent.copy( - marmotGroupEventId = groupEvent.id + marmotGroupEventId = groupEvent.id, + // A direct message's plaintext is already on its ChatMessage row, which is + // the sender's only copy of it. A second one here would be cleartext left + // in a table that otherwise holds nothing but wire events. + content = if (marmotInnerEvent.directMessageRecipientPublicKey != null) { + "" + } else { + marmotInnerEvent.content + } ) ) - val chatMessageOrNull = database.chatMessageDao().getChatMessagesByMarmotInnerEventId(innerEvent.id) + // Keyed on the queued row, not on `innerEvent.id`. For a direct message those + // differ -- the row is the rumor, the wire event is the wrap built around it -- + // and the wrap's id matches no ChatMessage, so this lookup would come back null, + // the message would never be linked, and no BroadcastNostrEventRequest would ever + // be inserted. Encrypted, stored, and silently never sent. They are the same value + // for every other kind of message. + val chatMessageOrNull = database.chatMessageDao().getChatMessagesByMarmotInnerEventId(marmotInnerEvent.id) logger.d("chatMessageOrNull: $chatMessageOrNull") chatMessageOrNull?.let { chatMessage -> 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 026e3439..b3ba8fd0 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,11 @@ 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.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.managers.GroupKeyStateManager +import press.mantra.compose.managers.MlsGroupCache import press.mantra.compose.managers.MarmotInboundManager import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException @@ -43,6 +48,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 @@ -385,9 +391,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) { @@ -434,28 +452,65 @@ 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, groupEvent = groupEvent, groupEventResult = groupEventResult, + // Who sent it comes from the MLS frame, not the + // payload. Resolved here because this is where the + // group is; the leaf index is on the result, and the + // tree has not moved -- an application message + // advances no epoch. + senderIdentity = (groupEventResult as? GroupEventResult.ApplicationMessage) + ?.let { mlsGroup.memberIdentityHex(it.senderLeafIndex) }, )?.let { chatMessage -> logger.d("chatMessage: $chatMessage") database.chatMessageDao().upsert( 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)?.let { innerEvent -> + when { + FrostSigningEvents.isFrostSigningKind(innerEvent.kind) -> + FrostSigningManager.processSigningPayload( + database = database, + localChatRoom = localChatRoom, + innerEvent = innerEvent, + userPublicKey = activeKeyPair.pubKey.toHex() + ) + + // What key this room signs with. Filed + // rather than acted on, and only after + // the room rederives from the key it + // names -- the manager drops anything + // that does not, whoever sent it. + GroupKeyStateEvent.isGroupKeyStateKind(innerEvent.kind) -> + GroupKeyStateManager.record( + database = database, + chatRoomId = localChatRoom.chatRoom.id, + innerEvent = innerEvent + ) + } + } + } } } 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 { @@ -501,6 +556,15 @@ abstract class NostrDao( giftWrapMessage ) + if (!giftWrapMessage.isAddressedTo(activeKeyPair)) { + // Undecryptable by design rather than by failure, so keep the event and + // the wrap we just stored and stop here. Throwing would roll the whole + // transaction back and lose both. + logger.d("GiftWrap ${nostrEvent.id} is addressed to ${giftWrapMessage.receiverPublicKey}, nothing to index") + + return@let + } + giftWrapMessage.decryptGiftWrapSeal( activeKeyPair ).let { giftWrapSeal -> @@ -639,14 +703,13 @@ abstract class NostrDao( } } - // A "sent-messages" reconciliation used to be queued here, - // for gift wraps with authors=[userPublicKey] p-tagged at this - // participant. It could never match anything: a gift wrap is - // signed with a throwaway key (see DatabaseChatRepository), so - // its pubkey is random and never ours. It was also unnecessary - // — createNip17ChatRoom puts us in our own participant list, so - // we wrap a copy to ourselves and the live gift wrap - // subscription picks our own sent messages up on every device. + // Nothing to sync when we do have the relay list: a gift + // wrap is authored by a throwaway key, so authors=us matched + // nothing and this request was always empty. Our own inbox is + // synced by p-tag instead — by the live gift wrap + // subscription, which also picks up the copy we wrap to + // ourselves (createNip17ChatRoom puts us in our own + // participant list), on every device. if (chatMessageRelayListEvent == null) { logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey") @@ -849,9 +912,8 @@ abstract class NostrDao( } } - // See the identical block above: the "sent-messages" - // reconciliation that used to live here could never match, and is - // covered by the live gift wrap subscription regardless. + // See the identical block above: authors=us never matches a + // gift wrap, so only the missing-relay-list case has work to do. if (chatMessageRelayListEvent == null) { logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey") 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..00104db4 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,17 +17,24 @@ 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.frost.GroupKeyStateEvent 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.TranslationArtifactVersionContributorListEvent 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.TranslationContributorListEvent import press.mantra.compose.nostr.nip30303.TranslationEvent +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import press.mantra.compose.nostr.MarmotDirectMessage import kotlin.time.Clock import kotlin.time.Instant @@ -77,6 +84,15 @@ data class ChatMessage( val content: String, val messageType: String = "message", + /** + * Who a direct message was for, or null for anything else. + * + * Carried on the row rather than read back off the wrap so the transcript can name + * the recipient without a join, on both the line the two of them can read and the + * line everybody else gets. See docs/marmot-direct-messages.md. + */ + val directMessageRecipientPublicKey: HexKey? = null, + override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt, override val savedAt: Instant = Clock.System.now(), @@ -85,6 +101,21 @@ data class ChatMessage( ): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity { companion object { + private val logger = Logger.withTag("ChatMessage") + + /** + * A one-to-one message inside a group, as a line in the group's chat. + * + * Written by both sides of the room. For its two parties [content] is the words; + * for everybody else it is empty, and the line says only that a private message + * was sent and to whom -- which is all the group can learn, and all it should. + * + * [directMessageRecipientPublicKey] is set on every one of these regardless of + * who can read it, because naming the recipient is the point of the line the + * bystanders get. See docs/marmot-direct-messages.md. + */ + const val TYPE_DIRECT_MESSAGE = "directMessage" + /** * A ChillDKG ritual message, as a line in the group's chat. * @@ -178,6 +209,65 @@ 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" + + /** + * 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, + 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, @@ -188,17 +278,155 @@ data class ChatMessage( TYPE_DKG_FAILED, ) + /** + * Files one direct message, from whichever side of it this device is on. + * + * The decision is [MarmotDirectMessage.classify]'s, which is where it is tested; + * this turns it into rows. See docs/marmot-direct-messages.md. + */ + private suspend fun directMessage( + database: MantraDatabase, + activeKeyPair: KeyPair, + groupEvent: GroupEvent, + chatRoomId: String, + wrap: Event, + senderIdentity: HexKey? + ): ChatMessage? { + val recipientPublicKey = wrap.tags.firstTagValue("p") + + return when (val delivery = MarmotDirectMessage.classify(wrap, activeKeyPair, senderIdentity)) { + // Our own words coming back. We cannot open our own wrap -- its key was + // discarded at send time -- so this is indistinguishable from a + // bystander's view by decryption alone, and filing it as one would + // replace the words on the row `sendChatMessage` wrote with an empty + // placeholder. That row is the only copy of them that exists. + // `NostrDao.persistInboundChatMessage` carries the same guard, for the + // same reason, on the NIP-17 path. + MarmotDirectMessage.Delivery.Ours -> { + logger.d("Direct message ${wrap.id} is ours; the row written on send is the only copy") + null + } + + // A forged or malformed message costs its own line and nothing else. It + // is dropped rather than thrown because the caller is inside + // storeNostrEvent's transaction, and this must not cost the whole event. + is MarmotDirectMessage.Delivery.Rejected -> { + logger.e("Dropping direct message ${wrap.id}: ${delivery.reason}") + null + } + + // Not ours to read. The group is meant to see that a private message was + // sent and to whom, and nothing more. + MarmotDirectMessage.Delivery.Unreadable -> { + // Relays redeliver and negentropy re-syncs; the wrap yields the same + // id every time, so this is what keeps a second delivery from becoming + // a second line. ChatMessage.id is autogenerated and would take a + // duplicate. + if (database.chatMessageDao().getChatMessagesByMarmotInnerEventId(wrap.id) != null) { + return null + } + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_DIRECT_MESSAGE, + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = wrap.id, + senderPublicKey = senderIdentity ?: wrap.pubKey, + directMessageRecipientPublicKey = recipientPublicKey, + isUserMessage = false, + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(wrap.createdAt), + content = "" + ) + } + + is MarmotDirectMessage.Delivery.Readable -> { + val opened = delivery.opened + + if (database.chatMessageDao().getChatMessagesByMarmotInnerEventId(opened.rumor.id) != null) { + return null + } + + // Keyed on the rumor, not the wrap. This is the id the sender queued, + // so both sides of the conversation hold the same message under the + // same identity. + database.marmotInnerEventDao().upsert( + MarmotInnerEvent( + id = opened.rumor.id, + publicKey = opened.rumor.pubKey, + marmotGroupEventId = groupEvent.id, + tags = opened.rumor.tags, + content = opened.rumor.content, + chatRoomId = chatRoomId, + kind = opened.rumor.kind, + createdAt = Instant.fromEpochSeconds(opened.rumor.createdAt) + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_DIRECT_MESSAGE, + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = opened.rumor.id, + senderPublicKey = opened.seal.pubKey, + directMessageRecipientPublicKey = recipientPublicKey, + isUserMessage = false, + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(opened.rumor.createdAt), + content = opened.rumor.content + ) + } + } + } + + /** + * [senderIdentity] is the MLS credential identity of the leaf that sent this, + * resolved by the caller, which holds the group. It is not derivable from the + * payload: a direct message's wrap is keyed to a throwaway key and names nobody, + * and even for kinds that do carry a pubkey the MLS frame is the authenticated + * source while the field is merely asserted. + * + * It is also the only thing [ChatMessage.isUserMessage] can honestly be computed + * from. Every arm below used to compare the active key against `groupEvent.pubKey` + * -- the kind:445's signer, which is a fresh random ephemeral key on every send + * (`NostrSignerInternal(KeyPair())` in `encryptAndSendMarmotInnerEvent`). That can + * never equal anybody's identity, so it was false for every Marmot message in + * every room, and every one of them rendered as somebody else's. + * + * Null for results that carry no leaf index -- the commit and proposal statuses -- + * which yields false, exactly as before. + */ suspend fun fromGroupEventResult( database: MantraDatabase, activeKeyPair: KeyPair, groupEvent: GroupEvent, - groupEventResult: GroupEventResult + groupEventResult: GroupEventResult, + senderIdentity: HexKey? = null ): ChatMessage? { return when(groupEventResult) { is GroupEventResult.ApplicationMessage -> { 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 +436,51 @@ 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, - ) - ) + // A direct message is not a nip30303 payload to apply. It is + // gift wrapped for one member, and what this device does with it + // depends only on whether its key opens it. + if (event.kind == GiftWrapEvent.KIND) { + return directMessage( + database = database, + activeKeyPair = activeKeyPair, + groupEvent = groupEvent, + chatRoomId = groupEventResult.groupId, + wrap = event, + senderIdentity = senderIdentity + ) + } - 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 = senderIdentity == activeKeyPair.pubKey.toHex(), + chatRoomId = groupEventResult.groupId, + createdAt = Instant.fromEpochSeconds(event.createdAt), + content = event.content, + ) + } else { + applyInnerEvent( + database = database, + groupId = groupEventResult.groupId, + event = payload ?: event, + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = event.id, + senderPublicKey = event.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), + createdAt = Instant.fromEpochSeconds(event.createdAt), + ) } } is GroupEventResult.CommitPending -> { @@ -507,7 +490,7 @@ data class ChatMessage( marmotInnerEventId = null, messageType = "pendingCommit", senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), content = "Pending Commit in epoch ${groupEventResult.epoch}", // TODO: Figure out what to do here... @@ -520,7 +503,7 @@ data class ChatMessage( marmotInnerEventId = null, messageType = "processedCommit", senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), content = "Processed Commit in epoch ${groupEventResult.newEpoch}", // TODO: Figure out what to do here... @@ -533,7 +516,7 @@ data class ChatMessage( // groupEventId = groupEvent.id, // messageType = "duplicate", // senderPublicKey = groupEvent.pubKey, -// isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, +// isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), // chatRoomId = groupEventResult.groupId, // createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), // content = groupEventResult.innerEventJson, // TODO: Figure out what to do here... @@ -546,7 +529,7 @@ data class ChatMessage( // groupEventId = groupEvent.id, // messageType = "error", // senderPublicKey = groupEvent.pubKey, -// isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, +// isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), // chatRoomId = groupEventResult.groupId, // createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), // content = groupEventResult.innerEventJson, // TODO: Figure out what to do here... @@ -560,7 +543,7 @@ data class ChatMessage( marmotInnerEventId = null, messageType = "proposalStaged", senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), content = "Proposal staged: ${groupEventResult.senderLeafIndex}", // TODO: Figure out what to do here... @@ -573,7 +556,7 @@ data class ChatMessage( marmotInnerEventId = null, messageType = "undecryptableOuterLayer", senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), content = "Undecryptable Message", // TODO: Figure out what to do here... @@ -581,5 +564,330 @@ 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. + */ + internal suspend fun applyInnerEvent( + database: MantraDatabase, + groupId: String, + event: Event, + marmotGroupEventId: HexKey?, + marmotInnerEventId: HexKey?, + senderPublicKey: HexKey, + isUserMessage: Boolean, + createdAt: Instant, + ): ChatMessage? { + return when (event.kind) { + ChatEvent.KIND -> { + ChatMessage( + giftWrapPayloadId = null, + messageType = "message", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + 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 = marmotGroupEventId, + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = "artifact", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + 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 = marmotGroupEventId, + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = "artifactVersion", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + 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 = marmotGroupEventId, + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = "chapter", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + 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 = marmotGroupEventId, + ) + ) + + // 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 = marmotGroupEventId, + ) + ) + + + ChatMessage( + giftWrapPayloadId = null, + messageType = "dialect", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + 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 = marmotGroupEventId, + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = "translationArtifactVersion", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + 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 = marmotGroupEventId, + ) + ) + // 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 = marmotGroupEventId, + ) + ) + + + } + 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 = marmotGroupEventId, + ) + ) + ChatMessage( + giftWrapPayloadId = null, + messageType = "translation", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text + ) + } + } + // 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 + + // The room saying what key it signs with. Standing state rather + // than something that happened, and the room's own id already + // says it to anyone who can derive -- so there is nothing here a + // reader of the transcript needs told. Falling through to + // "unsupported" would put the raw announcement in the chat. + GroupKeyStateEvent.KIND -> null + + else -> { + ChatMessage( + giftWrapPayloadId = null, + messageType = "unsupported", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + 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/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/GiftWrapMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt index c0b471ea..a7b92840 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt @@ -7,13 +7,11 @@ import androidx.room3.PrimaryKey import co.touchlab.kermit.Logger 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.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlin.time.Clock import kotlin.time.Instant @@ -83,6 +81,18 @@ data class GiftWrapMessage( @Ignore private val logger = Logger.withTag("GiftWrapMessage") + /** + * Whether this gift wrap is addressed to [keyPair], i.e. whether we hold the + * private key that can unwrap it. + * + * NIP-59 encrypts the wrap to its recipient using an ephemeral key that + * [GiftWrapEvent.create] throws away, so a wrap addressed to anyone else can + * never be decrypted by us, not even one we sent ourselves. + */ + fun isAddressedTo( + keyPair: KeyPair + ): Boolean = receiverPublicKey.equals(keyPair.pubKey.toHexKey(), ignoreCase = true) + suspend fun decryptGiftWrapSeal( keyPair: KeyPair ): GiftWrapSeal? { @@ -105,19 +115,12 @@ data class GiftWrapMessage( giftWrapEvent.recipientPubKey()?.let { recipientPublicKey -> logger.d("Recipient PublicKey: $recipientPublicKey") - if (keyPair.pubKey.toHexKey() != recipientPublicKey) { - logger.e("We are unwrapping a message we may have sent from ${keyPair.pubKey.toHexKey()}") - - keyPair.privKey?.let { privateKey -> - val sealJSON = Nip44.decrypt( - giftWrapEvent.content, - privateKey = privateKey, - pubKey = giftWrapEvent.pubKey.hexToByteArray() - ) - logger.d("SealJSON: $sealJSON") - - null - } + if (!recipientPublicKey.equals(keyPair.pubKey.toHexKey(), ignoreCase = true)) { + // Not ours to open, and no key we hold ever will be: the wrap is + // encrypted to the recipient with a one-off key that + // GiftWrapEvent.create() discards, so not even the sender can + // unwrap their own gift wrap. + logger.d("GiftWrap $id is addressed to $recipientPublicKey, not to us") } else { val nostrSigner = NostrSignerInternal( keyPair = KeyPair(privKey = keyPair.privKey) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt new file mode 100644 index 00000000..48acc1b5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt @@ -0,0 +1,106 @@ +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.managers.SharedKeyDerivation + +/** + * Which shared key a room signs with, as this device has been told. + * + * Written from a `GroupKeyStateEvent` -- the room's own announcement of the + * ceremony behind it -- and read when a signing request arrives, to pick the + * secret share out of the right [DkgSession]. A device that took part in more + * than one ceremony holds more than one share, and they are not + * interchangeable: a partial signature made with the wrong one cannot + * aggregate. + * + * ### Why this is a row and not a rederivation + * + * `FrostSigningManager.completedKey` found the key by walking every ceremony + * this device holds a share for and rederiving each one's room id until one + * matched. That works, and it stays as the fallback for rooms made before this + * table existed, but it can only find rooms derived at the *default* path -- + * the one path the constant names. A room derived anywhere else was invisible + * to it. [derivationPath] is what fixes that, which is also the reason the path + * is stored rather than assumed. + * + * ### Nothing secret lives here + * + * [thresholdPublicKey] is the key signatures verify against, not the secret + * behind it, and [dkgSessionId] is a pointer. The share itself never leaves + * `DkgSession.secretShare` on the device that generated it. + * + * One row per room: a room is derived from one key, and a group that re-runs + * its ceremony derives a different room rather than re-keying this one. + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = ChatRoom::class, + parentColumns = ["id"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + Index("dkgSessionId"), + ], +) +data class GroupKeyState( + /** The Marmot room this is the key state for. Its id is the derived key. */ + @PrimaryKey + val chatRoomId: String, + + /** + * The ceremony that made the key, and so the row holding this device's + * share of it. + * + * Not a foreign key on purpose. A member can be in the room without + * holding a share -- they were added after the ceremony, or reinstalled -- + * and the state is still worth keeping: it says what the room signs with, + * which is what tells them they cannot. + */ + val dkgSessionId: String, + + /** The group's ChillDKG threshold public key, 33-byte compressed hex. */ + val thresholdPublicKey: HexKey, + + /** The path [chatRoomId] was derived at, `m/9420/0/0` style. */ + val derivationPath: String, + + /** Who announced it. Kept for the transcript; the derivation is what vouches for it. */ + val announcedBy: HexKey, + + /** The announcement's own timestamp, so the newest state per room wins. */ + val announcedAt: Instant, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt, + override val savedAt: Instant = createdAt, +): TimestampedEntity, LocalStoreEntity { + /** [derivationPath] as indices, or null if it is not a walkable path. */ + fun pathIndices(): List? = SharedKeyDerivation.parsePathString(derivationPath) + + /** + * Whether this state actually describes the room it claims to. + * + * The room's id is the threshold key derived at the path, so this is the + * whole of the trust model: a state that does not rederive its own room was + * announced by somebody pointing the room at a key it was not made from. + * Checked before the row is written and cheap enough to check again. + */ + fun verifies(): Boolean { + val path = pathIndices() ?: return false + + return runCatching { + SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) == chatRoomId + }.getOrDefault(false) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotCommitResult.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotCommitResult.kt index 160a7a5c..ef749688 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotCommitResult.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotCommitResult.kt @@ -8,6 +8,7 @@ import press.mantra.compose.database.model.traits.SoftDeletableEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.database.model.traits.UserViewableEntity import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlin.time.Clock import kotlin.time.Instant @@ -72,6 +73,40 @@ data class MarmotCommitResult( // TODO: Rename this to GiftWrapPayload... companion object { const val TAG = "MarmotCommitResult" + /** + * The persisted record of a commit, built from the [CommitResult] that produced it. + * + * The five payload fields are carried over from quartz verbatim -- same names, same + * order, same `ByteArray` type on both sides of the copy -- so a value taken from the + * wrong field of the right object typechecks and reaches the database unnoticed. + * `framedCommitBytes = commitResult.preCommitExporterSecret` survived exactly that way, + * storing the group's pre-commit exporter secret in the column documented to hold a + * broadcastable MLS envelope. + * + * Mapping here rather than at the call site means it is written once, in declaration + * order, and pinned by MarmotCommitResultMappingTest. + */ + fun from( + commitEventId: HexKey, + commitResult: CommitResult, + chatRoomId: HexKey, + userPublicKey: HexKey, + peerKeyPackageEventId: HexKey, + isOneMemberInitialGroupCreation: Boolean, + createdAt: Instant, + ): MarmotCommitResult = MarmotCommitResult( + id = commitEventId, + userPublicKey = userPublicKey, + peerKeyPackageEventId = peerKeyPackageEventId, + chatRoomId = chatRoomId, + isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation, + commitBytes = commitResult.commitBytes, + welcomeBytes = commitResult.welcomeBytes, + groupInfoBytes = commitResult.groupInfoBytes, + framedCommitBytes = commitResult.framedCommitBytes, + preCommitExporterSecret = commitResult.preCommitExporterSecret, + createdAt = createdAt, + ) } override fun equals(other: Any?): Boolean { 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..78d628c9 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,11 +64,32 @@ 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 */ val marmotGroupEventId: String? = null, + /** + * Who this is a direct message to, or null for an ordinary group message. + * + * Set on the way out only. It is what tells the notary to gift wrap this row for one + * member instead of sending it to the whole group, and afterwards what tells the + * transcript the row was private. Inbound rows leave it null -- the recipient is on + * the wrap's `p` tag, which is where every member reads it from. + * + * See docs/marmot-direct-messages.md. + */ + val directMessageRecipientPublicKey: HexKey? = null, + /** * Current time */ @@ -117,7 +138,9 @@ 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 (directMessageRecipientPublicKey != other.directMessageRecipientPublicKey) return false if (createdAt != other.createdAt) return false if (updatedAt != other.updatedAt) return false if (savedAt != other.savedAt) return false @@ -136,7 +159,9 @@ 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 + (directMessageRecipientPublicKey?.hashCode() ?: 0) result = 31 * result + createdAt.hashCode() result = 31 * result + updatedAt.hashCode() result = 31 * result + savedAt.hashCode() 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/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index b12e3723..4eacf017 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -173,24 +173,58 @@ class DatabaseChatRepository( override suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, - messageType: Kind + messageType: Kind, + directMessageRecipientPublicKey: HexKey? ) { logger.i("sendChatMessage($text): $localChatRoom") val mlsGroup = localChatRoom.chatRoom.toMlsGroup() if (mlsGroup != null) { - // Create ChatRumor... - val createdAt = Clock.System.now().epochSeconds - val marmotInnerEventKind = if (messageType == ChatMessageEvent.KIND) { - ChatEvent.KIND // nip-17 chatMessageEvent.kind is in MLS chatMessage.kind - } else { - messageType + // Resolved before anything is written. A recipient outside the room cannot be + // sent to -- the wrap would be undecryptable by every member including them, + // while the group still saw that a private message had gone somewhere -- and + // the room is also where the relay hint for the `p` tag comes from. + val directMessageRecipient = directMessageRecipientPublicKey?.let { recipient -> + require(recipient != localChatRoom.chatRoom.userPublicKey) { + "Cannot send a direct message to yourself: the wrap's throwaway key means it could never be read back" + } + + localChatRoom.localParticipants + .map { it.participant } + .firstOrNull { it.participantPublicKey == recipient } + ?: throw IllegalArgumentException( + "Cannot send a direct message to $recipient: not a member of ${localChatRoom.chatRoom.id}" + ) } + // Create ChatRumor... + val createdAt = Clock.System.now().epochSeconds + val marmotInnerEventKind = when { + // A direct message is a NIP-17 chat message that happens to travel inside + // the group, so it keeps kind:14 instead of being mapped down to the + // group's kind:9. That is also what tells the two apart on the way back in. + directMessageRecipient != null -> ChatMessageEvent.KIND + messageType == ChatMessageEvent.KIND -> ChatEvent.KIND // nip-17 chatMessageEvent.kind is in MLS chatMessage.kind + else -> messageType + } + + val marmotInnerEventTags = directMessageRecipient?.let { recipient -> + arrayOf( + PTag.assemble( + recipient.participantPublicKey, + recipient.relayHint?.let { NormalizedRelayUrl(it) } + ) + ) + } ?: emptyArray() + + // For a direct message the queued row IS the rumor the wrap will carry, so + // this id is the one the recipient arrives at after unwrapping -- and, on the + // way out, the one the notary uses to find this chat message again. The wrap + // built around it has its own id, which is why that lookup keys on the row. val marmotInnerEventId = EventHasher.hashId( pubKey = localChatRoom.chatRoom.userPublicKey, createdAt = createdAt, - tags = emptyArray(), + tags = marmotInnerEventTags, content = text, kind = marmotInnerEventKind ) @@ -200,10 +234,11 @@ class DatabaseChatRepository( id = marmotInnerEventId, publicKey = localChatRoom.chatRoom.userPublicKey, createdAt = Instant.fromEpochSeconds(createdAt), - tags = emptyArray(), + tags = marmotInnerEventTags, content = text, chatRoomId = localChatRoom.chatRoom.id, - kind = marmotInnerEventKind + kind = marmotInnerEventKind, + directMessageRecipientPublicKey = directMessageRecipient?.participantPublicKey ) ) @@ -216,6 +251,15 @@ class DatabaseChatRepository( giftWrapPayloadId = null, marmotGroupEventId = null, marmotInnerEventId = marmotInnerEventId, + // The sender's only copy: the wrap is sealed with a key that is + // discarded, so nothing can reconstruct these words from the + // transcript later, on this device or any other. + messageType = if (directMessageRecipient != null) { + ChatMessage.TYPE_DIRECT_MESSAGE + } else { + "message" + }, + directMessageRecipientPublicKey = directMessageRecipient?.participantPublicKey ) ) } else { @@ -280,6 +324,21 @@ class DatabaseChatRepository( giftWrapPayload: GiftWrapPayload, nostrSignerSync: NostrSignerSync ) { + // An MLS room must never produce a NIP-17 gift wrap. Its messages already travel + // inside kind:445, and its direct messages are real, correctly signed NIP-59 wraps + // -- indistinguishable from something this path would be right to publish. The + // only thing keeping one off a relay is that it never becomes a GiftWrapPayload, + // so refuse here too rather than trusting every future caller to know that. + // See docs/marmot-direct-messages.md. + val chatRoom = database.chatRoomDao().findChatRoomById(giftWrapPayload.chatRoomId) + if (chatRoom?.chatRoom?.mlsGroupState != null) { + logger.e( + "Refusing to seal payload ${giftWrapPayload.id}: ${giftWrapPayload.chatRoomId} is an MLS room, " + + "and sealing would broadcast it to relays" + ) + return + } + database.participantDao().findParticipantsByChatRoomId(giftWrapPayload.chatRoomId).forEach { participant -> if (giftWrapPayload.kind == WelcomeEvent.KIND) { logger.i("Only giftWrap welcomeEvent payload (${giftWrapPayload.id}) to the participant who published the related keyPackage") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt index bd1eeb6f..daacde83 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt @@ -3,9 +3,11 @@ package press.mantra.compose.database.repository import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.managers.ChillDkgRitualManager +import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.repository.DkgRepository import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -48,6 +50,31 @@ class DatabaseDkgRepository( override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = ChillDkgRitualManager.pendingApproval(database, session) + override suspend fun announceGroupKeyState( + chatRoomId: String, + userPublicKey: HexKey, + session: DkgSession + ): GroupKeyState? { + val thresholdPublicKey = session.thresholdPublicKey ?: return null + + return try { + GroupKeyStateManager.announce( + database = database, + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + dkgSessionId = session.id, + thresholdPublicKey = thresholdPublicKey + ) + } catch (e: Throwable) { + // The room not deriving from the key it is about to announce is a + // bug rather than a condition, but it is not worth failing the room + // over: the group still has a working chat, and signing simply falls + // back to the rederivation scan it used before there was a state. + logger.e("Error announcing the key state for $chatRoomId", e) + null + } + } + override suspend fun approve( localChatRoom: LocalChatRoom, sessionId: String, 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/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/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..5fbdbbaf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -0,0 +1,1227 @@ +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.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.GroupKeyState +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 +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, in its #admins room. + * + * The shape is [ChillDkgRitualManager]'s, deliberately: the member who proposes + * 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. + * + * ### 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, + innerEvent: Event, + userPublicKey: HexKey + ) { + val sessionId = FrostSigningEvents.parseSessionId(innerEvent.tags) + if (sessionId == null) { + logger.w("FROST payload ${innerEvent.id} has no session tag; dropping") + return + } + + val session = if (innerEvent.kind == FrostSigningEvents.PROPOSAL) { + acceptProposal( + database = database, + localChatRoom = localChatRoom, + innerEvent = innerEvent, + sessionId = sessionId, + userPublicKey = userPublicKey + ) + } else { + // 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 ${innerEvent.kind}; leaving it stored") + return + } + + if (session.stage == FrostSigningStage.FAILED) { + logger.i("Session $sessionId already failed; ignoring kind ${innerEvent.kind}") + return + } + + if (!record(database, session, innerEvent)) 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, + innerEvent: Event, + 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(innerEvent.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(innerEvent.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(innerEvent.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 = innerEvent.pubKey, + 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.marmotInnerEventDao().getByChatRoomAndKinds( + chatRoomId = session.chatRoomId, + kinds = FrostSigningEvents.ALL.toList() + ).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 { innerEvent -> + if (!record(database, session, innerEvent)) 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, + innerEvent: Event + ): Boolean { + when (innerEvent.kind) { + FrostSigningEvents.PROPOSAL -> Unit // handled by acceptProposal + + FrostSigningEvents.NONCE, + FrostSigningEvents.PARTIAL_SIGNATURE -> { + val known = database.frostSigningSessionDao() + .getMessage(session.id, innerEvent.kind, innerEvent.pubKey) != null + + database.frostSigningSessionDao().upsert( + FrostSignerMessage( + sessionId = session.id, + signerPublicKey = innerEvent.pubKey, + kind = innerEvent.kind, + payload = innerEvent.content + ) + ) + + if (!known) { + announceStep(database, session, innerEvent.kind, innerEvent.pubKey) + } + } + + FrostSigningEvents.SIGNER_SET -> { + if (!isFromCoordinator(session, innerEvent)) return true + + val signerIds = FrostSigningEvents.parseSignerIds(innerEvent.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 = innerEvent.content, + signerIds = signerIds.joinToString(",") + ) + } else { + current + } + } + + if (!known) { + announceStep(database, session, innerEvent.kind, innerEvent.pubKey) + } + } + + FrostSigningEvents.SIGNATURE -> { + if (!isFromCoordinator(session, innerEvent)) return true + + val known = current(database, session).signature != null + + update(database, session) { current -> + if (current.signature == null) { + current.copy(signature = innerEvent.content) + } else { + current + } + } + + if (!known) { + announceStep(database, session, innerEvent.kind, innerEvent.pubKey) + } + } + + FrostSigningEvents.FAILURE -> { + fail( + database = database, + session = session, + reason = "Abandoned by ${innerEvent.pubKey.take(8)}: ${innerEvent.content}", + culprit = innerEvent.pubKey + ) + return false + } + } + + return true + } + + private fun isFromCoordinator( + session: FrostSigningSession, + innerEvent: Event + ): Boolean { + if (innerEvent.pubKey == session.coordinatorPublicKey) return true + + logger.w( + "Session ${session.id}: kind ${innerEvent.kind} from " + + "${innerEvent.pubKey.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) } + + // 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, + 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" + ) + } + } + + /** + * 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. + * + * 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 key a room signs with, or null when it has none. + * + * 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 bound together by the room's [GroupKeyState]: the announcement + * the room opened with, naming the ceremony behind it. That is a lookup + * rather than a search, and it carries the derivation path, so a room + * derived anywhere other than [SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH] + * is findable at all -- which the rederivation below cannot manage, since it + * can only rederive at the one path the constant names. + * + * The state buys none of its authority from being written down. It is only + * ever stored having rederived the room it describes, so what actually binds + * a room to a key is still that the room's id *is* the key, and a room still + * cannot be pointed at a key it was not derived from. + * + * Two fallbacks behind it, both for rooms that predate the table: the + * original scan over every ceremony this device holds a share for, and then + * 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? { + GroupKeyStateManager.keyStateFor(database, chatRoomId)?.let { state -> + database.dkgSessionDao().getSessionById(state.dkgSessionId)?.takeIf { key -> + key.stage == DkgRitualStage.COMPLETE && + key.secretShare != null && + key.thresholdPublicKey == state.thresholdPublicKey + }?.let { return it } + } + + 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 = + 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 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, + localChatRoom: LocalChatRoom, + session: FrostSigningSession, + kind: Kind, + content: String, + includeKey: Boolean = false, + signerIds: List? = null + ) { + val tags = FrostSigningEvents.assembleTags( + sessionId = session.id, + dkgSessionId = if (includeKey) session.dkgSessionId else null, + signerIds = signerIds + ) + + val createdAt = Clock.System.now().epochSeconds + + 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, + createdAt = Instant.fromEpochSeconds(createdAt), + tags = tags, + content = content, + chatRoomId = localChatRoom.chatRoom.id + ) + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt new file mode 100644 index 00000000..81ccbbad --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt @@ -0,0 +1,177 @@ +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.crypto.EventHasher +import kotlin.time.Clock +import kotlin.time.Instant +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.MarmotInnerEvent +import press.mantra.compose.nostr.frost.GroupKeyStateEvent + +/** + * Announces and files what key a room signs with. + * + * The coordinator [announce]s once, as the new room's first message; every + * other member [record]s what arrives. Both ends land on the same + * [GroupKeyState] row, which is what a signing request is resolved against -- + * see `FrostSigningManager.completedKey`. + * + * Nothing here is trusted on the strength of who said it. A state is kept only + * if the room's id rederives from the key it names, which is the same check + * `completedKey` used to make by scanning, and the reason a coordinator cannot + * point a room at a key it was not made from. + */ +object GroupKeyStateManager { + private const val TAG = "GroupKeyStateManager" + + private val logger = Logger.withTag(TAG) + + /** The key state a room signs under, or null while it has none. */ + suspend fun keyStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? = + database.groupKeyStateDao().getByChatRoomId(chatRoomId) + + /** + * Says what the freshly made room signs with, and files it locally. + * + * Called once, by the member who created the room, before anybody has been + * added to it -- the announcement is the room's first message, so a member + * arriving on a welcome finds it waiting rather than having to be told + * separately. + * + * Queued before it is recorded, matching the signing pipeline: a crash + * between the two costs a duplicate announcement, which [record] folds + * away, rather than a room whose key nobody ever named. + * + * Refuses to announce a state that does not describe the room, because a + * state that fails [GroupKeyState.verifies] here is this device having + * derived the room from one key and announced another -- a bug worth + * failing on rather than broadcasting. + */ + suspend fun announce( + database: MantraDatabase, + chatRoomId: String, + userPublicKey: HexKey, + dkgSessionId: String, + thresholdPublicKey: HexKey, + path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, + createdAt: Long = Clock.System.now().epochSeconds + ): GroupKeyState { + val state = GroupKeyState( + chatRoomId = chatRoomId, + dkgSessionId = dkgSessionId, + thresholdPublicKey = thresholdPublicKey, + derivationPath = SharedKeyDerivation.formatPath(path), + announcedBy = userPublicKey, + announcedAt = Instant.fromEpochSeconds(createdAt) + ) + + check(state.verifies()) { + "Room $chatRoomId is not derived from $thresholdPublicKey at ${state.derivationPath}" + } + + val tags = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = dkgSessionId, + path = path + ) + + 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 = userPublicKey, + createdAt = createdAt, + tags = tags, + content = thresholdPublicKey, + kind = GroupKeyStateEvent.KIND + ), + publicKey = userPublicKey, + kind = GroupKeyStateEvent.KIND, + createdAt = Instant.fromEpochSeconds(createdAt), + tags = tags, + content = thresholdPublicKey, + chatRoomId = chatRoomId + ) + ) + + logger.i("Announcing key ${state.thresholdPublicKey} for room $chatRoomId at ${state.derivationPath}") + + return database.groupKeyStateDao().replace(state) + } + + /** + * Files an inbound announcement, or drops it and says why. + * + * Storing is all this adds to [stateFrom], which is where the deciding + * happens -- kept apart so the check a member's safety rests on can be + * exercised without standing up a database. + */ + suspend fun record( + database: MantraDatabase, + chatRoomId: String, + innerEvent: Event + ): GroupKeyState? = + stateFrom(chatRoomId, innerEvent)?.let { database.groupKeyStateDao().replace(it) } + + /** + * The state an announcement amounts to, or null if it amounts to none. + * + * Every reason to return null is a reason the announcement does not describe + * this room, and none of them are about who sent it: a member with no share, + * or none of the ceremony at all, can announce a true state and it is still + * true. What cannot be tolerated is a state naming a key the room was not + * derived from, because acting on one means signing with a share that will + * not aggregate -- or, worse, treating a key the group does not hold as the + * key the group holds. + */ + fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? { + val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags) + if (announced != null && announced != chatRoomId) { + logger.w("Key state for room $announced arrived in $chatRoomId; dropping") + return null + } + + val thresholdPublicKey = GroupKeyStateEvent.parseThresholdPublicKey(innerEvent.content) + if (thresholdPublicKey == null) { + logger.w("Key state in $chatRoomId carries no threshold key; dropping") + return null + } + + val dkgSessionId = GroupKeyStateEvent.parseDkgSessionId(innerEvent.tags) + if (dkgSessionId == null) { + logger.w("Key state in $chatRoomId names no ceremony; dropping") + return null + } + + val path = GroupKeyStateEvent.parsePath(innerEvent.tags) + if (path == null) { + logger.w("Key state in $chatRoomId carries no walkable derivation path; dropping") + return null + } + + val state = GroupKeyState( + chatRoomId = chatRoomId, + dkgSessionId = dkgSessionId, + thresholdPublicKey = thresholdPublicKey, + derivationPath = SharedKeyDerivation.formatPath(path), + announcedBy = innerEvent.pubKey, + announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt) + ) + + // The whole trust model, in one line. Anybody may say what this room + // signs with; only the truth rederives the room they said it in. + if (!state.verifies()) { + logger.w( + "Key state from ${innerEvent.pubKey} names $thresholdPublicKey at " + + "${state.derivationPath}, which does not derive room $chatRoomId; dropping" + ) + return null + } + + return state + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt index e3492a7f..7627fcfc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt @@ -34,6 +34,7 @@ import press.mantra.compose.network.relays.LiveSubscriptionTransport import press.mantra.compose.network.relays.isRelayBackPressure import press.mantra.compose.network.sockets.NostrIncomingMessage import press.mantra.compose.network.sockets.endsLiveSubscription +import press.mantra.compose.nostr.Nip17Filters import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository @@ -118,33 +119,17 @@ class LiveSubscriptionManager( private val MAX_REOPEN_DELAY = 5.minutes - /** - * Matches what `ChatRoomListViewModel` queued, deliberately: the id a negentropy - * request is stored under is a hash of its filter, so an identical filter shape - * collapses into the same row rather than queueing the same reconciliation twice - * while both callers exist. - * - * The value itself barely matters — the negentropy pump drops `limit` outright, and it - * only survives into the plain-REQ fallback. - */ - private const val CATCH_UP_LIMIT = 50 - - /** +/** * Every gift wrap addressed to us: direct messages, and the Marmot Welcome events * that make us a member of a group. * - * There is deliberately no `since`. NIP-59 randomizes a wrap's `created_at` into the - * past — our own outbound path stamps them with `TimeUtils.randomWithTwoDays()` — so - * a wrap published right now can carry a timestamp two days old, and a `since` - * anywhere near the present would silently drop it. "Some messages just never - * arrive" is the worst failure mode to debug, and re-receiving a wrap costs one - * no-op `storeNostrEvent`. + * Deferred to [Nip17Filters] rather than written here, because this shape has been + * got wrong in several call sites before and the reasons it is what it is belong in + * one place — chiefly the absence of a `since`, which looks like an oversight and is + * the difference between receiving mail and silently not. */ - internal fun giftWrapFilter(publicKey: HexKey) = Filter( - kinds = listOf(GiftWrapEvent.KIND), - tags = mapOf("p" to listOf(publicKey)), - limit = INITIAL_HISTORY_LIMIT, - ) + internal fun giftWrapFilter(publicKey: HexKey) = + Nip17Filters.liveInbox(publicKey, limit = INITIAL_HISTORY_LIMIT) /** * Marmot group messages for a chunk of the groups we belong to. @@ -266,11 +251,7 @@ class LiveSubscriptionManager( private suspend fun queueCatchUpSynchronization(publicKey: HexKey) { val groupIds = liveGroupIds(publicKey) - val giftWrapFilter = SynchronizationFilter( - kinds = arrayOf(GiftWrapEvent.KIND), - tags = mapOf("p" to listOf(publicKey)), - limit = CATCH_UP_LIMIT, - ) + val giftWrapFilter = Nip17Filters.inbox(publicKey) val requests = mutableListOf() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt index c7be3dba..af284650 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt @@ -32,10 +32,12 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.MarmotDirectMessage import press.mantra.compose.nostr.Relays import kotlin.time.Clock @@ -61,6 +63,45 @@ object MarmotInboundManager { private val commitTracker = CommitOrdering.EpochCommitTracker() + /** + * Why an inner application event may not be accepted from this sender, or null. + * + * MIP-03: an inner event's `pubkey` MUST equal the MLS sender's credential identity, + * so a member cannot mint events claiming a different author. + * + * A gift wrap is exempt. Its pubkey is a throwaway key by construction -- see + * [MarmotDirectMessage] -- so there is no author field to compare and the check + * cannot be applied as written. The authorship claim moves inside, to the signed + * kind:13 seal, which [MarmotDirectMessage.classify] binds to this same identity + * before a word is rendered: a verified signature bound to an MLS leaf, rather than a + * plaintext field compared to one. + * + * The exemption is on kind:1059 alone. Widening it to any other kind, or dropping the + * kind guard, hands every member the ability to publish events as anybody. + * + * Other Marmot clients do not have this carve-out and drop these messages as + * impersonation. See docs/marmot-direct-messages.md. + * + * [senderIdentity] is required rather than merely compared: every sender-derived + * field downstream reads from it -- who a message is from, and whether it is ours -- + * because a gift wrap payload carries no author of its own. + */ + fun mip03Rejection( + innerEventKind: Int, + innerEventPubKey: HexKey, + senderIdentity: HexKey?, + ): String? { + if (senderIdentity == null) { + return "No MLS credential identity for the sender leaf" + } + + if (innerEventKind != GiftWrapEvent.KIND && innerEventPubKey != senderIdentity) { + return "MIP-03: inner event pubkey ($innerEventPubKey) does not match MLS sender identity ($senderIdentity)" + } + + return null + } + suspend fun processGroupMembershipChanges( database: MantraDatabase, localChatRoom: LocalChatRoom, @@ -267,11 +308,9 @@ object MarmotInboundManager { if (innerEvent != null) { val senderIdentity = mlsGroup.memberIdentityHex(decrypted.senderLeafIndex) - if (senderIdentity == null || innerEvent.pubKey != senderIdentity) { - return GroupEventResult.Error( - groupId, - "MIP-03: inner event pubkey (${innerEvent.pubKey}) does not match MLS sender identity ($senderIdentity)", - ) + + mip03Rejection(innerEvent.kind, innerEvent.pubKey, senderIdentity)?.let { reason -> + return GroupEventResult.Error(groupId, reason) } } 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..efe838a1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MlsGroupCache.kt @@ -0,0 +1,152 @@ +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 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. + * + * 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? = cache.withInstance( + key = chatRoomId, + storedState = storedStateHex, + build = build, + save = save, + block = block + ) +} + +/** + * 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("$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 + } + + // 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 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/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt index 1a138c00..cde305fe 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt @@ -115,12 +115,40 @@ object SharedKeyDerivation { ?.firstOrNull { it.trimStart().startsWith(PATH_MARKER) } ?: return null - val path = line.trimStart().removePrefix(PATH_MARKER).trim() + return parsePathString(line.trimStart().removePrefix(PATH_MARKER).trim()) + } + + /** The indices [tweakScalar] can actually tell apart: a BIP32-shaped uint32. */ + private val INDEX_RANGE = 0L..0xFFFFFFFFL + + /** + * A bare `m/9420/0/0` as indices, or null if it is not one. + * + * Split out from [parsePath] because a path also travels on its own, in a + * `GroupKeyStateEvent`'s [press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag], + * where there is no description to dig it out of. Both spellings have to + * agree on what a path is, so there is only one reader of one. + * + * Indices outside a uint32 are rejected, which matters because a path now + * arrives from the wire rather than only from [MARMOT_ADMIN_GROUP_PATH]. + * [tweakScalar] serialises an index as its low four bytes, so without this + * `m/4294967296/0/0` walks to the same key as `m/0/0/0` and a room could be + * described by a path nobody would write. Nothing is stolen by that -- a + * state still has to derive the room it names -- but it would make + * [formatPath] a lossy round trip and leave two spellings of one path for + * any later code to disagree over. Negative indices go the same way: they + * are not a thing a path has. + */ + fun parsePathString(path: String): List? { if (!path.startsWith("m/")) return null return path.removePrefix("m/") .split("/") - .map { segment -> segment.toLongOrNull() ?: return null } + .map { segment -> + val index = segment.toLongOrNull() ?: return null + if (index !in INDEX_RANGE) return null + index + } .takeIf { it.isNotEmpty() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt new file mode 100644 index 00000000..4a7fc810 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt @@ -0,0 +1,248 @@ +package press.mantra.compose.nostr + +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.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * A one-to-one message carried inside a Marmot group, as the application payload of a + * kind:445 group event. See docs/marmot-direct-messages.md. + * + * The payload is a stock NIP-59 gift wrap: a throwaway-keyed kind:1059 around a + * sender-signed kind:13 seal around the kind:14 rumor that holds the words. Every member + * of the group decrypts the MLS layer and sees the wrap; only the recipient can open it. + * + * Everything here is a pure function of its arguments, with no database and no MLS state, + * which is what makes it testable — Room-backed code cannot be unit-tested in this project. + * + * **Nothing built here may ever be broadcast.** It is a genuine, correctly signed NIP-59 + * gift wrap, indistinguishable from one the NIP-17 path would be right to publish, so the + * only thing keeping it off a relay is the tables it is kept out of: a Marmot direct + * message lives in `MarmotInnerEvent`, never in `GiftWrapPayload` or `GiftWrapMessage`. + */ +object MarmotDirectMessage { + /** + * A wrap that opened, both layers of it. + * + * The seal is returned alongside the rumor because it is the only layer that names + * the sender, and the caller has to bind it to the MLS sender identity before it + * believes a word — see [MarmotDirectMessage] and the inbound path. + */ + data class Opened( + val seal: Event, + val rumor: Event, + ) + + /** + * What this device should do with an arriving wrap. + * + * Separated from the filing of it so the decision can be tested: the persistence it + * drives needs a database, which cannot be unit-tested in this project, and the + * decision includes the check that replaces MIP-03 for kind:1059. That check is the + * only thing standing between somebody else's words and being rendered as a member's + * own, so it is not somewhere to rely on integration testing that does not exist. + */ + sealed interface Delivery { + /** + * Our own message coming back, which we cannot open -- the wrap's key was + * discarded at send time -- and so cannot tell apart from [Unreadable] by + * decryption alone. Only the MLS sender identity distinguishes them. + * + * File nothing: the row written on send holds the only copy of these words, and + * an unreadable line would replace them with a placeholder. + */ + data object Ours : Delivery + + /** Somebody else's. The group sees that it happened, and to whom. */ + data object Unreadable : Delivery + + /** Addressed to us, opened, and consistent with the leaf that sent it. */ + data class Readable( + val opened: Opened, + ) : Delivery + + /** Something is wrong with it. Drop it; do not render it as anybody's. */ + data class Rejected( + val reason: String, + ) : Delivery + } + + /** + * Decides what [wrap] is to this device, given the identity MLS says sent it. + * + * [senderIdentity] is not optional and is not read from the payload: a wrap names + * nobody, so the MLS frame is the only source of who sent it. Absent one there is + * nothing to attribute a line to and nothing to check the seal against. + */ + fun classify( + wrap: Event, + me: KeyPair, + senderIdentity: HexKey?, + ): Delivery { + if (senderIdentity == null) { + return Delivery.Rejected("no MLS sender identity to attribute it to") + } + + if (senderIdentity == me.pubKey.toHexKey()) return Delivery.Ours + + val opened = open(wrap, me) ?: return Delivery.Unreadable + + // The seal is the one layer the sender signs. Binding it to the leaf MLS says + // sent this message is what replaces MIP-03's pubkey check for kind:1059 -- + // a verified signature bound to a leaf, rather than a plaintext field compared + // to one. A divergence here is somebody passing off words as another member's. + if (opened.seal.pubKey != senderIdentity) { + return Delivery.Rejected( + "sealed by ${opened.seal.pubKey} but sent by $senderIdentity", + ) + } + + if (!opened.seal.verify()) { + return Delivery.Rejected("the seal's signature does not verify") + } + + if (opened.rumor.pubKey != opened.seal.pubKey) { + return Delivery.Rejected( + "rumor by ${opened.rumor.pubKey} inside a seal by ${opened.seal.pubKey}", + ) + } + + if (opened.rumor.kind != ChatMessageEvent.KIND) { + return Delivery.Rejected("unsupported rumor kind ${opened.rumor.kind}") + } + + return Delivery.Readable(opened) + } + + /** + * Builds the payload for a direct message to [recipientPublicKey]. + * + * [createdAt] is used unfuzzed on all three layers. NIP-59 randomises the wrap and + * the seal by up to two days to frustrate correlation at a relay, and both + * `GiftWrapEvent.create` and `SealedRumorEvent.create` default to that; there is no + * relay at this layer, the kind:445 around it already carries the true time, and + * fuzzing would only scatter the "sent a private message" line up to two days out of + * position in every other member's transcript. + * + * The rumor is assembled from exactly the fields the caller queued, so its id is the + * one the recipient computes after unwrapping — and, on the way out, the one the + * outbound path uses to find the chat message to link and broadcast. + */ + fun wrap( + signer: NostrSignerSync, + recipientPublicKey: HexKey, + kind: Kind, + createdAt: Long, + tags: Array>, + content: String, + recipientRelayHint: NormalizedRelayUrl? = null, + ): GiftWrapEvent { + val rumor = rumor(signer.pubKey, kind, createdAt, tags, content) + + // The only layer that names the sender, and the only one they sign. Everything + // the recipient believes about who wrote this rests on this signature. + // + // Built by hand rather than through SealedRumorEvent.create, which is suspend, + // takes a NostrSigner rather than the sync signer the outbound path holds, and + // defaults createdAt to the two-day fuzz. + val seal = + signer.signNormal( + createdAt = createdAt, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = signer.nip44Encrypt(rumor.toJson(), recipientPublicKey), + ) + + // create() mints and discards its own random key -- "GiftWrap is always a random + // key" -- which is the point: nothing in the wrap names the sender. Who sent it + // comes from the MLS frame instead, which is authenticated and cannot be forged. + return GiftWrapEvent.create( + event = seal, + recipientPubKey = recipientPublicKey, + createdAt = createdAt, + recipientRelayHint = recipientRelayHint, + ) + } + + /** + * The rumor a direct message carries, unsigned and unencrypted. + * + * Exposed so the outbound queue can compute a row id before it has a signer, and so + * tests can assert that the id the recipient arrives at is the id the sender queued. + */ + fun rumor( + senderPublicKey: HexKey, + kind: Kind, + createdAt: Long, + tags: Array>, + content: String, + ): Event = + RumorAssembler.assembleRumor( + pubKey = senderPublicKey, + ev = + EventTemplate( + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + ), + ) + + /** + * Opens [wrap] with [me]'s key, or returns null if it was not addressed to them. + * + * Trying is the test: the `p` tag is a routing hint, not the authority, and the wrap + * either decrypts with our key or it does not. Null covers every way that can fail -- + * somebody else's message, a malformed payload, a layer that is not the kind it + * claims -- because the caller is inside inbound processing for a kind:445 that may + * carry a perfectly good message for somebody, and a throw would abandon all of it. + * + * **The sender cannot open their own wrap.** The throwaway key is discarded at send + * time and nothing can reconstruct it, so a sender's own message returns null here + * too. That is a property, not a defect: see docs/marmot-direct-messages.md. Do not + * "fix" it by storing the throwaway private key. + */ + fun open( + wrap: Event, + me: KeyPair, + ): Opened? { + if (wrap.kind != GiftWrapEvent.KIND) return null + val privateKey = me.privKey ?: return null + + val seal = decryptEvent(wrap.content, privateKey, wrap.pubKey) ?: return null + if (seal.kind != SealedRumorEvent.KIND) return null + + val rumor = decryptEvent(seal.content, privateKey, seal.pubKey) ?: return null + + return Opened(seal = seal, rumor = rumor) + } + + private fun decryptEvent( + ciphertext: String, + privateKey: ByteArray, + fromPublicKey: HexKey, + ): Event? = + try { + Event.fromJsonOrNull( + Nip44.decrypt( + payload = ciphertext, + privateKey = privateKey, + pubKey = fromPublicKey.hexToByteArray(), + ), + ) + } catch (_: Exception) { + null + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Nip17Filters.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Nip17Filters.kt new file mode 100644 index 00000000..4d9a0da4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Nip17Filters.kt @@ -0,0 +1,63 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import press.mantra.compose.database.model.types.SynchronizationFilter + +/** + * The one filter shape that can return a NIP-17 message we are able to read. + * + * A gift wrap hides everything except who it is for. The author is the throwaway + * key [GiftWrapEvent.create] mints and discards, the content is sealed to the + * recipient, and `created_at` is randomised up to two days into the past. That + * leaves the `p` tag as the only clause worth writing, and it has to name us: + * naming a peer subscribes to mail no key of ours can open, and adding `authors` + * matches nothing on any relay, ever. Both mistakes were live in three separate + * call sites, so the filter is built in one place now and asserted in one place. + */ +object Nip17Filters { + + /** + * Everything gift-wrapped to [publicKey], capped at [limit] events. + * + * Deliberately carries no `since`. NIP-59 back-dates a wrap by up to two days, + * so a cursor built from the newest wrap we hold silently skips mail that was + * sent later but stamped earlier. + */ + fun inbox( + publicKey: HexKey, + limit: Int = DEFAULT_LIMIT, + ) = SynchronizationFilter( + kinds = arrayOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(publicKey)), + limit = limit, + ) + + /** + * The same inbox, shaped for a REQ instead of for a negentropy request. + * + * Two types for one filter is not a duplication worth removing: a + * [SynchronizationFilter] is what the request queue stores and what + * `computeId` hashes, while a live subscription puts a quartz `Filter` + * straight onto the wire. Keeping both here is what stops them drifting — + * which for this filter means one of them quietly stops matching mail. + * + * The default limit is larger because this one is opened rather than queued: + * NIP-01 scopes `limit` to the stored events a relay sends before EOSE and + * explicitly not to the stream after it, so it bounds what a reconnect costs + * without capping anything that arrives live. + */ + fun liveInbox( + publicKey: HexKey, + limit: Int = DEFAULT_LIVE_LIMIT, + ) = Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(publicKey)), + limit = limit, + ) + + const val DEFAULT_LIMIT = 50 + + const val DEFAULT_LIVE_LIMIT = 100 +} 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..04b99d1d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt @@ -0,0 +1,107 @@ +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. + * + * 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: + * + * ``` + * 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 + * ``` + * + * [GroupKeyStateEvent] sits just past them on 30326. It is not part of a + * session -- it is the standing fact a session is opened against, saying which + * key the room signs with -- so it is deliberately outside [ALL], which is what + * the inbound path dispatches a session message on. + * + * ### Why 3032x + * + * 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. + * + * 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 { + /** + * 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/GroupKeyStateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt new file mode 100644 index 00000000..a6a59b73 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt @@ -0,0 +1,108 @@ +package press.mantra.compose.nostr.frost + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag +import press.mantra.compose.nostr.frost.tags.FrostKeyTag + +/** + * What key a Marmot room signs with, announced into the room itself. + * + * The coordinator posts one as the room's first message, right after creating + * it. Content is the group's ChillDKG threshold public key; the tags name the + * ceremony that produced it and the path the room's id was derived at. + * + * ``` + * coordinator --[ 30326 group key state ]-> everyone "this room signs with K, at m/9420/0/0" + * ``` + * + * ### What it is for + * + * A signer holds a different secret share under every ceremony it took part in, + * and signing with the wrong one produces a partial signature that cannot + * aggregate. This is the record that says which. A member reads the state, + * follows [FrostKeyTag] to the `DkgSession` row their own device already holds, + * and takes the share from there -- the association travels, the share does not. + * + * Nothing secret is in here, and that is not an accident. Every member of the + * room can read it, so a share put on this event would be every member holding + * every other member's share, which is a 1-of-n key wearing a t-of-n's clothes. + * + * ### Trusted no further than it can be checked + * + * The coordinator posts it, and the coordinator is untrusted by construction. + * A receiver therefore verifies rather than believes: the room's id *is* the + * threshold key derived at the path, so + * `SharedKeyDerivation.marmotGroupId(content, path) == chatRoomId` has to hold + * or the state is dropped. That is the same check + * `FrostSigningManager.completedKey` made by rederiving, kept rather than + * replaced -- this event makes the association explicit and cheap to look up, + * not easier to forge. + * + * ### Replaceable, by this app rather than by a relay + * + * Like every kind in [FrostSigningEvents] this is a rumor inside a Marmot group + * event, so no relay ever sees it and the addressable semantics of the 3xxxx + * range never fire. [DTag] is the room id and the newest state per room wins, + * which the local store enforces on its own. Being able to say it twice is what + * matters in practice: a redelivered announcement, or a second member saying the + * same true thing, folds away instead of accumulating. + * + * One room only ever names one key today. A group that re-runs its ceremony + * derives a *different* room from the new key, so rotation in place does not + * arise -- and if it ever does, the verification above is what has to change + * first, because a rotated key no longer derives the room it is announced in. + */ +object GroupKeyStateEvent { + /** + * Sits with the signing family in the Marmot inner-event space. 30320-30325 + * are a signing session; this is the standing fact a session is opened + * against, so it is adjacent rather than inside. + */ + val KIND: Kind = 30326 + + fun isGroupKeyStateKind(kind: Kind): Boolean = kind == KIND + + /** + * The tags for a state naming [dkgSessionId], for the room derived at [path]. + * + * The room id goes on as the `d` tag so the event is self-addressing: a + * reader can tell which room a state belongs to without the envelope it + * arrived in, which is what makes dropping a state announced into the wrong + * room a check rather than an assumption. + */ + fun assembleTags( + chatRoomId: String, + dkgSessionId: String, + path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + ): Array> = arrayOf( + DTag.assemble(chatRoomId), + FrostKeyTag.assemble(dkgSessionId), + FrostDerivationPathTag.assemble(path) + ) + + /** The room this state is about, or null if it names none. */ + fun parseChatRoomId(tags: Array>): String? = + tags.firstOrNull { it.size > 1 && it[0] == DTag.TAG_NAME }?.get(1)?.ifBlank { null } + + /** The ceremony whose share signs for this room, or null if it names none. */ + fun parseDkgSessionId(tags: Array>): String? = + tags.firstNotNullOfOrNull(FrostKeyTag::parse)?.dkgSessionId + + /** The derivation path, or null if it carries none or an unwalkable one. */ + fun parsePath(tags: Array>): List? = + tags.firstNotNullOfOrNull(FrostDerivationPathTag::parse)?.path + + /** + * The threshold public key a state announces, or null when the content is + * not one. + * + * Shape only -- 33 compressed bytes of hex. Whether it is *the* key for the + * room is settled by rederiving the room id from it, not by looking at it. + */ + fun parseThresholdPublicKey(content: String): HexKey? = + content.trim() + .takeIf { it.length == 66 && it.all { char -> char.isDigit() || char in 'a'..'f' || char in 'A'..'F' } } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostDerivationPathTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostDerivationPathTag.kt new file mode 100644 index 00000000..ad6d570f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostDerivationPathTag.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 +import press.mantra.compose.managers.SharedKeyDerivation + +/** + * The path the room's id was derived at, `m/9420/0/0` style. + * + * Recorded rather than assumed. `SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH` + * is the only path anything walks today, but a room derived at a second one + * would be unfindable by a lookup that hardcodes the first, and the path is + * also what rebuilds the `TweakCache` a signing session needs. + * + * Hardened indices are rejected on parse: hardened derivation needs the parent + * private key, which in a threshold group nobody has, so a path carrying one + * was never walked. + */ +class FrostDerivationPathTag( + val path: List, +) { + fun toTagArray() = assemble(path = path) + + companion object { + const val TAG_NAME = "frost_path" + + fun parse(tag: Array): FrostDerivationPathTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + val path = SharedKeyDerivation.parsePathString(tag[1]) ?: return null + + return FrostDerivationPathTag(path = path) + } + + fun assemble(path: List): Array = + arrayOf(TAG_NAME, SharedKeyDerivation.formatPath(path)) + + fun assemble(frostDerivationPathTag: FrostDerivationPathTag) = + assemble(path = frostDerivationPathTag.path) + } +} 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/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/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 4669fae9..1f4a3894 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -79,10 +79,20 @@ interface ChatRepository { peers: List>, ): List + /** + * Sends [text] to the room, or -- when [directMessageRecipientPublicKey] is given -- + * to that one member of it. + * + * A direct message still travels as one group event that every member receives and + * can see the shape of; only its contents are private. See + * docs/marmot-direct-messages.md. NIP-17 rooms do not support it: there is no group + * for the rest of to see it, so a private message there is just a message. + */ suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, - messageType: Kind = ChatMessageEvent.KIND + messageType: Kind = ChatMessageEvent.KIND, + directMessageRecipientPublicKey: HexKey? = null ) fun updateChatRoomSubject(publicKey: String, subject: String): ChatRoom? @@ -192,7 +202,8 @@ interface ChatRepository { override suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, - messageType: Kind + messageType: Kind, + directMessageRecipientPublicKey: HexKey? ) { // TODO("Not yet implemented") } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt index fcecf161..2024cd8a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -2,6 +2,7 @@ package press.mantra.compose.repository import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.DkgApprovalStep import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -42,6 +43,19 @@ interface DkgRepository { nostrPrivateKey: ByteArray ) + /** + * Tells [chatRoomId] which ceremony's key it signs with, as its first message. + * + * Called once by whoever creates the room. Null if the ritual has produced + * no key yet, or if the room does not derive from the one it produced -- + * both of which mean there is nothing true to announce. + */ + suspend fun announceGroupKeyState( + chatRoomId: String, + userPublicKey: HexKey, + session: DkgSession + ): GroupKeyState? + companion object { val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository { override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow = flowOf(null) @@ -65,6 +79,12 @@ interface DkgRepository { step: DkgApprovalStep, nostrPrivateKey: ByteArray ) = Unit + + override suspend fun announceGroupKeyState( + chatRoomId: String, + userPublicKey: HexKey, + session: DkgSession + ): GroupKeyState? = null } } } 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/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/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index 38d68b94..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 @@ -18,15 +18,14 @@ 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.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 @@ -36,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 @@ -45,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 @@ -108,14 +110,21 @@ 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) } + // 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( @@ -132,16 +141,33 @@ 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, 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 +292,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/composable/AddDialectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt new file mode 100644 index 00000000..40b8cf69 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt @@ -0,0 +1,348 @@ +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.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 +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.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 +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.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +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 +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, + frostSigningRepository: FrostSigningRepository, + 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, + frostSigningRepository = frostSigningRepository + ) + ) + + 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() + + // 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( + title = { + addDialectUIState.localChatRoom.RenderChatRoomTitleText() + }, + actions = { + + } + ) + }, + bottomBar = { + BottomAppBar( + 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 = { 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( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed Dialect") + ) + } + ) + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Propose dialect" + ) + Text("Propose 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") + + 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), + 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, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_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/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index 3cd87d96..e909ab6c 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 @@ -4,15 +4,19 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.BottomAppBarDefaults @@ -43,6 +47,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 +146,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 + ) + ) } ) } @@ -157,6 +172,49 @@ fun ChatRoomMessagingScreen( .background(BottomAppBarDefaults.containerColor) .navigationBarsPadding() ) { + // An armed composer has to be impossible to miss. The whole risk + // of this feature is somebody sending to the room what they meant + // for one person, or the reverse, and by the time either is on the + // wire it cannot be taken back. Hence three signals at once: the + // chip, the placeholder, and the tinted field. + val directMessageRecipient = chatMessageListViewModel.directMessageRecipient + + if (directMessageRecipient != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Text( + modifier = Modifier.weight(1f), + text = "Private to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}", + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelMedium + ) + + // The one way back to the room, and it has to be one tap. + IconButton( + onClick = { chatMessageListViewModel.cancelDirectMessage() } + ) { + Icon( + Icons.Default.Close, + contentDescription = "Send to the whole group instead", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + Box( modifier = Modifier.fillMaxWidth() ) { @@ -166,11 +224,25 @@ fun ChatRoomMessagingScreen( colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent + disabledBorderColor = Color.Transparent, + focusedContainerColor = if (directMessageRecipient != null) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + }, + unfocusedContainerColor = if (directMessageRecipient != null) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + } ), placeholder = { Text( - text = "Say what now?" + text = if (directMessageRecipient != null) { + "Private message to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}" + } else { + "Say what now?" + } ) }, trailingIcon = { 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 1a6cd763..cc29ee3e 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 @@ -20,6 +20,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 @@ -107,6 +108,8 @@ 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.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 @@ -116,6 +119,8 @@ 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.FrostSigningScreen import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen import press.mantra.compose.ui.composable.ChapterDetailScreen import press.mantra.compose.ui.composable.TranslateChunkScreen @@ -166,6 +171,13 @@ fun MantraNavHost( ) } + val databaseFrostSigningRepository = remember { + DatabaseFrostSigningRepository( + database = auxDatabaseManager.auxDatabase, + applicationIOScope + ) + } + val databaseMarmotRepository = remember { DatabaseMarmotRepository( database = auxDatabaseManager.auxDatabase, @@ -834,6 +846,46 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddDialectScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + 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 + } + } + }, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + } + ) + } + 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/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/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/AddArtifactViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt index a84ec5a8..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 @@ -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 } @@ -94,21 +85,7 @@ class AddArtifactViewModel( isActionPending.value = true 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 - } - + val artifact = runCatching { mantraRepository.addArtifact( localChatRoom = localChatRoom, name = name, @@ -123,16 +100,13 @@ class AddArtifactViewModel( logger.e("Failed to add artifact", error) }.getOrNull() - if (artifactInnerEvent != null) { + if (artifact != null) { nameField.clearText() urlField.clearText() versionLabelField.clearText() - dialectNameField.clearText() - dialectCountryField.clearText() - dialectLanguageField.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 new file mode 100644 index 00000000..a77d4593 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt @@ -0,0 +1,162 @@ +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.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.nostr.nip30303.DialectEvent +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 frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var addDialectUIState: AddDialectUIState by mutableStateOf(initialAddDialectUIState) + private set + + private val logger = Logger.withTag(TAG) + + 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) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + addDialectUIState = if (localChatRoom == null) { + AddDialectUIState.Error("Couldn't find the chat room") + } 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: (sessionId: 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 dialectEventTemplate = DialectEvent.build( + name = name, + country = country, + language = language, + ) + + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + kind = dialectEventTemplate.kind, + tags = dialectEventTemplate.tags, + content = dialectEventTemplate.content, + ) + }.onFailure { error -> + logger.e("Failed to propose a dialect for signing", error) + }.getOrNull() + + if (session != null) { + nameField.clearText() + countryField.clearText() + languageField.clearText() + + viewModelScope.launch(Dispatchers.Main) { + onSuccess.invoke(session.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, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AddDialectViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddDialectUIState = initialAddDialectUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + 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 7cb011d2..2a9c0b93 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn @@ -24,6 +25,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 @@ -31,9 +34,12 @@ import androidx.compose.material.icons.filled.WorkspacePremium import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.KeyOff +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Pending import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator @@ -59,17 +65,20 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.extensions.shortened import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.nostr.Nip17Filters import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.widgets.profile.ProfileColor import press.mantra.compose.ui.view.state.ChatMessageListUIState import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -88,6 +97,60 @@ class ChatMessageListViewModel( val isReceiverChatMessageRelayListMissing: MutableState = mutableStateOf(false) + /** + * The member the next message goes to privately, or null to send it to the room. + * + * Held here rather than on the composer because it survives recomposition and is read + * by both the input and the send call. It is cleared on send: a private message is a + * deliberate act each time, and leaving the composer armed after one is how somebody + * sends the room's next message to one person, or one person's to the room. + */ + var directMessageRecipient: Participant? by mutableStateOf(null) + private set + + /** The message whose actions are open, or null. One at a time. */ + var openMessageActionsFor: Long? by mutableStateOf(null) + private set + + fun startDirectMessage(participant: Participant) { + logger.d("Arming a direct message to ${participant.participantPublicKey}") + directMessageRecipient = participant + openMessageActionsFor = null + } + + fun cancelDirectMessage() { + directMessageRecipient = null + } + + fun openMessageActions(chatMessageId: Long?) { + openMessageActionsFor = chatMessageId + } + + /** The member behind a pubkey, if they are in this room. */ + fun participantFor(publicKey: HexKey?): Participant? = + publicKey?.let { key -> + localChatRoom.localParticipants + .map { it.participant } + .firstOrNull { it.participantPublicKey == key } + } + + /** + * What to call somebody, for a line that has to name them. + * + * Falls back to the key itself rather than to "unknown": a direct message names a + * real member of this room, and a line that cannot say which one is worse than an + * ugly one. + */ + fun nameFor(publicKey: HexKey?): String { + if (publicKey == null) return "someone" + + return localChatRoom.localParticipants + .firstOrNull { it.participant.participantPublicKey == publicKey } + ?.profile + ?.humanReadableNameOrPubkey() + ?: publicKey.shortened() + } + fun initiate() { logger.d("init") scheduleSynchronization() @@ -129,7 +192,11 @@ class ChatMessageListViewModel( val chatMessageRelayListEvent = chatRepository.getChatMessageRelayForPublicKey(recipients.participant.participantPublicKey) - // We already know where to reach them; nothing to find out. + // Opening a conversation used to refresh our own inbox here, with + // Nip17Filters.inbox on our own DM relays. That is now redundant rather + // than wrong: LiveSubscriptionManager holds exactly that filter open for + // the whole account and reconciles it on every foreground, so there is + // nothing left for opening a chat to ask for. if (chatMessageRelayListEvent != null) return@forEach isReceiverChatMessageRelayListMissing.value = true @@ -150,6 +217,8 @@ class ChatMessageListViewModel( relayURL = normalizedRelayUrl.url, synchronizationFilter = chatMessageRelayListFilter ), + // Only one case reaches here now, so the purpose is no longer a + // question. purpose = "chat-message-relays", synchronizationFilter = chatMessageRelayListFilter, relayURL = normalizedRelayUrl.url, @@ -168,7 +237,7 @@ class ChatMessageListViewModel( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable - fun RenderMessages(onOpenSharedKey: () -> Unit) { + fun RenderMessages(onOpenSharedKey: () -> Unit, onOpenSigning: () -> Unit) { Column( modifier = Modifier.fillMaxWidth(), @@ -223,8 +292,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 { @@ -300,6 +371,34 @@ class ChatMessageListViewModel( return@items } + // A private message this device cannot open. Everything + // about it is known except the one thing that matters, + // so it is a notice rather than an empty bubble -- + // which would read as the sender having said nothing. + if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE && + localChatMessage.chatMessage.content.isBlank() + ) { + PrivateMessageNotice( + sender = nameFor(localChatMessage.chatMessage.senderPublicKey), + recipient = nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey) + ) + 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() ) { @@ -313,12 +412,22 @@ class ChatMessageListViewModel( Arrangement.Start } ) { + // Only somebody else's message, and only in a + // group -- a NIP-17 room has no audience for a + // message to be private from. + val canReplyPrivately = + localChatRoom.chatRoom.mlsGroupState != null && + !localChatMessage.chatMessage.isUserMessage && + participantFor(localChatMessage.chatMessage.senderPublicKey) != null + Card( modifier = Modifier.widthIn( max = screenWidth * 0.8f ).wrapContentWidth(), onClick = { - + if (canReplyPrivately) { + openMessageActions(localChatMessage.chatMessage.id) + } }, ) { Column( @@ -340,6 +449,34 @@ class ChatMessageListViewModel( ) } + // A readable direct message must never + // pass for a public one. The label says + // who the other party is, since that is + // the thing a reader would otherwise + // assume was the whole room. + if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = if (localChatMessage.chatMessage.isUserMessage) { + "Private to ${nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey)}" + } else { + "Private to you" + }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelSmall + ) + } + } + SelectionContainer { Text( @@ -388,6 +525,24 @@ class ChatMessageListViewModel( } } } + + DropdownMenu( + expanded = openMessageActionsFor == localChatMessage.chatMessage.id, + onDismissRequest = { openMessageActions(null) } + ) { + DropdownMenuItem( + text = { + Text("Reply privately to ${nameFor(localChatMessage.chatMessage.senderPublicKey)}") + }, + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, + onClick = { + participantFor(localChatMessage.chatMessage.senderPublicKey) + ?.let { startDirectMessage(it) } + } + ) + } } } } @@ -421,12 +576,17 @@ class ChatMessageListViewModel( fun sendMessage(textFieldState: TextFieldState) { if (textFieldState.text.isNotBlank()) { val text = textFieldState.text.toString() + // Read and cleared together with the text, before the send suspends, so a + // second message cannot inherit the first one's audience. + val recipient = directMessageRecipient textFieldState.clearText() + directMessageRecipient = null viewModelScope.launch(Dispatchers.IO) { chatRepository.sendChatMessage( text = text, - localChatRoom = localChatRoom + localChatRoom = localChatRoom, + directMessageRecipientPublicKey = recipient?.participantPublicKey ) } } @@ -453,6 +613,43 @@ class ChatMessageListViewModel( } } +/** + * A private message this device cannot read, as a system line. + * + * Deliberately not a bubble. The group is meant to know that a private message was sent + * and to whom -- that is the honest half of the feature -- but an empty bubble attributed + * to the sender would read as them having said nothing, and a bubble with placeholder text + * would read as them having said the placeholder. + * + * Not tappable: there is nothing behind it to open. See docs/marmot-direct-messages.md. + */ +@Composable +private fun PrivateMessageNotice( + sender: String, + recipient: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = "$sender sent a private message to $recipient", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall + ) + } +} + /** * A ChillDKG milestone, as a system line across the transcript. * @@ -490,6 +687,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 } @@ -498,10 +704,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 } @@ -531,7 +742,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/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/model/ChatRoomListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt index 8251e967..6ef606ae 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt @@ -27,13 +27,13 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.Nip17Filters import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.view.state.ChatRoomListUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch @@ -81,15 +81,7 @@ class ChatRoomListViewModel( logger.d("scheduleSynchronization") viewModelScope.launch(Dispatchers.IO) { // Sync Notifications... might want to also run this in the background - val chatRequestFilter = SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - tags = mapOf( - Pair("p", listOf(publicKey)) - ), - limit = 50 - ) + val chatRequestFilter = Nip17Filters.inbox(publicKey) nostrRepository.queueNegentropySynchronizeRequest( Relays.DefaultDMRelayList.shuffled().map { normalizedRelayUrl -> NegentropySynchronizeRequest( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index 8ae7816f..81f26cf0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -251,7 +251,8 @@ class DkgRitualViewModel( if (isActionPending.value) return val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return - val thresholdPublicKey = loaded.session?.thresholdPublicKey ?: return + val session = loaded.session ?: return + val thresholdPublicKey = session.thresholdPublicKey ?: return val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() if (nostrPrivateKey == null) { @@ -383,6 +384,28 @@ class DkgRitualViewModel( logger.e("Failed to add members to admin group $groupId", it) }.getOrElse { addable.map { (publicKey, _) -> publicKey } } + // The room's first message: which ceremony's key it signs with, and + // the path its id was derived at. What a signer reaches for when a + // signing request arrives and it has to pick one of its shares. + // + // After the members are added rather than before, which is the only + // order that works: adding them commits a new epoch, and MLS will not + // let a member read what was encrypted before the epoch they joined + // at. Announced first, the announcement would reach nobody but its + // author. It is still the room's first *application* message -- what + // comes before it is handshake. + // + // A member invited later still misses it for the same reason, and is + // left where every member was before this event existed: falling back + // to FrostSigningManager.completedKey's rederivation. Re-announcing + // on invite is the fix, and is cheap because a repeat announcement + // folds away rather than accumulating. + dkgRepository.announceGroupKeyState( + chatRoomId = localChatRoom.chatRoom.id, + userPublicKey = activeUserPublicKey, + session = session + ) + isActionPending.value = false if (notAdded.isNotEmpty()) { 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 new file mode 100644 index 00000000..8f41867d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt @@ -0,0 +1,22 @@ +package press.mantra.compose.ui.view.state + +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( + 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( 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 +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/GiftWrapMessageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/GiftWrapMessageTest.kt new file mode 100644 index 00000000..6502f728 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/GiftWrapMessageTest.kt @@ -0,0 +1,141 @@ +package press.mantra.compose.database.model + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Who can open a gift wrap, and what becomes of everyone else's. + * + * NIP-59 encrypts a wrap under ECDH(ephemeralPriv, recipientPub), and + * [GiftWrapEvent.create] discards that ephemeral key before it returns. The + * recipient named in the `p` tag is therefore the only party who can ever unseal + * one -- the sender included. Reading a wrap that is not ours is not a decryption + * that might fail, it is one that cannot be attempted, and the code that tried + * anyway threw `IllegalStateException: Invalid Mac` out of Nip44 and took the + * enclosing Room transaction down with it, so the event was rolled back and + * re-fetched on every later sync. + * + * These run real secp256k1 rather than recorded fixtures on purpose: the property + * under test is about the key agreement itself, and a fixture would only prove the + * fixture still parses. + */ +class GiftWrapMessageTest { + + private val us = KeyPair() + private val peer = KeyPair() + private val stranger = KeyPair() + + /** + * A real wrap, sealed the way DatabaseChatRepository seals one and then mapped + * into the entity the way NostrEvent.toGiftWrapMessageWithReceiverPTag maps it. + */ + private fun wrap( + sender: KeyPair, + recipient: KeyPair, + ): GiftWrapMessage { + val signer = NostrSignerSync(sender) + + val seal = signer.signNormal( + createdAt = SEALED_AT, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = signer.nip44Encrypt( + plaintext = """{"kind":14,"content":"dumela"}""", + toPublicKey = recipient.pubKey.toHexKey(), + ), + ) + + val giftWrap = GiftWrapEvent.create( + event = seal, + recipientPubKey = recipient.pubKey.toHexKey(), + createdAt = WRAPPED_AT, + ) + + return GiftWrapMessage( + id = giftWrap.id, + publicKey = giftWrap.pubKey, + receiverPublicKey = recipient.pubKey.toHexKey(), + receiverRelayHit = null, + content = giftWrap.content, + signature = giftWrap.sig, + nostrEventId = giftWrap.id, + createdAt = Instant.fromEpochSeconds(giftWrap.createdAt), + ) + } + + @Test + fun `a wrap addressed to us gives up its seal`() = runTest { + val seal = wrap(sender = peer, recipient = us).decryptGiftWrapSeal(us) + + assertNotNull(seal) + // Only the wrap was anonymous. The seal inside carries the real sender, which + // is what lets the impersonation check downstream compare it to the payload. + assertEquals(peer.pubKey.toHexKey(), seal.publicKey) + } + + @Test + fun `someone else's mail comes back null rather than throwing`() = runTest { + // The event from the crash report: a wrap between two other people, pulled in + // by a filter that named a peer where it should have named us. + val theirs = wrap(sender = stranger, recipient = peer) + + assertNull(theirs.decryptGiftWrapSeal(us)) + } + + @Test + fun `not even the sender can reopen what they sent`() = runTest { + // What the old branch called "a message we may have sent" and tried to decrypt + // regardless. The key that encrypted it no longer exists anywhere; holding the + // sending identity buys nothing back. + val ours = wrap(sender = us, recipient = peer) + + assertNull(ours.decryptGiftWrapSeal(us)) + } + + @Test + fun `isAddressedTo reads the p tag whatever case it arrived in`() { + val message = wrap(sender = peer, recipient = us) + + assertTrue(message.isAddressedTo(us)) + assertFalse(message.isAddressedTo(peer)) + assertTrue( + message + .copy(receiverPublicKey = message.receiverPublicKey.uppercase()) + .isAddressedTo(us), + ) + } + + @Test + fun `isAddressedTo answers exactly what unsealing would`() = runTest { + // NostrDao skips indexing on isAddressedTo and throws GiftWrapUnsealException on + // a null seal. Should those two ever disagree, one path or the other is wrong: + // either mail we can open is skipped, or the transaction rolls back again. + listOf( + wrap(sender = peer, recipient = us), + wrap(sender = stranger, recipient = peer), + wrap(sender = us, recipient = peer), + ).forEach { message -> + assertEquals( + message.isAddressedTo(us), + message.decryptGiftWrapSeal(us) != null, + "isAddressedTo and decryptGiftWrapSeal disagree on ${message.id}", + ) + } + } + + private companion object { + const val SEALED_AT = 1_700_000_000L + const val WRAPPED_AT = 1_700_000_100L + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotCommitResultMappingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotCommitResultMappingTest.kt new file mode 100644 index 00000000..74cd329e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotCommitResultMappingTest.kt @@ -0,0 +1,134 @@ +package press.mantra.compose.database.model + +import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.time.Instant + +/** + * Where a commit's bytes land when the row that records it is written. + * + * `MarmotCommitResult` carries quartz's `CommitResult` payload fields verbatim -- + * `commitBytes`, `welcomeBytes`, `groupInfoBytes`, `framedCommitBytes`, + * `preCommitExporterSecret`, the same names and all of them `ByteArray`. A value + * taken from the wrong field of the right object therefore typechecks, and + * `framedCommitBytes = commitResult.preCommitExporterSecret` reached the database + * that way and sat there unnoticed: the column documented to hold a broadcastable + * `MlsMessage(PublicMessage(FramedContent(commit)))` envelope held 32 bytes of the + * group's pre-commit exporter secret instead. + * + * Nothing caught it because nothing read the column. The bytes that reached the + * relay come off the in-memory `CommitResult`, so the wire stayed correct while the + * record of it did not, and the row is written precisely so that the + * acknowledgement path in `DatabaseNostrRepository` can pick work back up later. A + * rebroadcast reading `framedCommitBytes` would have published noise the group + * decrypts, fails to parse, and drops -- silent, which is this subsystem's + * characteristic failure. + * + * So the routing is pinned here. Every payload gets a distinct, self-identifying + * value: a field that ends up in the wrong column names both halves of the mistake + * when it fails, rather than comparing equal by accident. + */ +class MarmotCommitResultMappingTest { + private val commitBytes = "raw-commit".encodeToByteArray() + private val framedCommitBytes = "framed-commit-envelope".encodeToByteArray() + private val welcomeBytes = "welcome".encodeToByteArray() + private val groupInfoBytes = "group-info".encodeToByteArray() + + /** Stands in for `MLS-Exporter("marmot", "group-event", 32)` at the pre-commit epoch. */ + private val preCommitExporterSecret = ByteArray(32) { 0x5E } + + private val commitEventId = "a".repeat(64) + private val chatRoomId = "b".repeat(64) + private val userPublicKey = "c".repeat(64) + private val peerKeyPackageEventId = "d".repeat(64) + private val createdAt = Instant.fromEpochSeconds(1_700_000_000) + + private fun commitResult( + framedCommitBytes: ByteArray = this.framedCommitBytes, + preCommitExporterSecret: ByteArray = this.preCommitExporterSecret, + ) = CommitResult( + commitBytes = commitBytes, + welcomeBytes = welcomeBytes, + groupInfoBytes = groupInfoBytes, + framedCommitBytes = framedCommitBytes, + preCommitExporterSecret = preCommitExporterSecret, + ) + + private fun map(commitResult: CommitResult) = MarmotCommitResult.from( + commitEventId = commitEventId, + commitResult = commitResult, + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + peerKeyPackageEventId = peerKeyPackageEventId, + isOneMemberInitialGroupCreation = false, + createdAt = createdAt, + ) + + @Test + fun `every payload field lands in its own column`() { + val row = map(commitResult()) + + assertContentEquals(commitBytes, row.commitBytes, "commitBytes") + assertContentEquals(welcomeBytes, row.welcomeBytes, "welcomeBytes") + assertContentEquals(groupInfoBytes, row.groupInfoBytes, "groupInfoBytes") + assertContentEquals(framedCommitBytes, row.framedCommitBytes, "framedCommitBytes") + assertContentEquals( + preCommitExporterSecret, + row.preCommitExporterSecret, + "preCommitExporterSecret" + ) + } + + @Test + fun `the framed commit column never holds the exporter secret`() { + // The regression. Stated as the invariant rather than as an equality check, + // so it keeps holding for a CommitResult this test did not anticipate. + val row = map(commitResult()) + + assertFalse( + row.framedCommitBytes.contentEquals(row.preCommitExporterSecret), + "the group's exporter secret was stored as the framed commit" + ) + } + + @Test + fun `a CommitResult that never framed its commit still stores a commit`() { + // quartz defaults framedCommitBytes to commitBytes, and the entity repeats that + // default. Whichever of the two a row ends up with, it must be a commit -- the + // fallback must not quietly become the secret either. + val unframed = CommitResult( + commitBytes = commitBytes, + welcomeBytes = welcomeBytes, + groupInfoBytes = groupInfoBytes, + preCommitExporterSecret = preCommitExporterSecret, + ) + + val row = map(unframed) + + assertContentEquals(commitBytes, row.framedCommitBytes) + assertFalse( + row.framedCommitBytes.contentEquals(row.preCommitExporterSecret), + "the group's exporter secret was stored as the framed commit" + ) + } + + @Test + fun `the bookkeeping the acknowledgement path reads is carried through`() { + // DatabaseNostrRepository finds this row by the commit event's id and delivers the + // welcome using chatRoomId, userPublicKey and peerKeyPackageEventId. All four are + // supplied by the caller rather than the CommitResult, so they are checked here to + // keep the argument order of `from` honest -- every one of them is a 64-char hex + // string, and swapping two would otherwise typecheck as silently as the bug did. + val row = map(commitResult()) + + assertEquals(commitEventId, row.id) + assertEquals(chatRoomId, row.chatRoomId) + assertEquals(userPublicKey, row.userPublicKey) + assertEquals(peerKeyPackageEventId, row.peerKeyPackageEventId) + assertEquals(createdAt, row.createdAt) + assertFalse(row.isOneMemberInitialGroupCreation) + } +} 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/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 + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt new file mode 100644 index 00000000..854d78d6 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -0,0 +1,245 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.secp256k1.Hex +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.frost.GroupKeyStateEvent + +/** + * What a room's key-state announcement is allowed to convince a member of. + * + * The announcement is made by the coordinator, and the coordinator is untrusted + * by construction -- the same assumption every other part of the ceremony is + * written under. So the interesting cases here are all the ones where a state + * is *wrong*: a member who acts on a state naming a key their room was not made + * from signs with a share that cannot aggregate, or worse, treats a key the + * group does not hold as the key the group holds. + * + * `GroupKeyStateManager` needs a database and so cannot be stood up here. What + * can be is the check it defers to, which is where the whole trust model lives. + */ +class GroupKeyStateTest { + /** Stands in for a ceremony's output. Any valid point will do. */ + private val thresholdPublicKey = PrivateKey( + Hex.decode("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ).publicKey().value.toHex() + + /** A second group's key, for the states that name the wrong one. */ + private val otherKey = PrivateKey( + Hex.decode("2bada550000000000000000000000000000000000000000000000000000000b2") + ).publicKey().value.toHex() + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) + + private fun state( + chatRoomId: String = this.chatRoomId, + thresholdPublicKey: String = this.thresholdPublicKey, + derivationPath: String = SharedKeyDerivation.formatPath(path) + ) = GroupKeyState( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + thresholdPublicKey = thresholdPublicKey, + derivationPath = derivationPath, + announcedBy = "c00rd1na70r", + announcedAt = Instant.fromEpochSeconds(1_700_000_000) + ) + + @Test + fun `a state describing the room it was announced in verifies`() { + assertTrue(state().verifies()) + } + + @Test + fun `a state naming another group's key does not verify`() { + // The attack this is here for: a coordinator pointing the room at a key + // the group never made, so that everything signed in it is signed by + // whoever holds that key instead. + assertFalse(state(thresholdPublicKey = otherKey).verifies()) + } + + @Test + fun `a state naming the right key at the wrong path does not verify`() { + // The path is half the derivation, so getting it wrong reaches a + // different room just as surely as getting the key wrong does. + assertFalse(state(derivationPath = "m/9420/0/1").verifies()) + } + + @Test + fun `a state for one room does not verify against another`() { + assertFalse(state(chatRoomId = otherKey).verifies()) + } + + @Test + fun `a state carrying an unwalkable path does not verify`() { + // Hardened derivation needs the parent private key, which nobody in a + // threshold group has, so a hardened path was never walked to anything. + assertFalse(state(derivationPath = "m/9420'/0/0").verifies()) + assertFalse(state(derivationPath = "9420/0/0").verifies()) + assertFalse(state(derivationPath = "").verifies()) + } + + @Test + fun `the tags a state is announced on read back as they were written`() { + val tags = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path + ) + + assertEquals(chatRoomId, GroupKeyStateEvent.parseChatRoomId(tags)) + assertEquals("ceremony-1", GroupKeyStateEvent.parseDkgSessionId(tags)) + assertEquals(path, GroupKeyStateEvent.parsePath(tags)) + } + + @Test + fun `a threshold key is only read out of content that is one`() { + assertEquals(thresholdPublicKey, GroupKeyStateEvent.parseThresholdPublicKey(thresholdPublicKey)) + + // 32 bytes is an x-only key, not the 33-byte compressed point a ceremony + // reports; anything else is not a key at all. + assertNull(GroupKeyStateEvent.parseThresholdPublicKey(chatRoomId)) + assertNull(GroupKeyStateEvent.parseThresholdPublicKey("")) + assertNull(GroupKeyStateEvent.parseThresholdPublicKey("not a key")) + assertNull(GroupKeyStateEvent.parseThresholdPublicKey("z".repeat(66))) + } + + @Test + fun `a path survives the trip through a tag`() { + val deep = listOf(9420L, 7L, 0L, 1L) + val tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", deep) + + assertEquals(deep, GroupKeyStateEvent.parsePath(tags)) + } + + @Test + fun `a room derived at a path other than the default still verifies at it`() { + // The reason the path is announced rather than assumed: a lookup that + // hardcodes MARMOT_ADMIN_GROUP_PATH cannot find this room at all. + val sibling = listOf(9420L, 0L, 1L) + val siblingRoom = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, sibling) + + assertTrue( + state( + chatRoomId = siblingRoom, + derivationPath = SharedKeyDerivation.formatPath(sibling) + ).verifies() + ) + } + + // ---- The announcement as it actually arrives ------------------------- + // + // Everything above checks the verdict on a state already assembled. These + // check the assembling: a real Event, with the tags and content an + // announcement is carried on, through the function the inbound path calls. + + private fun announcement( + content: String = thresholdPublicKey, + tags: Array> = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path), + pubKey: String = "c00rd1na70r", + createdAt: Long = 1_700_000_000 + ) = Event( + id = "an-id", + pubKey = pubKey, + createdAt = createdAt, + kind = GroupKeyStateEvent.KIND, + tags = tags, + content = content, + sig = "" + ) + + @Test + fun `an announcement of the room it arrives in is taken`() { + val state = GroupKeyStateManager.stateFrom(chatRoomId, announcement()) + + assertEquals(chatRoomId, state?.chatRoomId) + assertEquals("ceremony-1", state?.dkgSessionId) + assertEquals(thresholdPublicKey, state?.thresholdPublicKey) + assertEquals("m/9420/0/0", state?.derivationPath) + // Attribution and ordering come off the event, not off the clock. + assertEquals("c00rd1na70r", state?.announcedBy) + assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt) + } + + @Test + fun `an announcement naming another group's key is dropped`() { + // The one that matters: a coordinator pointing the room at a key the + // group never made. Everything else here is malformed input; this is + // well-formed input that lies. + assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = otherKey))) + } + + @Test + fun `an announcement addressed to another room is dropped`() { + val elsewhere = GroupKeyStateEvent.assembleTags(otherKey, "ceremony-1", path) + + assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = elsewhere))) + } + + @Test + fun `an announcement missing any of what it has to say is dropped`() { + val full = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path) + + // No ceremony to reach a share through. + assertNull( + GroupKeyStateManager.stateFrom( + chatRoomId, + announcement(tags = full.filterNot { it[0] == "frost_key" }.toTypedArray()) + ) + ) + // No path, so nothing to rebuild a TweakCache from. + assertNull( + GroupKeyStateManager.stateFrom( + chatRoomId, + announcement(tags = full.filterNot { it[0] == "frost_path" }.toTypedArray()) + ) + ) + // No key. + assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = ""))) + } + + @Test + fun `an announcement carrying no d tag is judged on its derivation alone`() { + // The d tag is a convenience for a reader holding the event on its own. + // Dropping it loses nothing that matters, because the room it arrived in + // plus the derivation still settle the question. + val undirected = arrayOf( + arrayOf("frost_key", "ceremony-1"), + arrayOf("frost_path", "m/9420/0/0") + ) + + assertEquals( + chatRoomId, + GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = undirected))?.chatRoomId + ) + } + + @Test + fun `a path index wider than a uint32 is not a path`() { + // tweakScalar serialises an index as its low four bytes, so m/4294967296 + // would otherwise walk exactly where m/0 does -- one room, two spellings, + // both verifying. Rejected at the parser so formatPath stays a round trip. + assertNull(SharedKeyDerivation.parsePathString("m/4294967296/0/0")) + assertNull(SharedKeyDerivation.parsePathString("m/-1/0/0")) + + assertEquals(listOf(4294967295L), SharedKeyDerivation.parsePathString("m/4294967295")) + assertEquals(listOf(0L), SharedKeyDerivation.parsePathString("m/0")) + } + + @Test + fun `a state whose path indices are out of range does not verify`() { + // Reachable only by constructing the row directly; the parser above + // refuses to build one. Checked because verifies() is what everything + // else defers to, and it should not be the thing that trusts its input. + assertFalse(state(derivationPath = "m/4294967296/0/0").verifies()) + } +} 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) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt new file mode 100644 index 00000000..f6059a92 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt @@ -0,0 +1,82 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The one hole deliberately left in MIP-03's author check, and its edges. + * + * MIP-03 requires an inner application event's pubkey to equal the MLS sender's credential + * identity, which is what stops a member minting events attributed to somebody else. A + * gift wrap cannot satisfy it -- its pubkey is a throwaway key that names nobody -- so + * kind:1059 is exempt, and the authorship claim moves to the signed seal inside. + * + * The exemption is the most dangerous line in this feature. Widened to another kind, or + * stripped of its kind guard, it hands every member of every group the ability to publish + * events as anybody, and nothing else in the pipeline would notice. These tests exist so + * that widening it fails here rather than in a group. + */ +class MarmotMip03CarveOutTest { + private val sender = "a".repeat(64) + private val somebodyElse = "b".repeat(64) + private val throwaway = "c".repeat(64) + + @Test + fun `an ordinary message from its own author is accepted`() { + assertNull(MarmotInboundManager.mip03Rejection(ChatEvent.KIND, sender, sender)) + } + + @Test + fun `an ordinary message claiming somebody else is rejected`() { + val reason = MarmotInboundManager.mip03Rejection(ChatEvent.KIND, somebodyElse, sender) + + assertNotNull(reason) + assertTrue(reason.startsWith("MIP-03"), "expected the MIP-03 rejection, got: $reason") + } + + @Test + fun `a gift wrap is accepted despite naming nobody`() { + // The carve-out. The wrap's pubkey is a throwaway key and matches no member, which + // is the point of it; what authenticates the sender is the MLS frame, and what + // authenticates the author is the seal inside. See MarmotDirectMessage.classify. + assertNull(MarmotInboundManager.mip03Rejection(GiftWrapEvent.KIND, throwaway, sender)) + } + + @Test + fun `the carve-out does not extend to any other kind`() { + // Every kind carried as an application payload today, plus the seal and rumor + // kinds a gift wrap contains -- none of which should ever arrive unwrapped, and + // all of which would be a way to launder an author if the guard were widened. + val kinds = listOf( + ChatEvent.KIND, + ChatMessageEvent.KIND, + SealedRumorEvent.KIND, + GiftWrapEvent.KIND - 1, + GiftWrapEvent.KIND + 1, + 0, + 1, + ) + + kinds.forEach { kind -> + assertNotNull( + MarmotInboundManager.mip03Rejection(kind, somebodyElse, sender), + "kind $kind was allowed to claim an author it does not own", + ) + } + } + + @Test + fun `a sender with no credential identity is rejected, wrap or not`() { + // Required rather than merely compared: with no identity there is nothing to + // attribute a line to, and for a gift wrap there is nothing to check the seal + // against either. Both kinds must fail, not just the one that does a comparison. + assertNotNull(MarmotInboundManager.mip03Rejection(ChatEvent.KIND, sender, null)) + assertNotNull(MarmotInboundManager.mip03Rejection(GiftWrapEvent.KIND, throwaway, null)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt new file mode 100644 index 00000000..ee4b8473 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt @@ -0,0 +1,276 @@ +package press.mantra.compose.nostr + +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.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * What a device decides to do with an arriving wrap. + * + * This is where the check that replaces MIP-03 for kind:1059 is tested. The wrap itself + * names nobody, so everything a transcript says about who sent a private message rests on + * one comparison -- the seal's author against the identity MLS authenticated -- and if it + * stops being made, a member can have their words attributed to somebody else, or + * somebody else's attributed to them. The rest of the inbound path is Room-backed and + * cannot be unit-tested here, which is exactly why the decision was separated from the + * filing of it. + * + * `Ours` carries a second kind of risk. It is indistinguishable from `Unreadable` by + * decryption alone -- we cannot open our own wrap either -- and getting it wrong does not + * fail loudly: it files a placeholder over the only copy of the sender's own words. + */ +class MarmotDirectMessageDeliveryTest { + private val alice = NostrSignerSync(KeyPair()) + private val bob = KeyPair() + private val eve = NostrSignerSync(KeyPair()) + + private val alicePublicKey = alice.pubKey + private val bobPublicKey = bob.pubKey.toHexKey() + + private val at = 1_700_000_000L + private val text = "the vote is at six" + + private fun wrapFor( + signer: NostrSignerSync = alice, + recipient: String = bobPublicKey, + kind: Int = ChatMessageEvent.KIND, + content: String = text, + ): GiftWrapEvent = + MarmotDirectMessage.wrap( + signer = signer, + recipientPublicKey = recipient, + kind = kind, + createdAt = at, + tags = arrayOf(PTag.assemble(recipient, null)), + content = content, + ) + + @Test + fun `the recipient gets the words`() { + val delivery = MarmotDirectMessage.classify(wrapFor(), bob, alicePublicKey) + + val readable = assertIs(delivery) + assertEquals(text, readable.opened.rumor.content) + assertEquals(alicePublicKey, readable.opened.seal.pubKey) + } + + @Test + fun `a bystander gets a line with nothing in it`() { + val carol = KeyPair() + + assertEquals( + MarmotDirectMessage.Delivery.Unreadable, + MarmotDirectMessage.classify(wrapFor(), carol, alicePublicKey), + ) + } + + @Test + fun `our own message is ours, even though we cannot open it`() { + // The case that destroys data if it is missed. Alice cannot decrypt the wrap she + // sent -- its key was discarded -- so nothing about the payload distinguishes this + // from a bystander's view. Only the MLS sender identity does. Were it to come back + // Unreadable, the inbound path would file an empty line over the row sendChatMessage + // wrote, which is the only copy of those words anywhere. + val delivery = MarmotDirectMessage.classify(wrapFor(), alice.keyPair, alicePublicKey) + + assertEquals(MarmotDirectMessage.Delivery.Ours, delivery) + } + + @Test + fun `ours is decided before anything is opened`() { + // Same as above, from the other direction: a wrap Alice could never open, that is + // not even addressed to her, is still hers if MLS says she sent it. Nothing here + // may depend on decryption succeeding. + val toEve = wrapFor(recipient = eve.pubKey) + + assertEquals( + MarmotDirectMessage.Delivery.Ours, + MarmotDirectMessage.classify(toEve, alice.keyPair, alicePublicKey), + ) + } + + @Test + fun `a message with no sender identity is dropped`() { + // Attribution has no other source. A line that cannot say who sent it must not be + // rendered at all rather than be attributed to the wrap, which names nobody. + val delivery = MarmotDirectMessage.classify(wrapFor(), bob, null) + + assertIs(delivery) + } + + @Test + fun `words sealed by one member and sent by another are dropped`() { + // The check that replaces MIP-03. Eve seals to Bob and sends; if MLS says the + // sender was Alice, the two disagree about who is speaking and the message is not + // rendered as anybody's. Without this, the seal inside is free to claim an author + // the frame does not support. + val delivery = MarmotDirectMessage.classify(wrapFor(signer = eve), bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains(eve.pubKey) && rejected.reason.contains(alicePublicKey), + "the reason should name both parties to the disagreement: ${rejected.reason}", + ) + } + + @Test + fun `a seal relabelled with another author cannot even be opened`() { + // Eve's seal with Alice's pubkey written over it. This never reaches the author + // check, because NIP-44 derives the conversation key from the pubkey being + // claimed: relabelling a seal makes it undecryptable by the person it was + // encrypted for. The label is bound to the key, not merely asserted alongside it. + // + // So the outcome is Unreadable rather than Rejected -- Bob genuinely cannot read + // it -- and the group sees a private message it cannot open, which is the truth. + val forged = resealedAs(alicePublicKey, sealedBy = eve, to = bob) + + assertEquals( + MarmotDirectMessage.Delivery.Unreadable, + MarmotDirectMessage.classify(forged, bob, alicePublicKey), + ) + } + + @Test + fun `a seal tampered with after signing is dropped`() { + // Alice's own seal with its timestamp altered. Everything the previous test + // relies on still holds -- the pubkey is Alice's, so the content decrypts -- and + // the pubkey matches what MLS says, so the author check passes. Only the + // signature is left, and it is what catches this: the id no longer commits to the + // fields, so verify() fails. + val tampered = tamperedTimestamp(sealedBy = alice, to = bob) + + val delivery = MarmotDirectMessage.classify(tampered, bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains("does not verify"), + "expected a signature rejection, got: ${rejected.reason}", + ) + } + + @Test + fun `a rumor by somebody other than the sealer is dropped`() { + // A correctly signed seal by Alice, wrapped around a rumor claiming to be Eve's. + // Everything outside the innermost layer checks out; the words would be filed + // under whoever the rumor names if this were not caught. + val mismatched = sealAroundForeignRumor(sealedBy = alice, rumorFrom = eve.pubKey, to = bobPublicKey) + + val delivery = MarmotDirectMessage.classify(mismatched, bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains("inside a seal by"), + "expected a rumor/seal author mismatch, got: ${rejected.reason}", + ) + } + + @Test + fun `a rumor of the wrong kind is dropped`() { + // kind:9 is what an ordinary group message uses. Arriving gift wrapped it is not a + // direct message this version knows how to render, and rendering it as one would + // put a group message behind a lock. + val delivery = MarmotDirectMessage.classify(wrapFor(kind = ChatEvent.KIND), bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains("${ChatEvent.KIND}"), + "expected the offending kind in the reason, got: ${rejected.reason}", + ) + } + + /** [sealedBy]'s seal with [claimedAuthor] written over its pubkey, wrapped for [to]. */ + private fun resealedAs( + claimedAuthor: String, + sealedBy: NostrSignerSync, + to: KeyPair, + ): GiftWrapEvent { + val honest = MarmotDirectMessage.wrap( + signer = sealedBy, + recipientPublicKey = to.pubKey.toHexKey(), + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = emptyArray(), + content = text, + ) + val seal = MarmotDirectMessage.open(honest, to)!!.seal + + val relabelled = + Event( + id = seal.id, + pubKey = claimedAuthor, + createdAt = seal.createdAt, + kind = SealedRumorEvent.KIND, + tags = seal.tags, + content = seal.content, + sig = seal.sig, + ) + + return GiftWrapEvent.create( + event = relabelled, + recipientPubKey = to.pubKey.toHexKey(), + createdAt = at, + ) + } + + /** [sealedBy]'s own seal, its timestamp altered after signing, wrapped for [to]. */ + private fun tamperedTimestamp( + sealedBy: NostrSignerSync, + to: KeyPair, + ): GiftWrapEvent { + val honest = MarmotDirectMessage.wrap( + signer = sealedBy, + recipientPublicKey = to.pubKey.toHexKey(), + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = emptyArray(), + content = text, + ) + val seal = MarmotDirectMessage.open(honest, to)!!.seal + + val altered = + Event( + id = seal.id, + pubKey = seal.pubKey, + createdAt = seal.createdAt + 86_400, + kind = SealedRumorEvent.KIND, + tags = seal.tags, + content = seal.content, + sig = seal.sig, + ) + + return GiftWrapEvent.create( + event = altered, + recipientPubKey = to.pubKey.toHexKey(), + createdAt = at, + ) + } + + /** A properly signed seal by [sealedBy] whose rumor claims [rumorFrom] wrote it. */ + private fun sealAroundForeignRumor( + sealedBy: NostrSignerSync, + rumorFrom: String, + to: String, + ): GiftWrapEvent { + val rumor = MarmotDirectMessage.rumor(rumorFrom, ChatMessageEvent.KIND, at, emptyArray(), text) + + val seal = + sealedBy.signNormal( + createdAt = at, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = sealedBy.nip44Encrypt(rumor.toJson(), to), + ) + + return GiftWrapEvent.create(event = seal, recipientPubKey = to, createdAt = at) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt new file mode 100644 index 00000000..1d3d2474 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt @@ -0,0 +1,185 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The envelope a direct message travels in, run against real secp256k1 and real NIP-44. + * + * Two of these assert properties that read like bugs and are not. The wrap names nobody, + * so the group can only learn who sent it from the MLS frame around it; and the sender + * cannot reopen their own message, because the key that sealed it was discarded. Both are + * consequences of using a throwaway key, both are documented in + * docs/marmot-direct-messages.md, and both are here so that a later change which quietly + * reverses them fails rather than ships. + */ +class MarmotDirectMessageTest { + private val alice = NostrSignerSync(KeyPair()) + private val bob = KeyPair() + private val eve = KeyPair() + + private val bobPublicKey = bob.pubKey.toHexKey() + private val at = 1_700_000_000L + private val text = "the vote is at six, do not tell the room" + + private fun aliceWrapsForBob(content: String = text): GiftWrapEvent = + MarmotDirectMessage.wrap( + signer = alice, + recipientPublicKey = bobPublicKey, + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = arrayOf(PTag.assemble(bobPublicKey, null)), + content = content, + ) + + @Test + fun `the recipient reads what the sender wrote`() { + val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob), "Bob could not open a wrap addressed to him") + + assertEquals(text, opened.rumor.content) + assertEquals(alice.pubKey, opened.rumor.pubKey) + assertEquals(ChatMessageEvent.KIND, opened.rumor.kind) + } + + @Test + fun `the sender cannot reopen their own message`() { + // The throwaway key is gone, so this is unrecoverable by construction. Asserted + // rather than merely documented, because the obvious "fix" -- persisting the + // throwaway private key -- would be strictly worse than the identity-keyed wrap + // this design was chosen over, and would reintroduce the attribution the + // throwaway key exists to remove. + assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), alice.keyPair)) + } + + @Test + fun `a bystander gets nothing, and no exception`() { + // Null rather than a throw: the caller is midway through processing a kind:445 + // that carries a real message for somebody, and an exception would abandon it. + assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), eve)) + } + + @Test + fun `the wrap names nobody`() { + val first = aliceWrapsForBob() + val second = aliceWrapsForBob() + + assertNotEquals(alice.pubKey, first.pubKey, "the wrap is keyed to its sender") + assertNotEquals(bobPublicKey, first.pubKey, "the wrap is keyed to its recipient") + assertNotEquals(first.pubKey, second.pubKey, "the throwaway key is being reused") + + // Signed by the throwaway key, per NIP-59. The signature attributes nothing -- + // the signer is meaningless and discarded -- and keeping it is what makes this a + // real gift wrap that GiftWrapEvent.create builds and unwrapOrNull opens. + assertTrue(first.verify(), "the wrap does not verify against its own key") + assertEquals(GiftWrapEvent.KIND, first.kind) + } + + @Test + fun `the seal is what binds the words to their author`() { + val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob)) + + assertEquals(SealedRumorEvent.KIND, opened.seal.kind) + // The inbound path checks exactly this pair against the MLS sender identity. It + // is what replaces the MIP-03 pubkey check for kind:1059, so if the seal stops + // being signed by the sender, the carve-out becomes a hole. + assertEquals(alice.pubKey, opened.seal.pubKey) + assertTrue(opened.seal.verify(), "the seal's signature does not verify") + assertEquals(opened.seal.pubKey, opened.rumor.pubKey) + } + + @Test + fun `a seal from somebody else is still opened, and is caught by its pubkey`() { + // open() decrypts; it does not adjudicate. Eve can seal her own rumor to Bob and + // wrap it, and Bob's key will open it -- what stops it being rendered as Alice's + // is the inbound check that the seal's pubkey is the MLS sender's identity. This + // test pins the half open() is responsible for: the pubkey it reports is Eve's. + val eveSigner = NostrSignerSync(eve) + val forged = + MarmotDirectMessage.wrap( + signer = eveSigner, + recipientPublicKey = bobPublicKey, + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = arrayOf(PTag.assemble(bobPublicKey, null)), + content = "alice here, send the funds", + ) + + val opened = assertNotNull(MarmotDirectMessage.open(forged, bob)) + + assertEquals(eveSigner.pubKey, opened.seal.pubKey) + assertNotEquals(alice.pubKey, opened.seal.pubKey) + } + + @Test + fun `the rumor id is the one the sender queued`() { + val tags = arrayOf(PTag.assemble(bobPublicKey, null)) + + // What the outbound queue computes before it has a signer, and what it later uses + // to find the chat message to link and broadcast. If these ever diverge the + // message is encrypted, stored, and silently never sent. + val queued = + EventHasher.hashId( + alice.pubKey, + at, + ChatMessageEvent.KIND, + tags, + text, + ) + + val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob)) + + assertEquals(queued, opened.rumor.id) + assertEquals(queued, MarmotDirectMessage.rumor(alice.pubKey, ChatMessageEvent.KIND, at, tags, text).id) + } + + @Test + fun `no layer is fuzzed into the past`() { + val wrap = aliceWrapsForBob() + val opened = assertNotNull(MarmotDirectMessage.open(wrap, bob)) + + // NIP-59 defaults every one of these to randomWithTwoDays(). Inside MLS that only + // scatters the bystander line up to two days out of position in every other + // member's transcript, so all three carry the real message time. + assertEquals(at, wrap.createdAt) + assertEquals(at, opened.seal.createdAt) + assertEquals(at, opened.rumor.createdAt) + } + + @Test + fun `the recipient is on the wrap where the group can see it`() { + val wrap = aliceWrapsForBob() + + // Deliberate: it is what lets a bystander's transcript say who the message was + // for. Removing it would hide the recipient from the group at the cost of the + // named line -- see the decisions table in docs/marmot-direct-messages.md. + assertEquals(bobPublicKey, wrap.tags.firstTagValue("p")) + } + + @Test + fun `garbage in the wrap does not become a message`() { + val notAWrap = + alice.signNormal( + createdAt = at, + kind = GiftWrapEvent.KIND, + tags = arrayOf(PTag.assemble(bobPublicKey, null)), + content = "not nip-44 at all", + ) + + assertNull(MarmotDirectMessage.open(notAWrap, bob)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/Nip17FiltersTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/Nip17FiltersTest.kt new file mode 100644 index 00000000..8ecf0c8f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/Nip17FiltersTest.kt @@ -0,0 +1,80 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.query.NostrEventFilterQuery +import press.mantra.compose.network.serialization.encodeToJsonString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Pins the only gift wrap filter that can come back with something we can read. + * + * Every clause here is one that was got wrong in production. Two call sites asked + * for `authors = [our pubkey]`, which cannot match a wrap signed by a throwaway + * key and so returned nothing at all, silently, for as long as it existed. A third + * asked for `p = [the peer]`, which returned other people's mail and crashed the + * save that tried to unseal it. Neither failure was visible from reading the + * filter, so the shape is asserted instead of trusted. + */ +class Nip17FiltersTest { + + private val us = "a".repeat(64) + + @Test + fun `it asks for wraps addressed to us`() { + assertEquals(mapOf("p" to listOf(us)), Nip17Filters.inbox(us).tags) + } + + @Test + fun `it constrains no authors`() { + // GiftWrapEvent.create signs with a key it generates and drops, so the author + // of a wrap is a value nobody can predict -- least of all the sender's own + // pubkey. Any authors clause here silently matches zero events on every relay. + assertNull(Nip17Filters.inbox(us).authors) + } + + @Test + fun `it carries no since cursor`() { + // A wrap is stamped up to two days earlier than it was sent, so a high-water + // mark taken from the newest wrap we hold skips mail that arrives behind it. + // Anything reintroducing `since` has to back-date by at least two days first. + assertNull(Nip17Filters.inbox(us).since) + assertNull(Nip17Filters.inbox(us).until) + } + + @Test + fun `it asks for gift wraps and nothing else`() { + assertEquals(listOf(1059), Nip17Filters.inbox(us).kinds?.toList()) + } + + @Test + fun `two callers asking for the same inbox make one request`() { + // computeId hashes the encoded filter, so the chat room list and the chat + // message screen collapse into a single negentropy request only while both + // encode identically. Building the filter once is what holds that true; the + // wire shape is asserted so an added default cannot quietly split them. + assertEquals(Nip17Filters.inbox(us), Nip17Filters.inbox(us)) + assertEquals( + """{"kinds":[1059],"tags":{"p":["$us"]},"limit":50}""", + Nip17Filters.inbox(us).encodeToJsonString(), + ) + } + + @Test + fun `the local set it builds is the same set the relay is asked for`() { + // Negentropy reconciles our local set against the relay's: this filter goes out + // in NEG-OPEN, and the local side is built by running the same filter through + // NostrEventFilterQuery. A clause that survives one trip and not the other + // reports differences that are not real -- events re-downloaded forever, or + // pushed at a relay that excluded them on purpose. What matters here is that + // the local query reads the p tag and, like the wire filter, bounds no author: + // an authors clause would show up as `pubKey IN (?)`. + val query = NostrEventFilterQuery.build(Nip17Filters.inbox(us)) + + assertEquals( + "SELECT * FROM NostrEvent WHERE kind IN (?) AND (tags LIKE ? ESCAPE '\\') " + + "ORDER BY createdAt DESC, id DESC", + query.sql, + ) + } +} 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()) + } +} diff --git a/docs/README.md b/docs/README.md index 71c340dc..dc1987ae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,9 +9,12 @@ 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 | +| [marmot-direct-messages.md](./marmot-direct-messages.md) | a one-to-one message inside a group as a stock NIP-59 gift wrap — what its MIP-03 carve-out costs, why the sender cannot read their own, and the one query that would broadcast it | +| [mls-skipped-keys.md](./mls-skipped-keys.md) | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app | | [long-running-sync.md](./long-running-sync.md) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list | | [dead-code.md](./dead-code.md) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover | -Start with the ceremony if you are new to this area; the next two both assume it. -The last two stand alone, and the dead-code inventory reads as a follow-up to the -sync note. +Start with the ceremony if you are new to this area; the Marmot notes all 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. The +sync note stands alone, and the dead-code inventory reads as a follow-up to it. diff --git a/docs/marmot-direct-messages.md b/docs/marmot-direct-messages.md new file mode 100644 index 00000000..8540c89d --- /dev/null +++ b/docs/marmot-direct-messages.md @@ -0,0 +1,360 @@ +# Direct messages inside a Marmot group + +A one-to-one message carried as an MLS application message: an ordinary NIP-59 gift +wrap, ephemeral-keyed and signed the way NIP-59 says, addressed to one member and +never broadcast to a relay. Every member of the group sees that a private message +was sent and to whom. Only the recipient can read it. + +Read [Why a throwaway key, and what it costs](#why-a-throwaway-key-and-what-it-costs) +before changing anything here. Two consequences of that choice reach into every +part of this, and one of them cannot be undone. + +## What a direct message is, outside in + +``` +kind:445 GroupEvent ephemeral signer, h-tag = nostrGroupId → relays +└─ ChaCha20-Poly1305 MLS exporter secret → group + └─ MLS PrivateMessage ContentType.APPLICATION → group + │ sender authenticated by MLS leaf ────────┐ + └─ kind:1059 wrap pubkey = throwaway, signed by it │ → group + └─ NIP-44 conversation key: throwaway ↔ recipient │ + └─ kind:13 seal signed by the sender ───── bound to ─────┘ + └─ NIP-44 → recipient + └─ kind:14 rumor → recipient +``` + +The group reaches the fourth layer and stops. Everything above it is what a +bystander renders from: a recipient, a time, a wall — and a sender who comes from +the MLS frame rather than from anything written in the wrap. + +The two outer layers are unchanged. A direct message is an ordinary kind:445 to +anyone watching a relay and an ordinary application message to `MlsGroup`; the +whole feature is what goes in as the application payload. + +`MarmotDirectMessage` builds and opens it, as pure functions of their arguments +with no database and no MLS state — which is what makes it testable, since +Room-backed code cannot be unit-tested in this project. + +## Why a throwaway key, and what it costs + +The wrap uses a fresh random key and is signed by it, exactly as NIP-59 specifies +and exactly as `GiftWrapEvent.create` builds one. Nothing in the wrap names the +sender. + +**What it does not buy: hiding the sender from the group.** MLS authenticates every +application message to a leaf. `mlsGroup.memberIdentityHex(decrypted.senderLeafIndex)` +yields the sender's real pubkey no matter what the inner event claims, and there is +no way to send an application message that does not. The throwaway key removes a +*redundant* copy of an identity the MLS frame already proves — it does not remove +the identity. + +**What it does buy:** a wrap lifted out of its MLS frame — a log line, a database +export, a crash dump — is not attributable to anyone. It is also a well-formed +NIP-59 gift wrap rather than a Marmot-shaped variant, which is worth something to +anyone reading the payload with ordinary nostr tooling. + +Two costs follow, and both are load-bearing. + +### It requires a carve-out in a security check + +`MarmotInboundManager.processPrivateMessage` rejects any inner application event +whose `pubKey` is not the MLS sender's credential identity: + +``` +MIP-03: inner event pubkey (…) does not match MLS sender identity (…) +``` + +That is MIP-03, not local policy, and it exists so a member cannot mint events +claiming a different author. A throwaway-keyed wrap cannot satisfy a check about +its author, so kind:1059 is exempt from it: + +```kotlin +if (innerEvent.kind != GiftWrapEvent.KIND && innerEvent.pubKey != senderIdentity) { + return GroupEventResult.Error(groupId, "MIP-03: inner event pubkey …") +} +``` + +`senderIdentity` is now *required* rather than merely compared. Under the old check +a null identity failed only because the comparison failed; attribution depends on +it outright, so it fails on its own. + +**The check is not weakened, it is relocated and strengthened.** What replaces it +for kind:1059 is `seal.pubKey == senderIdentity` on a seal whose `verify()` passes +— a signature bound to an MLS leaf, rather than a plaintext field compared to one. +The attack it stops is a member re-wrapping a seal they were legitimately sent and +passing it to a third party as its author's; that fails because the MLS frame says +who actually sent *this* one. Forging one outright needs the other member's private +key. + +A seal is harder to relabel than it looks, and not because of the signature. +NIP-44 derives the conversation key from the pubkey being claimed, so writing +another member's key over a seal makes it undecryptable by the person it was +encrypted for — it fails at the decryption, before any check runs, and comes out as +a message the recipient simply cannot read. The label is bound to the key rather +than asserted alongside it. `verify()` is what catches the remaining case: a seal +altered after signing in a way that survives decryption. Both have tests, because +the first was asserted the wrong way round until one of them failed. + +> **Compatibility.** Every member's client needs this carve-out. A Marmot client +> implementing MIP-03 as written drops these messages as impersonation — silently, +> as a `GroupEventResult.Error` — so a group with one unpatched member has one +> member who never receives a direct message and is never told why. This is a +> divergence from the spec, and a reason to raise it upstream rather than carry it +> indefinitely. + +### The sender cannot read their own messages back + +The throwaway private key is discarded at send time, so nothing can reopen the wrap +afterwards — not even the person who built it. NIP-17 solves this by sending a +second wrap addressed to yourself; here that would be a second application message, +and so a second *"sent a private message"* line in everyone else's transcript, so it +is not available. + +What follows: + +- The sending device keeps its own copy, because `sendChatMessage` writes the + plaintext `ChatMessage` row on the way out. Normal use is unaffected. +- A second device, or a reinstall, gets **nothing** — the sender's own half of + every conversation is unreadable to them anywhere it was not typed. The + recipient's half is unaffected. +- On a re-sync the sender's own message comes back as an opaque wrap they cannot + open, indistinguishable from a bystander's view. `ChatMessage.directMessage` + guards that case explicitly and files nothing, because a bystander line would + replace the words on the row `sendChatMessage` wrote — the only copy there is. + `NostrDao.persistInboundChatMessage` carries the same guard, for the same reason, + on the NIP-17 path. + +Do not solve this by storing the throwaway private key. A per-message private key at +rest is strictly worse than the identity-keyed wrap it was chosen over, and it +reintroduces the attribution the throwaway key exists to remove. There is a test — +`the sender cannot reopen their own message` — whose only job is to make that +reversal fail loudly rather than ship. + +## The one way to broadcast this by accident + +Nothing sweeps events to relays on its own. Broadcast is driven by +`BroadcastNostrEventRequest` rows joined to `NostrEvent`, inserted explicitly. So +the requirement "this must never be broadcast" reduces to one query: + +```sql +SELECT * FROM GiftWrapPayload WHERE publicKey = :publicKey AND giftWrapSealId IS NULL +``` + +`GiftWrapPayloadDao.observeUnsealedGiftWrapPayloads`. The notary watches it, and +`DatabaseChatRepository.sealGiftWrapPayload` seals every row it returns and hands it +to `NostrNip17Dao.persistAndBroadcastGiftWrap` — which inserts a +`BroadcastNostrEventRequest` per relay. **Write one `GiftWrapPayload` row authored +by the local user with a null seal id and the direct message leaves the device.** + +Hence the rule: + +> A Marmot direct message never writes to `GiftWrapPayload` or `GiftWrapMessage` +> at all. Its outbound queue is `MarmotInnerEvent`, and so is its inbound record. + +The risk is sharper here than it would be with a Marmot-shaped payload, because what +this builds is a genuine, well-formed, correctly signed NIP-59 gift wrap. It is +indistinguishable from something the NIP-17 path would be right to publish. Nothing +but the tables it is kept out of stops it going to a relay. + +`GiftWrapMessage` could not be written anyway without a `NostrEvent` row — its +foreign key — and `NostrEvent` is the broadcast join target. The rule is also +enforced at the other end: `sealGiftWrapPayload` refuses any payload whose room has +a non-null `mlsGroupState`, and logs. An MLS room should never produce a NIP-17 gift +wrap for any reason, and an invariant in code is what stops a later refactor from +walking a direct message onto a relay without reading this page first. + +## Attribution comes from MLS, not from the payload + +Because nothing in the wrap names the sender, every sender-derived field is read +from the MLS frame. This is not a workaround; it is the correct source, and it is +what makes the carve-out above safe. + +`GroupEventResult.ApplicationMessage` carries `senderLeafIndex` but not the resolved +identity, and `ChatMessage.fromGroupEventResult` has no `MlsGroup` to resolve it +with. The obvious fix — adding `senderIdentity` to the result — is not available: +quartz is a **binary dependency** here (`com.vitorpamplona.quartz:quartz:1.14.0`), +and the local checkout at `~/Documents/development/nostr/amethyst/quartz` is a +reference copy, not a build input. Changing the result type would mean publishing a +fork. + +So `NostrDao` resolves it at the call site instead and passes it in. It holds the +group, the leaf index is already on the result, and an application message advances +no epoch, so the tree has not moved by the time it reads it. Same value, no fork. + +This also settled a bug that predates the feature. All fourteen arms of +`fromGroupEventResult` computed: + +```kotlin +isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey +``` + +`groupEvent.pubKey` is the kind:445's signer, and that is a fresh random ephemeral +key on every send — `NostrSignerInternal(KeyPair())` in +`encryptAndSendMarmotInnerEvent` — so it could never equal the active key. It was +false for every Marmot message in every room, which meant every message a member +sent themselves rendered as somebody else's: wrong side of the transcript, wrong +colour, and no delivery status, which is drawn only for our own lines. All fourteen +now read `senderIdentity`. For the four results that carry no leaf index — the +commit and proposal statuses — it is null and yields false, exactly as before. + +## The message's identity is the rumor id + +The queued `MarmotInnerEvent` for a direct message **is the rumor** — kind:14, +plaintext, p-tagged — so its id is a hash of exactly the fields the recipient will +have after unwrapping. + +This matters in two places. It keeps the outbound link intact, which is the +difference between a message being sent and silently not being sent (see +[Two bugs this uncovered](#two-bugs-this-uncovered)). And it gives the recipient a +stable id for dedupe across redelivery and re-sync. + +It does **not** give sender and recipient a shared identity for the same message the +way an identity-keyed wrap would, because the sender never re-derives the rumor from +an echo they cannot open. Two devices belonging to the sender do not converge; they +simply do not both have the message. + +The wire event — the wrap — has its own id, so a direct message leaves two rows on a +recipient's device: one for the artifact that travelled and one for the message it +carried. + +## Timestamps are not fuzzed + +NIP-59 randomises the wrap's and the seal's `created_at` by up to two days to +frustrate correlation at a relay, and both `GiftWrapEvent.create` and +`SealedRumorEvent.create` default to `TimeUtils.randomWithTwoDays()`. + +The wrap never reaches a relay, and the kind:445 around it already carries the true +time. Fuzzing would do nothing but scatter the *"sent a private message"* line up to +two days out of position in every other member's transcript. `MarmotDirectMessage` +passes the real message time to all three layers — a default to override, not one to +accept, and the easiest thing here to get wrong by omission. There is a test for it. + +## Three decisions + +| decision | taken | what it costs | +|---|---|---| +| Is the recipient visible to the group? | yes — the `p` tag stays on the wrap | The group learns who messages whom, and how often. Omitting the tag is genuinely cheap here: the recipient decrypts against the wrap's own throwaway pubkey, so they need no tag to find their own mail, and one failed NIP-44 decrypt per member per message is the whole cost. What it takes is the named bystander line — *"sent a private message"* with no recipient — and any ordering by conversation. Worth revisiting; not free, but close. | +| Is the wrap signed? | yes — by the throwaway key, per NIP-59 | Nothing, which is the point. The signer is meaningless and discarded, so the signature attributes nothing, and keeping it means the payload is a real NIP-59 gift wrap that `GiftWrapEvent.create` builds and `unwrapOrNull` opens with no special cases. | +| What kind is the rumor? | `ChatMessageEvent.KIND` (14) | Group messages here are kind:9 (`ChatEvent`) — `sendChatMessage` maps 14 → 9 on the way in for an ordinary message and leaves a direct message at 14. It is a NIP-17 chat message that happens to travel inside a group, and the kind is what tells the two apart on the way back in. Costs one more arm in the inbound switch. | + +## The path a message takes + +**Out.** `sendChatMessage` resolves the recipient against the room's participants — +refusing a non-member, whose wrap nobody could open, and refusing yourself, whose +wrap could never be read back — and writes two rows: the queued `MarmotInnerEvent` +(the rumor) and the `ChatMessage` holding the plaintext. The notary picks the queued +row up, `MarmotOutboundDao` calls `MarmotDirectMessage.wrap` instead of assembling a +bare rumor, and everything downstream is unchanged. Once sent, the plaintext is +scrubbed from the queued row: it is already on the `ChatMessage`, and a second copy +would be cleartext left in a table that otherwise holds nothing but wire events. + +**In.** `MarmotInboundManager` decrypts and resolves the sender's identity, and +`MarmotDirectMessage.classify` decides what the wrap is to this device — ours, +readable, unreadable, or rejected. `ChatMessage.directMessage` turns that into rows. +The split is so the decision can be tested: only the filing needs a database, and +Room-backed code cannot be unit-tested here. The recipient opens the wrap, +validates the seal against `senderIdentity`, stores the rumor as its own +`MarmotInnerEvent` keyed on the rumor's id, and gets the words. A bystander cannot +open it and gets a line with no content. The sender gets nothing, because their +local row is the only copy. + +A failed validation drops the message and logs; it does not throw. The caller is +inside `storeNostrEvent`'s transaction, and a forged direct message should cost its +own line, not the whole event. + +**On screen.** Tapping somebody else's message in a group offers *Reply privately*, +which arms the composer; sending clears it. An armed composer shows a chip naming +the recipient, a placeholder that says who it is going to, and a tinted field, and +the chip's close button is the one tap back to the room. Three signals rather than +one because the failure this guards against — sending to the room what was meant for +one person, or the reverse — cannot be taken back once it is on the wire. + +## Two bugs this uncovered + +### The wrap's id is not the rumor's + +`encryptAndSendMarmotInnerEvent` found the chat message to link and broadcast with +`getChatMessagesByMarmotInnerEventId(innerEvent.id)` — the *recomputed* id. For a +direct message that is the wrap's id, not the queued rumor's, so the lookup returned +null, the `ChatMessage` was never linked, **and no `BroadcastNostrEventRequest` was +ever inserted**. The message would have been encrypted, persisted, and never sent, +with no error anywhere. + +It now keys on `marmotInnerEvent.id` — what `sendChatMessage` wrote into +`ChatMessage.marmotInnerEventId`, and identical to `innerEvent.id` for every +non-direct message, so nothing else moved. + +### `isUserMessage` was always false for Marmot messages + +Covered under [Attribution comes from MLS](#attribution-comes-from-mls-not-from-the-payload). +It stopped being merely cosmetic here: with nothing in the payload naming the +sender, `senderIdentity` is the only source of attribution there is. + +## Where it lives + +| file | what | +|---|---| +| `nostr/MarmotDirectMessage.kt` | wrap / open, the pure seam both directions call | +| `commonTest/.../MarmotDirectMessageTest.kt` | the envelope: ten cases, against real secp256k1 and real NIP-44 | +| `commonTest/.../MarmotDirectMessageDeliveryTest.kt` | what a device does with an arriving wrap, including every forgery it must refuse | +| `commonTest/.../MarmotMip03CarveOutTest.kt` | the kind:1059 exemption and its edges | +| `managers/MarmotInboundManager.kt` | the kind:1059 carve-out; requires the sender identity | +| `database/model/ChatMessage.kt` | `TYPE_DIRECT_MESSAGE`; the kind:1059 arm and its three outcomes | +| `database/dao/NostrDao.kt` | resolves `senderIdentity` and passes it in | +| `database/repository/DatabaseChatRepository.kt` | queues the rumor; refuses to seal an MLS room's payload | +| `database/dao/MarmotOutboundDao.kt` | wraps on the way out; scrubs the plaintext | +| `database/model/MarmotInnerEvent.kt` | `directMessageRecipientPublicKey`, the outbound signal | +| `ui/view/model/ChatMessageListViewModel.kt` | armed state; the two renderings | +| `ui/composable/ChatRoomMessagingScreen.kt` | the recipient chip | + +Untouched, deliberately: `NostrNip17Dao`, `GiftWrapMessage`, `GiftWrapSeal`, +`GiftWrapPayload`, and every broadcast path. + +## What this does not do + +Each of these will be reported as a bug at some point. They are not. + +**The group learns that a direct message happened, and to whom.** Only the contents +are private, and the sender is authenticated by MLS whatever the wrap says. That is +the design, and the UI should say so in words somewhere a user meets before their +first private message — it does not yet. + +**The sender cannot read their own messages anywhere they were not typed.** The +throwaway key is gone. A reinstall or a second device recovers the recipient's half +of a conversation and none of its own. + +**Other Marmot clients drop these messages.** Until the carve-out is upstream, a +group needs every member on a client that carries it, and an unpatched member fails +silently. + +**The inner layer is not forward secret.** The kind:445 envelope inherits MLS epoch +forward secrecy; the NIP-44 layers inside do not. A recipient's identity key that +leaks opens every direct message they still hold, including ones sent years earlier. +This is strictly weaker than the group messages sitting beside them in the same +room. + +**A removed member keeps what they already have.** Removal advances the epoch; it +does not reach back into their device. + +**Disappearing messages apply at the envelope only.** The group's +`disappearingMessageSecs` puts a NIP-40 expiration on the kind:445, which relays +honour. Local rows are unaffected, exactly as for group messages today. + +**One recipient per message.** Several would mean several wraps, and so several +bystander lines for one message. Worth doing; worth designing first. + +**No reactions, receipts, replies or attachments.** A reply needs an `e` tag inside +the rumor and is a small addition later. A reaction is a design question rather than +a coding one, because the reaction itself would be visible to the group. + +**No member picker.** The only way to start a private message is to reply to one the +member already sent, so you cannot open a conversation with somebody who has not +spoken. + +**Three things have no automated test**, all of them for want of infrastructure +rather than by choice. The recipient validation in `sendChatMessage` (refusing a +non-member, refusing yourself) and the outbound id lookup both need a database; the +invariant the lookup depends on — that the queued row's id is the rumor's — is +tested in its place. The two transcript renderings need Compose UI testing, which +this project has no dependency on. Anything touching a real `MlsGroup` is likewise +untested: the carve-out is tested through `mip03Rejection`, not through a group. 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.