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