envio 3.3.2 → 3.5.0-alpha.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 (61) 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/Bin.res +2 -2
  5. package/src/ChainState.res +6 -1
  6. package/src/ChainState.res.mjs +4 -3
  7. package/src/Config.res +38 -5
  8. package/src/Config.res.mjs +35 -6
  9. package/src/Core.res +65 -20
  10. package/src/Core.res.mjs +36 -6
  11. package/src/EnvioGlobal.res +0 -2
  12. package/src/EnvioGlobal.res.mjs +0 -1
  13. package/src/ExitOnCaughtUp.res +6 -2
  14. package/src/ExitOnCaughtUp.res.mjs +4 -0
  15. package/src/FetchState.res +3 -3
  16. package/src/HandlerRegister.res +357 -434
  17. package/src/HandlerRegister.res.mjs +202 -242
  18. package/src/HandlerRegister.resi +7 -15
  19. package/src/IndexerState.res +10 -0
  20. package/src/IndexerState.res.mjs +9 -3
  21. package/src/IndexerState.resi +3 -0
  22. package/src/Internal.res +10 -1
  23. package/src/Main.res.mjs +4 -4
  24. package/src/PgStorage.res +16 -1
  25. package/src/PgStorage.res.mjs +9 -1
  26. package/src/SimulateItems.res +61 -40
  27. package/src/SimulateItems.res.mjs +37 -21
  28. package/src/TestIndexer.res +453 -454
  29. package/src/TestIndexer.res.mjs +321 -344
  30. package/src/bindings/ClickHouse.res +68 -2
  31. package/src/bindings/ClickHouse.res.mjs +47 -2
  32. package/src/sources/EvmChain.res +1 -1
  33. package/src/sources/EvmChain.res.mjs +2 -2
  34. package/src/sources/FuelHyperSync.res +74 -0
  35. package/src/sources/FuelHyperSync.res.mjs +80 -0
  36. package/src/sources/FuelHyperSync.resi +22 -0
  37. package/src/sources/FuelHyperSyncClient.res +120 -0
  38. package/src/sources/FuelHyperSyncClient.res.mjs +71 -0
  39. package/src/sources/FuelHyperSyncSource.res +257 -0
  40. package/src/sources/FuelHyperSyncSource.res.mjs +252 -0
  41. package/src/sources/HyperSyncClient.res +2 -2
  42. package/src/sources/HyperSyncClient.res.mjs +1 -1
  43. package/src/sources/SvmHyperSyncClient.res +139 -102
  44. package/src/sources/SvmHyperSyncClient.res.mjs +45 -5
  45. package/src/sources/SvmHyperSyncSource.res +60 -440
  46. package/src/sources/SvmHyperSyncSource.res.mjs +42 -363
  47. package/src/TestIndexerProxyStorage.res +0 -196
  48. package/src/TestIndexerProxyStorage.res.mjs +0 -121
  49. package/src/TestIndexerWorker.res +0 -4
  50. package/src/TestIndexerWorker.res.mjs +0 -7
  51. package/src/sources/EventRouter.res +0 -165
  52. package/src/sources/EventRouter.res.mjs +0 -153
  53. package/src/sources/HyperFuel.res +0 -179
  54. package/src/sources/HyperFuel.res.mjs +0 -146
  55. package/src/sources/HyperFuel.resi +0 -36
  56. package/src/sources/HyperFuelClient.res +0 -127
  57. package/src/sources/HyperFuelClient.res.mjs +0 -20
  58. package/src/sources/HyperFuelSource.res +0 -502
  59. package/src/sources/HyperFuelSource.res.mjs +0 -481
  60. /package/src/sources/{HyperSyncSource.res → EvmHyperSyncSource.res} +0 -0
  61. /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
 
@@ -237,7 +199,11 @@ let handleWriteBatch = (
237
199
  if deleted->Array.length > 0 {
238
200
  entityObj->Dict.set("deleted", deleted->(Utils.magic: array<string> => unknown))
239
201
  }
240
- change->Dict.set(entityName, entityObj->(Utils.magic: dict<unknown> => unknown))
202
+ // Match the capitalized entity accessor the generated change types expose.
203
+ change->Dict.set(
204
+ entityName->Utils.String.capitalize,
205
+ entityObj->(Utils.magic: dict<unknown> => unknown),
206
+ )
241
207
  }
