refactor(quartz): tighten projection package layout + relay flows from store

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
This commit is contained in:
Claude
2026-04-30 14:51:16 +00:00
parent e126ca679a
commit ecbb7ea10c
8 changed files with 91 additions and 114 deletions
@@ -22,6 +22,7 @@ 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
/**
@@ -51,6 +52,8 @@ class InternedEventStore(
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)
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) = inner.transaction(body)
@@ -18,7 +18,7 @@
* 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.projection
package com.vitorpamplona.quartz.nip01Core.cache.projection
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
@@ -26,7 +26,8 @@ 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.relay.normalizer.NormalizedRelayUrl
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
@@ -76,8 +77,8 @@ import kotlinx.coroutines.yield
* handle owned by the same author (for GiftWrap, the recipient).
* Cross-author deletions are inert.
* - **NIP-62 right to vanish.** A [RequestToVanishEvent] whose
* `shouldVanishFrom([relay])` is true drops every handle from
* the same author with `created_at < vanish.created_at`.
* `shouldVanishFrom(store.relay)` is true drops every handle
* from the same author with `created_at < vanish.created_at`.
* - **NIP-40 expiration.** Events whose `expiration` tag has
* already lapsed at the moment they arrive are dropped before
* they ever enter [items].
@@ -115,7 +116,6 @@ import kotlinx.coroutines.yield
class EventStoreProjection<T : Event>(
private val store: ObservableEventStore,
private val filters: List<Filter>,
private val relay: NormalizedRelayUrl?,
scope: CoroutineScope,
private val nowProvider: () -> Long = TimeUtils::now,
) : AutoCloseable {
@@ -199,7 +199,7 @@ class EventStoreProjection<T : Event>(
if (event is DeletionEvent) {
if (handleDeletion(event)) changed = true
}
if (event is RequestToVanishEvent && event.shouldVanishFrom(relay)) {
if (event is RequestToVanishEvent && event.shouldVanishFrom(store.relay)) {
if (dropWhere { ev -> ownerOf(ev) == event.pubKey && ev.createdAt < event.createdAt }) changed = true
}
@@ -408,3 +408,19 @@ class EventStoreProjection<T : Event>(
}
}
}
/**
* Convenience: open a projection over this observable store. NIP-62
* vanish handling is scoped by the inner store's `relay`. Cancel
* [scope] (or call [EventStoreProjection.close]) to release the
* projection.
*/
fun <T : Event> ObservableEventStore.observe(
filters: List<Filter>,
scope: CoroutineScope,
): EventStoreProjection<T> = EventStoreProjection(this, filters, scope)
fun <T : Event> ObservableEventStore.observe(
filter: Filter,
scope: CoroutineScope,
): EventStoreProjection<T> = EventStoreProjection(this, listOf(filter), scope)
@@ -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 {
@@ -18,16 +18,14 @@
* 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.projection
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.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -50,18 +48,21 @@ import kotlinx.coroutines.flow.asSharedFlow
* [changes] so projections can render them while they live. Already
* expired ephemerals are silently dropped.
*
* Wrap any store you want to observe [SQLiteEventStore], FS-backed,
* an in-memory test fake and feed [EventStoreProjection] from the
* Wrap any store you want to observe `SQLiteEventStore`, FS-backed,
* an in-memory test fake and feed `EventStoreProjection` from the
* resulting [changes] flow.
*
* Reads (`query`, `count`) and out-of-band writes (`delete`,
* `deleteExpiredEvents`) forward to the inner store unchanged. The
* latter are *not* surfaced on [changes] see the projection's
* docstring for the rationale.
* `deleteExpiredEvents`) forward to the inner store; the latter are
* also surfaced on [changes] as [StoreChange.DeleteByFilter] /
* [StoreChange.DeleteExpired] so projections can drop the matching
* slots in memory without re-querying.
*/
class ObservableEventStore(
val inner: IEventStore,
) : IEventStore {
override val relay: NormalizedRelayUrl? get() = inner.relay
private val _changes =
MutableSharedFlow<StoreChange>(
replay = 0,
@@ -72,15 +73,47 @@ class ObservableEventStore(
/**
* Stream of mutations accepted by the observable layer. One
* emission per successful [insert] (or per accepted event in a
* [transaction] body), one emission per [delete] / [delete] /
* [transaction] body), one emission per [delete] /
* [deleteExpiredEvents] call. Rejected inserts and rolled-back
* transactions emit nothing.
*
* Projections consume this stream see [EventStoreProjection]
* Projections consume this stream see `EventStoreProjection`
* for how each [StoreChange] is interpreted.
*/
val changes: SharedFlow<StoreChange> = _changes.asSharedFlow()
/**
* Mutations published by [changes]. Projections react to these to
* keep their in-memory view in sync with the underlying store.
*
* - [Insert] is emitted for every event accepted by the
* observable layer (persistable or ephemeral). Carries the
* event itself so the projection can run its NIP-01 / NIP-09 /
* NIP-62 interpretation.
* - [DeleteByFilter] is emitted for every `delete(filter)` /
* `delete(filters)` call on the observable. Carries the same
* filters the store used so projections can apply
* [Filter.match] in memory and drop the matching slots without
* re-querying.
* - [DeleteExpired] is emitted for every `deleteExpiredEvents()`
* sweep. The optional [DeleteExpired.asOf] cutoff lets the
* store pin the timestamp it actually used, so the projection
* drops exactly the events the store dropped.
*/
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
@@ -162,24 +195,5 @@ class ObservableEventStore(
_changes.emit(StoreChange.DeleteExpired(asOf))
}
/**
* Open a reactive [EventStoreProjection] over this observable
* store. [relay] scopes NIP-62 vanish handling pass the relay
* URL the events are arriving from, or `null` to apply only
* unscoped (`ALL_RELAYS`) vanish requests. Cancel [scope] (or
* call [EventStoreProjection.close]) to release the projection.
*/
fun <T : Event> observe(
filters: List<Filter>,
relay: NormalizedRelayUrl?,
scope: CoroutineScope,
): EventStoreProjection<T> = EventStoreProjection(this, filters, relay, scope)
fun <T : Event> observe(
filter: Filter,
relay: NormalizedRelayUrl?,
scope: CoroutineScope,
): EventStoreProjection<T> = observe(listOf(filter), relay, scope)
override fun close() = inner.close()
}
@@ -1,57 +0,0 @@
/*
* 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.projection
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
/**
* Mutations published by [ObservableEventStore.changes]. Projections
* react to these to keep their in-memory view in sync with the
* underlying store.
*
* - [Insert] is emitted for every event accepted by the observable
* layer (persistable or ephemeral). Carries the event itself so
* the projection can run its NIP-01 / NIP-09 / NIP-62
* interpretation.
* - [DeleteByFilter] is emitted for every `delete(filter)` /
* `delete(filters)` call on the observable. Carries the same
* filters the store used so projections can apply
* [Filter.match] in memory and drop the matching slots without
* re-querying.
* - [DeleteExpired] is emitted for every `deleteExpiredEvents()`
* sweep. The optional [DeleteExpired.asOf] cutoff lets the store
* pin the timestamp it actually used, so the projection drops
* exactly the events the store dropped.
*/
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
}
@@ -34,7 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore
*/
class EventStore(
dbName: String? = "events.db",
val 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)
@@ -18,14 +18,14 @@
* 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.projection
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.projection.ObservableEventStore
import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -96,7 +96,7 @@ class EventStoreProjectionTest {
observable.insert(a)
observable.insert(b)
val projection = observable.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), store.relay, scope)
val projection = observable.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
val items = projection.items.value
@@ -112,7 +112,7 @@ class EventStoreProjectionTest {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
observable.insert(a)
val projection = observable.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), store.relay, scope)
val projection = observable.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
val before = projection.items.value
assertEquals(1, before.size)
@@ -132,7 +132,7 @@ class EventStoreProjectionTest {
val text = signer.sign(TextNoteEvent.build("a", createdAt = 100))
observable.insert(text)
val projection = observable.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), store.relay, scope)
val projection = observable.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
val seed = projection.items.value
@@ -154,7 +154,6 @@ class EventStoreProjectionTest {
val projection =
observable.observe<MetadataEvent>(
Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(v1.pubKey)),
store.relay,
scope,
)
projection.ready.await()
@@ -186,7 +185,6 @@ class EventStoreProjectionTest {
authors = listOf(v1.pubKey),
tags = mapOf("d" to listOf("blog")),
),
store.relay,
scope,
)
projection.ready.await()
@@ -222,7 +220,6 @@ class EventStoreProjectionTest {
val projection =
observable.observe<MetadataEvent>(
Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(v1.pubKey)),
store.relay,
scope,
)
projection.ready.await()
@@ -251,7 +248,7 @@ class EventStoreProjectionTest {
observable.insert(a)
observable.insert(b)
val projection = observable.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), store.relay, scope)
val projection = observable.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
assertEquals(2, projection.items.value.size)
@@ -273,7 +270,7 @@ class EventStoreProjectionTest {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
observable.insert(a)
val projection = observable.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), store.relay, scope)
val projection = observable.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
val seed = projection.items.value
assertEquals(1, seed.size)
@@ -301,7 +298,7 @@ class EventStoreProjectionTest {
observable.insert(a)
observable.insert(b)
val projection = observable.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), store.relay, scope)
val projection = observable.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
assertEquals(2, projection.items.value.size)
@@ -330,7 +327,7 @@ class EventStoreProjectionTest {
val a = signer.sign(TextNoteEvent.build("a", createdAt = time))
observable.insert(a)
val projection = observable.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), store.relay, scope)
val projection = observable.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
val seed = projection.items.value
@@ -365,7 +362,6 @@ class EventStoreProjectionTest {
val projection =
observable.observe<Event>(
Filter(kinds = listOf(TextNoteEvent.KIND)),
store.relay,
scope,
)
projection.ready.await()
@@ -400,7 +396,6 @@ class EventStoreProjectionTest {
val projection =
observable.observe<Event>(
Filter(kinds = listOf(TextNoteEvent.KIND)),
store.relay,
scope,
)
projection.ready.await()
@@ -426,7 +421,6 @@ class EventStoreProjectionTest {
val projection =
observable.observe<TextNoteEvent>(
Filter(kinds = listOf(TextNoteEvent.KIND), limit = 2),
store.relay,
scope,
)
projection.ready.await()
@@ -466,7 +460,6 @@ class EventStoreProjectionTest {
val projection =
observable.observe<TextNoteEvent>(
listOf(filterA, filterB),
store.relay,
scope,
)
projection.ready.await()
@@ -490,7 +483,6 @@ class EventStoreProjectionTest {
val projection =
observable.observe<TextNoteEvent>(
Filter(kinds = listOf(TextNoteEvent.KIND), limit = 2),
store.relay,
scope,
)
projection.ready.await()
@@ -510,7 +502,7 @@ class EventStoreProjectionTest {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
observable.insert(a)
val projection = observable.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), store.relay, scope)
val projection = observable.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
projection.close()
@@ -532,7 +524,7 @@ class EventStoreProjectionTest {
runBlocking {
val ephemeralKind = 22_000
val projection =
observable.observe<Event>(Filter(kinds = listOf(ephemeralKind)), store.relay, scope)
observable.observe<Event>(Filter(kinds = listOf(ephemeralKind)), scope)
projection.ready.await()
assertTrue(projection.items.value.isEmpty())
@@ -555,7 +547,7 @@ class EventStoreProjectionTest {
// A fresh projection on the same store gets nothing — the
// event was only ever live, not durable.
val freshProjection =
observable.observe<Event>(Filter(kinds = listOf(ephemeralKind)), store.relay, scope)
observable.observe<Event>(Filter(kinds = listOf(ephemeralKind)), scope)
freshProjection.ready.await()
assertTrue(freshProjection.items.value.isEmpty())
@@ -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