envio 3.0.0-alpha.2 → 3.0.0-alpha.20

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 (175) hide show
  1. package/README.md +164 -30
  2. package/bin.mjs +49 -0
  3. package/evm.schema.json +79 -169
  4. package/fuel.schema.json +50 -21
  5. package/index.d.ts +497 -1
  6. package/index.js +4 -0
  7. package/package.json +42 -31
  8. package/rescript.json +4 -1
  9. package/src/Batch.res +11 -8
  10. package/src/Batch.res.mjs +11 -9
  11. package/src/ChainFetcher.res +531 -0
  12. package/src/ChainFetcher.res.mjs +339 -0
  13. package/src/ChainManager.res +190 -0
  14. package/src/ChainManager.res.mjs +166 -0
  15. package/src/Change.res +3 -3
  16. package/src/Config.gen.ts +19 -0
  17. package/src/Config.res +737 -22
  18. package/src/Config.res.mjs +703 -26
  19. package/src/{Indexer.res → Ctx.res} +1 -1
  20. package/src/Ecosystem.res +9 -124
  21. package/src/Ecosystem.res.mjs +19 -160
  22. package/src/Env.res +30 -74
  23. package/src/Env.res.mjs +25 -87
  24. package/src/Envio.gen.ts +3 -1
  25. package/src/Envio.res +20 -9
  26. package/src/EventProcessing.res +469 -0
  27. package/src/EventProcessing.res.mjs +337 -0
  28. package/src/EvmTypes.gen.ts +6 -0
  29. package/src/EvmTypes.res +1 -0
  30. package/src/FetchState.res +1256 -639
  31. package/src/FetchState.res.mjs +1135 -612
  32. package/src/GlobalState.res +1190 -0
  33. package/src/GlobalState.res.mjs +1183 -0
  34. package/src/GlobalStateManager.res +68 -0
  35. package/src/GlobalStateManager.res.mjs +75 -0
  36. package/src/GlobalStateManager.resi +7 -0
  37. package/src/HandlerLoader.res +89 -0
  38. package/src/HandlerLoader.res.mjs +79 -0
  39. package/src/HandlerRegister.res +357 -0
  40. package/src/HandlerRegister.res.mjs +299 -0
  41. package/src/{EventRegister.resi → HandlerRegister.resi} +13 -13
  42. package/src/Hasura.res +111 -175
  43. package/src/Hasura.res.mjs +88 -150
  44. package/src/InMemoryStore.res +1 -1
  45. package/src/InMemoryStore.res.mjs +3 -3
  46. package/src/InMemoryTable.res +1 -1
  47. package/src/InMemoryTable.res.mjs +1 -1
  48. package/src/Internal.gen.ts +4 -0
  49. package/src/Internal.res +230 -12
  50. package/src/Internal.res.mjs +115 -1
  51. package/src/LoadLayer.res +444 -0
  52. package/src/LoadLayer.res.mjs +296 -0
  53. package/src/LoadLayer.resi +32 -0
  54. package/src/LogSelection.res +33 -27
  55. package/src/LogSelection.res.mjs +6 -0
  56. package/src/Logging.res +21 -7
  57. package/src/Logging.res.mjs +16 -8
  58. package/src/Main.res +377 -0
  59. package/src/Main.res.mjs +339 -0
  60. package/src/Persistence.res +7 -21
  61. package/src/Persistence.res.mjs +3 -3
  62. package/src/PgStorage.gen.ts +10 -0
  63. package/src/PgStorage.res +116 -69
  64. package/src/PgStorage.res.d.mts +5 -0
  65. package/src/PgStorage.res.mjs +93 -50
  66. package/src/Prometheus.res +294 -224
  67. package/src/Prometheus.res.mjs +353 -340
  68. package/src/ReorgDetection.res +6 -10
  69. package/src/ReorgDetection.res.mjs +6 -6
  70. package/src/SafeCheckpointTracking.res +4 -4
  71. package/src/SafeCheckpointTracking.res.mjs +2 -2
  72. package/src/Sink.res +4 -2
  73. package/src/Sink.res.mjs +2 -1
  74. package/src/TableIndices.res +0 -1
  75. package/src/TestIndexer.res +692 -0
  76. package/src/TestIndexer.res.mjs +527 -0
  77. package/src/TestIndexerProxyStorage.res +205 -0
  78. package/src/TestIndexerProxyStorage.res.mjs +151 -0
  79. package/src/TopicFilter.res +1 -1
  80. package/src/Types.ts +1 -1
  81. package/src/UserContext.res +424 -0
  82. package/src/UserContext.res.mjs +279 -0
  83. package/src/Utils.res +97 -26
  84. package/src/Utils.res.mjs +91 -44
  85. package/src/bindings/BigInt.res +10 -0
  86. package/src/bindings/BigInt.res.mjs +15 -0
  87. package/src/bindings/ClickHouse.res +120 -23
  88. package/src/bindings/ClickHouse.res.mjs +118 -28
  89. package/src/bindings/DateFns.res +74 -0
  90. package/src/bindings/DateFns.res.mjs +22 -0
  91. package/src/bindings/EventSource.res +8 -1
  92. package/src/bindings/EventSource.res.mjs +8 -1
  93. package/src/bindings/Express.res +1 -0
  94. package/src/bindings/Hrtime.res +14 -1
  95. package/src/bindings/Hrtime.res.mjs +22 -2
  96. package/src/bindings/Hrtime.resi +4 -0
  97. package/src/bindings/Lodash.res +0 -1
  98. package/src/bindings/NodeJs.res +49 -3
  99. package/src/bindings/NodeJs.res.mjs +11 -3
  100. package/src/bindings/Pino.res +24 -10
  101. package/src/bindings/Pino.res.mjs +14 -8
  102. package/src/bindings/Postgres.gen.ts +8 -0
  103. package/src/bindings/Postgres.res +5 -1
  104. package/src/bindings/Postgres.res.d.mts +5 -0
  105. package/src/bindings/PromClient.res +0 -10
  106. package/src/bindings/PromClient.res.mjs +0 -3
  107. package/src/bindings/Vitest.res +142 -0
  108. package/src/bindings/Vitest.res.mjs +9 -0
  109. package/src/bindings/WebSocket.res +27 -0
  110. package/src/bindings/WebSocket.res.mjs +2 -0
  111. package/src/bindings/Yargs.res +8 -0
  112. package/src/bindings/Yargs.res.mjs +2 -0
  113. package/src/db/EntityHistory.res +7 -7
  114. package/src/db/EntityHistory.res.mjs +9 -9
  115. package/src/db/InternalTable.res +59 -111
  116. package/src/db/InternalTable.res.mjs +73 -104
  117. package/src/db/Table.res +27 -8
  118. package/src/db/Table.res.mjs +25 -14
  119. package/src/sources/Evm.res +84 -0
  120. package/src/sources/Evm.res.mjs +105 -0
  121. package/src/sources/EvmChain.res +94 -0
  122. package/src/sources/EvmChain.res.mjs +60 -0
  123. package/src/sources/Fuel.res +19 -34
  124. package/src/sources/Fuel.res.mjs +34 -16
  125. package/src/sources/FuelSDK.res +38 -0
  126. package/src/sources/FuelSDK.res.mjs +29 -0
  127. package/src/sources/HyperFuel.res +2 -2
  128. package/src/sources/HyperFuel.resi +1 -1
  129. package/src/sources/HyperFuelClient.res +2 -2
  130. package/src/sources/HyperFuelSource.res +33 -13
  131. package/src/sources/HyperFuelSource.res.mjs +24 -16
  132. package/src/sources/HyperSync.res +36 -6
  133. package/src/sources/HyperSync.res.mjs +9 -7
  134. package/src/sources/HyperSync.resi +4 -0
  135. package/src/sources/HyperSyncClient.res +1 -1
  136. package/src/sources/HyperSyncHeightStream.res +47 -116
  137. package/src/sources/HyperSyncHeightStream.res.mjs +46 -73
  138. package/src/sources/HyperSyncSource.res +118 -139
  139. package/src/sources/HyperSyncSource.res.mjs +104 -121
  140. package/src/sources/Rpc.res +86 -14
  141. package/src/sources/Rpc.res.mjs +101 -9
  142. package/src/sources/RpcSource.res +621 -364
  143. package/src/sources/RpcSource.res.mjs +843 -410
  144. package/src/sources/RpcWebSocketHeightStream.res +181 -0
  145. package/src/sources/RpcWebSocketHeightStream.res.mjs +196 -0
  146. package/src/sources/Source.res +7 -5
  147. package/src/sources/SourceManager.res +325 -225
  148. package/src/sources/SourceManager.res.mjs +314 -171
  149. package/src/sources/SourceManager.resi +17 -6
  150. package/src/sources/Svm.res +81 -0
  151. package/src/sources/Svm.res.mjs +90 -0
  152. package/src/tui/Tui.res +247 -0
  153. package/src/tui/Tui.res.mjs +337 -0
  154. package/src/tui/bindings/Ink.res +371 -0
  155. package/src/tui/bindings/Ink.res.mjs +72 -0
  156. package/src/tui/bindings/Style.res +123 -0
  157. package/src/tui/bindings/Style.res.mjs +2 -0
  158. package/src/tui/components/BufferedProgressBar.res +40 -0
  159. package/src/tui/components/BufferedProgressBar.res.mjs +57 -0
  160. package/src/tui/components/CustomHooks.res +122 -0
  161. package/src/tui/components/CustomHooks.res.mjs +179 -0
  162. package/src/tui/components/Messages.res +41 -0
  163. package/src/tui/components/Messages.res.mjs +75 -0
  164. package/src/tui/components/SyncETA.res +174 -0
  165. package/src/tui/components/SyncETA.res.mjs +263 -0
  166. package/src/tui/components/TuiData.res +47 -0
  167. package/src/tui/components/TuiData.res.mjs +34 -0
  168. package/svm.schema.json +112 -0
  169. package/bin.js +0 -48
  170. package/src/EventRegister.res +0 -241
  171. package/src/EventRegister.res.mjs +0 -240
  172. package/src/bindings/Ethers.gen.ts +0 -14
  173. package/src/bindings/Ethers.res +0 -204
  174. package/src/bindings/Ethers.res.mjs +0 -130
  175. /package/src/{Indexer.res.mjs → Ctx.res.mjs} +0 -0
