envio 3.3.0-alpha.0 → 3.3.0-alpha.10

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 (140) hide show
  1. package/evm.schema.json +10 -0
  2. package/index.d.ts +45 -25
  3. package/package.json +7 -7
  4. package/src/Address.res +5 -2
  5. package/src/Address.res.mjs +3 -1
  6. package/src/Api.res +1 -1
  7. package/src/Api.res.mjs +1 -1
  8. package/src/Batch.res +0 -6
  9. package/src/Batch.res.mjs +1 -12
  10. package/src/BatchProcessing.res +7 -12
  11. package/src/BatchProcessing.res.mjs +8 -7
  12. package/src/Bin.res +3 -0
  13. package/src/Bin.res.mjs +4 -0
  14. package/src/ChainFetching.res +48 -16
  15. package/src/ChainFetching.res.mjs +36 -17
  16. package/src/ChainMetadata.res +1 -11
  17. package/src/ChainMetadata.res.mjs +1 -10
  18. package/src/ChainState.res +628 -149
  19. package/src/ChainState.res.mjs +440 -102
  20. package/src/ChainState.resi +74 -4
  21. package/src/Config.res +61 -33
  22. package/src/Config.res.mjs +50 -10
  23. package/src/ContractRegisterContext.res +2 -12
  24. package/src/ContractRegisterContext.res.mjs +3 -5
  25. package/src/Core.res +14 -3
  26. package/src/CrossChainState.res +142 -93
  27. package/src/CrossChainState.res.mjs +88 -60
  28. package/src/CrossChainState.resi +2 -10
  29. package/src/Ecosystem.res +5 -6
  30. package/src/Ecosystem.res.mjs +3 -3
  31. package/src/Env.res +0 -7
  32. package/src/Env.res.mjs +0 -6
  33. package/src/Envio.res +17 -11
  34. package/src/EnvioGlobal.res +53 -0
  35. package/src/EnvioGlobal.res.mjs +31 -0
  36. package/src/EventConfigBuilder.res +177 -88
  37. package/src/EventConfigBuilder.res.mjs +99 -42
  38. package/src/EventProcessing.res +74 -28
  39. package/src/EventProcessing.res.mjs +55 -29
  40. package/src/ExitOnCaughtUp.res +10 -2
  41. package/src/ExitOnCaughtUp.res.mjs +10 -1
  42. package/src/FetchState.res +558 -300
  43. package/src/FetchState.res.mjs +437 -423
  44. package/src/HandlerLoader.res +8 -104
  45. package/src/HandlerLoader.res.mjs +2 -81
  46. package/src/HandlerRegister.res +518 -144
  47. package/src/HandlerRegister.res.mjs +285 -133
  48. package/src/HandlerRegister.resi +24 -8
  49. package/src/IndexerLoop.res +2 -3
  50. package/src/IndexerLoop.res.mjs +1 -1
  51. package/src/IndexerState.res +10 -11
  52. package/src/IndexerState.res.mjs +14 -11
  53. package/src/IndexerState.resi +2 -5
  54. package/src/IndexingAddresses.res +108 -0
  55. package/src/IndexingAddresses.res.mjs +101 -0
  56. package/src/IndexingAddresses.resi +34 -0
  57. package/src/Internal.res +155 -46
  58. package/src/Internal.res.mjs +30 -2
  59. package/src/LoadLayer.res +5 -5
  60. package/src/LoadLayer.res.mjs +6 -6
  61. package/src/LogSelection.res +102 -165
  62. package/src/LogSelection.res.mjs +101 -116
  63. package/src/Main.res +71 -236
  64. package/src/Main.res.mjs +60 -145
  65. package/src/Metrics.res +74 -0
  66. package/src/Metrics.res.mjs +75 -0
  67. package/src/PgStorage.res +4 -4
  68. package/src/PgStorage.res.mjs +5 -5
  69. package/src/Prometheus.res +10 -74
  70. package/src/Prometheus.res.mjs +124 -186
  71. package/src/PruneStaleHistory.res +2 -2
  72. package/src/PruneStaleHistory.res.mjs +3 -3
  73. package/src/RawEvent.res +7 -2
  74. package/src/RawEvent.res.mjs +4 -4
  75. package/src/Rollback.res +3 -3
  76. package/src/Rollback.res.mjs +4 -4
  77. package/src/RollbackCommit.res +4 -1
  78. package/src/RollbackCommit.res.mjs +3 -2
  79. package/src/SimulateDeadInputTracker.res +85 -0
  80. package/src/SimulateDeadInputTracker.res.mjs +73 -0
  81. package/src/SimulateDeadInputTracker.resi +12 -0
  82. package/src/SimulateItems.res +91 -23
  83. package/src/SimulateItems.res.mjs +55 -32
  84. package/src/TestIndexer.res +54 -40
  85. package/src/TestIndexer.res.mjs +37 -30
  86. package/src/bindings/Performance.res +7 -0
  87. package/src/bindings/Performance.res.mjs +21 -0
  88. package/src/bindings/Performance.resi +7 -0
  89. package/src/bindings/Viem.res +0 -41
  90. package/src/bindings/Viem.res.mjs +1 -43
  91. package/src/sources/BlockStore.res +46 -0
  92. package/src/sources/BlockStore.res.mjs +24 -0
  93. package/src/sources/EventRouter.res +36 -33
  94. package/src/sources/EventRouter.res.mjs +13 -14
  95. package/src/sources/Evm.res +83 -72
  96. package/src/sources/Evm.res.mjs +53 -69
  97. package/src/sources/EvmChain.res +18 -20
  98. package/src/sources/EvmChain.res.mjs +15 -15
  99. package/src/sources/EvmRpcClient.res +74 -5
  100. package/src/sources/EvmRpcClient.res.mjs +13 -4
  101. package/src/sources/FieldMask.res +39 -0
  102. package/src/sources/FieldMask.res.mjs +42 -0
  103. package/src/sources/Fuel.res +9 -6
  104. package/src/sources/Fuel.res.mjs +5 -10
  105. package/src/sources/HyperFuelSource.res +46 -50
  106. package/src/sources/HyperFuelSource.res.mjs +64 -54
  107. package/src/sources/HyperSync.res +39 -7
  108. package/src/sources/HyperSync.res.mjs +57 -37
  109. package/src/sources/HyperSync.resi +12 -2
  110. package/src/sources/HyperSyncClient.res +22 -111
  111. package/src/sources/HyperSyncClient.res.mjs +2 -30
  112. package/src/sources/HyperSyncHeightStream.res +1 -8
  113. package/src/sources/HyperSyncHeightStream.res.mjs +0 -2
  114. package/src/sources/HyperSyncSource.res +94 -107
  115. package/src/sources/HyperSyncSource.res.mjs +82 -86
  116. package/src/sources/Rpc.res +15 -47
  117. package/src/sources/Rpc.res.mjs +25 -56
  118. package/src/sources/RpcSource.res +320 -467
  119. package/src/sources/RpcSource.res.mjs +268 -385
  120. package/src/sources/RpcWebSocketHeightStream.res +0 -5
  121. package/src/sources/RpcWebSocketHeightStream.res.mjs +0 -2
  122. package/src/sources/SimulateSource.res +10 -4
  123. package/src/sources/SimulateSource.res.mjs +16 -6
  124. package/src/sources/Source.res +34 -6
  125. package/src/sources/SourceManager.res +86 -27
  126. package/src/sources/SourceManager.res.mjs +84 -47
  127. package/src/sources/SourceManager.resi +12 -3
  128. package/src/sources/Svm.res +31 -16
  129. package/src/sources/Svm.res.mjs +36 -13
  130. package/src/sources/SvmHyperSyncClient.res +5 -32
  131. package/src/sources/SvmHyperSyncSource.res +159 -134
  132. package/src/sources/SvmHyperSyncSource.res.mjs +164 -129
  133. package/src/sources/TransactionStore.res +49 -0
  134. package/src/sources/TransactionStore.res.mjs +30 -0
  135. package/src/tui/Tui.res +13 -16
  136. package/src/tui/Tui.res.mjs +12 -14
  137. package/svm.schema.json +49 -37
  138. package/src/bindings/Hrtime.res +0 -58
  139. package/src/bindings/Hrtime.res.mjs +0 -90
  140. package/src/bindings/Hrtime.resi +0 -30
