refactor(notifications): observer takes a single predicate

Filter.match is itself just a boolean predicate, so carrying a Filter and
a separate composition predicate on NewEventMatchingFilter duplicated the
abstraction. Collapse to one predicate:

- NewEventMatchingFilter(predicate, onNew): drops the Filter parameter.
- LocalCache.observeNewEvents(predicate): primary API.
- LocalCache.observeNewEvents(filter): kept as a one-liner that forwards
  filter::match, so existing feed/DAL callers are unchanged.

In the dispatcher the freshness check, kind check, p-tag match, and
since cutoff now live in a single lambda, short-circuiting on cheap
kind comparison first. NOTIFICATION_KINDS is now a Set<Int> for O(1)
membership instead of O(n) List.contains — cheap, but on a hot path
that runs for every new cache insertion it pays for itself.

https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
This commit is contained in:
Claude
2026-04-24 14:39:42 +00:00
parent 12a5926f6a
commit 86a86e1426
3 changed files with 44 additions and 49 deletions
@@ -22,24 +22,21 @@ package com.vitorpamplona.amethyst.commons.model.observables
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
/**
* Emits each new event that matches the given Nostr filter, one at a time.
* Emits each new event for which [predicate] returns true, one at a time,
* as it is inserted into the cache. Unlike [EventListMatchingFilter] /
* [NoteListMatchingFilter], this does not accumulate a list — it simply
* calls [onNew] per matching event. Useful for reactive event-triggered
* pipelines like notifications.
*
* Unlike [EventListMatchingFilter] / [NoteListMatchingFilter], this does not
* accumulate a list — it simply calls [onNew] per matching event as it is
* inserted into the cache. Useful for reactive event-triggered pipelines
* (e.g. notifications) that need per-event delivery without list overhead.
*
* The optional [predicate] runs after the protocol [filter] matches; use it
* for checks the Nostr [Filter] grammar can't express (e.g. rolling age
* cutoffs, arbitrary tag shapes, derived content fields). The predicate has
* to be fast — it runs on every new cache insertion.
* The predicate has to be fast — it runs on every new cache insertion.
* Callers with a Nostr [com.vitorpamplona.quartz.nip01Core.relay.filters.Filter]
* can pass `filter::match` as the predicate and compose additional checks
* with `&&`.
*/
class NewEventMatchingFilter<T : Event>(
private val filter: Filter,
private val predicate: (Event) -> Boolean = { true },
private val predicate: (Event) -> Boolean,
private val onNew: (T) -> Unit,
) : Observable {
@Suppress("UNCHECKED_CAST")
@@ -47,7 +44,7 @@ class NewEventMatchingFilter<T : Event>(
event: Event,
note: Note,
) {
if (filter.match(event) && predicate(event)) {
if (predicate(event)) {
onNew(event as T)
}
}