mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
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:
@@ -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,
|
||||
|
||||
+119
@@ -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
|
||||
}
|
||||
+100
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user