242
208
  })
243
209
  | None => ()
@@ -285,8 +251,6 @@ let makeInitialState = (
285
251
  chains,
286
252
  checkpointId: InternalTable.Checkpoints.initialCheckpointId,
287
253
  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
254
  envioInfo: Some(Config.getPublicConfigJson()->Config.stripSensitiveData),
291
255
  }
292
256
  }
@@ -406,6 +370,18 @@ let parseBlockRange = (
406
370
  {startBlock, endBlock}
407
371
  }
408
372
 
373
+ // The store owns its entities. Copy on the boundary with user code — both when
374
+ // handing one out (get/getAll/getOrThrow) and when taking one in (set) — so a
375
+ // user mutating a returned entity, or an object they passed to `set`, can't
376
+ // corrupt the in-memory store. The copy is shallow (matching InMemoryTable):
377
+ // scalar fields (string/bigint/BigDecimal) are immutable, but array-valued
378
+ // fields still share the backing array, so in-place mutation of those leaks.
379
+ let copyEntity = (entity: Internal.entity): Internal.entity =>
380
+ entity
381
+ ->(Utils.magic: Internal.entity => dict<unknown>)
382
+ ->Utils.Dict.shallowCopy
383
+ ->(Utils.magic: dict<unknown> => Internal.entity)
384
+
409
385
  // Entity operations for direct manipulation outside of handlers
410
386
  let getEntityFromState = (
411
387
  ~state: testIndexerState,
@@ -419,7 +395,7 @@ let getEntityFromState = (
419
395
  )
420
396
  }
421
397
  let entityDict = state.entities->Dict.get(entityConfig.name)->Option.getOr(Dict.make())
422
- entityDict->Dict.get(entityId)
398
+ entityDict->Dict.get(entityId)->Option.map(copyEntity)
423
399
  }
424
400
 
425
401
  let makeEntityGet = (~state: testIndexerState, ~entityConfig: Internal.entityConfig): (
@@ -462,7 +438,7 @@ let makeEntitySet = (~state: testIndexerState, ~entityConfig: Internal.entityCon
462
438
  state.entities->Dict.set(entityConfig.name, dict)
463
439
  dict
464
440
  }
465
- entityDict->Dict.set(entity.id, entity)
441
+ entityDict->Dict.set(entity.id, copyEntity(entity))
466
442
  }
467
443
  }
468
444
 
@@ -476,7 +452,7 @@ let makeEntityGetAll = (~state: testIndexerState, ~entityConfig: Internal.entity
476
452
  )
477
453
  }
478
454
  let entityDict = state.entities->Dict.get(entityConfig.name)->Option.getOr(Dict.make())
479
- Promise.resolve(entityDict->Dict.valuesToArray)
455
+ Promise.resolve(entityDict->Dict.valuesToArray->Array.map(copyEntity))
480
456
  }
481
457
  }
482
458
 
@@ -487,411 +463,434 @@ type entityOperations = {
487
463
  set: Internal.entity => unit,
488
464
  }
489
465
 
