executor.go raw
1 //go:build !(js && wasm)
2
3 // Package graph implements NIP-XX Graph Query protocol support.
4 // This file contains the executor that runs graph traversal queries.
5 package graph
6
7 import (
8 "encoding/json"
9 "fmt"
10 "sort"
11 "strconv"
12 "time"
13
14 "next.orly.dev/pkg/lol/chk"
15 "next.orly.dev/pkg/lol/log"
16
17 "next.orly.dev/pkg/nostr/encoders/event"
18 "next.orly.dev/pkg/nostr/encoders/tag"
19 "next.orly.dev/pkg/nostr/interfaces/signer"
20 "next.orly.dev/pkg/nostr/interfaces/signer/p8k"
21 )
22
23 // Response kind for graph queries (ephemeral, relay-signed)
24 const KindGraphResult = 39000
25
26 // GraphResultI is the interface that database.GraphResult implements.
27 type GraphResultI interface {
28 ToDepthArrays() [][]string
29 ToEventDepthArrays() [][]string
30 GetAllPubkeys() []string
31 GetAllEvents() []string
32 GetPubkeysByDepth() map[int][]string
33 GetEventsByDepth() map[int][]string
34 GetTotalPubkeys() int
35 GetTotalEvents() int
36 GetInboundRefs() map[uint16]map[string][]string
37 GetOutboundRefs() map[uint16]map[string][]string
38 }
39
40 // GraphDatabase defines the interface for graph traversal operations.
41 // Each method corresponds to one cell in the edge × direction matrix.
42 type GraphDatabase interface {
43 // Pubkey↔Pubkey (pp) — noun-noun edges via ppg/gpp index
44 TraversePubkeyPubkey(seedPubkey []byte, maxDepth int, direction string) (GraphResultI, error)
45
46 // Pubkey↔Event (pe) — adverb edges via peg/epg index
47 TraversePubkeyEvent(seedPubkey []byte, maxDepth int, direction string) (GraphResultI, error)
48
49 // Event↔Event (ee) — adjective edges via eeg/gee index
50 TraverseEventEvent(seedEventID []byte, maxDepth int, direction string) (GraphResultI, error)
51
52 // Baseline (no graph indexes) — for benchmark comparison.
53 // Same semantics as TraversePubkeyPubkey but uses multi-hop NIP-01 queries.
54 TraversePubkeyPubkeyBaseline(seedPubkey []byte, maxDepth int, direction string) (GraphResultI, error)
55 }
56
57 // Executor handles graph query execution and response generation.
58 type Executor struct {
59 db GraphDatabase
60 relaySigner signer.I
61 relayPubkey []byte
62 baseline bool // when true, use baseline (no ppg/gpp) for pp queries
63 }
64
65 // NewExecutor creates a new graph query executor.
66 func NewExecutor(db GraphDatabase, secretKey []byte) (*Executor, error) {
67 s, err := p8k.New()
68 if err != nil {
69 return nil, err
70 }
71 if err = s.InitSec(secretKey); err != nil {
72 return nil, err
73 }
74 return &Executor{
75 db: db,
76 relaySigner: s,
77 relayPubkey: s.Pub(),
78 }, nil
79 }
80
81 // SetBaseline enables or disables baseline mode for pp queries.
82 // In baseline mode, pubkey↔pubkey traversal uses multi-hop NIP-01 queries
83 // instead of the ppg/gpp materialized index, for benchmark comparison.
84 func (e *Executor) SetBaseline(enabled bool) {
85 e.baseline = enabled
86 }
87
88 // Execute runs a graph query and returns a relay-signed event with results.
89 func (e *Executor) Execute(q *Query) (*event.E, error) {
90 var result GraphResultI
91 var err error
92
93 start := time.Now()
94
95 switch q.Edge {
96 case "pp":
97 // Pubkey↔pubkey: decode seed as pubkey hex
98 seedBytes, decErr := decodeHex(q.Pubkey)
99 if decErr != nil {
100 return nil, decErr
101 }
102 if e.baseline {
103 result, err = e.db.TraversePubkeyPubkeyBaseline(seedBytes, q.Depth, q.Direction)
104 } else {
105 result, err = e.db.TraversePubkeyPubkey(seedBytes, q.Depth, q.Direction)
106 }
107
108 case "pe":
109 // Pubkey↔event: decode seed as pubkey hex
110 seedBytes, decErr := decodeHex(q.Pubkey)
111 if decErr != nil {
112 return nil, decErr
113 }
114 result, err = e.db.TraversePubkeyEvent(seedBytes, q.Depth, q.Direction)
115
116 case "ee":
117 // Event↔event: seed is still a pubkey in our spec, but the traversal
118 // seeds from events authored by that pubkey. For direct event→event
119 // traversal, the adapter can resolve this.
120 seedBytes, decErr := decodeHex(q.Pubkey)
121 if decErr != nil {
122 return nil, decErr
123 }
124 result, err = e.db.TraverseEventEvent(seedBytes, q.Depth, q.Direction)
125
126 default:
127 return nil, ErrInvalidEdge
128 }
129
130 if err != nil {
131 return nil, err
132 }
133
134 elapsed := time.Since(start)
135 log.D.F("graph query: edge=%s dir=%s depth=%d elapsed=%s pubkeys=%d events=%d baseline=%v",
136 q.Edge, q.Direction, q.Depth, elapsed, result.GetTotalPubkeys(), result.GetTotalEvents(), e.baseline)
137
138 return e.generateResponse(q, result, elapsed)
139 }
140
141 // generateResponse creates a relay-signed event containing the query results.
142 func (e *Executor) generateResponse(q *Query, result GraphResultI, elapsed time.Duration) (*event.E, error) {
143 var content ResponseContent
144 content.Elapsed = elapsed.String()
145
146 if q.IsPubkeyPubkey() || (q.IsPubkeyEvent() && q.IsOutbound()) {
147 content.PubkeysByDepth = result.ToDepthArrays()
148 content.TotalPubkeys = result.GetTotalPubkeys()
149 }
150 if q.IsEventEvent() || (q.IsPubkeyEvent() && q.IsInbound()) {
151 content.EventsByDepth = result.ToEventDepthArrays()
152 content.TotalEvents = result.GetTotalEvents()
153 }
154
155 contentBytes, err := json.Marshal(content)
156 if err != nil {
157 return nil, err
158 }
159
160 tags := tag.NewS(
161 tag.NewFromAny("edge", q.Edge),
162 tag.NewFromAny("direction", q.Direction),
163 tag.NewFromAny("seed", q.Pubkey),
164 tag.NewFromAny("depth", strconv.Itoa(q.Depth)),
165 tag.NewFromAny("elapsed", elapsed.String()),
166 )
167 if e.baseline {
168 tags.Append(tag.NewFromAny("baseline", "true"))
169 }
170
171 ev := &event.E{
172 Kind: KindGraphResult,
173 CreatedAt: time.Now().Unix(),
174 Tags: tags,
175 Content: contentBytes,
176 }
177
178 if err = ev.Sign(e.relaySigner); chk.E(err) {
179 return nil, err
180 }
181
182 return ev, nil
183 }
184
185 // ResponseContent is the JSON structure for graph query responses.
186 type ResponseContent struct {
187 PubkeysByDepth [][]string `json:"pubkeys_by_depth,omitempty"`
188 EventsByDepth [][]string `json:"events_by_depth,omitempty"`
189 TotalPubkeys int `json:"total_pubkeys,omitempty"`
190 TotalEvents int `json:"total_events,omitempty"`
191 Elapsed string `json:"elapsed,omitempty"`
192 }
193
194 // RefSummary represents aggregated reference data.
195 type RefSummary struct {
196 Kind uint16 `json:"kind"`
197 Target string `json:"target"`
198 Count int `json:"count"`
199 Refs []string `json:"refs,omitempty"`
200 }
201
202 func buildRefSummaries(refs map[uint16]map[string][]string) []RefSummary {
203 var summaries []RefSummary
204 for kind, targets := range refs {
205 for targetID, refIDs := range targets {
206 summaries = append(summaries, RefSummary{
207 Kind: kind,
208 Target: targetID,
209 Count: len(refIDs),
210 Refs: refIDs,
211 })
212 }
213 }
214 sort.Slice(summaries, func(i, j int) bool {
215 if summaries[i].Count != summaries[j].Count {
216 return summaries[i].Count > summaries[j].Count
217 }
218 return summaries[i].Kind < summaries[j].Kind
219 })
220 return summaries
221 }
222
223 // decodeHex decodes a hex string to bytes, with validation.
224 func decodeHex(hexStr string) ([]byte, error) {
225 if len(hexStr) != 64 {
226 return nil, fmt.Errorf("expected 64-char hex, got %d chars", len(hexStr))
227 }
228 b := make([]byte, 32)
229 for i := 0; i < 32; i++ {
230 hi := unhex(hexStr[i*2])
231 lo := unhex(hexStr[i*2+1])
232 if hi == 0xFF || lo == 0xFF {
233 return nil, fmt.Errorf("invalid hex char at position %d", i*2)
234 }
235 b[i] = hi<<4 | lo
236 }
237 return b, nil
238 }
239
240 func unhex(c byte) byte {
241 switch {
242 case c >= '0' && c <= '9':
243 return c - '0'
244 case c >= 'a' && c <= 'f':
245 return c - 'a' + 10
246 case c >= 'A' && c <= 'F':
247 return c - 'A' + 10
248 default:
249 return 0xFF
250 }
251 }
252