query-events.go raw
1 package neo4j
2
3 import (
4 "context"
5 stdhex "encoding/hex"
6 "fmt"
7 "math"
8 "sort"
9 "strconv"
10 "strings"
11 "time"
12
13 "next.orly.dev/pkg/nostr/encoders/event"
14 "next.orly.dev/pkg/nostr/encoders/filter"
15 "next.orly.dev/pkg/nostr/encoders/hex"
16 "next.orly.dev/pkg/nostr/encoders/kind"
17 "next.orly.dev/pkg/nostr/encoders/tag"
18 "next.orly.dev/pkg/lol/log"
19 "next.orly.dev/pkg/database"
20 "next.orly.dev/pkg/database/indexes/types"
21 "next.orly.dev/pkg/interfaces/store"
22 )
23
24 // QueryEvents retrieves events matching the given filter
25 func (n *N) QueryEvents(c context.Context, f *filter.F) (evs event.S, err error) {
26 log.T.F("Neo4j QueryEvents called with filter: kinds=%v, authors=%d, tags=%v",
27 f.Kinds != nil, f.Authors != nil && len(f.Authors.T) > 0, f.Tags != nil)
28 return n.QueryEventsWithOptions(c, f, false, false)
29 }
30
31 // QueryAllVersions retrieves all versions of events matching the filter
32 func (n *N) QueryAllVersions(c context.Context, f *filter.F) (evs event.S, err error) {
33 return n.QueryEventsWithOptions(c, f, false, true)
34 }
35
36 // QueryEventsWithOptions retrieves events with specific options
37 func (n *N) QueryEventsWithOptions(
38 c context.Context, f *filter.F, includeDeleteEvents bool, showAllVersions bool,
39 ) (evs event.S, err error) {
40 // NIP-50 search queries use a separate path with relevance scoring
41 if len(f.Search) > 0 {
42 return n.QuerySearchEvents(c, f)
43 }
44
45 // Build Cypher query from Nostr filter
46 cypher, params := n.buildCypherQuery(f, includeDeleteEvents)
47
48 // Execute query
49 result, err := n.ExecuteRead(c, cypher, params)
50 if err != nil {
51 return nil, fmt.Errorf("failed to execute query: %w", err)
52 }
53
54 // Parse response
55 allEvents, err := n.parseEventsFromResult(result)
56 if err != nil {
57 return nil, fmt.Errorf("failed to parse events: %w", err)
58 }
59
60 // Filter replaceable events to only return the latest version
61 // unless showAllVersions is true
62 if showAllVersions {
63 return allEvents, nil
64 }
65
66 // Separate events by type and filter replaceables
67 replaceableEvents := make(map[string]*event.E) // key: pubkey:kind
68 paramReplaceableEvents := make(map[string]map[string]*event.E) // key: pubkey:kind -> d-tag -> event
69 var regularEvents event.S
70
71 for _, ev := range allEvents {
72 if kind.IsReplaceable(ev.Kind) {
73 // For replaceable events, keep only the latest per pubkey:kind
74 key := hex.Enc(ev.Pubkey) + ":" + strconv.Itoa(int(ev.Kind))
75 existing, exists := replaceableEvents[key]
76 if !exists || ev.CreatedAt > existing.CreatedAt {
77 replaceableEvents[key] = ev
78 }
79 } else if kind.IsParameterizedReplaceable(ev.Kind) {
80 // For parameterized replaceable events, keep only the latest per pubkey:kind:d-tag
81 key := hex.Enc(ev.Pubkey) + ":" + strconv.Itoa(int(ev.Kind))
82
83 // Get the 'd' tag value
84 dTag := ev.Tags.GetFirst([]byte("d"))
85 var dValue string
86 if dTag != nil && dTag.Len() > 1 {
87 dValue = string(dTag.Value())
88 }
89
90 // Initialize inner map if needed
91 if _, exists := paramReplaceableEvents[key]; !exists {
92 paramReplaceableEvents[key] = make(map[string]*event.E)
93 }
94
95 // Keep only the newest version
96 existing, exists := paramReplaceableEvents[key][dValue]
97 if !exists || ev.CreatedAt > existing.CreatedAt {
98 paramReplaceableEvents[key][dValue] = ev
99 }
100 } else {
101 regularEvents = append(regularEvents, ev)
102 }
103 }
104
105 // Combine results
106 evs = make(event.S, 0, len(replaceableEvents)+len(paramReplaceableEvents)+len(regularEvents))
107
108 for _, ev := range replaceableEvents {
109 evs = append(evs, ev)
110 }
111
112 for _, innerMap := range paramReplaceableEvents {
113 for _, ev := range innerMap {
114 evs = append(evs, ev)
115 }
116 }
117
118 evs = append(evs, regularEvents...)
119
120 // Re-sort by timestamp (newest first)
121 sort.Slice(evs, func(i, j int) bool {
122 return evs[i].CreatedAt > evs[j].CreatedAt
123 })
124
125 // Re-apply limit after filtering
126 if f.Limit != nil && len(evs) > int(*f.Limit) {
127 evs = evs[:*f.Limit]
128 }
129
130 return evs, nil
131 }
132
133 // buildCypherQuery constructs a Cypher query from a Nostr filter
134 // This is the core translation layer between Nostr's REQ filter format and Neo4j's Cypher
135 func (n *N) buildCypherQuery(f *filter.F, includeDeleteEvents bool) (string, map[string]any) {
136 // NIP-50 search: delegate to word-based graph query
137 if len(f.Search) > 0 {
138 return n.buildSearchCypherQuery(f, includeDeleteEvents)
139 }
140
141 params := make(map[string]any)
142 var whereClauses []string
143
144 // Start with basic MATCH clause
145 matchClause := "MATCH (e:Event)"
146
147 // IDs filter - uses exact match or prefix matching
148 // Note: IDs can be either binary (32 bytes) or hex strings (64 chars)
149 // We need to normalize to lowercase hex for consistent Neo4j matching
150 if f.Ids != nil && len(f.Ids.T) > 0 {
151 idConditions := make([]string, 0, len(f.Ids.T))
152 for i, id := range f.Ids.T {
153 if len(id) == 0 {
154 continue // Skip empty IDs
155 }
156 paramName := fmt.Sprintf("id_%d", i)
157
158 // Normalize to lowercase hex using our utility function
159 // This handles both binary-encoded IDs and hex string IDs (including uppercase)
160 hexID := NormalizePubkeyHex(id)
161 if hexID == "" {
162 continue
163 }
164
165 // Handle prefix matching for partial IDs
166 // After normalization, check hex length (should be 64 for full ID)
167 if len(hexID) < 64 {
168 idConditions = append(idConditions, fmt.Sprintf("e.id STARTS WITH $%s", paramName))
169 } else {
170 idConditions = append(idConditions, fmt.Sprintf("e.id = $%s", paramName))
171 }
172 params[paramName] = hexID
173 }
174 if len(idConditions) > 0 {
175 whereClauses = append(whereClauses, "("+strings.Join(idConditions, " OR ")+")")
176 }
177 }
178
179 // Authors filter - supports prefix matching for partial pubkeys
180 // Note: Authors can be either binary (32 bytes) or hex strings (64 chars)
181 // We need to normalize to lowercase hex for consistent Neo4j matching
182 if f.Authors != nil && len(f.Authors.T) > 0 {
183 authorConditions := make([]string, 0, len(f.Authors.T))
184 for i, author := range f.Authors.T {
185 if len(author) == 0 {
186 continue // Skip empty authors
187 }
188 paramName := fmt.Sprintf("author_%d", i)
189
190 // Normalize to lowercase hex using our utility function
191 // This handles both binary-encoded pubkeys and hex string pubkeys (including uppercase)
192 hexAuthor := NormalizePubkeyHex(author)
193 log.T.F("Neo4j author filter: raw_len=%d, normalized=%q", len(author), hexAuthor)
194 if hexAuthor == "" {
195 continue
196 }
197
198 // Handle prefix matching for partial pubkeys
199 // After normalization, check hex length (should be 64 for full pubkey)
200 if len(hexAuthor) < 64 {
201 authorConditions = append(authorConditions, fmt.Sprintf("e.pubkey STARTS WITH $%s", paramName))
202 } else {
203 authorConditions = append(authorConditions, fmt.Sprintf("e.pubkey = $%s", paramName))
204 }
205 params[paramName] = hexAuthor
206 }
207 if len(authorConditions) > 0 {
208 whereClauses = append(whereClauses, "("+strings.Join(authorConditions, " OR ")+")")
209 }
210 }
211
212 // Kinds filter - matches event types
213 if f.Kinds != nil && len(f.Kinds.K) > 0 {
214 kinds := make([]int64, len(f.Kinds.K))
215 for i, k := range f.Kinds.K {
216 kinds[i] = int64(k.K)
217 }
218 params["kinds"] = kinds
219 whereClauses = append(whereClauses, "e.kind IN $kinds")
220 }
221
222 // Time range filters - for temporal queries
223 // Note: Check both pointer and value - a zero timestamp (Unix epoch 1970) is almost
224 // certainly not a valid constraint as Nostr events didn't exist then
225 if f.Since != nil && f.Since.V > 0 {
226 params["since"] = f.Since.V
227 whereClauses = append(whereClauses, "e.created_at >= $since")
228 }
229 if f.Until != nil && f.Until.V > 0 {
230 params["until"] = f.Until.V
231 whereClauses = append(whereClauses, "e.created_at <= $until")
232 }
233
234 // Tag filters - this is where Neo4j's graph capabilities shine
235 // We use EXISTS subqueries to efficiently filter events by tags
236 // This ensures events are only returned if they have matching tags
237 tagIndex := 0
238 if f.Tags != nil {
239 for _, tagValues := range *f.Tags {
240 if len(tagValues.T) > 0 {
241 tagTypeParam := fmt.Sprintf("tagType_%d", tagIndex)
242 tagValuesParam := fmt.Sprintf("tagValues_%d", tagIndex)
243
244 // The first element is the tag type (e.g., "e", "p", "#e", "#p", etc.)
245 // Filter tags may have "#" prefix (e.g., "#d" for d-tag filters)
246 // Event tags are stored without prefix, so we must strip it
247 tagTypeBytes := tagValues.T[0]
248 var tagType string
249 if len(tagTypeBytes) > 0 && tagTypeBytes[0] == '#' {
250 tagType = string(tagTypeBytes[1:]) // Strip "#" prefix
251 } else {
252 tagType = string(tagTypeBytes)
253 }
254
255 log.T.F("Neo4j tag filter: type=%q (raw=%q, len=%d)", tagType, string(tagTypeBytes), len(tagTypeBytes))
256
257 // Convert remaining tag values to strings (skip first element which is the type)
258 // For e/p tags, use NormalizePubkeyHex to handle binary encoding and uppercase hex
259 tagValueStrings := make([]string, 0, len(tagValues.T)-1)
260 for _, tv := range tagValues.T[1:] {
261 if tagType == "e" || tagType == "p" {
262 // Normalize e/p tag values to lowercase hex (handles binary encoding)
263 normalized := NormalizePubkeyHex(tv)
264 log.T.F("Neo4j tag filter: %s-tag value normalized: %q (raw len=%d, binary=%v)",
265 tagType, normalized, len(tv), IsBinaryEncoded(tv))
266 if normalized != "" {
267 tagValueStrings = append(tagValueStrings, normalized)
268 }
269 } else {
270 // For other tags, use direct string conversion
271 val := string(tv)
272 log.T.F("Neo4j tag filter: %s-tag value: %q (len=%d)", tagType, val, len(val))
273 tagValueStrings = append(tagValueStrings, val)
274 }
275 }
276
277 // Skip if no valid values after normalization
278 if len(tagValueStrings) == 0 {
279 log.W.F("Neo4j tag filter: no valid values for tag type %q, skipping", tagType)
280 continue
281 }
282
283 log.T.F("Neo4j tag filter: type=%s, values=%v", tagType, tagValueStrings)
284
285 // Use EXISTS subquery to filter events that have matching tags
286 // This is more correct than OPTIONAL MATCH because it requires the tag to exist
287 params[tagTypeParam] = tagType
288 params[tagValuesParam] = tagValueStrings
289 whereClauses = append(whereClauses,
290 fmt.Sprintf("EXISTS { MATCH (e)-[:TAGGED_WITH]->(t:Tag) WHERE t.type = $%s AND t.value IN $%s }",
291 tagTypeParam, tagValuesParam))
292
293 tagIndex++
294 }
295 }
296 }
297
298 // Exclude delete events unless requested
299 if !includeDeleteEvents {
300 whereClauses = append(whereClauses, "e.kind <> 5")
301 }
302
303 // Filter out expired events (NIP-40) unless querying by explicit IDs
304 // Events with expiration > 0 that have passed are hidden from results
305 // EXCEPT when the query includes specific event IDs (allowing explicit lookup)
306 hasExplicitIds := f.Ids != nil && len(f.Ids.T) > 0
307 if !hasExplicitIds {
308 params["now"] = time.Now().Unix()
309 // Show events where either: no expiration (expiration = 0) OR expiration hasn't passed yet
310 whereClauses = append(whereClauses, "(e.expiration = 0 OR e.expiration > $now)")
311 }
312
313 // Build WHERE clause
314 whereClause := ""
315 if len(whereClauses) > 0 {
316 whereClause = " WHERE " + strings.Join(whereClauses, " AND ")
317 }
318
319 // Build RETURN clause with all event properties
320 returnClause := `
321 RETURN e.id AS id,
322 e.kind AS kind,
323 e.created_at AS created_at,
324 e.content AS content,
325 e.sig AS sig,
326 e.pubkey AS pubkey,
327 e.tags AS tags,
328 e.serial AS serial`
329
330 // Add ordering (most recent first)
331 orderClause := " ORDER BY e.created_at DESC"
332
333 // Add limit - use the smaller of requested limit and configured max limit
334 // This prevents unbounded queries that could exhaust memory
335 limitClause := ""
336 requestedLimit := 0
337 if f.Limit != nil && *f.Limit > 0 {
338 requestedLimit = int(*f.Limit)
339 }
340
341 // Apply the configured query result limit as a safety cap
342 // If queryResultLimit is 0 (unlimited), only use the requested limit
343 effectiveLimit := requestedLimit
344 if n.queryResultLimit > 0 {
345 if effectiveLimit == 0 || effectiveLimit > n.queryResultLimit {
346 effectiveLimit = n.queryResultLimit
347 }
348 }
349
350 if effectiveLimit > 0 {
351 params["limit"] = effectiveLimit
352 limitClause = " LIMIT $limit"
353 }
354
355 // Combine all parts
356 cypher := matchClause + whereClause + returnClause + orderClause + limitClause
357
358 // Log the generated query for debugging
359 log.T.F("Neo4j query: %s", cypher)
360 // Log params at trace level for debugging
361 var paramSummary strings.Builder
362 for k, v := range params {
363 switch val := v.(type) {
364 case []string:
365 if len(val) <= 3 {
366 paramSummary.WriteString(fmt.Sprintf("%s: %v ", k, val))
367 } else {
368 paramSummary.WriteString(fmt.Sprintf("%s: [%d values] ", k, len(val)))
369 }
370 case []int64:
371 paramSummary.WriteString(fmt.Sprintf("%s: %v ", k, val))
372 default:
373 paramSummary.WriteString(fmt.Sprintf("%s: %v ", k, v))
374 }
375 }
376 log.T.F("Neo4j params: %s", paramSummary.String())
377
378 return cypher, params
379 }
380
381 // parseEventsFromResult converts Neo4j query results to Nostr events
382 func (n *N) parseEventsFromResult(result *CollectedResult) ([]*event.E, error) {
383 events := make([]*event.E, 0)
384 ctx := context.Background()
385
386 // Iterate through result records
387 for result.Next(ctx) {
388 record := result.Record()
389 if record == nil {
390 continue
391 }
392
393 // Parse event fields
394 idRaw, _ := record.Get("id")
395 kindRaw, _ := record.Get("kind")
396 createdAtRaw, _ := record.Get("created_at")
397 contentRaw, _ := record.Get("content")
398 sigRaw, _ := record.Get("sig")
399 pubkeyRaw, _ := record.Get("pubkey")
400 tagsRaw, _ := record.Get("tags")
401
402 idStr, _ := idRaw.(string)
403 kind, _ := kindRaw.(int64)
404 createdAt, _ := createdAtRaw.(int64)
405 content, _ := contentRaw.(string)
406 sigStr, _ := sigRaw.(string)
407 pubkeyStr, _ := pubkeyRaw.(string)
408 tagsStr, _ := tagsRaw.(string)
409
410 // Decode hex strings
411 id, err := hex.Dec(idStr)
412 if err != nil {
413 continue
414 }
415 sig, err := hex.Dec(sigStr)
416 if err != nil {
417 continue
418 }
419 pubkey, err := hex.Dec(pubkeyStr)
420 if err != nil {
421 continue
422 }
423
424 // Parse tags from JSON
425 tags := tag.NewS()
426 if tagsStr != "" {
427 _ = tags.UnmarshalJSON([]byte(tagsStr))
428 }
429
430 // Create event with decoded binary fields
431 e := &event.E{
432 ID: id,
433 Pubkey: pubkey,
434 Kind: uint16(kind),
435 CreatedAt: createdAt,
436 Content: []byte(content),
437 Tags: tags,
438 Sig: sig,
439 }
440
441 events = append(events, e)
442 }
443
444 if err := result.Err(); err != nil {
445 return nil, fmt.Errorf("error iterating results: %w", err)
446 }
447
448 return events, nil
449 }
450
451 // QueryDeleteEventsByTargetId retrieves delete events targeting a specific event ID
452 func (n *N) QueryDeleteEventsByTargetId(c context.Context, targetEventId []byte) (
453 evs event.S, err error,
454 ) {
455 targetIDStr := hex.Enc(targetEventId)
456
457 // Query for kind 5 events that reference this event
458 // This uses Neo4j's graph traversal to find delete events
459 cypher := `
460 MATCH (target:Event {id: $targetId})
461 MATCH (e:Event {kind: 5})-[:REFERENCES]->(target)
462 RETURN e.id AS id,
463 e.kind AS kind,
464 e.created_at AS created_at,
465 e.content AS content,
466 e.sig AS sig,
467 e.pubkey AS pubkey,
468 e.tags AS tags,
469 e.serial AS serial
470 ORDER BY e.created_at DESC`
471
472 params := map[string]any{"targetId": targetIDStr}
473
474 result, err := n.ExecuteRead(c, cypher, params)
475 if err != nil {
476 return nil, fmt.Errorf("failed to query delete events: %w", err)
477 }
478
479 evs, err = n.parseEventsFromResult(result)
480 if err != nil {
481 return nil, fmt.Errorf("failed to parse delete events: %w", err)
482 }
483
484 return evs, nil
485 }
486
487 // QueryForSerials retrieves event serials matching a filter
488 func (n *N) QueryForSerials(c context.Context, f *filter.F) (
489 serials types.Uint40s, err error,
490 ) {
491 // Build query but only return serial numbers
492 cypher, params := n.buildCypherQuery(f, false)
493
494 // Replace RETURN clause to only fetch serials
495 returnClause := " RETURN e.serial AS serial"
496 cypherParts := strings.Split(cypher, "RETURN")
497 if len(cypherParts) < 2 {
498 return nil, fmt.Errorf("invalid query structure")
499 }
500
501 // Rebuild query with serial-only return, preserving ORDER BY and LIMIT
502 cypher = cypherParts[0] + returnClause
503 remainder := cypherParts[1]
504 if strings.Contains(remainder, "ORDER BY") {
505 orderAndLimit := " ORDER BY" + strings.Split(remainder, "ORDER BY")[1]
506 cypher += orderAndLimit
507 } else if strings.Contains(remainder, "LIMIT") {
508 // No ORDER BY but has LIMIT
509 limitPart := " LIMIT" + strings.Split(remainder, "LIMIT")[1]
510 cypher += limitPart
511 }
512
513 result, err := n.ExecuteRead(c, cypher, params)
514 if err != nil {
515 return nil, fmt.Errorf("failed to query serials: %w", err)
516 }
517
518 // Parse serials from result
519 serials = make([]*types.Uint40, 0)
520 ctx := context.Background()
521
522 for result.Next(ctx) {
523 record := result.Record()
524 if record == nil {
525 continue
526 }
527
528 serialRaw, found := record.Get("serial")
529 if !found {
530 continue
531 }
532
533 serialVal, ok := serialRaw.(int64)
534 if !ok {
535 continue
536 }
537
538 serial := types.Uint40{}
539 serial.Set(uint64(serialVal))
540 serials = append(serials, &serial)
541 }
542
543 return serials, nil
544 }
545
546 // QueryForIds retrieves event IDs matching a filter
547 func (n *N) QueryForIds(c context.Context, f *filter.F) (
548 idPkTs []*store.IdPkTs, err error,
549 ) {
550 // Build query but only return ID, pubkey, created_at, serial
551 cypher, params := n.buildCypherQuery(f, false)
552
553 // Replace RETURN clause
554 returnClause := `
555 RETURN e.id AS id,
556 e.pubkey AS pubkey,
557 e.created_at AS created_at,
558 e.serial AS serial`
559
560 cypherParts := strings.Split(cypher, "RETURN")
561 if len(cypherParts) < 2 {
562 return nil, fmt.Errorf("invalid query structure")
563 }
564
565 // Rebuild query preserving ORDER BY and LIMIT
566 cypher = cypherParts[0] + returnClause
567 remainder := cypherParts[1]
568 if strings.Contains(remainder, "ORDER BY") {
569 orderAndLimit := " ORDER BY" + strings.Split(remainder, "ORDER BY")[1]
570 cypher += orderAndLimit
571 } else if strings.Contains(remainder, "LIMIT") {
572 // No ORDER BY but has LIMIT
573 limitPart := " LIMIT" + strings.Split(remainder, "LIMIT")[1]
574 cypher += limitPart
575 }
576
577 result, err := n.ExecuteRead(c, cypher, params)
578 if err != nil {
579 return nil, fmt.Errorf("failed to query IDs: %w", err)
580 }
581
582 // Parse IDs from result
583 idPkTs = make([]*store.IdPkTs, 0)
584 ctx := context.Background()
585
586 for result.Next(ctx) {
587 record := result.Record()
588 if record == nil {
589 continue
590 }
591
592 idRaw, _ := record.Get("id")
593 pubkeyRaw, _ := record.Get("pubkey")
594 createdAtRaw, _ := record.Get("created_at")
595 serialRaw, _ := record.Get("serial")
596
597 idStr, _ := idRaw.(string)
598 pubkeyStr, _ := pubkeyRaw.(string)
599 createdAt, _ := createdAtRaw.(int64)
600 serialVal, _ := serialRaw.(int64)
601
602 id, err := hex.Dec(idStr)
603 if err != nil {
604 continue
605 }
606 pubkey, err := hex.Dec(pubkeyStr)
607 if err != nil {
608 continue
609 }
610
611 ipkts := store.NewIdPkTs(id, pubkey, createdAt, uint64(serialVal))
612 idPkTs = append(idPkTs, &ipkts)
613 }
614
615 return idPkTs, nil
616 }
617
618 // buildSearchCypherQuery constructs a Cypher query for NIP-50 word search.
619 // It matches events via HAS_WORD relationships to Word nodes, counts matches
620 // per event, and returns matchCount for Go-side relevance scoring.
621 func (n *N) buildSearchCypherQuery(f *filter.F, includeDeleteEvents bool) (string, map[string]any) {
622 params := make(map[string]any)
623
624 // Tokenize the search string using the same tokenizer as indexing
625 searchTokens := database.TokenWords(f.Search)
626 if len(searchTokens) == 0 {
627 // No valid search tokens — return query that matches nothing
628 return "MATCH (e:Event) WHERE false RETURN e.id AS id, e.kind AS kind, e.created_at AS created_at, e.content AS content, e.sig AS sig, e.pubkey AS pubkey, e.tags AS tags, e.serial AS serial, 0 AS matchCount", params
629 }
630
631 wordHashes := make([]string, len(searchTokens))
632 for i, wt := range searchTokens {
633 wordHashes[i] = stdhex.EncodeToString(wt.Hash)
634 }
635 params["wordHashes"] = wordHashes
636
637 // Build WHERE clauses for additional filters (authors, kinds, time, tags)
638 var whereClauses []string
639
640 // Authors filter
641 if f.Authors != nil && len(f.Authors.T) > 0 {
642 authorConditions := make([]string, 0, len(f.Authors.T))
643 for i, author := range f.Authors.T {
644 if len(author) == 0 {
645 continue
646 }
647 paramName := fmt.Sprintf("author_%d", i)
648 hexAuthor := NormalizePubkeyHex(author)
649 if hexAuthor == "" {
650 continue
651 }
652 if len(hexAuthor) < 64 {
653 authorConditions = append(authorConditions, fmt.Sprintf("e.pubkey STARTS WITH $%s", paramName))
654 } else {
655 authorConditions = append(authorConditions, fmt.Sprintf("e.pubkey = $%s", paramName))
656 }
657 params[paramName] = hexAuthor
658 }
659 if len(authorConditions) > 0 {
660 whereClauses = append(whereClauses, "("+strings.Join(authorConditions, " OR ")+")")
661 }
662 }
663
664 // Kinds filter
665 if f.Kinds != nil && len(f.Kinds.K) > 0 {
666 kinds := make([]int64, len(f.Kinds.K))
667 for i, k := range f.Kinds.K {
668 kinds[i] = int64(k.K)
669 }
670 params["kinds"] = kinds
671 whereClauses = append(whereClauses, "e.kind IN $kinds")
672 }
673
674 // Time range filters
675 if f.Since != nil && f.Since.V > 0 {
676 params["since"] = f.Since.V
677 whereClauses = append(whereClauses, "e.created_at >= $since")
678 }
679 if f.Until != nil && f.Until.V > 0 {
680 params["until"] = f.Until.V
681 whereClauses = append(whereClauses, "e.created_at <= $until")
682 }
683
684 // Tag filters
685 tagIndex := 0
686 if f.Tags != nil {
687 for _, tagValues := range *f.Tags {
688 if len(tagValues.T) > 0 {
689 tagTypeParam := fmt.Sprintf("tagType_%d", tagIndex)
690 tagValuesParam := fmt.Sprintf("tagValues_%d", tagIndex)
691
692 tagTypeBytes := tagValues.T[0]
693 var tagType string
694 if len(tagTypeBytes) > 0 && tagTypeBytes[0] == '#' {
695 tagType = string(tagTypeBytes[1:])
696 } else {
697 tagType = string(tagTypeBytes)
698 }
699
700 tagValueStrings := make([]string, 0, len(tagValues.T)-1)
701 for _, tv := range tagValues.T[1:] {
702 if tagType == "e" || tagType == "p" {
703 normalized := NormalizePubkeyHex(tv)
704 if normalized != "" {
705 tagValueStrings = append(tagValueStrings, normalized)
706 }
707 } else {
708 tagValueStrings = append(tagValueStrings, string(tv))
709 }
710 }
711
712 if len(tagValueStrings) == 0 {
713 continue
714 }
715
716 params[tagTypeParam] = tagType
717 params[tagValuesParam] = tagValueStrings
718 whereClauses = append(whereClauses,
719 fmt.Sprintf("EXISTS { MATCH (e)-[:TAGGED_WITH]->(t:Tag) WHERE t.type = $%s AND t.value IN $%s }",
720 tagTypeParam, tagValuesParam))
721 tagIndex++
722 }
723 }
724 }
725
726 // Exclude delete events
727 if !includeDeleteEvents {
728 whereClauses = append(whereClauses, "e.kind <> 5")
729 }
730
731 // Expiration filter (NIP-40)
732 hasExplicitIds := f.Ids != nil && len(f.Ids.T) > 0
733 if !hasExplicitIds {
734 params["now"] = time.Now().Unix()
735 whereClauses = append(whereClauses, "(e.expiration = 0 OR e.expiration > $now)")
736 }
737
738 // Build additional WHERE string
739 additionalWhere := ""
740 if len(whereClauses) > 0 {
741 additionalWhere = " AND " + strings.Join(whereClauses, " AND ")
742 }
743
744 // Limit: only apply the safety cap (queryResultLimit) in Cypher.
745 // The user-requested f.Limit is applied in Go after relevance scoring,
746 // so that recency-boosted events aren't prematurely discarded.
747 limitClause := ""
748 if n.queryResultLimit > 0 {
749 params["limit"] = n.queryResultLimit
750 limitClause = "\nLIMIT $limit"
751 }
752
753 // Core search query: traverse word graph, count matches per event
754 cypher := `
755 MATCH (e:Event)-[:HAS_WORD]->(w:Word)
756 WHERE w.hash IN $wordHashes` + additionalWhere + `
757 WITH e, count(DISTINCT w) AS matchCount
758 RETURN e.id AS id,
759 e.kind AS kind,
760 e.created_at AS created_at,
761 e.content AS content,
762 e.sig AS sig,
763 e.pubkey AS pubkey,
764 e.tags AS tags,
765 e.serial AS serial,
766 matchCount
767 ORDER BY matchCount DESC, e.created_at DESC` + limitClause
768
769 log.T.F("Neo4j search query: %s", cypher)
770
771 return cypher, params
772 }
773
774 // searchScoredEvent pairs a parsed event with its search match count for scoring.
775 type searchScoredEvent struct {
776 Event *event.E
777 MatchCount int
778 }
779
780 // QuerySearchEvents performs a NIP-50 word search query with relevance scoring.
781 // Events are scored using a 50/50 blend of match count and recency, matching
782 // the Badger backend's scoring algorithm.
783 func (n *N) QuerySearchEvents(c context.Context, f *filter.F) (evs event.S, err error) {
784 cypher, params := n.buildSearchCypherQuery(f, false)
785
786 result, err := n.ExecuteRead(c, cypher, params)
787 if err != nil {
788 return nil, fmt.Errorf("failed to execute search query: %w", err)
789 }
790
791 // Parse results with match counts
792 var scored []searchScoredEvent
793 ctx := context.Background()
794
795 for result.Next(ctx) {
796 record := result.Record()
797 if record == nil {
798 continue
799 }
800
801 // Parse event fields (same as parseEventsFromResult)
802 idRaw, _ := record.Get("id")
803 kindRaw, _ := record.Get("kind")
804 createdAtRaw, _ := record.Get("created_at")
805 contentRaw, _ := record.Get("content")
806 sigRaw, _ := record.Get("sig")
807 pubkeyRaw, _ := record.Get("pubkey")
808 tagsRaw, _ := record.Get("tags")
809 matchCountRaw, _ := record.Get("matchCount")
810
811 idStr, _ := idRaw.(string)
812 kindVal, _ := kindRaw.(int64)
813 createdAt, _ := createdAtRaw.(int64)
814 content, _ := contentRaw.(string)
815 sigStr, _ := sigRaw.(string)
816 pubkeyStr, _ := pubkeyRaw.(string)
817 tagsStr, _ := tagsRaw.(string)
818 matchCount, _ := matchCountRaw.(int64)
819
820 id, err := hex.Dec(idStr)
821 if err != nil {
822 continue
823 }
824 sig, err := hex.Dec(sigStr)
825 if err != nil {
826 continue
827 }
828 pubkey, err := hex.Dec(pubkeyStr)
829 if err != nil {
830 continue
831 }
832
833 tags := tag.NewS()
834 if tagsStr != "" {
835 _ = tags.UnmarshalJSON([]byte(tagsStr))
836 }
837
838 e := &event.E{
839 ID: id,
840 Pubkey: pubkey,
841 Kind: uint16(kindVal),
842 CreatedAt: createdAt,
843 Content: []byte(content),
844 Tags: tags,
845 Sig: sig,
846 }
847
848 scored = append(scored, searchScoredEvent{Event: e, MatchCount: int(matchCount)})
849 }
850
851 if err := result.Err(); err != nil {
852 return nil, fmt.Errorf("error iterating search results: %w", err)
853 }
854
855 if len(scored) == 0 {
856 return nil, nil
857 }
858
859 // Apply 50/50 relevance scoring (match count + recency)
860 maxCount := 0
861 var minTs, maxTs int64
862 for i, s := range scored {
863 if s.MatchCount > maxCount {
864 maxCount = s.MatchCount
865 }
866 ts := s.Event.CreatedAt
867 if i == 0 {
868 minTs, maxTs = ts, ts
869 } else {
870 if ts < minTs {
871 minTs = ts
872 }
873 if ts > maxTs {
874 maxTs = ts
875 }
876 }
877 }
878
879 tsSpan := float64(maxTs - minTs)
880
881 sort.Slice(scored, func(i, j int) bool {
882 ci := float64(scored[i].MatchCount) / math.Max(float64(maxCount), 1)
883 cj := float64(scored[j].MatchCount) / math.Max(float64(maxCount), 1)
884
885 var ai, aj float64
886 if tsSpan > 0 {
887 ai = float64(scored[i].Event.CreatedAt-minTs) / tsSpan
888 aj = float64(scored[j].Event.CreatedAt-minTs) / tsSpan
889 }
890
891 si := 0.5*ci + 0.5*ai
892 sj := 0.5*cj + 0.5*aj
893
894 if si == sj {
895 return scored[i].Event.CreatedAt > scored[j].Event.CreatedAt
896 }
897 return si > sj
898 })
899
900 // Extract sorted events
901 evs = make(event.S, len(scored))
902 for i, s := range scored {
903 evs[i] = s.Event
904 }
905
906 // Apply limit
907 if f.Limit != nil && len(evs) > int(*f.Limit) {
908 evs = evs[:*f.Limit]
909 }
910
911 return evs, nil
912 }
913
914 // CountEvents counts events matching a filter
915 func (n *N) CountEvents(c context.Context, f *filter.F) (
916 count int, approximate bool, err error,
917 ) {
918 // Build query but only count results
919 cypher, params := n.buildCypherQuery(f, false)
920
921 // Replace RETURN clause with COUNT
922 returnClause := " RETURN count(e) AS count"
923 cypherParts := strings.Split(cypher, "RETURN")
924 if len(cypherParts) < 2 {
925 return 0, false, fmt.Errorf("invalid query structure")
926 }
927
928 // Remove ORDER BY and LIMIT for count query
929 cypher = cypherParts[0] + returnClause
930 delete(params, "limit") // Remove limit parameter if it exists
931
932 result, err := n.ExecuteRead(c, cypher, params)
933 if err != nil {
934 return 0, false, fmt.Errorf("failed to count events: %w", err)
935 }
936
937 // Parse count from result
938 ctx := context.Background()
939
940 if result.Next(ctx) {
941 record := result.Record()
942 if record != nil {
943 countRaw, found := record.Get("count")
944 if found {
945 countVal, ok := countRaw.(int64)
946 if ok {
947 count = int(countVal)
948 }
949 }
950 }
951 }
952
953 return count, false, nil
954 }
955