490
- type workerData = {
491
- chainId: int,
492
- startBlock: int,
493
- endBlock: option<int>,
494
- simulate: option<array<JSON.t>>,
495
- initialState: Persistence.initialState,
466
+ // Adapt the real storage interface to the in-memory entity store. In-process
467
+ // there's no worker boundary, so entities are stored and loaded decoded — no
468
+ // JSON serialization round-trip.
469
+ let makeInMemoryStorage = (~state: testIndexerState): Persistence.storage => {
470
+ name: "test-inmemory",
471
+ isInitialized: async () => true,
472
+ // The runner injects the config-derived initial state by setting
473
+ // `persistence.storageStatus = Ready(...)` directly, bypassing `Persistence.init`,
474
+ // so neither of these is reached.
475
+ initialize: async (~chainConfigs as _=?, ~entities as _=?, ~enums as _=?, ~envioInfo as _) =>
476
+ JsError.throwWithMessage(
477
+ "TestIndexer: initialize should not be called; the initial state is derived from config.",
478
+ ),
479
+ resumeInitialState: async () =>
480
+ JsError.throwWithMessage(
481
+ "TestIndexer: resumeInitialState should not be called; the initial state is derived from config.",
482
+ ),
483
+ loadOrThrow: async (~filter, ~table: Table.table) =>
484
+ state
485
+ ->handleLoad(~tableName=table.tableName, ~filter)
486
+ ->(Utils.magic: array<Internal.entity> => array<unknown>),
487
+ writeBatch: async (
488
+ ~batch,
489
+ ~rollback as _,
490
+ ~isInReorgThreshold as _,
491
+ ~config as _,
492
+ ~allEntities as _,
493
+ ~updatedEffectsCache as _,
494
+ ~updatedEntities,
495
+ ~chainMetaData as _,
496
+ ~onWrite as _,
497
+ ) =>
498
+ state->handleWriteBatch(
499
+ ~updatedEntities,
500
+ ~checkpointIds=batch.checkpointIds,
501
+ ~checkpointChainIds=batch.checkpointChainIds,
502
+ ~checkpointBlockNumbers=batch.checkpointBlockNumbers,
503
+ ~checkpointEventsProcessed=batch.checkpointEventsProcessed,
504
+ ),
505
+ dumpEffectCache: async () => (),
506
+ reset: async () => (),
507
+ setChainMeta: async _ => Obj.magic(),
508
+ pruneStaleCheckpoints: async (~safeCheckpointId as _) => (),
509
+ pruneStaleEntityHistory: async (~entityName as _, ~entityIndex as _, ~safeCheckpointId as _) =>
510
+ (),
511
+ getRollbackTargetCheckpoint: async (~reorgChainId as _, ~lastKnownValidBlockNumber as _) =>
512
+ JsError.throwWithMessage(
513
+ "TestIndexer: Rollback is not supported. The runner forces rollbackOnReorg off, so this should be unreachable.",
514
+ ),
515
+ getRollbackProgressDiff: async (~rollbackTargetCheckpointId as _) =>
516
+ JsError.throwWithMessage(
517
+ "TestIndexer: Rollback is not supported. The runner forces rollbackOnReorg off, so this should be unreachable.",
518
+ ),
519
+ getRollbackData: async (~entityConfig as _, ~rollbackTargetCheckpointId as _) =>
520
+ JsError.throwWithMessage(
521
+ "TestIndexer: Rollback is not supported. The runner forces rollbackOnReorg off, so this should be unreachable.",
522
+ ),
523
+ close: async () => (),
496
524
  }
497
525
 
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
- })
526
+ // Copy the per-chain registration arrays so a process() run's simulate-source
527
+ // additions (SimulateItems.patchConfig pushes onEventRegistrations) never
528
+ // mutate the shared base registration — lets independent createTestIndexer runs
529
+ // proceed in parallel without clobbering each other's registrations.
530
+ let cloneRegistrations = (
531
+ base: HandlerRegister.registrationsByChainId,
532
+ ): HandlerRegister.registrationsByChainId => {
533
+ let clone = Dict.make()
534
+ base
535
+ ->Dict.toArray
536
+ ->Array.forEach(((chainIdStr, chainRegistrations: HandlerRegister.chainRegistrations)) =>
537
+ clone->Dict.set(
538
+ chainIdStr,
539
+ {
540
+ HandlerRegister.onEventRegistrations: chainRegistrations.onEventRegistrations->Array.copy,
541
+ onBlockRegistrations: chainRegistrations.onBlockRegistrations->Array.copy,
542
+ },
543
+ )
544
+ )
545
+ clone
546
+ }
509
547
 
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
- })
548
+ // User handlers register into the process-global HandlerRegister as an import
549
+ // side effect. Capture the resolved registrations once per process (imports are
550
+ // module-cached anyway) and reuse them across every createTestIndexer run, so
551
+ // the global registration cycle runs a single time and never races.
552
+ let registrationsRef: ref<option<promise<HandlerRegister.registrationsByChainId>>> = ref(None)
553
+ let getRegistrations = (~config) =>
554
+ switch registrationsRef.contents {
555
+ | Some(promise) => promise
556
+ | None =>
557
+ let promise = HandlerLoader.registerAllHandlers(~config)
558
+ registrationsRef := Some(promise)
559
+ promise
560
+ }
530
561
 
