envio 3.0.0-alpha.21 → 3.0.0-alpha.23

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 (220) hide show
  1. package/README.md +3 -3
  2. package/bin.mjs +2 -48
  3. package/evm.schema.json +67 -0
  4. package/fuel.schema.json +67 -0
  5. package/index.d.ts +822 -38
  6. package/index.js +5 -3
  7. package/package.json +10 -8
  8. package/rescript.json +5 -9
  9. package/src/Address.res +4 -5
  10. package/src/Address.res.mjs +9 -12
  11. package/src/Api.res +15 -0
  12. package/src/Api.res.mjs +20 -0
  13. package/src/Batch.res +32 -34
  14. package/src/Batch.res.mjs +172 -187
  15. package/src/Bin.res +89 -0
  16. package/src/Bin.res.mjs +97 -0
  17. package/src/ChainFetcher.res +33 -57
  18. package/src/ChainFetcher.res.mjs +197 -227
  19. package/src/ChainManager.res +6 -14
  20. package/src/ChainManager.res.mjs +74 -85
  21. package/src/ChainMap.res +14 -16
  22. package/src/ChainMap.res.mjs +38 -38
  23. package/src/Config.res +193 -135
  24. package/src/Config.res.mjs +566 -592
  25. package/src/Core.res +182 -0
  26. package/src/Core.res.mjs +207 -0
  27. package/src/Ecosystem.res +25 -4
  28. package/src/Ecosystem.res.mjs +12 -13
  29. package/src/Env.res +20 -13
  30. package/src/Env.res.mjs +124 -113
  31. package/src/EnvSafe.res +269 -0
  32. package/src/EnvSafe.res.mjs +296 -0
  33. package/src/EnvSafe.resi +18 -0
  34. package/src/Envio.res +37 -26
  35. package/src/Envio.res.mjs +59 -60
  36. package/src/ErrorHandling.res +2 -2
  37. package/src/ErrorHandling.res.mjs +15 -15
  38. package/src/EventConfigBuilder.res +219 -81
  39. package/src/EventConfigBuilder.res.mjs +259 -202
  40. package/src/EventProcessing.res +27 -38
  41. package/src/EventProcessing.res.mjs +165 -183
  42. package/src/EventUtils.res +11 -11
  43. package/src/EventUtils.res.mjs +21 -22
  44. package/src/EvmTypes.res +0 -1
  45. package/src/EvmTypes.res.mjs +5 -5
  46. package/src/FetchState.res +360 -256
  47. package/src/FetchState.res.mjs +958 -914
  48. package/src/GlobalState.res +365 -351
  49. package/src/GlobalState.res.mjs +958 -992
  50. package/src/GlobalStateManager.res +1 -2
  51. package/src/GlobalStateManager.res.mjs +36 -44
  52. package/src/HandlerLoader.res +107 -23
  53. package/src/HandlerLoader.res.mjs +128 -38
  54. package/src/HandlerRegister.res +127 -103
  55. package/src/HandlerRegister.res.mjs +164 -164
  56. package/src/HandlerRegister.resi +12 -4
  57. package/src/Hasura.res +35 -22
  58. package/src/Hasura.res.mjs +158 -167
  59. package/src/InMemoryStore.res +20 -27
  60. package/src/InMemoryStore.res.mjs +64 -80
  61. package/src/InMemoryTable.res +34 -39
  62. package/src/InMemoryTable.res.mjs +165 -170
  63. package/src/Internal.res +52 -33
  64. package/src/Internal.res.mjs +84 -81
  65. package/src/LazyLoader.res.mjs +55 -61
  66. package/src/LoadLayer.res +77 -78
  67. package/src/LoadLayer.res.mjs +160 -189
  68. package/src/LoadManager.res +16 -21
  69. package/src/LoadManager.res.mjs +79 -84
  70. package/src/LogSelection.res +236 -68
  71. package/src/LogSelection.res.mjs +211 -141
  72. package/src/Logging.res +13 -9
  73. package/src/Logging.res.mjs +130 -143
  74. package/src/Main.res +430 -51
  75. package/src/Main.res.mjs +530 -271
  76. package/src/Persistence.res +80 -84
  77. package/src/Persistence.res.mjs +131 -132
  78. package/src/PgStorage.res +294 -167
  79. package/src/PgStorage.res.mjs +799 -817
  80. package/src/Prometheus.res +50 -58
  81. package/src/Prometheus.res.mjs +345 -373
  82. package/src/ReorgDetection.res +22 -24
  83. package/src/ReorgDetection.res.mjs +100 -106
  84. package/src/SafeCheckpointTracking.res +7 -7
  85. package/src/SafeCheckpointTracking.res.mjs +40 -43
  86. package/src/SimulateItems.res +41 -49
  87. package/src/SimulateItems.res.mjs +257 -272
  88. package/src/Sink.res +2 -2
  89. package/src/Sink.res.mjs +22 -26
  90. package/src/TableIndices.res +1 -2
  91. package/src/TableIndices.res.mjs +42 -48
  92. package/src/TestIndexer.res +196 -189
  93. package/src/TestIndexer.res.mjs +536 -536
  94. package/src/TestIndexerProxyStorage.res +16 -16
  95. package/src/TestIndexerProxyStorage.res.mjs +99 -122
  96. package/src/TestIndexerWorker.res +4 -0
  97. package/src/TestIndexerWorker.res.mjs +7 -0
  98. package/src/Throttler.res +3 -3
  99. package/src/Throttler.res.mjs +23 -24
  100. package/src/Time.res +1 -1
  101. package/src/Time.res.mjs +18 -21
  102. package/src/TopicFilter.res +3 -3
  103. package/src/TopicFilter.res.mjs +29 -30
  104. package/src/UserContext.res +93 -54
  105. package/src/UserContext.res.mjs +197 -182
  106. package/src/Utils.res +141 -86
  107. package/src/Utils.res.mjs +334 -295
  108. package/src/bindings/BigDecimal.res +0 -2
  109. package/src/bindings/BigDecimal.res.mjs +19 -23
  110. package/src/bindings/ClickHouse.res +28 -27
  111. package/src/bindings/ClickHouse.res.mjs +243 -240
  112. package/src/bindings/DateFns.res +11 -11
  113. package/src/bindings/DateFns.res.mjs +7 -7
  114. package/src/bindings/EventSource.res.mjs +2 -2
  115. package/src/bindings/Express.res +2 -5
  116. package/src/bindings/Hrtime.res +2 -2
  117. package/src/bindings/Hrtime.res.mjs +30 -32
  118. package/src/bindings/Lodash.res.mjs +1 -1
  119. package/src/bindings/NodeJs.res +14 -9
  120. package/src/bindings/NodeJs.res.mjs +20 -20
  121. package/src/bindings/Pino.res +8 -10
  122. package/src/bindings/Pino.res.mjs +40 -43
  123. package/src/bindings/Postgres.res +7 -5
  124. package/src/bindings/Postgres.res.mjs +9 -9
  125. package/src/bindings/PromClient.res +17 -2
  126. package/src/bindings/PromClient.res.mjs +30 -7
  127. package/src/bindings/SDSL.res.mjs +2 -2
  128. package/src/bindings/Viem.res +4 -4
  129. package/src/bindings/Viem.res.mjs +20 -22
  130. package/src/bindings/Vitest.res +1 -1
  131. package/src/bindings/Vitest.res.mjs +2 -2
  132. package/src/bindings/WebSocket.res +1 -1
  133. package/src/db/EntityHistory.res +9 -3
  134. package/src/db/EntityHistory.res.mjs +84 -59
  135. package/src/db/InternalTable.res +62 -60
  136. package/src/db/InternalTable.res.mjs +271 -203
  137. package/src/db/Schema.res +1 -2
  138. package/src/db/Schema.res.mjs +28 -32
  139. package/src/db/Table.res +28 -27
  140. package/src/db/Table.res.mjs +276 -292
  141. package/src/sources/EventRouter.res +21 -16
  142. package/src/sources/EventRouter.res.mjs +55 -57
  143. package/src/sources/Evm.res +17 -1
  144. package/src/sources/Evm.res.mjs +16 -8
  145. package/src/sources/EvmChain.res +15 -17
  146. package/src/sources/EvmChain.res.mjs +40 -42
  147. package/src/sources/Fuel.res +14 -1
  148. package/src/sources/Fuel.res.mjs +16 -8
  149. package/src/sources/FuelSDK.res +1 -1
  150. package/src/sources/FuelSDK.res.mjs +6 -8
  151. package/src/sources/HyperFuel.res +8 -10
  152. package/src/sources/HyperFuel.res.mjs +113 -123
  153. package/src/sources/HyperFuelClient.res.mjs +6 -7
  154. package/src/sources/HyperFuelSource.res +19 -20
  155. package/src/sources/HyperFuelSource.res.mjs +339 -356
  156. package/src/sources/HyperSync.res +11 -13
  157. package/src/sources/HyperSync.res.mjs +206 -220
  158. package/src/sources/HyperSyncClient.res +5 -7
  159. package/src/sources/HyperSyncClient.res.mjs +70 -75
  160. package/src/sources/HyperSyncHeightStream.res +8 -9
  161. package/src/sources/HyperSyncHeightStream.res.mjs +78 -86
  162. package/src/sources/HyperSyncJsonApi.res +18 -15
  163. package/src/sources/HyperSyncJsonApi.res.mjs +201 -231
  164. package/src/sources/HyperSyncSource.res +17 -21
  165. package/src/sources/HyperSyncSource.res.mjs +268 -290
  166. package/src/sources/Rpc.res +5 -5
  167. package/src/sources/Rpc.res.mjs +168 -192
  168. package/src/sources/RpcSource.res +166 -167
  169. package/src/sources/RpcSource.res.mjs +972 -1046
  170. package/src/sources/RpcWebSocketHeightStream.res +10 -11
  171. package/src/sources/RpcWebSocketHeightStream.res.mjs +131 -145
  172. package/src/sources/SimulateSource.res +1 -1
  173. package/src/sources/SimulateSource.res.mjs +35 -38
  174. package/src/sources/Source.res +1 -1
  175. package/src/sources/Source.res.mjs +3 -3
  176. package/src/sources/SourceManager.res +39 -20
  177. package/src/sources/SourceManager.res.mjs +340 -371
  178. package/src/sources/SourceManager.resi +2 -1
  179. package/src/sources/Svm.res +12 -5
  180. package/src/sources/Svm.res.mjs +44 -41
  181. package/src/tui/Tui.res +23 -12
  182. package/src/tui/Tui.res.mjs +292 -290
  183. package/src/tui/bindings/Ink.res +2 -4
  184. package/src/tui/bindings/Ink.res.mjs +35 -41
  185. package/src/tui/components/BufferedProgressBar.res +7 -7
  186. package/src/tui/components/BufferedProgressBar.res.mjs +46 -46
  187. package/src/tui/components/CustomHooks.res +1 -2
  188. package/src/tui/components/CustomHooks.res.mjs +102 -122
  189. package/src/tui/components/Messages.res +1 -2
  190. package/src/tui/components/Messages.res.mjs +38 -42
  191. package/src/tui/components/SyncETA.res +10 -11
  192. package/src/tui/components/SyncETA.res.mjs +178 -196
  193. package/src/tui/components/TuiData.res +1 -1
  194. package/src/tui/components/TuiData.res.mjs +7 -6
  195. package/src/vendored/Rest.res +52 -66
  196. package/src/vendored/Rest.res.mjs +324 -364
  197. package/svm.schema.json +67 -0
  198. package/src/Address.gen.ts +0 -8
  199. package/src/Config.gen.ts +0 -19
  200. package/src/Envio.gen.ts +0 -55
  201. package/src/EvmTypes.gen.ts +0 -6
  202. package/src/InMemoryStore.gen.ts +0 -6
  203. package/src/Internal.gen.ts +0 -64
  204. package/src/PgStorage.gen.ts +0 -10
  205. package/src/PgStorage.res.d.mts +0 -5
  206. package/src/Types.ts +0 -56
  207. package/src/bindings/BigDecimal.gen.ts +0 -14
  208. package/src/bindings/BigDecimal.res.d.mts +0 -5
  209. package/src/bindings/BigInt.gen.ts +0 -10
  210. package/src/bindings/BigInt.res +0 -70
  211. package/src/bindings/BigInt.res.d.mts +0 -5
  212. package/src/bindings/BigInt.res.mjs +0 -154
  213. package/src/bindings/Ethers.res.d.mts +0 -5
  214. package/src/bindings/Pino.gen.ts +0 -17
  215. package/src/bindings/Postgres.gen.ts +0 -8
  216. package/src/bindings/Postgres.res.d.mts +0 -5
  217. package/src/bindings/Promise.res +0 -67
  218. package/src/bindings/Promise.res.mjs +0 -26
  219. package/src/db/InternalTable.gen.ts +0 -36
  220. package/src/sources/HyperSyncClient.gen.ts +0 -19
