feat(quartz): index Trusted List titles for NIP-50 search

The Trusted List family (30392-30395) shipped with a `title` tag and no
`SearchableEvent`, so a list published as "Podcaster" could not be found by
name -- the only way to reach one was to already know its address. Nothing
recorded that as a decision; the feature commit wired the kinds into
EventFactory and KindNames and never touched search.

Implements SearchableEvent on the TrustedListEvent base, so all four kinds
inherit it, and indexes the title alone:

    override fun indexableContent() = title() ?: ""

Nothing else in the family is human-authored prose. `metric` names a
computation and `d` identifies the list -- machine ids, kept out so a search
for a common word in one doesn't return every list that ran the same job. The
member tags are hex ids and `content` is a JSON echo of the same membership,
so indexing either would put thousands of identifiers into the full-text
index for no lookup a #p/#e/#a/#i filter doesn't already serve better. A list
with no title indexes the empty string rather than throwing, since
indexableContent() runs inside the store's insert transaction.

The kinds are already registered in EventFactory, so the store's kind
pre-filter and the reindex scan pick them up with no further wiring.

Covered by unit tests over all four kinds (including the titleless case) and
a SQLite store test asserting the title is searchable while the metric, the
list id and the membership are not. Documents the indexing rule in the
package README and adds the rows to the searchable-kinds reference table that
external search engines mirror.

