@ -38,6 +38,17 @@ export interface LoadEventIndexResult {
missingEvents : MissingEventInfo [ ] ;
missingEvents : MissingEventInfo [ ] ;
}
}
/ * *
* Chunk an array into batches of specified size
* /
function chunkArray < T > ( array : T [ ] , chunkSize : number ) : T [ ] [ ] {
const chunks : T [ ] [ ] = [ ] ;
for ( let i = 0 ; i < array . length ; i += chunkSize ) {
chunks . push ( array . slice ( i , i + chunkSize ) ) ;
}
return chunks ;
}
/ * *
/ * *
* Internal recursive function to load event index hierarchy
* Internal recursive function to load event index hierarchy
* @param opEvent The kind 30040 event to load
* @param opEvent The kind 30040 event to load
@ -122,85 +133,193 @@ async function loadEventIndexRecursive(
// Track which event IDs are already loaded by a-tags (a-tags take precedence)
// Track which event IDs are already loaded by a-tags (a-tags take precedence)
const eventIdsLoadedByATags = new Set < string > ( ) ;
const eventIdsLoadedByATags = new Set < string > ( ) ;
// Batch fetch all a-tags and e-tags in parallel
// Load events by address (a-tags) - per NKBIP-01, a-tags are the standard method
// Load events by address (a-tags) - per NKBIP-01, a-tags are the standard method
// Format: ["a", "<kind:pubkey:dtag>", "<relay hint>", "<event id>"]
// Format: ["a", "<kind:pubkey:dtag>", "<relay hint>", "<event id>"]
if ( aTags . length > 0 ) {
for ( const aTagInfo of aTags ) {
const parts = aTagInfo . address . split ( ':' ) ;
// Collect all event IDs from a-tags for batch fetching
const aTagEventIds : string [ ] = [ ] ;
const aTagEventIdMap = new Map < string , ATagInfo > ( ) ; // eventId -> aTagInfo
// Collect all address queries for a-tags (group by kind/pubkey/d-tag)
const addressQueries : Array < { kind : number ; pubkey : string ; dTag : string ; aTagInfo : ATagInfo } > = [ ] ;
for ( const aTagInfo of aTags ) {
const parts = aTagInfo . address . split ( ':' ) ;
if ( parts . length === 3 ) {
const kind = parseInt ( parts [ 0 ] , 10 ) ;
const pubkey = parts [ 1 ] ;
const dTag = parts [ 2 ] ;
if ( ! isNaN ( kind ) && pubkey && dTag ) {
// If event ID is provided, add to batch fetch list
if ( aTagInfo . eventId ) {
aTagEventIds . push ( aTagInfo . eventId ) ;
aTagEventIdMap . set ( aTagInfo . eventId , aTagInfo ) ;
}
// Also add address query
addressQueries . push ( { kind , pubkey , dTag , aTagInfo } ) ;
}
}
}
// Collect all relay hints from a-tags
const relayHintsForATags = new Set < string > ( ) ;
for ( const aTagInfo of aTags ) {
if ( aTagInfo . relayHint ) {
relayHintsForATags . add ( aTagInfo . relayHint ) ;
}
}
const allRelaysForATags = relayHintsForATags . size > 0
? [ . . . new Set ( [ . . . Array . from ( relayHintsForATags ) , . . . relays ] ) ]
: relays ;
// Batch fetch all a-tag event IDs in parallel (chunked to 100 per batch)
const BATCH_SIZE = 100 ;
const aTagEventsById : NostrEvent [ ] = [ ] ;
if ( aTagEventIds . length > 0 ) {
const aTagEventIdChunks = chunkArray ( aTagEventIds , BATCH_SIZE ) ;
const aTagFetchPromises = aTagEventIdChunks . map ( chunk = >
nostrClient . fetchEvents (
[ { ids : chunk , limit : chunk.length } ] ,
allRelaysForATags ,
{ useCache : true , cacheResults : true }
)
) ;
const aTagFetchResults = await Promise . all ( aTagFetchPromises ) ;
for ( const result of aTagFetchResults ) {
aTagEventsById . push ( . . . result ) ;
}
}
// Group address queries by (kind, pubkey) to batch fetch
const addressQueryGroups = new Map < string , Array < { dTag : string ; aTagInfo : ATagInfo } > > ( ) ;
for ( const query of addressQueries ) {
const key = ` ${ query . kind } : ${ query . pubkey } ` ;
if ( ! addressQueryGroups . has ( key ) ) {
addressQueryGroups . set ( key , [ ] ) ;
}
addressQueryGroups . get ( key ) ! . push ( { dTag : query.dTag , aTagInfo : query.aTagInfo } ) ;
}
// Batch fetch all addresses in parallel (chunked to 100 per batch)
const addressFetchPromises : Promise < NostrEvent [ ] > [ ] = [ ] ;
const addressFetchMap = new Map < string , ATagInfo > ( ) ; // query key -> aTagInfo
for ( const [ key , queries ] of addressQueryGroups . entries ( ) ) {
const [ kindStr , pubkey ] = key . split ( ':' ) ;
const kind = parseInt ( kindStr , 10 ) ;
// Chunk queries into batches of 100
const queryChunks = chunkArray ( queries , BATCH_SIZE ) ;
for ( const queryChunk of queryChunks ) {
const dTags = queryChunk . map ( q = > q . dTag ) ;
// Create fetch promise for this chunk
const fetchPromise = nostrClient . fetchEvents (
[ { kinds : [ kind ] , authors : [ pubkey ] , '#d' : dTags , limit : dTags.length } ] ,
allRelaysForATags ,
{ useCache : true , cacheResults : true }
) ;
addressFetchPromises . push ( fetchPromise ) ;
// Store mapping for each d-tag in this chunk
for ( const query of queryChunk ) {
addressFetchMap . set ( ` ${ key } : ${ query . dTag } ` , query . aTagInfo ) ;
}
}
}
// Wait for all address fetches in parallel
const addressFetchResults = await Promise . all ( addressFetchPromises ) ;
const allAddressEvents : NostrEvent [ ] = [ ] ;
for ( const result of addressFetchResults ) {
allAddressEvents . push ( . . . result ) ;
}
// Process a-tag results
const aTagResults = new Map < string , NostrEvent > ( ) ; // address or eventId -> event
// Process events fetched by ID
for ( const event of aTagEventsById ) {
const aTagInfo = aTagEventIdMap . get ( event . id ) ;
if ( aTagInfo ) {
const parts = aTagInfo . address . split ( ':' ) ;
if ( parts . length === 3 ) {
if ( parts . length === 3 ) {
const kind = parseInt ( parts [ 0 ] , 10 ) ;
const kind = parseInt ( parts [ 0 ] , 10 ) ;
const pubkey = parts [ 1 ] ;
const pubkey = parts [ 1 ] ;
const dTag = parts [ 2 ] ;
const dTag = parts [ 2 ] ;
// Verify the event matches the address
if ( event . kind === kind &&
event . pubkey === pubkey &&
event . tags . some ( t = > t [ 0 ] === 'd' && t [ 1 ] === dTag ) ) {
aTagResults . set ( aTagInfo . address , event ) ;
eventIdsLoadedByATags . add ( event . id ) ;
}
}
}
}
if ( ! isNaN ( kind ) && pubkey && dTag ) {
// Process events fetched by address
let event : NostrEvent | undefined ;
for ( const event of allAddressEvents ) {
const dTag = event . tags . find ( t = > t [ 0 ] === 'd' ) ? . [ 1 ] ;
// If event ID is provided in a-tag (4th element), try to fetch by ID first for version tracking
if ( dTag ) {
if ( aTagInfo . eventId ) {
const key = ` ${ event . kind } : ${ event . pubkey } : ${ dTag } ` ;
const eventsById = await nostrClient . fetchEvents (
const aTagInfo = addressFetchMap . get ( key ) ;
[ { ids : [ aTagInfo . eventId ] , limit : 1 } ] ,
if ( aTagInfo && ! aTagResults . has ( aTagInfo . address ) ) {
aTagInfo . relayHint ? [ aTagInfo . relayHint ] : relays ,
// Get newest version if multiple found
{ useCache : true , cacheResults : true }
const existing = aTagResults . get ( aTagInfo . address ) ;
) ;
if ( ! existing || event . created_at > existing . created_at ) {
aTagResults . set ( aTagInfo . address , event ) ;
if ( eventsById . length > 0 ) {
eventIdsLoadedByATags . add ( event . id ) ;
const fetchedEvent = eventsById [ 0 ] ;
}
// Verify the event matches the address (kind, pubkey, d-tag)
}
if ( fetchedEvent . kind === kind &&
}
fetchedEvent . pubkey === pubkey &&
}
fetchedEvent . tags . some ( t = > t [ 0 ] === 'd' && t [ 1 ] === dTag ) ) {
event = fetchedEvent ;
}
}
}
// If not found by event ID, or no event ID provided, fetch by address
// Process all a-tag results
if ( ! event ) {
console . log ( ` [EventIndex] Processing ${ aTags . length } a-tags, found ${ aTagResults . size } events ` ) ;
const fetchRelays = aTagInfo . relayHint ? [ aTagInfo . relayHint , . . . relays ] : relays ;
for ( const aTagInfo of aTags ) {
const events = await nostrClient . fetchEvents (
const parts = aTagInfo . address . split ( ':' ) ;
[ { kinds : [ kind ] , authors : [ pubkey ] , '#d' : [ dTag ] , limit : 1 } ] ,
if ( parts . length === 3 ) {
fetchRelays ,
const kind = parseInt ( parts [ 0 ] , 10 ) ;
{ useCache : true , cacheResults : true }
const pubkey = parts [ 1 ] ;
) ;
const dTag = parts [ 2 ] ;
if ( events . length > 0 ) {
if ( ! isNaN ( kind ) && pubkey && dTag ) {
// Get newest version (for replaceable events)
const event = aTagResults . get ( aTagInfo . address ) ;
event = events . sort ( ( a , b ) = > b . created_at - a . created_at ) [ 0 ] ;
}
}
if ( event ) {
if ( event ) {
// Check if this event is also a kind 30040 (nested index)
console . log ( ` [EventIndex] Processing event ${ event . id } , kind ${ event . kind } , level ${ level } ` ) ;
if ( event . kind === 30040 ) {
// Check if this event is also a kind 30040 (nested index)
// Recursively load nested index
if ( event . kind === 30040 ) {
const nestedResult = await loadEventIndexRecursive ( event , level + 1 , maxDepth ) ;
console . log ( ` [EventIndex] Found kind 30040 event ${ event . id } at level ${ level } , loading children... ` ) ;
// Create a parent item with children
// Recursively load nested index
const parentItem : EventIndexItem = {
const nestedResult = await loadEventIndexRecursive ( event , level + 1 , maxDepth ) ;
event ,
console . log ( ` [EventIndex] Loaded nested index ${ event . id } with ${ nestedResult . items . length } children at level ${ level + 1 } ` , nestedResult . items . map ( i = > ( { id : i.event.id , kind : i.event.kind , level : i.level } ) ) ) ;
order : aTagInfo.order ,
// Create a parent item with children
level ,
const parentItem : EventIndexItem = {
children : nestedResult.items
event ,
} ;
order : aTagInfo.order ,
items . push ( parentItem ) ;
level ,
// Merge missing events from nested index
children : nestedResult.items
missingEvents . push ( . . . nestedResult . missingEvents ) ;
} ;
} else {
items . push ( parentItem ) ;
// Regular event (content section)
// Merge missing events from nested index
items . push ( { event , order : aTagInfo.order , level } ) ;
missingEvents . push ( . . . nestedResult . missingEvents ) ;
}
loadedAddresses . add ( aTagInfo . address ) ;
if ( aTagInfo . eventId ) {
loadedEventIds . add ( aTagInfo . eventId ) ;
eventIdsLoadedByATags . add ( aTagInfo . eventId ) ;
}
// Also track by event ID if we have it
eventIdsLoadedByATags . add ( event . id ) ;
} else {
} else {
missingAddresses . push ( aTagInfo . address ) ;
// Regular event (content section)
missingEvents . push ( { dTag , order : aTagInfo.order , type : 'a-tag' } ) ;
items . push ( { event , order : aTagInfo.order , level } ) ;
console . warn ( ` [EventIndex] Missing event referenced by a-tag: ${ aTagInfo . address } (d-tag: ${ dTag } ) ` ) ;
}
loadedAddresses . add ( aTagInfo . address ) ;
if ( aTagInfo . eventId ) {
loadedEventIds . add ( aTagInfo . eventId ) ;
}
}
} else {
missingAddresses . push ( aTagInfo . address ) ;
missingEvents . push ( { dTag , order : aTagInfo.order , type : 'a-tag' } ) ;
console . warn ( ` [EventIndex] Missing event referenced by a-tag: ${ aTagInfo . address } (d-tag: ${ dTag } ) ` ) ;
}
}
}
}
}
}
@ -223,15 +342,24 @@ async function loadEventIndexRecursive(
}
}
}
}
// Fetch all e-tag events at on ce
// Fetch all e-tag events in b atch es of 100
const allRelays = relayHintsForETags . size > 0
const allRelays = relayHintsForETags . size > 0
? [ . . . new Set ( [ . . . Array . from ( relayHintsForETags ) , . . . relays ] ) ]
? [ . . . new Set ( [ . . . Array . from ( relayHintsForETags ) , . . . relays ] ) ]
: relays ;
: relays ;
const eventsById = await nostrClient . fetchEvents (
[ { ids : eTagIdsToLoad , limit : eTagIdsToLoad.length } ] ,
const eTagIdChunks = chunkArray ( eTagIdsToLoad , BATCH_SIZE ) ;
allRelays ,
const eTagFetchPromises = eTagIdChunks . map ( chunk = >
{ useCache : true , cacheResults : true }
nostrClient . fetchEvents (
[ { ids : chunk , limit : chunk.length } ] ,
allRelays ,
{ useCache : true , cacheResults : true }
)
) ;
) ;
const eTagFetchResults = await Promise . all ( eTagFetchPromises ) ;
const eventsById : NostrEvent [ ] = [ ] ;
for ( const result of eTagFetchResults ) {
eventsById . push ( . . . result ) ;
}
for ( const eTagInfo of eTags ) {
for ( const eTagInfo of eTags ) {
// Skip if already loaded by a-tag
// Skip if already loaded by a-tag
@ -396,6 +524,9 @@ async function loadEventIndexRecursive(
items . sort ( ( a , b ) = > a . order - b . order ) ;
items . sort ( ( a , b ) = > a . order - b . order ) ;
missingEvents . sort ( ( a , b ) = > a . order - b . order ) ;
missingEvents . sort ( ( a , b ) = > a . order - b . order ) ;
const itemsWithChildren = items . filter ( item = > item . children && item . children . length > 0 ) ;
console . log ( ` [EventIndex] Returning ${ items . length } items ( ${ itemsWithChildren . length } with children) at level ${ level } ` ) ;
return { items , missingEvents } ;
return { items , missingEvents } ;
}
}