refactor(quartz): make EventStoreProjection store-agnostic, NIP-aware

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
This commit is contained in:
Claude
2026-04-29 19:56:00 +00:00
parent d370784f3f
commit c54f34ef08
9 changed files with 614 additions and 530 deletions
@@ -18,13 +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.sqlite
package com.vitorpamplona.quartz.nip01Core.store.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.sqlite.EventStore
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -50,6 +51,7 @@ import kotlin.test.assertTrue
class EventStoreProjectionTest {
private val signer = NostrSignerSync()
private val otherSigner = NostrSignerSync()
private lateinit var store: EventStore
private lateinit var scope: CoroutineScope
@@ -66,13 +68,6 @@ class EventStoreProjectionTest {
store.close()
}
/**
* Wait until [items][EventStoreProjection.items] reaches a state
* for which [predicate] is true. We poll the StateFlow rather than
* collect because the projection only re-emits on membership
* change in-place addressable updates intentionally don't move
* the list reference.
*/
private suspend fun <T : Event> EventStoreProjection<T>.awaitItems(
timeoutMs: Long = 5_000,
predicate: (List<MutableStateFlow<T>>) -> Boolean,
@@ -81,11 +76,6 @@ class EventStoreProjectionTest {
items.first { predicate(it) }
}
/**
* Wait until [flow] reaches [expected]. Used to observe an
* in-place addressable update where the list reference doesn't
* change but the slot's value does.
*/
private suspend fun <T : Event> awaitFlow(
flow: MutableStateFlow<T>,
timeoutMs: Long = 5_000,
@@ -108,7 +98,6 @@ class EventStoreProjectionTest {
val items = projection.items.value
assertEquals(2, items.size)
// Sorted by created_at DESC.
assertEquals(b.id, items[0].value.id)
assertEquals(a.id, items[1].value.id)
projection.close()
@@ -131,12 +120,11 @@ class EventStoreProjectionTest {
val after = projection.awaitItems { it.size == 2 }
assertNotSame(before, after, "insert must produce a new list reference")
assertEquals(b.id, after[0].value.id)
assertEquals(a.id, after[1].value.id)
projection.close()
}
@Test
fun insertingNonMatchingEventDoesNotChangeList() =
fun nonMatchingInsertDoesNotChangeList() =
runBlocking {
val text = signer.sign(TextNoteEvent.build("a", createdAt = 100))
store.insert(text)
@@ -145,11 +133,9 @@ class EventStoreProjectionTest {
projection.ready.await()
val seed = projection.items.value
// Metadata kind doesn't match the filter.
val meta = signer.sign(MetadataEvent.createNew("Vitor", createdAt = 200))
store.insert(meta)
// Give the projection time to process the change.
delay(150)
assertSame(seed, projection.items.value)
projection.close()
@@ -176,11 +162,8 @@ class EventStoreProjectionTest {
val v2 = signer.sign(MetadataEvent.createNew("v2", createdAt = time + 1))
store.insert(v2)
// The slot's flow updates...
awaitFlow(slot) { it.id == v2.id }
// ...but the list reference is the SAME, and the slot is the SAME instance.
assertSame(seedList, projection.items.value, "addressable replace must not change list reference")
assertSame(seedList, projection.items.value, "replaceable update must not change list reference")
assertSame(slot, projection.items.value[0])
projection.close()
}
@@ -203,16 +186,54 @@ class EventStoreProjectionTest {
)
projection.ready.await()
val seedList = projection.items.value
assertEquals(1, seedList.size)
val slot = seedList[0]
assertEquals(v1.id, slot.value.id)
val v2 = signer.sign(LongTextNoteEvent.build("blog v2", "title", dTag = "blog", createdAt = time + 1))
store.insert(v2)
awaitFlow(slot) { it.id == v2.id }
assertSame(seedList, projection.items.value, "addressable update must not change list reference")
assertSame(slot, projection.items.value[0])
projection.close()
}
/**
* 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.
store.insert(v2)
val projection =
store.observe<MetadataEvent>(
Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(v1.pubKey)),
scope,
)
projection.ready.await()
val slot = projection.items.value[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 {
store.insert(v1)
} catch (_: Throwable) {
// expected — store enforces the same rule
}
delay(150)
assertEquals(v2.id, slot.value.id)
projection.close()
}
@@ -224,7 +245,7 @@ class EventStoreProjectionTest {
store.insert(a)
store.insert(b)
val projection = store.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
val projection = store.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
assertEquals(2, projection.items.value.size)
@@ -236,8 +257,37 @@ class EventStoreProjectionTest {
projection.close()
}
/**
* NIP-09 cross-author deletions are inert. A different signer
* publishing a kind-5 targeting `a` must not drop the slot.
*/
@Test
fun nip62VanishRemovesAllAuthorsEvents() =
fun nip09CrossAuthorDeletionIsInert() =
runBlocking {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
store.insert(a)
val projection = store.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
val seed = projection.items.value
assertEquals(1, seed.size)
val foreignDeletion = otherSigner.sign(DeletionEvent.build(listOf(a)))
store.insert(foreignDeletion)
// Give the projection time to process the event.
delay(150)
assertSame(seed, projection.items.value)
assertEquals(
a.id,
projection.items.value[0]
.value.id,
)
projection.close()
}
@Test
fun nip62VanishRemovesAuthorEvents() =
runBlocking {
val time = TimeUtils.now()
val a = signer.sign(TextNoteEvent.build("a", createdAt = time))
@@ -245,7 +295,7 @@ class EventStoreProjectionTest {
store.insert(a)
store.insert(b)
val projection = store.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
val projection = store.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
assertEquals(2, projection.items.value.size)
@@ -263,8 +313,42 @@ class EventStoreProjectionTest {
projection.close()
}
/**
* NIP-62 only removes events from the same author. A vanish from
* a different author must not touch slots owned by [signer].
*/
@Test
fun nip40ExpirationRemovesSlotOnSweep() =
fun nip62OtherAuthorVanishLeavesEventsAlone() =
runBlocking {
val time = TimeUtils.now()
val a = signer.sign(TextNoteEvent.build("a", createdAt = time))
store.insert(a)
val projection = store.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
val seed = projection.items.value
val foreignVanish =
otherSigner.sign(
RequestToVanishEvent.build(
"wss://quartz.local".normalizeRelayUrl(),
createdAt = time + 2,
),
)
store.insert(foreignVanish)
delay(150)
assertSame(seed, projection.items.value)
projection.close()
}
/**
* NIP-40 per-projection ticker: a slot whose `expiration` lapses
* after the projection has loaded should be dropped on the next
* tick, even though the store hasn't run its sweep yet.
*/
@Test
fun nip40ExpirationDroppedByTicker() =
runBlocking {
val time = TimeUtils.now()
val safe = signer.sign(TextNoteEvent.build("safe", createdAt = time) { expiration(time + 100) })
@@ -272,36 +356,26 @@ class EventStoreProjectionTest {
store.insert(safe)
store.insert(short)
val projection = store.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
// Drive the ticker frequently so the test doesn't sit idle.
val projection =
EventStoreProjection<Event>(
store,
listOf(Filter(kinds = listOf(TextNoteEvent.KIND))),
relay = null,
scope = scope,
expirationTickMs = 100,
)
projection.ready.await()
assertEquals(2, projection.items.value.size)
// Wait for the expiration to lapse, then run the sweep.
// Wait past the short expiration.
delay(2000)
store.deleteExpiredEvents()
val after = projection.awaitItems { it.size == 1 }
val after = projection.awaitItems(timeoutMs = 5_000) { it.size == 1 }
assertEquals(safe.id, after[0].value.id)
projection.close()
}
@Test
fun manualDeleteByIdRemovesSlot() =
runBlocking {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
store.insert(a)
val projection = store.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
assertEquals(1, projection.items.value.size)
store.store.delete(a.id)
val after = projection.awaitItems { it.isEmpty() }
assertTrue(after.isEmpty())
projection.close()
}
@Test
fun limitIsEnforcedOnInsertOverflow() =
runBlocking {
@@ -318,7 +392,6 @@ class EventStoreProjectionTest {
projection.ready.await()
assertEquals(2, projection.items.value.size)
// Newer event arrives; it should push the oldest out.
val c = signer.sign(TextNoteEvent.build("c", createdAt = 300))
store.insert(c)
@@ -339,7 +412,6 @@ class EventStoreProjectionTest {
projection.ready.await()
projection.close()
// Subsequent inserts must not surface in the (now empty) projection.
store.insert(signer.sign(TextNoteEvent.build("b", createdAt = 200)))
delay(150)
assertTrue(projection.items.value.isEmpty())