feat(nip29): subgroup hierarchy (parent/child) for relay groups

Implements the NIP-29 Subgroups feature merged upstream: groups can now be
organized into a parent/child tree, scoped per host relay.

Quartz:
- Add `parent`/`child` tag classes and TagArray (builder) helpers.
- GroupMetadataEvent (39000): parent()/children()/isRoot() accessors and
  build params.
- EditMetadataEvent (9002): parent()/children() accessors and build params
  (a 9002 re-carries the full child list, per spec, or the relay rejects it).
- SubgroupTree: assembles a relay's flat 39000 set into the hierarchy —
  structure follows each group's parent tag, sibling order follows the
  parent's child-tag order, orphans surface as roots, and malformed cycles
  are broken rather than looping.
- NIP-11: advertise/detect subgroup support via `nip29: { subgroups: true }`,
  with a `subgroups()` builder DSL helper.
- Tests for tag round-trips, tree assembly, ordering, orphans and cycles,
  plus NIP-11 serialization.

Amethyst:
- RelayGroupChannel: parentGroupId()/childGroupIds()/isSubgroup() reading the
  latest metadata.
- Account.editRelayGroupMetadata: preserve the group's current parent and full
  children list on a plain metadata edit so an admin renaming a subgroup no
  longer detaches it (or gets rejected for dropping children).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
