diff --git a/src/components/SearchBar/index.tsx b/src/components/SearchBar/index.tsx index f1f024b6..214fa1b5 100644 --- a/src/components/SearchBar/index.tsx +++ b/src/components/SearchBar/index.tsx @@ -98,7 +98,14 @@ const SearchBar = forwardRef< if (params.type === 'note') { // Prime event cache so note page finds it without re-fetch - eventService.fetchEvent(params.search).then((ev) => { if (ev) eventService.addEventToCache(ev) }).catch(() => {}) + eventService + .fetchEvent(params.search) + .then((ev) => { + if (!ev) return + const hex = /^[0-9a-f]{64}$/i.test(ev.id) ? ev.id.toLowerCase() : undefined + eventService.addEventToCache(ev, hex ? { explicitNoteLookupHexId: hex } : undefined) + }) + .catch(() => {}) navigateToNote(toNote(params.search)) } else if (params.type === 'hashtag') { navigateToHashtag(toNoteList({ hashtag: params.search })) diff --git a/src/components/SearchResult/FullTextSearchByRelay.tsx b/src/components/SearchResult/FullTextSearchByRelay.tsx new file mode 100644 index 00000000..6ae96c13 --- /dev/null +++ b/src/components/SearchResult/FullTextSearchByRelay.tsx @@ -0,0 +1,353 @@ +import NoteCard from '@/components/NoteCard' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { compareEventsForDTagQuery } from '@/lib/dtag-search' +import logger from '@/lib/logger' +import { cn } from '@/lib/utils' +import { normalizeUrl } from '@/lib/url' +import client from '@/services/client.service' +import { relayHostForSubscribeLog } from '@/services/relay-operation-log.service' +import type { Event, Filter } from 'nostr-tools' +import { Loader2 } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +/** One-shot NIP-50 REQ per relay; bounded wait so the page always reaches a terminal state. */ +const FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS = 10_000 +/** Avoid opening every index relay at once (pool + main thread); still completes all relays. */ +const FULL_TEXT_SEARCH_RELAY_CONCURRENCY = 3 +const FULL_TEXT_SEARCH_PER_RELAY_LIMIT = 80 +/** Cap rows per card so a hot relay cannot mount hundreds of {@link NoteCard}s at once. */ +const FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY = 40 + +type RelayCardPhase = 'loading' | 'done' | 'error' + +type RelayCardModel = { + relayUrl: string + host: string + phase: RelayCardPhase + events: Event[] + ms?: number + errorMessage?: string +} + +function normalizeRelayList(urls: readonly string[]): string[] { + return Array.from( + new Set(urls.map((u) => normalizeUrl(u) || u.trim()).filter((u): u is string => u.length > 0)) + ).sort((a, b) => relayHostForSubscribeLog(a).localeCompare(relayHostForSubscribeLog(b))) +} + +/** Console hint: what this one-shot outcome suggests about NIP-50 (never proof without NIP-11). */ +function nip50OutcomeHint(args: { + phase: 'done' | 'error' + rawCount: number + connectionError?: string +}): string { + if (args.phase === 'error') { + return 'no_transport_or_relay_closed_request — cannot tell NIP-50 from this run' + } + if (args.rawCount > 0) { + return 'returned_events_for_REQ_with_search_field — relay likely honors NIP-50 for this query (verify with NIP-11 supported_nips)' + } + if (args.connectionError) { + return 'zero_events_but_connection_error_message — partial failure or restrictive CLOSE; NIP-50 unclear' + } + return 'zero_events_clean_close — no_hits_or_search_ignored_or_empty_index — cannot distinguish without NIP-11 or a known match' +} + +export default function FullTextSearchByRelay({ + searchQuery, + relayUrls, + kinds +}: { + searchQuery: string + relayUrls: readonly string[] + kinds: readonly number[] +}) { + const { t } = useTranslation() + const runGeneration = useRef(0) + const [cards, setCards] = useState([]) + + const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls]) + + const q = searchQuery.trim() + const timeoutSec = Math.round(FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS / 1000) + + useEffect(() => { + const myRun = ++runGeneration.current + if (!q || normalizedRelays.length === 0) { + setCards([]) + return + } + + /** React 18 Strict Mode (dev) mounts twice; bump invalidates the previous run’s workers and ignores stale fetches. */ + const cleanupInvalidatePreviousRun = () => { + runGeneration.current += 1 + } + + const filter: Filter = { + search: q, + kinds: [...kinds], + limit: FULL_TEXT_SEARCH_PER_RELAY_LIMIT + } + + const poolSize = Math.min(FULL_TEXT_SEARCH_RELAY_CONCURRENCY, normalizedRelays.length) + + setCards( + normalizedRelays.map((relayUrl) => ({ + relayUrl, + host: relayHostForSubscribeLog(relayUrl), + phase: 'loading', + events: [] + })) + ) + + let relayCursor = 0 + const nextRelayUrl = (): string | undefined => { + if (relayCursor >= normalizedRelays.length) return undefined + return normalizedRelays[relayCursor++]! + } + + const runOneRelay = async (relayUrl: string) => { + const host = relayHostForSubscribeLog(relayUrl) + logger.debug('[NIP-50 full-text] card_begin', { + runId: myRun, + relayUrl, + host, + timeoutMs: FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS, + filter: { search: filter.search, kinds: filter.kinds, limit: filter.limit } + }) + + const t0 = performance.now() + try { + const { events: raw, connectionError } = await client.fetchEventsFromSingleRelay( + relayUrl, + filter, + { globalTimeout: FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS } + ) + if (myRun !== runGeneration.current) return + + const sorted = [...raw].sort((a, b) => compareEventsForDTagQuery(q, a, b)).slice(0, FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY) + for (const e of sorted) { + client.addEventToCache(e, { explicitNoteLookupHexId: e.id }) + } + + const ms = Math.round(performance.now() - t0) + if (sorted.length === 0 && connectionError) { + logger.debug('[NIP-50 full-text] card_end', { + runId: myRun, + relayUrl, + host, + phase: 'error' as const, + ms, + eventCountRaw: raw.length, + eventCountShown: 0, + connectionError, + cardErrorMessage: connectionError, + nip50Hint: nip50OutcomeHint({ phase: 'error', rawCount: 0, connectionError }) + }) + setCards((prev) => + prev.map((c) => + c.relayUrl === relayUrl + ? { + ...c, + phase: 'error', + events: [], + ms, + errorMessage: connectionError + } + : c + ) + ) + return + } + + logger.debug('[NIP-50 full-text] card_end', { + runId: myRun, + relayUrl, + host, + phase: 'done' as const, + ms, + eventCountRaw: raw.length, + eventCountShown: sorted.length, + connectionError: sorted.length > 0 ? undefined : connectionError, + cardNote: + sorted.length === 0 && connectionError + ? 'UI shows soft warning (empty with message)' + : sorted.length === 0 + ? 'UI empty state' + : 'UI lists notes', + nip50Hint: nip50OutcomeHint({ + phase: 'done', + rawCount: raw.length, + connectionError: sorted.length > 0 ? undefined : connectionError + }) + }) + + setCards((prev) => + prev.map((c) => + c.relayUrl === relayUrl + ? { + ...c, + phase: 'done', + events: sorted, + ms, + errorMessage: sorted.length > 0 ? undefined : connectionError + } + : c + ) + ) + } catch (err) { + if (myRun !== runGeneration.current) return + const msg = err instanceof Error ? err.message : String(err) + const ms = Math.round(performance.now() - t0) + logger.debug('[NIP-50 full-text] card_end', { + runId: myRun, + relayUrl, + host, + phase: 'error' as const, + ms, + eventCountRaw: 0, + eventCountShown: 0, + connectionError: undefined, + cardErrorMessage: msg, + nip50Hint: nip50OutcomeHint({ phase: 'error', rawCount: 0 }) + }) + setCards((prev) => + prev.map((c) => + c.relayUrl === relayUrl + ? { + ...c, + phase: 'error', + events: [], + ms, + errorMessage: msg + } + : c + ) + ) + } + } + + const worker = async () => { + while (myRun === runGeneration.current) { + const relayUrl = nextRelayUrl() + if (!relayUrl) break + await runOneRelay(relayUrl) + } + } + + void (async () => { + logger.debug('[NIP-50 full-text] wave_begin', { + runId: myRun, + query: q, + relayCount: normalizedRelays.length, + concurrency: poolSize, + filter: { search: filter.search, kinds: filter.kinds, limit: filter.limit }, + relays: normalizedRelays.map((u) => ({ url: u, host: relayHostForSubscribeLog(u) })) + }) + try { + await Promise.all(Array.from({ length: poolSize }, () => worker())) + } catch { + /* runOneRelay already updates card errors */ + } + if (myRun !== runGeneration.current) return + logger.debug('[NIP-50 full-text] wave_end', { + runId: myRun, + relayCount: normalizedRelays.length, + note: 'matches UI "all relays finished" when every card is done or error' + }) + })() + + return cleanupInvalidatePreviousRun + }, [q, normalizedRelays, kinds]) + + const allTerminal = + cards.length > 0 && cards.every((c) => c.phase === 'done' || c.phase === 'error') + const anyLoading = cards.some((c) => c.phase === 'loading') + + if (!q) { + return null + } + + return ( +
+

+ {t('Full-text search per relay intro', { + relayCount: normalizedRelays.length, + seconds: timeoutSec, + concurrency: FULL_TEXT_SEARCH_RELAY_CONCURRENCY + })} +

+ +
+ {cards.map((c) => ( + + +
+ {c.host} + {c.phase === 'loading' ? ( + + ) : ( + + {c.events.length} + + )} +
+ {c.relayUrl} + {c.phase === 'done' && c.ms != null && ( +

+ {t('Full-text search relay timing', { ms: c.ms })} +

+ )} +
+ + {c.phase === 'loading' && ( +
+ + + +
+ )} + {c.phase === 'error' && ( +

+ {t('Full-text search relay error')}: {c.errorMessage ?? t('Full-text search relay unknown error')} +

+ )} + {c.phase === 'done' && c.events.length === 0 && !c.errorMessage && ( +

{t('Full-text search relay no hits')}

+ )} + {c.phase === 'done' && c.events.length === 0 && c.errorMessage && ( +

{c.errorMessage}

+ )} + {c.events.length > 0 && ( +
    + {c.events.map((ev) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ))} +
+ + {allTerminal && ( +

+ {t('Full-text search all relays finished')} +

+ )} +
+ ) +} diff --git a/src/components/SearchResult/index.tsx b/src/components/SearchResult/index.tsx index f1a8dc27..cafe7b01 100644 --- a/src/components/SearchResult/index.tsx +++ b/src/components/SearchResult/index.tsx @@ -1,7 +1,7 @@ import { FAST_READ_RELAY_URLS, NIP_SEARCH_PAGE_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants' -import { compareEventsForDTagQuery } from '@/lib/dtag-search' import { TSearchParams } from '@/types' import NormalFeed from '../NormalFeed' +import FullTextSearchByRelay from './FullTextSearchByRelay' import Profile from '../Profile' import { ProfileListBySearch } from '../ProfileListBySearch' import Relay from '../Relay' @@ -32,7 +32,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa [searchableUrls] ) - // User stack + defaults (full list for second subRequest; excludes searchable URLs to avoid duplicate sockets) + // User stack + defaults (hashtag search uses the non-searchable slice as a second shard) const combinedRelays = useMemo(() => { let relays: string[] = [] @@ -75,24 +75,11 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa return } if (searchParams.type === 'notes') { - const notesFilter = { - search: searchParams.search, - kinds: [...NIP_SEARCH_PAGE_KINDS], - limit: 100 - } - const subRequests = [ - { urls: searchableUrls, filter: notesFilter }, - ...(nonSearchableRelays.length > 0 ? [{ urls: nonSearchableRelays, filter: notesFilter }] : []) - ] return ( - compareEventsForDTagQuery(searchParams.search, a, b)} + ) } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1bfef33d..ec3eec6e 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1851,6 +1851,14 @@ export default { "Search threads by title, content, tags, npub, author...": "Search threads by title, content, tags, npub, author...", "Searching all available relays...": "Searching all available relays...", "Searching…": "Searching…", + "Full-text search per relay intro": + "Each card runs one bounded NIP-50 query on that index relay ({{relayCount}} relays, {{seconds}}s timeout each, up to {{concurrency}} in parallel so the tab stays responsive). This is not a live feed — results do not auto-update.", + "Full-text search relay querying": "Querying relay…", + "Full-text search relay timing": "Finished in {{ms}} ms", + "Full-text search relay no hits": "No hits on this relay.", + "Full-text search relay error": "Query failed", + "Full-text search relay unknown error": "Unknown error", + "Full-text search all relays finished": "All relay queries have finished.", "See reference": "See reference", "Select Group": "Select Group", "Select Media Type": "Select Media Type", diff --git a/src/lib/event-ingest-filter.ts b/src/lib/event-ingest-filter.ts index 949e7654..ddf9dfe2 100644 --- a/src/lib/event-ingest-filter.ts +++ b/src/lib/event-ingest-filter.ts @@ -31,7 +31,44 @@ function isIncompleteRelayReviewIngest(event: NEvent): boolean { return !getRelayUrlFromRelayReviewEvent(event) } -/** Single gate for subscribe/cache/IDB read paths: drop kind-1 JSON-object spam and malformed relay reviews. */ -export function shouldDropEventOnIngest(event: NEvent): boolean { - return isStringifiedJsonObjectContentNostrEvent(event) || isIncompleteRelayReviewIngest(event) +/** + * Kacti-style kind-1 “broadcast” payloads (non-human notes that flood index relays). Not valid Nostr discussion text. + * Dropped from timelines, search, and prefetch; still loadable when the user opens that exact id (hex / note1 / nevent). + */ +function isKactiBroadcastSpamKind1(event: Pick): boolean { + if (event.kind !== kinds.ShortTextNote) return false + const c = typeof event.content === 'string' ? event.content.trimStart() : '' + return c.startsWith('[broadcast:[#') +} + +export type ShouldDropEventOnIngestOptions = { + /** + * When set to the same 64-char hex as {@link NEvent.id} (lowercase), {@link isKactiBroadcastSpamKind1} does not apply + * so `fetchEvent` / direct note views can still show the payload. + */ + explicitNoteLookupHexId?: string +} + +function explicitLookupMatchesEvent(eventId: string, lookup?: string): boolean { + if (!lookup) return false + const l = lookup.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(l)) return false + return eventId.toLowerCase() === l +} + +/** + * Single gate for subscribe/cache/IDB read paths: drop kind-1 JSON-object spam, Kacti broadcast spam, + * and malformed relay reviews. Optional {@link ShouldDropEventOnIngestOptions} relaxes Kacti drops for explicit id fetch. + */ +export function shouldDropEventOnIngest( + event: NEvent, + options?: ShouldDropEventOnIngestOptions +): boolean { + if (isIncompleteRelayReviewIngest(event)) return true + if (isStringifiedJsonObjectContentNostrEvent(event)) return true + if (isKactiBroadcastSpamKind1(event)) { + if (explicitLookupMatchesEvent(event.id, options?.explicitNoteLookupHexId)) return false + return true + } + return false } diff --git a/src/pages/secondary/NotePage/NotFound.tsx b/src/pages/secondary/NotePage/NotFound.tsx index c703d96e..f8c4e796 100644 --- a/src/pages/secondary/NotePage/NotFound.tsx +++ b/src/pages/secondary/NotePage/NotFound.tsx @@ -171,7 +171,10 @@ export default function NotFound({ if (idHex) { const fromDb = await indexedDb.getEventFromPublicationStore(idHex) if (fromDb) { - client.addEventToCache(fromDb) + client.addEventToCache( + fromDb, + idHex ? { explicitNoteLookupHexId: idHex.toLowerCase() } : undefined + ) onEventFound?.(fromDb) found = true logger.info('Event found in IndexedDB (NotFound try-harder)', { bech32Id }) @@ -199,7 +202,8 @@ export default function NotFound({ if (event) { logger.info('Event found on external relay (NotFound)', { bech32Id, hexEventId }) - client.addEventToCache(event) + const hex = idHex ?? (event.id && /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : undefined) + client.addEventToCache(event, hex ? { explicitNoteLookupHexId: hex } : undefined) onEventFound?.(event) found = true } else { diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 9877b023..21d414b7 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -600,7 +600,10 @@ function ParentNote({ const navigate = useCallback( (e: MouseEvent) => { e.stopPropagation() - if (event) client.addEventToCache(event) + if (event) { + const hex = /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : undefined + client.addEventToCache(event, hex ? { explicitNoteLookupHexId: hex } : undefined) + } navigateToNote( toNote(event ?? eventBech32Id), event, diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 1b1b6afe..20effecb 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -35,7 +35,7 @@ import { import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config' import { isCalendarEventKind } from '@/lib/calendar-event' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' -import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' +import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter' import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { normalizeUrl } from '@/lib/url' @@ -151,11 +151,14 @@ export class EventService { return null } - /** Returns cached event or undefined; evicts stringified-JSON-object spam from the session LRU. */ - private getSessionEventIfAllowed(hexId: string): NEvent | undefined { + /** Returns cached event or undefined; evicts ingest-blocked events from the session LRU. */ + private getSessionEventIfAllowed(hexId: string, forExplicitNoteIdLookup = false): NEvent | undefined { const e = this.sessionEventCache.get(hexId) if (!e) return undefined - if (shouldDropEventOnIngest(e)) { + const ingestOpts: ShouldDropEventOnIngestOptions | undefined = forExplicitNoteIdLookup + ? { explicitNoteLookupHexId: hexId } + : undefined + if (shouldDropEventOnIngest(e, ingestOpts)) { this.sessionEventCache.delete(hexId) return undefined } @@ -223,7 +226,7 @@ export class EventService { const trimmed = noteId.trim() const hex = this.resolveHexWaiterKey(trimmed) if (hex) { - return this.getSessionEventIfAllowed(hex) + return this.getSessionEventIfAllowed(hex, true) } try { const { type, data } = nip19.decode(trimmed) @@ -247,7 +250,7 @@ export class EventService { subscribeWhenSessionHasEvent(eventId: string, callback: () => void): () => void { const hex = this.resolveHexWaiterKey(eventId) if (hex) { - if (this.getSessionEventIfAllowed(hex)) { + if (this.getSessionEventIfAllowed(hex, true)) { queueMicrotask(() => callback()) /** Already in cache: do not register a waiter — the next {@link addEventToCache} would notify again and double-fire embeds / useFetchEvent. */ return () => {} @@ -338,17 +341,18 @@ export class EventService { } } if (hexId) { - const fromSession = this.getSessionEventIfAllowed(hexId) + const fromSession = this.getSessionEventIfAllowed(hexId, true) if (fromSession) return fromSession const cachedPromise = this.eventCacheMap.get(hexId) if (cachedPromise) { const resolved = await cachedPromise - if (resolved && !shouldDropEventOnIngest(resolved)) return resolved - const fromSessionAfterMiss = this.getSessionEventIfAllowed(hexId) + if (resolved && !shouldDropEventOnIngest(resolved, { explicitNoteLookupHexId: hexId })) + return resolved + const fromSessionAfterMiss = this.getSessionEventIfAllowed(hexId, true) if (fromSessionAfterMiss) return fromSessionAfterMiss const fromDb = await indexedDb.getEventFromPublicationStore(hexId) - if (fromDb && !shouldDropEventOnIngest(fromDb)) { - this.addEventToCache(fromDb) + if (fromDb && !shouldDropEventOnIngest(fromDb, { explicitNoteLookupHexId: hexId })) { + this.addEventToCache(fromDb, { explicitNoteLookupHexId: hexId }) return fromDb } // Prior load() finished with undefined but left the promise in cacheMap — never retrying. @@ -357,14 +361,18 @@ export class EventService { } if (opts?.relayHints?.length || pointerHasFetchHints) { const hinted = await this._fetchEvent(trimmed, opts?.relayHints) - if (hinted && !shouldDropEventOnIngest(hinted)) return hinted + if ( + hinted && + !shouldDropEventOnIngest(hinted, hexId ? { explicitNoteLookupHexId: hexId } : undefined) + ) + return hinted } const loaded = await this.eventDataLoader.load(hexId ?? trimmed) if (hexId) { - const fromSessionAfter = this.getSessionEventIfAllowed(hexId) + const fromSessionAfter = this.getSessionEventIfAllowed(hexId, true) if (fromSessionAfter) return fromSessionAfter } - if (loaded && shouldDropEventOnIngest(loaded)) { + if (loaded && shouldDropEventOnIngest(loaded, hexId ? { explicitNoteLookupHexId: hexId } : undefined)) { return undefined } return loaded @@ -491,6 +499,10 @@ export class EventService { return undefined } + const ingestOpts: ShouldDropEventOnIngestOptions | undefined = + filter.ids?.length === 1 && /^[0-9a-f]{64}$/i.test(String(filter.ids[0])) + ? { explicitNoteLookupHexId: String(filter.ids[0]).toLowerCase() } + : undefined const logKey = 'ids' in filter && filter.ids?.[0] ? filter.ids[0].slice(0, 8) @@ -521,7 +533,7 @@ export class EventService { }) const usable = events - .filter((e) => !shouldDropEventOnIngest(e)) + .filter((e) => !shouldDropEventOnIngest(e, ingestOpts)) .sort((a, b) => b.created_at - a.created_at) return usable[0] } @@ -529,8 +541,8 @@ export class EventService { /** * Add event to session cache */ - addEventToCache(event: NEvent): void { - if (shouldDropEventOnIngest(event)) return + addEventToCache(event: NEvent, ingestOpts?: ShouldDropEventOnIngestOptions): void { + if (shouldDropEventOnIngest(event, ingestOpts)) return const cleanEvent = { ...event } delete (cleanEvent as any).relayStatuses // REQ filters and nip19 decode use lowercase hex; some relays/clients emit uppercase ids. @@ -1116,12 +1128,22 @@ export class EventService { if (!filter) return undefined + const ingestHexForIdFetch = + filter.ids?.length === 1 && + typeof filter.ids[0] === 'string' && + /^[0-9a-f]{64}$/i.test(filter.ids[0]) + ? filter.ids[0].toLowerCase() + : undefined + const ingestOpts: ShouldDropEventOnIngestOptions | undefined = ingestHexForIdFetch + ? { explicitNoteLookupHexId: ingestHexForIdFetch } + : undefined + if (filter.ids?.length === 1) { const hid = filter.ids[0]!.toLowerCase() if (/^[0-9a-f]{64}$/.test(hid)) { const fromArchive = await loadArchivedEventForFetch(hid) - if (fromArchive && !shouldDropEventOnIngest(fromArchive)) { - this.addEventToCache(fromArchive) + if (fromArchive && !shouldDropEventOnIngest(fromArchive, ingestOpts)) { + this.addEventToCache(fromArchive, ingestOpts) return fromArchive } } @@ -1130,8 +1152,8 @@ export class EventService { // Try cache first if (filter.ids?.length) { const cached = await indexedDb.getEventFromPublicationStore(filter.ids[0]) - if (cached && !shouldDropEventOnIngest(cached)) { - this.addEventToCache(cached) + if (cached && !shouldDropEventOnIngest(cached, ingestOpts)) { + this.addEventToCache(cached, ingestOpts) // Extract relay hints from cached event's tags (e, a, q tags) const eventRelayHints = this.extractRelayHintsFromEvent(cached) if (eventRelayHints.length > 0) { @@ -1144,8 +1166,8 @@ export class EventService { // Try big relays first (uses user's inboxes + defaults) if (filter.ids?.length) { const event = await this.fetchEventFromBigRelaysDataloader.load(filter.ids[0]) - if (event && !shouldDropEventOnIngest(event)) { - this.addEventToCache(event) + if (event && !shouldDropEventOnIngest(event, ingestOpts)) { + this.addEventToCache(event, ingestOpts) // Extract relay hints from found event's tags (e, a, q tags) const eventRelayHints = this.extractRelayHintsFromEvent(event) if (eventRelayHints.length > 0) { @@ -1156,9 +1178,9 @@ export class EventService { } // Always try comprehensive relay list (author's outboxes + user's inboxes + hints + seen + defaults) - const event = await this.tryHarderToFetchEvent(relays, filter, true, authorHintPubkey) - if (event && !shouldDropEventOnIngest(event)) { - this.addEventToCache(event) + const event = await this.tryHarderToFetchEvent(relays, filter, true, authorHintPubkey, ingestOpts) + if (event && !shouldDropEventOnIngest(event, ingestOpts)) { + this.addEventToCache(event, ingestOpts) return event } @@ -1166,7 +1188,7 @@ export class EventService { if (filter.ids?.length === 1) { const raw = filter.ids[0] const key = /^[0-9a-f]{64}$/i.test(raw) ? raw.toLowerCase() : raw - const sess = this.getSessionEventIfAllowed(key) + const sess = this.getSessionEventIfAllowed(key, true) if (sess) return sess } @@ -1203,7 +1225,8 @@ export class EventService { relayHints: string[], filter: Filter, alreadyFetchedFromBigRelays = false, - authorHintPubkey?: string + authorHintPubkey?: string, + ingestOpts?: ShouldDropEventOnIngestOptions ): Promise { // Get seen relays if we have an event ID const seenRelays = filter.ids?.length ? client.getSeenEventRelayUrls(filter.ids[0]) : [] @@ -1249,7 +1272,7 @@ export class EventService { }) const event = events - .filter((e) => !shouldDropEventOnIngest(e)) + .filter((e) => !shouldDropEventOnIngest(e, ingestOpts)) .sort((a, b) => b.created_at - a.created_at)[0] if (event && isSingleEventById && !isReplaceableEvent(event.kind)) { @@ -1283,6 +1306,10 @@ export class EventService { const missingIds = missingIndices.map((i) => normalized[i]!) const isSingleEventFetch = missingIds.length === 1 + const batchIngestOpts: ShouldDropEventOnIngestOptions | undefined = + isSingleEventFetch && /^[0-9a-f]{64}$/i.test(missingIds[0]!) + ? { explicitNoteLookupHexId: missingIds[0]!.toLowerCase() } + : undefined // For single-event fetches, always use immediateReturn to return ASAP // This is especially important for non-replaceable events (not in 10000-19999 or 30000-39999 ranges) const events = await this.queryService.query( @@ -1301,10 +1328,10 @@ export class EventService { const fetchedById = new Map() for (const event of events) { - if (shouldDropEventOnIngest(event)) continue + if (shouldDropEventOnIngest(event, batchIngestOpts)) continue const key = /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id fetchedById.set(key, event) - this.addEventToCache(event) + this.addEventToCache(event, batchIngestOpts) } return normalized.map((k, i) => fromSession[i] ?? fetchedById.get(k)) diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 69ccffa7..a44fa254 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -746,7 +746,14 @@ export class QueryService { return { url, filters: filtersForRelay } }) - if (groupedRequests.length === 1) { + const hasNip50Search = filters.some( + (f) => typeof f.search === 'string' && f.search.trim().length > 0 + ) + /** + * Single-relay `pool.close` before subscribe resets the socket. Overlapping NIP-50 one-shots (e.g. Strict Mode + * double effect) then tear down each other’s REQ before EOSE → empty results until globalTimeout. + */ + if (groupedRequests.length === 1 && !hasNip50Search) { try { this.pool.close([groupedRequests[0]!.url]) } catch { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 76cf577b..31500d38 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -94,7 +94,7 @@ function canonicalSeenOnEventId(eventId: string): string { const t = eventId.trim() return /^[0-9a-f]{64}$/i.test(t) ? t.toLowerCase() : t } -import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' +import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter' import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' import { patchPoolRelayAuthRaceAndFeedback } from '@/lib/nostr-relay-auth-patch' @@ -179,6 +179,8 @@ import { buildProfileKind0SearchFilters } from '@/lib/profile-relay-search-filte import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-failure' import { compactFilterForRelayLog, + humanizeSubscribeTerminalDetail, + relayHostForSubscribeLog, RelayOpTerminalRow, RelayPublishOpBatch, RelaySubscribeOpBatch @@ -200,6 +202,66 @@ const TIMELINE_STREAMING_COALESCE_MS = 24 */ const TIMELINE_STRAGGLER_MAX_AGE_SEC = 600 +/** Same shape as `QueryService` `req_end` `perRelay` — NIP-50 search uses `subscribeTimeline`, not `query()`. */ +function logSearchTimelineNip50ReqEnd(args: { + timelineBatchId: string + search: string + subRequests: { urls: string[]; filter: TSubRequestFilter }[] + eventsSnapshot: NEvent[] + terminals: RelayOpTerminalRow[] + getSeenForEvent: (eventId: string) => string[] +}): void { + const { timelineBatchId, search, subRequests, eventsSnapshot, terminals, getSeenForEvent } = args + const norm = (u: string) => normalizeUrl(u) || u + type Row = { + url: string + host: string + terminal?: RelayOpTerminalRow['outcome'] + detail?: string + eventsReturned: number + } + const byKey = new Map() + const rowFor = (url: string): Row => { + const key = norm(url) + let r = byKey.get(key) + if (!r) { + r = { url: key, host: relayHostForSubscribeLog(key), eventsReturned: 0 } + byKey.set(key, r) + } + return r + } + for (const t of terminals) { + const r = rowFor(t.relayUrl) + r.terminal = t.outcome + r.detail = humanizeSubscribeTerminalDetail(t.outcome, t.detail) + } + for (const e of eventsSnapshot) { + for (const u of getSeenForEvent(e.id)) { + rowFor(u).eventsReturned += 1 + } + } + const inputRelaysOrdered = Array.from( + new Set(subRequests.flatMap((s) => s.urls).map((u) => norm(u)).filter(Boolean)) + ) + for (const u of inputRelaysOrdered) { + rowFor(u) + } + const kindHistogram: Record = {} + for (const e of eventsSnapshot) { + const k = String(e.kind) + kindHistogram[k] = (kindHistogram[k] ?? 0) + 1 + } + const perRelay = [...byKey.values()].sort((a, b) => a.host.localeCompare(b.host)) + logger.info('[QueryService] search_req_end', { + timelineBatchId, + source: 'subscribeTimeline', + search, + eventCount: eventsSnapshot.length, + kindHistogram, + perRelay + }) +} + function summarizeFiltersForRelayLog(filters: Filter[]): Record { const f = filters[0] if (!f) return {} @@ -2023,6 +2085,29 @@ class ClientService extends EventTarget { relayCounts: subRequests.map((r) => r.urls.length) }) + const nip50SearchTerm = (() => { + for (const s of subRequests) { + const q = + typeof (s.filter as Filter).search === 'string' ? (s.filter as Filter).search!.trim() : '' + if (q.length > 0) return q + } + return '' + })() + if (nip50SearchTerm) { + const relaysForLog = Array.from( + new Set(subRequests.flatMap((s) => s.urls.map((u) => normalizeUrl(u) || u).filter(Boolean))) + ) + logger.info('[QueryService] search_req_begin', { + timelineBatchId, + source: 'subscribeTimeline', + search: nip50SearchTerm, + relays: relaysForLog, + shardCount: subRequests.length, + relayCountsPerShard: subRequests.map((r) => r.urls.length), + filters: subRequests.map((s) => compactFilterForRelayLog(s.filter as Filter)) + }) + } + const newEventIdSet = new Set() const requestCount = subRequests.length let eventIdSet = new Set() @@ -2078,16 +2163,23 @@ class ClientService extends EventTarget { let subscribeWaveShardsRemaining = subRequests.length const subscribeWaveAcc: RelayOpTerminalRow[] = [] - const onShardSubscribeBatchEnd = - onRelaySubscribeWaveComplete != null - ? (rows: RelayOpTerminalRow[]) => { - subscribeWaveAcc.push(...rows) - subscribeWaveShardsRemaining-- - if (subscribeWaveShardsRemaining === 0) { - onRelaySubscribeWaveComplete(subscribeWaveAcc.slice()) - } - } - : undefined + const onShardSubscribeBatchEnd = (rows: RelayOpTerminalRow[]) => { + subscribeWaveAcc.push(...rows) + subscribeWaveShardsRemaining-- + if (subscribeWaveShardsRemaining === 0) { + if (nip50SearchTerm) { + logSearchTimelineNip50ReqEnd({ + timelineBatchId, + search: nip50SearchTerm, + subRequests, + eventsSnapshot: events.length ? [...events] : [], + terminals: subscribeWaveAcc.slice(), + getSeenForEvent: (id) => this.getSeenEventRelayUrls(id) + }) + } + onRelaySubscribeWaveComplete?.(subscribeWaveAcc.slice()) + } + } const subs = await mapPoolWithConcurrency( subRequests, @@ -2127,9 +2219,7 @@ class ClientService extends EventTarget { firstRelayResultGraceMs, relayReqLog: { groupId: `${timelineBatchId}:shard${shardIndex}`, - ...(onShardSubscribeBatchEnd - ? { onBatchEnd: onShardSubscribeBatchEnd } - : {}) + onBatchEnd: onShardSubscribeBatchEnd } } ) @@ -3137,12 +3227,15 @@ class ClientService extends EventTarget { if (!normalized) { return { events: [], connectionError: 'Invalid relay URL' } } + const queryOpts = { + globalTimeout: options?.globalTimeout ?? 25_000, + relayOpSource: 'fetchEventsFromSingleRelay' as const + } + if (isHttpRelayUrl(normalized)) { // HTTP index relay: use HTTP API instead of WebSocket pool try { - const events = await this.queryService.query([normalized], filter, undefined, { - globalTimeout: options?.globalTimeout ?? 25_000 - }) + const events = await this.queryService.query([normalized], filter, undefined, queryOpts) return { events, connectionError: undefined } } catch (e) { return { events: [], connectionError: e instanceof Error ? e.message : String(e) } @@ -3155,9 +3248,7 @@ class ClientService extends EventTarget { return { events: [], connectionError: msg } } try { - const events = await this.queryService.query([normalized], filter, undefined, { - globalTimeout: options?.globalTimeout ?? 25_000 - }) + const events = await this.queryService.query([normalized], filter, undefined, queryOpts) return { events, connectionError: undefined } } catch (e) { return { @@ -3193,8 +3284,8 @@ class ClientService extends EventTarget { return this.eventService.fetchEventWithExternalRelays(eventId, externalRelays) } - addEventToCache(event: NEvent) { - this.eventService.addEventToCache(event) + addEventToCache(event: NEvent, ingestOpts?: ShouldDropEventOnIngestOptions) { + this.eventService.addEventToCache(event, ingestOpts) } reapplySessionLruFromSettings(): void { diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index c8079c6d..9f7e2281 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -491,15 +491,7 @@ class IndexedDbService { logger.error('[IndexedDB] Store name not found for kind', { kind: cleanEvent.kind }) return Promise.reject('store name not found') } - - logger.debug('[IndexedDB] Putting replaceable event', { - kind: cleanEvent.kind, - storeName, - eventId: cleanEvent.id, - pubkey: cleanEvent.pubkey, - created_at: cleanEvent.created_at - }) - + await this.initPromise // Wait a bit for database upgrade to complete if store doesn't exist @@ -526,69 +518,34 @@ class IndexedDbService { availableStores: Array.from(this.db.objectStoreNames), dbVersion: this.db.version }) - logger.error('[IndexedDB] Store not found in database after waiting', { - storeName, - kind: cleanEvent.kind, - availableStores: Array.from(this.db.objectStoreNames) - }) // Return the event anyway (don't reject) - caching is optional return resolve(cleanEvent) } - - logger.debug('[IndexedDB] Store exists, proceeding with save', { - storeName, - kind: cleanEvent.kind, - eventId: cleanEvent.id, - dbVersion: this.db.version, - allStores: Array.from(this.db.objectStoreNames) - }) - + const transaction = this.db.transaction(storeName, 'readwrite') const store = transaction.objectStore(storeName) const key = this.getReplaceableEventKeyFromEvent(cleanEvent) - logger.debug('[IndexedDB] Getting existing event', { storeName, key, eventId: cleanEvent.id }) - + const getRequest = store.get(key) getRequest.onsuccess = () => { const oldValue = getRequest.result as TValue | undefined - if (oldValue?.value) { - logger.debug('[IndexedDB] Found existing event', { - storeName, - key, - oldEventId: oldValue.value.id, - oldCreatedAt: oldValue.value.created_at, - newCreatedAt: cleanEvent.created_at, - willUpdate: cleanEvent.created_at > oldValue.value.created_at - }) - } else { - logger.debug('[IndexedDB] No existing event found', { storeName, key }) - } - + if (oldValue?.value && oldValue.value.created_at > cleanEvent.created_at) { - logger.debug('[IndexedDB] Keeping existing event (strictly newer timestamp)', { + logger.debug('[IndexedDB] putReplaceableEvent', { storeName, key, + eventId: cleanEvent.id, + kind: cleanEvent.kind, + outcome: 'kept_existing_newer_row', existingEventId: oldValue.value.id }) transaction.commit() return resolve(oldValue.value) } - - logger.debug('[IndexedDB] Putting new event', { - storeName, - key, - eventId: cleanEvent.id, - content: cleanEvent.content - }) + const putRequest = store.put(this.formatValue(key, cleanEvent)) putRequest.onsuccess = () => { - logger.debug('[IndexedDB] Successfully put event', { - storeName, - key, - eventId: cleanEvent.id, - content: cleanEvent.content - }) transaction.commit() resolve(cleanEvent) } diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 76ab3bbc..109d4706 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -21,7 +21,23 @@ import { TTheme, TThemeSetting, } from '@/types' -import indexedDb from './indexed-db.service' +/** + * Lazy-load IndexedDB service to avoid a static import cycle: `indexed-db` pulls modules that can + * re-import this file during evaluation; the `indexedDb` binding would still be in the TDZ when + * {@link LocalStorageService} runs its eager constructor. + */ +let indexedDbSingletonPromise: ReturnType | null = null + +function importIndexedDbModule() { + return import('./indexed-db.service').then((m) => m.default) +} + +function loadIndexedDb() { + if (!indexedDbSingletonPromise) { + indexedDbSingletonPromise = importIndexedDbModule() + } + return indexedDbSingletonPromise +} /** Keys we persist to IndexedDB (and migrate from localStorage when IDB is empty). */ const SETTINGS_KEYS = [ @@ -450,7 +466,9 @@ class LocalStorageService { /** Persist a setting. Keys in SETTINGS_KEYS go only to IndexedDB; others use localStorage. */ private persistSetting(key: string, value: string): void { if ((SETTINGS_KEYS as readonly string[]).includes(key)) { - indexedDb.setSetting(key, value).catch(() => {}) + void loadIndexedDb() + .then((idb) => idb.setSetting(key, value)) + .catch(() => {}) return } window.localStorage.setItem(key, value) @@ -469,11 +487,12 @@ class LocalStorageService { async initAsync(): Promise { if (this.initPromise) return this.initPromise this.initPromise = (async () => { - await indexedDb.init() - let idbBefore = await indexedDb.getAllSettings() + const idb = await loadIndexedDb() + await idb.init() + let idbBefore = await idb.getAllSettings() if (Object.keys(idbBefore).length === 0) { await this.migrateToIdb() - idbBefore = await indexedDb.getAllSettings() + idbBefore = await idb.getAllSettings() } const merged = this.mergeSettingsRecordWithLocalStorage(idbBefore) this.applySettings(merged) @@ -501,11 +520,12 @@ class LocalStorageService { idbBefore: Record, merged: Record ): Promise { + const idb = await loadIndexedDb() for (const key of SETTINGS_KEYS) { const v = merged[key] if (v == null) continue if (idbBefore[key] !== v) { - await indexedDb.setSetting(key, v).catch(() => {}) + await idb.setSetting(key, v).catch(() => {}) } } } @@ -518,9 +538,10 @@ class LocalStorageService { } private async migrateToIdb(): Promise { + const idb = await loadIndexedDb() for (const key of SETTINGS_KEYS) { const value = window.localStorage.getItem(key) - if (value != null) await indexedDb.setSetting(key, value) + if (value != null) await idb.setSetting(key, value) } }