envio 3.3.2 → 3.4.0

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 (60) hide show
  1. package/package.json +6 -6
  2. package/src/Api.res +1 -8
  3. package/src/Api.res.mjs +1 -5
  4. package/src/ChainState.res +6 -1
  5. package/src/ChainState.res.mjs +4 -3
  6. package/src/Config.res +33 -4
  7. package/src/Config.res.mjs +34 -5
  8. package/src/Core.res +30 -19
  9. package/src/Core.res.mjs +5 -5
  10. package/src/EnvioGlobal.res +0 -2
  11. package/src/EnvioGlobal.res.mjs +0 -1
  12. package/src/ExitOnCaughtUp.res +6 -2
  13. package/src/ExitOnCaughtUp.res.mjs +4 -0
  14. package/src/FetchState.res +3 -3
  15. package/src/HandlerRegister.res +357 -434
  16. package/src/HandlerRegister.res.mjs +202 -242
  17. package/src/HandlerRegister.resi +7 -15
  18. package/src/IndexerState.res +10 -0
  19. package/src/IndexerState.res.mjs +9 -3
  20. package/src/IndexerState.resi +3 -0
  21. package/src/Internal.res +10 -1
  22. package/src/Main.res.mjs +4 -4
  23. package/src/PgStorage.res +16 -1
  24. package/src/PgStorage.res.mjs +9 -1
  25. package/src/SimulateItems.res +61 -40
  26. package/src/SimulateItems.res.mjs +37 -21
  27. package/src/TestIndexer.res +446 -453
  28. package/src/TestIndexer.res.mjs +320 -343
  29. package/src/bindings/ClickHouse.res +68 -2
  30. package/src/bindings/ClickHouse.res.mjs +47 -2
  31. package/src/sources/EvmChain.res +1 -1
  32. package/src/sources/EvmChain.res.mjs +2 -2
  33. package/src/sources/FuelHyperSync.res +74 -0
  34. package/src/sources/FuelHyperSync.res.mjs +80 -0
  35. package/src/sources/FuelHyperSync.resi +22 -0
  36. package/src/sources/FuelHyperSyncClient.res +120 -0
  37. package/src/sources/FuelHyperSyncClient.res.mjs +71 -0
  38. package/src/sources/FuelHyperSyncSource.res +257 -0
  39. package/src/sources/FuelHyperSyncSource.res.mjs +252 -0
  40. package/src/sources/HyperSyncClient.res +2 -2
  41. package/src/sources/HyperSyncClient.res.mjs +1 -1
  42. package/src/sources/SvmHyperSyncClient.res +139 -102
  43. package/src/sources/SvmHyperSyncClient.res.mjs +45 -5
  44. package/src/sources/SvmHyperSyncSource.res +60 -440
  45. package/src/sources/SvmHyperSyncSource.res.mjs +42 -363
  46. package/src/TestIndexerProxyStorage.res +0 -196
  47. package/src/TestIndexerProxyStorage.res.mjs +0 -121
  48. package/src/TestIndexerWorker.res +0 -4
  49. package/src/TestIndexerWorker.res.mjs +0 -7
  50. package/src/sources/EventRouter.res +0 -165
  51. package/src/sources/EventRouter.res.mjs +0 -153
  52. package/src/sources/HyperFuel.res +0 -179
  53. package/src/sources/HyperFuel.res.mjs +0 -146
  54. package/src/sources/HyperFuel.resi +0 -36
  55. package/src/sources/HyperFuelClient.res +0 -127
  56. package/src/sources/HyperFuelClient.res.mjs +0 -20
  57. package/src/sources/HyperFuelSource.res +0 -502
  58. package/src/sources/HyperFuelSource.res.mjs +0 -481
  59. /package/src/sources/{HyperSyncSource.res → EvmHyperSyncSource.res} +0 -0
  60. /package/src/sources/{HyperSyncSource.res.mjs → EvmHyperSyncSource.res.mjs} +0 -0
@@ -70,48 +70,31 @@ let getIndexingAddressesByChain = (state: testIndexerState): dict<
70
70
  byChain
71
71
  }
72
72
 
