add first draft graph query implementation
Some checks failed
Go / build-and-release (push) Has been cancelled

This commit is contained in:
2025-12-04 09:28:13 +00:00
parent 8dbc19ee9e
commit 6b98c23606
40 changed files with 9078 additions and 46 deletions

183
pkg/protocol/graph/query.go Normal file
View File

@@ -0,0 +1,183 @@
// Package graph implements NIP-XX Graph Query protocol support.
// It provides types and functions for parsing and validating graph traversal queries.
package graph
import (
"encoding/json"
"errors"
"git.mleku.dev/mleku/nostr/encoders/filter"
)
// Query represents a graph traversal query from a _graph filter extension.
type Query struct {
// Method is the traversal method: "follows", "followers", "mentions", "thread"
Method string `json:"method"`
// Seed is the starting point for traversal (pubkey hex or event ID hex)
Seed string `json:"seed"`
// Depth is the maximum traversal depth (1-16, default: 1)
Depth int `json:"depth,omitempty"`
// InboundRefs specifies which inbound references to collect
// (events that reference discovered events via e-tags)
InboundRefs []RefSpec `json:"inbound_refs,omitempty"`
// OutboundRefs specifies which outbound references to collect
// (events referenced by discovered events via e-tags)
OutboundRefs []RefSpec `json:"outbound_refs,omitempty"`
}
// RefSpec specifies which event references to include in results.
type RefSpec struct {
// Kinds is the list of event kinds to match (OR semantics within this spec)
Kinds []int `json:"kinds"`
// FromDepth specifies the minimum depth at which to collect refs (default: 0)
// 0 = include refs from seed itself
// 1 = start from first-hop connections
FromDepth int `json:"from_depth,omitempty"`
}
// Validation errors
var (
ErrMissingMethod = errors.New("_graph.method is required")
ErrInvalidMethod = errors.New("_graph.method must be one of: follows, followers, mentions, thread")
ErrMissingSeed = errors.New("_graph.seed is required")
ErrInvalidSeed = errors.New("_graph.seed must be a 64-character hex string")
ErrDepthTooHigh = errors.New("_graph.depth cannot exceed 16")
ErrEmptyRefSpecKinds = errors.New("ref spec kinds array cannot be empty")
)
// Valid method names
var validMethods = map[string]bool{
"follows": true,
"followers": true,
"mentions": true,
"thread": true,
}
// Validate checks the query for correctness and applies defaults.
func (q *Query) Validate() error {
// Method is required
if q.Method == "" {
return ErrMissingMethod
}
if !validMethods[q.Method] {
return ErrInvalidMethod
}
// Seed is required
if q.Seed == "" {
return ErrMissingSeed
}
if len(q.Seed) != 64 {
return ErrInvalidSeed
}
// Validate hex characters
for _, c := range q.Seed {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return ErrInvalidSeed
}
}
// Apply depth defaults and limits
if q.Depth < 1 {
q.Depth = 1
}
if q.Depth > 16 {
return ErrDepthTooHigh
}
// Validate ref specs
for _, rs := range q.InboundRefs {
if len(rs.Kinds) == 0 {
return ErrEmptyRefSpecKinds
}
}
for _, rs := range q.OutboundRefs {
if len(rs.Kinds) == 0 {
return ErrEmptyRefSpecKinds
}
}
return nil
}
// HasInboundRefs returns true if the query includes inbound reference collection.
func (q *Query) HasInboundRefs() bool {
return len(q.InboundRefs) > 0
}
// HasOutboundRefs returns true if the query includes outbound reference collection.
func (q *Query) HasOutboundRefs() bool {
return len(q.OutboundRefs) > 0
}
// HasRefs returns true if the query includes any reference collection.
func (q *Query) HasRefs() bool {
return q.HasInboundRefs() || q.HasOutboundRefs()
}
// InboundKindsAtDepth returns a set of kinds that should be collected at the given depth.
// It aggregates all RefSpecs where from_depth <= depth.
func (q *Query) InboundKindsAtDepth(depth int) map[int]bool {
kinds := make(map[int]bool)
for _, rs := range q.InboundRefs {
if rs.FromDepth <= depth {
for _, k := range rs.Kinds {
kinds[k] = true
}
}
}
return kinds
}
// OutboundKindsAtDepth returns a set of kinds that should be collected at the given depth.
func (q *Query) OutboundKindsAtDepth(depth int) map[int]bool {
kinds := make(map[int]bool)
for _, rs := range q.OutboundRefs {
if rs.FromDepth <= depth {
for _, k := range rs.Kinds {
kinds[k] = true
}
}
}
return kinds
}
// ExtractFromFilter checks if a filter has a _graph extension and parses it.
// Returns nil if no _graph field is present.
// Returns an error if _graph is present but invalid.
func ExtractFromFilter(f *filter.F) (*Query, error) {
if f == nil || f.Extra == nil {
return nil, nil
}
raw, ok := f.Extra["_graph"]
if !ok {
return nil, nil
}
var q Query
if err := json.Unmarshal(raw, &q); err != nil {
return nil, err
}
if err := q.Validate(); err != nil {
return nil, err
}
return &q, nil
}
// IsGraphQuery returns true if the filter contains a _graph extension.
// This is a quick check that doesn't parse the full query.
func IsGraphQuery(f *filter.F) bool {
if f == nil || f.Extra == nil {
return false
}
_, ok := f.Extra["_graph"]
return ok
}