mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
test+docs: commons finder test + document per-visible loading (Phase 5)
- EventFinderFilterAssemblyTest (commons jvmTest): filterMissingEvents batches one ids-filter per relay with sorted ids; ICacheProvider.checkGetOrCreateUser default tolerates a throwing/null getOrCreateUser via a fake cache. - relay-client skill + commons/ARCHITECTURE.md: document observeUser*/ EventFinderFilterAssemblerSubscription/observeNote* as the canonical per-visible loading entry points and the LocalUserFinder/LocalUserFinderAccount/LocalEventFinder seams. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a867f9b2ca
commit
fbe163e8f2
@@ -94,6 +94,32 @@ class MetadataFilterAssembler(
|
||||
|
||||
Assemblers stay pure — no state, no I/O. They're the composition seam: `FeedMetadataCoordinator` takes a list of visible notes and assembles a single metadata filter covering every referenced pubkey.
|
||||
|
||||
## Per-visible loading — the canonical entry points (`observeUser*` / `observeNote*`)
|
||||
|
||||
Prefer these over hand-rolled "load metadata for this list" calls. They are the shared,
|
||||
KMP way to load data **only for what's on screen** — a composable subscribes while it is in
|
||||
composition and unsubscribes ~30s after it leaves (or the app backgrounds). Both live in
|
||||
`commons/relayClient/`:
|
||||
|
||||
- **Per user** (`relayClient/user/`): `observeUserInfo/Picture/Banner/AboutMe/Name(user)`
|
||||
each open a composition-scoped `UserFinderFilterAssemblerSubscription(user)` **and** return
|
||||
reactive `State`. Metadata (kind 0 + relay lists) loads for on-screen users only, coalesced
|
||||
into one batched REQ per relay for the whole visible set.
|
||||
- **Per note** (`relayClient/event/`): `EventFinderFilterAssemblerSubscription(note)` loads a
|
||||
note's interactions (reactions / zaps / reposts / replies) while it is composed. Android's
|
||||
`observeNote*` display observers layer on top of the same subscription.
|
||||
|
||||
Both read front-end-provided CompositionLocals — `LocalUserFinder` / `LocalUserFinderAccount`
|
||||
(reused by the event finder) / `LocalEventFinder` — provided once near the composition root
|
||||
(Android `AppModules`, Desktop `Main.kt` via its subscriptions coordinator). The account seam
|
||||
is the narrow `UserFinderAccount` (snapshot relay-hint getters), NOT the fat `IAccount`.
|
||||
`error()` defaults mean these must never be reached from a composition without a relay client
|
||||
(e.g. the Android `:napplet` sandbox).
|
||||
|
||||
The load-once, viewport-batch path (`FeedMetadataCoordinator.loadMetadataForNotes` /
|
||||
`loadMetadataBatched`) is superseded for foreground loading; `MetadataPreloader` remains only
|
||||
as an optional off-screen background warmer.
|
||||
|
||||
## Preloaders
|
||||
|
||||
`MetadataPreloader` is the "I need metadata for 200 pubkeys, but don't melt my CPU or the relay" path. It uses `MetadataRateLimiter` (token bucket) to throttle bulk fetches and group them into relay-friendly chunks.
|
||||
|
||||
@@ -101,7 +101,7 @@ they are shared across the GUI apps. Treat as GUI-shared, not strictly headless.
|
||||
### Relay client
|
||||
| Package | UI? | Purpose |
|
||||
|----------------|-----|---------|
|
||||
| `relayClient` | no | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a `composeSubscriptionManagers` subpackage name, this is subscription-lifecycle logic, not UI.) |
|
||||
| `relayClient` | no | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a `composeSubscriptionManagers` subpackage name, this is subscription-lifecycle logic, not UI.) The canonical **per-visible loading** entry points live here: `relayClient/user/` (`observeUser*` — kind-0 metadata) and `relayClient/event/` (`EventFinderFilterAssemblerSubscription`/`observeNote*` — reactions/zaps/reposts). See the `relay-client` skill. |
|
||||
| `relays` | no | Low-level EOSE/relay-timing bookkeeping (`EOSECache`, `EOSERelayList`). |
|
||||
|
||||
### Platform abstractions (`expect`/`actual`)
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.event
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the extracted (Phase 2b) per-note event-finder filter assembly
|
||||
* and the [ICacheProvider] seam it relies on.
|
||||
*/
|
||||
class EventFinderFilterAssemblyTest {
|
||||
private val relay1 = NormalizedRelayUrl("wss://relay1.test/")
|
||||
private val relay2 = NormalizedRelayUrl("wss://relay2.test/")
|
||||
|
||||
/**
|
||||
* One batched `ids` filter per relay — NOT one filter per event id — and the
|
||||
* ids are sorted deterministically so REQs dedup across rebuilds.
|
||||
*/
|
||||
@Test
|
||||
fun `filterMissingEvents batches one filter per relay with sorted ids`() {
|
||||
val filters =
|
||||
filterMissingEvents(
|
||||
mapOf(
|
||||
relay1 to setOf("bbbb", "aaaa", "cccc"),
|
||||
relay2 to setOf("dddd"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("one batched filter per relay", 2, filters.size)
|
||||
|
||||
val r1 = filters.first { it.relay == relay1 }
|
||||
assertEquals(listOf("aaaa", "bbbb", "cccc"), r1.filter.ids)
|
||||
|
||||
val r2 = filters.first { it.relay == relay2 }
|
||||
assertEquals(listOf("dddd"), r2.filter.ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filterMissingEvents skips relays with no ids and empty input`() {
|
||||
assertTrue(filterMissingEvents(emptyMap()).isEmpty())
|
||||
assertTrue(filterMissingEvents(mapOf(relay1 to emptySet())).isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* The new default [ICacheProvider.checkGetOrCreateUser] must swallow a
|
||||
* malformed-key throw and return null (the event-finder follows pubkey hints
|
||||
* parsed out of arbitrary events, some of which are junk).
|
||||
*/
|
||||
@Test
|
||||
fun `checkGetOrCreateUser tolerates a throwing getOrCreateUser`() {
|
||||
val throwing =
|
||||
object : StubCache() {
|
||||
override fun getOrCreateUser(pubkey: HexKey): User? = throw IllegalArgumentException("bad key")
|
||||
}
|
||||
assertNull(throwing.checkGetOrCreateUser("not-a-key"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checkGetOrCreateUser passes through a null result`() {
|
||||
assertNull(StubCache().checkGetOrCreateUser("00"))
|
||||
}
|
||||
|
||||
/** Minimal [ICacheProvider] that returns nothing; override per test. */
|
||||
private open class StubCache : ICacheProvider {
|
||||
override val relayHints = HintIndexer()
|
||||
|
||||
override fun getAnyChannel(note: Note): Channel? = null
|
||||
|
||||
override fun getUserIfExists(pubkey: HexKey): User? = null
|
||||
|
||||
override fun countUsers(predicate: (String, User) -> Boolean): Int = 0
|
||||
|
||||
override fun getNoteIfExists(hexKey: HexKey): Note? = null
|
||||
|
||||
override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null
|
||||
|
||||
override fun getOrCreateAddressableNote(key: Address): com.vitorpamplona.amethyst.commons.model.AddressableNote = error("unused")
|
||||
|
||||
override fun getEventStream(): ICacheEventStream = error("unused")
|
||||
|
||||
override fun hasBeenDeleted(event: Any): Boolean = false
|
||||
|
||||
override fun getOrCreateUser(pubkey: HexKey): User? = null
|
||||
|
||||
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user