graph-query.service.ts raw
1 import { normalizeUrl } from '@/lib/url'
2 import { TGraphQueryCapability, TRelayInfo } from '@/types'
3 import { GraphQuery, GraphResponse } from '@/types/graph'
4 import { Event as NEvent, Filter, SimplePool, verifyEvent } from 'nostr-tools'
5 import relayInfoService from './relay-info.service'
6 import storage from './local-storage.service'
7
8 // Graph query response kinds (relay-signed)
9 const GRAPH_RESPONSE_KINDS = {
10 FOLLOWS: 39000,
11 MENTIONS: 39001,
12 THREAD: 39002
13 }
14
15 class GraphQueryService {
16 static instance: GraphQueryService
17
18 private pool: SimplePool
19 private capabilityCache = new Map<string, TGraphQueryCapability | null>()
20 private capabilityFetchPromises = new Map<string, Promise<TGraphQueryCapability | null>>()
21
22 constructor() {
23 this.pool = new SimplePool()
24 }
25
26 public static getInstance(): GraphQueryService {
27 if (!GraphQueryService.instance) {
28 GraphQueryService.instance = new GraphQueryService()
29 }
30 return GraphQueryService.instance
31 }
32
33 /**
34 * Check if graph queries are enabled in settings
35 */
36 isEnabled(): boolean {
37 return storage.getGraphQueriesEnabled()
38 }
39
40 /**
41 * Get relay's graph query capability via NIP-11
42 */
43 async getRelayCapability(url: string): Promise<TGraphQueryCapability | null> {
44 const normalizedUrl = normalizeUrl(url)
45
46 // Check memory cache first
47 if (this.capabilityCache.has(normalizedUrl)) {
48 return this.capabilityCache.get(normalizedUrl) ?? null
49 }
50
51 // Check if already fetching
52 const existingPromise = this.capabilityFetchPromises.get(normalizedUrl)
53 if (existingPromise) {
54 return existingPromise
55 }
56
57 // Fetch capability
58 const fetchPromise = this._fetchRelayCapability(normalizedUrl)
59 this.capabilityFetchPromises.set(normalizedUrl, fetchPromise)
60
61 try {
62 const capability = await fetchPromise
63 this.capabilityCache.set(normalizedUrl, capability)
64 return capability
65 } finally {
66 this.capabilityFetchPromises.delete(normalizedUrl)
67 }
68 }
69
70 private async _fetchRelayCapability(url: string): Promise<TGraphQueryCapability | null> {
71 try {
72 const relayInfo = (await relayInfoService.getRelayInfo(url)) as TRelayInfo | undefined
73
74 if (!relayInfo?.graph_query?.enabled) {
75 return null
76 }
77
78 return relayInfo.graph_query
79 } catch {
80 return null
81 }
82 }
83
84 /**
85 * Check if a relay supports a specific graph query method
86 */
87 async supportsMethod(url: string, method: GraphQuery['method']): Promise<boolean> {
88 const capability = await this.getRelayCapability(url)
89 if (!capability?.enabled) return false
90 return capability.methods.includes(method)
91 }
92
93 /**
94 * Find a relay supporting graph queries from a list of URLs
95 */
96 async findGraphCapableRelay(
97 urls: string[],
98 method?: GraphQuery['method']
99 ): Promise<string | null> {
100 if (!this.isEnabled()) return null
101
102 // Check capabilities in parallel
103 const results = await Promise.all(
104 urls.map(async (url) => {
105 const capability = await this.getRelayCapability(url)
106 if (!capability?.enabled) return null
107 if (method && !capability.methods.includes(method)) return null
108 return url
109 })
110 )
111
112 return results.find((url) => url !== null) ?? null
113 }
114
115 /**
116 * Execute a graph query against a specific relay
117 */
118 async executeQuery(relayUrl: string, query: GraphQuery): Promise<GraphResponse | null> {
119 if (!this.isEnabled()) return null
120
121 const capability = await this.getRelayCapability(relayUrl)
122 if (!capability?.enabled) {
123 console.warn(`Relay ${relayUrl} does not support graph queries`)
124 return null
125 }
126
127 // Validate method support
128 if (!capability.methods.includes(query.method)) {
129 console.warn(`Relay ${relayUrl} does not support method: ${query.method}`)
130 return null
131 }
132
133 // Validate depth
134 const depth = query.depth ?? 1
135 if (depth > capability.max_depth) {
136 console.warn(`Requested depth ${depth} exceeds relay max ${capability.max_depth}`)
137 query = { ...query, depth: capability.max_depth }
138 }
139
140 // Build the filter with graph extension
141 // The _graph field is a custom extension not in the standard Filter type
142 const filter = {
143 _graph: query
144 } as Filter
145
146 // Determine expected response kind
147 const expectedKind = this.getExpectedResponseKind(query.method)
148
149 return new Promise<GraphResponse | null>(async (resolve) => {
150 let resolved = false
151 const timeout = setTimeout(() => {
152 if (!resolved) {
153 resolved = true
154 resolve(null)
155 }
156 }, 30000) // 30s timeout for graph queries
157
158 try {
159 const relay = await this.pool.ensureRelay(relayUrl, { connectionTimeout: 5000 })
160
161 const sub = relay.subscribe([filter], {
162 onevent: (event: NEvent) => {
163 // Verify it's a relay-signed graph response
164 if (event.kind !== expectedKind) return
165
166 // Verify event signature
167 if (!verifyEvent(event)) {
168 console.warn('Invalid signature on graph response')
169 return
170 }
171
172 try {
173 const response = JSON.parse(event.content) as GraphResponse
174 if (!resolved) {
175 resolved = true
176 clearTimeout(timeout)
177 sub.close()
178 resolve(response)
179 }
180 } catch (e) {
181 console.error('Failed to parse graph response:', e)
182 }
183 },
184 oneose: () => {
185 // If we got EOSE without a response, the query may not be supported
186 if (!resolved) {
187 resolved = true
188 clearTimeout(timeout)
189 sub.close()
190 resolve(null)
191 }
192 }
193 })
194 } catch (error) {
195 console.error('Failed to connect to relay for graph query:', error)
196 if (!resolved) {
197 resolved = true
198 clearTimeout(timeout)
199 resolve(null)
200 }
201 }
202 })
203 }
204
205 private getExpectedResponseKind(method: GraphQuery['method']): number {
206 switch (method) {
207 case 'follows':
208 case 'followers':
209 return GRAPH_RESPONSE_KINDS.FOLLOWS
210 case 'mentions':
211 return GRAPH_RESPONSE_KINDS.MENTIONS
212 case 'thread':
213 return GRAPH_RESPONSE_KINDS.THREAD
214 default:
215 return GRAPH_RESPONSE_KINDS.FOLLOWS
216 }
217 }
218
219 /**
220 * High-level method: Query follow graph with fallback
221 */
222 async queryFollowGraph(
223 relayUrls: string[],
224 seed: string,
225 depth: number = 1
226 ): Promise<GraphResponse | null> {
227 const graphRelay = await this.findGraphCapableRelay(relayUrls, 'follows')
228 if (!graphRelay) return null
229
230 return this.executeQuery(graphRelay, {
231 method: 'follows',
232 seed,
233 depth
234 })
235 }
236
237 /**
238 * High-level method: Query follower graph
239 */
240 async queryFollowerGraph(
241 relayUrls: string[],
242 seed: string,
243 depth: number = 1
244 ): Promise<GraphResponse | null> {
245 const graphRelay = await this.findGraphCapableRelay(relayUrls, 'followers')
246 if (!graphRelay) return null
247
248 return this.executeQuery(graphRelay, {
249 method: 'followers',
250 seed,
251 depth
252 })
253 }
254
255 /**
256 * High-level method: Query thread with optional ref aggregation
257 */
258 async queryThread(
259 relayUrls: string[],
260 eventId: string,
261 depth: number = 10,
262 options?: {
263 inboundRefKinds?: number[]
264 outboundRefKinds?: number[]
265 }
266 ): Promise<GraphResponse | null> {
267 const graphRelay = await this.findGraphCapableRelay(relayUrls, 'thread')
268 if (!graphRelay) return null
269
270 const query: GraphQuery = {
271 method: 'thread',
272 seed: eventId,
273 depth
274 }
275
276 if (options?.inboundRefKinds?.length) {
277 query.inbound_refs = [{ kinds: options.inboundRefKinds, from_depth: 0 }]
278 }
279
280 if (options?.outboundRefKinds?.length) {
281 query.outbound_refs = [{ kinds: options.outboundRefKinds, from_depth: 0 }]
282 }
283
284 return this.executeQuery(graphRelay, query)
285 }
286
287 /**
288 * High-level method: Query mentions with aggregation
289 */
290 async queryMentions(
291 relayUrls: string[],
292 pubkey: string,
293 options?: {
294 inboundRefKinds?: number[] // e.g., [7, 9735] for reactions and zaps
295 }
296 ): Promise<GraphResponse | null> {
297 const graphRelay = await this.findGraphCapableRelay(relayUrls, 'mentions')
298 if (!graphRelay) return null
299
300 const query: GraphQuery = {
301 method: 'mentions',
302 seed: pubkey
303 }
304
305 if (options?.inboundRefKinds?.length) {
306 query.inbound_refs = [{ kinds: options.inboundRefKinds, from_depth: 0 }]
307 }
308
309 return this.executeQuery(graphRelay, query)
310 }
311
312 /**
313 * Clear capability cache for a relay (e.g., when relay info is updated)
314 */
315 clearCapabilityCache(url?: string): void {
316 if (url) {
317 const normalizedUrl = normalizeUrl(url)
318 this.capabilityCache.delete(normalizedUrl)
319 } else {
320 this.capabilityCache.clear()
321 }
322 }
323 }
324
325 const instance = GraphQueryService.getInstance()
326 export default instance
327