For AI agents: the documentation index is at /llms.txt. Markdown versions of pages are available by appending .md to the URL.
Skip to main content

Migrate to HyperIndex V3

This guide covers what's new in V3 and every change required to upgrade a HyperIndex V2 project. New projects created with pnpx envio init are already on V3. For every release since 3.0, see the Changelog.

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

What's New in V3​

HyperIndex V3 is the current major version of HyperIndex, released in May 2026. It rebuilt the parts of HyperIndex that V2 had outgrown, from the handler API and module system to testing, storage and packaging.

V2 vs V3 at a Glance​

V2V3Read more
Handler APIGenerated exports for each contract and eventOne indexer API for events, dynamic contracts and blocksEvent handlers
Event filteringeventFilters on indexed parameterswhere, which also handles per-chain filters, conditional handlers and per-event start blocksWildcard indexing
Module systemCommonJSESM, so handler files can use top-level awaitTop-level await
Handler filesA path for every contract in config.yamlPicked up automatically from src/handlersHandler file path
Loading entitiesOpt-in loaders or preload_handlersPreload optimization, always onPreload optimization
Multichain orderingOrdered or unorderedUnordered onlyMultichain indexing
TestingMocha and Chai with manual mockscreateTestIndexer() with Vitest and snapshot testsTesting
StoragePostgresPostgres, plus ClickHouse chosen per entityStorage backends
MetricsUnofficial Prometheus metrics and --benchDocumented Prometheus metrics on /metrics, plus envio metricsObservability
Package managerpnpm requirednpm, pnpm, yarn or BunCLI commands
SolanaNot supportedSupportedSolana

What Else Came with 3.0​

  • 3x faster historical backfill, from fetching several block ranges at once.
  • A smaller envio package. It went from 141MB to 53MB once the generated ReScript code was removed, and envio codegen is now near instant.
  • An RPC for realtime only. Set for: realtime on an RPC and it takes over once the indexer reaches the head. See RPC sync.
  • Config and live state in handlers. The indexer value exposes chains, contracts, addresses and whether each chain is realtime. See Accessing config.yaml data.
  • Built for AI agents. New projects ship with Claude and Cursor skills. See Quickstart with AI.

For the full list, see the v3.0.0 release notes. Every release since is on the Changelog, and Shipper's Logs has short video recaps.

While still on V2:

  1. Upgrade to envio@^2.32.6.
  2. Set preload_handlers: true in config.yaml.
  3. If using loaders, migrate them per Migrating from Loaders.
  4. 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.node to >=22.0.0.
  • Update envio to the latest v3 release.
  • Remove optionalDependencies.generated - the local generated package 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"]
}
}
tip

verbatimModuleSyntax and noUncheckedIndexedAccess are optional extra strictness - disable them to simplify migration.

Step 4: Update config.yaml​

Renames:

  • networks → chains
  • confirmed_block_threshold → max_reorg_depth
  • rpc_config → rpc (now supports multiple URLs, for: sync | realtime | fallback, and WebSocket config)

Remove if present:

  • unordered_multichain_mode and any multichain: 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_MODE
  • UNORDERED_MULTICHAIN_MODE
  • MAX_BATCH_SIZE (use full_batch_size in config.yaml)
  • ENVIO_INDEXING_BLOCK_LAG (use per-chain block_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 returns false, 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 optionwhere callback returning { params: [...] }
experimental_createEffectcreateEffect
block.chainId (in block handlers)context.chain.id
transaction.kindtransaction.type
transaction.chainIdcontext.chain.id or event.chainId
chain typeChainId (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_eventLogEvmEvent<"ERC20", "Transfer">
ERC20_Transfer_blockEvmEvent<"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 chainingAutomatic - pass multiple events in simulate

Step 9: Update CLI Usage​

  • envio dev no longer auto-resets the DB - use envio dev -r (--restart) if you relied on that.
  • envio start is now production-only; use envio dev for 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.