mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
feat(quartz): add an allocation-free read path for NIP-50 indexable text
`indexableContent()` is a write-path API. A store calls it once per event on
insert, so joining a string there is free — and 86 of the 126 implementations do
exactly that, via `listOfNotNull(...).joinToString("\n")`: a list, a
StringBuilder and a joined String per call. The JSON-backed kinds are worse;
`MetadataEvent.contactMetaData()` has no cache, so kind 0 reparses its profile
every single call.
Matching a query against the whole cache inverts that cost — once per event per
keystroke — so this adds a read path beside it rather than changing it:
fun interface IndexableFieldVisitor { fun visit(field: String?): Boolean }
fun forEachIndexableField(visitor: IndexableFieldVisitor)
A `fun interface` rather than a lambda parameter, because an interface method
cannot be inline and a lambda written at the call site would allocate per event —
which is the entire thing being avoided. One visitor is built per scan and
carries the term, so the walk allocates nothing and stops at the first field that
matches: a hit on the title never builds the body.
`indexableContent()` is untouched and stays the store contract, so the
externally-mirrored kind table needs no reindex. The default visitor falls back to
it — already free for the ~28 kinds whose indexable content is `content` itself —
and the kinds local search actually scans override it: text notes, long-form,
wiki, highlights, classifieds, live activities and community definitions.
`EventSearchMatcher` matches an event against a NIP-50 search string in memory:
terms ANDed, each a case-insensitive substring of a tag value or an indexable
field, with unsupported extensions ignored per the spec so an extensions-only
search matches everything rather than nothing. Substring because that is what
Amethyst's local search has always done and tokens would silently stop matching
mid-word; AND because that is what a relay does with the same string.
A test pins the two paths together: for every overriding kind, the visitor's
fields rejoined must equal `indexableContent()` byte-for-byte, including the
subsets where a null field is what makes a join drift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
This commit is contained in:
@@ -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<EventIdHint> {
|
||||
val eHints = tags.mapNotNull(MarkedETag::parseAsHint)
|
||||
val qHints = tags.mapNotNull(QTag::parseEventAsHint)
|
||||
|
||||
+9
@@ -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<EventIdHint> {
|
||||
val qHints = tags.mapNotNull(QTag::parseEventAsHint)
|
||||
val nip19Hints = citedNIP19().eventHints()
|
||||
|
||||
+128
@@ -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<String> = DEFAULT_EXCLUDED_TAGS,
|
||||
) {
|
||||
private val terms: Array<String> =
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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<EventIdHint> {
|
||||
val pinnedEvents = pinned()
|
||||
if (pinnedEvents.isEmpty()) return emptyList()
|
||||
|
||||
@@ -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<EventIdHint> {
|
||||
val eHints = tags.mapNotNull(MarkedETag::parseAsHint)
|
||||
val qHints = tags.mapNotNull(QTag::parseEventAsHint)
|
||||
|
||||
+10
@@ -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)
|
||||
|
||||
+9
@@ -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<EventIdHint> {
|
||||
val eHints = tags.mapNotNull(ETag::parseAsHint)
|
||||
val qHints = tags.mapNotNull(QTag::parseEventAsHint)
|
||||
|
||||
+9
@@ -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)
|
||||
|
||||
+108
@@ -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<Array<String>> = 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -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<Array<String>>,
|
||||
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<String> {
|
||||
val out = mutableListOf<String>()
|
||||
event.forEachIndexableField { field ->
|
||||
if (field != null) out.add(field)
|
||||
true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun assertAgrees(
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user