Files
Claude 9afa8127da refactor!: take quartz from vitorpamplona/quartz
Quartz moves to its own repo, the way the crypto primitives moved to
crypto-kmp. Every module depends on com.vitorpamplona.quartz:quartz
2.0.0 (libs.quartz) instead of :quartz, and -PquartzDir=../quartz
builds a local checkout instead of the artifact, for every module and
for quic's own quartz dependency (which replaces the root build's
substitution of the published quartz with :quartz).

What moved with quartz: its CI jobs (lint tasks, test-quartz-linux-native,
test-quartz-ios), the Maven Central publish step of the release
workflow, jitpack.yml, tools/unicode-nfkc, the nostr-expert and
event-store-semantics skills, the agent-skill check, and the README's
library usage docs.

What stays here:
- The four quartz client tests that need a real Ktor socket relay
  (TrustNetworkSync*, GiantReqStreamTest, BulkDownloadBenchmark) move to
  geode's tests; geode forwards -PprodRelayBench for the latter. quartz's
  other relay-client tests now use a test relay of its own, which ends
  quartz's test dependency on geode.
- The benchmark module's resources (previously read from quartz's
  androidDeviceTest resources) move into benchmark/; relayBench --real
  reads the corpus from there.
- NoteComposeKindCoverageTest reads EventFactory.kt from quartz's JVM
  sources jar, or from the -PquartzDir checkout, passed by commonsUI's
  build.
- commons and commonsUI declare crypto-kmp (and commons mls-kmp), which
  they import directly. Through quartz's api alone, a -PcryptoKmpDir
  checkout never reached the common metadata compile.
- The catalog drops what only quartz used (kchesslib, negentropy,
  jackson-databind, sqlite-bundled, secp256k1-kmp common, compose
  runtime-annotation, the maven-publish plugin).

agent_config_check checks a skill's quartz/... paths against a ../quartz
checkout when there is one and skips them otherwise. AGENTS.md, the
skills, CONTRIBUTING, BUILDING, RELEASE_OPS, PLANS and the READMEs point
at the new repo.

Needs quartz 2.0.0 (and crypto-kmp 1.0.0) on Maven Central before CI can
resolve it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEkJaDWYrcTbGvT8JLQCrG
2026-10-21 16:32:25 +00:00

113 lines
7.3 KiB
Markdown