package/src/Main.res CHANGED
@@ -1,19 +1,4 @@
1
- type chainData = {
2
- chainId: float,
3
- poweredByHyperSync: bool,
4
- firstEventBlockNumber: option<int>,
5
- latestProcessedBlock: option<int>,
6
- timestampCaughtUpToHeadOrEndblock: option<Date.t>,
7
- numEventsProcessed: float,
8
- latestFetchedBlockNumber: int,
9
- // Need this for API backwards compatibility
10
- @as("currentBlockHeight")
11
- knownHeight: int,
12
- numBatchesFetched: int,
13
- startBlock: int,
14
- endBlock: option<int>,
15
- numAddresses: int,
16
- }
1
+ type chainData = ChainState.chainData
17
2
  @tag("status")
18
3
  type state =
19
4
  | @as("disabled") Disabled({})
@@ -54,40 +39,24 @@ let stateSchema = S.union([
54
39
  })),
55
40
  ])
56
41
 
57
- // Shape of the user-returned `{_gte?, _lte?, _every?}` filter chunk after
58
- // the ecosystem-specific wrapper is stripped. Shared across all ecosystems
59
- // the outer `block.number` / `block.height` / `slot` unwrap lives on each
60
- // ecosystem's `onBlockFilterSchema`, and the inner range fields are the
61
- // same everywhere.
62
- type blockRange = {
63
- _gte: option<int>,
64
- _lte: option<int>,
65
- _every: int,
66
- }
67
-
68
- // `S.strict` rejects unknown fields so typos like `_gt` / `_evry` surface
69
- // with a readable schema error pointing at the offending key, instead of
70
- // silently registering a broken filter. `_every` defaults to 1 inside the
71
- // schema so the caller always sees a plain `int`, and `intMin(1)` rejects
72
- // zero/negative strides — `(blockNumber - startBlock) % 0` would crash and
73
- // any negative stride would never match.
74
- let blockRangeSchema: S.t<blockRange> = S.object(s => {
75
- _gte: s.field("_gte", S.option(S.int)),
76
- _lte: s.field("_lte", S.option(S.int)),
77
- _every: s.field("_every", S.option(S.int->S.intMin(1))->S.Option.getOr(1)),
78
- })->S.strict
79
-
80
- let defaultBlockRange: blockRange = {_gte: None, _lte: None, _every: 1}
81
-
82
- let indexerStateRef: ref<option<IndexerState.t>> = ref(None)
42
+ // Runtime state lives in the process-wide `EnvioGlobal` record (shared
43
+ // across duplicate envio module instances); the slots are opaque there, so
44
+ // cast them to the real types here.
45
+ let getIndexerState = () =>
46
+ EnvioGlobal.value.indexerState->(Utils.magic: option<unknown> => option<IndexerState.t>)
47
+ let setIndexerState = (state: IndexerState.t) =>
48
+ EnvioGlobal.value.indexerState = Some(state->(Utils.magic: IndexerState.t => unknown))
83
49
 