package/src/Main.res CHANGED
@@ -1,11 +1,9 @@
1
- open Belt
2
-
3
1
  type chainData = {
4
2
  chainId: float,
5
3
  poweredByHyperSync: bool,
6
4
  firstEventBlockNumber: option<int>,
7
5
  latestProcessedBlock: option<int>,
8
- timestampCaughtUpToHeadOrEndblock: option<Js.Date.t>,
6
+ timestampCaughtUpToHeadOrEndblock: option<Date.t>,
9
7
  numEventsProcessed: float,
10
8
  latestFetchedBlockNumber: int,
11
9
  // Need this for API backwards compatibility
@@ -24,7 +22,7 @@ type state =
24
22
  Active({
25
23
  envioVersion: string,
26
24
  chains: array<chainData>,
27
- indexerStartTime: Js.Date.t,
25
+ indexerStartTime: Date.t,
28
26
  isPreRegisteringDynamicContracts: bool,
29
27
  isUnorderedMultichainMode: bool,
30
28
  rollbackOnReorg: bool,
@@ -58,40 +56,90 @@ let stateSchema = S.union([
58
56
  })),
59
57
  ])
60
58
 
61
- let globalGsManagerRef: ref<option<GlobalStateManager.t>> = ref(None)
59
+ // Shape of the user-returned `{_gte?, _lte?, _every?}` filter chunk after
60
+ // the ecosystem-specific wrapper is stripped. Shared across all ecosystems —
61
+ // the outer `block.number` / `block.height` / `slot` unwrap lives on each
62
+ // ecosystem's `onBlockFilterSchema`, and the inner range fields are the
63
+ // same everywhere.
64
+ type blockRange = {
65
+ _gte: option<int>,
66
+ _lte: option<int>,
67
+ _every: int,
68
+ }
62
69
 
