Files
mantra-kmp/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt
T
Kgothatso NgakoandClaude Opus 5 72afe8aa40 feat(sync): a fetch queue per identity, and the inbox reopened on activation
Phase 6 of docs/multiple-profiles.md. The one data problem, closed from both
ends.

The two queues that fetch -- SynchronizeNostrEventRequest and
NegentropySynchronizeRequest -- gain a nullable ownerPublicKey, the identity
that asked, because what a fetch brings back is opened with the fetcher's
key. The pumps ask for their own: the head of the queue among this
identity's requests and the ones nobody owns, so that a request another
identity queued waits for that identity. Rows from before the column read
back null, meaning "the device's", 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, and holding A's outgoing message until A is
opened again would be a delivery failure the user would never be told about.
Room version 20, an AutoMigration for two nullable columns, with the schema
export committed beside its predecessors.

The stamp is the repository's, not the call site's. Eighteen view models and
two DAO paths queue requests, and every one of them does so as the active
identity -- a screen cannot queue anything as anyone else. DatabaseNostrRepository
takes the identity flow at construction, which meant moving the view model
above the repositories in the nav host, and stamps every request it queues
where the caller stamped nothing; a negentropy request carries its owner
into the REQ it becomes. The DAO's own requests -- the placeholder-profile
syncs it plants while indexing, and the participant syncs of a Marmot join
-- stay unowned, on purpose and against the plan's sketch: they fetch public
kinds that need no key to open, and any profile that is open may as well
fetch them.

The other end: wraps that were fetched under the wrong key -- before this,
or by a read-only identity whose nsec was pasted later, the case the npub
plan's DAO guard left with the words "the key that opens this one may be
signed in later" and no code behind them. storeNostrEvent never indexes an
event it already holds, so a wrap that arrived under the wrong key stayed
closed for good: the live subscription re-received it, the DAO saw a known
id, and returned. NostrDao.reopenInbox finds every wrap addressed to the
active key that no seal names and runs each through indexNostrEvent again
under the right key, one transaction per wrap so that one that cannot be
opened rolls back its own changes and the next is still tried; one pass,
since wraps do not depend on one another. It runs from the sync pumps'
collector once per activation, for an identity that can sign, before the
live inbox and the broadcast pump are launched -- so the sweep and the live
subscription are not opening the same wrap at once; both are idempotent by
event id.

Found on the way: GiftWrapSeal.giftWrapMessageId, a foreign key to the wrap
a seal came out of, existed and was never written, so nothing in the
database could say which wraps had been opened. decryptGiftWrapSeal now sets
it. A seal from before reads back null, is swept once, and comes back with
the link -- the reindex is idempotent, and persistInboundChatMessage already
files a message it has once.

Tests: ReadOnlyGiftWrapDaoJvmTest -- a wrap for us stored under another
profile's key is stored and not opened, a re-delivery under our key changes
nothing, the sweep opens it once and finds nothing the second time; the
other profile's sweep and a read-only pair open nothing; a wrap stored
read-only is opened once the key is here. OwnedRequestQueueJvmTest -- A's
request is offered to A and not to B, nobody's to both, oldest first; the
repository stamps what it queues with the identity that is open, nothing
when none is, and keeps an owner a caller named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@759199f2af
2026-09-13 15:51:48 +02:00

