Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
14d54b2241
  1. 9
      src/components/SearchBar/index.tsx
  2. 353
      src/components/SearchResult/FullTextSearchByRelay.tsx
  3. 25
      src/components/SearchResult/index.tsx
  4. 8
      src/i18n/locales/en.ts
  5. 43
      src/lib/event-ingest-filter.ts
  6. 8
      src/pages/secondary/NotePage/NotFound.tsx
  7. 5
      src/pages/secondary/NotePage/index.tsx
  8. 89
      src/services/client-events.service.ts
  9. 9
      src/services/client-query.service.ts
  10. 135
      src/services/client.service.ts
  11. 51
      src/services/indexed-db.service.ts
  12. 35
      src/services/local-storage.service.ts

9
src/components/SearchBar/index.tsx

@ -98,7 +98,14 @@ const SearchBar = forwardRef<
if (params.type === 'note') { if (params.type === 'note') {
// Prime event cache so note page finds it without re-fetch // 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)) navigateToNote(toNote(params.search))
} else if (params.type === 'hashtag') { } else if (params.type === 'hashtag') {
navigateToHashtag(toNoteList({ hashtag: params.search })) navigateToHashtag(toNoteList({ hashtag: params.search }))

353
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<RelayCardModel[]>([])
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 (
<div className="min-w-0 space-y-4" aria-busy={anyLoading}>
<p className="text-sm text-muted-foreground">
{t('Full-text search per relay intro', {
relayCount: normalizedRelays.length,
seconds: timeoutSec,
concurrency: FULL_TEXT_SEARCH_RELAY_CONCURRENCY
})}
</p>
<div
className={cn(
'grid gap-4 min-w-0',
'grid-cols-1 md:grid-cols-2 xl:grid-cols-3'
)}
>
{cards.map((c) => (
<Card key={c.relayUrl} className="min-w-0 flex flex-col overflow-hidden">
<CardHeader className="pb-2 space-y-1">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base font-medium break-all">{c.host}</CardTitle>
{c.phase === 'loading' ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : (
<Badge variant="secondary" className="shrink-0">
{c.events.length}
</Badge>
)}
</div>
<CardDescription className="break-all text-xs font-mono opacity-80">{c.relayUrl}</CardDescription>
{c.phase === 'done' && c.ms != null && (
<p className="text-xs text-muted-foreground">
{t('Full-text search relay timing', { ms: c.ms })}
</p>
)}
</CardHeader>
<CardContent className="flex-1 min-h-0 pt-0 flex flex-col gap-2">
{c.phase === 'loading' && (
<div className="space-y-2" aria-label={t('Full-text search relay querying')}>
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-12 w-full" />
</div>
)}
{c.phase === 'error' && (
<p className="text-sm text-destructive">
{t('Full-text search relay error')}: {c.errorMessage ?? t('Full-text search relay unknown error')}
</p>
)}
{c.phase === 'done' && c.events.length === 0 && !c.errorMessage && (
<p className="text-sm text-muted-foreground">{t('Full-text search relay no hits')}</p>
)}
{c.phase === 'done' && c.events.length === 0 && c.errorMessage && (
<p className="text-sm text-muted-foreground">{c.errorMessage}</p>
)}
{c.events.length > 0 && (
<ul
className="max-h-[min(28rem,55vh)] overflow-y-auto space-y-3 pr-1 -mr-1 min-w-0"
role="list"
>
{c.events.map((ev) => (
<li key={ev.id} className="min-w-0">
<NoteCard event={ev} className="w-full" filterMutedNotes />
</li>
))}
</ul>
)}
</CardContent>
</Card>
))}
</div>
{allTerminal && (
<p className="text-sm text-muted-foreground border-t pt-3" role="status">
{t('Full-text search all relays finished')}
</p>
)}
</div>
)
}

25
src/components/SearchResult/index.tsx

@ -1,7 +1,7 @@
import { FAST_READ_RELAY_URLS, NIP_SEARCH_PAGE_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants' 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 { TSearchParams } from '@/types'
import NormalFeed from '../NormalFeed' import NormalFeed from '../NormalFeed'
import FullTextSearchByRelay from './FullTextSearchByRelay'
import Profile from '../Profile' import Profile from '../Profile'
import { ProfileListBySearch } from '../ProfileListBySearch' import { ProfileListBySearch } from '../ProfileListBySearch'
import Relay from '../Relay' import Relay from '../Relay'
@ -32,7 +32,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
[searchableUrls] [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(() => { const combinedRelays = useMemo(() => {
let relays: string[] = [] let relays: string[] = []
@ -75,24 +75,11 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
return <ProfileListBySearch search={searchParams.search} /> return <ProfileListBySearch search={searchParams.search} />
} }
if (searchParams.type === 'notes') { 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 ( return (
<NormalFeed <FullTextSearchByRelay
subRequests={subRequests} searchQuery={searchParams.search}
useFilterAsIs relayUrls={searchableUrls}
clientSideKindFilter kinds={NIP_SEARCH_PAGE_KINDS}
timelinePublicReadFallback
progressiveWarmupQuery={searchParams.search}
progressiveDocumentKinds={NIP_SEARCH_PAGE_KINDS}
oneShotAfterMergeComparator={(a, b) => compareEventsForDTagQuery(searchParams.search, a, b)}
/> />
) )
} }

8
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...", "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 all available relays...": "Searching all available relays...",
"Searching…": "Searching…", "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", "See reference": "See reference",
"Select Group": "Select Group", "Select Group": "Select Group",
"Select Media Type": "Select Media Type", "Select Media Type": "Select Media Type",

43
src/lib/event-ingest-filter.ts

@ -31,7 +31,44 @@ function isIncompleteRelayReviewIngest(event: NEvent): boolean {
return !getRelayUrlFromRelayReviewEvent(event) 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 { * Kacti-style kind-1 broadcast payloads (non-human notes that flood index relays). Not valid Nostr discussion text.
return isStringifiedJsonObjectContentNostrEvent(event) || isIncompleteRelayReviewIngest(event) * Dropped from timelines, search, and prefetch; still loadable when the user opens that exact id (hex / note1 / nevent).
*/
function isKactiBroadcastSpamKind1(event: Pick<NEvent, 'kind' | 'content'>): 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
} }

8
src/pages/secondary/NotePage/NotFound.tsx

@ -171,7 +171,10 @@ export default function NotFound({
if (idHex) { if (idHex) {
const fromDb = await indexedDb.getEventFromPublicationStore(idHex) const fromDb = await indexedDb.getEventFromPublicationStore(idHex)
if (fromDb) { if (fromDb) {
client.addEventToCache(fromDb) client.addEventToCache(
fromDb,
idHex ? { explicitNoteLookupHexId: idHex.toLowerCase() } : undefined
)
onEventFound?.(fromDb) onEventFound?.(fromDb)
found = true found = true
logger.info('Event found in IndexedDB (NotFound try-harder)', { bech32Id }) logger.info('Event found in IndexedDB (NotFound try-harder)', { bech32Id })
@ -199,7 +202,8 @@ export default function NotFound({
if (event) { if (event) {
logger.info('Event found on external relay (NotFound)', { bech32Id, hexEventId }) 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) onEventFound?.(event)
found = true found = true
} else { } else {

5
src/pages/secondary/NotePage/index.tsx

@ -600,7 +600,10 @@ function ParentNote({
const navigate = useCallback( const navigate = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
e.stopPropagation() 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( navigateToNote(
toNote(event ?? eventBech32Id), toNote(event ?? eventBech32Id),
event, event,

89
src/services/client-events.service.ts

@ -35,7 +35,7 @@ import {
import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config' import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config'
import { isCalendarEventKind } from '@/lib/calendar-event' import { isCalendarEventKind } from '@/lib/calendar-event'
import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' 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 { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
@ -151,11 +151,14 @@ export class EventService {
return null return null
} }
/** Returns cached event or undefined; evicts stringified-JSON-object spam from the session LRU. */ /** Returns cached event or undefined; evicts ingest-blocked events from the session LRU. */
private getSessionEventIfAllowed(hexId: string): NEvent | undefined { private getSessionEventIfAllowed(hexId: string, forExplicitNoteIdLookup = false): NEvent | undefined {
const e = this.sessionEventCache.get(hexId) const e = this.sessionEventCache.get(hexId)
if (!e) return undefined if (!e) return undefined
if (shouldDropEventOnIngest(e)) { const ingestOpts: ShouldDropEventOnIngestOptions | undefined = forExplicitNoteIdLookup
? { explicitNoteLookupHexId: hexId }
: undefined
if (shouldDropEventOnIngest(e, ingestOpts)) {
this.sessionEventCache.delete(hexId) this.sessionEventCache.delete(hexId)
return undefined return undefined
} }
@ -223,7 +226,7 @@ export class EventService {
const trimmed = noteId.trim() const trimmed = noteId.trim()
const hex = this.resolveHexWaiterKey(trimmed) const hex = this.resolveHexWaiterKey(trimmed)
if (hex) { if (hex) {
return this.getSessionEventIfAllowed(hex) return this.getSessionEventIfAllowed(hex, true)
} }
try { try {
const { type, data } = nip19.decode(trimmed) const { type, data } = nip19.decode(trimmed)
@ -247,7 +250,7 @@ export class EventService {
subscribeWhenSessionHasEvent(eventId: string, callback: () => void): () => void { subscribeWhenSessionHasEvent(eventId: string, callback: () => void): () => void {
const hex = this.resolveHexWaiterKey(eventId) const hex = this.resolveHexWaiterKey(eventId)
if (hex) { if (hex) {
if (this.getSessionEventIfAllowed(hex)) { if (this.getSessionEventIfAllowed(hex, true)) {
queueMicrotask(() => callback()) queueMicrotask(() => callback())
/** Already in cache: do not register a waiter — the next {@link addEventToCache} would notify again and double-fire embeds / useFetchEvent. */ /** Already in cache: do not register a waiter — the next {@link addEventToCache} would notify again and double-fire embeds / useFetchEvent. */
return () => {} return () => {}
@ -338,17 +341,18 @@ export class EventService {
} }
} }
if (hexId) { if (hexId) {
const fromSession = this.getSessionEventIfAllowed(hexId) const fromSession = this.getSessionEventIfAllowed(hexId, true)
if (fromSession) return fromSession if (fromSession) return fromSession
const cachedPromise = this.eventCacheMap.get(hexId) const cachedPromise = this.eventCacheMap.get(hexId)
if (cachedPromise) { if (cachedPromise) {
const resolved = await cachedPromise const resolved = await cachedPromise
if (resolved && !shouldDropEventOnIngest(resolved)) return resolved if (resolved && !shouldDropEventOnIngest(resolved, { explicitNoteLookupHexId: hexId }))
const fromSessionAfterMiss = this.getSessionEventIfAllowed(hexId) return resolved
const fromSessionAfterMiss = this.getSessionEventIfAllowed(hexId, true)
if (fromSessionAfterMiss) return fromSessionAfterMiss if (fromSessionAfterMiss) return fromSessionAfterMiss
const fromDb = await indexedDb.getEventFromPublicationStore(hexId) const fromDb = await indexedDb.getEventFromPublicationStore(hexId)
if (fromDb && !shouldDropEventOnIngest(fromDb)) { if (fromDb && !shouldDropEventOnIngest(fromDb, { explicitNoteLookupHexId: hexId })) {
this.addEventToCache(fromDb) this.addEventToCache(fromDb, { explicitNoteLookupHexId: hexId })
return fromDb return fromDb
} }
// Prior load() finished with undefined but left the promise in cacheMap — never retrying. // 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) { if (opts?.relayHints?.length || pointerHasFetchHints) {
const hinted = await this._fetchEvent(trimmed, opts?.relayHints) 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) const loaded = await this.eventDataLoader.load(hexId ?? trimmed)
if (hexId) { if (hexId) {
const fromSessionAfter = this.getSessionEventIfAllowed(hexId) const fromSessionAfter = this.getSessionEventIfAllowed(hexId, true)
if (fromSessionAfter) return fromSessionAfter if (fromSessionAfter) return fromSessionAfter
} }
if (loaded && shouldDropEventOnIngest(loaded)) { if (loaded && shouldDropEventOnIngest(loaded, hexId ? { explicitNoteLookupHexId: hexId } : undefined)) {
return undefined return undefined
} }
return loaded return loaded
@ -491,6 +499,10 @@ export class EventService {
return undefined 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 = const logKey =
'ids' in filter && filter.ids?.[0] 'ids' in filter && filter.ids?.[0]
? filter.ids[0].slice(0, 8) ? filter.ids[0].slice(0, 8)
@ -521,7 +533,7 @@ export class EventService {
}) })
const usable = events const usable = events
.filter((e) => !shouldDropEventOnIngest(e)) .filter((e) => !shouldDropEventOnIngest(e, ingestOpts))
.sort((a, b) => b.created_at - a.created_at) .sort((a, b) => b.created_at - a.created_at)
return usable[0] return usable[0]
} }
@ -529,8 +541,8 @@ export class EventService {
/** /**
* Add event to session cache * Add event to session cache
*/ */
addEventToCache(event: NEvent): void { addEventToCache(event: NEvent, ingestOpts?: ShouldDropEventOnIngestOptions): void {
if (shouldDropEventOnIngest(event)) return if (shouldDropEventOnIngest(event, ingestOpts)) return
const cleanEvent = { ...event } const cleanEvent = { ...event }
delete (cleanEvent as any).relayStatuses delete (cleanEvent as any).relayStatuses
// REQ filters and nip19 decode use lowercase hex; some relays/clients emit uppercase ids. // 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 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) { if (filter.ids?.length === 1) {
const hid = filter.ids[0]!.toLowerCase() const hid = filter.ids[0]!.toLowerCase()
if (/^[0-9a-f]{64}$/.test(hid)) { if (/^[0-9a-f]{64}$/.test(hid)) {
const fromArchive = await loadArchivedEventForFetch(hid) const fromArchive = await loadArchivedEventForFetch(hid)
if (fromArchive && !shouldDropEventOnIngest(fromArchive)) { if (fromArchive && !shouldDropEventOnIngest(fromArchive, ingestOpts)) {
this.addEventToCache(fromArchive) this.addEventToCache(fromArchive, ingestOpts)
return fromArchive return fromArchive
} }
} }
@ -1130,8 +1152,8 @@ export class EventService {
// Try cache first // Try cache first
if (filter.ids?.length) { if (filter.ids?.length) {
const cached = await indexedDb.getEventFromPublicationStore(filter.ids[0]) const cached = await indexedDb.getEventFromPublicationStore(filter.ids[0])
if (cached && !shouldDropEventOnIngest(cached)) { if (cached && !shouldDropEventOnIngest(cached, ingestOpts)) {
this.addEventToCache(cached) this.addEventToCache(cached, ingestOpts)
// Extract relay hints from cached event's tags (e, a, q tags) // Extract relay hints from cached event's tags (e, a, q tags)
const eventRelayHints = this.extractRelayHintsFromEvent(cached) const eventRelayHints = this.extractRelayHintsFromEvent(cached)
if (eventRelayHints.length > 0) { if (eventRelayHints.length > 0) {
@ -1144,8 +1166,8 @@ export class EventService {
// Try big relays first (uses user's inboxes + defaults) // Try big relays first (uses user's inboxes + defaults)
if (filter.ids?.length) { if (filter.ids?.length) {
const event = await this.fetchEventFromBigRelaysDataloader.load(filter.ids[0]) const event = await this.fetchEventFromBigRelaysDataloader.load(filter.ids[0])
if (event && !shouldDropEventOnIngest(event)) { if (event && !shouldDropEventOnIngest(event, ingestOpts)) {
this.addEventToCache(event) this.addEventToCache(event, ingestOpts)
// Extract relay hints from found event's tags (e, a, q tags) // Extract relay hints from found event's tags (e, a, q tags)
const eventRelayHints = this.extractRelayHintsFromEvent(event) const eventRelayHints = this.extractRelayHintsFromEvent(event)
if (eventRelayHints.length > 0) { 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) // Always try comprehensive relay list (author's outboxes + user's inboxes + hints + seen + defaults)
const event = await this.tryHarderToFetchEvent(relays, filter, true, authorHintPubkey) const event = await this.tryHarderToFetchEvent(relays, filter, true, authorHintPubkey, ingestOpts)
if (event && !shouldDropEventOnIngest(event)) { if (event && !shouldDropEventOnIngest(event, ingestOpts)) {
this.addEventToCache(event) this.addEventToCache(event, ingestOpts)
return event return event
} }
@ -1166,7 +1188,7 @@ export class EventService {
if (filter.ids?.length === 1) { if (filter.ids?.length === 1) {
const raw = filter.ids[0] const raw = filter.ids[0]
const key = /^[0-9a-f]{64}$/i.test(raw) ? raw.toLowerCase() : raw 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 if (sess) return sess
} }
@ -1203,7 +1225,8 @@ export class EventService {
relayHints: string[], relayHints: string[],
filter: Filter, filter: Filter,
alreadyFetchedFromBigRelays = false, alreadyFetchedFromBigRelays = false,
authorHintPubkey?: string authorHintPubkey?: string,
ingestOpts?: ShouldDropEventOnIngestOptions
): Promise<NEvent | undefined> { ): Promise<NEvent | undefined> {
// Get seen relays if we have an event ID // Get seen relays if we have an event ID
const seenRelays = filter.ids?.length ? client.getSeenEventRelayUrls(filter.ids[0]) : [] const seenRelays = filter.ids?.length ? client.getSeenEventRelayUrls(filter.ids[0]) : []
@ -1249,7 +1272,7 @@ export class EventService {
}) })
const event = events const event = events
.filter((e) => !shouldDropEventOnIngest(e)) .filter((e) => !shouldDropEventOnIngest(e, ingestOpts))
.sort((a, b) => b.created_at - a.created_at)[0] .sort((a, b) => b.created_at - a.created_at)[0]
if (event && isSingleEventById && !isReplaceableEvent(event.kind)) { if (event && isSingleEventById && !isReplaceableEvent(event.kind)) {
@ -1283,6 +1306,10 @@ export class EventService {
const missingIds = missingIndices.map((i) => normalized[i]!) const missingIds = missingIndices.map((i) => normalized[i]!)
const isSingleEventFetch = missingIds.length === 1 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 // 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) // This is especially important for non-replaceable events (not in 10000-19999 or 30000-39999 ranges)
const events = await this.queryService.query( const events = await this.queryService.query(
@ -1301,10 +1328,10 @@ export class EventService {
const fetchedById = new Map<string, NEvent>() const fetchedById = new Map<string, NEvent>()
for (const event of events) { 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 const key = /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id
fetchedById.set(key, event) fetchedById.set(key, event)
this.addEventToCache(event) this.addEventToCache(event, batchIngestOpts)
} }
return normalized.map((k, i) => fromSession[i] ?? fetchedById.get(k)) return normalized.map((k, i) => fromSession[i] ?? fetchedById.get(k))

9
src/services/client-query.service.ts

@ -746,7 +746,14 @@ export class QueryService {
return { url, filters: filtersForRelay } 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 others REQ before EOSE empty results until globalTimeout.
*/
if (groupedRequests.length === 1 && !hasNip50Search) {
try { try {
this.pool.close([groupedRequests[0]!.url]) this.pool.close([groupedRequests[0]!.url])
} catch { } catch {

135
src/services/client.service.ts

@ -94,7 +94,7 @@ function canonicalSeenOnEventId(eventId: string): string {
const t = eventId.trim() const t = eventId.trim()
return /^[0-9a-f]{64}$/i.test(t) ? t.toLowerCase() : t 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 { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { patchPoolRelayAuthRaceAndFeedback } from '@/lib/nostr-relay-auth-patch' 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 { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-failure'
import { import {
compactFilterForRelayLog, compactFilterForRelayLog,
humanizeSubscribeTerminalDetail,
relayHostForSubscribeLog,
RelayOpTerminalRow, RelayOpTerminalRow,
RelayPublishOpBatch, RelayPublishOpBatch,
RelaySubscribeOpBatch RelaySubscribeOpBatch
@ -200,6 +202,66 @@ const TIMELINE_STREAMING_COALESCE_MS = 24
*/ */
const TIMELINE_STRAGGLER_MAX_AGE_SEC = 600 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<string, Row>()
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<string, number> = {}
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<string, unknown> { function summarizeFiltersForRelayLog(filters: Filter[]): Record<string, unknown> {
const f = filters[0] const f = filters[0]
if (!f) return {} if (!f) return {}
@ -2023,6 +2085,29 @@ class ClientService extends EventTarget {
relayCounts: subRequests.map((r) => r.urls.length) 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<string>() const newEventIdSet = new Set<string>()
const requestCount = subRequests.length const requestCount = subRequests.length
let eventIdSet = new Set<string>() let eventIdSet = new Set<string>()
@ -2078,16 +2163,23 @@ class ClientService extends EventTarget {
let subscribeWaveShardsRemaining = subRequests.length let subscribeWaveShardsRemaining = subRequests.length
const subscribeWaveAcc: RelayOpTerminalRow[] = [] const subscribeWaveAcc: RelayOpTerminalRow[] = []
const onShardSubscribeBatchEnd = const onShardSubscribeBatchEnd = (rows: RelayOpTerminalRow[]) => {
onRelaySubscribeWaveComplete != null subscribeWaveAcc.push(...rows)
? (rows: RelayOpTerminalRow[]) => { subscribeWaveShardsRemaining--
subscribeWaveAcc.push(...rows) if (subscribeWaveShardsRemaining === 0) {
subscribeWaveShardsRemaining-- if (nip50SearchTerm) {
if (subscribeWaveShardsRemaining === 0) { logSearchTimelineNip50ReqEnd({
onRelaySubscribeWaveComplete(subscribeWaveAcc.slice()) timelineBatchId,
} search: nip50SearchTerm,
} subRequests,
: undefined eventsSnapshot: events.length ? [...events] : [],
terminals: subscribeWaveAcc.slice(),
getSeenForEvent: (id) => this.getSeenEventRelayUrls(id)
})
}
onRelaySubscribeWaveComplete?.(subscribeWaveAcc.slice())
}
}
const subs = await mapPoolWithConcurrency( const subs = await mapPoolWithConcurrency(
subRequests, subRequests,
@ -2127,9 +2219,7 @@ class ClientService extends EventTarget {
firstRelayResultGraceMs, firstRelayResultGraceMs,
relayReqLog: { relayReqLog: {
groupId: `${timelineBatchId}:shard${shardIndex}`, groupId: `${timelineBatchId}:shard${shardIndex}`,
...(onShardSubscribeBatchEnd onBatchEnd: onShardSubscribeBatchEnd
? { onBatchEnd: onShardSubscribeBatchEnd }
: {})
} }
} }
) )
@ -3137,12 +3227,15 @@ class ClientService extends EventTarget {
if (!normalized) { if (!normalized) {
return { events: [], connectionError: 'Invalid relay URL' } return { events: [], connectionError: 'Invalid relay URL' }
} }
const queryOpts = {
globalTimeout: options?.globalTimeout ?? 25_000,
relayOpSource: 'fetchEventsFromSingleRelay' as const
}
if (isHttpRelayUrl(normalized)) { if (isHttpRelayUrl(normalized)) {
// HTTP index relay: use HTTP API instead of WebSocket pool // HTTP index relay: use HTTP API instead of WebSocket pool
try { try {
const events = await this.queryService.query([normalized], filter, undefined, { const events = await this.queryService.query([normalized], filter, undefined, queryOpts)
globalTimeout: options?.globalTimeout ?? 25_000
})
return { events, connectionError: undefined } return { events, connectionError: undefined }
} catch (e) { } catch (e) {
return { events: [], connectionError: e instanceof Error ? e.message : String(e) } return { events: [], connectionError: e instanceof Error ? e.message : String(e) }
@ -3155,9 +3248,7 @@ class ClientService extends EventTarget {
return { events: [], connectionError: msg } return { events: [], connectionError: msg }
} }
try { try {
const events = await this.queryService.query([normalized], filter, undefined, { const events = await this.queryService.query([normalized], filter, undefined, queryOpts)
globalTimeout: options?.globalTimeout ?? 25_000
})
return { events, connectionError: undefined } return { events, connectionError: undefined }
} catch (e) { } catch (e) {
return { return {
@ -3193,8 +3284,8 @@ class ClientService extends EventTarget {
return this.eventService.fetchEventWithExternalRelays(eventId, externalRelays) return this.eventService.fetchEventWithExternalRelays(eventId, externalRelays)
} }
addEventToCache(event: NEvent) { addEventToCache(event: NEvent, ingestOpts?: ShouldDropEventOnIngestOptions) {
this.eventService.addEventToCache(event) this.eventService.addEventToCache(event, ingestOpts)
} }
reapplySessionLruFromSettings(): void { reapplySessionLruFromSettings(): void {

51
src/services/indexed-db.service.ts

@ -492,14 +492,6 @@ class IndexedDbService {
return Promise.reject('store name not found') 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 await this.initPromise
// Wait a bit for database upgrade to complete if store doesn't exist // 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), availableStores: Array.from(this.db.objectStoreNames),
dbVersion: this.db.version 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 the event anyway (don't reject) - caching is optional
return resolve(cleanEvent) 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 transaction = this.db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName) const store = transaction.objectStore(storeName)
const key = this.getReplaceableEventKeyFromEvent(cleanEvent) const key = this.getReplaceableEventKeyFromEvent(cleanEvent)
logger.debug('[IndexedDB] Getting existing event', { storeName, key, eventId: cleanEvent.id })
const getRequest = store.get(key) const getRequest = store.get(key)
getRequest.onsuccess = () => { getRequest.onsuccess = () => {
const oldValue = getRequest.result as TValue<Event> | undefined const oldValue = getRequest.result as TValue<Event> | 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) { if (oldValue?.value && oldValue.value.created_at > cleanEvent.created_at) {
logger.debug('[IndexedDB] Keeping existing event (strictly newer timestamp)', { logger.debug('[IndexedDB] putReplaceableEvent', {
storeName, storeName,
key, key,
eventId: cleanEvent.id,
kind: cleanEvent.kind,
outcome: 'kept_existing_newer_row',
existingEventId: oldValue.value.id existingEventId: oldValue.value.id
}) })
transaction.commit() transaction.commit()
return resolve(oldValue.value) 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)) const putRequest = store.put(this.formatValue(key, cleanEvent))
putRequest.onsuccess = () => { putRequest.onsuccess = () => {
logger.debug('[IndexedDB] Successfully put event', {
storeName,
key,
eventId: cleanEvent.id,
content: cleanEvent.content
})
transaction.commit() transaction.commit()
resolve(cleanEvent) resolve(cleanEvent)
} }

35
src/services/local-storage.service.ts

@ -21,7 +21,23 @@ import {
TTheme, TTheme,
TThemeSetting, TThemeSetting,
} from '@/types' } 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<typeof importIndexedDbModule> | 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). */ /** Keys we persist to IndexedDB (and migrate from localStorage when IDB is empty). */
const SETTINGS_KEYS = [ const SETTINGS_KEYS = [
@ -450,7 +466,9 @@ class LocalStorageService {
/** Persist a setting. Keys in SETTINGS_KEYS go only to IndexedDB; others use localStorage. */ /** Persist a setting. Keys in SETTINGS_KEYS go only to IndexedDB; others use localStorage. */
private persistSetting(key: string, value: string): void { private persistSetting(key: string, value: string): void {
if ((SETTINGS_KEYS as readonly string[]).includes(key)) { if ((SETTINGS_KEYS as readonly string[]).includes(key)) {
indexedDb.setSetting(key, value).catch(() => {}) void loadIndexedDb()
.then((idb) => idb.setSetting(key, value))
.catch(() => {})
return return
} }
window.localStorage.setItem(key, value) window.localStorage.setItem(key, value)
@ -469,11 +487,12 @@ class LocalStorageService {
async initAsync(): Promise<void> { async initAsync(): Promise<void> {
if (this.initPromise) return this.initPromise if (this.initPromise) return this.initPromise
this.initPromise = (async () => { this.initPromise = (async () => {
await indexedDb.init() const idb = await loadIndexedDb()
let idbBefore = await indexedDb.getAllSettings() await idb.init()
let idbBefore = await idb.getAllSettings()
if (Object.keys(idbBefore).length === 0) { if (Object.keys(idbBefore).length === 0) {
await this.migrateToIdb() await this.migrateToIdb()
idbBefore = await indexedDb.getAllSettings() idbBefore = await idb.getAllSettings()
} }
const merged = this.mergeSettingsRecordWithLocalStorage(idbBefore) const merged = this.mergeSettingsRecordWithLocalStorage(idbBefore)
this.applySettings(merged) this.applySettings(merged)
@ -501,11 +520,12 @@ class LocalStorageService {
idbBefore: Record<string, string>, idbBefore: Record<string, string>,
merged: Record<string, string> merged: Record<string, string>
): Promise<void> { ): Promise<void> {
const idb = await loadIndexedDb()
for (const key of SETTINGS_KEYS) { for (const key of SETTINGS_KEYS) {
const v = merged[key] const v = merged[key]
if (v == null) continue if (v == null) continue
if (idbBefore[key] !== v) { 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<void> { private async migrateToIdb(): Promise<void> {
const idb = await loadIndexedDb()
for (const key of SETTINGS_KEYS) { for (const key of SETTINGS_KEYS) {
const value = window.localStorage.getItem(key) const value = window.localStorage.getItem(key)
if (value != null) await indexedDb.setSetting(key, value) if (value != null) await idb.setSetting(key, value)
} }
} }

Loading…
Cancel
Save