query.go raw

   1  // Package graph implements NIP-XX Graph Query protocol support.
   2  // It provides types and functions for parsing and validating graph traversal queries.
   3  //
   4  // The _graph filter extension uses a 4-element array:
   5  //
   6  //	["_graph", ["<pubkey_hex>", <depth>, "<edge>", "<direction>"]]
   7  //
   8  // Parameters:
   9  //   - pubkey:    64-char hex seed pubkey — the starting node for traversal
  10  //   - depth:     integer 1-16 — maximum BFS depth from seed
  11  //   - edge:      "pp" | "pe" | "ee" — which graph edge family to traverse
  12  //   - direction: "out" | "in" | "both" — traversal direction
  13  //
  14  // Edge types map to the relational grammar:
  15  //   - "pp" (pubkey↔pubkey): noun-noun — direct social graph (materialized ppg/gpp index)
  16  //   - "pe" (pubkey↔event):  adverb   — authorship and p-tag references (epg/peg index)
  17  //   - "ee" (event↔event):   adjective — e-tag thread structure (eeg/gee index)
  18  package graph
  19  
  20  import (
  21  	"encoding/json"
  22  	"errors"
  23  	"fmt"
  24  
  25  	"next.orly.dev/pkg/nostr/encoders/filter"
  26  )
  27  
  28  // Query represents a graph traversal query from a _graph filter extension.
  29  type Query struct {
  30  	// Pubkey is the 64-char hex seed pubkey for traversal.
  31  	Pubkey string `json:"pubkey"`
  32  
  33  	// Depth is the maximum BFS traversal depth (1-16, default: 1).
  34  	Depth int `json:"depth"`
  35  
  36  	// Edge selects which graph edge family to traverse:
  37  	// "pp" = pubkey↔pubkey, "pe" = pubkey↔event, "ee" = event↔event
  38  	Edge string `json:"edge"`
  39  
  40  	// Direction selects traversal direction: "out", "in", or "both".
  41  	Direction string `json:"direction"`
  42  }
  43  
  44  // Validation errors
  45  var (
  46  	ErrMissingPubkey    = errors.New("_graph[0] (pubkey) is required")
  47  	ErrInvalidPubkey    = errors.New("_graph[0] (pubkey) must be a 64-character hex string")
  48  	ErrInvalidDepth     = errors.New("_graph[1] (depth) must be between 1 and 16")
  49  	ErrMissingEdge      = errors.New("_graph[2] (edge) is required")
  50  	ErrInvalidEdge      = errors.New("_graph[2] (edge) must be one of: pp, pe, ee")
  51  	ErrMissingDirection = errors.New("_graph[3] (direction) is required")
  52  	ErrInvalidDirection = errors.New("_graph[3] (direction) must be one of: out, in, both")
  53  	ErrInvalidArray     = errors.New("_graph must be a 4-element array: [pubkey, depth, edge, direction]")
  54  )
  55  
  56  // Valid edge types
  57  var validEdges = map[string]bool{
  58  	"pp": true, // pubkey↔pubkey (noun-noun)
  59  	"pe": true, // pubkey↔event (adverb)
  60  	"ee": true, // event↔event (adjective)
  61  }
  62  
  63  // Valid direction values
  64  var validDirections = map[string]bool{
  65  	"out":  true,
  66  	"in":   true,
  67  	"both": true,
  68  }
  69  
  70  // Validate checks the query for correctness and applies defaults.
  71  func (q *Query) Validate() error {
  72  	// Pubkey is required
  73  	if q.Pubkey == "" {
  74  		return ErrMissingPubkey
  75  	}
  76  	if len(q.Pubkey) != 64 {
  77  		return ErrInvalidPubkey
  78  	}
  79  	for _, c := range q.Pubkey {
  80  		if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
  81  			return ErrInvalidPubkey
  82  		}
  83  	}
  84  
  85  	// Apply depth defaults and limits
  86  	if q.Depth < 1 {
  87  		q.Depth = 1
  88  	}
  89  	if q.Depth > 16 {
  90  		return ErrInvalidDepth
  91  	}
  92  
  93  	// Edge is required
  94  	if q.Edge == "" {
  95  		return ErrMissingEdge
  96  	}
  97  	if !validEdges[q.Edge] {
  98  		return ErrInvalidEdge
  99  	}
 100  
 101  	// Direction is required
 102  	if q.Direction == "" {
 103  		return ErrMissingDirection
 104  	}
 105  	if !validDirections[q.Direction] {
 106  		return ErrInvalidDirection
 107  	}
 108  
 109  	return nil
 110  }
 111  
 112  // ExtractFromFilter checks if a filter has a _graph extension and parses it.
 113  // The _graph field is a 4-element JSON array: [pubkey, depth, edge, direction].
 114  // Returns nil if no _graph field is present.
 115  // Returns an error if _graph is present but invalid.
 116  func ExtractFromFilter(f *filter.F) (*Query, error) {
 117  	if f == nil || f.Extra == nil {
 118  		return nil, nil
 119  	}
 120  
 121  	raw, ok := f.Extra["_graph"]
 122  	if !ok {
 123  		return nil, nil
 124  	}
 125  
 126  	// Parse as a JSON array of 4 elements
 127  	var arr []json.RawMessage
 128  	if err := json.Unmarshal(raw, &arr); err != nil {
 129  		// Try legacy object format for backward compatibility
 130  		var q Query
 131  		if err2 := json.Unmarshal(raw, &q); err2 != nil {
 132  			return nil, fmt.Errorf("_graph: expected 4-element array, got: %w", err)
 133  		}
 134  		if err2 := q.Validate(); err2 != nil {
 135  			return nil, err2
 136  		}
 137  		return &q, nil
 138  	}
 139  
 140  	if len(arr) != 4 {
 141  		return nil, ErrInvalidArray
 142  	}
 143  
 144  	var q Query
 145  
 146  	// [0] pubkey: string
 147  	if err := json.Unmarshal(arr[0], &q.Pubkey); err != nil {
 148  		return nil, fmt.Errorf("_graph[0] (pubkey): %w", err)
 149  	}
 150  
 151  	// [1] depth: number
 152  	if err := json.Unmarshal(arr[1], &q.Depth); err != nil {
 153  		return nil, fmt.Errorf("_graph[1] (depth): %w", err)
 154  	}
 155  
 156  	// [2] edge: string
 157  	if err := json.Unmarshal(arr[2], &q.Edge); err != nil {
 158  		return nil, fmt.Errorf("_graph[2] (edge): %w", err)
 159  	}
 160  
 161  	// [3] direction: string
 162  	if err := json.Unmarshal(arr[3], &q.Direction); err != nil {
 163  		return nil, fmt.Errorf("_graph[3] (direction): %w", err)
 164  	}
 165  
 166  	if err := q.Validate(); err != nil {
 167  		return nil, err
 168  	}
 169  
 170  	return &q, nil
 171  }
 172  
 173  // IsGraphQuery returns true if the filter contains a _graph extension.
 174  func IsGraphQuery(f *filter.F) bool {
 175  	if f == nil || f.Extra == nil {
 176  		return false
 177  	}
 178  	_, ok := f.Extra["_graph"]
 179  	return ok
 180  }
 181  
 182  // IsPubkeyPubkey returns true if this query traverses pubkey↔pubkey edges.
 183  func (q *Query) IsPubkeyPubkey() bool { return q.Edge == "pp" }
 184  
 185  // IsPubkeyEvent returns true if this query traverses pubkey↔event edges.
 186  func (q *Query) IsPubkeyEvent() bool { return q.Edge == "pe" }
 187  
 188  // IsEventEvent returns true if this query traverses event↔event edges.
 189  func (q *Query) IsEventEvent() bool { return q.Edge == "ee" }
 190  
 191  // IsOutbound returns true if traversal follows outbound edges.
 192  func (q *Query) IsOutbound() bool { return q.Direction == "out" || q.Direction == "both" }
 193  
 194  // IsInbound returns true if traversal follows inbound edges.
 195  func (q *Query) IsInbound() bool { return q.Direction == "in" || q.Direction == "both" }
 196  
 197  // IsBidirectional returns true if traversal follows both directions.
 198  func (q *Query) IsBidirectional() bool { return q.Direction == "both" }
 199