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