Compare commits

...

4 Commits

Author SHA1 Message Date
Claude
ab4e2df043 test(relay): use INostrClient.fetchAll in LocalStoreRelayClient test
Replace the hand-rolled SubscriptionListener + CompletableDeferred + withTimeout
with the existing fetchAll extension, which subscribes, collects until EOSE and
unsubscribes — also covering the CLOSE path on the local relay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UftXQRNxCkXGKYYaxXvg9n
2026-06-19 14:36:46 +00:00
Claude
5623cbf515 feat(relay): serve the local store via a native LocalStoreRelayClient
Replace the in-process NostrServer + InProcessWebSocket bridge with a
quartz-native relay client, so the on-device SQLite store is a real
IRelayClient in the one NostrClient's pool — no socket and no JSON in the
path.

Quartz:
- Add RelayBuilder, the relay-level analogue of WebsocketBuilder: the pool
  now asks it for an IRelayClient per URL instead of always building a
  BasicRelayClient. Default is BasicRelayBuilder (websocket-backed), so
  every existing NostrClient(websocketBuilder[, scope]) call is unchanged.
- RelayPool takes a RelayBuilder; NostrClient gains an optional relayBuilder
  param (defaults to BasicRelayBuilder over the websocket builder).
- Add LocalStoreRelayClient: answers REQ/COUNT/EVENT straight from an
  IEventStore (REQ -> stored matches + EOSE, COUNT -> count, EVENT -> insert
  + OK). Snapshot semantics (no live tail); reports connected immediately so
  the pool's connect -> onConnected -> syncFilters path delivers filters once.
- jvmTest drives a real NostrClient against it over an in-memory store.

Amethyst:
- LocalEventStore drops the NostrServer and just exposes the EventStore +
  write-through; LocalRelayBuilder routes LOCAL_RELAY_URL to a
  LocalStoreRelayClient and delegates every other URL to the default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UftXQRNxCkXGKYYaxXvg9n
2026-06-19 14:26:48 +00:00
Claude
22527f82ce feat(store): serve the local event store as an in-process relay
Replace the boot-time hydration with an in-process Nostr relay so the
on-device SQLite store is reachable as just another relay through the
normal NostrClient, with no websocket.

- LocalEventStore now hosts a NostrServer over the SQLite EventStore
  (policy: verify + allow only the persisted kinds). REQ/COUNT/NIP-50
  reads are answered straight from SQLite.
- LocalRelayWebsocketBuilder wraps the app's OkHttp websocket builder and
  routes LOCAL_RELAY_URL to an InProcessWebSocket over that server;
  every other URL dials over the network unchanged. It is plugged into
  NostrClient, so any subscription/publish targeting the local URL is
  served in-process.
- Drop the startup hydration that replayed events into LocalCache; the
  store now opens lazily (warmed up off the main thread).

Write-through population (kind 0, relay lists, trusted assertions from
every user) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UftXQRNxCkXGKYYaxXvg9n
2026-06-18 16:35:51 +00:00
Claude
51fc2328c7 feat(store): persist user directory in on-device SQLite local relay
Add a SQLite-backed local event store (quartz's EventStore) to the Android
app that permanently keeps the cross-account "user directory" events:

  - kind 0 (profile metadata) for every user we see
  - relay lists (NIP-65, DM, search, and the NIP-51 relay lists)
  - trusted assertions (NIP-85)

These are all replaceable/addressable, so the store keeps exactly one current
version per (kind, pubkey[, d-tag]); it never prunes by age yet stays small.
NIP-40 expiration and NIP-09 deletions are still honoured by the store triggers.

Events from remote relays are written-through via CacheClientConnector; on boot
the store hydrates LocalCache so users and their relay lists/assertions render
offline before any remote relay connects. A single global DB is used since this
is public data and both the cache and relay client are app-wide singletons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UftXQRNxCkXGKYYaxXvg9n
2026-06-18 03:41:29 +00:00
11 changed files with 785 additions and 15 deletions

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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 =

View File

@@ -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
}
}

View File

@@ -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())

View File

@@ -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 ->

View File

@@ -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
}

View File

@@ -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,
)
}

View File

@@ -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
}
}
}
}

View File

@@ -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")
}
}