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:
Claude
2026-04-29 20:34:56 +00:00
parent c54f34ef08
commit 86537edef5
8 changed files with 293 additions and 113 deletions
@@ -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()
}
}