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
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
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
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
Add a navigation row in AllSettingsScreen to open the existing
PaymentTargetsScreen, so users can configure their NIP-A3 payment
targets (kind 10133) from the account settings menu.
Every desktop packaging job (Windows MSI, macOS DMG, Linux DEB) currently
breaks on the smallest blip when fetching VLC from get.videolan.org. The
`ir.mahozad.vlc-setup` plugin registers `vlcDownload` / `upxDownload`
tasks that extend `de.undercouch.gradle.tasks.download.Download`, but it
does not configure retries or a generous read timeout, so a single
`SocketTimeoutException` aborts the build.
Recent main runs all failed at the same step:
> Task :desktopApp:vlcDownload FAILED
> A failure occurred while executing ...DefaultWorkAction
> java.net.SocketTimeoutException: Read timed out
Configure all `Download` tasks in this project with:
- 5 attempts total (initial + 4 retries),
- 30 s connect timeout,
- 5 min read timeout (VLC archives are 40-90 MB and the mirror can be
slow under load),
- `tempAndMove` so a partial download from one attempt cannot poison
the next.
This is a CI-only change \u2014 it does not alter what gets bundled, just
makes the fetch resilient.
The profile 'Last seen' line was rendered by feeding timeAgo() output
into the 'Last seen %1$s ago' template. timeAgo() returns a bare
absolute date (e.g. 'Jan 14' or 'Jul 27, 2024') for anything older
than a month, so the result was nonsensical:
- 'Last seen Jan 14 ago'
- 'Last seen Jul 27, 2024 ago'
Replace the call with a new lastSeenSentence() helper that always
returns a self-contained, grammatical sentence:
- 'Last seen 5 minutes ago'
- 'Last seen 2 hours ago'
- 'Last seen 3 days ago'
- 'Last seen 2 weeks ago'
- 'Last seen on Jul 27, 2024 (9 months ago)'
- 'Last seen on Jan 14, 2024 (1 year ago)'
Anything older than a week now also shows the absolute date alongside
a coarse relative duration, so users can see exactly when the activity
happened without giving up the human-readable 'X ago' framing.
Adds plural-aware duration_minutes/hours/days/weeks/months/years
resources plus last_seen_on_date / last_seen_just_now / last_seen_never
helper strings. The existing 'last_seen' string keeps its placeholder
shape so existing translations continue to work for the recent-duration
case.
Adds 124 missing translations across cs-rCZ, pt-rBR, sv-rSE, and de-rDE
covering the new Nests (NIP-53 audio-rooms) UI, AI-suggested alt-text
hints, tracked broadcasts setting, and meeting/live-stream tags.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`subscribeSpeaker_survives_session_swap` and
`unsubscribe_before_session_swap_releases_handle` had two race
conditions exposed only on macOS scheduling:
1. ScriptedListener.subscribeCount is incremented inside
opener(listener) — BEFORE the wrapper's pump reaches
`handle.objects.collect { frames.emit(it) }`. Waiting on
subscribeCount alone doesn't prove the pump is collecting
from first.frames. An emit upstream before the pump's collect
registers is silently dropped.
2. The wrapper's outer `frames` SharedFlow is replay=0. The
`scope.async { take(2).toList() }` consumer might not have
subscribed by the time the test thread races into the first
emit, dropping the value.
Both are fixed by waiting for actual subscription registration:
- first/second.frames.subscriptionCount.first { it > 0 } — flips
to 1 only after the pump's collect lands, which is strictly
after liveHandleRef.set(handle).
- onSubscription + CompletableDeferred — fires after the
consumer's collector is registered against the SharedFlow.
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.
Activity used the system default soft-input behavior (pan), so opening
the keyboard slid the whole screen up, partially hiding the composer
and putting the top of the screen out of reach. Set adjustResize on
NestActivity and add consumeWindowInsets + imePadding to the Scaffold
body so the column reflows above the IME and the textfield stays in
view.
Bring the audio-room lobby filter closer to the NostrNests reference:
- Reject service/endpoint URLs that aren't HTTPS, dropping legacy
`wss+livekit://` rooms a moq-lite client can't reach.
- Drop PLANNED rooms whose `starts` is more than 1 h in the past or
more than 30 d in the future.
- Add a single lobby-wide kind-10312 REQ (10 min, limit 500) per
active relay and use it to hide OPEN/PRIVATE rooms whose host
crashed without flipping status to CLOSED. Brand-new rooms keep
showing for the freshness window so they don't pop in late.
- Expand follow-style top filters to include rooms whose p-tagged
speakers are followed, not just the host.