Files
next.orly.dev/pkg/protocol/graph/executor.go
mleku 6b98c23606
Some checks failed
Go / build-and-release (push) Has been cancelled
add first draft graph query implementation
2025-12-04 09:28:13 +00:00

203 lines
6.0 KiB
Go

//go:build !(js && wasm)
// Package graph implements NIP-XX Graph Query protocol support.
// This file contains the executor that runs graph traversal queries.
package graph
import (
"encoding/json"
"strconv"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/tag"
"git.mleku.dev/mleku/nostr/interfaces/signer"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
)
// Response kinds for graph queries (ephemeral range, relay-signed)
const (
KindGraphFollows = 39000 // Response for follows/followers queries
KindGraphMentions = 39001 // Response for mentions queries
KindGraphThread = 39002 // Response for thread traversal queries
)
// GraphResultI is the interface that database.GraphResult implements.
// This allows the executor to work with the database result without importing it.
type GraphResultI interface {
ToDepthArrays() [][]string
ToEventDepthArrays() [][]string
GetAllPubkeys() []string
GetAllEvents() []string
GetPubkeysByDepth() map[int][]string
GetEventsByDepth() map[int][]string
GetTotalPubkeys() int
GetTotalEvents() int
}
// GraphDatabase defines the interface for graph traversal operations.
// This is implemented by the database package.
type GraphDatabase interface {
// TraverseFollows performs BFS traversal of follow graph
TraverseFollows(seedPubkey []byte, maxDepth int) (GraphResultI, error)
// TraverseFollowers performs BFS traversal to find followers
TraverseFollowers(seedPubkey []byte, maxDepth int) (GraphResultI, error)
// FindMentions finds events mentioning a pubkey
FindMentions(pubkey []byte, kinds []uint16) (GraphResultI, error)
// TraverseThread performs BFS traversal of thread structure
TraverseThread(seedEventID []byte, maxDepth int, direction string) (GraphResultI, error)
}
// Executor handles graph query execution and response generation.
type Executor struct {
db GraphDatabase
relaySigner signer.I
relayPubkey []byte
}
// NewExecutor creates a new graph query executor.
// The secretKey should be the 32-byte relay identity secret key.
func NewExecutor(db GraphDatabase, secretKey []byte) (*Executor, error) {
s, err := p8k.New()
if err != nil {
return nil, err
}
if err = s.InitSec(secretKey); err != nil {
return nil, err
}
return &Executor{
db: db,
relaySigner: s,
relayPubkey: s.Pub(),
}, nil
}
// Execute runs a graph query and returns a relay-signed event with results.
func (e *Executor) Execute(q *Query) (*event.E, error) {
var result GraphResultI
var err error
var responseKind uint16
// Decode seed (hex string to bytes)
seedBytes, err := hex.Dec(q.Seed)
if err != nil {
return nil, err
}
// Execute the appropriate traversal
switch q.Method {
case "follows":
responseKind = KindGraphFollows
result, err = e.db.TraverseFollows(seedBytes, q.Depth)
if err != nil {
return nil, err
}
log.D.F("graph executor: follows traversal returned %d pubkeys", result.GetTotalPubkeys())
case "followers":
responseKind = KindGraphFollows
result, err = e.db.TraverseFollowers(seedBytes, q.Depth)
if err != nil {
return nil, err
}
log.D.F("graph executor: followers traversal returned %d pubkeys", result.GetTotalPubkeys())
case "mentions":
responseKind = KindGraphMentions
// Mentions don't use depth traversal, just find direct mentions
// Convert RefSpec kinds to uint16 for the database call
var kinds []uint16
if len(q.InboundRefs) > 0 {
for _, rs := range q.InboundRefs {
for _, k := range rs.Kinds {
kinds = append(kinds, uint16(k))
}
}
} else {
kinds = []uint16{1} // Default to kind 1 (notes)
}
result, err = e.db.FindMentions(seedBytes, kinds)
if err != nil {
return nil, err
}
log.D.F("graph executor: mentions query returned %d events", result.GetTotalEvents())
case "thread":
responseKind = KindGraphThread
result, err = e.db.TraverseThread(seedBytes, q.Depth, "both")
if err != nil {
return nil, err
}
log.D.F("graph executor: thread traversal returned %d events", result.GetTotalEvents())
default:
return nil, ErrInvalidMethod
}
// Generate response event
return e.generateResponse(q, result, responseKind)
}
// generateResponse creates a relay-signed event containing the query results.
func (e *Executor) generateResponse(q *Query, result GraphResultI, responseKind uint16) (*event.E, error) {
// Build content as JSON with depth arrays
var content ResponseContent
if q.Method == "follows" || q.Method == "followers" {
// For pubkey-based queries, use pubkeys_by_depth
content.PubkeysByDepth = result.ToDepthArrays()
content.TotalPubkeys = result.GetTotalPubkeys()
} else {
// For event-based queries, use events_by_depth
content.EventsByDepth = result.ToEventDepthArrays()
content.TotalEvents = result.GetTotalEvents()
}
contentBytes, err := json.Marshal(content)
if err != nil {
return nil, err
}
// Build tags
tags := tag.NewS(
tag.NewFromAny("method", q.Method),
tag.NewFromAny("seed", q.Seed),
tag.NewFromAny("depth", strconv.Itoa(q.Depth)),
)
// Create event
ev := &event.E{
Kind: responseKind,
CreatedAt: time.Now().Unix(),
Tags: tags,
Content: contentBytes,
}
// Sign with relay identity
if err = ev.Sign(e.relaySigner); chk.E(err) {
return nil, err
}
return ev, nil
}
// ResponseContent is the JSON structure for graph query responses.
type ResponseContent struct {
// PubkeysByDepth contains arrays of pubkeys at each depth (1-indexed)
// Each pubkey appears ONLY at the depth where it was first discovered.
PubkeysByDepth [][]string `json:"pubkeys_by_depth,omitempty"`
// EventsByDepth contains arrays of event IDs at each depth (1-indexed)
EventsByDepth [][]string `json:"events_by_depth,omitempty"`
// TotalPubkeys is the total count of unique pubkeys discovered
TotalPubkeys int `json:"total_pubkeys,omitempty"`
// TotalEvents is the total count of unique events discovered
TotalEvents int `json:"total_events,omitempty"`
}