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:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ProjectEvent
|
||||
import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.SubmissionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
|
||||
@@ -141,6 +144,49 @@ class RumorIdAgreementTest {
|
||||
assertEquals(rumorIdOf(template), entity?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a project's row and its rumor agree`() {
|
||||
val template = ProjectEvent.build(
|
||||
name = "Gospel of John into Sesotho",
|
||||
description = "The four chapters.",
|
||||
creatorPublicKey = author,
|
||||
ownerPublicKeys = setOf(other, "c".repeat(64))
|
||||
)
|
||||
|
||||
val entity = MantraProject.fromProjectEventTemplate(
|
||||
projectEventTemplate = template,
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = author
|
||||
)
|
||||
|
||||
assertEquals(rumorIdOf(template), entity?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a project membership's row and its rumor agree`() {
|
||||
val source = ProjectArtifactVersionEvent.build(
|
||||
projectId = other,
|
||||
artifactVersionId = "c".repeat(64)
|
||||
)
|
||||
val translation = ProjectTranslationArtifactVersionEvent.build(
|
||||
projectId = other,
|
||||
translationArtifactVersionId = "c".repeat(64)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
rumorIdOf(source),
|
||||
MantraProjectArtifactVersion
|
||||
.fromProjectArtifactVersionEventTemplate(source, chatRoomId, author)?.id
|
||||
)
|
||||
assertEquals(
|
||||
rumorIdOf(translation),
|
||||
MantraProjectTranslationArtifactVersion
|
||||
.fromProjectTranslationArtifactVersionEventTemplate(
|
||||
translation, chatRoomId, author
|
||||
)?.id
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the submission names the id the row was written under`() {
|
||||
// The end of the chain the rest of this file checks a link of: what a
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraArtifactVersion
|
||||
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.MantraTranslationArtifactVersion
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.nostr.nip30303.ProjectEvent
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
/**
|
||||
* The three tables, and the one guarantee the schema makes about them.
|
||||
*
|
||||
* A project's contents are foreign keys into this room's own rows. That is the
|
||||
* scope decision -- one room, for now -- and it is load-bearing rather than
|
||||
* decorative: it is what makes a membership naming a version this device does
|
||||
* not hold fail at insert, loudly, instead of landing as a row pointing at
|
||||
* nothing that every later read has to be defensive about. When projects learn
|
||||
* to span rooms the keys go away, and this test is what has to be rewritten
|
||||
* rather than quietly starting to pass for a different reason.
|
||||
*
|
||||
* The other thing here is that the owners are a string. A set stored as text is
|
||||
* a shape that rots -- an empty entry, a stray space, the order changing between
|
||||
* two devices -- so `ownerPublicKeySet` is tested against each of those rather
|
||||
* than against the happy path it obviously handles.
|
||||
*/
|
||||
class MantraProjectDaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val roomId = "b".repeat(64)
|
||||
private val otherRoomId = "f".repeat(64)
|
||||
private val signature = "0".repeat(128)
|
||||
|
||||
private val creator = "a".repeat(64)
|
||||
private val ownerOne = "1".repeat(64)
|
||||
private val ownerTwo = "2".repeat(64)
|
||||
|
||||
private val artifactVersionId = "3".repeat(64)
|
||||
private val translationId = "5".repeat(64)
|
||||
|
||||
/** The room, an artifact version and a translation: the keys in the way. */
|
||||
private suspend fun seed() {
|
||||
val nostrEventId = "e".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = creator,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = signature,
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = creator, nostrEventId = nostrEventId))
|
||||
|
||||
listOf(roomId, otherRoomId).forEach { id ->
|
||||
db.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = id,
|
||||
userPublicKey = creator,
|
||||
subject = "a translation room",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val dialectId = "1".repeat(64)
|
||||
db.mantraDialectDao().upsert(
|
||||
MantraDialect(
|
||||
id = dialectId,
|
||||
publicKey = roomId,
|
||||
name = "Sesotho",
|
||||
country = "ZA",
|
||||
language = "st",
|
||||
signature = signature,
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
)
|
||||
|
||||
val artifactId = "2".repeat(64)
|
||||
db.mantraArtifactDao().upsert(
|
||||
MantraArtifact(
|
||||
id = artifactId,
|
||||
publicKey = roomId,
|
||||
name = "To Kill a Mockingbird",
|
||||
url = "https://harper.com/2-kill-Bird",
|
||||
visibility = "private",
|
||||
dialectId = dialectId,
|
||||
license = "cc",
|
||||
chatRoomId = roomId,
|
||||
signature = signature,
|
||||
)
|
||||
)
|
||||
|
||||
db.mantraArtifactVersionDao().upsert(
|
||||
MantraArtifactVersion(
|
||||
id = artifactVersionId,
|
||||
artifactId = artifactId,
|
||||
publicKey = roomId,
|
||||
versionLabel = "1.0",
|
||||
signature = signature,
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
)
|
||||
|
||||
db.mantraTranslationArtifactVersionDao().upsert(
|
||||
MantraTranslationArtifactVersion(
|
||||
id = translationId,
|
||||
publicKey = roomId,
|
||||
artifactVersionId = artifactVersionId,
|
||||
dialectId = dialectId,
|
||||
name = "Sesotho",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
signature = signature,
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun project(
|
||||
id: String,
|
||||
name: String = "Gospel of John",
|
||||
chatRoomId: String = roomId,
|
||||
owners: String = ownerOne,
|
||||
) = MantraProject(
|
||||
id = id,
|
||||
publicKey = chatRoomId,
|
||||
name = name,
|
||||
description = "The four chapters.",
|
||||
creatorPublicKey = creator,
|
||||
ownerPublicKeys = owners,
|
||||
chatRoomId = chatRoomId,
|
||||
signature = signature,
|
||||
).also { db.mantraProjectDao().upsert(it) }
|
||||
|
||||
// --- the project table ---
|
||||
|
||||
@Test
|
||||
fun `a project round trips, owners and creator intact`() = runBlocking {
|
||||
seed()
|
||||
project(id = "p".repeat(64), owners = "$ownerOne,$ownerTwo")
|
||||
|
||||
val stored = db.mantraProjectDao().getProjectById("p".repeat(64))
|
||||
?: fail("the project was not stored")
|
||||
|
||||
assertEquals(creator, stored.creatorPublicKey)
|
||||
assertEquals(setOf(ownerOne, ownerTwo), stored.ownerPublicKeySet())
|
||||
assertTrue(stored.isOwnedBy(ownerTwo))
|
||||
assertTrue(!stored.isOwnedBy(creator))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `projects are listed per room, by name`() = runBlocking {
|
||||
seed()
|
||||
project(id = "p2".padEnd(64, '0'), name = "Zechariah")
|
||||
project(id = "p1".padEnd(64, '0'), name = "Amos")
|
||||
project(id = "p3".padEnd(64, '0'), name = "Elsewhere", chatRoomId = otherRoomId)
|
||||
|
||||
val names = db.mantraProjectDao().getProjectsByChatRoomId(roomId).map { it.name }
|
||||
|
||||
assertEquals(listOf("Amos", "Zechariah"), names)
|
||||
}
|
||||
|
||||
// --- ownerPublicKeySet, against the ways a delimited string goes wrong ---
|
||||
|
||||
@Test
|
||||
fun `the owner set survives spacing, empties and a single entry`() {
|
||||
fun owners(stored: String) = MantraProject(
|
||||
id = "p".repeat(64),
|
||||
publicKey = roomId,
|
||||
name = "n",
|
||||
description = "",
|
||||
creatorPublicKey = creator,
|
||||
ownerPublicKeys = stored,
|
||||
chatRoomId = roomId,
|
||||
signature = signature,
|
||||
).ownerPublicKeySet()
|
||||
|
||||
assertEquals(setOf(ownerOne), owners(ownerOne))
|
||||
assertEquals(setOf(ownerOne, ownerTwo), owners(" $ownerOne , $ownerTwo "))
|
||||
assertEquals(setOf(ownerOne), owners("$ownerOne,,"))
|
||||
assertEquals(emptySet(), owners(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two rows built from the same owners in different orders agree`() {
|
||||
val event = { owners: Set<String> ->
|
||||
ProjectEvent.build(
|
||||
name = "Gospel of John",
|
||||
description = "",
|
||||
creatorPublicKey = creator,
|
||||
ownerPublicKeys = owners,
|
||||
createdAt = 1_000,
|
||||
)
|
||||
}
|
||||
|
||||
val one = MantraProject.fromProjectEventTemplate(
|
||||
event(linkedSetOf(ownerTwo, ownerOne)), roomId, roomId
|
||||
)
|
||||
val other = MantraProject.fromProjectEventTemplate(
|
||||
event(linkedSetOf(ownerOne, ownerTwo)), roomId, roomId
|
||||
)
|
||||
|
||||
assertEquals(one?.ownerPublicKeys, other?.ownerPublicKeys)
|
||||
// And the ids too, since the event sorts before hashing.
|
||||
assertEquals(one?.id, other?.id)
|
||||
}
|
||||
|
||||
// --- the memberships, and the keys that make a bad one fail ---
|
||||
|
||||
@Test
|
||||
fun `a project's contents come back resolved`() = runBlocking {
|
||||
seed()
|
||||
val projectId = "p".repeat(64)
|
||||
project(id = projectId)
|
||||
|
||||
db.mantraProjectArtifactVersionDao().upsert(
|
||||
MantraProjectArtifactVersion(
|
||||
id = "m1".padEnd(64, '0'),
|
||||
publicKey = roomId,
|
||||
projectId = projectId,
|
||||
artifactVersionId = artifactVersionId,
|
||||
chatRoomId = roomId,
|
||||
signature = signature,
|
||||
)
|
||||
)
|
||||
db.mantraProjectTranslationArtifactVersionDao().upsert(
|
||||
MantraProjectTranslationArtifactVersion(
|
||||
id = "m2".padEnd(64, '0'),
|
||||
publicKey = roomId,
|
||||
projectId = projectId,
|
||||
translationArtifactVersionId = translationId,
|
||||
chatRoomId = roomId,
|
||||
signature = signature,
|
||||
)
|
||||
)
|
||||
|
||||
val sources = db.mantraProjectArtifactVersionDao()
|
||||
.getArtifactVersionsByProjectId(projectId)
|
||||
val translations = db.mantraProjectTranslationArtifactVersionDao()
|
||||
.getTranslationArtifactVersionsByProjectId(projectId)
|
||||
|
||||
assertEquals(listOf("1.0"), sources.map { it.versionLabel })
|
||||
assertEquals(listOf("Sesotho"), translations.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact version says which projects it is in`() = runBlocking {
|
||||
seed()
|
||||
val projectId = "p".repeat(64)
|
||||
project(id = projectId, name = "Gospel of John")
|
||||
|
||||
db.mantraProjectArtifactVersionDao().upsert(
|
||||
MantraProjectArtifactVersion(
|
||||
id = "m1".padEnd(64, '0'),
|
||||
publicKey = roomId,
|
||||
projectId = projectId,
|
||||
artifactVersionId = artifactVersionId,
|
||||
chatRoomId = roomId,
|
||||
signature = signature,
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Gospel of John"),
|
||||
db.mantraProjectArtifactVersionDao()
|
||||
.getProjectsByArtifactVersionId(artifactVersionId)
|
||||
.map { it.name }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a membership naming a version this room does not hold is refused`() = runBlocking {
|
||||
seed()
|
||||
val projectId = "p".repeat(64)
|
||||
project(id = projectId)
|
||||
|
||||
val absent = "9".repeat(64)
|
||||
|
||||
val failure = runCatching {
|
||||
db.mantraProjectArtifactVersionDao().upsert(
|
||||
MantraProjectArtifactVersion(
|
||||
id = "m1".padEnd(64, '0'),
|
||||
publicKey = roomId,
|
||||
projectId = projectId,
|
||||
artifactVersionId = absent,
|
||||
chatRoomId = roomId,
|
||||
signature = signature,
|
||||
)
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
|
||||
// The foreign key, not a check in the apply arm. When Phase 9 takes it
|
||||
// away this is the assertion that has to be rewritten rather than left
|
||||
// to start passing because nothing writes memberships any more.
|
||||
assertTrue(
|
||||
failure != null,
|
||||
"a membership pointing at an absent version was accepted"
|
||||
)
|
||||
assertNull(db.mantraProjectArtifactVersionDao().getById("m1".padEnd(64, '0')))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting the room takes the project and its memberships with it`() = runBlocking {
|
||||
seed()
|
||||
val projectId = "p".repeat(64)
|
||||
project(id = projectId)
|
||||
|
||||
db.mantraProjectArtifactVersionDao().upsert(
|
||||
MantraProjectArtifactVersion(
|
||||
id = "m1".padEnd(64, '0'),
|
||||
publicKey = roomId,
|
||||
projectId = projectId,
|
||||
artifactVersionId = artifactVersionId,
|
||||
chatRoomId = roomId,
|
||||
signature = signature,
|
||||
)
|
||||
)
|
||||
|
||||
db.chatRoomDao().delete(
|
||||
ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = creator,
|
||||
subject = "a translation room",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
)
|
||||
|
||||
assertNull(db.mantraProjectDao().getProjectById(projectId))
|
||||
assertTrue(db.mantraProjectArtifactVersionDao().getByProjectId(projectId).isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user