Files
mantra-kmp/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt
Kgothatso Ngako dff41d417d feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.

## What moved, and why it had to

A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.

So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.

itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.

No itemCount column. The count is a COUNT(*), for the same reason signerIds is
derived from the ceremony's participant order rather than stored: a
denormalised count is one more thing that can disagree with the rows.

## Migration 9 -> 10

Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.

Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.

## Invariants established here for the phases that follow

- signerIds and every item's aggregatedNonce are one write-once unit, applied
  by applyAggregate() -- items first in one transaction, then the session, so
  "some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
  reading a flag.
- complete() verifies every signature before applying any event, so a batch is
  all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. Independent seeds mean
  an off-by-one in index handling produces a session that fails to aggregate
  rather than one that signs two messages under a single nonce.

signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.

## Tests

- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
  replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
  database, asserting the seed and aggregate values survive -- not merely that
  a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00

342 lines
16 KiB
Kotlin

package press.mantra.compose.database
import androidx.room3.ColumnTypeConverters
import androidx.room3.AutoMigration
import androidx.room3.Database
import androidx.room3.RoomDatabase
import androidx.room3.immediateTransaction
import androidx.room3.useWriterConnection
import press.mantra.compose.database.converters.MantraConverters
import press.mantra.compose.database.dao.BroadcastNostrEventReceiptDao
import press.mantra.compose.database.dao.BroadcastNostrEventRequestDao
import press.mantra.compose.database.dao.ChatMessageBroadcastNostrEventReceiptRelationDao
import press.mantra.compose.database.dao.ChatMessageBroadcastNostrEventRequestRelationDao
import press.mantra.compose.database.dao.ChatMessageDao
import press.mantra.compose.database.dao.ChatMessageNostrEventRelationDao
import press.mantra.compose.database.dao.ChatRoomDao
import press.mantra.compose.database.dao.ConnectionDao
import press.mantra.compose.database.dao.DkgSessionDao
import press.mantra.compose.database.dao.FrostSigningSessionDao
import press.mantra.compose.database.dao.GiftWrapMessageDao
import press.mantra.compose.database.dao.GiftWrapPayloadDao
import press.mantra.compose.database.dao.GiftWrapSealDao
import press.mantra.compose.database.dao.GroupKeyStateDao
import press.mantra.compose.database.dao.InReplyToRelationDao
import press.mantra.compose.database.dao.MantraArtifactDao
import press.mantra.compose.database.dao.MantraArtifactVersionDao
import press.mantra.compose.database.dao.MantraChapterDao
import press.mantra.compose.database.dao.MantraChunkDao
import press.mantra.compose.database.dao.MantraDao
import press.mantra.compose.database.dao.MantraDialectDao
import press.mantra.compose.database.dao.MantraTranslationArtifactVersionDao
import press.mantra.compose.database.dao.MantraTranslationArtifactVersionContributorDao
import press.mantra.compose.database.dao.MantraTranslationChapterDao
import press.mantra.compose.database.dao.MantraTranslationChapterContributorDao
import press.mantra.compose.database.dao.MantraTranslationChunkDao
import press.mantra.compose.database.dao.MantraTranslationDao
import press.mantra.compose.database.dao.MantraTranslationContributorDao
import press.mantra.compose.database.dao.MarmotCommitResultDao
import press.mantra.compose.database.dao.MarmotOutboundDao
import press.mantra.compose.database.dao.MarmotGroupEventDao
import press.mantra.compose.database.dao.MarmotInnerEventDao
import press.mantra.compose.database.dao.MarmotKeyPackageBundleDao
import press.mantra.compose.database.dao.MarmotKeyPackageDao
import press.mantra.compose.database.dao.MarmotRetainedEpochSecretDao
import press.mantra.compose.database.dao.MentionDao
import press.mantra.compose.database.dao.NegentropySynchronizeRequestDao
import press.mantra.compose.database.dao.NegentropySynchronizeResultDao
import press.mantra.compose.database.dao.NostrDao
import press.mantra.compose.database.dao.NostrEventDao
import press.mantra.compose.database.dao.NostrEventRelayDao
import press.mantra.compose.database.dao.NostrNip17Dao
import press.mantra.compose.database.dao.ParticipantDao
import press.mantra.compose.database.dao.PostDao
import press.mantra.compose.database.dao.ProfileDao
import press.mantra.compose.database.dao.QuotedRelationDao
import press.mantra.compose.database.dao.ReactionDao
import press.mantra.compose.database.dao.RecentSearchDao
import press.mantra.compose.database.dao.RelayDao
import press.mantra.compose.database.dao.RepostedRelationDao
import press.mantra.compose.database.dao.SynchronizeNostrEventRequestDao
import press.mantra.compose.database.dao.SynchronizeNostrEventResultDao
import press.mantra.compose.database.dao.UnsignedNostrEventDao
import press.mantra.compose.database.dao.ZapDao
import press.mantra.compose.database.model.BroadcastNostrEventReceipt
import press.mantra.compose.database.model.BroadcastNostrEventRequest
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatMessageBroadcastNostrEventReceiptRelation
import press.mantra.compose.database.model.ChatMessageBroadcastNostrEventRequestRelation
import press.mantra.compose.database.model.ChatMessageNostrEventRelation
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.Connection
import press.mantra.compose.database.model.GiftWrapMessage
import press.mantra.compose.database.model.GiftWrapPayload
import press.mantra.compose.database.model.GiftWrapSeal
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.InReplyToRelation
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MantraChapter
import press.mantra.compose.database.model.MantraChunk
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.MantraTranslation
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
import press.mantra.compose.database.model.MantraTranslationArtifactVersionContributor
import press.mantra.compose.database.model.MantraTranslationChapter
import press.mantra.compose.database.model.MantraTranslationChapterContributor
import press.mantra.compose.database.model.MantraTranslationChunk
import press.mantra.compose.database.model.MantraTranslationContributor
import press.mantra.compose.database.model.MarmotCommitResult
import press.mantra.compose.database.model.MarmotGroupEvent
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.database.model.MarmotKeyPackage
import press.mantra.compose.database.model.MarmotKeyPackageBundle
import press.mantra.compose.database.model.MarmotRetainedEpochSecret
import press.mantra.compose.database.model.Mention
import press.mantra.compose.database.model.NegentropySynchronizeRequest
import press.mantra.compose.database.model.NegentropySynchronizeResult
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.NostrEventRelay
import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.Post
import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.QuotedRelation
import press.mantra.compose.database.model.Reaction
import press.mantra.compose.database.model.RecentSearch
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.Relay
import press.mantra.compose.database.model.RepostedRelation
import press.mantra.compose.database.model.SynchronizeNostrEventRequest
import press.mantra.compose.database.model.SynchronizeNostrEventResult
import press.mantra.compose.database.model.UnsignedNostrEvent
import press.mantra.compose.database.model.Zap
import kotlin.time.Instant
val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
@Database(
entities = [
BroadcastNostrEventReceipt::class,
BroadcastNostrEventRequest::class,
Connection::class,
ChatMessage::class,
ChatMessageBroadcastNostrEventRequestRelation::class,
ChatMessageBroadcastNostrEventReceiptRelation::class,
ChatMessageNostrEventRelation::class,
ChatRoom::class,
DkgParticipantMessage::class,
DkgSession::class,
FrostSignerMessage::class,
FrostSigningItem::class,
FrostSigningSession::class,
GiftWrapMessage::class,
GiftWrapSeal::class,
GiftWrapPayload::class,
GroupKeyState::class,
InReplyToRelation::class,
MantraArtifact::class,
MantraArtifactVersion::class,
MantraChapter::class,
MantraChunk::class,
MantraDialect::class,
MantraTranslation::class,
MantraTranslationArtifactVersion::class,
MantraTranslationArtifactVersionContributor::class,
MantraTranslationChapter::class,
MantraTranslationChapterContributor::class,
MantraTranslationChunk::class,
MantraTranslationContributor::class,
MarmotCommitResult::class,
MarmotGroupEvent::class,
MarmotInnerEvent::class,
MarmotKeyPackage::class,
MarmotKeyPackageBundle::class,
MarmotRetainedEpochSecret::class,
Mention::class,
NegentropySynchronizeRequest::class,
NegentropySynchronizeResult::class,
NostrEvent::class,
NostrEventRelay::class,
Participant::class,
Post::class,
Profile::class,
QuotedRelation::class,
Reaction::class,
RecentSearch::class,
Relay::class,
RepostedRelation::class,
SynchronizeNostrEventRequest::class,
SynchronizeNostrEventResult::class,
UnsignedNostrEvent::class,
Zap::class
],
version = 10,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
AutoMigration(from = 1, to = 2),
// v3 adds the three nullable approval timestamps to DkgSession. Nullable
// additions need no default and drop no data, so Room generates this one
// too. Rituals already in flight come back with all three null, which reads
// as "not approved yet" and simply asks the member for each step.
AutoMigration(from = 2, to = 3),
// v4 changes no schema at all -- it rewrites `dkgApprovalNeeded` chat rows
// into one type per ritual step. Data, not shape, so it is a manual
// migration passed to the builder rather than an entry here. See
// MIGRATION_3_4.
//
// v5 adds the nullable MarmotInnerEvent.payloadEventId, which names the
// nip30303 event a SubmissionEvent rumor carries. Rumors queued before
// this come back null, which reads as "not a submission" -- correct,
// since none of them were.
AutoMigration(from = 4, to = 5),
// v6 adds the FrostSigningSession/FrostSignerMessage tables and the
// nullable DkgSession.publicShares. New tables and a nullable column are
// both shapes Room can migrate itself. A ceremony that completed before
// this reads back null, and signing falls back to not cross-checking
// shares rather than refusing to run.
AutoMigration(from = 5, to = 6),
// v7 adds the GroupKeyState table, which records what shared key a room
// signs with instead of leaving it to be rederived. A new table is a
// shape Room migrates itself. Rooms created before this have no row and
// fall back to the rederivation scan in FrostSigningManager.completedKey,
// which is why that scan stays.
AutoMigration(from = 6, to = 7),
// v8 adds a nullable direct message recipient to MarmotInnerEvent and
// ChatMessage. Nullable additions need no default and drop no data, so Room
// generates this one. Rows written before it come back null, which reads as
// "not a direct message" -- the only answer that is true of all of them.
AutoMigration(from = 7, to = 8),
// v9 adds the nullable FrostSigningSession.derivationPath, which says
// what key off the ceremony's the session signs as. Sessions started
// before it read back null, meaning the untweaked threshold key -- which
// is what they were signing as, so one caught mid-flight finishes the way
// it began rather than switching keys between two of its own rounds.
AutoMigration(from = 8, to = 9)
// v10 moves FrostSigningSession's five per-event columns onto the new
// FrostSigningItem table, so one session can sign a batch. It copies
// before it drops, which no AutoMigration can express -- see
// MIGRATION_9_10, and the note there on why an in-flight session losing
// its nonce seed would be worse than losing the session.
]
)
@ColumnTypeConverters(MantraConverters::class)
abstract class MantraDatabase: RoomDatabase() {
abstract fun broadcastNostrEventReceiptDao(): BroadcastNostrEventReceiptDao
abstract fun broadcastNostrEventRequestDao(): BroadcastNostrEventRequestDao
abstract fun chatMessageDao(): ChatMessageDao
abstract fun chatMessageBroadcastNostrEventRequestRelationDao(): ChatMessageBroadcastNostrEventRequestRelationDao
abstract fun chatMessageBroadcastNostrEventReceiptRelationDao(): ChatMessageBroadcastNostrEventReceiptRelationDao
abstract fun chatMessageNostrEventRelationDao(): ChatMessageNostrEventRelationDao
abstract fun chatRoomDao(): ChatRoomDao
abstract fun dkgSessionDao(): DkgSessionDao
abstract fun frostSigningSessionDao(): FrostSigningSessionDao
abstract fun groupKeyStateDao(): GroupKeyStateDao
abstract fun connectionDao(): ConnectionDao
abstract fun giftWrapMessageDao(): GiftWrapMessageDao
abstract fun giftWrapSealDao(): GiftWrapSealDao
abstract fun giftWrapPayloadDao(): GiftWrapPayloadDao
abstract fun inReplyToRelationDao(): InReplyToRelationDao
abstract fun mantraDao(): MantraDao
abstract fun mantraArtifactDao(): MantraArtifactDao
abstract fun mantraArtifactVersionDao(): MantraArtifactVersionDao
abstract fun mantraChapterDao(): MantraChapterDao
abstract fun mantraChunkDao(): MantraChunkDao
abstract fun mantraDialectDao(): MantraDialectDao
abstract fun mantraTranslationArtifactVersionDao(): MantraTranslationArtifactVersionDao
abstract fun mantraTranslationArtifactVersionContributorDao(): MantraTranslationArtifactVersionContributorDao
abstract fun mantraTranslationChapterDao(): MantraTranslationChapterDao
abstract fun mantraTranslationChapterContributorDao(): MantraTranslationChapterContributorDao
abstract fun mantraTranslationChunkDao(): MantraTranslationChunkDao
abstract fun mantraTranslationDao(): MantraTranslationDao
abstract fun mantraTranslationContributorDao(): MantraTranslationContributorDao
abstract fun marmotCommitResultDao(): MarmotCommitResultDao
abstract fun marmotGroupEventDao(): MarmotGroupEventDao
abstract fun marmotInnerEventDao(): MarmotInnerEventDao
abstract fun marmotKeyPackageBundleDao(): MarmotKeyPackageBundleDao
abstract fun marmotKeyPackageDao(): MarmotKeyPackageDao
abstract fun marmotOutboundDao(): MarmotOutboundDao
abstract fun marmotRetainedEpochSecretDao(): MarmotRetainedEpochSecretDao
abstract fun mentionDao(): MentionDao
abstract fun negentropySynchronizeRequestDao(): NegentropySynchronizeRequestDao
abstract fun negentropySynchronizeResultDao(): NegentropySynchronizeResultDao
abstract fun nostrDao(): NostrDao
abstract fun nostrEventDao(): NostrEventDao
abstract fun nostrEventRelayDao(): NostrEventRelayDao
abstract fun nostrNip17Dao(): NostrNip17Dao
abstract fun participantDao(): ParticipantDao
abstract fun postDao(): PostDao
abstract fun profileDao(): ProfileDao
abstract fun quotedRelationDao(): QuotedRelationDao
abstract fun reactionDao(): ReactionDao
abstract fun recentSearchDao(): RecentSearchDao
abstract fun relayDao(): RelayDao
abstract fun repostedRelationDao(): RepostedRelationDao
abstract fun synchronizeNostrEventRequestDao(): SynchronizeNostrEventRequestDao
abstract fun synchronizeNostrEventResultDao(): SynchronizeNostrEventResultDao
abstract fun unsignedNostrEventDao(): UnsignedNostrEventDao
abstract fun zapDao(): ZapDao
suspend fun wipeData() {
useWriterConnection { transactor ->
transactor.immediateTransaction {
// TODO: Actually delete the data...
}
}
}
}