@ -8,11 +8,10 @@ import {
@@ -8,11 +8,10 @@ import {
import { normalizeToDTag , parseAdvancedSearch } from '@/lib/search-parser'
import logger from '@/lib/logger'
import { extractNip32LabelValues , isBooklistNip32Label } from '@/lib/nip32-label'
import { queryIndexRelay , queryIndexRelayForLibrary , queryIndexRelayPublicationSearch } from '@/lib/index-relay-http'
import { queryIndexRelay , queryIndexRelayForLibrary , queryIndexRelayPublicationMetadata Search } from '@/lib/index-relay-http'
import {
buildIndexByAddress ,
buildStructuralPublicationIndexMap ,
collectPublicationIndexEventIds ,
collectReachableAddressesCached ,
eventTagAddress ,
filterValidIndexEvents ,
@ -55,7 +54,11 @@ const INDEX_MAX_PAGES_PER_RELAY = 100
@@ -55,7 +54,11 @@ const INDEX_MAX_PAGES_PER_RELAY = 100
const INDEX_VERIFY_CHUNK = 80
const ENGAGEMENT_ADDRESS_CHUNK = 36
const ENGAGEMENT_EVENT_ID_CHUNK = 44
const MAX_TARGET_ADDRESSES = 480
/** Cap engagement relay queries to the first slice of the catalog (not the full index corpus). */
const MAX_TARGET_ADDRESSES = 120
const MAX_TARGET_EVENT_IDS = 160
const MAX_ENGAGEMENT_HTTP_CHUNKS = 6
const ENGAGEMENT_FETCH_TIMEOUT_MS = 25 _000
const HYDRATE_MISSING_CAP = 64
export const LIBRARY_PAGE_SIZE = 120
/** @deprecated Use {@link LIBRARY_PAGE_SIZE} */
@ -63,6 +66,8 @@ export const LIBRARY_RECENT_FALLBACK_LIMIT = LIBRARY_PAGE_SIZE
@@ -63,6 +66,8 @@ export const LIBRARY_RECENT_FALLBACK_LIMIT = LIBRARY_PAGE_SIZE
const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200
export const LIBRARY_RELAY_SEARCH_LIMIT = 100
const LIBRARY_RELAY_SEARCH_TIMEOUT_MS = 28 _000
/** Max paginated HTTP pages when title/author metadata API is unavailable (Mercury v0.2.0). */
const LIBRARY_RELAY_SEARCH_SCAN_MAX_PAGES = 80
/** NIP-51 pin list (kind 10001). */
const PIN_LIST_KIND = 10001
/** Per-relay WS page fetch — one relay at a time avoids multi-relay onclose resolving after ~1s. */
@ -198,8 +203,13 @@ type LibrarySearchSessionRow = {
@@ -198,8 +203,13 @@ type LibrarySearchSessionRow = {
const librarySearchSessionCache = new Map < string , LibrarySearchSessionRow > ( )
function librarySearchQueryKey ( query : string ) : string {
return normalizeGeneralSearchQuery ( query ) . toLowerCase ( )
function librarySearchQueryKey (
query : string ,
axis? : LibraryPublicationRelaySearchAxis | null
) : string {
const base = normalizeGeneralSearchQuery ( query ) . toLowerCase ( )
if ( ! axis ) return base
return ` ${ axis } : ${ base } `
}
function librarySearchFingerprint ( context : LibrarySearchContext ) : string {
@ -230,9 +240,9 @@ function librarySearchFingerprint(context: LibrarySearchContext): string {
@@ -230,9 +240,9 @@ function librarySearchFingerprint(context: LibrarySearchContext): string {
function getLibrarySearchSessionRow (
query : string ,
context : LibrarySearchContext ,
opts ? : { requireRelaySearch? : boolean }
opts ? : { requireRelaySearch? : boolean ; axis? : LibraryPublicationRelaySearchAxis | null }
) : LibrarySearchSessionRow | null {
const key = librarySearchQueryKey ( query )
const key = librarySearchQueryKey ( query , opts ? . axis )
if ( ! key ) return null
const row = librarySearchSessionCache . get ( key )
if ( ! row ) return null
@ -244,9 +254,10 @@ function getLibrarySearchSessionRow(
@@ -244,9 +254,10 @@ function getLibrarySearchSessionRow(
function putLibrarySearchSessionRow (
query : string ,
context : LibrarySearchContext ,
row : Omit < LibrarySearchSessionRow , ' fingerprint ' >
row : Omit < LibrarySearchSessionRow , ' fingerprint ' > ,
axis? : LibraryPublicationRelaySearchAxis | null
) : void {
const key = librarySearchQueryKey ( query )
const key = librarySearchQueryKey ( query , axis )
if ( ! key ) return
librarySearchSessionCache . set ( key , {
. . . row ,
@ -257,9 +268,10 @@ function putLibrarySearchSessionRow(
@@ -257,9 +268,10 @@ function putLibrarySearchSessionRow(
/** Sync read of cached search hits for the current index + engagement snapshot. */
export function peekLibrarySearchResults (
query : string ,
context : LibrarySearchContext
context : LibrarySearchContext ,
axis? : LibraryPublicationRelaySearchAxis | null
) : LibraryPublicationEntry [ ] | null {
return getLibrarySearchSessionRow ( query , context ) ? . entries ? ? null
return getLibrarySearchSessionRow ( query , context , { axis } ) ? . entries ? ? null
}
export function clearLibrarySearchSessionCache ( ) : void {
@ -909,8 +921,21 @@ export async function fetchPublicationEngagementMaps(
@@ -909,8 +921,21 @@ export async function fetchPublicationEngagementMaps(
return emptyPublicationEngagementMaps ( )
}
const addressChunks = chunkArray ( [ . . . targetAddresses ] , ENGAGEMENT_ADDRESS_CHUNK )
const eventIdChunks = chunkArray ( [ . . . targetEventIds ] , ENGAGEMENT_EVENT_ID_CHUNK )
return withEngagementTimeout (
fetchPublicationEngagementMapsInner ( relayUrls , targetAddresses , targetEventIds , options ) ,
emptyPublicationEngagementMaps ( ) ,
'maps'
)
}
async function fetchPublicationEngagementMapsInner (
relayUrls : string [ ] ,
targetAddresses : Set < string > ,
targetEventIds : Set < string > ,
options ? : { viewerPubkey? : string | null }
) : Promise < PublicationEngagementMaps > {
const addressChunks = limitEngagementChunks ( chunkArray ( [ . . . targetAddresses ] , ENGAGEMENT_ADDRESS_CHUNK ) )
const eventIdChunks = limitEngagementChunks ( chunkArray ( [ . . . targetEventIds ] , ENGAGEMENT_EVENT_ID_CHUNK ) )
const { wsRelays , httpRelays } = splitWsAndHttpRelays ( relayUrls )
const useWsEngagement = wsRelays . length > 0
if ( import . meta . env . DEV ) {
@ -1563,8 +1588,10 @@ export async function refreshLibraryEngagement(
@@ -1563,8 +1588,10 @@ export async function refreshLibraryEngagement(
viewerPubkey? : string | null
) : Promise < { engagement : PublicationEngagementMaps ; engaged : LibraryPublicationEntry [ ] } > {
const indexByAddress = buildIndexByAddress ( indexEvents )
const targetAddresses = collectTargetAddressesFromIndexes ( indexEvents , indexByAddress )
const targetEventIds = collectPublicationIndexEventIds ( indexEvents )
const { addresses : targetAddresses , eventIds : targetEventIds } = collectEngagementTargets (
indexEvents ,
indexByAddress
)
const engagementRelayUrls = await buildLibraryEngagementRelayUrls (
viewerPubkey ? ? undefined ,
indexRelayUrls ,
@ -1589,23 +1616,24 @@ export async function refreshLibraryEngagement(
@@ -1589,23 +1616,24 @@ export async function refreshLibraryEngagement(
}
}
/** Search all cached kind-30040 indexes (library index store), mapping nested hits to top-level roots. */
export function searchLibraryPublicationIndex (
const LIBRARY_SEARCH_BATCH_SIZE = 80
function collectLibraryPublicationIndexSearchRoots (
query : string ,
indexEvents : Event [ ] ,
indexByAddress : Map < string , Event >
) : Event [ ] {
topLevelIds : Set < string > ,
addressToRoot : Map < string , Event > ,
axis : LibraryPublicationRelaySearchAxis | null | undefined ,
roots : Map < string , Event > ,
start : number ,
end : number
) : void {
const q = query . trim ( )
if ( ! q || indexEvents . length === 0 ) return [ ]
const topLevel = getTopLevelIndexEvents ( indexEvents )
const topLevelIds = new Set ( topLevel . map ( ( ev ) = > ev . id ) )
const addressToRoot = buildAddressToRootMap ( topLevel , indexByAddress )
const roots = new Map < string , Event > ( )
for ( const ev of indexEvents ) {
for ( let i = start ; i < end ; i ++ ) {
const ev = indexEvents [ i ]
if ( ev . kind !== ExtendedKind . PUBLICATION ) continue
if ( ! publicationIndexMatchesSearchQuery ( ev , q ) ) continue
if ( ! publicationIndexMatchesSearchQueryWithAxis ( ev , q , axis ) ) continue
if ( topLevelIds . has ( ev . id ) ) {
roots . set ( ev . id , ev )
@ -1616,10 +1644,78 @@ export function searchLibraryPublicationIndex(
@@ -1616,10 +1644,78 @@ export function searchLibraryPublicationIndex(
const root = addr ? addressToRoot . get ( addr ) : undefined
if ( root ) roots . set ( root . id , root )
}
}
/** Search all cached kind-30040 indexes (library index store), mapping nested hits to top-level roots. */
export function searchLibraryPublicationIndex (
query : string ,
indexEvents : Event [ ] ,
indexByAddress : Map < string , Event > ,
axis? : LibraryPublicationRelaySearchAxis | null
) : Event [ ] {
const q = query . trim ( )
if ( ! q || indexEvents . length === 0 ) return [ ]
const topLevel = getTopLevelIndexEvents ( indexEvents )
const topLevelIds = new Set ( topLevel . map ( ( ev ) = > ev . id ) )
const addressToRoot = buildAddressToRootMap ( topLevel , indexByAddress )
const roots = new Map < string , Event > ( )
collectLibraryPublicationIndexSearchRoots (
q ,
indexEvents ,
topLevelIds ,
addressToRoot ,
axis ,
roots ,
0 ,
indexEvents . length
)
return [ . . . roots . values ( ) ]
}
/** Yields between batches so large index scans do not freeze the UI. */
export function searchLibraryPublicationIndexAsync (
query : string ,
indexEvents : Event [ ] ,
indexByAddress : Map < string , Event > ,
axis? : LibraryPublicationRelaySearchAxis | null ,
signal ? : { cancelled : boolean }
) : Promise < Event [ ] > {
const q = query . trim ( )
if ( ! q || indexEvents . length === 0 ) return Promise . resolve ( [ ] )
const topLevel = getTopLevelIndexEvents ( indexEvents )
const topLevelIds = new Set ( topLevel . map ( ( ev ) = > ev . id ) )
const addressToRoot = buildAddressToRootMap ( topLevel , indexByAddress )
const roots = new Map < string , Event > ( )
let i = 0
return new Promise ( ( resolve ) = > {
const step = ( ) = > {
if ( signal ? . cancelled ) return
const end = Math . min ( i + LIBRARY_SEARCH_BATCH_SIZE , indexEvents . length )
collectLibraryPublicationIndexSearchRoots (
q ,
indexEvents ,
topLevelIds ,
addressToRoot ,
axis ,
roots ,
i ,
end
)
i = end
if ( signal ? . cancelled ) return
if ( i < indexEvents . length ) {
requestAnimationFrame ( step )
} else {
resolve ( [ . . . roots . values ( ) ] )
}
}
requestAnimationFrame ( step )
} )
}
export type LibrarySearchContext = {
indexEvents : Event [ ]
engagement? : PublicationEngagementMaps
@ -1631,15 +1727,20 @@ export type LibrarySearchContext = {
@@ -1631,15 +1727,20 @@ export type LibrarySearchContext = {
* /
export async function searchLibraryPublications (
query : string ,
context : LibrarySearchContext
context : LibrarySearchContext ,
axis? : LibraryPublicationRelaySearchAxis | null
) : Promise < LibraryPublicationEntry [ ] > {
const q = query . trim ( )
if ( ! q ) return [ ]
const cached = getLibrarySearchSessionRow ( q , context )
const cached = getLibrarySearchSessionRow ( q , context , { axis } )
if ( cached ) {
if ( import . meta . env . DEV ) {
logger . info ( '[Library] search cache hit' , { query : q , relaySearched : cached.relaySearched } )
logger . info ( '[Library] search cache hit' , {
query : q ,
axis : axis ? ? 'all' ,
relaySearched : cached.relaySearched
} )
}
return cached . entries
}
@ -1651,7 +1752,7 @@ export async function searchLibraryPublications(
@@ -1651,7 +1752,7 @@ export async function searchLibraryPublications(
const engagement = context . engagement ? ? EMPTY_ENGAGEMENT
const indexByAddress = buildIndexByAddress ( indexEvents )
const fromIndex = searchLibraryPublicationIndex ( q , indexEvents , indexByAddress )
const fromIndex = await searchLibraryPublicationIndexAsync ( q , indexEvents , indexByAddress , axi s )
const rootMap = new Map < string , Event > ( )
for ( const root of fromIndex ) rootMap . set ( root . id , root )
@ -1667,7 +1768,7 @@ export async function searchLibraryPublications(
@@ -1667,7 +1768,7 @@ export async function searchLibraryPublications(
)
for ( const ev of fromReadingCache ) {
if ( ev . kind !== ExtendedKind . PUBLICATION ) continue
if ( ! publicationIndexMatchesSearchQuery ( ev , q ) ) continue
if ( ! publicationIndexMatchesSearchQueryWithAxis ( ev , q , axis ) ) continue
if ( rootMap . has ( ev . id ) ) continue
const addr = eventTagAddress ( ev )
@ -1694,12 +1795,17 @@ export async function searchLibraryPublications(
@@ -1694,12 +1795,17 @@ export async function searchLibraryPublications(
const entries = sortLibraryPublications ( libraryEntriesFromRoots ( roots , indexByAddress , engagement ) )
const searchContext : LibrarySearchContext = { indexEvents , engagement }
const prev = getLibrarySearchSessionRow ( q , searchContext )
putLibrarySearchSessionRow ( q , searchContext , {
entries ,
mergedIndexEvents : prev?.mergedIndexEvents ? ? indexEvents ,
relaySearched : prev?.relaySearched ? ? false
} )
const prev = getLibrarySearchSessionRow ( q , searchContext , { axis } )
putLibrarySearchSessionRow (
q ,
searchContext ,
{
entries ,
mergedIndexEvents : prev?.mergedIndexEvents ? ? indexEvents ,
relaySearched : prev?.relaySearched ? ? false
} ,
axis
)
return entries
}
@ -1727,6 +1833,15 @@ function normalizePublicationDTag(term: string): string {
@@ -1727,6 +1833,15 @@ function normalizePublicationDTag(term: string): string {
. replace ( /^-|-$/g , '' )
}
/** Relay search axis for kind-30040 publication indexes. */
export type LibraryPublicationRelaySearchAxis = 'd-tag' | 'title' | 'author'
export const LIBRARY_PUBLICATION_RELAY_SEARCH_AXES : LibraryPublicationRelaySearchAxis [ ] = [
'd-tag' ,
'title' ,
'author'
]
/** d-tag filter values: hyphenated slug variants for relay `#d` REQ. */
export function publicationQueryDTagVariants ( query : string ) : string [ ] {
const raw = query . trim ( )
@ -1742,14 +1857,106 @@ export function publicationQueryDTagVariants(query: string): string[] {
@@ -1742,14 +1857,106 @@ export function publicationQueryDTagVariants(query: string): string[] {
return [ . . . seen ]
}
/ * *
* OR - merge REQ filters for kind * * 30040 * * publication indexes : ` #d ` slugs plus NIP - 50 ` search `
* ( title , author , summary / description on index relays ) .
* /
export function buildLibraryPublicationRelaySearchFilters ( opts : {
/** Normalized needles for publication metadata tag match (d / title / author). */
export function publicationQueryNeedles ( query : string ) : string [ ] {
const raw = normalizeGeneralSearchQuery ( query . trim ( ) )
if ( ! raw ) return [ ]
const lower = raw . toLowerCase ( )
const normalized = lower . replace ( /\s+/g , ' ' ) . trim ( )
const hyphen = lower
. replace ( /\s+/g , '-' )
. replace ( /-+/g , '-' )
. replace ( /^-|-$/g , '' )
return [ . . . new Set ( [ lower , normalized , hyphen ] . filter ( Boolean ) ) ]
}
function publicationTagValueMatchesNeedles (
tagValue : string ,
needles : string [ ] ,
exactOnly : boolean
) : boolean {
const val = tagValue . trim ( ) . toLowerCase ( )
const valSpaced = val . replace ( /-/g , ' ' ) . replace ( /\s+/g , ' ' ) . trim ( )
for ( const needle of needles ) {
if ( ! needle ) continue
const needleSpaced = needle . replace ( /-/g , ' ' ) . replace ( /\s+/g , ' ' ) . trim ( )
if ( val === needle || valSpaced === needleSpaced ) return true
if ( exactOnly || needle . length < 2 ) continue
if ( val . includes ( needle ) || valSpaced . includes ( needleSpaced ) ) return true
}
return false
}
export function publicationMetadataTagMatchesQuery (
event : Event ,
tagName : 'd' | 'title' | 'author' ,
query : string
limit? : number
} ) : Filter [ ] {
) : boolean {
const needles = publicationQueryNeedles ( query )
if ( needles . length === 0 ) return false
const exactOnly = tagName === 'd'
for ( const tag of event . tags ? ? [ ] ) {
if ( ( tag [ 0 ] || '' ) . toLowerCase ( ) !== tagName ) continue
const value = tag [ 1 ] ? . trim ( )
if ( value && publicationTagValueMatchesNeedles ( value , needles , exactOnly ) ) return true
}
return false
}
function publicationRelaySearchSourceTerms ( query : string ) : string [ ] {
const raw = query . trim ( )
if ( ! raw ) return [ ]
const terms = new Set < string > ( [ raw ] )
const adv = parseAdvancedSearch ( raw )
if ( adv . title ) {
for ( const title of Array . isArray ( adv . title ) ? adv . title : [ adv . title ] ) {
const t = title . trim ( )
if ( t ) terms . add ( t )
}
}
return [ . . . terms ]
}
function publicationRelaySearchTermsForAxis (
axis : LibraryPublicationRelaySearchAxis ,
query : string
) : string [ ] {
const raw = query . trim ( )
if ( ! raw ) return [ ]
const adv = parseAdvancedSearch ( raw )
if ( axis === 'title' && adv . title ) {
const titles = Array . isArray ( adv . title ) ? adv . title : [ adv . title ]
const trimmed = titles . map ( ( t ) = > t . trim ( ) ) . filter ( Boolean )
if ( trimmed . length > 0 ) return [ . . . new Set ( trimmed ) ]
}
if ( axis === 'author' && adv . author ) {
const authors = Array . isArray ( adv . author ) ? adv . author : [ adv . author ]
const trimmed = authors . map ( ( a ) = > a . trim ( ) ) . filter ( Boolean )
if ( trimmed . length > 0 ) return [ . . . new Set ( trimmed ) ]
}
if ( axis === 'd-tag' ) {
return publicationRelaySearchSourceTerms ( raw )
}
return [ raw ]
}
function addPublicationKindFilter (
out : Filter [ ] ,
seen : Set < string > ,
filter : Filter
) {
const key = JSON . stringify ( filter )
if ( seen . has ( key ) ) return
seen . add ( key )
out . push ( filter )
}
/** One axis of kind-30040 relay discovery: `#d`, metadata title/author (HTTP), or `authors` for npub. */
export function buildLibraryPublicationRelaySearchFiltersForAxis (
axis : LibraryPublicationRelaySearchAxis ,
opts : { query : string ; limit? : number }
) : Filter [ ] {
const searchRaw = opts . query . trim ( )
if ( ! searchRaw ) return [ ]
@ -1757,67 +1964,162 @@ export function buildLibraryPublicationRelaySearchFilters(opts: {
@@ -1757,67 +1964,162 @@ export function buildLibraryPublicationRelaySearchFilters(opts: {
const kind = ExtendedKind . PUBLICATION
const seen = new Set < string > ( )
const out : Filter [ ] = [ ]
const add = ( filter : Filter ) = > {
const key = JSON . stringify ( filter )
if ( seen . has ( key ) ) return
seen . add ( key )
out . push ( filter )
if ( axis === 'author' ) {
const npub = tryNpubFromQuery ( searchRaw )
if ( npub ) {
addPublicationKindFilter ( out , seen , { kinds : [ kind ] , authors : [ npub ] , limit } )
return out
}
return out
}
const npub = tryNpubFromQuery ( searchRaw )
if ( npub ) {
add ( { kinds : [ kind ] , authors : [ npub ] , limit } )
if ( axis === 'title' ) {
return out
}
const dTags = publicationQueryDTagVariants ( searchRaw )
if ( dTags . length > 0 ) {
add ( { kinds : [ kind ] , '#d' : dTags , limit } )
if ( axis === 'd-tag' ) {
const dTags = new Set < string > ( )
for ( const term of publicationRelaySearchTermsForAxis ( 'd-tag' , searchRaw ) ) {
for ( const d of publicationQueryDTagVariants ( term ) ) dTags . add ( d )
}
if ( dTags . size === 0 ) return [ ]
addPublicationKindFilter ( out , seen , { kinds : [ kind ] , '#d' : [ . . . dTags ] , limit } )
return out
}
const searchNorm = normalizeGeneralSearchQuery ( searchRaw )
add ( { kinds : [ kind ] , search : searchRaw , limit } )
if ( searchNorm !== searchRaw ) {
add ( { kinds : [ kind ] , search : searchNorm , limit } )
return out
}
/ * *
* REQ filters for kind * * 30040 * * publication indexes , split by axis ( d - tag , title , author ) .
* Title and author text use HTTP metadata search ( not NIP - 50 ) . Only ` #d ` and pubkey ` authors ` use NIP - 01 filters .
* /
export function buildLibraryPublicationRelaySearchFilters ( opts : {
query : string
limit? : number
} ) : Filter [ ] {
const seen = new Set < string > ( )
const out : Filter [ ] = [ ]
for ( const axis of LIBRARY_PUBLICATION_RELAY_SEARCH_AXES ) {
for ( const filter of buildLibraryPublicationRelaySearchFiltersForAxis ( axis , opts ) ) {
const key = JSON . stringify ( filter )
if ( seen . has ( key ) ) continue
seen . add ( key )
out . push ( filter )
}
}
return out
}
const adv = parseAdvancedSearch ( searchRaw )
const titleValues = adv . title
? Array . isArray ( adv . title )
? adv . title
: [ adv . title ]
: [ ]
for ( const title of titleValues ) {
const t = title . trim ( )
if ( ! t ) continue
add ( { kinds : [ kind ] , search : t , limit } )
const titleDTags = publicationQueryDTagVariants ( t )
if ( titleDTags . length > 0 ) {
add ( { kinds : [ kind ] , '#d' : titleDTags , limit } )
export function filterEventsForPublicationRelaySearchAxis (
events : Event [ ] ,
axis : LibraryPublicationRelaySearchAxis ,
query : string
) : Event [ ] {
const terms = publicationRelaySearchTermsForAxis ( axis , query )
if ( terms . length === 0 ) return [ ]
return events . filter ( ( event ) = > {
if ( event . kind !== ExtendedKind . PUBLICATION ) return false
if ( axis === 'author' ) {
const npub = tryNpubFromQuery ( query . trim ( ) )
if ( npub && event . pubkey . toLowerCase ( ) === npub ) return true
}
const tagName = axis === 'd-tag' ? 'd' : axis
return terms . some ( ( term ) = > publicationMetadataTagMatchesQuery ( event , tagName , term ) )
} )
}
export function publicationIndexMatchesSearchQueryWithAxis (
event : Event ,
query : string ,
axis? : LibraryPublicationRelaySearchAxis | null
) : boolean {
if ( ! axis ) return publicationIndexMatchesSearchQuery ( event , query )
return filterEventsForPublicationRelaySearchAxis ( [ event ] , axis , query ) . length > 0
}
async function scanHttpIndexRelayForPublicationAxis (
httpRelay : string ,
axis : LibraryPublicationRelaySearchAxis ,
term : string
) : Promise < Event [ ] > {
const filter : Filter = { kinds : [ ExtendedKind . PUBLICATION ] , limit : INDEX_HTTP_PAGE_LIMIT }
const matched : Event [ ] = [ ]
const seen = new Set < string > ( )
const collect = ( batch : Event [ ] ) = > {
for ( const ev of filterEventsForPublicationRelaySearchAxis ( batch , axis , term ) ) {
if ( ! seen . has ( ev . id ) ) {
seen . add ( ev . id )
matched . push ( ev )
}
}
}
const authorValues = adv . author
? Array . isArray ( adv . author )
? adv . author
: [ adv . author ]
: [ ]
for ( const author of authorValues ) {
const a = author . trim ( )
if ( a ) add ( { kinds : [ kind ] , search : a , limit } )
let firstPage : Event [ ]
try {
firstPage = ( await queryIndexRelayForLibrary ( httpRelay , filter ) ) . events as Event [ ]
} catch ( e ) {
if ( import . meta . env . DEV ) {
logger . warn ( '[Library] HTTP publication scan first page failed' , {
relay : httpRelay ,
axis ,
message : e instanceof Error ? e.message : String ( e )
} )
}
return [ ]
}
const descriptionValues = adv . description
? Array . isArray ( adv . description )
? adv . description
: [ adv . description ]
: [ ]
for ( const description of descriptionValues ) {
const d = description . trim ( )
if ( d ) add ( { kinds : [ kind ] , search : d , limit } )
collect ( firstPage )
if ( matched . length >= LIBRARY_RELAY_SEARCH_LIMIT || firstPage . length === 0 ) {
return matched . slice ( 0 , LIBRARY_RELAY_SEARCH_LIMIT )
}
return out
let until = oldestCreatedAt ( firstPage ) - 1
for ( let page = 1 ; page < LIBRARY_RELAY_SEARCH_SCAN_MAX_PAGES ; page ++ ) {
if ( until < 0 ) break
let batch : Event [ ] = [ ]
let apiRowCount = 0
try {
const pageResult = await queryIndexRelayForLibrary ( httpRelay , { . . . filter , until } )
batch = pageResult . events as Event [ ]
apiRowCount = pageResult . apiRowCount
} catch ( e ) {
if ( import . meta . env . DEV ) {
logger . warn ( '[Library] HTTP publication scan page failed' , {
relay : httpRelay ,
axis ,
page ,
message : e instanceof Error ? e.message : String ( e )
} )
}
break
}
if ( apiRowCount === 0 ) break
collect ( batch )
if ( matched . length >= LIBRARY_RELAY_SEARCH_LIMIT ) break
if ( apiRowCount < INDEX_HTTP_PAGE_LIMIT ) break
const oldest = oldestCreatedAt ( batch )
if ( oldest === Number . MAX_SAFE_INTEGER ) break
until = oldest - 1
}
return matched . slice ( 0 , LIBRARY_RELAY_SEARCH_LIMIT )
}
async function searchHttpIndexRelayPublicationAxis (
httpRelay : string ,
axis : LibraryPublicationRelaySearchAxis ,
term : string
) : Promise < Event [ ] > {
const meta = await queryIndexRelayPublicationMetadataSearch ( httpRelay , term , {
limit : LIBRARY_RELAY_SEARCH_LIMIT
} )
const fromApi = filterEventsForPublicationRelaySearchAxis ( meta . events as Event [ ] , axis , term )
if ( fromApi . length > 0 ) return fromApi
return scanHttpIndexRelayForPublicationAxis ( httpRelay , axis , term )
}
/** Query document relays for kind-30040 indexes matching {@link buildLibraryPublicationRelaySearchFilters}. */
@ -1825,7 +2127,7 @@ export async function searchLibraryPublicationsOnRelays(
@@ -1825,7 +2127,7 @@ export async function searchLibraryPublicationsOnRelays(
query : string ,
relayUrls : string [ ] ,
context : LibrarySearchContext ,
options ? : { forceRefresh? : boolean }
options ? : { forceRefresh? : boolean ; axis? : LibraryPublicationRelaySearchAxis | null }
) : Promise < {
events : Event [ ]
entries : LibraryPublicationEntry [ ]
@ -1838,7 +2140,10 @@ export async function searchLibraryPublicationsOnRelays(
@@ -1838,7 +2140,10 @@ export async function searchLibraryPublicationsOnRelays(
}
if ( ! options ? . forceRefresh ) {
const cached = getLibrarySearchSessionRow ( q , context , { requireRelaySearch : true } )
const cached = getLibrarySearchSessionRow ( q , context , {
requireRelaySearch : true ,
axis : options?.axis
} )
if ( cached ) {
if ( import . meta . env . DEV ) {
logger . info ( '[Library] relay search cache hit' , { query : q } )
@ -1852,43 +2157,36 @@ export async function searchLibraryPublicationsOnRelays(
@@ -1852,43 +2157,36 @@ export async function searchLibraryPublicationsOnRelays(
}
}
const filters = buildLibraryPublicationRelaySearchFilters ( { query : q } )
if ( filters . length === 0 ) {
return { events : [ ] , entries : [ ] , mergedIndexEvents : context.indexEvents ? ? [ ] , fromCache : false }
}
const indexRelays = libraryIndexRelayUrls ( relayUrls )
const { wsRelays , httpRelays } = splitWsAndHttpRelays ( indexRelays )
const batches : Promise < Event [ ] > [ ] = [ ]
let filterCount = 0
const axes = options ? . axis ? [ options . axis ] : LIBRARY_PUBLICATION_RELAY_SEARCH_AXES
if ( wsRelays . length > 0 ) {
batches . push (
queryService
. fetchEvents ( wsRelays , filters , {
globalTimeout : LIBRARY_RELAY_SEARCH_TIMEOUT_MS ,
eoseTimeout : 8_000 ,
firstRelayResultGraceMs : false
} )
. catch ( ( e ) = > {
if ( import . meta . env . DEV ) {
logger . warn ( '[Library] WS publication search failed' , {
message : e instanceof Error ? e.message : String ( e )
} )
}
return [ ] as Event [ ]
} )
)
}
for ( const axis of axes ) {
const npubQuery = tryNpubFromQuery ( q )
if ( npubQuery && axis !== 'author' ) continue
for ( const httpRelay of httpRelays ) {
for ( const filter of filters ) {
const axisFilters = buildLibraryPublicationRelaySearchFiltersForAxis ( axis , { query : q } )
const hasNip01Filters = axisFilters . length > 0
const hasMetadataSearch = axis === 'title' || ( axis === 'author' && ! npubQuery )
if ( ! hasNip01Filters && ! hasMetadataSearch ) continue
filterCount += axisFilters . length
if ( wsRelays . length > 0 && hasNip01Filters ) {
batches . push (
queryIndexRelayPublicationSearch ( httpRelay , filter )
. then ( ( page ) = > page . events as Event [ ] )
queryService
. fetchEvents ( wsRelays , axisFilters , {
globalTimeout : LIBRARY_RELAY_SEARCH_TIMEOUT_MS ,
eoseTimeout : 8_000 ,
firstRelayResultGraceMs : false
} )
. then ( ( events ) = > filterEventsForPublicationRelaySearchAxis ( events , axis , q ) )
. catch ( ( e ) = > {
if ( import . meta . env . DEV ) {
logger . warn ( '[Library] HTTP publication search failed' , {
relay : httpRelay ,
logger . warn ( '[Library] WS publication search failed' , {
axis ,
message : e instanceof Error ? e.message : String ( e )
} )
}
@ -1896,6 +2194,49 @@ export async function searchLibraryPublicationsOnRelays(
@@ -1896,6 +2194,49 @@ export async function searchLibraryPublicationsOnRelays(
} )
)
}
for ( const httpRelay of httpRelays ) {
if ( hasNip01Filters ) {
for ( const filter of axisFilters ) {
batches . push (
queryIndexRelayForLibrary ( httpRelay , filter )
. then ( ( page ) = > filterEventsForPublicationRelaySearchAxis ( page . events as Event [ ] , axis , q ) )
. catch ( ( e ) = > {
if ( import . meta . env . DEV ) {
logger . warn ( '[Library] HTTP publication filter search failed' , {
relay : httpRelay ,
axis ,
message : e instanceof Error ? e.message : String ( e )
} )
}
return [ ] as Event [ ]
} )
)
}
}
if ( ! hasMetadataSearch ) continue
for ( const term of publicationRelaySearchTermsForAxis ( axis , q ) ) {
filterCount += 1
batches . push (
searchHttpIndexRelayPublicationAxis ( httpRelay , axis , term ) . catch ( ( e ) = > {
if ( import . meta . env . DEV ) {
logger . warn ( '[Library] HTTP publication metadata search failed' , {
relay : httpRelay ,
axis ,
message : e instanceof Error ? e.message : String ( e )
} )
}
return [ ] as Event [ ]
} )
)
}
}
}
if ( batches . length === 0 ) {
return { events : [ ] , entries : [ ] , mergedIndexEvents : context.indexEvents ? ? [ ] , fromCache : false }
}
const settled = await Promise . all ( batches )
@ -1908,7 +2249,7 @@ export async function searchLibraryPublicationsOnRelays(
@@ -1908,7 +2249,7 @@ export async function searchLibraryPublicationsOnRelays(
void persistLibraryIndexCacheEvents ( mergedIndex )
}
const indexByAddress = buildIndexByAddress ( mergedIndex )
const roots = searchLibraryPublicationIndex ( q , mergedIndex , indexByAddress )
const roots = searchLibraryPublicationIndex ( q , mergedIndex , indexByAddress , options ? . axis )
const engagement = context . engagement ? ? EMPTY_ENGAGEMENT
const entries = sortLibraryPublications (
libraryEntriesFromRoots ( roots , indexByAddress , engagement )
@ -1918,15 +2259,22 @@ export async function searchLibraryPublicationsOnRelays(
@@ -1918,15 +2259,22 @@ export async function searchLibraryPublicationsOnRelays(
indexEvents : mergedIndex ,
engagement
}
putLibrarySearchSessionRow ( q , searchContext , {
entries ,
mergedIndexEvents : mergedIndex ,
relaySearched : true
} )
putLibrarySearchSessionRow (
q ,
searchContext ,
{
entries ,
mergedIndexEvents : mergedIndex ,
relaySearched : true
} ,
options ? . axis
)
if ( import . meta . env . DEV ) {
logger . info ( '[Library] relay search done' , {
filters : filters.length ,
axes : LIBRARY_PUBLICATION_RELAY_SEARCH_AXES.length ,
filters : filterCount ,
batches : batches.length ,
network : networkEvents.length ,
valid : valid.length ,
roots : roots.length
@ -1977,19 +2325,66 @@ function collectTargetAddressesFromIndexes(
@@ -1977,19 +2325,66 @@ function collectTargetAddressesFromIndexes(
indexEvents : Event [ ] ,
indexByAddress : Map < string , Event >
) : Set < string > {
return collectEngagementTargets ( indexEvents , indexByAddress ) . addresses
}
/** Capped address + event-id targets for label/comment/highlight relay queries. */
export function collectEngagementTargets (
indexEvents : Event [ ] ,
indexByAddress : Map < string , Event >
) : { addresses : Set < string > ; eventIds : Set < string > } {
const addresses = new Set < string > ( )
const eventIds = new Set < string > ( )
outer : for ( const root of getTopLevelIndexEvents ( indexEvents ) ) {
eventIds . add ( root . id . toLowerCase ( ) )
if ( eventIds . size >= MAX_TARGET_EVENT_IDS ) break
for ( const addr of collectReachableAddressesCached ( root , indexByAddress ) ) {
addresses . add ( addr )
if ( addresses . size >= MAX_TARGET_ADDRESSES ) break outer
const indexed = indexByAddress . get ( addr )
if ( indexed ) eventIds . add ( indexed . id . toLowerCase ( ) )
if ( addresses . size >= MAX_TARGET_ADDRESSES || eventIds . size >= MAX_TARGET_EVENT_IDS ) {
break outer
}
}
const rootAddr = eventTagAddress ( root )
if ( rootAddr ) {
addresses . add ( rootAddr )
if ( addresses . size >= MAX_TARGET_ADDRESSES ) break outer
if ( addresses . size >= MAX_TARGET_ADDRESSES || eventIds . size >= MAX_TARGET_EVENT_IDS ) {
break outer
}
}
}
return addresses
return { addresses , eventIds }
}
function limitEngagementChunks < T > ( chunks : T [ ] [ ] ) : T [ ] [ ] {
return chunks . length <= MAX_ENGAGEMENT_HTTP_CHUNKS
? chunks
: chunks . slice ( 0 , MAX_ENGAGEMENT_HTTP_CHUNKS )
}
async function withEngagementTimeout < T > (
promise : Promise < T > ,
fallback : T ,
label : string
) : Promise < T > {
let timer : ReturnType < typeof setTimeout > | undefined
try {
return await Promise . race ( [
promise ,
new Promise < T > ( ( resolve ) = > {
timer = setTimeout ( ( ) = > {
if ( import . meta . env . DEV ) {
logger . warn ( '[Library] engagement fetch timed out' , { label , ms : ENGAGEMENT_FETCH_TIMEOUT_MS } )
}
resolve ( fallback )
} , ENGAGEMENT_FETCH_TIMEOUT_MS )
} )
] )
} finally {
if ( timer ) clearTimeout ( timer )
}
}
async function buildEngagedFromCache (
@ -2002,8 +2397,10 @@ async function buildEngagedFromCache(
@@ -2002,8 +2397,10 @@ async function buildEngagedFromCache(
const topLevel = getTopLevelIndexEvents ( indexEvents )
let maps = engagement
if ( ! maps ) {
const targetAddresses = collectTargetAddressesFromIndexes ( indexEvents , indexByAddress )
const targetEventIds = collectPublicationIndexEventIds ( indexEvents )
const { addresses : targetAddresses , eventIds : targetEventIds } = collectEngagementTargets (
indexEvents ,
indexByAddress
)
const engagementRelayUrls = await buildLibraryEngagementRelayUrls (
viewerPubkey ? ? undefined ,
indexRelayUrls ,
@ -2080,11 +2477,10 @@ async function runLibraryPublicationIndexLoad(
@@ -2080,11 +2477,10 @@ async function runLibraryPublicationIndexLoad(
if ( ! options ? . forceRefresh && sessionCache ? . relayKey === key ) {
const cachedIndexEvents = indexEventsFromCache ( sessionCache )
if ( sessionCache . viewerPubkey !== viewerPubkey ) {
const targetAddresses = collectTargetAddressesFromIndexe s (
const { addresses : targetAddresses , eventIds : targetEventIds } = collectEngagementTarget s (
cachedIndexEvents ,
sessionCache . indexByAddress
)
const targetEventIds = collectPublicationIndexEventIds ( cachedIndexEvents )
const engagementRelayUrls = await buildLibraryEngagementRelayUrls (
viewerPubkey ? ? undefined ,
relayUrls ,
@ -2148,8 +2544,10 @@ async function runLibraryPublicationIndexLoad(
@@ -2148,8 +2544,10 @@ async function runLibraryPublicationIndexLoad(
}
topLevel = getTopLevelIndexEventsFromMap ( indexByAddress )
const targetAddresses = collectTargetAddressesFromIndexes ( indexEvents , indexByAddress )
const targetEventIds = collectPublicationIndexEventIds ( indexEvents )
const { addresses : targetAddresses , eventIds : targetEventIds } = collectEngagementTargets (
indexEvents ,
buildIndexByAddress ( indexEvents )
)
if ( import . meta . env . DEV ) {
logger . info ( '[Library] fetching engagement' , {
targetAddresses : targetAddresses.size ,
@ -2210,7 +2608,7 @@ export function clearLibraryPublicationIndexCache(): void {
@@ -2210,7 +2608,7 @@ export function clearLibraryPublicationIndexCache(): void {
clearLibrarySearchSessionCache ( )
}
/** Clears Library tab session + IDB index cache only (publication reading cache is unchanged ). */
/** Clears Library tab session cache and relay-discovered catalog masters (opened publications stay ). */
export async function clearAllLibraryIndexCaches ( ) : Promise < void > {
sessionCache = null
indexLoadJob = null