84
50
  // Persistence is set by Main.start before handler modules load, so that
85
51
  // the exported indexer value can lazily expose DB state (startBlock,
86
52
  // endBlock, isRealtime, dynamic contract addresses) once it's ready.
87
- let globalPersistenceRef: ref<option<Persistence.t>> = ref(None)
53
+ let getGlobalPersistence = () =>
54
+ EnvioGlobal.value.persistence->(Utils.magic: option<unknown> => option<Persistence.t>)
55
+ let setGlobalPersistence = (persistence: Persistence.t) =>
56
+ EnvioGlobal.value.persistence = Some(persistence->(Utils.magic: Persistence.t => unknown))
88
57
 
89
58
  let getInitialChainState = (~chainId: int): option<Persistence.initialChainState> => {
90
- switch globalPersistenceRef.contents {
59
+ switch getGlobalPersistence() {
91
60
  | Some(persistence) =>
92
61
  switch persistence.storageStatus {
93
62
  | Ready(initialState) => initialState.chains->Array.find(c => c.id === chainId)
@@ -97,7 +66,7 @@ let getInitialChainState = (~chainId: int): option<Persistence.initialChainState
97
66
  }
98
67
  }
99
68
 
100
- // Importing `generated` must not trigger `Config.loadWithoutRegistrations()`,
69
+ // Importing `generated` must not trigger `Config.load()`,
101
70
  // so the exported indexer calls this lazily on first `indexer.chains` access.
102
71
  let buildChainsObject = (~config: Config.t) => {
103
72
  let chainIds = []
@@ -145,7 +114,7 @@ let buildChainsObject = (~config: Config.t) => {
145
114
  {
146
115
  enumerable: true,
147
116
  get: () => {
148
- switch indexerStateRef.contents {
117
+ switch getIndexerState() {
149
118
  | Some(state) => state->IndexerState.isRealtime
150
119
  // Before the global state is available (eg during handler
151
120
  // module load after resume), derive from persistence: every chain
@@ -179,22 +148,11 @@ let buildChainsObject = (~config: Config.t) => {
179
148
  {
180
149
  enumerable: true,
181
150
  get: () => {
182
- switch indexerStateRef.contents {
151
+ switch getIndexerState() {
183
152
  | Some(state) => {
184
153
  let chain = ChainMap.Chain.makeUnsafe(~chainId=chainConfig.id)
185
154
  let chainState = state->IndexerState.getChainState(~chain)
186
- let indexingAddresses = (chainState->ChainState.fetchState).indexingAddresses
187
-
188
- // Collect all addresses for this contract name from indexingAddresses
189
- let addresses = []
190
- let values = indexingAddresses->Dict.valuesToArray
191
- for idx in 0 to values->Array.length - 1 {
192
- let indexingContract = values->Array.getUnsafe(idx)
193
- if indexingContract.contractName === contract.name {
194
- addresses->Array.push(indexingContract.address)->ignore
195
- }
196
- }
197
- addresses
155
+ chainState->ChainState.contractAddresses(~contractName=contract.name)
198
156
  }
199
157
  // Before the global state is available (eg during handler
200
158
  // module load after resume), combine static addresses from config
@@ -371,121 +329,24 @@ let getGlobalIndexer = (): 'indexer => {
371
329
  let _ = RollbackCommit.register(callback->(Utils.magic: 'a => RollbackCommit.callback))
372
330
  }
373
331
 
374
- // Two-stage parse: first the ecosystem-specific outer schema unwraps the
375
- // wrapper (`block.number` / `block.height` / `slot`) and surfaces the
376
- // inner chunk as raw `unknown`; then the shared `blockRangeSchema`
377
- // validates the `{_gte?, _lte?, _every?}` fields. Keeping the inner
378
- // validation in one place means typos and shape mismatches surface with
379
- // the same user-friendly error regardless of ecosystem.
380
- let extractRange = (filter: unknown, ~name, ~ecosystem: Ecosystem.t): blockRange =>
381
- try {
382
- switch filter->S.parseOrThrow(ecosystem.onBlockFilterSchema) {
383
- | None => defaultBlockRange
384
- | Some(inner) => inner->S.parseOrThrow(blockRangeSchema)
385
- }
386
- } catch {
387
- | S.Raised(exn) =>
388
- JsError.throwWithMessage(
389
- `\`indexer.${ecosystem.onBlockMethodName}("${name}")\` \`where\` returned an invalid filter: ${exn
390
- ->Utils.prettifyExn
391
- ->(Utils.magic: exn => string)}`,
392
- )
393
- }
394
-
395
- // `where` is evaluated once per configured chain at registration time.
396
- // Decoded ranges/stride feed directly into `HandlerRegister.registerOnBlock`
397
- // so the fetcher's `(blockNumber - handlerStartBlock) % interval === 0`
398
- // math at `FetchState.res:619` stays untouched.
399
332
  let onBlockFn = (rawOptions: 'a, handler: 'b) => {
400
333
  HandlerRegister.throwIfFinishedRegistration(~methodName="onBlock")
401
- let config = Config.loadWithoutRegistrations()
402
- let ecosystem = config.ecosystem
403
334
  let raw =
404
335
  rawOptions->(
405
336
  Utils.magic: 'a => {
406
337
  "name": string,
407
- "where": option<Envio.onBlockWhereArgs<unknown> => unknown>,
338
+ "where": unknown,
408
339
  }
409
340
  )
410
- let typedHandler = handler->(Utils.magic: 'b => Internal.onBlockArgs => promise<unit>)
411
- let (chains, _) = buildChainsObject(~config)
412
- let chainsDict = chains->(Utils.magic: {..} => dict<unknown>)
413
- let name = raw["name"]
414
- let logger = Logging.createChild(~params={"onBlock": name})
415
-
416
- // `where` must be a function (unlike onEvent, which also accepts a static
417
- // value). A static value would have to be evaluated against every chain
418
- // independently, which has no useful semantic for block handlers.
419
- // Normalize undefined/null to None up front so the per-chain loop below
420
- // can't accidentally call `null` as a predicate (ReScript treats a JS
421
- // `null` value as `Some(null)` when the field is typed as option).
422
- let where = switch raw["where"]->(Utils.magic: option<'a> => unknown) {
423
- | w if w === %raw(`undefined`) || w === %raw(`null`) => None
424
- | w if typeof(w) === #function => Some(raw["where"]->Option.getUnsafe)
425
- | w =>
426
- JsError.throwWithMessage(
427
- `\`indexer.${ecosystem.onBlockMethodName}("${name}")\` expected \`where\` to be a function or omitted, but got ${(typeof(
428
- w,
429
- ) :> string)}.`,
430
- )
431
- }
432
-
433
- let matchedAny = ref(false)
434
-
435
- config.chainMap
436
- ->ChainMap.values
437
- ->Array.forEach(chainConfig => {
438
- let chainId = chainConfig.id
439
- let chainObj = chainsDict->Dict.getUnsafe(chainId->Int.toString)
440
-
441
- // Predicate returns `true` → match with no filter; `false` → skip;
442
- // any plain object → structured filter. `undefined`/`null` returns
443
- // are rejected — the TS type excludes `void`, so a missing return is
444
- // a user bug we surface early rather than silently match-all.
445
- let result = switch where {
446
- | None => %raw(`true`)
447
- | Some(predicate) => predicate({chain: chainObj})
448
- }
449
-
450
- let (shouldRegister, range) = if result === %raw(`true`) {
451
- (true, defaultBlockRange)
452
- } else if result === %raw(`false`) {
453
- (false, defaultBlockRange)
454
- } else if typeof(result) === #object && !(result->Array.isArray) && result !== %raw(`null`) {
455
- (true, extractRange(result, ~name, ~ecosystem))
456
- } else {
457
- // Reject numbers, strings, functions, arrays, undefined, null —
458
- // anything that isn't bool or a plain object would silently
459
- // misregister.
460
- JsError.throwWithMessage(
461
- `\`indexer.${ecosystem.onBlockMethodName}("${name}")\` \`where\` predicate returned an invalid value of type ${(typeof(
462
- result,
463
- ) :> string)}. Expected boolean or a filter object.`,
464
- )
465
- }
466
-
467
- if shouldRegister {
468
- matchedAny := true
469
- HandlerRegister.registerOnBlock(
470
- ~name,
471
- ~chainId,
472
- ~interval=range._every,
473
- ~startBlock=range._gte,
474
- ~endBlock=range._lte,
475
- ~handler=typedHandler,
476
- )
477
- }
478
- })
479
-
480
- // Catches misconfigured `where` predicates that return `false` for every
481
- // configured chain — the handler would otherwise never fire with no hint.
482
- // Includes the ecosystem-specific method name so SVM users see "onSlot"
483
- // and don't get confused looking for a "Block handler" they never wrote.
484
- if !matchedAny.contents {
485
- logger->Logging.childWarn(
486
- `\`indexer.${ecosystem.onBlockMethodName}\` matched 0 chains. Check the \`where\` predicate.`,
487
- )
488
- }
341
+ HandlerRegister.registerOnBlock(
342
+ ~name=raw["name"],
343
+ ~where=raw["where"],
344
+ ~handler=handler->(Utils.magic: 'b => Internal.onBlockArgs => promise<unit>),
345
+ ~getChainsObject=config => {
346
+ let (chains, _) = buildChainsObject(~config)
347
+ chains->(Utils.magic: {..} => dict<unknown>)
348
+ },
349
+ )
489
350
  }
490
351
 
491
352
  // Ecosystem-specific surface: EVM/Fuel expose event + block handlers; SVM
@@ -497,13 +358,13 @@ let getGlobalIndexer = (): 'indexer => {
497
358
  // `Api.res` calls `getGlobalIndexer()` at envio-package load, so the keys
498
359
  // array is memoized lazily: an early `createEffect` / `S` import that
499
360
  // never touches the indexer must not trigger a config parse. The memo is
500
- // safe because `Config.loadWithoutRegistrations` is itself pure.
361
+ // safe because `Config.load` is itself pure.
501
362
  let keysMemo: ref<option<array<string>>> = ref(None)
502
363
  let getKeys = () =>
503
364
  switch keysMemo.contents {
504
365
  | Some(k) => k
505
366
  | None => {
506
- let keys = switch Config.loadWithoutRegistrations().ecosystem.name {
367
+ let keys = switch Config.load().ecosystem.name {
507
368
  | Evm | Fuel => [
508
369
  "name",
509
370
  "description",
@@ -531,15 +392,14 @@ let getGlobalIndexer = (): 'indexer => {
531
392
 
532
393
  let get = (~prop: string) =>
533
394
  switch prop {
534
- | "name" => Config.loadWithoutRegistrations().name->(Utils.magic: string => unknown)
535
- | "description" =>
536
- Config.loadWithoutRegistrations().description->(Utils.magic: option<string> => unknown)
395
+ | "name" => Config.load().name->(Utils.magic: string => unknown)
396
+ | "description" => Config.load().description->(Utils.magic: option<string> => unknown)
537
397
  | "chainIds" => {
538
- let (_, chainIds) = buildChainsObject(~config=Config.loadWithoutRegistrations())
398
+ let (_, chainIds) = buildChainsObject(~config=Config.load())
539
399
  chainIds->(Utils.magic: array<int> => unknown)
540
400
  }
541
401
  | "chains" => {
542
- let (chains, _) = buildChainsObject(~config=Config.loadWithoutRegistrations())
402
+ let (chains, _) = buildChainsObject(~config=Config.load())
543
403
  chains->(Utils.magic: {..} => unknown)
544
404
  }
545
405
  | "onEvent" => onEventFn->Utils.magic
@@ -642,9 +502,9 @@ let startServer = (~getState, ~persistence: Persistence.t, ~isDevelopmentMode: b
642
502
  app->get("/metrics", (_req, res) => {
643
503
  res->set("Content-Type", PromClient.defaultRegister->PromClient.getContentType)
644
504
  let _ =
645
- PromClient.defaultRegister
646
- ->PromClient.metrics
647
- ->Promise.thenResolve(metrics => res->endWithData(metrics))
505
+ Metrics.collect(~state=getIndexerState())->Promise.thenResolve(metrics =>
506
+ res->endWithData(metrics)
507
+ )
648
508
  })
649
509
 
650
510
  app->get("/metrics/runtime", (_req, res) => {
@@ -683,7 +543,7 @@ type mainArgs = Yargs.parsedArgs<args>
683
543
  let getEnvioInfo = () => Config.getPublicConfigJson()->Config.stripSensitiveData
684
544
 
685
545
  let migrate = async (~reset) => {
686
- let config = Config.loadWithoutRegistrations()
546
+ let config = Config.load()
687
547
  let persistence = PgStorage.makePersistenceFromConfig(~config)
688
548
  await persistence->Persistence.init(
689
549
  ~reset,
@@ -696,18 +556,22 @@ let migrate = async (~reset) => {
696
556
  }
697
557
 
698
558
  let dropSchema = async () => {
699
- let config = Config.loadWithoutRegistrations()
559
+ let config = Config.load()
700
560
  let persistence = PgStorage.makePersistenceFromConfig(~config)
701
561
  await persistence.storage.reset()
702
562
  await persistence.storage.close()
703
563
  }
704
564
 
565
+ // Rejection carried by `onError`: the failure is already logged with full
566
+ // context, so callers should act on it (exit / re-throw) without logging again.
567
+ exception FatalError(exn)
568
+
705
569
  let start = async (
706
570
  ~persistence: option<Persistence.t>=?,
707
571
  ~reset=false,
708
572
  ~isTest=false,
709
573
  ~exitAfterFirstEventBlock=false,
710
- ~patchConfig: option<(Config.t, HandlerRegister.registrations) => Config.t>=?,
574
+ ~patchConfig: option<(Config.t, HandlerRegister.registrationsByChainId) => Config.t>=?,
711
575
  ) => {
712
576
  let mainArgs: mainArgs = process->argv->Yargs.hideBin->Yargs.yargs->Yargs.argv
713
577
  let explicitTui = switch mainArgs.tuiOff {
@@ -721,31 +585,30 @@ let start = async (
721
585
  }
722
586
  // Initialize persistence first so the exported indexer value contains state from the database
723
587
  // when handler files are loaded (they may access the indexer at module top level).
724
- let configWithoutRegistrations = Config.loadWithoutRegistrations()
588
+ let config = Config.load()
725
589
  // isDevelopmentMode controls whether the indexer stays alive after all
726
590
  // chains finish (keepProcessAlive) and whether the console API is exposed.
727
591
  // Set by `envio dev` via the public config's `isDev` field; `envio start`
728
592
  // leaves it false so the process exits cleanly when indexing completes.
729
- let isDevelopmentMode = !isTest && configWithoutRegistrations.isDev
593
+ let isDevelopmentMode = !isTest && config.isDev
730
594
  let persistence = switch persistence {
731
595
  | Some(p) => p
732
- | None => PgStorage.makePersistenceFromConfig(~config=configWithoutRegistrations)
596
+ | None => PgStorage.makePersistenceFromConfig(~config)
733
597
  }
734
- globalPersistenceRef := Some(persistence)
598
+ setGlobalPersistence(persistence)
735
599
  await persistence->Persistence.init(
736
600
  ~reset,
737
- ~chainConfigs=configWithoutRegistrations.chainMap->ChainMap.values,
601
+ ~chainConfigs=config.chainMap->ChainMap.values,
738
602
  ~envioInfo=getEnvioInfo(),
739
603
  ~resetCommand=isDevelopmentMode ? "envio dev -r" : "envio start -r",
740
604
  ~runCommand=Some(isDevelopmentMode ? "envio dev" : "envio start"),
741
605
  )
742
606
 
743
- // `Config.loadWithoutRegistrations` never sees registration state; handler,
744
- // contractRegister, and eventFilters are baked into each event config only
745
- // by the returned value here.
746
- let (config, registrations) = await HandlerLoader.registerAllHandlers(
747
- ~config=configWithoutRegistrations,
748
- )
607
+ // Loads user handler files, which register handler/contractRegister/where
608
+ // state into the global `HandlerRegister` registry as a side effect; this
609
+ // returns that state resolved into per-chain registrations. `config` itself
610
+ // is never mutated by registration — it holds only event definitions.
611
+ let registrationsByChainId = await HandlerLoader.registerAllHandlers(~config)
749
612
  let config = if isTest {
750
613
  {...config, shouldRollbackOnReorg: false}
751
614
  } else {
@@ -753,21 +616,24 @@ let start = async (
753
616
  }
754
617
 
755
618
  let config = switch patchConfig {
756
- | Some(patchConfig) => patchConfig(config, registrations)
619
+ | Some(patchConfig) => patchConfig(config, registrationsByChainId)
757
620
  | None => config
758
621
  }
759
622
  // The single fatal-error handler, invoked once via IndexerState.errorExit.
623
+ // It logs the failure once (with chain context) and rejects the run wrapped in
624
+ // `FatalError` so callers know it's already logged — `Bin.res` just exits, the
625
+ // test worker unwraps and re-throws it to the parent thread. `runUntilFatalError`
626
+ // only ever rejects: on a clean run it stays pending and the process exits via
627
+ // ExitOnCaughtUp / when the indexer loop drains.
628
+ let onErrorReject = ref(None)
629
+ let runUntilFatalError: promise<unit> = Promise.make((_resolve, reject) =>
630
+ onErrorReject := Some(reject)
631
+ )
632
+ // `onErrorReject` is filled synchronously by `Promise.make` above, before the
633
+ // indexer can run and call `onError`, so it's always present here.
760
634
  let onError = (errHandler: ErrorHandling.t) => {
761
635
  errHandler->ErrorHandling.log
762
- if isTest {
763
- // The TestIndexer runs the indexer in a worker thread and reads the
764
- // failure off the worker 'error' event. Re-throw the original error
765
- // outside the promise chain on the next tick so the test sees its real
766
- // message instead of a generic non-zero exit code.
767
- NodeJs.setImmediate(() => errHandler->ErrorHandling.raiseExn)
768
- } else {
769
- NodeJs.process->NodeJs.exitWithCode(Failure)
770
- }
636
+ (onErrorReject.contents->Option.getUnsafe)(FatalError(errHandler.exn->Utils.prettifyExn))
771
637
  }
772
638
  let envioVersion = Utils.EnvioPackage.value.version
773
639
  Prometheus.Info.set(~version=envioVersion)
@@ -776,43 +642,11 @@ let start = async (
776
642
 
777
643
  if !isTest {
778
644
  startServer(~persistence, ~isDevelopmentMode, ~getState=() =>
779
- switch indexerStateRef.contents {
645
+ switch getIndexerState() {
780
646
  | None => Initializing({})
781
647
  | Some(state) => {
782
648
  let chains =
783
- state
784
- ->IndexerState.chainStates
785
- ->Dict.valuesToArray
786
- ->Array.map(cs => {
787
- let fetchState = cs->ChainState.fetchState
788
- let latestFetchedBlockNumber = Pervasives.max(
789
- FetchState.bufferBlockNumber(fetchState),
790
- 0,
791
- )
792
- let knownHeight =
793
- cs->ChainState.hasProcessedToEndblock
794
- ? fetchState.endBlock->Option.getOr(fetchState.knownHeight)
795
- : fetchState.knownHeight
796
-
797
- {
798
- chainId: (cs->ChainState.chainConfig).id->Int.toFloat,
799
- poweredByHyperSync: (
800
- cs->ChainState.sourceManager->SourceManager.getActiveSource
801
- ).poweredByHyperSync,
802
- latestFetchedBlockNumber,
803
- knownHeight,
804
- numBatchesFetched: 0,
805
- startBlock: fetchState.startBlock,
806
- endBlock: fetchState.endBlock,
807
- firstEventBlockNumber: fetchState.firstEventBlock,
808
- latestProcessedBlock: cs->ChainState.committedProgressBlockNumber === -1
809
- ? None
810
- : Some(cs->ChainState.committedProgressBlockNumber),
811
- timestampCaughtUpToHeadOrEndblock: cs->ChainState.timestampCaughtUpToHeadOrEndblock,
812
- numEventsProcessed: cs->ChainState.numEventsProcessed,
813
- numAddresses: fetchState->FetchState.numAddresses,
814
- }
815
- })
649
+ state->IndexerState.chainStates->Dict.valuesToArray->Array.map(ChainState.toChainData)
816
650
  Active({
817
651
  envioVersion,
818
652
  chains,
@@ -829,7 +663,7 @@ let start = async (
829
663
  ~config,
830
664
  ~persistence,
831
665
  ~initialState=persistence->Persistence.getInitializedState,
832
- ~registrations,
666
+ ~registrationsByChainId,
833
667
  ~isDevelopmentMode,
834
668
  ~shouldUseTui,
835
669
  ~exitAfterFirstEventBlock,
@@ -838,6 +672,7 @@ let start = async (
838
672
  if shouldUseTui {
839
673
  let _rerender = Tui.start(~getState=() => state)
840
674
  }
841
- indexerStateRef := Some(state)
675
+ setIndexerState(state)
842
676
  state->IndexerLoop.start
677
+ await runUntilFatalError
843
678
  }