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:
+124
-52
@@ -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())
|
||||
Reference in New Issue
Block a user