mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
4 Commits
b6bf3b8a70
...
claude/sql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab4e2df043 | ||
|
|
5623cbf515 | ||
|
|
22527f82ce | ||
|
|
51fc2328c7 |
@@ -52,6 +52,8 @@ import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
|
||||
import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
|
||||
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
|
||||
import com.vitorpamplona.amethyst.service.images.ThumbnailDiskCache
|
||||
import com.vitorpamplona.amethyst.service.localStore.LocalEventStore
|
||||
import com.vitorpamplona.amethyst.service.localStore.LocalRelayBuilder
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
|
||||
@@ -93,6 +95,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
|
||||
@@ -479,8 +482,25 @@ class AppModules(
|
||||
OkHttpLnurlEndpointResolver(roleBasedHttpClientBuilder::okHttpClientForMoney)
|
||||
}
|
||||
|
||||
// Provides a relay pool
|
||||
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
|
||||
// On-device SQLite relay that permanently keeps kind-0, relay lists and
|
||||
// trusted assertions for every user we see. Exposed as just another relay
|
||||
// (LOCAL_RELAY_URL) so any NostrClient subscription/publish can query it
|
||||
// in-process, without a websocket. See LocalRelayWebsocketBuilder.
|
||||
val localEventStore =
|
||||
LocalEventStore(
|
||||
dbFile = File(appContext.filesDir, "local-event-store/events.db"),
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
// Provides a relay pool. The relay builder is wrapped so the local URL is
|
||||
// backed by a LocalStoreRelayClient (served straight from SQLite, no socket);
|
||||
// every other URL is a normal websocket-backed relay.
|
||||
val client: INostrClient =
|
||||
NostrClient(
|
||||
websocketBuilder = websocketBuilder,
|
||||
parentScope = applicationIOScope,
|
||||
relayBuilder = LocalRelayBuilder(BasicRelayBuilder(websocketBuilder), localEventStore),
|
||||
)
|
||||
|
||||
// Self-heals the "Tor Active but every circuit dead" state the lifecycle watchdogs can't
|
||||
// see (they only arm while Connecting). Watches Tor-routed relay outcomes and, when enough
|
||||
@@ -506,8 +526,9 @@ class AppModules(
|
||||
applicationIOScope,
|
||||
)
|
||||
|
||||
// Verifies and inserts in the cache from all relays, all subscriptions
|
||||
val cacheClientConnector = CacheClientConnector(client, cache)
|
||||
// Verifies and inserts in the cache from all relays, all subscriptions.
|
||||
// Also write-through every persistable event into the local relay store.
|
||||
val cacheClientConnector = CacheClientConnector(client, cache, localEventStore)
|
||||
|
||||
// Show messages from the Relay and controls their dismissal
|
||||
val notifyCoordinator = NotifyCoordinator(client)
|
||||
@@ -744,6 +765,10 @@ class AppModules(
|
||||
sessionManager.loginWithDefaultAccountIfLoggedOff()
|
||||
}
|
||||
|
||||
// Opens the on-device relay store off the main thread so its first
|
||||
// query/write doesn't pay the DB-open cost inline.
|
||||
localEventStore.warmup()
|
||||
|
||||
// forces initialization of uiPrefs in the main thread to avoid blinking themes
|
||||
uiPrefs
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* 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.amethyst.service.localStore
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
|
||||
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.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.addressables.AddressableAssertionEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.events.EventAssertionEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.externalIds.ExternalIdAssertionEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* On-device SQLite store that permanently keeps the user-directory events the
|
||||
* app needs to render people: kind 0 (profile metadata), relay lists (NIP-65,
|
||||
* DM, search and the NIP-51 relay lists) and trusted assertions (NIP-85), for
|
||||
* every user we see.
|
||||
*
|
||||
* The [store] is exposed to the relay client as just another relay via
|
||||
* [LocalRelayBuilder] +
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.client.single.local.LocalStoreRelayClient],
|
||||
* so any [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient]
|
||||
* subscription/publish targeting [LOCAL_RELAY_URL] is answered straight from
|
||||
* SQLite — no socket, no relay-server engine.
|
||||
*
|
||||
* It is populated by a write-through from
|
||||
* [com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector]: every
|
||||
* persistable event arriving from a remote relay is also inserted here. Because
|
||||
* all persisted kinds are replaceable/addressable, the store keeps exactly one
|
||||
* current version per (kind, pubkey[, d-tag]), so it stays small even though it
|
||||
* never prunes by age. NIP-40 expiration and NIP-09 deletions are honoured by
|
||||
* the store's own triggers.
|
||||
*
|
||||
* A single global DB is used (not per-account): this is public, cross-account
|
||||
* data, and both the relay client and the cache are app-wide singletons.
|
||||
*/
|
||||
class LocalEventStore(
|
||||
private val dbFile: File,
|
||||
val scope: CoroutineScope,
|
||||
) : AutoCloseable {
|
||||
companion object {
|
||||
/** Stable URL clients use to reach this relay. */
|
||||
val LOCAL_RELAY_URL = NormalizedRelayUrl("ws://localhost/amethyst-local/")
|
||||
|
||||
/** The only kinds this store keeps. Everything else is ignored. */
|
||||
val PERSISTED_KINDS: Set<Int> =
|
||||
setOf(
|
||||
MetadataEvent.KIND, // 0
|
||||
// Relay lists
|
||||
AdvertisedRelayListEvent.KIND, // 10002 NIP-65
|
||||
ChatMessageRelayListEvent.KIND, // 10050 DM inbox relays
|
||||
SearchRelayListEvent.KIND, // 10007
|
||||
BlockedRelayListEvent.KIND, // 10006
|
||||
RelayFeedsListEvent.KIND, // 10012
|
||||
IndexerRelayListEvent.KIND, // 10086
|
||||
ProxyRelayListEvent.KIND, // 10087
|
||||
BroadcastRelayListEvent.KIND, // 10088
|
||||
TrustedRelayListEvent.KIND, // 10089
|
||||
// Trusted assertions (NIP-85)
|
||||
TrustProviderListEvent.KIND, // 10040
|
||||
ContactCardEvent.KIND, // 30382
|
||||
EventAssertionEvent.KIND, // 30383
|
||||
AddressableAssertionEvent.KIND, // 30384
|
||||
ExternalIdAssertionEvent.KIND, // 30385
|
||||
)
|
||||
|
||||
fun shouldPersist(event: Event): Boolean = event.kind in PERSISTED_KINDS
|
||||
}
|
||||
|
||||
// Opened lazily (off the main thread via warmup, or on first relay use).
|
||||
private val storeDelegate = lazy { openStore() }
|
||||
val store: EventStore by storeDelegate
|
||||
|
||||
// 250ms batching window so a burst of profiles collapses into one transaction.
|
||||
private val writeBundler = BasicBundledInsert<Event>(delay = 250, dispatcher = Dispatchers.IO, scope = scope)
|
||||
|
||||
private fun openStore(): EventStore {
|
||||
dbFile.parentFile?.mkdirs()
|
||||
val path = dbFile.absolutePath
|
||||
return try {
|
||||
EventStore(dbName = path, relay = LOCAL_RELAY_URL)
|
||||
} catch (e: Exception) {
|
||||
Log.w("LocalEventStore") { "DB open failed, recreating: ${e.message}" }
|
||||
deleteDbFiles(path)
|
||||
EventStore(dbName = path, relay = LOCAL_RELAY_URL)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the DB off the main thread before its first relay query/write. */
|
||||
fun warmup() {
|
||||
scope.launch {
|
||||
try {
|
||||
store.deleteExpiredEvents()
|
||||
} catch (e: Exception) {
|
||||
Log.w("LocalEventStore") { "Warmup failed: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write-through entry point. Called for every event consumed from a remote
|
||||
* relay; persists only the allow-listed kinds. Non-blocking — batches and
|
||||
* inserts on the store's coroutine scope.
|
||||
*/
|
||||
fun enqueue(event: Event) {
|
||||
if (!shouldPersist(event)) return
|
||||
writeBundler.invalidateList(event) { batch ->
|
||||
// batchInsert uses per-row savepoints, so a UNIQUE-constraint clash
|
||||
// (an older replaceable losing to a newer one) skips that row instead
|
||||
// of failing the whole batch.
|
||||
store.batchInsert(batch.toList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (storeDelegate.isInitialized()) {
|
||||
runCatching { store.close() }
|
||||
.onFailure { Log.w("LocalEventStore") { "Close error: ${it.message}" } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteDbFiles(path: String) {
|
||||
listOf("", "-wal", "-shm", "-journal").forEach { suffix ->
|
||||
File(path + suffix).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.amethyst.service.localStore
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.RelayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.local.LocalStoreRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* [RelayBuilder] that makes the on-device [LocalEventStore] just another relay
|
||||
* in the pool: [LocalEventStore.LOCAL_RELAY_URL] is served by a
|
||||
* [LocalStoreRelayClient] straight from SQLite (no socket, no JSON), and every
|
||||
* other URL is delegated to the real (websocket) [delegate] unchanged.
|
||||
*
|
||||
* Wrap the app's default relay builder with this before handing it to
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient].
|
||||
*/
|
||||
class LocalRelayBuilder(
|
||||
private val delegate: RelayBuilder,
|
||||
private val localStore: LocalEventStore,
|
||||
) : RelayBuilder {
|
||||
override fun build(
|
||||
url: NormalizedRelayUrl,
|
||||
listener: RelayConnectionListener,
|
||||
): IRelayClient =
|
||||
if (url == LocalEventStore.LOCAL_RELAY_URL) {
|
||||
LocalStoreRelayClient(url, localStore.store, listener, localStore.scope)
|
||||
} else {
|
||||
delegate.build(url, listener)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.localStore.LocalEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.EventCollector
|
||||
@@ -33,10 +34,17 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
class CacheClientConnector(
|
||||
val client: INostrClient,
|
||||
val cache: LocalCache,
|
||||
val localStore: LocalEventStore? = null,
|
||||
) {
|
||||
val receiver =
|
||||
EventCollector(client) { event, relay ->
|
||||
cache.justConsume(event, relay, false)
|
||||
val isNew = cache.justConsume(event, relay, false)
|
||||
// Write-through to the on-device store. Only newly-consumed events are
|
||||
// worth persisting; hydration goes straight to the cache (not via this
|
||||
// collector), so it never loops back into the store.
|
||||
if (isNew) {
|
||||
localStore?.enqueue(event)
|
||||
}
|
||||
}
|
||||
|
||||
val confirmationWatcher =
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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.amethyst.service.localStore
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.RelayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class LocalEventStoreTest {
|
||||
@Test
|
||||
fun keepsUserDirectoryKindsAndNothingElse() {
|
||||
// kind 0, relay lists and trusted assertions are persisted...
|
||||
assertTrue(MetadataEvent.KIND in LocalEventStore.PERSISTED_KINDS)
|
||||
assertTrue(AdvertisedRelayListEvent.KIND in LocalEventStore.PERSISTED_KINDS) // 10002
|
||||
assertTrue(ContactCardEvent.KIND in LocalEventStore.PERSISTED_KINDS) // 30382
|
||||
|
||||
// ...but feed content is not.
|
||||
assertFalse(1 in LocalEventStore.PERSISTED_KINDS) // text note
|
||||
assertFalse(3 in LocalEventStore.PERSISTED_KINDS) // contact list
|
||||
assertFalse(7 in LocalEventStore.PERSISTED_KINDS) // reaction
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delegatesNonLocalUrlsToTheRealBuilder() {
|
||||
val other = NormalizedRelayUrl("wss://relay.example.com/")
|
||||
val sentinel = NoopRelayClient(other)
|
||||
val delegate =
|
||||
object : RelayBuilder {
|
||||
var builtUrl: NormalizedRelayUrl? = null
|
||||
|
||||
override fun build(
|
||||
url: NormalizedRelayUrl,
|
||||
listener: RelayConnectionListener,
|
||||
): IRelayClient {
|
||||
builtUrl = url
|
||||
return sentinel
|
||||
}
|
||||
}
|
||||
|
||||
// DB stays closed: building a non-local relay never touches localStore.store.
|
||||
val store = LocalEventStore(File("unused/events.db"), CoroutineScope(Dispatchers.Unconfined))
|
||||
val builder = LocalRelayBuilder(delegate, store)
|
||||
|
||||
// A non-local URL is delegated to the real transport untouched.
|
||||
assertSame(sentinel, builder.build(other, NoopConnectionListener))
|
||||
assertEquals(other, delegate.builtUrl)
|
||||
}
|
||||
|
||||
private object NoopConnectionListener : RelayConnectionListener
|
||||
|
||||
private class NoopRelayClient(
|
||||
override val url: NormalizedRelayUrl,
|
||||
) : IRelayClient {
|
||||
override fun connect() = Unit
|
||||
|
||||
override fun needsToReconnect() = false
|
||||
|
||||
override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit
|
||||
|
||||
override fun isConnected() = false
|
||||
|
||||
override fun sendOrConnectAndSync(cmd: Command) = Unit
|
||||
|
||||
override fun sendIfConnected(cmd: Command) = Unit
|
||||
|
||||
override fun disconnect() = Unit
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolRequests
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.RelayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
@@ -80,10 +82,19 @@ import kotlinx.coroutines.launch
|
||||
class NostrClient(
|
||||
private val websocketBuilder: WebsocketBuilder,
|
||||
private val parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
/**
|
||||
* Decides what kind of [IRelayClient] backs each URL. Defaults to a
|
||||
* websocket-backed [BasicRelayBuilder] over [websocketBuilder] — the prior
|
||||
* behaviour. Pass a custom builder to serve specific URLs from another
|
||||
* transport (e.g. an on-device store via
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.client.single.local.LocalStoreRelayClient])
|
||||
* while delegating the rest to the default.
|
||||
*/
|
||||
relayBuilder: RelayBuilder = BasicRelayBuilder(websocketBuilder),
|
||||
) : INostrClient,
|
||||
RelayConnectionListener,
|
||||
AutoCloseable {
|
||||
private val relayPool: RelayPool = RelayPool(websocketBuilder, this)
|
||||
private val relayPool: RelayPool = RelayPool(relayBuilder, this)
|
||||
|
||||
/** Scope for all subscriptions. */
|
||||
private val scope = CoroutineScope(parentScope.coroutineContext + SupervisorJob())
|
||||
|
||||
@@ -23,11 +23,10 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.pool
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.RelayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -55,7 +54,7 @@ import kotlinx.coroutines.flow.update
|
||||
* - Maintaining optimal relay connections (updatePool/addRelay/removeRelay methods)
|
||||
*/
|
||||
class RelayPool(
|
||||
val websocketBuilder: WebsocketBuilder,
|
||||
val relayBuilder: RelayBuilder,
|
||||
val listener: RelayConnectionListener = EmptyConnectionListener,
|
||||
) : RelayConnectionListener {
|
||||
private val relays = LargeCache<NormalizedRelayUrl, IRelayClient>()
|
||||
@@ -68,12 +67,7 @@ class RelayPool(
|
||||
|
||||
fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url)
|
||||
|
||||
private fun createNewRelay(url: NormalizedRelayUrl) =
|
||||
BasicRelayClient(
|
||||
url = url,
|
||||
socketBuilder = websocketBuilder,
|
||||
listener = this,
|
||||
)
|
||||
private fun createNewRelay(url: NormalizedRelayUrl) = relayBuilder.build(url, this)
|
||||
|
||||
fun reconnectIfNeedsTo(ignoreRetryDelays: Boolean = false) {
|
||||
relays.forEach { url, relay ->
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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.nip01Core.relay.client.single
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Factory the [com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool]
|
||||
* uses to materialise an [IRelayClient] for a given URL. This is the relay-level
|
||||
* analogue of [com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder]:
|
||||
* where the websocket builder decides *how to dial* a relay, the relay builder
|
||||
* decides *what kind of relay client* to create at all.
|
||||
*
|
||||
* The default ([com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayBuilder])
|
||||
* always returns a websocket-backed [com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient].
|
||||
* Swap in a custom builder to serve specific URLs from a different transport —
|
||||
* e.g. an on-device [com.vitorpamplona.quartz.nip01Core.store.IEventStore] via
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.client.single.local.LocalStoreRelayClient]
|
||||
* — while delegating every other URL to the default. The pool then treats the
|
||||
* result like any other relay (connect, REQ/COUNT/EVENT, EOSE), with no socket
|
||||
* and no JSON in the path.
|
||||
*/
|
||||
fun interface RelayBuilder {
|
||||
fun build(
|
||||
url: NormalizedRelayUrl,
|
||||
listener: RelayConnectionListener,
|
||||
): IRelayClient
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.single.basic
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.RelayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
|
||||
/**
|
||||
* Default [RelayBuilder]: every URL becomes a websocket-backed
|
||||
* [BasicRelayClient] dialled through [socketBuilder]. This is the behaviour the
|
||||
* pool had before the builder was made pluggable, so it is the implicit default
|
||||
* when a [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] is
|
||||
* constructed with only a [WebsocketBuilder].
|
||||
*/
|
||||
class BasicRelayBuilder(
|
||||
val socketBuilder: WebsocketBuilder,
|
||||
) : RelayBuilder {
|
||||
override fun build(
|
||||
url: NormalizedRelayUrl,
|
||||
listener: RelayConnectionListener,
|
||||
): IRelayClient =
|
||||
BasicRelayClient(
|
||||
url = url,
|
||||
socketBuilder = socketBuilder,
|
||||
listener = listener,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* 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.nip01Core.relay.client.single.local
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
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.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* An [IRelayClient] that answers from a local [IEventStore] instead of a
|
||||
* websocket. Drop it into a [com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool]
|
||||
* (via a custom [com.vitorpamplona.quartz.nip01Core.relay.client.single.RelayBuilder])
|
||||
* and the on-device database becomes just another relay in the one
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient]: callers
|
||||
* `subscribe`/`count`/`publish` to [url] and the commands are served straight
|
||||
* from SQLite — no socket, no JSON round-trip, no relay-server engine.
|
||||
*
|
||||
* Command handling:
|
||||
* - `REQ` → stream every stored match as an `EVENT`, then `EOSE`.
|
||||
* - `COUNT`→ `store.count(filters)` as a `COUNT` reply.
|
||||
* - `EVENT`→ insert into the store, reply `OK true/false`.
|
||||
* - `CLOSE`→ cancel the in-flight query for that subscription, if any.
|
||||
* - `AUTH` and anything else → ignored (a local store needs no auth).
|
||||
*
|
||||
* **Snapshot semantics (no live tail).** A `REQ` replays what is in the store
|
||||
* *now* and closes with `EOSE`; events written *after* the scan are not pushed
|
||||
* to an already-open subscription — a re-`REQ` picks them up. That is the right
|
||||
* shape for a read cache (instant EOSE-backed load), and it avoids reimplementing
|
||||
* the server-side replay+index dedup of
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.server.backend.LiveEventStore].
|
||||
* Live tailing would layer on
|
||||
* [com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore.changes].
|
||||
*
|
||||
* The relay is always "connected": [connect] reports up immediately and the
|
||||
* pool's connect → `onConnected` → `syncFilters` path delivers the active
|
||||
* filters once, exactly as it would for a freshly-dialled socket relay.
|
||||
*/
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
class LocalStoreRelayClient(
|
||||
override val url: NormalizedRelayUrl,
|
||||
private val store: IEventStore,
|
||||
private val listener: RelayConnectionListener,
|
||||
private val scope: CoroutineScope,
|
||||
) : IRelayClient {
|
||||
@Volatile
|
||||
private var connected = false
|
||||
|
||||
/** Live query jobs per subscription id, so CLOSE / disconnect can cancel a long scan. */
|
||||
private val jobs = AtomicReference<Map<String, Job>>(emptyMap())
|
||||
|
||||
override fun isConnected(): Boolean = connected
|
||||
|
||||
// In-process: there is no socket to go stale, so a reconnect is never needed.
|
||||
override fun needsToReconnect(): Boolean = false
|
||||
|
||||
override fun connect() {
|
||||
if (connected) return
|
||||
listener.onConnecting(this)
|
||||
connected = true
|
||||
// Mirrors a socket's onOpen: the pool marks us connected and replays
|
||||
// the active REQ/COUNT/EVENT state to us via syncFilters.
|
||||
listener.onConnected(this, pingMillis = 0, compressed = false)
|
||||
}
|
||||
|
||||
override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) {
|
||||
if (!connected) connect()
|
||||
}
|
||||
|
||||
override fun sendOrConnectAndSync(cmd: Command) {
|
||||
if (connected) {
|
||||
handle(cmd)
|
||||
} else {
|
||||
// Don't serve yet: connecting triggers syncFilters, which re-sends
|
||||
// the active filters once. Serving here too would double-deliver.
|
||||
connect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendIfConnected(cmd: Command) {
|
||||
if (connected) handle(cmd)
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
if (!connected) return
|
||||
connected = false
|
||||
jobs.exchange(emptyMap()).values.forEach(Job::cancel)
|
||||
listener.onDisconnected(this)
|
||||
}
|
||||
|
||||
private fun handle(cmd: Command) {
|
||||
when (cmd) {
|
||||
is ReqCmd -> serveReq(cmd)
|
||||
is CountCmd -> serveCount(cmd)
|
||||
is EventCmd -> serveEvent(cmd)
|
||||
is CloseCmd -> cancelJob(cmd.subId)
|
||||
else -> Unit // AUTH etc. — nothing to do for a local store.
|
||||
}
|
||||
// Tell the pool the command was accepted so it advances its per-relay
|
||||
// bookkeeping (won't resend this filter). No bytes left the device.
|
||||
listener.onSent(this, cmd.label(), cmd, success = true)
|
||||
}
|
||||
|
||||
private fun serveReq(cmd: ReqCmd) {
|
||||
val job =
|
||||
scope.launch {
|
||||
try {
|
||||
store.query<Event>(cmd.filters) { event ->
|
||||
emit(EventMessage(cmd.subId, event))
|
||||
}
|
||||
emit(EoseMessage(cmd.subId))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("LocalStoreRelayClient") { "REQ ${cmd.subId} failed: ${e.message}" }
|
||||
}
|
||||
}
|
||||
putJob(cmd.subId, job)
|
||||
}
|
||||
|
||||
private fun serveCount(cmd: CountCmd) {
|
||||
scope.launch {
|
||||
try {
|
||||
emit(CountMessage(cmd.queryId, CountResult(store.count(cmd.filters))))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("LocalStoreRelayClient") { "COUNT ${cmd.queryId} failed: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun serveEvent(cmd: EventCmd) {
|
||||
scope.launch {
|
||||
val ok =
|
||||
try {
|
||||
when (val outcome = store.batchInsert(listOf(cmd.event)).first()) {
|
||||
is IEventStore.InsertOutcome.Accepted -> OkMessage(cmd.event.id, true, "")
|
||||
is IEventStore.InsertOutcome.Rejected -> OkMessage(cmd.event.id, false, outcome.reason)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
OkMessage(cmd.event.id, false, "error: ${e.message ?: e::class.simpleName}")
|
||||
}
|
||||
emit(ok)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emit(msg: Message) {
|
||||
// In-process: nothing is serialised. msgStr is only read by stats/logging
|
||||
// listeners, and "" keeps the relay's byte-accounting at a truthful zero.
|
||||
listener.onIncomingMessage(this, "", msg)
|
||||
}
|
||||
|
||||
private fun putJob(
|
||||
subId: String,
|
||||
job: Job,
|
||||
) {
|
||||
while (true) {
|
||||
val cur = jobs.load()
|
||||
val prev = cur[subId]
|
||||
if (jobs.compareAndSet(cur, cur + (subId to job))) {
|
||||
prev?.cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelJob(subId: String) {
|
||||
while (true) {
|
||||
val cur = jobs.load()
|
||||
val prev = cur[subId] ?: return
|
||||
if (jobs.compareAndSet(cur, cur - subId)) {
|
||||
prev.cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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.nip01Core.relay.client.single.local
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.RelayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* End-to-end: drive a real [NostrClient] against a [LocalStoreRelayClient]
|
||||
* backed by an in-memory SQLite [EventStore]. Proves the local store behaves as
|
||||
* just another relay in the pool — `subscribe` returns the stored matches plus
|
||||
* `EOSE`, honouring the filter — with no socket and no JSON in the path.
|
||||
*/
|
||||
class LocalStoreRelayClientTest {
|
||||
private val localUrl = NormalizedRelayUrl("ws://localhost/amethyst-local/")
|
||||
|
||||
private val profile =
|
||||
MetadataEvent(
|
||||
id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe",
|
||||
pubKey = "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67",
|
||||
createdAt = 1740669816,
|
||||
tags = arrayOf(arrayOf("name", "Vitor")),
|
||||
content = "{\"name\":\"Vitor\"}",
|
||||
sig = "977a6152199f17d103d8d56736ed1b7767054464cf9423d017c01c8cdd2344698f0a5e13da8dff98d01bb1f798837e3b6271e1fd1cac861bb90686f622ae6ef4",
|
||||
)
|
||||
|
||||
private val note =
|
||||
TextNoteEvent(
|
||||
id = "fecb2ecf61a1433d417a784d10bd1e8ec19a916170a53ca8fb3a15fc666a6592",
|
||||
pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
createdAt = 1747753115,
|
||||
tags = arrayOf(),
|
||||
content = "hello",
|
||||
sig = "12070e663272f1227c639fb834eb2122fc7bb995f4c49e55ebb1dfe2135ef7347d44810bacd2e64fd26b8826fd47d2800ce6c3d3b579bb3afe39088ffd4faa60",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun servesStoredMatchesThroughNostrClient() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
val store = EventStore(dbName = null, relay = localUrl)
|
||||
store.insert(profile)
|
||||
store.insert(note)
|
||||
|
||||
// The store is the only relay; build a LocalStoreRelayClient for every URL.
|
||||
val relayBuilder =
|
||||
RelayBuilder { url, listener ->
|
||||
LocalStoreRelayClient(url, store, listener, scope)
|
||||
}
|
||||
val client = NostrClient(NoSocket, scope, relayBuilder)
|
||||
|
||||
try {
|
||||
// fetchAll subscribes, collects until EOSE, then unsubscribes — exactly the
|
||||
// REQ -> EVENT* -> EOSE -> CLOSE round-trip our relay must support. Ask only
|
||||
// for kind 0, so the note (kind 1) must be filtered out by the store query.
|
||||
val events = client.fetchAll(localUrl, Filter(kinds = listOf(0)), timeoutMs = 5_000)
|
||||
|
||||
assertEquals(listOf(profile.id), events.map { it.id }, "Only the kind-0 profile should match")
|
||||
} finally {
|
||||
client.close()
|
||||
scope.cancel()
|
||||
store.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Never invoked: the test's RelayBuilder serves every URL from the store. */
|
||||
private object NoSocket : WebsocketBuilder {
|
||||
override fun build(
|
||||
url: NormalizedRelayUrl,
|
||||
out: WebSocketListener,
|
||||
): WebSocket = throw UnsupportedOperationException("no sockets in this test")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user