63
- let getGlobalIndexer = (~config: Config.t): 'indexer => {
64
- let indexer = Utils.Object.createNullObject()
70
+ // `S.strict` rejects unknown fields so typos like `_gt` / `_evry` surface
71
+ // with a readable schema error pointing at the offending key, instead of
72
+ // silently registering a broken filter. `_every` defaults to 1 inside the
73
+ // schema so the caller always sees a plain `int`, and `intMin(1)` rejects
74
+ // zero/negative strides — `(blockNumber - startBlock) % 0` would crash and
75
+ // any negative stride would never match.
76
+ let blockRangeSchema: S.t<blockRange> = S.object(s => {
77
+ _gte: s.field("_gte", S.option(S.int)),
78
+ _lte: s.field("_lte", S.option(S.int)),
79
+ _every: s.field("_every", S.option(S.int->S.intMin(1))->S.Option.getOr(1)),
80
+ })->S.strict
65
81
 
66
- indexer
67
- ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: config.name})
68
- ->Utils.Object.definePropertyWithValue(
69
- "description",
70
- {enumerable: true, value: config.description},
71
- )
72
- ->ignore
82
+ let defaultBlockRange: blockRange = {_gte: None, _lte: None, _every: 1}
73
83
 
74
- let chainIds = []
84
+ let globalGsManagerRef: ref<option<GlobalStateManager.t>> = ref(None)
75
85
 
