Files
amethyst/.claude/skills/account-state/references/local-cache.md
Vitor PamplonaandClaude Opus 5.5 395af4dc9c chore(skills): consolidate 33 skills into 14, verified against the tree
The 33 descriptions (~16k chars, ~19k with paths) overran Codex's skill-list
budget (2% of the context window, or 8,000 chars when unknown), and a review
found most bodies stale: paths from before the commons/commonsUI moves, the
deleted legacy desktop app, a callback signer API that no longer exists, and
example APIs that were never in the tree.

Kept, rewritten and checked against the code (every path, class and command):
account-state (+feeds), amy-expert, android-expert, auth-signers,
compose-expert (+API style, Navigation 3 back stacks), desktop-expert
(+run/package), event-store-semantics (+NIP-50 search), find-missing-
translations, kotlin-coroutines (+structured concurrency, flow modeling),
kotlin-multiplatform (+Gradle, composable extraction), ngit-pr,
nip85-trusted-assertions, nostr-expert (+NIP lookup, generated NIP catalog),
relay-client.

Dropped: textbook Compose/Kotlin technique skills, kotlin-expert,
kotlin-types-value-class, gradle-expert, quartz-integration (external-facing;
see the Quartz docs commit), find-non-lambda-logs (audit finished; the rule is
now in AGENTS.md Kotlin Style), and the /nip, /desktop-run, /extract commands.
The skill list is now ~4k chars. Also drops .claude/core-skills-plan.md
(stale history) and repoints SearchableEvent's KDoc to event-store-semantics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:21:01 -04:00

4.9 KiB

LocalCache: the in-memory event store

commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/:

  • LocalCache.kt — object LocalCache : EventCache(), the process-wide instance both app modules use.
  • EventCache.kt — all the logic (open class EventCache : ILocalCache, ICacheProvider, Dao). It is a class so a test can build a cache of its own instead of sharing the singleton.

Structures (fields of EventCache)

val users = LargeSoftCache<HexKey, User>()
val notes = LargeSoftCache<HexKey, Note>()
val addressables = LargeSoftCache<Address, AddressableNote>()

val chatroomList = LargeCache<HexKey, ChatroomList>()
val publicChatChannels = LargeCache<HexKey, PublicChatChannel>()
val liveChatChannels = LargeCache<Address, LiveActivitiesChannel>()
// … ephemeralChannels, geohashChannels, relayGroupChannels, concordChannels
val deletionIndex = DeletionIndex()
val observables = FilterIndex<Observable>()
val pruner = CachePruner(this)
val search = CacheSearch(this)
val live: LocalCacheFlow = LocalCacheFlow()

LargeSoftCache (LargeSoftCache.kt) holds weak references (a ConcurrentSkipListMap of weak refs on JVM/Android). A Note or User nobody holds strongly can be collected, which is why every account state object keeps its note in a field. Channel maps are plain LargeCache (quartz utils/cache/LargeCache.kt; see nostr-expert).

Insertion path

relay EVENT
  └─ CacheClientConnector (commons/src/jvmAndroid/.../relayClient/) → cache.justConsume(event, relay, false)
       ├─ already deleted? → re-send the deletion to that relay, drop the event
       ├─ addressable and we hold a newer version? → re-send ours to that relay
       └─ justConsumeAndUpdateIndexes
            ├─ getOrCreateAddressableNote(address) / getOrCreateNote(id)
            ├─ justConsumeInnerInner(event, …)   ← per-class `when`: a typed consume(...) overload,
            │                                       consumeBaseReplaceable, … ; unlisted class → rejected
            └─ if new: hint / mint / gatherer indexes
       consume(...) → refreshNewNoteObservers(note): matching `observables`, then live.newNote(note)

Locally signed events enter through justConsumeMyOwnEvent(event); embedded events through consumeEmbedded(event).

Lookups

Need Call
note by id or address string getOrCreateNote(hex), getNoteIfExists(hex), checkGetOrCreateNote(hex) (null for bad input)
addressable getOrCreateAddressableNote(address), getAddressableNoteIfExists(address)
user getOrCreateUser(pubkey), getUserIfExists(pubkey), checkGetOrCreateUser(key)
live filter results observeNotes(filter: Filter): Flow<List<Note>>
text search search (CacheSearch.kt)

getOrCreate* never does network I/O and returns the same wrapper for the same id while it is alive.

Reactive consumption

  • One note: note.flow() returns its NoteFlowSet (metadata, replies, reactions, boosts, zaps, reports, relays, ots, edits, labels). flowSet is null until someone calls flow().
  • One user's profile: user.metadata().flow.
  • Everything: LocalCache.live.newEventBundles / deletedEventBundles — SharedFlow<Set<Note>> batched by BundledInsert (1 s windows), DROP_OLDEST with a 100-bundle buffer. Feeds consume it through ICacheProvider.getEventStream().

Replacement and deletion

  • Regular events: first copy wins; kind-5 deletions go to deletionIndex, and a later copy of a deleted event is refused (deletionIndex.hasBeenDeleted).
  • Replaceable and addressable events: the newest created_at replaces note.event in place on the same wrapper (addressables are keyed by Address). Hold the Note, never the Event.

Pruning and memory

  • CachePruner (pruner): pruneOldMessages, pruneHiddenMessages, pruneExpiredEvents, prunePastVersionsOfReplaceables, pruneRepliesAndReactions, cleanObservers (calls clearFlow() on every note).
  • MemoryTrimmingService (model/cache/MemoryTrimmingService.kt) drives it; AppModules.kt and DesktopAppModules.kt each create one.
  • No TTL beyond that. A long session accumulates; bound retention in the feed (limit()), not by hoping the cache drops things.

Gotchas

  • A new event class must be routed in justConsumeInnerInner. EventFactory parsing it is not enough; an unlisted class hits the else branch and is rejected.
  • Observers fire inside consume. refreshNewNoteObservers runs as part of consume(...), so state the cache derives from that event (channels and similar) may not be filled in yet when a collector wakes. Re-read in the collector.
  • Full scans are expensive. notes.filterIntoSet { … } over the whole cache is what a feed's feed() does on refresh; never do it per frame or in composition.
  • Processes: LocalCache lives only in the Android main process — see AGENTS.md Android Runtime Processes.