feat(projects): three tables, schema 20 -> 21

Phase 2 of docs/project-feature.md. Still nothing that writes a row: this is the
shape, the keys and the queries each screen will want.

A project's contents are real foreign keys into this room's own rows, with
CASCADE, like every reference in this family. That is the one-room scope
decision made load-bearing rather than documented: a membership naming a version
this device does not hold fails at insert, loudly, instead of landing as a row
pointing at nothing that every later read has to be defensive about. Projects
that span rooms take those keys away again -- Phase 9, following
`parentChatRoomId`, which is deliberately not a foreign key because a dangling
value is the normal state there -- and that is a migration rather than something
to carry unused until then. `MantraProjectDaoJvmTest` asserts the refusal, which
is also what says Room has foreign keys switched on at all.

The owners are a comma-joined sorted string with an `ownerPublicKeySet()`
accessor beside it, which is what `DkgSession.participantPublicKeys` already
does with a set of keys, sort included and for the same reason. A fourth table
would buy querying by owner, which no screen does. A set stored as text is a
shape that rots, so the accessor is tested against spacing, empty entries and
the single-entry case rather than the happy path.

`fromProjectEvent` refuses an event with no name, no creator or no owners rather
than filling the gap from local knowledge -- which is exactly the failure the
tags exist to prevent. The empty-owner refusal does double duty: an owner list
that comes back empty is what `ROLE_OWNER` missing from `KNOWN_ROLES` looks like
from here, so the guard against a malformed event is also the alarm for that
bug.

No `toProjectEvent()`. Those eight rebuilds serve rooms whose work predates
`GroupSignedEvent` at v13; projects arrive at v21, so every project this app
will ever hold is on file as a signed event. Adding three more would be adding
to a mechanism with a removal checklist already written for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 19:07:01 +02:00
parent 2d0a5ce50f
commit 7afda9cf80
10 changed files with 7062 additions and 1 deletions

View File

