envio 3.6.0 → 3.6.1-subgraph

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.
Files changed (43) hide show
  1. package/package.json +6 -6
  2. package/src/ChainState.res +10 -4
  3. package/src/ChainState.res.mjs +5 -1
  4. package/src/Config.res +12 -0
  5. package/src/Config.res.mjs +8 -2
  6. package/src/Core.res +15 -0
  7. package/src/Core.res.mjs +11 -0
  8. package/src/EventProcessing.res +6 -6
  9. package/src/EventProcessing.res.mjs +8 -6
  10. package/src/HandlerLoader.res +20 -8
  11. package/src/HandlerLoader.res.mjs +11 -2
  12. package/src/Metrics.res +5 -5
  13. package/src/Metrics.res.mjs +5 -1
  14. package/src/UserContext.res +358 -51
  15. package/src/UserContext.res.mjs +271 -35
  16. package/src/sources/SourceManager.res +6 -12
  17. package/src/sources/SourceManager.res.mjs +8 -5
  18. package/src/subgraph/blocks.ts +176 -0
  19. package/src/subgraph/calls.ts +213 -0
  20. package/src/subgraph/conformance.ts +99 -0
  21. package/src/subgraph/division.ts +100 -0
  22. package/src/subgraph/errors.ts +90 -0
  23. package/src/subgraph/graph-ts-types/VERSION +2 -0
  24. package/src/subgraph/graph-ts-types/chain/arweave.d.ts +70 -0
  25. package/src/subgraph/graph-ts-types/chain/cosmos.d.ts +327 -0
  26. package/src/subgraph/graph-ts-types/chain/ethereum.d.ts +233 -0
  27. package/src/subgraph/graph-ts-types/chain/near.d.ts +253 -0
  28. package/src/subgraph/graph-ts-types/chain/starknet.d.ts +32 -0
  29. package/src/subgraph/graph-ts-types/common/collections.d.ts +136 -0
  30. package/src/subgraph/graph-ts-types/common/conversion.d.ts +11 -0
  31. package/src/subgraph/graph-ts-types/common/datasource.d.ts +30 -0
  32. package/src/subgraph/graph-ts-types/common/eager-offset.d.ts +0 -0
  33. package/src/subgraph/graph-ts-types/common/json.d.ts +17 -0
  34. package/src/subgraph/graph-ts-types/common/numbers.d.ts +120 -0
  35. package/src/subgraph/graph-ts-types/common/value.d.ts +120 -0
  36. package/src/subgraph/graph-ts-types/common/yaml.d.ts +90 -0
  37. package/src/subgraph/graph-ts-types/global/global.d.ts +194 -0
  38. package/src/subgraph/graph-ts-types/helper-functions.d.ts +22 -0
  39. package/src/subgraph/graph-ts-types/index.d.ts +102 -0
  40. package/src/subgraph/graph-ts.ts +1695 -0
  41. package/src/subgraph/hosts.ts +148 -0
  42. package/src/subgraph/runtime.ts +827 -0
  43. package/src/subgraph/scope.ts +63 -0
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Utils from "./Utils.res.mjs";
4
+ import * as ChainId from "./ChainId.res.mjs";
4
5
  import * as Logging from "./Logging.res.mjs";
5
6
  import * as Internal from "./Internal.res.mjs";
6
7
  import * as Ecosystem from "./Ecosystem.res.mjs";
@@ -12,8 +13,70 @@ import * as InMemoryStore from "./InMemoryStore.res.mjs";
12
13
  import * as InMemoryTable from "./InMemoryTable.res.mjs";
13
14
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
14
15
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
16
+ import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
15
17
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
16
18
  import * as S$RescriptSchema from "rescript-schema/src/S.res.mjs";