---
name: account-state
description: Account, LocalCache, User/Note and feeds in commons/model + commons/feeds: per-user XState objects (.flow), FeedFilter/AdditiveFeedFilter, FeedViewModel. Use when adding an account setting or list, a feed, or choosing LocalCache vs account flows.
---
# Account, LocalCache and Feeds
The client state backbone, all headless in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/`
(abbreviated `commons/…/` below):
- `model/cache/LocalCache.kt` — `object LocalCache : EventCache()`. Every `Note` / `User` the client has
seen. All logic is in `model/cache/EventCache.kt` (an open class so tests can build their own);
Android and Desktop both use the `LocalCache` singleton (`AppModules.kt`, `DesktopAppModules.kt`).
- `model/Account.kt` — one `Account` per signed-in user: ~50 per-feature **state objects**
(`kind3FollowList`, `nip65RelayList`, `muteList`, `bookmarkState`, …), each exposing `.flow`.
- `feeds/` + `viewmodels/` + `<feature>/dal/` — feeds: a `FeedFilter` scans `LocalCache`, a
`FeedContentState` / `FeedViewModel` keeps the result live.
```
relay EVENT ─► EventCache.justConsume ─► Note/User updated, indexes, live.newNote(...)
│ │
▼ ▼
XState pins its addressable note, LocalCacheFlow.newEventBundles
note.flow().metadata → XState.flow → FeedViewModel → FeedFilter.applyFilter
│ │
└────────► ViewModels / composables ◄┘
```
## Account state objects
Pattern, copied from `model/nip51Lists/muteList/MuteListState.kt`:
- **Pin the note.** `val muteListNote = cache.getOrCreateAddressableNote(MuteListEvent.createAddress(signer.pubKey))`.
`LocalCache`'s `notes` / `addressables` are `LargeSoftCache` (weak references), so the state
object's field is what keeps its note from being collected.
- **Expose one `StateFlow`.** Map `muteListNote.flow().metadata.stateFlow` → decrypt (private lists go
through a per-feature `…DecryptionCache` such as `MuteListDecryptionCache`, held next to it in
`Account` as `muteListDecryptionCache`) → fall back to the `AccountSettings` backup
(`settings.backupMuteList`) when the event is absent → `stateIn(scope, SharingStarted.Eagerly, …)`.
- **Mutation helpers return a signed event — publishing it is the caller's job.**
`MuteListState.hideUser(pubkey)` returns a `MuteListEvent`; `Account.hideUser` does
`sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex))`. A state change you never send is
invisible to every other client and is lost when the next relay copy arrives.
- Consumers read `account.<feature>.flow`, never a `MutableStateFlow` on `Account`.
Full catalog of properties, classes, kinds and packages: `references/account-state-flow.md`.
### Adding an account-scoped setting or list
1. Persisted as a Nostr event? Pick the kind (NIP-51 list, NIP-78 app data, a relay list) and make
sure quartz has the event class (see quartz's `nostr-expert` skill; a new one is a quartz
change, built here with `-PquartzDir=../quartz`).
2. Add `commons/…/model/nipXX…/XState.kt` modeled on `MuteListState` (encrypted) or
`nip51Lists/BookmarkListState.kt` (plain): pin, flow, suspend mutation helpers that build the
event with the quartz class and sign it.
3. Instantiate it as a `val` in `Account.kt` (plus its `DecryptionCache` if private) and add an
`Account` method that sends the returned event (`sendMyPublicAndPrivateOutbox`, `sendAutomatic`, …).
If the list must survive relay loss, add a `backupX` field to `AccountSettings`.
4. Make sure `EventCache.justConsumeInnerInner` lists the event class — a kind that is not listed
falls into the `else` branch and is **rejected**, so the state object never sees it, even though
`EventFactory` parses it. Replaceable kinds without custom logic go in the
`consumeBaseReplaceable` group.
5. Relay subscription: `relay-client`. UI: settings screens are shared in
`commonsUI/…/commons/ui/screen/loggedIn/settings/`; strings follow the AGENTS.md *Strings* rules.
## LocalCache vs Account flow vs feed
| You render… | Read |
|---|---|
| one note / user you have an id for | `LocalCache.getOrCreateNote(id)` / `getOrCreateUser(pubkey)`, then `note.flow().metadata` (or `replies`, `reactions`, `zaps`, …) |
| "my follows / mutes / relays / bookmarks" | `account.kind3FollowList.flow`, `account.muteList.flow`, `account.nip65RelayList.flow`, `account.hiddenUsers.flow`, … |
| a list of notes | a `FeedFilter` + `FeedContentState` / `FeedViewModel` — `references/feeds.md` |
| ad-hoc filter results | `LocalCache.observeNotes(filter: Filter): Flow<List<Note>>` |
Never scan `LocalCache.notes` inside a composable.
## Feeds in one paragraph
Bases are in `commons/…/feeds/`: `IFeedFilter` / `FeedFilter<T>` (`feed()`, `feedKey()`, `limit()`
default 500, `showHiddenKey()`), `AdditiveFeedFilter<T>` (adds `applyFilter` + `sort` for
incremental merges), `AdditiveComplexFeedFilter<T, U>` (incoming type differs from row type, e.g.
`home/dal/HomeLiveFilter.kt`), `ChangesFlowFilter<T>`, `FilterByListParams`, `DefaultFeedOrder` /
`sortedByDefaultFeedOrder()`, `FeedContentState`, `FeedState`. Concrete filters are **shared**, in
`commons/…/<feature>/dal/` (`home/dal/`, `hashtag/dal/`, `notifications/dal/`, ~55 of them). The
account's main-tab feeds are long-lived `FeedContentState`s in `viewmodels/AccountFeedContentStates.kt`
(owned by `BaseAccountViewModel.feedStates`); per-screen feeds use a `FeedViewModel` subclass. Recipe,
real code and traps: `references/feeds.md`.
## Gotchas
- **`LocalCache` is shared across accounts.** Switching accounts doesn't wipe it; every `Account`
derives its flows from the same cache. Filters that depend on the account must put the account
pubkey in `feedKey()`.
- **Hold the `Note`, not the `Event`.** `getOrCreate*` returns the same wrapper per id; for
replaceable/addressable notes `note.event` is swapped in place when a newer version arrives.
- **Flows on a `Note` are not durable.** `CachePruner` (`model/cache/CachePruner.kt`) calls
`clearFlow()` on notes during cleanup, and anything not strongly held can be collected. Re-fetch
via `note.flow()`; don't stash flow references.
- **Observers can fire before derived state exists.** `EventCache` notifies observers
(`refreshNewNoteObservers`) as part of consuming an event, before some of what it derives from it
(channels, indexes) is filled in. Re-read state in the collector rather than trusting it is complete.
- **Memory pressure trims.** `MemoryTrimmingService` (`model/cache/`) is run by both app modules,
and `AccountFeedContentStates` trims every feed to 200 items on `memoryPressure`. Never assume a
previously seen note is still resident.
## References
- `references/account-state-flow.md` — catalog of `Account` state objects, kinds and packages.
- `references/local-cache.md` — `EventCache` structures, insertion path, lookups, pruning.
- `references/feeds.md` — adding a feed: filter, state holder, invalidation, rendering, tests.
- Related skills: quartz's `nostr-expert` (event classes, `EventFactory`), `relay-client` (subscriptions),
`auth-signers` (how mutations sign), `compose-expert` (rendering).