From eba973331b7e3d76ecce611cb6d840ba67d3a0b3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 17:50:57 +0200 Subject: [PATCH] feat(subgroups): schema 21 -- a parentage is a row, so a room can have two Phase 2 of docs/subgroups-multiple-parents.md, the storage. Still dark: a lineage of two now verifies and is filed, and nothing in the app creates one until the adoption flow lands. `GroupKeyState` carried `parentChatRoomId` and `birthCertificateJson` because a group could only be founded with a parent, never adopted by a second. Those are `GroupKeyStateParent` now, one row per parent, and nothing about what they mean changed in the move -- including that the parent side is never a foreign key onto `ChatRoom`, for the three reasons that entity's comment now carries. **The primary key is the rule.** `(chatRoomId, parentChatRoomId)` is "one certificate per child per parent", which `SubgroupLineage.of` already refuses to break; the key is what makes it a property of the store rather than of a reader remembering. The cascade points at `GroupKeyState`, which is the one cascade here that is wanted: a lineage must not outlive the state that claimed it, or a room recreated at the same derived id would inherit a parentage nobody signed for it. **`replace` rewrites the lineage rather than merging it, and that is why it is a transaction.** A key state is replaceable and the newest wins *wholesale*, so the set of parents it names is the set. That is what makes a disavowal expressible at all -- a room drops a parent by re-announcing without it -- and merged rows would leave the dropped parent surviving the statement that dropped it. Deleting before inserting inside one transaction also stops a reader seeing a state beside somebody else's parents: were the writes separate, a concurrent read could land between them and get a state with the old lineage, which looks exactly like a true answer. An older state still changes nothing, now including its parents. **`stateFrom` returns `LocalGroupKeyState`, because a state and its lineage are one signed statement.** `lineageOf` checks every parent and drops the whole state if any one certificate fails -- the all-or-nothing rule generalised, for the reason it was written: keeping the provable half would file a group as having one parent when its own members see two. Each link is checked on its own, which is what lets a lineage be assembled a parent at a time, and there is a comment where somebody will look for the check that is deliberately absent: nothing requires the certificates to agree on the set. An earlier certificate cannot name a later co-parent, so requiring agreement would make every adoption cost a fresh quorum in every group that had already certified the room. The child's key state is the sole authority. **`ChatRoom.parentChatRoomId` is dropped, unread.** It was a cache of the verified value, written by `MarmotGroupCreation` and read in production by nothing at all -- the live answer went through `GroupKeyState`. Keeping it would mean a column holding *one* of several parents, which is worse than unused: a cache that is wrong. Its one query, `ChatRoomDao.observeByParentChatRoomId`, had only tests for callers and goes with it; the comment left in its place says where the two live answers are. `create`'s `parentChatRoomId` parameter threaded through four files to reach the write that is now gone, so it goes too rather than sitting dead. `MIGRATION_20_21` creates, copies, then drops -- the shape MIGRATION_9_10 established, and for the same reason it gives: `DROP TABLE` fires cascades and would delete the rows the migration had just written, so `ALTER TABLE ... DROP COLUMN` is used instead. `BundledSQLiteDriver` is pinned on every platform, so 3.35 is ours rather than the host's. The `WHERE` takes both halves of a parentage: a half row should not exist, since `stateFrom` has always dropped such a state rather than filing it, but this is the one moment the data is reshaped rather than re-derived and a half row copied forward would become a parent whose certificate is the string "null". Better to leave it behind -- the state keeps saying what the room signs with, and loses a lineage it could never prove. `parentOf` is `parentsOf` and returns a list, through the repository to `ChatRoomDetailUIState`; the parent card becomes one card per parent, keyed, because they are a set rather than a path -- neither is "the" parent and the order says nothing about which came first. Thirteen tests in `SubgroupDaoJvmTest`, rewritten for the table: the two cascades in both directions, a child with two parents appearing in both their lists, a re-announcement that drops a parent and one that clears the lineage entirely, an older state changing neither the state nor its parents, and the primary key collapsing a repeat to one row. In `GroupKeyStateTest`, Phase 1's "two parents are dropped" becomes "two parents are kept, each with its own certificate" -- it needed a third real group, since the earlier draft's second parent was the child certifying itself -- beside a new case where one bad certificate of two drops the whole state. 444 common tests, 937 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 --- .../21.json | 5738 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 13 +- .../builder/PlatformDatabaseBuilder.kt | 17 +- .../compose/database/dao/ChatRoomDao.kt | 27 +- .../compose/database/dao/GroupKeyStateDao.kt | 96 +- .../migrations/SubgroupLineageMigration.kt | 86 + .../mantra/compose/database/model/ChatRoom.kt | 23 - .../compose/database/model/GroupKeyState.kt | 41 +- .../database/model/GroupKeyStateParent.kt | 86 + .../model/intermdiate/LocalGroupKeyState.kt | 62 + .../repository/DatabaseChatRepository.kt | 10 +- .../repository/DatabaseDkgRepository.kt | 6 +- .../compose/managers/GroupKeyStateManager.kt | 188 +- .../compose/managers/MarmotGroupCreation.kt | 14 - .../compose/managers/SubgroupManager.kt | 28 +- .../compose/repository/ChatRepository.kt | 18 +- .../compose/repository/DkgRepository.kt | 10 +- .../ui/composable/ChatRoomDetailScreen.kt | 10 +- .../ui/view/model/ChatRoomDetailViewModel.kt | 2 +- .../ui/view/model/DkgRitualViewModel.kt | 6 +- .../ui/view/state/ChatRoomDetailUIState.kt | 2 +- .../compose/managers/GroupKeyStateTest.kt | 126 +- .../database/dao/SubgroupDaoJvmTest.kt | 291 +- .../managers/SignedGroupKeyStateTest.kt | 65 +- 24 files changed, 6556 insertions(+), 409 deletions(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json new file mode 100644 index 00000000..c387bc7a --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json @@ -0,0 +1,5738 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "ff296ccd92cdb4bd61b3e150565f59c4", + "entities": [ + { + "tableName": "BroadcastNostrEventReceipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAccepted", + "columnName": "isAccepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSync", + "columnName": "isSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messages", + "columnName": "messages", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventReceipt_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventReceipt_isAccepted", + "unique": false, + "columnNames": [ + "isAccepted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "BroadcastNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourcePublicKey", + "columnName": "sourcePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationPublicKey", + "columnName": "destinationPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourcePublicKey", + "destinationPublicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourcePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "destinationPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "ChatMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `directMessageRecipientPublicKey` TEXT, `frostSigningSessionId` TEXT, `dkgSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUserMessage", + "columnName": "isUserMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftWrapPayloadId", + "columnName": "giftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotInnerEventId", + "columnName": "marmotInnerEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotedMessageId", + "columnName": "quotedMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "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" + ] + }, + "indices": [ + { + "name": "index_ChatMessage_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessage_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotInnerEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotInnerEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventRequestRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventRequestId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventRequestId`) REFERENCES `BroadcastNostrEventRequest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventRequestId", + "columnName": "broadcastNostrEventRequestId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventRequest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventReceiptRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventReceiptId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventReceiptId`) REFERENCES `BroadcastNostrEventReceipt`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventReceiptId", + "columnName": "broadcastNostrEventReceiptId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventReceipt", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventReceiptId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageNostrEventRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatRoom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mlsGroupState", + "columnName": "mlsGroupState", + "affinity": "TEXT" + }, + { + "fieldPath": "initialGiftWrapPayloadId", + "columnName": "initialGiftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "leftGroupAt", + "columnName": "leftGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "joinedGroupAt", + "columnName": "joinedGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "chronicleRequestedAt", + "columnName": "chronicleRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "initialGiftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgParticipantMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `DkgSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `participantPublicKeys` TEXT, `subject` TEXT, `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, `parentChatRoomId` 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": "participantPublicKeys", + "columnName": "participantPublicKeys", + "affinity": "TEXT" + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "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": "parentChatRoomId", + "columnName": "parentChatRoomId", + "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": "GroupKeyStateParent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `parentChatRoomId` TEXT NOT NULL, `birthCertificateJson` TEXT NOT NULL, PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthCertificateJson", + "columnName": "birthCertificateJson", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "parentChatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyStateParent_parentChatRoomId", + "unique": false, + "columnNames": [ + "parentChatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` ON `${TABLE_NAME}` (`parentChatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "GroupKeyState", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "chatRoomId" + ] + } + ] + }, + { + "tableName": "GroupSignedEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `derivationPath` TEXT, `frostSigningSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_GroupSignedEvent_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_GroupSignedEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_GroupSignedEvent_frostSigningSessionId", + "unique": false, + "columnNames": [ + "frostSigningSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_frostSigningSessionId` ON `${TABLE_NAME}` (`frostSigningSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "InReplyToRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "replyingNostrEventId", + "columnName": "replyingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToProfilePublicKey", + "columnName": "inReplyToProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootProfilePublicKey", + "columnName": "inReplyToRootProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "replyingNostrEventId", + "inReplyToNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionLabel", + "columnName": "versionLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifact", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraDialect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChunkId", + "columnName": "translationChunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersionContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "editorPublicKey", + "columnName": "editorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersionContributor", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapterContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkId", + "columnName": "chunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dTag", + "columnName": "dTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationId", + "columnName": "translationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslation", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotCommitResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerKeyPackageEventId", + "columnName": "peerKeyPackageEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOneMemberInitialGroupCreation", + "columnName": "isOneMemberInitialGroupCreation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commitBytes", + "columnName": "commitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "welcomeBytes", + "columnName": "welcomeBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "groupInfoBytes", + "columnName": "groupInfoBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "framedCommitBytes", + "columnName": "framedCommitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "preCommitExporterSecret", + "columnName": "preCommitExporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "MarmotGroupEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encryptedContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotInnerEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `payloadEventId` TEXT, `marmotGroupEventId` TEXT, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadEventId", + "columnName": "payloadEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `ownerPublicKey` TEXT, `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": "ownerPublicKey", + "columnName": "ownerPublicKey", + "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" + ] + }, + "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, `ownerPublicKey` TEXT, `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": "ownerPublicKey", + "columnName": "ownerPublicKey", + "affinity": "TEXT" + }, + { + "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, 'ff296ccd92cdb4bd61b3e150565f59c4')" + ] + } +} \ 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 dcd795cd..6fb73be9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -74,6 +74,7 @@ import press.mantra.compose.database.model.GiftWrapMessage import press.mantra.compose.database.model.GiftWrapPayload import press.mantra.compose.database.model.GiftWrapSeal import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.InReplyToRelation import press.mantra.compose.database.model.MantraArtifact @@ -139,6 +140,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) GiftWrapSeal::class, GiftWrapPayload::class, GroupKeyState::class, + GroupKeyStateParent::class, GroupSignedEvent::class, InReplyToRelation::class, MantraArtifact::class, @@ -177,7 +179,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 20, + version = 21, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -321,6 +323,15 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // which is what they were, and any identity may drain them. The broadcast // queue deliberately gets no owner: a signed event is anyone's to carry. AutoMigration(from = 19, to = 20), + // v21 moves a room's parentage off two GroupKeyState columns onto the new + // GroupKeyStateParent table, so a room can be the child of more than one + // group -- founded with one and adopted by another later. It copies + // before it drops, which no AutoMigration can express, and it drops + // ChatRoom.parentChatRoomId as well: that column was a cache of the + // verified value which nothing in production ever read, and keeping it + // would mean a column holding *one* of several parents, which is worse + // than unused. See MIGRATION_20_21 and + // docs/subgroups-multiple-parents.md. ] ) @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 99ff7ece..212e6c8e 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_20_21 import press.mantra.compose.database.migrations.MIGRATION_9_10 import press.mantra.compose.database.migrations.MIGRATION_13_14 import press.mantra.compose.database.migrations.MIGRATION_14_15 @@ -19,13 +20,21 @@ fun getRoomDatabase( builder: RoomDatabase.Builder ): press.mantra.compose.database.MantraDatabase { return builder - // Everything else Room generates itself. These four move data rather than + // Everything else Room generates itself. These five move data rather than // only changing shape, which an AutoMigration cannot express: 3->4 rewrites // chat rows, 9->10 copies a session's per-event columns onto the items // table before dropping them, 13->14 renames a column and rewrites the - // chat rows that named it the old way, and 14->15 adds a column and deletes - // the chat rows written for messages from before this device joined. - .addMigrations(MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14, MIGRATION_14_15) + // chat rows that named it the old way, 14->15 adds a column and deletes + // the chat rows written for messages from before this device joined, and + // 20->21 copies each room's single parentage onto the table that can hold + // several before dropping the columns it came from. + .addMigrations( + MIGRATION_3_4, + MIGRATION_9_10, + MIGRATION_13_14, + MIGRATION_14_15, + MIGRATION_20_21 + ) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt index c072cb30..c27e11bf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt @@ -66,25 +66,14 @@ interface ChatRoomDao { ) fun observeChatRoomListByUserPublicKey(userPublicKey: String): Flow> - /** - * The subgroups of [parentChatRoomId] this device actually holds rooms for. - * - * Deliberately not the answer to "what subgroups does this group have" -- - * that is `SubgroupManager.subgroupsOf`, which reads the parent's signed - * certificates and so can name a child whose room has not been created, or - * was created for somebody else. This names the ones there is something to - * open. - * - * Soft-deleted rooms are excluded like everywhere else: a room the user has - * cleared away should not reappear because its parent lists it. - */ - @Transaction - @Query( - CHAT_ROOM_WITH_LAST_MESSAGE + - "WHERE ChatRoom.parentChatRoomId = :parentChatRoomId AND ChatRoom.deletedAt IS NULL " + - ORDER_BY_LAST_ACTIVITY - ) - fun observeByParentChatRoomId(parentChatRoomId: String): Flow> + // "The subgroups of this room" used to be a column filter here, over a + // ChatRoom.parentChatRoomId that production never read -- the live answer + // went through GroupKeyState, and SubgroupManager.subgroupsOf reads the + // parent's own certificates, which can name a child whose room this device + // does not have. The column is gone at v21, because a room can have more + // than one parent and a single column would have held one of them. The + // rooms a parent's children occupy are reached through + // GroupKeyStateDao.getByParentChatRoomId and resolved from there. @Upsert suspend fun upsert(chatRoom: ChatRoom) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt index 4a7f0b6f..f0076895 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt @@ -6,6 +6,8 @@ import androidx.room3.Transaction import androidx.room3.Upsert import kotlinx.coroutines.flow.Flow import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent +import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState @Dao abstract class GroupKeyStateDao { @@ -15,6 +17,18 @@ abstract class GroupKeyStateDao { @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") abstract fun observeByChatRoomId(chatRoomId: String): Flow + /** + * A state with the groups that have certified it, which is how a lineage + * should always be read -- the two are one signed statement. + */ + @Transaction + @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") + abstract suspend fun getLocalByChatRoomId(chatRoomId: String): LocalGroupKeyState? + + @Transaction + @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") + abstract fun observeLocalByChatRoomId(chatRoomId: String): Flow + /** Every room that signs with one ceremony's key. One, today. */ @Query("SELECT * FROM GroupKeyState WHERE dkgSessionId = :dkgSessionId") abstract suspend fun getByDkgSessionId(dkgSessionId: String): List @@ -23,26 +37,52 @@ abstract class GroupKeyStateDao { * Every subgroup of [parentChatRoomId] this device holds a verified state * for. * - * Verified is the whole of what these rows are: a state only reaches the - * table with a parent on it once its birth certificate has been checked - * against that parent's signature, so this is a list of children the parent - * really did certify rather than a list of rooms claiming a parent. + * Verified is the whole of what these rows are: a parentage only reaches the + * table once its birth certificate has been checked against that parent's + * signature, so this is a list of children the parent really did certify + * rather than a list of rooms claiming a parent. * * It is not the whole list. A subgroup whose room this device does not have * leaves no state here at all, and the certificates in `GroupSignedEvent` are * the fuller answer -- see `SubgroupManager.subgroupsOf`. + * + * A child with two parents appears in both parents' answers, which is the + * point of the join: the row that makes it this parent's child says nothing + * about who else certified it. */ - @Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC") - abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List + @Transaction + @Query( + "SELECT GroupKeyState.* FROM GroupKeyState " + + "JOIN GroupKeyStateParent ON GroupKeyStateParent.chatRoomId = GroupKeyState.chatRoomId " + + "WHERE GroupKeyStateParent.parentChatRoomId = :parentChatRoomId " + + "ORDER BY GroupKeyState.announcedAt DESC" + ) + abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List - @Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC") - abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow> + @Transaction + @Query( + "SELECT GroupKeyState.* FROM GroupKeyState " + + "JOIN GroupKeyStateParent ON GroupKeyStateParent.chatRoomId = GroupKeyState.chatRoomId " + + "WHERE GroupKeyStateParent.parentChatRoomId = :parentChatRoomId " + + "ORDER BY GroupKeyState.announcedAt DESC" + ) + abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow> + + /** The parents of one room, as rows. Prefer [getLocalByChatRoomId]. */ + @Query("SELECT * FROM GroupKeyStateParent WHERE chatRoomId = :chatRoomId") + abstract suspend fun getParentsByChatRoomId(chatRoomId: String): List @Upsert abstract suspend fun upsert(groupKeyState: GroupKeyState) + @Upsert + abstract suspend fun upsertParents(parents: List) + + @Query("DELETE FROM GroupKeyStateParent WHERE chatRoomId = :chatRoomId") + abstract suspend fun deleteParentsByChatRoomId(chatRoomId: String) + /** - * Files a state, keeping the newest one per room. + * Files a state and its whole lineage, keeping the newest one per room. * * This is where the event's replaceable semantics actually happen. Relays * never see a `GroupKeyStateEvent` -- it lives inside a Marmot group and is @@ -56,13 +96,36 @@ abstract class GroupKeyStateDao { * state agree by construction, since both derived it from the room they are * standing in. * - * Returns the state now on file. + * ### The lineage is replaced, not merged, and that is the whole reason this + * is a transaction + * + * A key state is replaceable and the newest wins **wholesale**, so the set of + * parents it names is the set -- not an addition to whatever was there. That + * is what makes a disavowal expressible at all: a room drops a parent by + * re-announcing without it, and if these rows were merged rather than + * rewritten the dropped parent would survive the statement that dropped it. + * + * Deleting before inserting inside one transaction is also what stops a + * reader seeing a state beside somebody else's parents. Were the two writes + * separate, a concurrent `getLocalByChatRoomId` could land between them and + * read a state with no lineage, or with the old one -- both of which look + * exactly like a true answer. + * + * Returns the state now on file, with the lineage now on file beside it. */ @Transaction - open suspend fun replace(groupKeyState: GroupKeyState): GroupKeyState { + open suspend fun replace( + groupKeyState: GroupKeyState, + parents: List = emptyList(), + ): LocalGroupKeyState { val known = getByChatRoomId(groupKeyState.chatRoomId) - if (known != null && known.announcedAt >= groupKeyState.announcedAt) return known + if (known != null && known.announcedAt >= groupKeyState.announcedAt) { + return LocalGroupKeyState( + keyState = known, + parents = getParentsByChatRoomId(known.chatRoomId) + ) + } val stamped = groupKeyState.copy( createdAt = known?.createdAt ?: groupKeyState.createdAt, @@ -70,6 +133,13 @@ abstract class GroupKeyStateDao { ) upsert(stamped) - return stamped + // The state's own row has to exist before its parents', because they + // reference it. Room turns foreign keys off around a migration but not + // around a transaction, so the order here is load-bearing rather than + // tidy. + deleteParentsByChatRoomId(stamped.chatRoomId) + if (parents.isNotEmpty()) upsertParents(parents) + + return LocalGroupKeyState(keyState = stamped, parents = parents) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt new file mode 100644 index 00000000..e7869173 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt @@ -0,0 +1,86 @@ +package press.mantra.compose.database.migrations + +import androidx.room3.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +/** + * Moves a room's parentage off two `GroupKeyState` columns onto + * `GroupKeyStateParent`, so a room can be the child of more than one group. + * + * 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. + * + * ### Every existing lineage is a set of one + * + * A group could only be founded with a parent, never adopted by a second, so + * every row that carries a parentage carries exactly one. Each becomes one row + * in the new table and nothing is lost -- and the rule the new primary key + * states, one certificate per child per parent, is already true of all of them. + * + * Rows with no parentage -- every group made before subgroups, and every + * top-level group made after -- contribute nothing, which is what "nobody's + * child" looks like in the new shape: no rows rather than a null. + * + * The `WHERE` takes both halves. A row with one and not the other should not + * exist, because `GroupKeyStateManager.stateFrom` has always dropped a state + * carrying a half-claim rather than filing it -- but this is the one moment the + * data is reshaped rather than re-derived, and a half row copied forward would + * become a parent whose certificate is the string "null". Better to leave such a + * row behind entirely: the state keeps saying what the room signs with, which is + * the part that matters, and loses a lineage it could never have proved. + * + * ### `ChatRoom.parentChatRoomId` goes with them, unread + * + * It was a cache of the verified value, written by `MarmotGroupCreation` and + * read -- in production -- by nothing at all; the live answer went through + * `GroupKeyState`. Keeping it would mean a column holding *one* of several + * parents, which is worse than unused: it is a cache that is wrong. So it is + * dropped rather than backfilled, and nothing is copied out of it. + * + * ### 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. `GroupKeyStateParent` + * references `GroupKeyState(chatRoomId)` `ON DELETE CASCADE`, and `DROP TABLE` + * fires cascades: with foreign keys enforced it would delete every parent row + * this migration had just written. Dropping `ChatRoom` would be far worse, since + * most of the schema cascades off it. + * + * `ALTER TABLE ... DROP COLUMN` avoids the question. It needs SQLite 3.35 and + * the columns must be free of indices and constraints -- all three are, and + * `getRoomDatabase` pins `BundledSQLiteDriver` on every platform, so the version + * is ours rather than the host's. Both tables keep their keys, their foreign keys + * and their indices. + */ +val MIGRATION_20_21 = object : Migration(20, 21) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `GroupKeyStateParent` (" + + "`chatRoomId` TEXT NOT NULL, " + + "`parentChatRoomId` TEXT NOT NULL, " + + "`birthCertificateJson` TEXT NOT NULL, " + + "PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), " + + "FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` " + + "ON `GroupKeyStateParent` (`parentChatRoomId`)" + ) + + connection.execSQL( + "INSERT OR IGNORE INTO `GroupKeyStateParent` " + + "(`chatRoomId`, `parentChatRoomId`, `birthCertificateJson`) " + + "SELECT `chatRoomId`, `parentChatRoomId`, `birthCertificateJson` " + + "FROM `GroupKeyState` " + + "WHERE `parentChatRoomId` IS NOT NULL AND `birthCertificateJson` IS NOT NULL" + ) + + listOf("parentChatRoomId", "birthCertificateJson").forEach { column -> + connection.execSQL("ALTER TABLE `GroupKeyState` DROP COLUMN `$column`") + } + + connection.execSQL("ALTER TABLE `ChatRoom` DROP COLUMN `parentChatRoomId`") + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt index 20dc9cc5..8babc6ff 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt @@ -112,29 +112,6 @@ data class ChatRoom( */ val chronicleRequestedAt: Instant? = null, - /** - * The group this room is a subgroup of, or null for a room that is nobody's - * child. - * - * A copy of the verified `GroupKeyState.parentChatRoomId`, kept here so the - * room list and the room's own screen can answer "whose child is this" - * without a second read. It is written as the room is created or adopts its - * key state, and only from a state whose certificate passed - * `SubgroupBirthCertificateEvent.certifies`. - * - * **Never a foreign key onto `ChatRoom`.** Every other reference to a room in - * this schema cascades, and a self-referential cascade would mean deleting a - * parent silently deletes its subgroups -- and then, by their own cascades, - * those rooms' messages, participants, key states, signing sessions and - * signed events. A user tidying up a group they left would destroy a group - * they are still in. `RESTRICT` would be no better: it would make a parent - * undeletable while a child row exists, which is a foreign key deciding a - * product question. This routinely names a room this device does not have -- - * see `GroupKeyState.parentChatRoomId` -- so a dangling value is the normal, - * expected state. - */ - val parentChatRoomId: HexKey? = null, - override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt, override val savedAt: Instant = createdAt, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt index 05800bf5..a3524da5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt @@ -94,41 +94,16 @@ data class GroupKeyState( val announcedAt: Instant, /** - * The group this one is a subgroup of, or null for a group that is nobody's - * child -- which is every group made before subgroups existed. + * A lineage used to live here, as a parent id and a certificate. It is + * `GroupKeyStateParent` now, one row per parent, because a room can have + * more than one -- see `docs/subgroups-multiple-parents.md`. Nothing about + * what it means changed in the move, including that the parent side is never + * a foreign key onto `ChatRoom`; that entity's comment carries the argument. * - * **Not a foreign key, and that is deliberate.** The three tables around this - * one cascade off `ChatRoom`, and a self-referential cascade here would mean - * deleting a parent room deletes every subgroup's state beneath it. It could - * not be `RESTRICT` either: this routinely names a room the device does not - * have at all -- a member of a subgroup who was never in its parent holds the - * id and nothing else -- so a dangling value is the normal state and readers - * resolve it with a lookup allowed to return null. - * - * Written only from a state whose [birthCertificateJson] passed - * `SubgroupBirthCertificateEvent.certifies`, so a row carrying this is a row - * where the parent's quorum really did sign for this room. See - * `GroupKeyStateManager.stateFrom`, which drops the whole state rather than - * keeping a parentage it could not check. + * Read the two together through `LocalGroupKeyState`, never apart: a state + * and the lineage it claims are one signed statement, and + * `GroupKeyStateManager.stateFrom` believes all of it or drops all of it. */ - val parentChatRoomId: HexKey? = null, - - /** - * The parent's signed certificate, whole, as JSON -- or null when this group - * has no parent. - * - * The claim itself, where [parentChatRoomId] is only an index into it. Kept - * as the event rather than as its signature so that a reader can check it - * again from the row alone: a signature plus a rule for rebuilding the event - * it covers is a rule that breaks silently the first time the certificate's - * shape changes, and a rebuild differing by one byte hashes to an id whose - * signature fails and is indistinguishable from a forgery. - * - * Always set together with [parentChatRoomId]. One without the other is a - * state that never reached this table. - */ - val birthCertificateJson: String? = null, - override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt, override val savedAt: Instant = createdAt, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt new file mode 100644 index 00000000..8dda692d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt @@ -0,0 +1,86 @@ +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 group that has certified a room as its child, and the signature saying so. + * + * A row per parent, because a room can have more than one: it is founded with + * one and may be adopted by another later, and an adoption is the parent + * certifying plus the child re-announcing its key state with the pair added. See + * `docs/subgroups-multiple-parents.md`. + * + * These rows were two nullable columns on [GroupKeyState] while a lineage could + * only ever be one link. Nothing about their meaning changed in the move. + * + * ### The primary key is the rule + * + * `(chatRoomId, parentChatRoomId)` -- one certificate per child per parent. A + * second entry for a parent already in the set is one parent asked twice and is + * refused by `SubgroupLineage.of` before it gets here; the key is what makes + * that a property of the store rather than of a reader remembering. + * + * ### Verified before it is written, every time + * + * A row exists only where `SubgroupBirthCertificateEvent.certifies` has said + * that this parent's quorum really signed for this room -- see + * `GroupKeyStateManager.stateFrom`, which drops the **whole** state when any one + * parent's certificate fails. So the presence of a row is itself the claim that + * the lineage was checked, and there is no state in which some parents are + * believed and others are not. + * + * [birthCertificateJson] is the certificate whole rather than its signature, + * so a reader can check it again from the row alone. A signature plus a rule for + * rebuilding the event it covers is a rule that breaks silently the first time + * the certificate's shape changes: a rebuild differing by one byte hashes to an + * id whose signature fails and is indistinguishable from a forgery. + * + * ### The cascade points at the state, never at the parent's room + * + * `ON DELETE CASCADE` from [GroupKeyState] is what keeps a lineage from + * outliving the state that claimed it -- and it is what lets a re-announcement + * that drops a parent actually drop it, since `GroupKeyStateDao.replace` + * rewrites these rows inside the same transaction. + * + * [parentChatRoomId] is deliberately **not** a foreign key onto `ChatRoom`. A + * self-referential cascade would mean deleting a parent room deletes its + * children's key states, and then by their own cascades those rooms' messages, + * participants, signing sessions and signed events -- a user tidying up a group + * they left would destroy a group they are still in. `RESTRICT` would be no + * better: it would make a parent undeletable while a child row exists, which is + * a foreign key deciding a product question. And this routinely names a room the + * device does not have at all, since a member of a subgroup need never have been + * in its parent, so a dangling value is the normal, expected state and readers + * resolve it with a lookup allowed to return null. + */ +@Entity( + primaryKeys = ["chatRoomId", "parentChatRoomId"], + foreignKeys = [ + ForeignKey( + entity = GroupKeyState::class, + parentColumns = ["chatRoomId"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + // The composite primary key already indexes chatRoomId as its leftmost + // column, so only the other direction needs one: "which rooms has this + // group certified", which is how a parent finds the children whose state + // this device holds. + Index("parentChatRoomId"), + ], +) +data class GroupKeyStateParent( + /** The child: the room whose key state carries this parentage. */ + val chatRoomId: String, + + /** The parent's room id, which is also the key its certificate verifies against. */ + val parentChatRoomId: HexKey, + + /** The parent's signed certificate, whole, as JSON. */ + val birthCertificateJson: String, +) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt new file mode 100644 index 00000000..30991c56 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt @@ -0,0 +1,62 @@ +package press.mantra.compose.database.model.intermdiate + +import androidx.room3.Embedded +import androidx.room3.Relation +import com.vitorpamplona.quartz.nip01Core.core.Event +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent +import press.mantra.compose.nostr.subgroup.SubgroupLineage +import press.mantra.compose.nostr.subgroup.SubgroupParentage + +/** + * A room's key state with every group that has certified it as a child. + * + * The two always travel together, because a state and the lineage it claims are + * one signed statement: `GroupKeyStateManager.stateFrom` either believes all of + * it or drops all of it, so a reader holding the state without the parents would + * be holding half of something that has no halves. + * + * Also the shape `stateFrom` returns. It is a Room read model -- `@Embedded` + * over `@Relation`, like `LocalChatRoom` -- and a plain data class besides, so + * the pure verifier can build one with no database anywhere near it. + */ +data class LocalGroupKeyState( + @Embedded + val keyState: GroupKeyState, + + @Relation( + entity = GroupKeyStateParent::class, + parentColumns = ["chatRoomId"], + entityColumns = ["chatRoomId"], + ) + val parents: List = emptyList(), +) { + /** + * The lineage these rows amount to, or null for a room that is nobody's + * child. + * + * Null also where the rows cannot be read back as a lineage -- a certificate + * whose JSON no longer parses, or a repeat the store should have made + * impossible. Both are the same answer here, and it is the safe one: a + * lineage that cannot be stated is not half-stated, for the reason + * `stateFrom` drops a whole state rather than keeping a parentage it could + * not check. + * + * Rebuilt rather than cached, so the canonical order and the no-repeats rule + * come from `SubgroupLineage` on every read instead of from whatever order + * the rows came back in. + */ + fun lineage(): SubgroupLineage? = SubgroupLineage.of( + parents.mapNotNull { parent -> + Event.fromJsonOrNull(parent.birthCertificateJson)?.let { certificate -> + SubgroupParentage( + parentChatRoomId = parent.parentChatRoomId, + certificate = certificate + ) + } + }.takeIf { it.size == parents.size } ?: return null + ) + + /** The groups that have certified this room, in canonical order. */ + fun parentChatRoomIds(): List = lineage()?.parentChatRoomIds.orEmpty() +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 6b71b5e8..915fdec8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -55,7 +55,6 @@ class DatabaseChatRepository( userPublicKey: HexKey, keyPair: KeyPair, path: List, - parentChatRoomId: HexKey?, ): MarmotGroupCreation.Outcome = MarmotGroupCreation.create( database = database, chatRepository = this, @@ -66,7 +65,6 @@ class DatabaseChatRepository( userPublicKey = userPublicKey, keyPair = keyPair, path = path, - parentChatRoomId = parentChatRoomId, ) override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> { return database.chatRoomDao().observeChatRoomListByUserPublicKey(publicKey) @@ -113,11 +111,11 @@ class DatabaseChatRepository( emptyList() } - override suspend fun parentOf(chatRoomId: String): HexKey? = try { - SubgroupManager.parentOf(database, chatRoomId) + override suspend fun parentsOf(chatRoomId: String): List = try { + SubgroupManager.parentsOf(database, chatRoomId) } catch (e: Throwable) { - logger.e("Error reading the parent of $chatRoomId", e) - null + logger.e("Error reading the parents of $chatRoomId", e) + emptyList() } override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt index e1674fc5..1353970d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt @@ -5,7 +5,7 @@ import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.database.model.FrostSigningSession -import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession @@ -110,7 +110,7 @@ class DatabaseDkgRepository( ): Flow> = database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId) - override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = + override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = database.groupSignedEventDao() .observeByKind(GroupKeyStateEvent.KIND) .map { signedEvents -> @@ -132,7 +132,7 @@ class DatabaseDkgRepository( subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) } } - override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = try { + override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = try { GroupKeyStateManager.adopt(database = database, chatRoomId = chatRoomId) } catch (e: Throwable) { // The room is made and usable either way; what it loses is the shortcut diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt index ff4613f6..757221c8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt @@ -9,8 +9,10 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.subgroup.SubgroupLineage @@ -122,18 +124,6 @@ object GroupKeyStateManager { "Ceremony ${key.id} has produced no key for $signingRoomId to propose" ) - // The event can carry a lineage of any size; the row cannot yet. Until - // `GroupKeyState` holds a set, a caller reaching here with two parents - // would have one of them silently dropped on the way to the columns, so - // it is refused outright rather than half-kept. The tags are already - // plural -- see `GroupKeyStateEvent.assembleTags` -- which is what lets - // the wire land before the storage does. - check(lineage == null || lineage.size == 1) { - "Room $signingRoomId cannot yet propose a key state naming " + - "${lineage?.size} parents" - } - val parent = lineage?.parents?.single() - // The room this is about: the group's key walked to the admin path, // which is that room's id. It need not exist yet, and in the flow this // was written for it does not. @@ -150,9 +140,7 @@ object GroupKeyStateManager { thresholdPublicKey = thresholdPublicKey, derivationPath = SharedKeyDerivation.formatPath(path), announcedBy = userPublicKey, - announcedAt = Instant.fromEpochSeconds(createdAt), - parentChatRoomId = parent?.parentChatRoomId, - birthCertificateJson = parent?.certificate?.toJson() + announcedAt = Instant.fromEpochSeconds(createdAt) ) check(state.verifies()) { @@ -160,10 +148,14 @@ object GroupKeyStateManager { } // Refused here rather than discovered by the group. Every device that - // receives this state runs the same check in `parentageOf` and drops the - // state when it fails, so proposing one this device would not believe - // spends a quorum's attention on a statement nobody will keep. - if (parent != null) { + // receives this state runs the same checks in `lineageOf` and drops the + // state when any of them fails, so proposing one this device would not + // believe spends a quorum's attention on a statement nobody will keep. + // + // Every parent, because the receiving check is all-or-nothing: one bad + // certificate in a set of two drops the whole state, so a proposal is + // only worth making if every link in it holds. + lineage?.parents?.forEach { parent -> check( SubgroupBirthCertificateEvent.certifies( event = parent.certificate, @@ -214,18 +206,19 @@ object GroupKeyStateManager { database: MantraDatabase, chatRoomId: String, innerEvent: Event - ): GroupKeyState? { - val state = stateFrom(chatRoomId, innerEvent) ?: return null + ): LocalGroupKeyState? { + val local = stateFrom(chatRoomId, innerEvent) ?: return null + val announced = local.keyState.chatRoomId - if (database.chatRoomDao().findChatRoomById(state.chatRoomId) == null) { + if (database.chatRoomDao().findChatRoomById(announced) == null) { logger.i( - "The group signed a key state for ${state.chatRoomId}, which does not exist " + + "The group signed a key state for $announced, which does not exist " + "yet; holding it until it does" ) return null } - return database.groupKeyStateDao().replace(state) + return database.groupKeyStateDao().replace(local.keyState, local.parents) } /** @@ -248,12 +241,15 @@ object GroupKeyStateManager { * Idempotent: `replace` keeps the newest state per room, so adopting twice * settles on the same row. */ - suspend fun adopt(database: MantraDatabase, chatRoomId: String): GroupKeyState? { - val state = signedStateFor(database, chatRoomId) ?: return null + suspend fun adopt(database: MantraDatabase, chatRoomId: String): LocalGroupKeyState? { + val local = signedStateFor(database, chatRoomId) ?: return null - logger.i("Room $chatRoomId adopts the key state its group signed at ${state.announcedAt}") + logger.i( + "Room $chatRoomId adopts the key state its group signed at " + + "${local.keyState.announcedAt}, naming ${local.parents.size} parents" + ) - return database.groupKeyStateDao().replace(state) + return database.groupKeyStateDao().replace(local.keyState, local.parents) } /** @@ -266,7 +262,7 @@ object GroupKeyStateManager { * the ritual screen gates the "create the room" button on, and what [adopt] * files once there is a room to file it against. */ - suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? = + suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): LocalGroupKeyState? = stateAmong(database.groupSignedEventDao().getByKind(GroupKeyStateEvent.KIND), chatRoomId) /** @@ -295,7 +291,7 @@ object GroupKeyStateManager { * arrive can ask the same question of each emission -- which is how the * ceremony screen knows the group has finished agreeing. */ - fun stateAmong(signedEvents: List, chatRoomId: String): GroupKeyState? = + fun stateAmong(signedEvents: List, chatRoomId: String): LocalGroupKeyState? = signedAmong(signedEvents, chatRoomId)?.second /** @@ -310,7 +306,7 @@ object GroupKeyStateManager { private fun signedAmong( signedEvents: List, chatRoomId: String - ): Pair? = + ): Pair? = signedEvents .asSequence() .filter { it.kind == GroupKeyStateEvent.KIND } @@ -319,7 +315,7 @@ object GroupKeyStateManager { // every room at once, so a state that names no room names nothing. .filter { GroupKeyStateEvent.parseChatRoomId(it.tags) == chatRoomId } .mapNotNull { signed -> stateFrom(chatRoomId, signed.toEvent())?.let { signed to it } } - .maxByOrNull { (_, state) -> state.announcedAt } + .maxByOrNull { (_, local) -> local.keyState.announcedAt } /** * The state an event amounts to, or null if it amounts to none. @@ -358,20 +354,21 @@ object GroupKeyStateManager { * names no room at all, and appears in the logs. */ /** - * A parentage that was checked, wrapping a value that is allowed to be + * A lineage that was checked, wrapping a value that is allowed to be * absent. * * Three outcomes have to be told apart and a bare nullable can only carry * two. A state may claim no parent, which is every group made before - * subgroups and every top-level group made after; it may claim one and prove - * it; or it may claim one it cannot prove, which is not a state with a field - * missing but a device asserting a relationship the parent never agreed to. - * The third has to stop [stateFrom] rather than be flattened into the first. + * subgroups and every top-level group made after; it may claim some and + * prove them all; or it may claim some it cannot prove, which is not a state + * with a field missing but a device asserting a relationship a parent never + * agreed to. The third has to stop [stateFrom] rather than be flattened into + * the first. * * So `null` here means refused, and a present [value] of `null` means none * claimed. */ - private class Parentage(val value: SubgroupParentage?) + private class Lineage(val value: SubgroupLineage?) /** * The parentage an event actually establishes, or null if it claims one it @@ -387,12 +384,12 @@ object GroupKeyStateManager { * not a state with one field wrong, and half-believing it would file a group * as top-level that its own members will see as a subgroup. */ - private fun parentageOf( + private fun lineageOf( announced: String, thresholdPublicKey: HexKey, innerEvent: Event - ): Parentage? { - if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Parentage(null) + ): Lineage? { + if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Lineage(null) // Both or neither, per parent, and no parent twice -- all of which // `parseLineage` decides. A parent named with no certificate is a claim @@ -410,55 +407,57 @@ object GroupKeyStateManager { return null } - // One parent is all a row can hold until `GroupKeyState` carries a set. - // Refused rather than truncated: taking the first of two would file a - // lineage the group did not sign and leave the device believing a group - // has one parent when its own members see two. Nothing in this build - // emits a second, so this fires only on a state from a newer one. - if (lineage.size > 1) { - logger.w( - "Key state for $announced claims ${lineage.size} parents " + - "(${lineage.parentChatRoomIds}), which this build cannot file; dropping" - ) - return null + // Every parent, or none of them. One bad certificate drops the whole + // state rather than the one pair, for the reason the all-or-nothing rule + // was written: a state that claims a lineage it cannot back is a device + // asserting a relationship somebody never agreed to, and keeping the + // provable half would file a group as having one parent when its own + // members see two. A set that is partly believed is not a weaker set. + lineage.parents.forEach { parentage -> + val parentChatRoomId = parentage.parentChatRoomId + val certificate = parentage.certificate + + // The parent's own quorum, over this very room's id. Everything that + // makes a lineage worth anything is in here -- see + // `SubgroupBirthCertificateEvent.certifies`, which needs no lookup, + // no key it has to be told, and no knowledge of the other parents. + // Each link is checked on its own, which is why a lineage can be + // assembled a parent at a time. + if (!SubgroupBirthCertificateEvent.certifies(certificate, announced, parentChatRoomId)) { + logger.w( + "Key state for $announced carries no certificate by $parentChatRoomId for " + + "that room; dropping" + ) + return null + } + + // Belt and braces: the id already derives from this key, and the + // certificate's id already derives from the key it names, so two + // certificates for one room cannot name two keys. Stated anyway + // because the two facts live in different places and a future change + // to either should have to notice this. + val certifiedKey = + SubgroupBirthCertificateEvent.parseThresholdPublicKey(certificate.tags) + if (!thresholdPublicKey.equals(certifiedKey, ignoreCase = true)) { + logger.w( + "Key state for $announced names $thresholdPublicKey and the certificate " + + "by $parentChatRoomId names $certifiedKey; dropping" + ) + return null + } } - val parentage = lineage.parents.single() - val parentChatRoomId = parentage.parentChatRoomId - val certificate = parentage.certificate - - // The parent's own quorum, over this very room's id. Everything that - // makes a lineage worth anything is in here -- see - // `SubgroupBirthCertificateEvent.certifies`, which needs no lookup and no - // key it has to be told. - if (!SubgroupBirthCertificateEvent.certifies(certificate, announced, parentChatRoomId)) { - logger.w( - "Key state for $announced carries no certificate by $parentChatRoomId for " + - "that room; dropping" - ) - return null - } - - // Belt and braces: the id already derives from this key, and the - // certificate's id already derives from the key it names, so two - // certificates for one room cannot name two keys. Stated anyway because - // the two facts live in different places and a future change to either - // should have to notice this. - val certifiedKey = SubgroupBirthCertificateEvent.parseThresholdPublicKey(certificate.tags) - if (!thresholdPublicKey.equals(certifiedKey, ignoreCase = true)) { - logger.w( - "Key state for $announced names $thresholdPublicKey and its certificate names " + - "$certifiedKey; dropping" - ) - return null - } - - return Parentage( - SubgroupParentage(parentChatRoomId = parentChatRoomId, certificate = certificate) - ) + // Deliberately not checked: whether the parents know about each other. + // An earlier certificate cannot name a later co-parent -- the first + // parent signed before the second was a possibility -- so requiring them + // to agree on the set would make every adoption cost a fresh quorum in + // every group that had already certified this room. The child's key state + // is the sole authority on the set; each parent has a veto on being added + // and none on who else is in it. See docs/subgroups-multiple-parents.md. + return Lineage(lineage) } - fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? { + fun stateFrom(chatRoomId: String, innerEvent: Event): LocalGroupKeyState? { val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags) ?: chatRoomId val thresholdPublicKey = GroupKeyStateEvent.parseThresholdPublicKey(innerEvent.content) @@ -491,7 +490,7 @@ object GroupKeyStateManager { return null } - val parent = parentageOf(announced, thresholdPublicKey, innerEvent) ?: return null + val lineage = lineageOf(announced, thresholdPublicKey, innerEvent) ?: return null val state = GroupKeyState( chatRoomId = announced, @@ -499,9 +498,7 @@ object GroupKeyStateManager { thresholdPublicKey = thresholdPublicKey, derivationPath = SharedKeyDerivation.formatPath(path), announcedBy = innerEvent.pubKey, - announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt), - parentChatRoomId = parent.value?.parentChatRoomId, - birthCertificateJson = parent.value?.certificate?.toJson() + announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt) ) // Half the trust model, in one line, and the half that does not care who @@ -514,6 +511,15 @@ object GroupKeyStateManager { return null } - return state + return LocalGroupKeyState( + keyState = state, + parents = lineage.value?.parents?.map { + GroupKeyStateParent( + chatRoomId = announced, + parentChatRoomId = it.parentChatRoomId, + birthCertificateJson = it.certificate.toJson() + ) + }.orEmpty() + ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt index c25a8c11..841bbff4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt @@ -93,10 +93,6 @@ object MarmotGroupCreation { * appended to it, because MIP-01 has no field for one and the path is what * rebuilds the `TweakCache` a signing session needs. * - * [parentChatRoomId] is written onto the room when this is a subgroup. It is - * the verified value off the child's own key state -- never a claim off a - * proposal. - * * [name] is put under [MarmotGroupName] here rather than trusted from the * caller. Every Marmot room on a device is a `#name`, and this is the funnel * both flows that make one already go through, so it is the one place the @@ -112,7 +108,6 @@ object MarmotGroupCreation { userPublicKey: HexKey, keyPair: KeyPair, path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, - parentChatRoomId: HexKey? = null, keyPackageLookupTimeout: Long = KEY_PACKAGE_LOOKUP_TIMEOUT ): Outcome { chatRepository.getChatRoomByIdentifier(groupId)?.let { return Outcome.Existing(it) } @@ -163,15 +158,6 @@ object MarmotGroupCreation { mlsGroupState = group.saveState().encodeTls().toHex() ) ?: return Outcome.Failed("Couldn't create the group. Please try again.") - if (parentChatRoomId != null) { - // Written after the room exists rather than passed into its creation, - // because `getOrCreateChatRoom` is shared with every other way a room - // appears and none of them has a parent to hand it. - database.chatRoomDao().upsert( - localChatRoom.chatRoom.copy(parentChatRoomId = parentChatRoomId) - ) - } - // The room comes into existence already knowing what it signs with. The // group agreed that before any of this ran and this device has been // holding the signed statement since; this is simply the first moment diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 1e3b0083..42e57358 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -410,18 +410,26 @@ object SubgroupManager { .sortedByDescending { it.certificate.createdAt } /** - * The group a room is a subgroup of, verified, or null if it is nobody's - * child. + * Every group that has certified a room as its child, verified, in canonical + * order -- empty for a room that is nobody's child. * - * Off the `GroupKeyState`, which only carries a parent whose certificate was - * checked -- see `GroupKeyStateManager.stateFrom`. A room whose state this - * device does not hold answers null even when it really is a subgroup, which - * is the position a member welcomed in after the founding is in; the - * unverified hint in the room's own description is the most they get, and it - * must never be written here. + * Off `GroupKeyStateParent`, whose rows exist only where the certificate was + * checked against that parent's own signature -- see + * `GroupKeyStateManager.stateFrom`, which drops the whole state rather than + * keeping a lineage it could not check. So every id here is a group that + * really did certify this room. + * + * More than one is the ordinary case now, not a corner: a group is founded + * with one parent and may be adopted by another later. The order is the + * lineage's own and says nothing about which came first. + * + * A room whose state this device does not hold answers empty even when it + * really is a subgroup, which is the position a member welcomed in after the + * founding is in. Nothing unverified may be written here. */ - suspend fun parentOf(database: MantraDatabase, chatRoomId: String): HexKey? = - database.groupKeyStateDao().getByChatRoomId(chatRoomId)?.parentChatRoomId + suspend fun parentsOf(database: MantraDatabase, chatRoomId: String): List = + database.groupKeyStateDao().getLocalByChatRoomId(chatRoomId)?.parentChatRoomIds() + .orEmpty() /** * Files a certificate that arrived, or drops it and says why. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 42794c40..9453a9e7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -70,14 +70,16 @@ interface ChatRepository { suspend fun subgroupsOf(chatRoomId: String): List /** - * The group this room is a subgroup of, verified, or null if it is nobody's - * child. + * Every group that has certified this room as its child, verified, in + * canonical order -- empty if it is nobody's child. * - * Off the room's `GroupKeyState`, which only carries a parent whose - * certificate was checked. A member welcomed in after the founding holds no - * state and gets null here even when the room really is a subgroup. + * Off `GroupKeyStateParent`, whose rows exist only where the certificate was + * checked. More than one is ordinary: a group is founded with one parent and + * may be adopted by another later. A member welcomed in after the founding + * holds no state and gets an empty list even when the room really is a + * subgroup. */ - suspend fun parentOf(chatRoomId: String): HexKey? + suspend fun parentsOf(chatRoomId: String): List /** * One event the group signed, as the group signed it, or null when this @@ -203,7 +205,6 @@ interface ChatRepository { userPublicKey: HexKey, keyPair: KeyPair, path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, - parentChatRoomId: HexKey? = null, ): MarmotGroupCreation.Outcome /** @@ -279,7 +280,7 @@ interface ChatRepository { override suspend fun subgroupsOf(chatRoomId: String): List = emptyList() - override suspend fun parentOf(chatRoomId: String): HexKey? = null + override suspend fun parentsOf(chatRoomId: String): List = emptyList() override suspend fun groupSignedEvent( chatRoomId: String, @@ -386,7 +387,6 @@ interface ChatRepository { userPublicKey: HexKey, keyPair: KeyPair, path: List, - parentChatRoomId: HexKey?, ): MarmotGroupCreation.Outcome = MarmotGroupCreation.Outcome.Failed("No database") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt index cb4da4eb..1e6c16d6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -3,7 +3,7 @@ package press.mantra.compose.repository import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSigningSession -import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession @@ -149,10 +149,10 @@ interface DkgRepository { * a finished subgroup with its key state signed, its certificate signed, and * no way to create the room -- the lookup found no ceremony at all. */ - fun observeSignedGroupKeyState(dkgSessionId: String): Flow + fun observeSignedGroupKeyState(dkgSessionId: String): Flow /** Files the state the group signed for a room that now exists. */ - suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? + suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? /** * Asks the parent's quorum to certify the subgroup this ceremony produced. @@ -242,10 +242,10 @@ interface DkgRepository { chatRoomId: String ): Flow> = flowOf(emptyList()) - override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = + override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = flowOf(null) - override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = null + override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = null override suspend fun proposeBirthCertificate( parentChatRoomId: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 0689fb34..9c9efbae 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -259,8 +259,14 @@ fun ChatRoomDetailScreen( // nobody's child, and absent -- rather than guessed -- // on a subgroup this device holds no key state for, // since an unverified lineage is not one worth showing. - chatRoomDetailUIState.parentChatRoomId?.let { parentChatRoomId -> - item { + // + // One card per parent, because a room can have more + // than one: founded with one and adopted by another + // later. They are a set rather than a path -- neither + // is "the" parent and the order says nothing about + // which came first -- so each gets an equal row. + chatRoomDetailUIState.parentChatRoomIds.forEach { parentChatRoomId -> + item(key = "parent-$parentChatRoomId") { Card( modifier = Modifier.fillMaxWidth(), onClick = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 3bfb3db2..4500b9ac 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -81,7 +81,7 @@ class ChatRoomDetailViewModel( groupKeyState = chatRepository.groupKeyState(chatRoomId), signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), subgroups = chatRepository.subgroupsOf(chatRoomId), - parentChatRoomId = chatRepository.parentOf(chatRoomId), + parentChatRoomIds = chatRepository.parentsOf(chatRoomId), canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && chatRepository.canSign(chatRoomId), ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index d369fd14..76d61b2b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -607,11 +607,7 @@ class DkgRitualViewModel( }, adminPublicKeys = members, userPublicKey = activeUserPublicKey, - keyPair = keyPair, - // Verified by the time it is written: the key state this room was - // gated on carries the certificate, and `stateFrom` refused it - // otherwise. - parentChatRoomId = subgroupParent + keyPair = keyPair ) isActionPending.value = false diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 80f08814..7082d6d8 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -49,7 +49,7 @@ sealed interface ChatRoomDetailUIState { * founding holds no state and so sees nothing here, even in a room that * really is a subgroup. */ - val parentChatRoomId: HexKey? = null, + val parentChatRoomIds: List = emptyList(), /** * Whether this device could open a subgroup here at all. * 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 fb4b7a5b..48892ee3 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -75,6 +75,15 @@ class GroupKeyStateTest { threshold = threshold ) + /** A third group, for the lineages that name two parents. */ + private val secondParentKeyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("3c0c0a0000000000000000000000000000000000000000000000000000000000c3") + ), + nParticipants = participants, + threshold = threshold + ) + private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex() /** The other group's key, for the states that name the wrong one. */ @@ -307,15 +316,15 @@ class GroupKeyStateTest { fun `a state the group signed about the room it arrives in is taken`() { val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState()) - assertEquals(chatRoomId, state?.chatRoomId) - assertEquals("ceremony-1", state?.dkgSessionId) - assertEquals(thresholdPublicKey, state?.thresholdPublicKey) - assertEquals("m/9420/0/0", state?.derivationPath) + assertEquals(chatRoomId, state?.keyState?.chatRoomId) + assertEquals("ceremony-1", state?.keyState?.dkgSessionId) + assertEquals(thresholdPublicKey, state?.keyState?.thresholdPublicKey) + assertEquals("m/9420/0/0", state?.keyState?.derivationPath) // Attribution and ordering come off the event, not off the clock. The // author is the room itself now, which is the whole point: nobody in // particular said this. - assertEquals(chatRoomId, state?.announcedBy) - assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt) + assertEquals(chatRoomId, state?.keyState?.announcedBy) + assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.keyState?.announcedAt) } @Test @@ -412,8 +421,8 @@ class GroupKeyStateTest { ) ) - assertNotEquals(chatRoomId, state?.chatRoomId) - assertEquals(siblingRoom, state?.chatRoomId) + assertNotEquals(chatRoomId, state?.keyState?.chatRoomId) + assertEquals(siblingRoom, state?.keyState?.chatRoomId) } @Test @@ -475,7 +484,8 @@ class GroupKeyStateTest { assertEquals( chatRoomId, - GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected))?.chatRoomId + GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected)) + ?.keyState?.chatRoomId ) } @@ -760,6 +770,23 @@ class GroupKeyStateTest { /** The parent group's room, which is `otherKeyMaterial` wearing a second hat. */ private val parentChatRoomId get() = identityOf(otherKeyMaterial).hex + /** A second, unrelated parent's room -- the group that adopts the child later. */ + private val secondParentChatRoomId get() = identityOf(secondParentKeyMaterial).hex + + /** A lineage of both parents, each with its own group's real signature. */ + private fun bothParents() = SubgroupLineage.of( + listOf( + SubgroupParentage(parentChatRoomId, birthCertificate()), + SubgroupParentage( + secondParentChatRoomId, + birthCertificate( + parent = secondParentChatRoomId, + author = secondParentKeyMaterial + ) + ) + ) + ) + /** A certificate by [author]'s room saying [subgroupChatRoomId] is its child. */ private fun birthCertificate( subgroupChatRoomId: String = chatRoomId, @@ -799,18 +826,26 @@ class GroupKeyStateTest { fun `a state whose parent certified it keeps the parent and the certificate`() { val state = GroupKeyStateManager.stateFrom(chatRoomId, keyStateWithParent()) - assertEquals(parentChatRoomId, state?.parentChatRoomId) - assertEquals(birthCertificate().toJson(), state?.birthCertificateJson) + assertEquals(listOf(parentChatRoomId), state?.parentChatRoomIds()) + assertEquals( + birthCertificate().toJson(), + state?.parents?.single()?.birthCertificateJson + ) + // And the row points back at the child, which is what makes it a row + // rather than a column. + assertEquals(chatRoomId, state?.parents?.single()?.chatRoomId) } @Test - fun `a state claiming no parent keeps both fields null`() { + fun `a state claiming no parent carries no parent rows at all`() { // Every group made before subgroups existed, and every top-level group - // made after. Null here is the ordinary case, not missing data. + // made after. No rows is the ordinary case, not missing data -- and it is + // one way of saying "nobody's child" rather than two, which is why a + // lineage of none is null rather than empty. val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState()) - assertNull(state?.parentChatRoomId) - assertNull(state?.birthCertificateJson) + assertEquals(emptyList(), state?.parents) + assertNull(state?.lineage()) } @Test @@ -924,17 +959,11 @@ class GroupKeyStateTest { // ids and then all the certificates, such a reader would pair one // parent's id with another's certificate, fail `certifies`, and drop the // whole state -- losing the row that says which share signs for the room. - val second = birthCertificate(parent = chatRoomId, author = keyMaterial) val tags = GroupKeyStateEvent.assembleTags( chatRoomId = chatRoomId, dkgSessionId = "ceremony-1", path = path, - lineage = SubgroupLineage.of( - listOf( - SubgroupParentage(parentChatRoomId, birthCertificate()), - SubgroupParentage(chatRoomId, second) - ) - ) + lineage = bothParents() ) val names = tags.map { it[0] }.filter { @@ -960,11 +989,43 @@ class GroupKeyStateTest { } @Test - fun `a state claiming two parents is dropped rather than truncated`() { - // Until a row can hold a set. Taking the first of two would file a - // lineage the group did not sign, and leave this device believing a group - // has one parent while its own members see two. - val second = birthCertificate(parent = chatRoomId, author = keyMaterial) + fun `a state claiming two parents keeps both, each with its own certificate`() { + // Two groups, two quorums, two signatures over the same child's id. The + // second one signed later and could not have named the first as a + // co-parent, which is why nothing requires the certificates to agree on + // the set -- the child's key state is the sole authority on it. + val state = signedKeyState( + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = bothParents() + ) + ) + + val local = GroupKeyStateManager.stateFrom(chatRoomId, state) + + assertEquals( + listOf(parentChatRoomId, secondParentChatRoomId).sorted(), + local?.parentChatRoomIds()?.sorted() + ) + assertEquals(2, local?.parents?.size) + // Each row carries the certificate of the parent it names, not whichever + // one came first off the tags. + local?.parents?.forEach { parent -> + assertEquals( + parent.parentChatRoomId, + Event.fromJson(parent.birthCertificateJson).pubKey, + "each parentage row keeps its own parent's certificate" + ) + } + } + + @Test + fun `one bad certificate in a set of two drops the whole state`() { + // All or nothing, generalised. Keeping the provable half would file a + // group as having one parent when its own members see two, and a set + // that is partly believed is not a weaker set. val state = signedKeyState( tags = GroupKeyStateEvent.assembleTags( chatRoomId = chatRoomId, @@ -973,7 +1034,16 @@ class GroupKeyStateTest { lineage = SubgroupLineage.of( listOf( SubgroupParentage(parentChatRoomId, birthCertificate()), - SubgroupParentage(chatRoomId, second) + // Really signed by the second parent, but for a different + // child -- so it certifies something, just not this. + SubgroupParentage( + secondParentChatRoomId, + birthCertificate( + subgroupChatRoomId = otherKey, + parent = secondParentChatRoomId, + author = secondParentKeyMaterial + ) + ) ) ) ) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt index cb040477..1e1a9e9f 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt @@ -16,31 +16,38 @@ import press.mantra.compose.database.builder.getRoomDatabase import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.types.DkgRitualStage /** - * What schema 17 has to survive, and the one thing it must refuse to do. + * What schema 21 has to survive, and the two things it must refuse to do. * - * Four nullable columns say a group is somebody's child. Three properties of - * them are invisible to the compiler and each one is a way subgroups could break - * quietly. + * A room's parentage is a table now rather than two nullable columns, because a + * room can be the child of more than one group -- founded with one and adopted by + * another later. Four properties of that are invisible to the compiler and each + * one is a way a lineage could break quietly. * * A parent pointer **may name a room this device does not have**. A member of a * subgroup who was never in its parent holds the id and nothing else, so a - * dangling value is the normal state rather than an error -- and if any of these - * columns were ever given a foreign key, that member's rows would stop being + * dangling value is the normal state rather than an error -- and if the parent + * side were ever given a foreign key, that member's rows would stop being * writable at all. * * Deleting a parent **must not touch its children**. Every other reference to a - * room in this schema cascades. A self-referential cascade here would mean a user + * room in this schema cascades. A self-referential cascade would mean a user * tidying away a group they had left silently destroying a group they are still * in, together with its messages, participants, key state and signed work. * - * And the two `GroupKeyState` columns **travel together**. One without the other - * is a state whose parentage cannot be rechecked from the row, which is the whole - * reason the certificate is stored whole rather than as a signature. + * A state's lineage **is replaced wholesale, never merged**. A key state is + * replaceable and the newest wins, so the set of parents it names is the set. + * That is what makes a disavowal expressible at all: a room drops a parent by + * re-announcing without it, and merged rows would leave the dropped parent + * surviving the statement that dropped it. + * + * And a lineage **does not outlive the state that claimed it**, which is the one + * cascade here that is wanted. */ class SubgroupDaoJvmTest { @@ -59,83 +66,72 @@ class SubgroupDaoJvmTest { /** A parent this device has never held a room for. */ private val absentParent = "33".repeat(32) - private val certificate = - """{"id":"aa","pubkey":"bb","created_at":1700000000,"kind":30329,"tags":[],"content":"cc","sig":"dd"}""" + private fun certificate(by: String) = + """{"id":"aa","pubkey":"$by","created_at":1700000000,"kind":30329,""" + + """"tags":[],"content":"cc","sig":"dd"}""" @Test - fun `a key state keeps its parent and the certificate behind it`() = runBlocking { + fun `a key state keeps every parent and the certificate behind each`() = runBlocking { seedRooms() - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = childRoom, - parentChatRoomId = parentRoom, - birthCertificateJson = certificate - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom), parentage(absentParent)) ) - val stored = db.groupKeyStateDao().getByChatRoomId(childRoom) + val stored = db.groupKeyStateDao().getLocalByChatRoomId(childRoom) - assertEquals(parentRoom, stored?.parentChatRoomId) - assertEquals(certificate, stored?.birthCertificateJson) + assertEquals( + listOf(parentRoom, absentParent).sorted(), + stored?.parents?.map { it.parentChatRoomId }?.sorted() + ) + assertEquals( + certificate(parentRoom), + stored?.parents?.single { it.parentChatRoomId == parentRoom }?.birthCertificateJson + ) } @Test - fun `a group that is nobody's child keeps both columns null`() = runBlocking { + fun `a group that is nobody's child carries no parent rows`() = runBlocking { seedRooms() - db.groupKeyStateDao().upsert(keyState(chatRoomId = parentRoom)) + db.groupKeyStateDao().replace(keyState(chatRoomId = parentRoom)) - val stored = db.groupKeyStateDao().getByChatRoomId(parentRoom) + val stored = db.groupKeyStateDao().getLocalByChatRoomId(parentRoom) // Every group made before subgroups existed reads back this way, and so - // does every top-level group made after. Null here is not missing data. - assertNull(stored?.parentChatRoomId) - assertNull(stored?.birthCertificateJson) + // does every top-level group made after. No rows is not missing data, and + // it is one way of saying "nobody's child" rather than two. + assertEquals(emptyList(), stored?.parents) + assertNull(stored?.lineage()) } @Test - fun `a key state may name a parent this device holds no room for`() = runBlocking { + fun `a parentage may name a parent this device holds no room for`() = runBlocking { seedRooms() // The position a subgroup member who was never in the parent is in: they - // hold the id off a certificate and nothing else. If this column were a - // foreign key their state could not be written at all. - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = childRoom, - parentChatRoomId = absentParent, - birthCertificateJson = certificate - ) + // hold the id off a certificate and nothing else. Were the parent side a + // foreign key their lineage could not be written at all. + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(absentParent)) ) - assertEquals(absentParent, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId) - assertNull(db.chatRoomDao().findChatRoomById(absentParent)) - } - - @Test - fun `a room may name a parent this device holds no room for`() = runBlocking { - seedRooms() - - db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = absentParent)) - assertEquals( - absentParent, - db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId + listOf(absentParent), + db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId } ) + assertNull(db.chatRoomDao().findChatRoomById(absentParent)) } @Test fun `deleting a parent room leaves its subgroup standing`() = runBlocking { seedRooms() - db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom)) - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = childRoom, - parentChatRoomId = parentRoom, - birthCertificateJson = certificate - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom)) ) db.chatRoomDao().delete(room(parentRoom)) @@ -146,10 +142,27 @@ class SubgroupDaoJvmTest { // them owns. assertNull(db.chatRoomDao().findChatRoomById(parentRoom)) assertEquals( - parentRoom, - db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId + listOf(parentRoom), + db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId } ) - assertEquals(parentRoom, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId) + } + + @Test + fun `deleting the child takes its lineage with it`() = runBlocking { + seedRooms() + + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom)) + ) + + // The one cascade that is wanted: a lineage must not outlive the state + // that claimed it, or a room recreated at the same derived id would + // inherit a parentage nobody signed for it. + db.chatRoomDao().delete(room(childRoom)) + + assertNull(db.groupKeyStateDao().getByChatRoomId(childRoom)) + assertEquals(emptyList(), db.groupKeyStateDao().getParentsByChatRoomId(childRoom)) } @Test @@ -159,73 +172,130 @@ class SubgroupDaoJvmTest { val second = "44".repeat(32) db.chatRoomDao().upsert(room(second)) - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = childRoom, - parentChatRoomId = parentRoom, - birthCertificateJson = certificate, - announcedAt = Instant.fromEpochSeconds(1_000) - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)), + listOf(parentage(parentRoom)) ) - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = second, - parentChatRoomId = parentRoom, - birthCertificateJson = certificate, - announcedAt = Instant.fromEpochSeconds(2_000) - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = second, announcedAt = Instant.fromEpochSeconds(2_000)), + listOf(parentage(parentRoom, child = second)) ) // Another group's child, to prove the filter is on the parent rather // than on having a parent at all. - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = parentRoom, - parentChatRoomId = absentParent, - birthCertificateJson = certificate - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = parentRoom), + listOf(parentage(absentParent, child = parentRoom)) ) assertEquals( listOf(second, childRoom), - db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.chatRoomId } + db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.keyState.chatRoomId } ) assertEquals( listOf(second, childRoom), - db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first().map { it.chatRoomId } + db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first() + .map { it.keyState.chatRoomId } ) } @Test - fun `a parent lists only the subgroup rooms this device actually holds`() = runBlocking { + fun `a child with two parents appears in both their lists`() = runBlocking { seedRooms() - db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom)) + db.chatRoomDao().upsert(room(absentParent)) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom), parentage(absentParent)) + ) - val rooms = db.chatRoomDao().observeByParentChatRoomId(parentRoom).first() - - assertEquals(listOf(childRoom), rooms.map { it.chatRoom.id }) - - // A child that was certified but whose room was never created here shows - // up nowhere in this query. That is the difference between it and - // SubgroupManager.subgroupsOf, which reads the parent's certificates. - assertTrue(db.chatRoomDao().observeByParentChatRoomId(absentParent).first().isEmpty()) - } - - @Test - fun `a soft-deleted subgroup drops out of its parent's list`() = runBlocking { - seedRooms() - - db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom)) - db.chatRoomDao().upsert( - room(childRoom).copy( - parentChatRoomId = parentRoom, - deletedAt = Instant.fromEpochSeconds(9_000) + // The row that makes it this parent's child says nothing about who else + // certified it, which is the whole point of the join. + listOf(parentRoom, absentParent).forEach { parent -> + assertEquals( + listOf(childRoom), + db.groupKeyStateDao().getByParentChatRoomId(parent).map { it.keyState.chatRoomId }, + "$parent should list the child it certified" ) + } + } + + @Test + fun `a newer state replaces the lineage rather than adding to it`() = runBlocking { + seedRooms() + + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)), + listOf(parentage(parentRoom), parentage(absentParent)) ) - // A room the user has cleared away must not come back because its parent - // still lists it. - assertTrue(db.chatRoomDao().observeByParentChatRoomId(parentRoom).first().isEmpty()) + // A disavowal: the child re-announces without one of them. Were these + // rows merged, the dropped parent would survive the statement dropping + // it -- and a parent nobody can drop is a parent for life. + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)), + listOf(parentage(parentRoom)) + ) + + assertEquals( + listOf(parentRoom), + db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId } + ) + assertTrue(db.groupKeyStateDao().getByParentChatRoomId(absentParent).isEmpty()) + } + + @Test + fun `a re-announcement to nobody's child clears the lineage`() = runBlocking { + seedRooms() + + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)), + listOf(parentage(parentRoom)) + ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)) + ) + + assertEquals(emptyList(), db.groupKeyStateDao().getParentsByChatRoomId(childRoom)) + } + + @Test + fun `an older state changes neither the state nor the lineage on file`() = runBlocking { + seedRooms() + + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)), + listOf(parentage(parentRoom)) + ) + + // A session replayed after a crash must not walk the room back to a + // state it has already moved past -- and must not walk its lineage back + // either, which is new: the parents are part of the statement now. + val kept = db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)), + listOf(parentage(absentParent)) + ) + + assertEquals(Instant.fromEpochSeconds(2_000), kept.keyState.announcedAt) + assertEquals(listOf(parentRoom), kept.parents.map { it.parentChatRoomId }) + assertEquals( + listOf(parentRoom), + db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId } + ) + } + + @Test + fun `one parent gets one row, whatever a caller tries`() = runBlocking { + seedRooms() + + // The primary key is the rule "one certificate per child per parent". + // `SubgroupLineage.of` refuses a repeat long before here; this is the + // store saying so too, so the rule does not rest on a reader remembering. + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom), parentage(parentRoom)) + ) + + assertEquals(1, db.groupKeyStateDao().getParentsByChatRoomId(childRoom).size) } @Test @@ -271,10 +341,15 @@ class SubgroupDaoJvmTest { mlsGroupState = null, ) + private fun parentage(parentChatRoomId: String, child: String = childRoom) = + GroupKeyStateParent( + chatRoomId = child, + parentChatRoomId = parentChatRoomId, + birthCertificateJson = certificate(parentChatRoomId), + ) + private fun keyState( chatRoomId: String, - parentChatRoomId: String? = null, - birthCertificateJson: String? = null, announcedAt: Instant = Instant.fromEpochSeconds(1_700_000_000) ) = GroupKeyState( chatRoomId = chatRoomId, @@ -283,8 +358,6 @@ class SubgroupDaoJvmTest { derivationPath = "m/9420/0/0", announcedBy = user, announcedAt = announcedAt, - parentChatRoomId = parentChatRoomId, - birthCertificateJson = birthCertificateJson, ) private fun session( 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 bb0eccc8..90bd88d6 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -194,7 +194,7 @@ class SignedGroupKeyStateTest { /** The one event a session of one signs. */ suspend fun item(sessionId: String) = items(sessionId).single() - suspend fun keyState() = db.groupKeyStateDao().getByChatRoomId(roomId) + suspend fun keyState() = db.groupKeyStateDao().getLocalByChatRoomId(roomId) /** Everything the group has signed here, as the group signed it. */ suspend fun signedEvents() = db.groupSignedEventDao().getByChatRoomId(roomId) @@ -515,13 +515,13 @@ class SignedGroupKeyStateTest { listOf(creator, other).forEach { device -> val state = device.keyState() assertNotNull(state, "every signer should hold the state the group signed") - assertEquals(thresholdPublicKey, state.thresholdPublicKey) - assertEquals(ceremonyId, state.dkgSessionId) - assertEquals(SharedKeyDerivation.formatPath(), state.derivationPath) + assertEquals(thresholdPublicKey, state.keyState.thresholdPublicKey) + assertEquals(ceremonyId, state.keyState.dkgSessionId) + assertEquals(SharedKeyDerivation.formatPath(), state.keyState.derivationPath) // Signed by the room, so attributed to the room -- nobody in // particular said this. - assertEquals(adminRoomId, state.announcedBy) - assertTrue(state.verifies()) + assertEquals(adminRoomId, state.keyState.announcedBy) + assertTrue(state.keyState.verifies()) } } @@ -589,7 +589,7 @@ class SignedGroupKeyStateTest { absent.keyState(), "a member the quorum did not need still learns what the room signs with" ) - assertEquals(adminRoomId, absent.keyState()?.announcedBy) + assertEquals(adminRoomId, absent.keyState()?.keyState?.announcedBy) } // ---- The key state, signed before the room exists ---------------------- @@ -722,22 +722,22 @@ class SignedGroupKeyStateTest { val adopted = GroupKeyStateManager.adopt(device.db, adminRoomId) assertNotNull(adopted, "a room should adopt the state its group signed for it") - assertEquals(adminRoomId, adopted.chatRoomId) - assertEquals(thresholdPublicKey, adopted.thresholdPublicKey) - assertEquals(ceremonyId, adopted.dkgSessionId) - assertEquals(SharedKeyDerivation.formatPath(), adopted.derivationPath) - assertEquals(adminRoomId, adopted.announcedBy) - assertTrue(adopted.verifies()) + assertEquals(adminRoomId, adopted.keyState.chatRoomId) + assertEquals(thresholdPublicKey, adopted.keyState.thresholdPublicKey) + assertEquals(ceremonyId, adopted.keyState.dkgSessionId) + assertEquals(SharedKeyDerivation.formatPath(), adopted.keyState.derivationPath) + assertEquals(adminRoomId, adopted.keyState.announcedBy) + assertTrue(adopted.keyState.verifies()) // On file, not merely returned. Compared field by field because the // store keeps whole seconds and the row in hand does not. val stored = device.db.groupKeyStateDao().getByChatRoomId(adminRoomId) assertNotNull(stored) - assertEquals(adopted.chatRoomId, stored.chatRoomId) - assertEquals(adopted.thresholdPublicKey, stored.thresholdPublicKey) - assertEquals(adopted.dkgSessionId, stored.dkgSessionId) - assertEquals(adopted.derivationPath, stored.derivationPath) - assertEquals(adopted.announcedAt, stored.announcedAt) + assertEquals(adopted.keyState.chatRoomId, stored.chatRoomId) + assertEquals(adopted.keyState.thresholdPublicKey, stored.thresholdPublicKey) + assertEquals(adopted.keyState.dkgSessionId, stored.dkgSessionId) + assertEquals(adopted.keyState.derivationPath, stored.derivationPath) + assertEquals(adopted.keyState.announcedAt, stored.announcedAt) } } @@ -763,14 +763,14 @@ class SignedGroupKeyStateTest { assertNotNull(first) assertNotNull(second) - assertEquals(first.thresholdPublicKey, second.thresholdPublicKey) - assertEquals(first.dkgSessionId, second.dkgSessionId) - assertEquals(first.derivationPath, second.derivationPath) - assertEquals(first.announcedAt, second.announcedAt) + assertEquals(first.keyState.thresholdPublicKey, second.keyState.thresholdPublicKey) + assertEquals(first.keyState.dkgSessionId, second.keyState.dkgSessionId) + assertEquals(first.keyState.derivationPath, second.keyState.derivationPath) + assertEquals(first.keyState.announcedAt, second.keyState.announcedAt) // The second adopt is a no-op rather than a rewrite: `replace` keeps the // state already on file when nothing newer has been signed. To whole // seconds, which is all the store keeps. - assertEquals(first.createdAt.epochSeconds, second.createdAt.epochSeconds) + assertEquals(first.keyState.createdAt.epochSeconds, second.keyState.createdAt.epochSeconds) } @Test @@ -1380,9 +1380,9 @@ class SignedGroupKeyStateTest { val state = creator.keyState() assertNotNull(state, "a room off the default path must still reach a signed state") - assertEquals(SharedKeyDerivation.formatPath(sibling), state.derivationPath) - assertEquals(siblingRoomId, state.announcedBy) - assertTrue(state.verifies()) + assertEquals(SharedKeyDerivation.formatPath(sibling), state.keyState.derivationPath) + assertEquals(siblingRoomId, state.keyState.announcedBy) + assertTrue(state.keyState.verifies()) } @Test @@ -1572,10 +1572,11 @@ class SignedGroupKeyStateTest { // The whole join. Neither device was sent a row; both derived the // event from their own items, both re-ran `certifies` against the - // parent's id, and both wrote the same two columns. - assertEquals(parentRoomId, state.parentChatRoomId) - assertEquals(certificate.toJson(), state.birthCertificateJson) - assertTrue(state.verifies()) + // parent's id, and both wrote the same parentage row. + assertEquals(listOf(parentRoomId), state.parentChatRoomIds()) + assertEquals(certificate.toJson(), state.parents.single().birthCertificateJson) + assertEquals(adminRoomId, state.parents.single().chatRoomId) + assertTrue(state.keyState.verifies()) } } @@ -1626,8 +1627,8 @@ class SignedGroupKeyStateTest { // does every top-level group made after. listOf(creator, other).forEach { device -> val state = assertNotNull(device.keyState()) - assertNull(state.parentChatRoomId) - assertNull(state.birthCertificateJson) + assertEquals(emptyList(), state.parents) + assertNull(state.lineage()) } }