feat(cli): cache-first reads across feed, dm, marmot commands

Audit found four commands draining replaceable relay-list events
(kind:10050, 10051, 10002) on every invocation, plus one fetching
the user's own kind:3 contact list. All five are now cache-first.

New Context helpers:
  - dmInboxOf(pk)         → kind:10050  (ChatMessageRelayListEvent)
  - keyPackageRelaysOf(pk)→ kind:10051  (KeyPackageRelayListEvent)
  - cachedRelayListsOf(pk)→ assembles a RecipientRelayFetcher.Lists
                             from the local store; returns null if
                             nothing is cached so callers can fall
                             back with `?:`.

Wired into:
  - FeedCommand.resolveFollowing — replaces a kind:3 drain with
    ctx.contactsOf(self).
  - DmCommands.resolveDmRelays — `amy dm send` / `dm list`.
  - KeyPackageCommands.check.
  - AwaitCommands (key-package / member / admin polling loops).
  - GroupAddMemberCommand (one cache hit per invitee).

All five sites now go: try cache → if miss, run the existing
RecipientRelayFetcher / drain. The drain itself populates the cache
via verifyAndStore, so subsequent runs are local-only. Replaceable
slot shortcut means each cached read is one stat + one readString,
not a directory walk.

README "Local event store" section gains a list of which commands
are cache-first today.
This commit is contained in:
Claude
2026-04-25 03:34:00 +00:00
parent bf1c4c23cb
commit b2bf874c15
7 changed files with 104 additions and 11 deletions
+17 -3
View File
@@ -70,11 +70,25 @@ from the store first and only fall back to a relay fetch on miss.
Three convenience helpers exist on `Context`:
```kotlin
ctx.profileOf(pubKey) // latest kind:0 (NIP-01)
ctx.relaysOf(pubKey) // latest kind:10002 (NIP-65)
ctx.contactsOf(pubKey) // latest kind:3 (NIP-02)
ctx.profileOf(pubKey) // latest kind:0 (NIP-01)
ctx.relaysOf(pubKey) // latest kind:10002 (NIP-65)
ctx.contactsOf(pubKey) // latest kind:3 (NIP-02)
ctx.dmInboxOf(pubKey) // latest kind:10050 (NIP-17 DM inbox)
ctx.keyPackageRelaysOf(pubKey) // latest kind:10051 (MIP-00 KP relays)
ctx.cachedRelayListsOf(pubKey) // RecipientRelayFetcher.Lists from cache
```
Commands that already read these cache-first:
- `amy profile show` — `--refresh` to bypass.
- `amy feed --following` — local kind:3 served via slot lookup; falls
back to a relay drain on first run.
- `amy dm send` — recipient's kind:10050 / 10051 / 10002 served from
cache before falling back to `RecipientRelayFetcher`.
- `amy marmot key-package check` and `amy marmot await key-package`
— same recipient-relay lookup, cache-first.
- `amy marmot group add` — invitee relay lists served from cache.
The store implements every feature of the Quartz SQLite store —
NIP-01 replaceable / addressable uniqueness, NIP-09 deletion
tombstones, NIP-40 expiration, NIP-50 search, NIP-62 right-to-vanish,
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.marmot.ingest
import com.vitorpamplona.quartz.marmot.MarmotFilters
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -44,6 +46,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlinx.coroutines.channels.Channel
@@ -333,6 +336,54 @@ class Context(
Filter(authors = listOf(pubKey), kinds = listOf(ContactListEvent.KIND), limit = 1),
).firstOrNull() as? ContactListEvent
/**
* Latest known kind:10050 chat-message (NIP-17 DM) inbox relay list
* for [pubKey], or `null` if Amy has never observed one. Used by
* `dm send` to resolve where to deliver a wrap.
*/
fun dmInboxOf(pubKey: HexKey): ChatMessageRelayListEvent? =
store
.query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(ChatMessageRelayListEvent.KIND), limit = 1),
).firstOrNull() as? ChatMessageRelayListEvent
/**
* Latest known kind:10051 KeyPackage relay list (MIP-00) for
* [pubKey], or `null` if Amy has never observed one. Used by
* `marmot key-package check` and `marmot await key-package` to
* locate where the recipient publishes their KeyPackages.
*/
fun keyPackageRelaysOf(pubKey: HexKey): KeyPackageRelayListEvent? =
store
.query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(KeyPackageRelayListEvent.KIND), limit = 1),
).firstOrNull() as? KeyPackageRelayListEvent
/**
* Assemble a [RecipientRelayFetcher.Lists] from the local store —
* the same shape callers get from [RecipientRelayFetcher.fetchRelayLists]
* after a network drain, but no network round-trip. Returns `null`
* only when the cache has *no* relay-list events at all for
* [pubKey] (none of kind 10050 / 10051 / 10002), so callers can
* trivially fall back to the network fetcher with `?:`.
*
* Stale-data caveat: replaceable events are immutable per snapshot
* — if the recipient has rotated their inbox since we last saw them,
* we'll still hand back the old list. Commands that care can drain
* (which re-populates the cache) or expose a `--refresh` flag.
*/
fun cachedRelayListsOf(pubKey: HexKey): RecipientRelayFetcher.Lists? {
val dm = dmInboxOf(pubKey)
val kp = keyPackageRelaysOf(pubKey)
val nip65 = relaysOf(pubKey)
if (dm == null && kp == null && nip65 == null) return null
return RecipientRelayFetcher.Lists(
dmInbox = dm?.relays().orEmpty(),
keyPackage = kp?.relays().orEmpty(),
nip65 = nip65,
)
}
/**
* Pull down everything needed to bring local Marmot state current:
* - kind:1059 gift wraps on inbox relays → try to unwrap Welcomes
@@ -76,7 +76,13 @@ object AwaitCommands {
// target hasn't published either list yet, fall back to the
// bootstrap pool so the loop still has something to query.
val seed = ctx.bootstrapRelays()
val lists = RecipientRelayFetcher.fetchRelayLists(ctx.client, target, seed)
// Cache-first: relay lists are kind:10050 / 10051 / 10002 —
// all replaceable, served from the local store via the slot
// shortcut. Falls back to a network drain only if Amy has
// never observed any of them for `target`.
val lists =
ctx.cachedRelayListsOf(target)
?: RecipientRelayFetcher.fetchRelayLists(ctx.client, target, seed)
val relays =
KeyPackageFetcher.fetchRelaysFor(
targetKeyPackageRelays = lists.keyPackage,
@@ -415,7 +415,13 @@ object DmCommands {
allowFallback: Boolean,
): RelaySet {
val seed = ctx.bootstrapRelays()
val lists = RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, seed)
// Cache-first: if Amy has previously seen the recipient's
// kind:10050 / 10051 / 10002 events, use the local copy and
// skip the network drain entirely. Falls back to the live
// fetcher only if the local store has nothing.
val lists =
ctx.cachedRelayListsOf(recipient)
?: RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, seed)
val dmInbox = lists.dmInbox.toSet()
if (dmInbox.isNotEmpty()) return RelaySet(dmInbox, "kind_10050")
if (!allowFallback) return RelaySet(emptySet(), "kind_10050")
@@ -155,6 +155,13 @@ object FeedCommand {
ctx: Context,
timeoutMs: Long,
): List<HexKey> {
// Cache-first: contact lists are kind:3 (replaceable). If we've
// ever seen ours, the local store has it already and reading is
// a slot lookup — no relay round-trip.
ctx.contactsOf(ctx.identity.pubKeyHex)?.let {
return it.verifiedFollowKeySet().toList()
}
val relays = ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
if (relays.isEmpty()) return emptyList()
val filter =
@@ -72,12 +72,17 @@ object GroupAddMemberCommand {
// this the inviter can only broadcast to their own relays,
// which silently fails the moment the two users have
// disjoint relay configs.
//
// Cache-first via Context.cachedRelayListsOf — every
// relay list seen previously is in the local store
// already.
val recipient =
RecipientRelayFetcher.fetchRelayLists(
client = ctx.client,
pubKey = pub,
seedRelays = seed,
)
ctx.cachedRelayListsOf(pub)
?: RecipientRelayFetcher.fetchRelayLists(
client = ctx.client,
pubKey = pub,
seedRelays = seed,
)
// KeyPackage discovery (MIP-00): prefer the invitee's own
// kind:10051, then their kind:10002 write marker, then our
@@ -77,7 +77,11 @@ object KeyPackageCommands {
// when the target and inviter share no relays.
val seed = ctx.bootstrapRelays()
if (seed.isEmpty()) return Json.error("no_relays", "configure relays first")
val recipient = RecipientRelayFetcher.fetchRelayLists(ctx.client, targetHex, seed)
// Cache-first via Context.cachedRelayListsOf — replaceable
// events live in the local store after the first sync.
val recipient =
ctx.cachedRelayListsOf(targetHex)
?: RecipientRelayFetcher.fetchRelayLists(ctx.client, targetHex, seed)
val relays =
KeyPackageFetcher.fetchRelaysFor(
targetKeyPackageRelays = recipient.keyPackage,