diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt index dd3deb8c2e..e9049476af 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.quartz.nip19Bech32.eventHints import com.vitorpamplona.quartz.nip19Bech32.eventIds import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints import com.vitorpamplona.quartz.nip19Bech32.pubKeys +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -66,6 +67,13 @@ class TextNoteEvent( SearchableEvent { override fun indexableContent() = listOfNotNull(subject(), content).joinToString("\n") + // The read path: hands over the same fields indexableContent() joins, without + // building the joined string a scan would throw away. + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + if (!visitor.visit(subject())) return + visitor.visit(content) + } + override fun eventHints(): List { val eHints = tags.mapNotNull(MarkedETag::parseAsHint) val qHints = tags.mapNotNull(QTag::parseEventAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt index 93f4826b9f..501b0ea929 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi @@ -72,6 +73,14 @@ class LongTextNoteEvent( SearchableEvent { override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") + // The read path: hands over the same fields indexableContent() joins, without + // building the joined string a scan would throw away. + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + if (!visitor.visit(title())) return + if (!visitor.visit(summary())) return + visitor.visit(content) + } + override fun eventHints(): List { val qHints = tags.mapNotNull(QTag::parseEventAsHint) val nip19Hints = citedNIP19().eventHints() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcher.kt new file mode 100644 index 0000000000..82a38d5c6f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcher.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * 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.nip50Search + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.fastAny + +/** + * Matches an event against a NIP-50 `search` string, in memory, without an index. + * + * Built once per query and reused across every event of a scan: the terms are split and the + * extensions stripped here, not per event, and `match` allocates nothing at all. + * + * ## What it matches + * + * Terms are ANDed and each is a case-insensitive substring — of a tag value, or of the event's + * own indexable fields. Substring rather than token because that is what Amethyst's local search + * has always done (`content.contains(text, true)`), and switching to tokens would silently stop + * matching mid-word queries; AND rather than one literal phrase because that is what a relay does + * with the same string, and the point of routing local search through a `Filter` is that the two + * stop disagreeing. For a single-word query — the overwhelmingly common case — the two rules are + * identical anyway. + * + * Unsupported NIP-50 extensions are ignored rather than treated as terms, per the spec, so a + * search that is nothing but extensions matches everything rather than nothing. + * + * ## What it does not do + * + * No relevance score. A relay ranks bm25-first; this answers only yes or no, and any ordering is + * the caller's to apply afterwards. + * + * ## Thread confinement + * + * An instance reuses one visitor across every event it is asked about, which is what keeps the + * scan allocation-free — a lambda written inline would capture the term and allocate once per + * event, defeating the whole point. That reuse makes an instance **single-threaded**: build one + * per scan, do not share it between coroutines. + */ +class EventSearchMatcher( + search: String?, + /** + * Tag names whose values are not worth searching — `p`/`e`/`a` hold hex ids that only ever + * match by accident, `client` and `alt` hold text the author did not write. + */ + private val exceptTagNames: Set = DEFAULT_EXCLUDED_TAGS, +) { + private val terms: Array = + SearchQuery + .stripExtensions(search) + ?.split(' ', '\t', '\n', '\r') + ?.filter { it.isNotBlank() } + ?.toTypedArray() + ?: emptyArray() + + /** True when this matcher constrains nothing, so callers can skip the walk entirely. */ + val isEmpty: Boolean get() = terms.isEmpty() + + fun match(event: Event): Boolean { + if (terms.isEmpty()) return true + for (i in terms.indices) { + if (!matchesTerm(event, terms[i])) return false + } + return true + } + + /** Reused for every field of every event; see the thread-confinement note on the class. */ + private val visitor = TermVisitor() + + private fun matchesTerm( + event: Event, + term: String, + ): Boolean { + if (event.tags.fastAny { it.size > 1 && it[0] !in exceptTagNames && it[1].contains(term, true) }) { + return true + } + if (event !is SearchableEvent) return event.content.contains(term, true) + return visitor.matches(event, term) + } + + /** + * Carries the term into the walk on a field rather than in a closure, so no lambda is + * allocated per event, and stops the walk at the first field that holds it. + */ + private class TermVisitor : IndexableFieldVisitor { + private var term: String = "" + private var found = false + + fun matches( + event: SearchableEvent, + term: String, + ): Boolean { + this.term = term + found = false + event.forEachIndexableField(this) + return found + } + + override fun visit(field: String?): Boolean { + if (field != null && field.contains(term, true)) { + found = true + return false + } + return true + } + } + + companion object { + val DEFAULT_EXCLUDED_TAGS = setOf("client", "p", "e", "a", "alt") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt index aacd7ccd8f..1d7b3bb65f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt @@ -20,6 +20,48 @@ */ package com.vitorpamplona.quartz.nip50Search +/** + * Receives one indexable field at a time. Returning false stops the walk. + * + * This is a `fun interface` rather than a lambda parameter on purpose: an interface method cannot + * be `inline`, so a lambda written at the call site would allocate on every event. One instance + * of this is built per scan and reused for every event in it, carrying whatever the walk needs — + * the query terms, a running hit flag — on itself. That is what makes a full-cache scan + * allocation-free. + */ +fun interface IndexableFieldVisitor { + /** + * @param field one field's text, or null where the event does not carry it — implementors + * pass their optional fields straight through rather than filtering them first. + * @return false to stop the walk. A hit on the title need not build the body. + */ + fun visit(field: String?): Boolean +} + +/** + * An event whose human-authored text is worth indexing for NIP-50 search. + * + * There are two ways to read that text, and they exist for opposite cost profiles: + * + * - [indexableContent] is the **write path**. The stores call it once per event on insert, so it + * is free to build a joined string; the SQLite store hands the result to FTS5 and the + * filesystem store tokenizes it. Its exact output per kind is mirrored by external search + * engines — see `references/searchable-kinds.md` in the `searchable-events` skill — so changing + * what it returns is a breaking change that needs a reindex. + * + * - [forEachIndexableField] is the **read path**. Matching a query against the whole cache calls + * it once per event per keystroke, where building a string per event is not affordable. It + * hands over the fields the event already holds, allocating nothing, and stops early on a hit. + * + * The default [forEachIndexableField] falls back to [indexableContent], which is correct for + * every kind and free for the ~28 whose indexable content is `content` itself. Kinds that join + * several fields — the majority — should override it so the read path stops paying for a join + * the reader may not even need. + */ interface SearchableEvent { fun indexableContent(): String + + fun forEachIndexableField(visitor: IndexableFieldVisitor) { + visitor.visit(indexableContent()) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt index e02bf44442..d0f2692be5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip53LiveActivities.LiveStreamLike import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag @@ -65,6 +66,14 @@ class LiveActivitiesEvent( SearchableEvent { override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") + // The read path: hands over the same fields indexableContent() joins, without + // building the joined string a scan would throw away. + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + if (!visitor.visit(title())) return + if (!visitor.visit(summary())) return + visitor.visit(content) + } + override fun eventHints(): List { val pinnedEvents = pinned() if (pinnedEvents.isEmpty()) return emptyList() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt index 0298ab9346..42cae11b8a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi @@ -79,6 +80,14 @@ class WikiNoteEvent( SearchableEvent { override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") + // The read path: hands over the same fields indexableContent() joins, without + // building the joined string a scan would throw away. + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + if (!visitor.visit(title())) return + if (!visitor.visit(summary())) return + visitor.visit(content) + } + override fun eventHints(): List { val eHints = tags.mapNotNull(MarkedETag::parseAsHint) val qHints = tags.mapNotNull(QTag::parseEventAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt index a74aa022da..1eb9ada1f7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.DescriptionTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ImageTag @@ -59,6 +60,15 @@ class CommunityDefinitionEvent( SearchableEvent { override fun indexableContent() = listOfNotNull(name(), description(), rules(), content).joinToString("\n") + // The read path: hands over the same fields indexableContent() joins, without + // building the joined string a scan would throw away. + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + if (!visitor.visit(name())) return + if (!visitor.visit(description())) return + if (!visitor.visit(rules())) return + visitor.visit(content) + } + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(QTag::parseEventId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt index 7a245fe805..d5af20931b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip19Bech32.eventIds import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag @@ -71,6 +72,14 @@ class HighlightEvent( SearchableEvent { override fun indexableContent() = listOfNotNull(comment(), context(), content).joinToString("\n") + // The read path: hands over the same fields indexableContent() joins, without + // building the joined string a scan would throw away. + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + if (!visitor.visit(comment())) return + if (!visitor.visit(context())) return + visitor.visit(content) + } + override fun eventHints(): List { val eHints = tags.mapNotNull(ETag::parseAsHint) val qHints = tags.mapNotNull(QTag::parseEventAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt index dba1ba9905..57f6b3299d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag @@ -65,6 +66,14 @@ class ClassifiedsEvent( SearchableEvent { override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n") + // The read path: hands over the same fields indexableContent() joins, without + // building the joined string a scan would throw away. + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + if (!visitor.visit(title())) return + if (!visitor.visit(summary())) return + visitor.visit(content) + } + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcherTest.kt new file mode 100644 index 0000000000..aa7984e3ec --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/EventSearchMatcherTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * 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.nip50Search + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EventSearchMatcherTest { + private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + private val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758" + + private fun note( + content: String, + tags: Array> = emptyArray(), + kind: Int = 1, + ): Event = EventFactory.create(id, pubkey, 1683596206, kind, tags, content, "") + + private fun matches( + search: String?, + event: Event, + ) = EventSearchMatcher(search).match(event) + + @Test + fun anEmptySearchConstrainsNothing() { + assertTrue(EventSearchMatcher(null).isEmpty) + assertTrue(EventSearchMatcher(" ").isEmpty) + assertTrue(matches(null, note("anything"))) + assertTrue(matches("", note("anything"))) + } + + @Test + fun aTermIsACaseInsensitiveSubstring() { + // Substring, not token: Amethyst's local search has always matched mid-word, and + // switching to tokens would silently stop finding these. + assertTrue(matches("itcoi", note("Bitcoin is money"))) + assertTrue(matches("BITCOIN", note("bitcoin is money"))) + assertFalse(matches("ethereum", note("bitcoin is money"))) + } + + @Test + fun termsAreAnded() { + // What a relay does with the same string; a single-word query is unaffected either way. + assertTrue(matches("bitcoin money", note("bitcoin is money"))) + assertFalse(matches("bitcoin gold", note("bitcoin is money"))) + } + + @Test + fun tagValuesAreSearchedExceptTheOnesNobodyMeansToSearch() { + assertTrue(matches("subj", note("body", arrayOf(arrayOf("subject", "a subject"))))) + assertTrue(matches("bitcoin", note("body", arrayOf(arrayOf("t", "bitcoin"))))) + // `p`/`e`/`a` hold hex ids, `client` and `alt` hold text the author did not write. + assertFalse(matches("amethyst", note("body", arrayOf(arrayOf("client", "amethyst"))))) + assertFalse(matches("deadbeef", note("body", arrayOf(arrayOf("p", "deadbeef".repeat(8)))))) + } + + @Test + fun indexableFieldsBeyondContentAreSearched() { + // A long-form title lives in a tag, but its summary reaches the matcher through the + // visitor as well — both paths must find it. + val article = note("the body", arrayOf(arrayOf("title", "Lightning"), arrayOf("summary", "a summary")), kind = 30023) + assertTrue(matches("Lightning", article)) + assertTrue(matches("summary", article)) + assertTrue(matches("body", article)) + } + + @Test + fun unsupportedExtensionsAreIgnoredRatherThanTreatedAsTerms() { + // NIP-50: a relay ignores extensions it does not implement. An extensions-only search + // must therefore match everything, never nothing. + assertTrue(EventSearchMatcher("domain:nostr.com").isEmpty) + assertTrue(matches("domain:nostr.com", note("anything"))) + assertTrue(matches("bitcoin language:en", note("bitcoin is money"))) + assertFalse(matches("gold language:en", note("bitcoin is money"))) + } + + @Test + fun oneMatcherAnswersManyEventsConsistently() { + // The visitor is reused across events; a stale term or hit flag would show up here. + val matcher = EventSearchMatcher("lightning") + val hit = note("about lightning", kind = 30023) + val miss = note("about bitcoin", kind = 30023) + repeat(5) { + assertTrue(matcher.match(hit)) + assertFalse(matcher.match(miss)) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFieldVisitorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFieldVisitorTest.kt new file mode 100644 index 0000000000..374fdb4880 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFieldVisitorTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * 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.nip50Search + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The read path ([SearchableEvent.forEachIndexableField]) and the write path + * ([SearchableEvent.indexableContent]) must never disagree about what an event says. + * + * They cannot simply share code — one joins a string because a store wants one, the other refuses + * to build a string at all — so the agreement is pinned here instead. A kind whose visitor drifts + * from its joined content would make local search find things the relay does not, or miss things + * it does, with nothing else to catch it. + */ +class IndexableFieldVisitorTest { + private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + private val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758" + + private fun event( + kind: Int, + tags: Array>, + content: String, + ): Event = EventFactory.create(id, pubkey, 1683596206, kind, tags, content, "") + + /** Every field the visitor hands over, in order, nulls dropped exactly as `listOfNotNull` does. */ + private fun visited(event: SearchableEvent): List { + val out = mutableListOf() + event.forEachIndexableField { field -> + if (field != null) out.add(field) + true + } + return out + } + + private fun assertAgrees( + kind: Int, + tags: Array>, + content: String, + ) { + val e = event(kind, tags, content) + assertTrue(e is SearchableEvent, "kind $kind did not resolve to a SearchableEvent (is it in EventFactory?)") + assertEquals( + e.indexableContent(), + visited(e).joinToString("\n"), + "kind $kind: the visitor's fields do not rejoin to what the stores index", + ) + } + + @Test + fun theKindsThatOverrideTheVisitorRejoinToTheirIndexedContent() { + // A note with a subject, and one without — the null field is where a join drifts. + assertAgrees(1, arrayOf(arrayOf("subject", "A subject")), "the body") + assertAgrees(1, emptyArray(), "the body") + + // title + summary + content, and each subset of it. + listOf(30023, 30818, 30402, 30311).forEach { kind -> + assertAgrees(kind, arrayOf(arrayOf("title", "T"), arrayOf("summary", "S")), "body") + assertAgrees(kind, arrayOf(arrayOf("title", "T")), "body") + assertAgrees(kind, emptyArray(), "body") + } + + // Highlight: comment + context + content. + assertAgrees(9802, arrayOf(arrayOf("comment", "C"), arrayOf("context", "X")), "quoted") + assertAgrees(9802, emptyArray(), "quoted") + + // Community definition: name + description + rules + content. + assertAgrees(34550, arrayOf(arrayOf("name", "N"), arrayOf("description", "D"), arrayOf("rules", "R")), "c") + assertAgrees(34550, emptyArray(), "c") + } + + @Test + fun theDefaultVisitorHandsOverTheJoinedContentUntouched() { + // A kind that does not override still answers the read path correctly, via the default. + val e = event(1111, arrayOf(arrayOf("t", "bitcoin")), "a comment") + assertTrue(e is SearchableEvent) + assertEquals(e.indexableContent(), visited(e).joinToString("\n")) + } + + @Test + fun theWalkStopsAtTheFirstFieldThatSatisfiesIt() { + val e = event(30023, arrayOf(arrayOf("title", "T"), arrayOf("summary", "S")), "body") + assertTrue(e is SearchableEvent) + var seen = 0 + e.forEachIndexableField { + seen++ + // Stop immediately: a hit on the title must not go on to build the body. + false + } + assertEquals(1, seen, "the visitor kept walking after being told to stop") + } +}