This commit is contained in:
Claude
2026-07-16 01:51:40 +00:00
parent 615aa221e1
commit e24eec9ca6
13 changed files with 507 additions and 1 deletions
@@ -2811,7 +2811,16 @@ class Account(
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Edit the group's relay-signed metadata with a kind 9002 event (admin only). */
/**
* Edit the group's relay-signed metadata with a kind 9002 event (admin only).
*
* NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy
* links: a 9002 with no `parent` tag re-roots the group, and one that drops any
* existing `child` is rejected by the relay. So unless the caller is explicitly
* re-parenting, we re-carry the group's current [parent] and full [children] list
* from its latest known metadata to keep the tree intact across a plain name/flag
* edit. Pass an explicit value to change them.
*/
suspend fun editRelayGroupMetadata(
channel: RelayGroupChannel,
name: String?,
@@ -2823,6 +2832,8 @@ class Account(
isRestricted: Boolean,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
parent: String? = channel.parentGroupId(),
children: List<String> = channel.childGroupIds(),
) {
val template =
EditMetadataEvent.build(
@@ -2833,6 +2844,8 @@ class Account(
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags,
geohashes = geohashes,
parent = parent,
children = children,
)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
@@ -137,6 +137,15 @@ class RelayGroupChannel(
fun hasLivekit(): Boolean = event?.hasLivekit() ?: false
/** Subgroups: the id of this group's parent on the same host relay, or null when it's a root. */
fun parentGroupId(): String? = event?.parent()
/** Subgroups: the ordered ids of this group's direct children (empty when it has none). */
fun childGroupIds(): List<String> = event?.children() ?: emptyList()
/** Whether this group sits under a parent group (i.e. it is a subgroup). */
fun isSubgroup(): Boolean = event?.isRoot() == false
fun updateGroupInfo(
event: GroupMetadataEvent,
eventNote: Note? = null,
@@ -53,6 +53,7 @@ data class Nip11RelayInformation(
val fees: RelayInformationFees? = null,
val nip50: List<String>? = null,
val supported_grasps: List<String>? = null,
val nip29: Nip29Support? = null,
) {
/**
* Serializes this document to JSON for serving at the relay's root over the
@@ -67,6 +68,17 @@ data class Nip11RelayInformation(
fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson<Nip11RelayInformation>(json)
}
/**
* NIP-29 relay capability advertisement. A relay that supports the subgroup
* hierarchy sets `nip29: { "subgroups": true }` in its NIP-11 document so
* clients know they can offer parent/child grouping for this relay's groups.
*/
@Stable
@Serializable
data class Nip29Support(
val subgroups: Boolean? = null,
)
@Stable
@Serializable
data class RelayInformationFee(
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip11RelayInfo
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.Nip29Support
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFee
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFees
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationLimitation
@@ -87,6 +88,7 @@ class Nip11RelayInformationBuilder {
private var limitation: RelayInformationLimitation? = null
private var fees: RelayInformationFees? = null
private var nip29: Nip29Support? = null
/** Advertise supported NIP numbers, e.g. `supports(1, 11, 42, 50)`. Repeatable. */
fun supports(vararg nips: Int) = apply { nips.forEach { supportedNips.add(it.toString()) } }
@@ -112,6 +114,9 @@ class Nip11RelayInformationBuilder {
/** GRASP git-server capabilities the relay implements (`supported_grasps`). Repeatable. */
fun grasps(vararg values: String) = apply { supportedGrasps.addAll(values) }
/** Advertise NIP-29 subgroup support (`nip29: { "subgroups": true }`). */
fun subgroups(supported: Boolean = true) = apply { nip29 = Nip29Support(subgroups = supported) }
/** Declare the relay's `limitation` object via a nested DSL. */
fun limitation(initializer: LimitationBuilder.() -> Unit) =
apply {
@@ -165,6 +170,7 @@ class Nip11RelayInformationBuilder {
fees = fees,
nip50 = nip50Subfeatures.ifEmpty { null }?.toList(),
supported_grasps = supportedGrasps.ifEmpty { null }?.toList(),
nip29 = nip29,
)
@Nip11DslMarker
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip29RelayGroups
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/** A group and its (recursively assembled) subgroups. */
@Immutable
data class GroupTreeNode(
val metadata: GroupMetadataEvent,
val children: List<GroupTreeNode>,
)
/**
* Assembles the NIP-29 subgroup hierarchy from a flat set of `kind:39000`
* metadata events (all from the same relay the tree is relay-scoped).
*
* Structure follows each group's own `parent` tag, which is the single source of
* truth for who a group's parent is (a group carries at most one `parent`, so a
* group can never end up under two parents). A parent's advertised `child` tag
* order is used only to order siblings; children a parent hasn't listed yet are
* appended after the listed ones in a stable order.
*
* Robustness rules, matching the spec's relay behaviour:
* - A group whose declared parent is not present in the set is treated as a root
* (the spec has relays reject such edits, but a client aggregating a partial
* view must still show the group rather than drop it).
* - Cycles cannot occur on a compliant relay (it rejects any `kind:9002` that
* would create one), but if malformed data produces one it is broken and the
* involved groups surface as roots rather than looping forever.
* - When multiple `kind:39000` events share a `d` id the newest wins.
*/
object SubgroupTree {
fun build(events: Collection<GroupMetadataEvent>): List<GroupTreeNode> {
val byId = LinkedHashMap<String, GroupMetadataEvent>()
events.forEach { event ->
val id = event.groupId().ifEmpty { return@forEach }
val existing = byId[id]
if (existing == null || event.createdAt >= existing.createdAt) byId[id] = event
}
val childrenOf = HashMap<String, MutableList<GroupMetadataEvent>>()
byId.values.forEach { event ->
val parent = event.parent()
if (parent != null && parent in byId) {
childrenOf.getOrPut(parent) { mutableListOf() }.add(event)
}
}
val placed = HashSet<String>()
fun node(event: GroupMetadataEvent): GroupTreeNode {
val id = event.groupId()
placed.add(id)
// Order siblings by the parent's advertised `child` tag order; anything
// the parent hasn't listed keeps its natural order after the listed ones.
val order = event.children().withIndex().associate { (index, childId) -> childId to index }
val orderedChildren =
childrenOf[id]
.orEmpty()
.filter { it.groupId() !in placed } // cycle guard
.sortedBy { order[it.groupId()] ?: Int.MAX_VALUE }
return GroupTreeNode(event, orderedChildren.map { node(it) })
}
val roots =
byId.values.filter {
val parent = it.parent()
parent == null || parent !in byId
}
val tree = roots.map { node(it) }.toMutableList()
// Safety net: any group left unplaced was part of a cycle — surface it as a root.
byId.values.forEach { event ->
if (event.groupId() !in placed) tree.add(node(event))
}
return tree
}
}
@@ -32,6 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -83,6 +85,22 @@ class GroupMetadataEvent(
/** Group supports LiveKit-powered live audio/video. Presence of the `livekit` flag. */
fun hasLivekit() = tags.hasTagName("livekit")
/**
* Subgroups: the id of this group's parent, or null when this is a root group.
* At most one `parent` tag is expected (NIP-29 §Subgroups).
*/
fun parent(): String? = tags.firstNotNullOfOrNull(ParentTag::parse)
/**
* Subgroups: the ordered ids of this group's direct children. The position of
* each `child` tag in the array is the intended display order. Empty when the
* group has no children (or the relay doesn't advertise them).
*/
fun children(): List<String> = tags.mapNotNull(ChildTag::parse)
/** A group with no `parent` tag is a root group in the subgroup tree. */
fun isRoot(): Boolean = parent() == null
/**
* The kinds this group accepts, when constrained, e.g. `["supported_kinds", "9", "11"]`.
* `null` (tag absent) means all kinds are accepted.
@@ -127,6 +145,8 @@ class GroupMetadataEvent(
supportedKinds: List<Int>? = null,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
parent: String? = null,
children: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GroupMetadataEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
@@ -141,6 +161,8 @@ class GroupMetadataEvent(
addAll(HashtagTag.assemble(hashtags))
// Mip-map each geohash into every prefix so a coarser followed geohash still matches.
geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) }
parent?.let { add(ParentTag.assemble(it)) }
addAll(ChildTag.assemble(children))
initializer()
}
}
@@ -54,6 +54,16 @@ class EditMetadataEvent(
fun geohashes() = tags.geohashes()
/** Subgroups: the requested parent group id, or null to (re-)root this group. */
fun parent() = tags.parentGroupId()
/**
* Subgroups: the ordered child ids carried on this edit. Per NIP-29 a metadata
* edit of a parent group MUST re-list all of its children, so the relay rejects
* a `kind:9002` that drops any of them.
*/
fun children() = tags.childGroupIds()
fun previousEvents() = tags.previousEvents()
override fun indexableContent() = listOfNotNull(name(), about()).joinToString("\n")
@@ -69,6 +79,8 @@ class EditMetadataEvent(
status: Set<GroupMetadataEvent.GroupStatus> = emptySet(),
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
parent: String? = null,
children: List<String> = emptyList(),
previousEvents: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<EditMetadataEvent>.() -> Unit = {},
@@ -81,6 +93,8 @@ class EditMetadataEvent(
addAll(HashtagTag.assemble(hashtags))
// Mip-map each geohash into every prefix so a coarser followed geohash still matches.
geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) }
parent?.let { parentGroup(it) }
childGroups(children)
previous(previousEvents)
initializer()
}
@@ -23,14 +23,22 @@ package com.vitorpamplona.quartz.nip29RelayGroups.moderation
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.CodeTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag
fun <T : Event> TagArrayBuilder<T>.groupId(groupId: String) = addUnique(GroupIdTag.assemble(groupId))
fun <T : Event> TagArrayBuilder<T>.previous(eventIdPrefixes: List<String>) = addAll(PreviousTag.assemble(eventIdPrefixes))
/** Sets the subgroup `parent` tag (the parent group's id). At most one per event. */
fun <T : Event> TagArrayBuilder<T>.parentGroup(parentGroupId: String) = addUnique(ParentTag.assemble(parentGroupId))
/** Appends the ordered `child` subgroup tags. */
fun <T : Event> TagArrayBuilder<T>.childGroups(childGroupIds: List<String>) = addAll(ChildTag.assemble(childGroupIds))
fun <T : Event> TagArrayBuilder<T>.userPubKey(pubKey: HexKey) = add(arrayOf("p", pubKey))
fun <T : Event> TagArrayBuilder<T>.userPubKeyWithRoles(
@@ -25,14 +25,22 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.ChildTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.CodeTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag
fun TagArray.groupId() = firstTagValue(GroupIdTag.TAG_NAME)
fun TagArray.previousEvents() = mapNotNull(PreviousTag::parse)
/** The `parent` group id (subgroups), or null when this is a root group. At most one is expected. */
fun TagArray.parentGroupId() = firstNotNullOfOrNull(ParentTag::parse)
/** The ordered list of direct `child` subgroup ids advertised on a parent's metadata. */
fun TagArray.childGroupIds(): List<String> = mapNotNull(ChildTag::parse)
fun TagArray.userPubKeys(): List<HexKey> = mapNotNull(PTag::parseKey)
fun TagArray.deletedEventIds(): List<HexKey> = mapValueTagged("e") { it }
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip29RelayGroups.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-29 subgroups `child` tag. A parent group's `kind:39000` metadata carries one
* `child` tag per direct subgroup, pointing at the child's `d` identifier. The
* order of the `child` tags in the array is the display order of the children; the
* relay appends new children as they are created and an admin can reorder them via
* a `kind:9002` carrying the full desired `child` list.
*/
class ChildTag {
companion object {
const val TAG_NAME = "child"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(childGroupId: String) = arrayOf(TAG_NAME, childGroupId)
fun assemble(childGroupIds: List<String>) = childGroupIds.map { assemble(it) }
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip29RelayGroups.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-29 subgroups `parent` tag. Points at the parent group's `d` identifier and
* appears on the subgroup's own `kind:39000` metadata (and on the `kind:9002`
* edit-metadata request that sets it). A group with no `parent` tag is a root.
*
* The spec allows at most one `parent` tag per group.
*/
class ParentTag {
companion object {
const val TAG_NAME = "parent"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(parentGroupId: String) = arrayOf(TAG_NAME, parentGroupId)
}
}
@@ -147,6 +147,28 @@ class Nip11RelayInformationBuilderTest {
assertEquals(3600, info.retention?.get(1)?.time)
}
@Test
fun advertisesNip29SubgroupSupport() {
val info =
relayInformation {
name = "Groups"
supports(29)
subgroups()
}
assertEquals(true, info.nip29?.subgroups)
val json = info.toJson()
assertTrue(json.contains("\"nip29\":{\"subgroups\":true}"), json)
assertEquals(info, Nip11RelayInformation.fromJson(json))
}
@Test
fun omitsNip29WhenNotDeclared() {
val info = relayInformation { name = "R" }
assertNull(info.nip29)
assertTrue(!info.toJson().contains("nip29"), info.toJson())
}
@Test
fun listHelpersAreRepeatableAndCollapseWhenEmpty() {
val info =
@@ -0,0 +1,196 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip29RelayGroups
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* NIP-29 §Subgroups: the `parent`/`child` tags on `kind:39000` group metadata and
* `kind:9002` edit-metadata, plus [SubgroupTree] assembly of a relay's flat group
* set into the parent/child hierarchy (ordering, orphan-as-root, cycle safety).
*/
class SubgroupTest {
private val relaySelf = "aa".repeat(32)
private val sig = "bb".repeat(64)
private val id = "00".repeat(32)
private fun metadata(
groupId: String,
parent: String? = null,
children: List<String> = emptyList(),
createdAt: Long = 100,
): GroupMetadataEvent {
val template = GroupMetadataEvent.build(groupId, name = groupId, parent = parent, children = children, createdAt = createdAt)
return EventFactory.create(id, relaySelf, template.createdAt, GroupMetadataEvent.KIND, template.tags, "", sig) as GroupMetadataEvent
}
@Test
fun rootGroupHasNoParent() {
val root = metadata("tech", children = listOf("nostr"))
assertNull(root.parent())
assertTrue(root.isRoot())
assertEquals(listOf("nostr"), root.children())
}
@Test
fun subgroupCarriesParentAndChildOrder() {
val sub = metadata("nostr", parent = "tech", children = listOf("nip29", "nips"))
assertEquals("tech", sub.parent())
assertEquals(false, sub.isRoot())
assertEquals(listOf("nip29", "nips"), sub.children())
}
@Test
fun editMetadataRoundTripsParentAndChildren() {
val template = EditMetadataEvent.build("nostr", name = "Nostr", parent = "social", children = listOf("nip29"))
val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent
assertEquals("nostr", event.groupId())
assertEquals("social", event.parent())
assertEquals(listOf("nip29"), event.children())
}
@Test
fun editMetadataWithoutParentRoots() {
val template = EditMetadataEvent.build("nostr", name = "Nostr")
val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent
assertNull(event.parent())
}
@Test
fun buildsNestedTree() {
val tree =
SubgroupTree.build(
listOf(
metadata("tech", children = listOf("nostr")),
metadata("nostr", parent = "tech", children = listOf("nip29")),
metadata("nip29", parent = "nostr"),
),
)
assertEquals(1, tree.size)
val tech = tree[0]
assertEquals("tech", tech.metadata.groupId())
assertEquals(1, tech.children.size)
val nostr = tech.children[0]
assertEquals("nostr", nostr.metadata.groupId())
assertEquals(
"nip29",
nostr.children
.single()
.metadata
.groupId(),
)
}
@Test
fun ordersSiblingsByParentChildTags() {
// Parent lists children in b, a, c order — the tree must follow that, not insertion order.
val tree =
SubgroupTree.build(
listOf(
metadata("root", children = listOf("b", "a", "c")),
metadata("a", parent = "root"),
metadata("b", parent = "root"),
metadata("c", parent = "root"),
),
)
assertEquals(listOf("b", "a", "c"), tree.single().children.map { it.metadata.groupId() })
}
@Test
fun unlistedChildrenComeAfterListedOnes() {
val tree =
SubgroupTree.build(
listOf(
metadata("root", children = listOf("a")),
metadata("a", parent = "root"),
// "b" points at root but root hasn't listed it yet
metadata("b", parent = "root"),
),
)
assertEquals(listOf("a", "b"), tree.single().children.map { it.metadata.groupId() })
}
@Test
fun groupWithMissingParentBecomesRoot() {
// "nostr" declares a parent that isn't in the set — surface it as a root, not dropped.
val tree = SubgroupTree.build(listOf(metadata("nostr", parent = "ghost")))
assertEquals(listOf("nostr"), tree.map { it.metadata.groupId() })
}
@Test
fun multipleRootsAreReturned() {
val tree =
SubgroupTree.build(
listOf(
metadata("tech"),
metadata("food"),
metadata("pizza", parent = "food"),
),
)
assertEquals(setOf("tech", "food"), tree.map { it.metadata.groupId() }.toSet())
}
@Test
fun cycleDoesNotLoopForever() {
// Malformed data: a <-> b point at each other. A compliant relay rejects this,
// but the assembler must terminate and still surface the groups.
val tree =
SubgroupTree.build(
listOf(
metadata("a", parent = "b"),
metadata("b", parent = "a"),
),
)
val allIds = mutableSetOf<String>()
fun collect(node: GroupTreeNode) {
allIds.add(node.metadata.groupId())
node.children.forEach(::collect)
}
tree.forEach(::collect)
assertTrue(allIds.containsAll(setOf("a", "b")))
}
@Test
fun newestMetadataWinsForSameId() {
val tree =
SubgroupTree.build(
listOf(
metadata("g", children = listOf("old"), createdAt = 100),
metadata("g", children = listOf("new"), createdAt = 200),
),
)
assertEquals(listOf("new"), tree.single().metadata.children())
}
}