@@ -0,0 +1,692 @@
1
+ open Belt
2
+
3
+ type chainConfig = {
4
+ startBlock: int,
5
+ endBlock: int,
6
+ }
7
+
8
+ type processResult = {changes: array<unknown>}
9
+
10
+ type t<'processConfig> = {process: 'processConfig => promise<processResult>}
11
+
12
+ type entityChange = {
13
+ sets: array<unknown>,
14
+ deleted: array<string>,
15
+ }
16
+
17
+ type testIndexerState = {
18
+ mutable processInProgress: bool,
19
+ progressBlockByChain: dict<int>,
20
+ // Store decoded entities (not JSON) for proper comparison operations
21
+ entities: dict<dict<Internal.entity>>,
22
+ entityConfigs: dict<Internal.entityConfig>,
23
+ mutable processChanges: array<unknown>,
24
+ }
25
+
26
+ // Cast Internal.entity back to DynamicContractRegistry.t
27
+ external castFromDcRegistry: Internal.entity => InternalTable.DynamicContractRegistry.t =
28
+ "%identity"
29
+
30
+ // Convert DynamicContractRegistry.t to Internal.indexingContract
31
+ let toIndexingContract = (
32
+ dc: InternalTable.DynamicContractRegistry.t,
33
+ ): Internal.indexingContract => {
34
+ address: dc.contractAddress,
35
+ contractName: dc.contractName,
36
+ startBlock: dc.registeringEventBlockNumber,
37
+ registrationBlock: Some(dc.registeringEventBlockNumber),
38
+ }
39
+
40
+ let handleLoadByIds = (
41
+ state: testIndexerState,
42
+ ~tableName: string,
43
+ ~ids: array<string>,
44
+ ): Js.Json.t => {
45
+ let entityDict = state.entities->Js.Dict.get(tableName)->Option.getWithDefault(Js.Dict.empty())
46
+ let entityConfig = state.entityConfigs->Js.Dict.unsafeGet(tableName)
47
+ let results = []
48
+ ids->Array.forEach(id => {
49
+ switch entityDict->Js.Dict.get(id) {
50
+ | Some(entity) =>
51
+ // Serialize entity back to JSON for worker thread
52
+ let jsonEntity = entity->S.reverseConvertToJsonOrThrow(entityConfig.schema)
53
+ results->Array.push(jsonEntity)->ignore
54
+ | None => ()
55
+ }
56
+ })
57
+ results->Js.Json.array
58
+ }
59
+
60
+ let handleLoadByField = (
61
+ state: testIndexerState,
62
+ ~tableName: string,
63
+ ~fieldName: string,
64
+ ~fieldValue: Js.Json.t,
65
+ ~operator: Persistence.operator,
66
+ ): Js.Json.t => {
67
+ let entityDict = state.entities->Js.Dict.get(tableName)->Option.getWithDefault(Js.Dict.empty())
68
+ let entityConfig = state.entityConfigs->Js.Dict.unsafeGet(tableName)
69
+ let results = []
70
+
71
+ // Get the field schema from the entity's table to properly parse the JSON field value
72
+ let fieldSchema = switch entityConfig.table->Table.getFieldByName(fieldName) {
73
+ | Some(Table.Field({fieldSchema})) => fieldSchema
74
+ | _ => Js.Exn.raiseError(`Field ${fieldName} not found in entity ${tableName}`)
75
+ }
76
+
77
+ // Parse JSON field value to typed value using the field's schema
78
+ let parsedFieldValue = fieldValue->S.convertOrThrow(fieldSchema)->TableIndices.FieldValue.castFrom
79
+
80
+ // Compare using TableIndices.FieldValue logic (same approach as InMemoryTable)
81
+ // This properly handles bigint and BigDecimal comparisons
82
+ entityDict
83
+ ->Js.Dict.values
84
+ ->Array.forEach(entity => {
85
+ // Cast entity to dict of field values (same approach as InMemoryTable)
86
+ let entityAsDict = entity->(Utils.magic: Internal.entity => dict<TableIndices.FieldValue.t>)
87
+ switch entityAsDict->Js.Dict.get(fieldName) {
88
+ | Some(entityFieldValue) => {
89
+ let matches = switch operator {
90
+ | #"=" => entityFieldValue->TableIndices.FieldValue.eq(parsedFieldValue)
91
+ | #">" => entityFieldValue->TableIndices.FieldValue.gt(parsedFieldValue)
92
+ | #"<" => entityFieldValue->TableIndices.FieldValue.lt(parsedFieldValue)
93
+ }
94
+ if matches {
95
+ // Serialize entity back to JSON for worker thread
96
+ let jsonEntity = entity->S.reverseConvertToJsonOrThrow(entityConfig.schema)
97
+ results->Array.push(jsonEntity)->ignore
98
+ }
99
+ }
100
+ | None => ()
101
+ }
102
+ })
103
+
104
+ results->Js.Json.array
105
+ }
106
+
107
+ let handleWriteBatch = (
108
+ state: testIndexerState,
109
+ ~updatedEntities: array<TestIndexerProxyStorage.serializableUpdatedEntity>,
110
+ ~checkpointIds: array<bigint>,
111
+ ~checkpointChainIds: array<int>,
112
+ ~checkpointBlockNumbers: array<int>,
113
+ ~checkpointBlockHashes: array<Js.Null.t<string>>,
114
+ ~checkpointEventsProcessed: array<int>,
115
+ ): unit => {
116
+ // Group entity changes by checkpointId
117
+ // checkpointId -> entityName -> entityChange
118
+ let changesByCheckpoint: dict<dict<entityChange>> = Js.Dict.empty()
119
+
120
+ updatedEntities->Array.forEach(({entityName, updates}) => {
121
+ let entityDict = switch state.entities->Js.Dict.get(entityName) {
122
+ | Some(dict) => dict
123
+ | None =>
124
+ let dict = Js.Dict.empty()
125
+ state.entities->Js.Dict.set(entityName, dict)
126
+ dict
127
+ }
128
+ let entityConfig = state.entityConfigs->Js.Dict.unsafeGet(entityName)
129
+
130
+ updates->Array.forEach(update => {
131
+ // Helper to process a single change (Set or Delete)
132
+ let processChange = (change: TestIndexerProxyStorage.serializableChange) => {
133
+ switch change {
134
+ | Set({entityId, entity, checkpointId}) =>
135
+ // Parse entity immediately to store decoded values for proper comparisons
136
+ // (bigint/BigDecimal need actual values, not JSON strings)
137
+ let parsedEntity = entity->S.parseOrThrow(entityConfig.schema)
138
+
139
+ // Update entities dict with parsed entity for load operations
140
+ entityDict->Js.Dict.set(entityId, parsedEntity)
141
+
142
+ // Track change by checkpoint
143
+ let checkpointKey = checkpointId->BigInt.toString
144
+ let entityChanges = switch changesByCheckpoint->Js.Dict.get(checkpointKey) {
145
+ | Some(changes) => changes
146
+ | None =>
147
+ let changes = Js.Dict.empty()
148
+ changesByCheckpoint->Js.Dict.set(checkpointKey, changes)
149
+ changes
150
+ }
151
+ let entityChange = switch entityChanges->Js.Dict.get(entityName) {
152
+ | Some(change) => change
153
+ | None =>
154
+ let change = {sets: [], deleted: []}
155
+ entityChanges->Js.Dict.set(entityName, change)
156
+ change
157
+ }
158
+ entityChange.sets->Array.push(parsedEntity->Utils.magic)->ignore
159
+
160
+ | Delete({entityId, checkpointId}) =>
161
+ // Update entities dict for load operations
162
+ Js.Dict.unsafeDeleteKey(entityDict->Obj.magic, entityId)
163
+
164
+ // Track change by checkpoint
165
+ let checkpointKey = checkpointId->BigInt.toString
166
+ let entityChanges = switch changesByCheckpoint->Js.Dict.get(checkpointKey) {
167
+ | Some(changes) => changes
168
+ | None =>
169
+ let changes = Js.Dict.empty()
170
+ changesByCheckpoint->Js.Dict.set(checkpointKey, changes)
171
+ changes
172
+ }
173
+ let entityChange = switch entityChanges->Js.Dict.get(entityName) {
174
+ | Some(change) => change
175
+ | None =>
176
+ let change = {sets: [], deleted: []}
177
+ entityChanges->Js.Dict.set(entityName, change)
178
+ change
179
+ }
180
+ entityChange.deleted->Array.push(entityId)->ignore
181
+ }
182
+ }
183
+
184
+ // Iterate over all history entries (mirroring PgStorage.res behavior)
185
+ update.history->Array.forEach(processChange)
186
+
187
+ // Also include latestChange if history is empty (fallback for backwards compatibility)
188
+ if update.history->Array.length === 0 {
189
+ processChange(update.latestChange)
190
+ }
191
+ })
192
+ })
193
+
194
+ // Build combined checkpoint + entity changes objects
195
+ for i in 0 to checkpointIds->Array.length - 1 {
196
+ let checkpointId = checkpointIds->Array.getUnsafe(i)
197
+ let change: dict<unknown> = Js.Dict.empty()
198
+
199
+ // Add checkpoint metadata
200
+ change->Js.Dict.set("block", checkpointBlockNumbers->Array.getUnsafe(i)->Utils.magic)
201
+ switch checkpointBlockHashes->Array.getUnsafe(i)->Js.Null.toOption {
202
+ | Some(hash) => change->Js.Dict.set("blockHash", hash->Utils.magic)
203
+ | None => () // Skip blockHash when null
204
+ }
205
+ change->Js.Dict.set("chainId", checkpointChainIds->Array.getUnsafe(i)->Utils.magic)
206
+ change->Js.Dict.set(
207
+ "eventsProcessed",
208
+ checkpointEventsProcessed->Array.getUnsafe(i)->Utils.magic,
209
+ )
210
+
211
+ // Add entity changes for this checkpoint
212
+ let checkpointKey = checkpointId->BigInt.toString
213
+ switch changesByCheckpoint->Js.Dict.get(checkpointKey) {
214
+ | Some(entityChanges) =>
215
+ entityChanges
216
+ ->Js.Dict.entries
217
+ ->Array.forEach(((entityName, {sets, deleted})) => {
218
+ // Transform dynamic_contract_registry to addresses with simplified structure
219
+ if entityName === InternalTable.DynamicContractRegistry.name {
220
+ let entityObj: dict<unknown> = Js.Dict.empty()
221
+ if sets->Array.length > 0 {
222
+ // Transform sets to simplified {address, contract} objects
223
+ let simplifiedSets = sets->Array.map(entity => {
224
+ let dc = entity->Utils.magic->castFromDcRegistry
225
+ {"address": dc.contractAddress, "contract": dc.contractName}
226
+ })
227
+ entityObj->Js.Dict.set("sets", simplifiedSets->Utils.magic)
228
+ }
229
+ // Note: deleted is not relevant for addresses since we use address string directly
230
+ change->Js.Dict.set("addresses", entityObj->Utils.magic)
231
+ } else {
232
+ let entityObj: dict<unknown> = Js.Dict.empty()
233
+ if sets->Array.length > 0 {
234
+ entityObj->Js.Dict.set("sets", sets->Utils.magic)
235
+ }
236
+ if deleted->Array.length > 0 {
237
+ entityObj->Js.Dict.set("deleted", deleted->Utils.magic)
238
+ }
239
+ change->Js.Dict.set(entityName, entityObj->Utils.magic)
240
+ }
241
+ })
242
+ | None => ()
243
+ }
244
+
245
+ state.processChanges->Array.push(change->Utils.magic)->ignore
246
+ }
247
+ }
248
+
249
+ let makeInitialState = (
250
+ ~config: Config.t,
251
+ ~processConfigChains: Js.Dict.t<chainConfig>,
252
+ ~dynamicContractsByChain: dict<array<Internal.indexingContract>>,
253
+ ): Persistence.initialState => {
254
+ let chainKeys = processConfigChains->Js.Dict.keys
255
+ let chains = chainKeys->Array.map(chainIdStr => {
256
+ let chainId = chainIdStr->Int.fromString->Option.getWithDefault(0)
257
+ let chain = ChainMap.Chain.makeUnsafe(~chainId)
258
+
259
+ if !(config.chainMap->ChainMap.has(chain)) {
260
+ Js.Exn.raiseError(`Chain ${chainIdStr} is not configured in config.yaml`)
261
+ }
262
+
263
+ let processChainConfig = processConfigChains->Js.Dict.unsafeGet(chainIdStr)
264
+ let dynamicContracts =
265
+ dynamicContractsByChain
266
+ ->Js.Dict.get(chainIdStr)
267
+ ->Option.getWithDefault([])
268
+ {
269
+ Persistence.id: chainId,
270
+ startBlock: processChainConfig.startBlock,
271
+ endBlock: Some(processChainConfig.endBlock),
272
+ sourceBlockNumber: processChainConfig.endBlock,
273
+ maxReorgDepth: 0, // No reorg support in test indexer
274
+ progressBlockNumber: -1,
275
+ numEventsProcessed: 0.,
276
+ firstEventBlockNumber: None,
277
+ timestampCaughtUpToHeadOrEndblock: None,
278
+ dynamicContracts,
279
+ }
280
+ })
281
+
282
+ {
283
+ cleanRun: true,
284
+ cache: Js.Dict.empty(),
285
+ chains,
286
+ checkpointId: InternalTable.Checkpoints.initialCheckpointId,
287
+ reorgCheckpoints: [],
288
+ }
289
+ }
290
+
291
+ let validateBlockRange = (
292
+ ~chainId: string,
293
+ ~configChain: Config.chain,
294
+ ~processChainConfig: chainConfig,
295
+ ~progressBlock: option<int>,
296
+ ) => {
297
+ // Check startBlock >= config.startBlock
298
+ if processChainConfig.startBlock < configChain.startBlock {
299
+ Js.Exn.raiseError(
300
+ `Invalid block range for chain ${chainId}: startBlock (${processChainConfig.startBlock->Int.toString}) is less than config.startBlock (${configChain.startBlock->Int.toString}). ` ++
301
+ `Either use startBlock >= ${configChain.startBlock->Int.toString} or create a new test indexer with createTestIndexer().`,
302
+ )
303
+ }
304
+
305
+ // Check endBlock <= config.endBlock (if defined)
306
+ switch configChain.endBlock {
307
+ | Some(configEndBlock) if processChainConfig.endBlock > configEndBlock =>
308
+ Js.Exn.raiseError(
309
+ `Invalid block range for chain ${chainId}: endBlock (${processChainConfig.endBlock->Int.toString}) exceeds config.endBlock (${configEndBlock->Int.toString}). ` ++
310
+ `Either use endBlock <= ${configEndBlock->Int.toString} or create a new test indexer with createTestIndexer().`,
311
+ )
312
+ | _ => ()
313
+ }
314
+
315
+ // Check startBlock > progressBlock
316
+ switch progressBlock {
317
+ | Some(prevEndBlock) if processChainConfig.startBlock <= prevEndBlock =>
318
+ Js.Exn.raiseError(
319
+ `Invalid block range for chain ${chainId}: startBlock (${processChainConfig.startBlock->Int.toString}) must be greater than previously processed endBlock (${prevEndBlock->Int.toString}). ` ++
320
+ `Either use startBlock > ${prevEndBlock->Int.toString} or create a new test indexer with createTestIndexer().`,
321
+ )
322
+ | _ => ()
323
+ }
324
+ }
325
+
326
+ // Entity operations for direct manipulation outside of handlers
327
+ let makeEntityGet = (~state: testIndexerState, ~entityConfig: Internal.entityConfig): (
328
+ string => promise<option<Internal.entity>>
329
+ ) => {
330
+ entityId => {
331
+ if state.processInProgress {
332
+ Js.Exn.raiseError(
333
+ `Cannot call ${entityConfig.name}.get() while indexer.process() is running. ` ++ "Wait for process() to complete before accessing entities directly.",
334
+ )
335
+ }
336
+ let entityDict =
337
+ state.entities->Js.Dict.get(entityConfig.name)->Option.getWithDefault(Js.Dict.empty())
338
+ Promise.resolve(entityDict->Js.Dict.get(entityId))
339
+ }
340
+ }
341
+
342
+ let makeEntitySet = (~state: testIndexerState, ~entityConfig: Internal.entityConfig): (
343
+ Internal.entity => unit
344
+ ) => {
345
+ entity => {
346
+ if state.processInProgress {
347
+ Js.Exn.raiseError(
348
+ `Cannot call ${entityConfig.name}.set() while indexer.process() is running. ` ++ "Wait for process() to complete before modifying entities directly.",
349
+ )
350
+ }
351
+ let entityDict = switch state.entities->Js.Dict.get(entityConfig.name) {
352
+ | Some(dict) => dict
353
+ | None =>
354
+ let dict = Js.Dict.empty()
355
+ state.entities->Js.Dict.set(entityConfig.name, dict)
356
+ dict
357
+ }
358
+ entityDict->Js.Dict.set(entity.id, entity)
359
+ }
360
+ }
361
+
362
+ type entityOps = {
363
+ get: string => promise<option<Internal.entity>>,
364
+ set: Internal.entity => unit,
365
+ }
366
+
367
+ let makeCreateTestIndexer = (
368
+ ~config: Config.t,
369
+ ~workerPath: string,
370
+ ~allEntities: array<Internal.entityConfig>,
371
+ ): (unit => t<'processConfig>) => {
372
+ () => {
373
+ let entities = Js.Dict.empty()
374
+ let entityConfigs = Js.Dict.empty()
375
+ allEntities->Array.forEach(entityConfig => {
376
+ entities->Js.Dict.set(entityConfig.name, Js.Dict.empty())
377
+ entityConfigs->Js.Dict.set(entityConfig.name, entityConfig)
378
+ })
379
+ let state = {
380
+ processInProgress: false,
381
+ progressBlockByChain: Js.Dict.empty(),
382
+ entities,
383
+ entityConfigs,
384
+ processChanges: [],
385
+ }
386
+
387
+ // Build entity operations for each user entity
388
+ let entityOpsDict: Js.Dict.t<entityOps> = Js.Dict.empty()
389
+ allEntities->Array.forEach(entityConfig => {
390
+ // Only create ops for user entities (not internal tables like dynamic_contract_registry)
391
+ if entityConfig.name !== InternalTable.DynamicContractRegistry.name {
392
+ entityOpsDict->Js.Dict.set(
393
+ entityConfig.name,
394
+ {
395
+ get: makeEntityGet(~state, ~entityConfig),
396
+ set: makeEntitySet(~state, ~entityConfig),
397
+ },
398
+ )
399
+ }
400
+ })
401
+
402
+ // Build chain info from config (similar to Main.getGlobalIndexer but static)
403
+ let chainIds = []
404
+ let chains = Utils.Object.createNullObject()
405
+ config.chainMap
406
+ ->ChainMap.values
407
+ ->Array.forEach(chainConfig => {
408
+ let chainIdStr = chainConfig.id->Int.toString
409
+ chainIds->Js.Array2.push(chainConfig.id)->ignore
410
+
411
+ let chainObj = Utils.Object.createNullObject()
412
+ chainObj
413
+ ->Utils.Object.definePropertyWithValue("id", {enumerable: true, value: chainConfig.id})
414
+ ->Utils.Object.definePropertyWithValue(
415
+ "startBlock",
416
+ {enumerable: true, value: chainConfig.startBlock},
417
+ )
418
+ ->Utils.Object.definePropertyWithValue(
419
+ "endBlock",
420
+ {enumerable: true, value: chainConfig.endBlock},
421
+ )
422
+ ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: chainConfig.name})
423
+ ->Utils.Object.definePropertyWithValue("isLive", {enumerable: true, value: false})
424
+ ->ignore
425
+
426
+ // Add contracts to chain object
427
+ chainConfig.contracts->Array.forEach(contract => {
428
+ let contractObj = Utils.Object.createNullObject()
429
+ contractObj
430
+ ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: contract.name})
431
+ ->Utils.Object.definePropertyWithValue("abi", {enumerable: true, value: contract.abi})
432
+ ->Utils.Object.defineProperty(
433
+ "addresses",
434
+ {
435
+ enumerable: true,
436
+ get: () => {
437
+ if state.processInProgress {
438
+ Js.Exn.raiseError(
439
+ `Cannot access ${contract.name}.addresses while indexer.process() is running. ` ++ "Wait for process() to complete before reading contract addresses.",
440
+ )
441
+ }
442
+ // Start with static config addresses
443
+ let addresses = contract.addresses->Array.copy
444
+ // Add accumulated dynamic contract addresses
445
+ switch state.entities->Js.Dict.get(InternalTable.DynamicContractRegistry.name) {
446
+ | Some(dcDict) =>
447
+ dcDict
448
+ ->Js.Dict.values
449
+ ->Array.forEach(
450
+ entity => {
451
+ let dc = entity->castFromDcRegistry
452
+ if dc.contractName === contract.name && dc.chainId === chainConfig.id {
453
+ addresses->Array.push(dc.contractAddress)->ignore
454
+ }
455
+ },
456
+ )
457
+ | None => ()
458
+ }
459
+ addresses
460
+ },
461
+ },
462
+ )
463
+ ->ignore
464
+
465
+ chainObj
466
+ ->Utils.Object.definePropertyWithValue(
467
+ contract.name,
468
+ {enumerable: true, value: contractObj},
469
+ )
470
+ ->ignore
471
+ })
472
+
473
+ chains
474
+ ->Utils.Object.definePropertyWithValue(chainIdStr, {enumerable: true, value: chainObj})
475
+ ->ignore
476
+
477
+ if chainConfig.name !== chainIdStr {
478
+ chains
479
+ ->Utils.Object.definePropertyWithValue(
480
+ chainConfig.name,
481
+ {enumerable: false, value: chainObj},
482
+ )
483
+ ->ignore
484
+ }
485
+ })
486
+
487
+ // Build the result object with process + entity operations + chain info
488
+ let result: Js.Dict.t<unknown> = Js.Dict.empty()
489
+ result->Js.Dict.set("chainIds", chainIds->(Utils.magic: array<int> => unknown))
490
+ result->Js.Dict.set("chains", chains->(Utils.magic: {..} => unknown))
491
+ entityOpsDict
492
+ ->Js.Dict.entries
493
+ ->Array.forEach(((name, ops)) => {
494
+ result->Js.Dict.set(name, ops->(Utils.magic: entityOps => unknown))
495
+ })
496
+
497
+ result->Js.Dict.set(
498
+ "process",
499
+ (processConfig => {
500
+ // Check if already processing
501
+ if state.processInProgress {
502
+ Js.Exn.raiseError(
503
+ "createTestIndexer process is already running. Only one process call is allowed at a time",
504
+ )
505
+ }
506
+
507
+ // Validate chains
508
+ let chains: Js.Dict.t<chainConfig> = (processConfig->Utils.magic)["chains"]->Utils.magic
509
+ let chainKeys = chains->Js.Dict.keys
510
+
511
+ switch chainKeys->Array.length {
512
+ | 0 => Js.Exn.raiseError("createTestIndexer requires exactly one chain to be defined")
513
+ | 1 => ()
514
+ | n =>
515
+ Js.Exn.raiseError(
516
+ `createTestIndexer does not support processing multiple chains at once. Found ${n->Int.toString} chains defined`,
517
+ )
518
+ }
519
+
520
+ // Validate block ranges for each chain
521
+ chainKeys->Array.forEach(chainIdStr => {
522
+ let chainId = chainIdStr->Int.fromString->Option.getWithDefault(0)
523
+ let chain = ChainMap.Chain.makeUnsafe(~chainId)
524
+ let configChain = config.chainMap->ChainMap.get(chain)
525
+ let processChainConfig = chains->Js.Dict.unsafeGet(chainIdStr)
526
+ let progressBlock = state.progressBlockByChain->Js.Dict.get(chainIdStr)
527
+
528
+ validateBlockRange(~chainId=chainIdStr, ~configChain, ~processChainConfig, ~progressBlock)
529
+ })
530
+
531
+ // Reset processChanges for this run
532
+ state.processChanges = []
533
+
534
+ // Extract dynamic contracts from state.entities for each chain
535
+ let dynamicContractsByChain: dict<array<Internal.indexingContract>> = Js.Dict.empty()
536
+ switch state.entities->Js.Dict.get(InternalTable.DynamicContractRegistry.name) {
537
+ | Some(dcDict) =>
538
+ dcDict
539
+ ->Js.Dict.values
540
+ ->Array.forEach(entity => {
541
+ let dc = entity->castFromDcRegistry
542
+ let chainIdStr = dc.chainId->Int.toString
543
+ let contracts = switch dynamicContractsByChain->Js.Dict.get(chainIdStr) {
544
+ | Some(arr) => arr
545
+ | None =>
546
+ let arr = []
547
+ dynamicContractsByChain->Js.Dict.set(chainIdStr, arr)
548
+ arr
549
+ }
550
+ contracts->Array.push(dc->toIndexingContract)->ignore
551
+ })
552
+ | None => ()
553
+ }
554
+
555
+ // Create initialState from processConfig chains
556
+ let initialState = makeInitialState(
557
+ ~config,
558
+ ~processConfigChains=chains,
559
+ ~dynamicContractsByChain,
560
+ )
561
+
562
+ Promise.make((resolve, reject) => {
563
+ // Include initialState in workerData
564
+ let workerDataObj = {
565
+ "processConfig": processConfig->(Utils.magic: 'a => Js.Json.t),
566
+ "initialState": initialState->(Utils.magic: Persistence.initialState => Js.Json.t),
567
+ }
568
+ let workerData = workerDataObj->(Utils.magic: {"processConfig": Js.Json.t, "initialState": Js.Json.t} => Js.Json.t)
569
+ let worker = try {
570
+ NodeJs.WorkerThreads.makeWorker(
571
+ workerPath,
572
+ {
573
+ workerData: workerData,
574
+ },
575
+ )
576
+ } catch {
577
+ | exn =>
578
+ reject(exn->Utils.magic)
579
+ raise(exn)
580
+ }
581
+
582
+ // Set flag only after worker is successfully created
583
+ state.processInProgress = true
584
+
585
+ // Handle messages from worker
586
+ worker->NodeJs.WorkerThreads.onMessage((msg: TestIndexerProxyStorage.workerMessage) => {
587
+ let respond = data =>
588
+ worker->NodeJs.WorkerThreads.workerPostMessage(
589
+ {
590
+ TestIndexerProxyStorage.id: msg.id,
591
+ payload: TestIndexerProxyStorage.Response({data: data}),
592
+ }->Utils.magic,
593
+ )
594
+
595
+ switch msg.payload {
596
+ | LoadByIds({tableName, ids}) => state->handleLoadByIds(~tableName, ~ids)->respond
597
+
598
+ | LoadByField({tableName, fieldName, fieldValue, operator}) =>
599
+ state->handleLoadByField(~tableName, ~fieldName, ~fieldValue, ~operator)->respond
600
+
601
+ | WriteBatch({
602
+ updatedEntities,
603
+ checkpointIds,
604
+ checkpointChainIds,
605
+ checkpointBlockNumbers,
606
+ checkpointBlockHashes,
607
+ checkpointEventsProcessed,
608
+ }) =>
609
+ state->handleWriteBatch(
610
+ ~updatedEntities,
611
+ ~checkpointIds,
612
+ ~checkpointChainIds,
613
+ ~checkpointBlockNumbers,
614
+ ~checkpointBlockHashes,
615
+ ~checkpointEventsProcessed,
616
+ )
617
+ Js.Json.null->respond
618
+ }
619
+ })
620
+
621
+ worker->NodeJs.WorkerThreads.onError(err => {
622
+ state.processInProgress = false
623
+ worker->NodeJs.WorkerThreads.terminate->ignore
624
+ reject(err)
625
+ })
626
+
627
+ worker->NodeJs.WorkerThreads.onExit(code => {
628
+ state.processInProgress = false
629
+ if code !== 0 {
630
+ reject(Utils.Error.make(`Worker exited with code ${code->Int.toString}`))
631
+ } else {
632
+ // Update progressBlockByChain with processed endBlock for each chain
633
+ chainKeys->Array.forEach(
634
+ chainIdStr => {
635
+ let processChainConfig = chains->Js.Dict.unsafeGet(chainIdStr)
636
+ state.progressBlockByChain->Js.Dict.set(chainIdStr, processChainConfig.endBlock)
637
+ },
638
+ )
639
+ // Worker exited successfully (SuccessExit was dispatched in GlobalState)
640
+ resolve({
641
+ changes: state.processChanges,
642
+ })
643
+ }
644
+ })
645
+ })
646
+ })->(Utils.magic: ('a => promise<processResult>) => unknown),
647
+ )
648
+
649
+ result->(Utils.magic: Js.Dict.t<unknown> => t<'processConfig>)
650
+ }
651
+ }
652
+
653
+ type workerData = {
654
+ processConfig: Js.Json.t,
655
+ initialState: Persistence.initialState,
656
+ }
657
+
658
+ let initTestWorker = (~makeGeneratedConfig: unit => Config.t) => {
659
+ if NodeJs.WorkerThreads.isMainThread {
660
+ Js.Exn.raiseError("initTestWorker must be called from a worker thread")
661
+ }
662
+
663
+ let parentPort = switch NodeJs.WorkerThreads.parentPort->Js.Nullable.toOption {
664
+ | Some(port) => port
665
+ | None => Js.Exn.raiseError("initTestWorker: No parent port available")
666
+ }
667
+
668
+ let workerData: option<workerData> = NodeJs.WorkerThreads.workerData->Js.Nullable.toOption
669
+ switch workerData {
670
+ | Some({initialState}) =>
671
+ // Create proxy storage that communicates with main thread
672
+ let proxy = TestIndexerProxyStorage.make(~parentPort, ~initialState)
673
+ let storage = TestIndexerProxyStorage.makeStorage(proxy)
674
+ let config = makeGeneratedConfig()
675
+ let persistence = Persistence.make(
676
+ ~userEntities=config.userEntities,
677
+ ~allEnums=config.allEnums,
678
+ ~storage,
679
+ )
680
+
681
+ // Silence logs by default in test mode unless LOG_LEVEL is explicitly set
682
+ switch Env.userLogLevel {
683
+ | None => Logging.setLogLevel(#silent)
684
+ | Some(_) => ()
685
+ }
686
+
687
+ Main.start(~makeGeneratedConfig, ~persistence, ~isTest=true)->ignore
688
+ | None =>
689
+ Logging.error("TestIndexerWorker: No worker data provided")
690
+ NodeJs.process->NodeJs.exitWithCode(Failure)
691
+ }
692
+ }