envio 3.3.0-alpha.8 → 3.3.0-alpha.9

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.
@@ -22,58 +22,26 @@ type chainRegistrations = {
22
22
  // The finished registration state returned by `finishRegistration`.
23
23
  type registrationsByChainId = dict<chainRegistrations>
24
24
 
25
- type pendingRegistrations = {onBlockByChainId: dict<array<Internal.onBlockRegistration>>}
25
+ // Incrementally built during registration: every `indexer.onEvent` /
26
+ // `.contractRegister` call resolves its `where` per chain right away and
27
+ // stores the resulting registration here, keyed by "Contract.Event", so
28
+ // invalid configuration throws at the user's registration call site.
29
+ type pendingChainRegistrations = {
30
+ onEventRegistrations: dict<Internal.onEventRegistration>,
31
+ onBlockRegistrations: array<Internal.onBlockRegistration>,
32
+ }
26
33
 
27
34
  type activeRegistration = {
28
- ecosystem: Ecosystem.t,
29
- registrations: pendingRegistrations,
35
+ config: Config.t,
36
+ registrationsByChainId: dict<pendingChainRegistrations>,
30
37
  mutable finished: bool,
31
38
  }
32
39
 
33
- // Stashed on `globalThis` so a duplicate envio module instance — e.g. when the
34
- // CLI's `bin.mjs` resolves envio from one path but the user's handlers resolve
35
- // it from `node_modules/envio` shares one registry. Without this, each copy
36
- // keeps its own dict and `buildOnEventRegistrations` reads empty state.
37
- //
38
- // Version-gated: the record shapes below can evolve between envio versions,
39
- // so the guard uses strict full-version equality. On mismatch we throw with
40
- // a deduplication hint instead of silently mixing shapes across builds.
41
- type registryShape = {
42
- version: string,
43
- eventRegistrations: dict<eventRegistration>,
44
- activeRegistration: ref<option<activeRegistration>>,
45
- preRegistered: array<activeRegistration => unit>,
46
- }
47
-
48
- // Record type with `mutable` so assignment typechecks; ReScript keeps the
49
- // field name verbatim in the generated JS so the globalThis slot is
50
- // `__envioRegistry`.
51
- type globalThis = {mutable __envioRegistry: Nullable.t<registryShape>}
52
- @val external globalThis: globalThis = "globalThis"
53
-
54
- %%private(
55
- let registry: registryShape = {
56
- let version = Utils.EnvioPackage.value.version
57
- switch globalThis.__envioRegistry->Nullable.toOption {
58
- | Some(existing) if existing.version === version => existing
59
- | Some(existing) =>
60
- JsError.throwWithMessage(
61
- `Multiple incompatible envio versions loaded in the same process: ${existing.version} and ${version}. Deduplicate the 'envio' dependency in your project.`,
62
- )
63
- | None =>
64
- let fresh = {
65
- version,
66
- eventRegistrations: Dict.make(),
67
- activeRegistration: ref(None),
68
- preRegistered: [],
69
- }
70
- globalThis.__envioRegistry = Nullable.make(fresh)
71
- fresh
72
- }
73
- }
74
- )
75
-
76
- let eventRegistrations = registry.eventRegistrations
40
+ // Registration state lives in the process-wide `EnvioGlobal` record (shared
41
+ // across duplicate envio module instances); the slots are opaque there, so
42
+ // cast them to the real types once here.
43
+ let eventRegistrations =
44
+ EnvioGlobal.value.eventRegistrations->(Utils.magic: dict<unknown> => dict<eventRegistration>)
77
45
 
78
46
  let getKey = (~contractName, ~eventName) => contractName ++ "." ++ eventName
79
47
 
@@ -88,7 +56,8 @@ let set = (~contractName, ~eventName, registration) => {
88
56
  eventRegistrations->Dict.set(getKey(~contractName, ~eventName), registration)
89
57
  }
90
58
 
91
- let activeRegistration = registry.activeRegistration
59
+ let getActiveRegistration = () =>
60
+ EnvioGlobal.value.activeRegistration->(Utils.magic: option<unknown> => option<activeRegistration>)
92
61
 
93
62
  // Might happen for tests when the handler file
94
63
  // is imported by a non-envio process (eg mocha)
@@ -97,10 +66,13 @@ let activeRegistration = registry.activeRegistration
97
66
  // Theoretically we could keep preRegistration without an explicit start
98
67
  // but I want it to be this way, so for the actual indexer run
99
68
  // an error is thrown with the exact stack trace where the handler was registered.
100
- let preRegistered = registry.preRegistered
69
+ let preRegistered =
70
+ EnvioGlobal.value.preRegistered->(
71
+ Utils.magic: array<unknown> => array<activeRegistration => unit>
72
+ )
101
73
 
102
74
  let withRegistration = (fn: activeRegistration => unit) => {
103
- switch activeRegistration.contents {
75
+ switch getActiveRegistration() {
104
76
  | None => preRegistered->Array.push(fn)
105
77
  | Some(r) =>
106
78
  if r.finished {
@@ -113,15 +85,13 @@ let withRegistration = (fn: activeRegistration => unit) => {
113
85
  }
114
86
  }
115
87
 
116
- let startRegistration = (~ecosystem) => {
88
+ let startRegistration = (~config: Config.t) => {
117
89
  let r = {
118
- ecosystem,
119
- registrations: {
120
- onBlockByChainId: Dict.make(),
121
- },
90
+ config,
91
+ registrationsByChainId: Dict.make(),
122
92
  finished: false,
123
93
  }
124
- activeRegistration.contents = Some(r)
94
+ EnvioGlobal.value.activeRegistration = Some(r->(Utils.magic: activeRegistration => unknown))
125
95
  while preRegistered->Array.length > 0 {
126
96
  // Loop + cleanup in one go
127
97
  switch preRegistered->Array.pop {
@@ -131,24 +101,30 @@ let startRegistration = (~ecosystem) => {
131
101
  }
132
102
  }
133
103
 
134
- // Enrich one event definition into its (event, chain) registration using
135
- // whatever handler/contractRegister/where the user registered for it. Shared
136
- // by chain startup (`buildOnEventRegistrations`), `simulate`, and test
137
- // helpers so the three stay in sync instead of re-deriving the per-ecosystem
138
- // dispatch each place.
139
- let buildOnEventRegistration = (
104
+ let getPendingChainRegistrations = (r: activeRegistration, ~chainId: int) => {
105
+ let key = chainId->Int.toString
106
+ switch r.registrationsByChainId->Utils.Dict.dangerouslyGetNonOption(key) {
107
+ | Some(pending) => pending
108
+ | None =>
109
+ let fresh = {
110
+ onEventRegistrations: Dict.make(),
111
+ onBlockRegistrations: [],
112
+ }
113
+ r.registrationsByChainId->Dict.set(key, fresh)
114
+ fresh
115
+ }
116
+ }
117
+
118
+ let buildOnEventRegistrationWith = (
140
119
  ~config: Config.t,
141
120
  ~chainId: int,
142
121
  ~eventConfig: Internal.eventConfig,
122
+ ~isWildcard: bool,
123
+ ~handler: option<Internal.handler>,
124
+ ~contractRegister: option<Internal.contractRegister>,
125
+ ~where: option<JSON.t>,
143
126
  ~startBlock=?,
144
127
  ): Internal.onEventRegistration => {
145
- let contractName = eventConfig.contractName
146
- let eventName = eventConfig.name
147
- let t = get(~contractName, ~eventName)
148
- let isWildcard = t.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false)
149
- let handler = t.handler
150
- let contractRegister = t.contractRegister
151
-
152
128
  switch config.ecosystem.name {
153
129
  | Fuel =>
154
130
  (EventConfigBuilder.buildFuelOnEventRegistration(
@@ -174,217 +150,36 @@ let buildOnEventRegistration = (
174
150
  ~isWildcard,
175
151
  ~handler,
176
152
  ~contractRegister,
177
- ~eventFilters=t.eventOptions->Option.flatMap(v => v.where),
178
- ~probeChainId=chainId,
153
+ ~where,
154
+ ~chainId,
179
155
  ~onEventBlockFilterSchema=config.ecosystem.onEventBlockFilterSchema,
180
156
  ~startBlock?,
181
157
  ) :> Internal.onEventRegistration)
182
158
  }
183
159
  }
184
160
 
185
- // Enrich a chain's static event definitions with the registered
186
- // handler/contractRegister/where (validating them along the way) to produce
187
- // the onEventRegistrations ChainState indexes off. Runs once per chain when
188
- // registration finishes, so a bad `where`/duplicate event throws during
189
- // startup with a stack trace pointing here instead of surfacing later from
190
- // inside ChainState's construction. Events without a handler/contractRegister
191
- // get added to `notRegisteredEventsByContract` (event names grouped by
192
- // contract name) so the caller can report them once for the whole indexer
193
- // instead of per chain.
194
- let buildOnEventRegistrations = (
195
- ~chainConfig: Config.chain,
161
+ // Enrich one event definition into its (event, chain) registration using
162
+ // whatever handler/contractRegister/where the user registered for it. Shared
163
+ // by the incremental per-chain sync below, `simulate`, and test helpers so
164
+ // they stay in sync instead of re-deriving the per-ecosystem dispatch each
165
+ // place.
166
+ let buildOnEventRegistration = (
196
167
  ~config: Config.t,
197
- ~notRegisteredEventsByContract: dict<Utils.Set.t<string>>,
198
- ): array<Internal.onEventRegistration> => {
199
- // We don't need the router itself, but only validation logic,
200
- // since now event router is created for selection of events
201
- // and validation doesn't work correctly in routers.
202
- // Ideally to split it into two different parts.
203
- let eventRouter = EventRouter.empty()
204
-
205
- let onEventRegistrations: array<Internal.onEventRegistration> = []
206
-
207
- chainConfig.contracts->Array.forEach(contract => {
208
- let contractName = contract.name
209
-
210
- contract.events->Array.forEach(eventConfig => {
211
- let eventName = eventConfig.name
212
-
213
- let onEventRegistration = buildOnEventRegistration(
214
- ~config,
215
- ~chainId=chainConfig.id,
216
- ~eventConfig,
217
- ~startBlock=?contract.startBlock,
218
- )
219
- let {isWildcard, handler, contractRegister} = onEventRegistration
220
-
221
- // Should validate the events
222
- eventRouter->EventRouter.addOrThrow(
223
- eventConfig.id,
224
- (),
225
- ~contractName,
226
- ~chain=ChainMap.Chain.makeUnsafe(~chainId=chainConfig.id),
227
- ~eventName,
228
- ~isWildcard,
229
- )
230
-
231
- // Filter out events without a handler/contractRegister so they aren't
232
- // fetched or dispatched (unless raw events are enabled).
233
- let shouldBeIncluded = if config.enableRawEvents {
234
- true
235
- } else {
236
- let isRegistered = contractRegister->Option.isSome || handler->Option.isSome
237
- if !isRegistered {
238
- let eventNames = switch notRegisteredEventsByContract->Utils.Dict.dangerouslyGetNonOption(
239
- contractName,
240
- ) {
241
- | Some(set) => set
242
- | None => {
243
- let set = Utils.Set.make()
244
- notRegisteredEventsByContract->Dict.set(contractName, set)
245
- set
246
- }
247
- }
248
- eventNames->Utils.Set.add(eventName)->ignore
249
- }
250
- isRegistered
251
- }
252
-
253
- // Check if event has Static([]) filters (from a dynamic where
254
- // callback returning `false` / SkipAll for this chain).
255
- // If so, skip it entirely - it should never be fetched
256
- let shouldSkip = try {
257
- let getEventFiltersOrThrow = (
258
- onEventRegistration->(
259
- Utils.magic: Internal.onEventRegistration => Internal.evmOnEventRegistration
260
- )
261
- ).getEventFiltersOrThrow
262
-
263
- // Check for non-evm chains
264
- if (
265
- getEventFiltersOrThrow->(Utils.magic: (ChainMap.Chain.t => Internal.eventFilters) => bool)
266
- ) {
267
- switch getEventFiltersOrThrow(ChainMap.Chain.makeUnsafe(~chainId=chainConfig.id)) {
268
- | Static([]) => true
269
- | _ => false
270
- }
271
- } else {
272
- false
273
- }
274
- } catch {
275
- // Can throw when filter is invalid
276
- // Don't skip an event in this case. Let it throw in a better place - source code
277
- | _ => false
278
- }
279
-
280
- if shouldBeIncluded && !shouldSkip {
281
- onEventRegistrations->Array.push(onEventRegistration)
282
- }
283
- })
284
- })
285
-
286
- onEventRegistrations
287
- }
288
-
289
- let finishRegistration = (~config: Config.t): registrationsByChainId => {
290
- switch activeRegistration.contents {
291
- | Some(r) => {
292
- r.finished = true
293
- let notRegisteredEventsByContract: dict<Utils.Set.t<string>> = Dict.make()
294
- let registrationsByChainId: registrationsByChainId = Dict.make()
295
- config.chainMap
296
- ->ChainMap.values
297
- ->Array.forEach(chainConfig => {
298
- let key = chainConfig.id->Int.toString
299
- registrationsByChainId->Dict.set(
300
- key,
301
- {
302
- onEventRegistrations: buildOnEventRegistrations(
303
- ~chainConfig,
304
- ~config,
305
- ~notRegisteredEventsByContract,
306
- ),
307
- onBlockRegistrations: r.registrations.onBlockByChainId
308
- ->Utils.Dict.dangerouslyGetNonOption(key)
309
- ->Option.getOr([]),
310
- },
311
- )
312
- })
313
-
314
- // Reported once for the whole indexer (a shared contract on multiple
315
- // chains would otherwise repeat the same message per chain).
316
- let notRegisteredEntries = notRegisteredEventsByContract->Dict.toArray
317
- if notRegisteredEntries->Utils.Array.notEmpty {
318
- let groups =
319
- notRegisteredEntries
320
- ->Array.map(((contractName, eventNames)) =>
321
- `${contractName} (${eventNames->Utils.Set.toArray->Array.joinUnsafe(", ")})`
322
- )
323
- ->Array.joinUnsafe(", ")
324
- Logging.getLogger()->Logging.childInfo(
325
- `Events without a handler, skipped for indexing: ${groups}`,
326
- )
327
- }
328
-
329
- registrationsByChainId
330
- }
331
- | None =>
332
- JsError.throwWithMessage(
333
- "The indexer has not started registering handlers, so can't finish it.",
334
- )
335
- }
336
- }
337
-
338
- let isPendingRegistration = () => {
339
- switch activeRegistration.contents {
340
- | Some(r) => !r.finished
341
- | None => false
342
- }
343
- }
344
-
345
- // Early guard called from `indexer.onEvent` / `.contractRegister` / `.onBlock` /
346
- // `.onSlot` so the user sees a method-specific error at the call site, instead
347
- // of hitting the generic `withRegistration` throw deep inside `setHandler` etc.
348
- let throwIfFinishedRegistration = (~methodName) => {
349
- switch activeRegistration.contents {
350
- | Some({finished: true}) =>
351
- JsError.throwWithMessage(
352
- `Cannot call \`indexer.${methodName}\` after the indexer has started. Make sure all handlers are registered at the top level of your handler module.`,
353
- )
354
- | _ => ()
355
- }
356
- }
357
-
358
- let registerOnBlock = (
359
- ~name,
360
- ~chainId,
361
- ~interval,
362
- ~startBlock,
363
- ~endBlock,
364
- ~handler: Internal.onBlockArgs => promise<unit>,
365
- ) => {
366
- withRegistration(registration => {
367
- let onBlockByChainId = registration.registrations.onBlockByChainId
368
- let key = chainId->Int.toString
369
- let index =
370
- onBlockByChainId
371
- ->Utils.Dict.dangerouslyGetNonOption(key)
372
- ->Option.mapOr(0, configs => configs->Array.length)
373
- onBlockByChainId->Utils.Dict.push(
374
- key,
375
- (
376
- {
377
- index,
378
- name,
379
- startBlock,
380
- endBlock,
381
- interval,
382
- chainId,
383
- handler,
384
- }: Internal.onBlockRegistration
385
- ),
386
- )
387
- })
168
+ ~chainId: int,
169
+ ~eventConfig: Internal.eventConfig,
170
+ ~startBlock=?,
171
+ ): Internal.onEventRegistration => {
172
+ let t = get(~contractName=eventConfig.contractName, ~eventName=eventConfig.name)
173
+ buildOnEventRegistrationWith(
174
+ ~config,
175
+ ~chainId,
176
+ ~eventConfig,
177
+ ~isWildcard=t.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false),
178
+ ~handler=t.handler,
179
+ ~contractRegister=t.contractRegister,
180
+ ~where=t.eventOptions->Option.flatMap(v => v.where),
181
+ ~startBlock?,
182
+ )
388
183
  }
389
184
 
390
185
  let getHandler = (~contractName, ~eventName) => get(~contractName, ~eventName).handler
@@ -392,9 +187,6 @@ let getHandler = (~contractName, ~eventName) => get(~contractName, ~eventName).h
392
187
  let getContractRegister = (~contractName, ~eventName) =>
393
188
  get(~contractName, ~eventName).contractRegister
394
189
 
395
- let getOnEventWhere = (~contractName, ~eventName) =>
396
- get(~contractName, ~eventName).eventOptions->Option.flatMap(value => value.where)
397
-
398
190
  let isWildcard = (~contractName, ~eventName) =>
399
191
  get(~contractName, ~eventName).eventOptions
400
192
  ->Option.flatMap(value => value.wildcard)
@@ -413,35 +205,79 @@ let raiseDuplicateRegistration = (~contractName, ~eventName, ~msg, ~logger) => {
413
205
  JsError.throwWithMessage(fullMsg)
414
206
  }
415
207
 
416
- // Compare two raw `where` configs as the user passed them (object/array/bool/function).
417
- // At registration time we haven't parsed the config into `Internal.eventFilters` yet,
418
- // so structural equality on the raw JSON shape is what users actually wrote. For a
419
- // dynamic callback (a function value) structural equality is meaningless, so fall
420
- // back to referential equality on the function reference.
421
- let whereMatch = (a: option<JSON.t>, b: option<JSON.t>) => {
422
- switch (a, b) {
423
- | (None, None) => true
424
- | (Some(a), Some(b)) =>
425
- if typeof(a) === #function || typeof(b) === #function {
426
- a === b
427
- } else {
428
- a == b
429
- }
430
- | _ => false
431
- }
432
- }
433
-
208
+ // `where` equality is checked per chain on the resolved structure (see
209
+ // `syncOnEventRegistrations`), so registration options only need to agree on
210
+ // `wildcard` here two callbacks that resolve to identical filters count as
211
+ // identical options even when the function references differ.
434
212
  let eventOptionsMatch = (
435
213
  existing: option<Internal.eventOptions<JSON.t>>,
436
214
  incoming: option<Internal.eventOptions<JSON.t>>,
437
215
  ) => {
438
216
  switch (existing, incoming) {
439
217
  | (None, None) => true
440
- | (Some(a), Some(b)) => a.wildcard === b.wildcard && whereMatch(a.where, b.where)
218
+ | (Some(a), Some(b)) => a.wildcard === b.wildcard
441
219
  | _ => false
442
220
  }
443
221
  }
444
222
 
223
+ let getResolvedWhere = (reg: Internal.onEventRegistration) =>
224
+ (
225
+ reg->(Utils.magic: Internal.onEventRegistration => Internal.evmOnEventRegistration)
226
+ ).resolvedWhere
227
+
228
+ // Resolve the registration for every configured chain that defines the event
229
+ // and store it in the pending per-chain registry. When the chain already
230
+ // holds a registration for this event, the resolved `where` structures must
231
+ // deep-compare equal (`Values` by hex arrays, `ContractAddresses` by contract
232
+ // name, plus `startBlock`) — otherwise it's a conflicting duplicate
233
+ // registration. Both live registrations and `preRegistered` callbacks
234
+ // replayed by `startRegistration` run through this single code path.
235
+ let syncOnEventRegistrations = (
236
+ r: activeRegistration,
237
+ ~contractName,
238
+ ~eventName,
239
+ ~where: option<JSON.t>,
240
+ ~duplicateMsg,
241
+ ~logger,
242
+ ) => {
243
+ let config = r.config
244
+ let t = get(~contractName, ~eventName)
245
+ let isWildcard = t.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false)
246
+ let key = getKey(~contractName, ~eventName)
247
+
248
+ config.chainMap
249
+ ->ChainMap.values
250
+ ->Array.forEach(chainConfig => {
251
+ chainConfig.contracts->Array.forEach(contract => {
252
+ if contract.name === contractName {
253
+ switch contract.events->Array.find(e => e.name === eventName) {
254
+ | None => ()
255
+ | Some(eventConfig) =>
256
+ let newRegistration = buildOnEventRegistrationWith(
257
+ ~config,
258
+ ~chainId=chainConfig.id,
259
+ ~eventConfig,
260
+ ~isWildcard,
261
+ ~handler=t.handler,
262
+ ~contractRegister=t.contractRegister,
263
+ ~where,
264
+ ~startBlock=?contract.startBlock,
265
+ )
266
+ let pending = r->getPendingChainRegistrations(~chainId=chainConfig.id)
267
+ switch pending.onEventRegistrations->Utils.Dict.dangerouslyGetNonOption(key) {
268
+ | Some(existing) if config.ecosystem.name === Evm =>
269
+ if !(existing->getResolvedWhere == newRegistration->getResolvedWhere) {
270
+ raiseDuplicateRegistration(~contractName, ~eventName, ~msg=duplicateMsg, ~logger)
271
+ }
272
+ | _ => ()
273
+ }
274
+ pending.onEventRegistrations->Dict.set(key, newRegistration)
275
+ }
276
+ }
277
+ })
278
+ })
279
+ }
280
+
445
281
  let setEventOptions = (~contractName, ~eventName, ~eventOptions, ~logger=Logging.getLogger()) => {
446
282
  switch eventOptions {
447
283
  | Some(value) =>
@@ -470,9 +306,13 @@ let setHandler = (
470
306
  ~eventOptions,
471
307
  ~logger=Logging.getLogger(),
472
308
  ) => {
473
- withRegistration(_registration => {
309
+ withRegistration(registration => {
474
310
  let t = get(~contractName, ~eventName)
475
311
  let newHandler = handler->(Utils.magic: Internal.genericHandler<'args> => Internal.handler)
312
+ let incomingEventOptions =
313
+ eventOptions->Option.map(v =>
314
+ v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions<JSON.t>)
315
+ )
476
316
  switch t.handler {
477
317
  | None =>
478
318
  setEventOptions(~contractName, ~eventName, ~eventOptions, ~logger)
@@ -486,10 +326,6 @@ let setHandler = (
486
326
  },
487
327
  )
488
328
  | Some(prevHandler) =>
489
- let incomingEventOptions =
490
- eventOptions->Option.map(v =>
491
- v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions<JSON.t>)
492
- )
493
329
  if eventOptionsMatch(t.eventOptions, incomingEventOptions) {
494
330
  let composedHandler: Internal.handler = async args => {
495
331
  await prevHandler(args)
@@ -512,6 +348,13 @@ let setHandler = (
512
348
  )
513
349
  }
514
350
  }
351
+ registration->syncOnEventRegistrations(
352
+ ~contractName,
353
+ ~eventName,
354
+ ~where=incomingEventOptions->Option.flatMap(v => v.where),
355
+ ~duplicateMsg="Cannot register a second handler with different options. Make sure all handlers for the same event use identical options (wildcard, where)",
356
+ ~logger,
357
+ )
515
358
  })
516
359
  }
517
360
 
@@ -522,7 +365,7 @@ let setContractRegister = (
522
365
  ~eventOptions,
523
366
  ~logger=Logging.getLogger(),
524
367
  ) => {
525
- withRegistration(_registration => {
368
+ withRegistration(registration => {
526
369
  let t = get(~contractName, ~eventName)
527
370
  let newContractRegister =
528
371
  contractRegister->(
@@ -530,6 +373,10 @@ let setContractRegister = (
530
373
  Internal.genericContractRegisterArgs<'event, 'context>,
531
374
  > => Internal.contractRegister
532
375
  )
376
+ let incomingEventOptions =
377
+ eventOptions->Option.map(v =>
378
+ v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions<JSON.t>)
379
+ )
533
380
  switch t.contractRegister {
534
381
  | None =>
535
382
  setEventOptions(~contractName, ~eventName, ~eventOptions, ~logger)
@@ -543,10 +390,6 @@ let setContractRegister = (
543
390
  },
544
391
  )
545
392
  | Some(prevContractRegister) =>
546
- let incomingEventOptions =
547
- eventOptions->Option.map(v =>
548
- v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions<JSON.t>)
549
- )
550
393
  if eventOptionsMatch(t.eventOptions, incomingEventOptions) {
551
394
  let composedContractRegister: Internal.contractRegister = async args => {
552
395
  await prevContractRegister(args)
@@ -569,5 +412,333 @@ let setContractRegister = (
569
412
  )
570
413
  }
571
414
  }
415
+ registration->syncOnEventRegistrations(
416
+ ~contractName,
417
+ ~eventName,
418
+ ~where=incomingEventOptions->Option.flatMap(v => v.where),
419
+ ~duplicateMsg="Cannot register a second contractRegister with different options. Make sure all handlers for the same event use identical options (wildcard, where)",
420
+ ~logger,
421
+ )
422
+ })
423
+ }
424
+
425
+ // Shape of the user-returned `{_gte?, _lte?, _every?}` filter chunk after
426
+ // the ecosystem-specific wrapper is stripped. Shared across all ecosystems —
427
+ // the outer `block.number` / `block.height` / `slot` unwrap lives on each
428
+ // ecosystem's `onBlockFilterSchema`, and the inner range fields are the
429
+ // same everywhere.
430
+ type blockRange = {
431
+ _gte: option<int>,
432
+ _lte: option<int>,
433
+ _every: int,
434
+ }
435
+
436
+ // `S.strict` rejects unknown fields so typos like `_gt` / `_evry` surface
437
+ // with a readable schema error pointing at the offending key, instead of
438
+ // silently registering a broken filter. `_every` defaults to 1 inside the
439
+ // schema so the caller always sees a plain `int`, and `intMin(1)` rejects
440
+ // zero/negative strides — `(blockNumber - startBlock) % 0` would crash and
441
+ // any negative stride would never match.
442
+ let blockRangeSchema: S.t<blockRange> = S.object(s => {
443
+ _gte: s.field("_gte", S.option(S.int)),
444
+ _lte: s.field("_lte", S.option(S.int)),
445
+ _every: s.field("_every", S.option(S.int->S.intMin(1))->S.Option.getOr(1)),
446
+ })->S.strict
447
+
448
+ let defaultBlockRange: blockRange = {_gte: None, _lte: None, _every: 1}
449
+
450
+ // Two-stage parse: first the ecosystem-specific outer schema unwraps the
451
+ // wrapper (`block.number` / `block.height` / `slot`) and surfaces the
452
+ // inner chunk as raw `unknown`; then the shared `blockRangeSchema`
453
+ // validates the `{_gte?, _lte?, _every?}` fields. Keeping the inner
454
+ // validation in one place means typos and shape mismatches surface with
455
+ // the same user-friendly error regardless of ecosystem.
456
+ let extractRange = (filter: unknown, ~name, ~ecosystem: Ecosystem.t): blockRange =>
457
+ try {
458
+ switch filter->S.parseOrThrow(ecosystem.onBlockFilterSchema) {
459
+ | None => defaultBlockRange
460
+ | Some(inner) => inner->S.parseOrThrow(blockRangeSchema)
461
+ }
462
+ } catch {
463
+ | S.Raised(exn) =>
464
+ JsError.throwWithMessage(
465
+ `\`indexer.${ecosystem.onBlockMethodName}("${name}")\` \`where\` returned an invalid filter: ${exn
466
+ ->Utils.prettifyExn
467
+ ->(Utils.magic: exn => string)}`,
468
+ )
469
+ }
470
+
471
+ // Mirrors `Envio.onBlockWhereArgs` without depending on the module.
472
+ type onBlockWhereArgs = {chain: unknown}
473
+
474
+ // `where` is evaluated once per configured chain at registration time.
475
+ // Decoded ranges/stride feed directly into the per-chain registration store
476
+ // so the fetcher's `(blockNumber - handlerStartBlock) % interval === 0`
477
+ // math in `FetchState` stays untouched. Deferred via `withRegistration` so
478
+ // the per-chain loop sees the registration's config, which may be a narrowed
479
+ // version of the generated one (TestIndexer runs with a per-test chain
480
+ // subset). `where` arrives unvalidated (`unknown`) straight from the user's
481
+ // options object.
482
+ let registerOnBlock = (
483
+ ~name: string,
484
+ ~where: unknown,
485
+ ~handler: Internal.onBlockArgs => promise<unit>,
486
+ ~getChainsObject: Config.t => dict<unknown>,
487
+ ) => {
488
+ withRegistration(registration => {
489
+ let config = registration.config
490
+ let ecosystem = config.ecosystem
491
+ let chainsDict = getChainsObject(config)
492
+ let logger = Logging.createChild(~params={"onBlock": name})
493
+
494
+ // `where` must be a function (unlike onEvent, which also accepts a static
495
+ // value). A static value would have to be evaluated against every chain
496
+ // independently, which has no useful semantic for block handlers.
497
+ // Normalize undefined/null to None up front so the per-chain loop below
498
+ // can't accidentally call `null` as a predicate.
499
+ let where = switch where {
500
+ | w if w === %raw(`undefined`) || w === %raw(`null`) => None
501
+ | w if typeof(w) === #function => Some(w->(Utils.magic: unknown => onBlockWhereArgs => unknown))
502
+ | w =>
503
+ JsError.throwWithMessage(
504
+ `\`indexer.${ecosystem.onBlockMethodName}("${name}")\` expected \`where\` to be a function or omitted, but got ${(typeof(
505
+ w,
506
+ ) :> string)}.`,
507
+ )
508
+ }
509
+
510
+ let matchedAny = ref(false)
511
+
512
+ config.chainMap
513
+ ->ChainMap.values
514
+ ->Array.forEach(chainConfig => {
515
+ let chainId = chainConfig.id
516
+ let chainObj = chainsDict->Dict.getUnsafe(chainId->Int.toString)
517
+
518
+ // Predicate returns `true` → match with no filter; `false` → skip;
519
+ // any plain object → structured filter. `undefined`/`null` returns
520
+ // are rejected — the TS type excludes `void`, so a missing return is
521
+ // a user bug we surface early rather than silently match-all.
522
+ let result = switch where {
523
+ | None => %raw(`true`)
524
+ | Some(predicate) => predicate({chain: chainObj})
525
+ }
526
+
527
+ let (shouldRegister, range) = if result === %raw(`true`) {
528
+ (true, defaultBlockRange)
529
+ } else if result === %raw(`false`) {
530
+ (false, defaultBlockRange)
531
+ } else if typeof(result) === #object && !(result->Array.isArray) && result !== %raw(`null`) {
532
+ (true, extractRange(result, ~name, ~ecosystem))
533
+ } else {
534
+ // Reject numbers, strings, functions, arrays, undefined, null —
535
+ // anything that isn't bool or a plain object would silently
536
+ // misregister.
537
+ JsError.throwWithMessage(
538
+ `\`indexer.${ecosystem.onBlockMethodName}("${name}")\` \`where\` predicate returned an invalid value of type ${(typeof(
539
+ result,
540
+ ) :> string)}. Expected boolean or a filter object.`,
541
+ )
542
+ }
543
+
544
+ if shouldRegister {
545
+ matchedAny := true
546
+ if range._gte->Option.getOr(chainConfig.startBlock) < chainConfig.startBlock {
547
+ JsError.throwWithMessage(
548
+ `The start block for onBlock handler "${name}" is less than the chain start block (${chainConfig.startBlock->Int.toString}). This is not supported yet.`,
549
+ )
550
+ }
551
+ switch chainConfig.endBlock {
552
+ | Some(chainEndBlock) =>
553
+ if range._lte->Option.getOr(chainEndBlock) > chainEndBlock {
554
+ JsError.throwWithMessage(
555
+ `The end block for onBlock handler "${name}" is greater than the chain end block (${chainEndBlock->Int.toString}). This is not supported yet.`,
556
+ )
557
+ }
558
+ | None => ()
559
+ }
560
+ let pending = registration->getPendingChainRegistrations(~chainId)
561
+ pending.onBlockRegistrations
562
+ ->Array.push(
563
+ (
564
+ {
565
+ index: pending.onBlockRegistrations->Array.length,
566
+ name,
567
+ startBlock: range._gte,
568
+ endBlock: range._lte,
569
+ interval: range._every,
570
+ chainId,
571
+ handler,
572
+ }: Internal.onBlockRegistration
573
+ ),
574
+ )
575
+ ->ignore
576
+ }
577
+ })
578
+
579
+ // Catches misconfigured `where` predicates that return `false` for every
580
+ // configured chain — the handler would otherwise never fire with no hint.
581
+ // Includes the ecosystem-specific method name so SVM users see "onSlot"
582
+ // and don't get confused looking for a "Block handler" they never wrote.
583
+ if !matchedAny.contents {
584
+ logger->Logging.childWarn(
585
+ `\`indexer.${ecosystem.onBlockMethodName}\` matched 0 chains. Check the \`where\` predicate.`,
586
+ )
587
+ }
572
588
  })
573
589
  }
590
+
591
+ let finishRegistration = (~config: Config.t): registrationsByChainId => {
592
+ switch getActiveRegistration() {
593
+ | Some(r) => {
594
+ r.finished = true
595
+ let notRegisteredEventsByContract: dict<Utils.Set.t<string>> = Dict.make()
596
+ let registrationsByChainId: registrationsByChainId = Dict.make()
597
+ config.chainMap
598
+ ->ChainMap.values
599
+ ->Array.forEach(chainConfig => {
600
+ let key = chainConfig.id->Int.toString
601
+ let pending = r.registrationsByChainId->Utils.Dict.dangerouslyGetNonOption(key)
602
+
603
+ // We don't need the router itself, but only validation logic,
604
+ // since now event router is created for selection of events
605
+ // and validation doesn't work correctly in routers.
606
+ // Ideally to split it into two different parts.
607
+ let eventRouter = EventRouter.empty()
608
+
609
+ let onEventRegistrations: array<Internal.onEventRegistration> = []
610
+
611
+ chainConfig.contracts->Array.forEach(contract => {
612
+ let contractName = contract.name
613
+
614
+ contract.events->Array.forEach(
615
+ eventConfig => {
616
+ let eventName = eventConfig.name
617
+ let registration =
618
+ pending->Option.flatMap(
619
+ pending =>
620
+ pending.onEventRegistrations->Utils.Dict.dangerouslyGetNonOption(
621
+ getKey(~contractName, ~eventName),
622
+ ),
623
+ )
624
+
625
+ // Should validate the events
626
+ eventRouter->EventRouter.addOrThrow(
627
+ eventConfig.id,
628
+ (),
629
+ ~contractName,
630
+ ~chain=ChainMap.Chain.makeUnsafe(~chainId=chainConfig.id),
631
+ ~eventName,
632
+ ~isWildcard=switch registration {
633
+ | Some(registration) => registration.isWildcard
634
+ | None => isWildcard(~contractName, ~eventName)
635
+ },
636
+ )
637
+
638
+ let registration = switch registration {
639
+ | Some(_) as registration => registration
640
+ | None =>
641
+ // No entry in the incremental store, but the persistent dict
642
+ // may still hold a handler: handler modules are import-cached,
643
+ // so a repeated registration cycle in the same process (tests
644
+ // restarting the indexer) never re-runs the `indexer.onEvent`
645
+ // calls. Rebuild from the dict in that case. Events without a
646
+ // handler/contractRegister aren't fetched or dispatched
647
+ // (unless raw events are enabled).
648
+ if hasRegistration(~contractName, ~eventName) || config.enableRawEvents {
649
+ Some(
650
+ buildOnEventRegistration(
651
+ ~config,
652
+ ~chainId=chainConfig.id,
653
+ ~eventConfig,
654
+ ~startBlock=?contract.startBlock,
655
+ ),
656
+ )
657
+ } else {
658
+ let eventNames = switch notRegisteredEventsByContract->Utils.Dict.dangerouslyGetNonOption(
659
+ contractName,
660
+ ) {
661
+ | Some(set) => set
662
+ | None => {
663
+ let set = Utils.Set.make()
664
+ notRegisteredEventsByContract->Dict.set(contractName, set)
665
+ set
666
+ }
667
+ }
668
+ eventNames->Utils.Set.add(eventName)->ignore
669
+ None
670
+ }
671
+ }
672
+
673
+ switch registration {
674
+ | Some(registration) =>
675
+ // A `where` that resolved to no topic selections (`false` for
676
+ // this chain) drops the chain's registration entirely — the
677
+ // event should never be fetched here.
678
+ let isDroppedByWhere =
679
+ config.ecosystem.name === Evm &&
680
+ (registration->getResolvedWhere).topicSelections->Utils.Array.isEmpty
681
+ if !isDroppedByWhere {
682
+ onEventRegistrations->Array.push(registration)
683
+ }
684
+ | None => ()
685
+ }
686
+ },
687
+ )
688
+ })
689
+
690
+ registrationsByChainId->Dict.set(
691
+ key,
692
+ {
693
+ onEventRegistrations,
694
+ onBlockRegistrations: switch pending {
695
+ | Some(pending) => pending.onBlockRegistrations
696
+ | None => []
697
+ },
698
+ },
699
+ )
700
+ })
701
+
702
+ // Reported once for the whole indexer (a shared contract on multiple
703
+ // chains would otherwise repeat the same message per chain).
704
+ let notRegisteredEntries = notRegisteredEventsByContract->Dict.toArray
705
+ if notRegisteredEntries->Utils.Array.notEmpty {
706
+ let groups =
707
+ notRegisteredEntries
708
+ ->Array.map(((contractName, eventNames)) =>
709
+ `${contractName} (${eventNames->Utils.Set.toArray->Array.joinUnsafe(", ")})`
710
+ )
711
+ ->Array.joinUnsafe(", ")
712
+ Logging.getLogger()->Logging.childInfo(
713
+ `Events without a handler, skipped for indexing: ${groups}`,
714
+ )
715
+ }
716
+
717
+ registrationsByChainId
718
+ }
719
+ | None =>
720
+ JsError.throwWithMessage(
721
+ "The indexer has not started registering handlers, so can't finish it.",
722
+ )
723
+ }
724
+ }
725
+
726
+ let isPendingRegistration = () => {
727
+ switch getActiveRegistration() {
728
+ | Some(r) => !r.finished
729
+ | None => false
730
+ }
731
+ }
732
+
733
+ // Early guard called from `indexer.onEvent` / `.contractRegister` / `.onBlock` /
734
+ // `.onSlot` so the user sees a method-specific error at the call site, instead
735
+ // of hitting the generic `withRegistration` throw deep inside `setHandler` etc.
736
+ let throwIfFinishedRegistration = (~methodName) => {
737
+ switch getActiveRegistration() {
738
+ | Some({finished: true}) =>
739
+ JsError.throwWithMessage(
740
+ `Cannot call \`indexer.${methodName}\` after the indexer has started. Make sure all handlers are registered at the top level of your handler module.`,
741
+ )
742
+ | _ => ()
743
+ }
744
+ }