@@ -29,6 +29,9 @@ 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.MantraProjectArtifactVersionDao
import press.mantra.compose.database.dao.MantraProjectDao
import press.mantra.compose.database.dao.MantraProjectTranslationArtifactVersionDao
import press.mantra.compose.database.dao.MantraTranslationArtifactVersionDao
import press.mantra.compose.database.dao.MantraTranslationArtifactVersionContributorDao
import press.mantra.compose.database.dao.MantraTranslationChapterDao
@@ -81,6 +84,9 @@ 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.MantraProject
import press.mantra.compose.database.model.MantraProjectArtifactVersion
import press.mantra.compose.database.model.MantraProjectTranslationArtifactVersion
import press.mantra.compose.database.model.MantraTranslation
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
import press.mantra.compose.database.model.MantraTranslationArtifactVersionContributor
@@ -146,6 +152,9 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
MantraChapter::class,
MantraChunk::class,
MantraDialect::class,
MantraProject::class,
MantraProjectArtifactVersion::class,
MantraProjectTranslationArtifactVersion::class,
MantraTranslation::class,
MantraTranslationArtifactVersion::class,
MantraTranslationArtifactVersionContributor::class,
@@ -177,7 +186,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 +330,22 @@ 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 adds the three project tables -- a project, and one row per source
// version or translation that is part of it. Three new tables and
// nothing existing changes shape, so Room generates this itself.
//
// A project's contents are foreign keys into this room's own rows, with
// CASCADE, like every other reference in this family. That is the
// one-room scope decision made load-bearing: a membership naming a
// version this device does not hold fails at insert rather than landing
// as a row pointing at nothing. Projects that span rooms take those keys
// away again -- see Phase 9 of docs/project-feature.md -- and that is a
// migration rather than something to carry unused until then.
//
// The owners are a comma-joined sorted string on the project row rather
// than a fourth table, which is what DkgSession.participantPublicKeys
// already does with a set of keys and for the same reason.
AutoMigration(from = 20, to = 21),
]
)
@ColumnTypeConverters(MantraConverters::class)
@@ -368,6 +393,12 @@ abstract class MantraDatabase: RoomDatabase() {
abstract fun mantraDialectDao(): MantraDialectDao
abstract fun mantraProjectDao(): MantraProjectDao
abstract fun mantraProjectArtifactVersionDao(): MantraProjectArtifactVersionDao
abstract fun mantraProjectTranslationArtifactVersionDao(): MantraProjectTranslationArtifactVersionDao
abstract fun mantraTranslationArtifactVersionDao(): MantraTranslationArtifactVersionDao
abstract fun mantraTranslationArtifactVersionContributorDao(): MantraTranslationArtifactVersionContributorDao

View File

@@ -0,0 +1,47 @@
package press.mantra.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MantraProject
import press.mantra.compose.database.model.MantraProjectArtifactVersion
@Dao
interface MantraProjectArtifactVersionDao {
@Upsert
suspend fun upsert(mantraProjectArtifactVersion: MantraProjectArtifactVersion)
@Query("SELECT * FROM MantraProjectArtifactVersion WHERE projectId = :projectId")
suspend fun getByProjectId(projectId: String): List<MantraProjectArtifactVersion>
@Query("SELECT * FROM MantraProjectArtifactVersion WHERE id = :id")
suspend fun getById(id: String): MantraProjectArtifactVersion?
/**
* The versions themselves, resolved here rather than by the caller.
*
* No screen wants a join row, and resolving it in one indexed query beats an
* N+1 in a view model.
*/
@Query(
"""
SELECT v.* FROM MantraArtifactVersion v
INNER JOIN MantraProjectArtifactVersion m ON m.artifactVersionId = v.id
WHERE m.projectId = :projectId
ORDER BY v.versionLabel
"""
)
suspend fun getArtifactVersionsByProjectId(projectId: String): List<MantraArtifactVersion>
/** The inverse: which projects a version is part of. */
@Query(
"""
SELECT p.* FROM MantraProject p
INNER JOIN MantraProjectArtifactVersion m ON m.projectId = p.id
WHERE m.artifactVersionId = :artifactVersionId
ORDER BY p.name
"""
)
suspend fun getProjectsByArtifactVersionId(artifactVersionId: String): List<MantraProject>
}

View File

@@ -0,0 +1,18 @@
package press.mantra.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import press.mantra.compose.database.model.MantraProject
@Dao
interface MantraProjectDao {
@Upsert
suspend fun upsert(mantraProject: MantraProject)
@Query("SELECT * FROM MantraProject WHERE chatRoomId = :chatRoomId ORDER BY name")
suspend fun getProjectsByChatRoomId(chatRoomId: String): List<MantraProject>
@Query("SELECT * FROM MantraProject WHERE id = :id")
suspend fun getProjectById(id: String): MantraProject?
}

View File

@@ -0,0 +1,49 @@
package press.mantra.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import press.mantra.compose.database.model.MantraProject
import press.mantra.compose.database.model.MantraProjectTranslationArtifactVersion
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
@Dao
interface MantraProjectTranslationArtifactVersionDao {
@Upsert
suspend fun upsert(
mantraProjectTranslationArtifactVersion: MantraProjectTranslationArtifactVersion
)
@Query("SELECT * FROM MantraProjectTranslationArtifactVersion WHERE projectId = :projectId")
suspend fun getByProjectId(projectId: String): List<MantraProjectTranslationArtifactVersion>
@Query("SELECT * FROM MantraProjectTranslationArtifactVersion WHERE id = :id")
suspend fun getById(id: String): MantraProjectTranslationArtifactVersion?
/** The translations themselves. See the sibling DAO's note. */
@Query(
"""
SELECT t.* FROM MantraTranslationArtifactVersion t
INNER JOIN MantraProjectTranslationArtifactVersion m
ON m.translationArtifactVersionId = t.id
WHERE m.projectId = :projectId
ORDER BY t.name
"""
)
suspend fun getTranslationArtifactVersionsByProjectId(
projectId: String
): List<MantraTranslationArtifactVersion>
/** The inverse: which projects a translation is part of. */
@Query(
"""
SELECT p.* FROM MantraProject p
INNER JOIN MantraProjectTranslationArtifactVersion m ON m.projectId = p.id
WHERE m.translationArtifactVersionId = :translationArtifactVersionId
ORDER BY p.name
"""
)
suspend fun getProjectsByTranslationArtifactVersionId(
translationArtifactVersionId: String
): List<MantraProject>
}

View File

@@ -0,0 +1,165 @@
package press.mantra.compose.database.model
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.nostr.nip30303.ProjectEvent
import kotlin.time.Clock
import kotlin.time.Instant
/**
* 30330
*
* No `toProjectEvent()`, deliberately. The eight `toXEvent()` methods exist to
* rebuild events for a chronicle in rooms whose work predates `GroupSignedEvent`
* (schema v13); they are deprecated and there is a removal checklist in
* docs/member-chronicle.md. Projects arrive at v21, so every project this app
* will ever hold is on file as a signed event and `signedEventsOf` finds it. A
* rebuild here would be adding to a mechanism being retired, to serve rows that
* cannot exist.
*/
@Entity(
foreignKeys = [
ForeignKey(
entity = ChatRoom::class,
parentColumns = ["id"],
childColumns = ["chatRoomId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index("chatRoomId"),
],
)
data class MantraProject(
@PrimaryKey
val id: HexKey,
/** The room's own key on a group-signed row, which is every project. */
val publicKey: HexKey,
val name: String,
val description: String,
/**
* Who started it, off the event's `creator` tag.
*
* Never the submitter, and never filled in from what this device happens to
* know: the point of the tag is that it is inside the id the quorum signed,
* and a column that could disagree with it would be worth less than no
* column. It is the group's claim about whose work this is rather than proof
* -- the creator signs nothing, the room does -- and the fact about who
* actually acted is on the `FrostSigningSession`.
*/
val creatorPublicKey: HexKey,
/**
* Who it belongs to: the owner contributor tags, comma-joined and sorted.
* Read it through [ownerPublicKeySet].
*
* One column rather than a join table, following
* `DkgSession.participantPublicKeys` -- including the sort, which is there so
* two devices that assembled the set in different orders store the same
* string. A table would buy querying by owner, which no screen does.
*
* Never empty: `ProjectEvent.build` refuses an empty set and
* [fromProjectEvent] refuses an event with no owners.
*/
val ownerPublicKeys: String,
val chatRoomId: String,
/** Signature will be an empty string if this is a rumor */
val signature: String,
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/** Related nostrEventId if this is not a rumor... */
override val nostrEventId: HexKey? = null,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt
): OptionalNostrEventEntity, TimestampedEntity {
/** The owners as the set they are. Empty only on a row written around this. */
fun ownerPublicKeySet(): Set<HexKey> = ownerPublicKeys
.split(",")
.mapNotNull { key -> key.trim().takeIf { it.isNotEmpty() } }
.toSet()
/** Whether [publicKey] owns this, for a screen deciding what to say. */
fun isOwnedBy(publicKey: HexKey) = publicKey in ownerPublicKeySet()
companion object {
fun fromProjectEventTemplate(
projectEventTemplate: EventTemplate<ProjectEvent>,
chatRoomId: HexKey,
userPublicKey: HexKey,
): MantraProject? {
return fromProjectEvent(
ProjectEvent(
id = EventHasher.hashId(
pubKey = userPublicKey,
tags = projectEventTemplate.tags,
content = projectEventTemplate.content,
createdAt = projectEventTemplate.createdAt,
kind = projectEventTemplate.kind,
),
content = projectEventTemplate.content,
tags = projectEventTemplate.tags,
createdAt = projectEventTemplate.createdAt,
pubKey = userPublicKey,
sig = "", // Unsigned rumor
),
chatRoomId = chatRoomId,
)
}
/**
* Null for an event missing anything it was built to carry.
*
* The name, the creator and at least one owner are all required, and an
* event without one of them has either been truncated or is not what it
* claims. Filling the gap from local knowledge is exactly the failure the
* tags exist to prevent, so this refuses instead.
*
* An empty owner list is also what `ContributorTag.ROLE_OWNER` missing
* from that tag's known roles looks like from here -- the role parses as
* null and every owner disappears -- so refusing is the alarm for that
* bug as well as the guard against a malformed event.
*/
fun fromProjectEvent(
projectEvent: ProjectEvent,
chatRoomId: HexKey,
): MantraProject? {
val name = projectEvent.name() ?: return null
val creatorPublicKey = projectEvent.creator() ?: return null
val owners = projectEvent.owners().takeIf { it.isNotEmpty() } ?: return null
return MantraProject(
id = projectEvent.id,
publicKey = projectEvent.pubKey,
name = name,
description = projectEvent.content,
creatorPublicKey = creatorPublicKey,
// Sorted here as well as in the event. The event's order is what
// the signature covers; this is so two rows built from the same
// owners compare equal whatever order they arrived in.
ownerPublicKeys = owners.toSortedSet().joinToString(","),
chatRoomId = chatRoomId,
signature = projectEvent.sig,
createdAt = Instant.fromEpochSeconds(projectEvent.createdAt),
)
}
}
}

View File

@@ -0,0 +1,125 @@
package press.mantra.compose.database.model
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent
import kotlin.time.Clock
import kotlin.time.Instant
/**
* 30331 -- one source version is part of one project.
*
* Both references are real foreign keys, which is the whole of the
* one-room scope decision: a proposal naming a version this room does not hold
* fails loudly at apply time rather than landing as a row pointing at nothing.
* Cross-room projects are Phase 9 of docs/project-feature.md and start by taking
* these away, following `parentChatRoomId`'s precedent -- a plain column, never a
* foreign key, because a dangling value is the normal state there.
*/
@Entity(
foreignKeys = [
ForeignKey(
entity = MantraProject::class,
parentColumns = ["id"],
childColumns = ["projectId"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = MantraArtifactVersion::class,
parentColumns = ["id"],
childColumns = ["artifactVersionId"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = ChatRoom::class,
parentColumns = ["id"],
childColumns = ["chatRoomId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
// Every read of this table is "this project's contents".
Index("projectId"),
// And the inverse, for an artifact version saying which projects it is in.
Index("artifactVersionId"),
Index("chatRoomId"),
],
)
data class MantraProjectArtifactVersion(
@PrimaryKey
val id: HexKey,
val publicKey: HexKey,
val projectId: HexKey,
val artifactVersionId: HexKey,
val chatRoomId: String,
/** Signature will be an empty string if this is a rumor */
val signature: String,
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/** Related nostrEventId if this is not a rumor... */
override val nostrEventId: HexKey? = null,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt
): OptionalNostrEventEntity, TimestampedEntity {
companion object {
fun fromProjectArtifactVersionEventTemplate(
projectArtifactVersionEventTemplate: EventTemplate<ProjectArtifactVersionEvent>,
chatRoomId: HexKey,
userPublicKey: HexKey,
): MantraProjectArtifactVersion? {
return fromProjectArtifactVersionEvent(
ProjectArtifactVersionEvent(
id = EventHasher.hashId(
pubKey = userPublicKey,
tags = projectArtifactVersionEventTemplate.tags,
content = projectArtifactVersionEventTemplate.content,
createdAt = projectArtifactVersionEventTemplate.createdAt,
kind = projectArtifactVersionEventTemplate.kind,
),
content = projectArtifactVersionEventTemplate.content,
tags = projectArtifactVersionEventTemplate.tags,
createdAt = projectArtifactVersionEventTemplate.createdAt,
pubKey = userPublicKey,
sig = "", // Unsigned rumor
),
chatRoomId = chatRoomId,
)
}
fun fromProjectArtifactVersionEvent(
projectArtifactVersionEvent: ProjectArtifactVersionEvent,
chatRoomId: HexKey,
): MantraProjectArtifactVersion? {
val projectId = projectArtifactVersionEvent.projectId() ?: return null
val artifactVersionId =
projectArtifactVersionEvent.artifactVersionId() ?: return null
return MantraProjectArtifactVersion(
id = projectArtifactVersionEvent.id,
publicKey = projectArtifactVersionEvent.pubKey,
projectId = projectId,
artifactVersionId = artifactVersionId,
chatRoomId = chatRoomId,
signature = projectArtifactVersionEvent.sig,
createdAt = Instant.fromEpochSeconds(projectArtifactVersionEvent.createdAt),
)
}
}
}

View File

@@ -0,0 +1,123 @@
package press.mantra.compose.database.model
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent
import kotlin.time.Clock
import kotlin.time.Instant
/**
* 30332 -- one translation is part of one project.
*
* The other half of [MantraProjectArtifactVersion]; see there for why both
* references are real foreign keys.
*/
@Entity(
foreignKeys = [
ForeignKey(
entity = MantraProject::class,
parentColumns = ["id"],
childColumns = ["projectId"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = MantraTranslationArtifactVersion::class,
parentColumns = ["id"],
childColumns = ["translationArtifactVersionId"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = ChatRoom::class,
parentColumns = ["id"],
childColumns = ["chatRoomId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index("projectId"),
Index("translationArtifactVersionId"),
Index("chatRoomId"),
],
)
data class MantraProjectTranslationArtifactVersion(
@PrimaryKey
val id: HexKey,
val publicKey: HexKey,
val projectId: HexKey,
val translationArtifactVersionId: HexKey,
val chatRoomId: String,
/** Signature will be an empty string if this is a rumor */
val signature: String,
val relayHint: String? = null,
val marmotGroupEventId: HexKey? = null,
/** The [GroupSignedEvent] this row was made from, when the group signed it. */
val groupSignedEventId: HexKey? = null,
/** Related nostrEventId if this is not a rumor... */
override val nostrEventId: HexKey? = null,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt
): OptionalNostrEventEntity, TimestampedEntity {
companion object {
fun fromProjectTranslationArtifactVersionEventTemplate(
template: EventTemplate<ProjectTranslationArtifactVersionEvent>,
chatRoomId: HexKey,
userPublicKey: HexKey,
): MantraProjectTranslationArtifactVersion? {
return fromProjectTranslationArtifactVersionEvent(
ProjectTranslationArtifactVersionEvent(
id = EventHasher.hashId(
pubKey = userPublicKey,
tags = template.tags,
content = template.content,
createdAt = template.createdAt,
kind = template.kind,
),
content = template.content,
tags = template.tags,
createdAt = template.createdAt,
pubKey = userPublicKey,
sig = "", // Unsigned rumor
),
chatRoomId = chatRoomId,
)
}
fun fromProjectTranslationArtifactVersionEvent(
projectTranslationArtifactVersionEvent: ProjectTranslationArtifactVersionEvent,
chatRoomId: HexKey,
): MantraProjectTranslationArtifactVersion? {
val projectId =
projectTranslationArtifactVersionEvent.projectId() ?: return null
val translationArtifactVersionId =
projectTranslationArtifactVersionEvent.translationArtifactVersionId()
?: return null
return MantraProjectTranslationArtifactVersion(
id = projectTranslationArtifactVersionEvent.id,
publicKey = projectTranslationArtifactVersionEvent.pubKey,
projectId = projectId,
translationArtifactVersionId = translationArtifactVersionId,
chatRoomId = chatRoomId,
signature = projectTranslationArtifactVersionEvent.sig,
createdAt = Instant.fromEpochSeconds(
projectTranslationArtifactVersionEvent.createdAt
),
)
}
}
}