443 lines
22 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.GroupSignedEventDao
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.GroupSignedEvent
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,
GroupSignedEvent::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 = 20,
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.
//
// v11 adds the nullable ChatMessage.frostSigningSessionId, which says
// which signing session a transcript line is about. Lines written before
// it read back null and are read by the clock, the way they always were:
// a room that could only propose one thing at a time cannot have written
// two sessions' lines into the same stretch of transcript.
AutoMigration(from = 10, to = 11),
// v12 adds the nullable ChatRoom.archiveRequestedAt -- renamed to
// chronicleRequestedAt in v14 -- which stops a device with no signed work
// in a room asking the group for its history on every launch while an
// answer is in flight. Rooms written before it read back null, meaning
// "never asked" -- true of all of them, and harmless: the request only
// goes out for a room that holds no signed work, and a room that holds
// some will not ask.
AutoMigration(from = 11, to = 12),
// v13 adds the GroupSignedEvent table and the nullable
// `groupSignedEventId` on every row a signed event turns into. A new
// table and nullable additions are both shapes Room migrates itself.
// Rows written before it read back null, meaning "this device does not
// hold the event behind this row" -- true of all of them, since nothing
// kept it. They are not backfilled: the events are gone, and inventing
// an id for one would point a row at a signature nobody can produce.
AutoMigration(from = 12, to = 13),
// v14 changes one column name and three stored strings, all of which say
// "archive" about the group's signed record. The word was wanted back for
// the ordinary thing a user does to a chat, and a column called
// `archiveRequestedAt` sitting on ChatRoom is exactly where the two
// meanings would have met. Room can rename a column and cannot rewrite the
// rows in the same breath, so this is a manual migration passed to the
// builder rather than an entry here. See MIGRATION_13_14.
//
// v15 adds the nullable ChatRoom.joinedGroupAt, which says when this
// device became a member and so which of the group's messages were never
// its to read. Adding a nullable column is a shape Room migrates itself;
// deleting the placeholder chat lines already written for those messages
// is not, and a member who joined a busy room is looking at a screenful of
// them. Manual for that half, so it too is passed to the builder rather
// than listed here. See MIGRATION_14_15.
//
// v16 adds an index on ChatMessage.chatRoomId. No column changes and no
// rows move -- the chat list now looks up each room's newest line, and
// without the index that lookup reads every message on the device. Room
// creates an index on its own.
AutoMigration(from = 15, to = 16),
// v17 adds four nullable columns for subgroups -- a group made by
// another group, which certifies it with its own quorum. See
// docs/subgroups.md.
//
// GroupKeyState gains `parentChatRoomId` and `birthCertificateJson`, the
// parent's signature over this room's id and the index into it. ChatRoom
// gains `parentChatRoomId`, a copy of the same verified value so the room
// list needs no second read. DkgSession gains one too, which is an
// unverified claim off a ceremony proposal and is read for nothing but a
// screen's title.
//
// **None of the three is a foreign key.** A self-referential cascade on
// ChatRoom would mean deleting a parent room deletes its subgroups and,
// by their own cascades, those rooms' messages, participants, key states
// and signed events. Each column also routinely names a room this device
// does not have, since a subgroup member need never have been in the
// parent, so a dangling value is the normal state. Adding nullable
// columns is a shape Room migrates itself.
AutoMigration(from = 16, to = 17),
// v18 adds the nullable ChatMessage.dkgSessionId, the pair to
// frostSigningSessionId, because "the room's ceremony" stopped naming one
// thing. A subgroup whose admins are the whole group runs in the room the
// group's own ceremony ran in, so a transcript line has to say which of
// the two it is about -- otherwise a member tapping "your approval is
// needed" is shown whichever ceremony is newest, which is routinely the
// wrong one. Rows written before it read back null and fall back to the
// clock, which is correct for them: nothing that predates subgroups ran
// two ceremonies in one room.
AutoMigration(from = 17, to = 18),
// v19 adds two nullable DkgSession columns, both of which the ceremony's
// room used to answer and no longer can: a subgroup's ChillDKG now runs
// in the **parent's** Marmot room rather than in a NIP-17 room derived
// from the child's admins. See docs/subgroups.md.
//
// `participantPublicKeys` is who the ceremony is with -- a subset of the
// parent's members, so the room's roster is the wrong set -- and
// `subject` is what the room it will make is to be called, which used to
// be the ceremony room's own name. Both are read off the proposal, which
// has carried both since before this move; what changed is where they
// land. Rows written before this read back null and fall back to the
// room, which is correct for them: a ceremony held in its own NIP-17 room
// ran over exactly that room's members and was named after it.
AutoMigration(from = 18, to = 19),
// v20 adds the nullable ownerPublicKey to SynchronizeNostrEventRequest and
// NegentropySynchronizeRequest: the identity that asked, so that only its
// pump answers. The two queues used to be the device's, drained by whoever
// was open with their key pair, and a gift wrap fetched for one profile
// while another was open was stored under the wrong key and never opened
// again. Rows written before this read back null, meaning "the device's",
// 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),
]
)
@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 groupSignedEventDao(): GroupSignedEventDao
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...
}
}
}
}