@subsquid/portal-client 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,639 @@
1
+ import {HttpBody, HttpClient, HttpClientOptions, HttpError, HttpResponse, RequestOptions} from '@subsquid/http-client'
2
+ import {
3
+ addErrorContext,
4
+ createFuture,
5
+ Future,
6
+ last,
7
+ unexpectedCase,
8
+ wait,
9
+ withErrorContext,
10
+ } from '@subsquid/util-internal'
11
+ import {Readable} from 'stream'
12
+ import {evm, PortalBlock, PortalQuery, solana, substrate} from './query'
13
+ import {Simplify} from './query/common'
14
+
15
+ export type * from './query'
16
+
17
+ const USER_AGENT = `@subsquid/portal-client (https://sqd.ai)`
18
+
19
+ export interface PortalClientOptions {
20
+ /**
21
+ * The URL of the portal dataset.
22
+ */
23
+ url: string
24
+
25
+ /**
26
+ * Optional custom HTTP client to use.
27
+ */
28
+ http?: HttpClient | HttpClientOptions
29
+
30
+ /**
31
+ * Minimum number of bytes to return.
32
+ * @default 10_485_760 (10MB)
33
+ */
34
+ minBytes?: number
35
+
36
+ /**
37
+ * Maximum number of bytes to return.
38
+ * @default minBytes
39
+ */
40
+ maxBytes?: number
41
+
42
+ /**
43
+ * Maximum time between stream data in milliseconds for return.
44
+ * @default 300
45
+ */
46
+ maxIdleTime?: number
47
+
48
+ /**
49
+ * Maximum wait time in milliseconds for return.
50
+ * @default 5_000
51
+ */
52
+ maxWaitTime?: number
53
+
54
+ /**
55
+ * Interval for polling the head in milliseconds.
56
+ * @default 0
57
+ */
58
+ headPollInterval?: number
59
+ }
60
+
61
+ export interface PortalRequestOptions {
62
+ headers?: HeadersInit
63
+ retryAttempts?: number
64
+ retrySchedule?: number[]
65
+ httpTimeout?: number
66
+ bodyTimeout?: number
67
+ abort?: AbortSignal
68
+ }
69
+
70
+ export interface PortalStreamOptions {
71
+ request?: Omit<PortalRequestOptions, 'abort'>
72
+
73
+ minBytes?: number
74
+ maxBytes?: number
75
+ maxIdleTime?: number
76
+ maxWaitTime?: number
77
+
78
+ headPollInterval?: number
79
+ }
80
+
81
+ export type PortalStreamData<B> = {
82
+ blocks: B[]
83
+ finalizedHead?: BlockRef
84
+ meta: {
85
+ bytes: number
86
+ }
87
+ }
88
+
89
+ export interface PortalStream<B> extends AsyncIterable<PortalStreamData<B>> {}
90
+
91
+ export type GetBlock<Q extends evm.Query | solana.Query | substrate.Query> = Q['type'] extends 'evm'
92
+ ? evm.Block<Q['fields']>
93
+ : Q['type'] extends 'solana'
94
+ ? solana.Block<Q['fields']>
95
+ : Q['type'] extends 'substrate'
96
+ ? substrate.Block<Q['fields']>
97
+ : PortalBlock
98
+
99
+ export type BlockRef = {
100
+ hash: string
101
+ number: number
102
+ }
103
+
104
+ export function createQuery<Q extends evm.Query | solana.Query | substrate.Query>(query: Q): Simplify<Q & PortalQuery> {
105
+ return query
106
+ }
107
+
108
+ export class PortalClient {
109
+ private url: URL
110
+ private client: HttpClient
111
+ private headPollInterval: number
112
+ private minBytes: number
113
+ private maxBytes: number
114
+ private maxIdleTime: number
115
+ private maxWaitTime: number
116
+
117
+ constructor(options: PortalClientOptions) {
118
+ this.url = new URL(options.url)
119
+ this.client = options.http instanceof HttpClient ? options.http : new HttpClient(options.http)
120
+ this.headPollInterval = options.headPollInterval ?? 0
121
+ this.minBytes = options.minBytes ?? 10 * 1024 * 1024
122
+ this.maxBytes = options.maxBytes ?? this.minBytes
123
+ this.maxIdleTime = options.maxIdleTime ?? 300
124
+ this.maxWaitTime = options.maxWaitTime ?? 5_000
125
+ }
126
+
127
+ private getDatasetUrl(path: string): string {
128
+ let u = new URL(this.url)
129
+ if (this.url.pathname.endsWith('/')) {
130
+ u.pathname += path
131
+ } else {
132
+ u.pathname += '/' + path
133
+ }
134
+ return u.toString()
135
+ }
136
+
137
+ async getHead(options?: PortalRequestOptions): Promise<BlockRef | undefined> {
138
+ const res = await this.request('GET', this.getDatasetUrl('head'), options)
139
+ return res.body ?? undefined
140
+ }
141
+
142
+ async getFinalizedHead(options?: PortalRequestOptions): Promise<BlockRef | undefined> {
143
+ const res = await this.request('GET', this.getDatasetUrl('finalized-head'), options)
144
+ return res.body ?? undefined
145
+ }
146
+
147
+ /**
148
+ * @deprecated
149
+ */
150
+ async getFinalizedHeight(options?: PortalRequestOptions): Promise<number> {
151
+ let {body} = await this.request<string>('GET', this.getDatasetUrl('finalized-stream/height'), options)
152
+ let height = parseInt(body)
153
+ return height
154
+ }
155
+
156
+ getFinalizedQuery<Q extends evm.Query | solana.Query | substrate.Query, R extends GetBlock<Q> = GetBlock<Q>>(
157
+ query: Q,
158
+ options?: PortalRequestOptions
159
+ ): Promise<R[]> {
160
+ // FIXME: is it needed or it is better to always use stream?
161
+ return this.request<Buffer>('POST', this.getDatasetUrl(`finalized-stream`), {
162
+ ...options,
163
+ json: query,
164
+ })
165
+ .catch(
166
+ withErrorContext({
167
+ archiveQuery: query,
168
+ })
169
+ )
170
+ .then((res) => {
171
+ let blocks = res.body
172
+ .toString('utf8')
173
+ .trimEnd()
174
+ .split('\n')
175
+ .map((line) => JSON.parse(line))
176
+ return blocks
177
+ })
178
+ }
179
+
180
+ getQuery<Q extends PortalQuery = PortalQuery, R extends PortalBlock = PortalBlock>(
181
+ query: Q,
182
+ options?: PortalRequestOptions
183
+ ): Promise<R[]> {
184
+ // FIXME: is it needed or it is better to always use stream?
185
+ return this.request<Buffer>('POST', this.getDatasetUrl(`stream`), {
186
+ ...options,
187
+ json: query,
188
+ })
189
+ .catch(
190
+ withErrorContext({
191
+ archiveQuery: query,
192
+ })
193
+ )
194
+ .then((res) => {
195
+ let blocks = res.body
196
+ .toString('utf8')
197
+ .trimEnd()
198
+ .split('\n')
199
+ .map((line) => JSON.parse(line))
200
+ return blocks
201
+ })
202
+ }
203
+
204
+ getFinalizedStream<Q extends evm.Query | solana.Query | substrate.Query, R extends GetBlock<Q> = GetBlock<Q>>(
205
+ query: Q,
206
+ options?: PortalStreamOptions
207
+ ): PortalStream<R> {
208
+ return createPortalStream(query, this.getStreamOptions(options), async (q, o) =>
209
+ this.getStreamRequest('finalized-stream', q, o)
210
+ )
211
+ }
212
+
213
+ getStream<Q extends evm.Query | solana.Query | substrate.Query, R extends GetBlock<Q> = GetBlock<Q>>(
214
+ query: Q,
215
+ options?: PortalStreamOptions
216
+ ): PortalStream<R> {
217
+ return createPortalStream(query, this.getStreamOptions(options), async (q, o) =>
218
+ this.getStreamRequest('stream', q, o)
219
+ )
220
+ }
221
+
222
+ private getStreamOptions(options?: PortalStreamOptions) {
223
+ let {
224
+ headPollInterval = this.headPollInterval,
225
+ minBytes = this.minBytes,
226
+ maxBytes = this.maxBytes,
227
+ maxIdleTime = this.maxIdleTime,
228
+ maxWaitTime = this.maxWaitTime,
229
+ request = {},
230
+ } = options ?? {}
231
+
232
+ return {
233
+ headPollInterval,
234
+ minBytes,
235
+ maxBytes,
236
+ maxIdleTime,
237
+ maxWaitTime,
238
+ request,
239
+ }
240
+ }
241
+
242
+ private async getStreamRequest(path: string, query: PortalQuery, options?: PortalRequestOptions) {
243
+ try {
244
+ let res = await this.request<Readable | undefined>('POST', this.getDatasetUrl(path), {
245
+ ...options,
246
+ json: query,
247
+ stream: true,
248
+ }).catch(
249
+ withErrorContext({
250
+ query: query,
251
+ })
252
+ )
253
+
254
+ switch (res.status) {
255
+ case 200:
256
+ let finalizedHead = getFinalizedHeadHeader(res.headers)
257
+ let stream = res.body ? splitLines(res.body) : undefined
258
+
259
+ return {
260
+ finalizedHead,
261
+ stream,
262
+ }
263
+ case 204:
264
+ return {
265
+ finalizedHead: getFinalizedHeadHeader(res.headers),
266
+ }
267
+ default:
268
+ throw unexpectedCase(res.status)
269
+ }
270
+ } catch (e: unknown) {
271
+ if (isForkHttpError(e) && query.fromBlock != null && query.parentBlockHash != null) {
272
+ e = new ForkException(e.response.body.lastBlocks, {
273
+ number: query.fromBlock - 1,
274
+ hash: query.parentBlockHash,
275
+ })
276
+ }
277
+
278
+ throw addErrorContext(e as any, {
279
+ query,
280
+ })
281
+ }
282
+ }
283
+
284
+ private request<T = any>(method: string, url: string, options: RequestOptions & HttpBody = {}) {
285
+ return this.client.request<T>(method, url, {
286
+ ...options,
287
+ headers: {
288
+ 'User-Agent': USER_AGENT,
289
+ ...options?.headers,
290
+ },
291
+ })
292
+ }
293
+ }
294
+
295
+ function isForkHttpError(err: unknown): err is HttpError {
296
+ if (!(err instanceof HttpError)) return false
297
+ if (err.response.status !== 409) return false
298
+ if (err.response.body.lastBlocks == null) return false
299
+ return true
300
+ }
301
+
302
+ function createPortalStream<Q extends PortalQuery = PortalQuery, R extends PortalBlock = any>(
303
+ query: Q,
304
+ options: Required<PortalStreamOptions>,
305
+ requestStream: (
306
+ query: Q,
307
+ options?: PortalRequestOptions
308
+ ) => Promise<{finalizedHead?: BlockRef; stream?: AsyncIterable<string[]> | null | undefined}>
309
+ ): PortalStream<R> {
310
+ let {headPollInterval, request, ...bufferOptions} = options
311
+
312
+ let buffer = new PortalStreamBuffer<R>(bufferOptions)
313
+
314
+ let {fromBlock = 0, toBlock, parentBlockHash} = query
315
+
316
+ const ingest = async () => {
317
+ if (buffer.signal.aborted) return
318
+ if (toBlock != null && fromBlock > toBlock) return
319
+
320
+ let res = await requestStream(
321
+ {
322
+ ...query,
323
+ fromBlock,
324
+ parentBlockHash,
325
+ },
326
+ {
327
+ ...request,
328
+ abort: buffer.signal,
329
+ }
330
+ )
331
+
332
+ const finalizedHead = res.finalizedHead
333
+
334
+ // we are on head
335
+ if (!('stream' in res)) {
336
+ await buffer.put({blocks: [], meta: {bytes: 0}, finalizedHead})
337
+ buffer.flush()
338
+ if (headPollInterval > 0) {
339
+ await wait(headPollInterval, buffer.signal)
340
+ }
341
+ return ingest()
342
+ }
343
+
344
+ // no data left on this range
345
+ if (res.stream == null) return
346
+
347
+ let iterator = res.stream[Symbol.asyncIterator]()
348
+ try {
349
+ while (true) {
350
+ let data = await iterator.next()
351
+ if (data.done) break
352
+
353
+ let blocks: R[] = []
354
+ let bytes = 0
355
+
356
+ for (let line of data.value) {
357
+ let block = JSON.parse(line) as R
358
+ blocks.push(block)
359
+ bytes += line.length
360
+
361
+ fromBlock = block.header.number + 1
362
+ parentBlockHash = block.header.hash
363
+ }
364
+
365
+ await buffer.put({blocks, finalizedHead, meta: {bytes}})
366
+ }
367
+
368
+ buffer.flush()
369
+ } catch (err) {
370
+ if (buffer.signal.aborted || isStreamAbortedError(err)) {
371
+ // ignore
372
+ } else {
373
+ throw err
374
+ }
375
+ } finally {
376
+ await iterator?.return?.().catch(() => {})
377
+ }
378
+
379
+ return ingest()
380
+ }
381
+
382
+ ingest().then(
383
+ () => buffer.close(),
384
+ (err) => buffer.fail(err)
385
+ )
386
+
387
+ return buffer.iterate()
388
+ }
389
+
390
+ class PortalStreamBuffer<B> {
391
+ private buffer: PortalStreamData<B> | undefined
392
+ private state: 'pending' | 'ready' | 'failed' | 'closed' = 'pending'
393
+ private error: unknown
394
+
395
+ private readyFuture: Future<void> = createFuture()
396
+ private takeFuture: Future<void> = createFuture()
397
+ private putFuture: Future<void> = createFuture()
398
+
399
+ private idleTimeout: ReturnType<typeof setTimeout> | undefined
400
+ private waitTimeout: ReturnType<typeof setTimeout> | undefined
401
+
402
+ private minBytes: number
403
+ private maxBytes: number
404
+ private maxIdleTime: number
405
+ private maxWaitTime: number
406
+
407
+ private abortController = new AbortController()
408
+
409
+ get signal() {
410
+ return this.abortController.signal
411
+ }
412
+
413
+ constructor(options: {maxWaitTime: number; maxBytes: number; maxIdleTime: number; minBytes: number}) {
414
+ this.maxWaitTime = options.maxWaitTime
415
+ this.minBytes = options.minBytes
416
+ this.maxBytes = Math.max(options.maxBytes, options.minBytes)
417
+ this.maxIdleTime = options.maxIdleTime
418
+ }
419
+
420
+ async take(): Promise<PortalStreamData<B> | undefined> {
421
+ if (this.state === 'pending') {
422
+ this.waitTimeout = setTimeout(() => this._ready(), this.maxWaitTime)
423
+ }
424
+
425
+ await Promise.all([this.readyFuture.promise(), this.putFuture.promise()])
426
+
427
+ if (this.state === 'failed') {
428
+ throw this.error
429
+ }
430
+
431
+ let result = this.buffer
432
+ this.buffer = undefined
433
+
434
+ if (this.state === 'closed') {
435
+ return result
436
+ }
437
+
438
+ if (result == null) {
439
+ throw new Error('Buffer is empty')
440
+ }
441
+
442
+ this.takeFuture.resolve()
443
+
444
+ this.readyFuture = createFuture()
445
+ this.putFuture = createFuture()
446
+ this.takeFuture = createFuture()
447
+ this.state = 'pending'
448
+
449
+ return result
450
+ }
451
+
452
+ async put(data: PortalStreamData<B>) {
453
+ if (this.state === 'closed' || this.state === 'failed') {
454
+ throw new Error('Buffer is closed')
455
+ }
456
+
457
+ if (this.idleTimeout != null) {
458
+ clearTimeout(this.idleTimeout)
459
+ this.idleTimeout = undefined
460
+ }
461
+
462
+ if (this.buffer == null) {
463
+ this.buffer = {blocks: [], meta: {bytes: 0}}
464
+ }
465
+
466
+ this.buffer.blocks.push(...data.blocks)
467
+ this.buffer.finalizedHead = data.finalizedHead
468
+ this.buffer.meta.bytes += data.meta.bytes
469
+
470
+ this.putFuture.resolve()
471
+
472
+ if (this.buffer.meta.bytes >= this.minBytes) {
473
+ this.readyFuture.resolve()
474
+ }
475
+
476
+ if (this.buffer.meta.bytes >= this.maxBytes) {
477
+ await this.takeFuture.promise()
478
+ }
479
+
480
+ if (this.state === 'pending') {
481
+ this.idleTimeout = setTimeout(() => this._ready(), this.maxIdleTime)
482
+ }
483
+ }
484
+
485
+ flush() {
486
+ if (this.buffer == null) return
487
+ this._ready()
488
+ }
489
+
490
+ close() {
491
+ if (this.state === 'closed' || this.state === 'failed') return
492
+ this.state = 'closed'
493
+ this._cleanup()
494
+ }
495
+
496
+ fail(err: any) {
497
+ if (this.state === 'closed' || this.state === 'failed') return
498
+ this.state = 'failed'
499
+ this.error = err
500
+ this._cleanup()
501
+ }
502
+
503
+ iterate() {
504
+ return {
505
+ [Symbol.asyncIterator]: (): AsyncIterator<PortalStreamData<B>> => {
506
+ return {
507
+ next: async (): Promise<IteratorResult<PortalStreamData<B>>> => {
508
+ const value = await this.take()
509
+ if (value == null) {
510
+ return {done: true, value: undefined}
511
+ }
512
+ return {done: false, value}
513
+ },
514
+ return: async (): Promise<IteratorResult<PortalStreamData<B>>> => {
515
+ this.close()
516
+ return {done: true, value: undefined}
517
+ },
518
+ throw: async (error?: any): Promise<IteratorResult<PortalStreamData<B>>> => {
519
+ this.fail(error)
520
+ throw error
521
+ },
522
+ }
523
+ },
524
+ }
525
+ }
526
+
527
+ private _ready() {
528
+ if (this.state === 'pending') {
529
+ this.state = 'ready'
530
+ this.readyFuture.resolve()
531
+ }
532
+ if (this.idleTimeout != null) {
533
+ clearTimeout(this.idleTimeout)
534
+ this.idleTimeout = undefined
535
+ }
536
+ if (this.waitTimeout != null) {
537
+ clearTimeout(this.waitTimeout)
538
+ this.waitTimeout = undefined
539
+ }
540
+ }
541
+
542
+ private _cleanup() {
543
+ if (this.idleTimeout != null) {
544
+ clearTimeout(this.idleTimeout)
545
+ this.idleTimeout = undefined
546
+ }
547
+ if (this.waitTimeout != null) {
548
+ clearTimeout(this.waitTimeout)
549
+ this.waitTimeout = undefined
550
+ }
551
+ this.readyFuture.resolve()
552
+ this.putFuture.resolve()
553
+ this.takeFuture.resolve()
554
+ this.abortController.abort()
555
+ }
556
+ }
557
+
558
+ async function* splitLines(chunks: AsyncIterable<Uint8Array>) {
559
+ let splitter = new LineSplitter()
560
+ for await (let chunk of chunks) {
561
+ let lines = splitter.push(chunk)
562
+ if (lines) yield lines
563
+ }
564
+ let lastLine = splitter.end()
565
+ if (lastLine) yield [lastLine]
566
+ }
567
+
568
+ class LineSplitter {
569
+ private decoder = new TextDecoder('utf8')
570
+ private line = ''
571
+
572
+ push(data: Uint8Array): string[] | undefined {
573
+ let s = this.decoder.decode(data)
574
+ if (!s) return
575
+ let lines = s.split('\n')
576
+ if (lines.length == 1) {
577
+ this.line += lines[0]
578
+ } else {
579
+ let result: string[] = []
580
+ lines[0] = this.line + lines[0]
581
+ this.line = last(lines)
582
+ for (let i = 0; i < lines.length - 1; i++) {
583
+ let line = lines[i]
584
+ if (line) {
585
+ result.push(line)
586
+ }
587
+ }
588
+ if (result.length > 0) return result
589
+ }
590
+ }
591
+
592
+ end(): string | undefined {
593
+ if (this.line) return this.line
594
+ }
595
+ }
596
+
597
+ export class ForkException extends Error {
598
+ readonly name = 'ForkError'
599
+
600
+ constructor(readonly lastBlocks: BlockRef[], readonly head: BlockRef) {
601
+ let parent = last(lastBlocks)
602
+ super(
603
+ `expected ${head.number + 1} to have parent ${parent.number}#${parent.hash}, but got ${head.number}#${
604
+ head.hash
605
+ }`
606
+ )
607
+ }
608
+ }
609
+
610
+ export function isForkException(err: unknown): err is ForkException {
611
+ if (err instanceof ForkException) return true
612
+ if (err instanceof Error && err.name === 'ForkError') return true
613
+ return false
614
+ }
615
+
616
+ function getFinalizedHeadHeader(headers: HttpResponse['headers']) {
617
+ let finalizedHeadHash = headers.get('X-Sqd-Finalized-Head-Hash')
618
+ let finalizedHeadNumber = headers.get('X-Sqd-Finalized-Head-Number')
619
+
620
+ return finalizedHeadHash != null && finalizedHeadNumber != null
621
+ ? {
622
+ hash: finalizedHeadHash,
623
+ number: parseInt(finalizedHeadNumber),
624
+ }
625
+ : undefined
626
+ }
627
+
628
+ function isStreamAbortedError(err: unknown) {
629
+ if (!(err instanceof Error)) return false
630
+ if (!('code' in err)) return false
631
+ switch (err.code) {
632
+ case 'ABORT_ERR':
633
+ case 'ERR_STREAM_PREMATURE_CLOSE':
634
+ case 'ECONNRESET':
635
+ return true
636
+ default:
637
+ return false
638
+ }
639
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @example '0x0123456789abcdef'
3
+ */
4
+ export type Hex = string & {}
5
+
6
+ /**
7
+ * @example '123456789ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
8
+ */
9
+ export type Base58 = string & {}
10
+
11
+ /**
12
+ * @example '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz+/='
13
+ */
14
+ export type Base64 = string & {}
15
+
16
+ export type Simplify<T> = {[K in keyof T]: T[K]} & {}
17
+
18
+ export type Distribute<T> = T extends any ? T : never
19
+
20
+ export type ConditionalKeys<T, V> = {
21
+ [Key in keyof T]-?: T[Key] extends V ? (T[Key] extends never ? (V extends never ? Key : never) : Key) : never
22
+ }[keyof T]
23
+
24
+ export type ConditionalPick<T, V> = Simplify<Pick<T, ConditionalKeys<T, V>>>
25
+
26
+ export type ConditionalOmit<T, V> = Simplify<Omit<T, ConditionalKeys<T, V>>>
27
+
28
+ export type Selector<Props extends string | number | symbol = string> = {
29
+ [P in Props]?: boolean
30
+ }
31
+
32
+ export type Select<T, S> = S extends never
33
+ ? never
34
+ : {
35
+ [K in keyof T as K extends keyof S ? (S[K] extends true ? K : never) : never]: T[K]
36
+ }
37
+
38
+ export type Selection = {
39
+ [P in string]?: boolean | Selection
40
+ }
41
+
42
+ export type Trues<T extends Selection> = Simplify<{
43
+ [K in keyof T]-?: [T[K] & {}] extends [Selection] ? Trues<T[K] & {}> : true
44
+ }>
45
+
46
+ export type PortalQuery = {
47
+ type: string
48
+ fromBlock?: number
49
+ toBlock?: number
50
+ parentBlockHash?: string
51
+ [key: string]: unknown
52
+ }
53
+
54
+ export type PortalBlock = {
55
+ header: {
56
+ number: number
57
+ hash: string
58
+ }
59
+ }