Add _graph extension support to Neo4j driver
Some checks failed
Go / build-and-release (push) Has been cancelled

- Implement TraverseFollows using Cypher path queries on FOLLOWS relationships
- Implement TraverseFollowers using reverse path traversal
- Implement FindMentions using MENTIONS relationships from p-tags
- Implement TraverseThread using REFERENCES relationships from e-tags
  with bidirectional traversal (inbound replies, outbound parents)
- Add GraphAdapter to bridge Neo4j to graph.GraphDatabase interface
- Add GraphResult type implementing graph.GraphResultI for Neo4j
- Initialize graph executor for Neo4j backend in app/main.go

The implementation uses existing Neo4j schema and relationships created
by SaveEvent() - no schema changes required. The _graph extension now
works transparently with either Badger or Neo4j backends.

Bump version to v0.35.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-12 07:07:31 +01:00
parent a816737cd3
commit ba84e12ea9
7 changed files with 879 additions and 3 deletions

201
pkg/neo4j/graph-follows.go Normal file
View File

@@ -0,0 +1,201 @@
package neo4j
import (
"context"
"fmt"
"strings"
"git.mleku.dev/mleku/nostr/encoders/hex"
"next.orly.dev/pkg/protocol/graph"
)
// TraverseFollows performs BFS traversal of the follow graph starting from a seed pubkey.
// Returns pubkeys grouped by first-discovered depth (no duplicates across depths).
//
// Uses Neo4j's native path queries with FOLLOWS relationships created by
// the social event processor from kind 3 contact list events.
//
// The traversal works by using variable-length path patterns:
// - Depth 1: Direct follows (seed)-[:FOLLOWS]->(followed)
// - Depth 2: Follows of follows (seed)-[:FOLLOWS*2]->(followed)
// - etc.
//
// Each pubkey appears only at the depth where it was first discovered.
func (n *N) TraverseFollows(seedPubkey []byte, maxDepth int) (graph.GraphResultI, error) {
result := NewGraphResult()
if len(seedPubkey) != 32 {
return result, fmt.Errorf("invalid pubkey length: expected 32, got %d", len(seedPubkey))
}
seedHex := strings.ToLower(hex.Enc(seedPubkey))
ctx := context.Background()
// Track visited pubkeys to ensure each appears only at first-discovered depth
visited := make(map[string]bool)
visited[seedHex] = true // Seed is at depth 0, not included in results
// Process each depth level separately to maintain BFS semantics
for depth := 1; depth <= maxDepth; depth++ {
// Query for pubkeys at exactly this depth that haven't been seen yet
// We use a variable-length path of exactly 'depth' hops
cypher := fmt.Sprintf(`
MATCH path = (seed:NostrUser {pubkey: $seed})-[:FOLLOWS*%d]->(target:NostrUser)
WHERE target.pubkey <> $seed
AND NOT target.pubkey IN $visited
RETURN DISTINCT target.pubkey AS pubkey
`, depth)
// Convert visited map to slice for query
visitedList := make([]string, 0, len(visited))
for pk := range visited {
visitedList = append(visitedList, pk)
}
params := map[string]any{
"seed": seedHex,
"visited": visitedList,
}
queryResult, err := n.ExecuteRead(ctx, cypher, params)
if err != nil {
n.Logger.Warningf("TraverseFollows: error at depth %d: %v", depth, err)
continue
}
newPubkeysAtDepth := 0
for queryResult.Next(ctx) {
record := queryResult.Record()
pubkey, ok := record.Values[0].(string)
if !ok || pubkey == "" {
continue
}
// Normalize to lowercase for consistency
pubkey = strings.ToLower(pubkey)
// Add to result if not already seen
if !visited[pubkey] {
visited[pubkey] = true
result.AddPubkeyAtDepth(pubkey, depth)
newPubkeysAtDepth++
}
}
n.Logger.Debugf("TraverseFollows: depth %d found %d new pubkeys", depth, newPubkeysAtDepth)
// Early termination if no new pubkeys found at this depth
if newPubkeysAtDepth == 0 {
break
}
}
n.Logger.Debugf("TraverseFollows: completed with %d total pubkeys across %d depths",
result.TotalPubkeys, len(result.PubkeysByDepth))
return result, nil
}
// TraverseFollowers performs BFS traversal to find who follows the seed pubkey.
// This is the reverse of TraverseFollows - it finds users whose kind-3 lists
// contain the target pubkey(s).
//
// Uses Neo4j's native path queries, but in reverse direction:
// - Depth 1: Users who directly follow the seed (follower)-[:FOLLOWS]->(seed)
// - Depth 2: Users who follow anyone at depth 1 (followers of followers)
// - etc.
func (n *N) TraverseFollowers(seedPubkey []byte, maxDepth int) (graph.GraphResultI, error) {
result := NewGraphResult()
if len(seedPubkey) != 32 {
return result, fmt.Errorf("invalid pubkey length: expected 32, got %d", len(seedPubkey))
}
seedHex := strings.ToLower(hex.Enc(seedPubkey))
ctx := context.Background()
// Track visited pubkeys
visited := make(map[string]bool)
visited[seedHex] = true
// Process each depth level separately for BFS semantics
for depth := 1; depth <= maxDepth; depth++ {
// Query for pubkeys at exactly this depth that haven't been seen yet
// Direction is reversed: we find users who follow the targets
cypher := fmt.Sprintf(`
MATCH path = (follower:NostrUser)-[:FOLLOWS*%d]->(seed:NostrUser {pubkey: $seed})
WHERE follower.pubkey <> $seed
AND NOT follower.pubkey IN $visited
RETURN DISTINCT follower.pubkey AS pubkey
`, depth)
visitedList := make([]string, 0, len(visited))
for pk := range visited {
visitedList = append(visitedList, pk)
}
params := map[string]any{
"seed": seedHex,
"visited": visitedList,
}
queryResult, err := n.ExecuteRead(ctx, cypher, params)
if err != nil {
n.Logger.Warningf("TraverseFollowers: error at depth %d: %v", depth, err)
continue
}
newPubkeysAtDepth := 0
for queryResult.Next(ctx) {
record := queryResult.Record()
pubkey, ok := record.Values[0].(string)
if !ok || pubkey == "" {
continue
}
pubkey = strings.ToLower(pubkey)
if !visited[pubkey] {
visited[pubkey] = true
result.AddPubkeyAtDepth(pubkey, depth)
newPubkeysAtDepth++
}
}
n.Logger.Debugf("TraverseFollowers: depth %d found %d new pubkeys", depth, newPubkeysAtDepth)
if newPubkeysAtDepth == 0 {
break
}
}
n.Logger.Debugf("TraverseFollowers: completed with %d total pubkeys", result.TotalPubkeys)
return result, nil
}
// TraverseFollowsFromHex is a convenience wrapper that accepts hex-encoded pubkey.
func (n *N) TraverseFollowsFromHex(seedPubkeyHex string, maxDepth int) (*GraphResult, error) {
seedPubkey, err := hex.Dec(seedPubkeyHex)
if err != nil {
return nil, err
}
result, err := n.TraverseFollows(seedPubkey, maxDepth)
if err != nil {
return nil, err
}
return result.(*GraphResult), nil
}
// TraverseFollowersFromHex is a convenience wrapper that accepts hex-encoded pubkey.
func (n *N) TraverseFollowersFromHex(seedPubkeyHex string, maxDepth int) (*GraphResult, error) {
seedPubkey, err := hex.Dec(seedPubkeyHex)
if err != nil {
return nil, err
}
result, err := n.TraverseFollowers(seedPubkey, maxDepth)
if err != nil {
return nil, err
}
return result.(*GraphResult), nil
}