feat: add NIP-77 negentropy sync support
Add support for NIP-77 (Negentropy Syncing) protocol messages using the negentropy-kmp library for efficient set reconciliation between client and relay. New files: - NegOpenCmd, NegMsgCmd, NegCloseCmd: Client-to-relay commands - NegMsgMessage, NegErrMessage: Relay-to-client messages - NegentropySession: Client-side reconciliation orchestrator - NegentropyServerSession: Server-side reconciliation handler - NegentropyManager: High-level sync manager with listener callbacks Updated serializers: - Jackson MessageSerializer/Deserializer for NEG-MSG, NEG-ERR - Jackson CommandSerializer/Deserializer for NEG-OPEN, NEG-MSG, NEG-CLOSE - Kotlin MessageKSerializer/CommandKSerializer for all NIP-77 types Compatible with strfry relay's negentropy implementation via the negentropy-kmp library which implements the same Protocol V1 spec. https://claude.ai/code/session_01Dc6W1G1jURAAR9kzyVvk6g
This commit is contained in:
@@ -41,6 +41,7 @@ materialIconsExtended = "1.7.3"
|
|||||||
media3 = "1.9.3"
|
media3 = "1.9.3"
|
||||||
mockk = "1.14.9"
|
mockk = "1.14.9"
|
||||||
kotlinx-coroutines-test = "1.10.2"
|
kotlinx-coroutines-test = "1.10.2"
|
||||||
|
negentropyKmp = "1.0.1"
|
||||||
netUrlencoderLibVersion = "1.6.0"
|
netUrlencoderLibVersion = "1.6.0"
|
||||||
navigationCompose = "2.9.7"
|
navigationCompose = "2.9.7"
|
||||||
okhttp = "5.3.2"
|
okhttp = "5.3.2"
|
||||||
@@ -156,6 +157,7 @@ markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", n
|
|||||||
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
|
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
|
||||||
mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" }
|
mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" }
|
||||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
|
||||||
|
negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", version.ref = "negentropyKmp" }
|
||||||
net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" }
|
net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" }
|
||||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
|
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ kotlin {
|
|||||||
|
|
||||||
// RFC3986 library(normalizes URLs)
|
// RFC3986 library(normalizes URLs)
|
||||||
api(libs.uri.reference.kmp)
|
api(libs.uri.reference.kmp)
|
||||||
|
|
||||||
|
// Negentropy set reconciliation (NIP-77)
|
||||||
|
api(libs.negentropy.kmp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
@@ -27,6 +27,9 @@ 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.EventCmd
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||||
@@ -78,6 +81,21 @@ object CommandKSerializer : KSerializer<Command> {
|
|||||||
add(FilterKSerializer.serializeToElement(filter))
|
add(FilterKSerializer.serializeToElement(filter))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is NegOpenCmd -> {
|
||||||
|
add(JsonPrimitive(value.subId))
|
||||||
|
add(FilterKSerializer.serializeToElement(value.filter))
|
||||||
|
add(JsonPrimitive(value.initialMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
is NegMsgCmd -> {
|
||||||
|
add(JsonPrimitive(value.subId))
|
||||||
|
add(JsonPrimitive(value.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
is NegCloseCmd -> {
|
||||||
|
add(JsonPrimitive(value.subId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
jsonEncoder.encodeJsonElement(element)
|
jsonEncoder.encodeJsonElement(element)
|
||||||
@@ -119,6 +137,27 @@ object CommandKSerializer : KSerializer<Command> {
|
|||||||
AuthCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject) as RelayAuthEvent)
|
AuthCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject) as RelayAuthEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NegOpenCmd.LABEL -> {
|
||||||
|
NegOpenCmd(
|
||||||
|
subId = array[1].jsonPrimitive.content,
|
||||||
|
filter = FilterKSerializer.deserializeFromElement(array[2].jsonObject),
|
||||||
|
initialMessage = array[3].jsonPrimitive.content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NegMsgCmd.LABEL -> {
|
||||||
|
NegMsgCmd(
|
||||||
|
subId = array[1].jsonPrimitive.content,
|
||||||
|
message = array[2].jsonPrimitive.content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NegCloseCmd.LABEL -> {
|
||||||
|
NegCloseCmd(
|
||||||
|
subId = array[1].jsonPrimitive.content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
throw IllegalArgumentException("Message $type is not supported")
|
throw IllegalArgumentException("Message $type is not supported")
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -29,6 +29,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
|||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||||
@@ -94,6 +96,16 @@ object MessageKSerializer : KSerializer<Message> {
|
|||||||
is EoseMessage -> {
|
is EoseMessage -> {
|
||||||
add(JsonPrimitive(value.subId))
|
add(JsonPrimitive(value.subId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is NegMsgMessage -> {
|
||||||
|
add(JsonPrimitive(value.subId))
|
||||||
|
add(JsonPrimitive(value.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
is NegErrMessage -> {
|
||||||
|
add(JsonPrimitive(value.subId))
|
||||||
|
add(JsonPrimitive(value.reason))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
jsonEncoder.encodeJsonElement(element)
|
jsonEncoder.encodeJsonElement(element)
|
||||||
@@ -148,6 +160,20 @@ object MessageKSerializer : KSerializer<Message> {
|
|||||||
CountMessage(queryId, result)
|
CountMessage(queryId, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NegMsgMessage.LABEL -> {
|
||||||
|
NegMsgMessage(
|
||||||
|
subId = array[1].jsonPrimitive.content,
|
||||||
|
message = array[2].jsonPrimitive.content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NegErrMessage.LABEL -> {
|
||||||
|
NegErrMessage(
|
||||||
|
subId = array[1].jsonPrimitive.content,
|
||||||
|
reason = if (array.size > 2) array[2].jsonPrimitive.content else "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
throw IllegalArgumentException("Message $type is not supported")
|
throw IllegalArgumentException("Message $type is not supported")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip77Negentropy
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||||
|
|
||||||
|
class NegCloseCmd(
|
||||||
|
val subId: String,
|
||||||
|
) : Command {
|
||||||
|
override fun label(): String = LABEL
|
||||||
|
|
||||||
|
override fun isValid() = subId.isNotEmpty()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val LABEL = "NEG-CLOSE"
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip77Negentropy
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||||
|
|
||||||
|
class NegErrMessage(
|
||||||
|
val subId: String,
|
||||||
|
val reason: String,
|
||||||
|
) : Message {
|
||||||
|
override fun label() = LABEL
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val LABEL = "NEG-ERR"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip77Negentropy
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||||
|
|
||||||
|
class NegMsgCmd(
|
||||||
|
val subId: String,
|
||||||
|
val message: String,
|
||||||
|
) : Command {
|
||||||
|
override fun label(): String = LABEL
|
||||||
|
|
||||||
|
override fun isValid() = subId.isNotEmpty() && message.isNotEmpty()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val LABEL = "NEG-MSG"
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip77Negentropy
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||||
|
|
||||||
|
class NegMsgMessage(
|
||||||
|
val subId: String,
|
||||||
|
val message: String,
|
||||||
|
) : Message {
|
||||||
|
override fun label() = LABEL
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val LABEL = "NEG-MSG"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip77Negentropy
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
|
||||||
|
class NegOpenCmd(
|
||||||
|
val subId: String,
|
||||||
|
val filter: Filter,
|
||||||
|
val initialMessage: String,
|
||||||
|
) : Command {
|
||||||
|
override fun label(): String = LABEL
|
||||||
|
|
||||||
|
override fun isValid() = subId.isNotEmpty() && initialMessage.isNotEmpty()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val LABEL = "NEG-OPEN"
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip77Negentropy
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback interface for negentropy reconciliation results.
|
||||||
|
*/
|
||||||
|
interface INegentropyListener {
|
||||||
|
fun onHaveIds(
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
subId: String,
|
||||||
|
haveIds: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun onNeedIds(
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
subId: String,
|
||||||
|
needIds: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun onComplete(
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
subId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun onError(
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
subId: String,
|
||||||
|
reason: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages NIP-77 negentropy sync sessions across multiple relays.
|
||||||
|
*
|
||||||
|
* This class acts as a [IRelayClientListener] that intercepts NEG-MSG and NEG-ERR
|
||||||
|
* messages and drives the reconciliation protocol. It maintains active sessions
|
||||||
|
* per relay and subscription ID.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* 1. Register this as a listener on the NostrClient
|
||||||
|
* 2. Call [startSync] with a filter and local events to begin reconciliation
|
||||||
|
* 3. Receive results via [INegentropyListener] callbacks
|
||||||
|
*/
|
||||||
|
class NegentropyManager(
|
||||||
|
private val listener: INegentropyListener,
|
||||||
|
) : IRelayClientListener {
|
||||||
|
private val activeSessions = mutableMapOf<String, Pair<NormalizedRelayUrl, NegentropySession>>()
|
||||||
|
|
||||||
|
fun startSync(
|
||||||
|
relay: IRelayClient,
|
||||||
|
subId: String,
|
||||||
|
filter: Filter,
|
||||||
|
localEvents: List<Event>,
|
||||||
|
frameSizeLimit: Long = 0,
|
||||||
|
) {
|
||||||
|
val session = NegentropySession(subId, filter, localEvents, frameSizeLimit)
|
||||||
|
activeSessions[subId] = Pair(relay.url, session)
|
||||||
|
|
||||||
|
val openCmd = session.open()
|
||||||
|
relay.sendOrConnectAndSync(openCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeSync(
|
||||||
|
relay: IRelayClient,
|
||||||
|
subId: String,
|
||||||
|
) {
|
||||||
|
val (_, session) = activeSessions.remove(subId) ?: return
|
||||||
|
relay.sendIfConnected(session.close())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIncomingMessage(
|
||||||
|
relay: IRelayClient,
|
||||||
|
msgStr: String,
|
||||||
|
msg: Message,
|
||||||
|
) {
|
||||||
|
when (msg) {
|
||||||
|
is NegMsgMessage -> handleNegMsg(relay, msg)
|
||||||
|
is NegErrMessage -> handleNegErr(relay, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleNegMsg(
|
||||||
|
relay: IRelayClient,
|
||||||
|
msg: NegMsgMessage,
|
||||||
|
) {
|
||||||
|
val (relayUrl, session) = activeSessions[msg.subId] ?: return
|
||||||
|
|
||||||
|
val result = session.processMessage(msg.message)
|
||||||
|
|
||||||
|
if (result.haveIds.isNotEmpty()) {
|
||||||
|
listener.onHaveIds(relayUrl, msg.subId, result.haveIds)
|
||||||
|
}
|
||||||
|
if (result.needIds.isNotEmpty()) {
|
||||||
|
listener.onNeedIds(relayUrl, msg.subId, result.needIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.nextCmd != null) {
|
||||||
|
relay.sendIfConnected(result.nextCmd)
|
||||||
|
} else {
|
||||||
|
activeSessions.remove(msg.subId)
|
||||||
|
relay.sendIfConnected(session.close())
|
||||||
|
listener.onComplete(relayUrl, msg.subId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleNegErr(
|
||||||
|
relay: IRelayClient,
|
||||||
|
msg: NegErrMessage,
|
||||||
|
) {
|
||||||
|
val (relayUrl, _) = activeSessions.remove(msg.subId) ?: return
|
||||||
|
listener.onError(relayUrl, msg.subId, msg.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDisconnected(relay: IRelayClient) {
|
||||||
|
val toRemove = activeSessions.filter { it.value.first == relay.url }
|
||||||
|
toRemove.forEach { (subId, pair) ->
|
||||||
|
activeSessions.remove(subId)
|
||||||
|
listener.onError(pair.first, subId, "closed: relay disconnected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip77Negentropy
|
||||||
|
|
||||||
|
import com.vitorpamplona.negentropy.Negentropy
|
||||||
|
import com.vitorpamplona.negentropy.storage.StorageVector
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.utils.Hex
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side handler for NIP-77 negentropy reconciliation.
|
||||||
|
*
|
||||||
|
* Used when acting as a relay (or relay-relay sync) to respond to
|
||||||
|
* incoming NEG-OPEN and NEG-MSG from a client.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* 1. On NEG-OPEN: create a [NegentropyServerSession] with the matching local events
|
||||||
|
* 2. Call [processMessage] with the initial hex message from NEG-OPEN
|
||||||
|
* 3. Send back the resulting [NegMsgMessage]
|
||||||
|
* 4. On subsequent NEG-MSG: call [processMessage] again and send the response
|
||||||
|
*/
|
||||||
|
class NegentropyServerSession(
|
||||||
|
val subId: String,
|
||||||
|
localEvents: List<Event>,
|
||||||
|
frameSizeLimit: Long = 0,
|
||||||
|
) {
|
||||||
|
private val storage = StorageVector()
|
||||||
|
private val negentropy: Negentropy
|
||||||
|
|
||||||
|
init {
|
||||||
|
for (event in localEvents) {
|
||||||
|
storage.insert(event.createdAt, event.id)
|
||||||
|
}
|
||||||
|
storage.seal()
|
||||||
|
negentropy = Negentropy(storage, frameSizeLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun processMessage(hexMessage: String): NegMsgMessage? {
|
||||||
|
val msgBytes = Hex.decode(hexMessage)
|
||||||
|
val result = negentropy.reconcile(msgBytes)
|
||||||
|
return if (result.msg != null) {
|
||||||
|
NegMsgMessage(
|
||||||
|
subId = subId,
|
||||||
|
message = Hex.encode(result.msg!!),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.nip77Negentropy
|
||||||
|
|
||||||
|
import com.vitorpamplona.negentropy.Negentropy
|
||||||
|
import com.vitorpamplona.negentropy.storage.StorageVector
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.utils.Hex
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages a single NIP-77 negentropy reconciliation session with a relay.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* 1. Create a NegentropySession with local events and a filter
|
||||||
|
* 2. Call [open] to get the NEG-OPEN command
|
||||||
|
* 3. Feed relay NEG-MSG responses via [processMessage]
|
||||||
|
* 4. If [processMessage] returns a non-null [NegMsgCmd], send it back
|
||||||
|
* 5. Repeat until [processMessage] returns a result with a null command (reconciliation complete)
|
||||||
|
* 6. Use [haveIds] and [needIds] from the result to send/request events
|
||||||
|
* 7. Call [close] to produce a NEG-CLOSE command when done
|
||||||
|
*/
|
||||||
|
class NegentropySession(
|
||||||
|
val subId: String,
|
||||||
|
val filter: Filter,
|
||||||
|
localEvents: List<Event>,
|
||||||
|
frameSizeLimit: Long = 0,
|
||||||
|
) {
|
||||||
|
private val storage = StorageVector()
|
||||||
|
private val negentropy: Negentropy
|
||||||
|
|
||||||
|
init {
|
||||||
|
for (event in localEvents) {
|
||||||
|
storage.insert(event.createdAt, event.id)
|
||||||
|
}
|
||||||
|
storage.seal()
|
||||||
|
negentropy = Negentropy(storage, frameSizeLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun open(): NegOpenCmd {
|
||||||
|
val initialMessage = negentropy.initiate()
|
||||||
|
return NegOpenCmd(
|
||||||
|
subId = subId,
|
||||||
|
filter = filter,
|
||||||
|
initialMessage = Hex.encode(initialMessage),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun processMessage(hexMessage: String): ReconcileResult {
|
||||||
|
val msgBytes = Hex.decode(hexMessage)
|
||||||
|
val result = negentropy.reconcile(msgBytes)
|
||||||
|
|
||||||
|
val haveIds = result.sendIds.map { it.toHexString() }
|
||||||
|
val needIds = result.needIds.map { it.toHexString() }
|
||||||
|
|
||||||
|
val nextCmd =
|
||||||
|
if (result.msg != null) {
|
||||||
|
NegMsgCmd(
|
||||||
|
subId = subId,
|
||||||
|
message = Hex.encode(result.msg!!),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReconcileResult(
|
||||||
|
nextCmd = nextCmd,
|
||||||
|
haveIds = haveIds,
|
||||||
|
needIds = needIds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close(): NegCloseCmd = NegCloseCmd(subId)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReconcileResult(
|
||||||
|
val nextCmd: NegMsgCmd?,
|
||||||
|
val haveIds: List<String>,
|
||||||
|
val needIds: List<String>,
|
||||||
|
) {
|
||||||
|
fun isComplete() = nextCmd == null
|
||||||
|
}
|
||||||
+306
@@ -0,0 +1,306 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip77Negentropy
|
||||||
|
|
||||||
|
import com.vitorpamplona.negentropy.Negentropy
|
||||||
|
import com.vitorpamplona.negentropy.storage.StorageVector
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.utils.Hex
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class NegentropySessionTest {
|
||||||
|
private fun makeEvent(
|
||||||
|
id: String,
|
||||||
|
createdAt: Long,
|
||||||
|
): Event =
|
||||||
|
Event(
|
||||||
|
id = id,
|
||||||
|
pubKey = "a".repeat(64),
|
||||||
|
createdAt = createdAt,
|
||||||
|
kind = 1,
|
||||||
|
tags = emptyArray(),
|
||||||
|
content = "",
|
||||||
|
sig = "b".repeat(64),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientServerSync_identicalSets_noIdsExchanged() {
|
||||||
|
val events =
|
||||||
|
listOf(
|
||||||
|
makeEvent("a".repeat(64), 1000),
|
||||||
|
makeEvent("b".repeat(64), 2000),
|
||||||
|
makeEvent("c".repeat(64), 3000),
|
||||||
|
)
|
||||||
|
|
||||||
|
val clientSession = NegentropySession("sub1", Filter(), events)
|
||||||
|
val openCmd = clientSession.open()
|
||||||
|
|
||||||
|
assertEquals("NEG-OPEN", openCmd.label())
|
||||||
|
assertEquals("sub1", openCmd.subId)
|
||||||
|
assertTrue(openCmd.initialMessage.isNotEmpty())
|
||||||
|
|
||||||
|
// Server side: same events
|
||||||
|
val serverStorage = StorageVector()
|
||||||
|
for (e in events) {
|
||||||
|
serverStorage.insert(e.createdAt, e.id)
|
||||||
|
}
|
||||||
|
serverStorage.seal()
|
||||||
|
val serverNeg = Negentropy(serverStorage)
|
||||||
|
|
||||||
|
val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||||
|
assertNotNull(serverResponse.msg)
|
||||||
|
|
||||||
|
val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!))
|
||||||
|
|
||||||
|
// Identical sets: no haves or needs
|
||||||
|
assertEquals(0, result.haveIds.size)
|
||||||
|
assertEquals(0, result.needIds.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientServerSync_clientHasExtra_detectsHaveIds() {
|
||||||
|
val sharedEvents =
|
||||||
|
listOf(
|
||||||
|
makeEvent("a".repeat(64), 1000),
|
||||||
|
makeEvent("b".repeat(64), 2000),
|
||||||
|
)
|
||||||
|
|
||||||
|
val clientOnlyEvent = makeEvent("c".repeat(64), 3000)
|
||||||
|
val clientEvents = sharedEvents + clientOnlyEvent
|
||||||
|
|
||||||
|
val clientSession = NegentropySession("sub1", Filter(), clientEvents)
|
||||||
|
val openCmd = clientSession.open()
|
||||||
|
|
||||||
|
// Server side: only shared events
|
||||||
|
val serverStorage = StorageVector()
|
||||||
|
for (e in sharedEvents) {
|
||||||
|
serverStorage.insert(e.createdAt, e.id)
|
||||||
|
}
|
||||||
|
serverStorage.seal()
|
||||||
|
val serverNeg = Negentropy(serverStorage)
|
||||||
|
|
||||||
|
val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||||
|
assertNotNull(serverResponse.msg)
|
||||||
|
|
||||||
|
val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!))
|
||||||
|
|
||||||
|
// Client has extra event that server needs
|
||||||
|
assertTrue(result.haveIds.contains("c".repeat(64)))
|
||||||
|
assertEquals(0, result.needIds.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientServerSync_serverHasExtra_detectsNeedIds() {
|
||||||
|
val sharedEvents =
|
||||||
|
listOf(
|
||||||
|
makeEvent("a".repeat(64), 1000),
|
||||||
|
makeEvent("b".repeat(64), 2000),
|
||||||
|
)
|
||||||
|
|
||||||
|
val clientSession = NegentropySession("sub1", Filter(), sharedEvents)
|
||||||
|
val openCmd = clientSession.open()
|
||||||
|
|
||||||
|
// Server side: shared + extra
|
||||||
|
val serverStorage = StorageVector()
|
||||||
|
for (e in sharedEvents) {
|
||||||
|
serverStorage.insert(e.createdAt, e.id)
|
||||||
|
}
|
||||||
|
serverStorage.insert(3000, "d".repeat(64))
|
||||||
|
serverStorage.seal()
|
||||||
|
val serverNeg = Negentropy(serverStorage)
|
||||||
|
|
||||||
|
val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||||
|
assertNotNull(serverResponse.msg)
|
||||||
|
|
||||||
|
val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!))
|
||||||
|
|
||||||
|
// Server has an event client needs
|
||||||
|
assertTrue(result.needIds.contains("d".repeat(64)))
|
||||||
|
assertEquals(0, result.haveIds.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientServerSync_bothHaveUnique_detectsBothDirections() {
|
||||||
|
val sharedEvents =
|
||||||
|
listOf(
|
||||||
|
makeEvent("a".repeat(64), 1000),
|
||||||
|
)
|
||||||
|
|
||||||
|
val clientOnly = makeEvent("b".repeat(64), 2000)
|
||||||
|
val clientEvents = sharedEvents + clientOnly
|
||||||
|
|
||||||
|
val clientSession = NegentropySession("sub1", Filter(), clientEvents)
|
||||||
|
val openCmd = clientSession.open()
|
||||||
|
|
||||||
|
// Server side: shared + different extra
|
||||||
|
val serverStorage = StorageVector()
|
||||||
|
for (e in sharedEvents) {
|
||||||
|
serverStorage.insert(e.createdAt, e.id)
|
||||||
|
}
|
||||||
|
serverStorage.insert(3000, "c".repeat(64))
|
||||||
|
serverStorage.seal()
|
||||||
|
val serverNeg = Negentropy(serverStorage)
|
||||||
|
|
||||||
|
val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||||
|
assertNotNull(serverResponse.msg)
|
||||||
|
|
||||||
|
val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!))
|
||||||
|
|
||||||
|
assertTrue(result.haveIds.contains("b".repeat(64)))
|
||||||
|
assertTrue(result.needIds.contains("c".repeat(64)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientServerSync_emptyClient_needsAll() {
|
||||||
|
val clientSession = NegentropySession("sub1", Filter(), emptyList())
|
||||||
|
val openCmd = clientSession.open()
|
||||||
|
|
||||||
|
// Server has events
|
||||||
|
val serverStorage = StorageVector()
|
||||||
|
serverStorage.insert(1000, "a".repeat(64))
|
||||||
|
serverStorage.insert(2000, "b".repeat(64))
|
||||||
|
serverStorage.seal()
|
||||||
|
val serverNeg = Negentropy(serverStorage)
|
||||||
|
|
||||||
|
val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||||
|
assertNotNull(serverResponse.msg)
|
||||||
|
|
||||||
|
val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!))
|
||||||
|
|
||||||
|
assertEquals(0, result.haveIds.size)
|
||||||
|
assertEquals(2, result.needIds.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientServerSync_emptyServer_hasAll() {
|
||||||
|
val clientEvents =
|
||||||
|
listOf(
|
||||||
|
makeEvent("a".repeat(64), 1000),
|
||||||
|
makeEvent("b".repeat(64), 2000),
|
||||||
|
)
|
||||||
|
|
||||||
|
val clientSession = NegentropySession("sub1", Filter(), clientEvents)
|
||||||
|
val openCmd = clientSession.open()
|
||||||
|
|
||||||
|
// Server is empty
|
||||||
|
val serverStorage = StorageVector()
|
||||||
|
serverStorage.seal()
|
||||||
|
val serverNeg = Negentropy(serverStorage)
|
||||||
|
|
||||||
|
val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||||
|
assertNotNull(serverResponse.msg)
|
||||||
|
|
||||||
|
val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!))
|
||||||
|
|
||||||
|
assertEquals(2, result.haveIds.size)
|
||||||
|
assertEquals(0, result.needIds.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multiRoundSync_withFrameLimit() {
|
||||||
|
// Create enough events to require multiple rounds with a small frame limit
|
||||||
|
val clientEvents =
|
||||||
|
(1..500).map { i ->
|
||||||
|
makeEvent(
|
||||||
|
i.toString().padStart(64, '0'),
|
||||||
|
i.toLong() * 1000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val serverEvents =
|
||||||
|
(251..750).map { i ->
|
||||||
|
Pair(i.toLong() * 1000, i.toString().padStart(64, '0'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small frame limit to force multiple rounds
|
||||||
|
val clientSession = NegentropySession("sub1", Filter(), clientEvents, frameSizeLimit = 4096)
|
||||||
|
val openCmd = clientSession.open()
|
||||||
|
|
||||||
|
val serverStorage = StorageVector()
|
||||||
|
for ((ts, id) in serverEvents) {
|
||||||
|
serverStorage.insert(ts, id)
|
||||||
|
}
|
||||||
|
serverStorage.seal()
|
||||||
|
val serverNeg = Negentropy(serverStorage, 4096)
|
||||||
|
|
||||||
|
var serverMsg = serverNeg.reconcile(Hex.decode(openCmd.initialMessage))
|
||||||
|
assertNotNull(serverMsg.msg)
|
||||||
|
|
||||||
|
val allHaveIds = mutableListOf<String>()
|
||||||
|
val allNeedIds = mutableListOf<String>()
|
||||||
|
|
||||||
|
var result = clientSession.processMessage(Hex.encode(serverMsg.msg!!))
|
||||||
|
allHaveIds.addAll(result.haveIds)
|
||||||
|
allNeedIds.addAll(result.needIds)
|
||||||
|
|
||||||
|
var rounds = 1
|
||||||
|
while (!result.isComplete()) {
|
||||||
|
rounds++
|
||||||
|
serverMsg = serverNeg.reconcile(Hex.decode(result.nextCmd!!.message))
|
||||||
|
assertNotNull(serverMsg.msg)
|
||||||
|
result = clientSession.processMessage(Hex.encode(serverMsg.msg!!))
|
||||||
|
allHaveIds.addAll(result.haveIds)
|
||||||
|
allNeedIds.addAll(result.needIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client has 1-250 exclusively, server has 501-750 exclusively
|
||||||
|
assertEquals(250, allHaveIds.size)
|
||||||
|
assertEquals(250, allNeedIds.size)
|
||||||
|
assertTrue(rounds > 1, "Should require multiple rounds with frame limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serverSession_processesMessages() {
|
||||||
|
val sharedEvents =
|
||||||
|
listOf(
|
||||||
|
makeEvent("a".repeat(64), 1000),
|
||||||
|
)
|
||||||
|
val serverOnlyEvent = makeEvent("b".repeat(64), 2000)
|
||||||
|
val serverEvents = sharedEvents + serverOnlyEvent
|
||||||
|
|
||||||
|
// Client initiates
|
||||||
|
val clientSession = NegentropySession("sub1", Filter(), sharedEvents)
|
||||||
|
val openCmd = clientSession.open()
|
||||||
|
|
||||||
|
// Server processes via NegentropyServerSession
|
||||||
|
val serverSession = NegentropyServerSession("sub1", serverEvents)
|
||||||
|
val response = serverSession.processMessage(openCmd.initialMessage)
|
||||||
|
|
||||||
|
assertNotNull(response)
|
||||||
|
assertEquals("NEG-MSG", response.label())
|
||||||
|
assertEquals("sub1", response.subId)
|
||||||
|
assertTrue(response.message.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun close_returnsCorrectCmd() {
|
||||||
|
val session = NegentropySession("sub1", Filter(), emptyList())
|
||||||
|
val closeCmd = session.close()
|
||||||
|
|
||||||
|
assertEquals("NEG-CLOSE", closeCmd.label())
|
||||||
|
assertEquals("sub1", closeCmd.subId)
|
||||||
|
assertTrue(closeCmd.isValid())
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -26,6 +26,8 @@ import com.fasterxml.jackson.databind.DeserializationContext
|
|||||||
import com.fasterxml.jackson.databind.JsonNode
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
|
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
|
||||||
import com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer
|
import com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage
|
||||||
|
|
||||||
class MessageDeserializer : StdDeserializer<Message>(Message::class.java) {
|
class MessageDeserializer : StdDeserializer<Message>(Message::class.java) {
|
||||||
val eventDeserializer = EventDeserializer()
|
val eventDeserializer = EventDeserializer()
|
||||||
@@ -101,6 +103,20 @@ class MessageDeserializer : StdDeserializer<Message>(Message::class.java) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NegMsgMessage.LABEL -> {
|
||||||
|
NegMsgMessage(
|
||||||
|
subId = jp.nextTextValue(),
|
||||||
|
message = jp.nextTextValue(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NegErrMessage.LABEL -> {
|
||||||
|
NegErrMessage(
|
||||||
|
subId = jp.nextTextValue(),
|
||||||
|
reason = jp.nextTextValue() ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
throw IllegalArgumentException("Message $type is not supported")
|
throw IllegalArgumentException("Message $type is not supported")
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -24,6 +24,8 @@ import com.fasterxml.jackson.core.JsonGenerator
|
|||||||
import com.fasterxml.jackson.databind.SerializerProvider
|
import com.fasterxml.jackson.databind.SerializerProvider
|
||||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer
|
import com.fasterxml.jackson.databind.ser.std.StdSerializer
|
||||||
import com.vitorpamplona.quartz.nip01Core.jackson.EventSerializer
|
import com.vitorpamplona.quartz.nip01Core.jackson.EventSerializer
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage
|
||||||
|
|
||||||
class MessageSerializer : StdSerializer<Message>(Message::class.java) {
|
class MessageSerializer : StdSerializer<Message>(Message::class.java) {
|
||||||
val eventSerializer = EventSerializer()
|
val eventSerializer = EventSerializer()
|
||||||
@@ -75,6 +77,16 @@ class MessageSerializer : StdSerializer<Message>(Message::class.java) {
|
|||||||
is EoseMessage -> {
|
is EoseMessage -> {
|
||||||
gen.writeString(msg.subId)
|
gen.writeString(msg.subId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is NegMsgMessage -> {
|
||||||
|
gen.writeString(msg.subId)
|
||||||
|
gen.writeString(msg.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NegErrMessage -> {
|
||||||
|
gen.writeString(msg.subId)
|
||||||
|
gen.writeString(msg.reason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
gen.writeEndArray()
|
gen.writeEndArray()
|
||||||
|
|||||||
+30
@@ -29,6 +29,9 @@ import com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer
|
|||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.ManualFilterDeserializer
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.ManualFilterDeserializer
|
||||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
|
||||||
|
|
||||||
class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
|
class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
|
||||||
val eventDeserializer = EventDeserializer()
|
val eventDeserializer = EventDeserializer()
|
||||||
@@ -97,6 +100,33 @@ class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NegOpenCmd.LABEL -> {
|
||||||
|
val subId = jp.nextTextValue()
|
||||||
|
jp.nextToken()
|
||||||
|
val filterObj: ObjectNode = jp.codec.readTree(jp)
|
||||||
|
val filter = ManualFilterDeserializer.fromJson(filterObj)
|
||||||
|
val initialMessage = jp.nextTextValue()
|
||||||
|
|
||||||
|
NegOpenCmd(
|
||||||
|
subId = subId,
|
||||||
|
filter = filter,
|
||||||
|
initialMessage = initialMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NegMsgCmd.LABEL -> {
|
||||||
|
NegMsgCmd(
|
||||||
|
subId = jp.nextTextValue(),
|
||||||
|
message = jp.nextTextValue(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NegCloseCmd.LABEL -> {
|
||||||
|
NegCloseCmd(
|
||||||
|
subId = jp.nextTextValue(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
throw IllegalArgumentException("Message $type is not supported")
|
throw IllegalArgumentException("Message $type is not supported")
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -25,6 +25,9 @@ import com.fasterxml.jackson.databind.SerializerProvider
|
|||||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer
|
import com.fasterxml.jackson.databind.ser.std.StdSerializer
|
||||||
import com.vitorpamplona.quartz.nip01Core.jackson.EventSerializer
|
import com.vitorpamplona.quartz.nip01Core.jackson.EventSerializer
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
|
||||||
|
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
|
||||||
|
|
||||||
class CommandSerializer : StdSerializer<Command>(Command::class.java) {
|
class CommandSerializer : StdSerializer<Command>(Command::class.java) {
|
||||||
val eventSerializer = EventSerializer()
|
val eventSerializer = EventSerializer()
|
||||||
@@ -65,6 +68,21 @@ class CommandSerializer : StdSerializer<Command>(Command::class.java) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is NegOpenCmd -> {
|
||||||
|
gen.writeString(cmd.subId)
|
||||||
|
filterSerializer.serialize(cmd.filter, gen, provider)
|
||||||
|
gen.writeString(cmd.initialMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NegMsgCmd -> {
|
||||||
|
gen.writeString(cmd.subId)
|
||||||
|
gen.writeString(cmd.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NegCloseCmd -> {
|
||||||
|
gen.writeString(cmd.subId)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
+286
@@ -0,0 +1,286 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip77Negentropy
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class Nip77SerializationTest {
|
||||||
|
// =========================================================================
|
||||||
|
// NEG-MSG Message (relay-to-client) Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serializeNegMsgMessage_matchesJackson() {
|
||||||
|
val msg = NegMsgMessage("neg-sub1", "abcdef0123456789")
|
||||||
|
val jacksonJson = JacksonMapper.toJson(msg)
|
||||||
|
val kotlinJson = KotlinSerializationMapper.toJson(msg)
|
||||||
|
assertEquals(jacksonJson, kotlinJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deserializeNegMsgMessage_jackson() {
|
||||||
|
val json = """["NEG-MSG","neg-sub1","abcdef0123456789"]"""
|
||||||
|
val deserialized = JacksonMapper.fromJsonToMessage(json)
|
||||||
|
|
||||||
|
assertTrue(deserialized is NegMsgMessage)
|
||||||
|
assertEquals("neg-sub1", deserialized.subId)
|
||||||
|
assertEquals("abcdef0123456789", deserialized.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deserializeNegMsgMessage_kotlin() {
|
||||||
|
val json = """["NEG-MSG","neg-sub1","abcdef0123456789"]"""
|
||||||
|
val deserialized = KotlinSerializationMapper.fromJsonToMessage(json)
|
||||||
|
|
||||||
|
assertTrue(deserialized is NegMsgMessage)
|
||||||
|
assertEquals("neg-sub1", deserialized.subId)
|
||||||
|
assertEquals("abcdef0123456789", deserialized.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negMsgMessage_crossDeserialization() {
|
||||||
|
val msg = NegMsgMessage("neg-sub1", "abcdef0123456789")
|
||||||
|
|
||||||
|
val jacksonJson = JacksonMapper.toJson(msg)
|
||||||
|
val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(jacksonJson)
|
||||||
|
assertTrue(kotlinDeserialized is NegMsgMessage)
|
||||||
|
assertEquals(msg.subId, kotlinDeserialized.subId)
|
||||||
|
assertEquals(msg.message, kotlinDeserialized.message)
|
||||||
|
|
||||||
|
val kotlinJson = KotlinSerializationMapper.toJson(msg)
|
||||||
|
val jacksonDeserialized = JacksonMapper.fromJsonToMessage(kotlinJson)
|
||||||
|
assertTrue(jacksonDeserialized is NegMsgMessage)
|
||||||
|
assertEquals(msg.subId, (jacksonDeserialized as NegMsgMessage).subId)
|
||||||
|
assertEquals(msg.message, jacksonDeserialized.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// NEG-ERR Message (relay-to-client) Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serializeNegErrMessage_matchesJackson() {
|
||||||
|
val msg = NegErrMessage("neg-sub1", "blocked: query too large")
|
||||||
|
val jacksonJson = JacksonMapper.toJson(msg)
|
||||||
|
val kotlinJson = KotlinSerializationMapper.toJson(msg)
|
||||||
|
assertEquals(jacksonJson, kotlinJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deserializeNegErrMessage_jackson() {
|
||||||
|
val json = """["NEG-ERR","neg-sub1","blocked: query too large"]"""
|
||||||
|
val deserialized = JacksonMapper.fromJsonToMessage(json)
|
||||||
|
|
||||||
|
assertTrue(deserialized is NegErrMessage)
|
||||||
|
assertEquals("neg-sub1", deserialized.subId)
|
||||||
|
assertEquals("blocked: query too large", deserialized.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deserializeNegErrMessage_kotlin() {
|
||||||
|
val json = """["NEG-ERR","neg-sub1","closed: timeout"]"""
|
||||||
|
val deserialized = KotlinSerializationMapper.fromJsonToMessage(json)
|
||||||
|
|
||||||
|
assertTrue(deserialized is NegErrMessage)
|
||||||
|
assertEquals("neg-sub1", deserialized.subId)
|
||||||
|
assertEquals("closed: timeout", deserialized.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negErrMessage_crossDeserialization() {
|
||||||
|
val msg = NegErrMessage("neg-sub1", "blocked: too many records")
|
||||||
|
|
||||||
|
val jacksonJson = JacksonMapper.toJson(msg)
|
||||||
|
val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(jacksonJson)
|
||||||
|
assertTrue(kotlinDeserialized is NegErrMessage)
|
||||||
|
assertEquals(msg.reason, (kotlinDeserialized as NegErrMessage).reason)
|
||||||
|
|
||||||
|
val kotlinJson = KotlinSerializationMapper.toJson(msg)
|
||||||
|
val jacksonDeserialized = JacksonMapper.fromJsonToMessage(kotlinJson)
|
||||||
|
assertTrue(jacksonDeserialized is NegErrMessage)
|
||||||
|
assertEquals(msg.reason, (jacksonDeserialized as NegErrMessage).reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// NEG-OPEN Command (client-to-relay) Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serializeNegOpenCmd_matchesJackson() {
|
||||||
|
val filter = Filter(kinds = listOf(1), since = 1000L)
|
||||||
|
val cmd = NegOpenCmd("neg-sub1", filter, "61abcdef")
|
||||||
|
val jacksonJson = JacksonMapper.toJson(cmd)
|
||||||
|
val kotlinJson = KotlinSerializationMapper.toJson(cmd)
|
||||||
|
assertEquals(jacksonJson, kotlinJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deserializeNegOpenCmd_jackson() {
|
||||||
|
val json = """["NEG-OPEN","neg-sub1",{"kinds":[1],"since":1000},"61abcdef"]"""
|
||||||
|
val deserialized = JacksonMapper.fromJsonToCommand(json)
|
||||||
|
|
||||||
|
assertTrue(deserialized is NegOpenCmd)
|
||||||
|
assertEquals("neg-sub1", deserialized.subId)
|
||||||
|
assertEquals(listOf(1), deserialized.filter.kinds)
|
||||||
|
assertEquals(1000L, deserialized.filter.since)
|
||||||
|
assertEquals("61abcdef", deserialized.initialMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deserializeNegOpenCmd_kotlin() {
|
||||||
|
val json = """["NEG-OPEN","neg-sub1",{"kinds":[1],"since":1000},"61abcdef"]"""
|
||||||
|
val deserialized = KotlinSerializationMapper.fromJsonToCommand(json)
|
||||||
|
|
||||||
|
assertTrue(deserialized is NegOpenCmd)
|
||||||
|
assertEquals("neg-sub1", deserialized.subId)
|
||||||
|
assertEquals(listOf(1), deserialized.filter.kinds)
|
||||||
|
assertEquals("61abcdef", deserialized.initialMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negOpenCmd_crossDeserialization() {
|
||||||
|
val filter = Filter(kinds = listOf(1, 7), limit = 100)
|
||||||
|
val cmd = NegOpenCmd("neg-sub1", filter, "61aabbccdd")
|
||||||
|
|
||||||
|
val jacksonJson = JacksonMapper.toJson(cmd)
|
||||||
|
val kotlinDeserialized = KotlinSerializationMapper.fromJsonToCommand(jacksonJson)
|
||||||
|
assertTrue(kotlinDeserialized is NegOpenCmd)
|
||||||
|
assertEquals(cmd.subId, kotlinDeserialized.subId)
|
||||||
|
assertEquals(cmd.initialMessage, kotlinDeserialized.initialMessage)
|
||||||
|
|
||||||
|
val kotlinJson = KotlinSerializationMapper.toJson(cmd)
|
||||||
|
val jacksonDeserialized = JacksonMapper.fromJsonToCommand(kotlinJson)
|
||||||
|
assertTrue(jacksonDeserialized is NegOpenCmd)
|
||||||
|
assertEquals(cmd.subId, (jacksonDeserialized as NegOpenCmd).subId)
|
||||||
|
assertEquals(cmd.initialMessage, jacksonDeserialized.initialMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// NEG-MSG Command (client-to-relay) Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serializeNegMsgCmd_matchesJackson() {
|
||||||
|
val cmd = NegMsgCmd("neg-sub1", "aabbccdd")
|
||||||
|
val jacksonJson = JacksonMapper.toJson(cmd)
|
||||||
|
val kotlinJson = KotlinSerializationMapper.toJson(cmd)
|
||||||
|
assertEquals(jacksonJson, kotlinJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deserializeNegMsgCmd_jackson() {
|
||||||
|
val json = """["NEG-MSG","neg-sub1","aabbccdd"]"""
|
||||||
|
val deserialized = JacksonMapper.fromJsonToCommand(json)
|
||||||
|
|
||||||
|
assertTrue(deserialized is NegMsgCmd)
|
||||||
|
assertEquals("neg-sub1", deserialized.subId)
|
||||||
|
assertEquals("aabbccdd", deserialized.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deserializeNegMsgCmd_kotlin() {
|
||||||
|
val json = """["NEG-MSG","neg-sub1","aabbccdd"]"""
|
||||||
|
val deserialized = KotlinSerializationMapper.fromJsonToCommand(json)
|
||||||
|
|
||||||
|
assertTrue(deserialized is NegMsgCmd)
|
||||||
|
assertEquals("neg-sub1", deserialized.subId)
|
||||||
|
assertEquals("aabbccdd", deserialized.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// NEG-CLOSE Command (client-to-relay) Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serializeNegCloseCmd_matchesJackson() {
|
||||||
|
val cmd = NegCloseCmd("neg-sub1")
|
||||||
|
val jacksonJson = JacksonMapper.toJson(cmd)
|
||||||
|
val kotlinJson = KotlinSerializationMapper.toJson(cmd)
|
||||||
|
assertEquals(jacksonJson, kotlinJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deserializeNegCloseCmd_jackson() {
|
||||||
|
val json = """["NEG-CLOSE","neg-sub1"]"""
|
||||||
|
val deserialized = JacksonMapper.fromJsonToCommand(json)
|
||||||
|
|
||||||
|
assertTrue(deserialized is NegCloseCmd)
|
||||||
|
assertEquals("neg-sub1", deserialized.subId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deserializeNegCloseCmd_kotlin() {
|
||||||
|
val json = """["NEG-CLOSE","neg-sub1"]"""
|
||||||
|
val deserialized = KotlinSerializationMapper.fromJsonToCommand(json)
|
||||||
|
|
||||||
|
assertTrue(deserialized is NegCloseCmd)
|
||||||
|
assertEquals("neg-sub1", deserialized.subId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Validation Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negOpenCmd_isValid() {
|
||||||
|
val valid = NegOpenCmd("sub1", Filter(), "61abcd")
|
||||||
|
assertTrue(valid.isValid())
|
||||||
|
|
||||||
|
val invalidSubId = NegOpenCmd("", Filter(), "61abcd")
|
||||||
|
assertTrue(!invalidSubId.isValid())
|
||||||
|
|
||||||
|
val invalidMsg = NegOpenCmd("sub1", Filter(), "")
|
||||||
|
assertTrue(!invalidMsg.isValid())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negMsgCmd_isValid() {
|
||||||
|
val valid = NegMsgCmd("sub1", "abcd")
|
||||||
|
assertTrue(valid.isValid())
|
||||||
|
|
||||||
|
val invalidSubId = NegMsgCmd("", "abcd")
|
||||||
|
assertTrue(!invalidSubId.isValid())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negCloseCmd_isValid() {
|
||||||
|
val valid = NegCloseCmd("sub1")
|
||||||
|
assertTrue(valid.isValid())
|
||||||
|
|
||||||
|
val invalid = NegCloseCmd("")
|
||||||
|
assertTrue(!invalid.isValid())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negMsgMessage_label() {
|
||||||
|
assertEquals("NEG-MSG", NegMsgMessage("sub1", "msg").label())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negErrMessage_label() {
|
||||||
|
assertEquals("NEG-ERR", NegErrMessage("sub1", "error").label())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user