Migrate to HyperIndex V3
This guide covers every change required to upgrade a HyperIndex V2 project to V3. For new V3 capabilities, see What's New in V3.
Easiest path — prompt your AI tool (Claude/Cursor/Codex):
Upgrade my indexer to V3 by following the migration instructions step by step https://docs.envio.dev/docs/HyperIndex/migrate-to-v3
Step 0: Prepare on V2 (Recommended)
While still on V2:
- Upgrade to
envio@^2.32.6. - Set
preload_handlers: trueinconfig.yaml. - If using loaders, migrate them per Migrating from Loaders.
- Verify with
pnpm dev.
Step 1: Update Node.js
Use Node.js 22+ (24 recommended). Earlier versions are unsupported.
Step 2: Update package.json
- Add
"type": "module"(required — without it the project fails to start with ESM errors). - Set
engines.nodeto>=22.0.0. - Update
envioto the latest v3 release. - Remove
optionalDependencies.generated— the localgeneratedpackage no longer exists.
{
"type": "module",
"engines": { "node": ">=22.0.0" },
"dependencies": { "envio": "3.0.0" },
"devDependencies": {
"@types/node": "24.12.2",
"typescript": "6.0.3",
"vitest": "4.1.0"
}
}
If you used ts-node for the start script, replace it with "start": "envio start".
Test runner
Option A — Vitest (recommended).
pnpm remove ts-mocha ts-node mocha chai @types/mocha @types/chai
pnpm add -D vitest@4.0.16
Set "test": "vitest run", then move test/Test.ts → src/indexer.test.ts and update imports:
// Before (mocha/chai)
import { describe, it } from "mocha";
import { expect } from "chai";
// After (vitest)
import { describe, it, expect } from "vitest";
import { createTestIndexer } from "envio";
Option B — Keep Mocha. Replace ts-mocha/ts-node with tsx:
pnpm remove ts-mocha ts-node
pnpm add -D tsx@4.21.0
{
"scripts": {
"mocha": "tsc --noEmit && NODE_OPTIONS='--no-warnings --import tsx' mocha --exit test/**/*.ts"
}
}
Step 3: Update tsconfig.json
Update for ESM (copy-paste the file as-is, comments included):
{
/* For details: https://www.totaltypescript.com/tsconfig-cheat-sheet */
"compilerOptions": {
/* Base Options: */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
/* For running Envio: */
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
/* Code doesn't run in the DOM: */
"lib": ["es2022"],
"types": ["node"]
}
}
verbatimModuleSyntax and noUncheckedIndexedAccess are optional extra strictness — disable them to simplify migration.
Step 4: Update config.yaml
Renames:
networks→chainsconfirmed_block_threshold→max_reorg_depthrpc_config→rpc(now supports multiple URLs,for: sync | realtime | fallback, and WebSocket config)
Remove if present:
unordered_multichain_modeand anymultichain: ordered— unordered is the only mode in V3.loaders,preload_handlers— Preload Optimization is always enabled.preRegisterDynamicContracts— no longer needed.event_decoder— the Rust decoder is the only implementation.output— types always emitted to.envio/.
Env var → config: replace the MAX_BATCH_SIZE env var with full_batch_size: 5000.
Optional (recommended): move handler files to src/handlers/ and drop the explicit handler paths (the handler field still works).
Step 5: Update Environment Variables
Add — if using HyperSync (the default), set ENVIO_API_TOKEN (get a free token at envio.dev/app/api-tokens).
Remove:
UNSTABLE__TEMP_UNORDERED_HEAD_MODEUNORDERED_MULTICHAIN_MODEMAX_BATCH_SIZE(usefull_batch_sizeinconfig.yaml)ENVIO_INDEXING_BLOCK_LAG(use per-chainblock_lag)
Rename:
TUI_OFF=true→ENVIO_TUI=false(TUI also auto-disabled in CI and under AI agents)ENVIO_PG_PUBLIC_SCHEMA→ENVIO_PG_SCHEMA(old name supported until v4)
Step 6: Update Handler Code
Contract-specific exports are removed. Register handlers through the unified indexer from the envio package, which replaces generated.
Event handlers
// Before
import { ERC20 } from "generated";
ERC20.Transfer.handler(
async ({ event, context }) => {},
{
wildcard: true,
eventFilters: ({ chainId }) => [
{ from: ZERO_ADDRESS, to: WHITELIST[chainId] },
],
}
);
// After
import { indexer } from "envio";
indexer.onEvent(
{
contract: "ERC20",
event: "Transfer",
wildcard: true,
where: ({ chain }) => ({
params: [{ from: ZERO_ADDRESS, to: WHITELIST[chain.id] }],
}),
},
async ({ event, context }) => {},
);
eventFilters→where. Callback receives{ chain }(not{ chainId }) and returnsfalse,true, or{ params: [...], block?: { number: { _gte, _lte, _every } } }.- The top-level array shorthand is gone — wrap it in
{ params: [...] }.
Filtering by the contract's own addresses — V2's eventFilters addresses argument becomes chain.<ContractName>.addresses (kept in sync with context.chain.<ContractName>.add(...)):
// Before
import { Safe } from "generated";
Safe.Transfer.handler(async ({ event, context }) => {}, {
wildcard: true,
eventFilters: ({ addresses }) => [{ from: addresses }, { to: addresses }],
});
// After
import { indexer } from "envio";
indexer.onEvent(
{
contract: "Safe",
event: "Transfer",
wildcard: true,
where: ({ chain }) => ({
params: [{ from: chain.Safe.addresses }, { to: chain.Safe.addresses }],
}),
},
async ({ event, context }) => {},
);
Dynamic contract registration
// Before
import { UniV3 } from "generated";
UniV3.PoolFactory.contractRegister(async ({ event, context }) => {
context.addPool(event.params.poolAddress);
});
// After
import { indexer } from "envio";
indexer.contractRegister(
{ contract: "UniV3", event: "PoolFactory" },
async ({ event, context }) => {
context.chain.Pool.add(event.params.poolAddress);
},
);
context.add<ContractName>(addr) → context.chain.<ContractName>.add(addr).
Block handlers
Behavior change. V2's onBlock ran on one chain (its chain option) with top-level interval/startBlock/endBlock. V3's indexer.onBlock runs on every chain by default. To restore V2's single-chain + range + interval behavior, pass a where callback that returns false for unwanted chains and { block: { number: { _gte, _lte, _every } } } for the range/interval.
// Before — only chain 1, every 100 blocks, fixed range
import { onBlock } from "generated";
onBlock(
{ name: "Ranges", chain: 1, startBlock: 20_000_000, endBlock: 22_000_000, interval: 100 },
async ({ block, context }) => {},
);
// After
import { indexer } from "envio";
indexer.onBlock(
{
name: "Ranges",
where: ({ chain }) => {
if (chain.id !== 1) return false;
return { block: { number: { _gte: 20_000_000, _lte: 22_000_000, _every: 100 } } };
},
},
async ({ block, context }) => {},
);
To run on every chain (the new default), omit where. Inside the handler, block.chainId → context.chain.id.
getWhere API
Switch to GraphQL-style filter syntax (new operators: _gte, _lte, _in):
// Before
await context.Transfer.getWhere.from.eq("0x123...");
await context.Transfer.getWhere.value.gt(1000n);
// After
await context.Transfer.getWhere({ from: { _eq: "0x123..." } });
await context.Transfer.getWhere({ value: { _gt: 1000n } });
Rename and removal cheat sheet
| V2 (removed) | V3 |
|---|---|
Contract.Event.handler(...) | indexer.onEvent({ contract, event, ...options }, handler) |
Contract.Event.contractRegister(...) | indexer.contractRegister({ contract, event }, handler) |
onBlock({ chain, ... }, handler) | indexer.onBlock({ name, where? }, handler) |
context.add<Contract>(addr) | context.chain.<Contract>.add(addr) |
eventFilters option | where callback returning { params: [...] } |
experimental_createEffect | createEffect |
block.chainId (in block handlers) | context.chain.id |
transaction.kind | transaction.type |
transaction.chainId | context.chain.id or event.chainId |
chain type | ChainId (now a union type) |
getGeneratedByChainId(...) | indexer.chains[chainId] |
Entity.getWhere.field.eq(value) | Entity.getWhere({ field: { _eq: value } }) |
Entity.getWhere.field.gt(value) | Entity.getWhere({ field: { _gt: value } }) |
Entity.getWhere.field.lt(value) | Entity.getWhere({ field: { _lt: value } }) |
Lowercased entity types (e.g. transfer) | Capitalized (Transfer) |
ERC20_Transfer_eventLog | EvmEvent<"ERC20", "Transfer"> |
ERC20_Transfer_block | EvmEvent<"ERC20", "Transfer">["block"] |
MyEnum (direct export) | Enum<"MyEnum"> |
MyEntity (direct export) | Entity<"MyEntity"> (preferred; direct still exported) |
Other type changes: Address is now `0x${string}` (was string); entity array fields are readonly; S.nullable returns T | null (was T | undefined); the internal ContractType enum was removed.
Step 7: Remove generated
The generated package is no longer needed — remove it. Import everything from "envio" instead. This works via envio-env.d.ts, which is linked automatically (no tsconfig.json change needed).
Step 8: Update Tests
MockDb is removed. Use createTestIndexer() with simulate.
-import { TestHelpers, type User } from "generated";
-const { MockDb, Greeter, Addresses } = TestHelpers;
+import { createTestIndexer, type User, TestHelpers } from "envio";
+const { Addresses } = TestHelpers;
it("A NewGreeting event creates a User entity", async (t) => {
- const mockDbInitial = MockDb.createMockDb();
+ const indexer = createTestIndexer();
const userAddress = Addresses.defaultAddress;
const greeting = "Hi there";
- const mockNewGreetingEvent = Greeter.NewGreeting.createMockEvent({
- greeting: greeting,
- user: userAddress,
- });
-
- const updatedMockDb = await Greeter.NewGreeting.processEvent({
- event: mockNewGreetingEvent,
- mockDb: mockDbInitial,
- });
+ await indexer.process({
+ chains: {
+ 137: {
+ simulate: [
+ { contract: "Greeter", event: "NewGreeting", params: { greeting, user: userAddress } },
+ ],
+ },
+ },
+ });
const expectedUserEntity: User = {
id: userAddress,
latestGreeting: greeting,
numberOfGreetings: 1,
greetings: [greeting],
};
- const actualUserEntity = updatedMockDb.entities.User.get(userAddress);
+ const actualUserEntity = await indexer.User.getOrThrow(userAddress);
t.expect(actualUserEntity).toEqual(expectedUserEntity);
});
Old (MockDb) | New (createTestIndexer) |
|---|---|
MockDb.createMockDb() | createTestIndexer() |
Contract.Event.createMockEvent({...}) | Inline in simulate: [{ contract, event, params }] |
Contract.Event.processEvent({event,mockDb}) | indexer.process({ chains: { id: { simulate } } }) |
mockDb.entities.Entity.get(id) | await indexer.Entity.getOrThrow(id) |
mockDb.entities.Entity.set({...}) | indexer.Entity.set({...}) |
| Manual handler threading & event chaining | Automatic — pass multiple events in simulate |
Step 9: Update CLI Usage
envio devno longer auto-resets the DB — useenvio dev -r(--restart) if you relied on that.envio startis now production-only; useenvio devfor local development.- Handler file changes no longer trigger codegen on
pnpm dev.
Step 10: Run Codegen and Verify
pnpm envio codegen
pnpm dev
Postgres column type changes (raw_events.event_id: NUMERIC→BIGINT, raw_events.serial: SERIAL→BIGSERIAL, envio_chains.events_processed: INTEGER→BIGINT, envio_checkpoints.id: INTEGER→BIGINT) apply automatically. The deprecated envio_chains._num_batches_fetched always returns 0.
Step 11: Update Agent Skills
Refresh the bundled agent skills so agent-driven development stays aligned with V3:
pnpx envio skills update
This populates .claude/skills (consumed by Claude, Cursor, and other agentic tooling). Re-run it on each new HyperIndex release.
Getting Help
Issues during migration? Join our Discord community.