Add _graph extension support to Neo4j driver
Some checks failed
Go / build-and-release (push) Has been cancelled
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:
201
pkg/neo4j/graph-follows.go
Normal file
201
pkg/neo4j/graph-follows.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user