docs(quartz): trim verbose KDoc on cache + observable layers

Reviews before merge:

- EventInterner: drop stale "used by Event.fromJson" reference (we
  reverted that earlier). Tighten the class doc.
- ObservableEventStore: collapse the 30+ line class KDoc + duplicated
  StoreChange description into one focused doc each.
- EventStoreProjection: trim the 60+ line class doc; reactive
  semantics live on the project() extension. Add explicit caveat
  about in-place addressable updates not re-evaluating filter
  membership.

No behaviour change. All cache + store + projection tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
This commit is contained in:
Claude
2026-04-30 16:03:50 +00:00
parent f4b94d6c6a
commit f759f44eea
3 changed files with 52 additions and 131 deletions
@@ -24,31 +24,21 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Process-wide interner that canonicalises [Event] instances by id so
* every consumer (relay client, store deserialization, projections,
* tests) sees the same object reference for the same event id.
* Canonicalises [Event] instances by id so every consumer sees the
* same object reference for the same event id.
*
* Backed by weak references — entries vanish when no live consumer
* holds the event, so the cache only grows as long as projections /
* UI state actually need the events. Sized for ~5000 hot events; the
* underlying map resizes if usage exceeds that.
*
* Use [intern] on every event arrival path. The first occurrence wins
* and becomes canonical; subsequent equivalent decodes return that
* canonical instance.
* holds the event. First-seen wins; equivalence is by event id, so
* callers must trust ids are content-derived (signed events satisfy
* this). Sized for ~5000 hot entries; resizes if usage exceeds that.
*
* Platforms without weak references fall back to a passthrough that
* returns [event] unchanged — no canonicalisation, but no leaks
* either.
* returns [event] unchanged — no canonicalisation, no leaks.
*/
expect class EventInterner() {
/**
* Returns the canonical [Event] for [event]'s id. If a live
* canonical instance already exists for this id, returns it;
* otherwise stores [event] as the new canonical and returns it.
*
* Equivalence is by event id only — callers must trust the id
* was content-derived (signed events satisfy this).
* Returns the canonical [Event] for [event]'s id, storing
* [event] as canonical if no live entry exists.
*/
fun intern(event: Event): Event
@@ -62,7 +52,7 @@ expect class EventInterner() {
fun clear()
companion object {
/** Process-wide default interner used by [Event.fromJson]. */
/** Process-wide shared instance. */
val Default: EventInterner
}
}
@@ -58,70 +58,35 @@ sealed interface ProjectionState<out T : Event> {
/**
* State machine that maintains a reactive view over an
* [ObservableEventStore] for a fixed set of [filters]. Each visible
* event is wrapped in a [MutableStateFlow] so the UI can collect
* three different kinds of change with the right granularity:
* [ObservableEventStore] for a fixed set of [filters]. Pure logic, no
* coroutine ownership: construct, [seed] once, then [apply] each
* [StoreChange] and read [snapshot]. For the standard "wrap into a
* cold Flow and collect from a ViewModel" path, use
* [ObservableEventStore.project] which composes this class.
*
* - **Membership** (events arriving or leaving) produces a brand
* new [List] in [snapshot]. The list reference is stable while
* membership is unchanged.
* - **In-place replaceable / addressable update** (a new version of
* the same `kind:pubkey:dtag` arrives) updates the existing
* handle's [MutableStateFlow.value] without changing membership.
* Only collectors of that one handle re-render. The list ordering
* is *not* reshuffled when the new version has a later
* `created_at` — each slot remembers the sort key it was inserted
* with, so updates feel like pure value mutations.
* - **Removal** (NIP-09 deletion, NIP-62 vanish, NIP-40 expiration,
* `delete(filter)`) drops the handle from the list.
* Each visible event is wrapped in a [MutableStateFlow], giving the
* UI three change granularities:
*
* Three kinds of [StoreChange] are interpreted in-projection:
* - **Membership** (insert / removal): [snapshot] returns a fresh
* list. Stable list reference while membership is unchanged.
* - **In-place replaceable / addressable update**: same slot, new
* [MutableStateFlow.value]. Only collectors of that handle
* re-render. The slot's sort key is frozen at insertion, so the
* list does not reshuffle when the new version has a later
* `created_at`. *Caveat: the slot stays in whatever filters
* matched the original version; if the new version no longer
* matches a filter (e.g. its tags changed), the slot is not
* re-evaluated and remains live.*
* - **Removal**: NIP-09, NIP-62, NIP-40 expiration, `delete(filter)`.
*
* - [StoreChange.Insert] — a single arriving event can carry NIP-01
* / NIP-09 / NIP-62 semantics:
* - **NIP-01 supersession.** New replaceable / addressable events
* replace prior ones for the same `kind:pubkey[:dtag]`. The
* NIP-01 lexical-id tiebreaker (`new.id < old.id` when
* `created_at` ties) is honoured.
* - **NIP-09 deletions.** A [DeletionEvent] removes any matching
* handle owned by the same author (for GiftWrap, the recipient).
* Cross-author deletions are inert.
* - **NIP-62 right to vanish.** A [RequestToVanishEvent] whose
* `shouldVanishFrom(store.relay)` is true drops every handle
* from the same author with `created_at < vanish.created_at`.
* - **NIP-40 expiration.** Events whose `expiration` tag has
* already lapsed at the moment they arrive are dropped before
* they ever enter the snapshot.
* - [StoreChange.DeleteByFilter] — emitted on `delete(filter)` /
* `delete(filters)`. Drops every slot matching any of the rule's
* filters via [Filter.match].
* - [StoreChange.DeleteExpired] — emitted on `deleteExpiredEvents()`.
* Drops every slot whose `expiration` has lapsed at the cutoff the
* store pinned. **There is no per-projection expiration ticker** —
* expiration only triggers when the application calls
* `deleteExpiredEvents()` on the store.
* Limit is **per-filter**: each filter retains at most its own
* `limit` matches; the snapshot is the deduped union, so disjoint
* matches across filters can exceed any single cap. Removed slots
* are not refilled from the store.
*
* Limit handling is **per-filter**: each filter retains at most its
* own `limit` matches in a private capped set, sorted by created_at
* DESC + id ASC. The snapshot is the deduped union of those sets, so
* when filter A and filter B match disjoint events the union can be
* larger than any single filter's `limit`. We do not refill from the
* store after a deletion — if a removal leaves a filter under cap, it
* stays under cap until another match arrives.
*
* Ephemeral events (kinds `20000-29999`) reach the projection without
* ever being persisted; they appear in the snapshot for as long as
* the projection is alive but never survive a re-seed. They aren't
* covered by the store's `deleteExpiredEvents()` sweep (the DB never
* had them), so an ephemeral with an `expiration` tag will linger
* until it's superseded or until the projection is closed.
*
* **Lifecycle**: this class is a pure state machine with no
* coroutine ownership. Construct it, call [seed] once, then call
* [apply] for each [StoreChange] from [ObservableEventStore.changes].
* For the standard "wrap into a Flow and collect from a ViewModel"
* use case, use the [ObservableEventStore.project] extension which
* does this composition for you.
* Ephemeral events appear in the snapshot for as long as the
* projection is alive; they never survive a re-seed (the inner store
* never had them) and aren't touched by `deleteExpiredEvents()`.
*/
class EventStoreProjection<T : Event>(
private val store: ObservableEventStore,
@@ -174,9 +139,8 @@ class EventStoreProjection<T : Event>(
dropWhere { ev -> storeEvent.filters.any { it.match(ev) } }
}
// Store's sweep uses strict `<`; isExpirationBefore is
// `<=`, so subtract 1 to match.
is StoreChange.DeleteExpired -> {
// Store's sweep uses strict `<`; isExpirationBefore is `<=`, so subtract 1.
val cutoff = (storeEvent.asOf ?: nowProvider()) - 1
dropWhere { it.isExpirationBefore(cutoff) }
}
@@ -32,31 +32,18 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* A reactive façade over any [IEventStore] that publishes every event
* accepted for *observation* — a superset of the events the inner
* store persists.
* Reactive façade over any [IEventStore]. Publishes a [StoreChange]
* on [changes] for every accepted mutation so projections can stay in
* sync without re-querying.
*
* The split between persistence and observation is the whole point of
* this class:
*
* - **Non-ephemeral events** are forwarded to the inner store. If the
* inner store rejects (expired, NIP-09 / NIP-62 tombstone, NIP-01
* supersession loser), the rejection propagates and nothing is
* emitted on [changes].
* - **Ephemeral events** (kinds `20000-29999`) skip the inner store
* entirely — they're never persisted — but they still emit on
* [changes] so projections can render them while they live. Already
* expired ephemerals are silently dropped.
*
* Wrap any store you want to observe — `SQLiteEventStore`, FS-backed,
* an in-memory test fake — and feed `EventStoreProjection` from the
* resulting [changes] flow.
*
* Reads (`query`, `count`) and out-of-band writes (`delete`,
* `deleteExpiredEvents`) forward to the inner store; the latter are
* also surfaced on [changes] as [StoreChange.DeleteByFilter] /
* [StoreChange.DeleteExpired] so projections can drop the matching
* slots in memory without re-querying.
* - Non-ephemeral [insert] / [transaction] entries forward to the
* inner store. On rejection (expired, NIP-09 / NIP-62 tombstone,
* NIP-01 supersession loser) the throw propagates and nothing is
* emitted.
* - Ephemeral events (kinds `20000-29999`) skip the inner store but
* still emit. Already-expired ephemerals are silently dropped.
* - [delete] and [deleteExpiredEvents] also emit so projections drop
* the matching slots in memory.
*/
class ObservableEventStore(
val inner: IEventStore,
@@ -70,35 +57,15 @@ class ObservableEventStore(
onBufferOverflow = BufferOverflow.SUSPEND,
)
/**
* Stream of mutations accepted by the observable layer. One
* emission per successful [insert] (or per accepted event in a
* [transaction] body), one emission per [delete] /
* [deleteExpiredEvents] call. Rejected inserts and rolled-back
* transactions emit nothing.
*
* Projections consume this stream — see `EventStoreProjection`
* for how each [StoreChange] is interpreted.
*/
/** Stream of mutations accepted by this layer. See [StoreChange] for the cases. */
val changes: SharedFlow<StoreChange> = _changes.asSharedFlow()
/**
* Mutations published by [changes]. Projections react to these to
* keep their in-memory view in sync with the underlying store.
*
* - [Insert] is emitted for every event accepted by the
* observable layer (persistable or ephemeral). Carries the
* event itself so the projection can run its NIP-01 / NIP-09 /
* NIP-62 interpretation.
* - [DeleteByFilter] is emitted for every `delete(filter)` /
* `delete(filters)` call on the observable. Carries the same
* filters the store used so projections can apply
* [Filter.match] in memory and drop the matching slots without
* re-querying.
* - [DeleteExpired] is emitted for every `deleteExpiredEvents()`
* sweep. The optional [DeleteExpired.asOf] cutoff lets the
* store pin the timestamp it actually used, so the projection
* drops exactly the events the store dropped.
* One [Insert] per accepted event; one [DeleteByFilter] per
* `delete(filter[s])`; one [DeleteExpired] per
* `deleteExpiredEvents()` sweep. The cutoff carried by
* [DeleteExpired] is pinned at the moment the sweep ran so
* projections drop exactly the events the store dropped.
*/
sealed interface StoreChange {
data class Insert(