You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
183 lines
4.8 KiB
183 lines
4.8 KiB
// 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 |
|
}
|
|
|