19
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
20
+
21
+ let Suspend = /* @__PURE__ */Primitive_exceptions.create("UserContext.Suspend");
22
+
23
+ function isSuspend(exn) {
24
+ return exn.RE_EXN_ID === Suspend;
25
+ }
26
+
27
+ function makeSyncState() {
28
+ return {
29
+ status: "Active",
30
+ pending: undefined,
31
+ memo: undefined
32
+ };
33
+ }
34
+
35
+ function getPending(sync) {
36
+ let pending = sync.pending;
37
+ if (pending !== undefined) {
38
+ return pending;
39
+ }
40
+ let pending$1 = [];
41
+ sync.pending = pending$1;
42
+ return pending$1;
43
+ }
44
+
45
+ function getMemo(sync) {
46
+ let memo = sync.memo;
47
+ if (memo !== undefined) {
48
+ return memo;
49
+ }
50
+ let memo$1 = {};
51
+ sync.memo = memo$1;
52
+ return memo$1;
53
+ }
54
+
55
+ function checkStatusOrThrow(params, access) {
56
+ let exn = params.sync.status;
57
+ if (typeof exn !== "object") {
58
+ if (exn === "Active") {
59
+ return;
60
+ } else {
61
+ return ErrorHandling.mkLogAndRaise(Ecosystem.getItemLogger(params.item, params.config.ecosystem), undefined, new Error(`Impossible to access ` + access + ` after the handler is resolved. Make sure you didn't miss an await in the handler.`));
62
+ }
63
+ }
64
+ throw exn._0;
65
+ }
66
+
67
+ function scheduleAndSuspend(params, promise) {
68
+ getPending(params.sync).push(promise);
69
+ params.sync.status = {
70
+ TAG: "Aborted",
71
+ _0: {
72
+ RE_EXN_ID: Suspend
73
+ }
74
+ };
75
+ throw {
76
+ RE_EXN_ID: Suspend,
77
+ Error: new Error()
78
+ };
79
+ }
17
80
 
18
81
  let paramsByThis = new WeakMap();
19
82
 
