wao 0.3.1 → 0.4.1

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.
@@ -0,0 +1,709 @@
1
+ import Arweave from "arweave"
2
+ const KB = 1024
3
+ const MB = KB * 1024
4
+ const CACHE_SZ = 32 * KB
5
+ const CHUNK_SZ = 128 * MB
6
+ const NOTIFY_SZ = 512 * MB
7
+
8
+ export default class WeaveDrive {
9
+ constructor(ar) {
10
+ this.drive = function WeaveDrive(mod, FS) {
11
+ return {
12
+ reset(fd) {
13
+ //console.log("WeaveDrive: Resetting fd: ", fd)
14
+ FS.streams[fd].node.position = 0
15
+ FS.streams[fd].node.cache = new Uint8Array(0)
16
+ },
17
+
18
+ joinUrl({ url, path }) {
19
+ if (!path) return url
20
+ if (path.startsWith("/"))
21
+ return this.joinUrl({ url, path: path.slice(1) })
22
+
23
+ url = new URL(url)
24
+ url.pathname += path
25
+ return url.toString()
26
+ },
27
+
28
+ async customFetch(path, options) {
29
+ /**
30
+ * mod.ARWEAVE may be a comma-delimited list of urls.
31
+ * So we parse it into an array that we sequentially consume
32
+ * using fetch, and return the first successful response.
33
+ *
34
+ * The first url is considered "primary". So if all urls fail
35
+ * to produce a successful response, then we return the primary's
36
+ * error response
37
+ */
38
+ const urlList = mod.ARWEAVE.includes(",")
39
+ ? mod.ARWEAVE.split(",").map(url => url.trim())
40
+ : [mod.ARWEAVE]
41
+
42
+ let p
43
+ for (const url of urlList) {
44
+ const res = fetch(this.joinUrl({ url, path }), options)
45
+ if (await res.then(r => r.ok).catch(() => false)) return res
46
+ if (!p) p = res
47
+ }
48
+
49
+ /**
50
+ * None succeeded so fallback to the primary and accept
51
+ * whatever it returned
52
+ */
53
+ return p
54
+ },
55
+
56
+ async create(id) {
57
+ var properties = { isDevice: false, contents: null }
58
+
59
+ if (!(await this.checkAdmissible(id))) {
60
+ //console.log("WeaveDrive: Arweave ID is not admissable! ", id)
61
+ return 0
62
+ }
63
+
64
+ // Create the file in the emscripten FS
65
+
66
+ // This check/mkdir was added for AOP 6 Boot loader because create is
67
+ // called first because were only loading Data, we needed to create
68
+ // the directory. See: https://github.com/permaweb/aos/issues/342
69
+ if (!FS.analyzePath("/data/").exists) {
70
+ FS.mkdir("/data/")
71
+ }
72
+
73
+ var node = FS.createFile("/", "data/" + id, properties, true, false)
74
+ // Set initial parameters
75
+ /*
76
+ var bytesLength = await this.customFetch(`/${id}`, {
77
+ method: "HEAD",
78
+ }).then(res => res.headers.get("Content-Length"))
79
+ */
80
+ let data = await ar.data(id)
81
+ const bytesLength = data ? new TextEncoder().encode(data).length : 100
82
+ node.total_size = Number(bytesLength)
83
+ node.cache = new Uint8Array(0)
84
+ node.position = 0
85
+
86
+ // Add a function that defers querying the file size until it is asked the first time.
87
+ Object.defineProperties(node, {
88
+ usedBytes: {
89
+ get: function () {
90
+ return bytesLength
91
+ },
92
+ },
93
+ })
94
+
95
+ // Now we have created the file in the emscripten FS, we can open it as a stream
96
+ var stream = FS.open("/data/" + id, "r")
97
+
98
+ //console.log("JS: Created file: ", id, " fd: ", stream.fd);
99
+ return stream
100
+ },
101
+ async createBlockHeader(id) {
102
+ const customFetch = this.customFetch
103
+ // todo: add a bunch of retries
104
+ async function retry(x) {
105
+ return new Promise(r => {
106
+ setTimeout(function () {
107
+ r(customFetch(`/block/height/${id}`))
108
+ }, x * 10000)
109
+ })
110
+ }
111
+ var result = await this.customFetch(`/block/height/${id}`)
112
+ .then(res => (!res.ok ? retry(1) : res))
113
+ .then(res => (!res.ok ? retry(2) : res))
114
+ .then(res => (!res.ok ? retry(3) : res))
115
+ .then(res => (!res.ok ? retry(4) : res))
116
+ .then(res => res.text())
117
+
118
+ var bytesLength = result.length
119
+
120
+ var node = FS.createDataFile(
121
+ "/",
122
+ "block/" + id,
123
+ Buffer.from(result, "utf-8"),
124
+ true,
125
+ false,
126
+ )
127
+
128
+ var stream = FS.open("/block/" + id, "r")
129
+ return stream
130
+ },
131
+ async createTxHeader(id) {
132
+ const customFetch = this.customFetch
133
+ async function toAddress(owner) {
134
+ return Arweave.utils.bufferTob64Url(
135
+ await Arweave.crypto.hash(Arweave.utils.b64UrlToBuffer(owner)),
136
+ )
137
+ }
138
+ async function retry(x) {
139
+ return new Promise(r => {
140
+ setTimeout(function () {
141
+ r(customFetch(`/tx/${id}`))
142
+ }, x * 10000)
143
+ })
144
+ }
145
+ // todo: add a bunch of retries
146
+ var result = await this.customFetch(`/tx/${id}`)
147
+ .then(res => (!res.ok ? retry(1) : res))
148
+ .then(res => (!res.ok ? retry(2) : res))
149
+ .then(res => (!res.ok ? retry(3) : res))
150
+ .then(res => (!res.ok ? retry(4) : res))
151
+ .then(res => res.json())
152
+ .then(async entry => ({
153
+ ...entry,
154
+ ownerAddress: await toAddress(entry.owner),
155
+ }))
156
+ //.then(x => (console.error(x), x))
157
+ .then(x => JSON.stringify(x))
158
+
159
+ var node = FS.createDataFile(
160
+ "/",
161
+ "tx/" + id,
162
+ Buffer.from(result, "utf-8"),
163
+ true,
164
+ false,
165
+ )
166
+ var stream = FS.open("/tx/" + id, "r")
167
+ return stream
168
+ },
169
+ async createDataItemTxHeader(id) {
170
+ const gqlQuery = this.gqlQuery
171
+ var GET_TRANSACTION_QUERY = `
172
+ query GetTransactions ($transactionIds: [ID!]!) {
173
+ transactions(ids: $transactionIds) {
174
+ edges {
175
+ node {
176
+ id
177
+ anchor
178
+ data {
179
+ size
180
+ }
181
+ signature
182
+ recipient
183
+ owner {
184
+ address
185
+ key
186
+ }
187
+ fee {
188
+ ar
189
+ winston
190
+ }
191
+ quantity {
192
+ winston
193
+ ar
194
+ }
195
+ tags {
196
+ name
197
+ value
198
+ }
199
+ bundledIn {
200
+ id
201
+ }
202
+ block {
203
+ id
204
+ timestamp
205
+ height
206
+ previous
207
+ }
208
+ }
209
+ }
210
+ }
211
+ }`
212
+ var variables = { transactionIds: [id] }
213
+ async function retry(x) {
214
+ return new Promise(r => {
215
+ setTimeout(function () {
216
+ r(gqlQuery(GET_TRANSACTION_QUERY, variables))
217
+ }, x * 10000)
218
+ })
219
+ }
220
+
221
+ const gqlExists = await this.gqlExists()
222
+ if (!gqlExists) {
223
+ return "GQL Not Found!"
224
+ }
225
+
226
+ // todo: add a bunch of retries
227
+ var result = await this.gqlQuery(GET_TRANSACTION_QUERY, variables)
228
+ .then(res => (!res.ok ? retry(1) : res))
229
+ .then(res => (!res.ok ? retry(2) : res))
230
+ .then(res => (!res.ok ? retry(3) : res))
231
+ .then(res => (!res.ok ? retry(4) : res))
232
+ .then(res => res.json())
233
+ .then(res => {
234
+ return res?.data?.transactions?.edges?.[0]?.node
235
+ ? res.data.transactions.edges[0].node
236
+ : "No results"
237
+ })
238
+ .then(async entry => {
239
+ return typeof entry == "string"
240
+ ? entry
241
+ : {
242
+ format: 3,
243
+ ...entry,
244
+ }
245
+ })
246
+ .then(x => {
247
+ return typeof x == "string" ? x : JSON.stringify(x)
248
+ })
249
+
250
+ if (result === "No results") {
251
+ return result
252
+ }
253
+ FS.createDataFile(
254
+ "/",
255
+ "tx2/" + id,
256
+ Buffer.from(result, "utf-8"),
257
+ true,
258
+ false,
259
+ )
260
+ var stream = FS.open("/tx2/" + id, "r")
261
+
262
+ return stream
263
+ },
264
+ async open(filename) {
265
+ const pathCategory = filename.split("/")[1]
266
+ const id = filename.split("/")[2]
267
+ console.log("JS: Opening ID: ", id)
268
+ if (pathCategory === "tx") {
269
+ FS.createPath("/", "tx", true, false)
270
+ if (FS.analyzePath(filename).exists) {
271
+ var stream = FS.open(filename, "r")
272
+ if (stream.fd) return stream.fd
273
+ return 0
274
+ } else {
275
+ const stream = await this.createTxHeader(id)
276
+ return stream.fd
277
+ }
278
+ }
279
+ if (pathCategory === "tx2") {
280
+ FS.createPath("/", "tx2", true, false)
281
+ if (FS.analyzePath(filename).exists) {
282
+ var stream = FS.open(filename, "r")
283
+ if (stream.fd) return stream.fd
284
+ return 0
285
+ } else {
286
+ const stream = await this.createDataItemTxHeader(id)
287
+ if (stream.fd) return stream.fd
288
+ return 0
289
+ }
290
+ }
291
+ if (pathCategory === "block") {
292
+ FS.createPath("/", "block", true, false)
293
+ if (FS.analyzePath(filename).exists) {
294
+ var stream = FS.open(filename, "r")
295
+ if (stream.fd) return stream.fd
296
+ return 0
297
+ } else {
298
+ const stream = await this.createBlockHeader(id)
299
+ return stream.fd
300
+ }
301
+ }
302
+ if (pathCategory === "data") {
303
+ if (FS.analyzePath(filename).exists) {
304
+ var stream = FS.open(filename, "r")
305
+ if (stream.fd) return stream.fd
306
+ console.log("JS: File not found: ", filename)
307
+ return 0
308
+ } else {
309
+ //console.log("JS: Open => Creating file: ", id);
310
+ const stream = await this.create(id)
311
+ //console.log("JS: Open => Created file: ", id, " fd: ", stream.fd);
312
+ return stream.fd
313
+ }
314
+ } else if (pathCategory === "headers") {
315
+ console.log("Header access not implemented yet.")
316
+ return 0
317
+ } else {
318
+ console.log("JS: Invalid path category: ", pathCategory)
319
+ return 0
320
+ }
321
+ },
322
+ async read(fd, raw_dst_ptr, raw_length) {
323
+ // Note: The length and dst_ptr are 53 bit integers in JS, so this _should_ be ok into a large memspace.
324
+ var to_read = Number(raw_length)
325
+ var dst_ptr = Number(raw_dst_ptr)
326
+
327
+ var stream = 0
328
+ for (var i = 0; i < FS.streams.length; i++) {
329
+ if (FS.streams[i].fd === fd) {
330
+ stream = FS.streams[i]
331
+ }
332
+ }
333
+ // read block headers
334
+ if (stream.path.includes("/block")) {
335
+ mod.HEAP8.set(stream.node.contents.subarray(0, to_read), dst_ptr)
336
+ return to_read
337
+ }
338
+ // read tx headers
339
+ if (stream.path.includes("/tx")) {
340
+ mod.HEAP8.set(stream.node.contents.subarray(0, to_read), dst_ptr)
341
+ return to_read
342
+ }
343
+ // Satisfy what we can with the cache first
344
+ var bytes_read = this.readFromCache(stream, dst_ptr, to_read)
345
+ stream.position += bytes_read
346
+ stream.lastReadPosition = stream.position
347
+ dst_ptr += bytes_read
348
+ to_read -= bytes_read
349
+
350
+ // Return if we have satisfied the request
351
+ if (to_read === 0) {
352
+ //console.log("WeaveDrive: Satisfied request with cache. Returning...")
353
+ return bytes_read
354
+ }
355
+ //console.log("WeaveDrive: Read from cache: ", bytes_read, " Remaining to read: ", to_read)
356
+
357
+ const chunk_download_sz = Math.max(to_read, CACHE_SZ)
358
+ const to = Math.min(
359
+ stream.node.total_size,
360
+ stream.position + chunk_download_sz,
361
+ )
362
+ //console.log("WeaveDrive: fd: ", fd, " Read length: ", to_read, " Reading ahead:", to - to_read - stream.position)
363
+
364
+ // Fetch with streaming
365
+ /*
366
+ const response = await this.customFetch(`/${stream.node.name}`, {
367
+ method: "GET",
368
+ redirect: "follow",
369
+ headers: { Range: `bytes=${stream.position}-${to}` },
370
+ })
371
+
372
+ const reader = response.body.getReader()
373
+ */
374
+ const data = new TextEncoder().encode(
375
+ (await ar.data(stream.node.name)) ?? "",
376
+ )
377
+
378
+ // Extract the Range header to determine the start and end of the requested chunk
379
+ const start = 0
380
+ const end = data.length
381
+
382
+ // Create a ReadableStream for the requested chunk
383
+ const chunk = data.subarray(start, end)
384
+ const response = new Response(
385
+ new ReadableStream({
386
+ start(controller) {
387
+ controller.enqueue(chunk) // Push the chunk to the stream
388
+ controller.close() // Close the stream when done
389
+ },
390
+ }),
391
+ {
392
+ headers: { "Content-Length": chunk.length.toString() },
393
+ },
394
+ )
395
+ const reader = response.body.getReader()
396
+ var bytes_until_cache = CHUNK_SZ
397
+ var bytes_until_notify = NOTIFY_SZ
398
+ var downloaded_bytes = 0
399
+ var cache_chunks = []
400
+
401
+ try {
402
+ while (true) {
403
+ const { done, value: chunk_bytes } = await reader.read()
404
+ if (done) break
405
+ // Update the number of downloaded bytes to be _all_, not just the write length
406
+ downloaded_bytes += chunk_bytes.length
407
+ bytes_until_cache -= chunk_bytes.length
408
+ bytes_until_notify -= chunk_bytes.length
409
+
410
+ // Write bytes from the chunk and update the pointer if necessary
411
+ const write_length = Math.min(chunk_bytes.length, to_read)
412
+ if (write_length > 0) {
413
+ //console.log("WeaveDrive: Writing: ", write_length, " bytes to: ", dst_ptr)
414
+ mod.HEAP8.set(chunk_bytes.subarray(0, write_length), dst_ptr)
415
+ dst_ptr += write_length
416
+ bytes_read += write_length
417
+ stream.position += write_length
418
+ to_read -= write_length
419
+ }
420
+
421
+ if (to_read == 0) {
422
+ // Add excess bytes to our cache
423
+ const chunk_to_cache = chunk_bytes.subarray(write_length)
424
+ //console.log("WeaveDrive: Cacheing excess: ", chunk_to_cache.length)
425
+ cache_chunks.push(chunk_to_cache)
426
+ }
427
+
428
+ if (bytes_until_cache <= 0) {
429
+ console.log(
430
+ "WeaveDrive: Chunk size reached. Compressing cache...",
431
+ )
432
+ stream.node.cache = this.addChunksToCache(
433
+ stream.node.cache,
434
+ cache_chunks,
435
+ )
436
+ cache_chunks = []
437
+ bytes_until_cache = CHUNK_SZ
438
+ }
439
+
440
+ if (bytes_until_notify <= 0) {
441
+ console.log(
442
+ "WeaveDrive: Downloaded: ",
443
+ (downloaded_bytes / stream.node.total_size) * 100,
444
+ "%",
445
+ )
446
+ bytes_until_notify = NOTIFY_SZ
447
+ }
448
+ }
449
+ } catch (error) {
450
+ console.error("WeaveDrive: Error reading the stream: ", error)
451
+ } finally {
452
+ reader.releaseLock()
453
+ }
454
+ // If we have no cache, or we have not satisfied the full request, we need to download the rest
455
+ // Rebuild the cache from the new cache chunks
456
+ stream.node.cache = this.addChunksToCache(
457
+ stream.node.cache,
458
+ cache_chunks,
459
+ )
460
+
461
+ // Update the last read position
462
+ stream.lastReadPosition = stream.position
463
+ return bytes_read
464
+ },
465
+ close(fd) {
466
+ var stream = 0
467
+ for (var i = 0; i < FS.streams.length; i++) {
468
+ if (FS.streams[i].fd === fd) {
469
+ stream = FS.streams[i]
470
+ }
471
+ }
472
+ FS.close(stream)
473
+ },
474
+
475
+ // Readahead cache functions
476
+ readFromCache(stream, dst_ptr, length) {
477
+ // Check if the cache has been invalidated by a seek
478
+ if (stream.lastReadPosition !== stream.position) {
479
+ //console.log("WeaveDrive: Invalidating cache for fd: ", stream.fd, " Current pos: ", stream.position, " Last read pos: ", stream.lastReadPosition)
480
+ stream.node.cache = new Uint8Array(0)
481
+ return 0
482
+ }
483
+ // Calculate the bytes of the request that can be satisfied with the cache
484
+ var cache_part_length = Math.min(length, stream.node.cache.length)
485
+ var cache_part = stream.node.cache.subarray(0, cache_part_length)
486
+ mod.HEAP8.set(cache_part, dst_ptr)
487
+ // Set the new cache to the remainder of the unused cache and update pointers
488
+ stream.node.cache = stream.node.cache.subarray(cache_part_length)
489
+
490
+ return cache_part_length
491
+ },
492
+
493
+ addChunksToCache(old_cache, chunks) {
494
+ // Make a new cache array of the old cache length + the sum of the chunk lengths, capped by the max cache size
495
+ var new_cache_length = Math.min(
496
+ old_cache.length +
497
+ chunks.reduce((acc, chunk) => acc + chunk.length, 0),
498
+ CACHE_SZ,
499
+ )
500
+ var new_cache = new Uint8Array(new_cache_length)
501
+ // Copy the old cache to the new cache
502
+ new_cache.set(old_cache, 0)
503
+ // Load the cache chunks into the new cache
504
+ var current_offset = old_cache.length
505
+ for (let chunk of chunks) {
506
+ if (current_offset < new_cache_length) {
507
+ new_cache.set(
508
+ chunk.subarray(0, new_cache_length - current_offset),
509
+ current_offset,
510
+ )
511
+ current_offset += chunk.length
512
+ }
513
+ }
514
+ return new_cache
515
+ },
516
+
517
+ // General helpder functions
518
+ async checkAdmissible(ID) {
519
+ if (mod.mode && mod.mode == "test") {
520
+ // CAUTION: If the module is initiated with `mode = test` we don't check availability.
521
+ return true
522
+ }
523
+
524
+ // Check if we are attempting to load the On-Boot id, if so allow it
525
+ // this was added for AOP 6 Boot loader See: https://github.com/permaweb/aos/issues/342
526
+ const bootTag = this.getTagValue("On-Boot", mod.spawn.tags)
527
+ if (bootTag && bootTag === ID) return true
528
+
529
+ // Check that this module or process set the WeaveDrive tag on spawn
530
+ const blockHeight = mod.blockHeight
531
+ const moduleExtensions = this.getTagValues(
532
+ "Extension",
533
+ mod.module.tags,
534
+ )
535
+ const moduleHasWeaveDrive = moduleExtensions.includes("WeaveDrive")
536
+ const processExtensions = this.getTagValues(
537
+ "Extension",
538
+ mod.spawn.tags,
539
+ )
540
+ const processHasWeaveDrive =
541
+ moduleHasWeaveDrive || processExtensions.includes("WeaveDrive")
542
+
543
+ if (!processHasWeaveDrive) {
544
+ console.log(
545
+ "WeaveDrive: Process tried to call WeaveDrive, but extension not set!",
546
+ )
547
+ return false
548
+ }
549
+
550
+ const modes = ["Assignments", "Individual", "Library"]
551
+ // Get the Availability-Type from the spawned process's Module or Process item
552
+ // First check the module for its defaults
553
+ const moduleAvailabilityType = this.getTagValue(
554
+ "Availability-Type",
555
+ mod.module.tags,
556
+ )
557
+ const moduleMode = moduleAvailabilityType
558
+ ? moduleAvailabilityType
559
+ : "Assignments" // Default to assignments
560
+
561
+ // Now check the process's spawn item. These settings override Module item settings.
562
+ const processAvailabilityType = this.getTagValue(
563
+ "Availability-Type",
564
+ mod.spawn.tags,
565
+ )
566
+ const processMode = processAvailabilityType
567
+ ? processAvailabilityType
568
+ : moduleMode
569
+
570
+ if (!modes.includes(processMode)) {
571
+ throw `Unsupported WeaveDrive mode: ${processMode}`
572
+ }
573
+
574
+ const attestors = this.serializeStringArr(
575
+ [
576
+ this.getTagValue("Scheduler", mod.spawn.tags),
577
+ ...this.getTagValues("Attestor", mod.spawn.tags),
578
+ ].filter(t => !!t),
579
+ )
580
+
581
+ // Init a set of GraphQL queries to run in order to find a valid attestation
582
+ // Every WeaveDrive process has at least the "Assignments" availability check form.
583
+ const assignmentsHaveID = await this.queryHasResult(
584
+ `query {
585
+ transactions(
586
+ owners: ${attestors},
587
+ block: {min: 0, max: ${blockHeight}},
588
+ tags: [
589
+ { name: "Type", values: ["Attestation"] },
590
+ { name: "Message", values: ["${ID}"]}
591
+ { name: "Data-Protocol", values: ["ao"] },
592
+ ]
593
+ )
594
+ {
595
+ edges {
596
+ node {
597
+ tags {
598
+ name
599
+ value
600
+ }
601
+ }
602
+ }
603
+ }
604
+ }`,
605
+ )
606
+
607
+ if (assignmentsHaveID) {
608
+ return true
609
+ }
610
+
611
+ if (processMode == "Individual") {
612
+ const individualsHaveID = await this.queryHasResult(
613
+ `query {
614
+ transactions(
615
+ owners: ${attestors},
616
+ block: {min: 0, max: ${blockHeight}},
617
+ tags: [
618
+ { name: "Type", values: ["Available"]},
619
+ { name: "ID", values: ["${ID}"]}
620
+ { name: "Data-Protocol", values: ["WeaveDrive"] },
621
+ ]
622
+ )
623
+ {
624
+ edges {
625
+ node {
626
+ tags {
627
+ name
628
+ value
629
+ }
630
+ }
631
+ }
632
+ }
633
+ }`,
634
+ )
635
+
636
+ if (individualsHaveID) {
637
+ return true
638
+ }
639
+ }
640
+
641
+ // Halt message processing if the process requires Library mode.
642
+ // This should signal 'Cannot Process' to the CU, not that the message itself is
643
+ // invalid. Subsequently, the CU should not be slashable for saying that the process
644
+ // execution failed on this message. The CU must also not continue to execute further
645
+ // messages on this process. Attesting to them would be slashable, as the state would
646
+ // be incorrect.
647
+ if (processMode == "Library") {
648
+ throw "This WeaveDrive implementation does not support Library attestations yet!"
649
+ }
650
+
651
+ return false
652
+ },
653
+
654
+ serializeStringArr(arr = []) {
655
+ return `[${arr.map(s => `"${s}"`).join(", ")}]`
656
+ },
657
+
658
+ getTagValues(key, tags) {
659
+ var values = []
660
+ for (i = 0; i < tags.length; i++) {
661
+ if (tags[i].name == key) {
662
+ values.push(tags[i].value)
663
+ }
664
+ }
665
+ return values
666
+ },
667
+
668
+ getTagValue(key, tags) {
669
+ const values = this.getTagValues(key, tags)
670
+ return values.pop()
671
+ },
672
+
673
+ async queryHasResult(query, variables) {
674
+ const json = await this.gqlQuery(query, variables).then(res =>
675
+ res.json(),
676
+ )
677
+
678
+ return !!json?.data?.transactions?.edges?.length
679
+ },
680
+
681
+ async gqlExists() {
682
+ const query = `query {
683
+ transactions(
684
+ first: 1
685
+ ) {
686
+ pageInfo {
687
+ hasNextPage
688
+ }
689
+ }
690
+ }
691
+ `
692
+
693
+ const gqlExists = await this.gqlQuery(query, {}).then(res => res.ok)
694
+ return gqlExists
695
+ },
696
+
697
+ async gqlQuery(query, variables) {
698
+ const options = {
699
+ method: "POST",
700
+ body: JSON.stringify({ query, variables }),
701
+ headers: { "Content-Type": "application/json" },
702
+ }
703
+
704
+ return this.customFetch("graphql", options)
705
+ },
706
+ }
707
+ }
708
+ }
709
+ }