refactor(quartz): introduce ObservableEventStore so projections see ephemerals
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
This commit is contained in:
+45
-1
@@ -359,7 +359,7 @@ class EventStoreProjectionTest {
|
||||
// Drive the ticker frequently so the test doesn't sit idle.
|
||||
val projection =
|
||||
EventStoreProjection<Event>(
|
||||
store,
|
||||
store.observable,
|
||||
listOf(Filter(kinds = listOf(TextNoteEvent.KIND))),
|
||||
relay = null,
|
||||
scope = scope,
|
||||
@@ -416,4 +416,48 @@ class EventStoreProjectionTest {
|
||||
delay(150)
|
||||
assertTrue(projection.items.value.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.observable.ObservableEventStore.events]
|
||||
* 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 =
|
||||
store.observe<Event>(Filter(kinds = listOf(ephemeralKind)), scope)
|
||||
projection.ready.await()
|
||||
assertTrue(projection.items.value.isEmpty())
|
||||
|
||||
val ephemeral: Event =
|
||||
signer.sign(
|
||||
TimeUtils.now(),
|
||||
ephemeralKind,
|
||||
arrayOf(emptyArray()),
|
||||
"live",
|
||||
)
|
||||
store.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 =
|
||||
store.observe<Event>(Filter(kinds = listOf(ephemeralKind)), scope)
|
||||
freshProjection.ready.await()
|
||||
assertTrue(freshProjection.items.value.isEmpty())
|
||||
|
||||
projection.close()
|
||||
freshProjection.close()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user