@@ -47,28 +110,69 @@ var EffectContext = function(params, chainId, effectName, defaultShouldCache, ca
47
110
  EffectContext.prototype = effectContextPrototype;
48
111
  ;
49
112
 
50
- function initEffect(params) {
51
- let handlerChainId = Internal.getItemChainId(params.item);
113
+ function prepareEffectCall(params, effect, input, caller) {
52
114
  let isCrossChain = effect => Stdlib_Option.getOr(effect.crossChain, params.config.defaultCrossChain);
53
- let makeCaller = caller => ((effect, input) => {
54
- let scope = isCrossChain(effect) ? "crossChain" : handlerChainId;
55
- if (caller !== undefined && isCrossChain(caller) && !isCrossChain(effect)) {
56
- Stdlib_JsError.throwWithMessage(`The cross-chain effect "` + caller.name + `" cannot call the chain-scoped effect "` + effect.name + `", because a cross-chain effect isn't tied to a single chain. Make "` + effect.name + `" cross-chain (\`crossChain: true\`), or make "` + caller.name + `" chain-scoped (\`crossChain: false\`).`);
115
+ let scope = isCrossChain(effect) ? "crossChain" : Internal.getItemChainId(params.item);
116
+ if (caller !== undefined && isCrossChain(caller) && !isCrossChain(effect)) {
117
+ Stdlib_JsError.throwWithMessage(`The cross-chain effect "` + caller.name + `" cannot call the chain-scoped effect "` + effect.name + `", because a cross-chain effect isn't tied to a single chain. Make "` + effect.name + `" cross-chain (\`crossChain: true\`), or make "` + caller.name + `" chain-scoped (\`crossChain: false\`).`);
118
+ }
119
+ let tmp;
120
+ tmp = scope === "crossChain" ? undefined : Primitive_option.some(scope);
121
+ let effectContext = new EffectContext(params, tmp, effect.name, effect.defaultShouldCache, (nested, nestedInput) => callEffectAsync(params, nested, nestedInput, effect));
122
+ let effectArgs_cacheKey = Utils.Hash.makeOrThrow(S$RescriptSchema.reverseConvertOrThrow(input, effect.input));
123
+ let effectArgs_checkpointId = params.checkpointId;
124
+ let effectArgs = {
125
+ input: input,
126
+ context: effectContext,
127
+ cacheKey: effectArgs_cacheKey,
128
+ checkpointId: effectArgs_checkpointId
129
+ };
130
+ return [
131
+ scope,
132
+ effectArgs
133
+ ];
134
+ }
135
+
136
+ function callEffectAsync(params, effect, input, caller) {
137
+ let match = prepareEffectCall(params, effect, input, caller);
138
+ return LoadLayer.loadEffect(params.loadManager, params.persistence, effect, match[1], match[0], params.indexerState, params.isPreload, params.item, params.config.ecosystem);
139
+ }
140
+
141
+ function initEffect(params) {
142
+ return (effect, input) => {
143
+ checkStatusOrThrow(params, "context.effect");
144
+ return callEffectAsync(params, effect, input, undefined);
145
+ };
146
+ }
147
+
148
+ function effectMemoKey(effect, scope, cacheKey) {
149
+ if (scope === "crossChain") {
150
+ return effect.name + `.` + cacheKey;
151
+ } else {
152
+ return effect.name + `.` + ChainId.toString(scope) + `.` + cacheKey;
153
+ }
154
+ }
155
+
156
+ function initEffectSync(params) {
157
+ return (effect, input) => {
158
+ checkStatusOrThrow(params, "context.effectSync");
159
+ let match = prepareEffectCall(params, effect, input, undefined);
160
+ let effectArgs = match[1];
161
+ let scope = match[0];
162
+ let memo = getMemo(params.sync);
163
+ let memoKey = effectMemoKey(effect, scope, effectArgs.cacheKey);
164
+ let output = memo[memoKey];
165
+ if (output !== undefined) {
166
+ return Primitive_option.valFromOption(output);
57
167
  }
58
- let tmp;
59
- tmp = scope === "crossChain" ? undefined : Primitive_option.some(scope);
60
- let effectContext = new EffectContext(params, tmp, effect.name, effect.defaultShouldCache, makeCaller(effect));
61
- let effectArgs_cacheKey = Utils.Hash.makeOrThrow(S$RescriptSchema.reverseConvertOrThrow(input, effect.input));
62
- let effectArgs_checkpointId = params.checkpointId;
63
- let effectArgs = {
64
- input: input,
65
- context: effectContext,
66
- cacheKey: effectArgs_cacheKey,
67
- checkpointId: effectArgs_checkpointId
68
- };
69
- return LoadLayer.loadEffect(params.loadManager, params.persistence, effect, effectArgs, scope, params.indexerState, params.isPreload, params.item, params.config.ecosystem);
70
- });
71
- return makeCaller(undefined);
168
+ let inMemTable = InMemoryStore.getEffectInMemTable(params.indexerState, effect, scope);
169
+ if (!InMemoryStore.hasEffectOutput(inMemTable, effectArgs.cacheKey)) {
170
+ return scheduleAndSuspend(params, LoadLayer.loadEffect(params.loadManager, params.persistence, effect, effectArgs, scope, params.indexerState, params.isPreload, params.item, params.config.ecosystem));
171
+ }
172
+ let output$1 = InMemoryStore.getEffectOutputUnsafe(inMemTable, effectArgs.cacheKey);
173
+ memo[memoKey] = output$1;
174
+ return output$1;
175
+ };
72
176
  }
73
177
 
74
178
  function entityScope(params) {
@@ -97,24 +201,83 @@ function throwClickHouseReadOnly(entityConfig, op) {
97
201
  return Stdlib_JsError.throwWithMessage(`context.` + entityConfig.name + `.` + op + `() is unavailable: ClickHouse storage is currently write-only. Follow Envio releases to be notified when ClickHouse supports both reads and writes from handlers.`);
98
202
  }
99
203
 
204
+ function getSyncHandler(params, entityId) {
205
+ let inMemTable = InMemoryStore.getInMemTable(params.indexerState, params.entityConfig, entityScope(params));
206
+ if (entityId in inMemTable.latestEntityChangeById) {
207
+ return InMemoryTable.Entity.getUnsafe(inMemTable)(entityId);
208
+ } else {
209
+ return scheduleAndSuspend(params, LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, entityScope(params), params.indexerState, params.isPreload, params.item, params.config.ecosystem, entityId));
210
+ }
211
+ }
212
+
213
+ function getWhereSyncHandler(params, filter) {
214
+ let entityConfig = params.entityConfig;
215
+ let inMemTable = InMemoryStore.getInMemTable(params.indexerState, entityConfig, entityScope(params));
216
+ let hasIndex = InMemoryTable.Entity.hasIndex(inMemTable);
217
+ let getOnIndex = InMemoryTable.Entity.getUnsafeOnIndex(inMemTable);
218
+ let filters = EntityFilter.parseGetWhereOrThrow(filter, entityConfig.name, entityConfig.table);
219
+ let missing = [];
220
+ let entities = [];
221
+ filters.forEach(filter => {
222
+ let filterKey = EntityFilter.toString(filter);
223
+ if (hasIndex(filterKey)) {
224
+ entities.push(...getOnIndex(filterKey));
225
+ } else {
226
+ missing.push(LoadLayer.loadByFilter(params.loadManager, params.persistence, entityConfig, entityScope(params), params.indexerState, params.isPreload, params.item, params.config.ecosystem, filter));
227
+ }
228
+ });
229
+ if (Utils.$$Array.notEmpty(missing)) {
230
+ let pending = getPending(params.sync);
231
+ missing.forEach(promise => {
232
+ pending.push(promise);
233
+ });
234
+ params.sync.status = {
235
+ TAG: "Aborted",
236
+ _0: {
237
+ RE_EXN_ID: Suspend
238
+ }
239
+ };
240
+ throw {
241
+ RE_EXN_ID: Suspend,
242
+ Error: new Error()
243
+ };
244
+ }
245
+ return entities;
246
+ }
247
+
248
+ function getInBlockSyncHandler(params, entityId) {
249
+ let inMemTable = InMemoryStore.getInMemTable(params.indexerState, params.entityConfig, entityScope(params));
250
+ let change = inMemTable.latestEntityChangeById[entityId];
251
+ if (change !== undefined && change.checkpointId === params.checkpointId) {
252
+ return InMemoryTable.Entity.mapChangeToEntity(change);
253
+ }
254
+ }
255
+
100
256
  let entityTraps_get = (params, prop) => {
257
+ checkStatusOrThrow(params, `context.` + params.entityConfig.name + `.` + prop);
101
258
  let isClickHouseOnly = !params.entityConfig.storage.postgres;
102
- let set = params.isPreload ? noopSet : entity => InMemoryTable.Entity.set(InMemoryStore.getInMemTable(params.indexerState, params.entityConfig, entityScope(params)), IndexerState.committedCheckpointId(params.indexerState), {
103
- type: "SET",
104
- entityId: entity.id,
105
- entity: entity,
106
- checkpointId: params.checkpointId
107
- });
259
+ let set = params.isPreload ? noopSet : entity => {
260
+ checkStatusOrThrow(params, `context.` + params.entityConfig.name + `.set`);
261
+ InMemoryTable.Entity.set(InMemoryStore.getInMemTable(params.indexerState, params.entityConfig, entityScope(params)), IndexerState.committedCheckpointId(params.indexerState), {
262
+ type: "SET",
263
+ entityId: entity.id,
264
+ entity: entity,
265
+ checkpointId: params.checkpointId
266
+ });
267
+ };
108
268
  switch (prop) {
109
269
  case "deleteUnsafe" :
110
270
  if (params.isPreload) {
111
271
  return noopDeleteUnsafe;
112
272
  } else {
113
- return entityId => InMemoryTable.Entity.set(InMemoryStore.getInMemTable(params.indexerState, params.entityConfig, entityScope(params)), IndexerState.committedCheckpointId(params.indexerState), {
114
- type: "DELETE",
115
- entityId: entityId,
116
- checkpointId: params.checkpointId
117
- });
273
+ return entityId => {
274
+ checkStatusOrThrow(params, `context.` + params.entityConfig.name + `.deleteUnsafe`);
275
+ InMemoryTable.Entity.set(InMemoryStore.getInMemTable(params.indexerState, params.entityConfig, entityScope(params)), IndexerState.committedCheckpointId(params.indexerState), {
276
+ type: "DELETE",
277
+ entityId: entityId,
278
+ checkpointId: params.checkpointId
279
+ });
280
+ };
118
281
  }
119
282
  case "get" :
120
283
  if (isClickHouseOnly) {
@@ -122,6 +285,8 @@ let entityTraps_get = (params, prop) => {
122
285
  } else {
123
286
  return entityId => LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, entityScope(params), params.indexerState, params.isPreload, params.item, params.config.ecosystem, entityId);
124
287
  }
288
+ case "getInBlockSync" :
289
+ return entityId => getInBlockSyncHandler(params, entityId);
125
290
  case "getOrCreate" :
126
291
  if (isClickHouseOnly) {
127
292
  return _entity => throwClickHouseReadOnly(params.entityConfig, "getOrCreate");
@@ -147,12 +312,16 @@ let entityTraps_get = (params, prop) => {
147
312
  }
148
313
  });
149
314
  }
315
+ case "getSync" :
316
+ return entityId => getSyncHandler(params, entityId);
150
317
  case "getWhere" :
151
318
  if (isClickHouseOnly) {
152
319
  return _filter => throwClickHouseReadOnly(params.entityConfig, "getWhere");
153
320
  } else {
154
321
  return filter => getWhereHandler(params, filter);
155
322
  }
323
+ case "getWhereSync" :
324
+ return filter => getWhereSyncHandler(params, filter);
156
325
  case "set" :
157
326
  return set;
158
327
  default:
@@ -164,16 +333,63 @@ let entityTraps = {
164
333
  get: entityTraps_get
165
334
  };
166
335
 
167
- let handlerTraps_get = (params, prop) => {
168
- if (params.isResolved) {
169
- ErrorHandling.mkLogAndRaise(Ecosystem.getItemLogger(params.item, params.config.ecosystem), undefined, new Error(`Impossible to access context.` + prop + ` after the handler is resolved. Make sure you didn't miss an await in the handler.`));
336
+ async function runSyncRound(params, fn, round) {
337
+ if (round > 10000) {
338
+ Stdlib_JsError.throwWithMessage(`The handler suspended on a synchronous read too many times: gave up after ` + (10000).toString() + ` rounds. This usually means the code isn't deterministic across reruns.`);
339
+ }
340
+ params.sync.status = "Active";
341
+ params.sync.pending = undefined;
342
+ let suspended;
343
+ try {
344
+ fn();
345
+ suspended = false;
346
+ } catch (raw_exn) {
347
+ let exn = Primitive_exceptions.internalToException(raw_exn);
348
+ if (exn.RE_EXN_ID === Suspend) {
349
+ suspended = true;
350
+ } else {
351
+ let pending = params.sync.pending;
352
+ if (pending !== undefined) {
353
+ params.sync.pending = undefined;
354
+ pending.forEach(promise => {
355
+ Utils.$$Promise.silentCatch(promise);
356
+ });
357
+ }
358
+ throw exn;
359
+ }
170
360
  }
361
+ let pending$1 = params.sync.pending;
362
+ if (pending$1 === undefined) {
363
+ return;
364
+ }
365
+ params.sync.pending = undefined;
366
+ if (suspended) {
367
+ let errors = [];
368
+ await Promise.all(pending$1.map(promise => Stdlib_Promise.$$catch(promise, exn => {
369
+ errors.push(exn);
370
+ return Promise.resolve();
371
+ })));
372
+ let exn$1 = errors[0];
373
+ if (exn$1 !== undefined) {
374
+ throw exn$1;
375
+ }
376
+ return await runSyncRound(params, fn, round + 1 | 0);
377
+ }
378
+ pending$1.forEach(promise => {
379
+ Utils.$$Promise.silentCatch(promise);
380
+ });
381
+ }
382
+
383
+ let handlerTraps_get = (params, prop) => {
384
+ checkStatusOrThrow(params, `context.` + prop);
171
385
  switch (prop) {
172
386
  case "chain" :
173
387
  let chainId = Internal.getItemChainId(params.item);
174
388
  return params.chains[chainId];
175
389
  case "effect" :
176
390
  return initEffect(params);
391
+ case "effectSync" :
392
+ return initEffectSync(params);
177
393
  case "isPreload" :
178
394
  return params.isPreload;
179
395
  case "log" :
@@ -182,6 +398,8 @@ let handlerTraps_get = (params, prop) => {
182
398
  } else {
183
399
  return Ecosystem.getItemUserLogger(params.item, params.config.ecosystem);
184
400
  }
401
+ case "runSync" :
402
+ return fn => runSyncRound(params, fn, 1);
185
403
  default:
186
404
  let entityConfig = params.config.userEntitiesByName[prop];
187
405
  if (entityConfig !== undefined) {
@@ -194,7 +412,7 @@ let handlerTraps_get = (params, prop) => {
194
412
  isPreload: params.isPreload,
195
413
  chains: params.chains,
196
414
  config: params.config,
197
- isResolved: params.isResolved,
415
+ sync: params.sync,
198
416
  entityConfig: entityConfig
199
417
  }, entityTraps);
200
418
  } else {
@@ -211,16 +429,34 @@ function getHandlerContext(params) {
211
429
  return new Proxy(params, handlerTraps);
212
430
  }
213
431
 
432
+ let maxSyncRounds = 10000;
433
+
214
434
  export {
435
+ Suspend,
436
+ isSuspend,
437
+ makeSyncState,
438
+ getPending,
439
+ getMemo,
440
+ checkStatusOrThrow,
441
+ scheduleAndSuspend,
215
442
  paramsByThis,
216
443
  effectContextPrototype,
444
+ prepareEffectCall,
445
+ callEffectAsync,
217
446
  initEffect,
447
+ effectMemoKey,
448
+ initEffectSync,
218
449
  entityScope,
219
450
  getWhereHandler,
220
451
  noopSet,
221
452
  noopDeleteUnsafe,
222
453
  throwClickHouseReadOnly,
454
+ getSyncHandler,
455
+ getWhereSyncHandler,
456
+ getInBlockSyncHandler,
223
457
  entityTraps,
458
+ maxSyncRounds,
459
+ runSyncRound,
224
460
  handlerTraps,
225
461
  getHandlerContext,
226
462
  }
@@ -468,16 +468,16 @@ let getSourceNewHeight = async (
468
468
  // head on every (re)connect; waking the wait loop on a height we already
469
469
  // know spins it and leaks fallback pollers (#1270).
470
470
  if newHeight > sourceState.knownHeight {
471
+ sourceState->recordRequestStats([{Source.method: "heightPush", seconds: 0.}])
471
472
  sourceState.knownHeight = newHeight
472
473
  let resolvers = sourceState.pendingHeightResolvers
473
474
  sourceState.pendingHeightResolvers = []
474
475
  resolvers->Array.forEach(resolve => resolve(newHeight))
476
+ } else {
477
+ sourceState->recordRequestStats([{Source.method: "heightPushIgnored", seconds: 0.}])
475
478
  }
476
479
  })
477
480
  sourceState.unsubscribe = Some(unsubscribe)
478
- // Count a subscription (re)start rather than every pushed height —
479
- // there's no request/response to time here.
480
- sourceState->recordRequestStats([{Source.method: "heightSubscription", seconds: 0.}])
481
481
  | _ =>
482
482
  // Slowdown polling when the chain isn't progressing
483
483
  let pollingInterval = if reducedPolling {
@@ -724,9 +724,7 @@ let executeQuery = async (
724
724
  ) {
725
725
  | Some(s) =>
726
726
  if s.source !== sourceManager.activeSource {
727
- let logger = Logging.createChild(
728
- ~params={"chainId": sourceManager.activeSource.chainId},
729
- )
727
+ let logger = Logging.createChild(~params={"chainId": sourceManager.activeSource.chainId})
730
728
  logger->Logging.childInfo({
731
729
  "msg": "Switching data-source",
732
730
  "source": s.source.name,
@@ -736,9 +734,7 @@ let executeQuery = async (
736
734
  }
737
735
  s
738
736
  | None =>
739
- let logger = Logging.createChild(
740
- ~params={"chainId": sourceManager.activeSource.chainId},
741
- )
737
+ let logger = Logging.createChild(~params={"chainId": sourceManager.activeSource.chainId})
742
738
  %raw(`null`)->ErrorHandling.mkLogAndRaise(~logger, ~msg=noSourcesError)
743
739
  }
744
740
  sourceManager.activeSource = sourceState.source
@@ -895,9 +891,7 @@ let getBlockHashes = async (sourceManager: t, ~blockNumbers: array<int>, ~isReal
895
891
  let sourceState = switch sourceManager->getNextSource(~isRealtime) {
896
892
  | Some(s) => s
897
893
  | None =>
898
- let logger = Logging.createChild(
899
- ~params={"chainId": sourceManager.activeSource.chainId},
900
- )
894
+ let logger = Logging.createChild(~params={"chainId": sourceManager.activeSource.chainId})
901
895
  %raw(`null`)->ErrorHandling.mkLogAndRaise(
902
896
  ~logger,
903
897
  ~msg="No data-sources available for fetching block hashes.",
@@ -388,18 +388,21 @@ async function getSourceNewHeight(sourceManager, sourceState, knownHeight, stall
388
388
  if (createSubscription !== undefined && isRealtime) {
389
389
  let unsubscribe = createSubscription(newHeight => {
390
390
  if (newHeight <= sourceState.knownHeight) {
391
- return;
391
+ return recordRequestStats(sourceState, [{
392
+ method: "heightPushIgnored",
393
+ seconds: 0
394
+ }]);
392
395
  }
396
+ recordRequestStats(sourceState, [{
397
+ method: "heightPush",
398
+ seconds: 0
399
+ }]);
393
400
  sourceState.knownHeight = newHeight;
394
401
  let resolvers = sourceState.pendingHeightResolvers;
395
402
  sourceState.pendingHeightResolvers = [];
396
403
  resolvers.forEach(resolve => resolve(newHeight));
397
404
  });
398
405
  sourceState.unsubscribe = unsubscribe;
399
- recordRequestStats(sourceState, [{
400
- method: "heightSubscription",
401
- seconds: 0
402
- }]);
403
406
  } else {
404
407
  exit = 1;
405
408
  }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * A block handler's `block.timestamp`.
3
+ *
4
+ * envio hands a block handler only the block number, so the timestamp has to be
5
+ * fetched. Every handler invocation in a batch asks within the same microtask,
6
+ * so the requests are collected and answered by a single HyperSync range query
7
+ * rather than one round trip per block. An RPC endpoint, if one is configured,
8
+ * is the fallback for whatever HyperSync couldn't answer.
9
+ */
10
+
11
+ import { createPublicClient, fallback, http } from "viem";
12
+
13
+ const API_TOKEN_ENV_VAR = "ENVIO_API_TOKEN";
14
+
15
+ const hypersyncUrl = (chainId: number) => `https://${chainId}.hypersync.xyz/query`;
16
+
17
+ type Waiter = {
18
+ resolve: (timestamp: bigint) => void;
19
+ reject: (error: unknown) => void;
20
+ };
21
+
22
+ /** Blocks asked for since the last flush, by chain then block number. */
23
+ let pending = new Map<number, Map<number, Waiter[]>>();
24
+ let flushScheduled = false;
25
+
26
+ let rpcUrls: string[] = [];
27
+ let client: ReturnType<typeof createPublicClient> | null = null;
28
+
29
+ export function configureBlockTimestamps(urls: string[]) {
30
+ rpcUrls = urls;
31
+ client = null;
32
+ pending = new Map();
33
+ flushScheduled = false;
34
+ }
35
+
36
+ export function requestBlockTimestamp(chainId: number, blockNumber: number): Promise<bigint> {
37
+ return new Promise((resolve, reject) => {
38
+ let blocks = pending.get(chainId);
39
+ if (!blocks) {
40
+ blocks = new Map();
41
+ pending.set(chainId, blocks);
42
+ }
43
+ const waiters = blocks.get(blockNumber);
44
+ if (waiters) {
45
+ waiters.push({ resolve, reject });
46
+ } else {
47
+ blocks.set(blockNumber, [{ resolve, reject }]);
48
+ }
49
+
50
+ if (!flushScheduled) {
51
+ flushScheduled = true;
52
+ queueMicrotask(() => {
53
+ flushScheduled = false;
54
+ const collected = pending;
55
+ pending = new Map();
56
+ for (const [chain, blocksForChain] of collected) {
57
+ void answer(chain, blocksForChain);
58
+ }
59
+ });
60
+ }
61
+ });
62
+ }
63
+
64
+ async function answer(chainId: number, blocks: Map<number, Waiter[]>) {
65
+ let timestamps = new Map<number, bigint>();
66
+
67
+ // A batch can hold thousands of blocks, and one argument per block would
68
+ // overflow the call stack inside the try, where it would read as a HyperSync
69
+ // failure and fall back to one round trip per block.
70
+ let lowest = Infinity;
71
+ let highest = -Infinity;
72
+ for (const blockNumber of blocks.keys()) {
73
+ if (blockNumber < lowest) lowest = blockNumber;
74
+ if (blockNumber > highest) highest = blockNumber;
75
+ }
76
+
77
+ try {
78
+ timestamps = await fromHyperSync(chainId, lowest, highest);
79
+ } catch (error) {
80
+ if (rpcUrls.length === 0) {
81
+ const message = error instanceof Error ? error.message : String(error);
82
+ for (const waiters of blocks.values()) {
83
+ for (const waiter of waiters) {
84
+ waiter.reject(
85
+ new Error(
86
+ `Envio Subgraph couldn't read a block timestamp from HyperSync: ${message}\n` +
87
+ `Set ${API_TOKEN_ENV_VAR} in .env or the environment — create one at\n` +
88
+ `https://envio.dev/app/api-tokens — or set ENVIO_SUBGRAPH_RPC to fall back to RPC.`,
89
+ ),
90
+ );
91
+ }
92
+ }
93
+ return;
94
+ }
95
+ }
96
+
97
+ for (const [blockNumber, waiters] of blocks) {
98
+ const known = timestamps.get(blockNumber);
99
+ if (known !== undefined) {
100
+ for (const waiter of waiters) waiter.resolve(known);
101
+ continue;
102
+ }
103
+ try {
104
+ const timestamp = await fromRpc(blockNumber);
105
+ for (const waiter of waiters) waiter.resolve(timestamp);
106
+ } catch (error) {
107
+ for (const waiter of waiters) waiter.reject(error);
108
+ }
109
+ }
110
+ }
111
+
112
+ type HyperSyncResponse = {
113
+ data: { blocks?: { number: number; timestamp: string }[] }[];
114
+ next_block: number;
115
+ };
116
+
117
+ async function fromHyperSync(
118
+ chainId: number,
119
+ fromBlock: number,
120
+ toBlock: number,
121
+ ): Promise<Map<number, bigint>> {
122
+ const token = process.env[API_TOKEN_ENV_VAR];
123
+ const timestamps = new Map<number, bigint>();
124
+
125
+ let cursor = fromBlock;
126
+ // HyperSync answers as much of the range as one response holds and points at
127
+ // where to resume, so a wide range takes more than one round trip.
128
+ while (cursor <= toBlock) {
129
+ const response = await fetch(hypersyncUrl(chainId), {
130
+ method: "POST",
131
+ headers: {
132
+ "content-type": "application/json",
133
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
134
+ },
135
+ body: JSON.stringify({
136
+ from_block: cursor,
137
+ to_block: toBlock + 1,
138
+ include_all_blocks: true,
139
+ field_selection: { block: ["number", "timestamp"] },
140
+ }),
141
+ });
142
+
143
+ if (!response.ok) {
144
+ throw new Error(`HyperSync returned ${response.status} ${await response.text()}`);
145
+ }
146
+
147
+ const body = (await response.json()) as HyperSyncResponse;
148
+ for (const page of body.data ?? []) {
149
+ for (const block of page.blocks ?? []) {
150
+ timestamps.set(block.number, BigInt(block.timestamp));
151
+ }
152
+ }
153
+
154
+ if (!body.next_block || body.next_block <= cursor) {
155
+ break;
156
+ }
157
+ cursor = body.next_block;
158
+ }
159
+
160
+ return timestamps;
161
+ }
162
+
163
+ async function fromRpc(blockNumber: number): Promise<bigint> {
164
+ if (rpcUrls.length === 0) {
165
+ throw new Error(
166
+ `Envio Subgraph couldn't read the timestamp of block ${blockNumber}: HyperSync didn't\n` +
167
+ `return it, and no RPC fallback is configured. Set ENVIO_SUBGRAPH_RPC in .env or the\n` +
168
+ `environment.`,
169
+ );
170
+ }
171
+ client ??= createPublicClient({
172
+ transport: fallback(rpcUrls.map((url) => http(url, { retryCount: 0 }))),
173
+ });
174
+ const block = await client.getBlock({ blockNumber: BigInt(blockNumber) });
175
+ return block.timestamp;
176
+ }