diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/10.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/10.json new file mode 100644 index 00000000..0d184295 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/10.json @@ -0,0 +1,5443 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "ab781e3489c1258d2dc39c2b2f35cb61", + "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": "FrostSigningItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `itemIndex` INTEGER NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signature` TEXT, PRIMARY KEY(`sessionId`, `itemIndex`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemIndex", + "columnName": "itemIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unsignedEventJson", + "columnName": "unsignedEventJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nonceRandom", + "columnName": "nonceRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aggregatedNonce", + "columnName": "aggregatedNonce", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "itemIndex" + ] + }, + "indices": [ + { + "name": "index_FrostSigningItem_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, `signerIds` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, `approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signerId", + "columnName": "signerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "signApprovedAt", + "columnName": "signApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedAt", + "columnName": "approvalRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_FrostSigningSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_FrostSigningSession_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `receiverPublicKey` TEXT NOT NULL, `receiverRelayHit` TEXT, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverRelayHit", + "columnName": "receiverRelayHit", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapSeal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `giftWrapMessageId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapMessageId`) REFERENCES `GiftWrapMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "giftWrapMessageId", + "columnName": "giftWrapMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapMessageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapPayload", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "giftWrapSealId", + "columnName": "giftWrapSealId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapSeal", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapSealId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyState", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedBy", + "columnName": "announcedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedAt", + "columnName": "announcedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyState_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "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, 'ab781e3489c1258d2dc39c2b2f35cb61')" + ] + } +} \ 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 9baaaeb7..4f62d523 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -106,6 +106,7 @@ 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.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.Relay import press.mantra.compose.database.model.RepostedRelation @@ -130,6 +131,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) DkgParticipantMessage::class, DkgSession::class, FrostSignerMessage::class, + FrostSigningItem::class, FrostSigningSession::class, GiftWrapMessage::class, GiftWrapSeal::class, @@ -172,7 +174,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 9, + version = 10, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -215,6 +217,11 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // is what they were signing as, so one caught mid-flight finishes the way // it began rather than switching keys between two of its own rounds. AutoMigration(from = 8, to = 9) + // v10 moves FrostSigningSession's five per-event columns onto the new + // FrostSigningItem table, so one session can sign a batch. It copies + // before it drops, which no AutoMigration can express -- see + // MIGRATION_9_10, and the note there on why an in-flight session losing + // its nonce seed would be worse than losing the session. ] ) @ColumnTypeConverters(MantraConverters::class) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt index 50bf1dbc..92b31069 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt @@ -3,6 +3,7 @@ package press.mantra.compose.database.builder import androidx.room3.RoomDatabase import androidx.sqlite.driver.bundled.BundledSQLiteDriver import press.mantra.compose.database.migrations.MIGRATION_3_4 +import press.mantra.compose.database.migrations.MIGRATION_9_10 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -16,9 +17,11 @@ fun getRoomDatabase( builder: RoomDatabase.Builder ): press.mantra.compose.database.MantraDatabase { return builder - // Everything else Room generates itself; this one rewrites rows rather than - // changing shape, which an AutoMigration cannot express. - .addMigrations(MIGRATION_3_4) + // Everything else Room generates itself. These two move data rather than + // only changing shape, which an AutoMigration cannot express: 3->4 rewrites + // chat rows, and 9->10 copies a session's per-event columns onto the items + // table before dropping them. + .addMigrations(MIGRATION_3_4, MIGRATION_9_10) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt index 0b68a2b2..bc6ba0d0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt @@ -2,11 +2,13 @@ package press.mantra.compose.database.dao import androidx.room3.Dao import androidx.room3.Query +import androidx.room3.Transaction 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.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession @Dao @@ -36,6 +38,46 @@ interface FrostSigningSessionDao { @Upsert suspend fun upsert(frostSignerMessage: FrostSignerMessage) + /** + * A session's events, in the order the batch fixed at proposal. + * + * Every join of nonces and partial signatures is positional, so this + * ordering is part of the protocol rather than a presentation choice — + * two devices reading a batch in different orders aggregate against + * different messages. + */ + @Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId ORDER BY itemIndex ASC") + suspend fun getItems(sessionId: String): List + + @Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId ORDER BY itemIndex ASC") + fun observeItems(sessionId: String): Flow> + + @Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId AND itemIndex = :itemIndex") + suspend fun getItem(sessionId: String, itemIndex: Int): FrostSigningItem? + + @Upsert + suspend fun upsert(frostSigningItem: FrostSigningItem) + + /** + * Writes a whole batch's worth of items at once. + * + * A transaction rather than a loop because the coordinator's two broadcasts + * each settle every item together: a half-written aggregate would leave the + * session gated on `signerIds` while some items had no nonce to aggregate + * against, which is a stall no message can clear. + */ + @Transaction + @Upsert + suspend fun upsertItems(items: List) + + /** How many events this session signs. Derived rather than stored — see [FrostSigningItem]. */ + @Query("SELECT COUNT(*) FROM FrostSigningItem WHERE sessionId = :sessionId") + suspend fun countItems(sessionId: String): Int + + /** How many of them the group has finished. Equal to [countItems] once it is done. */ + @Query("SELECT COUNT(*) FROM FrostSigningItem WHERE sessionId = :sessionId AND signature IS NOT NULL") + suspend fun countSignedItems(sessionId: String): Int + @Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY createdAt ASC") suspend fun getMessagesByKind(sessionId: String, kind: Kind): List diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigration.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigration.kt new file mode 100644 index 00000000..3b247268 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigration.kt @@ -0,0 +1,90 @@ +package press.mantra.compose.database.migrations + +import androidx.room3.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +/** + * Moves a signing session's per-event columns onto `FrostSigningItem`, so a + * session can sign more than one event. + * + * Not an `AutoMigration`. Room can create the table and it can drop the columns, + * but it cannot copy between them, and this migration is entirely about the copy. + * + * ### Why the backfill has to be exact + * + * A session in flight at upgrade time holds two things that cannot be + * regenerated: `nonceRandom`, the seed its secret nonce is derived from, and + * `aggregatedNonce`, the aggregate its partial signature is made against. Lose + * the seed and the next pass derives a *different* nonce for the same message, + * then publishes a second partial signature over it -- two partial signatures, + * one message, two nonces, which is precisely how a secret share is extracted. + * Lose the aggregate and the session republishes against a new one, with the + * same result. + * + * So this copies them verbatim into item 0, and an in-flight session resumes as + * though nothing happened. Dropping the columns and letting sessions start over + * would have been the one dangerous way to write this migration. + * + * `signerIds` stays on the session: it is shared by every item of a batch, and + * an upgraded session keeps whichever set it had chosen. + * + * ### Why `DROP COLUMN` rather than a table rebuild + * + * The usual way to remove columns in SQLite -- create a new table, copy, drop + * the old one, rename -- is unsafe here and quietly so. `FrostSignerMessage` and + * the new `FrostSigningItem` both reference `FrostSigningSession(id)` + * `ON DELETE CASCADE`, and `DROP TABLE` fires cascades: with foreign keys + * enforced it would delete every signer message and every item this migration + * had just written. It would depend entirely on Room having turned foreign keys + * off around the migration, which is not a thing worth depending on when there + * is an alternative that cannot go wrong. + * + * `ALTER TABLE ... DROP COLUMN` is that alternative. It needs SQLite 3.35, and + * the columns must be free of indices and constraints -- these five are, and + * `getRoomDatabase` pins `BundledSQLiteDriver` on every platform, so the version + * is ours rather than the host's. The table, its foreign key and both its + * indices are left alone. + */ +val MIGRATION_9_10 = object : Migration(9, 10) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `FrostSigningItem` (" + + "`sessionId` TEXT NOT NULL, " + + "`itemIndex` INTEGER NOT NULL, " + + "`unsignedEventJson` TEXT NOT NULL, " + + "`eventId` TEXT NOT NULL, " + + "`nonceRandom` TEXT NOT NULL, " + + "`aggregatedNonce` TEXT, " + + "`signature` TEXT, " + + "PRIMARY KEY(`sessionId`, `itemIndex`), " + + "FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` " + + "ON `FrostSigningItem` (`sessionId`)" + ) + + // Every existing session signed exactly one event, so every one of them + // becomes a batch of one at index 0. + connection.execSQL( + "INSERT INTO `FrostSigningItem` " + + "(`sessionId`, `itemIndex`, `unsignedEventJson`, `eventId`, " + + "`nonceRandom`, `aggregatedNonce`, `signature`) " + + "SELECT `id`, 0, `unsignedEventJson`, `eventId`, " + + "`nonceRandom`, `aggregatedNonce`, `signature` " + + "FROM `FrostSigningSession`" + ) + + listOf( + "unsignedEventJson", + "eventId", + "nonceRandom", + "aggregatedNonce", + "signature", + ).forEach { column -> + connection.execSQL("ALTER TABLE `FrostSigningSession` DROP COLUMN `$column`") + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningItem.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningItem.kt new file mode 100644 index 00000000..8ebc6534 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningItem.kt @@ -0,0 +1,88 @@ +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 + +/** + * One event a signing session signs, and everything about it that cannot be + * shared with the others. + * + * A session signs a *batch*: one ceremony, one signer set, one approval, and `n` + * events. What separates them is forced by FROST rather than chosen. A Schnorr + * partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under + * one nonce `R` give two equations in one unknown and the secret share falls + * out. Every item therefore carries its own nonce material and its own result, + * and the only things a batch may hold in common are the ones that do not enter + * that equation -- see [FrostSigningSession]. + * + * These five columns lived on [FrostSigningSession] until the schema moved to + * v10, which is why a session created before it reads back as exactly one item + * at [itemIndex] 0. + */ +@Entity( + primaryKeys = ["sessionId", "itemIndex"], + foreignKeys = [ + ForeignKey( + entity = FrostSigningSession::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + Index("sessionId"), + ], +) +data class FrostSigningItem( + val sessionId: String, + + /** + * Position in the batch, from zero. + * + * Load-bearing rather than cosmetic: it is the order every device joins + * nonces and partial signatures in, so two devices that disagree about it + * aggregate against different messages and produce a signature nobody can + * verify. Fixed by the proposal and never re-sorted. + * + * Named `itemIndex` rather than `index` because `index` needs quoting in + * every hand-written query it appears in, and one missing backtick is a + * compile error at best. + */ + val itemIndex: Int, + + /** + * 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 this item to one message -- which is what makes reusing + * [nonceRandom] safe, exactly as it was when a session signed one event. + */ + val eventId: HexKey, + + /** Secret. 32 bytes of fresh randomness, the seed for this item's nonce. */ + val nonceRandom: HexKey, + + /** + * The coordinator's `AggregatedNonce` for this item once it arrives, hex. + * + * Written once, and written for every item of the batch in one transaction + * alongside [FrostSigningSession.signerIds] -- so "some items aggregated" is + * a state that cannot be reached, and `signerIds != null` is the one gate + * the rest of the session reads. + */ + val aggregatedNonce: HexKey? = null, + + /** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */ + val signature: HexKey? = null, +) { + /** Whether the group has produced this item's signature. */ + fun isSigned(): Boolean = signature != null +} 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 index e823ec1f..e1d43bf5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt @@ -21,22 +21,40 @@ import press.mantra.compose.managers.SharedKeyDerivation * 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 + * ### One session, several events * - * [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. + * A session signs a batch of one or more events, held as [FrostSigningItem] + * rows. What lives here is what the whole batch shares, and the split is not a + * matter of taste: an item's nonce, message and signature are the terms of + * `s = k + e·x` with `e = H(R‖P‖m)`, so sharing any of them across two messages + * is how a secret share is extracted. Everything on this row is outside that + * equation. + * + * | shared, and so here | per item, and so on [FrostSigningItem] | + * |---|---| + * | ceremony, threshold, participant count | the unsigned event and its id | + * | [signerIds] and the signer set | nonce seed, and the nonce from it | + * | [derivationPath] and its tweak cache | aggregated nonce | + * | [signApprovedAt] -- one decision | the signature | + * + * ### The nonce, and why one item means one message + * + * [FrostSigningItem.nonceRandom] is secret, and regenerating this device's nonce + * from it is safe for exactly one reason: an item 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. + * - [FrostSigningItem.eventId] is written when the session is created, and a + * proposal that disagrees with the batch it already holds is rejected rather + * than applied. + * - [FrostSigningItem.aggregatedNonce] and [signerIds] are written once, and + * written together. A second, different signer set for the same session is + * ignored, not honoured. */ @Entity( foreignKeys = [ @@ -92,38 +110,16 @@ data class FrostSigningSession( * it is running in. Null is the honest answer for a room that is not derived * from the key at all, and is what sessions predating this column read back * as -- both of which signed as the threshold key itself. + * + * Shared by the whole batch: every item of a session signs as the same room. */ val derivationPath: String? = null, 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, /** @@ -131,10 +127,12 @@ data class FrostSigningSession( * 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. + * One gate rather than the DKG's three, and one gate for the whole batch + * rather than one per item. What a signer is consenting to is the events, + * and the events are 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. That holds for a batch only as long + * as the member can actually see every event in it before answering. */ val signApprovedAt: Instant? = null, 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 index abd6ed08..5a7b00b2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt @@ -8,6 +8,7 @@ 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.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.FrostSigningStage @@ -29,6 +30,12 @@ class DatabaseFrostSigningRepository( override fun observeMessages(sessionId: String): Flow> = database.frostSigningSessionDao().observeMessages(sessionId) + override fun observeItems(sessionId: String): Flow> = + database.frostSigningSessionDao().observeItems(sessionId) + + override suspend fun getItems(sessionId: String): List = + database.frostSigningSessionDao().getItems(sessionId) + override suspend fun getSessionById(sessionId: String): FrostSigningSession? = database.frostSigningSessionDao().getSessionById(sessionId) @@ -92,8 +99,8 @@ class DatabaseFrostSigningRepository( } } - override fun signedEvent(session: FrostSigningSession): Event? = - FrostSigningManager.signedEvent(session) + override fun signedEvents(items: List): List = + FrostSigningManager.signedEvents(items) companion object { private const val TAG = "DatabaseFrostSigningRepository" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 6927d41e..240d56bf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -23,6 +23,7 @@ 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.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.MarmotInnerEvent @@ -145,15 +146,11 @@ object FrostSigningManager { participantCount = ceremony.participantCount, signerId = signerId, derivationPath = path?.let(SharedKeyDerivation::formatPath), - 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) + database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, listOf(unsignedEvent))) announceStarted(database, session) logger.i("Proposing signature $sessionId over event ${unsignedEvent.id} with key ${ceremony.id}") @@ -163,7 +160,7 @@ object FrostSigningManager { localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.PROPOSAL, - content = session.unsignedEventJson, + content = unsignedEvent.toJson(), includeKey = true ) @@ -236,15 +233,16 @@ object FrostSigningManager { 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. + // The events a session signs are fixed at creation. A second proposal + // under the same id carrying different ones 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) { + val signing = database.frostSigningSessionDao().getItems(sessionId).map { it.eventId } + if (proposed != null && signing != listOf(proposed.id)) { logger.w( "Session $sessionId re-proposed with event ${proposed.id}, " + - "but it is already signing ${existing.eventId}; ignoring" + "but it is already signing ${signing.joinToString()}; ignoring" ) } return existing @@ -312,12 +310,10 @@ object FrostSigningManager { threshold = key.threshold, participantCount = key.participantCount, signerId = signerId, - derivationPath = path?.let(SharedKeyDerivation::formatPath), - unsignedEventJson = unsignedEvent.toJson(), - eventId = unsignedEvent.id, - nonceRandom = RandomInstance.bytes(32).toHex() + derivationPath = path?.let(SharedKeyDerivation::formatPath) ) database.frostSigningSessionDao().upsert(session) + database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, listOf(unsignedEvent))) announceStarted(database, session) logger.i("Recorded signing session $sessionId over ${unsignedEvent.id}; awaiting approval") @@ -406,26 +402,19 @@ object FrostSigningManager { 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 - } - } + // + // `signerIds` is the gate for the whole batch: it and every item's + // aggregated nonce are written together, so a session holding one + // holds all of them. + if (current(database, session).signerIds != null) return true - if (!known) { + if (applyAggregate(database, session, signerIds, listOf(innerEvent.content))) { announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } @@ -433,17 +422,12 @@ object FrostSigningManager { FrostSigningEvents.SIGNATURE -> { if (!isFromCoordinator(session, innerEvent)) return true - val known = current(database, session).signature != null + // Write-once as a unit, for the same reason the aggregate is: a + // batch that had some of its signatures would be one the group + // could neither finish nor safely retry. + if (isSigned(database, session)) return true - update(database, session) { current -> - if (current.signature == null) { - current.copy(signature = innerEvent.content) - } else { - current - } - } - - if (!known) { + if (applySignatures(database, session, listOf(innerEvent.content))) { announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } @@ -504,7 +488,7 @@ object FrostSigningManager { // there -- while going on to ask would be asking for a decision that // can no longer change anything, and would let a late "don't sign" // abandon a signature that exists. - if (session.signature != null) { + if (isSigned(database, session)) { complete(database, session) return } @@ -526,14 +510,19 @@ object FrostSigningManager { val tweakCache = SharedKeyDerivation .derive(thresholdPublicKey, session.pathIndices()) .cache - val message = ByteVector(session.eventId.hexToByteArray()) val publicShares = key.publicShareList() + // The events this session signs, in the order the proposal fixed. Every + // join below is positional against it. + val items = database.frostSigningSessionDao().getItems(sessionId) + val item = items.firstOrNull() ?: return + val message = ByteVector(item.eventId.hexToByteArray()) + // Regenerated rather than stored -- SecretNonce refuses both. Safe - // because the session's message can never change; see the notes on + // because an item's message can never change; see the notes on // FrostSigningSession. val (secretNonce, publicNonce) = SecretNonce.generate( - sessionRandom = ByteVector32(session.nonceRandom), + sessionRandom = ByteVector32(item.nonceRandom), secretShare = secretShare, publicShare = publicShares?.getOrNull(session.signerId), tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, @@ -551,26 +540,26 @@ object FrostSigningManager { ) } - if (session.isCoordinator() && session.aggregatedNonce == null) { + if (session.isCoordinator() && session.signerIds == 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() } - ) + val chosenIds = chosen.map { (id, _) -> id } + if (!applyAggregate(database, session, chosenIds, listOf(aggregated.toByteArray().toHex()))) { + return } + session = current(database, session) + broadcast( database = database, localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.SIGNER_SET, content = aggregated.toByteArray().toHex(), - signerIds = chosen.map { (id, _) -> id } + signerIds = chosenIds ) announceStep( database, @@ -580,8 +569,9 @@ object FrostSigningManager { ) } - val aggregatedNonce = session.aggregatedNonce ?: return val signerIds = session.signerIdList() ?: return + val aggregatedNonce = database.frostSigningSessionDao() + .getItem(sessionId, item.itemIndex)?.aggregatedNonce ?: return session = moveTo(database, session, FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES) val signingSession = Session.create( @@ -613,7 +603,7 @@ object FrostSigningManager { ) } - if (session.isCoordinator() && session.signature == null) { + if (session.isCoordinator() && !isSigned(database, session)) { val partials = orderedPartialSignatures(database, session, signerIds) ?: return val signature = signingSession @@ -621,7 +611,9 @@ object FrostSigningManager { .orThrow("aggregating partial signatures") .toHex() - session = update(database, session) { it.copy(signature = signature) } + if (!applySignatures(database, session, listOf(signature))) return + session = current(database, session) + broadcast( database = database, localChatRoom = localChatRoom, @@ -662,16 +654,25 @@ object FrostSigningManager { * like every other line in the transcript. */ private suspend fun complete(database: MantraDatabase, session: FrostSigningSession) { - val signature = session.signature ?: return + val items = database.frostSigningSessionDao().getItems(session.id) + if (items.isEmpty() || items.any { !it.isSigned() }) return - 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}") + // Every signature is checked before any event is applied. A batch is + // all-or-nothing, so a bad one anywhere has to fail the session rather + // than leave some of its events already filed. + val signedEvents = items.map { item -> + val signedEvent = signedEvent(item, item.signature!!) + val verified = Nip01Crypto.verify( + signature = item.signature.hexToByteArray(), + hash = item.eventId.hexToByteArray(), + pubKey = signedEvent.pubKey.hexToByteArray() + ) + if (!verified) { + throw IllegalStateException( + "The aggregated signature does not verify against ${item.eventId}" + ) + } + signedEvent } update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) } @@ -683,14 +684,14 @@ object FrostSigningManager { // 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) + signedEvents.forEach { applySignedEvent(database, session, it) } announce( database = database, session = session, messageType = ChatMessage.TYPE_FROST_COMPLETE, - content = "The group signed the event. It took ${session.threshold} of " + - "${session.participantCount} members.", + content = "The group signed ${describe(signedEvents.size)}. It took " + + "${session.threshold} of ${session.participantCount} members.", actor = session.coordinatorPublicKey ) @@ -736,8 +737,8 @@ object FrostSigningManager { * 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) + fun signedEvent(item: FrostSigningItem, signature: HexKey): Event { + val unsigned = Event.fromJson(item.unsignedEventJson) return Event( id = unsigned.id, @@ -750,9 +751,115 @@ object FrostSigningManager { ) } - /** The finished event, or null while the session is still running. */ - fun signedEvent(session: FrostSigningSession): Event? = - session.signature?.let { signedEvent(session, it) } + /** The finished event, or null while this item is still running. */ + fun signedEvent(item: FrostSigningItem): Event? = + item.signature?.let { signedEvent(item, it) } + + /** Every finished event of a batch, empty while any of them is still running. */ + fun signedEvents(items: List): List = + items.map { it.signature ?: return emptyList() } + .mapIndexed { index, signature -> signedEvent(items[index], signature) } + + /** + * The items a session signs: one per event, each with its own nonce seed. + * + * Independent seeds rather than one derived per index, which would work and + * save nothing worth having. Independence means an off-by-one anywhere in + * the index handling produces a session that fails to aggregate, instead of + * one that signs two messages under a single nonce. + */ + private fun itemsOver(sessionId: String, events: List): List = + events.mapIndexed { index, event -> + FrostSigningItem( + sessionId = sessionId, + itemIndex = index, + unsignedEventJson = event.toJson(), + eventId = event.id, + nonceRandom = RandomInstance.bytes(32).toHex() + ) + } + + /** + * Records the coordinator's signer set and the aggregated nonce each item is + * signed against, as one write. Returns false when the message does not fit + * the batch, so the caller neither announces nor acts on it. + * + * The two belong together. [FrostSigningSession.signerIds] is what the rest + * of the session gates on, so a session holding it while an item still had no + * aggregate would stall on a message that has already been delivered — and + * the write that cleared the stall would be a second aggregate for an item + * that had one, which is the case the write-once rule exists to prevent. + * Items first, in one transaction, then the session: the gate is only ever + * set on a batch that is entirely ready. + */ + private suspend fun applyAggregate( + database: MantraDatabase, + session: FrostSigningSession, + signerIds: List, + aggregatedNonces: List + ): Boolean { + val items = database.frostSigningSessionDao().getItems(session.id) + if (items.isEmpty() || items.size != aggregatedNonces.size) { + logger.w( + "Session ${session.id}: signer set carries ${aggregatedNonces.size} " + + "aggregated nonce(s) for ${items.size} item(s); ignoring" + ) + return false + } + + database.frostSigningSessionDao().upsertItems( + items.mapIndexed { index, item -> item.copy(aggregatedNonce = aggregatedNonces[index]) } + ) + update(database, session) { it.copy(signerIds = signerIds.joinToString(",")) } + + return true + } + + /** + * Records the group's finished signatures, all of them or none. Returns false + * when the message does not fit the batch. + * + * Settled as a unit for the same reason the aggregate is: a batch holding + * some of its signatures is one the group can neither finish nor safely + * retry, since a retry means fresh nonces for events that already have a + * signature against the old ones. + */ + private suspend fun applySignatures( + database: MantraDatabase, + session: FrostSigningSession, + signatures: List + ): Boolean { + val items = database.frostSigningSessionDao().getItems(session.id) + if (items.isEmpty() || items.size != signatures.size) { + logger.w( + "Session ${session.id}: ${signatures.size} signature(s) for " + + "${items.size} item(s); ignoring" + ) + return false + } + + database.frostSigningSessionDao().upsertItems( + items.mapIndexed { index, item -> item.copy(signature = signatures[index]) } + ) + + return true + } + + /** + * Whether the group has produced every signature this session was opened for. + * + * Counted rather than flagged, so it cannot disagree with the rows it + * describes. A session with no items is not signed — it is one whose proposal + * has not landed yet. + */ + private suspend fun isSigned(database: MantraDatabase, session: FrostSigningSession): Boolean { + val total = database.frostSigningSessionDao().countItems(session.id) + + return total > 0 && database.frostSigningSessionDao().countSignedItems(session.id) == total + } + + /** "the event" or "3 events", for a transcript line that reads the same at either size. */ + private fun describe(count: Int): String = if (count == 1) "the event" else "$count events" /** * The nonces on offer, as (signer id, nonce), ordered by signer id — or null @@ -1091,7 +1198,10 @@ object FrostSigningManager { * 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 { + fun isAwaitingApproval( + session: FrostSigningSession, + items: List + ): Boolean { if (session.stage == FrostSigningStage.COMPLETE || session.stage == FrostSigningStage.FAILED) { return false } @@ -1100,7 +1210,7 @@ object FrostSigningManager { // the session on its next pass without asking them anything. Offering the // decision anyway would be offering two bad answers: a nonce nobody is // waiting for, or a refusal that abandons a signature already made. - if (session.signature != null) return false + if (items.isNotEmpty() && items.all { it.isSigned() }) return false return session.signApprovedAt == null } @@ -1118,8 +1228,9 @@ object FrostSigningManager { sessionId: String ) { val session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return + val items = database.frostSigningSessionDao().getItems(sessionId) - if (!isAwaitingApproval(session)) { + if (!isAwaitingApproval(session, items)) { logger.i("Session $sessionId is not waiting on an approval; ignoring it") return } @@ -1145,8 +1256,9 @@ object FrostSigningManager { sessionId: String ) { val session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return + val items = database.frostSigningSessionDao().getItems(sessionId) - if (!isAwaitingApproval(session)) return + if (!isAwaitingApproval(session, items)) return fail( database = database, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt index 44d4a534..66abf28e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt @@ -6,6 +6,7 @@ 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.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -24,6 +25,17 @@ interface FrostSigningRepository { fun observeMessages(sessionId: String): Flow> + /** + * The events a session signs, in the order its proposal fixed. + * + * Separate from the session because a session signs a batch, and what + * separates one event of it from another is forced by FROST rather than + * chosen — see [FrostSigningItem]. + */ + fun observeItems(sessionId: String): Flow> + + suspend fun getItems(sessionId: String): List + suspend fun getSessionById(sessionId: String): FrostSigningSession? /** @@ -58,8 +70,8 @@ interface FrostSigningRepository { /** 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? + /** The finished events, or empty while any of them is still running. */ + fun signedEvents(items: List): List companion object { val NO_OP_FROST_SIGNING_REPOSITORY: FrostSigningRepository = object : FrostSigningRepository { @@ -71,6 +83,11 @@ interface FrostSigningRepository { override fun observeMessages(sessionId: String): Flow> = flowOf(emptyList()) + override fun observeItems(sessionId: String): Flow> = + flowOf(emptyList()) + + override suspend fun getItems(sessionId: String): List = emptyList() + override suspend fun getSessionById(sessionId: String): FrostSigningSession? = null override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? = null @@ -89,7 +106,7 @@ interface FrostSigningRepository { override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit - override fun signedEvent(session: FrostSigningSession): Event? = null + override fun signedEvents(items: List): List = emptyList() } } } 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 index 2b7730a5..2480394e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -146,7 +146,7 @@ fun FrostSigningScreen( .padding(20.dp), verticalArrangement = Arrangement.spacedBy(15.dp) ) { - WhatIsBeingSigned(frostSigningViewModel.proposedEvent(session)) + WhatIsBeingSigned(frostSigningViewModel.proposedEvents(state.items).firstOrNull()) HorizontalDivider() @@ -229,7 +229,7 @@ fun FrostSigningScreen( // Asked rather than re-derived: the manager owns when a session // is still waiting on its owner, and a second copy of that rule // here is a second copy to keep in step. - if (FrostSigningManager.isAwaitingApproval(session)) { + if (FrostSigningManager.isAwaitingApproval(session, state.items)) { HorizontalDivider() Text( 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 index b2942491..6a1d0afa 100644 --- 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 @@ -16,6 +16,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.repository.ChatRepository @@ -66,12 +67,14 @@ class FrostSigningViewModel( combine( frostSigningRepository.observeSessionById(id), - frostSigningRepository.observeMessages(id) - ) { session, messages -> session to messages } - .collect { (session, messages) -> + frostSigningRepository.observeMessages(id), + frostSigningRepository.observeItems(id) + ) { session, messages, items -> Triple(session, messages, items) } + .collect { (session, messages, items) -> frostSigningUIState = FrostSigningUIState.Loaded( localChatRoom = localChatRoom, session = session, + items = items, offeredNonce = messages .filter { it.kind == FrostSigningEvents.NONCE } .map { it.signerPublicKey } @@ -85,12 +88,12 @@ class FrostSigningViewModel( } } - /** The event the group is being asked to sign, for showing it before they agree. */ - fun proposedEvent(session: FrostSigningSession): Event? = - Event.fromJsonOrNull(session.unsignedEventJson) + /** The events the group is being asked to sign, for showing them before they agree. */ + fun proposedEvents(items: List): List = + items.mapNotNull { Event.fromJsonOrNull(it.unsignedEventJson) } - fun signedEvent(session: FrostSigningSession): Event? = - frostSigningRepository.signedEvent(session) + fun signedEvents(items: List): List = + frostSigningRepository.signedEvents(items) fun approve(onDone: () -> Unit) { val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return 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 index ccf8cdcb..515a603f 100644 --- 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 @@ -1,6 +1,7 @@ package press.mantra.compose.ui.view.state import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -11,6 +12,12 @@ sealed interface FrostSigningUIState { /** Null on the first emission, before the session has been collected. */ val session: FrostSigningSession? = null, + /** + * The events the session signs, in the order its proposal fixed. Empty + * on the first emission, and for a session whose proposal has not landed. + */ + val items: List = emptyList(), + /** Who has offered a nonce, so the screen can name who it is waiting on. */ val offeredNonce: Set = emptySet(), diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt index 97460090..1f854414 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt @@ -20,6 +20,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Instant import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.types.FrostSigningStage import press.mantra.compose.extensions.toHex @@ -227,20 +228,30 @@ class FrostSigningSessionTest { threshold = 2, participantCount = 3, signerId = signerId, - unsignedEventJson = "{}", - eventId = "e".repeat(64), - nonceRandom = "f".repeat(64), signerIds = signerIds ) + /** The one event such a session signs, signed or not. */ + private fun items(signature: String? = null) = listOf( + FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = 0, + unsignedEventJson = "{}", + eventId = "e".repeat(64), + nonceRandom = "f".repeat(64), + signature = signature + ) + ) + @Test fun `a session waits on its owner until they answer`() { val open = session(signerId = 2, signerIds = null) - assertTrue(FrostSigningManager.isAwaitingApproval(open)) + assertTrue(FrostSigningManager.isAwaitingApproval(open, items())) assertFalse( FrostSigningManager.isAwaitingApproval( - open.copy(signApprovedAt = Instant.fromEpochSeconds(1)) + open.copy(signApprovedAt = Instant.fromEpochSeconds(1)), + items() ) ) } @@ -249,8 +260,12 @@ class FrostSigningSessionTest { fun `a session that has settled asks its owner nothing`() { val open = session(signerId = 2, signerIds = null) - assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE))) - assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED))) + assertFalse( + FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE), items()) + ) + assertFalse( + FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED), items()) + ) } @Test @@ -261,9 +276,10 @@ class FrostSigningSessionTest { // decision in that window offers two bad answers: a nonce nobody is // waiting for, or a refusal that abandons a signature that exists. val signedWithoutThem = session(signerId = 2, signerIds = "0,1") - .copy(signature = "a".repeat(128)) - assertFalse(FrostSigningManager.isAwaitingApproval(signedWithoutThem)) + assertFalse( + FrostSigningManager.isAwaitingApproval(signedWithoutThem, items("a".repeat(128))) + ) } @Test @@ -415,7 +431,7 @@ class FrostSigningCompletionTest { * never named them. Every column the completion path reads is here; the ones * it must not need are deliberately left null. */ - private fun leftOutMemberSession(signature: String? = null) = FrostSigningSession( + private fun leftOutMemberSession() = FrostSigningSession( id = "s".repeat(64), chatRoomId = "room", coordinatorPublicKey = "c".repeat(64), @@ -425,24 +441,30 @@ class FrostSigningCompletionTest { participantCount = 3, signerId = 2, derivationPath = SharedKeyDerivation.formatPath(), + signerIds = null, + signApprovedAt = null + ) + + /** The event that session signs: everything completing it needs, and nothing more. */ + private fun leftOutMemberItem(signature: String? = null) = FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = 0, unsignedEventJson = unsignedEvent.toJson(), eventId = unsignedEvent.id, nonceRandom = "f".repeat(64), aggregatedNonce = null, - signerIds = null, - signature = signature, - signApprovedAt = null + signature = signature ) @Test fun `a member who never took part can still check what the group signed`() { - val session = leftOutMemberSession(signature) - val signed = FrostSigningManager.signedEvent(session)!! + val item = leftOutMemberItem(signature) + val signed = FrostSigningManager.signedEvent(item)!! assertTrue( Nip01Crypto.verify( signature = signed.sig.hexToByteArray(), - hash = session.eventId.hexToByteArray(), + hash = item.eventId.hexToByteArray(), pubKey = signed.pubKey.hexToByteArray() ), "completing must need only the row: the event, its id and the signature" @@ -454,7 +476,7 @@ class FrostSigningCompletionTest { // Not rebuilt and not rehashed: the id a session is pinned to is the id // the signature is over, so anything that changed here would produce an // event whose signature verifies against nothing. - val signed = FrostSigningManager.signedEvent(leftOutMemberSession(signature))!! + val signed = FrostSigningManager.signedEvent(leftOutMemberItem(signature))!! assertEquals(unsignedEvent.id, signed.id) assertEquals(unsignedEvent.pubKey, signed.pubKey) @@ -466,15 +488,22 @@ class FrostSigningCompletionTest { @Test fun `there is no finished event until the signature arrives`() { - assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberSession())) + assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberItem())) } @Test fun `the arrived signature is what stops the session asking`() { // The pair that matters to the screen and the transcript: the same row, // before and after the group finished without this member. - assertTrue(FrostSigningManager.isAwaitingApproval(leftOutMemberSession())) - assertFalse(FrostSigningManager.isAwaitingApproval(leftOutMemberSession(signature))) + assertTrue( + FrostSigningManager.isAwaitingApproval(leftOutMemberSession(), listOf(leftOutMemberItem())) + ) + assertFalse( + FrostSigningManager.isAwaitingApproval( + leftOutMemberSession(), + listOf(leftOutMemberItem(signature)) + ) + ) } @Test @@ -482,7 +511,7 @@ class FrostSigningCompletionTest { // What the check is for. A coordinator passing off something else must not // get it applied and announced as the group's, and the row is all there is // to catch it with. - val other = leftOutMemberSession(signature).copy( + val other = leftOutMemberItem(signature).copy( eventId = EventHasher.hashId( pubKey = groupPubKey, createdAt = 1_700_000_000L, diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt index ef1e3f52..e8340226 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -19,7 +19,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant -import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.frost.GroupKeyStateEvent @@ -281,21 +281,14 @@ class GroupKeyStateTest { val unsigned = unsignedKeyState(content, tags, createdAt, author) return FrostSigningManager.signedEvent( - session = sessionOver(unsigned), + item = itemOver(unsigned), signature = groupSignature(signer, unsigned.id) ) } - private fun sessionOver(unsignedEvent: Event) = FrostSigningSession( - id = "s".repeat(64), - chatRoomId = chatRoomId, - coordinatorPublicKey = "c00rd1na70r", - userPublicKey = "c00rd1na70r", - dkgSessionId = "ceremony-1", - threshold = threshold, - participantCount = participants, - signerId = 0, - derivationPath = SharedKeyDerivation.formatPath(path), + private fun itemOver(unsignedEvent: Event) = FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = 0, unsignedEventJson = unsignedEvent.toJson(), eventId = unsignedEvent.id, nonceRandom = "f".repeat(64) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt index a88d7dc9..1e78e49c 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt @@ -170,9 +170,6 @@ class SharedKeyDerivationTest { threshold = 2, participantCount = 3, signerId = 0, - derivationPath = derivationPath, - unsignedEventJson = "{}", - eventId = "e".repeat(64), - nonceRandom = "f".repeat(64) + derivationPath = derivationPath ) } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt index 26bc90f4..6aeea827 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt @@ -18,7 +18,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue -import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.extensions.toHex @@ -100,24 +100,17 @@ class SignedArtifactTest { sig = "" ) - private fun sessionOver(unsignedEvent: Event) = FrostSigningSession( - id = "s".repeat(64), - chatRoomId = chatRoomId, - coordinatorPublicKey = proposer, - userPublicKey = proposer, - dkgSessionId = "k".repeat(64), - threshold = threshold, - participantCount = participants, - signerId = 0, - derivationPath = SharedKeyDerivation.formatPath(), + private fun itemOver(unsignedEvent: Event) = FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = 0, unsignedEventJson = unsignedEvent.toJson(), eventId = unsignedEvent.id, nonceRandom = "f".repeat(64) ) - /** A quorum signing the session's event, in the manager's order. */ - private fun groupSignature(session: FrostSigningSession): String { - val message = ByteVector(session.eventId.hexToByteArray()) + /** A quorum signing the item's event, in the manager's order. */ + private fun groupSignature(item: FrostSigningItem): String { + val message = ByteVector(item.eventId.hexToByteArray()) val signerIds = listOf(0, 1) val nonces = signerIds.map { signerId -> @@ -154,8 +147,8 @@ class SignedArtifactTest { /** Everything from the form to the row a device holds afterwards. */ private fun signedArtifactEvent(versionLabel: String = "1.0"): ArtifactEvent { - val session = sessionOver(unsignedEventOf(proposalTemplate(versionLabel))) - val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + val item = itemOver(unsignedEventOf(proposalTemplate(versionLabel))) + val signed = FrostSigningManager.signedEvent(item, groupSignature(item)) return ArtifactEvent( signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig @@ -193,15 +186,15 @@ class SignedArtifactTest { // to be the one that was signed rather than anything recomputed from the // proposer. Otherwise members converge on nothing and each holds its own // copy of what is meant to be one artifact. - val session = sessionOver(unsignedEventOf(proposalTemplate())) - val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + val item = itemOver(unsignedEventOf(proposalTemplate())) + val signed = FrostSigningManager.signedEvent(item, groupSignature(item)) val artifact = MantraArtifact.fromArtifactEvent( ArtifactEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig), chatRoomId ) - assertEquals(session.eventId, artifact?.id) + assertEquals(item.eventId, artifact?.id) } @Test diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt index 2e03b6f7..d2c8a669 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt @@ -8,6 +8,7 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.builder.getRoomDatabase import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Profile @@ -81,6 +82,7 @@ class FrostSigningSessionDaoJvmTest { createdAt: Instant = Instant.fromEpochSeconds(1_000), stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, signature: String? = null, + events: Int = 1, ): FrostSigningSession = FrostSigningSession( id = id, chatRoomId = chatRoomId, @@ -91,12 +93,33 @@ class FrostSigningSessionDaoJvmTest { participantCount = 3, signerId = 1, stage = stage, - unsignedEventJson = "{}", - eventId = "ff".repeat(32), - nonceRandom = "aa".repeat(32), - signature = signature, createdAt = createdAt, - ).also { db.frostSigningSessionDao().upsert(it) } + ).also { + db.frostSigningSessionDao().upsert(it) + db.frostSigningSessionDao().upsertItems( + (0 until events).map { index -> item(id, index, signature) } + ) + } + + /** + * One event of a session's batch. Distinct ids and seeds per index, because + * that is what the manager writes and what every ordering assertion here + * would otherwise be blind to. + */ + private fun item( + sessionId: String, + itemIndex: Int = 0, + signature: String? = null, + aggregatedNonce: String? = null, + ) = FrostSigningItem( + sessionId = sessionId, + itemIndex = itemIndex, + unsignedEventJson = """{"index":$itemIndex}""", + eventId = "f$itemIndex".repeat(32), + nonceRandom = "a$itemIndex".repeat(32), + aggregatedNonce = aggregatedNonce, + signature = signature, + ) private suspend fun message( sessionId: String, @@ -242,6 +265,88 @@ class FrostSigningSessionDaoJvmTest { val found = assertNotNull(db.frostSigningSessionDao().getSessionById("s1")) assertEquals(FrostSigningStage.COMPLETE, found.stage) - assertEquals("ab".repeat(32), found.signature) + assertEquals("ab".repeat(32), db.frostSigningSessionDao().getItems("s1").single().signature) + } + + /** + * A batch's items come back in the order the proposal fixed, whatever order + * they went in. + * + * Not a presentation detail. Nonces and partial signatures are joined + * positionally against this list, so a device reading a batch in a different + * order aggregates item 2's partial signature against item 0's message and + * produces a signature nobody can verify. + */ + @Test + fun `a batch's items read back in index order`() = runBlocking { + seedRooms() + session("s1") + + db.frostSigningSessionDao().upsertItems( + listOf(item("s1", itemIndex = 3), item("s1", itemIndex = 1), item("s1", itemIndex = 2)) + ) + + val items = db.frostSigningSessionDao().getItems("s1") + + assertEquals(listOf(0, 1, 2, 3), items.map { it.itemIndex }) + assertEquals(4, db.frostSigningSessionDao().countItems("s1")) + } + + /** + * A redelivered item overwrites rather than accumulates, which is what the + * composite key buys — the same property the signer messages rely on, and for + * the same reason: a second row would make the batch the wrong length, and + * the length is what every payload is checked against. + */ + @Test + fun `a second write of one item replaces it`() = runBlocking { + seedRooms() + session("s1") + + db.frostSigningSessionDao().upsert(item("s1", signature = "cd".repeat(32))) + + val items = db.frostSigningSessionDao().getItems("s1") + + assertEquals(1, items.size) + assertEquals("cd".repeat(32), items.single().signature) + } + + /** Counted rather than flagged, so "the group has finished" cannot disagree with the rows. */ + @Test + fun `signed items are counted apart from the rest`() = runBlocking { + seedRooms() + session("s1", events = 3) + + assertEquals(3, db.frostSigningSessionDao().countItems("s1")) + assertEquals(0, db.frostSigningSessionDao().countSignedItems("s1")) + + db.frostSigningSessionDao().upsertItems( + listOf(item("s1", 0, signature = "ab".repeat(32)), item("s1", 1, signature = "cd".repeat(32))) + ) + + assertEquals(2, db.frostSigningSessionDao().countSignedItems("s1")) + } + + /** Items belong to their session and go with it, like the signer messages do. */ + @Test + fun `deleting a room takes its sessions' items with it`() = runBlocking { + seedRooms() + session("s1", chatRoomId = roomOne, events = 2) + session("s2", chatRoomId = roomTwo, events = 2) + + db.chatRoomDao().delete(assertNotNull(db.chatRoomDao().findChatRoomById(roomOne)).chatRoom) + + assertEquals(emptyList(), db.frostSigningSessionDao().getItems("s1")) + assertEquals(2, db.frostSigningSessionDao().getItems("s2").size) + } + + /** The single-item read the manager uses to pick one event out of a batch. */ + @Test + fun `an item can be read by its index`() = runBlocking { + seedRooms() + session("s1", events = 3) + + assertEquals("f2".repeat(32), db.frostSigningSessionDao().getItem("s1", 2)?.eventId) + assertNull(db.frostSigningSessionDao().getItem("s1", 3)) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigrationJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigrationJvmTest.kt new file mode 100644 index 00000000..6a2e3ffd --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigrationJvmTest.kt @@ -0,0 +1,186 @@ +package press.mantra.compose.database.migrations + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The v9 -> v10 backfill, against a real database holding a real half-finished + * session. + * + * The stake is higher than a migration test usually carries. A session in flight + * at upgrade time holds `nonceRandom`, the seed its secret nonce is regenerated + * from, and `aggregatedNonce`, the aggregate its partial signature is made + * against. If either fails to arrive at item 0, the session's next pass derives a + * *different* nonce for the same message and publishes a second partial signature + * over it -- two partial signatures, one message, two nonces, which is how a + * secret share is extracted. So this asserts the values, not merely that a row + * appeared. + * + * Run against the migration's own SQL on a bare connection rather than through + * Room. That covers the copy and the shape it leaves behind, which is what this + * migration is; it does not cover Room's version wiring, which belongs to + * `PlatformDatabaseBuilder` and is the same for every migration in that list. + */ +class FrostSigningItemMigrationJvmTest { + + private val connection: SQLiteConnection = BundledSQLiteDriver().open(":memory:") + + @AfterTest + fun close() = connection.close() + + /** v9's `FrostSigningSession`, verbatim from `schemas/9.json`. */ + private fun createV9() { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `FrostSigningSession` (`id` TEXT NOT NULL, " + + "`chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, " + + "`userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, " + + "`threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, " + + "`signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, " + + "`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 )" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` " + + "ON `FrostSigningSession` (`chatRoomId`)" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` " + + "ON `FrostSigningSession` (`dkgSessionId`)" + ) + } + + /** A session mid-flight: it has its seed, and the aggregate it is signing against. */ + private fun insertV9Session( + id: String = "s1", + aggregatedNonce: String? = null, + signature: String? = null, + ) = connection.execSQL( + "INSERT INTO `FrostSigningSession` VALUES (" + + "'$id', 'room', 'coord', 'user', 'dkg-1', 2, 3, 1, 'm/9420/0/0', 'COLLECTING_NONCES', " + + "'$unsignedEventJson', '$eventId', '$nonceRandom', " + + "${aggregatedNonce.orNull()}, '0,1', ${signature.orNull()}, " + + "NULL, 1, 1, 1000, 1000, 1000)" + ) + + private fun String?.orNull(): String = this?.let { "'$it'" } ?: "NULL" + + /** Column names in declaration order, which is what Room checks the table against. */ + private fun columns(table: String): List = + connection.prepare("PRAGMA table_info(`$table`)").use { statement -> + buildList { while (statement.step()) add(statement.getText(1)) } + } + + /** One column of the one row, as text, or null when the column is null. */ + private fun read(table: String, column: String): String? = + connection.prepare("SELECT `$column` FROM `$table`").use { statement -> + if (statement.step() && !statement.isNull(0)) statement.getText(0) else null + } + + private val unsignedEventJson = """{"kind":1}""" + private val eventId = "ff".repeat(32) + private val nonceRandom = "aa".repeat(32) + private val aggregatedNonce = "bb".repeat(66) + private val signature = "cc".repeat(32) + + @Test + fun `an in-flight session keeps the seed and the aggregate it is already signing against`() = + runBlocking { + createV9() + insertV9Session(aggregatedNonce = aggregatedNonce) + + MIGRATION_9_10.migrate(connection) + + // The two that cannot be regenerated. Losing either turns this + // session's next pass into a second partial signature over one message. + assertEquals(nonceRandom, read("FrostSigningItem", "nonceRandom")) + assertEquals(aggregatedNonce, read("FrostSigningItem", "aggregatedNonce")) + + assertEquals("0", read("FrostSigningItem", "itemIndex")) + assertEquals(eventId, read("FrostSigningItem", "eventId")) + assertEquals(unsignedEventJson, read("FrostSigningItem", "unsignedEventJson")) + } + + @Test + fun `a finished session keeps its signature`() = runBlocking { + createV9() + insertV9Session(aggregatedNonce = aggregatedNonce, signature = signature) + + MIGRATION_9_10.migrate(connection) + + assertEquals(signature, read("FrostSigningItem", "signature")) + } + + /** A session that had not been aggregated yet arrives with nothing invented for it. */ + @Test + fun `a session still collecting nonces gets an item with no aggregate`() = runBlocking { + createV9() + insertV9Session() + + MIGRATION_9_10.migrate(connection) + + assertEquals(null, read("FrostSigningItem", "aggregatedNonce")) + assertEquals(null, read("FrostSigningItem", "signature")) + assertEquals(nonceRandom, read("FrostSigningItem", "nonceRandom")) + } + + /** + * The shape Room checks on the next open. A column left behind, or one + * missing, is refused at open time -- a crash on launch rather than a bug + * anybody gets to debug. + */ + @Test + fun `the tables are left with exactly the columns v10 declares`() = runBlocking { + createV9() + insertV9Session() + + MIGRATION_9_10.migrate(connection) + + assertEquals( + listOf( + "id", "chatRoomId", "coordinatorPublicKey", "userPublicKey", "dkgSessionId", + "threshold", "participantCount", "signerId", "derivationPath", "stage", + "signerIds", "failureReason", "signApprovedAt", "approvalRequestedAt", + "createdAt", "updatedAt", "savedAt", + ), + columns("FrostSigningSession") + ) + assertEquals( + listOf( + "sessionId", "itemIndex", "unsignedEventJson", "eventId", + "nonceRandom", "aggregatedNonce", "signature", + ), + columns("FrostSigningItem") + ) + } + + /** `signerIds` is shared by every item of a batch, so it stays where it was. */ + @Test + fun `the signer set stays on the session`() = runBlocking { + createV9() + insertV9Session() + + MIGRATION_9_10.migrate(connection) + + assertEquals("0,1", read("FrostSigningSession", "signerIds")) + } + + /** An empty table migrates to an empty table rather than to one bad row. */ + @Test + fun `a database with no sessions gets no items`() = runBlocking { + createV9() + + MIGRATION_9_10.migrate(connection) + + assertEquals(null, read("FrostSigningItem", "sessionId")) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index b9a822ad..091ec8b4 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -121,6 +121,13 @@ class SignedGroupKeyStateTest { suspend fun session(sessionId: String): FrostSigningSession? = db.frostSigningSessionDao().getSessionById(sessionId) + /** The events a session signs, in the order its proposal fixed. */ + suspend fun items(sessionId: String) = + db.frostSigningSessionDao().getItems(sessionId) + + /** The one event a session of one signs. */ + suspend fun item(sessionId: String) = items(sessionId).single() + suspend fun keyState() = db.groupKeyStateDao().getByChatRoomId(roomId) } @@ -275,7 +282,7 @@ class SignedGroupKeyStateTest { val payload = Event.fromJson(proposal.content) assertEquals(GroupKeyStateEvent.KIND, payload.kind) assertEquals(thresholdPublicKey, payload.content) - assertEquals(session.eventId, payload.id) + assertEquals(creator.item(session.id).eventId, payload.id) } @Test @@ -289,7 +296,7 @@ class SignedGroupKeyStateTest { key = ceremonyOn(creator) ) - val payload = Event.fromJson(session.unsignedEventJson) + val payload = Event.fromJson(creator.item(session.id).unsignedEventJson) // Signing runs at the room's derivation path, so the key the group signs // as is the room's own id. Not the group's root key, which is what an @@ -320,7 +327,7 @@ class SignedGroupKeyStateTest { // Rebuilt from the event's own fields under this device's own reading of // the room. Agreeing on the id is agreeing on every byte that is signed, // the author included. - assertEquals(session.eventId, received.eventId) + assertEquals(creator.item(session.id).eventId, other.item(session.id).eventId) assertEquals(session.derivationPath, received.derivationPath) } @@ -337,7 +344,12 @@ class SignedGroupKeyStateTest { ) pump(creator, other) - assertTrue(FrostSigningManager.isAwaitingApproval(other.session(session.id)!!)) + assertTrue( + FrostSigningManager.isAwaitingApproval( + other.session(session.id)!!, + other.items(session.id) + ) + ) assertTrue( outbox(other).isEmpty(), "a device that has not been asked yet must publish nothing" @@ -394,7 +406,7 @@ class SignedGroupKeyStateTest { FrostSigningManager.approve(other.db, other.room, session.id) pump(creator, other) - val signed = FrostSigningManager.signedEvent(creator.session(session.id)!!) + val signed = FrostSigningManager.signedEvent(creator.item(session.id)) assertNotNull(signed, "a completed session must carry a signed event") assertEquals(adminRoomId, signed.pubKey) @@ -467,7 +479,7 @@ class SignedGroupKeyStateTest { pump(creator, other) listOf(creator, other).forEach { device -> - val dialect = device.db.mantraDialectDao().getDialectById(session.eventId) + val dialect = device.db.mantraDialectDao().getDialectById(device.item(session.id).eventId) assertNotNull(dialect, "a signed dialect should exist on every signer's device") assertEquals("Sepedi", dialect.name) // The ask this whole change serves: the group's work is authored by @@ -601,7 +613,7 @@ class SignedGroupKeyStateTest { ) assertEquals(SharedKeyDerivation.formatPath(sibling), session.derivationPath) - assertEquals(siblingRoomId, Event.fromJson(session.unsignedEventJson).pubKey) + assertEquals(siblingRoomId, Event.fromJson(creator.item(session.id).unsignedEventJson).pubKey) pump(creator, other) FrostSigningManager.approve(other.db, other.room, session.id) @@ -640,6 +652,6 @@ class SignedGroupKeyStateTest { assertNull(session.derivationPath, "no path reaches a room that was not derived") assertEquals(emptyList(), session.pathIndices()) - assertEquals(rootPublicKey, Event.fromJson(session.unsignedEventJson).pubKey) + assertEquals(rootPublicKey, Event.fromJson(creator.item(session.id).unsignedEventJson).pubKey) } } diff --git a/docs/README.md b/docs/README.md index af213bf5..3ddbf229 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ 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 | +| [frost-batch-signing.md](./frost-batch-signing.md) | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it | | [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite | | [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 | @@ -19,4 +20,6 @@ Start with the ceremony if you are new to this area; the Marmot notes all assume 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. The +batch-signing note is a plan rather than a description of what is there: read it +after the derivation note, whose one rule is the same one it is built around. The jvm-target note is unrelated to all of them: it is a build and packaging story. diff --git a/docs/frost-batch-signing.md b/docs/frost-batch-signing.md new file mode 100644 index 00000000..1002c114 --- /dev/null +++ b/docs/frost-batch-signing.md @@ -0,0 +1,450 @@ +# Batch signing with FROST + +How to have a group sign several events in one ceremony instead of one at a +time, phased, with the cryptographic constraint that shapes every phase stated +first. + +The headline: **there is no such thing as one FROST signature over many +messages, and no way to reuse a nonce across them.** What can be batched is the +ceremony — the rounds, the group events, and the approval a human is asked for. +A batch of `k` events is `k` independent FROST instances run in lockstep, +sharing one signer set, one transcript and one prompt. The saving is transport +and UX, not crypto, and that is the saving worth having: today +[FrostSigningManager](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt) +spends five MLS group events and one human decision per event signed. + +## The constraint + +A Schnorr partial signature is `s = k + e·x`, with `e = H(R‖P‖m)`. Two different +messages under the same nonce `R` give two equations in one unknown and the +secret share falls out. That is not a subtlety to be careful around; it is the +one way a t-of-n key is lost, and it is already what the two write-once rules on +[FrostSigningSession](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt) +exist to prevent. + +So the rule every phase below is built to keep: + +> **Every item in a batch has its own independent nonce, from every signer, and +> that nonce signs exactly one message for the life of the session.** + +`fr.acinq.bitcoin.crypto.frost.Session.create` takes the message and the +aggregated nonce, so a batch is `k` `Session` objects sharing a signer set and a +`TweakCache`. There is no batch primitive in the library and none is needed. + +What *is* shared across items, safely, and what is not: + +| shared across the batch | per item | +|---|---| +| signer set (`signerIds`) | nonce seed (`nonceRandom`) | +| ceremony, threshold, participant count | secret + public nonce | +| derivation path and `TweakCache` | aggregated nonce | +| the human approval | `Session`, partial signature, signature | +| the chat transcript | the unsigned event and its id | + +--- + +## Phase 1 — schema + +**Half a day. No wire change, no behaviour change.** + +Five columns move off `FrostSigningSession` onto a new child table: they are the +per-item ones in the table above. + +```kotlin +@Entity( + primaryKeys = ["sessionId", "itemIndex"], + foreignKeys = [ForeignKey( + entity = FrostSigningSession::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE, + )], + indices = [Index("sessionId")], +) +data class FrostSigningItem( + val sessionId: String, + /** Position in the batch. Fixed at proposal; it is the wire ordering. */ + val itemIndex: Int, + val unsignedEventJson: String, + val eventId: HexKey, + val nonceRandom: HexKey, + val aggregatedNonce: HexKey? = null, + val signature: HexKey? = null, +) +``` + +`itemIndex` is load-bearing rather than cosmetic: it is the order every device +joins nonces and partial signatures in, so two devices that disagree about it +produce aggregates nobody can verify. It is fixed by the proposal and never +re-sorted. Spelled `itemIndex` rather than `index` because `index` needs quoting +in every hand-written query it appears in, and one missing backtick is a compile +error at best. + +**No `itemCount` column.** The count is `SELECT COUNT(*) FROM FrostSigningItem +WHERE sessionId = :id`, for the same reason `signerIds` is derived from the +ceremony's participant order rather than stored: a denormalised count is one +more thing that can disagree with the session it describes. + +### Migration 9 → 10 + +Not an auto-migration. Room can add a table but cannot backfill one, and this +migration has to move data before it drops the columns it came from. Manual, in +the shape of `MIGRATION_3_4` — the existing precedent for a migration that is +about data rather than shape. + +```sql +CREATE TABLE FrostSigningItem (...); +INSERT INTO FrostSigningItem (sessionId, itemIndex, unsignedEventJson, eventId, + nonceRandom, aggregatedNonce, signature) + SELECT id, 0, unsignedEventJson, eventId, nonceRandom, aggregatedNonce, signature + FROM FrostSigningSession; +ALTER TABLE FrostSigningSession DROP COLUMN unsignedEventJson; -- and the other four +``` + +**`DROP COLUMN`, not a table rebuild.** The usual SQLite way to remove columns — +create a new table, copy, drop the old one, rename — is unsafe here and quietly +so. `FrostSignerMessage` and the new `FrostSigningItem` both reference +`FrostSigningSession(id)` `ON DELETE CASCADE`, and `DROP TABLE` fires cascades: +with foreign keys enforced it would delete every signer message and every item +the migration had just written. Whether it does depends on Room having turned +foreign keys off around the migration, which is not worth depending on when +`ALTER TABLE ... DROP COLUMN` cannot go wrong. It needs SQLite 3.35 and columns +free of indices and constraints; these five qualify, and `getRoomDatabase` pins +`BundledSQLiteDriver` on every platform, so the SQLite version is ours rather +than the host's. + +**Why the backfill has to be exact.** A session in flight at upgrade time holds +its nonce seed and, possibly, its aggregated nonce in those columns. Losing +either means regenerating a different nonce on the next pass — publishing a +second partial signature over the same message against a different aggregate, +which is the extraction case. Copying them verbatim into item 0 means an +in-flight session resumes as though nothing happened. A migration that dropped +them and let sessions restart would be the one dangerous way to write this. + +Update the entity list and `version = 10` in +[MantraDatabase.kt:175](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt), +regenerate `composeApp/schemas/10.json`, and add the DAO methods: +`getItems(sessionId)`, `observeItems(sessionId)`, `upsertItems(List)`, +`countItems(sessionId)`, `countSignedItems(sessionId)`. + +**Test:** extend +[FrostSigningSessionDaoJvmTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt) +with item CRUD, the `(sessionId, itemIndex)` dedupe, index ordering and the +cascade delete. Add a migration test that runs the migration over a v9 database +holding a half-finished session and asserts the seed and aggregate survive at +index 0 — the values, not merely that a row appeared. + +--- + +## Phase 2 — vectorise the manager at k = 1 + +**Two days. Still no wire change, still no API change.** + +The whole point of this phase is that **every existing test passes unchanged**. +`proposeSigning` still takes one event, still writes one item, and the messages +on the wire are byte-identical to today's. What changes is that `advance()` +loops. + +In [FrostSigningManager.advance](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt): + +1. Load `items` once, ordered by `index`. +2. Nonce generation becomes a `map` over items, each with its own + `session.nonceRandom` → `item.nonceRandom` and its own `message`. +3. `Session.create` becomes one per item; the `signerIds`, `publicShares`, + `nParticipants`, `threshold` and `tweakCache` arguments are the same for all + of them. +4. `sign` and `aggregateSigs` become per item. +5. `complete()` verifies each item's signature against `item.eventId` and calls + `applySignedEvent` for each. + +Three invariants to establish here, because Phase 3 depends on all three: + +- **The signer set and every aggregated nonce are one write-once unit.** The + coordinator writes `session.signerIds` and all `k` `item.aggregatedNonce` + values in a single `@Transaction`, so "some items aggregated" is unreachable. + `signerIds != null` stays the gate the rest of `advance()` reads, exactly as + today. +- **Signatures likewise.** All `k` land in one transaction; `countSignedItems == + countItems` is the settled test, replacing today's `session.signature != null` + in both `advance()` and `isAwaitingApproval`. +- **Item order is the wire order.** Every join and split goes through one pair + of helpers, never an ad-hoc `map` at a call site. + +### The cost that appears here + +`advance()` regenerates nonces and creates FROST sessions **unconditionally on +every inbound message**, before checking whether this device has already +published. At k=1 that is one native call per message and nobody notices. At +k=64 it is 64 nonce generations, 64 `Session.create`s and 64 signs on every +message the group sends — several hundred native calls to discover there is +nothing to do. + +Fix it in this phase, while it is still cheap to verify: short-circuit the +regenerate-and-sign block when this device has already published both its nonce +and its partial-signature messages, and skip `Session.create` for a device that +is not in the signer set and is not the coordinator. Both are pure +optimisations at k=1, which is the point of doing them before k>1 exists. + +**Test:** existing tests are the test. If +[FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt) +and +[SignedGroupKeyStateTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt) +pass without edits beyond the schema move, the vectorisation is faithful. + +--- + +## Phase 3 — k > 1 on the wire + +**Two days. This is the phase with the compatibility trap in it.** + +### Payload encoding + +`FrostSignerMessage` keeps its `(sessionId, signerPublicKey, kind)` primary key +and **one row carries all `k` values**, comma-joined — the same encoding +`FrostSigningSession.signerIds` already uses. + +Per-item message rows were the obvious alternative and are worse. The current +key is what makes a redelivered message overwrite rather than accumulate, which +is what keeps the coordinator's signer set the right length; splitting by item +multiplies the ways a partial delivery can look like a complete one. One group +event carrying a signer's whole contribution also matches the transport: a +signer publishes all `k` nonces or none. + +The parse is strict. A payload whose element count is not the session's item +count is dropped, not truncated and not padded: + +```kotlin +private fun splitPayload(payload: String, expected: Int): List? = + payload.split(",").takeIf { it.size == expected } +``` + +Note *where* this runs. `record()` stores payloads without parsing them, which +is what lets a nonce arrive before its proposal; the count check therefore +belongs in `orderedNonces` and `orderedPartialSignatures`, where the session — +and so the count — is known. Do not move it earlier to "fail faster"; that +breaks the out-of-order replay that `replayStoredMessages` exists for. + +### Proposal encoding, and the compatibility trap + +The proposal's content becomes a JSON **array** of unsigned events — but only +when `k > 1`. + +A single-event session must keep serialising as a bare JSON object, byte for +byte as today. The reason is what an old build does with each form: + +| old build receives | outcome | +|---|---| +| bare object (k=1) | signs it, exactly as now | +| JSON array (k>1) | `Event.fromJsonOrNull` returns null → "does not carry an event; dropping" | + +That is the correct failure. An old device refuses a batch rather than +mis-signing part of one, and a group with mixed versions keeps single signing +working throughout the rollout. Emitting an array unconditionally would break +every k=1 session for old devices and buy nothing. + +`acceptProposal` accepts both: array if the content starts with `[`, otherwise a +one-element list. New devices understand old proposals forever; old devices +understand new single proposals forever. + +### `acceptProposal` over a list + +Each element is rebuilt from its own fields under the room's path and checked +against the id it claims — the existing check, per item, and the whole proposal +is dropped if any one fails. The write-once rule extends from "the event this +session signs" to "the ordered list of events this session signs": a second +proposal under the same id whose list differs anywhere is logged and ignored, +never applied. + +### A cap on `k` + +Enforce a maximum batch size — 64 is a sane starting number — in +`proposeSigning` **and independently in `acceptProposal`**. + +The second check is the one that matters. Without it a proposer can hand every +member a batch of arbitrary size and have them do unbounded native work and +publish an unbounded group event, from a single message. The proposal is the one +place in this protocol where a remote party decides how much work everyone else +does, and it is currently bounded only by never having more than one item. + +Size it against the MLS group event limit rather than picking a round number: +each signer's nonce message is `k × 133` bytes of hex-and-commas, the partial +message `k × 65`, and the proposal itself carries `k` whole events, which is the +term that actually binds. + +--- + +## Phase 4 — the batch API and its call sites + +**A day.** + +```kotlin +suspend fun proposeSigningBatch( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List, // kind, tags, content + key: DkgSession? = null, + createdAt: Long = Clock.System.now().epochSeconds +): FrostSigningSession +``` + +`proposeSigning` stays, as the one-element call into it, so +[AddDialectViewModel](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt), +[AddArtifactViewModel](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt) +and +[GroupKeyStateManager.propose](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt) +need no edit at all. `GroupKeyStateManager` in particular should never batch: a +room's key state is the statement every other session is opened against, and +bundling it with anything else would make it as available as its worst +co-passenger. + +Mirror both on +[FrostSigningRepository](../composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt), +including the `NO_OP` implementation. `signedEvent(session): Event?` becomes +`signedEvents(session): List`. + +### Nonce seeds + +`k` independent 32-byte seeds, generated at proposal, one per item. Deriving +them from a single session seed by index would work and save nothing worth +having: independent seeds mean an off-by-one in index handling produces a +session that fails to aggregate, rather than one that signs two messages under +one nonce. + +### Failure policy: all or nothing + +A batch fails whole. If any item's aggregation fails, `fail()` runs as it does +today, nothing is applied, and the group is told once. + +This is a real cost and should be stated where callers can see it: **a batch is +only as available as its worst item**, so bundling unrelated events makes both +less likely to get signed. Batch things that belong together. + +The retry rule is the one that must not be got wrong, and deserves a comment on +`proposeSigningBatch` itself: **a retry is a new session id with new seeds.** +Never re-propose a failed batch under its own id, and never reuse an item's +`nonceRandom`. This is true of single sessions today; batches make it more +tempting to get wrong, because a batch that failed on item 5 looks like it has +four perfectly good nonces going spare. It does not — those four have already +been published against an aggregate. + +Per-item partial success is deliberately out of scope. It would need mixed-state +UI, a transcript that can say "3 of 5", and a `complete()` that applies a subset, +which is a lot of surface for an outcome that indicates a bug or a dishonest +coordinator rather than a normal ending. + +--- + +## Phase 5 — UI + +**Two days, most of it in the screen.** + +- `FrostSigningUIState.Loaded` gains `items: List`. +- [FrostSigningViewModel](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt) + combines `observeItems` into its existing `combine`; nothing else changes, + since it already re-renders on every session write. +- [FrostSigningScreen](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt) + renders a list where it renders one event today. + +**The approval gate must show every event, not a count.** The argument in +`FrostSigningManager`'s own header — one approval rather than three, because the +event is fixed before the member is asked — only holds if the member can see +what they are approving. "Sign 12 events?" behind a chevron is a worse prompt +than the twelve prompts it replaces. A member who cannot scroll the whole list +should not be able to approve it. + +`FrostSigningRoute` is unchanged: it already carries a session id, and a batch is +one session. + +### Transcript + +No new `ChatMessage` types. The existing `TYPE_FROST_*` constants, +`FROST_REQUEST_FULFILMENTS`, `FROST_SETTLEMENTS` and `FROST_TYPES` in +[ChatMessage.kt:220](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt) +all work as they stand — one line per step, per member, whatever `k` is. Only +the wording in `announceStep` and `announceStarted` gains a count: "signed their +part of 12 events". This is worth checking rather than assuming, because a new +type would need adding to five sets and would silently render as a chat bubble +if missed from `FROST_TYPES`. + +--- + +## Phase 6 — the test that actually proves it + +**A day, and do not skip it.** + +Extend +[SignedGroupKeyStateTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt), +which already runs a real signing session between two devices over two +databases, with a `k = 3` batch: both devices reach `COMPLETE`, all three +signatures verify against their own event ids under the room's key, and all +three events are applied locally on both. + +Then in +[FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt), +against real FROST and no database: + +- a `k = 3` batch producing three signatures nostr accepts, from one signer set; +- **the negative one that matters** — assert the three aggregated nonces are + pairwise distinct, and that the three seeds are. It is the cheapest possible + guard against the one mistake in this document that loses the key, and it will + catch an index bug that every positive test still passes; +- a wrong-length payload is dropped rather than truncated; +- a second proposal under the same session id with a changed item is ignored. + +--- + +## Phase 7 — rollout + +**No code.** + +Nothing here needs a feature flag. `k = 1` is the entire behaviour of the app +after Phase 4, byte-identical on the wire to the app before Phase 1, and no +caller batches anything until one is written to. Phases 1 and 2 are shippable on +their own and worth shipping on their own — they are a schema move and a +refactor, and landing them apart from the wire change means a bisect over a +signing bug lands on one or the other rather than on all of it. + +Before the first caller batches, confirm the group is on a build that +understands array proposals. There is no negotiation for this and adding one is +not worth it; the failure mode is a batch that never reaches threshold and is +abandoned, which is visible in the transcript and costs nothing but a retry. + +--- + +## Appendix — what was considered and rejected + +**One signature over `k` messages.** Does not exist for Schnorr. Aggregate +signature schemes that do this (BLS) are a different curve and a different +verification story, and nostr verifies BIP-340. + +**One nonce, `k` messages.** This is the extraction attack, described at the top. + +**Nonce pre-processing** — the FROST paper's own batching, where signers +pre-publish a list of π nonce commitments before any message is known, and each +later signature consumes one commitment per signer in a single online round. The +library supports it: `SecretNonce.generate` takes `message` as nullable. + +Rejected for now, and it is worth writing down why, because it is the thing +somebody will suggest next. It cuts *latency* rather than message count, which +is not the complaint. And it costs the property this whole design rests on: +today a nonce seed is safe to store and regenerate from **because** a session +signs one fixed message and cannot be made to sign another. Banked nonces have +no message to be bound to at generation time, so their safety moves from a +structural argument to a used/unused ledger that must be right across crashes, +redeliveries and two devices. A bug there leaks a share. Revisit only if +round-trip latency becomes the actual problem. + +**`Frost.deterministicSign` (BIP-445)**, where a signer going last derives its +nonce from the other signers' aggregate and persists nothing, is orthogonal to +batching but relevant to the same file. It would let one signer per session hold +no nonce state at all. Not part of this work. + +**Signing one manifest event that commits to `k` items** — a list of ids, or a +Merkle root. One signature, no protocol change, and by far the cheapest thing on +this page. Rejected as the general answer because each item stops carrying its +own verifiable signature, and the whole point of +[shared-key-derivation.md](./shared-key-derivation.md) is that a reader holding +one signed dialect can check it against the room it was found in without a +lookup. Still the right answer for any case where the items are only ever read +together.