mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 07:37:38 +00:00
An audit of all 429 concrete Event classes found 184 that carried pubkey / event / address references in public tags without exposing them through PubKeyHintProvider / EventHintProvider / AddressHintProvider (or exposed only some), and 72 that held human-written text without indexing it. Hint providers - New shared helpers: zap splits (TagArray.zapSplitHints/zapSplitPubKeys, now used by text notes, comments, long-form, chat messages, zap goals, classifieds, public messages, music tracks), NIP-71 video (P/E/A once on RegularVideoEvent/AddressableVideoEvent), NIP-90 (new DvmRequestEvent / DvmResponseEvent bases for all request/result/feedback kinds), chess (OpponentTag on BaseChessEvent), trusted lists (on TrustedListEvent). - Providers added or completed for gift wraps (recipient relay), 10040 trust-provider lists (service key + card relay), voice replies, threads, polls, pictures, torrents, user status, NIP-29 group kinds, NIP-51 lists with `a` pointers, mute-list threads, live-activity goals, zap receipts (P sender, zap-request author), `q` tags on chat/git status/classifieds/ public messages, the experimental and Buzz kinds, and more. - RelayUrlNormalizer.normalizeHintOrNull: hint parsers take a relay only when the slot holds a ws(s):// URL. normalizeOrNull turns any bare word into wss://<word>/, so a pubkey or label in a relay slot became a fake relay in the hint index and broadcast set. Applied to every new or newly wired parser, including the shared ATag.parseAsHint (a bare host there no longer counts as a hint, matching PTag/ETag). - New linkedAddressIds() only return well-formed kind:pubkey:d ids. - DraftWrapEvent is deliberately NOT a provider: drafts arrive from the private-storage relay, which would otherwise be recorded as a hint for the public events a draft references. Bugs fixed - NIP-22 RootAddressTag/ReplyAddressTag.parseAsHint required a 64-char value, so no `A`/`a` address ever produced a hint. - ZapSplitSetupSerializer dropped the empty relay slot, writing ["zap", pk, weight]; the parser then read weight 0 and discarded the split. The slot is kept now and the legacy 3-slot shape still parses. - EmojiPackEvent (30030) indexed its NIP-44 ciphertext `content`; it now indexes the public emoji shortcodes. - FeedDefinitionEvent (31890) and EncryptionKeyListEvent (10044) were not registered in EventFactory (31890's search text was unreachable). NIP-50 indexing - 42 kinds newly searchable (SearchableKinds.ALL, golden file, and searchable-kinds.md updated); indexableContent() extended on ~35 more (topics/labels, locations, subjects, segment titles, citation authors, option labels, file captions, ...). SearchFieldExtractor branches kept in sync; `t`/`location` stay with its funnel. Existing stores need IEventStore.reindexFullTextSearch() to pick these up. - RenderableKindsTest lists the new kinds as searchable-but-not-shown. Parsed-JSON caches added where the new hint/search paths decoded content on every call (Buzz personas/agents/teams/summaries/system messages, NIP-15 marketplace, bid confirmations, code-snippet and geocache naddrs). Left out on purpose: NIP-85 assertion subjects held only in `d` (linking them would spread score-relay hints to every subject), AppDefinitionEvent's over-inclusive indexing (policy), ReportEvent comments, NIP-19 citations in Buzz forum/stream content. Tests: ~110 new test classes; quartz jvmTest and commons jvmTest green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrXSz9uRnNLVru3AvD3wSa
125 lines
7.7 KiB
Markdown
125 lines
7.7 KiB
Markdown
---
|
||
name: searchable-events
|
||
description: The NIP-50 indexing surface of Quartz — the `SearchableEvent` interface, which event kinds are searchable, exactly what text each kind's `indexableContent()` contributes, how the SQLite/filesystem stores consume it, and the NIP-50 `SearchQuery` extension grammar plus `SearchRelayListEvent` (kind 10007). Use when making a kind searchable, changing what a kind indexes, diffing the searchable set at a Quartz version bump (external search engines mirror this table), debugging why an event is or isn't found by search, or working with search extensions (`include:spam`, `domain:`, …).
|
||
---
|
||
|
||
# Searchable Events — the NIP-50 indexing surface
|
||
|
||
## The contract
|
||
|
||
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt`:
|
||
|
||
```kotlin
|
||
interface SearchableEvent {
|
||
fun indexableContent(): String
|
||
}
|
||
```
|
||
|
||
One method; marker and extractor in one. An event kind is searchable **iff** its event class
|
||
implements this interface **and** the class is wired into `EventFactory` (the stores probe
|
||
searchability by kind through `EventFactory.create` — an unwired implementor is invisible).
|
||
|
||
Rules every implementation follows (keep them when adding one):
|
||
|
||
- **Plain text out.** Return the human-meaningful fields joined with `"\n"` (a handful of
|
||
metadata-ish kinds use `" "`); no markup stripping is performed — markdown/asciidoc content
|
||
goes in raw, JSON-content kinds (kind 0 metadata, marketplace stalls, channel info) **parse
|
||
first and join the extracted fields**, never the raw JSON.
|
||
- **Never throw, never null.** There is no defensive wrapper at any call site; a throw aborts
|
||
the insert transaction. Parsed-JSON implementations use `?.let { … } ?: ""`.
|
||
- **Only public data.** Encrypted content stays out (e.g. kind 30382 contact cards index only
|
||
the public petname/summary/topics, never the NIP-44 payload; kind 30030 emoji packs index the
|
||
public `emoji` shortcodes, never the NIP-44 private-tag ciphertext in `content`).
|
||
- Typical shapes: `content` alone (~57 kinds); `listOfNotNull(title(), content)`;
|
||
`listOfNotNull(title(), summary(), content)`; lists index `title() + description()`.
|
||
|
||
## The full kind table
|
||
|
||
**`references/searchable-kinds.md`** in this skill holds the authoritative table — every
|
||
implementor with its kind number, class, and the exact `indexableContent()` expression
|
||
(191 concrete classes / 192 kind values as of 2026-10; the kind set is exactly
|
||
`nip50Search/SearchableKinds.kt`'s `ALL`). Diff that file at a version bump to
|
||
answer "did the searchable set or any kind's indexed text change?".
|
||
|
||
Notables that surprise people:
|
||
|
||
- **Kind 9735 (zap receipt) indexes the embedded zap request's content**
|
||
(`zapRequest?.content.orEmpty()`) — receipts are searchable by the zapper's comment.
|
||
- **Kind 0 / 31990** index many profile fields space-joined (name, about, nip05, lud16,
|
||
website, picture URL, …).
|
||
- **Kind 30063** (`ReleaseArtifactSetEvent`) serves both NIP-51 release artifact sets and NIP-82
|
||
software releases: it indexes `title()\ndescription()`, plus the release notes in `content` only
|
||
for NIP-82 releases (NIP-51 `content` can be encrypted private items).
|
||
- Poll kinds (1068, 6969) append each option label on its own line.
|
||
|
||
## MANDATORY maintenance when you touch this surface
|
||
|
||
Adding `SearchableEvent` to a kind, removing it, or changing any `indexableContent()` body:
|
||
|
||
1. **Update `references/searchable-kinds.md`** in the same PR (external search engines — e.g.
|
||
the Vespa-backed store's `SearchExtractors` — mirror this table at pin bumps; a silent
|
||
change ships them stale search results).
|
||
2. **Remember existing databases don't reindex themselves.** Old rows keep their old (or
|
||
missing) FTS text until `IEventStore.reindexFullTextSearch()` runs — the KDoc on that method
|
||
is the contract. App-side, schedule the resumable overload after shipping such a change.
|
||
3. New implementors must be **registered in `EventFactory`** or the reindex scan and kind
|
||
pre-filter (`FullTextSearchModule.isSearchableKind`) will never see them, and listed in
|
||
`SearchableKinds.ALL` (`SearchableKindsTest` fails otherwise); any body change also moves
|
||
`jvmTest/resources/indexable-content.golden` (`IndexableContentGoldenTest`).
|
||
|
||
Eligibility policy: a kind becomes searchable when it carries human-authored, human-meaningful
|
||
text (titles, bodies, names, descriptions). Pure-machine kinds (reactions, follow lists, zaps
|
||
minus their comment, relay lists) stay out to keep the index small.
|
||
|
||
## How the stores consume it
|
||
|
||
**SQLite** (`nip01Core/store/sqlite/FullTextSearchModule.kt`):
|
||
`CREATE VIRTUAL TABLE event_fts USING fts5(content, content='', contentless_delete=1)` —
|
||
contentless, `rowid` = `event_headers.row_id`, an `AFTER DELETE` trigger keeps it in sync. On
|
||
insert (when FTS is on and not deferred): `if (event is SearchableEvent)` → bind
|
||
`event.indexableContent()` — the only method ever called. Tokenization is entirely SQLite's
|
||
default FTS5 `unicode61`; queries are always a bound `event_fts MATCH ?` (never concatenated),
|
||
ordered by bm25 `rank` then `created_at DESC`. Query-side semantics (relevance ordering,
|
||
extension stripping, FTS-off behavior, deferred catch-up) are rules STORE-S01…S06 in the
|
||
`event-store-semantics` skill.
|
||
|
||
**Filesystem store** (`jvmMain/.../store/fs/FsIndexer.kt` + `FsSearchTokenizer.kt`): tokenizes
|
||
`indexableContent()` itself, approximating `unicode61` (split on non-letter/digit, lowercase);
|
||
the same tokenizer runs on queries so drift cancels.
|
||
|
||
## NIP-50 client side
|
||
|
||
**`SearchQuery`** (`nip50Search/SearchQuery.kt`) — typed parse of the `search` filter string
|
||
into `terms` + `extensions`. A whitespace token is an extension iff it looks like
|
||
`lowercasekey:value` (the value not starting with `//`, so URLs stay free text); duplicate keys
|
||
keep the last; unknown extensions are preserved (`extension(key)`). Typed accessors:
|
||
`includeSpam`, `domain`, `language`, `sentiment`, `nsfw`. `stripExtensions()` /
|
||
`Filter.strippingSearchExtensions()` is the bridge the built-in stores use — unsupported
|
||
extensions are **ignored** (NIP-50), so an extensions-only search collapses to an unconstrained
|
||
query, never match-nothing. A server-side store that implements its own extensions
|
||
(`observer:`, `sort:rank`, …) receives the raw string (see the `IEventStore` KDoc) and should
|
||
parse with `SearchQuery.parse` so its syntax stays compatible with what clients send.
|
||
|
||
**`SearchRelayListEvent`** — **kind 10007**, the user's search-relay list (NIP-51-style, public
|
||
tags + NIP-44 private tags; *not* a `SearchableEvent` itself). Client consumption:
|
||
`commons/.../actions/SearchActions.kt`, bootstrap defaults in
|
||
`commons/.../account/AccountBootstrapEvents.kt`.
|
||
|
||
Don't confuse it with `commons/.../commons/search/SearchQuery.kt` — an app-level local-feed
|
||
query model (authors/kinds/hashtags/or-terms), unrelated to the NIP-50 wire string.
|
||
|
||
## Tests (executable spec)
|
||
|
||
- `commonTest/.../nip50Search/SearchQueryTest.kt` — the extension grammar, token by token.
|
||
- `commonTest/.../nip50Search/SearchableKindsTest.kt` — sweeps kinds 0–65535 through
|
||
`EventFactory`; the searchable set must equal `SearchableKinds.ALL`.
|
||
- `jvmTest/.../nip50Search/IndexableContentGoldenTest.kt` — each searchable kind's
|
||
`indexableContent()` for a tag-rich sample, pinned in `jvmTest/resources/indexable-content.golden`.
|
||
- `commonTest/.../store/sqlite/SearchTest.kt` — per-kind indexing (kind 0 profile fields,
|
||
40/41 channel JSON, 31924/30617), extension-token ignoring, reindex/resumable-reindex,
|
||
FTS cleanup on replaceable rotation.
|
||
- `commonTest/.../store/sqlite/SearchRelevanceOrderTest.kt` — bm25-before-recency ordering,
|
||
limit-after-score, multi-filter rank union.
|
||
- `commonTest/.../store/sqlite/NoFullTextSearchTest.kt` — FTS-off contract.
|
||
- `jvmTest/.../store/fs/FsSearchTest.kt` — tokenizer parity for the filesystem store.
|