Merge pull request #2664 from vitorpamplona/claude/quartz-eventstore-observer-o2f1O

Add event projection and interning for reactive event store queries
This commit is contained in:
Vitor Pamplona
2026-04-30 13:46:40 -04:00
committed by GitHub
15 changed files with 1837 additions and 5 deletions
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.cache.interning
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Apple actual: passthrough. Kotlin/Native has weak refs but no
* built-in concurrent map; rather than ship a half-baked impl, we
* skip canonicalisation entirely on Apple targets and let
* [Event.fromJson] return whatever the deserializer produced. Can
* be revisited if Amethyst's iOS port wants the memory savings.
*/
actual class EventInterner {
actual fun intern(event: Event): Event = event
actual fun get(id: HexKey): Event? = null
actual fun size(): Int = 0
actual fun clear() {}
actual companion object {
actual val Default: EventInterner = EventInterner()
}
}
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.cache.interning
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* 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. 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, no leaks.
*/
expect class EventInterner() {
/**
* Returns the canonical [Event] for [event]'s id, storing
* [event] as canonical if no live entry exists.
*/
fun intern(event: Event): Event
/** Returns the canonical [Event] for [id] if one is live, else null. */
fun get(id: HexKey): Event?
/** Number of map entries (including dead weak refs not yet cleaned). */
fun size(): Int
/** Drop every entry. Mostly useful for tests. */
fun clear()
companion object {
/** Process-wide shared instance. */
val Default: EventInterner
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.cache.interning
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
/**
* Decorator that canonicalises every [Event] returned by the inner
* store through an [EventInterner], so all consumers of read paths
* share a single object reference per event id.
*
* Wrap any [IEventStore] (SQLite, FS, in-memory test fake) when you
* want event-identity sharing across the read paths:
*
* ```
* val sqlite = EventStore(...)
* val cached = InterningEventStore(sqlite)
* val observable = ObservableEventStore(cached)
* ```
*
* Writes ([insert] / [transaction]) forward to the inner store and,
* on success, register the accepted event with the interner. The
* caller's instance becomes canonical for that id (so a later read
* of the same id returns the same `===` reference, until the weak
* ref is collected). The decorator does *not* substitute the
* caller's event for a previously-cached one on the way in — sigs
* may differ across "same id, different decode" cases, and the
* caller's instance is the freshest.
*
* Reads ([query]) pipe results through `interner.intern`, returning
* canonical instances for ids that have a live cached entry.
*
* Out-of-band removals (`delete*`, `deleteExpiredEvents`) pass
* through unchanged.
*
* The default [interner] is [EventInterner.Default]; pass a fresh
* instance for tests or any context that needs isolation.
*/
class InterningEventStore(
private val inner: IEventStore,
private val interner: EventInterner = EventInterner.Default,
) : IEventStore {
override val relay: NormalizedRelayUrl? get() = inner.relay
override suspend fun insert(event: Event) {
inner.insert(event)
// Inner accepted (didn't throw) — register the canonical
// instance so subsequent reads share the same reference.
interner.intern(event)
}
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) {
// Capture every accepted event so we can intern after the
// inner transaction commits. If body throws or the inner
// rolls back, `accepted` is discarded with the txn.
val accepted = ArrayList<Event>()
inner.transaction {
val innerTxn = this
val wrapped =
object : IEventStore.ITransaction {
override fun insert(event: Event) {
innerTxn.insert(event)
accepted.add(event)
}
}
wrapped.body()
}
for (e in accepted) interner.intern(e)
}
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(filter: Filter): List<T> = inner.query<T>(filter).map { interner.intern(it) as T }
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(filters: List<Filter>): List<T> = inner.query<T>(filters).map { interner.intern(it) as T }
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(
filter: Filter,
onEach: (T) -> Unit,
) = inner.query<T>(filter) { onEach(interner.intern(it) as T) }
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(
filters: List<Filter>,
onEach: (T) -> Unit,
) = inner.query<T>(filters) { onEach(interner.intern(it) as T) }
override suspend fun count(filter: Filter): Int = inner.count(filter)
override suspend fun count(filters: List<Filter>): Int = inner.count(filters)
override suspend fun delete(filter: Filter) = inner.delete(filter)
override suspend fun delete(filters: List<Filter>) = inner.delete(filters)
override suspend fun deleteExpiredEvents() = inner.deleteExpiredEvents()
override fun close() = inner.close()
}
@@ -0,0 +1,436 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.cache.projection
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore
import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore.StoreChange
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.yield
/**
* Lifecycle state of an [ObservableEventStore.project] flow.
*
* - [Loading] is the initial state before the seed query completes.
* The UI should show a spinner / skeleton here.
* - [Loaded] holds the current deduped, sorted list of slots. Every
* membership change publishes a fresh [Loaded] instance; in-place
* addressable updates do *not* publish (the slots' own flows do).
*/
sealed interface ProjectionState<out T : Event> {
data object Loading : ProjectionState<Nothing>
data class Loaded<T : Event>(
val items: List<MutableStateFlow<T>>,
) : ProjectionState<T>
}
/**
* State machine that maintains a reactive view over an
* [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.
*
* Each visible event is wrapped in a [MutableStateFlow], giving the
* UI three change granularities:
*
* - **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 ordering doesn't reshuffle on a later `created_at`. Filter
* membership *is* re-evaluated against the new event, so a v2
* that no longer matches a filter (e.g. tag changed) drops out;
* a v2 that newly matches another filter joins it. In the common
* case (filter on `kinds + authors`) v2 still matches and the
* list reference stays stable.
* - **Removal**: NIP-09, NIP-62, NIP-40 expiration, `delete(filter)`.
*
* 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.
*
* 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,
private val filters: List<Filter>,
private val nowProvider: () -> Long = TimeUtils::now,
) {
/** Slots keyed by the *current* event id. Re-keyed when a replaceable / addressable handle takes a new version. */
private val byId = HashMap<HexKey, Slot<T>>()
/** Slots keyed by replaceable / addressable [Address] for in-place updates and supersession lookups. */
private val byAddress = HashMap<Address, Slot<T>>()
/**
* Per-filter capped sets. Each filter independently retains at most
* `filter.limit` matches; a slot is "live" iff it appears in at
* least one of these sets. Identity-keyed — [Filter] is `@Stable`
* but not a data class.
*/
private val perFilter: Map<Filter, java.util.SortedSet<Slot<T>>> =
filters.associateWith { sortedSetOf(slotComparator()) }
/**
* Run the seed query against the store and populate the indexes.
* Call once, before the first [apply]. Expired events are skipped
* (the store doesn't filter them at query time, so we do here).
*/
suspend fun seed() {
val now = nowProvider()
for (event in store.query<T>(filters)) {
if (event.isExpirationBefore(now)) continue
applyInsert(event)
yield()
}
}
/**
* Apply a [StoreChange] from [ObservableEventStore.changes].
* Returns true if the change altered membership (caller should
* publish a fresh [snapshot]); false for in-place addressable
* updates, NIP-01 tiebreaker rejections, and arrivals that no
* filter matches.
*/
fun apply(storeEvent: StoreChange): Boolean =
when (storeEvent) {
is StoreChange.Insert -> {
applyInsert(storeEvent.event)
}
is StoreChange.DeleteByFilter -> {
dropWhere { ev -> storeEvent.filters.any { it.match(ev) } }
}
is StoreChange.DeleteExpired -> {
// Store's sweep uses strict `<`; isExpirationBefore is `<=`, so subtract 1.
val cutoff = (storeEvent.asOf ?: nowProvider()) - 1
dropWhere { it.isExpirationBefore(cutoff) }
}
}
/**
* Current snapshot as a [ProjectionState.Loaded]. The
* single-filter case skips the union pass — that filter's set
* is already sorted. Multi-filter projections dedup + merge
* via a transient TreeSet.
*/
fun snapshot(): ProjectionState.Loaded<T> {
if (byId.isEmpty()) return ProjectionState.Loaded(emptyList())
if (perFilter.size == 1) {
return ProjectionState.Loaded(perFilter.values.first().map { it.flow })
}
val union = sortedSetOf(slotComparator<T>())
for (set in perFilter.values) union.addAll(set)
return ProjectionState.Loaded(union.map { it.flow })
}
private fun applyInsert(event: Event): Boolean {
if (event.isExpirationBefore(nowProvider())) return false
var changed = false
// NIP-09 / NIP-62 side effects come first — a deletion event
// that arrives at the same instant as a matching event still
// removes its targets.
if (event is DeletionEvent) {
if (handleDeletion(event)) changed = true
}
if (event is RequestToVanishEvent && event.shouldVanishFrom(store.relay)) {
if (dropWhere { ev -> ownerOf(ev) == event.pubKey && ev.createdAt < event.createdAt }) changed = true
}
if (handleInsert(event)) changed = true
return changed
}
/**
* Returns true if processing the event caused the projection's
* membership to change. Returns false for in-place supersession
* updates, NIP-01 tiebreaker rejections, and arrivals that no
* filter matches.
*/
@Suppress("UNCHECKED_CAST")
private fun handleInsert(event: Event): Boolean {
val address = addressOf(event)
if (address != null) {
val existing = byAddress[address]
if (existing != null) {
if (!supersedes(event, existing.flow.value)) return false
// Same address, new winner. Rekey byId, mutate
// flow.value in place, then re-evaluate filter
// membership against the new event — v2 may add
// matches or lose previously-matching filters.
val previousId = existing.flow.value.id
if (previousId != event.id) {
byId.remove(previousId)
byId[event.id] = existing
}
existing.flow.value = event as T
var changed = false
for ((f, set) in perFilter) {
val nowMatches = f.match(event)
val wasIn = set.contains(existing)
when {
nowMatches && !wasIn -> {
if (admit(existing, f, set)) changed = true
}
!nowMatches && wasIn -> {
set.remove(existing)
changed = true
}
}
}
// If no filter retains the slot anymore, fully drop it.
if (perFilter.values.none { it.contains(existing) }) {
if (removeIndexes(existing)) changed = true
}
return changed
}
} else if (byId.containsKey(event.id)) {
return false
}
// Genuinely new slot. Offer it to every matching filter; if
// any filter still holds it after cap-eviction, the slot
// becomes live and gets indexed.
var changed = false
val slot = Slot(event as T)
for ((f, set) in perFilter) {
if (!f.match(event)) continue
if (admit(slot, f, set)) changed = true
}
if (perFilter.values.any { it.contains(slot) }) {
byId[event.id] = slot
if (address != null) byAddress[address] = slot
changed = true
}
return changed
}
/**
* Add [slot] to filter [f]'s [set] and evict the tail if the cap
* is exceeded. Returns true if an eviction triggered a slot drop
* from the indexes (its only filter retention was the evicted
* one).
*/
private fun admit(
slot: Slot<T>,
f: Filter,
set: java.util.SortedSet<Slot<T>>,
): Boolean {
set.add(slot)
val cap = f.limit ?: return false
var changed = false
while (set.size > cap) {
val tail = set.last()
set.remove(tail)
if (tail !== slot && perFilter.values.none { it.contains(tail) }) {
if (removeIndexes(tail)) changed = true
}
}
return changed
}
private fun handleDeletion(deletion: DeletionEvent): Boolean {
var changed = false
// NIP-09 by id, only if the deletion's author owns the target.
// For GiftWrap, the owner is the p-tag recipient.
for (id in deletion.deleteEventIds()) {
val slot = byId[id] ?: continue
if (ownerOf(slot.flow.value) == deletion.pubKey && removeSlot(slot)) changed = true
}
// NIP-09 by address, only original author, only events with
// `created_at <= deletion.created_at`. `addr` is already an
// [Address] — same equals/hashCode as our index keys.
for (addr in deletion.deleteAddresses()) {
if (addr.pubKeyHex != deletion.pubKey) continue
val slot = byAddress[addr] ?: continue
if (slot.flow.value.createdAt <= deletion.createdAt && removeSlot(slot)) changed = true
}
return changed
}
/** Drop every live slot whose current event matches [predicate]. */
private inline fun dropWhere(predicate: (Event) -> Boolean): Boolean {
// Snapshot before mutating — removeSlot mutates byId.
val targets = byId.values.filter { predicate(it.flow.value) }
var changed = false
for (slot in targets) {
if (removeSlot(slot)) changed = true
}
return changed
}
/** Remove a live slot from every index AND from each per-filter set. */
private fun removeSlot(slot: Slot<T>): Boolean {
for (set in perFilter.values) set.remove(slot)
return removeIndexes(slot)
}
/**
* Remove a slot from [byId] / [byAddress] without touching the
* per-filter sets. Used by the per-filter eviction loop, which
* already owns that bookkeeping.
*/
private fun removeIndexes(slot: Slot<T>): Boolean {
val removed = byId.remove(slot.flow.value.id) != null
if (!removed) return false
slot.address?.let(byAddress::remove)
return true
}
/**
* Wraps an event with the indexes the projection needs. Sort key
* and address are frozen at construction — in-place supersession
* updates rewrite `flow.value` but the slot's position inside
* each [perFilter] set is stable across updates.
*/
private class Slot<T : Event>(
initial: T,
) {
val sortCreatedAt: Long = initial.createdAt
val sortId: HexKey = initial.id
val address: Address? = addressOf(initial)
val flow: MutableStateFlow<T> = MutableStateFlow(initial)
}
companion object {
/** [Address] for replaceable / addressable supersession; `null` for regular events. */
private fun addressOf(event: Event): Address? =
when {
event is AddressableEvent -> event.address()
event.kind.isReplaceable() -> Address(event.kind, event.pubKey, "")
else -> null
}
/**
* NIP-01 supersession tiebreaker. The new event wins iff its
* `created_at` is strictly greater, or the timestamps tie and
* its `id` is lexically smaller.
*/
private fun supersedes(
new: Event,
existing: Event,
): Boolean =
when {
new.createdAt > existing.createdAt -> true
new.createdAt < existing.createdAt -> false
else -> new.id < existing.id
}
/**
* Owner pubkey for ownership checks (NIP-09 author match,
* NIP-62 vanish target). For GiftWrap the owner is the p-tag
* recipient; for everything else it's `event.pubKey`.
*/
private fun ownerOf(event: Event): HexKey = (event as? GiftWrapEvent)?.recipientPubKey() ?: event.pubKey
/**
* created_at DESC, id ASC. Sort keys are frozen at slot
* construction, so a slot's position never changes after it
* joins a set. Distinct events have distinct ids, so no third
* tiebreak is needed.
*
* Singleton — stored as `Comparator<Slot<*>>` and cast at the
* use site, since the comparator only reads `sortCreatedAt`
* and `sortId` which don't depend on `T`.
*/
private val SLOT_COMPARATOR: Comparator<Slot<*>> =
Comparator { a, b ->
if (a === b) return@Comparator 0
val byTime = b.sortCreatedAt.compareTo(a.sortCreatedAt)
if (byTime != 0) return@Comparator byTime
a.sortId.compareTo(b.sortId)
}
@Suppress("UNCHECKED_CAST")
private fun <T : Event> slotComparator(): Comparator<Slot<T>> = SLOT_COMPARATOR as Comparator<Slot<T>>
}
}
/**
* Open a cold reactive projection over this observable store for the
* given [filters]. The returned flow:
*
* - emits [ProjectionState.Loading] on subscription;
* - runs the seed query against the store;
* - emits [ProjectionState.Loaded] with the seeded list;
* - then emits a fresh [ProjectionState.Loaded] every time membership
* changes (in-place addressable updates do not re-emit — collectors
* of the slot's own [MutableStateFlow] handle those).
*
* Each collection allocates its own state machine, runs its own seed,
* and unsubscribes when the collector cancels — there's no
* `close()`. For multiple collectors on the same view, share with
* `stateIn(scope, SharingStarted.WhileSubscribed(...), Loading)` (the
* standard ViewModel pattern).
*
* NIP-62 vanish handling is scoped by the inner store's `relay`.
*/
fun <T : Event> ObservableEventStore.project(filters: List<Filter>): Flow<ProjectionState<T>> =
flow {
val projection = EventStoreProjection<T>(this@project, filters)
// `onSubscription` runs after the SharedFlow subscription is
// active but before events are pulled, so emissions during
// seed land in the buffer and we drain them via `apply` when
// collect proceeds. `outer` bridges the StoreChange-typed
// collectors back to our ProjectionState output.
val outer = this
emit(ProjectionState.Loading)
changes
.onSubscription {
projection.seed()
outer.emit(projection.snapshot())
}.collect { change ->
if (projection.apply(change)) outer.emit(projection.snapshot())
}
}
fun <T : Event> ObservableEventStore.project(filter: Filter): Flow<ProjectionState<T>> = project(listOf(filter))
@@ -22,8 +22,17 @@ package com.vitorpamplona.quartz.nip01Core.store
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
interface IEventStore : AutoCloseable {
/**
* Relay URL this store is acting on behalf of, or `null` for an
* unscoped store. Used by NIP-62 right-to-vanish handling: only
* vanish requests whose `relays` list contains this URL (or
* `ALL_RELAYS`) cascade.
*/
val relay: NormalizedRelayUrl?
suspend fun insert(event: Event)
interface ITransaction {
@@ -0,0 +1,166 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.isEphemeral
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* Reactive façade over any [IEventStore]. Publishes a [StoreChange]
* on [changes] for every accepted mutation so projections can stay in
* sync 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,
) : IEventStore {
override val relay: NormalizedRelayUrl? get() = inner.relay
private val _changes =
MutableSharedFlow<StoreChange>(
replay = 0,
extraBufferCapacity = 256,
onBufferOverflow = BufferOverflow.SUSPEND,
)
/** Stream of mutations accepted by this layer. See [StoreChange] for the cases. */
val changes: SharedFlow<StoreChange> = _changes.asSharedFlow()
/**
* 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(
val event: Event,
) : StoreChange
data class DeleteByFilter(
val filters: List<Filter>,
) : StoreChange
data class DeleteExpired(
val asOf: Long? = null,
) : StoreChange
}
override suspend fun insert(event: Event) {
if (event.kind.isEphemeral()) {
// Ephemeral kinds bypass persistence. We still drop ones
// that are already expired — they were never going to live
// long enough for a UI to render them.
if (event.isExpired()) return
_changes.emit(StoreChange.Insert(event))
return
}
// Non-ephemeral: let the inner store enforce expiration,
// tombstones, supersession, etc. If it throws, we never emit.
inner.insert(event)
_changes.emit(StoreChange.Insert(event))
}
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) {
val accepted = ArrayList<Event>()
inner.transaction {
val innerTxn = this
val wrapped =
object : IEventStore.ITransaction {
override fun insert(event: Event) {
if (event.kind.isEphemeral()) {
// Same ephemeral handling as the single-event
// path. The inner txn never sees these so the
// store's batch contains only persistable
// events.
if (event.isExpired()) return
accepted.add(event)
return
}
innerTxn.insert(event)
accepted.add(event)
}
}
wrapped.body()
}
// Emit only after the inner transaction commits. If it throws
// / rolls back, `accepted` is discarded.
for (e in accepted) _changes.emit(StoreChange.Insert(e))
}
override suspend fun <T : Event> query(filter: Filter): List<T> = inner.query(filter)
override suspend fun <T : Event> query(filters: List<Filter>): List<T> = inner.query(filters)
override suspend fun <T : Event> query(
filter: Filter,
onEach: (T) -> Unit,
) = inner.query(filter, onEach)
override suspend fun <T : Event> query(
filters: List<Filter>,
onEach: (T) -> Unit,
) = inner.query(filters, onEach)
override suspend fun count(filter: Filter): Int = inner.count(filter)
override suspend fun count(filters: List<Filter>): Int = inner.count(filters)
override suspend fun delete(filter: Filter) {
inner.delete(filter)
_changes.emit(StoreChange.DeleteByFilter(listOf(filter)))
}
override suspend fun delete(filters: List<Filter>) {
inner.delete(filters)
_changes.emit(StoreChange.DeleteByFilter(filters))
}
override suspend fun deleteExpiredEvents() {
// Pin the cutoff before forwarding so the projection's drop
// matches the store's drop exactly. The store's SQL uses
// `unixepoch()` so there's still a small skew, but pinning at
// call time is closer than letting each projection use its
// own clock when the event is processed.
val asOf = TimeUtils.now()
inner.deleteExpiredEvents()
_changes.emit(StoreChange.DeleteExpired(asOf))
}
override fun close() = inner.close()
}
@@ -27,9 +27,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
/**
* SQLite-backed [IEventStore] with default DB-file name and relay
* scoping. Wrap in `ObservableEventStore` if you want to feed
* `EventStoreProjection`.
*/
class EventStore(
dbName: String? = "events.db",
relay: NormalizedRelayUrl? = "wss://quartz.local/".normalizeRelayUrl(),
override val relay: NormalizedRelayUrl? = "wss://quartz.local/".normalizeRelayUrl(),
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : IEventStore {
val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy)
@@ -232,7 +232,7 @@ class QueryBuilder(
}
}
private fun <T : Event> SQLiteStatement.toEvent() =
private fun <T : Event> SQLiteStatement.toEvent(): T =
EventFactory.create<T>(
getText(0),
getText(1),
@@ -0,0 +1,593 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.cache.projection
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotSame
import kotlin.test.assertSame
import kotlin.test.assertTrue
class EventStoreProjectionTest {
private val signer = NostrSignerSync()
private val otherSigner = NostrSignerSync()
private lateinit var store: EventStore
private lateinit var observable: ObservableEventStore
private lateinit var scope: CoroutineScope
@BeforeTest
fun setUp() {
Secp256k1Instance
store = EventStore(dbName = null)
observable = ObservableEventStore(store)
scope = CoroutineScope(SupervisorJob())
}
@AfterTest
fun tearDown() {
scope.cancel()
store.close()
}
/** Open a hot StateFlow over the projection for the lifetime of the test scope. */
private fun <T : Event> projectionOf(filter: Filter): StateFlow<ProjectionState<T>> = observable.project<T>(filter).stateIn(scope, SharingStarted.Eagerly, ProjectionState.Loading)
private fun <T : Event> projectionOf(filters: List<Filter>): StateFlow<ProjectionState<T>> = observable.project<T>(filters).stateIn(scope, SharingStarted.Eagerly, ProjectionState.Loading)
/** Snapshot of the currently-loaded items, or empty if still seeding. */
private val <T : Event> StateFlow<ProjectionState<T>>.items: List<MutableStateFlow<T>>
get() = (value as? ProjectionState.Loaded)?.items.orEmpty()
/** Suspends until the seed completes; returns the loaded list. */
private suspend fun <T : Event> StateFlow<ProjectionState<T>>.awaitLoaded(timeoutMs: Long = 5_000): List<MutableStateFlow<T>> =
withTimeout(timeoutMs) {
(first { it is ProjectionState.Loaded } as ProjectionState.Loaded).items
}
private suspend fun <T : Event> StateFlow<ProjectionState<T>>.awaitItems(
timeoutMs: Long = 5_000,
predicate: (List<MutableStateFlow<T>>) -> Boolean,
): List<MutableStateFlow<T>> =
withTimeout(timeoutMs) {
(first { it is ProjectionState.Loaded && predicate(it.items) } as ProjectionState.Loaded).items
}
private suspend fun <T : Event> awaitFlow(
flow: MutableStateFlow<T>,
timeoutMs: Long = 5_000,
predicate: (T) -> Boolean,
): T =
withTimeout(timeoutMs) {
flow.first { predicate(it) }
}
@Test
fun seedReturnsExistingEvents() =
runBlocking {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
val b = signer.sign(TextNoteEvent.build("b", createdAt = 200))
observable.insert(a)
observable.insert(b)
val projection = projectionOf<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)))
projection.awaitLoaded()
val items = projection.items
assertEquals(2, items.size)
assertEquals(b.id, items[0].value.id)
assertEquals(a.id, items[1].value.id)
}
@Test
fun insertAddsNewSlot() =
runBlocking {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
observable.insert(a)
val projection = projectionOf<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)))
projection.awaitLoaded()
val before = projection.items
assertEquals(1, before.size)
val b = signer.sign(TextNoteEvent.build("b", createdAt = 200))
observable.insert(b)
val after = projection.awaitItems { it.size == 2 }
assertNotSame(before, after, "insert must produce a new list reference")
assertEquals(b.id, after[0].value.id)
}
@Test
fun nonMatchingInsertDoesNotChangeList() =
runBlocking {
val text = signer.sign(TextNoteEvent.build("a", createdAt = 100))
observable.insert(text)
val projection = projectionOf<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)))
projection.awaitLoaded()
val seed = projection.items
val meta = signer.sign(MetadataEvent.createNew("Vitor", createdAt = 200))
observable.insert(meta)
delay(150)
assertSame(seed, projection.items)
}
@Test
fun replaceableUpdateMutatesSlotInPlace() =
runBlocking {
val time = TimeUtils.now()
val v1 = signer.sign(MetadataEvent.createNew("v1", createdAt = time))
observable.insert(v1)
val projection =
projectionOf<MetadataEvent>(Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(v1.pubKey)))
projection.awaitLoaded()
val seedList = projection.items
assertEquals(1, seedList.size)
val slot = seedList[0]
assertEquals(v1.id, slot.value.id)
val v2 = signer.sign(MetadataEvent.createNew("v2", createdAt = time + 1))
observable.insert(v2)
awaitFlow(slot) { it.id == v2.id }
assertSame(seedList, projection.items, "replaceable update must not change list reference")
assertSame(slot, projection.items[0])
}
@Test
fun addressableUpdateMutatesSlotInPlace() =
runBlocking {
val time = TimeUtils.now()
val v1 = signer.sign(LongTextNoteEvent.build("blog v1", "title", dTag = "blog", createdAt = time))
observable.insert(v1)
val projection =
projectionOf<LongTextNoteEvent>(
Filter(
kinds = listOf(LongTextNoteEvent.KIND),
authors = listOf(v1.pubKey),
tags = mapOf("d" to listOf("blog")),
),
)
projection.awaitLoaded()
val seedList = projection.items
val slot = seedList[0]
val v2 = signer.sign(LongTextNoteEvent.build("blog v2", "title", dTag = "blog", createdAt = time + 1))
observable.insert(v2)
awaitFlow(slot) { it.id == v2.id }
assertSame(seedList, projection.items, "addressable update must not change list reference")
}
/**
* If v2 of an addressable no longer matches the filter (e.g.
* tag list changed), the slot is dropped. The projection
* re-evaluates filter membership on every supersession.
*/
@Test
fun addressableUpdateDropsSlotWhenFilterStopsMatching() =
runBlocking {
val time = TimeUtils.now()
// v1 carries tag "nostr"; v2 changes the tag to "bitcoin".
val v1 =
signer.sign(
LongTextNoteEvent.build("blog v1", "title", dTag = "blog", createdAt = time) {
hashtag("nostr")
},
)
observable.insert(v1)
// Filter narrows to events that ALSO carry hashtag "nostr".
val projection =
projectionOf<LongTextNoteEvent>(
Filter(
kinds = listOf(LongTextNoteEvent.KIND),
authors = listOf(v1.pubKey),
tags = mapOf("t" to listOf("nostr")),
),
)
projection.awaitLoaded()
assertEquals(1, projection.items.size)
val v2 =
signer.sign(
LongTextNoteEvent.build("blog v2", "title", dTag = "blog", createdAt = time + 1) {
hashtag("bitcoin")
},
)
observable.insert(v2)
// v2 doesn't match — slot drops and the snapshot is empty.
val after = projection.awaitItems { it.isEmpty() }
assertTrue(after.isEmpty())
}
/**
* Out-of-order arrival: the projection sees the *newer* version
* first (e.g. the relay sent v2 first), then v1. The projection
* must keep v2 in place and not regress to v1 — that's the NIP-01
* supersession contract from the projection's side, since the
* store would have rejected v1 anyway.
*/
@Test
fun olderReplaceableArrivingAfterNewerIsRejected() =
runBlocking {
val time = TimeUtils.now()
val v1 = signer.sign(MetadataEvent.createNew("v1", createdAt = time))
val v2 = signer.sign(MetadataEvent.createNew("v2", createdAt = time + 5))
// Seed the projection with v2 before v1 even hits the store
// — by inserting v2 first.
observable.insert(v2)
val projection =
projectionOf<MetadataEvent>(Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(v1.pubKey)))
projection.awaitLoaded()
val slot = projection.items[0]
assertEquals(v2.id, slot.value.id)
// The store rejects v1 because v2 already won; the
// projection therefore never sees v1 on the inserts
// stream. The slot must still hold v2.
try {
observable.insert(v1)
} catch (_: Throwable) {
// expected — store enforces the same rule
}
delay(150)
assertEquals(v2.id, slot.value.id)
}
@Test
fun nip09DeletionRemovesSlot() =
runBlocking {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
val b = signer.sign(TextNoteEvent.build("b", createdAt = 200))
observable.insert(a)
observable.insert(b)
val projection = projectionOf<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)))
projection.awaitLoaded()
assertEquals(2, projection.items.size)
val deletion = signer.sign(DeletionEvent.build(listOf(a)))
observable.insert(deletion)
val after = projection.awaitItems { it.size == 1 }
assertEquals(b.id, after[0].value.id)
}
/**
* NIP-09 cross-author deletions are inert. A different signer
* publishing a kind-5 targeting `a` must not drop the slot.
*/
@Test
fun nip09CrossAuthorDeletionIsInert() =
runBlocking {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
observable.insert(a)
val projection = projectionOf<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)))
projection.awaitLoaded()
val seed = projection.items
assertEquals(1, seed.size)
val foreignDeletion = otherSigner.sign(DeletionEvent.build(listOf(a)))
observable.insert(foreignDeletion)
// Give the projection time to process the event.
delay(150)
assertSame(seed, projection.items)
assertEquals(
a.id,
projection.items[0]
.value.id,
)
}
@Test
fun nip62VanishRemovesAuthorEvents() =
runBlocking {
val time = TimeUtils.now()
val a = signer.sign(TextNoteEvent.build("a", createdAt = time))
val b = signer.sign(TextNoteEvent.build("b", createdAt = time + 1))
observable.insert(a)
observable.insert(b)
val projection = projectionOf<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)))
projection.awaitLoaded()
assertEquals(2, projection.items.size)
val vanish =
signer.sign(
RequestToVanishEvent.build(
"wss://quartz.local".normalizeRelayUrl(),
createdAt = time + 2,
),
)
observable.insert(vanish)
val after = projection.awaitItems { it.isEmpty() }
assertTrue(after.isEmpty())
}
/**
* NIP-62 only removes events from the same author. A vanish from
* a different author must not touch slots owned by [signer].
*/
@Test
fun nip62OtherAuthorVanishLeavesEventsAlone() =
runBlocking {
val time = TimeUtils.now()
val a = signer.sign(TextNoteEvent.build("a", createdAt = time))
observable.insert(a)
val projection = projectionOf<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)))
projection.awaitLoaded()
val seed = projection.items
val foreignVanish =
otherSigner.sign(
RequestToVanishEvent.build(
"wss://quartz.local".normalizeRelayUrl(),
createdAt = time + 2,
),
)
observable.insert(foreignVanish)
delay(150)
assertSame(seed, projection.items)
}
/**
* NIP-40 expiration drops slots only when the application calls
* `deleteExpiredEvents()` on the observable store — projections
* no longer run their own ticker.
*/
@Test
fun nip40ExpirationDroppedOnStoreSweep() =
runBlocking {
val time = TimeUtils.now()
val safe = signer.sign(TextNoteEvent.build("safe", createdAt = time) { expiration(time + 100) })
val short = signer.sign(TextNoteEvent.build("short", createdAt = time) { expiration(time + 1) })
observable.insert(safe)
observable.insert(short)
val projection =
projectionOf<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)))
projection.awaitLoaded()
assertEquals(2, projection.items.size)
// Let the short expiration lapse, then ask the store to
// sweep — the projection drops the expired slot in
// response to the resulting StoreChange.Delete(Expired).
delay(2000)
observable.deleteExpiredEvents()
val after = projection.awaitItems { it.size == 1 }
assertEquals(safe.id, after[0].value.id)
}
/**
* `delete(filter)` on the observable propagates to open
* projections: they drop every slot matching the filter using
* the same Filter.match logic the store would.
*/
@Test
fun deleteByFilterRemovesMatchingSlots() =
runBlocking {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
val b = signer.sign(TextNoteEvent.build("b", createdAt = 200))
val foreign = otherSigner.sign(TextNoteEvent.build("foreign", createdAt = 150))
observable.insert(a)
observable.insert(b)
observable.insert(foreign)
val projection =
projectionOf<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)))
projection.awaitLoaded()
assertEquals(3, projection.items.size)
// Drop everything authored by `signer` — should leave
// only the foreign event.
observable.delete(Filter(authors = listOf(signer.pubKey)))
val after = projection.awaitItems { it.size == 1 }
assertEquals(foreign.id, after[0].value.id)
}
@Test
fun limitIsEnforcedOnInsertOverflow() =
runBlocking {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
val b = signer.sign(TextNoteEvent.build("b", createdAt = 200))
observable.insert(a)
observable.insert(b)
val projection =
projectionOf<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND), limit = 2))
projection.awaitLoaded()
assertEquals(2, projection.items.size)
val c = signer.sign(TextNoteEvent.build("c", createdAt = 300))
observable.insert(c)
val after = projection.awaitItems { it[0].value.id == c.id }
assertEquals(2, after.size)
assertEquals(c.id, after[0].value.id)
assertEquals(b.id, after[1].value.id)
}
/**
* Per-filter limit: filter A caps at 2, filter B caps at 2, but
* the events they match are disjoint, so the projection's union
* is 4 (larger than any single filter's cap).
*/
@Test
fun perFilterLimitUnionExceedsSingleLimit() =
runBlocking {
val authorA = NostrSignerSync()
val authorB = NostrSignerSync()
val a1 = authorA.sign(TextNoteEvent.build("a1", createdAt = 100))
val a2 = authorA.sign(TextNoteEvent.build("a2", createdAt = 200))
val b1 = authorB.sign(TextNoteEvent.build("b1", createdAt = 110))
val b2 = authorB.sign(TextNoteEvent.build("b2", createdAt = 210))
observable.insert(a1)
observable.insert(a2)
observable.insert(b1)
observable.insert(b2)
val filterA = Filter(kinds = listOf(TextNoteEvent.KIND), authors = listOf(authorA.pubKey), limit = 2)
val filterB = Filter(kinds = listOf(TextNoteEvent.KIND), authors = listOf(authorB.pubKey), limit = 2)
val projection =
projectionOf<TextNoteEvent>(
listOf(filterA, filterB),
)
projection.awaitLoaded()
assertEquals(4, projection.items.size, "per-filter caps don't dedupe union")
}
/**
* Each filter's cap is enforced on its own retained set: filter A
* keeps only its 2 newest matches even when more arrive.
*/
@Test
fun perFilterLimitEvictsOldestWithinFilter() =
runBlocking {
val a1 = signer.sign(TextNoteEvent.build("a1", createdAt = 100))
val a2 = signer.sign(TextNoteEvent.build("a2", createdAt = 200))
val a3 = signer.sign(TextNoteEvent.build("a3", createdAt = 300))
observable.insert(a1)
observable.insert(a2)
val projection =
projectionOf<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND), limit = 2))
projection.awaitLoaded()
assertEquals(2, projection.items.size)
observable.insert(a3)
val after = projection.awaitItems { it[0].value.id == a3.id }
assertEquals(2, after.size)
assertEquals(a3.id, after[0].value.id)
assertEquals(a2.id, after[1].value.id)
}
@Test
fun cancellingScopeStopsListening() =
runBlocking {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
observable.insert(a)
// Sub-scope so we can cancel just the projection's collector
// without taking down the test's outer scope.
val collectorScope = CoroutineScope(SupervisorJob())
val projection =
observable
.project<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)))
.stateIn(collectorScope, SharingStarted.Eagerly, ProjectionState.Loading)
projection.awaitLoaded()
assertEquals(1, projection.items.size)
collectorScope.cancel()
delay(50)
// After the collector scope dies, new inserts don't update
// the StateFlow — its value is frozen at the last emission.
observable.insert(signer.sign(TextNoteEvent.build("b", createdAt = 200)))
delay(150)
assertEquals(1, projection.items.size)
assertEquals(a.id, projection.items[0].value.id)
}
/**
* Ephemeral events (kinds `20000-29999`) are never persisted, so
* the inner SQLite store silently drops them. The
* `ObservableEventStore` wrapper still routes them onto its
* [events][com.vitorpamplona.quartz.nip01Core.store.projection.ObservableEventStore.changes]
* flow, so an open projection sees them while it's alive. They
* vanish from any future seed because the DB never had them.
*/
@Test
fun ephemeralEventsAppearInProjection() =
runBlocking {
val ephemeralKind = 22_000
val projection =
projectionOf<Event>(Filter(kinds = listOf(ephemeralKind)))
projection.awaitLoaded()
assertTrue(projection.items.isEmpty())
val ephemeral: Event =
signer.sign(
TimeUtils.now(),
ephemeralKind,
arrayOf(emptyArray()),
"live",
)
observable.insert(ephemeral)
val after = projection.awaitItems { it.size == 1 }
assertEquals(ephemeral.id, after[0].value.id)
// Confirmation that the inner SQLite store didn't persist.
val persisted = store.query<Event>(Filter(kinds = listOf(ephemeralKind)))
assertEquals(0, persisted.size)
// A fresh projection on the same store gets nothing — the
// event was only ever live, not durable.
val freshProjection =
projectionOf<Event>(Filter(kinds = listOf(ephemeralKind)))
freshProjection.awaitLoaded()
assertTrue(freshProjection.items.isEmpty())
}
}
@@ -22,9 +22,10 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import kotlin.test.assertEquals
suspend fun <T : Event> EventStore.assertQuery(
suspend fun <T : Event> IEventStore.assertQuery(
expected: T?,
filter: Filter,
) {
@@ -40,7 +41,7 @@ suspend fun <T : Event> EventStore.assertQuery(
}
}
suspend fun <T : Event> EventStore.assertQuery(
suspend fun <T : Event> IEventStore.assertQuery(
expected: List<T>,
filter: Filter,
) {
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.cache.interning
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import java.lang.ref.WeakReference
import java.util.concurrent.ConcurrentHashMap
/**
* JVM/Android actual: ConcurrentHashMap of WeakReference, pre-sized
* for the expected hot working set. Dead entries are reclaimed
* lazily on access (see [intern] and [get]) — when a weak ref's
* referent has been GC'd, the map slot is removed atomically and
* replaced if a fresh event takes its place.
*/
actual class EventInterner {
private val cache = ConcurrentHashMap<HexKey, WeakReference<Event>>(INITIAL_CAPACITY, LOAD_FACTOR)
actual fun intern(event: Event): Event {
// Fast path: existing canonical is still alive.
cache[event.id]?.get()?.let { return it }
// Race-safe install: putIfAbsent ensures only one writer
// wins. If another thread beat us, return whatever they put
// (resolving the rare case where their ref was already GC'd
// by retrying through the slow path).
while (true) {
val ref = WeakReference(event)
val existing = cache.putIfAbsent(event.id, ref)
if (existing == null) return event
val canonical = existing.get()
if (canonical != null) return canonical
// Existing entry's referent was GC'd; drop it and retry.
cache.remove(event.id, existing)
}
}
actual fun get(id: HexKey): Event? {
val ref = cache[id] ?: return null
val event = ref.get()
if (event != null) return event
// Self-cleaning: drop the dead entry so the map doesn't bloat.
cache.remove(id, ref)
return null
}
actual fun size(): Int = cache.size
actual fun clear() {
cache.clear()
}
actual companion object {
actual val Default: EventInterner = EventInterner()
private const val INITIAL_CAPACITY = 5_000
private const val LOAD_FACTOR = 0.75f
}
}
@@ -66,7 +66,7 @@ open class FsEventStore(
* `null`, only "ALL_RELAYS" vanish requests cascade — matches
* `SQLiteEventStore`'s relay arg semantics.
*/
private val relay: NormalizedRelayUrl? = null,
override val relay: NormalizedRelayUrl? = null,
/**
* How to render an event to JSON before writing the canonical file.
* Default is the compact NIP-01 form ([Event.toJson]); CLIs that
@@ -0,0 +1,138 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.cache.interning
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
class EventInternerTest {
private val signer = NostrSignerSync()
private lateinit var interner: EventInterner
@BeforeTest
fun setUp() {
Secp256k1Instance
interner = EventInterner()
}
@AfterTest
fun tearDown() {
interner.clear()
}
@Test
fun internReturnsFirstInstance() {
val original = signer.sign(TextNoteEvent.build("hello", createdAt = 100))
val canonical = interner.intern(original)
assertSame(original, canonical)
assertEquals(1, interner.size())
}
@Test
fun internCollapsesDuplicates() {
val a = signer.sign(TextNoteEvent.build("hello", createdAt = 100))
// Round-trip through OptimizedJsonMapper directly (NOT
// Event.fromJson, which would already intern via Default) so
// we get a non-identical-but-equal event for our isolated
// interner to deduplicate.
val b = OptimizedJsonMapper.fromJson(a.toJson()) as TextNoteEvent
assertEquals(a.id, b.id)
val firstCanonical = interner.intern(a)
val secondCanonical = interner.intern(b)
assertSame(firstCanonical, secondCanonical)
assertSame(a, secondCanonical)
assertEquals(1, interner.size())
}
@Test
fun getReturnsLiveEntry() {
val a = signer.sign(TextNoteEvent.build("hello", createdAt = 100))
interner.intern(a)
assertSame(a, interner.get(a.id))
}
@Test
fun getReturnsNullForUnknownId() {
assertNull(interner.get("0".repeat(64)))
}
@Test
fun getEvictsDeadEntries() {
// Confine the event to a helper so no strong ref leaks into
// this method's stack frame after it returns. The interner's
// WeakReference is then the only thing holding it.
val id = internAndDiscard(interner)
for (i in 0..40) {
System.gc()
Thread.sleep(20)
if (interner.get(id) == null) break
}
assertNull(interner.get(id))
assertEquals(0, interner.size(), "dead entry should be evicted on access")
}
private fun internAndDiscard(interner: EventInterner): HexKey {
val ev = signer.sign(TextNoteEvent.build("hello", createdAt = 100))
interner.intern(ev)
return ev.id
}
@Test
fun draftChurnDoesNotLeak() {
// 100 distinct drafts, no strong references retained.
val ids = ArrayList<HexKey>(100)
for (i in 0 until 100) {
val e = signer.sign(TextNoteEvent.build("draft-$i", createdAt = 1_000L + i))
ids.add(e.id)
interner.intern(e)
}
// Force GC + sweep — calling get() on every id triggers
// the on-access cleanup path.
for (i in 0..40) {
System.gc()
Thread.sleep(20)
ids.forEach { interner.get(it) }
if (interner.size() == 0) break
}
assertTrue(interner.size() <= 5, "expected most drafts to be evicted, got ${interner.size()}")
}
@Test
fun defaultInstanceIsShared() {
val a = signer.sign(TextNoteEvent.build("hello", createdAt = 100))
val canonical = EventInterner.Default.intern(a)
// A second intern call from anywhere returns the same canonical.
val b = EventInterner.Default.intern(a)
assertSame(canonical, b)
EventInterner.Default.clear()
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.cache.interning
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/** Linux native actual: passthrough. See `EventInterner.apple.kt`. */
actual class EventInterner {
actual fun intern(event: Event): Event = event
actual fun get(id: HexKey): Event? = null
actual fun size(): Int = 0
actual fun clear() {}
actual companion object {
actual val Default: EventInterner = EventInterner()
}
}