76
- // Build chains object with chain ID as string key
86
+ // Persistence is set by Main.start before handler modules load, so that
87
+ // the exported indexer value can lazily expose DB state (startBlock,
88
+ // endBlock, isLive, dynamic contract addresses) once it's ready.
89
+ let globalPersistenceRef: ref<option<Persistence.t>> = ref(None)
90
+
91
+ let getInitialChainState = (~chainId: int): option<Persistence.initialChainState> => {
92
+ switch globalPersistenceRef.contents {
93
+ | Some(persistence) =>
94
+ switch persistence.storageStatus {
95
+ | Ready(initialState) => initialState.chains->Array.find(c => c.id === chainId)
96
+ | _ => None
97
+ }
98
+ | None => None
99
+ }
100
+ }
101
+
102
+ // Importing `generated` must not trigger `Config.loadWithoutRegistrations()`,
103
+ // so the exported indexer calls this lazily on first `indexer.chains` access.
104
+ let buildChainsObject = (~config: Config.t) => {
105
+ let chainIds = []
77
106
  let chains = Utils.Object.createNullObject()
78
107
  config.chainMap
79
108
  ->ChainMap.values
80
109
  ->Array.forEach(chainConfig => {
81
110
  let chainIdStr = chainConfig.id->Int.toString
82
111
 
83
- chainIds->Js.Array2.push(chainConfig.id)->ignore
112
+ chainIds->Array.push(chainConfig.id)->ignore
84
113
 
85
114
  let chainObj = Utils.Object.createNullObject()
86
115
  chainObj
87
116
  ->Utils.Object.definePropertyWithValue("id", {enumerable: true, value: chainConfig.id})
88
- ->Utils.Object.definePropertyWithValue(
117
+ ->Utils.Object.defineProperty(
89
118
  "startBlock",
90
- {enumerable: true, value: chainConfig.startBlock},
119
+ {
120
+ enumerable: true,
121
+ get: () => {
122
+ switch getInitialChainState(~chainId=chainConfig.id) {
123
+ | Some(chainState) => chainState.startBlock
124
+ | None => chainConfig.startBlock
125
+ }
126
+ },
127
+ },
91
128
  )
92
- ->Utils.Object.definePropertyWithValue(
129
+ ->Utils.Object.defineProperty(
93
130
  "endBlock",
94
- {enumerable: true, value: chainConfig.endBlock},
131
+ {
132
+ enumerable: true,
133
+ get: () => {
134
+ // Persistence may store endBlock=None (eg the test indexer's
135
+ // auto-exit mode where the user didn't specify an endBlock).
136
+ // Only override the config when persistence has an explicit value.
137
+ switch getInitialChainState(~chainId=chainConfig.id) {
138
+ | Some({endBlock: Some(_) as eb}) => eb
139
+ | _ => chainConfig.endBlock
140
+ }
141
+ },
142
+ },
95
143
  )
96
144
  ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: chainConfig.name})
97
145
  ->Utils.Object.defineProperty(
@@ -100,12 +148,20 @@ let getGlobalIndexer = (~config: Config.t): 'indexer => {
100
148
  enumerable: true,
101
149
  get: () => {
102
150
  switch globalGsManagerRef.contents {
103
- | None => false
104
151
  | Some(gsManager) =>
105
152
  let state = gsManager->GlobalStateManager.getState
106
153
  let chain = ChainMap.Chain.makeUnsafe(~chainId=chainConfig.id)
107
154
  let chainFetcher = state.chainManager.chainFetchers->ChainMap.get(chain)
108
155
  chainFetcher->ChainFetcher.isReady
156
+ // Before the GlobalStateManager is available (eg during handler
157
+ // module load after resume), derive liveness from persistence:
158
+ // a chain is considered live when it previously caught up to head
159
+ // or endBlock (timestampCaughtUpToHeadOrEndblock is set).
160
+ | None =>
161
+ switch getInitialChainState(~chainId=chainConfig.id) {
162
+ | Some(chainState) => chainState.timestampCaughtUpToHeadOrEndblock->Option.isSome
163
+ | None => false
164
+ }
109
165
  }
110
166
  },
111
167
  },
@@ -124,24 +180,40 @@ let getGlobalIndexer = (~config: Config.t): 'indexer => {
124
180
  enumerable: true,
125
181
  get: () => {
126
182
  switch globalGsManagerRef.contents {
127
- | None => contract.addresses
128
183
  | Some(gsManager) => {
129
184
  let state = gsManager->GlobalStateManager.getState
130
185
  let chain = ChainMap.Chain.makeUnsafe(~chainId=chainConfig.id)
131
186
  let chainFetcher = state.chainManager.chainFetchers->ChainMap.get(chain)
132
- let indexingContracts = chainFetcher.fetchState.indexingContracts
187
+ let indexingAddresses = chainFetcher.fetchState.indexingAddresses
133
188
 
134
- // Collect all addresses for this contract name from indexingContracts
189
+ // Collect all addresses for this contract name from indexingAddresses
135
190
  let addresses = []
136
- let values = indexingContracts->Js.Dict.values
191
+ let values = indexingAddresses->Dict.valuesToArray
137
192
  for idx in 0 to values->Array.length - 1 {
138
- let indexingContract = values->Js.Array2.unsafe_get(idx)
193
+ let indexingContract = values->Array.getUnsafe(idx)
139
194
  if indexingContract.contractName === contract.name {
140
195
  addresses->Array.push(indexingContract.address)->ignore
141
196
  }
142
197
  }
143
198
  addresses
144
199
  }
200
+ // Before the GlobalStateManager is available (eg during handler
201
+ // module load after resume), combine static addresses from config
202
+ // with dynamic contracts persisted in the database.
203
+ | None =>
204
+ switch getInitialChainState(~chainId=chainConfig.id) {
205
+ | Some(chainState) =>
206
+ let addresses = contract.addresses->Array.copy
207
+ chainState.indexingAddresses->Array.forEach(
208
+ dc => {
209
+ if dc.contractName === contract.name {
210
+ addresses->Array.push(dc.address)->ignore
211
+ }
212
+ },
213
+ )
214
+ addresses
215
+ | None => contract.addresses
216
+ }
145
217
  }
146
218
  },
147
219
  },
@@ -165,12 +237,282 @@ let getGlobalIndexer = (~config: Config.t): 'indexer => {
165
237
  ->ignore
166
238
  }
167
239
  })
