mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
+21
@@ -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.
|
||||
|
||||
+12
-1
@@ -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()
|
||||
|
||||
|
||||
+54
@@ -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(
|
||||
|
||||
+31
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user