The README's "Wiring relays into the store" tutorial used a
per-subscription SubscriptionListener.onEvent callback to feed the
store. EventCollector landed exactly to remove that boilerplate, so
update the example to register one connection-level collector at app
init and stop wiring listeners per subscribe() call.
The "Building a reactive feed UI" example showed project() ->
stateIn(WhileSubscribed) but never opened the relay subscription.
Bind it to the projection's collection lifecycle with .onStart {
client.subscribe } / .onCompletion { client.unsubscribe }, so the
relay subscription's lifetime equals the UI collector's. Also
demonstrate ProjectionState.filterItems for narrowing without a
re-query.
Drop the redundant client.connect() call from the wiring example
and add an explicit note (in both README and CLIENT) that
NostrClient connects on demand from subscribe()/publish() — connect()
is only useful after disconnect().
Other small fixes:
- client.send(...) -> client.publish(...) (the actual API name).
- Rename the variable from `observable` to `db` to match the demoApp
code and the conceptual "this is the local database" framing.
- Wrap the publish path in a viewModelScope.launch so the snippet
is copy-pasteable into a ViewModel.
Tutorial-style README covering the full pipeline:
relay → NostrClient → ObservableEventStore → InterningEventStore →
EventStore → project() → ViewModel → Compose.
Two worked examples:
1. Wiring NostrClient subscriptions into the store via
SubscriptionListener.onEvent.
2. Building a reactive feed UI with project().stateIn(...) and
Compose, showing how the three reactivity layers (Loading/Loaded
state, list membership, per-event handles) map to Compose's
recomposition model.
Cross-links the existing CLIENT.md, RELAY.md, and store/sqlite
README. Pointers to per-class KDoc for projection internals.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Three small wins on paths the projection runs every event arrival
or every snapshot publish:
1. Cache `address` on Slot. Previously `removeIndexes(slot)` called
`addressOf(slot.flow.value)` on every drop — for replaceables
that's a fresh Address allocation every time. Now the address is
computed once at slot construction and reused on removal.
2. Single-filter snapshot fast path. The deduped sorted union via
TreeSet was unnecessary when there's only one per-filter set —
that set is already sorted in the slot comparator's order. The
common UI case (one filter per projection) now skips the
TreeSet allocation + log-n inserts entirely.
3. Comparator singleton. `slotComparator()` was allocating a fresh
Comparator lambda on every call (ctor + every snapshot).
Replaced with a single `Comparator<Slot<*>>` cast at the use
site — the comparator only reads sort keys that don't depend on
the type parameter.
Also trimmed a verbose comment block in `project()` (3 lines → 1).
All 18 projection tests + 240 store + interner tests still pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
When a replaceable / addressable update arrives in handleInsert, the
projection now re-runs Filter.match against the new event for every
filter. A v2 that no longer matches a filter (e.g. tag list changed)
is removed from that filter's set; a v2 that newly matches a filter
joins it. If no filter retains the slot afterwards, it's fully
dropped from byId / byAddress.
Closes the previous gap where v2 of an addressable kept a stale
filter membership inherited from v1. The slot's MutableStateFlow is
still updated in place — collectors of that handle still see the
content change before the membership update emits.
The cap-eviction-with-cleanup logic was extracted into a small
`admit` helper since the supersession path and the new-slot path
both need it.
For the common case (filter on `kinds + authors`, no tag/time
constraints), v2 always still matches — the new branches are no-ops
and the list reference stays stable, preserving the in-place update
guarantee. The behaviour change kicks in for tag, time-window, or
id-list filters.
New test addressableUpdateDropsSlotWhenFilterStopsMatching: v1 has
hashtag "nostr" and matches a `t = nostr` filter; v2 changes to
"bitcoin"; the projection drops the slot. 18/18 projection tests
pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
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
After the inner store accepts an event (insert succeeds), register
the caller's instance with the interner so subsequent reads of the
same id return the same `===` reference. This means an event that
was just inserted and is then queried back resolves to the original
in-memory object, not a freshly-deserialized clone.
Same in transaction { }: every accepted event is interned after the
inner transaction commits. If the body throws or inner rolls back,
nothing is interned (accepted list is discarded).
The decorator still does not *substitute* the caller's event with a
previously-cached one — same-id-different-sig collisions resolve
the new event into the cache without overwriting (intern() is
first-seen-wins). The caller keeps the instance they passed in.
All cache + store + projection tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
channelFlow was overkill for a single-producer chain. The cost was
an extra Channel hop between every projection.snapshot() and the
downstream collector — pure overhead with no concurrency benefit.
flow { } needs the outer collector captured (because inside
changes.onSubscription { ... } and changes.collect { ... } the
implicit `this` is FlowCollector<StoreChange>, not
FlowCollector<ProjectionState<T>>). One `val outer = this` fixes
that.
Backpressure now flows directly: a slow collector suspends emit,
which suspends our changes.collect, which suspends the SharedFlow's
buffer drain — natural propagation, no decoupling Channel in the
middle.
17/17 projection tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The pre-check in SQLiteEventStore.insertEvent rejects events whose
expiration is <= TimeUtils.now(). With expiration = time + 1, the
wall clock can tick past time + 1 between sampling `time` and the
insert call, intermittently failing testDeletingExpiredEvents.
https://claude.ai/code/session_01GoqXtcFnwgyjBict1EURyA
Removes scope ownership from EventStoreProjection. The class is now a
pure state machine — no Job, no AutoCloseable, no embedded
StateFlow:
class EventStoreProjection(store, filters, nowProvider) {
suspend fun seed() // run the seed query
fun apply(change: StoreChange): Boolean // apply one mutation
fun snapshot(): ProjectionState.Loaded // current view
}
ObservableEventStore.observe(filter, scope) is renamed to
ObservableEventStore.project(filter) and now returns a cold
Flow<ProjectionState<T>>. Each collection allocates its own state
machine, runs its own seed, and unsubscribes when the collector
cancels. The flow is built using channelFlow + the state machine —
the class stays as the load-bearing primitive.
Standard caller pattern:
val state = observable.project<TextNoteEvent>(filter)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000),
ProjectionState.Loading)
Tradeoffs:
- Cold flow → multiple collectors each re-seed. ViewModel pattern
with stateIn shares one collection across observers (the standard
Android approach).
- close() goes away. Cancelling the collecting scope tears down the
projection.
- closeStopsListening test renamed to cancellingScopeStopsListening
and now verifies the StateFlow's value freezes after its scope is
cancelled.
17/17 projection + 240/240 store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
EventStoreProjection now exposes `state: StateFlow<ProjectionState<T>>`
instead of separate `items` + `ready` signals. ProjectionState is a
sealed interface with two cases:
data object Loading // initial state
data class Loaded(items: List<MutableStateFlow<T>>) // post-seed view
This lets the UI distinguish "still seeding" from "seeded but empty"
— the previous `emptyList()` initial value conflated the two. Future
extension to `Failed(throwable)` is a single case addition.
The `ready: CompletableDeferred<Unit>` signal is gone; callers use
`state.first { it is Loaded }` (or the test `awaitReady()` helper).
Test fixtures gain two private extensions on EventStoreProjection<T>:
- val items: List<MutableStateFlow<T>> — terse access to the loaded
list (returns empty if still seeding).
- suspend awaitReady() — replaces ready.await().
awaitItems(predicate) now matches against the Loaded state. 17/17
projection + 240/240 store + interner tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Past-participle "Interned" suggested an attribute of the events
flowing through; present-participle "Interning" describes what the
decorator actively does. Read clearer.
File renamed, no consumers needed updating (this commit is the only
external reference, since callers compose the decorator inline).
All 240 store + projection + interner tests still pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Three changes wrapped together:
1. relay moves up to IEventStore. The interface gains an abstract
`val relay: NormalizedRelayUrl?` and SQLiteEventStore, EventStore,
FsEventStore, InternedEventStore, and ObservableEventStore all
override it (decorators forward inner.relay). EventStoreProjection
loses its `relay` ctor param and uses `store.relay` for NIP-62
vanish scoping; ObservableEventStore.observe overloads lose their
relay arg. The relay was always a property of the store anyway —
threading it through projection construction was redundant.
2. StoreChange inlines into ObservableEventStore. The sealed type is
now ObservableEventStore.StoreChange (with Insert / DeleteByFilter /
DeleteExpired as nested cases) since it's strictly a contract of
the change stream, never used independently. StoreChange.kt deleted.
3. Package reshuffle:
store/projection/ObservableEventStore.kt → store/ObservableEventStore.kt
store/projection/EventStoreProjection.kt → cache/projection/EventStoreProjection.kt
store/projection/StoreChange.kt → inlined into ObservableEventStore
ObservableEventStore is a store decorator and lives next to
IEventStore. EventStoreProjection is a cache primitive (alongside
EventInterner / InternedEventStore) and lives under cache/. The
`observe(filters, scope)` convenience moved from a method on
ObservableEventStore to a top-level extension function in
cache/projection/, which avoids the otherwise-circular
store → cache/projection → store dependency.
7/7 interner + 17/17 projection + all other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Groups EventInterner + InternedEventStore + the platform actuals + the
test under a focused interning sub-package. Leaves nip01Core/cache/ as
the namespace for cache primitives generally; future cache types
(LargeWeakCache, etc.) get sibling sub-packages.
Files moved:
cache/EventInterner.kt → cache/interning/EventInterner.kt
cache/InternedEventStore.kt → cache/interning/InternedEventStore.kt
cache/EventInterner.jvmAndroid → cache/interning/EventInterner.jvmAndroid
cache/EventInterner.apple → cache/interning/EventInterner.apple
cache/EventInterner.linux → cache/interning/EventInterner.linux
cache/EventInternerTest.kt → cache/interning/EventInternerTest.kt
No consumers to update — the previous decorator refactor already
removed the interner from all the store ctors. 7/7 interner +
240/240 store + projection tests still pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The "Event" name was overloaded — the store-mutation type lived
right next to Nostr Event, so StoreEvent.Insert(event: Event) read
awkwardly. Renaming to StoreChange disambiguates and matches
reactive vocabulary ("store changes").
- StoreEvent → StoreChange (file, sealed type, all references).
- ObservableEventStore.events → ObservableEventStore.changes.
- Internal field _events → _changes.
- KDoc cross-references updated.
Case names (Insert, DeleteByFilter, DeleteExpired) unchanged. All
240 store + projection + interner tests still pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Replaces the ctor-injected interner inside SQLiteEventStore /
QueryBuilder / EventStore / FsEventStore with a standalone
InternedEventStore decorator in nip01Core/cache/.
Composition is now explicit at the call site:
val sqlite = EventStore(...)
val cached = InternedEventStore(sqlite)
val observable = ObservableEventStore(cached)
Each layer has one job: EventStore persists, InternedEventStore
canonicalises read results, ObservableEventStore publishes the bus.
Stores no longer carry an interner field; passing one through three
layers of constructor params is gone.
InternedEventStore wraps every IEventStore.query variant (list +
streaming, single filter + multi) and pipes results through
interner.intern. Writes (insert, transaction, delete*,
deleteExpiredEvents) and counts pass through untouched.
assertQuery test helpers generalized from EventStore /
SQLiteEventStore to IEventStore so the decorator works with the
existing fixtures.
BaseDBTest reverts to plain EventStore — the basic store tests
don't read through the projection / interning layer, so they don't
need decoration. Tests that DO want canonical-instance reads can
wrap explicitly: `InternedEventStore(eventStore)`.
7/7 interner + 240/240 store + projection tests still pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Splits the cache primitives off into their own package. Files moved:
nip01Core/core/EventInterner.kt → nip01Core/cache/EventInterner.kt
nip01Core/core/EventInterner.jvmAndroid → nip01Core/cache/EventInterner.jvmAndroid
nip01Core/core/EventInterner.apple → nip01Core/cache/EventInterner.apple
nip01Core/core/EventInterner.linux → nip01Core/cache/EventInterner.linux
nip01Core/core/EventInternerTest.kt → nip01Core/cache/EventInternerTest.kt
Imports updated in SQLiteEventStore, QueryBuilder, EventStore (sqlite),
FsEventStore, BaseDBTest. Each platform actual now explicitly imports
nip01Core.core.Event / HexKey since we crossed package boundaries.
7/7 interner + 240/240 store + projection tests still pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The previous commit interned at Event.fromJson, which is too early —
the deserializer can produce events with manipulated id fields that
don't match their content. Move interning to the post-validation
boundary: events are only canonicalised when read back from durable
storage, where they were valid when written.
- Revert Event.fromJson to plain OptimizedJsonMapper.fromJson.
- Revert ObservableEventStore.insert interning. Substituting the
caller's event for a previously-cached one with the same id but
potentially different sig was a write-side surprise; we leave the
caller's instance alone.
- Intern at SQLiteStatement.toEvent (read deserialization).
- Intern at FsEventStore.readEvent (FS reads).
The interner is also now constructor-injected through every store
layer (SQLiteEventStore, EventStore, QueryBuilder, FsEventStore)
defaulting to EventInterner.Default. BaseDBTest gives each parallel
forEachDB store its own EventInterner — without this, parallel test
runs that sign "same id, different sig" events through the shared
Default would see the first run's sig leak across all DBs.
All 240 store + projection + interner tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Process-wide interner that canonicalises Event instances by id —
whenever the same event id is decoded twice (relay duplicates,
projection seeds, FS re-reads, etc.), every consumer sees the same
object reference. Backed by weak references so entries vanish once
no live consumer (projection slot, UI state) holds the event.
Shape:
- expect class EventInterner in nip01Core/core/, with a Default
process-wide instance.
- jvmAndroid actual: ConcurrentHashMap<HexKey, WeakReference<Event>>
pre-sized to 5_000 (load factor 0.75). On-access cleanup atomically
removes dead entries via map.remove(key, ref). intern() uses
putIfAbsent + retry-on-dead-canonical to be race-safe.
- apple / linux actuals: passthrough (no canonicalisation). Kotlin/
Native has weak refs but no built-in concurrent map; rather than
ship a half-baked impl on Apple targets we skip canonicalisation
there. Can be revisited if iOS wants the memory savings.
Wire-up:
- Event.fromJson now routes through EventInterner.Default.intern,
so every deserialised event becomes canonical for free. In-process
events (signer.sign(...) etc.) aren't auto-interned — the
canonicaliser pays off for events that re-occur from multiple
sources, which signed-locally events don't.
Tests (jvmTest):
- internReturnsFirstInstance / internCollapsesDuplicates:
identity is preserved across equivalent decodes.
- getReturnsLiveEntry / getReturnsNullForUnknownId.
- getEvictsDeadEntries: weak ref cleared by GC, on-access
cleanup drops the entry.
- draftChurnDoesNotLeak: 100 distinct drafts churn through the
interner with no strong refs; map shrinks back to ~0 after GC.
- defaultInstanceIsShared: the global Default interner is process-wide.
- 7/7 pass; 240/240 store + projection tests still green.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Malformed `i` tags whose platform-identity field has no `:` (observed in
production logcat as e.g. `["i", " ", " "]`) made `create()` throw
`IndexOutOfBoundsException` from destructuring `split(':')`. The exception
was caught but each event spammed multiple stack traces. Validate the
separator up front in `parse()` and return null silently. Also fix the
diagnostic `joinToString { "," }` typo so the log shows real tag contents.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review of the projection found one real bug and four redundancies:
Bug fix: handleInsert tracked `retained` inline as it walked filters,
flipping it to false when the slot self-evicted from a filter's cap.
But if filter A retained the slot (cap=null or with room) and filter B
later cap-evicted it, retained ended up false even though the slot was
still in A's set — leaking a slot into a per-filter set with no
byId/byAddress entry. Fix: compute retention after the loop with
`perFilter.values.any { it.contains(slot) }`.
Simplifications:
- Drop the redundant `ordered: SortedSet<Slot>` field. It was only
read by publish(); it's the deduped sorted union of perFilter
values. Compute it lazily at publish time instead. Removes the
field, all the ordered.add/ordered.remove calls, and one helper.
- Collapse `dropSlotIndexes` and `removeSlot` paths into
`removeIndexes` (id+address) + `removeSlot` (also clears per-filter
sets). The eviction loop calls removeIndexes; everywhere else
calls removeSlot.
- Drop the defensive `byAddress[address] === slot` identity check —
with byId as the primary index, removeSlot is only called once per
slot, and rekey only ever moves byId, never byAddress.
- Pull the iterate-and-drop pattern into a single `dropWhere(predicate)`
inline helper. handleVanish, applyDeleteByFilter, and
applyDeleteExpired all collapse to one-liners.
- Apply now returns Boolean from each branch and publish() runs once
at the end, instead of three separate `if-publish` blocks.
Net: 233 → 142 lines. Same behaviour, plus the retained bug fix.
17/17 projection tests + all other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Five changes wrapped together:
1. Move ObservableEventStore + StoreEvent from store/observable/ into
store/projection/. One package owns the projection bus + projection
itself; consumers import only `store.projection.*`.
2. Flatten DeleteRule into the StoreEvent sealed type:
StoreEvent.Insert(event)
StoreEvent.DeleteByFilter(filters)
StoreEvent.DeleteExpired(asOf?)
ObservableEventStore.delete(filter|filters) emits DeleteByFilter;
deleteExpiredEvents() emits DeleteExpired with the pinned cutoff.
3. Use event.address() for AddressableEvent and event.isExpirationBefore(t)
for NIP-40 checks; drop the local addressOf(kind, pubKey, dTag) and
isExpiredAt helpers. Plain replaceables (kind 0/3, 10000-19999) still
build Address(kind, pubKey, "") manually since they don't implement
AddressableEvent. NIP-09 delete-by-address now looks up the projection's
byAddress index with the deletion's Address directly (it's already an
Address, no conversion needed).
4. Per-filter limit. Each filter retains its own capped TreeSet<Slot>
(sorted created_at DESC, id ASC). A slot is "live" iff at least one
filter retains it. The projection's items is the deduped union, so
when filter A and filter B match disjoint events the union can
exceed any single filter's limit. New tests confirm both behaviours:
per-filter eviction + union-larger-than-cap.
5. Drop the redundant + 1 in expiration checks; use isExpirationBefore
directly with TimeUtils.now() for arrival-time guards. The sweep
path subtracts 1 because the store's SQL uses strict `<` while the
helper is `<=`.
17/17 projection tests + all other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Renames `byStableKey: HashMap<String, Slot>` to
`byAddress: HashMap<Address, Slot>` in EventStoreProjection. Address
is a data class on every platform so equals/hashCode are derived
from (kind, pubKeyHex, dTag) — same identity as the synthetic string
key, but typed.
`stableKey(event)` / `stableKey(kind, pubKeyHex, dTag)` become
`addressOf(...)` returning `Address?`. Plain replaceables map to
`Address(kind, pubKey, "")`, addressables to
`Address(kind, pubKey, dTag)`, regular events to null.
`limit` was already used at insertNew() to enforce the cap on
inserts; left unchanged.
15/15 projection tests still pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Replaces ObservableEventStore.events: SharedFlow<Event> with
SharedFlow<StoreEvent>, where StoreEvent is:
Insert(event)
Delete(rule: DeleteRule)
DeleteRule.Filtered(filters) ← from delete(filter) / delete(filters)
DeleteRule.Expired(asOf) ← from deleteExpiredEvents()
ObservableEventStore now emits Insert for accepted events, and Delete
for every out-of-band removal that went through it. The Expired rule
carries the cutoff timestamp (TimeUtils.now() at call time) so the
projection's in-memory drop matches the store's on-disk drop without
clock skew between collectors.
EventStoreProjection no longer runs its own NIP-40 ticker. Expired
events linger in [items] until the application calls
deleteExpiredEvents() on the observable — at which point the
projection drops everything whose expiration is past `asOf`. The
ticker, expirationTickMs constructor arg, and sweepExpired helper are
all removed.
For DeleteRule.Filtered the projection iterates current slots and
drops any whose event matches any of the rule's filters via
Filter.match — same predicate the store uses.
Tests:
- nip40ExpirationDroppedByTicker → nip40ExpirationDroppedOnStoreSweep:
drives a deleteExpiredEvents() call instead of waiting on a ticker.
- New deleteByFilterRemovesMatchingSlots: confirms delete(filter) on
the observable removes exactly the matching slots from the projection
without touching others.
- 15/15 projection tests + all other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Reverts the previous round's choice of embedding an ObservableEventStore
inside EventStore. The wrapper now lives strictly outside any specific
store: callers compose `ObservableEventStore(EventStore(...))` (or
`ObservableEventStore(FsEventStore(...))`, or any IEventStore) and use
that as their projection bus.
EventStore is back to a plain IEventStore implementation over
SQLiteEventStore — no `observable`, `events`, or `observe()` on it.
ObservableEventStore gains the `observe(filters, relay, scope)` and
`observe(filter, relay, scope)` convenience methods. NIP-62 vanish
scoping is passed in per-projection (rather than a constructor field
on the wrapper) so the same observable can feed projections with
different relay scopes.
Tests construct `observable = ObservableEventStore(store)` in
`setUp` and route inserts through `observable.insert(...)` so the
projection bus actually publishes them. 14/14 projection + 219/219
other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Two interrelated bugs from the deployed-schema flip:
1. Rooms created in Amethyst didn't appear in nostrnests's "Live Now"
lobby. The lobby filter in NestsUI's skeleton.js requires a `title`
tag on every kind:30312 — events without one are dropped from
`live`/`planned`/`ended`. RoomNameTag still emitted the legacy
`room` name, so our rooms passed our own readers (which tolerate
both names) but were invisible to NestsUI. Flip canonical to
`title`, keep `room` as legacy on read.
2. The mute badge over the avatar disappeared shortly after toggling
mute, even though the broadcaster was still muted. Two causes:
- ParticipantsGrid gated both the muted ring and the MicStateBadge
on `member.publishing`. But per the deployed kind-10312 semantics
`publishing=0` whenever `muted=1` (NestRoomPresencePublisher
mirrors this — `publishingNow = broadcasting && !isMuted`), so
the badge that was supposed to *indicate* mute was hidden
exactly when it became relevant. Show the badge when on stage
and either publishing OR muted; gate the muted ring on `muted`
alone.
- The presence heartbeat captured `micMutedTag` / `publishingTag`
/ `onstageTag` / `handRaised` at LaunchedEffect launch and never
refreshed them. The 500 ms debounced LaunchedEffect did publish
the new mute state, but ~30 s later the heartbeat overwrote it
with the captured pre-toggle values, flipping `publishing`
back to 1 and `muted` back to 0 server-side. Wrap the dynamic
fields in `rememberUpdatedState` so the heartbeat reads the
latest snapshot on every refresh; keep the existing key set
(event/handRaised/onstage) for prompt re-emission on those
transitions.
Tests updated for the new canonical `title` emission; both jvmTest
suites pass.
Splits "events accepted for persistence" from "events accepted for
observation". The new ObservableEventStore wraps any IEventStore and
owns events: SharedFlow<Event>:
- Non-ephemeral events forward to the inner store; success → emit, any
rejection (expired, NIP-09 / NIP-62 tombstone, NIP-01 supersession
loser) → no emit.
- Ephemeral events (kinds 20000-29999) skip persistence entirely but
still emit, so an open EventStoreProjection renders them while alive.
They vanish from any future seed because the DB never had them.
Reverts the previous round of plumbing inside SQLiteEventStore /
FsEventStore: stores no longer carry an inserts SharedFlow. IEventStore
goes back to a clean read/write contract. ObservableEventStore is the
single place that decides what makes it onto the projection bus.
EventStore (the SQLite convenience class) embeds an ObservableEventStore
internally so existing call sites like `store.observe(filter, scope)`
keep working without changes.
EventStoreProjection now collects via Flow.onSubscription so the
collector subscription is established before the seed query runs —
fixes a race where an insert immediately after `ready.await()` could
land in the SharedFlow before the collector was subscribed.
Tests:
- ephemeralEventsAppearInProjection — kind-22000 event reaches items
but isn't queryable from the inner store, and a fresh projection
on the same store gets an empty seed.
- 14/14 projection tests + 219/219 other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The projection now sits on top of any IEventStore, not just SQLite.
Stores publish a simple `inserts: SharedFlow<Event>` (one event per
successful insert), and the projection itself replays the NIP rules
against that stream:
- NIP-01 supersession with the lexical-id tiebreaker, for both
replaceables (kind 0/3/10000-19999) and addressables (30000-39999).
Both now share a single in-place update path.
- NIP-09 deletions, with the original-author check (cross-author
kind-5s are inert, matching the store).
- NIP-62 right-to-vanish, scoped by the projection's relay arg.
- NIP-40 expiration via a per-projection ticker that drops slots whose
expiration tag has lapsed.
Out-of-band store mutations (`delete(id)`, `clearDB()`, the periodic
`deleteExpiredEvents()` sweep) are no longer visible to projections —
they're maintenance ops; projections re-seed when their scope is
restarted.
Removed from SQLiteEventStore:
- ChangeLogModule + temp-table + AFTER DELETE trigger.
- StoreChange sealed type and the per-mutation drain/publish path.
Both SQLiteEventStore and FsEventStore now satisfy
`IEventStore.inserts`. FsEventStore.insertLocked returns Boolean so
no-op idempotent retries (canonical already on disk) don't re-publish.
Tests:
- EventStoreProjectionTest moves to `store/projection/`, gains four
new cases: NIP-01 out-of-order rejection, NIP-09 cross-author
inertness, NIP-62 cross-author no-op, NIP-40 ticker-driven removal.
- 13/13 projection tests + 232/232 total store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The deployed nostrnests reference (NestsUI v2 + moq-auth + moq-rs)
emits a different on-the-wire schema than the previous EGG-01 / EGG-09
drafts and Quartz writers. Verified by reading the production
NestsUI bundle. The deployed schema is now canonical:
kind:30312 (room event)
- relay URL → ["streaming", url] (was ["endpoint", url])
- auth URL → ["auth", url] (was ["service", url])
- live status → ["status", "live"] (was "open")
- ended status → ["status", "ended"] (was "closed")
- room name → ["title", name] (was ["room", name])
kind:10112 (user MoQ-server list)
- 3-element entries: ["server", relay, auth]
- first-element name "relay" accepted as a synonym (legacy)
- 2-element entries: derive auth host by replacing leading "moq."
with "moq-auth.", or prepending "moq-auth." otherwise
Implementation
- ServiceUrlTag.TAG_NAME = "auth", LEGACY_TAG_NAME = "service"
- EndpointUrlTag.TAG_NAME = "streaming", LEGACY_TAG_NAME = "endpoint"
- StatusTag enum: PLANNED/LIVE/PRIVATE/ENDED with code "live"/"ended";
legacy "open"/"closed" still parsed on read
- NestsServersEvent: emit/read 3-element [server, relay, auth] tags
with the legacy-shape tolerances above; expose a NestsServer pair
- Account.nestsServers.flow now produces List<NestsServer> pairs
- NestsServersScreen: rewritten edit-field asks for both URLs,
recommended row shows the nostrnests pair, list rows show both URLs
- NestsScreen first-time setup writes the nostrnests pair, not a
single URL; gate the create FAB on both URLs being parseable
- CreateNestViewModel: drop the resolveServerPair hardcoded mapping —
the saved pair is now authoritative; defaults stay correct for an
empty kind-10112 list
Specs
- EGG-01 rewritten to canonical streaming/auth/live/ended with a
legacy-spelling table; example uses the real nostrnests pair
- EGG-02 references "auth" tag throughout; error taxonomy says "ended"
- EGG-09 rewritten to 3-element server tag with derivation fallback
- New nestsClient/specs/nip-53-proposed.md — a single self-contained
proposed update to upstream NIP-53 covering kind 30312 + kind 10112
with the deployed schema and the JWT-mint flow
All existing unit tests adjusted; quartz:jvmTest, amethyst:testPlayDebugUnitTest,
and nestsClient:jvmTest pass.
Adds a stateful observer that turns a Filter into a StateFlow<List<MutableStateFlow<Event>>>:
- A TEMP table + AFTER DELETE trigger on event_headers logs OLD.id for every row
that leaves the store, regardless of cause (replaceable / addressable
supersession, NIP-09 deletion, NIP-62 vanish, NIP-40 expiration sweep, manual
delete, clearDB).
- SQLiteEventStore drains the log around each writer unit of work and emits a
StoreChange(inserted, removedIds) on a SharedFlow.
- EventStoreProjection seeds itself from the store, then maintains stable
MutableStateFlow handles keyed by (kind:pubkey:dtag) for addressables and by
id otherwise. Addressable updates mutate the handle's value in place — list
reference stable; insert / remove rebuilds the list reference.
- 11 new tests cover seed, insert, replaceable update, addressable update,
NIP-09 deletion, NIP-62 vanish, NIP-40 expiration, manual delete, limit
enforcement, non-matching insert, and close-cancels-listener.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The previous defaults pointed both `service` and `endpoint` URLs at
`moq.nostrnests.com:4443`. That host only listens on UDP/QUIC for the
WebTransport relay — TCP :4443 is unreachable. OkHttp can't speak HTTP/3,
so the JWT-mint POST hangs and fails with `ConnectException`, leaving
the room screen stuck on "Reconnecting".
The real nostrnests deployment co-locates auth and relay on different
hosts (verified against the production NestsUI bundle):
relay (WebTransport, QUIC only): https://moq.nostrnests.com:4443
auth (JWT mint, regular HTTPS): https://moq-auth.nostrnests.com
Split the defaults accordingly and add a `resolveServerPair` helper that
maps a single saved kind-10112 URL onto the (service, endpoint) pair the
kind-30312 event needs. The helper recognises both the auth-host and
the legacy relay-host the prior defaults wrote, so users upgrading from
the broken version migrate transparently the next time they open the
create-room sheet. Community deployments that genuinely co-locate keep
working via the fallback.
Update the recommended-servers list and the kind-10112 documentation to
match: the stored `server` URL is the moq-auth base (the URL that ends
up in the kind-30312 `service` tag).
The triple benchmark probed libschnorr256k1 with a try/catch that only
handled UnsatisfiedLinkError. On Windows the loader raises
IllegalStateException when the JNI .dll for the platform isn't packaged,
which propagated out as a test failure instead of skipping the C row.
Mirror the broader catch already used in Secp256k1CrossValidationTest so
the benchmark prints a SKIP-style note and continues with the ACINQ +
Kotlin rows on platforms where the C lib isn't available.
The custom C secp256k1 implementation under quartz/src/main/c/ was never
wired into Gradle (no externalNativeBuild block) and only existed for the
3-way benchmark + cross-validation test against ACINQ on JVM/Android.
The C library has been split out to vitorpamplona/libschnorr256k1{,-kmp},
so the in-repo copy is dead weight.
This change replaces it with the published Maven Central artifact
`com.vitorpamplona:schnorr256k1-kmp:1.0.0`, scoped to test/benchmark
configurations only — production crypto continues to use ACINQ
secp256k1-kmp on JVM/Android and the pure-Kotlin Secp256k1 on native.
The Android AAR ships libschnorr256k1_jni.so for arm64-v8a and x86_64,
so the Android benchmark now exercises the C row automatically (no more
manual build_android.sh). On JVM the .so still has to be installed by the
developer; the cross-validation test and triple-benchmark gracefully skip
the C row when System.loadLibrary fails, matching prior behavior.
Verified on JVM: all 13 secp256k1 jvmTest classes pass (188 tests), and
ACINQ vs pure-Kotlin benchmark numbers match the documented baseline
within sandbox noise (verifySchnorr ~15k ops/s, signSchnorr cached
~33k ops/s, pubkeyCreate+Compress ~36k ops/s).
Removes ~5,100 LOC: quartz/src/main/c/ (4,500), Secp256k1InstanceC.*
expect/actual shim (560), Secp256k1C JNI declaration object.
https://claude.ai/code/session_01KnvpK2amcVZKfFiZJvHjVe
After verifying the nostrnests reference (NestsUI-v2 @ main):
- ProfileCard.tsx writes kicks as ['action','kick'] tags with empty content
- useAdminCommands.ts reads action via tags.find(t => t==='action') and
applies a 60-s relay since plus a processedRef Set to dedup re-deliveries
- p-tag role marker for moderators is 'admin', not 'moderator'
Amethyst was diverging on every one of those, which means our outbound
admin commands were invisible to nostrnests, theirs to us, and any
nostrnests admin (role='admin') failed our isModerator() / canSpeak()
gates entirely — kicks and force-mutes signed by them were silently
dropped.
Changes:
quartz/AdminCommandEvent.kt
- Emit ['action', '<verb>'] tag with empty content
- Reader prefers the tag, falls back to content for any in-flight
Amethyst-built kick from before this commit
- kick() and forceMute() share a common build() helper
quartz/ParticipantTag.kt
- ROLE.MODERATOR.code = 'admin' (matches nostrnests + EGG-07)
- Adds legacyCodes = ['moderator'] so older Amethyst-emitted
kind-30312 events still parse as MODERATOR
- effectiveRole() walks both code + legacyCodes
amethyst/AdminCommandsCollector
- Filter carries since = now - 60 (EGG-07 #7)
- Defensive per-event freshness re-check for cached events / clock skew
- mutableSetOf<String>() processed-id dedup for the lifetime of the
collector, mirroring useAdminCommands.ts's processedRef
EGG-07.md
- Documents the Amethyst-only ['action','mute'] extension under a new
'Implemented extensions' section. nostrnests doesn't emit or honour
it today; cross-client force-mutes only work between Amethyst peers
- 'warn' stays in the future-actions list — nostrnests has no plans
for it either
Tests:
- ParticipantTagTest: new asserts that 'admin' / 'Admin' / 'ADMIN'
parse as ROLE.MODERATOR; pins the wire string to 'admin' and the
legacy alias to 'moderator'
- AdminCommandEventTest: kick/forceMute templates carry ['action', _]
tag with empty content; legacy content-form still parses; tag wins
over content when both are present
Three improvements stacked into one commit since they share files:
#3 Empty-stage hint: StageGrid no longer disappears when nobody is on
stage. The "Stage" label stays and a quiet "Waiting for speakers…"
line keeps the strip visible so the room doesn't look broken before
the first speaker arrives.
#5 Local per-speaker hush: AudioPlayer gains a setVolume(Float)
default-no-op method; AudioTrackPlayer composes mute and volume
multiplicatively into AudioTrack.setVolume so a hushed stream stays
silent regardless of mute state. NestViewModel exposes
locallyHushed: ImmutableSet<String> in NestUiState plus
setLocalHushed(pubkey, hushed) — applied at attach time so a
re-subscribe of an already-hushed speaker stays silent. New "Hush
this speaker" / "Restore this speaker" row in
ParticipantHostActionsSheet, available to anyone (it affects only
our own playback, nothing on the wire).
#4 Host moderation gaps: wires Promote-to-Moderator using the existing
RoomParticipantActions.setRole(ROLE.MODERATOR) builder — UI gap
only, no protocol change. Adds a new AdminCommandEvent.Action.MUTE
variant + AdminCommandEvent.forceMute(room, target) builder; the
sheet emits it on "Force-mute speaker" and the
AdminCommandsCollector dispatches incoming MUTE actions to a new
NestViewModel.onForceMuted() that routes through the existing
setMicMuted(true) path. Honor-based, same trust model as KICK —
relays don't enforce signer authority, the client checks the
signer is host or moderator on the active kind-30312.
The first-party nostrnests web client (NestsUI-v2) emits kind-30312
events using NIP-53 *streaming-event* tag names rather than the
kind-30312 names defined by NIP-53 itself. Confirmed against
`nostrnests/nests/NestsUI-v2/src/components/EditRoomDialog.tsx` and
`useRoomList.ts`:
- room name: `title` (NIP-53 spec for 30312: `room`)
- MoQ relay: `streaming` (NIP-53 spec for 30312: `endpoint`)
- moq-auth: `auth` (NIP-53 spec for 30312: `service`)
- status: `live` (NIP-53 spec for 30312: `open` / `private`
/ `closed` / `planned`)
Our parsers followed the NIP-53 spec, so events from the production
nostrnests web app fell through to status=null, which `checkStatus`
then surfaced as "Ended" in the live-rooms UI. Add legacy-alias
acceptance to all four parsers — read-side only; we still emit the
canonical NIP-53 forms, matching EGG-01.
Per-tag changes:
StatusTag: "live" → OPEN, "ended" → CLOSED
RoomNameTag: "title" alias → room
EndpointUrlTag: "streaming" alias → endpoint
ServiceUrlTag: "auth" alias → service
No emit-side changes; no spec change.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
Wire MeetingSpaceEvent's existing RecordingTag into the
note-view renderer. Closed rooms (status=CLOSED) that ship a
`["recording", url]` tag get an OutlinedButton that hands the
URL to the system media player via ACTION_VIEW — most users have
a podcast / audio app registered for HTTPS audio URLs.
Live and scheduled rooms keep the Join button. Closed rooms
without a recording render no CTA — there's nothing to enter.
Adds:
* MeetingSpaceEvent.recording() accessor
* TagArrayBuilder<MeetingSpaceEvent>.recording(url) DSL builder
* ListenToRecordingButton composable in the note renderer
Note: nostrnests' moq-lite refactor dropped the LiveKit-era
recording HTTP surface, but the kind-30312 `recording` tag is
still spec-allowed and individual hosts can populate it via
out-of-band capture. This commit makes Amethyst the receiving
end for any host who does.
Closes the deferred font tag from the Tier-3 plan. Wire-up:
* quartz: FontTag(family, optionalUrl) parser/assembler with
blank-rejection on family + blank-URL-becomes-null normalisation.
* MeetingSpaceEvent.font(): FontTag? accessor.
* TagArrayBuilder.font(family, url) DSL.
* RoomTheme gains fontFamily / fontUrl fields.
* AudioRoomThemedScope maps `family` to a Compose FontFamily for
the four CSS-style generic-family names ("sans-serif", "serif",
"monospace", "cursive") — each maps to the corresponding system
fallback. The whole Material3 typography is rebuilt with that
family so headlines / body / labels all swap together.
URL-based font loading (RoomTheme.fontUrl) is the natural follow-up:
fetch + cache via OkHttp, then build a FontFamily from a local
file. Until then, an unknown family is silently a no-op — the room
renders in the platform default rather than crashing or fetching
on the UI thread.
Tests:
* FontTagTest — 9 cases covering family-only, family+URL,
blank rejection, missing family, wrong tag name, blank-URL
normalisation, assembler shapes (with + without URL), and
the assembler's blank-family rejection.
* RoomThemeTest gains 3 cases for font projection (family-only /
family+URL / empty event).