From f5f8f605ec3af3edffe7f4e1edc8eaddf6a97031 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 20:46:38 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01G1vXqHYHWXeni4xim66vvf --- .../references/searchable-kinds.md | 9 +++- .../experimental/trustedLists/README.md | 21 ++++++++ .../trustedLists/TrustedListEvent.kt | 13 ++++- .../trustedLists/TrustedListEventTest.kt | 54 +++++++++++++++++++ .../nip01Core/store/sqlite/SearchTest.kt | 31 +++++++++++ 5 files changed, 125 insertions(+), 3 deletions(-) diff --git a/.claude/skills/searchable-events/references/searchable-kinds.md b/.claude/skills/searchable-events/references/searchable-kinds.md index ccae95ebfb..b025f7fea3 100644 --- a/.claude/skills/searchable-events/references/searchable-kinds.md +++ b/.claude/skills/searchable-events/references/searchable-kinds.md @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/README.md index fd24570c20..efc0ad1af5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/README.md @@ -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. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEvent.kt index a49e416bc2..019d71b7cf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEvent.kt @@ -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() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEventTest.kt index 75a2935eaa..e218745170 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/TrustedListEventTest.kt @@ -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( + 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(event, "kind $kind should be searchable") + assertEquals("Podcaster", event.indexableContent(), "kind $kind should index its title") + } + } + + @Test + fun indexesTheTitleAndNothingElse() { + val event = podcasterList() + assertIs(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(event) + + assertEquals("", event.indexableContent()) + } + @Test fun memberTagsRoundTripThroughTheirWireShape() { assertEquals( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt index 689466b310..7f238e30e6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -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 ->