Note for existing databases: rows written before this change keep their
missing FTS text until IEventStore.reindexFullTextSearch() runs (`amy store
reindex-fts` drives it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1vXqHYHWXeni4xim66vvf
This commit is contained in:
Claude
2026-08-25 20:46:38 +00:00
parent 58f144f0d1
commit f5f8f605ec
5 changed files with 125 additions and 3 deletions
@@ -2,9 +2,9 @@
Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()`
expression. **Update this file in the same PR as any change to the searchable set or to an
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-04.
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-25.
Counts: 126 concrete classes covering 129 kind values (`GitStatusEvent` spans 4 kinds;
Counts: 130 concrete classes covering 133 kind values (`GitStatusEvent` spans 4 kinds;
kind 30063 has a collision — see the footnote). File paths are under
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
@@ -100,6 +100,10 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 30313 | MeetingRoomEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(title(), summary())` NL |
| 30315 | StatusEvent | nip38UserStatus | `content` |
| 30382 | ContactCardEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content |
| 30392 | UserTrustedListEvent | experimental/trustedLists/users | inherited `TrustedListEvent`: `title() ?: ""` — the label only; `metric`/`d` are machine ids and `content` is a JSON echo of the membership |
| 30393 | EventTrustedListEvent | experimental/trustedLists/events | inherited `TrustedListEvent`: `title() ?: ""` |
| 30394 | AddressableTrustedListEvent | experimental/trustedLists/addressables | inherited `TrustedListEvent`: `title() ?: ""` |
| 30395 | ExternalIdTrustedListEvent | experimental/trustedLists/externalIds | inherited `TrustedListEvent`: `title() ?: ""` |
| 30402 | ClassifiedsEvent | nip99Classifieds | `listOfNotNull(title(), summary(), content)` NL |
| 30617 | GitRepositoryEvent | nip34Git/repository | `listOfNotNull(name(), description(), content)` NL |
| 30620 | WorkflowDefEvent | buzz/workflow | `listOfNotNull(name(), content)` NL |
@@ -150,6 +154,7 @@ declares `KIND = 30063` and implements `SearchableEvent` (`content`), but `Event
| `InteractiveStoryBaseEvent` | `listOfNotNull(title(), summary(), content)` NL | 30296, 30297 |
| `AddressableVideoEvent` | `listOfNotNull(title(), content)` NL | 34235, 34236 |
| `RegularVideoEvent` | `listOfNotNull(title(), content)` NL | 21, 22 |
| `TrustedListEvent` | `title() ?: ""` | 30392, 30393, 30394, 30395 |
## How to regenerate / verify this table
@@ -111,3 +111,24 @@ val template =
minRank(2)
}
```
## Search
`TrustedListEvent` implements `SearchableEvent`, so all four kinds are indexed
for NIP-50 — and they index **`title` alone**:
```kotlin
override fun indexableContent() = title() ?: ""
```
Nothing else in the family is human-authored prose. `metric` is the name of a
computation and `d` is the list identifier — machine ids, kept out so a search
for a common word in one of them doesn't return every list that ran the same
job. The member tags are hex ids and `content` is a JSON echo of the same
membership, so indexing either would put thousands of identifiers into the
full-text index for no lookup a `#p`/`#e`/`#a`/`#i` filter doesn't already
serve better. A list with no `title` indexes the empty string rather than
throwing — `indexableContent()` runs inside the store's insert transaction.
Stores built before this was added keep their old rows unindexed until
`IEventStore.reindexFullTextSearch()` runs.
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
/**
* Base of the Tapestry Trusted List family: an addressable event that
@@ -58,7 +59,17 @@ abstract class TrustedListEvent(
tags: TagArray,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig),
SearchableEvent {
/**
* The list's label, and nothing else -- it is the only human-authored text
* the family carries. `content` is a machine echo of the membership and the
* member tags are hex ids, so neither belongs in a full-text index; `metric`
* and `d` are computation and list identifiers, not prose. Inherited by
* every kind in the family, so all four index the same field.
*/
override fun indexableContent() = title() ?: ""
/** The addressable identity of this list. Deterministic per list. */
fun listId() = dTag()
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.experimental.trustedLists.tags.ListStatus
import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent
import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -397,6 +398,59 @@ class TrustedListEventTest {
assertEquals(1, event.memberCount(), "only the well-formed p tag is a member")
}
private fun listWithTitle(
kind: Int,
title: String?,
): Event =
EventFactory.create<Event>(
id = "00".repeat(32),
pubKey = "a68dbf561cfe3da1b76f1e65c7d4d9cc116f79921b38a815fd75cb5460b4b599",
createdAt = 1_787_253_028L,
kind = kind,
tags =
listOfNotNull(
arrayOf("d", "tl"),
arrayOf("metric", "pinned-tag-membership"),
title?.let { arrayOf("title", it) },
).toTypedArray(),
content = "",
sig = dummySig,
)
@Test
fun everyKindInTheFamilyIsSearchable() {
listOf(
UserTrustedListEvent.KIND,
EventTrustedListEvent.KIND,
AddressableTrustedListEvent.KIND,
ExternalIdTrustedListEvent.KIND,
).forEach { kind ->
val event = listWithTitle(kind, "Podcaster")
assertIs<SearchableEvent>(event, "kind $kind should be searchable")
assertEquals("Podcaster", event.indexableContent(), "kind $kind should index its title")
}
}
@Test
fun indexesTheTitleAndNothingElse() {
val event = podcasterList()
assertIs<SearchableEvent>(event)
// not the metric, not the list id, not the membership, and not the
// JSON echo in content -- none of those are human-authored prose
assertEquals("Podcaster", event.indexableContent())
}
@Test
fun aTitlelessListIndexesEmptyRatherThanThrowing() {
// indexableContent() runs inside the store's insert transaction, where
// a throw would abort the write
val event = listWithTitle(UserTrustedListEvent.KIND, null)
assertIs<SearchableEvent>(event)
assertEquals("", event.indexableContent())
}
@Test
fun memberTagsRoundTripThroughTheirWireShape() {
assertEquals(
@@ -20,6 +20,10 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import com.vitorpamplona.quartz.experimental.trustedLists.metric
import com.vitorpamplona.quartz.experimental.trustedLists.title
import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent
import com.vitorpamplona.quartz.experimental.trustedLists.users.tags.PubKeyMemberTag
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -190,6 +194,33 @@ class SearchTest : BaseDBTest() {
db.assertQuery(repo, Filter(kinds = listOf(GitRepositoryEvent.KIND), search = "uniqdesc"))
}
@Test
fun testTrustedListsAreSearchableByTitleAlone() =
forEachDB { db ->
val memberKey = "b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450"
val list =
signer.sign(
UserTrustedListEvent.build(
listId = "tl-pin-uniqlist",
members = listOf(PubKeyMemberTag(memberKey, score = 99)),
content = "{\"members\":[{\"pubkey\":\"$memberKey\",\"score\":99}]}",
) {
title("uniqpodcaster")
metric("uniqmetric-membership")
},
)
db.store.insertEvent(list)
db.assertQuery(list, Filter(search = "uniqpodcaster"))
db.assertQuery(list, Filter(kinds = listOf(UserTrustedListEvent.KIND), search = "uniqpodcaster"))
// the computation name, the list id and the membership -- tags and
// the JSON echo in content alike -- are machine data, never indexed
db.assertQuery(null, Filter(search = "uniqmetric"))
db.assertQuery(null, Filter(search = "uniqlist"))
db.assertQuery(null, Filter(search = memberKey))
}
@Test
fun testProfileJsonFieldsAreSearchable() =
forEachDB { db ->