feat(relay): NIP-77 negentropy reconciliation
Server-side negentropy: NEG-OPEN snapshots the matching event set, NEG-MSG drives the round-trip via the kmp-negentropy library, and NEG-CLOSE frees per-subId state. Same access controls as REQ apply to NEG-OPEN. supported_nips bumped to advertise "77".
This commit is contained in:
+7
@@ -96,4 +96,11 @@ class LiveEventStore(
|
||||
}
|
||||
|
||||
suspend fun count(filters: List<Filter>) = store.count(filters)
|
||||
|
||||
/**
|
||||
* One-shot snapshot query. Used by NIP-77 negentropy: the server
|
||||
* needs the full set of event ids matching the filter at the
|
||||
* moment the NEG-OPEN arrives, not a streamed/live result.
|
||||
*/
|
||||
suspend fun snapshotQuery(filter: Filter): List<Event> = store.query(filter)
|
||||
}
|
||||
|
||||
+93
@@ -34,6 +34,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegentropyServerSession
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -53,6 +58,14 @@ class RelaySession(
|
||||
) : AutoCloseable {
|
||||
private val subscriptions = LargeCache<String, Job>()
|
||||
|
||||
/**
|
||||
* NIP-77 negentropy reconciliation sessions, keyed by NEG-OPEN
|
||||
* subId. Plain hash map here (not [LargeCache]) because it's
|
||||
* mutated only from the single-threaded `receive()` path —
|
||||
* RelaySession.receive is serialised by the WebSocket handler.
|
||||
*/
|
||||
private val negSessions = HashMap<String, NegentropyServerSession>()
|
||||
|
||||
private fun addSubscription(
|
||||
subId: String,
|
||||
job: Job,
|
||||
@@ -67,6 +80,7 @@ class RelaySession(
|
||||
fun cancelAllSubscriptions() {
|
||||
subscriptions.forEach { _, job -> job.cancel() }
|
||||
subscriptions.clear()
|
||||
negSessions.clear()
|
||||
}
|
||||
|
||||
fun send(message: Message) {
|
||||
@@ -107,6 +121,9 @@ class RelaySession(
|
||||
is ReqCmd -> handleReq(cmd)
|
||||
is CloseCmd -> handleClose(cmd)
|
||||
is CountCmd -> handleCount(cmd)
|
||||
is NegOpenCmd -> handleNegOpen(cmd)
|
||||
is NegMsgCmd -> handleNegMsg(cmd)
|
||||
is NegCloseCmd -> handleNegClose(cmd)
|
||||
else -> send(NoticeMessage("error: unsupported command ${cmd.label()}"))
|
||||
}
|
||||
}
|
||||
@@ -194,6 +211,82 @@ class RelaySession(
|
||||
}
|
||||
}
|
||||
|
||||
// -- NIP-77: NEG-OPEN -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Open a negentropy reconciliation session. The relay snapshots its
|
||||
* matching events at this instant — concurrent inserts during the
|
||||
* sync are not surfaced; clients re-open if they want fresh state.
|
||||
*
|
||||
* Access control reuses the REQ policy hook: a relay that requires
|
||||
* AUTH or has kind/pubkey allow-deny lists applies the same rules
|
||||
* to NEG-OPEN as it does to subscription REQs.
|
||||
*/
|
||||
private suspend fun handleNegOpen(cmd: NegOpenCmd) {
|
||||
// Run the same access controls as REQ would.
|
||||
val asReq = ReqCmd(cmd.subId, listOf(cmd.filter))
|
||||
val gate = policy.accept(asReq)
|
||||
if (gate is PolicyResult.Rejected) {
|
||||
send(NegErrMessage(cmd.subId, gate.reason))
|
||||
return
|
||||
}
|
||||
val filters = (gate as PolicyResult.Accepted).cmd.filters
|
||||
|
||||
// Drop any prior session at this subId (NIP-77: same-subId
|
||||
// OPEN replaces).
|
||||
negSessions.remove(cmd.subId)
|
||||
|
||||
val events =
|
||||
if (filters.size == 1) {
|
||||
store.snapshotQuery(filters[0])
|
||||
} else {
|
||||
// Multiple filters: union the snapshots and dedupe by id.
|
||||
val seen = HashSet<String>()
|
||||
val merged = mutableListOf<com.vitorpamplona.quartz.nip01Core.core.Event>()
|
||||
for (f in filters) {
|
||||
for (e in store.snapshotQuery(f)) {
|
||||
if (seen.add(e.id)) merged += e
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
val neg = NegentropyServerSession(cmd.subId, events)
|
||||
negSessions[cmd.subId] = neg
|
||||
|
||||
try {
|
||||
val response = neg.processMessage(cmd.initialMessage)
|
||||
if (response != null) send(response)
|
||||
} catch (e: Exception) {
|
||||
negSessions.remove(cmd.subId)
|
||||
send(NegErrMessage(cmd.subId, "error: ${e.message ?: e::class.simpleName}"))
|
||||
}
|
||||
}
|
||||
|
||||
// -- NIP-77: NEG-MSG ------------------------------------------------------
|
||||
private fun handleNegMsg(cmd: NegMsgCmd) {
|
||||
val neg = negSessions[cmd.subId]
|
||||
if (neg == null) {
|
||||
send(NegErrMessage(cmd.subId, "error: no negentropy session for ${cmd.subId}"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
val response = neg.processMessage(cmd.message)
|
||||
if (response != null) send(response)
|
||||
} catch (e: Exception) {
|
||||
negSessions.remove(cmd.subId)
|
||||
send(NegErrMessage(cmd.subId, "error: ${e.message ?: e::class.simpleName}"))
|
||||
}
|
||||
}
|
||||
|
||||
// -- NIP-77: NEG-CLOSE ----------------------------------------------------
|
||||
private fun handleNegClose(cmd: NegCloseCmd) {
|
||||
// Spec: clients send NEG-CLOSE to free server-side state.
|
||||
// Silent no-op if the session is unknown — there's no authoritative
|
||||
// error response in NIP-77 for an unknown close.
|
||||
negSessions.remove(cmd.subId)
|
||||
}
|
||||
|
||||
init {
|
||||
policy.onConnect(::send)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user