531
- let state = {
532
- processInProgress: false,
533
- progressBlockByChain: Dict.make(),
534
- entities,
535
- entityConfigs,
536
- processChanges: [],
537
- }
562
+ let createTestIndexer = (): t<'processConfig> => {
563
+ let config = Config.load()
564
+ let allEntities = config.allEntities
565
+ let entities = Dict.make()
566
+ let entityConfigs = Dict.make()
567
+ allEntities->Array.forEach(entityConfig => {
568
+ entities->Dict.set(entityConfig.name, Dict.make())
569
+ entityConfigs->Dict.set(entityConfig.name, entityConfig)
570
+ })
538
571
 
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
- }
572
+ // Populate config addresses into the entity dict, mirroring PgStorage.initialize
573
+ let envioAddressesDict = entities->Dict.getUnsafe(InternalTable.EnvioAddresses.name)
574
+ config.chainMap
575
+ ->ChainMap.values
576
+ ->Array.forEach(chainConfig => {
577
+ chainConfig.contracts->Array.forEach(contract => {
578
+ contract.addresses->Array.forEach(
579
+ address => {
580
+ let entity: InternalTable.EnvioAddresses.t = {
581
+ id: Config.EnvioAddresses.makeId(~chainId=chainConfig.id, ~address),
582
+ chainId: chainConfig.id,
583
+ contractName: contract.name,
584
+ registrationBlock: -1,
585
+ registrationLogIndex: -1,
586
+ }
587
+ envioAddressesDict->Dict.set(entity.id, entity->Config.EnvioAddresses.castToInternal)
588
+ },
589
+ )
554
590
  })
591
+ })
555
592
 
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
593
+ let state = {
594
+ processInProgress: false,
595
+ progressBlockByChain: Dict.make(),
596
+ entities,
597
+ entityConfigs,
598
+ processChanges: [],
599
+ }
564
600
 
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},
601
+ // Per-instance in-memory storage over `state.entities`. Separate indexers
602
+ // get separate storages, so independent indexers run in parallel without
603
+ // shared mutable state.
604
+ let storage = makeInMemoryStorage(~state)
605
+ let persistence = Persistence.make(
606
+ ~userEntities=config.userEntities,
607
+ ~allEnums=config.allEnums,
608
+ ~storage,
609
+ )
610
+
611
+ // Silence logs by default in test mode unless LOG_LEVEL is explicitly set.
612
+ switch Env.userLogLevel {
613
+ | None => Logging.setLogLevel(#silent)
614
+ | Some(_) => ()
615
+ }
616
+
617
+ // Build entity operations for each user entity
618
+ let entityOpsDict: dict<entityOperations> = Dict.make()
619
+ allEntities->Array.forEach(entityConfig => {
620
+ // Only create ops for user entities (not internal tables like envio_addresses)
621
+ if entityConfig.name !== InternalTable.EnvioAddresses.name {
622
+ entityOpsDict->Dict.set(
623
+ entityConfig.name,
624
+ {
625
+ get: makeEntityGet(~state, ~entityConfig),
626
+ getAll: makeEntityGetAll(~state, ~entityConfig),
627
+ getOrThrow: makeEntityGetOrThrow(~state, ~entityConfig),
628
+ set: makeEntitySet(~state, ~entityConfig),
629
+ },
571
630
  )
572
- ->Utils.Object.definePropertyWithValue(
573
- "endBlock",
574
- {enumerable: true, value: chainConfig.endBlock},
631
+ }
632
+ })
633
+
634
+ // Build chain info from config (similar to Main.getGlobalIndexer but static)
635
+ let chainIds = []
636
+ let chains = Utils.Object.createNullObject()
637
+ config.chainMap
638
+ ->ChainMap.values
639
+ ->Array.forEach(chainConfig => {
640
+ let chainIdStr = chainConfig.id->Int.toString
641
+ chainIds->Array.push(chainConfig.id)->ignore
642
+
643
+ let chainObj = Utils.Object.createNullObject()
644
+ chainObj
645
+ ->Utils.Object.definePropertyWithValue("id", {enumerable: true, value: chainConfig.id})
646
+ ->Utils.Object.definePropertyWithValue(
647
+ "startBlock",
648
+ {enumerable: true, value: chainConfig.startBlock},
649
+ )
650
+ ->Utils.Object.definePropertyWithValue(
651
+ "endBlock",
652
+ {enumerable: true, value: chainConfig.endBlock},
653
+ )
654
+ ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: chainConfig.name})
655
+ ->Utils.Object.definePropertyWithValue("isRealtime", {enumerable: true, value: false})
656
+ ->ignore
657
+
658
+ // Add contracts to chain object
659
+ chainConfig.contracts->Array.forEach(contract => {
660
+ let contractObj = Utils.Object.createNullObject()
661
+ contractObj
662
+ ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: contract.name})
663
+ ->Utils.Object.definePropertyWithValue("abi", {enumerable: true, value: contract.abi})
664
+ ->Utils.Object.defineProperty(
665
+ "addresses",
666
+ {
667
+ enumerable: true,
668
+ get: () => {
669
+ if state.processInProgress {
670
+ JsError.throwWithMessage(
671
+ `Cannot access ${contract.name}.addresses while indexer.process() is running. ` ++ "Wait for process() to complete before reading contract addresses.",
672
+ )
673
+ }
674
+ getIndexingAddressesByChain(state)
675
+ ->Dict.get(chainConfig.id->Int.toString)
676
+ ->Option.getOr([])
677
+ ->Array.filterMap(ia => ia.contractName === contract.name ? Some(ia.address) : None)
678
+ },
679
+ },
575
680
  )
576
- ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: chainConfig.name})
577
- ->Utils.Object.definePropertyWithValue("isRealtime", {enumerable: true, value: false})
578
681
  ->ignore
