From 825400115d4bf67299f14cf4748f8aba5eb74c71 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 13:42:01 +0000 Subject: [PATCH 01/16] Allow multiple onEvent registrations per event Registering the same event more than once no longer errors or composes handlers. Each `indexer.onEvent` becomes its own registration; a `contractRegister` merges into a matching handler registration (either registration order, matched on the resolved `where` and wildcard flag), otherwise it stands on its own. Wildcard registrations are no longer capped at one per signature. The routing, buffer dedup (keyed on registration index), and query selection layers already fanned one log out to every matching registration, so this only reshapes the registration layer: - HandlerRegister resolves each call eagerly into a process-global per-chain store (survives an import-cached re-registration cycle; one config per isolate). `finishRegistration` just backfills raw-event-only regs, drops where-empty EVM regs, and assigns the chain-scoped index. The raw persistent slot, per-call resolved store, and the merge/throw paths are gone. - The duplicate-event guard moves from a runtime check to config parse (`Contract::new`), keyed on sighash + indexed-topic count for EVM and the discriminator for SVM. The wildcard-interference guard is dropped. - simulate fans a simulated event out to every registration. raw_events keeps one row per fetched item, so a log matched by N registrations writes N rows (no dedup, by design). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- .../cli/src/config_parsing/system_config.rs | 94 ++- .../test/HandlerRegisterValidation_test.res | 54 -- .../envio-tests/test/HandlerRegister_test.res | 195 +++++ packages/envio/src/EnvioGlobal.res | 4 +- packages/envio/src/HandlerRegister.res | 746 +++++++----------- packages/envio/src/HandlerRegister.resi | 32 +- packages/envio/src/SimulateItems.res | 77 +- .../test/HandlerRegisterLifecycle_test.res | 40 +- .../test/OnEventRegistration_test.res | 2 +- .../test/__mocks__/MockConfig.res | 12 +- 10 files changed, 657 insertions(+), 599 deletions(-) delete mode 100644 packages/envio-tests/test/HandlerRegisterValidation_test.res create mode 100644 packages/envio-tests/test/HandlerRegister_test.res diff --git a/packages/cli/src/config_parsing/system_config.rs b/packages/cli/src/config_parsing/system_config.rs index 646fcdfce..16ef157fb 100644 --- a/packages/cli/src/config_parsing/system_config.rs +++ b/packages/cli/src/config_parsing/system_config.rs @@ -1919,12 +1919,44 @@ impl Contract { events: Vec, abi: Abi, ) -> Result { - // TODO: Validatate that all event names are unique validate_names_valid_rescript( &events.iter().map(|e| e.name.clone()).collect(), "event".to_string(), )?; + // Two event definitions on one contract that share a dispatch key are + // indistinguishable at routing time — one log/instruction would decode + // to both — so reject them here. The key mirrors the runtime `eventId`: + // sighash plus indexed-topic count for EVM, the discriminator for SVM + // (already program-scoped, since these are one program's instructions). + let mut seen_by_dispatch_key: HashMap = HashMap::new(); + for event in &events { + let dispatch_key = match &event.kind { + EventKind::Params(params) => { + let indexed_count = params.iter().filter(|p| p.indexed).count(); + Some(format!("{}_{}", event.sighash, indexed_count)) + } + EventKind::Svm(svm) => Some( + svm.discriminator + .clone() + .unwrap_or_else(|| "none".to_string()), + ), + EventKind::Fuel(_) => None, + }; + if let Some(dispatch_key) = dispatch_key { + if let Some(existing) = + seen_by_dispatch_key.insert(dispatch_key, event.name.clone()) + { + return Err(anyhow!( + "Duplicate event detected on contract {name}: {existing} and {} share the \ + same signature, so they can't be told apart while indexing. Remove the \ + duplicate event.", + event.name, + )); + } + } + } + Ok(Self { name, events, @@ -2711,6 +2743,66 @@ mod test { ); } + #[test] + fn rejects_duplicate_event_on_same_contract() { + // Two events with the same signature on one contract are + // indistinguishable at routing time; parsing must reject them. + let yaml = r#" +name: dup-event-test +contracts: + - name: ERC20 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + rpc: + url: https://eth.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" +"#; + let err = SystemConfig::parse_yaml(yaml, None, &HashMap::new(), &HashMap::new(), false) + .err() + .expect("expected a duplicate-event error"); + assert!( + format!("{err:#}").contains("Duplicate event detected on contract ERC20"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn allows_distinct_events_and_shared_signature_across_contracts() { + // Distinct signatures on one contract are fine, and the same signature + // on two different contracts is allowed (routing scopes by contract). + let yaml = r#" +name: ok-events-test +contracts: + - name: ERC20 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Approval(address indexed owner, address indexed spender, uint256 value) + - name: ERC721 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + rpc: + url: https://eth.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" + - name: ERC721 + address: "0x2222222222222222222222222222222222222222" +"#; + SystemConfig::parse_yaml(yaml, None, &HashMap::new(), &HashMap::new(), false) + .expect("config with distinct/cross-contract events should parse"); + } + #[test] fn test_get_contract_abi() { let test_dir = format!("{}/test", env!("CARGO_MANIFEST_DIR")); diff --git a/packages/envio-tests/test/HandlerRegisterValidation_test.res b/packages/envio-tests/test/HandlerRegisterValidation_test.res deleted file mode 100644 index 88e7b18f0..000000000 --- a/packages/envio-tests/test/HandlerRegisterValidation_test.res +++ /dev/null @@ -1,54 +0,0 @@ -open Vitest - -// Covers `HandlerRegister.validateEventIdOrThrow`, the per-chain dispatch-id -// guard `finishRegistration` runs so a single log/receipt/instruction can't fan -// out to two events on one contract, or to two wildcards sharing a signature — -// Rust-side routing dispatches by these ids, so a collision double-delivers. - -describe("HandlerRegister.validateEventIdOrThrow", () => { - let add = (validator, ~eventId, ~contractName, ~isWildcard) => - validator->HandlerRegister.validateEventIdOrThrow( - ~eventId, - ~contractName, - ~eventName="Event1", - ~isWildcard, - ~chainId=1, - ) - - it("accepts the same eventId across different contracts", t => { - let validator = HandlerRegister.makeEventIdValidator() - validator->add(~eventId="0xsig", ~contractName="Contract1", ~isWildcard=false) - t.expect( - () => validator->add(~eventId="0xsig", ~contractName="Contract2", ~isWildcard=false), - ).not.toThrow() - }) - - it("throws on a duplicate event for the same contract", t => { - let validator = HandlerRegister.makeEventIdValidator() - validator->add(~eventId="0xsig", ~contractName="Contract1", ~isWildcard=false) - t.expect( - () => validator->add(~eventId="0xsig", ~contractName="Contract1", ~isWildcard=false), - ).toThrowError("Duplicate event detected: Event1 for contract Contract1 on chain 1") - }) - - it("throws when a second wildcard claims the same eventId", t => { - let validator = HandlerRegister.makeEventIdValidator() - validator->add(~eventId="0xsig", ~contractName="Contract1", ~isWildcard=true) - t.expect( - () => validator->add(~eventId="0xsig", ~contractName="Contract2", ~isWildcard=true), - ).toThrowError( - "Another event is already registered with the same signature that would interfere with wildcard filtering: Event1 for contract Contract2 on chain 1", - ) - }) - - it("accepts two wildcards whose eventIds differ (e.g. SVM program-scoped ids)", t => { - // finishRegistration scopes the SVM key by programId (`${programId}_${id}`), - // so two wildcard instructions sharing a discriminator on different programs - // reach the validator as distinct eventIds and must not collide. - let validator = HandlerRegister.makeEventIdValidator() - validator->add(~eventId="progA_0x0f", ~contractName="ProgA", ~isWildcard=true) - t.expect( - () => validator->add(~eventId="progB_0x0f", ~contractName="ProgB", ~isWildcard=true), - ).not.toThrow() - }) -}) diff --git a/packages/envio-tests/test/HandlerRegister_test.res b/packages/envio-tests/test/HandlerRegister_test.res new file mode 100644 index 000000000..cc56a1bdf --- /dev/null +++ b/packages/envio-tests/test/HandlerRegister_test.res @@ -0,0 +1,195 @@ +open Vitest + +// Covers `HandlerRegister`'s multiple-registrations-per-event behaviour: +// every `onEvent` becomes its own registration, a `contractRegister` merges +// into a handler registration (either order) when their filters match, and +// unlimited wildcard registrations are allowed. + +let config = MockIndexerConfig.parseYaml(` +name: handler-register-test +contracts: + - name: ERC20 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Approval(address indexed owner, address indexed spender, uint256 value) + - name: ERC721 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + rpc: + url: https://eth.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" + - name: ERC721 + address: "0x2222222222222222222222222222222222222222" +`).config + +// Each handler/contractRegister is a distinct function so registrations can be +// identified by reference in assertions. +let makeHandler = (): Internal.handler => %raw(`() => Promise.resolve()`) +let makeContractRegister = (): Internal.contractRegister => %raw(`() => Promise.resolve()`) + +let setHandler = (~contractName="ERC20", ~eventName="Transfer", ~eventOptions=?, handler) => + HandlerRegister.setHandler( + ~contractName, + ~eventName, + handler->(Utils.magic: Internal.handler => Internal.genericHandler<_>), + ~eventOptions, + ) + +let setContractRegister = ( + ~contractName="ERC20", + ~eventName="Transfer", + ~eventOptions=?, + contractRegister, +) => + HandlerRegister.setContractRegister( + ~contractName, + ~eventName, + contractRegister->(Utils.magic: Internal.contractRegister => Internal.genericContractRegister<_>), + ~eventOptions, + ) + +// Registrations for the given event on chain 1, described as +// `(handlerLabel, contractRegisterLabel, index)` where labels resolve the +// stored function back to the ones registered below. +let describeRegistrations = ( + registrations: HandlerRegister.registrationsByChainId, + ~contractName="ERC20", + ~eventName="Transfer", + ~labels: array<(Internal.handler, string)>, + ~crLabels: array<(Internal.contractRegister, string)>, +) => { + let handlerLabel = h => + labels->Array.find(((fn, _)) => fn === h)->Option.map(((_, label)) => label)->Option.getOr("?") + let crLabel = cr => + crLabels + ->Array.find(((fn, _)) => fn === cr) + ->Option.map(((_, label)) => label) + ->Option.getOr("?") + let chainRegistrations: HandlerRegister.chainRegistrations = + registrations->Utils.Dict.dangerouslyGetNonOption("1")->Option.getOrThrow + chainRegistrations.onEventRegistrations + ->Array.filter(reg => + reg.eventConfig.contractName === contractName && reg.eventConfig.name === eventName + ) + ->Array.map(reg => ( + reg.handler->Option.map(handlerLabel), + reg.contractRegister->Option.map(crLabel), + reg.index, + )) +} + +let register = fn => { + HandlerRegister.resetOnEventRegistrations() + HandlerRegister.startRegistration(~config) + fn() + HandlerRegister.finishRegistration(~config) +} + +describe("HandlerRegister multiple registrations", () => { + it("keeps two onEvent handlers as separate registrations in registration order", t => { + let h1 = makeHandler() + let h2 = makeHandler() + let registrations = register(() => { + setHandler(h1) + setHandler(h2) + }) + t.expect( + registrations->describeRegistrations( + ~labels=[(h1, "h1"), (h2, "h2")], + ~crLabels=[], + ), + ).toEqual([(Some("h1"), None, 0), (Some("h2"), None, 1)]) + }) + + it("merges a contractRegister into a handler with matching filter", t => { + let h1 = makeHandler() + let cr1 = makeContractRegister() + let registrations = register(() => { + setHandler(h1) + setContractRegister(cr1) + }) + t.expect( + registrations->describeRegistrations( + ~labels=[(h1, "h1")], + ~crLabels=[(cr1, "cr1")], + ), + ).toEqual([(Some("h1"), Some("cr1"), 0)]) + }) + + it("merges a handler into an earlier contractRegister, keeping the handler's slot", t => { + let h1 = makeHandler() + let cr1 = makeContractRegister() + let registrations = register(() => { + setContractRegister(cr1) + setHandler(h1) + }) + t.expect( + registrations->describeRegistrations( + ~labels=[(h1, "h1")], + ~crLabels=[(cr1, "cr1")], + ), + ).toEqual([(Some("h1"), Some("cr1"), 0)]) + }) + + it("keeps a handler and contractRegister with different `where` filters separate", t => { + let h1 = makeHandler() + let cr1 = makeContractRegister() + let registrations = register(() => { + setHandler(h1) + setContractRegister( + ~eventOptions={ + where: %raw(`{"params": {"from": "0x1111111111111111111111111111111111111111"}}`), + }, + cr1, + ) + }) + t.expect( + registrations->describeRegistrations( + ~labels=[(h1, "h1")], + ~crLabels=[(cr1, "cr1")], + ), + ).toEqual([(Some("h1"), None, 0), (None, Some("cr1"), 1)]) + }) + + it("does not merge a wildcard handler with a non-wildcard contractRegister", t => { + let h1 = makeHandler() + let cr1 = makeContractRegister() + let registrations = register(() => { + setHandler(~eventOptions={wildcard: true}, h1) + setContractRegister(cr1) + }) + t.expect( + registrations->describeRegistrations( + ~labels=[(h1, "h1")], + ~crLabels=[(cr1, "cr1")], + ), + ).toEqual([(Some("h1"), None, 0), (None, Some("cr1"), 1)]) + }) + + it("allows multiple wildcard registrations sharing a signature", t => { + let h1 = makeHandler() + let h2 = makeHandler() + let registrations = register(() => { + setHandler(~contractName="ERC20", ~eventOptions={wildcard: true}, h1) + setHandler(~contractName="ERC721", ~eventOptions={wildcard: true}, h2) + }) + t.expect(( + registrations->describeRegistrations( + ~contractName="ERC20", + ~labels=[(h1, "h1")], + ~crLabels=[], + ), + registrations->describeRegistrations( + ~contractName="ERC721", + ~labels=[(h2, "h2")], + ~crLabels=[], + ), + )).toEqual(([(Some("h1"), None, 0)], [(Some("h2"), None, 1)])) + }) +}) diff --git a/packages/envio/src/EnvioGlobal.res b/packages/envio/src/EnvioGlobal.res index 8e1fd30bc..c7403fd8b 100644 --- a/packages/envio/src/EnvioGlobal.res +++ b/packages/envio/src/EnvioGlobal.res @@ -15,7 +15,7 @@ // deduplication hint instead of silently mixing shapes across builds. type t = { version: string, - eventRegistrations: dict, + onEventRegistrationsByChainId: dict, mutable activeRegistration: option, preRegistered: array, rollbackCommitCallbacks: array, @@ -40,7 +40,7 @@ let value: t = { | None => let fresh = { version, - eventRegistrations: Dict.make(), + onEventRegistrationsByChainId: Dict.make(), activeRegistration: None, preRegistered: [], rollbackCommitCallbacks: [], diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index dfae00a37..4b6c85979 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -1,15 +1,3 @@ -type eventRegistration = { - handler: option, - contractRegister: option, - eventOptions: option>, -} - -let empty = { - handler: None, - contractRegister: None, - eventOptions: None, -} - // Per-chain onEventRegistrations built from the event definitions in // `Config.t` plus whatever handler/contractRegister/eventOptions got // registered for them, and the onBlock registrations collected during @@ -22,12 +10,10 @@ type chainRegistrations = { // The finished registration state returned by `finishRegistration`. type registrationsByChainId = dict -// Incrementally built during registration: every `indexer.onEvent` / -// `.contractRegister` call resolves its `where` per chain right away and -// stores the resulting registration here, keyed by "Contract.Event", so -// invalid configuration throws at the user's registration call site. +// onBlock registrations collected per chain while registration is active. +// onEvent registrations are resolved eagerly into the process-global store +// below, so nothing for them lives here. type pendingChainRegistrations = { - onEventRegistrations: dict, onBlockRegistrations: array, } @@ -37,24 +23,35 @@ type activeRegistration = { mutable finished: bool, } -// Registration state lives in the process-wide `EnvioGlobal` record (shared -// across duplicate envio module instances); the slots are opaque there, so -// cast them to the real types once here. -let eventRegistrations = - EnvioGlobal.value.eventRegistrations->(Utils.magic: dict => dict) +// Resolved onEvent registrations keyed by chain id, appended (and merged) +// eagerly as each `indexer.onEvent` / `.contractRegister` runs. Lives in the +// process-global `EnvioGlobal` record so it survives an import-cached +// re-registration cycle (handler modules register once per isolate, and there +// is one config per isolate, so a surviving store is only ever reused for the +// same config). `index` is -1 here; `finishRegistration` assigns the final +// chain-scoped index from array position. +let onEventRegistrationsByChainId = + EnvioGlobal.value.onEventRegistrationsByChainId->( + Utils.magic: dict => dict> + ) let getKey = (~contractName, ~eventName) => contractName ++ "." ++ eventName -let get = (~contractName, ~eventName) => { - switch eventRegistrations->Utils.Dict.dangerouslyGetNonOption(getKey(~contractName, ~eventName)) { - | Some(existing) => existing - | None => empty +let getChainOnEventRegistrations = (~chainId: int): array => + switch onEventRegistrationsByChainId->Utils.Dict.dangerouslyGetNonOption(chainId->Int.toString) { + | Some(regs) => regs + | None => [] } -} -let set = (~contractName, ~eventName, registration) => { - eventRegistrations->Dict.set(getKey(~contractName, ~eventName), registration) -} +let setChainOnEventRegistrations = (~chainId: int, regs) => + onEventRegistrationsByChainId->Dict.set(chainId->Int.toString, regs) + +// Test-only: clear the process-global store so a fresh registration cycle +// starts empty (production starts each isolate empty and registers once). +let resetOnEventRegistrations = () => + onEventRegistrationsByChainId + ->Dict.keysToArray + ->Array.forEach(key => onEventRegistrationsByChainId->Dict.set(key, [])) let getActiveRegistration = () => EnvioGlobal.value.activeRegistration->(Utils.magic: option => option) @@ -107,7 +104,6 @@ let getPendingChainRegistrations = (r: activeRegistration, ~chainId: int) => { | Some(pending) => pending | None => let fresh = { - onEventRegistrations: Dict.make(), onBlockRegistrations: [], } r.registrationsByChainId->Dict.set(key, fresh) @@ -158,92 +154,48 @@ let buildOnEventRegistrationWith = ( } } -// Enrich one event definition into its (event, chain) registration using -// whatever handler/contractRegister/where the user registered for it. Shared -// by the incremental per-chain sync below, `simulate`, and test helpers so -// they stay in sync instead of re-deriving the per-ecosystem dispatch each -// place. -let buildOnEventRegistration = ( - ~config: Config.t, - ~chainId: int, - ~eventConfig: Internal.eventConfig, - ~startBlock=?, -): Internal.onEventRegistration => { - let t = get(~contractName=eventConfig.contractName, ~eventName=eventConfig.name) - buildOnEventRegistrationWith( - ~config, - ~chainId, - ~eventConfig, - ~isWildcard=t.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false), - ~handler=t.handler, - ~contractRegister=t.contractRegister, - ~where=t.eventOptions->Option.flatMap(v => v.where), - ~startBlock?, - ) -} - -let getHandler = (~contractName, ~eventName) => get(~contractName, ~eventName).handler - -let getContractRegister = (~contractName, ~eventName) => - get(~contractName, ~eventName).contractRegister - -let isWildcard = (~contractName, ~eventName) => - get(~contractName, ~eventName).eventOptions - ->Option.flatMap(value => value.wildcard) - ->Option.getOr(false) - -let hasRegistration = (~contractName, ~eventName) => { - let r = get(~contractName, ~eventName) - r.handler->Option.isSome || r.contractRegister->Option.isSome -} - -type eventNamespace = {contractName: string, eventName: string} - -let raiseDuplicateRegistration = (~contractName, ~eventName, ~msg, ~logger) => { - let fullMsg = msg ++ " for " ++ contractName ++ "." ++ eventName - Logging.createChildFrom(~logger, ~params={contractName, eventName})->Logging.childError(fullMsg) - JsError.throwWithMessage(fullMsg) -} - -// `where` equality is checked per chain on the resolved structure (see -// `syncOnEventRegistrations`), so registration options only need to agree on -// `wildcard` here — two callbacks that resolve to identical filters count as -// identical options even when the function references differ. -let eventOptionsMatch = ( - existing: option>, - incoming: option>, -) => { - switch (existing, incoming) { - | (None, None) => true - | (Some(a), Some(b)) => a.wildcard === b.wildcard - | _ => false - } -} - let getResolvedWhere = (reg: Internal.onEventRegistration) => ( reg->(Utils.magic: Internal.onEventRegistration => Internal.evmOnEventRegistration) ).resolvedWhere -// Resolve the registration for every configured chain that defines the event -// and store it in the pending per-chain registry. When the chain already -// holds a registration for this event, the resolved `where` structures must -// deep-compare equal (`Values` by hex arrays, `ContractAddresses` by contract -// name, plus `startBlock`) — otherwise it's a conflicting duplicate -// registration. Both live registrations and `preRegistered` callbacks -// replayed by `startRegistration` run through this single code path. -let syncOnEventRegistrations = ( - r: activeRegistration, +// Two chain registrations target the same fetched log when they share the +// event, the wildcard flag, and (on EVM) the resolved `where` — a handler and +// a contractRegister that agree on all three can be merged into one +// registration (so one item per log runs both). `where` is compared on the +// resolved structure (`Values` by hex arrays, `ContractAddresses` by contract +// name, plus `startBlock`); differing filters stay separate registrations. +let sameEventAndFilter = ( + a: Internal.onEventRegistration, + b: Internal.onEventRegistration, + ~config: Config.t, +) => + a.eventConfig.contractName === b.eventConfig.contractName && + a.eventConfig.name === b.eventConfig.name && + a.isWildcard === b.isWildcard && + switch config.ecosystem.name { + | Evm => getResolvedWhere(a) == getResolvedWhere(b) + | Fuel | Svm => true + } + +// Resolve one `indexer.onEvent` / `.contractRegister` call into a registration +// per configured chain that defines the event, and append it to that chain's +// store. A handler and a contractRegister for the same event and filter merge +// into a single registration; two handlers (or two contractRegisters) never +// merge and each become their own registration. A merge always lands on the +// handler registration's slot so dispatch order follows handler registration +// order. +let addOnEventRegistration = ( + registration: activeRegistration, ~contractName, ~eventName, - ~where: option, - ~duplicateMsg, - ~logger, + ~handler: option, + ~contractRegister: option, + ~eventOptions: option>, ) => { - let config = r.config - let t = get(~contractName, ~eventName) - let isWildcard = t.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) - let key = getKey(~contractName, ~eventName) + let config = registration.config + let isWildcard = eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) + let where = eventOptions->Option.flatMap(v => v.where) config.chainMap ->ChainMap.values @@ -253,173 +205,282 @@ let syncOnEventRegistrations = ( switch contract.events->Array.find(e => e.name === eventName) { | None => () | Some(eventConfig) => - let newRegistration = buildOnEventRegistrationWith( + let chainId = chainConfig.id + let incoming = buildOnEventRegistrationWith( ~config, - ~chainId=chainConfig.id, + ~chainId, ~eventConfig, ~isWildcard, - ~handler=t.handler, - ~contractRegister=t.contractRegister, + ~handler, + ~contractRegister, ~where, ~startBlock=?contract.startBlock, ) - let pending = r->getPendingChainRegistrations(~chainId=chainConfig.id) - switch pending.onEventRegistrations->Utils.Dict.dangerouslyGetNonOption(key) { - | Some(existing) if config.ecosystem.name === Evm => - if !(existing->getResolvedWhere == newRegistration->getResolvedWhere) { - raiseDuplicateRegistration(~contractName, ~eventName, ~msg=duplicateMsg, ~logger) - } - | _ => () + let regs = getChainOnEventRegistrations(~chainId) + + // A handler scans for a contractRegister-only sibling to absorb, a + // contractRegister scans for a handler-only sibling to attach to. + let mergeTargetIndex = regs->Array.findIndex( + existing => + sameEventAndFilter(existing, incoming, ~config) && + switch (handler, contractRegister) { + | (Some(_), None) => + existing.handler->Option.isNone && existing.contractRegister->Option.isSome + | (None, Some(_)) => + existing.handler->Option.isSome && existing.contractRegister->Option.isNone + | _ => false + }, + ) + + switch (handler, contractRegister) { + // Handler absorbs the contractRegister-only registration: drop it and + // append the merged registration so it takes the handler's slot. + | (Some(_), None) if mergeTargetIndex >= 0 => + let target = regs->Array.getUnsafe(mergeTargetIndex) + let merged = {...incoming, contractRegister: target.contractRegister} + let next = regs->Array.filterWithIndex((_, i) => i !== mergeTargetIndex) + next->Array.push(merged)->ignore + setChainOnEventRegistrations(~chainId, next) + // ContractRegister merges into the handler registration, keeping its slot. + | (None, Some(_)) if mergeTargetIndex >= 0 => + let target = regs->Array.getUnsafe(mergeTargetIndex) + let merged = {...target, contractRegister} + let next = regs->Array.mapWithIndex((r, i) => i === mergeTargetIndex ? merged : r) + setChainOnEventRegistrations(~chainId, next) + | _ => setChainOnEventRegistrations(~chainId, regs->Array.concat([incoming])) } - pending.onEventRegistrations->Dict.set(key, newRegistration) } } }) }) } -let setEventOptions = (~contractName, ~eventName, ~eventOptions, ~logger=Logging.getLogger()) => { - switch eventOptions { - | Some(value) => - let value = value->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions) - let t = get(~contractName, ~eventName) - switch t.eventOptions { - | None => set(~contractName, ~eventName, {...t, eventOptions: Some(value)}) - | Some(existingValue) => - if !eventOptionsMatch(Some(existingValue), Some(value)) { - raiseDuplicateRegistration( - ~contractName, - ~eventName, - ~msg="Cannot register handler with different options. Make sure all handlers for the same event use identical options (wildcard, where)", - ~logger, - ) - } - } - | None => () - } -} - -let setHandler = ( - ~contractName, - ~eventName, - handler, - ~eventOptions, - ~logger=Logging.getLogger(), -) => { +let setHandler = (~contractName, ~eventName, handler, ~eventOptions) => { withRegistration(registration => { - let t = get(~contractName, ~eventName) let newHandler = handler->(Utils.magic: Internal.genericHandler<'args> => Internal.handler) - let incomingEventOptions = + let eventOptions = eventOptions->Option.map(v => v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions) ) - switch t.handler { - | None => - setEventOptions(~contractName, ~eventName, ~eventOptions, ~logger) - let t = get(~contractName, ~eventName) - set( - ~contractName, - ~eventName, - { - ...t, - handler: Some(newHandler), - }, - ) - | Some(prevHandler) => - if eventOptionsMatch(t.eventOptions, incomingEventOptions) { - let composedHandler: Internal.handler = async args => { - await prevHandler(args) - await newHandler(args) - } - set( - ~contractName, - ~eventName, - { - ...t, - handler: Some(composedHandler), - }, - ) - } else { - raiseDuplicateRegistration( - ~contractName, - ~eventName, - ~msg="Cannot register a second handler with different options. Make sure all handlers for the same event use identical options (wildcard, where)", - ~logger, - ) - } - } - registration->syncOnEventRegistrations( + registration->addOnEventRegistration( ~contractName, ~eventName, - ~where=incomingEventOptions->Option.flatMap(v => v.where), - ~duplicateMsg="Cannot register a second handler with different options. Make sure all handlers for the same event use identical options (wildcard, where)", - ~logger, + ~handler=Some(newHandler), + ~contractRegister=None, + ~eventOptions, ) }) } -let setContractRegister = ( - ~contractName, - ~eventName, - contractRegister, - ~eventOptions, - ~logger=Logging.getLogger(), -) => { +let setContractRegister = (~contractName, ~eventName, contractRegister, ~eventOptions) => { withRegistration(registration => { - let t = get(~contractName, ~eventName) let newContractRegister = contractRegister->( Utils.magic: Internal.genericContractRegister< Internal.genericContractRegisterArgs<'event, 'context>, > => Internal.contractRegister ) - let incomingEventOptions = + let eventOptions = eventOptions->Option.map(v => v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions) ) - switch t.contractRegister { - | None => - setEventOptions(~contractName, ~eventName, ~eventOptions, ~logger) - let t = get(~contractName, ~eventName) - set( - ~contractName, - ~eventName, - { - ...t, - contractRegister: Some(newContractRegister), - }, - ) - | Some(prevContractRegister) => - if eventOptionsMatch(t.eventOptions, incomingEventOptions) { - let composedContractRegister: Internal.contractRegister = async args => { - await prevContractRegister(args) - await newContractRegister(args) - } - set( - ~contractName, - ~eventName, + registration->addOnEventRegistration( + ~contractName, + ~eventName, + ~handler=None, + ~contractRegister=Some(newContractRegister), + ~eventOptions, + ) + }) +} + +// True when any registration for the event is a wildcard. Used by simulate to +// decide whether a src address needs deriving. +let isWildcard = (~contractName, ~eventName) => + onEventRegistrationsByChainId + ->Dict.valuesToArray + ->Array.some(regs => + regs->Array.some(reg => + reg.eventConfig.contractName === contractName && + reg.eventConfig.name === eventName && + reg.isWildcard + ) + ) + +// Every registration for one event on a chain, so simulate fans a simulated +// event out to each the way real routing does. Falls back to a bare +// registration when the event has no handler/contractRegister, so a simulated +// item still produces an item to run. +let getSimulateOnEventRegistrations = ( + ~config: Config.t, + ~chainId: int, + ~eventConfig: Internal.eventConfig, +): array => { + let matching = + getChainOnEventRegistrations(~chainId)->Array.filter(reg => + reg.eventConfig.contractName === eventConfig.contractName && + reg.eventConfig.name === eventConfig.name + ) + if matching->Utils.Array.notEmpty { + matching + } else { + [ + buildOnEventRegistrationWith( + ~config, + ~chainId, + ~eventConfig, + ~isWildcard=false, + ~handler=None, + ~contractRegister=None, + ~where=None, + ), + ] + } +} + +let finishRegistration = (~config: Config.t): registrationsByChainId => { + switch getActiveRegistration() { + | Some(r) => { + r.finished = true + let notRegisteredEventsByContract: dict> = Dict.make() + let registrationsByChainId: registrationsByChainId = Dict.make() + config.chainMap + ->ChainMap.values + ->Array.forEach(chainConfig => { + let chainId = chainConfig.id + let key = chainId->Int.toString + + let builtRegs = getChainOnEventRegistrations(~chainId) + let registeredKeys = Utils.Set.make() + builtRegs->Array.forEach(reg => + registeredKeys + ->Utils.Set.add( + getKey(~contractName=reg.eventConfig.contractName, ~eventName=reg.eventConfig.name), + ) + ->ignore + ) + + // Events with no handler/contractRegister aren't fetched or dispatched + // unless raw events are enabled, in which case a bare registration is + // added to fetch them. Otherwise they're reported once below. + let rawEventRegs = [] + chainConfig.contracts->Array.forEach(contract => { + contract.events->Array.forEach( + eventConfig => { + if ( + !( + registeredKeys->Utils.Set.has( + getKey(~contractName=contract.name, ~eventName=eventConfig.name), + ) + ) + ) { + if config.enableRawEvents { + rawEventRegs + ->Array.push( + buildOnEventRegistrationWith( + ~config, + ~chainId, + ~eventConfig, + ~isWildcard=false, + ~handler=None, + ~contractRegister=None, + ~where=None, + ~startBlock=?contract.startBlock, + ), + ) + ->ignore + } else { + let eventNames = switch notRegisteredEventsByContract->Utils.Dict.dangerouslyGetNonOption( + contract.name, + ) { + | Some(set) => set + | None => { + let set = Utils.Set.make() + notRegisteredEventsByContract->Dict.set(contract.name, set) + set + } + } + eventNames->Utils.Set.add(eventConfig.name)->ignore + } + } + }, + ) + }) + + // A `where` that resolved to no topic selections (`false` for this + // chain) drops the chain's registration entirely — the event should + // never be fetched here. Each survivor is assigned its chain-scoped + // index by position. + let onEventRegistrations: array = [] + builtRegs + ->Array.concat(rawEventRegs) + ->Array.forEach(reg => { + let isDroppedByWhere = + config.ecosystem.name === Evm && + (reg->getResolvedWhere).topicSelections->Utils.Array.isEmpty + if !isDroppedByWhere { + onEventRegistrations + ->Array.push({...reg, index: onEventRegistrations->Array.length}) + ->ignore + } + }) + + registrationsByChainId->Dict.set( + key, { - ...t, - contractRegister: Some(composedContractRegister), + onEventRegistrations, + onBlockRegistrations: switch r.registrationsByChainId->Utils.Dict.dangerouslyGetNonOption( + key, + ) { + | Some(pending) => pending.onBlockRegistrations + | None => [] + }, }, ) - } else { - raiseDuplicateRegistration( - ~contractName, - ~eventName, - ~msg="Cannot register a second contractRegister with different options. Make sure all handlers for the same event use identical options (wildcard, where)", - ~logger, + }) + + // Reported once for the whole indexer (a shared contract on multiple + // chains would otherwise repeat the same message per chain). + let notRegisteredEntries = notRegisteredEventsByContract->Dict.toArray + if notRegisteredEntries->Utils.Array.notEmpty { + let groups = + notRegisteredEntries + ->Array.map(((contractName, eventNames)) => + `${contractName} (${eventNames->Utils.Set.toArray->Array.joinUnsafe(", ")})` + ) + ->Array.joinUnsafe(", ") + Logging.getLogger()->Logging.childInfo( + `Events without a handler, skipped for indexing: ${groups}`, ) } + + registrationsByChainId } - registration->syncOnEventRegistrations( - ~contractName, - ~eventName, - ~where=incomingEventOptions->Option.flatMap(v => v.where), - ~duplicateMsg="Cannot register a second contractRegister with different options. Make sure all handlers for the same event use identical options (wildcard, where)", - ~logger, + | None => + JsError.throwWithMessage( + "The indexer has not started registering handlers, so can't finish it.", ) - }) + } +} + +let isPendingRegistration = () => { + switch getActiveRegistration() { + | Some(r) => !r.finished + | None => false + } +} + +// Early guard called from `indexer.onEvent` / `.contractRegister` / `.onBlock` / +// `.onSlot` so the user sees a method-specific error at the call site, instead +// of hitting the generic `withRegistration` throw deep inside `setHandler` etc. +let throwIfFinishedRegistration = (~methodName) => { + switch getActiveRegistration() { + | Some({finished: true}) => + JsError.throwWithMessage( + `Cannot call \`indexer.${methodName}\` after the indexer has started. Make sure all handlers are registered at the top level of your handler module.`, + ) + | _ => () + } } // Shape of the user-returned `{_gte?, _lte?, _every?}` filter chunk after @@ -587,218 +648,3 @@ let registerOnBlock = ( } }) } - -// Per-eventId dispatch validation state: two events on one contract can't -// share a dispatch id, and only one wildcard may claim it — Rust-side routing -// fans a log/receipt/instruction out by these ids, so a collision would -// double-deliver. Scoped per chain (a fresh validator per chain iteration). -type eventIdValidator = { - contractNamesByEventId: dict>, - wildcardEventIds: Utils.Set.t, -} - -let makeEventIdValidator = (): eventIdValidator => { - contractNamesByEventId: Dict.make(), - wildcardEventIds: Utils.Set.make(), -} - -let validateEventIdOrThrow = ( - validator: eventIdValidator, - ~eventId, - ~contractName, - ~eventName, - ~isWildcard, - ~chainId, -) => { - let chainSuffix = `on chain ${chainId->Int.toString}` - let contractNames = switch validator.contractNamesByEventId->Utils.Dict.dangerouslyGetNonOption( - eventId, - ) { - | Some(contractNames) => contractNames - | None => { - let contractNames = Utils.Set.make() - validator.contractNamesByEventId->Dict.set(eventId, contractNames) - contractNames - } - } - if contractNames->Utils.Set.has(contractName) { - JsError.throwWithMessage( - `Duplicate event detected: ${eventName} for contract ${contractName} ${chainSuffix}`, - ) - } - if isWildcard && validator.wildcardEventIds->Utils.Set.has(eventId) { - JsError.throwWithMessage( - `Another event is already registered with the same signature that would interfere with wildcard filtering: ${eventName} for contract ${contractName} ${chainSuffix}`, - ) - } - if isWildcard { - validator.wildcardEventIds->Utils.Set.add(eventId)->ignore - } - contractNames->Utils.Set.add(contractName)->ignore -} - -let finishRegistration = (~config: Config.t): registrationsByChainId => { - switch getActiveRegistration() { - | Some(r) => { - r.finished = true - let notRegisteredEventsByContract: dict> = Dict.make() - let registrationsByChainId: registrationsByChainId = Dict.make() - config.chainMap - ->ChainMap.values - ->Array.forEach(chainConfig => { - let key = chainConfig.id->Int.toString - let pending = r.registrationsByChainId->Utils.Dict.dangerouslyGetNonOption(key) - - let eventIdValidator = makeEventIdValidator() - - let onEventRegistrations: array = [] - - chainConfig.contracts->Array.forEach(contract => { - let contractName = contract.name - - contract.events->Array.forEach( - eventConfig => { - let eventName = eventConfig.name - let registration = - pending->Option.flatMap( - pending => - pending.onEventRegistrations->Utils.Dict.dangerouslyGetNonOption( - getKey(~contractName, ~eventName), - ), - ) - - // SVM dispatch is keyed by (programId, discriminator), not the - // discriminator alone — `eventConfig.id` is only the - // discriminator (or "none"). Scope the SVM validation key by - // programId so two wildcard handlers on different programs that - // share a discriminator aren't rejected as a false collision - // (Rust routing already scopes matches by program_id). - let eventId = switch config.ecosystem.name { - | Svm => - let svmEventConfig = - eventConfig->( - Utils.magic: Internal.eventConfig => Internal.svmInstructionEventConfig - ) - `${svmEventConfig.programId->SvmTypes.Pubkey.toString}_${eventConfig.id}` - | Evm | Fuel => eventConfig.id - } - eventIdValidator->validateEventIdOrThrow( - ~eventId, - ~contractName, - ~eventName, - ~isWildcard=switch registration { - | Some(registration) => registration.isWildcard - | None => isWildcard(~contractName, ~eventName) - }, - ~chainId=chainConfig.id, - ) - - let registration = switch registration { - | Some(_) as registration => registration - | None => - // No entry in the incremental store, but the persistent dict - // may still hold a handler: handler modules are import-cached, - // so a repeated registration cycle in the same process (tests - // restarting the indexer) never re-runs the `indexer.onEvent` - // calls. Rebuild from the dict in that case. Events without a - // handler/contractRegister aren't fetched or dispatched - // (unless raw events are enabled). - if hasRegistration(~contractName, ~eventName) || config.enableRawEvents { - Some( - buildOnEventRegistration( - ~config, - ~chainId=chainConfig.id, - ~eventConfig, - ~startBlock=?contract.startBlock, - ), - ) - } else { - let eventNames = switch notRegisteredEventsByContract->Utils.Dict.dangerouslyGetNonOption( - contractName, - ) { - | Some(set) => set - | None => { - let set = Utils.Set.make() - notRegisteredEventsByContract->Dict.set(contractName, set) - set - } - } - eventNames->Utils.Set.add(eventName)->ignore - None - } - } - - switch registration { - | Some(registration) => - // A `where` that resolved to no topic selections (`false` for - // this chain) drops the chain's registration entirely — the - // event should never be fetched here. - let isDroppedByWhere = - config.ecosystem.name === Evm && - (registration->getResolvedWhere).topicSelections->Utils.Array.isEmpty - if !isDroppedByWhere { - onEventRegistrations - ->Array.push({...registration, index: onEventRegistrations->Array.length}) - ->ignore - } - | None => () - } - }, - ) - }) - - registrationsByChainId->Dict.set( - key, - { - onEventRegistrations, - onBlockRegistrations: switch pending { - | Some(pending) => pending.onBlockRegistrations - | None => [] - }, - }, - ) - }) - - // Reported once for the whole indexer (a shared contract on multiple - // chains would otherwise repeat the same message per chain). - let notRegisteredEntries = notRegisteredEventsByContract->Dict.toArray - if notRegisteredEntries->Utils.Array.notEmpty { - let groups = - notRegisteredEntries - ->Array.map(((contractName, eventNames)) => - `${contractName} (${eventNames->Utils.Set.toArray->Array.joinUnsafe(", ")})` - ) - ->Array.joinUnsafe(", ") - Logging.getLogger()->Logging.childInfo( - `Events without a handler, skipped for indexing: ${groups}`, - ) - } - - registrationsByChainId - } - | None => - JsError.throwWithMessage( - "The indexer has not started registering handlers, so can't finish it.", - ) - } -} - -let isPendingRegistration = () => { - switch getActiveRegistration() { - | Some(r) => !r.finished - | None => false - } -} - -// Early guard called from `indexer.onEvent` / `.contractRegister` / `.onBlock` / -// `.onSlot` so the user sees a method-specific error at the call site, instead -// of hitting the generic `withRegistration` throw deep inside `setHandler` etc. -let throwIfFinishedRegistration = (~methodName) => { - switch getActiveRegistration() { - | Some({finished: true}) => - JsError.throwWithMessage( - `Cannot call \`indexer.${methodName}\` after the indexer has started. Make sure all handlers are registered at the top level of your handler module.`, - ) - | _ => () - } -} diff --git a/packages/envio/src/HandlerRegister.resi b/packages/envio/src/HandlerRegister.resi index 401050fac..353eb7bb2 100644 --- a/packages/envio/src/HandlerRegister.resi +++ b/packages/envio/src/HandlerRegister.resi @@ -5,49 +5,29 @@ type chainRegistrations = { type registrationsByChainId = dict let startRegistration: (~config: Config.t) => unit +let resetOnEventRegistrations: unit => unit let isPendingRegistration: unit => bool let finishRegistration: (~config: Config.t) => registrationsByChainId let throwIfFinishedRegistration: (~methodName: string) => unit -type eventIdValidator -let makeEventIdValidator: unit => eventIdValidator -let validateEventIdOrThrow: ( - eventIdValidator, - ~eventId: string, - ~contractName: string, - ~eventName: string, - ~isWildcard: bool, - ~chainId: int, -) => unit - -let buildOnEventRegistration: ( - ~config: Config.t, - ~chainId: int, - ~eventConfig: Internal.eventConfig, - ~startBlock: int=?, -) => Internal.onEventRegistration - let setHandler: ( ~contractName: string, ~eventName: string, Internal.genericHandler>, ~eventOptions: option>, - ~logger: Pino.t=?, ) => unit let setContractRegister: ( ~contractName: string, ~eventName: string, Internal.genericContractRegister>, ~eventOptions: option>, - ~logger: Pino.t=?, ) => unit -let getHandler: (~contractName: string, ~eventName: string) => option -let getContractRegister: ( - ~contractName: string, - ~eventName: string, -) => option let isWildcard: (~contractName: string, ~eventName: string) => bool -let hasRegistration: (~contractName: string, ~eventName: string) => bool +let getSimulateOnEventRegistrations: ( + ~config: Config.t, + ~chainId: int, + ~eventConfig: Internal.eventConfig, +) => array type blockRange = { _gte: option, diff --git a/packages/envio/src/SimulateItems.res b/packages/envio/src/SimulateItems.res index e33bbfbcb..efe7407d8 100644 --- a/packages/envio/src/SimulateItems.res +++ b/packages/envio/src/SimulateItems.res @@ -339,46 +339,49 @@ let parse = ( | None => seenCoordinates->Dict.set(coordinate, itemIndex) } - // Build a real registration the same way `HandlerRegister.finishRegistration` - // does at startup (not a stub), so the address filter and `where` - // behave identically to real indexing — the dead-input tracker relies - // on `clientAddressFilter` actually gating unrouted items. - let onEventRegistration = HandlerRegister.buildOnEventRegistration( + // Fan the simulated event out to every registration the way real routing + // does (one item per registration). Registrations are built the same way + // `HandlerRegister.finishRegistration` does at startup (not stubs), so the + // address filter and `where` behave identically to real indexing — the + // dead-input tracker relies on `clientAddressFilter` actually gating + // unrouted items. + HandlerRegister.getSimulateOnEventRegistrations( ~config, ~chainId, ~eventConfig, - ) - // Append into the registration array that the chain state will own and - // put that same registration object directly on the simulated item. - let onEventRegistrationIndex = onEventRegistrations->Array.length - let onEventRegistration = {...onEventRegistration, index: onEventRegistrationIndex} - onEventRegistrations->Array.push(onEventRegistration)->ignore - - items - ->Array.push( - Internal.Event({ - onEventRegistration, - chain, - blockNumber, - logIndex, - // Simulate keeps the transaction inline on the payload, so the store - // key is unused. - transactionIndex: 0, - payload: ( - { - contractName: eventConfig.contractName, - eventName: eventConfig.name, - params, - chainId, - srcAddress, - logIndex, - transaction, - block, - }: Evm.payload - )->Evm.fromPayload, - }), - ) - ->ignore + )->Array.forEach(reg => { + // Append into the registration array that the chain state will own and + // put that same registration object directly on the simulated item. + let onEventRegistrationIndex = onEventRegistrations->Array.length + let onEventRegistration = {...reg, index: onEventRegistrationIndex} + onEventRegistrations->Array.push(onEventRegistration)->ignore + + items + ->Array.push( + Internal.Event({ + onEventRegistration, + chain, + blockNumber, + logIndex, + // Simulate keeps the transaction inline on the payload, so the store + // key is unused. + transactionIndex: 0, + payload: ( + { + contractName: eventConfig.contractName, + eventName: eventConfig.name, + params, + chainId, + srcAddress, + logIndex, + transaction, + block, + }: Evm.payload + )->Evm.fromPayload, + }), + ) + ->ignore + }) | _ => JsError.throwWithMessage(`simulate: Invalid item. Each item must have "contract" and "event" fields.`) diff --git a/scenarios/test_codegen/test/HandlerRegisterLifecycle_test.res b/scenarios/test_codegen/test/HandlerRegisterLifecycle_test.res index cfa83ca76..6fe42b24f 100644 --- a/scenarios/test_codegen/test/HandlerRegisterLifecycle_test.res +++ b/scenarios/test_codegen/test/HandlerRegisterLifecycle_test.res @@ -1,10 +1,10 @@ open Vitest -// Covers the per-chain immediate resolution in `HandlerRegister`: duplicate -// registrations are compared on the resolved `where` structure (not the -// callback reference), invalid `where`/onBlock options throw at the user's -// registration call site, and `preRegistered` callbacks replayed by -// `startRegistration` run through the same code path. +// Covers the per-chain immediate resolution in `HandlerRegister`: every +// `onEvent` becomes its own registration (no composing, no duplicate error), +// invalid `where`/onBlock options still throw at the user's registration call +// site, and `preRegistered` callbacks replayed by `startRegistration` run +// through the same code path. // // This file must not import the handler fixtures — it drives the global // registry lifecycle itself, relying on vitest's per-file isolation. @@ -28,25 +28,21 @@ HandlerRegister.setHandler( ) HandlerRegister.startRegistration(~config) -describe("HandlerRegister — duplicate registrations compare resolved where", () => { - it("a distinct callback with an equal resolution composes instead of throwing", t => { - HandlerRegister.setHandler( - ~contractName="EventFiltersTest", - ~eventName="Transfer", - noopHandler, - ~eventOptions=eventOptions( - ~where=%raw(`({chain: _chain}) => ({params: {from: "0x0000000000000000000000000000000000000000"}})`), - ), - ) - t.expect( - HandlerRegister.getHandler( +describe("HandlerRegister — every onEvent registers separately", () => { + it("a second handler with an equal resolution registers without composing or throwing", t => { + t.expect(() => + HandlerRegister.setHandler( ~contractName="EventFiltersTest", ~eventName="Transfer", - )->Option.isSome, - ).toBe(true) + noopHandler, + ~eventOptions=eventOptions( + ~where=%raw(`({chain: _chain}) => ({params: {from: "0x0000000000000000000000000000000000000000"}})`), + ), + ) + ).not.toThrow() }) - it("a callback with a different resolution throws at the registration call site", t => { + it("a handler with a different resolution registers separately without throwing", t => { t.expect(() => HandlerRegister.setHandler( ~contractName="EventFiltersTest", @@ -56,9 +52,7 @@ describe("HandlerRegister — duplicate registrations compare resolved where", ( ~where=%raw(`({chain: _chain}) => ({params: {to: "0x0000000000000000000000000000000000000000"}})`), ), ) - ).toThrowError( - "Cannot register a second handler with different options. Make sure all handlers for the same event use identical options (wildcard, where) for EventFiltersTest.Transfer", - ) + ).not.toThrow() }) it("an invalid where throws at the registration call site", t => { diff --git a/scenarios/test_codegen/test/OnEventRegistration_test.res b/scenarios/test_codegen/test/OnEventRegistration_test.res index 4c23a221b..84d0e5df2 100644 --- a/scenarios/test_codegen/test/OnEventRegistration_test.res +++ b/scenarios/test_codegen/test/OnEventRegistration_test.res @@ -1,6 +1,6 @@ open Vitest -// Covers `HandlerRegister.buildOnEventRegistration`: the handler-state fields +// Covers the built onEvent registration: the handler-state fields // (`handler`, `contractRegister`, `isWildcard`) registered via // `indexer.onEvent` / `indexer.contractRegister` land on the built // registration, and `dependsOnAddresses` follows the shared diff --git a/scenarios/test_codegen/test/__mocks__/MockConfig.res b/scenarios/test_codegen/test/__mocks__/MockConfig.res index 27bad0872..838c37ace 100644 --- a/scenarios/test_codegen/test/__mocks__/MockConfig.res +++ b/scenarios/test_codegen/test/__mocks__/MockConfig.res @@ -17,10 +17,10 @@ let getEvmEventConfig = (~config=?, ~contractName, ~eventName, ~chainId=?) => Utils.magic: Internal.eventConfig => Internal.evmEventConfig ) -// Build the per-(event, chain) registration from the event definition + the -// registered handlers, mirroring `HandlerRegister.finishRegistration`. -// Handlers must have been registered (`HandlerLoader.registerAllHandlers`) -// before calling. +// The first per-(event, chain) registration built from the event definition + +// the registered handlers. Handlers must have been registered +// (`HandlerLoader.registerAllHandlers`) before calling; falls back to a bare +// registration when the event has none. let getOnEventRegistration = (~config=?, ~contractName, ~eventName, ~chainId=?) => { let config = switch config { | Some(c) => c @@ -31,7 +31,9 @@ let getOnEventRegistration = (~config=?, ~contractName, ~eventName, ~chainId=?) | Some(id) => id | None => config.chainMap->ChainMap.values->Array.get(0)->Option.mapOr(0, c => c.id) } - HandlerRegister.buildOnEventRegistration(~config, ~chainId=probeChainId, ~eventConfig) + HandlerRegister.getSimulateOnEventRegistrations(~config, ~chainId=probeChainId, ~eventConfig) + ->Array.get(0) + ->Option.getOrThrow } let getEvmOnEventRegistration = (~config=?, ~contractName, ~eventName, ~chainId=?) => From 0f0a8e391dd43fdc1dbe43d9abb16db6c0372e1f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 13:54:32 +0000 Subject: [PATCH 02/16] Don't drop where-empty registrations at finish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `where` resolving to no topic selections already contributes no query terms, so the registration fetches nothing on its own — the explicit drop in finishRegistration wasn't needed here. Keeping the registration also removes its awkward ordering dependency with the raw-event backfill (a filtered-out event still counts as registered, so no bare raw-event reg is added for it). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- packages/envio/src/HandlerRegister.res | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index 4b6c85979..4da2f4758 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -407,23 +407,11 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { ) }) - // A `where` that resolved to no topic selections (`false` for this - // chain) drops the chain's registration entirely — the event should - // never be fetched here. Each survivor is assigned its chain-scoped - // index by position. - let onEventRegistrations: array = [] - builtRegs - ->Array.concat(rawEventRegs) - ->Array.forEach(reg => { - let isDroppedByWhere = - config.ecosystem.name === Evm && - (reg->getResolvedWhere).topicSelections->Utils.Array.isEmpty - if !isDroppedByWhere { - onEventRegistrations - ->Array.push({...reg, index: onEventRegistrations->Array.length}) - ->ignore - } - }) + // Each registration is assigned its chain-scoped index by position. + let onEventRegistrations = + builtRegs + ->Array.concat(rawEventRegs) + ->Array.mapWithIndex((reg, index) => {...reg, index}) registrationsByChainId->Dict.set( key, From 021b096191eda8e3a1a246d18f32fe138aa811b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 14:51:55 +0000 Subject: [PATCH 03/16] Store chain-independent registration intents; fix review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration reverts to storing chain-independent handler/contractRegister intents, resolved per-config at finishRegistration, instead of a per-chain resolved cache. The cache was only populated while handler modules ran, but those are import-cached and registerAllHandlers is called repeatedly with a narrowed config (TestIndexer narrows chainMap per process() call) — so chains absent when handlers first ran got no registrations. Intents are config-independent, so finishRegistration materializes registrations for whatever chains the current config has, and repeated registration is idempotent. contractRegister→handler merge and the where-empty drop now happen at finishRegistration. Also from PR review: - Fuel events participate in the parser-level duplicate-dispatch-key check, keyed on sighash (logId / mint/burn/transfer/call), matching the router. - Restore dropping EVM registrations whose where resolves to no topic selections (per-chain opt-out) at finishRegistration. - Remove the intentional duplicate-handler fixtures (and their two composition tests) that only existed to exercise the removed handler composition; they otherwise inflate every downstream fetch/query count assertion. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- .../cli/src/config_parsing/system_config.rs | 5 +- .../envio-tests/test/HandlerRegister_test.res | 40 ++- packages/envio/src/EnvioGlobal.res | 4 +- packages/envio/src/HandlerRegister.res | 267 ++++++++++-------- .../src/handlers/EventHandlers.ts | 17 -- .../test_codegen/test/EventHandler.test.ts | 63 ----- 6 files changed, 187 insertions(+), 209 deletions(-) diff --git a/packages/cli/src/config_parsing/system_config.rs b/packages/cli/src/config_parsing/system_config.rs index 16ef157fb..bdc17d14d 100644 --- a/packages/cli/src/config_parsing/system_config.rs +++ b/packages/cli/src/config_parsing/system_config.rs @@ -1941,7 +1941,10 @@ impl Contract { .clone() .unwrap_or_else(|| "none".to_string()), ), - EventKind::Fuel(_) => None, + // Fuel routing dispatches by sighash (a `LogData` logId, or a + // fixed value like `mint`/`burn`/`transfer`/`call`), so two + // events sharing it on one contract are indistinguishable too. + EventKind::Fuel(_) => Some(event.sighash.clone()), }; if let Some(dispatch_key) = dispatch_key { if let Some(existing) = diff --git a/packages/envio-tests/test/HandlerRegister_test.res b/packages/envio-tests/test/HandlerRegister_test.res index cc56a1bdf..bda2e341e 100644 --- a/packages/envio-tests/test/HandlerRegister_test.res +++ b/packages/envio-tests/test/HandlerRegister_test.res @@ -28,6 +28,28 @@ chains: address: "0x2222222222222222222222222222222222222222" `).config +// Same contracts/events as `config` but a different chain id. Used to verify +// that registration intents are chain-independent: registering under `config` +// (chain 1) still materializes registrations when `finishRegistration` runs +// for chain 137 (mirrors TestIndexer narrowing `chainMap` per run). +let config137 = MockIndexerConfig.parseYaml(` +name: handler-register-test-137 +contracts: + - name: ERC20 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Approval(address indexed owner, address indexed spender, uint256 value) +chains: + - id: 137 + rpc: + url: https://polygon.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" +`).config + // Each handler/contractRegister is a distinct function so registrations can be // identified by reference in assertions. let makeHandler = (): Internal.handler => %raw(`() => Promise.resolve()`) @@ -59,6 +81,7 @@ let setContractRegister = ( // stored function back to the ones registered below. let describeRegistrations = ( registrations: HandlerRegister.registrationsByChainId, + ~chainKey="1", ~contractName="ERC20", ~eventName="Transfer", ~labels: array<(Internal.handler, string)>, @@ -72,7 +95,7 @@ let describeRegistrations = ( ->Option.map(((_, label)) => label) ->Option.getOr("?") let chainRegistrations: HandlerRegister.chainRegistrations = - registrations->Utils.Dict.dangerouslyGetNonOption("1")->Option.getOrThrow + registrations->Utils.Dict.dangerouslyGetNonOption(chainKey)->Option.getOrThrow chainRegistrations.onEventRegistrations ->Array.filter(reg => reg.eventConfig.contractName === contractName && reg.eventConfig.name === eventName @@ -192,4 +215,19 @@ describe("HandlerRegister multiple registrations", () => { ), )).toEqual(([(Some("h1"), None, 0)], [(Some("h2"), None, 1)])) }) + + it("materializes registrations for a chain not present during registration", t => { + // Register under `config` (chain 1), then finish for `config137` (chain 137) + // without re-registering — intents are chain-independent, so chain 137 must + // still get the handler (the TestIndexer chainMap-narrowing case). + let h1 = makeHandler() + HandlerRegister.resetOnEventRegistrations() + HandlerRegister.startRegistration(~config) + setHandler(h1) + let _ = HandlerRegister.finishRegistration(~config) + let registrations137 = HandlerRegister.finishRegistration(~config=config137) + t.expect( + registrations137->describeRegistrations(~chainKey="137", ~labels=[(h1, "h1")], ~crLabels=[]), + ).toEqual([(Some("h1"), None, 0)]) + }) }) diff --git a/packages/envio/src/EnvioGlobal.res b/packages/envio/src/EnvioGlobal.res index c7403fd8b..53dd6ee72 100644 --- a/packages/envio/src/EnvioGlobal.res +++ b/packages/envio/src/EnvioGlobal.res @@ -15,7 +15,7 @@ // deduplication hint instead of silently mixing shapes across builds. type t = { version: string, - onEventRegistrationsByChainId: dict, + pendingOnEventRegistrations: array, mutable activeRegistration: option, preRegistered: array, rollbackCommitCallbacks: array, @@ -40,7 +40,7 @@ let value: t = { | None => let fresh = { version, - onEventRegistrationsByChainId: Dict.make(), + pendingOnEventRegistrations: [], activeRegistration: None, preRegistered: [], rollbackCommitCallbacks: [], diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index 4da2f4758..461ccc896 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -11,8 +11,8 @@ type chainRegistrations = { type registrationsByChainId = dict // onBlock registrations collected per chain while registration is active. -// onEvent registrations are resolved eagerly into the process-global store -// below, so nothing for them lives here. +// onEvent intents are chain-independent and resolved per config at +// `finishRegistration`, so nothing for them lives here. type pendingChainRegistrations = { onBlockRegistrations: array, } @@ -23,35 +23,38 @@ type activeRegistration = { mutable finished: bool, } -// Resolved onEvent registrations keyed by chain id, appended (and merged) -// eagerly as each `indexer.onEvent` / `.contractRegister` runs. Lives in the -// process-global `EnvioGlobal` record so it survives an import-cached -// re-registration cycle (handler modules register once per isolate, and there -// is one config per isolate, so a surviving store is only ever reused for the -// same config). `index` is -1 here; `finishRegistration` assigns the final -// chain-scoped index from array position. -let onEventRegistrationsByChainId = - EnvioGlobal.value.onEventRegistrationsByChainId->( - Utils.magic: dict => dict> +// One `indexer.onEvent` / `.contractRegister` call as a chain-independent +// intent: the handler (xor contractRegister) plus its raw `where`/wildcard +// options. Resolved into per-chain `Internal.onEventRegistration`s at +// `finishRegistration`. Chain-independent so a single isolate can materialize +// registrations for different configs (TestIndexer narrows `chainMap` per +// `process()` call, and handler modules — import-cached — register only once). +type pendingOnEventRegistration = { + contractName: string, + eventName: string, + handler: option, + contractRegister: option, + eventOptions: option>, +} + +// Registration intents live in the process-wide `EnvioGlobal` record so they +// survive an import-cached re-registration cycle (handler modules run once; +// `finishRegistration` may run many times, per config). +let pendingOnEventRegistrations = + EnvioGlobal.value.pendingOnEventRegistrations->( + Utils.magic: array => array ) let getKey = (~contractName, ~eventName) => contractName ++ "." ++ eventName -let getChainOnEventRegistrations = (~chainId: int): array => - switch onEventRegistrationsByChainId->Utils.Dict.dangerouslyGetNonOption(chainId->Int.toString) { - | Some(regs) => regs - | None => [] - } - -let setChainOnEventRegistrations = (~chainId: int, regs) => - onEventRegistrationsByChainId->Dict.set(chainId->Int.toString, regs) - -// Test-only: clear the process-global store so a fresh registration cycle -// starts empty (production starts each isolate empty and registers once). +// Test-only: clear the intent store so a fresh registration cycle starts empty +// (production starts each isolate empty and registers once). let resetOnEventRegistrations = () => - onEventRegistrationsByChainId - ->Dict.keysToArray - ->Array.forEach(key => onEventRegistrationsByChainId->Dict.set(key, [])) + pendingOnEventRegistrations->Array.splice( + ~start=0, + ~remove=pendingOnEventRegistrations->Array.length, + ~insert=[], + ) let getActiveRegistration = () => EnvioGlobal.value.activeRegistration->(Utils.magic: option => option) @@ -178,102 +181,108 @@ let sameEventAndFilter = ( | Fuel | Svm => true } -// Resolve one `indexer.onEvent` / `.contractRegister` call into a registration -// per configured chain that defines the event, and append it to that chain's -// store. A handler and a contractRegister for the same event and filter merge -// into a single registration; two handlers (or two contractRegisters) never -// merge and each become their own registration. A merge always lands on the -// handler registration's slot so dispatch order follows handler registration -// order. -let addOnEventRegistration = ( - registration: activeRegistration, - ~contractName, - ~eventName, - ~handler: option, - ~contractRegister: option, - ~eventOptions: option>, -) => { - let config = registration.config - let isWildcard = eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) - let where = eventOptions->Option.flatMap(v => v.where) - - config.chainMap - ->ChainMap.values - ->Array.forEach(chainConfig => { +// Resolve the chain-independent intents into this chain's registrations, then +// merge each contractRegister into a matching handler registration (either +// registration order; the merged registration takes the handler's slot so +// dispatch order follows handler registration order). Two handlers (or two +// contractRegisters) for one event never merge. Shared by `finishRegistration` +// and simulate so both see the same registrations. +let resolveChainRegistrations = (~config: Config.t, ~chainConfig: Config.chain): array< + Internal.onEventRegistration, +> => { + let chainId = chainConfig.id + let resolved: array = [] + pendingOnEventRegistrations->Array.forEach(intent => { chainConfig.contracts->Array.forEach(contract => { - if contract.name === contractName { - switch contract.events->Array.find(e => e.name === eventName) { + if contract.name === intent.contractName { + switch contract.events->Array.find(e => e.name === intent.eventName) { | None => () | Some(eventConfig) => - let chainId = chainConfig.id - let incoming = buildOnEventRegistrationWith( - ~config, - ~chainId, - ~eventConfig, - ~isWildcard, - ~handler, - ~contractRegister, - ~where, - ~startBlock=?contract.startBlock, + let isWildcard = intent.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) + let where = intent.eventOptions->Option.flatMap(v => v.where) + resolved + ->Array.push( + buildOnEventRegistrationWith( + ~config, + ~chainId, + ~eventConfig, + ~isWildcard, + ~handler=intent.handler, + ~contractRegister=intent.contractRegister, + ~where, + ~startBlock=?contract.startBlock, + ), ) - let regs = getChainOnEventRegistrations(~chainId) - - // A handler scans for a contractRegister-only sibling to absorb, a - // contractRegister scans for a handler-only sibling to attach to. - let mergeTargetIndex = regs->Array.findIndex( - existing => - sameEventAndFilter(existing, incoming, ~config) && - switch (handler, contractRegister) { - | (Some(_), None) => - existing.handler->Option.isNone && existing.contractRegister->Option.isSome - | (None, Some(_)) => - existing.handler->Option.isSome && existing.contractRegister->Option.isNone - | _ => false - }, - ) - - switch (handler, contractRegister) { - // Handler absorbs the contractRegister-only registration: drop it and - // append the merged registration so it takes the handler's slot. - | (Some(_), None) if mergeTargetIndex >= 0 => - let target = regs->Array.getUnsafe(mergeTargetIndex) - let merged = {...incoming, contractRegister: target.contractRegister} - let next = regs->Array.filterWithIndex((_, i) => i !== mergeTargetIndex) - next->Array.push(merged)->ignore - setChainOnEventRegistrations(~chainId, next) - // ContractRegister merges into the handler registration, keeping its slot. - | (None, Some(_)) if mergeTargetIndex >= 0 => - let target = regs->Array.getUnsafe(mergeTargetIndex) - let merged = {...target, contractRegister} - let next = regs->Array.mapWithIndex((r, i) => i === mergeTargetIndex ? merged : r) - setChainOnEventRegistrations(~chainId, next) - | _ => setChainOnEventRegistrations(~chainId, regs->Array.concat([incoming])) - } + ->ignore } } }) }) + + let merged: ref> = ref([]) + resolved->Array.forEach((reg: Internal.onEventRegistration) => { + if reg.handler->Option.isSome { + // A handler absorbs a matching contractRegister-only registration, + // dropping it and taking its own (handler) slot. + switch merged.contents->Array.findIndex(m => + m.handler->Option.isNone && + m.contractRegister->Option.isSome && + sameEventAndFilter(m, reg, ~config) + ) { + | -1 => merged := merged.contents->Array.concat([reg]) + | i => + let target = merged.contents->Array.getUnsafe(i) + merged := + merged.contents + ->Array.filterWithIndex((_, j) => j !== i) + ->Array.concat([{...reg, contractRegister: target.contractRegister}]) + } + } else { + // A contractRegister merges into a matching handler registration, + // keeping the handler's slot. + switch merged.contents->Array.findIndex(m => + m.handler->Option.isSome && + m.contractRegister->Option.isNone && + sameEventAndFilter(m, reg, ~config) + ) { + | -1 => merged := merged.contents->Array.concat([reg]) + | i => + let target = merged.contents->Array.getUnsafe(i) + let next = merged.contents->Array.copy + next->Array.setUnsafe(i, {...target, contractRegister: reg.contractRegister}) + merged := next + } + } + }) + merged.contents } +// A `where` that resolved to no topic selections (`false` for this chain) +// should never be fetched or dispatched here — drop it. Only meaningful on EVM. +let isDroppedByWhere = (~config: Config.t, reg: Internal.onEventRegistration) => + config.ecosystem.name === Evm && (reg->getResolvedWhere).topicSelections->Utils.Array.isEmpty + let setHandler = (~contractName, ~eventName, handler, ~eventOptions) => { - withRegistration(registration => { + withRegistration(_ => { let newHandler = handler->(Utils.magic: Internal.genericHandler<'args> => Internal.handler) let eventOptions = eventOptions->Option.map(v => v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions) ) - registration->addOnEventRegistration( - ~contractName, - ~eventName, - ~handler=Some(newHandler), - ~contractRegister=None, - ~eventOptions, - ) + pendingOnEventRegistrations + ->Array.push({ + contractName, + eventName, + handler: Some(newHandler), + contractRegister: None, + eventOptions, + }) + ->ignore }) } let setContractRegister = (~contractName, ~eventName, contractRegister, ~eventOptions) => { - withRegistration(registration => { + withRegistration(_ => { let newContractRegister = contractRegister->( Utils.magic: Internal.genericContractRegister< @@ -284,27 +293,25 @@ let setContractRegister = (~contractName, ~eventName, contractRegister, ~eventOp eventOptions->Option.map(v => v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions) ) - registration->addOnEventRegistration( - ~contractName, - ~eventName, - ~handler=None, - ~contractRegister=Some(newContractRegister), - ~eventOptions, - ) + pendingOnEventRegistrations + ->Array.push({ + contractName, + eventName, + handler: None, + contractRegister: Some(newContractRegister), + eventOptions, + }) + ->ignore }) } // True when any registration for the event is a wildcard. Used by simulate to // decide whether a src address needs deriving. let isWildcard = (~contractName, ~eventName) => - onEventRegistrationsByChainId - ->Dict.valuesToArray - ->Array.some(regs => - regs->Array.some(reg => - reg.eventConfig.contractName === contractName && - reg.eventConfig.name === eventName && - reg.isWildcard - ) + pendingOnEventRegistrations->Array.some(p => + p.contractName === contractName && + p.eventName === eventName && + p.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) ) // Every registration for one event on a chain, so simulate fans a simulated @@ -316,8 +323,9 @@ let getSimulateOnEventRegistrations = ( ~chainId: int, ~eventConfig: Internal.eventConfig, ): array => { + let chainConfig = config.chainMap->ChainMap.get(ChainMap.Chain.makeUnsafe(~chainId)) let matching = - getChainOnEventRegistrations(~chainId)->Array.filter(reg => + resolveChainRegistrations(~config, ~chainConfig)->Array.filter(reg => reg.eventConfig.contractName === eventConfig.contractName && reg.eventConfig.name === eventConfig.name ) @@ -350,7 +358,7 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { let chainId = chainConfig.id let key = chainId->Int.toString - let builtRegs = getChainOnEventRegistrations(~chainId) + let builtRegs = resolveChainRegistrations(~config, ~chainConfig) let registeredKeys = Utils.Set.make() builtRegs->Array.forEach(reg => registeredKeys @@ -362,7 +370,9 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { // Events with no handler/contractRegister aren't fetched or dispatched // unless raw events are enabled, in which case a bare registration is - // added to fetch them. Otherwise they're reported once below. + // added to fetch them. Otherwise they're reported once below. Runs + // before the where-empty drop so a `where: false` event still counts as + // registered (and doesn't get a bare raw-event registration). let rawEventRegs = [] chainConfig.contracts->Array.forEach(contract => { contract.events->Array.forEach( @@ -407,11 +417,18 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { ) }) - // Each registration is assigned its chain-scoped index by position. - let onEventRegistrations = - builtRegs - ->Array.concat(rawEventRegs) - ->Array.mapWithIndex((reg, index) => {...reg, index}) + // Drop registrations whose `where` opts out of this chain, then assign + // each survivor its chain-scoped index by position. + let onEventRegistrations: array = [] + builtRegs + ->Array.concat(rawEventRegs) + ->Array.forEach(reg => { + if !isDroppedByWhere(~config, reg) { + onEventRegistrations + ->Array.push({...reg, index: onEventRegistrations->Array.length}) + ->ignore + } + }) registrationsByChainId->Dict.set( key, diff --git a/scenarios/test_codegen/src/handlers/EventHandlers.ts b/scenarios/test_codegen/src/handlers/EventHandlers.ts index 209008c1d..4b731d063 100644 --- a/scenarios/test_codegen/src/handlers/EventHandlers.ts +++ b/scenarios/test_codegen/src/handlers/EventHandlers.ts @@ -886,23 +886,6 @@ indexer.onEvent({ contract: "EventFiltersTest", event: "FilterTestEvent", where: } }); -// Duplicate handler registration tests - -// Same options (no options) → should compose without error. -// The composed handler sets an additional entity to prove it ran. -indexer.onEvent({ contract: "Gravatar", event: "CustomSelection" }, async ({ event, context }) => { - context.CustomSelectionTestPass.set({ - id: "composed-" + event.transaction.hash, - }); -}); - -// Same options → composed contractRegister registers an additional contract -indexer.contractRegister({ contract: "Gravatar", event: "FactoryEvent" }, async ({ event, context }) => { - if (event.params.testCase === "composeContractRegister") { - context.chain.NftFactory.add(event.params.contract); - } -}); - // Capture the inner add() closure in one contractRegister invocation, then try // to invoke the captured closure from a later onEvent handler (after the first // handler has resolved and params.isResolved === true). The call must throw — diff --git a/scenarios/test_codegen/test/EventHandler.test.ts b/scenarios/test_codegen/test/EventHandler.test.ts index 8f2b858cc..281ec76ec 100644 --- a/scenarios/test_codegen/test/EventHandler.test.ts +++ b/scenarios/test_codegen/test/EventHandler.test.ts @@ -747,69 +747,6 @@ describe("Use Envio test framework to test event handlers", () => { }); }); - it("composes duplicate handlers with same options", async () => { - const indexer = createTestIndexer(); - const dcAddress = "0x1234567890123456789012345678901234567890"; - - // Process CustomSelection — original handler + composed handler should both run - const result = await indexer.process({ - chains: { - 1337: { - startBlock: 1, - endBlock: 100, - simulate: [ - { - contract: "Gravatar", - event: "CustomSelection", - transaction: { from: "0xfoo" }, - block: { parentHash: "0xParentHash" }, - }, - ], - }, - }, - }); - - // Original handler sets entity with id = event.transaction.hash - // Composed handler sets entity with id = "composed-" + event.transaction.hash - const change = result.changes[0]?.CustomSelectionTestPass; - assert.equal(change?.sets?.length, 2, "Both original and composed handler should set entities"); - assert.ok( - change?.sets?.some((e: { id: string }) => e.id.startsWith("composed-")), - "Composed handler should have set an entity with 'composed-' prefix" - ); - }); - - it("composes duplicate contractRegister with same options", async () => { - const indexer = createTestIndexer(); - const dcAddress = "0x1234567890123456789012345678901234567890"; - - // Process FactoryEvent with composeContractRegister testCase — - // original contractRegister adds SimpleNft (via syncRegistration path), - // composed contractRegister adds NftFactory - const result = await indexer.process({ - chains: { - 1337: { - startBlock: 1, - endBlock: 100, - simulate: [ - { - contract: "Gravatar", - event: "FactoryEvent", - params: { contract: dcAddress, testCase: "composeContractRegister" }, - }, - ], - }, - }, - }); - - // The composed contractRegister should have registered NftFactory - const addresses = result.changes[0]?.addresses?.sets; - assert.ok( - addresses?.some((a: { contract: string }) => a.contract === "NftFactory"), - "Composed contractRegister should register NftFactory" - ); - }); - it("captured contractRegister add() throws after handler resolved", async () => { const indexer = createTestIndexer(); const dcAddress = "0x1234567890123456789012345678901234567890"; From e954219020753ad36b52639a02dfa15153312b0c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 15:09:35 +0000 Subject: [PATCH 04/16] Validate where at call site; drop composition-era test fixtures - Restore call-site `where` validation: setHandler/setContractRegister resolve the intent against the active config before storing it, so an invalid `where` throws at the registration call site again (not deferred to finishRegistration). - Remove the intentional duplicate-registration fixtures that only exercised the removed handler composition / mismatched-options throw: a second Gravatar.FactoryEvent handler+contractRegister pair (captured-add test, whose property is already covered by throwOnHangingRegistration) and a CustomSelection wildcard re-registration. These added extra registrations to the Gravatar contract, inflating fetch/query counts across the E2E and rollback suites. Drop the now-orphaned captured-add test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- packages/envio/src/HandlerRegister.res | 55 ++++++++++++++++--- .../src/handlers/EventHandlers.ts | 37 ------------- .../test_codegen/test/EventHandler.test.ts | 40 -------------- 3 files changed, 47 insertions(+), 85 deletions(-) diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index 461ccc896..5638c37ec 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -262,27 +262,68 @@ let resolveChainRegistrations = (~config: Config.t, ~chainConfig: Config.chain): let isDroppedByWhere = (~config: Config.t, reg: Internal.onEventRegistration) => config.ecosystem.name === Evm && (reg->getResolvedWhere).topicSelections->Utils.Array.isEmpty +// Resolve the intent against the first chain that defines its event and discard +// the result, so a broken `where` (bad filter, unknown indexed param) throws at +// the user's registration call site instead of being deferred to +// `finishRegistration`. `config` may be a narrowed TestIndexer chain subset; if +// no chain defines the event there's nothing to validate. +let validateIntentWhere = (~config: Config.t, intent: pendingOnEventRegistration) => { + let isWildcard = intent.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) + let where = intent.eventOptions->Option.flatMap(v => v.where) + config.chainMap + ->ChainMap.values + ->Array.some(chainConfig => + chainConfig.contracts->Array.some(contract => + if contract.name === intent.contractName { + switch contract.events->Array.find(e => e.name === intent.eventName) { + | Some(eventConfig) => + let _ = buildOnEventRegistrationWith( + ~config, + ~chainId=chainConfig.id, + ~eventConfig, + ~isWildcard, + ~handler=intent.handler, + ~contractRegister=intent.contractRegister, + ~where, + ~startBlock=?contract.startBlock, + ) + true + | None => false + } + } else { + false + } + ) + ) + ->ignore +} + +let addIntent = (registration: activeRegistration, intent: pendingOnEventRegistration) => { + // Validate before storing so a broken `where` never leaves a poisoned intent + // in the global store. + validateIntentWhere(~config=registration.config, intent) + pendingOnEventRegistrations->Array.push(intent)->ignore +} + let setHandler = (~contractName, ~eventName, handler, ~eventOptions) => { - withRegistration(_ => { + withRegistration(registration => { let newHandler = handler->(Utils.magic: Internal.genericHandler<'args> => Internal.handler) let eventOptions = eventOptions->Option.map(v => v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions) ) - pendingOnEventRegistrations - ->Array.push({ + registration->addIntent({ contractName, eventName, handler: Some(newHandler), contractRegister: None, eventOptions, }) - ->ignore }) } let setContractRegister = (~contractName, ~eventName, contractRegister, ~eventOptions) => { - withRegistration(_ => { + withRegistration(registration => { let newContractRegister = contractRegister->( Utils.magic: Internal.genericContractRegister< @@ -293,15 +334,13 @@ let setContractRegister = (~contractName, ~eventName, contractRegister, ~eventOp eventOptions->Option.map(v => v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions) ) - pendingOnEventRegistrations - ->Array.push({ + registration->addIntent({ contractName, eventName, handler: None, contractRegister: Some(newContractRegister), eventOptions, }) - ->ignore }) } diff --git a/scenarios/test_codegen/src/handlers/EventHandlers.ts b/scenarios/test_codegen/src/handlers/EventHandlers.ts index 4b731d063..848befde4 100644 --- a/scenarios/test_codegen/src/handlers/EventHandlers.ts +++ b/scenarios/test_codegen/src/handlers/EventHandlers.ts @@ -886,43 +886,6 @@ indexer.onEvent({ contract: "EventFiltersTest", event: "FilterTestEvent", where: } }); -// Capture the inner add() closure in one contractRegister invocation, then try -// to invoke the captured closure from a later onEvent handler (after the first -// handler has resolved and params.isResolved === true). The call must throw — -// this guards against the captured-add bypass where -// `const add = context.chain.X.add` survives past handler resolution. -// We signal success via the CustomSelectionTestPass entity so the test can -// observe the outcome across the createTestIndexer worker boundary. -let _capturedCrAdd: ((address: `0x${string}`) => void) | null = null; -indexer.contractRegister({ contract: "Gravatar", event: "FactoryEvent" }, async ({ event, context }) => { - if (event.params.testCase === "captureAdd") { - _capturedCrAdd = context.chain.SimpleNft.add; - } -}); -indexer.onEvent({ contract: "Gravatar", event: "FactoryEvent" }, async ({ event, context }) => { - if (event.params.testCase === "callCapturedAdd" && _capturedCrAdd) { - const outcome = (() => { - try { - _capturedCrAdd!("0x1234567890123456789012345678901234567890"); - return "captured-add-did-not-throw"; - } catch { - return "captured-add-threw"; - } - })(); - context.CustomSelectionTestPass.set({ - id: outcome, - }); - } -}); - -// Different options → should throw -export let mismatchedHandlerOptionsError: Error | undefined; -try { - indexer.onEvent({ contract: "Gravatar", event: "CustomSelection", wildcard: true }, async () => {}); -} catch (e) { - mismatchedHandlerOptionsError = e as Error; -} - // Handler for testing simulate block/logIndex behavior indexer.onEvent({ contract: "Gravatar", event: "EmptyEvent" }, async ({ event, context }) => { context.SimulateTestEvent.set({ diff --git a/scenarios/test_codegen/test/EventHandler.test.ts b/scenarios/test_codegen/test/EventHandler.test.ts index 281ec76ec..658f21f63 100644 --- a/scenarios/test_codegen/test/EventHandler.test.ts +++ b/scenarios/test_codegen/test/EventHandler.test.ts @@ -746,46 +746,6 @@ describe("Use Envio test framework to test event handlers", () => { sets: [{ address: expectedChecksummedAddress, contract: "SimpleNft" }], }); }); - - it("captured contractRegister add() throws after handler resolved", async () => { - const indexer = createTestIndexer(); - const dcAddress = "0x1234567890123456789012345678901234567890"; - - // Two sequential FactoryEvent events: - // 1. testCase "captureAdd" — contractRegister handler stashes - // context.chain.SimpleNft.add into a module-scoped variable. - // 2. testCase "callCapturedAdd" — onEvent handler tries to call the - // captured closure. By then the first event's contractRegister params - // have isResolved=true, so the closure must throw. The handler - // records the outcome via the CustomSelectionTestPass entity id. - const result = await indexer.process({ - chains: { - 1337: { - startBlock: 1, - endBlock: 100, - simulate: [ - { - contract: "Gravatar", - event: "FactoryEvent", - params: { contract: dcAddress, testCase: "captureAdd" }, - }, - { - contract: "Gravatar", - event: "FactoryEvent", - params: { contract: dcAddress, testCase: "callCapturedAdd" }, - }, - ], - }, - }, - }); - - const sets = result.changes[0]?.CustomSelectionTestPass?.sets ?? []; - assert.ok( - sets.some((e: { id: string }) => e.id === "captured-add-threw"), - `Captured contractRegister add() should throw after handler resolved. Got entity ids: ${sets.map((e: { id: string }) => e.id).join(", ")}` - ); - }); - it("Should be able to run effect with cache", async () => { const indexer = createTestIndexer(); const dcAddress = "0x1234567890123456789012345678901234567890"; From 089b24a77b8290818ec50a0e080c69999245c5d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 15:17:37 +0000 Subject: [PATCH 05/16] Say "dispatch key" not "signature" in duplicate-event error The per-contract collision key is a dispatch key (EVM sighash + indexed count, Fuel logId/receipt kind, SVM discriminator), not strictly a signature, so the diagnostic wording is more accurate this way. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- packages/cli/src/config_parsing/system_config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/config_parsing/system_config.rs b/packages/cli/src/config_parsing/system_config.rs index bdc17d14d..5d096f166 100644 --- a/packages/cli/src/config_parsing/system_config.rs +++ b/packages/cli/src/config_parsing/system_config.rs @@ -1952,7 +1952,7 @@ impl Contract { { return Err(anyhow!( "Duplicate event detected on contract {name}: {existing} and {} share the \ - same signature, so they can't be told apart while indexing. Remove the \ + same dispatch key, so they can't be told apart while indexing. Remove the \ duplicate event.", event.name, )); From 6dde62230b5311f047f3e3f4a602e38198f514ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 15:32:48 +0000 Subject: [PATCH 06/16] Validate intent `where` across all matching chains `validateIntentWhere` used `Array.some`, stopping at the first chain that defines the event. A `where` that resolves fine on the first chain but is structurally invalid on a later one slipped past the call-site check and only threw at `finishRegistration`. Iterate every matching chain so the error surfaces at the registration call site regardless of which chain's resolution is invalid. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- packages/envio/src/HandlerRegister.res | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index 5638c37ec..4b714045a 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -262,18 +262,19 @@ let resolveChainRegistrations = (~config: Config.t, ~chainConfig: Config.chain): let isDroppedByWhere = (~config: Config.t, reg: Internal.onEventRegistration) => config.ecosystem.name === Evm && (reg->getResolvedWhere).topicSelections->Utils.Array.isEmpty -// Resolve the intent against the first chain that defines its event and discard -// the result, so a broken `where` (bad filter, unknown indexed param) throws at -// the user's registration call site instead of being deferred to -// `finishRegistration`. `config` may be a narrowed TestIndexer chain subset; if -// no chain defines the event there's nothing to validate. +// Resolve the intent against every chain that defines its event and discard the +// results, so a broken `where` (bad filter, unknown indexed param) throws at the +// user's registration call site instead of being deferred to +// `finishRegistration` — even when only a later chain's resolution is invalid. +// `config` may be a narrowed TestIndexer chain subset; if no chain defines the +// event there's nothing to validate. let validateIntentWhere = (~config: Config.t, intent: pendingOnEventRegistration) => { let isWildcard = intent.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) let where = intent.eventOptions->Option.flatMap(v => v.where) config.chainMap ->ChainMap.values - ->Array.some(chainConfig => - chainConfig.contracts->Array.some(contract => + ->Array.forEach(chainConfig => + chainConfig.contracts->Array.forEach(contract => if contract.name === intent.contractName { switch contract.events->Array.find(e => e.name === intent.eventName) { | Some(eventConfig) => @@ -287,15 +288,11 @@ let validateIntentWhere = (~config: Config.t, intent: pendingOnEventRegistration ~where, ~startBlock=?contract.startBlock, ) - true - | None => false + | None => () } - } else { - false } ) ) - ->ignore } let addIntent = (registration: activeRegistration, intent: pendingOnEventRegistration) => { From 3932d7e23a0e66f54548a62748733ea6b8ea10a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 09:11:42 +0000 Subject: [PATCH 07/16] Capture raw events for where:false events; dedupe raw events per log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - finishRegistration: when raw events are enabled, an event whose `where` opts out of a chain (empty topic selections) is no longer fully dropped — its handler still doesn't run there, but a bare (handler-less) registration is added so the event's logs are captured for `raw_events`. The backfill now keys on surviving registrations; a `where: false` event still isn't reported as handler-less (tracked via intentKeys). - PgStorage.writeBatch: a single log fans out to one item per matching registration, but `raw_events` records the log, so dedupe rows by log coordinate (chain, block, logIndex) — one row per log. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- .../envio-tests/test/HandlerRegister_test.res | 34 +++++++++++++ packages/envio/src/HandlerRegister.res | 51 +++++++++++-------- packages/envio/src/PgStorage.res | 17 ++++++- .../test_codegen/test/EventFilters_test.res | 16 +++--- 4 files changed, 89 insertions(+), 29 deletions(-) diff --git a/packages/envio-tests/test/HandlerRegister_test.res b/packages/envio-tests/test/HandlerRegister_test.res index bda2e341e..f922e97c6 100644 --- a/packages/envio-tests/test/HandlerRegister_test.res +++ b/packages/envio-tests/test/HandlerRegister_test.res @@ -50,6 +50,26 @@ chains: address: "0x1111111111111111111111111111111111111111" `).config +// raw_events enabled, single chain. Used to verify a `where: false` event is +// still captured for raw events via a bare (handler-less) registration. +let configWithRawEvents = MockIndexerConfig.parseYaml(` +name: handler-register-raw-events +raw_events: true +contracts: + - name: ERC20 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + rpc: + url: https://eth.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" +`).config + // Each handler/contractRegister is a distinct function so registrations can be // identified by reference in assertions. let makeHandler = (): Internal.handler => %raw(`() => Promise.resolve()`) @@ -230,4 +250,18 @@ describe("HandlerRegister multiple registrations", () => { registrations137->describeRegistrations(~chainKey="137", ~labels=[(h1, "h1")], ~crLabels=[]), ).toEqual([(Some("h1"), None, 0)]) }) + + it("captures raw events for a where:false event via a bare registration", t => { + // The handler opts out of the chain with `where: false`, so its handler is + // dropped — but with raw events enabled the event's logs are still fetched + // via a bare (handler-less) registration. + let h1 = makeHandler() + HandlerRegister.resetOnEventRegistrations() + HandlerRegister.startRegistration(~config=configWithRawEvents) + setHandler(~eventOptions={where: %raw(`() => false`)}, h1) + let registrations = HandlerRegister.finishRegistration(~config=configWithRawEvents) + t.expect( + registrations->describeRegistrations(~labels=[(h1, "h1")], ~crLabels=[]), + ).toEqual([(None, None, 0)]) + }) }) diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index 4b714045a..67e796ec0 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -395,31 +395,40 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { let key = chainId->Int.toString let builtRegs = resolveChainRegistrations(~config, ~chainConfig) - let registeredKeys = Utils.Set.make() + // Registrations whose `where` opts out of this chain are dropped from + // normal fetching/dispatch. `intentKeys` still records them so a + // `where: false` event isn't reported as handler-less below. + let survivingRegs = builtRegs->Array.filter(reg => !isDroppedByWhere(~config, reg)) + let intentKeys = Utils.Set.make() builtRegs->Array.forEach(reg => - registeredKeys + intentKeys + ->Utils.Set.add( + getKey(~contractName=reg.eventConfig.contractName, ~eventName=reg.eventConfig.name), + ) + ->ignore + ) + let survivingKeys = Utils.Set.make() + survivingRegs->Array.forEach(reg => + survivingKeys ->Utils.Set.add( getKey(~contractName=reg.eventConfig.contractName, ~eventName=reg.eventConfig.name), ) ->ignore ) - // Events with no handler/contractRegister aren't fetched or dispatched + // An event with no surviving registration isn't fetched or dispatched // unless raw events are enabled, in which case a bare registration is - // added to fetch them. Otherwise they're reported once below. Runs - // before the where-empty drop so a `where: false` event still counts as - // registered (and doesn't get a bare raw-event registration). + // added to fetch every log for `raw_events` — this includes events that + // opted out via `where: false`, whose logs are still captured raw even + // though their handler doesn't run here. Events with no handler at all + // (and raw events off) are reported once below; a `where: false` event + // has a handler, so it's skipped from that report via `intentKeys`. let rawEventRegs = [] chainConfig.contracts->Array.forEach(contract => { contract.events->Array.forEach( eventConfig => { - if ( - !( - registeredKeys->Utils.Set.has( - getKey(~contractName=contract.name, ~eventName=eventConfig.name), - ) - ) - ) { + let eventKey = getKey(~contractName=contract.name, ~eventName=eventConfig.name) + if !(survivingKeys->Utils.Set.has(eventKey)) { if config.enableRawEvents { rawEventRegs ->Array.push( @@ -435,7 +444,7 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { ), ) ->ignore - } else { + } else if !(intentKeys->Utils.Set.has(eventKey)) { let eventNames = switch notRegisteredEventsByContract->Utils.Dict.dangerouslyGetNonOption( contract.name, ) { @@ -453,17 +462,15 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { ) }) - // Drop registrations whose `where` opts out of this chain, then assign - // each survivor its chain-scoped index by position. + // Assign each surviving/raw-event registration its chain-scoped index + // by position. let onEventRegistrations: array = [] - builtRegs + survivingRegs ->Array.concat(rawEventRegs) ->Array.forEach(reg => { - if !isDroppedByWhere(~config, reg) { - onEventRegistrations - ->Array.push({...reg, index: onEventRegistrations->Array.length}) - ->ignore - } + onEventRegistrations + ->Array.push({...reg, index: onEventRegistrations->Array.length}) + ->ignore }) registrationsByChainId->Dict.set( diff --git a/packages/envio/src/PgStorage.res b/packages/envio/src/PgStorage.res index 03222cad5..65baa07b0 100644 --- a/packages/envio/src/PgStorage.res +++ b/packages/envio/src/PgStorage.res @@ -849,9 +849,24 @@ let rec writeBatch = async ( let specificError = ref(None) let rawEvents = if config.enableRawEvents { + // A single on-chain log fans out to one item per matching registration; + // `raw_events` records the log itself, so dedupe by its coordinate + // (chain, block, logIndex) to keep one row per log. + let seenLogCoordinates = Utils.Set.make() let rows = batch.items->Array.filterMap(item => switch item { - | Internal.Event(_) => Some(config.ecosystem.toRawEvent(item->Internal.castUnsafeEventItem)) + | Internal.Event(_) => + let coordinate = `${item + ->Internal.getItemChainId + ->Int.toString}-${item + ->Internal.getItemBlockNumber + ->Int.toString}-${item->Internal.getItemLogIndex->Int.toString}` + if seenLogCoordinates->Utils.Set.has(coordinate) { + None + } else { + seenLogCoordinates->Utils.Set.add(coordinate)->ignore + Some(config.ecosystem.toRawEvent(item->Internal.castUnsafeEventItem)) + } | Internal.Block(_) => None } ) diff --git a/scenarios/test_codegen/test/EventFilters_test.res b/scenarios/test_codegen/test/EventFilters_test.res index 9527fb65c..5c37ffe92 100644 --- a/scenarios/test_codegen/test/EventFilters_test.res +++ b/scenarios/test_codegen/test/EventFilters_test.res @@ -481,16 +481,20 @@ describe("Test eventFilters", () => { t.expect(eventConfig.handler->Option.isSome).toBe(true) }) - it("Where returning false drops the chain's registration entirely", t => { + it("Where returning false keeps only a bare raw-events registration", t => { // WithExcessField's where returns `false` for chain 100 and a filter for - // chain 137 — the finished registrations must include it only on 137. - let hasEvent = chainId => + // chain 137. On 137 its handler registration is kept; on 100 the handler + // opts out, but raw_events is enabled (config.yaml), so the event is still + // fetched via a bare (handler-less) registration for `raw_events`. + let handlerPresence = chainId => switch registrationsByChainId->Dict.get(chainId) { | Some({HandlerRegister.onEventRegistrations: regs}) => - regs->Array.some(reg => reg.eventConfig.name === "WithExcessField") - | None => false + regs + ->Array.find(reg => reg.eventConfig.name === "WithExcessField") + ->Option.map(reg => reg.handler->Option.isSome) + | None => None } - t.expect((hasEvent("137"), hasEvent("100"))).toEqual((true, false)) + t.expect((handlerPresence("137"), handlerPresence("100"))).toEqual((Some(true), Some(false))) }) it("Fails on filter with excess field at registration time", t => { From eeb60806271ac0c4f9bca54f6fcffbae6d3b2178 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 09:19:04 +0000 Subject: [PATCH 08/16] Exclude where:false events from raw events; keep handler-less backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct the raw-events behavior: a `where: false` event has a handler that opted out of the chain, so it gets no registration there at all — not even a raw-events one. Event configs with no explicit handler still get a bare raw-events registration when raw events are enabled. This reverts the finishRegistration change from the previous commit back to keying the backfill on the resolved registrations; the raw-events row dedup (PgStorage) stays. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- .../envio-tests/test/HandlerRegister_test.res | 27 ++++++---- packages/envio/src/HandlerRegister.res | 52 ++++++++----------- .../test_codegen/test/EventFilters_test.res | 17 +++--- 3 files changed, 47 insertions(+), 49 deletions(-) diff --git a/packages/envio-tests/test/HandlerRegister_test.res b/packages/envio-tests/test/HandlerRegister_test.res index f922e97c6..6ea9bd683 100644 --- a/packages/envio-tests/test/HandlerRegister_test.res +++ b/packages/envio-tests/test/HandlerRegister_test.res @@ -50,8 +50,9 @@ chains: address: "0x1111111111111111111111111111111111111111" `).config -// raw_events enabled, single chain. Used to verify a `where: false` event is -// still captured for raw events via a bare (handler-less) registration. +// raw_events enabled, single chain. Used to verify that a `where: false` event +// is excluded entirely while a handler-less event still gets a bare raw-events +// registration. let configWithRawEvents = MockIndexerConfig.parseYaml(` name: handler-register-raw-events raw_events: true @@ -59,6 +60,7 @@ contracts: - name: ERC20 events: - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Approval(address indexed owner, address indexed spender, uint256 value) chains: - id: 1 rpc: @@ -251,17 +253,22 @@ describe("HandlerRegister multiple registrations", () => { ).toEqual([(Some("h1"), None, 0)]) }) - it("captures raw events for a where:false event via a bare registration", t => { - // The handler opts out of the chain with `where: false`, so its handler is - // dropped — but with raw events enabled the event's logs are still fetched - // via a bare (handler-less) registration. + it("excludes a where:false event but backfills a handler-less event for raw events", t => { + // Transfer's handler opts out of the chain via `where: false` → excluded + // entirely (no raw-events registration). Approval has no handler → with raw + // events enabled it gets a bare (handler-less) registration. let h1 = makeHandler() HandlerRegister.resetOnEventRegistrations() HandlerRegister.startRegistration(~config=configWithRawEvents) - setHandler(~eventOptions={where: %raw(`() => false`)}, h1) + setHandler(~eventName="Transfer", ~eventOptions={where: %raw(`() => false`)}, h1) let registrations = HandlerRegister.finishRegistration(~config=configWithRawEvents) - t.expect( - registrations->describeRegistrations(~labels=[(h1, "h1")], ~crLabels=[]), - ).toEqual([(None, None, 0)]) + t.expect(( + registrations->describeRegistrations( + ~eventName="Transfer", + ~labels=[(h1, "h1")], + ~crLabels=[], + ), + registrations->describeRegistrations(~eventName="Approval", ~labels=[], ~crLabels=[]), + )).toEqual(([], [(None, None, 0)])) }) }) diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index 67e796ec0..3d02e8dcb 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -395,40 +395,32 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { let key = chainId->Int.toString let builtRegs = resolveChainRegistrations(~config, ~chainConfig) - // Registrations whose `where` opts out of this chain are dropped from - // normal fetching/dispatch. `intentKeys` still records them so a - // `where: false` event isn't reported as handler-less below. - let survivingRegs = builtRegs->Array.filter(reg => !isDroppedByWhere(~config, reg)) - let intentKeys = Utils.Set.make() + let registeredKeys = Utils.Set.make() builtRegs->Array.forEach(reg => - intentKeys - ->Utils.Set.add( - getKey(~contractName=reg.eventConfig.contractName, ~eventName=reg.eventConfig.name), - ) - ->ignore - ) - let survivingKeys = Utils.Set.make() - survivingRegs->Array.forEach(reg => - survivingKeys + registeredKeys ->Utils.Set.add( getKey(~contractName=reg.eventConfig.contractName, ~eventName=reg.eventConfig.name), ) ->ignore ) - // An event with no surviving registration isn't fetched or dispatched + // Events with no handler/contractRegister aren't fetched or dispatched // unless raw events are enabled, in which case a bare registration is - // added to fetch every log for `raw_events` — this includes events that - // opted out via `where: false`, whose logs are still captured raw even - // though their handler doesn't run here. Events with no handler at all - // (and raw events off) are reported once below; a `where: false` event - // has a handler, so it's skipped from that report via `intentKeys`. + // added to fetch them. Otherwise they're reported once below. Keyed on + // the resolved registrations (before the where-empty drop) so a + // `where: false` event still counts as registered — its handler opted + // out of this chain, so it gets no raw-event registration either. let rawEventRegs = [] chainConfig.contracts->Array.forEach(contract => { contract.events->Array.forEach( eventConfig => { - let eventKey = getKey(~contractName=contract.name, ~eventName=eventConfig.name) - if !(survivingKeys->Utils.Set.has(eventKey)) { + if ( + !( + registeredKeys->Utils.Set.has( + getKey(~contractName=contract.name, ~eventName=eventConfig.name), + ) + ) + ) { if config.enableRawEvents { rawEventRegs ->Array.push( @@ -444,7 +436,7 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { ), ) ->ignore - } else if !(intentKeys->Utils.Set.has(eventKey)) { + } else { let eventNames = switch notRegisteredEventsByContract->Utils.Dict.dangerouslyGetNonOption( contract.name, ) { @@ -462,15 +454,17 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { ) }) - // Assign each surviving/raw-event registration its chain-scoped index - // by position. + // Drop registrations whose `where` opts out of this chain, then assign + // each survivor its chain-scoped index by position. let onEventRegistrations: array = [] - survivingRegs + builtRegs ->Array.concat(rawEventRegs) ->Array.forEach(reg => { - onEventRegistrations - ->Array.push({...reg, index: onEventRegistrations->Array.length}) - ->ignore + if !isDroppedByWhere(~config, reg) { + onEventRegistrations + ->Array.push({...reg, index: onEventRegistrations->Array.length}) + ->ignore + } }) registrationsByChainId->Dict.set( diff --git a/scenarios/test_codegen/test/EventFilters_test.res b/scenarios/test_codegen/test/EventFilters_test.res index 5c37ffe92..e4cf3bb37 100644 --- a/scenarios/test_codegen/test/EventFilters_test.res +++ b/scenarios/test_codegen/test/EventFilters_test.res @@ -481,20 +481,17 @@ describe("Test eventFilters", () => { t.expect(eventConfig.handler->Option.isSome).toBe(true) }) - it("Where returning false keeps only a bare raw-events registration", t => { + it("Where returning false drops the chain's registration entirely", t => { // WithExcessField's where returns `false` for chain 100 and a filter for - // chain 137. On 137 its handler registration is kept; on 100 the handler - // opts out, but raw_events is enabled (config.yaml), so the event is still - // fetched via a bare (handler-less) registration for `raw_events`. - let handlerPresence = chainId => + // chain 137 — the handler opted out of chain 100, so the event gets no + // registration there (not even a raw-events one), only on 137. + let hasEvent = chainId => switch registrationsByChainId->Dict.get(chainId) { | Some({HandlerRegister.onEventRegistrations: regs}) => - regs - ->Array.find(reg => reg.eventConfig.name === "WithExcessField") - ->Option.map(reg => reg.handler->Option.isSome) - | None => None + regs->Array.some(reg => reg.eventConfig.name === "WithExcessField") + | None => false } - t.expect((handlerPresence("137"), handlerPresence("100"))).toEqual((Some(true), Some(false))) + t.expect((hasEvent("137"), hasEvent("100"))).toEqual((true, false)) }) it("Fails on filter with excess field at registration time", t => { From 33de8ce973505d074c26d6b28a29b548a46f2526 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 09:46:01 +0000 Subject: [PATCH 09/16] Simplify duplicate-event message; move dup tests to ReScript Reword the same-contract duplicate-event error to drop the "dispatch key" jargon. Normalize SVM discriminators (lowercase) before keying so hex-casing variants collide, matching the router. Replace the two Rust `#[test]` cases with parseYaml tests in the ReScript suite, plus an SVM casing-collision case. Replay pre-registered handlers in FIFO source order so multi-handler dispatch order matches registration order, with a regression test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- .../cli/src/config_parsing/system_config.rs | 70 ++----------------- packages/envio-tests/test/ConfigYaml_test.res | 58 +++++++++++++++ .../envio-tests/test/HandlerRegister_test.res | 16 +++++ packages/envio/src/HandlerRegister.res | 22 +++--- 4 files changed, 92 insertions(+), 74 deletions(-) diff --git a/packages/cli/src/config_parsing/system_config.rs b/packages/cli/src/config_parsing/system_config.rs index 5d096f166..69b599b78 100644 --- a/packages/cli/src/config_parsing/system_config.rs +++ b/packages/cli/src/config_parsing/system_config.rs @@ -1936,9 +1936,12 @@ impl Contract { let indexed_count = params.iter().filter(|p| p.indexed).count(); Some(format!("{}_{}", event.sighash, indexed_count)) } + // The router decodes the discriminator to bytes before matching, + // so `0x0f` and `0x0F` collide — lowercase before keying. EventKind::Svm(svm) => Some( svm.discriminator - .clone() + .as_ref() + .map(|d| d.to_lowercase()) .unwrap_or_else(|| "none".to_string()), ), // Fuel routing dispatches by sighash (a `LogData` logId, or a @@ -1951,9 +1954,8 @@ impl Contract { seen_by_dispatch_key.insert(dispatch_key, event.name.clone()) { return Err(anyhow!( - "Duplicate event detected on contract {name}: {existing} and {} share the \ - same dispatch key, so they can't be told apart while indexing. Remove the \ - duplicate event.", + "Contract {name} has two events the indexer can't tell apart: {existing} \ + and {}. Please remove one of them.", event.name, )); } @@ -2746,66 +2748,6 @@ mod test { ); } - #[test] - fn rejects_duplicate_event_on_same_contract() { - // Two events with the same signature on one contract are - // indistinguishable at routing time; parsing must reject them. - let yaml = r#" -name: dup-event-test -contracts: - - name: ERC20 - events: - - event: Transfer(address indexed from, address indexed to, uint256 value) - - event: Transfer(address indexed from, address indexed to, uint256 value) -chains: - - id: 1 - rpc: - url: https://eth.com - for: sync - start_block: 0 - contracts: - - name: ERC20 - address: "0x1111111111111111111111111111111111111111" -"#; - let err = SystemConfig::parse_yaml(yaml, None, &HashMap::new(), &HashMap::new(), false) - .err() - .expect("expected a duplicate-event error"); - assert!( - format!("{err:#}").contains("Duplicate event detected on contract ERC20"), - "unexpected error: {err:#}" - ); - } - - #[test] - fn allows_distinct_events_and_shared_signature_across_contracts() { - // Distinct signatures on one contract are fine, and the same signature - // on two different contracts is allowed (routing scopes by contract). - let yaml = r#" -name: ok-events-test -contracts: - - name: ERC20 - events: - - event: Transfer(address indexed from, address indexed to, uint256 value) - - event: Approval(address indexed owner, address indexed spender, uint256 value) - - name: ERC721 - events: - - event: Transfer(address indexed from, address indexed to, uint256 value) -chains: - - id: 1 - rpc: - url: https://eth.com - for: sync - start_block: 0 - contracts: - - name: ERC20 - address: "0x1111111111111111111111111111111111111111" - - name: ERC721 - address: "0x2222222222222222222222222222222222222222" -"#; - SystemConfig::parse_yaml(yaml, None, &HashMap::new(), &HashMap::new(), false) - .expect("config with distinct/cross-contract events should parse"); - } - #[test] fn test_get_contract_abi() { let test_dir = format!("{}/test", env!("CARGO_MANIFEST_DIR")); diff --git a/packages/envio-tests/test/ConfigYaml_test.res b/packages/envio-tests/test/ConfigYaml_test.res index 723860832..cbdbee8a7 100644 --- a/packages/envio-tests/test/ConfigYaml_test.res +++ b/packages/envio-tests/test/ConfigYaml_test.res @@ -474,6 +474,24 @@ chains: }) describe("system config validation errors", () => { + it("rejects two events on one contract that the indexer can't tell apart", t => { + expectParseError( + t, + ` +name: duplicate-event +contracts: + - name: Token + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + start_block: 0 +`, + "the indexer can't tell apart", + ) + }) + it("preserves the root cause from nested Rust error contexts", t => { expectParseError( t, @@ -804,6 +822,35 @@ chains: }) describe("config YAML success cases", () => { + it("allows distinct events on one contract and the same event across contracts", t => { + // Distinct signatures on one contract are fine, and the same event on two + // different contracts is allowed — routing scopes matches by contract. + let {config} = MockIndexerConfig.parseYaml(` +name: distinct-and-cross-contract-events +contracts: + - name: ERC20 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Approval(address indexed owner, address indexed spender, uint256 value) + - name: ERC721 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + rpc: + url: https://eth.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" + - name: ERC721 + address: "0x2222222222222222222222222222222222222222" +`) + let chain = config.chainMap->ChainMap.values->Array.getUnsafe(0) + t.expect(chain.contracts->Array.length).toBe(2) + }) + it("parses a minimal Fuel config through the public boundary", t => { let {config} = MockIndexerConfig.parseYaml(` name: fuel-config @@ -1014,6 +1061,17 @@ chains: `, "declares the instruction \"Transfer\" more than once", ), + ( + "rejects two instructions whose discriminators differ only in hex casing", + prefix ++ ` + - name: Program + program_id: metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s + instructions: + - {name: Transfer, discriminator: "0x0f"} + - {name: Withdraw, discriminator: "0x0F"} +`, + "the indexer can't tell apart", + ), ( "rejects invalid discriminators", prefix ++ ` diff --git a/packages/envio-tests/test/HandlerRegister_test.res b/packages/envio-tests/test/HandlerRegister_test.res index 6ea9bd683..61eaa1651 100644 --- a/packages/envio-tests/test/HandlerRegister_test.res +++ b/packages/envio-tests/test/HandlerRegister_test.res @@ -253,6 +253,22 @@ describe("HandlerRegister multiple registrations", () => { ).toEqual([(Some("h1"), None, 0)]) }) + it("replays pre-registered handlers in source order, not reversed", t => { + // Handlers imported before `startRegistration` are queued in `preRegistered` + // and replayed at start. That replay order is the dispatch order, so two + // handlers on one event must keep their source order (h1 before h2). + let h1 = makeHandler() + let h2 = makeHandler() + HandlerRegister.resetOnEventRegistrations() + setHandler(h1) + setHandler(h2) + HandlerRegister.startRegistration(~config) + let registrations = HandlerRegister.finishRegistration(~config) + t.expect( + registrations->describeRegistrations(~labels=[(h1, "h1"), (h2, "h2")], ~crLabels=[]), + ).toEqual([(Some("h1"), None, 0), (Some("h2"), None, 1)]) + }) + it("excludes a where:false event but backfills a handler-less event for raw events", t => { // Transfer's handler opts out of the chain via `where: false` → excluded // entirely (no raw-events registration). Approval has no handler → with raw diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index 3d02e8dcb..ca0c1ff5b 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -47,14 +47,17 @@ let pendingOnEventRegistrations = let getKey = (~contractName, ~eventName) => contractName ++ "." ++ eventName -// Test-only: clear the intent store so a fresh registration cycle starts empty -// (production starts each isolate empty and registers once). -let resetOnEventRegistrations = () => +// Test-only: reset to fresh-import state so a new registration cycle starts +// empty — clear the intent store and the active registration (production starts +// each isolate empty and registers once). +let resetOnEventRegistrations = () => { pendingOnEventRegistrations->Array.splice( ~start=0, ~remove=pendingOnEventRegistrations->Array.length, ~insert=[], ) + EnvioGlobal.value.activeRegistration = None +} let getActiveRegistration = () => EnvioGlobal.value.activeRegistration->(Utils.magic: option => option) @@ -92,13 +95,12 @@ let startRegistration = (~config: Config.t) => { finished: false, } EnvioGlobal.value.activeRegistration = Some(r->(Utils.magic: activeRegistration => unknown)) - while preRegistered->Array.length > 0 { - // Loop + cleanup in one go - switch preRegistered->Array.pop { - | Some(fn) => fn(r) - | None => () - } - } + // Replay pre-registered callbacks in source (FIFO) order, then clear. For + // multiple handlers on one event this replay order is the dispatch order, so + // it must not reverse (which `Array.pop` would). + let queued = preRegistered->Array.copy + preRegistered->Array.splice(~start=0, ~remove=preRegistered->Array.length, ~insert=[]) + queued->Array.forEach(fn => fn(r)) } let getPendingChainRegistrations = (r: activeRegistration, ~chainId: int) => { From bacd334d7f738e3708d9a4a064a59d0fe07ec580 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 10:06:04 +0000 Subject: [PATCH 10/16] Resolve onEvent `where` once per chain; add dispatch-order test Cache each registration intent's per-chain resolution on the intent, so the user's `where` callback runs exactly once per chain instead of once at the registration call site and again at every `finishRegistration`/simulate. This restores the "invoked exactly once per chain" invariant. Add an end-to-end test: two handlers on one event (Gravatar.MultiHandlerOrder) both run, ordered by (blockNumber, logIndex, registration index), plus a unit test asserting a `where` callback is invoked once per chain across repeated finishes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- .../envio-tests/test/HandlerRegister_test.res | 18 +++ packages/envio/src/HandlerRegister.res | 121 +++++++++--------- scenarios/test_codegen/config.yaml | 1 + scenarios/test_codegen/src/Indexer.res | 35 +++++ .../src/handlers/EventHandlers.ts | 22 ++++ .../test_codegen/test/EventHandler.test.ts | 38 ++++++ 6 files changed, 174 insertions(+), 61 deletions(-) diff --git a/packages/envio-tests/test/HandlerRegister_test.res b/packages/envio-tests/test/HandlerRegister_test.res index 61eaa1651..8655e1eec 100644 --- a/packages/envio-tests/test/HandlerRegister_test.res +++ b/packages/envio-tests/test/HandlerRegister_test.res @@ -269,6 +269,24 @@ describe("HandlerRegister multiple registrations", () => { ).toEqual([(Some("h1"), None, 0), (Some("h2"), None, 1)]) }) + it("invokes a `where` callback once per chain, even across repeated finishes", t => { + // The `where` callback is resolved once per chain and cached on the intent, + // so re-materializing registrations (finishRegistration, simulate) must not + // call it again. + let calls = ref(0) + let h1 = makeHandler() + let whereFn = _ => { + calls := calls.contents + 1 + true + } + HandlerRegister.resetOnEventRegistrations() + HandlerRegister.startRegistration(~config) + setHandler(~eventOptions={where: whereFn->Obj.magic}, h1) + let _ = HandlerRegister.finishRegistration(~config) + let _ = HandlerRegister.finishRegistration(~config) + t.expect(calls.contents).toBe(1) + }) + it("excludes a where:false event but backfills a handler-less event for raw events", t => { // Transfer's handler opts out of the chain via `where: false` → excluded // entirely (no raw-events registration). Approval has no handler → with raw diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index ca0c1ff5b..ab7ed1d3e 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -35,6 +35,11 @@ type pendingOnEventRegistration = { handler: option, contractRegister: option, eventOptions: option>, + // Per-chain registration resolved lazily and cached here, keyed by chain id. + // Building invokes the user's `where` callback, so caching keeps that to + // exactly once per chain even though registrations are materialized many + // times (registration-time validation, `finishRegistration`, simulate). + resolvedByChainId: dict, } // Registration intents live in the process-wide `EnvioGlobal` record so they @@ -183,6 +188,43 @@ let sameEventAndFilter = ( | Fuel | Svm => true } +// Resolve one intent into this chain's registration, building it (and invoking +// the user's `where` callback) exactly once per chain and caching the result on +// the intent. Returns None when the chain doesn't define the intent's event. +let resolveIntentForChain = ( + ~config: Config.t, + ~chainConfig: Config.chain, + intent: pendingOnEventRegistration, +): option => { + let key = chainConfig.id->Int.toString + switch intent.resolvedByChainId->Utils.Dict.dangerouslyGetNonOption(key) { + | Some(_) as cached => cached + | None => + switch chainConfig.contracts->Array.find(c => c.name === intent.contractName) { + | None => None + | Some(contract) => + switch contract.events->Array.find(e => e.name === intent.eventName) { + | None => None + | Some(eventConfig) => + let isWildcard = intent.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) + let where = intent.eventOptions->Option.flatMap(v => v.where) + let reg = buildOnEventRegistrationWith( + ~config, + ~chainId=chainConfig.id, + ~eventConfig, + ~isWildcard, + ~handler=intent.handler, + ~contractRegister=intent.contractRegister, + ~where, + ~startBlock=?contract.startBlock, + ) + intent.resolvedByChainId->Dict.set(key, reg) + Some(reg) + } + } + } +} + // Resolve the chain-independent intents into this chain's registrations, then // merge each contractRegister into a matching handler registration (either // registration order; the merged registration takes the handler's slot so @@ -192,34 +234,13 @@ let sameEventAndFilter = ( let resolveChainRegistrations = (~config: Config.t, ~chainConfig: Config.chain): array< Internal.onEventRegistration, > => { - let chainId = chainConfig.id let resolved: array = [] - pendingOnEventRegistrations->Array.forEach(intent => { - chainConfig.contracts->Array.forEach(contract => { - if contract.name === intent.contractName { - switch contract.events->Array.find(e => e.name === intent.eventName) { - | None => () - | Some(eventConfig) => - let isWildcard = intent.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) - let where = intent.eventOptions->Option.flatMap(v => v.where) - resolved - ->Array.push( - buildOnEventRegistrationWith( - ~config, - ~chainId, - ~eventConfig, - ~isWildcard, - ~handler=intent.handler, - ~contractRegister=intent.contractRegister, - ~where, - ~startBlock=?contract.startBlock, - ), - ) - ->ignore - } - } - }) - }) + pendingOnEventRegistrations->Array.forEach(intent => + switch resolveIntentForChain(~config, ~chainConfig, intent) { + | Some(reg) => resolved->Array.push(reg)->ignore + | None => () + } + ) let merged: ref> = ref([]) resolved->Array.forEach((reg: Internal.onEventRegistration) => { @@ -264,43 +285,19 @@ let resolveChainRegistrations = (~config: Config.t, ~chainConfig: Config.chain): let isDroppedByWhere = (~config: Config.t, reg: Internal.onEventRegistration) => config.ecosystem.name === Evm && (reg->getResolvedWhere).topicSelections->Utils.Array.isEmpty -// Resolve the intent against every chain that defines its event and discard the -// results, so a broken `where` (bad filter, unknown indexed param) throws at the -// user's registration call site instead of being deferred to -// `finishRegistration` — even when only a later chain's resolution is invalid. -// `config` may be a narrowed TestIndexer chain subset; if no chain defines the -// event there's nothing to validate. -let validateIntentWhere = (~config: Config.t, intent: pendingOnEventRegistration) => { - let isWildcard = intent.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) - let where = intent.eventOptions->Option.flatMap(v => v.where) - config.chainMap +let addIntent = (registration: activeRegistration, intent: pendingOnEventRegistration) => { + // Resolve against every chain the config defines now, populating the intent's + // per-chain cache. This runs the user's `where` callback once per chain and + // surfaces a broken filter (bad filter, unknown indexed param) at the + // registration call site — even when only a later chain's resolution is + // invalid — instead of deferring the error to `finishRegistration`. `config` + // may be a narrowed TestIndexer chain subset; chains missing here resolve + // (and cache) lazily when `finishRegistration`/simulate first sees them. + registration.config.chainMap ->ChainMap.values ->Array.forEach(chainConfig => - chainConfig.contracts->Array.forEach(contract => - if contract.name === intent.contractName { - switch contract.events->Array.find(e => e.name === intent.eventName) { - | Some(eventConfig) => - let _ = buildOnEventRegistrationWith( - ~config, - ~chainId=chainConfig.id, - ~eventConfig, - ~isWildcard, - ~handler=intent.handler, - ~contractRegister=intent.contractRegister, - ~where, - ~startBlock=?contract.startBlock, - ) - | None => () - } - } - ) + resolveIntentForChain(~config=registration.config, ~chainConfig, intent)->ignore ) -} - -let addIntent = (registration: activeRegistration, intent: pendingOnEventRegistration) => { - // Validate before storing so a broken `where` never leaves a poisoned intent - // in the global store. - validateIntentWhere(~config=registration.config, intent) pendingOnEventRegistrations->Array.push(intent)->ignore } @@ -317,6 +314,7 @@ let setHandler = (~contractName, ~eventName, handler, ~eventOptions) => { handler: Some(newHandler), contractRegister: None, eventOptions, + resolvedByChainId: Dict.make(), }) }) } @@ -339,6 +337,7 @@ let setContractRegister = (~contractName, ~eventName, contractRegister, ~eventOp handler: None, contractRegister: Some(newContractRegister), eventOptions, + resolvedByChainId: Dict.make(), }) }) } diff --git a/scenarios/test_codegen/config.yaml b/scenarios/test_codegen/config.yaml index 3227ff538..8bc0a3f6c 100644 --- a/scenarios/test_codegen/config.yaml +++ b/scenarios/test_codegen/config.yaml @@ -53,6 +53,7 @@ chains: - event: "NewGravatar" - event: "UpdatedGravatar" - event: "FactoryEvent(address indexed contract, string testCase)" + - event: "MultiHandlerOrder(uint256 value)" - name: NftFactory abi_file_path: abis/NftFactory.json address: "0xa2F6E6029638cCb484A2ccb6414499aD3e825CaC" diff --git a/scenarios/test_codegen/src/Indexer.res b/scenarios/test_codegen/src/Indexer.res index f90dd4856..a622bf448 100644 --- a/scenarios/test_codegen/src/Indexer.res +++ b/scenarios/test_codegen/src/Indexer.res @@ -1068,6 +1068,40 @@ let contractName = "Gravatar" type onEventWhere = onEventWhereArgs => onEventWhereResult } + module MultiHandlerOrder = { + + let name = "MultiHandlerOrder" + let contractName = contractName + type params = {value: bigint} + /** Event params with all fields optional. Missing fields use default values. */ + type paramsConstructor = {value?: bigint} + type block = Block.t + type transaction = Transaction.t + + type event = { + /** The name of the contract that emitted this event. */ + contractName: string, + /** The name of the event. */ + eventName: string, + /** The parameters or arguments associated with this event. */ + params: params, + /** The unique identifier of the blockchain network where this event occurred. */ + chainId: chainId, + /** The address of the contract that emitted this event. */ + srcAddress: Address.t, + /** The index of this event's log within the block. */ + logIndex: int, + /** The transaction that triggered this event. Configurable in `config.yaml` via the `field_selection` option. */ + transaction: transaction, + /** The block in which this event was recorded. Configurable in `config.yaml` via the `field_selection` option. */ + block: block, + } + + type whereParams = {} + + type onEventWhere = Internal.noOnEventWhere + } + type rec eventIdentity<'event, 'paramsConstructor, 'where> = | @as("CustomSelection") CustomSelection: eventIdentity | @as("EmptyEvent") EmptyEvent: eventIdentity @@ -1079,6 +1113,7 @@ let contractName = "Gravatar" | @as("NewGravatar") NewGravatar: eventIdentity | @as("UpdatedGravatar") UpdatedGravatar: eventIdentity | @as("FactoryEvent") FactoryEvent: eventIdentity + | @as("MultiHandlerOrder") MultiHandlerOrder: eventIdentity } module NftFactory = { diff --git a/scenarios/test_codegen/src/handlers/EventHandlers.ts b/scenarios/test_codegen/src/handlers/EventHandlers.ts index 848befde4..b2f2e7264 100644 --- a/scenarios/test_codegen/src/handlers/EventHandlers.ts +++ b/scenarios/test_codegen/src/handlers/EventHandlers.ts @@ -886,6 +886,28 @@ indexer.onEvent({ contract: "EventFiltersTest", event: "FilterTestEvent", where: } }); +// Two handlers on one event, used to assert dispatch order. Both write +// SimulateTestEvent with an id suffixed by the registration order, so the +// order of `result.changes[0].SimulateTestEvent.sets` reveals the dispatch +// order: (blockNumber, logIndex, registration index). The first-registered +// handler gets the lower index and must run first for each log. +indexer.onEvent({ contract: "Gravatar", event: "MultiHandlerOrder" }, async ({ event, context }) => { + context.SimulateTestEvent.set({ + id: `${event.block.number}_${event.logIndex}_a`, + blockNumber: event.block.number, + logIndex: event.logIndex, + timestamp: event.block.timestamp, + }); +}); +indexer.onEvent({ contract: "Gravatar", event: "MultiHandlerOrder" }, async ({ event, context }) => { + context.SimulateTestEvent.set({ + id: `${event.block.number}_${event.logIndex}_b`, + blockNumber: event.block.number, + logIndex: event.logIndex, + timestamp: event.block.timestamp, + }); +}); + // Handler for testing simulate block/logIndex behavior indexer.onEvent({ contract: "Gravatar", event: "EmptyEvent" }, async ({ event, context }) => { context.SimulateTestEvent.set({ diff --git a/scenarios/test_codegen/test/EventHandler.test.ts b/scenarios/test_codegen/test/EventHandler.test.ts index 658f21f63..bc0df5bd0 100644 --- a/scenarios/test_codegen/test/EventHandler.test.ts +++ b/scenarios/test_codegen/test/EventHandler.test.ts @@ -1290,6 +1290,44 @@ describe("Use Envio test framework to test event handlers", () => { ]); }); + // Two handlers registered on one event (Gravatar.MultiHandlerOrder) must both + // run, ordered by (blockNumber, logIndex, registration index). Each handler + // writes SimulateTestEvent with an id suffixed `_a`/`_b` in registration + // order, so the `sets` order (which preserves dispatch order) reveals it: for + // each log the first-registered handler (`_a`) runs before `_b`, and logs run + // in (block, logIndex) order. + it("dispatches multiple handlers on one event in (block, logIndex, registration) order", async () => { + const indexer = createTestIndexer(); + + const result = await indexer.process({ + chains: { + 1337: { + startBlock: 1, + endBlock: 100, + simulate: [ + { contract: "Gravatar", event: "MultiHandlerOrder", block: { number: 1 } }, + { contract: "Gravatar", event: "MultiHandlerOrder", block: { number: 1 } }, + { contract: "Gravatar", event: "MultiHandlerOrder", block: { number: 2 } }, + ], + }, + }, + }); + + // Committed batches are in ascending block order; flatten their sets to + // read the full cross-block dispatch order in one value. + const dispatched = result.changes.flatMap( + (c) => c.SimulateTestEvent?.sets ?? [], + ); + assert.deepEqual(dispatched, [ + { id: "1_0_a", blockNumber: 1, logIndex: 0, timestamp: 0 }, + { id: "1_0_b", blockNumber: 1, logIndex: 0, timestamp: 0 }, + { id: "1_1_a", blockNumber: 1, logIndex: 1, timestamp: 0 }, + { id: "1_1_b", blockNumber: 1, logIndex: 1, timestamp: 0 }, + { id: "2_2_a", blockNumber: 2, logIndex: 2, timestamp: 0 }, + { id: "2_2_b", blockNumber: 2, logIndex: 2, timestamp: 0 }, + ]); + }); + it("simulate passes block timestamp to event", async () => { const indexer = createTestIndexer(); From bcf9df7f66adec114ef1f216b143937dc48fef20 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 10:15:07 +0000 Subject: [PATCH 11/16] Add MultiHandlerOrder to Indexer_test expected ABI The new Gravatar.MultiHandlerOrder event shifts the generated contract ABI, which `Indexer_test`'s full chain-config deep-equal asserts on. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- scenarios/test_codegen/test/Indexer_test.res | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenarios/test_codegen/test/Indexer_test.res b/scenarios/test_codegen/test/Indexer_test.res index 92d897ae1..84bbe5d03 100644 --- a/scenarios/test_codegen/test/Indexer_test.res +++ b/scenarios/test_codegen/test/Indexer_test.res @@ -37,7 +37,7 @@ describe("Indexer.indexer", () => { \"Gravatar": { name: "Gravatar", addresses: ["0x2B2f78c5BF6D9C12Ee1225D5F374aa91204580c3"->Address.unsafeFromString], - abi: %raw(`[{"type":"event","name":"CustomSelection","inputs":[],"anonymous":false},{"type":"event","name":"EmptyEvent","inputs":[],"anonymous":false},{"type":"event","name":"FactoryEvent","inputs":[{"name":"contract","type":"address","indexed":true},{"name":"testCase","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"NewGravatar","inputs":[{"name":"id","type":"uint256","indexed":false},{"name":"owner","type":"address","indexed":false},{"name":"displayName","type":"string","indexed":false},{"name":"imageUrl","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"TestEvent","inputs":[{"name":"id","type":"uint256","indexed":false},{"name":"user","type":"address","indexed":false,"internalType":"address"},{"name":"contactDetails","type":"tuple","indexed":false,"internalType":"struct TestContract.ContactDetails","components":[{"name":"name","type":"string","internalType":"string"},{"name":"email","type":"string","internalType":"string"}]}],"anonymous":false},{"type":"event","name":"TestEvent","inputs":[],"anonymous":false},{"type":"event","name":"TestEventThatCopiesBigIntViaLinkedEntities","inputs":[{"name":"param_that_should_be_removed_when_issue_1026_is_fixed","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"TestEventWithLongNameBeyondThePostgresEnumCharacterLimit","inputs":[{"name":"testField","type":"address","indexed":false}],"anonymous":false},{"type":"event","name":"TestEventWithReservedKeyword","inputs":[{"name":"module","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"UpdatedGravatar","inputs":[{"name":"id","type":"uint256","indexed":false},{"name":"owner","type":"address","indexed":false},{"name":"displayName","type":"string","indexed":false},{"name":"imageUrl","type":"string","indexed":false}],"anonymous":false}]`), + abi: %raw(`[{"type":"event","name":"CustomSelection","inputs":[],"anonymous":false},{"type":"event","name":"EmptyEvent","inputs":[],"anonymous":false},{"type":"event","name":"FactoryEvent","inputs":[{"name":"contract","type":"address","indexed":true},{"name":"testCase","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"MultiHandlerOrder","inputs":[{"name":"value","type":"uint256","indexed":false}],"anonymous":false},{"type":"event","name":"NewGravatar","inputs":[{"name":"id","type":"uint256","indexed":false},{"name":"owner","type":"address","indexed":false},{"name":"displayName","type":"string","indexed":false},{"name":"imageUrl","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"TestEvent","inputs":[{"name":"id","type":"uint256","indexed":false},{"name":"user","type":"address","indexed":false,"internalType":"address"},{"name":"contactDetails","type":"tuple","indexed":false,"internalType":"struct TestContract.ContactDetails","components":[{"name":"name","type":"string","internalType":"string"},{"name":"email","type":"string","internalType":"string"}]}],"anonymous":false},{"type":"event","name":"TestEvent","inputs":[],"anonymous":false},{"type":"event","name":"TestEventThatCopiesBigIntViaLinkedEntities","inputs":[{"name":"param_that_should_be_removed_when_issue_1026_is_fixed","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"TestEventWithLongNameBeyondThePostgresEnumCharacterLimit","inputs":[{"name":"testField","type":"address","indexed":false}],"anonymous":false},{"type":"event","name":"TestEventWithReservedKeyword","inputs":[{"name":"module","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"UpdatedGravatar","inputs":[{"name":"id","type":"uint256","indexed":false},{"name":"owner","type":"address","indexed":false},{"name":"displayName","type":"string","indexed":false},{"name":"imageUrl","type":"string","indexed":false}],"anonymous":false}]`), }, \"Noop": { name: "Noop", From 483ddadbc75e15c0b15467337b6a0d5004abdce7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 12:21:10 +0000 Subject: [PATCH 12/16] Resolve registrations at onEvent into one reused registration Replace the chain-independent intent array + per-intent resolved-where cache with a single persistent `activeRegistration` that handlers resolve into at `onEvent`/`onBlock` call time (the `where` callback runs once per chain there, at the call site). `startRegistration` is idempotent, so the import-cached handlers register once and every later `finishRegistration` reuses the result; `finishRegistration` reads the store and builds a fresh per-config output (merge, raw-events backfill, index) without mutating it, so a run appending a simulate/mock source stays isolated. onEvent + onBlock now share the `chainRegistrations` type in the session store. onBlock validation runs against the config start block (registration sees the full, un-narrowed config); the persistence-derived resume block overrides downstream without re-validation. MockIndexer registers once against the full config and narrows per run. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- .../envio-tests/test/HandlerRegister_test.res | 52 +++- packages/envio/src/EnvioGlobal.res | 2 - packages/envio/src/HandlerRegister.res | 272 ++++++++---------- .../test_codegen/test/helpers/MockIndexer.res | 35 +-- 4 files changed, 182 insertions(+), 179 deletions(-) diff --git a/packages/envio-tests/test/HandlerRegister_test.res b/packages/envio-tests/test/HandlerRegister_test.res index 8655e1eec..089e2e984 100644 --- a/packages/envio-tests/test/HandlerRegister_test.res +++ b/packages/envio-tests/test/HandlerRegister_test.res @@ -28,10 +28,8 @@ chains: address: "0x2222222222222222222222222222222222222222" `).config -// Same contracts/events as `config` but a different chain id. Used to verify -// that registration intents are chain-independent: registering under `config` -// (chain 1) still materializes registrations when `finishRegistration` runs -// for chain 137 (mirrors TestIndexer narrowing `chainMap` per run). +// Single chain 137 (narrowing target). Used with `configMultichain` to verify +// that one registration resolved for the full chain set narrows to each chain. let config137 = MockIndexerConfig.parseYaml(` name: handler-register-test-137 contracts: @@ -50,6 +48,35 @@ chains: address: "0x1111111111111111111111111111111111111111" `).config +// The full chain set (1 + 137). Handlers register against this; a +// `finishRegistration` for either `config` (chain 1) or `config137` (chain 137) +// narrows to that chain (mirrors MockIndexer registering once, narrowing per run). +let configMultichain = MockIndexerConfig.parseYaml(` +name: handler-register-multichain +contracts: + - name: ERC20 + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Approval(address indexed owner, address indexed spender, uint256 value) +chains: + - id: 1 + rpc: + url: https://eth.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" + - id: 137 + rpc: + url: https://polygon.com + for: sync + start_block: 0 + contracts: + - name: ERC20 + address: "0x1111111111111111111111111111111111111111" +`).config + // raw_events enabled, single chain. Used to verify that a `where: false` event // is excluded entirely while a handler-less event still gets a bare raw-events // registration. @@ -238,19 +265,20 @@ describe("HandlerRegister multiple registrations", () => { )).toEqual(([(Some("h1"), None, 0)], [(Some("h2"), None, 1)])) }) - it("materializes registrations for a chain not present during registration", t => { - // Register under `config` (chain 1), then finish for `config137` (chain 137) - // without re-registering — intents are chain-independent, so chain 137 must - // still get the handler (the TestIndexer chainMap-narrowing case). + it("narrows one registration to each requested chain", t => { + // Register once against the full set (chains 1 + 137), then finish for each + // chain separately without re-registering — both must materialize the + // handler (the MockIndexer register-once, narrow-per-run case). let h1 = makeHandler() HandlerRegister.resetOnEventRegistrations() - HandlerRegister.startRegistration(~config) + HandlerRegister.startRegistration(~config=configMultichain) setHandler(h1) - let _ = HandlerRegister.finishRegistration(~config) + let registrations1 = HandlerRegister.finishRegistration(~config) let registrations137 = HandlerRegister.finishRegistration(~config=config137) - t.expect( + t.expect(( + registrations1->describeRegistrations(~chainKey="1", ~labels=[(h1, "h1")], ~crLabels=[]), registrations137->describeRegistrations(~chainKey="137", ~labels=[(h1, "h1")], ~crLabels=[]), - ).toEqual([(Some("h1"), None, 0)]) + )).toEqual(([(Some("h1"), None, 0)], [(Some("h1"), None, 0)])) }) it("replays pre-registered handlers in source order, not reversed", t => { diff --git a/packages/envio/src/EnvioGlobal.res b/packages/envio/src/EnvioGlobal.res index 53dd6ee72..d5258e8ff 100644 --- a/packages/envio/src/EnvioGlobal.res +++ b/packages/envio/src/EnvioGlobal.res @@ -15,7 +15,6 @@ // deduplication hint instead of silently mixing shapes across builds. type t = { version: string, - pendingOnEventRegistrations: array, mutable activeRegistration: option, preRegistered: array, rollbackCommitCallbacks: array, @@ -40,7 +39,6 @@ let value: t = { | None => let fresh = { version, - pendingOnEventRegistrations: [], activeRegistration: None, preRegistered: [], rollbackCommitCallbacks: [], diff --git a/packages/envio/src/HandlerRegister.res b/packages/envio/src/HandlerRegister.res index ab7ed1d3e..b51226ad0 100644 --- a/packages/envio/src/HandlerRegister.res +++ b/packages/envio/src/HandlerRegister.res @@ -1,7 +1,7 @@ -// Per-chain onEventRegistrations built from the event definitions in -// `Config.t` plus whatever handler/contractRegister/eventOptions got -// registered for them, and the onBlock registrations collected during -// registration. +// Per-chain onEvent + onBlock registrations. Used both as the live registration +// store (`activeRegistration`, where onEvent regs are raw: registration order, +// unmerged, unindexed, `where:false` ones kept) and as the finished output of +// `finishRegistration` (merged, backfilled, indexed). type chainRegistrations = { onEventRegistrations: array, onBlockRegistrations: array, @@ -10,57 +10,23 @@ type chainRegistrations = { // The finished registration state returned by `finishRegistration`. type registrationsByChainId = dict -// onBlock registrations collected per chain while registration is active. -// onEvent intents are chain-independent and resolved per config at -// `finishRegistration`, so nothing for them lives here. -type pendingChainRegistrations = { - onBlockRegistrations: array, -} - +// The one registration, resolved once and reused. Handlers register into it at +// `onEvent`/`onBlock` call time (resolving for every chain in `config`, which is +// the full chain set), and it persists in `EnvioGlobal` across the many +// `finishRegistration` calls a single isolate makes (handler modules are +// import-cached and register only once). `finishRegistration` reads it and +// builds a fresh per-config output, never mutating the store. type activeRegistration = { config: Config.t, - registrationsByChainId: dict, + registrationsByChainId: dict, mutable finished: bool, } -// One `indexer.onEvent` / `.contractRegister` call as a chain-independent -// intent: the handler (xor contractRegister) plus its raw `where`/wildcard -// options. Resolved into per-chain `Internal.onEventRegistration`s at -// `finishRegistration`. Chain-independent so a single isolate can materialize -// registrations for different configs (TestIndexer narrows `chainMap` per -// `process()` call, and handler modules — import-cached — register only once). -type pendingOnEventRegistration = { - contractName: string, - eventName: string, - handler: option, - contractRegister: option, - eventOptions: option>, - // Per-chain registration resolved lazily and cached here, keyed by chain id. - // Building invokes the user's `where` callback, so caching keeps that to - // exactly once per chain even though registrations are materialized many - // times (registration-time validation, `finishRegistration`, simulate). - resolvedByChainId: dict, -} - -// Registration intents live in the process-wide `EnvioGlobal` record so they -// survive an import-cached re-registration cycle (handler modules run once; -// `finishRegistration` may run many times, per config). -let pendingOnEventRegistrations = - EnvioGlobal.value.pendingOnEventRegistrations->( - Utils.magic: array => array - ) - let getKey = (~contractName, ~eventName) => contractName ++ "." ++ eventName // Test-only: reset to fresh-import state so a new registration cycle starts -// empty — clear the intent store and the active registration (production starts -// each isolate empty and registers once). +// empty (production starts each isolate empty and registers once). let resetOnEventRegistrations = () => { - pendingOnEventRegistrations->Array.splice( - ~start=0, - ~remove=pendingOnEventRegistrations->Array.length, - ~insert=[], - ) EnvioGlobal.value.activeRegistration = None } @@ -93,27 +59,36 @@ let withRegistration = (fn: activeRegistration => unit) => { } } +// Idempotent: handlers register once (import-cached), so the first call builds +// the registration and every later call reuses it. `config` must be the full +// chain set — registrations resolve for all its chains here, and +// `finishRegistration` later narrows to whatever config it's given. let startRegistration = (~config: Config.t) => { - let r = { - config, - registrationsByChainId: Dict.make(), - finished: false, + switch getActiveRegistration() { + | Some(_) => () + | None => + let r = { + config, + registrationsByChainId: Dict.make(), + finished: false, + } + EnvioGlobal.value.activeRegistration = Some(r->(Utils.magic: activeRegistration => unknown)) + // Replay pre-registered callbacks in source (FIFO) order, then clear. For + // multiple handlers on one event this replay order is the dispatch order, so + // it must not reverse (which `Array.pop` would). + let queued = preRegistered->Array.copy + preRegistered->Array.splice(~start=0, ~remove=preRegistered->Array.length, ~insert=[]) + queued->Array.forEach(fn => fn(r)) } - EnvioGlobal.value.activeRegistration = Some(r->(Utils.magic: activeRegistration => unknown)) - // Replay pre-registered callbacks in source (FIFO) order, then clear. For - // multiple handlers on one event this replay order is the dispatch order, so - // it must not reverse (which `Array.pop` would). - let queued = preRegistered->Array.copy - preRegistered->Array.splice(~start=0, ~remove=preRegistered->Array.length, ~insert=[]) - queued->Array.forEach(fn => fn(r)) } -let getPendingChainRegistrations = (r: activeRegistration, ~chainId: int) => { +let getChainRegistrations = (r: activeRegistration, ~chainId: int): chainRegistrations => { let key = chainId->Int.toString switch r.registrationsByChainId->Utils.Dict.dangerouslyGetNonOption(key) { - | Some(pending) => pending + | Some(existing) => existing | None => - let fresh = { + let fresh: chainRegistrations = { + onEventRegistrations: [], onBlockRegistrations: [], } r.registrationsByChainId->Dict.set(key, fresh) @@ -188,60 +163,15 @@ let sameEventAndFilter = ( | Fuel | Svm => true } -// Resolve one intent into this chain's registration, building it (and invoking -// the user's `where` callback) exactly once per chain and caching the result on -// the intent. Returns None when the chain doesn't define the intent's event. -let resolveIntentForChain = ( - ~config: Config.t, - ~chainConfig: Config.chain, - intent: pendingOnEventRegistration, -): option => { - let key = chainConfig.id->Int.toString - switch intent.resolvedByChainId->Utils.Dict.dangerouslyGetNonOption(key) { - | Some(_) as cached => cached - | None => - switch chainConfig.contracts->Array.find(c => c.name === intent.contractName) { - | None => None - | Some(contract) => - switch contract.events->Array.find(e => e.name === intent.eventName) { - | None => None - | Some(eventConfig) => - let isWildcard = intent.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) - let where = intent.eventOptions->Option.flatMap(v => v.where) - let reg = buildOnEventRegistrationWith( - ~config, - ~chainId=chainConfig.id, - ~eventConfig, - ~isWildcard, - ~handler=intent.handler, - ~contractRegister=intent.contractRegister, - ~where, - ~startBlock=?contract.startBlock, - ) - intent.resolvedByChainId->Dict.set(key, reg) - Some(reg) - } - } - } -} - -// Resolve the chain-independent intents into this chain's registrations, then -// merge each contractRegister into a matching handler registration (either +// Merge each contractRegister into a matching handler registration (either // registration order; the merged registration takes the handler's slot so // dispatch order follows handler registration order). Two handlers (or two -// contractRegisters) for one event never merge. Shared by `finishRegistration` -// and simulate so both see the same registrations. -let resolveChainRegistrations = (~config: Config.t, ~chainConfig: Config.chain): array< +// contractRegisters) for one event never merge. Operates on the raw per-chain +// registrations stored at `onEvent` time; shared by `finishRegistration` and +// simulate so both see the same registrations. +let mergeRegistrations = (resolved: array, ~config: Config.t): array< Internal.onEventRegistration, > => { - let resolved: array = [] - pendingOnEventRegistrations->Array.forEach(intent => - switch resolveIntentForChain(~config, ~chainConfig, intent) { - | Some(reg) => resolved->Array.push(reg)->ignore - | None => () - } - ) - let merged: ref> = ref([]) resolved->Array.forEach((reg: Internal.onEventRegistration) => { if reg.handler->Option.isSome { @@ -285,20 +215,46 @@ let resolveChainRegistrations = (~config: Config.t, ~chainConfig: Config.chain): let isDroppedByWhere = (~config: Config.t, reg: Internal.onEventRegistration) => config.ecosystem.name === Evm && (reg->getResolvedWhere).topicSelections->Utils.Array.isEmpty -let addIntent = (registration: activeRegistration, intent: pendingOnEventRegistration) => { - // Resolve against every chain the config defines now, populating the intent's - // per-chain cache. This runs the user's `where` callback once per chain and - // surfaces a broken filter (bad filter, unknown indexed param) at the - // registration call site — even when only a later chain's resolution is - // invalid — instead of deferring the error to `finishRegistration`. `config` - // may be a narrowed TestIndexer chain subset; chains missing here resolve - // (and cache) lazily when `finishRegistration`/simulate first sees them. +// Resolve one `onEvent`/`contractRegister` call into a registration for every +// chain in the config (the full chain set) and store it in registration order. +// Building runs the user's `where` callback here — once per chain — so a broken +// filter throws at the call site. A chain that doesn't define the event is +// skipped. +let addOnEventRegistration = ( + registration: activeRegistration, + ~contractName, + ~eventName, + ~handler: option, + ~contractRegister: option, + ~eventOptions: option>, +) => { + let isWildcard = eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) + let where = eventOptions->Option.flatMap(v => v.where) registration.config.chainMap ->ChainMap.values ->Array.forEach(chainConfig => - resolveIntentForChain(~config=registration.config, ~chainConfig, intent)->ignore + switch chainConfig.contracts->Array.find(c => c.name === contractName) { + | None => () + | Some(contract) => + switch contract.events->Array.find(e => e.name === eventName) { + | None => () + | Some(eventConfig) => + let reg = buildOnEventRegistrationWith( + ~config=registration.config, + ~chainId=chainConfig.id, + ~eventConfig, + ~isWildcard, + ~handler, + ~contractRegister, + ~where, + ~startBlock=?contract.startBlock, + ) + (registration->getChainRegistrations(~chainId=chainConfig.id)).onEventRegistrations + ->Array.push(reg) + ->ignore + } + } ) - pendingOnEventRegistrations->Array.push(intent)->ignore } let setHandler = (~contractName, ~eventName, handler, ~eventOptions) => { @@ -308,14 +264,13 @@ let setHandler = (~contractName, ~eventName, handler, ~eventOptions) => { eventOptions->Option.map(v => v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions) ) - registration->addIntent({ - contractName, - eventName, - handler: Some(newHandler), - contractRegister: None, - eventOptions, - resolvedByChainId: Dict.make(), - }) + registration->addOnEventRegistration( + ~contractName, + ~eventName, + ~handler=Some(newHandler), + ~contractRegister=None, + ~eventOptions, + ) }) } @@ -331,25 +286,41 @@ let setContractRegister = (~contractName, ~eventName, contractRegister, ~eventOp eventOptions->Option.map(v => v->(Utils.magic: Internal.eventOptions<'where> => Internal.eventOptions) ) - registration->addIntent({ - contractName, - eventName, - handler: None, - contractRegister: Some(newContractRegister), - eventOptions, - resolvedByChainId: Dict.make(), - }) + registration->addOnEventRegistration( + ~contractName, + ~eventName, + ~handler=None, + ~contractRegister=Some(newContractRegister), + ~eventOptions, + ) }) } +// Raw onEvent registrations stored for a chain (empty if the chain has none). +let storedOnEventRegistrations = (r: activeRegistration, ~chainId: int): array< + Internal.onEventRegistration, +> => + switch r.registrationsByChainId->Utils.Dict.dangerouslyGetNonOption(chainId->Int.toString) { + | Some(chainRegs) => chainRegs.onEventRegistrations + | None => [] + } + // True when any registration for the event is a wildcard. Used by simulate to // decide whether a src address needs deriving. let isWildcard = (~contractName, ~eventName) => - pendingOnEventRegistrations->Array.some(p => - p.contractName === contractName && - p.eventName === eventName && - p.eventOptions->Option.flatMap(v => v.wildcard)->Option.getOr(false) - ) + switch getActiveRegistration() { + | Some(r) => + r.registrationsByChainId + ->Dict.valuesToArray + ->Array.some(chainRegs => + chainRegs.onEventRegistrations->Array.some(reg => + reg.eventConfig.contractName === contractName && + reg.eventConfig.name === eventName && + reg.isWildcard + ) + ) + | None => false + } // Every registration for one event on a chain, so simulate fans a simulated // event out to each the way real routing does. Falls back to a bare @@ -360,9 +331,12 @@ let getSimulateOnEventRegistrations = ( ~chainId: int, ~eventConfig: Internal.eventConfig, ): array => { - let chainConfig = config.chainMap->ChainMap.get(ChainMap.Chain.makeUnsafe(~chainId)) + let stored = switch getActiveRegistration() { + | Some(r) => r->storedOnEventRegistrations(~chainId) + | None => [] + } let matching = - resolveChainRegistrations(~config, ~chainConfig)->Array.filter(reg => + mergeRegistrations(stored, ~config)->Array.filter(reg => reg.eventConfig.contractName === eventConfig.contractName && reg.eventConfig.name === eventConfig.name ) @@ -395,7 +369,7 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { let chainId = chainConfig.id let key = chainId->Int.toString - let builtRegs = resolveChainRegistrations(~config, ~chainConfig) + let builtRegs = mergeRegistrations(r->storedOnEventRegistrations(~chainId), ~config) let registeredKeys = Utils.Set.make() builtRegs->Array.forEach(reg => registeredKeys @@ -472,10 +446,12 @@ let finishRegistration = (~config: Config.t): registrationsByChainId => { key, { onEventRegistrations, + // Copy so a consumer appending to the output (e.g. simulate source + // registration) never mutates the persistent store. onBlockRegistrations: switch r.registrationsByChainId->Utils.Dict.dangerouslyGetNonOption( key, ) { - | Some(pending) => pending.onBlockRegistrations + | Some(chainRegs) => chainRegs.onBlockRegistrations->Array.copy | None => [] }, }, @@ -661,12 +637,12 @@ let registerOnBlock = ( } | None => () } - let pending = registration->getPendingChainRegistrations(~chainId) - pending.onBlockRegistrations + let chainRegs = registration->getChainRegistrations(~chainId) + chainRegs.onBlockRegistrations ->Array.push( ( { - index: pending.onBlockRegistrations->Array.length, + index: chainRegs.onBlockRegistrations->Array.length, name, startBlock: range._gte, endBlock: range._lte, diff --git a/scenarios/test_codegen/test/helpers/MockIndexer.res b/scenarios/test_codegen/test/helpers/MockIndexer.res index 5fd903523..52c131642 100644 --- a/scenarios/test_codegen/test/helpers/MockIndexer.res +++ b/scenarios/test_codegen/test/helpers/MockIndexer.res @@ -436,22 +436,21 @@ module Indexer = { | Some(_) => () } - // Build the final per-test config (chain overrides, enableRawEvents, ...) - // before registering handlers: `HandlerRegister.finishRegistration` now - // builds each chain's onEventRegistrations (gated by `enableRawEvents`) - // from the config it's given, so registration must see the resolved - // config rather than the raw generated one. - let config = { - let config = switch customConfig { - | Some(config) => config - | None => Config.load() - } + // The full (un-narrowed) config. Handlers register against this so every + // chain resolves once; `finishRegistration` then narrows to the per-test + // `config` below. + let baseConfig = switch customConfig { + | Some(config) => config + | None => Config.load() + } + // Build the final per-test config (chain overrides, enableRawEvents, ...). + let config = { let chainMap = chains ->Array.map(chainConfig => { let chain = ChainMap.Chain.makeUnsafe(~chainId=(chainConfig.chain :> int)) - let originalChainConfig = config.chainMap->ChainMap.get(chain) + let originalChainConfig = baseConfig.chainMap->ChainMap.get(chain) ( chain, { @@ -468,24 +467,26 @@ module Indexer = { ->ChainMap.fromArrayUnsafe { - ...config, + ...baseConfig, shouldRollbackOnReorg, shouldSaveFullHistory: saveFullHistory, enableRawEvents, chainMap, - batchSize: batchSize->Option.getOr(config.batchSize), + batchSize: batchSize->Option.getOr(baseConfig.batchSize), reorgThresholdReadyTolerance, } } - let registrationsByChainId = switch customConfig { - | None => await HandlerLoader.registerAllHandlers(~config) + // Register handlers once against the full chain set (idempotent + + // import-cached, so re-`make` reuses), then narrow to this run's chains. + switch customConfig { + | None => let _ = await HandlerLoader.registerAllHandlers(~config=baseConfig) | Some(_) => // A supplied config has no handler files on disk; register inline // handlers (if any) through the same public registry lifecycle. - HandlerRegister.startRegistration(~config) - HandlerRegister.finishRegistration(~config) + HandlerRegister.startRegistration(~config=baseConfig) } + let registrationsByChainId = HandlerRegister.finishRegistration(~config) installMockSourceRegistrations(~config, ~registrationsByChainId) let sql = PgStorage.makeClient() From 1f5d6b43de08ac374b9f3dfee4f3b1867c6b28b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 12:37:46 +0000 Subject: [PATCH 13/16] Match exact duplicate-event error messages after base merge Base changed `expectParseError` to compare the full parse-error string exactly, so the duplicate-event assertions need the complete message (including the `Config parse error:` prefix and, for the EVM case, the `Failed parsing globally defined contract` context) instead of a substring. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- packages/envio-tests/test/ConfigYaml_test.res | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/envio-tests/test/ConfigYaml_test.res b/packages/envio-tests/test/ConfigYaml_test.res index 4f11718ce..42193c74c 100644 --- a/packages/envio-tests/test/ConfigYaml_test.res +++ b/packages/envio-tests/test/ConfigYaml_test.res @@ -493,7 +493,7 @@ chains: - id: 1 start_block: 0 `, - "the indexer can't tell apart", + "Config parse error: Failed parsing globally defined contract: Contract Token has two events the indexer can't tell apart: Transfer and Transfer. Please remove one of them.", ) }) @@ -1075,7 +1075,7 @@ chains: - {name: Transfer, discriminator: "0x0f"} - {name: Withdraw, discriminator: "0x0F"} `, - "the indexer can't tell apart", + "Config parse error: Contract Program has two events the indexer can't tell apart: Transfer and Withdraw. Please remove one of them.", ), ( "rejects invalid discriminators", From bfcaeeb0f0ae4c38d87de7b0b09934b6031892dc Mon Sep 17 00:00:00 2001 From: Dmitry Zakharov Date: Thu, 23 Jul 2026 18:41:57 +0400 Subject: [PATCH 14/16] Eliminate worker thread from TestIndexer, store entities decoded (#1476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Run createTestIndexer in-process instead of worker threads (#1467) * Run createTestIndexer in-process instead of worker threads Replace the per-chain worker + storage message-proxy with an in-process run against an in-memory Persistence.storage, removing the dominant cost (re-evaluating the envio module graph in a fresh isolate per process()). - IndexerState gets an injectable ~onExit; ExitOnCaughtUp resolves it instead of process.exit, so a caught-up run in-process resolves a promise rather than killing the test runner. Production default unchanged. - TestIndexer builds a per-instance in-memory storage (config-derived initial state, never a real DB) and drives IndexerState/IndexerLoop directly; runs bypass Persistence.init and stop the loop on completion. - Registrations are captured once and cloned per run (patchConfig appends a simulate source), so independent createTestIndexer instances run in parallel without shared mutable registration state. - Handlers run inside an AsyncLocalStorage scope so a handler calling indexer.onEvent throws (as in production) without finishing the global registration the test itself uses. - Delete TestIndexerWorker and the proxy's message-channel machinery. - Restore vitest to envio devDependencies (needed to resolve the Vitest binding for local scenario test runs). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fb6Yyif9GZG1DWAiCER4Gh * Drop JSON round-trip from TestIndexer in-memory storage The worker model serialized entities to JSON to cross the thread boundary. In-process there is no boundary, so store and load entities decoded: - handleLoad filters decoded entities with the already-typed filter and returns them directly (no serialize/parse, no rowsSchema round-trip). - handleWriteBatch takes Persistence.updatedEntity and stores the decoded entities as-is instead of encoding then re-parsing them. - Delete TestIndexerProxyStorage entirely — its serializable types were the only remaining use. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fb6Yyif9GZG1DWAiCER4Gh * Isolate test indexer entity store with defensive copies (#1472) * Harden in-process TestIndexer: isolate store, drop dead resume plumbing - Copy entities on the set/get boundary so user code mutating a returned entity (or an object passed to `set`) can't corrupt the in-memory store; add a regression test. - Remove the unused `resumeInitialState`/`currentInitialState` injection — the runner sets `storageStatus = Ready(...)` directly, so it was never reached; make `resumeInitialState` a throwing stub like the other unused storage methods. - Await the in-flight write fiber in cleanup instead of busy-polling it. - Document the loss of per-test isolation (shared module/effect-cache state) in the indexer-testing skill. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DAwNk4gUT3QV6dh7uBtef1 * Replace AsyncLocalStorage guard with plain finished-registration flag The in-process runner used an AsyncLocalStorage scope so a handler calling `indexer.onEvent` at runtime would throw, while still letting tests call the registration API after a run (which required clearing the finished flag). The only thing depending on the API staying callable post-registration was two type-surface tests; those are compile-time only and don't need to run after registration. - Move the `indexer.onEvent`/`contractRegister` type-surface checks into EventHandlers.ts under `if (0)` (type-checked, never executed), and drop the two runtime `it(...)` cases. - Delete the AsyncLocalStorage scope, `runInHandlerScope`/`isInHandlerScope`, and `clearActiveRegistration`; restore `throwIfFinishedRegistration` to the plain `finished` check. A handler registering at runtime now throws via the finished flag, as in production. - getRegistrations no longer reopens the registration API after capturing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DAwNk4gUT3QV6dh7uBtef1 * Tighten test-isolation note in indexer-testing skill Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DAwNk4gUT3QV6dh7uBtef1 --------- Co-authored-by: Claude * Collapse makeCreateTestIndexer factory into createTestIndexer The (~config) => unit => t currying was a dependency-injection seam (it once also threaded ~workerPath); nothing injects a custom config and its only caller invoked it immediately. Fold it into a single createTestIndexer() that loads the memoized config internally. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fb6Yyif9GZG1DWAiCER4Gh * Test handler errors and improve TestIndexer error messages (#1474) * Fix stale TestIndexer comments and drop dead chainId tuple element - copyEntity comment no longer claims all entity fields are immutable scalars; shallow copy still shares array-valued fields. - Rollback storage stubs no longer advise setting rollbackOnReorg to false — the runner already forces it off, making them unreachable. - Drop the unused chainId element from chainEntries (validation kept). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NCpLSmKU3MZaAyGRjmcAVc * Add regression test for handler-throw propagation A synchronous throw in an event handler body must reject the process() promise at the call site, carrying the handler's original message. This path (handler-body throw) was only covered indirectly before, via effect and getOrThrow throws. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NCpLSmKU3MZaAyGRjmcAVc --------- Co-authored-by: Claude --------- Co-authored-by: Claude * Friendlier event-registration validation and simulate handler checks - Reject duplicate event names per contract at parse time, suggesting the `name` alias for overloads (replaces the never-implemented uniqueness TODO). - Reword the same-dispatch-signature error so it reads clearly (names are unique by the check above, so it never prints "X and X"). - Simulate now applies the same `where:false` drop as finishRegistration and fails loudly when an event has no handler registered, instead of silently fabricating a bare registration. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Lcy9XY2kAFMscJRukX7Fqi * Test the new validation and simulate handler behavior ConfigYaml: same-signature rejection with distinct names, byte-identical duplicate, same-name overload (alias suggestion), the SVM hex-casing case, and a success case where a `name` alias resolves an overload. HandlerRegister: simulate returns the handled registration, drops a `where:false` one, and returns nothing for an unhandled event. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Lcy9XY2kAFMscJRukX7Fqi * Revert simulate-side changes that broke handler-less simulate The where:false drop and no-handler failure lived in the shared `getSimulateOnEventRegistrations`, which is also used by module-load-time registration probes (`MockConfig.getOnEventRegistration`) that need the bare fallback and the un-dropped registration. And failing on a handler-less event contradicts the framework's intentional pattern of simulating one (e.g. `Noop.EmptyEvent`) as a chain-advance marker. Keeps the parse-time event-name validation (items 1 and 4), which is unaffected. The simulate where:false drop needs to live in the simulate path only; revisiting separately. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Lcy9XY2kAFMscJRukX7Fqi * Fail simulate on unhandled events; drop where:false in simulate path only Apply the no-handler failure and the where:false drop in SimulateItems (the user-facing simulate path) instead of the shared getSimulateOnEventRegistrations, so module-load registration probes (MockConfig) keep their bare-fallback behavior. Simulate now fans out only to registrations that would actually run on the chain (a real handler/contractRegister, where not excluded), and throws a clear error when none would. Give Noop.EmptyEvent a no-op handler so the multichain-ordering test still processes an event on chain 1 (its only event) without writing an entity, and point the new no-handler failure test at Gravatar.TestEventWithReservedKeyword, which is defined but genuinely handler-less. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Lcy9XY2kAFMscJRukX7Fqi --------- Co-authored-by: Claude --- .../cli/src/config_parsing/system_config.rs | 40 +- .../.claude/skills/indexer-testing/SKILL.md | 11 + packages/envio-tests/test/ConfigYaml_test.res | 70 +- packages/envio/package.json | 3 +- packages/envio/src/Api.res | 9 +- packages/envio/src/ExitOnCaughtUp.res | 8 +- packages/envio/src/HandlerRegister.resi | 1 + packages/envio/src/IndexerState.res | 10 + packages/envio/src/IndexerState.resi | 3 + packages/envio/src/SimulateItems.res | 40 +- packages/envio/src/TestIndexer.res | 899 +++++++++--------- .../envio/src/TestIndexerProxyStorage.res | 196 ---- packages/envio/src/TestIndexerWorker.res | 4 - pnpm-lock.yaml | 3 + .../src/handlers/EventHandlers.ts | 42 + .../test_codegen/test/EventHandler.test.ts | 126 ++- 16 files changed, 739 insertions(+), 726 deletions(-) delete mode 100644 packages/envio/src/TestIndexerProxyStorage.res delete mode 100644 packages/envio/src/TestIndexerWorker.res diff --git a/packages/cli/src/config_parsing/system_config.rs b/packages/cli/src/config_parsing/system_config.rs index 4602c7608..b1c8a340b 100644 --- a/packages/cli/src/config_parsing/system_config.rs +++ b/packages/cli/src/config_parsing/system_config.rs @@ -1926,11 +1926,38 @@ impl Contract { "event".to_string(), )?; - // Two event definitions on one contract that share a dispatch key are + // Codegen keys the generated event modules by name and routing looks + // events up by name, so two events on one contract can't share a name. + // Overloads (same name, different signature) are the usual cause — point + // at the `name` alias as the fix; a byte-identical copy just needs + // removing. + let mut seen_by_name: HashMap<&str, &Event> = HashMap::new(); + for event in &events { + if let Some(existing) = seen_by_name.insert(&event.name, event) { + if existing.sighash == event.sighash { + return Err(anyhow!( + "Contract {name} defines the event \"{}\" more than once. \ + Please remove the duplicate.", + event.name, + )); + } + return Err(anyhow!( + "Contract {name} has two events named \"{}\". Give one of them a \ + unique name with the \"name\" field so the generated code and \ + the indexer's routing can tell them apart.", + event.name, + )); + } + } + + // Two events on one contract that share a dispatch key are // indistinguishable at routing time — one log/instruction would decode // to both — so reject them here. The key mirrors the runtime `eventId`: // sighash plus indexed-topic count for EVM, the discriminator for SVM - // (already program-scoped, since these are one program's instructions). + // (already program-scoped, since these are one program's instructions), + // the sighash for Fuel (a `LogData` logId or a fixed `mint`/`burn`/…). + // Names are unique by the check above, so a collision here is always + // between two differently-named events. let mut seen_by_dispatch_key: HashMap = HashMap::new(); for event in &events { let dispatch_key = match &event.kind { @@ -1946,9 +1973,6 @@ impl Contract { .map(|d| d.to_lowercase()) .unwrap_or_else(|| "none".to_string()), ), - // Fuel routing dispatches by sighash (a `LogData` logId, or a - // fixed value like `mint`/`burn`/`transfer`/`call`), so two - // events sharing it on one contract are indistinguishable too. EventKind::Fuel(_) => Some(event.sighash.clone()), }; if let Some(dispatch_key) = dispatch_key { @@ -1956,8 +1980,10 @@ impl Contract { seen_by_dispatch_key.insert(dispatch_key, event.name.clone()) { return Err(anyhow!( - "Contract {name} has two events the indexer can't tell apart: {existing} \ - and {}. Please remove one of them.", + "Contract {name} has two events the indexer can't tell apart: \ + \"{existing}\" and \"{}\". They match the same on-chain data, so \ + the indexer can't decide which one a log belongs to. Please remove \ + one of them.", event.name, )); } diff --git a/packages/cli/templates/static/shared/.claude/skills/indexer-testing/SKILL.md b/packages/cli/templates/static/shared/.claude/skills/indexer-testing/SKILL.md index ee738dc4b..8b0393f89 100644 --- a/packages/cli/templates/static/shared/.claude/skills/indexer-testing/SKILL.md +++ b/packages/cli/templates/static/shared/.claude/skills/indexer-testing/SKILL.md @@ -99,6 +99,17 @@ await indexer.EntityName.getOrThrow("id"); // throws if not found await indexer.EntityName.getAll(); // returns all entities of this type ``` +## Test isolation + +Each `createTestIndexer()` has its own entity store; within one indexer, entity +state and block progress persist across `process()` calls (each continues where +the last stopped). + +Tests run in-process, so **module-level** state in your handler code (top-level +`let`/`const`, memoized clients, effect caches) is shared across every indexer +and test in the file and persists between them. Reset it yourself (e.g. in a +`beforeEach`) if a test depends on it. + ## result.changes `result.changes` is an array of per-block change objects. Each entry has `block`, `chainId`, `eventsProcessed`, plus entity names as keys with `sets` arrays of created/updated entities. Dynamic contract registrations appear under `addresses.sets`. diff --git a/packages/envio-tests/test/ConfigYaml_test.res b/packages/envio-tests/test/ConfigYaml_test.res index 42193c74c..80e7438a7 100644 --- a/packages/envio-tests/test/ConfigYaml_test.res +++ b/packages/envio-tests/test/ConfigYaml_test.res @@ -479,7 +479,7 @@ chains: }) describe("system config validation errors", () => { - it("rejects two events on one contract that the indexer can't tell apart", t => { + it("rejects two differently-named events that share a dispatch signature", t => { expectParseError( t, ` @@ -489,11 +489,48 @@ contracts: events: - event: Transfer(address indexed from, address indexed to, uint256 value) - event: Transfer(address indexed from, address indexed to, uint256 value) + name: Transfer2 chains: - id: 1 start_block: 0 `, - "Config parse error: Failed parsing globally defined contract: Contract Token has two events the indexer can't tell apart: Transfer and Transfer. Please remove one of them.", + `Config parse error: Failed parsing globally defined contract: Contract Token has two events the indexer can't tell apart: "Transfer" and "Transfer2". They match the same on-chain data, so the indexer can't decide which one a log belongs to. Please remove one of them.`, + ) + }) + + it("rejects a byte-identical duplicate event, pointing at removal", t => { + expectParseError( + t, + ` +name: duplicate-event +contracts: + - name: Token + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + start_block: 0 +`, + `Config parse error: Failed parsing globally defined contract: Contract Token defines the event "Transfer" more than once. Please remove the duplicate.`, + ) + }) + + it("rejects two events with the same name but different signatures, suggesting the name alias", t => { + expectParseError( + t, + ` +name: overloaded-event +contracts: + - name: Token + events: + - event: Transfer(address from) + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + start_block: 0 +`, + `Config parse error: Failed parsing globally defined contract: Contract Token has two events named "Transfer". Give one of them a unique name with the "name" field so the generated code and the indexer's routing can tell them apart.`, ) }) @@ -856,6 +893,33 @@ chains: t.expect(chain.contracts->Array.length).toBe(2) }) + it("allows two same-named overloads once a name alias disambiguates them", t => { + // Two events resolving to the name "Transfer" would clash, but the alias on + // one gives them distinct names (and their signatures differ, so no dispatch + // collision either). + let {config} = MockIndexerConfig.parseYaml(` +name: aliased-overload +contracts: + - name: Token + events: + - event: Transfer(address from) + name: TransferSimple + - event: Transfer(address indexed from, address indexed to, uint256 value) +chains: + - id: 1 + rpc: + url: https://eth.com + for: sync + start_block: 0 + contracts: + - name: Token + address: "0x1111111111111111111111111111111111111111" +`) + let chain = config.chainMap->ChainMap.values->Array.getUnsafe(0) + let contract = chain.contracts->Array.getUnsafe(0) + t.expect(contract.events->Array.map(e => e.name)).toEqual(["TransferSimple", "Transfer"]) + }) + it("parses a minimal Fuel config through the public boundary", t => { let {config} = MockIndexerConfig.parseYaml(` name: fuel-config @@ -1075,7 +1139,7 @@ chains: - {name: Transfer, discriminator: "0x0f"} - {name: Withdraw, discriminator: "0x0F"} `, - "Config parse error: Contract Program has two events the indexer can't tell apart: Transfer and Withdraw. Please remove one of them.", + `Config parse error: Contract Program has two events the indexer can't tell apart: "Transfer" and "Withdraw". They match the same on-chain data, so the indexer can't decide which one a log belongs to. Please remove one of them.`, ), ( "rejects invalid discriminators", diff --git a/packages/envio/package.json b/packages/envio/package.json index 0c6edb1e9..ff572db4b 100644 --- a/packages/envio/package.json +++ b/packages/envio/package.json @@ -74,6 +74,7 @@ "tsx": "4.21.0" }, "devDependencies": { - "rescript": "12.2.0" + "rescript": "12.2.0", + "vitest": "4.1.0" } } diff --git a/packages/envio/src/Api.res b/packages/envio/src/Api.res index 7ff60f545..11b02006e 100644 --- a/packages/envio/src/Api.res +++ b/packages/envio/src/Api.res @@ -4,12 +4,5 @@ let indexer: unknown = Main.getGlobalIndexer() let createTestIndexer: unit => unknown = () => { - let workerPath = - NodeJs.Path.join( - NodeJs.Path.getDirname(NodeJs.ImportMeta.importMeta), - "TestIndexerWorker.res.mjs", - )->NodeJs.Path.toString - TestIndexer.makeCreateTestIndexer(~config=Config.load(), ~workerPath)()->( - Utils.magic: TestIndexer.t<'a> => unknown - ) + TestIndexer.createTestIndexer()->(Utils.magic: TestIndexer.t<'a> => unknown) } diff --git a/packages/envio/src/ExitOnCaughtUp.res b/packages/envio/src/ExitOnCaughtUp.res index 1c0399b33..5d989dc50 100644 --- a/packages/envio/src/ExitOnCaughtUp.res +++ b/packages/envio/src/ExitOnCaughtUp.res @@ -10,8 +10,12 @@ let run = async (state: IndexerState.t) => { ->IndexerState.simulateDeadInputTracker ->Option.flatMap(SimulateDeadInputTracker.failureMessage) { | None => - Logging.info("Exiting with success") - NodeJs.process->NodeJs.exitWithCode(Success) + switch state->IndexerState.onExit { + | Some(onExit) => onExit() + | None => + Logging.info("Exiting with success") + NodeJs.process->NodeJs.exitWithCode(Success) + } | Some(message) => state->IndexerState.errorExit(ErrorHandling.make(Utils.Error.make(message))) } } diff --git a/packages/envio/src/HandlerRegister.resi b/packages/envio/src/HandlerRegister.resi index 353eb7bb2..632a19b0a 100644 --- a/packages/envio/src/HandlerRegister.resi +++ b/packages/envio/src/HandlerRegister.resi @@ -23,6 +23,7 @@ let setContractRegister: ( ~eventOptions: option>, ) => unit let isWildcard: (~contractName: string, ~eventName: string) => bool +let isDroppedByWhere: (~config: Config.t, Internal.onEventRegistration) => bool let getSimulateOnEventRegistrations: ( ~config: Config.t, ~chainId: int, diff --git a/packages/envio/src/IndexerState.res b/packages/envio/src/IndexerState.res index a4ef3bbea..a838714d5 100644 --- a/packages/envio/src/IndexerState.res +++ b/packages/envio/src/IndexerState.res @@ -115,6 +115,11 @@ type t = { exitAfterFirstEventBlock: bool, // The single fatal-error handler. onError: ErrorHandling.t => unit, + // Invoked once when the indexer catches up and would otherwise exit the + // process. `None` keeps the production behavior (exit the process); the + // in-process test runner injects a callback that resolves its run promise + // instead, so a caught-up run doesn't kill the test process. + onExit: option unit>, // Set once on any fatal error. Every loop checks it to stop iterating and // every launch skips when it's set, so a single failure quiesces the indexer. mutable isStopped: bool, @@ -149,6 +154,7 @@ let make = ( ~shouldUseTui=false, ~exitAfterFirstEventBlock=false, ~onError: ErrorHandling.t => unit, + ~onExit=?, ) => { let chainMetaThrottler = { let intervalMillis = Env.ThrottleWrites.chainMetadataIntervalMillis @@ -194,6 +200,7 @@ let make = ( keepProcessAlive: isDevelopmentMode || shouldUseTui, exitAfterFirstEventBlock, onError, + onExit, isStopped: false, epoch: 0, simulateDeadInputTracker: SimulateDeadInputTracker.makeFromConfig(config), @@ -229,6 +236,7 @@ let makeFromDbState = ( ~reducedPollingInterval=?, ~targetBufferSize=CrossChainState.calculateTargetBufferSize(), ~onError, + ~onExit=?, ) => { let isInReorgThreshold = if initialState.cleanRun { false @@ -280,6 +288,7 @@ let makeFromDbState = ( ~shouldUseTui, ~exitAfterFirstEventBlock, ~onError, + ~onExit?, ) initialState.cache->Utils.Dict.forEach(({effectName, count, scope}) => { state.effectState->EffectState.setUnregisteredCacheCount(~effectName, ~scope, ~count) @@ -415,6 +424,7 @@ let indexerStartTime = (state: t) => state.indexerStartTime let loadManager = (state: t) => state.loadManager let keepProcessAlive = (state: t) => state.keepProcessAlive let exitAfterFirstEventBlock = (state: t) => state.exitAfterFirstEventBlock +let onExit = (state: t) => state.onExit let isStopped = (state: t) => state.isStopped let epoch = (state: t) => state.epoch let lastPrunedAtMillis = (state: t) => state.lastPrunedAtMillis diff --git a/packages/envio/src/IndexerState.resi b/packages/envio/src/IndexerState.resi index 25401de6d..2c864d7f2 100644 --- a/packages/envio/src/IndexerState.resi +++ b/packages/envio/src/IndexerState.resi @@ -30,6 +30,7 @@ let make: ( ~shouldUseTui: bool=?, ~exitAfterFirstEventBlock: bool=?, ~onError: ErrorHandling.t => unit, + ~onExit: unit => unit=?, ) => t let makeFromDbState: ( @@ -43,6 +44,7 @@ let makeFromDbState: ( ~reducedPollingInterval: int=?, ~targetBufferSize: int=?, ~onError: ErrorHandling.t => unit, + ~onExit: unit => unit=?, ) => t let unexpectedErrorMsg: string @@ -96,6 +98,7 @@ let indexerStartTime: t => Date.t let loadManager: t => LoadManager.t let keepProcessAlive: t => bool let exitAfterFirstEventBlock: t => bool +let onExit: t => option unit> let isStopped: t => bool let epoch: t => int let lastPrunedAtMillis: t => dict diff --git a/packages/envio/src/SimulateItems.res b/packages/envio/src/SimulateItems.res index efe7407d8..fc4a3bb05 100644 --- a/packages/envio/src/SimulateItems.res +++ b/packages/envio/src/SimulateItems.res @@ -339,17 +339,35 @@ let parse = ( | None => seenCoordinates->Dict.set(coordinate, itemIndex) } - // Fan the simulated event out to every registration the way real routing - // does (one item per registration). Registrations are built the same way - // `HandlerRegister.finishRegistration` does at startup (not stubs), so the - // address filter and `where` behave identically to real indexing — the - // dead-input tracker relies on `clientAddressFilter` actually gating - // unrouted items. - HandlerRegister.getSimulateOnEventRegistrations( - ~config, - ~chainId, - ~eventConfig, - )->Array.forEach(reg => { + // Fan the simulated event out to every registration that would actually + // run here the way real routing does (one item per registration). + // Registrations are built the same way `HandlerRegister.finishRegistration` + // does at startup (not stubs), so the address filter and `where` behave + // identically to real indexing — the dead-input tracker relies on + // `clientAddressFilter` actually gating unrouted items. Drop registrations + // whose `where` excludes this chain (they wouldn't be fetched live), and + // ignore the bare handler-less fallback so a missing handler surfaces below + // instead of silently running nothing. + let liveRegistrations = + HandlerRegister.getSimulateOnEventRegistrations( + ~config, + ~chainId, + ~eventConfig, + )->Array.filter(reg => + (reg.handler->Option.isSome || reg.contractRegister->Option.isSome) && + !HandlerRegister.isDroppedByWhere(~config, reg) + ) + if liveRegistrations->Utils.Array.isEmpty { + JsError.throwWithMessage( + `simulate: no handler runs for event "${eventName}" on contract "${contractName}"${switch config.chainMap + ->ChainMap.values + ->Array.length { + | 1 => "" + | _ => ` on chain ${chainId->Int.toString}` + }}. Register a handler with indexer.onEvent (and check any \`where\` filter isn't excluding this chain) before simulating it.`, + ) + } + liveRegistrations->Array.forEach(reg => { // Append into the registration array that the chain state will own and // put that same registration object directly on the simulated item. let onEventRegistrationIndex = onEventRegistrations->Array.length diff --git a/packages/envio/src/TestIndexer.res b/packages/envio/src/TestIndexer.res index babfdb9cc..042a440e7 100644 --- a/packages/envio/src/TestIndexer.res +++ b/packages/envio/src/TestIndexer.res @@ -70,48 +70,31 @@ let getIndexingAddressesByChain = (state: testIndexerState): dict< byChain } -let handleLoad = (state: testIndexerState, ~tableName: string, ~filter: EntityFilter.t): JSON.t => { +let handleLoad = (state: testIndexerState, ~tableName: string, ~filter: EntityFilter.t): array< + Internal.entity, +> => { // Loads for non-entity tables (e.g. effect caches `envio_effect_`) reach // here too. TestIndexer never persists those, so there's nothing to return — // an empty result makes the effect recompute instead of crashing on a missing // entityConfig. switch state.entityConfigs->Dict.get(tableName) { - | None => []->JSON.Encode.array - | Some(entityConfig) => + | None => [] + | Some(_) => let entityDict = state.entities->Dict.get(tableName)->Option.getOr(Dict.make()) - let results = [] - - // Field values arrive as JSON from the worker boundary, so parse them - // with the field's schema before comparing. This properly handles - // bigint and BigDecimal comparisons - let parseLeaf = (~fieldName, ~fieldValue: unknown, ~isArray): unknown => { - let queryField = switch entityConfig.table->Table.queryFields->Dict.get(fieldName) { - | Some(queryField) => queryField - | None => JsError.throwWithMessage(`Field ${fieldName} not found in entity ${tableName}`) - } - fieldValue->S.convertOrThrow(isArray ? queryField.arrayFieldSchema : queryField.fieldSchema) - } - let filter = filter->EntityFilter.mapValues(~mapValue=parseLeaf) - entityDict ->Dict.valuesToArray - ->Array.forEach(entity => { - // Cast entity to dict of field values (same approach as InMemoryTable) + ->Array.filter(entity => { + // The store holds decoded entities and the filter carries decoded values, + // so compare directly (same approach as InMemoryTable) — no JSON round-trip. let entityAsDict = entity->(Utils.magic: Internal.entity => dict) - if filter->EntityFilter.matches(~entity=entityAsDict) { - // Serialize entity back to JSON for worker thread - let jsonEntity = entity->S.reverseConvertToJsonOrThrow(entityConfig.schema) - results->Array.push(jsonEntity)->ignore - } + filter->EntityFilter.matches(~entity=entityAsDict) }) - - results->JSON.Encode.array } } let handleWriteBatch = ( state: testIndexerState, - ~updatedEntities: array, + ~updatedEntities: array, ~checkpointIds: array, ~checkpointChainIds: array, ~checkpointBlockNumbers: array, @@ -121,7 +104,8 @@ let handleWriteBatch = ( // checkpointId -> entityName -> entityChange let changesByCheckpoint: dict> = Dict.make() - updatedEntities->Array.forEach(({entityName, changes}) => { + updatedEntities->Array.forEach(({entityConfig, changes}: Persistence.updatedEntity) => { + let entityName = entityConfig.name let entityDict = switch state.entities->Dict.get(entityName) { | Some(dict) => dict | None => @@ -129,57 +113,35 @@ let handleWriteBatch = ( state.entities->Dict.set(entityName, dict) dict } - let entityConfig = state.entityConfigs->Dict.getUnsafe(entityName) - let processChange = (change: TestIndexerProxyStorage.serializableChange) => { + let entityChangeFor = checkpointId => { + let checkpointKey = checkpointId->BigInt.toString + let entityChanges = switch changesByCheckpoint->Dict.get(checkpointKey) { + | Some(changes) => changes + | None => + let changes = Dict.make() + changesByCheckpoint->Dict.set(checkpointKey, changes) + changes + } + switch entityChanges->Dict.get(entityName) { + | Some(change) => change + | None => + let change = {sets: [], deleted: []} + entityChanges->Dict.set(entityName, change) + change + } + } + + let processChange = (change: Change.t) => { switch change { | Set({entityId, entity, checkpointId}) => - // Parse entity immediately to store decoded values for proper comparisons - // (bigint/BigDecimal need actual values, not JSON strings) - let parsedEntity = entity->S.parseOrThrow(entityConfig.schema) - - // Update entities dict with parsed entity for load operations - entityDict->Dict.set(entityId, parsedEntity) - - // Track change by checkpoint - let checkpointKey = checkpointId->BigInt.toString - let entityChanges = switch changesByCheckpoint->Dict.get(checkpointKey) { - | Some(changes) => changes - | None => - let changes = Dict.make() - changesByCheckpoint->Dict.set(checkpointKey, changes) - changes - } - let entityChange = switch entityChanges->Dict.get(entityName) { - | Some(change) => change - | None => - let change = {sets: [], deleted: []} - entityChanges->Dict.set(entityName, change) - change - } - entityChange.sets->Array.push(parsedEntity->Utils.magic)->ignore - + // The store keeps decoded entities so load comparisons (bigint / + // BigDecimal) work on real values. + entityDict->Dict.set(entityId, entity) + entityChangeFor(checkpointId).sets->Array.push(entity->Utils.magic)->ignore | Delete({entityId, checkpointId}) => - // Update entities dict for load operations Dict.delete(entityDict->Obj.magic, entityId) - - // Track change by checkpoint - let checkpointKey = checkpointId->BigInt.toString - let entityChanges = switch changesByCheckpoint->Dict.get(checkpointKey) { - | Some(changes) => changes - | None => - let changes = Dict.make() - changesByCheckpoint->Dict.set(checkpointKey, changes) - changes - } - let entityChange = switch entityChanges->Dict.get(entityName) { - | Some(change) => change - | None => - let change = {sets: [], deleted: []} - entityChanges->Dict.set(entityName, change) - change - } - entityChange.deleted->Array.push(entityId)->ignore + entityChangeFor(checkpointId).deleted->Array.push(entityId)->ignore } } @@ -285,8 +247,6 @@ let makeInitialState = ( chains, checkpointId: InternalTable.Checkpoints.initialCheckpointId, reorgCheckpoints: [], - // TestIndexer fakes the resume path; mirror what Main.start passes as - // ~envioInfo so the compat check always sees an empty diff. envioInfo: Some(Config.getPublicConfigJson()->Config.stripSensitiveData), } } @@ -406,6 +366,18 @@ let parseBlockRange = ( {startBlock, endBlock} } +// The store owns its entities. Copy on the boundary with user code — both when +// handing one out (get/getAll/getOrThrow) and when taking one in (set) — so a +// user mutating a returned entity, or an object they passed to `set`, can't +// corrupt the in-memory store. The copy is shallow (matching InMemoryTable): +// scalar fields (string/bigint/BigDecimal) are immutable, but array-valued +// fields still share the backing array, so in-place mutation of those leaks. +let copyEntity = (entity: Internal.entity): Internal.entity => + entity + ->(Utils.magic: Internal.entity => dict) + ->Utils.Dict.shallowCopy + ->(Utils.magic: dict => Internal.entity) + // Entity operations for direct manipulation outside of handlers let getEntityFromState = ( ~state: testIndexerState, @@ -419,7 +391,7 @@ let getEntityFromState = ( ) } let entityDict = state.entities->Dict.get(entityConfig.name)->Option.getOr(Dict.make()) - entityDict->Dict.get(entityId) + entityDict->Dict.get(entityId)->Option.map(copyEntity) } let makeEntityGet = (~state: testIndexerState, ~entityConfig: Internal.entityConfig): ( @@ -462,7 +434,7 @@ let makeEntitySet = (~state: testIndexerState, ~entityConfig: Internal.entityCon state.entities->Dict.set(entityConfig.name, dict) dict } - entityDict->Dict.set(entity.id, entity) + entityDict->Dict.set(entity.id, copyEntity(entity)) } } @@ -476,7 +448,7 @@ let makeEntityGetAll = (~state: testIndexerState, ~entityConfig: Internal.entity ) } let entityDict = state.entities->Dict.get(entityConfig.name)->Option.getOr(Dict.make()) - Promise.resolve(entityDict->Dict.valuesToArray) + Promise.resolve(entityDict->Dict.valuesToArray->Array.map(copyEntity)) } } @@ -487,411 +459,432 @@ type entityOperations = { set: Internal.entity => unit, } -type workerData = { - chainId: int, - startBlock: int, - endBlock: option, - simulate: option>, - initialState: Persistence.initialState, +// Adapt the real storage interface to the in-memory entity store. In-process +// there's no worker boundary, so entities are stored and loaded decoded — no +// JSON serialization round-trip. +let makeInMemoryStorage = (~state: testIndexerState): Persistence.storage => { + name: "test-inmemory", + isInitialized: async () => true, + // The runner injects the config-derived initial state by setting + // `persistence.storageStatus = Ready(...)` directly, bypassing `Persistence.init`, + // so neither of these is reached. + initialize: async (~chainConfigs as _=?, ~entities as _=?, ~enums as _=?, ~envioInfo as _) => + JsError.throwWithMessage( + "TestIndexer: initialize should not be called; the initial state is derived from config.", + ), + resumeInitialState: async () => + JsError.throwWithMessage( + "TestIndexer: resumeInitialState should not be called; the initial state is derived from config.", + ), + loadOrThrow: async (~filter, ~table: Table.table) => + state + ->handleLoad(~tableName=table.tableName, ~filter) + ->(Utils.magic: array => array), + writeBatch: async ( + ~batch, + ~rollback as _, + ~isInReorgThreshold as _, + ~config as _, + ~allEntities as _, + ~updatedEffectsCache as _, + ~updatedEntities, + ~chainMetaData as _, + ~onWrite as _, + ) => + state->handleWriteBatch( + ~updatedEntities, + ~checkpointIds=batch.checkpointIds, + ~checkpointChainIds=batch.checkpointChainIds, + ~checkpointBlockNumbers=batch.checkpointBlockNumbers, + ~checkpointEventsProcessed=batch.checkpointEventsProcessed, + ), + dumpEffectCache: async () => (), + reset: async () => (), + setChainMeta: async _ => Obj.magic(), + pruneStaleCheckpoints: async (~safeCheckpointId as _) => (), + pruneStaleEntityHistory: async (~entityName as _, ~entityIndex as _, ~safeCheckpointId as _) => + (), + getRollbackTargetCheckpoint: async (~reorgChainId as _, ~lastKnownValidBlockNumber as _) => + JsError.throwWithMessage( + "TestIndexer: Rollback is not supported. The runner forces rollbackOnReorg off, so this should be unreachable.", + ), + getRollbackProgressDiff: async (~rollbackTargetCheckpointId as _) => + JsError.throwWithMessage( + "TestIndexer: Rollback is not supported. The runner forces rollbackOnReorg off, so this should be unreachable.", + ), + getRollbackData: async (~entityConfig as _, ~rollbackTargetCheckpointId as _) => + JsError.throwWithMessage( + "TestIndexer: Rollback is not supported. The runner forces rollbackOnReorg off, so this should be unreachable.", + ), + close: async () => (), } -let makeCreateTestIndexer = (~config: Config.t, ~workerPath: string): ( - unit => t<'processConfig> -) => { - () => { - let allEntities = config.allEntities - let entities = Dict.make() - let entityConfigs = Dict.make() - allEntities->Array.forEach(entityConfig => { - entities->Dict.set(entityConfig.name, Dict.make()) - entityConfigs->Dict.set(entityConfig.name, entityConfig) - }) +// Copy the per-chain registration arrays so a process() run's simulate-source +// additions (SimulateItems.patchConfig pushes onEventRegistrations) never +// mutate the shared base registration — lets independent createTestIndexer runs +// proceed in parallel without clobbering each other's registrations. +let cloneRegistrations = ( + base: HandlerRegister.registrationsByChainId, +): HandlerRegister.registrationsByChainId => { + let clone = Dict.make() + base + ->Dict.toArray + ->Array.forEach(((chainIdStr, chainRegistrations: HandlerRegister.chainRegistrations)) => + clone->Dict.set( + chainIdStr, + { + HandlerRegister.onEventRegistrations: chainRegistrations.onEventRegistrations->Array.copy, + onBlockRegistrations: chainRegistrations.onBlockRegistrations->Array.copy, + }, + ) + ) + clone +} - // Populate config addresses into the entity dict, mirroring PgStorage.initialize - let envioAddressesDict = entities->Dict.getUnsafe(InternalTable.EnvioAddresses.name) - config.chainMap - ->ChainMap.values - ->Array.forEach(chainConfig => { - chainConfig.contracts->Array.forEach(contract => { - contract.addresses->Array.forEach( - address => { - let entity: InternalTable.EnvioAddresses.t = { - id: Config.EnvioAddresses.makeId(~chainId=chainConfig.id, ~address), - chainId: chainConfig.id, - contractName: contract.name, - registrationBlock: -1, - registrationLogIndex: -1, - } - envioAddressesDict->Dict.set(entity.id, entity->Config.EnvioAddresses.castToInternal) - }, - ) - }) - }) +// User handlers register into the process-global HandlerRegister as an import +// side effect. Capture the resolved registrations once per process (imports are +// module-cached anyway) and reuse them across every createTestIndexer run, so +// the global registration cycle runs a single time and never races. +let registrationsRef: ref>> = ref(None) +let getRegistrations = (~config) => + switch registrationsRef.contents { + | Some(promise) => promise + | None => + let promise = HandlerLoader.registerAllHandlers(~config) + registrationsRef := Some(promise) + promise + } - let state = { - processInProgress: false, - progressBlockByChain: Dict.make(), - entities, - entityConfigs, - processChanges: [], - } +let createTestIndexer = (): t<'processConfig> => { + let config = Config.load() + let allEntities = config.allEntities + let entities = Dict.make() + let entityConfigs = Dict.make() + allEntities->Array.forEach(entityConfig => { + entities->Dict.set(entityConfig.name, Dict.make()) + entityConfigs->Dict.set(entityConfig.name, entityConfig) + }) - // Build entity operations for each user entity - let entityOpsDict: dict = Dict.make() - allEntities->Array.forEach(entityConfig => { - // Only create ops for user entities (not internal tables like envio_addresses) - if entityConfig.name !== InternalTable.EnvioAddresses.name { - entityOpsDict->Dict.set( - entityConfig.name, - { - get: makeEntityGet(~state, ~entityConfig), - getAll: makeEntityGetAll(~state, ~entityConfig), - getOrThrow: makeEntityGetOrThrow(~state, ~entityConfig), - set: makeEntitySet(~state, ~entityConfig), - }, - ) - } + // Populate config addresses into the entity dict, mirroring PgStorage.initialize + let envioAddressesDict = entities->Dict.getUnsafe(InternalTable.EnvioAddresses.name) + config.chainMap + ->ChainMap.values + ->Array.forEach(chainConfig => { + chainConfig.contracts->Array.forEach(contract => { + contract.addresses->Array.forEach( + address => { + let entity: InternalTable.EnvioAddresses.t = { + id: Config.EnvioAddresses.makeId(~chainId=chainConfig.id, ~address), + chainId: chainConfig.id, + contractName: contract.name, + registrationBlock: -1, + registrationLogIndex: -1, + } + envioAddressesDict->Dict.set(entity.id, entity->Config.EnvioAddresses.castToInternal) + }, + ) }) + }) - // Build chain info from config (similar to Main.getGlobalIndexer but static) - let chainIds = [] - let chains = Utils.Object.createNullObject() - config.chainMap - ->ChainMap.values - ->Array.forEach(chainConfig => { - let chainIdStr = chainConfig.id->Int.toString - chainIds->Array.push(chainConfig.id)->ignore + let state = { + processInProgress: false, + progressBlockByChain: Dict.make(), + entities, + entityConfigs, + processChanges: [], + } - let chainObj = Utils.Object.createNullObject() - chainObj - ->Utils.Object.definePropertyWithValue("id", {enumerable: true, value: chainConfig.id}) - ->Utils.Object.definePropertyWithValue( - "startBlock", - {enumerable: true, value: chainConfig.startBlock}, + // Per-instance in-memory storage over `state.entities`. Separate indexers + // get separate storages, so independent indexers run in parallel without + // shared mutable state. + let storage = makeInMemoryStorage(~state) + let persistence = Persistence.make( + ~userEntities=config.userEntities, + ~allEnums=config.allEnums, + ~storage, + ) + + // Silence logs by default in test mode unless LOG_LEVEL is explicitly set. + switch Env.userLogLevel { + | None => Logging.setLogLevel(#silent) + | Some(_) => () + } + + // Build entity operations for each user entity + let entityOpsDict: dict = Dict.make() + allEntities->Array.forEach(entityConfig => { + // Only create ops for user entities (not internal tables like envio_addresses) + if entityConfig.name !== InternalTable.EnvioAddresses.name { + entityOpsDict->Dict.set( + entityConfig.name, + { + get: makeEntityGet(~state, ~entityConfig), + getAll: makeEntityGetAll(~state, ~entityConfig), + getOrThrow: makeEntityGetOrThrow(~state, ~entityConfig), + set: makeEntitySet(~state, ~entityConfig), + }, ) - ->Utils.Object.definePropertyWithValue( - "endBlock", - {enumerable: true, value: chainConfig.endBlock}, + } + }) + + // Build chain info from config (similar to Main.getGlobalIndexer but static) + let chainIds = [] + let chains = Utils.Object.createNullObject() + config.chainMap + ->ChainMap.values + ->Array.forEach(chainConfig => { + let chainIdStr = chainConfig.id->Int.toString + chainIds->Array.push(chainConfig.id)->ignore + + let chainObj = Utils.Object.createNullObject() + chainObj + ->Utils.Object.definePropertyWithValue("id", {enumerable: true, value: chainConfig.id}) + ->Utils.Object.definePropertyWithValue( + "startBlock", + {enumerable: true, value: chainConfig.startBlock}, + ) + ->Utils.Object.definePropertyWithValue( + "endBlock", + {enumerable: true, value: chainConfig.endBlock}, + ) + ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: chainConfig.name}) + ->Utils.Object.definePropertyWithValue("isRealtime", {enumerable: true, value: false}) + ->ignore + + // Add contracts to chain object + chainConfig.contracts->Array.forEach(contract => { + let contractObj = Utils.Object.createNullObject() + contractObj + ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: contract.name}) + ->Utils.Object.definePropertyWithValue("abi", {enumerable: true, value: contract.abi}) + ->Utils.Object.defineProperty( + "addresses", + { + enumerable: true, + get: () => { + if state.processInProgress { + JsError.throwWithMessage( + `Cannot access ${contract.name}.addresses while indexer.process() is running. ` ++ "Wait for process() to complete before reading contract addresses.", + ) + } + getIndexingAddressesByChain(state) + ->Dict.get(chainConfig.id->Int.toString) + ->Option.getOr([]) + ->Array.filterMap(ia => ia.contractName === contract.name ? Some(ia.address) : None) + }, + }, ) - ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: chainConfig.name}) - ->Utils.Object.definePropertyWithValue("isRealtime", {enumerable: true, value: false}) ->ignore - // Add contracts to chain object - chainConfig.contracts->Array.forEach(contract => { - let contractObj = Utils.Object.createNullObject() - contractObj - ->Utils.Object.definePropertyWithValue("name", {enumerable: true, value: contract.name}) - ->Utils.Object.definePropertyWithValue("abi", {enumerable: true, value: contract.abi}) - ->Utils.Object.defineProperty( - "addresses", - { - enumerable: true, - get: () => { - if state.processInProgress { - JsError.throwWithMessage( - `Cannot access ${contract.name}.addresses while indexer.process() is running. ` ++ "Wait for process() to complete before reading contract addresses.", - ) - } - getIndexingAddressesByChain(state) - ->Dict.get(chainConfig.id->Int.toString) - ->Option.getOr([]) - ->Array.filterMap(ia => ia.contractName === contract.name ? Some(ia.address) : None) - }, - }, - ) - ->ignore - - chainObj - ->Utils.Object.definePropertyWithValue( - contract.name, - {enumerable: true, value: contractObj}, - ) - ->ignore - }) + chainObj + ->Utils.Object.definePropertyWithValue(contract.name, {enumerable: true, value: contractObj}) + ->ignore + }) + chains + ->Utils.Object.definePropertyWithValue(chainIdStr, {enumerable: true, value: chainObj}) + ->ignore + + if chainConfig.name !== chainIdStr { chains - ->Utils.Object.definePropertyWithValue(chainIdStr, {enumerable: true, value: chainObj}) + ->Utils.Object.definePropertyWithValue(chainConfig.name, {enumerable: false, value: chainObj}) ->ignore + } + }) - if chainConfig.name !== chainIdStr { - chains - ->Utils.Object.definePropertyWithValue( - chainConfig.name, - {enumerable: false, value: chainObj}, - ) - ->ignore - } - }) + // Build the result object with process + entity operations + chain info + let result: dict = Dict.make() + result->Dict.set("chainIds", chainIds->(Utils.magic: array => unknown)) + result->Dict.set("chains", chains->(Utils.magic: {..} => unknown)) + entityOpsDict + ->Dict.toArray + ->Array.forEach(((name, ops)) => { + result->Dict.set(name, ops->(Utils.magic: entityOperations => unknown)) + }) - // Build the result object with process + entity operations + chain info - let result: dict = Dict.make() - result->Dict.set("chainIds", chainIds->(Utils.magic: array => unknown)) - result->Dict.set("chains", chains->(Utils.magic: {..} => unknown)) - entityOpsDict - ->Dict.toArray - ->Array.forEach(((name, ops)) => { - result->Dict.set(name, ops->(Utils.magic: entityOperations => unknown)) - }) + result->Dict.set( + "process", + ( + processConfig => { + // Check if already processing + if state.processInProgress { + JsError.throwWithMessage( + "createTestIndexer process is already running. Only one process call is allowed at a time", + ) + } - result->Dict.set( - "process", - ( - processConfig => { - // Check if already processing - if state.processInProgress { - JsError.throwWithMessage( - "createTestIndexer process is already running. Only one process call is allowed at a time", - ) - } + // Parse and validate processConfig + let parsedConfig = try processConfig->S.parseOrThrow(processConfigSchema) catch { + | S.Raised(exn) => + JsError.throwWithMessage( + `Invalid processConfig: ${exn->Utils.prettifyExn->(Utils.magic: exn => string)}`, + ) + } + let rawChains = parsedConfig["chains"] + let chainKeys = rawChains->Dict.keysToArray - // Parse and validate processConfig - let parsedConfig = try processConfig->S.parseOrThrow(processConfigSchema) catch { - | S.Raised(exn) => + if chainKeys->Array.length === 0 { + JsError.throwWithMessage("createTestIndexer requires at least one chain to be defined") + } + + // Sort chain keys by chain ID for deterministic ordering + let sortedChainKeys = chainKeys->Array.copy + sortedChainKeys->Array.sort((a, b) => { + let aId = a->Int.fromString->Option.getOr(0) + let bId = b->Int.fromString->Option.getOr(0) + Int.compare(aId, bId) + }) + + // Parse and validate the block ranges upfront before running any chain. + let chainEntries = sortedChainKeys->Array.map(chainIdStr => { + let rawChainConfig = rawChains->Dict.getUnsafe(chainIdStr) + if chainIdStr->Int.fromString->Option.isNone { JsError.throwWithMessage( - `Invalid processConfig: ${exn->Utils.prettifyExn->(Utils.magic: exn => string)}`, + `Invalid chain ID "${chainIdStr}": expected a numeric chain ID`, ) } - let rawChains = parsedConfig["chains"] - let chainKeys = rawChains->Dict.keysToArray - - if chainKeys->Array.length === 0 { - JsError.throwWithMessage("createTestIndexer requires at least one chain to be defined") + let processChainConfig = parseBlockRange( + ~chainIdStr, + ~config, + ~rawChainConfig, + ~progressBlock=state.progressBlockByChain->Dict.get(chainIdStr), + ) + (chainIdStr, rawChainConfig, processChainConfig) + }) + + // Reset processChanges for this run + state.processChanges = [] + + let runChainInProcess = async (( + chainIdStr, + rawChainConfig: rawChainConfig, + processChainConfig, + )) => { + // Build initialState from resolved block range. Rebuilt per chain so + // later chains in the same process() call see contracts registered + // by earlier ones. + let chains: dict = Dict.make() + chains->Dict.set(chainIdStr, processChainConfig) + let indexingAddressesByChain = getIndexingAddressesByChain(state) + let initialState = makeInitialState( + ~config, + ~processConfigChains=chains, + ~indexingAddressesByChain, + ) + + // No endBlock means auto-exit mode: process one block checkpoint at a + // time and stop after the first block with events. + let exitAfterFirstEventBlock = processChainConfig.endBlock->Option.isNone + + // Rebuild the processConfig JSON that SimulateItems.patchConfig reads + // to turn `simulate` items into a SimulateSource for the chain. + let resolvedChainDict: dict = Dict.make() + resolvedChainDict->Dict.set( + "startBlock", + processChainConfig.startBlock->(Utils.magic: int => unknown), + ) + switch processChainConfig.endBlock { + | Some(eb) => resolvedChainDict->Dict.set("endBlock", eb->(Utils.magic: int => unknown)) + | None => () } - - // Sort chain keys by chain ID for deterministic ordering - let sortedChainKeys = chainKeys->Array.copy - sortedChainKeys->Array.sort((a, b) => { - let aId = a->Int.fromString->Option.getOr(0) - let bId = b->Int.fromString->Option.getOr(0) - Int.compare(aId, bId) - }) - - // Parse and validate the block ranges upfront before starting any workers. - let chainEntries = sortedChainKeys->Array.map(chainIdStr => { - let rawChainConfig = rawChains->Dict.getUnsafe(chainIdStr) - let chainId = switch chainIdStr->Int.fromString { - | Some(id) => id - | None => - JsError.throwWithMessage( - `Invalid chain ID "${chainIdStr}": expected a numeric chain ID`, - ) - } - let processChainConfig = parseBlockRange( - ~chainIdStr, - ~config, - ~rawChainConfig, - ~progressBlock=state.progressBlockByChain->Dict.get(chainIdStr), - ) - (chainIdStr, chainId, rawChainConfig, processChainConfig) - }) - - // Reset processChanges for this run - state.processChanges = [] - - let runChainWorker = (( + switch rawChainConfig.simulate { + | Some(s) => + resolvedChainDict->Dict.set("simulate", s->(Utils.magic: array => unknown)) + | None => () + } + let resolvedChainsDict: dict = Dict.make() + resolvedChainsDict->Dict.set( chainIdStr, - chainId, - rawChainConfig: rawChainConfig, - processChainConfig, - )) => { - // Build initialState from resolved block range - let chains: dict = Dict.make() - chains->Dict.set(chainIdStr, processChainConfig) - - // Rebuilt per chain so workers see contracts registered by earlier - // chains in the same process() call. - let indexingAddressesByChain = getIndexingAddressesByChain(state) - - let initialState = makeInitialState( - ~config, - ~processConfigChains=chains, - ~indexingAddressesByChain, - ) - - Promise.make((resolve, reject) => { - let workerData: workerData = { - chainId, - startBlock: processChainConfig.startBlock, - endBlock: processChainConfig.endBlock, - simulate: rawChainConfig.simulate, - initialState, - } - let worker = try { - NodeJs.WorkerThreads.makeWorker( - workerPath, - { - workerData: workerData->(Utils.magic: workerData => JSON.t), - // Explicitly forward parent env so handlers running in - // the worker observe the same environment as the test - // process (e.g. E2E_EXPECTED_END_BLOCK). - env: %raw(`process.env`), - }, - ) - } catch { - | exn => - reject(exn->Utils.magic) - throw(exn) - } - - // Handle messages from worker - worker->NodeJs.WorkerThreads.onMessage(( - msg: TestIndexerProxyStorage.workerMessage, - ) => { - let respond = data => - worker->NodeJs.WorkerThreads.workerPostMessage( - { - TestIndexerProxyStorage.id: msg.id, - payload: TestIndexerProxyStorage.Response({data: data}), - }->Utils.magic, - ) - - switch msg.payload { - | Load({tableName, filter}) => state->handleLoad(~tableName, ~filter)->respond - - | WriteBatch({ - updatedEntities, - checkpointIds, - checkpointChainIds, - checkpointBlockNumbers, - checkpointBlockHashes: _, - checkpointEventsProcessed, - }) => - state->handleWriteBatch( - ~updatedEntities, - ~checkpointIds, - ~checkpointChainIds, - ~checkpointBlockNumbers, - ~checkpointEventsProcessed, - ) - JSON.Encode.null->respond - } - }) - - worker->NodeJs.WorkerThreads.onError(err => { - worker->NodeJs.WorkerThreads.terminate->ignore - reject(err) - }) - - worker->NodeJs.WorkerThreads.onExit(code => { - if code !== 0 { - reject(Utils.Error.make(`Worker exited with code ${code->Int.toString}`)) - } else { - resolve() + resolvedChainDict->(Utils.magic: dict => unknown), + ) + let processConfigJson = + {"chains": resolvedChainsDict}->(Utils.magic: {"chains": dict} => JSON.t) + + // Each run gets its own copy of the shared base registration so the + // simulate-source registration it appends stays isolated. + let registrationsByChainId = cloneRegistrations(await getRegistrations(~config)) + let patchedConfig: Config.t = SimulateItems.patchConfig( + ~config, + ~processConfig=processConfigJson, + ~registrationsByChainId, + ) + let runConfig = {...patchedConfig, shouldRollbackOnReorg: false} + let runConfig = exitAfterFirstEventBlock ? {...runConfig, batchSize: 1} : runConfig + + // Bypass Persistence.init: hand the loop the config-derived initial + // state directly (never a real DB) and mark the storage Ready so + // writes go through. + persistence.storageStatus = Ready(initialState) + + let indexerStateRef = ref(None) + // Stop the loop and let any in-flight processing/write settle, so a + // finished run leaves nothing driving the shared state into the next. + let cleanup = async () => { + switch indexerStateRef.contents { + | Some(indexerState) => + indexerState->IndexerState.stop + while ( + indexerState->IndexerState.isProcessing || + indexerState->IndexerState.writeFiber->Option.isSome + ) { + switch indexerState->IndexerState.writeFiber { + // Await the in-flight write directly; only fall back to a tick + // yield while processing hasn't yet spawned a write fiber. + | Some(fiber) => await fiber + | None => await Utils.delay(0) } - }) - }) + } + | None => () + } } - - // Set flag before starting workers - state.processInProgress = true - - // Run worker threads sequentially, one chain at a time - let rec runChains = idx => { - if idx >= chainEntries->Array.length { - state.processInProgress = false - Promise.resolve({changes: state.processChanges}) - } else { - runChainWorker(chainEntries->Array.getUnsafe(idx))->Promise.then(_ => - runChains(idx + 1) + try { + await Promise.make((resolve, reject) => { + let indexerState = IndexerState.makeFromDbState( + ~config=runConfig, + ~persistence, + ~initialState, + ~registrationsByChainId, + ~exitAfterFirstEventBlock, + ~onError=errHandler => { + errHandler->ErrorHandling.log + reject(errHandler.exn->Utils.prettifyExn) + }, + // Caught up: resolve the run instead of exiting the process. + ~onExit=() => resolve(), ) - } + indexerStateRef := Some(indexerState) + indexerState->IndexerLoop.start + }) + await cleanup() + } catch { + | exn => + await cleanup() + throw(exn) } - - runChains(0)->Promise.catch(err => { - state.processInProgress = false - Promise.reject(err->Utils.prettifyExn) - }) } - )->(Utils.magic: ('a => promise) => unknown), - ) - - result->(Utils.magic: dict => t<'processConfig>) - } -} - -let initTestWorker = () => { - if NodeJs.WorkerThreads.isMainThread { - JsError.throwWithMessage("initTestWorker must be called from a worker thread") - } - - let parentPort = switch NodeJs.WorkerThreads.parentPort->Nullable.toOption { - | Some(port) => port - | None => JsError.throwWithMessage("initTestWorker: No parent port available") - } - - let workerData: option = NodeJs.WorkerThreads.workerData->Nullable.toOption - switch workerData { - | Some({chainId, startBlock, endBlock, simulate, initialState}) => - let chainIdStr = chainId->Int.toString - - // auto-exit mode: no endBlock means fetch first block with events and exit - let exitAfterFirstEventBlock = endBlock->Option.isNone - - // Build processConfig JSON for SimulateItems.patchConfig - let resolvedChainDict: dict = Dict.make() - resolvedChainDict->Dict.set("startBlock", startBlock->(Utils.magic: int => unknown)) - switch endBlock { - | Some(eb) => resolvedChainDict->Dict.set("endBlock", eb->(Utils.magic: int => unknown)) - | None => () - } - switch simulate { - | Some(s) => resolvedChainDict->Dict.set("simulate", s->(Utils.magic: array => unknown)) - | None => () - } - let resolvedChainsDict: dict = Dict.make() - resolvedChainsDict->Dict.set( - chainIdStr, - resolvedChainDict->(Utils.magic: dict => unknown), - ) - let processConfig = - {"chains": resolvedChainsDict}->(Utils.magic: {"chains": dict} => JSON.t) - - // Create proxy storage that communicates with main thread - let proxy = TestIndexerProxyStorage.make(~parentPort, ~initialState) - let storage = TestIndexerProxyStorage.makeStorage(proxy) - let config = Config.load() - let persistence = Persistence.make( - ~userEntities=config.userEntities, - ~allEnums=config.allEnums, - ~storage, - ) - // Silence logs by default in test mode unless LOG_LEVEL is explicitly set - switch Env.userLogLevel { - | None => Logging.setLogLevel(#silent) - | Some(_) => () - } + // Set flag before starting the run + state.processInProgress = true - let patchConfig = (config: Config.t, registrationsByChainId) => { - let config = SimulateItems.patchConfig(~config, ~processConfig, ~registrationsByChainId) + // Run chains sequentially, one at a time + let rec runChains = idx => { + if idx >= chainEntries->Array.length { + state.processInProgress = false + Promise.resolve({changes: state.processChanges}) + } else { + runChainInProcess(chainEntries->Array.getUnsafe(idx))->Promise.then(_ => + runChains(idx + 1) + ) + } + } - // In auto-exit mode, set batchSize=1 to process one block checkpoint at a time - if exitAfterFirstEventBlock { - {...config, batchSize: 1} - } else { - config - } - } - Main.start(~persistence, ~isTest=true, ~patchConfig, ~exitAfterFirstEventBlock) - ->Promise.catch(exn => { - // `Main.start` rejects on any fatal error: a runtime failure arrives wrapped - // in `Main.FatalError` (already logged), a setup throw (e.g. an invalid - // simulate item) arrives raw. The parent only learns of failures - // through the worker `error` event, which fires on an *uncaught* exception. - // Throwing synchronously in this catch would just reject the catch's own - // promise (swallowed by `ignore`); `setImmediate` re-throws outside the - // promise chain so it becomes uncaught and reaches the parent. - let toThrow = switch exn { - | Main.FatalError(inner) => inner - | _ => exn->Utils.prettifyExn + runChains(0)->Promise.catch(err => { + state.processInProgress = false + Promise.reject(err->Utils.prettifyExn) + }) } - NodeJs.setImmediate(() => throw(toThrow)) - Promise.resolve() - }) - ->ignore - | None => - Logging.error("TestIndexerWorker: No worker data provided") - NodeJs.process->NodeJs.exitWithCode(Failure) - } + )->(Utils.magic: ('a => promise) => unknown), + ) + + result->(Utils.magic: dict => t<'processConfig>) } diff --git a/packages/envio/src/TestIndexerProxyStorage.res b/packages/envio/src/TestIndexerProxyStorage.res deleted file mode 100644 index 6440e0648..000000000 --- a/packages/envio/src/TestIndexerProxyStorage.res +++ /dev/null @@ -1,196 +0,0 @@ -// Message types for communication between worker and main thread -type requestId = int - -// Serializable change with entity as JSON (for worker thread messaging) -@tag("type") -type serializableChange = - | @as("SET") Set({entityId: string, entity: JSON.t, checkpointId: bigint}) - | @as("DELETE") Delete({entityId: string, checkpointId: bigint}) - -type serializableUpdatedEntity = { - entityName: string, - changes: array, -} - -// Worker -> Main thread payloads -@tag("type") -type workerPayload = - | @as("load") - Load({ - tableName: string, - // Leaf field values are JSON-serialized with the table's field schemas - filter: EntityFilter.t, - }) - | @as("writeBatch") - WriteBatch({ - updatedEntities: array, - checkpointIds: array, - checkpointChainIds: array, - checkpointBlockNumbers: array, - checkpointBlockHashes: array>, - checkpointEventsProcessed: array, - }) - -// Main thread -> Worker payloads -@tag("type") -type mainPayload = - | @as("response") Response({data: JSON.t}) - | @as("error") Error({message: string}) - -// Message wrapper with id -type message<'payload> = {id: requestId, payload: 'payload} -type workerMessage = message -type mainMessage = message - -// Pending request tracker -type pendingRequest = { - resolve: JSON.t => unit, - reject: exn => unit, -} - -type t = { - parentPort: NodeJs.WorkerThreads.messagePort, - initialState: Persistence.initialState, - pendingRequests: dict, - mutable requestCounter: int, -} - -let make = (~parentPort, ~initialState): t => { - let proxy = { - parentPort, - initialState, - pendingRequests: Dict.make(), - requestCounter: 0, - } - - // Set up message listener for responses from main thread - parentPort->NodeJs.WorkerThreads.onPortMessage((msg: mainMessage) => { - let idStr = msg.id->Int.toString - let {resolve, reject} = switch proxy.pendingRequests->Utils.Dict.dangerouslyGetNonOption( - idStr, - ) { - | Some(pending) => pending - | None => JsError.throwWithMessage(`TestIndexer: No pending request found for id ${idStr}`) - } - Dict.delete(proxy.pendingRequests->Obj.magic, idStr) - - switch msg.payload { - | Response({data}) => resolve(data) - | Error({message}) => reject(Utils.Error.make(message)) - } - }) - - proxy -} - -let nextRequestId = (proxy: t): requestId => { - proxy.requestCounter = proxy.requestCounter + 1 - proxy.requestCounter -} - -let sendRequest = (proxy: t, ~payload: workerPayload): promise => { - Promise.make((resolve, reject) => { - let id = proxy->nextRequestId - proxy.pendingRequests->Dict.set(id->Int.toString, {resolve, reject}) - proxy.parentPort->NodeJs.WorkerThreads.postMessage({id, payload}) - }) -} - -let makeStorage = (proxy: t): Persistence.storage => { - name: "test-proxy", - isInitialized: async () => true, - initialize: async (~chainConfigs as _=?, ~entities as _=?, ~enums as _=?, ~envioInfo as _) => { - JsError.throwWithMessage( - "TestIndexer: initialize should not be called. Use resumeInitialState instead.", - ) - }, - resumeInitialState: async () => proxy.initialState, - loadOrThrow: async (~filter, ~table: Table.table) => { - let serializeLeafOrThrow = (~fieldName, ~fieldValue: unknown, ~isArray) => { - let queryField = switch table->Table.queryFields->Dict.get(fieldName) { - | Some(queryField) => queryField - | None => - JsError.throwWithMessage( - `TestIndexer: The table "${table.tableName}" doesn't have the field "${fieldName}"`, - ) - } - fieldValue - ->S.reverseConvertToJsonOrThrow( - isArray ? queryField.arrayFieldSchema : queryField.fieldSchema, - ) - ->(Utils.magic: JSON.t => unknown) - } - let response = await proxy->sendRequest( - ~payload=Load({ - tableName: table.tableName, - // Field values must be JSON-safe to survive the worker thread boundary - filter: filter->EntityFilter.mapValues(~mapValue=serializeLeafOrThrow), - }), - ) - response->S.parseOrThrow(table->Table.rowsSchema) - }, - writeBatch: async ( - ~batch, - ~rollback as _, - ~isInReorgThreshold as _, - ~config as _, - ~allEntities as _, - ~updatedEffectsCache as _, - ~updatedEntities, - ~chainMetaData as _, - ~onWrite as _, - ) => { - // Encode entities to JSON for serialization across worker boundary - let serializableEntities = updatedEntities->Array.map(( - {entityConfig, changes}: Persistence.updatedEntity, - ) => { - let encodeChange = (change: Change.t): serializableChange => { - switch change { - | Set({entityId, entity, checkpointId}) => - Set({ - entityId, - entity: entity->S.reverseConvertToJsonOrThrow(entityConfig.schema), - checkpointId, - }) - | Delete({entityId, checkpointId}) => Delete({entityId, checkpointId}) - } - } - { - entityName: entityConfig.name, - changes: changes->Array.map(encodeChange), - } - }) - let _ = await proxy->sendRequest( - ~payload=WriteBatch({ - updatedEntities: serializableEntities, - checkpointIds: batch.checkpointIds, - checkpointChainIds: batch.checkpointChainIds, - checkpointBlockNumbers: batch.checkpointBlockNumbers, - checkpointBlockHashes: batch.checkpointBlockHashes, - checkpointEventsProcessed: batch.checkpointEventsProcessed, - }), - ) - }, - dumpEffectCache: async () => (), - reset: async () => (), - setChainMeta: async _ => Obj.magic(), - pruneStaleCheckpoints: async (~safeCheckpointId as _) => (), - pruneStaleEntityHistory: async (~entityName as _, ~entityIndex as _, ~safeCheckpointId as _) => - (), - getRollbackTargetCheckpoint: async (~reorgChainId as _, ~lastKnownValidBlockNumber as _) => { - JsError.throwWithMessage( - "TestIndexer: Rollback is not supported. Set rollbackOnReorg to false in config.", - ) - }, - getRollbackProgressDiff: async (~rollbackTargetCheckpointId as _) => { - JsError.throwWithMessage( - "TestIndexer: Rollback is not supported. Set rollbackOnReorg to false in config.", - ) - }, - getRollbackData: async (~entityConfig as _, ~rollbackTargetCheckpointId as _) => { - JsError.throwWithMessage( - "TestIndexer: Rollback is not supported. Set rollbackOnReorg to false in config.", - ) - }, - close: async () => (), -} diff --git a/packages/envio/src/TestIndexerWorker.res b/packages/envio/src/TestIndexerWorker.res deleted file mode 100644 index fb1dee867..000000000 --- a/packages/envio/src/TestIndexerWorker.res +++ /dev/null @@ -1,4 +0,0 @@ -// Spawned by `TestIndexer.makeCreateTestIndexer` as a sibling of `Api.res.mjs` -// so `import.meta.url` resolves inside the envio package. - -TestIndexer.initTestWorker() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82f61f231..0e6ae1731 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,9 @@ importers: rescript: specifier: 12.2.0 version: 12.2.0 + vitest: + specifier: 4.1.0 + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.2)(jsdom@16.7.0)(vite@7.3.1(@types/node@24.12.2)(tsx@4.21.0)) packages/envio-tests: dependencies: diff --git a/scenarios/test_codegen/src/handlers/EventHandlers.ts b/scenarios/test_codegen/src/handlers/EventHandlers.ts index b2f2e7264..9311d2228 100644 --- a/scenarios/test_codegen/src/handlers/EventHandlers.ts +++ b/scenarios/test_codegen/src/handlers/EventHandlers.ts @@ -129,6 +129,40 @@ expectType< > >(true); +// Type-only surface checks for the registration API. Guarded by `if (0)` so +// tsc validates them but they never execute: invalid contract/event combos must +// stay compile errors, and these must not register real handlers. +if (0) { + indexer.onEvent( + // @ts-expect-error - "BadContract" is not a configured contract + { contract: "BadContract", event: "X" }, + async () => {}, + ); + indexer.onEvent( + // @ts-expect-error - "BadEvent" is not an event of Gravatar + { contract: "Gravatar", event: "BadEvent" }, + async () => {}, + ); + indexer.onEvent( + { contract: "Gravatar", event: "NewGravatar" }, + async ({ event }) => { + expectType>(true); + }, + ); + indexer.contractRegister( + { contract: "NftFactory", event: "SimpleNftCreated" }, + async ({ event, context }) => { + expectType< + TypeEqual + >(true); + context.chain.SimpleNft.add(event.params.contractAddress); + context.chain.NftFactory.add(event.params.contractAddress); + // @ts-expect-error - UnknownContract is not configured + context.chain.UnknownContract.add(event.params.contractAddress); + }, + ); +} + const zeroAddress = "0x0000000000000000000000000000000000000000"; indexer.onEvent({ contract: "Gravatar", event: "CustomSelection" }, async ({ event, context }) => { @@ -834,6 +868,10 @@ indexer.onEvent({ contract: "Gravatar", event: "FactoryEvent" }, async ({ event, fail("Should have thrown"); } + case "throwInHandler": { + throw new Error("Error from handler"); + } + // Reproduction for https://github.com/enviodev/hyperindex/issues/1199: // getWhere on a linkedEntity field (db column name `_id`) must // resolve the field schema in the in-memory TestIndexer. Production @@ -918,6 +956,10 @@ indexer.onEvent({ contract: "Gravatar", event: "EmptyEvent" }, async ({ event, c }); }); +// No-op handler so Noop.EmptyEvent (the only event on chain 1) can be processed +// and simulated without writing any entity — used by the multichain ordering test. +indexer.onEvent({ contract: "Noop", event: "EmptyEvent" }, async () => {}); + // Regression test for https://github.com/enviodev/hyperindex/issues/538: // the `contactDetails` param is a Solidity struct (`ContactDetails { name, email }`), // and the handler must see it as a named record so that `.name` / `.email` diff --git a/scenarios/test_codegen/test/EventHandler.test.ts b/scenarios/test_codegen/test/EventHandler.test.ts index bc0df5bd0..617338457 100644 --- a/scenarios/test_codegen/test/EventHandler.test.ts +++ b/scenarios/test_codegen/test/EventHandler.test.ts @@ -460,6 +460,36 @@ describe("Use Envio test framework to test event handlers", () => { assert.deepEqual(users, [existingUser]); }); + it("mutating entities across the set/get boundary doesn't corrupt the store", async () => { + const indexer = createTestIndexer(); + + const original: User = { + id: "0", + address: "existing", + updatesCountOnUserForTesting: 0, + gravatar_id: undefined, + accountType: "USER", + }; + indexer.User.set(original); + + // Mutating the object passed to set, or any entity handed back, must not + // leak into the in-memory store. + Object.assign(original, { address: "mutated-after-set" }); + Object.assign(await indexer.User.getOrThrow("0"), { address: "mutated-after-getOrThrow" }); + Object.assign((await indexer.User.get("0"))!, { address: "mutated-after-get" }); + (await indexer.User.getAll()).forEach((u) => Object.assign(u, { address: "mutated-after-getAll" })); + + assert.deepEqual(await indexer.User.getAll(), [ + { + id: "0", + address: "existing", + updatesCountOnUserForTesting: 0, + gravatar_id: undefined, + accountType: "USER", + }, + ]); + }); + it("entity.getOrThrow throws if entity doesn't exist", async () => { const indexer = createTestIndexer(); const dcAddress = "0x1234567890123456789012345678901234567890"; @@ -832,6 +862,30 @@ describe("Use Envio test framework to test event handlers", () => { ); }); + it("propagates a handler throw to the process() call site with its message", async () => { + const indexer = createTestIndexer(); + const dcAddress = "0x1234567890123456789012345678901234567890"; + + await assert.rejects( + indexer.process({ + chains: { + 1337: { + startBlock: 1, + endBlock: 100, + simulate: [ + { + contract: "Gravatar", + event: "FactoryEvent", + params: { contract: dcAddress, testCase: "throwInHandler" }, + }, + ], + }, + }, + }), + /Error from handler/, + ); + }); + it("Should throw when registering a handler after the indexer has finished initializing", async () => { const indexer = createTestIndexer(); const dcAddress = "0x1234567890123456789012345678901234567890"; @@ -976,9 +1030,9 @@ describe("Use Envio test framework to test event handlers", () => { 1: { startBlock: 1, endBlock: 100, - simulate: [ - { contract: "Noop", event: "EmptyEvent" }, - ], + // Noop.EmptyEvent is the only event on chain 1; it has a no-op handler + // so the chain processes an event without writing any entity. + simulate: [{ contract: "Noop", event: "EmptyEvent" }], }, }, }); @@ -1012,6 +1066,30 @@ describe("Use Envio test framework to test event handlers", () => { }); }); + // Simulating an event that has no registered handler is almost always a + // mistake (a typo'd event, or forgetting to write the handler), so it fails + // loudly rather than silently processing nothing. TestEventWithReservedKeyword + // is defined on Gravatar (chain 1337) but has no handler. + it("throws when simulating an event with no registered handler", async () => { + const indexer = createTestIndexer(); + + await assert.rejects( + () => + indexer.process({ + chains: { + 1337: { + startBlock: 1, + endBlock: 100, + simulate: [ + { contract: "Gravatar", event: "TestEventWithReservedKeyword" }, + ], + }, + }, + }), + /no handler runs for event "TestEventWithReservedKeyword" on contract "Gravatar"/ + ); + }); + // Regression for https://github.com/enviodev/hyperindex/issues/538: a struct // event param must reach the handler as a named record. If the runtime still // delivered it as a positional tuple, the handler would blow up on @@ -1692,44 +1770,10 @@ describe("onEvent / contractRegister types", () => { type _userOnCr = EvmContractRegisterContext["User"]; }); - it("indexer.onEvent rejects invalid contract/event combinations", () => { - indexer.onEvent( - // @ts-expect-error - "BadContract" is not a configured contract - { contract: "BadContract", event: "X" }, - async () => {}, - ); - - indexer.onEvent( - // @ts-expect-error - "BadEvent" is not an event of Gravatar - { contract: "Gravatar", event: "BadEvent" }, - async () => {}, - ); - - // Valid combination should compile (no @ts-expect-error) - indexer.onEvent( - { contract: "Gravatar", event: "NewGravatar" }, - async ({ event }) => { - expectType>(true); - }, - ); - }); - - it("indexer.contractRegister exposes context.chain.ContractName.add()", () => { - indexer.contractRegister( - { contract: "NftFactory", event: "SimpleNftCreated" }, - async ({ event, context }) => { - // event is typed - expectType< - TypeEqual - >(true); - // chain.ContractName.add(address) is available - context.chain.SimpleNft.add(event.params.contractAddress); - context.chain.NftFactory.add(event.params.contractAddress); - // @ts-expect-error - UnknownContract is not configured - context.chain.UnknownContract.add(event.params.contractAddress); - }, - ); - }); + // `indexer.onEvent` / `indexer.contractRegister` type-surface checks live in + // `src/handlers/EventHandlers.ts` (compile-time only): the registration API + // throws once handlers are registered, so they can't run as post-registration + // test cases here. it("EvmOnEventHandler defaults to union of all events", () => { // Without args, the handler accepts the union of all EVM events From ea3e7af31fc47a68f30efd670ccc008f538acbb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 15:06:46 +0000 Subject: [PATCH 15/16] Point merged tests at InternalTestIndexer.fromUserApi The merged main renamed the config helper (`MockIndexerConfig` -> `InternalTestIndexer.fromUserApi`); update the multi-registration and duplicate-event tests accordingly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- packages/envio-tests/test/HandlerRegister_test.res | 8 ++++---- packages/envio-tests/test/UserApiValidation_test.res | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/envio-tests/test/HandlerRegister_test.res b/packages/envio-tests/test/HandlerRegister_test.res index 089e2e984..9b71e9031 100644 --- a/packages/envio-tests/test/HandlerRegister_test.res +++ b/packages/envio-tests/test/HandlerRegister_test.res @@ -5,7 +5,7 @@ open Vitest // into a handler registration (either order) when their filters match, and // unlimited wildcard registrations are allowed. -let config = MockIndexerConfig.parseYaml(` +let config = InternalTestIndexer.fromUserApi(~configYaml=` name: handler-register-test contracts: - name: ERC20 @@ -30,7 +30,7 @@ chains: // Single chain 137 (narrowing target). Used with `configMultichain` to verify // that one registration resolved for the full chain set narrows to each chain. -let config137 = MockIndexerConfig.parseYaml(` +let config137 = InternalTestIndexer.fromUserApi(~configYaml=` name: handler-register-test-137 contracts: - name: ERC20 @@ -51,7 +51,7 @@ chains: // The full chain set (1 + 137). Handlers register against this; a // `finishRegistration` for either `config` (chain 1) or `config137` (chain 137) // narrows to that chain (mirrors MockIndexer registering once, narrowing per run). -let configMultichain = MockIndexerConfig.parseYaml(` +let configMultichain = InternalTestIndexer.fromUserApi(~configYaml=` name: handler-register-multichain contracts: - name: ERC20 @@ -80,7 +80,7 @@ chains: // raw_events enabled, single chain. Used to verify that a `where: false` event // is excluded entirely while a handler-less event still gets a bare raw-events // registration. -let configWithRawEvents = MockIndexerConfig.parseYaml(` +let configWithRawEvents = InternalTestIndexer.fromUserApi(~configYaml=` name: handler-register-raw-events raw_events: true contracts: diff --git a/packages/envio-tests/test/UserApiValidation_test.res b/packages/envio-tests/test/UserApiValidation_test.res index 56c43bf2a..1c201f9da 100644 --- a/packages/envio-tests/test/UserApiValidation_test.res +++ b/packages/envio-tests/test/UserApiValidation_test.res @@ -867,7 +867,7 @@ describe("config YAML success cases", () => { it("allows distinct events on one contract and the same event across contracts", t => { // Distinct signatures on one contract are fine, and the same event on two // different contracts is allowed — routing scopes matches by contract. - let {config} = MockIndexerConfig.parseYaml(` + let {config} = InternalTestIndexer.fromUserApi(~configYaml=` name: distinct-and-cross-contract-events contracts: - name: ERC20 @@ -897,7 +897,7 @@ chains: // Two events resolving to the name "Transfer" would clash, but the alias on // one gives them distinct names (and their signatures differ, so no dispatch // collision either). - let {config} = MockIndexerConfig.parseYaml(` + let {config} = InternalTestIndexer.fromUserApi(~configYaml=` name: aliased-overload contracts: - name: Token From 9b90e52f4054fcb7ed09692b63cefacc0ccabd1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 15:47:58 +0000 Subject: [PATCH 16/16] Pin Noop.EmptyEvent handler to chain 1; drop MultiHandlerOrder fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Noop.EmptyEvent's no-op handler registered on every chain that lists the Noop contract, including chain 137 where Noop has an address — adding a fetch partition that the rollback/reorg tests (one partition per chain) don't expect. Pin the handler to chain 1, its only intended chain. Drop the MultiHandlerOrder event + its two handlers and the end-to-end dispatch-order test: adding a new event to the shared chain-1337 Gravatar contract perturbs partition snapshots in the rollback suite. Dispatch ordering by (blockNumber, logIndex, registration index) stays covered by the FetchState buffer-order test and the HandlerRegister index-order tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBsVqEGhVkg7EyTjAdcy52 --- scenarios/test_codegen/config.yaml | 1 - scenarios/test_codegen/src/Indexer.res | 35 ----------------- .../src/handlers/EventHandlers.ts | 34 +++++------------ .../test_codegen/test/EventHandler.test.ts | 38 ------------------- scenarios/test_codegen/test/Indexer_test.res | 2 +- 5 files changed, 10 insertions(+), 100 deletions(-) diff --git a/scenarios/test_codegen/config.yaml b/scenarios/test_codegen/config.yaml index 8bc0a3f6c..3227ff538 100644 --- a/scenarios/test_codegen/config.yaml +++ b/scenarios/test_codegen/config.yaml @@ -53,7 +53,6 @@ chains: - event: "NewGravatar" - event: "UpdatedGravatar" - event: "FactoryEvent(address indexed contract, string testCase)" - - event: "MultiHandlerOrder(uint256 value)" - name: NftFactory abi_file_path: abis/NftFactory.json address: "0xa2F6E6029638cCb484A2ccb6414499aD3e825CaC" diff --git a/scenarios/test_codegen/src/Indexer.res b/scenarios/test_codegen/src/Indexer.res index a622bf448..f90dd4856 100644 --- a/scenarios/test_codegen/src/Indexer.res +++ b/scenarios/test_codegen/src/Indexer.res @@ -1068,40 +1068,6 @@ let contractName = "Gravatar" type onEventWhere = onEventWhereArgs => onEventWhereResult } - module MultiHandlerOrder = { - - let name = "MultiHandlerOrder" - let contractName = contractName - type params = {value: bigint} - /** Event params with all fields optional. Missing fields use default values. */ - type paramsConstructor = {value?: bigint} - type block = Block.t - type transaction = Transaction.t - - type event = { - /** The name of the contract that emitted this event. */ - contractName: string, - /** The name of the event. */ - eventName: string, - /** The parameters or arguments associated with this event. */ - params: params, - /** The unique identifier of the blockchain network where this event occurred. */ - chainId: chainId, - /** The address of the contract that emitted this event. */ - srcAddress: Address.t, - /** The index of this event's log within the block. */ - logIndex: int, - /** The transaction that triggered this event. Configurable in `config.yaml` via the `field_selection` option. */ - transaction: transaction, - /** The block in which this event was recorded. Configurable in `config.yaml` via the `field_selection` option. */ - block: block, - } - - type whereParams = {} - - type onEventWhere = Internal.noOnEventWhere - } - type rec eventIdentity<'event, 'paramsConstructor, 'where> = | @as("CustomSelection") CustomSelection: eventIdentity | @as("EmptyEvent") EmptyEvent: eventIdentity @@ -1113,7 +1079,6 @@ let contractName = "Gravatar" | @as("NewGravatar") NewGravatar: eventIdentity | @as("UpdatedGravatar") UpdatedGravatar: eventIdentity | @as("FactoryEvent") FactoryEvent: eventIdentity - | @as("MultiHandlerOrder") MultiHandlerOrder: eventIdentity } module NftFactory = { diff --git a/scenarios/test_codegen/src/handlers/EventHandlers.ts b/scenarios/test_codegen/src/handlers/EventHandlers.ts index 9311d2228..ebfe77f7e 100644 --- a/scenarios/test_codegen/src/handlers/EventHandlers.ts +++ b/scenarios/test_codegen/src/handlers/EventHandlers.ts @@ -924,28 +924,6 @@ indexer.onEvent({ contract: "EventFiltersTest", event: "FilterTestEvent", where: } }); -// Two handlers on one event, used to assert dispatch order. Both write -// SimulateTestEvent with an id suffixed by the registration order, so the -// order of `result.changes[0].SimulateTestEvent.sets` reveals the dispatch -// order: (blockNumber, logIndex, registration index). The first-registered -// handler gets the lower index and must run first for each log. -indexer.onEvent({ contract: "Gravatar", event: "MultiHandlerOrder" }, async ({ event, context }) => { - context.SimulateTestEvent.set({ - id: `${event.block.number}_${event.logIndex}_a`, - blockNumber: event.block.number, - logIndex: event.logIndex, - timestamp: event.block.timestamp, - }); -}); -indexer.onEvent({ contract: "Gravatar", event: "MultiHandlerOrder" }, async ({ event, context }) => { - context.SimulateTestEvent.set({ - id: `${event.block.number}_${event.logIndex}_b`, - blockNumber: event.block.number, - logIndex: event.logIndex, - timestamp: event.block.timestamp, - }); -}); - // Handler for testing simulate block/logIndex behavior indexer.onEvent({ contract: "Gravatar", event: "EmptyEvent" }, async ({ event, context }) => { context.SimulateTestEvent.set({ @@ -956,9 +934,15 @@ indexer.onEvent({ contract: "Gravatar", event: "EmptyEvent" }, async ({ event, c }); }); -// No-op handler so Noop.EmptyEvent (the only event on chain 1) can be processed -// and simulated without writing any entity — used by the multichain ordering test. -indexer.onEvent({ contract: "Noop", event: "EmptyEvent" }, async () => {}); +// No-op handler so Noop.EmptyEvent can be processed and simulated without +// writing any entity — used by the multichain ordering test. Pinned to chain 1: +// Noop is also configured on chain 137 (with an address), and registering it +// there would add an extra fetch partition that the rollback/reorg tests (which +// expect a single partition per chain) don't account for. +indexer.onEvent( + { contract: "Noop", event: "EmptyEvent", where: ({ chain }) => chain.id === 1 }, + async () => {}, +); // Regression test for https://github.com/enviodev/hyperindex/issues/538: // the `contactDetails` param is a Solidity struct (`ContactDetails { name, email }`), diff --git a/scenarios/test_codegen/test/EventHandler.test.ts b/scenarios/test_codegen/test/EventHandler.test.ts index 617338457..02919f3df 100644 --- a/scenarios/test_codegen/test/EventHandler.test.ts +++ b/scenarios/test_codegen/test/EventHandler.test.ts @@ -1368,44 +1368,6 @@ describe("Use Envio test framework to test event handlers", () => { ]); }); - // Two handlers registered on one event (Gravatar.MultiHandlerOrder) must both - // run, ordered by (blockNumber, logIndex, registration index). Each handler - // writes SimulateTestEvent with an id suffixed `_a`/`_b` in registration - // order, so the `sets` order (which preserves dispatch order) reveals it: for - // each log the first-registered handler (`_a`) runs before `_b`, and logs run - // in (block, logIndex) order. - it("dispatches multiple handlers on one event in (block, logIndex, registration) order", async () => { - const indexer = createTestIndexer(); - - const result = await indexer.process({ - chains: { - 1337: { - startBlock: 1, - endBlock: 100, - simulate: [ - { contract: "Gravatar", event: "MultiHandlerOrder", block: { number: 1 } }, - { contract: "Gravatar", event: "MultiHandlerOrder", block: { number: 1 } }, - { contract: "Gravatar", event: "MultiHandlerOrder", block: { number: 2 } }, - ], - }, - }, - }); - - // Committed batches are in ascending block order; flatten their sets to - // read the full cross-block dispatch order in one value. - const dispatched = result.changes.flatMap( - (c) => c.SimulateTestEvent?.sets ?? [], - ); - assert.deepEqual(dispatched, [ - { id: "1_0_a", blockNumber: 1, logIndex: 0, timestamp: 0 }, - { id: "1_0_b", blockNumber: 1, logIndex: 0, timestamp: 0 }, - { id: "1_1_a", blockNumber: 1, logIndex: 1, timestamp: 0 }, - { id: "1_1_b", blockNumber: 1, logIndex: 1, timestamp: 0 }, - { id: "2_2_a", blockNumber: 2, logIndex: 2, timestamp: 0 }, - { id: "2_2_b", blockNumber: 2, logIndex: 2, timestamp: 0 }, - ]); - }); - it("simulate passes block timestamp to event", async () => { const indexer = createTestIndexer(); diff --git a/scenarios/test_codegen/test/Indexer_test.res b/scenarios/test_codegen/test/Indexer_test.res index 84bbe5d03..92d897ae1 100644 --- a/scenarios/test_codegen/test/Indexer_test.res +++ b/scenarios/test_codegen/test/Indexer_test.res @@ -37,7 +37,7 @@ describe("Indexer.indexer", () => { \"Gravatar": { name: "Gravatar", addresses: ["0x2B2f78c5BF6D9C12Ee1225D5F374aa91204580c3"->Address.unsafeFromString], - abi: %raw(`[{"type":"event","name":"CustomSelection","inputs":[],"anonymous":false},{"type":"event","name":"EmptyEvent","inputs":[],"anonymous":false},{"type":"event","name":"FactoryEvent","inputs":[{"name":"contract","type":"address","indexed":true},{"name":"testCase","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"MultiHandlerOrder","inputs":[{"name":"value","type":"uint256","indexed":false}],"anonymous":false},{"type":"event","name":"NewGravatar","inputs":[{"name":"id","type":"uint256","indexed":false},{"name":"owner","type":"address","indexed":false},{"name":"displayName","type":"string","indexed":false},{"name":"imageUrl","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"TestEvent","inputs":[{"name":"id","type":"uint256","indexed":false},{"name":"user","type":"address","indexed":false,"internalType":"address"},{"name":"contactDetails","type":"tuple","indexed":false,"internalType":"struct TestContract.ContactDetails","components":[{"name":"name","type":"string","internalType":"string"},{"name":"email","type":"string","internalType":"string"}]}],"anonymous":false},{"type":"event","name":"TestEvent","inputs":[],"anonymous":false},{"type":"event","name":"TestEventThatCopiesBigIntViaLinkedEntities","inputs":[{"name":"param_that_should_be_removed_when_issue_1026_is_fixed","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"TestEventWithLongNameBeyondThePostgresEnumCharacterLimit","inputs":[{"name":"testField","type":"address","indexed":false}],"anonymous":false},{"type":"event","name":"TestEventWithReservedKeyword","inputs":[{"name":"module","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"UpdatedGravatar","inputs":[{"name":"id","type":"uint256","indexed":false},{"name":"owner","type":"address","indexed":false},{"name":"displayName","type":"string","indexed":false},{"name":"imageUrl","type":"string","indexed":false}],"anonymous":false}]`), + abi: %raw(`[{"type":"event","name":"CustomSelection","inputs":[],"anonymous":false},{"type":"event","name":"EmptyEvent","inputs":[],"anonymous":false},{"type":"event","name":"FactoryEvent","inputs":[{"name":"contract","type":"address","indexed":true},{"name":"testCase","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"NewGravatar","inputs":[{"name":"id","type":"uint256","indexed":false},{"name":"owner","type":"address","indexed":false},{"name":"displayName","type":"string","indexed":false},{"name":"imageUrl","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"TestEvent","inputs":[{"name":"id","type":"uint256","indexed":false},{"name":"user","type":"address","indexed":false,"internalType":"address"},{"name":"contactDetails","type":"tuple","indexed":false,"internalType":"struct TestContract.ContactDetails","components":[{"name":"name","type":"string","internalType":"string"},{"name":"email","type":"string","internalType":"string"}]}],"anonymous":false},{"type":"event","name":"TestEvent","inputs":[],"anonymous":false},{"type":"event","name":"TestEventThatCopiesBigIntViaLinkedEntities","inputs":[{"name":"param_that_should_be_removed_when_issue_1026_is_fixed","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"TestEventWithLongNameBeyondThePostgresEnumCharacterLimit","inputs":[{"name":"testField","type":"address","indexed":false}],"anonymous":false},{"type":"event","name":"TestEventWithReservedKeyword","inputs":[{"name":"module","type":"string","indexed":false}],"anonymous":false},{"type":"event","name":"UpdatedGravatar","inputs":[{"name":"id","type":"uint256","indexed":false},{"name":"owner","type":"address","indexed":false},{"name":"displayName","type":"string","indexed":false},{"name":"imageUrl","type":"string","indexed":false}],"anonymous":false}]`), }, \"Noop": { name: "Noop",