73
- let handleLoad = (state: testIndexerState, ~tableName: string, ~filter: EntityFilter.t): JSON.t => {
73
+ let handleLoad = (state: testIndexerState, ~tableName: string, ~filter: EntityFilter.t): array<
74
+ Internal.entity,
75
+ > => {
74
76
  // Loads for non-entity tables (e.g. effect caches `envio_effect_<name>`) reach
75
77
  // here too. TestIndexer never persists those, so there's nothing to return —
76
78
  // an empty result makes the effect recompute instead of crashing on a missing
77
79
  // entityConfig.
78
80
  switch state.entityConfigs->Dict.get(tableName) {
79
- | None => []->JSON.Encode.array
80
- | Some(entityConfig) =>
81
+ | None => []
82
+ | Some(_) =>
81
83
  let entityDict = state.entities->Dict.get(tableName)->Option.getOr(Dict.make())
82
- let results = []
83
-
84
- // Field values arrive as JSON from the worker boundary, so parse them
85
- // with the field's schema before comparing. This properly handles
86
- // bigint and BigDecimal comparisons
87
- let parseLeaf = (~fieldName, ~fieldValue: unknown, ~isArray): unknown => {
88
- let queryField = switch entityConfig.table->Table.queryFields->Dict.get(fieldName) {
89
- | Some(queryField) => queryField
90
- | None => JsError.throwWithMessage(`Field ${fieldName} not found in entity ${tableName}`)
91
- }
92
- fieldValue->S.convertOrThrow(isArray ? queryField.arrayFieldSchema : queryField.fieldSchema)
93
- }
94
- let filter = filter->EntityFilter.mapValues(~mapValue=parseLeaf)
95
-
96
84
  entityDict
97
85
  ->Dict.valuesToArray
98
- ->Array.forEach(entity => {
99
- // Cast entity to dict of field values (same approach as InMemoryTable)
86
+ ->Array.filter(entity => {
87
+ // The store holds decoded entities and the filter carries decoded values,
88
+ // so compare directly (same approach as InMemoryTable) — no JSON round-trip.
100
89
  let entityAsDict = entity->(Utils.magic: Internal.entity => dict<EntityFilter.FieldValue.t>)
101
- if filter->EntityFilter.matches(~entity=entityAsDict) {
102
- // Serialize entity back to JSON for worker thread
103
- let jsonEntity = entity->S.reverseConvertToJsonOrThrow(entityConfig.schema)
104
- results->Array.push(jsonEntity)->ignore
105
- }
90
+ filter->EntityFilter.matches(~entity=entityAsDict)
106
91
  })
107
-
108
- results->JSON.Encode.array
109
92
  }
110
93
  }
111
94
 
112
95
  let handleWriteBatch = (
113
96
  state: testIndexerState,
114
- ~updatedEntities: array<TestIndexerProxyStorage.serializableUpdatedEntity>,
97
+ ~updatedEntities: array<Persistence.updatedEntity>,
115
98
  ~checkpointIds: array<bigint>,
116
99
  ~checkpointChainIds: array<int>,
117
100
  ~checkpointBlockNumbers: array<int>,
@@ -121,7 +104,8 @@ let handleWriteBatch = (
121
104
  // checkpointId -> entityName -> entityChange
122
105
  let changesByCheckpoint: dict<dict<entityChange>> = Dict.make()
123
106
 
124
- updatedEntities->Array.forEach(({entityName, changes}) => {
107
+ updatedEntities->Array.forEach(({entityConfig, changes}: Persistence.updatedEntity) => {
108
+ let entityName = entityConfig.name
125
109
  let entityDict = switch state.entities->Dict.get(entityName) {
126
110
  | Some(dict) => dict
127
111
  | None =>
@@ -129,57 +113,35 @@ let handleWriteBatch = (
129
113
  state.entities->Dict.set(entityName, dict)
130
114
  dict
131
115
  }
132
- let entityConfig = state.entityConfigs->Dict.getUnsafe(entityName)
133
116
 
134
- let processChange = (change: TestIndexerProxyStorage.serializableChange) => {
117
+ let entityChangeFor = checkpointId => {
118
+ let checkpointKey = checkpointId->BigInt.toString
119
+ let entityChanges = switch changesByCheckpoint->Dict.get(checkpointKey) {
120
+ | Some(changes) => changes
121
+ | None =>
122
+ let changes = Dict.make()
123
+ changesByCheckpoint->Dict.set(checkpointKey, changes)
124
+ changes
125
+ }
126
+ switch entityChanges->Dict.get(entityName) {
127
+ | Some(change) => change
128
+ | None =>
129
+ let change = {sets: [], deleted: []}
130
+ entityChanges->Dict.set(entityName, change)
131
+ change
132
+ }
133
+ }
134
+
135
+ let processChange = (change: Change.t<Internal.entity>) => {
135
136
  switch change {
136
137
  | Set({entityId, entity, checkpointId}) =>
137
- // Parse entity immediately to store decoded values for proper comparisons
138
- // (bigint/BigDecimal need actual values, not JSON strings)
139
- let parsedEntity = entity->S.parseOrThrow(entityConfig.schema)
140
-
141
- // Update entities dict with parsed entity for load operations
142
- entityDict->Dict.set(entityId, parsedEntity)
143
-
144
- // Track change by checkpoint
145
- let checkpointKey = checkpointId->BigInt.toString
146
- let entityChanges = switch changesByCheckpoint->Dict.get(checkpointKey) {
147
- | Some(changes) => changes
148
- | None =>
149
- let changes = Dict.make()
150
- changesByCheckpoint->Dict.set(checkpointKey, changes)
151
- changes
152
- }
153
- let entityChange = switch entityChanges->Dict.get(entityName) {
154
- | Some(change) => change
155
- | None =>
156
- let change = {sets: [], deleted: []}
157
- entityChanges->Dict.set(entityName, change)
158
- change
159
- }
160
- entityChange.sets->Array.push(parsedEntity->Utils.magic)->ignore
161
-
138
+ // The store keeps decoded entities so load comparisons (bigint /
139
+ // BigDecimal) work on real values.
140
+ entityDict->Dict.set(entityId, entity)
141
+ entityChangeFor(checkpointId).sets->Array.push(entity->Utils.magic)->ignore
162
142
  | Delete({entityId, checkpointId}) =>
163
- // Update entities dict for load operations
164
143
  Dict.delete(entityDict->Obj.magic, entityId)
165
-
166
- // Track change by checkpoint
167
- let checkpointKey = checkpointId->BigInt.toString
168
- let entityChanges = switch changesByCheckpoint->Dict.get(checkpointKey) {
169
- | Some(changes) => changes
170
- | None =>
171
- let changes = Dict.make()
172
- changesByCheckpoint->Dict.set(checkpointKey, changes)
173
- changes
174
- }
175
- let entityChange = switch entityChanges->Dict.get(entityName) {
176
- | Some(change) => change
177
- | None =>
178
- let change = {sets: [], deleted: []}
179
- entityChanges->Dict.set(entityName, change)
180
- change
181
- }
182
- entityChange.deleted->Array.push(entityId)->ignore
144
+ entityChangeFor(checkpointId).deleted->Array.push(entityId)->ignore
183
145
  }
184
146
  }
185
147
 
@@ -285,8 +247,6 @@ let makeInitialState = (
285
247
  chains,
286
248
  checkpointId: InternalTable.Checkpoints.initialCheckpointId,
287
249
  reorgCheckpoints: [],
288
- // TestIndexer fakes the resume path; mirror what Main.start passes as
289
- // ~envioInfo so the compat check always sees an empty diff.
290
250
  envioInfo: Some(Config.getPublicConfigJson()->Config.stripSensitiveData),
291
251
  }
292
252
  }
@@ -406,6 +366,18 @@ let parseBlockRange = (
406
366
  {startBlock, endBlock}
407
367
  }
408
368
 
369
+ // The store owns its entities. Copy on the boundary with user code — both when
370
+ // handing one out (get/getAll/getOrThrow) and when taking one in (set) — so a
371
+ // user mutating a returned entity, or an object they passed to `set`, can't
372
+ // corrupt the in-memory store. The copy is shallow (matching InMemoryTable):
373
+ // scalar fields (string/bigint/BigDecimal) are immutable, but array-valued
374
+ // fields still share the backing array, so in-place mutation of those leaks.
375
+ let copyEntity = (entity: Internal.entity): Internal.entity =>
376
+ entity
377
+ ->(Utils.magic: Internal.entity => dict<unknown>)
378
+ ->Utils.Dict.shallowCopy
379
+ ->(Utils.magic: dict<unknown> => Internal.entity)
380
+
409
381
  // Entity operations for direct manipulation outside of handlers
410
382
  let getEntityFromState = (
411
383
  ~state: testIndexerState,
@@ -419,7 +391,7 @@ let getEntityFromState = (
419
391
  )
420
392
  }
421
393
  let entityDict = state.entities->Dict.get(entityConfig.name)->Option.getOr(Dict.make())
422
- entityDict->Dict.get(entityId)
394
+ entityDict->Dict.get(entityId)->Option.map(copyEntity)
423
395
  }
424
396
 
425
397
  let makeEntityGet = (~state: testIndexerState, ~entityConfig: Internal.entityConfig): (
@@ -462,7 +434,7 @@ let makeEntitySet = (~state: testIndexerState, ~entityConfig: Internal.entityCon
462
434
  state.entities->Dict.set(entityConfig.name, dict)
463
435
  dict
464
436
  }
465
- entityDict->Dict.set(entity.id, entity)
437
+ entityDict->Dict.set(entity.id, copyEntity(entity))
466
438
  }
467
439
  }
468
440
 
@@ -476,7 +448,7 @@ let makeEntityGetAll = (~state: testIndexerState, ~entityConfig: Internal.entity
476
448
  )
477
449
  }
478
450
  let entityDict = state.entities->Dict.get(entityConfig.name)->Option.getOr(Dict.make())
479
- Promise.resolve(entityDict->Dict.valuesToArray)
451
+ Promise.resolve(entityDict->Dict.valuesToArray->Array.map(copyEntity))
480
452
  }
481
453
  }
482
454
 
@@ -487,411 +459,432 @@ type entityOperations = {
487
459
  set: Internal.entity => unit,
488
460
  }
489
461
 
490
- type workerData = {
491
- chainId: int,
492
- startBlock: int,
493
- endBlock: option<int>,
494
- simulate: option<array<JSON.t>>,
495
- initialState: Persistence.initialState,
462
+ // Adapt the real storage interface to the in-memory entity store. In-process
463
+ // there's no worker boundary, so entities are stored and loaded decoded — no
464
+ // JSON serialization round-trip.
465
+ let makeInMemoryStorage = (~state: testIndexerState): Persistence.storage => {
466
+ name: "test-inmemory",
467
+ isInitialized: async () => true,
468
+ // The runner injects the config-derived initial state by setting
469
+ // `persistence.storageStatus = Ready(...)` directly, bypassing `Persistence.init`,
470
+ // so neither of these is reached.
471
+ initialize: async (~chainConfigs as _=?, ~entities as _=?, ~enums as _=?, ~envioInfo as _) =>
472
+ JsError.throwWithMessage(
473
+ "TestIndexer: initialize should not be called; the initial state is derived from config.",
474
+ ),
475
+ resumeInitialState: async () =>
476
+ JsError.throwWithMessage(
477
+ "TestIndexer: resumeInitialState should not be called; the initial state is derived from config.",
478
+ ),
479
+ loadOrThrow: async (~filter, ~table: Table.table) =>
480
+ state
481
+ ->handleLoad(~tableName=table.tableName, ~filter)
482
+ ->(Utils.magic: array<Internal.entity> => array<unknown>),
483
+ writeBatch: async (
484
+ ~batch,
485
+ ~rollback as _,
486
+ ~isInReorgThreshold as _,
487
+ ~config as _,
488
+ ~allEntities as _,
489
+ ~updatedEffectsCache as _,
490
+ ~updatedEntities,
491
+ ~chainMetaData as _,
492
+ ~onWrite as _,
493
+ ) =>
494
+ state->handleWriteBatch(
495
+ ~updatedEntities,
496
+ ~checkpointIds=batch.checkpointIds,
497
+ ~checkpointChainIds=batch.checkpointChainIds,
498
+ ~checkpointBlockNumbers=batch.checkpointBlockNumbers,
499
+ ~checkpointEventsProcessed=batch.checkpointEventsProcessed,
500
+ ),
501
+ dumpEffectCache: async () => (),
502
+ reset: async () => (),
503
+ setChainMeta: async _ => Obj.magic(),
504
+ pruneStaleCheckpoints: async (~safeCheckpointId as _) => (),
505
+ pruneStaleEntityHistory: async (~entityName as _, ~entityIndex as _, ~safeCheckpointId as _) =>
506
+ (),
507
+ getRollbackTargetCheckpoint: async (~reorgChainId as _, ~lastKnownValidBlockNumber as _) =>
508
+ JsError.throwWithMessage(
509
+ "TestIndexer: Rollback is not supported. The runner forces rollbackOnReorg off, so this should be unreachable.",
510
+ ),
511
+ getRollbackProgressDiff: async (~rollbackTargetCheckpointId as _) =>
512
+ JsError.throwWithMessage(
513
+ "TestIndexer: Rollback is not supported. The runner forces rollbackOnReorg off, so this should be unreachable.",
514
+ ),
515
+ getRollbackData: async (~entityConfig as _, ~rollbackTargetCheckpointId as _) =>
516
+ JsError.throwWithMessage(
517
+ "TestIndexer: Rollback is not supported. The runner forces rollbackOnReorg off, so this should be unreachable.",
518
+ ),
519
+ close: async () => (),
496
520
  }
497
521
 
498
- let makeCreateTestIndexer = (~config: Config.t, ~workerPath: string): (
499
- unit => t<'processConfig>
500
- ) => {
501
- () => {
502
- let allEntities = config.allEntities
503
- let entities = Dict.make()
504
- let entityConfigs = Dict.make()
505
- allEntities->Array.forEach(entityConfig => {
506
- entities->Dict.set(entityConfig.name, Dict.make())
507
- entityConfigs->Dict.set(entityConfig.name, entityConfig)
508
- })
522
+ // Copy the per-chain registration arrays so a process() run's simulate-source
523
+ // additions (SimulateItems.patchConfig pushes onEventRegistrations) never
524
+ // mutate the shared base registration — lets independent createTestIndexer runs
525
+ // proceed in parallel without clobbering each other's registrations.
526
+ let cloneRegistrations = (
527
+ base: HandlerRegister.registrationsByChainId,
528
+ ): HandlerRegister.registrationsByChainId => {
529
+ let clone = Dict.make()
530
+ base
531
+ ->Dict.toArray
532
+ ->Array.forEach(((chainIdStr, chainRegistrations: HandlerRegister.chainRegistrations)) =>
533
+ clone->Dict.set(
534
+ chainIdStr,
535
+ {
536
+ HandlerRegister.onEventRegistrations: chainRegistrations.onEventRegistrations->Array.copy,
537
+ onBlockRegistrations: chainRegistrations.onBlockRegistrations->Array.copy,
538
+ },
539
+ )
540
+ )
541
+ clone
542
+ }
509
543
 
510
- // Populate config addresses into the entity dict, mirroring PgStorage.initialize
511
- let envioAddressesDict = entities->Dict.getUnsafe(InternalTable.EnvioAddresses.name)
512
- config.chainMap
513
- ->ChainMap.values
514
- ->Array.forEach(chainConfig => {
515
- chainConfig.contracts->Array.forEach(contract => {
516
- contract.addresses->Array.forEach(
517
- address => {
518
- let entity: InternalTable.EnvioAddresses.t = {
519
- id: Config.EnvioAddresses.makeId(~chainId=chainConfig.id, ~address),
520
- chainId: chainConfig.id,
521
- contractName: contract.name,
522
- registrationBlock: -1,
523
- registrationLogIndex: -1,
524
- }
525
- envioAddressesDict->Dict.set(entity.id, entity->Config.EnvioAddresses.castToInternal)
526
- },
527
- )
528
- })
529
- })
544
+ // User handlers register into the process-global HandlerRegister as an import
545
+ // side effect. Capture the resolved registrations once per process (imports are
546
+ // module-cached anyway) and reuse them across every createTestIndexer run, so
547
+ // the global registration cycle runs a single time and never races.
548
+ let registrationsRef: ref<option<promise<HandlerRegister.registrationsByChainId>>> = ref(None)
549
+ let getRegistrations = (~config) =>
550
+ switch registrationsRef.contents {
551
+ | Some(promise) => promise
552
+ | None =>
553
+ let promise = HandlerLoader.registerAllHandlers(~config)
554
+ registrationsRef := Some(promise)
555
+ promise
556
+ }
530
557
 
531
- let state = {
532
- processInProgress: false,
533
- progressBlockByChain: Dict.make(),
534
- entities,
535
- entityConfigs,
536
- processChanges: [],
537
- }
558
+ let createTestIndexer = (): t<'processConfig> => {
559
+ let config = Config.load()
560
+ let allEntities = config.allEntities
561
+ let entities = Dict.make()
562
+ let entityConfigs = Dict.make()
563
+ allEntities->Array.forEach(entityConfig => {
564
+ entities->Dict.set(entityConfig.name, Dict.make())
565
+ entityConfigs->Dict.set(entityConfig.name, entityConfig)
566
+ })
538
567
 
539
- // Build entity operations for each user entity
540
- let entityOpsDict: dict<entityOperations> = Dict.make()
541
- allEntities->Array.forEach(entityConfig => {
542
- // Only create ops for user entities (not internal tables like envio_addresses)
543
- if entityConfig.name !== InternalTable.EnvioAddresses.name {
544
- entityOpsDict->Dict.set(
545
- entityConfig.name,
546
- {
547
- get: makeEntityGet(~state, ~entityConfig),
548
- getAll: makeEntityGetAll(~state, ~entityConfig),
549
- getOrThrow: makeEntityGetOrThrow(~state, ~entityConfig),
550
- set: makeEntitySet(~state, ~entityConfig),
551
- },
552
- )
553
- }
568
+ // Populate config addresses into the entity dict, mirroring PgStorage.initialize
569
+ let envioAddressesDict = entities->Dict.getUnsafe(InternalTable.EnvioAddresses.name)
570
+ config.chainMap
571
+ ->ChainMap.values
572
+ ->Array.forEach(chainConfig => {
573
+ chainConfig.contracts->Array.forEach(contract => {
574
+ contract.addresses->Array.forEach(
575
+ address => {
576
+ let entity: InternalTable.EnvioAddresses.t = {
577
+ id: Config.EnvioAddresses.makeId(~chainId=chainConfig.id, ~address),
578
+ chainId: chainConfig.id,
579
+ contractName: contract.name,
580
+ registrationBlock: -1,
581
+ registrationLogIndex: -1,
582
+ }
583
+ envioAddressesDict->Dict.set(entity.id, entity->Config.EnvioAddresses.castToInternal)
584
+ },
585
+ )
554
586
  })
587
+ })
555
588
 
556
- // Build chain info from config (similar to Main.getGlobalIndexer but static)
557
- let chainIds = []
558
- let chains = Utils.Object.createNullObject()
559
- config.chainMap
560
- ->ChainMap.values
561
- ->Array.forEach(chainConfig => {
562
- let chainIdStr = chainConfig.id->Int.toString
563
- chainIds->Array.push(chainConfig.id)->ignore
589
+ let state = {
590
+ processInProgress: false,
591
+ progressBlockByChain: Dict.make(),
592
+ entities,
593
+ entityConfigs,
594
+ processChanges: [],
595
+ }
564
596
 
565
- let chainObj = Utils.Object.createNullObject()
566
- chainObj
567
- ->Utils.Object.definePropertyWithValue("id", {enumerable: true, value: chainConfig.id})
568
- ->Utils.Object.definePropertyWithValue(
569
- "startBlock",
570
- {enumerable: true, value: chainConfig.startBlock},
597
+ // Per-instance in-memory storage over `state.entities`. Separate indexers
598
+ // get separate storages, so independent indexers run in parallel without
599
+ // shared mutable state.
600
+ let storage = makeInMemoryStorage(~state)
601
+ let persistence = Persistence.make(
602
+ ~userEntities=config.userEntities,
603
+ ~allEnums=config.allEnums,
604
+ ~storage,
605
+ )
606
+
607
+ // Silence logs by default in test mode unless LOG_LEVEL is explicitly set.
608
+ switch Env.userLogLevel {
609
+ | None => Logging.setLogLevel(#silent)
610
+ | Some(_) => ()
611
+ }
612
+
613
+ // Build entity operations for each user entity
614
+ let entityOpsDict: dict<entityOperations> = Dict.make()
615
+ allEntities->Array.forEach(entityConfig => {
616
+ // Only create ops for user entities (not internal tables like envio_addresses)
617
+ if entityConfig.name !== InternalTable.EnvioAddresses.name {
618
+ entityOpsDict->Dict.set(
619
+ entityConfig.name,
620
+ {
621
+ get: makeEntityGet(~state, ~entityConfig),
622
+ getAll: makeEntityGetAll(~state, ~entityConfig),
623
+ getOrThrow: makeEntityGetOrThrow(~state, ~entityConfig),
624
+ set: makeEntitySet(~state, ~entityConfig),
625
+ },
571
626
  )
572
- ->Utils.Object.definePropertyWithValue(
573
- "endBlock",
574
- {enumerable: true, value: chainConfig.endBlock},
627
+ }
628
+ })
629
+
630
+ // Build chain info from config (similar to Main.getGlobalIndexer but static)
631
+ let chainIds = []
632
+ let chains = Utils.Object.createNullObject()
633
+ config.chainMap
634
+ ->ChainMap.values
635
+ ->Array.forEach(chainConfig => {
636
+ let chainIdStr = chainConfig.id->Int.toString
637
+ chainIds->Array.push(chainConfig.id)->ignore
638
+
639
+ let chainObj = Utils.Object.createNullObject()
640
+ chainObj
641
+ ->Utils.Object.definePropertyWithValue("id", {enumerable: true, value: chainConfig.id})
642
+ ->Utils.Object.definePropertyWithValue(
643
+ "startBlock",
644
+ {enumerable: true, value: chainConfig.startBlock},
645
+ )
646
+ ->Utils.Object.definePropertyWithValue(
647
+ "endBlock",
648
+ {enumerable: true, value: chainConfig.endBlock},
649
+ )
650
+ ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: chainConfig.name})
651
+ ->Utils.Object.definePropertyWithValue("isRealtime", {enumerable: true, value: false})
652
+ ->ignore
653
+
654
+ // Add contracts to chain object
655
+ chainConfig.contracts->Array.forEach(contract => {
656
+ let contractObj = Utils.Object.createNullObject()
657
+ contractObj
658
+ ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: contract.name})
659
+ ->Utils.Object.definePropertyWithValue("abi", {enumerable: true, value: contract.abi})
660
+ ->Utils.Object.defineProperty(
661
+ "addresses",
662
+ {
663
+ enumerable: true,
664
+ get: () => {
665
+ if state.processInProgress {
666
+ JsError.throwWithMessage(
667
+ `Cannot access ${contract.name}.addresses while indexer.process() is running. ` ++ "Wait for process() to complete before reading contract addresses.",
668
+ )
669
+ }
670
+ getIndexingAddressesByChain(state)
671
+ ->Dict.get(chainConfig.id->Int.toString)
672
+ ->Option.getOr([])
673
+ ->Array.filterMap(ia => ia.contractName === contract.name ? Some(ia.address) : None)
674
+ },
675
+ },
575
676
  )
576
- ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: chainConfig.name})
577
- ->Utils.Object.definePropertyWithValue("isRealtime", {enumerable: true, value: false})
578
677
  ->ignore
579
678
 
580
- // Add contracts to chain object
581
- chainConfig.contracts->Array.forEach(contract => {
582
- let contractObj = Utils.Object.createNullObject()
583
- contractObj
584
- ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: contract.name})
585
- ->Utils.Object.definePropertyWithValue("abi", {enumerable: true, value: contract.abi})
586
- ->Utils.Object.defineProperty(
587
- "addresses",
588
- {
589
- enumerable: true,
590
- get: () => {
591
- if state.processInProgress {
592
- JsError.throwWithMessage(
593
- `Cannot access ${contract.name}.addresses while indexer.process() is running. ` ++ "Wait for process() to complete before reading contract addresses.",
594
- )
595
- }
596
- getIndexingAddressesByChain(state)
597
- ->Dict.get(chainConfig.id->Int.toString)
598
- ->Option.getOr([])
599
- ->Array.filterMap(ia => ia.contractName === contract.name ? Some(ia.address) : None)
600
- },
601
- },
602
- )
603
- ->ignore
604
-
605
- chainObj
606
- ->Utils.Object.definePropertyWithValue(
607
- contract.name,
608
- {enumerable: true, value: contractObj},
609
- )
610
- ->ignore
611
- })
679
+ chainObj
680
+ ->Utils.Object.definePropertyWithValue(contract.name, {enumerable: true, value: contractObj})
681
+ ->ignore
682
+ })
612
683
 
684
+ chains
685
+ ->Utils.Object.definePropertyWithValue(chainIdStr, {enumerable: true, value: chainObj})
686
+ ->ignore
687
+
688
+ if chainConfig.name !== chainIdStr {
613
689
  chains
614
- ->Utils.Object.definePropertyWithValue(chainIdStr, {enumerable: true, value: chainObj})
690
+ ->Utils.Object.definePropertyWithValue(chainConfig.name, {enumerable: false, value: chainObj})
615
691
  ->ignore
692
+ }
693
+ })
616
694
 
617
- if chainConfig.name !== chainIdStr {
618
- chains
619
- ->Utils.Object.definePropertyWithValue(
620
- chainConfig.name,
621
- {enumerable: false, value: chainObj},
622
- )
623
- ->ignore
624
- }
625
- })
695
+ // Build the result object with process + entity operations + chain info
696
+ let result: dict<unknown> = Dict.make()
697
+ result->Dict.set("chainIds", chainIds->(Utils.magic: array<int> => unknown))
698
+ result->Dict.set("chains", chains->(Utils.magic: {..} => unknown))
699
+ entityOpsDict
700
+ ->Dict.toArray
701
+ ->Array.forEach(((name, ops)) => {
702
+ result->Dict.set(name, ops->(Utils.magic: entityOperations => unknown))
703
+ })
626
704
 
627
- // Build the result object with process + entity operations + chain info
628
- let result: dict<unknown> = Dict.make()
629
- result->Dict.set("chainIds", chainIds->(Utils.magic: array<int> => unknown))
630
- result->Dict.set("chains", chains->(Utils.magic: {..} => unknown))
631
- entityOpsDict
632
- ->Dict.toArray
633
- ->Array.forEach(((name, ops)) => {
634
- result->Dict.set(name, ops->(Utils.magic: entityOperations => unknown))
635
- })
705
+ result->Dict.set(
706
+ "process",
707
+ (
708
+ processConfig => {
709
+ // Check if already processing
710
+ if state.processInProgress {
711
+ JsError.throwWithMessage(
712
+ "createTestIndexer process is already running. Only one process call is allowed at a time",
713
+ )
714
+ }
636
715
 
637
- result->Dict.set(
638
- "process",
639
- (
640
- processConfig => {
641
- // Check if already processing
642
- if state.processInProgress {
643
- JsError.throwWithMessage(
644
- "createTestIndexer process is already running. Only one process call is allowed at a time",
645
- )
646
- }
716
+ // Parse and validate processConfig
717
+ let parsedConfig = try processConfig->S.parseOrThrow(processConfigSchema) catch {
718
+ | S.Raised(exn) =>
719
+ JsError.throwWithMessage(
720
+ `Invalid processConfig: ${exn->Utils.prettifyExn->(Utils.magic: exn => string)}`,
721
+ )
722
+ }
723
+ let rawChains = parsedConfig["chains"]
724
+ let chainKeys = rawChains->Dict.keysToArray
647
725
 
648
- // Parse and validate processConfig
649
- let parsedConfig = try processConfig->S.parseOrThrow(processConfigSchema) catch {
650
- | S.Raised(exn) =>
726
+ if chainKeys->Array.length === 0 {
727
+ JsError.throwWithMessage("createTestIndexer requires at least one chain to be defined")
728
+ }
729
+
730
+ // Sort chain keys by chain ID for deterministic ordering
731
+ let sortedChainKeys = chainKeys->Array.copy
732
+ sortedChainKeys->Array.sort((a, b) => {
733
+ let aId = a->Int.fromString->Option.getOr(0)
734
+ let bId = b->Int.fromString->Option.getOr(0)
735
+ Int.compare(aId, bId)
736
+ })
737
+
738
+ // Parse and validate the block ranges upfront before running any chain.
739
+ let chainEntries = sortedChainKeys->Array.map(chainIdStr => {
740
+ let rawChainConfig = rawChains->Dict.getUnsafe(chainIdStr)
741
+ if chainIdStr->Int.fromString->Option.isNone {
651
742
  JsError.throwWithMessage(
652
- `Invalid processConfig: ${exn->Utils.prettifyExn->(Utils.magic: exn => string)}`,
743
+ `Invalid chain ID "${chainIdStr}": expected a numeric chain ID`,
653
744
  )
654
745
  }
655
- let rawChains = parsedConfig["chains"]
656
- let chainKeys = rawChains->Dict.keysToArray
657
-
658
- if chainKeys->Array.length === 0 {
659
- JsError.throwWithMessage("createTestIndexer requires at least one chain to be defined")
746
+ let processChainConfig = parseBlockRange(
747
+ ~chainIdStr,
748
+ ~config,
749
+ ~rawChainConfig,
750
+ ~progressBlock=state.progressBlockByChain->Dict.get(chainIdStr),
751
+ )
752
+ (chainIdStr, rawChainConfig, processChainConfig)
753
+ })
754
+
755
+ // Reset processChanges for this run
756
+ state.processChanges = []
757
+
758
+ let runChainInProcess = async ((
759
+ chainIdStr,
760
+ rawChainConfig: rawChainConfig,
761
+ processChainConfig,
762
+ )) => {
763
+ // Build initialState from resolved block range. Rebuilt per chain so
764
+ // later chains in the same process() call see contracts registered
765
+ // by earlier ones.
766
+ let chains: dict<chainConfig> = Dict.make()
767
+ chains->Dict.set(chainIdStr, processChainConfig)
768
+ let indexingAddressesByChain = getIndexingAddressesByChain(state)
769
+ let initialState = makeInitialState(
770
+ ~config,
771
+ ~processConfigChains=chains,
772
+ ~indexingAddressesByChain,
773
+ )
774
+
775
+ // No endBlock means auto-exit mode: process one block checkpoint at a
776
+ // time and stop after the first block with events.
777
+ let exitAfterFirstEventBlock = processChainConfig.endBlock->Option.isNone
778
+
779
+ // Rebuild the processConfig JSON that SimulateItems.patchConfig reads
780
+ // to turn `simulate` items into a SimulateSource for the chain.
781
+ let resolvedChainDict: dict<unknown> = Dict.make()
782
+ resolvedChainDict->Dict.set(
783
+ "startBlock",
784
+ processChainConfig.startBlock->(Utils.magic: int => unknown),
785
+ )
786
+ switch processChainConfig.endBlock {
787
+ | Some(eb) => resolvedChainDict->Dict.set("endBlock", eb->(Utils.magic: int => unknown))
788
+ | None => ()
660
789
  }
661
-
662
- // Sort chain keys by chain ID for deterministic ordering
663
- let sortedChainKeys = chainKeys->Array.copy
664
- sortedChainKeys->Array.sort((a, b) => {
665
- let aId = a->Int.fromString->Option.getOr(0)
666
- let bId = b->Int.fromString->Option.getOr(0)
667
- Int.compare(aId, bId)
668
- })
669
-
670
- // Parse and validate the block ranges upfront before starting any workers.
671
- let chainEntries = sortedChainKeys->Array.map(chainIdStr => {
672
- let rawChainConfig = rawChains->Dict.getUnsafe(chainIdStr)
673
- let chainId = switch chainIdStr->Int.fromString {
674
- | Some(id) => id
675
- | None =>
676
- JsError.throwWithMessage(
677
- `Invalid chain ID "${chainIdStr}": expected a numeric chain ID`,
678
- )
679
- }
680
- let processChainConfig = parseBlockRange(
681
- ~chainIdStr,
682
- ~config,
683
- ~rawChainConfig,
684
- ~progressBlock=state.progressBlockByChain->Dict.get(chainIdStr),
685
- )
686
- (chainIdStr, chainId, rawChainConfig, processChainConfig)
687
- })
688
-
689
- // Reset processChanges for this run
690
- state.processChanges = []
691
-
692
- let runChainWorker = ((
790
+ switch rawChainConfig.simulate {
791
+ | Some(s) =>
792
+ resolvedChainDict->Dict.set("simulate", s->(Utils.magic: array<JSON.t> => unknown))
793
+ | None => ()
794
+ }
795
+ let resolvedChainsDict: dict<unknown> = Dict.make()
796
+ resolvedChainsDict->Dict.set(
693
797
  chainIdStr,
694
- chainId,
695
- rawChainConfig: rawChainConfig,
696
- processChainConfig,
697
- )) => {
698
- // Build initialState from resolved block range
699
- let chains: dict<chainConfig> = Dict.make()
700
- chains->Dict.set(chainIdStr, processChainConfig)
701
-
702
- // Rebuilt per chain so workers see contracts registered by earlier
703
- // chains in the same process() call.
704
- let indexingAddressesByChain = getIndexingAddressesByChain(state)
705
-
706
- let initialState = makeInitialState(
707
- ~config,
708
- ~processConfigChains=chains,
709
- ~indexingAddressesByChain,
710
- )
711
-
712
- Promise.make((resolve, reject) => {
713
- let workerData: workerData = {
714
- chainId,
715
- startBlock: processChainConfig.startBlock,
716
- endBlock: processChainConfig.endBlock,
717
- simulate: rawChainConfig.simulate,
718
- initialState,
719
- }
720
- let worker = try {
721
- NodeJs.WorkerThreads.makeWorker(
722
- workerPath,
723
- {
724
- workerData: workerData->(Utils.magic: workerData => JSON.t),
725
- // Explicitly forward parent env so handlers running in
726
- // the worker observe the same environment as the test
727
- // process (e.g. E2E_EXPECTED_END_BLOCK).
728
- env: %raw(`process.env`),
729
- },
730
- )
731
- } catch {
732
- | exn =>
733
- reject(exn->Utils.magic)
734
- throw(exn)
735
- }
736
-
737
- // Handle messages from worker
738
- worker->NodeJs.WorkerThreads.onMessage((
739
- msg: TestIndexerProxyStorage.workerMessage,
740
- ) => {
741
- let respond = data =>
742
- worker->NodeJs.WorkerThreads.workerPostMessage(
743
- {
744
- TestIndexerProxyStorage.id: msg.id,
745
- payload: TestIndexerProxyStorage.Response({data: data}),
746
- }->Utils.magic,
747
- )
748
-
749
- switch msg.payload {
750
- | Load({tableName, filter}) => state->handleLoad(~tableName, ~filter)->respond
751
-
752
- | WriteBatch({
753
- updatedEntities,
754
- checkpointIds,
755
- checkpointChainIds,
756
- checkpointBlockNumbers,
757
- checkpointBlockHashes: _,
758
- checkpointEventsProcessed,
759
- }) =>
760
- state->handleWriteBatch(
761
- ~updatedEntities,
762
- ~checkpointIds,
763
- ~checkpointChainIds,
764
- ~checkpointBlockNumbers,
765
- ~checkpointEventsProcessed,
766
- )
767
- JSON.Encode.null->respond
768
- }
769
- })
770
-
771
- worker->NodeJs.WorkerThreads.onError(err => {
772
- worker->NodeJs.WorkerThreads.terminate->ignore
773
- reject(err)
774
- })
775
-
776
- worker->NodeJs.WorkerThreads.onExit(code => {
777
- if code !== 0 {
778
- reject(Utils.Error.make(`Worker exited with code ${code->Int.toString}`))
779
- } else {
780
- resolve()
798
+ resolvedChainDict->(Utils.magic: dict<unknown> => unknown),
799
+ )
800
+ let processConfigJson =
801
+ {"chains": resolvedChainsDict}->(Utils.magic: {"chains": dict<unknown>} => JSON.t)
802
+
803
+ // Each run gets its own copy of the shared base registration so the
804
+ // simulate-source registration it appends stays isolated.
805
+ let registrationsByChainId = cloneRegistrations(await getRegistrations(~config))
806
+ let patchedConfig: Config.t = SimulateItems.patchConfig(
807
+ ~config,
808
+ ~processConfig=processConfigJson,
809
+ ~registrationsByChainId,
810
+ )
811
+ let runConfig = {...patchedConfig, shouldRollbackOnReorg: false}
812
+ let runConfig = exitAfterFirstEventBlock ? {...runConfig, batchSize: 1} : runConfig
813
+
814
+ // Bypass Persistence.init: hand the loop the config-derived initial
815
+ // state directly (never a real DB) and mark the storage Ready so
816
+ // writes go through.
817
+ persistence.storageStatus = Ready(initialState)
818
+
819
+ let indexerStateRef = ref(None)
820
+ // Stop the loop and let any in-flight processing/write settle, so a
821
+ // finished run leaves nothing driving the shared state into the next.
822
+ let cleanup = async () => {
823
+ switch indexerStateRef.contents {
824
+ | Some(indexerState) =>
825
+ indexerState->IndexerState.stop
826
+ while (
827
+ indexerState->IndexerState.isProcessing ||
828
+ indexerState->IndexerState.writeFiber->Option.isSome
829
+ ) {
830
+ switch indexerState->IndexerState.writeFiber {
831
+ // Await the in-flight write directly; only fall back to a tick
832
+ // yield while processing hasn't yet spawned a write fiber.
833
+ | Some(fiber) => await fiber
834
+ | None => await Utils.delay(0)
781
835
  }
782
- })
783
- })
836
+ }
837
+ | None => ()
838
+ }
784
839
  }
785
-
786
- // Set flag before starting workers
787
- state.processInProgress = true
788
-
789
- // Run worker threads sequentially, one chain at a time
790
- let rec runChains = idx => {
791
- if idx >= chainEntries->Array.length {
792
- state.processInProgress = false
793
- Promise.resolve({changes: state.processChanges})
794
- } else {
795
- runChainWorker(chainEntries->Array.getUnsafe(idx))->Promise.then(_ =>
796
- runChains(idx + 1)
840
+ try {
841
+ await Promise.make((resolve, reject) => {
842
+ let indexerState = IndexerState.makeFromDbState(
843
+ ~config=runConfig,
844
+ ~persistence,
845
+ ~initialState,
846
+ ~registrationsByChainId,
847
+ ~exitAfterFirstEventBlock,
848
+ ~onError=errHandler => {
849
+ errHandler->ErrorHandling.log
850
+ reject(errHandler.exn->Utils.prettifyExn)
851
+ },
852
+ // Caught up: resolve the run instead of exiting the process.
853
+ ~onExit=() => resolve(),
797
854
  )
798
- }
855
+ indexerStateRef := Some(indexerState)
856
+ indexerState->IndexerLoop.start
857
+ })
858
+ await cleanup()
859
+ } catch {
860
+ | exn =>
861
+ await cleanup()
862
+ throw(exn)
799
863
  }
800
-
801
- runChains(0)->Promise.catch(err => {
802
- state.processInProgress = false
803
- Promise.reject(err->Utils.prettifyExn)
804
- })
805
864
  }
806
- )->(Utils.magic: ('a => promise<processResult>) => unknown),
807
- )
808
-
809
- result->(Utils.magic: dict<unknown> => t<'processConfig>)
810
- }
811
- }
812
-
813
- let initTestWorker = () => {
814
- if NodeJs.WorkerThreads.isMainThread {
815
- JsError.throwWithMessage("initTestWorker must be called from a worker thread")
816
- }
817
-
818
- let parentPort = switch NodeJs.WorkerThreads.parentPort->Nullable.toOption {
819
- | Some(port) => port
820
- | None => JsError.throwWithMessage("initTestWorker: No parent port available")
821
- }
822
-
823
- let workerData: option<workerData> = NodeJs.WorkerThreads.workerData->Nullable.toOption
824
- switch workerData {
825
- | Some({chainId, startBlock, endBlock, simulate, initialState}) =>
826
- let chainIdStr = chainId->Int.toString
827
-
828
- // auto-exit mode: no endBlock means fetch first block with events and exit
829
- let exitAfterFirstEventBlock = endBlock->Option.isNone
830
-
831
- // Build processConfig JSON for SimulateItems.patchConfig
832
- let resolvedChainDict: dict<unknown> = Dict.make()
833
- resolvedChainDict->Dict.set("startBlock", startBlock->(Utils.magic: int => unknown))
834
- switch endBlock {
835
- | Some(eb) => resolvedChainDict->Dict.set("endBlock", eb->(Utils.magic: int => unknown))
836
- | None => ()
837
- }
838
- switch simulate {
839
- | Some(s) => resolvedChainDict->Dict.set("simulate", s->(Utils.magic: array<JSON.t> => unknown))
840
- | None => ()
841
- }
842
- let resolvedChainsDict: dict<unknown> = Dict.make()
843
- resolvedChainsDict->Dict.set(
844
- chainIdStr,
845
- resolvedChainDict->(Utils.magic: dict<unknown> => unknown),
846
- )
847
- let processConfig =
848
- {"chains": resolvedChainsDict}->(Utils.magic: {"chains": dict<unknown>} => JSON.t)
849
-
850
- // Create proxy storage that communicates with main thread
851
- let proxy = TestIndexerProxyStorage.make(~parentPort, ~initialState)
852
- let storage = TestIndexerProxyStorage.makeStorage(proxy)
853
- let config = Config.load()
854
- let persistence = Persistence.make(
855
- ~userEntities=config.userEntities,
856
- ~allEnums=config.allEnums,
857
- ~storage,
858
- )
859
865
 
860
- // Silence logs by default in test mode unless LOG_LEVEL is explicitly set
861
- switch Env.userLogLevel {
862
- | None => Logging.setLogLevel(#silent)
863
- | Some(_) => ()
864
- }
866
+ // Set flag before starting the run
867
+ state.processInProgress = true
865
868
 
866
- let patchConfig = (config: Config.t, registrationsByChainId) => {
867
- let config = SimulateItems.patchConfig(~config, ~processConfig, ~registrationsByChainId)
869
+ // Run chains sequentially, one at a time
870
+ let rec runChains = idx => {
871
+ if idx >= chainEntries->Array.length {
872
+ state.processInProgress = false
873
+ Promise.resolve({changes: state.processChanges})
874
+ } else {
875
+ runChainInProcess(chainEntries->Array.getUnsafe(idx))->Promise.then(_ =>
876
+ runChains(idx + 1)
877
+ )
878
+ }
879
+ }
868
880
 
869
- // In auto-exit mode, set batchSize=1 to process one block checkpoint at a time
870
- if exitAfterFirstEventBlock {
871
- {...config, batchSize: 1}
872
- } else {
873
- config
874
- }
875
- }
876
- Main.start(~persistence, ~isTest=true, ~patchConfig, ~exitAfterFirstEventBlock)
877
- ->Promise.catch(exn => {
878
- // `Main.start` rejects on any fatal error: a runtime failure arrives wrapped
879
- // in `Main.FatalError` (already logged), a setup throw (e.g. an invalid
880
- // simulate item) arrives raw. The parent only learns of failures
881
- // through the worker `error` event, which fires on an *uncaught* exception.
882
- // Throwing synchronously in this catch would just reject the catch's own
883
- // promise (swallowed by `ignore`); `setImmediate` re-throws outside the
884
- // promise chain so it becomes uncaught and reaches the parent.
885
- let toThrow = switch exn {
886
- | Main.FatalError(inner) => inner
887
- | _ => exn->Utils.prettifyExn
881
+ runChains(0)->Promise.catch(err => {
882
+ state.processInProgress = false
883
+ Promise.reject(err->Utils.prettifyExn)
884
+ })
888
885
  }
889
- NodeJs.setImmediate(() => throw(toThrow))
890
- Promise.resolve()
891
- })
892
- ->ignore
893
- | None =>
894
- Logging.error("TestIndexerWorker: No worker data provided")
895
- NodeJs.process->NodeJs.exitWithCode(Failure)
896
- }
886
+ )->(Utils.magic: ('a => promise<processResult>) => unknown),
887
+ )
888
+
889
+ result->(Utils.magic: dict<unknown> => t<'processConfig>)
897
890
  }