579
682
 
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
- })
683
+ chainObj
684
+ ->Utils.Object.definePropertyWithValue(contract.name, {enumerable: true, value: contractObj})
685
+ ->ignore
686
+ })
612
687
 
688
+ chains
689
+ ->Utils.Object.definePropertyWithValue(chainIdStr, {enumerable: true, value: chainObj})
690
+ ->ignore
691
+
692
+ if chainConfig.name !== chainIdStr {
613
693
  chains
614
- ->Utils.Object.definePropertyWithValue(chainIdStr, {enumerable: true, value: chainObj})
694
+ ->Utils.Object.definePropertyWithValue(chainConfig.name, {enumerable: false, value: chainObj})
615
695
  ->ignore
696
+ }
697
+ })
616
698
 
617
- if chainConfig.name !== chainIdStr {
618
- chains
619
- ->Utils.Object.definePropertyWithValue(
620
- chainConfig.name,
621
- {enumerable: false, value: chainObj},
622
- )
623
- ->ignore
624
- }
625
- })
699
+ // Build the result object with process + entity operations + chain info
700
+ let result: dict<unknown> = Dict.make()
701
+ result->Dict.set("chainIds", chainIds->(Utils.magic: array<int> => unknown))
702
+ result->Dict.set("chains", chains->(Utils.magic: {..} => unknown))
703
+ entityOpsDict
704
+ ->Dict.toArray
705
+ ->Array.forEach(((name, ops)) => {
706
+ // Expose the capitalized accessor (indexer.Pool_snapshots) the generated
707
+ // types declare, matching the handler-context keys.
708
+ result->Dict.set(name->Utils.String.capitalize, ops->(Utils.magic: entityOperations => unknown))
709
+ })
626
710
 
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
- })
711
+ result->Dict.set(
712
+ "process",
713
+ (
714
+ processConfig => {
715
+ // Check if already processing
716
+ if state.processInProgress {
717
+ JsError.throwWithMessage(
718
+ "createTestIndexer process is already running. Only one process call is allowed at a time",
719
+ )
720
+ }
636
721
 
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
- }
722
+ // Parse and validate processConfig
723
+ let parsedConfig = try processConfig->S.parseOrThrow(processConfigSchema) catch {
724
+ | S.Raised(exn) =>
725
+ JsError.throwWithMessage(
726
+ `Invalid processConfig: ${exn->Utils.prettifyExn->(Utils.magic: exn => string)}`,
727
+ )
728
+ }
729
+ let rawChains = parsedConfig["chains"]
730
+ let chainKeys = rawChains->Dict.keysToArray
647
731
 
648
- // Parse and validate processConfig
649
- let parsedConfig = try processConfig->S.parseOrThrow(processConfigSchema) catch {
650
- | S.Raised(exn) =>
732
+ if chainKeys->Array.length === 0 {
733
+ JsError.throwWithMessage("createTestIndexer requires at least one chain to be defined")
734
+ }
735
+
736
+ // Sort chain keys by chain ID for deterministic ordering
737
+ let sortedChainKeys = chainKeys->Array.copy
738
+ sortedChainKeys->Array.sort((a, b) => {
739
+ let aId = a->Int.fromString->Option.getOr(0)
740
+ let bId = b->Int.fromString->Option.getOr(0)
741
+ Int.compare(aId, bId)
742
+ })
743
+
744
+ // Parse and validate the block ranges upfront before running any chain.
745
+ let chainEntries = sortedChainKeys->Array.map(chainIdStr => {
746
+ let rawChainConfig = rawChains->Dict.getUnsafe(chainIdStr)
747
+ if chainIdStr->Int.fromString->Option.isNone {
651
748
  JsError.throwWithMessage(
652
- `Invalid processConfig: ${exn->Utils.prettifyExn->(Utils.magic: exn => string)}`,
749
+ `Invalid chain ID "${chainIdStr}": expected a numeric chain ID`,
653
750
  )
654
751
  }
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")
752
+ let processChainConfig = parseBlockRange(
753
+ ~chainIdStr,
754
+ ~config,
755
+ ~rawChainConfig,
756
+ ~progressBlock=state.progressBlockByChain->Dict.get(chainIdStr),
757
+ )
758
+ (chainIdStr, rawChainConfig, processChainConfig)
759
+ })
760
+
761
+ // Reset processChanges for this run
762
+ state.processChanges = []
763
+
764
+ let runChainInProcess = async ((
765
+ chainIdStr,
766
+ rawChainConfig: rawChainConfig,
767
+ processChainConfig,
768
+ )) => {
769
+ // Build initialState from resolved block range. Rebuilt per chain so
770
+ // later chains in the same process() call see contracts registered
771
+ // by earlier ones.
772
+ let chains: dict<chainConfig> = Dict.make()
773
+ chains->Dict.set(chainIdStr, processChainConfig)
774
+ let indexingAddressesByChain = getIndexingAddressesByChain(state)
775
+ let initialState = makeInitialState(
776
+ ~config,
777
+ ~processConfigChains=chains,
778
+ ~indexingAddressesByChain,
779
+ )
780
+
781
+ // No endBlock means auto-exit mode: process one block checkpoint at a
782
+ // time and stop after the first block with events.
783
+ let exitAfterFirstEventBlock = processChainConfig.endBlock->Option.isNone
784
+
785
+ // Rebuild the processConfig JSON that SimulateItems.patchConfig reads
786
+ // to turn `simulate` items into a SimulateSource for the chain.
787
+ let resolvedChainDict: dict<unknown> = Dict.make()
788
+ resolvedChainDict->Dict.set(
789
+ "startBlock",
790
+ processChainConfig.startBlock->(Utils.magic: int => unknown),
791
+ )
792
+ switch processChainConfig.endBlock {
793
+ | Some(eb) => resolvedChainDict->Dict.set("endBlock", eb->(Utils.magic: int => unknown))
794
+ | None => ()
660
795
  }
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 = ((
796
+ switch rawChainConfig.simulate {
797
+ | Some(s) =>
798
+ resolvedChainDict->Dict.set("simulate", s->(Utils.magic: array<JSON.t> => unknown))
799
+ | None => ()
800
+ }
801
+ let resolvedChainsDict: dict<unknown> = Dict.make()
802
+ resolvedChainsDict->Dict.set(
693
803
  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()
804
+ resolvedChainDict->(Utils.magic: dict<unknown> => unknown),
805
+ )
806
+ let processConfigJson =
807
+ {"chains": resolvedChainsDict}->(Utils.magic: {"chains": dict<unknown>} => JSON.t)
808
+
809
+ // Each run gets its own copy of the shared base registration so the
810
+ // simulate-source registration it appends stays isolated.
811
+ let registrationsByChainId = cloneRegistrations(await getRegistrations(~config))
812
+ let patchedConfig: Config.t = SimulateItems.patchConfig(
813
+ ~config,
814
+ ~processConfig=processConfigJson,
815
+ ~registrationsByChainId,
816
+ )
817
+ let runConfig = {...patchedConfig, shouldRollbackOnReorg: false}
818
+ let runConfig = exitAfterFirstEventBlock ? {...runConfig, batchSize: 1} : runConfig
819
+
820
+ // Bypass Persistence.init: hand the loop the config-derived initial
821
+ // state directly (never a real DB) and mark the storage Ready so
822
+ // writes go through.
823
+ persistence.storageStatus = Ready(initialState)
824
+
825
+ let indexerStateRef = ref(None)
826
+ // Stop the loop and let any in-flight processing/write settle, so a
827
+ // finished run leaves nothing driving the shared state into the next.
828
+ let cleanup = async () => {
829
+ switch indexerStateRef.contents {
830
+ | Some(indexerState) =>
831
+ indexerState->IndexerState.stop
832
+ while (
833
+ indexerState->IndexerState.isProcessing ||
834
+ indexerState->IndexerState.writeFiber->Option.isSome
835
+ ) {
836
+ switch indexerState->IndexerState.writeFiber {
837
+ // Await the in-flight write directly; only fall back to a tick
838
+ // yield while processing hasn't yet spawned a write fiber.
839
+ | Some(fiber) => await fiber
840
+ | None => await Utils.delay(0)
781
841
  }
782
- })
783
- })
842
+ }
843
+ | None => ()
844
+ }
784
845
  }
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)
846
+ try {
847
+ await Promise.make((resolve, reject) => {
848
+ let indexerState = IndexerState.makeFromDbState(
849
+ ~config=runConfig,
850
+ ~persistence,
851
+ ~initialState,
852
+ ~registrationsByChainId,
853
+ ~exitAfterFirstEventBlock,
854
+ ~onError=errHandler => {
855
+ errHandler->ErrorHandling.log
856
+ reject(errHandler.exn->Utils.prettifyExn)
857
+ },
858
+ // Caught up: resolve the run instead of exiting the process.
859
+ ~onExit=() => resolve(),
797
860
  )
798
- }
861
+ indexerStateRef := Some(indexerState)
862
+ indexerState->IndexerLoop.start
863
+ })
864
+ await cleanup()
865
+ } catch {
866
+ | exn =>
867
+ await cleanup()
868
+ throw(exn)
799
869
  }
