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:
Claude
2026-04-30 16:10:47 +00:00
parent f759f44eea
commit 058c56dc21
2 changed files with 104 additions and 29 deletions
@@ -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