feat(quartz): re-evaluate filter membership on addressable supersession
When a replaceable / addressable update arrives in handleInsert, the projection now re-runs Filter.match against the new event for every filter. A v2 that no longer matches a filter (e.g. tag list changed) is removed from that filter's set; a v2 that newly matches a filter joins it. If no filter retains the slot afterwards, it's fully dropped from byId / byAddress. Closes the previous gap where v2 of an addressable kept a stale filter membership inherited from v1. The slot's MutableStateFlow is still updated in place — collectors of that handle still see the content change before the membership update emits. The cap-eviction-with-cleanup logic was extracted into a small `admit` helper since the supersession path and the new-slot path both need it. For the common case (filter on `kinds + authors`, no tag/time constraints), v2 always still matches — the new branches are no-ops and the list reference stays stable, preserving the in-place update guarantee. The behaviour change kicks in for tag, time-window, or id-list filters. New test addressableUpdateDropsSlotWhenFilterStopsMatching: v1 has hashtag "nostr" and matches a `t = nostr` filter; v2 changes to "bitcoin"; the projection drops the slot. 18/18 projection tests pass. https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
This commit is contained in:
+44
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
@@ -208,6 +209,49 @@ class EventStoreProjectionTest {
|
||||
assertSame(seedList, projection.items, "addressable update must not change list reference")
|
||||
}
|
||||
|
||||
/**
|
||||
* If v2 of an addressable no longer matches the filter (e.g.
|
||||
* tag list changed), the slot is dropped. The projection
|
||||
* re-evaluates filter membership on every supersession.
|
||||
*/
|
||||
@Test
|
||||
fun addressableUpdateDropsSlotWhenFilterStopsMatching() =
|
||||
runBlocking {
|
||||
val time = TimeUtils.now()
|
||||
// v1 carries tag "nostr"; v2 changes the tag to "bitcoin".
|
||||
val v1 =
|
||||
signer.sign(
|
||||
LongTextNoteEvent.build("blog v1", "title", dTag = "blog", createdAt = time) {
|
||||
hashtag("nostr")
|
||||
},
|
||||
)
|
||||
observable.insert(v1)
|
||||
|
||||
// Filter narrows to events that ALSO carry hashtag "nostr".
|
||||
val projection =
|
||||
projectionOf<LongTextNoteEvent>(
|
||||
Filter(
|
||||
kinds = listOf(LongTextNoteEvent.KIND),
|
||||
authors = listOf(v1.pubKey),
|
||||
tags = mapOf("t" to listOf("nostr")),
|
||||
),
|
||||
)
|
||||
projection.awaitLoaded()
|
||||
assertEquals(1, projection.items.size)
|
||||
|
||||
val v2 =
|
||||
signer.sign(
|
||||
LongTextNoteEvent.build("blog v2", "title", dTag = "blog", createdAt = time + 1) {
|
||||
hashtag("bitcoin")
|
||||
},
|
||||
)
|
||||
observable.insert(v2)
|
||||
|
||||
// v2 doesn't match — slot drops and the snapshot is empty.
|
||||
val after = projection.awaitItems { it.isEmpty() }
|
||||
assertTrue(after.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* Out-of-order arrival: the projection sees the *newer* version
|
||||
* first (e.g. the relay sent v2 first), then v1. The projection
|
||||
|
||||
Reference in New Issue
Block a user