800
-
801
- runChains(0)->Promise.catch(err => {
802
- state.processInProgress = false
803
- Promise.reject(err->Utils.prettifyExn)
804
- })
805
870
  }
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
871
 
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
- }
872
+ // Set flag before starting the run
873
+ state.processInProgress = true
865
874
 
866
- let patchConfig = (config: Config.t, registrationsByChainId) => {
867
- let config = SimulateItems.patchConfig(~config, ~processConfig, ~registrationsByChainId)
875
+ // Run chains sequentially, one at a time
876
+ let rec runChains = idx => {
877
+ if idx >= chainEntries->Array.length {
878
+ state.processInProgress = false
879
+ Promise.resolve({changes: state.processChanges})
880
+ } else {
881
+ runChainInProcess(chainEntries->Array.getUnsafe(idx))->Promise.then(_ =>
882
+ runChains(idx + 1)
883
+ )
884
+ }
885
+ }
868
886
 
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
887
+ runChains(0)->Promise.catch(err => {
888
+ state.processInProgress = false
889
+ Promise.reject(err->Utils.prettifyExn)
890
+ })
888
891
  }
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
- }
892
+ )->(Utils.magic: ('a => promise<processResult>) => unknown),
893
+ )
894
+
895
+ result->(Utils.magic: dict<unknown> => t<'processConfig>)
897
896
  }