refactor(nip65): move read/write-marker merge semantics from the CLI into quartz

The kind:10002 facet-merge rules (adding a write marker to a read-only
relay promotes it to BOTH; removing one facet of BOTH demotes to the
other; removing the last facet drops the relay) lived as private
helpers in the CLI's RelayCommands. Any frontend that edits a NIP-65
list needs them, so they now live in quartz nip65RelayList as
AdvertisedRelayListMutations (applyFacet/addFacet/removeFacet/setFacet)
with commonTest coverage. Behavior unchanged; the CLI rewires to the
shared functions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
This commit is contained in:
Claude
2026-07-18 21:03:56 +00:00
parent 80be7b909b
commit 7ea6920679
3 changed files with 229 additions and 67 deletions
@@ -40,7 +40,11 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayFacet
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.addFacet
import com.vitorpamplona.quartz.nip65RelayList.applyFacet
import com.vitorpamplona.quartz.nip65RelayList.setFacet
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayProber
@@ -220,9 +224,10 @@ object RelayCommands {
/** The two facet-nouns that edit the read/write markers of kind:10002. */
private enum class Facet(
val noun: String,
val rw: AdvertisedRelayFacet,
) {
OUTBOX("outbox"),
INBOX("inbox"),
OUTBOX("outbox", AdvertisedRelayFacet.WRITE),
INBOX("inbox", AdvertisedRelayFacet.READ),
}
// ------------------------------------------------------------------
@@ -459,7 +464,7 @@ object RelayCommands {
"add", "remove", "rm" -> {
val present = verb == "add"
val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url"))
val changed = mutateNip65(ctx, self) { applyFacet(it, url, facet, present) }
val changed = mutateNip65(ctx, self) { it.applyFacet(url, facet.rw, present) }
Output.emit(
mapOf(
"noun" to facet.noun,
@@ -479,7 +484,7 @@ object RelayCommands {
if (parsed.isEmpty()) return Output.error("bad_args", "set needs at least one URL; use `relay ${facet.noun} clear` to empty it")
parsed
}
mutateNip65(ctx, self) { facetSet(it, relays, facet) }
mutateNip65(ctx, self) { it.setFacet(relays, facet.rw) }
Output.emit(mapOf("noun" to facet.noun, "kind" to AdvertisedRelayListEvent.KIND, "relays" to facetUrls(ctx, self, facet)))
}
"list" -> Output.emit(mapOf("noun" to facet.noun, "kind" to AdvertisedRelayListEvent.KIND, "relays" to facetUrls(ctx, self, facet)))
@@ -549,7 +554,7 @@ object RelayCommands {
// nip65 as read+write (both).
changed["nip65"] =
if (add) {
mutateNip65(ctx, self) { applyFacet(applyFacet(it, url, Facet.OUTBOX, true), url, Facet.INBOX, true) }
mutateNip65(ctx, self) { it.addFacet(url, AdvertisedRelayFacet.WRITE).addFacet(url, AdvertisedRelayFacet.READ) }
} else {
mutateNip65(ctx, self) { infos -> infos.filterNot { it.relayUrl.url == url.url } }
}
@@ -661,68 +666,6 @@ object RelayCommands {
self: HexKey,
): List<AdvertisedRelayInfo> = ctx.relaysOf(self)?.relays().orEmpty()
/** Read/write flags for one relay, split out from [AdvertisedRelayType]. */
private data class RW(
val read: Boolean,
val write: Boolean,
)
private fun AdvertisedRelayType.rw() = RW(isRead(), isWrite())
private fun RW.toTypeOrNull(): AdvertisedRelayType? =
when {
read && write -> AdvertisedRelayType.BOTH
read -> AdvertisedRelayType.READ
write -> AdvertisedRelayType.WRITE
else -> null
}
/**
* Toggle one [facet] on/off for [url] within the kind:10002 entry list,
* applying the NIP-65 merge rules: turning a facet on merges into `both`
* when the other facet is set; turning the last facet off drops the relay.
* Order is preserved.
*/
private fun applyFacet(
infos: List<AdvertisedRelayInfo>,
url: NormalizedRelayUrl,
facet: Facet,
present: Boolean,
): List<AdvertisedRelayInfo> {
val urls = LinkedHashMap<String, NormalizedRelayUrl>()
val flags = LinkedHashMap<String, RW>()
for (i in infos) {
urls[i.relayUrl.url] = i.relayUrl
flags[i.relayUrl.url] = i.type.rw()
}
val cur = flags[url.url] ?: RW(read = false, write = false)
val next = if (facet == Facet.OUTBOX) cur.copy(write = present) else cur.copy(read = present)
if (next.read || next.write) {
urls[url.url] = url
flags[url.url] = next
} else {
urls.remove(url.url)
flags.remove(url.url)
}
return flags.entries.map { AdvertisedRelayInfo(urls[it.key]!!, it.value.toTypeOrNull()!!) }
}
/** Make exactly [targets] carry [facet], demoting/removing any relay that currently does but shouldn't. */
private fun facetSet(
infos: List<AdvertisedRelayInfo>,
targets: List<NormalizedRelayUrl>,
facet: Facet,
): List<AdvertisedRelayInfo> {
val keep = targets.map { it.url }.toSet()
var result = infos
for (i in infos) {
val has = if (facet == Facet.OUTBOX) i.type.isWrite() else i.type.isRead()
if (has && i.relayUrl.url !in keep) result = applyFacet(result, i.relayUrl, facet, present = false)
}
for (u in targets) result = applyFacet(result, u, facet, present = true)
return result
}
/** Read, transform, and (only if it changed) re-sign + store the kind:10002. Returns whether it changed. */
private suspend fun mutateNip65(
ctx: Context,
@@ -0,0 +1,119 @@
/*
* 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.nip65RelayList
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
/**
* One dimension of a NIP-65 kind:10002 entry's read/write marker.
*
* [AdvertisedRelayType] is the *combined* marker stored on the tag
* (`READ`, `WRITE`, or `BOTH`); a facet is a single flag of it, which is
* what UIs edit ("use this relay as an outbox" toggles [WRITE]).
*/
enum class AdvertisedRelayFacet {
READ,
WRITE,
}
/** Read/write flags for one relay, split out from [AdvertisedRelayType]. */
private data class RW(
val read: Boolean,
val write: Boolean,
)
private fun AdvertisedRelayType.rw() = RW(isRead(), isWrite())
private fun RW.toTypeOrNull(): AdvertisedRelayType? =
when {
read && write -> AdvertisedRelayType.BOTH
read -> AdvertisedRelayType.READ
write -> AdvertisedRelayType.WRITE
else -> null
}
/**
* Toggle one [facet] on/off for [url] within a kind:10002 entry list,
* applying the NIP-65 merge rules:
*
* - turning a facet **on** merges into `BOTH` when the other facet is
* already set (adding a write marker to a read-only relay promotes it
* to `BOTH`); adding to an absent relay creates a single-facet entry;
* - turning a facet **off** on a `BOTH` relay demotes it to the other
* facet; turning the **last** facet off drops the relay entirely.
*
* Entry order is preserved; the receiver is not modified.
*/
fun List<AdvertisedRelayInfo>.applyFacet(
url: NormalizedRelayUrl,
facet: AdvertisedRelayFacet,
present: Boolean,
): List<AdvertisedRelayInfo> {
val urls = LinkedHashMap<String, NormalizedRelayUrl>()
val flags = LinkedHashMap<String, RW>()
for (i in this) {
urls[i.relayUrl.url] = i.relayUrl
flags[i.relayUrl.url] = i.type.rw()
}
val cur = flags[url.url] ?: RW(read = false, write = false)
val next = if (facet == AdvertisedRelayFacet.WRITE) cur.copy(write = present) else cur.copy(read = present)
if (next.read || next.write) {
urls[url.url] = url
flags[url.url] = next
} else {
urls.remove(url.url)
flags.remove(url.url)
}
return flags.entries.map { AdvertisedRelayInfo(urls[it.key]!!, it.value.toTypeOrNull()!!) }
}
/** Adds [facet] to [url], promoting an existing entry to `BOTH` when it already carries the other facet. */
fun List<AdvertisedRelayInfo>.addFacet(
url: NormalizedRelayUrl,
facet: AdvertisedRelayFacet,
): List<AdvertisedRelayInfo> = applyFacet(url, facet, present = true)
/** Removes [facet] from [url], demoting a `BOTH` entry to the other facet or dropping the relay when it was the last one. */
fun List<AdvertisedRelayInfo>.removeFacet(
url: NormalizedRelayUrl,
facet: AdvertisedRelayFacet,
): List<AdvertisedRelayInfo> = applyFacet(url, facet, present = false)
/**
* Makes exactly [targets] carry [facet], demoting (or removing, per the
* [applyFacet] merge rules) any relay that currently carries it but
* shouldn't. Relays only carrying the other facet are left untouched.
*/
fun List<AdvertisedRelayInfo>.setFacet(
targets: List<NormalizedRelayUrl>,
facet: AdvertisedRelayFacet,
): List<AdvertisedRelayInfo> {
val keep = targets.map { it.url }.toSet()
var result = this
for (i in this) {
val has = if (facet == AdvertisedRelayFacet.WRITE) i.type.isWrite() else i.type.isRead()
if (has && i.relayUrl.url !in keep) result = result.applyFacet(i.relayUrl, facet, present = false)
}
for (u in targets) result = result.applyFacet(u, facet, present = true)
return result
}
@@ -0,0 +1,100 @@
/*
* 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.nip65RelayList
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import kotlin.test.Test
import kotlin.test.assertEquals
class AdvertisedRelayListMutationsTest {
fun norm(str: String) = RelayUrlNormalizer.normalizeOrNull(str)!!
val relay1 = norm("wss://relay1.com")
val relay2 = norm("wss://relay2.com")
fun List<AdvertisedRelayInfo>.keys() = map { it.relayUrl.url to it.type }
@Test
fun addWriteToReadOnlyPromotesToBoth() {
val before = listOf(AdvertisedRelayInfo(relay1, AdvertisedRelayType.READ))
val after = before.addFacet(relay1, AdvertisedRelayFacet.WRITE)
assertEquals(listOf(relay1.url to AdvertisedRelayType.BOTH), after.keys())
}
@Test
fun removeWriteFromBothDemotesToRead() {
val before = listOf(AdvertisedRelayInfo(relay1, AdvertisedRelayType.BOTH))
val after = before.removeFacet(relay1, AdvertisedRelayFacet.WRITE)
assertEquals(listOf(relay1.url to AdvertisedRelayType.READ), after.keys())
}
@Test
fun removeLastFacetDropsTheRelay() {
val before =
listOf(
AdvertisedRelayInfo(relay1, AdvertisedRelayType.WRITE),
AdvertisedRelayInfo(relay2, AdvertisedRelayType.BOTH),
)
val after = before.removeFacet(relay1, AdvertisedRelayFacet.WRITE)
assertEquals(listOf(relay2.url to AdvertisedRelayType.BOTH), after.keys())
}
@Test
fun addToAbsentRelayCreatesSingleFacetEntry() {
val before = listOf(AdvertisedRelayInfo(relay1, AdvertisedRelayType.BOTH))
val afterRead = before.addFacet(relay2, AdvertisedRelayFacet.READ)
val afterWrite = before.addFacet(relay2, AdvertisedRelayFacet.WRITE)
assertEquals(
listOf(relay1.url to AdvertisedRelayType.BOTH, relay2.url to AdvertisedRelayType.READ),
afterRead.keys(),
)
assertEquals(
listOf(relay1.url to AdvertisedRelayType.BOTH, relay2.url to AdvertisedRelayType.WRITE),
afterWrite.keys(),
)
}
@Test
fun setFacetDemotesUnlistedAndAddsTargets() {
val before =
listOf(
AdvertisedRelayInfo(relay1, AdvertisedRelayType.BOTH),
AdvertisedRelayInfo(relay2, AdvertisedRelayType.WRITE),
)
val after = before.setFacet(listOf(relay2), AdvertisedRelayFacet.WRITE)
assertEquals(
listOf(relay1.url to AdvertisedRelayType.READ, relay2.url to AdvertisedRelayType.WRITE),
after.keys(),
)
}
}