168
- indexer
169
- ->Utils.Object.definePropertyWithValue("chainIds", {enumerable: true, value: chainIds})
170
- ->ignore
171
- indexer->Utils.Object.definePropertyWithValue("chains", {enumerable: true, value: chains})->ignore
240
+ (chains, chainIds)
241
+ }
242
+
243
+ let getGlobalIndexer = (): 'indexer => {
244
+ // Parse eventIdentity config to extract contractName, eventName, and options.
245
+ // Supports two runtime formats:
246
+ // - From TypeScript: { contract: "X", event: "Y", wildcard?, where? }
247
+ // - From ReScript GADT: { event: { contract: "X", _0: "Y" }, wildcard?, where? }
248
+ let parseIdentityConfig = (identityConfig: 'a) => {
249
+ let raw =
250
+ identityConfig->(
251
+ Utils.magic: 'a => {
252
+ "contract": unknown,
253
+ "event": unknown,
254
+ "wildcard": option<bool>,
255
+ "where": option<JSON.t>,
256
+ }
257
+ )
258
+ // Detect format: if "contract" is a string, it's the TS format
259
+ let (contractName, eventName) = if typeof(raw["contract"]) === #string {
260
+ // TS format: { contract: "X", event: "Y" }
261
+ (
262
+ raw["contract"]->(Utils.magic: unknown => string),
263
+ raw["event"]->(Utils.magic: unknown => string),
264
+ )
265
+ } else {
266
+ // ReScript GADT format: { event: { contract: "X", _0: "Y" } }
267
+ let event = raw["event"]->(Utils.magic: unknown => {"contract": string, "_0": string})
268
+ (event["contract"], event["_0"])
269
+ }
270
+ let wildcard = raw["wildcard"]
271
+ let where = raw["where"]
272
+ let eventOptions: option<Internal.eventOptions<_>> = switch (wildcard, where) {
273
+ | (None, None) => None
274
+ | (wildcard, where) =>
275
+ Some({
276
+ ?wildcard,
277
+ where: ?(where->(Utils.magic: option<JSON.t> => option<_>)),
278
+ })
279
+ }
280
+ (contractName, eventName, eventOptions)
281
+ }
282
+
283
+ // onEvent: delegates to HandlerRegister.setHandler
284
+ let onEventFn = (identityConfig: 'a, handler: 'b) => {
285
+ HandlerRegister.throwIfFinishedRegistration(~methodName="onEvent")
286
+ let (contractName, eventName, eventOptions) = parseIdentityConfig(identityConfig)
287
+ HandlerRegister.setHandler(
288
+ ~contractName,
289
+ ~eventName,
290
+ handler->(
291
+ Utils.magic: 'b => Internal.genericHandler<
292
+ Internal.genericHandlerArgs<Internal.event, Internal.handlerContext>,
293
+ >
294
+ ),
295
+ ~eventOptions,
296
+ )
297
+ }
298
+
299
+ // contractRegister: delegates to HandlerRegister.setContractRegister
300
+ let contractRegisterFn = (identityConfig: 'a, handler: 'b) => {
301
+ HandlerRegister.throwIfFinishedRegistration(~methodName="contractRegister")
302
+ let (contractName, eventName, eventOptions) = parseIdentityConfig(identityConfig)
303
+ HandlerRegister.setContractRegister(
304
+ ~contractName,
305
+ ~eventName,
306
+ handler->(
307
+ Utils.magic: 'b => Internal.genericContractRegister<
308
+ Internal.genericContractRegisterArgs<Internal.event, Internal.contractRegisterContext>,
309
+ >
310
+ ),
311
+ ~eventOptions,
312
+ )
313
+ }
314
+
315
+ // Two-stage parse: first the ecosystem-specific outer schema unwraps the
316
+ // wrapper (`block.number` / `block.height` / `slot`) and surfaces the
317
+ // inner chunk as raw `unknown`; then the shared `blockRangeSchema`
318
+ // validates the `{_gte?, _lte?, _every?}` fields. Keeping the inner
319
+ // validation in one place means typos and shape mismatches surface with
320
+ // the same user-friendly error regardless of ecosystem.
321
+ let extractRange = (filter: unknown, ~name, ~ecosystem: Ecosystem.t): blockRange =>
322
+ try {
323
+ switch filter->S.parseOrThrow(ecosystem.onBlockFilterSchema) {
324
+ | None => defaultBlockRange
325
+ | Some(inner) => inner->S.parseOrThrow(blockRangeSchema)
326
+ }
327
+ } catch {
328
+ | S.Raised(exn) =>
329
+ JsError.throwWithMessage(
330
+ `\`indexer.${ecosystem.onBlockMethodName}("${name}")\` \`where\` returned an invalid filter: ${exn
331
+ ->Utils.prettifyExn
332
+ ->(Utils.magic: exn => string)}`,
333
+ )
334
+ }
335
+
336
+ // `where` is evaluated once per configured chain at registration time.
337
+ // Decoded ranges/stride feed directly into `HandlerRegister.registerOnBlock`
338
+ // so the fetcher's `(blockNumber - handlerStartBlock) % interval === 0`
339
+ // math at `FetchState.res:619` stays untouched.
340
+ let onBlockFn = (rawOptions: 'a, handler: 'b) => {
341
+ HandlerRegister.throwIfFinishedRegistration(~methodName="onBlock")
342
+ let config = Config.loadWithoutRegistrations()
343
+ let ecosystem = config.ecosystem
344
+ let raw =
345
+ rawOptions->(
346
+ Utils.magic: 'a => {
347
+ "name": string,
348
+ "where": option<Envio.onBlockWhereArgs<unknown> => unknown>,
349
+ }
350
+ )
351
+ let typedHandler = handler->(Utils.magic: 'b => Internal.onBlockArgs => promise<unit>)
352
+ let (chains, _) = buildChainsObject(~config)
353
+ let chainsDict = chains->(Utils.magic: {..} => dict<unknown>)
354
+ let name = raw["name"]
355
+ let logger = Logging.createChild(~params={"onBlock": name})
356
+
357
+ // `where` must be a function (unlike onEvent, which also accepts a static
358
+ // value). A static value would have to be evaluated against every chain
359
+ // independently, which has no useful semantic for block handlers.
360
+ // Normalize undefined/null to None up front so the per-chain loop below
361
+ // can't accidentally call `null` as a predicate (ReScript treats a JS
362
+ // `null` value as `Some(null)` when the field is typed as option).
363
+ let where = switch raw["where"]->(Utils.magic: option<'a> => unknown) {
364
+ | w if w === %raw(`undefined`) || w === %raw(`null`) => None
365
+ | w if typeof(w) === #function => Some(raw["where"]->Option.getUnsafe)
366
+ | w =>
367
+ JsError.throwWithMessage(
368
+ `\`indexer.${ecosystem.onBlockMethodName}("${name}")\` expected \`where\` to be a function or omitted, but got ${(typeof(
369
+ w,
370
+ ) :> string)}.`,
371
+ )
372
+ }
373
+
374
+ let matchedAny = ref(false)
375
+
376
+ config.chainMap
377
+ ->ChainMap.values
378
+ ->Array.forEach(chainConfig => {
379
+ let chainId = chainConfig.id
380
+ let chainObj = chainsDict->Dict.getUnsafe(chainId->Int.toString)
381
+
382
+ // Predicate returns `true` → match with no filter; `false` → skip;
383
+ // any plain object → structured filter. `undefined`/`null` returns
384
+ // are rejected — the TS type excludes `void`, so a missing return is
385
+ // a user bug we surface early rather than silently match-all.
386
+ let result = switch where {
387
+ | None => %raw(`true`)
388
+ | Some(predicate) => predicate({chain: chainObj})
389
+ }
390
+
391
+ let (shouldRegister, range) = if result === %raw(`true`) {
392
+ (true, defaultBlockRange)
393
+ } else if result === %raw(`false`) {
394
+ (false, defaultBlockRange)
395
+ } else if typeof(result) === #object && !(result->Array.isArray) && result !== %raw(`null`) {
396
+ (true, extractRange(result, ~name, ~ecosystem))
397
+ } else {
398
+ // Reject numbers, strings, functions, arrays, undefined, null —
399
+ // anything that isn't bool or a plain object would silently
400
+ // misregister.
401
+ JsError.throwWithMessage(
402
+ `\`indexer.${ecosystem.onBlockMethodName}("${name}")\` \`where\` predicate returned an invalid value of type ${(typeof(
403
+ result,
404
+ ) :> string)}. Expected boolean or a filter object.`,
405
+ )
406
+ }
407
+
408
+ if shouldRegister {
409
+ matchedAny := true
410
+ HandlerRegister.registerOnBlock(
411
+ ~name,
412
+ ~chainId,
413
+ ~interval=range._every,
414
+ ~startBlock=range._gte,
415
+ ~endBlock=range._lte,
416
+ ~handler=typedHandler,
417
+ )
418
+ }
419
+ })
420
+
421
+ // Catches misconfigured `where` predicates that return `false` for every
422
+ // configured chain — the handler would otherwise never fire with no hint.
423
+ // Includes the ecosystem-specific method name so SVM users see "onSlot"
424
+ // and don't get confused looking for a "Block handler" they never wrote.
425
+ if !matchedAny.contents {
426
+ logger->Logging.childWarn(
427
+ `\`indexer.${ecosystem.onBlockMethodName}\` matched 0 chains. Check the \`where\` predicate.`,
428
+ )
429
+ }
430
+ }
431
+
432
+ // Ecosystem-specific surface: EVM/Fuel expose event + block handlers; SVM
433
+ // exposes slot handlers only. The TS `.d.ts` already models this separation
434
+ // — the Proxy mirrors it at runtime so `Object.keys(indexer)` reflects the
435
+ // actually-callable methods and typos surface via the unknown-prop throw
436
+ // rather than silent `undefined` returns.
437
+ //
438
+ // `Api.res` calls `getGlobalIndexer()` at envio-package load, so the keys
439
+ // array is memoized lazily: an early `createEffect` / `S` import that
440
+ // never touches the indexer must not trigger a config parse. The memo is
441
+ // safe because `Config.loadWithoutRegistrations` is itself pure.
442
+ let keysMemo: ref<option<array<string>>> = ref(None)
443
+ let getKeys = () =>
444
+ switch keysMemo.contents {
445
+ | Some(k) => k
446
+ | None => {
447
+ let keys = switch Config.loadWithoutRegistrations().ecosystem.name {
448
+ | Evm | Fuel => [
449
+ "name",
450
+ "description",
451
+ "chainIds",
452
+ "chains",
453
+ "onEvent",
454
+ "contractRegister",
455
+ "onBlock",
456
+ ]
457
+ | Svm => ["name", "description", "chainIds", "chains", "onSlot"]
458
+ }
459
+ keysMemo := Some(keys)
460
+ keys
461
+ }
462
+ }
463
+
464
+ let get = (~prop: string) =>
465
+ switch prop {
466
+ | "name" => Config.loadWithoutRegistrations().name->(Utils.magic: string => unknown)
467
+ | "description" =>
468
+ Config.loadWithoutRegistrations().description->(Utils.magic: option<string> => unknown)
469
+ | "chainIds" => {
470
+ let (_, chainIds) = buildChainsObject(~config=Config.loadWithoutRegistrations())
471
+ chainIds->(Utils.magic: array<int> => unknown)
472
+ }
473
+ | "chains" => {
474
+ let (chains, _) = buildChainsObject(~config=Config.loadWithoutRegistrations())
475
+ chains->(Utils.magic: {..} => unknown)
476
+ }
477
+ | "onEvent" => onEventFn->Utils.magic
478
+ | "contractRegister" => contractRegisterFn->Utils.magic
479
+ | "onBlock" | "onSlot" => onBlockFn->Utils.magic
480
+ | _ =>
481
+ JsError.throwWithMessage(
482
+ `Field \`${prop}\` does not exist on \`indexer\`. Available fields: ${getKeys()->Array.join(
483
+ ", ",
484
+ )}.`,
485
+ )
486
+ }
487
+
488
+ let traps: Utils.Proxy.traps<{..}> = {
489
+ // Engine internals (`Symbol.toStringTag`, `Symbol.toPrimitive`, inspect
490
+ // hooks, etc.) read symbol-keyed properties — fall through to the
491
+ // underlying null-proto target so stringification / inspection of the
492
+ // indexer value stays well-behaved instead of throwing.
493
+ get: (~target, ~prop) =>
494
+ if typeof(prop) === #string {
495
+ get(~prop=prop->(Utils.magic: unknown => string))
496
+ } else {
497
+ target->(Utils.magic: {..} => dict<unknown>)->Dict.getUnsafe(prop->Utils.magic)
498
+ },
499
+ ownKeys: (~target as _) => getKeys(),
500
+ getOwnPropertyDescriptor: (~target as _, ~prop) =>
501
+ if (
502
+ typeof(prop) === #string &&
503
+ getKeys()->Array.includes(prop->(Utils.magic: unknown => string))
504
+ ) {
505
+ Some({
506
+ value: get(~prop=prop->(Utils.magic: unknown => string)),
507
+ enumerable: true,
508
+ configurable: true,
509
+ })
510
+ } else {
511
+ None
512
+ },
513
+ }
172
514
 
173
- indexer->(Utils.magic: 'a => 'indexer)
515
+ Utils.Proxy.make(Utils.Object.createNullObject(), traps)->(Utils.magic: {..} => 'indexer)
174
516
  }
175
517
 
176
518
  let startServer = (~getState, ~ctx: Ctx.t, ~isDevelopmentMode: bool) => {
@@ -179,7 +521,7 @@ let startServer = (~getState, ~ctx: Ctx.t, ~isDevelopmentMode: bool) => {
179
521
  let app = make()
180
522
 
181
523
  let consoleCorsMiddleware = (req, res, next) => {
182
- switch req.headers->Js.Dict.get("origin") {
524
+ switch req.headers->Dict.get("origin") {
183
525
  | Some(origin) if origin === Env.prodEnvioAppUrl || origin === Env.envioAppUrl =>
184
526
  res->setHeader("Access-Control-Allow-Origin", origin)
185
527
  | _ => ()
@@ -218,7 +560,7 @@ let startServer = (~getState, ~ctx: Ctx.t, ~isDevelopmentMode: bool) => {
218
560
  if isDevelopmentMode {
219
561
  (ctx.persistence->Persistence.getInitializedStorageOrThrow).dumpEffectCache()
220
562
  ->Promise.thenResolve(_ => res->json(Boolean(true)))
221
- ->Promise.done
563
+ ->Promise.ignore
222
564
  } else {
223
565
  res->json(Boolean(false))
224
566
  }
@@ -245,7 +587,7 @@ let startServer = (~getState, ~ctx: Ctx.t, ~isDevelopmentMode: bool) => {
245
587
 
246
588
  let server = app->listen(Env.serverPort)
247
589
  server->Express.onError(err => {
248
- let code = (err->(Utils.magic: Js.Exn.t => {..}))["code"]
590
+ let code = (err->(Utils.magic: JsExn.t => {..}))["code"]
249
591
  if code === "EADDRINUSE" {
250
592
  Logging.error(
251
593
  `Port ${Env.serverPort->Int.toString} is already in use. To fix this either:` ++
@@ -266,24 +608,63 @@ type process
266
608
 
267
609
  type mainArgs = Yargs.parsedArgs<args>
268
610
 
611
+ type migrateOpts = {reset: bool, persistedState: JSON.t}
612
+
613
+ let migrate = async (~reset, ~persistedState) => {
614
+ let config = Config.loadWithoutRegistrations()
615
+ let persistence = PgStorage.makePersistenceFromConfig(~config)
616
+ await persistence->Persistence.init(~reset, ~chainConfigs=config.chainMap->ChainMap.values)
617
+ await Core.upsertPersistedState(persistedState->JSON.stringify)
618
+ await persistence.storage.close()
619
+ }
620
+
621
+ let dropSchema = async () => {
622
+ let config = Config.loadWithoutRegistrations()
623
+ let persistence = PgStorage.makePersistenceFromConfig(~config)
624
+ await persistence.storage.reset()
625
+ await persistence.storage.close()
626
+ }
627
+
269
628
  let start = async (
270
- ~makeGeneratedConfig: unit => Config.t,
271
- ~persistence: Persistence.t,
629
+ ~persistence: option<Persistence.t>=?,
630
+ ~migrate: option<migrateOpts>=?,
272
631
  ~isTest=false,
273
632
  ~exitAfterFirstEventBlock=false,
274
633
  ~patchConfig: option<(Config.t, HandlerRegister.registrations) => Config.t>=?,
275
634
  ) => {
276
635
  let mainArgs: mainArgs = process->argv->Yargs.hideBin->Yargs.yargs->Yargs.argv
277
636
  let shouldUseTui = !isTest && !(mainArgs.tuiOff->Belt.Option.getWithDefault(Env.tuiOffEnvVar))
278
- // The most simple check to verify whether we are running in development mode
279
- // and prevent exposing the console to public, when creating a real deployment.
280
- // Note: isTest overrides isDevelopmentMode to ensure proper process exit in test mode.
281
- let isDevelopmentMode = !isTest && Env.Db.password === "testing"
282
-
283
- // Register all handlers first, then get the config with registrations
284
- let configWithoutRegistrations = makeGeneratedConfig()
285
- let registrations = await HandlerLoader.registerAllHandlers(~config=configWithoutRegistrations)
286
- let config = makeGeneratedConfig()
637
+ // isDevelopmentMode controls whether the indexer stays alive after all
638
+ // chains finish (keepProcessAlive) and whether the console API is exposed.
639
+ // Set by `envio dev` via the ENVIO_DEV_MODE env var; `envio start` leaves
640
+ // it unset so the process exits cleanly when indexing completes.
641
+ let isDevelopmentMode = !isTest && Envio.isDevMode()
642
+
643
+ // Initialize persistence first so the exported indexer value contains state from the database
644
+ // when handler files are loaded (they may access the indexer at module top level).
645
+ // `migrate`, when provided, folds the DB setup into the same `init()` call.
646
+ let configWithoutRegistrations = Config.loadWithoutRegistrations()
647
+ let persistence = switch persistence {
648
+ | Some(p) => p
649
+ | None => PgStorage.makePersistenceFromConfig(~config=configWithoutRegistrations)
650
+ }
651
+ globalPersistenceRef := Some(persistence)
652
+ let reset = migrate->Option.map(m => m.reset)->Option.getOr(false)
653
+ await persistence->Persistence.init(
654
+ ~reset,
655
+ ~chainConfigs=configWithoutRegistrations.chainMap->ChainMap.values,
656
+ )
657
+ switch migrate {
658
+ | Some({persistedState}) => await Core.upsertPersistedState(persistedState->JSON.stringify)
659
+ | None => ()
660
+ }
661
+
662
+ // `Config.loadWithoutRegistrations` never sees registration state; handler,
663
+ // contractRegister, and eventFilters are baked into each event config only
664
+ // by the returned value here.
665
+ let (config, registrations) = await HandlerLoader.registerAllHandlers(
666
+ ~config=configWithoutRegistrations,
667
+ )
287
668
  let config = if isTest {
288
669
  {...config, shouldRollbackOnReorg: false}
289
670
  } else {
@@ -322,11 +703,11 @@ let start = async (
322
703
  )
323
704
  let knownHeight =
324
705
  cf->ChainFetcher.hasProcessedToEndblock
325
- ? cf.fetchState.endBlock->Option.getWithDefault(cf.fetchState.knownHeight)
706
+ ? cf.fetchState.endBlock->Option.getOr(cf.fetchState.knownHeight)
326
707
  : cf.fetchState.knownHeight
327
708
 
328
709
  {
329
- chainId: cf.chainConfig.id->Js.Int.toFloat,
710
+ chainId: cf.chainConfig.id->Int.toFloat,
330
711
  poweredByHyperSync: (
331
712
  cf.sourceManager->SourceManager.getActiveSource
332
713
  ).poweredByHyperSync,
@@ -360,9 +741,7 @@ let start = async (
360
741
  )
361
742
  }
362
743
 
363
- await ctx.persistence->Persistence.init(~chainConfigs=ctx.config.chainMap->ChainMap.values)
364
-
365
- let chainManager = await ChainManager.makeFromDbState(
744
+ let chainManager = ChainManager.makeFromDbState(
366
745
  ~initialState=ctx.persistence->Persistence.getInitializedState,
367
746
  ~config=ctx.config,
368
747
  ~registrations=ctx.registrations,