From 28f93216448c61c733582da69c55a27c4222da2c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 15:08:08 +0000 Subject: [PATCH 1/4] Consolidate TypeScript API type checks into a dedicated test file The public TypeScript API's type-surface assertions (EvmEvent narrowing, onEvent/contractRegister option/handler/context shapes, custom field selection, Entity/Enum lookups, the onBlock surface, and the registration guards) were scattered across scenario test files and handler source, coupled to the test_codegen config. Move the self-contained checks into packages/envio-tests/test/TypeScriptApiTypes_test.res, which drives the TS compiler over handler snippets via InternalTestIndexer.fromUserApi against a small purpose-built mock config. Delete the migrated checks from EventHandler.test.ts (four type-only describe blocks + the EvmEvent type block), CustomSelection.test.ts (custom-selection assertions), and EventHandlers.ts (the if(0) registration block and the where.block type guard). Runtime value tests and inline handler-body type asserts stay put. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N8ssQQn6qQ2WMKCTci7iGJ --- .../test/TypeScriptApiTypes_test.res | 455 ++++++++++++++++++ .../src/handlers/EventHandlers.ts | 103 +--- .../test_codegen/test/CustomSelection.test.ts | 46 +- .../test_codegen/test/EventHandler.test.ts | 392 --------------- 4 files changed, 458 insertions(+), 538 deletions(-) create mode 100644 packages/envio-tests/test/TypeScriptApiTypes_test.res diff --git a/packages/envio-tests/test/TypeScriptApiTypes_test.res b/packages/envio-tests/test/TypeScriptApiTypes_test.res new file mode 100644 index 0000000000..e30df48afd --- /dev/null +++ b/packages/envio-tests/test/TypeScriptApiTypes_test.res @@ -0,0 +1,455 @@ +open Vitest + +// A self-contained mock indexer whose generated `envio` types back every +// assertion below. Two chains, two contracts (one event carries a custom +// `field_selection`, the rest inherit the global one), plus a schema with +// entities and enums — enough surface to pin the whole public TypeScript API. +let schema = ` +enum AccountType { + ADMIN + USER +} + +enum GravatarSize { + SMALL + MEDIUM + LARGE +} + +type Account { + id: ID! + balance: BigInt! + accountType: AccountType! + delegate: Account +} + +type Delegation { + id: ID! + amount: BigInt! +} +` + +let configYaml = ` +name: ts-api-types +field_selection: + transaction_fields: + - transactionIndex + - hash +contracts: + - name: Token + events: + - event: Transfer(address indexed from, address indexed to, uint256 value) + - event: Approval(address indexed owner, address indexed spender, uint256 value) + field_selection: + block_fields: + - parentHash + transaction_fields: + - to + - from + - hash + - event: "Synced()" + - name: Factory + events: + - event: PoolCreated(address indexed pool) +chains: + - id: 1 + start_block: 0 + contracts: + - name: Token + address: "0x0000000000000000000000000000000000000001" + - name: Factory + address: "0x0000000000000000000000000000000000000002" + - id: 137 + start_block: 0 + contracts: + - name: Token + address: "0x0000000000000000000000000000000000000003" + - name: Factory + address: "0x0000000000000000000000000000000000000004" +` + +// Type-checks `handlers` against the mock's generated types. A type error is +// thrown as the test failure, so each `it` only needs to hand over a snippet. +let check = handlers => InternalTestIndexer.fromUserApi(~schema, ~handlers, ~configYaml)->ignore + +describe("TypeScript API types", () => { + it("resolves config-bound chain/contract name and id unions", _ => + check(` +import type { + Address, + EvmChainId, + EvmChainName, + EvmContractName, + FuelChainId, + SvmChainId, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); + +// @ts-expect-error - "NotAContract" is not configured +const _bad: EvmContractName = "NotAContract"; + +// Non-configured ecosystems resolve to the codegen hint string, not a union. +expectType< + TypeEqual< + FuelChainId, + "FuelChainId is not available. Configure Fuel chains in config.yaml and run 'envio codegen'" + > +>(true); +expectType< + TypeEqual< + SvmChainId, + "SvmChainId is not available. Configure SVM chains in config.yaml and run 'envio codegen'" + > +>(true); +`) + ) + + it("looks up EvmEvent by contract and event name", _ => + check(` +import type { Address, EvmChainId, EvmEvent } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +// Without generics, the discriminant spans every configured event. +type AllEvents = EvmEvent; +expectType>(true); + +type TokenEvent = EvmEvent<"Token">; +expectType>(true); + +type TransferEvent = EvmEvent<"Token", "Transfer">; +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType< + TypeEqual< + TransferEvent["params"], + { + readonly from: Address; + readonly to: Address; + readonly value: bigint; + } + > +>(true); +expectType>(true); +expectType>(true); +expectType>(true); +`) + ) + + it("shapes onEvent / contractRegister options, handlers and contexts", _ => + check(` +import type { + Account, + Address, + EvmContractRegisterContext, + EvmContractRegisterHandler, + EvmContractRegisterOptions, + EvmEvent, + EvmOnEventContext, + EvmOnEventHandler, + EvmOnEventOptions, + EvmOnEventWhere, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +// Synced() has no indexed params, so the eventFilters lookup resolves to {}. +type SyncedOpts = EvmOnEventOptions>; +expectType< + TypeEqual< + SyncedOpts, + { + readonly contract: "Token"; + readonly event: "Synced"; + readonly wildcard?: boolean; + readonly where?: EvmOnEventWhere<{}, "Token">; + } + > +>(true); + +type PoolCreated = EvmEvent<"Factory", "PoolCreated">; +expectType< + TypeEqual, EvmOnEventOptions> +>(true); + +expectType< + TypeEqual< + EvmOnEventHandler, + (args: { event: PoolCreated; context: EvmOnEventContext }) => Promise + > +>(true); +expectType< + TypeEqual< + EvmContractRegisterHandler, + (args: { + event: PoolCreated; + context: EvmContractRegisterContext; + }) => Promise + > +>(true); + +// Without generics the handler accepts the union of every EVM event. +type DefaultArgs = Parameters[0]; +expectType>(true); +expectType>(true); + +expectType>(true); +expectType>(true); +expectType>(true); +expectType< + TypeEqual< + EvmOnEventContext["Account"]["get"], + (id: string) => Promise + > +>(true); +expectType< + TypeEqual void> +>(true); + +expectType>(true); +expectType< + TypeEqual< + EvmContractRegisterContext["chain"]["Token"]["add"], + (address: Address) => void + > +>(true); +expectType< + TypeEqual< + EvmContractRegisterContext["chain"]["Factory"]["add"], + (address: Address) => void + > +>(true); + +// contractRegister context exposes no entity operations. +// @ts-expect-error - Account ops are not on the contractRegister context +type _accountOnCr = EvmContractRegisterContext["Account"]; + +// EvmOnEventOptions rejects an Event that isn't EventLike. +// @ts-expect-error - missing contractName/eventName +type _bad = EvmOnEventOptions<{ foo: "bar" }>; + +// Union events keep contract/event paired; mismatches are rejected. +type UnionEvent = EvmEvent<"Token", "Transfer"> | EvmEvent<"Factory", "PoolCreated">; +type UnionOpts = EvmOnEventOptions; +const _a: UnionOpts = { contract: "Token", event: "Transfer" }; +const _b: UnionOpts = { contract: "Factory", event: "PoolCreated" }; +// @ts-expect-error - "PoolCreated" is not an event of "Token" +const _bad1: UnionOpts = { contract: "Token", event: "PoolCreated" }; +// @ts-expect-error - "Transfer" is not an event of "Factory" +const _bad2: UnionOpts = { contract: "Factory", event: "Transfer" }; +`) + ) + + it("narrows event block/transaction fields by field_selection", _ => + check(` +import type { EvmEvent } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +// Unselected fields carry the branded FieldNotSelected sentinel so reading +// them is a compile error instead of silently passing. +type IsNotSelected = T extends { readonly __fieldNotSelected: string } + ? true + : false; + +// Approval declares custom block_fields [parentHash] and transaction_fields +// [to, from, hash]; defaults (number/timestamp/hash) stay included. +type ApprovalEvent = EvmEvent<"Token", "Approval">; +expectType>(true); +expectType>(true); +expectType>(true); +expectType< + TypeEqual +>(true); +expectType< + TypeEqual +>(true); +expectType>(true); +expectType>(true); + +// Transfer inherits the global selection: transaction [transactionIndex, hash]. +type TransferEvent = EvmEvent<"Token", "Transfer">; +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +`) + ) + + it("binds Entity / EntityName / Enum / EnumName to the schema", _ => + check(` +import type { Account, Entity, EntityName, Enum, EnumName } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +const _account: EntityName = "Account"; +const _delegation: EntityName = "Delegation"; +// @ts-expect-error - "NotAnEntity" is not in the schema +const _badEntity: EntityName = "NotAnEntity"; + +expectType, Account>>(true); +expectType["id"], string>>(true); +expectType["balance"], bigint>>(true); +expectType["accountType"], "ADMIN" | "USER">>(true); +expectType["delegate_id"], string | undefined>>(true); +// @ts-expect-error - "NotAnEntity" is not assignable to EntityName +type _badEntityLookup = Entity<"NotAnEntity">; + +const _accountType: EnumName = "AccountType"; +const _size: EnumName = "GravatarSize"; +// @ts-expect-error - "NotAnEnum" is not in the schema +const _badEnum: EnumName = "NotAnEnum"; + +expectType, "ADMIN" | "USER">>(true); +expectType, "SMALL" | "MEDIUM" | "LARGE">>(true); +// @ts-expect-error - "NotAnEnum" is not assignable to EnumName +type _badEnumLookup = Enum<"NotAnEnum">; +`) + ) + + it("shapes the onBlock surface", _ => + check(` +import type { + EvmChainId, + EvmOnBlockContext, + EvmOnBlockFilter, + EvmOnBlockHandler, + EvmOnBlockHandlerArgs, + EvmOnBlockWhereArgs, + EvmOnBlockWhereResult, + EvmOnEventContext, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType>(true); +expectType< + TypeEqual +>(true); +expectType>(true); +expectType< + TypeEqual Promise> +>(true); + +expectType>(true); +expectType>(true); +expectType>(true); +expectType< + TypeEqual +>(true); + +// The predicate result excludes void/undefined, so an implicit return fails. +expectType>(true); +type _Predicate = (args: { + readonly chain: { readonly id: number }; +}) => EvmOnBlockWhereResult; +// @ts-expect-error - implicit undefined return is not assignable +const _missingReturn: _Predicate = ({ chain }) => { + if (chain.id === 1) return true; +}; + +const _ok: EvmOnBlockFilter = { + block: { number: { _gte: 1, _lte: 10, _every: 2 } }, +}; +const _empty: EvmOnBlockFilter = {}; +expectType(_ok); +expectType(_empty); +`) + ) + + it("guards the indexer.onEvent / contractRegister registration surface", _ => + check(` +import { indexer } from "envio"; +import type { Address } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +// Type-only: guarded by \`if (0)\` so tsc validates the surface without +// registering handlers (registration throws once the indexer is built). +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 Token + { contract: "Token", event: "BadEvent" }, + async () => {}, + ); + indexer.onEvent( + { contract: "Token", event: "Transfer" }, + async ({ event }) => { + expectType>(true); + expectType>(true); + }, + ); + indexer.contractRegister( + { contract: "Factory", event: "PoolCreated" }, + async ({ event, context }) => { + expectType>(true); + context.chain.Factory.add(event.params.pool); + context.chain.Token.add(event.params.pool); + // @ts-expect-error - UnknownContract is not configured + context.chain.UnknownContract.add(event.params.pool); + }, + ); + + // where.block must stay on the EVM \`number\` filter with only \`_gte\`. + indexer.onEvent( + { + contract: "Token", + event: "Transfer", + wildcard: true, + where: { block: { number: { _gte: 1 } } }, + }, + async () => {}, + ); + indexer.onEvent( + { + contract: "Token", + event: "Transfer", + wildcard: true, + // @ts-expect-error - EVM keys block by \`number\`, not \`height\`. + where: { block: { height: { _gte: 1 } } }, + }, + async () => {}, + ); + indexer.onEvent( + { + contract: "Token", + event: "Transfer", + wildcard: true, + where: { + block: { + number: { + // @ts-expect-error - Only \`_gte\` is supported on event filters. + _lte: 1, + }, + }, + }, + }, + async () => {}, + ); + indexer.onEvent( + { + contract: "Token", + event: "Transfer", + wildcard: true, + where: { + block: { + number: { + // @ts-expect-error - Only \`_gte\` is supported on event filters. + _every: 100, + }, + }, + }, + }, + async () => {}, + ); +} +`) + ) +}) diff --git a/scenarios/test_codegen/src/handlers/EventHandlers.ts b/scenarios/test_codegen/src/handlers/EventHandlers.ts index e22b040031..df894e813f 100644 --- a/scenarios/test_codegen/src/handlers/EventHandlers.ts +++ b/scenarios/test_codegen/src/handlers/EventHandlers.ts @@ -129,40 +129,6 @@ 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 }) => { @@ -188,7 +154,8 @@ indexer.onEvent({ contract: "Gravatar", event: "CustomSelection" }, async ({ eve S.assertOrThrow(event.block, blockSchema)!; deepEqual(context.chain.id, event.chainId); - // Type checking for custom field selection is done in CustomSelection.test.ts + // Type checking for custom field selection lives in + // packages/envio-tests/test/TypeScriptApiTypes_test.res // Test chain field accessibility in TypeScript expectType< @@ -1044,69 +1011,3 @@ indexer.onBlock( { name: "test_onblock_skip_all", where: () => false }, async () => {}, ); - -// Type-level regression guards for `where.block` on EVM. Declared as an -// unreached function so `tsc --noEmit` checks the types without runtime -// re-registering events. The `@ts-expect-error` assertions catch the -// class of bug where `EvmOnEventWhere` might get wired through the wrong -// filter shape (e.g. Fuel's `block.height`) — a regression would flip -// the directive from "expected" to "unused", failing the build. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function _typeCheckEvmWhereBlockShape() { - indexer.onEvent( - { - contract: "EventFiltersTest", - event: "Transfer", - wildcard: true, - where: { block: { number: { _gte: 1 } } }, - }, - async () => {}, - ); - indexer.onEvent( - { - contract: "EventFiltersTest", - event: "Transfer", - wildcard: true, - where: { - block: { - // @ts-expect-error EVM keys block by `number`, not `height`. - height: { _gte: 1 }, - }, - }, - }, - async () => {}, - ); - indexer.onEvent( - { - contract: "EventFiltersTest", - event: "Transfer", - wildcard: true, - where: { - block: { - number: { - // @ts-expect-error Only `_gte` is supported on event filters. - _lte: 1, - }, - }, - }, - }, - async () => {}, - ); - indexer.onEvent( - { - contract: "EventFiltersTest", - event: "Transfer", - wildcard: true, - where: { - block: { - number: { - // @ts-expect-error Only `_gte` is supported on event filters. - _every: 100, - }, - }, - }, - }, - async () => {}, - ); -} - diff --git a/scenarios/test_codegen/test/CustomSelection.test.ts b/scenarios/test_codegen/test/CustomSelection.test.ts index 10eb968b25..b2540dc7d9 100644 --- a/scenarios/test_codegen/test/CustomSelection.test.ts +++ b/scenarios/test_codegen/test/CustomSelection.test.ts @@ -1,50 +1,6 @@ -import { expectType, type TypeEqual } from "ts-expect"; import assert from "assert"; import { it } from "vitest"; -import { createTestIndexer, type EvmEvent } from "envio"; - -type CustomSelectionEvent = EvmEvent<"Gravatar", "CustomSelection">; -type EmptyEventEvent = EvmEvent<"Gravatar", "EmptyEvent">; - -// Unselected fields are typed as the branded `FieldNotSelected<...>` (not `never`) -// so reading them is a type error instead of silently passing as `never`. -type IsNotSelected = T extends { readonly __fieldNotSelected: string } - ? true - : false; - -// Compile-time type assertions for custom field selection -// CustomSelection event has custom block_fields: [parentHash] -// Default fields (number, timestamp, hash) are always included -expectType>(true); -expectType>(true); -expectType>(true); -expectType>(true); -// Unselected block fields are not selected -expectType>(true); -expectType>(true); - -// CustomSelection event has custom transaction_fields: [to, from, hash] -expectType>(true); -expectType>(true); -expectType>(true); -// Unselected transaction fields are not selected -expectType>(true); -expectType>(true); - -// Events without custom field selection should use the global one -// Global has transactionIndex + hash -expectType>(true); -expectType>(true); -// Fields not in global selection are not selected -expectType>(true); -expectType>(true); - -// Global block has defaults only (number, timestamp, hash) -expectType>(true); -expectType>(true); -expectType>(true); -// parentHash not in global selection — not selected -expectType>(true); +import { createTestIndexer } from "envio"; it("Handles event with a custom field selection (in TS)", async () => { const indexer = createTestIndexer(); diff --git a/scenarios/test_codegen/test/EventHandler.test.ts b/scenarios/test_codegen/test/EventHandler.test.ts index dbd5e23925..f95b5c8dcb 100644 --- a/scenarios/test_codegen/test/EventHandler.test.ts +++ b/scenarios/test_codegen/test/EventHandler.test.ts @@ -6,34 +6,11 @@ import { type Indexer, type EvmChainId, type EvmChainName, - type EvmContractName, - type EvmEvent, type FuelChainId, - type FuelEvent, type SvmChainId, type TestIndexer, type Token, - type EvmOnEventOptions, - type EvmOnEventHandler, - type EvmContractRegisterOptions, - type EvmContractRegisterHandler, - type EvmOnEventContext, - type EvmContractRegisterContext, - type EvmOnEventWhere, - type Entity, - type EntityName, - type Enum, - type EnumName, -} from "envio"; -import { type Address, - type EvmOnBlockWhereResult, - type EvmOnBlockFilter, - type EvmOnBlockOptions, - type EvmOnBlockContext, - type EvmOnBlockHandler, - type EvmOnBlockHandlerArgs, - type EvmOnBlockWhereArgs, } from "envio"; import { expectType, type TypeEqual } from "ts-expect"; import { createTestIndexer } from "envio"; @@ -1649,373 +1626,4 @@ describe("Use Envio test framework to test event handlers", () => { }], }); }); - - it("EvmEvent type", () => { - // EvmEvent without generics is a union of all events - type AllEvents = EvmEvent; - - // contractName and eventName are discriminant fields with literal types - expectType< - TypeEqual< - AllEvents["contractName"], - | "Gravatar" - | "NftFactory" - | "SimpleNft" - | "TestEvents" - | "Noop" - | "EventFiltersTest" - > - >(true); - - // Narrowing to a specific contract's events - type GravatarEvent = EvmEvent<"Gravatar">; - expectType< - TypeEqual< - GravatarEvent["contractName"], - "Gravatar" - > - >(true); - - // Narrowing to a specific event - type NewGravatarEvent = EvmEvent<"Gravatar", "NewGravatar">; - expectType>(true); - expectType>(true); - expectType>(true); - expectType>(true); - expectType>(true); - expectType< - TypeEqual< - NewGravatarEvent["params"], - { - readonly id: bigint; - readonly owner: `0x${string}`; - readonly displayName: string; - readonly imageUrl: string; - } - > - >(true); - - // Block and transaction have proper types - expectType>(true); - expectType>(true); - expectType>(true); - - // Non-configured ecosystem event types return error string - expectType< - TypeEqual< - FuelEvent, - "FuelEvent is not available. Configure Fuel contracts in config.yaml and run 'envio codegen'" - > - >(true); - }); -}); - -describe("onEvent / contractRegister types", () => { - it("EvmOnEventOptions resolves contract/event literals from Event", () => { - type GravatarNewGravatar = EvmEvent<"Gravatar", "NewGravatar">; - type Opts = EvmOnEventOptions; - - // Gravatar.NewGravatar has no indexed params, so the project-bound - // EvmEventFilters lookup resolves params to {} and `where` is typed - // as `EvmOnEventWhere<{}, "Gravatar">` rather than `unknown`. - expectType< - TypeEqual< - Opts, - { - readonly contract: "Gravatar"; - readonly event: "NewGravatar"; - readonly wildcard?: boolean; - readonly where?: EvmOnEventWhere<{}, "Gravatar">; - } - > - >(true); - }); - - it("EvmContractRegisterOptions has same shape as EvmOnEventOptions", () => { - type Ev = EvmEvent<"NftFactory", "SimpleNftCreated">; - expectType< - TypeEqual, EvmOnEventOptions> - >(true); - }); - - it("EvmOnEventHandler has correct args shape", () => { - type Ev = EvmEvent<"NftFactory", "SimpleNftCreated">; - type Handler = EvmOnEventHandler; - - expectType< - TypeEqual< - Handler, - (args: { event: Ev; context: EvmOnEventContext }) => Promise - > - >(true); - }); - - it("EvmContractRegisterHandler uses EvmContractRegisterContext", () => { - type Ev = EvmEvent<"NftFactory", "SimpleNftCreated">; - type Handler = EvmContractRegisterHandler; - - expectType< - TypeEqual< - Handler, - (args: { - event: Ev; - context: EvmContractRegisterContext; - }) => Promise - > - >(true); - }); - - it("EvmOnEventContext has chain info and entity ops", () => { - expectType>(true); - expectType>(true); - expectType>(true); - - // Entity ops are available on context - expectType< - TypeEqual< - EvmOnEventContext["User"]["get"], - (id: string) => Promise - > - >(true); - expectType< - TypeEqual void> - >(true); - }); - - it("EvmContractRegisterContext has chain.ContractName.add() registration", () => { - expectType< - TypeEqual - >(true); - expectType< - TypeEqual< - EvmContractRegisterContext["chain"]["NftFactory"]["add"], - (address: Address) => void - > - >(true); - expectType< - TypeEqual< - EvmContractRegisterContext["chain"]["SimpleNft"]["add"], - (address: Address) => void - > - >(true); - expectType< - TypeEqual< - EvmContractRegisterContext["chain"]["Gravatar"]["add"], - (address: Address) => void - > - >(true); - }); - - it("EvmContractRegisterContext does not expose entity operations", () => { - // @ts-expect-error - User entity ops should not be on contractRegister context - type _userOnCr = EvmContractRegisterContext["User"]; - }); - - // `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 - type DefaultHandler = EvmOnEventHandler; - type DefaultArgs = Parameters[0]; - - // The event field is the union of all EVM events - expectType>(true); - expectType>(true); - }); - - it("EvmOnEventOptions rejects invalid Event constraint", () => { - // EventLike requires contractName and eventName fields - // @ts-expect-error - missing required fields - type _bad = EvmOnEventOptions<{ foo: "bar" }>; - }); - - it("EvmOnEventOptions preserves contract/event pairing across union members", () => { - // With distributive conditional typing, a union Event type yields a union - // of options where each member's contract/event are paired together. - // Mismatched pairings (e.g. contract: "Gravatar", event: "SimpleNftCreated") - // must be rejected. - type UnionEvent = - | EvmEvent<"Gravatar", "NewGravatar"> - | EvmEvent<"NftFactory", "SimpleNftCreated">; - type UnionOpts = EvmOnEventOptions; - - // Valid pairings compile - const _a: UnionOpts = { contract: "Gravatar", event: "NewGravatar" }; - const _b: UnionOpts = { - contract: "NftFactory", - event: "SimpleNftCreated", - }; - - // @ts-expect-error - "SimpleNftCreated" is not an event of "Gravatar" - const _bad1: UnionOpts = { - contract: "Gravatar", - event: "SimpleNftCreated", - }; - // @ts-expect-error - "NewGravatar" is not an event of "NftFactory" - const _bad2: UnionOpts = { - contract: "NftFactory", - event: "NewGravatar", - }; - }); - - // Type-level surface checks for `indexer.onBlock`. These assertions are - // cheap (no runtime), but they catch regressions where the `where`-return - // type widens back to include `void`/`undefined` — which the runtime no - // longer silently accepts, so the TS type must stay strict. - it("indexer.onBlock exists on the indexer (value-level)", () => { - expectType(indexer.onBlock); - // Smoke-check the options shape by constructing one. If `where` ever - // loses `void` exclusion, the implicit-return negative test below - // catches it; this ensures the `name` + `where` shape still type-checks. - const _opts: Parameters[0] = { - name: "someBlockHandler", - where: ({ chain }) => (chain.id === 1 ? true : false), - }; - expectType[0]>(_opts); - }); - - it("EvmOnBlockWhereResult excludes void/undefined", () => { - // Narrow intent: the result type is exactly `boolean | EvmOnBlockFilter`. - expectType>( - true - ); - // Negative: a function whose body omits a return path is rejected. - // `noImplicitReturns`/the declared return type catches the missing - // return, so the assignment below should fail to type-check. - type _Predicate = (args: { - readonly chain: { readonly id: number }; - }) => EvmOnBlockWhereResult; - // @ts-expect-error - implicit undefined return is not assignable - const _missingReturn: _Predicate = ({ chain }) => { - if (chain.id === 1) return true; - // Falls through with no return — must be a type error. - }; - }); - - it("EvmOnBlockFilter accepts partial/empty shapes (strict-key checks live in the runtime schema)", () => { - // The TS type uses `?:` for every field and doesn't catch typos - // (e.g. `_gt` is fine to TS). The runtime `S.strict` on - // `blockRangeSchema` is what rejects them with a clear error. This - // assertion documents that split: TS validates *shape*, runtime - // validates *keys*. - const _ok: EvmOnBlockFilter = { - block: { number: { _gte: 1, _lte: 10, _every: 2 } }, - }; - const _partial: EvmOnBlockFilter = { block: { number: { _gte: 1 } } }; - const _empty: EvmOnBlockFilter = {}; - expectType(_ok); - expectType(_partial); - expectType(_empty); - }); -}); - -describe("Schema-bound types: Entity / EntityName / Enum / EnumName", () => { - it("EntityName accepts schema entity names and rejects others", () => { - // Positive: known entities are assignable to EntityName. - const _user: EntityName = "User"; - const _gravatar: EntityName = "Gravatar"; - expectType(_user); - expectType(_gravatar); - - // @ts-expect-error - "NotAnEntity" is not in the schema - const _bad: EntityName = "NotAnEntity"; - }); - - it("Entity resolves to the per-entity shape", () => { - // Entity<"User"> is the same shape as the per-entity alias `User`. - expectType, User>>(true); - - // Spot-check a couple of fields on the resolved type. - expectType["id"], string>>(true); - expectType< - TypeEqual["accountType"], "ADMIN" | "USER"> - >(true); - expectType< - TypeEqual["gravatar_id"], string | undefined> - >(true); - - // @ts-expect-error - "NotAnEntity" is not assignable to EntityName - type _bad = Entity<"NotAnEntity">; - }); - - it("EnumName accepts schema enum names and rejects others", () => { - const _account: EnumName = "AccountType"; - const _size: EnumName = "GravatarSize"; - expectType(_account); - expectType(_size); - - // @ts-expect-error - "NotAnEnum" is not in the schema - const _bad: EnumName = "NotAnEnum"; - }); - - it("Enum resolves to the schema enum's value union", () => { - expectType, "ADMIN" | "USER">>(true); - expectType< - TypeEqual, "SMALL" | "MEDIUM" | "LARGE"> - >(true); - - // @ts-expect-error - "NotAnEnum" is not assignable to EnumName - type _bad = Enum<"NotAnEnum">; - }); -}); - -describe("Config-bound types: EvmContractName", () => { - it("EvmContractName is the union of configured EVM contract names", () => { - expectType< - TypeEqual< - EvmContractName, - | "NftFactory" - | "EventFiltersTest" - | "SimpleNft" - | "TestEvents" - | "Gravatar" - | "Noop" - > - >(true); - - // @ts-expect-error - "NotAContract" is not configured - const _bad: EvmContractName = "NotAContract"; - }); -}); - -describe("EvmOnBlock surface: Args / Context / Handler / WhereArgs", () => { - it("EvmOnBlockContext is an alias of EvmOnEventContext", () => { - expectType>(true); - }); - - it("EvmOnBlockHandlerArgs has block.number and the block context", () => { - expectType< - TypeEqual - >(true); - expectType< - TypeEqual - >(true); - }); - - it("EvmOnBlockHandler is an async function from args to void", () => { - expectType< - TypeEqual< - EvmOnBlockHandler, - (args: EvmOnBlockHandlerArgs) => Promise - > - >(true); - }); - - it("EvmOnBlockWhereArgs.chain exposes id and per-contract handles", () => { - // chain.id narrows to the configured EVM chain-id union. - expectType>(true); - expectType>( - true - ); - // Configured contracts are reachable on the chain handle. - expectType< - TypeEqual - >(true); - expectType< - TypeEqual - >(true); - }); }); From 044128306cf6ed58f40e6371fe80bc17fa01b25f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 10:18:55 +0000 Subject: [PATCH 2/4] Extend TypeScript API type coverage to Fuel and SVM The consolidated type-surface test only covered EVM, leaving nearly every exposed Fuel and SVM type untested at the type level. Drive the same InternalTestIndexer.fromUserApi harness over Fuel and SVM mock configs: - Fuel: a small mock backed by a real Sway greeter ABI (fixture) covers FuelChainId/Name, FuelContractName, FuelEvent (+ height/id/time block, Fuel transaction), the onEvent/contractRegister option/handler/context types, the onBlock/where surface keyed on block.height, and the indexer registration guards (including block.number rejection). - SVM: an inline program/instruction mock covers SvmChainId/Name, the onSlot surface, the config-independent instruction named types (SvmInstruction, SvmInstructionParams/Block, SvmLog, SvmTokenBalance), SvmTransaction field selection, and onInstruction option/handler with args/accounts narrowing. Also close remaining EVM gaps: Effect/EffectOptions/RateLimit, getWhere filters, the dynamic where callback (EvmOnEventWhereChain/Args/Filter and indexed-param where.params narrowing), SingleOrMultiple, Logger, and the Indexer/TestIndexer/TestHelpers instance surface. 86/90 exposed symbols are now referenced; the remaining four (Global, Prettify, and the IndexerFromConfig/TestIndexerFromConfig generics behind the Indexer/ TestIndexer aliases) are internal or exercised transitively. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N8ssQQn6qQ2WMKCTci7iGJ --- .../test/TypeScriptApiTypes_test.res | 631 +++++++++++++++++- .../test/helpers/FuelAbiFixtures.res | 189 ++++++ 2 files changed, 819 insertions(+), 1 deletion(-) create mode 100644 packages/envio-tests/test/helpers/FuelAbiFixtures.res diff --git a/packages/envio-tests/test/TypeScriptApiTypes_test.res b/packages/envio-tests/test/TypeScriptApiTypes_test.res index e30df48afd..205ae05e6a 100644 --- a/packages/envio-tests/test/TypeScriptApiTypes_test.res +++ b/packages/envio-tests/test/TypeScriptApiTypes_test.res @@ -72,6 +72,54 @@ chains: // thrown as the test failure, so each `it` only needs to hand over a snippet. let check = handlers => InternalTestIndexer.fromUserApi(~schema, ~handlers, ~configYaml)->ignore +// Fuel needs a real Sway ABI (parsed by the `fuels` crate), so a known-good +// greeter ABI is supplied as a virtual file. +let fuelFiles = Dict.fromArray([("abis/greeter-abi.json", FuelAbiFixtures.greeter)]) +let fuelConfig = ` +name: fuel-api-types +ecosystem: fuel +chains: + - id: 0 + start_block: 0 + contracts: + - name: Greeter + address: 0xb9bc445e5696c966dcf7e5d1237bd03c04e3ba6929bdaedfeebc7aae784c3a0b + abi_file_path: abis/greeter-abi.json + events: + - name: NewGreeting + - name: ClearGreeting +` + +let svmConfig = ` +name: svm-api-types +ecosystem: svm +chains: + - start_block: 0 + experimental: + hypersync_config: + url: https://solana.hypersync.xyz + programs: + - name: Swapper + program_id: 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 + instructions: + - name: swap + discriminator: "0x09" + args: + - { name: amountIn, type: u64 } + - { name: minAmountOut, type: u64 } + accounts: + - source + - destination + field_selection: + transaction_fields: [signatures] +` + +let checkFuel = handlers => + InternalTestIndexer.fromUserApi(~schema, ~files=fuelFiles, ~handlers, ~configYaml=fuelConfig)->ignore + +let checkSvm = handlers => + InternalTestIndexer.fromUserApi(~schema, ~handlers, ~configYaml=svmConfig)->ignore + describe("TypeScript API types", () => { it("resolves config-bound chain/contract name and id unions", _ => check(` @@ -111,13 +159,27 @@ expectType< it("looks up EvmEvent by contract and event name", _ => check(` -import type { Address, EvmChainId, EvmEvent } from "envio"; +import type { + Address, + EvmChainId, + EvmEvent, + EvmOnEvent, + EvmOnEventWhereChain, +} from "envio"; import { expectType, type TypeEqual } from "ts-expect"; // Without generics, the discriminant spans every configured event. type AllEvents = EvmEvent; expectType>(true); +// EvmOnEvent is the config-generic form behind the EvmEvent alias. +expectType>(true); + +// The where-callback chain object exposes the event's own contract addresses. +expectType< + TypeEqual["Token"]["addresses"], readonly Address[]> +>(true); + type TokenEvent = EvmEvent<"Token">; expectType>(true); @@ -319,6 +381,7 @@ import type { EvmOnBlockFilter, EvmOnBlockHandler, EvmOnBlockHandlerArgs, + EvmOnBlockOptions, EvmOnBlockWhereArgs, EvmOnBlockWhereResult, EvmOnEventContext, @@ -326,6 +389,11 @@ import type { import { expectType, type TypeEqual } from "ts-expect"; expectType>(true); +const _blockOpts: EvmOnBlockOptions = { + name: "b", + where: ({ chain }) => (chain.id === 1 ? true : false), +}; +expectType(_blockOpts); expectType< TypeEqual >(true); @@ -450,6 +518,567 @@ if (0) { async () => {}, ); } +`) + ) + + it("narrows onEvent where.params by indexed event params", _ => + check(` +import { indexer } from "envio"; +import type { Address, EvmOnEventWhere, SingleOrMultiple } from "envio"; +import { expectType } from "ts-expect"; + +const ZERO: Address = "0x0000000000000000000000000000000000000000"; + +// Transfer's indexed params (from/to) resolve the where.params filter, each +// accepting a single value or an array (OR semantics). +type TransferWhere = EvmOnEventWhere< + { + readonly from?: SingleOrMultiple
; + readonly to?: SingleOrMultiple
; + }, + "Token" +>; +const _single: TransferWhere = { params: { from: ZERO } }; +const _multi: TransferWhere = { params: { from: [ZERO], to: [ZERO] } }; +expectType(_single); +expectType(_multi); + +if (0) { + indexer.onEvent( + { contract: "Token", event: "Transfer", wildcard: true, where: { params: { from: ZERO } } }, + async () => {}, + ); + indexer.onEvent( + { contract: "Token", event: "Transfer", wildcard: true, where: { params: { to: [ZERO] } } }, + async () => {}, + ); + indexer.onEvent( + { + contract: "Token", + event: "Transfer", + wildcard: true, + // @ts-expect-error - value is not an indexed param, so it isn't filterable + where: { params: { value: 1n } }, + }, + async () => {}, + ); +} +`) + ) + + it("binds the Indexer / TestIndexer instances and TestHelpers", _ => + check(` +import { createTestIndexer, indexer, TestHelpers } from "envio"; +import type { + Account, + Indexer, + TestIndexer, + TestIndexerProcessConfig, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType>(true); +expectType TestIndexer>>(true); +expectType>(true); +expectType>(true); + +// The test indexer exposes entity operations bound to the schema. +expectType< + TypeEqual void> +>(true); + +// process() config is keyed by chain id. +const _proc: TestIndexerProcessConfig = { chains: { 1: { startBlock: 0 } } }; +expectType(_proc); + +expectType< + TypeEqual +>(true); +`) + ) +}) + +describe("Fuel API types", () => { + it("resolves config-bound Fuel chain/contract name and id unions", _ => + checkFuel(` +import type { FuelChainId, FuelChainName, FuelContractName } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType>(true); +expectType>(true); +expectType>(true); + +// @ts-expect-error - "NotAContract" is not configured +const _bad: FuelContractName = "NotAContract"; +`) + ) + + it("looks up FuelEvent and its Fuel-specific block/transaction", _ => + checkFuel(` +import type { FuelChainId, FuelEvent } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +type AllEvents = FuelEvent; +expectType>(true); + +type NewGreeting = FuelEvent<"Greeter", "NewGreeting">; +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); + +// Fuel blocks are keyed by height (not number) and carry an id + time. +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +`) + ) + + it("shapes Fuel onEvent / contractRegister options, handlers and contexts", _ => + checkFuel(` +import type { + Account, + Address, + FuelContractRegisterContext, + FuelContractRegisterHandler, + FuelContractRegisterOptions, + FuelEvent, + FuelOnEventContext, + FuelOnEventHandler, + FuelOnEventOptions, + FuelOnEventWhere, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +type NewGreeting = FuelEvent<"Greeter", "NewGreeting">; + +// Fuel has no indexed params, so the eventFilters lookup resolves to {}. +expectType< + TypeEqual< + FuelOnEventOptions, + { + readonly contract: "Greeter"; + readonly event: "NewGreeting"; + readonly wildcard?: boolean; + readonly where?: FuelOnEventWhere<{}, "Greeter">; + } + > +>(true); + +expectType< + TypeEqual< + FuelContractRegisterOptions, + FuelOnEventOptions + > +>(true); + +expectType< + TypeEqual< + FuelOnEventHandler, + (args: { event: NewGreeting; context: FuelOnEventContext }) => Promise + > +>(true); +expectType< + TypeEqual< + FuelContractRegisterHandler, + (args: { + event: NewGreeting; + context: FuelContractRegisterContext; + }) => Promise + > +>(true); + +expectType>(true); +expectType>(true); +expectType< + TypeEqual void> +>(true); + +expectType>(true); +expectType< + TypeEqual< + FuelContractRegisterContext["chain"]["Greeter"]["add"], + (address: Address) => void + > +>(true); +`) + ) + + it("keys the Fuel onBlock / where surface on block.height", _ => + checkFuel(` +import type { + Address, + FuelChainId, + FuelOnBlockContext, + FuelOnBlockFilter, + FuelOnBlockHandler, + FuelOnBlockHandlerArgs, + FuelOnBlockOptions, + FuelOnBlockWhereArgs, + FuelOnBlockWhereResult, + FuelOnEvent, + FuelOnEventContext, + FuelOnEventWhere, + FuelOnEventWhereArgs, + FuelOnEventWhereChain, + FuelOnEventWhereFilter, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType>(true); + +// FuelOnEvent is the config-generic form behind the FuelEvent alias. +expectType>(true); + +const _fBlockOpts: FuelOnBlockOptions = { + name: "b", + where: ({ chain }) => (chain.id === 0 ? true : false), +}; +expectType(_fBlockOpts); + +// The Fuel where-callback surface mirrors EVM but filters on block.height. +expectType["id"], number>>(true); +expectType< + TypeEqual< + FuelOnEventWhereArgs<"Greeter">["chain"]["Greeter"]["addresses"], + readonly Address[] + > +>(true); +const _fFilter: FuelOnEventWhereFilter<{}> = { block: { height: { _gte: 1 } } }; +expectType>(_fFilter); +expectType< + TypeEqual +>(true); +expectType>(true); +expectType< + TypeEqual Promise> +>(true); +expectType>(true); +expectType>(true); + +const _ok: FuelOnBlockFilter = { + block: { height: { _gte: 1, _lte: 10, _every: 2 } }, +}; +expectType(_ok); + +// Fuel event filters narrow block.height with _gte only. +const _where: FuelOnEventWhere<{}, "Greeter"> = { + block: { height: { _gte: 1 } }, +}; +expectType>(_where); +`) + ) + + it("guards the Fuel indexer registration surface", _ => + checkFuel(` +import { indexer } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +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 Greeter + { contract: "Greeter", event: "BadEvent" }, + async () => {}, + ); + indexer.onEvent( + { contract: "Greeter", event: "NewGreeting" }, + async ({ event }) => { + expectType>(true); + }, + ); + indexer.contractRegister( + { contract: "Greeter", event: "ClearGreeting" }, + async ({ context }) => { + context.chain.Greeter.add("0x0"); + }, + ); + indexer.onBlock( + { name: "fuelBlock", where: ({ chain }) => (chain.id === 0 ? true : false) }, + async ({ block }) => { + expectType>(true); + }, + ); + indexer.onEvent( + { + contract: "Greeter", + event: "NewGreeting", + wildcard: true, + // @ts-expect-error - Fuel keys block by \`height\`, not \`number\`. + where: { block: { number: { _gte: 1 } } }, + }, + async () => {}, + ); +} +`) + ) +}) + +describe("SVM API types", () => { + it("resolves config-bound SVM chain name and id unions", _ => + checkSvm(` +import type { SvmChainId, SvmChainName } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType>(true); +expectType>(true); +`) + ) + + it("shapes the onSlot surface", _ => + checkSvm(` +import type { + Account, + SvmChainId, + SvmOnSlotContext, + SvmOnSlotFilter, + SvmOnSlotHandler, + SvmOnSlotHandlerArgs, + SvmOnSlotOptions, + SvmOnSlotWhereArgs, + SvmOnSlotWhereResult, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType>(true); + +const _slotOpts: SvmOnSlotOptions = { + name: "s", + where: ({ chain }) => (chain.id === 0 ? true : false), +}; +expectType(_slotOpts); +expectType>(true); +expectType< + TypeEqual void> +>(true); + +expectType>(true); +expectType>(true); +expectType< + TypeEqual Promise> +>(true); + +expectType>(true); +expectType>(true); + +const _ok: SvmOnSlotFilter = { slot: { _gte: 1, _lte: 10, _every: 2 } }; +const _empty: SvmOnSlotFilter = {}; +expectType(_ok); +expectType(_empty); +`) + ) + + it("shapes the config-independent instruction named types", _ => + checkSvm(` +import type { + SvmInstruction, + SvmInstructionBlock, + SvmInstructionParams, + SvmLog, + SvmTokenBalance, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); + +expectType>(true); +expectType>(true); +expectType< + TypeEqual>> +>(true); +expectType< + TypeEqual +>(true); + +expectType>(true); +expectType>(true); +expectType>(true); + +expectType>(true); +expectType>(true); +`) + ) + + it("shapes onInstruction options / handler and narrows params from config", _ => + checkSvm(` +import type { + SvmOnInstructionHandler, + SvmOnInstructionHandlerArgs, + SvmOnInstructionOptions, + SvmOnSlotContext, + SvmTransaction, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType< + TypeEqual< + SvmOnInstructionOptions<"Swapper", "swap">, + { readonly program: "Swapper"; readonly instruction: "swap" } + > +>(true); +expectType< + TypeEqual +>(true); +expectType< + TypeEqual< + SvmOnInstructionHandler, + (args: SvmOnInstructionHandlerArgs) => Promise + > +>(true); + +// The configured instruction selects the signatures transaction field. +expectType>(true); +`) + ) + + it("guards the SVM indexer registration surface", _ => + checkSvm(` +import { indexer } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +if (0) { + indexer.onSlot( + { name: "everySlot", where: ({ chain }) => (chain.id === 0 ? true : false) }, + async ({ slot }) => { + expectType>(true); + }, + ); + indexer.onInstruction( + // @ts-expect-error - "BadProgram" is not a configured program + { program: "BadProgram", instruction: "swap" }, + async () => {}, + ); + indexer.onInstruction( + { program: "Swapper", instruction: "swap" }, + async ({ instruction }) => { + expectType>(true); + if (instruction.params) { + expectType>(true); + expectType>(true); + } + }, + ); +} +`) + ) +}) + +describe("Effect and utility types", () => { + it("infers createEffect input/output and Effect handles", _ => + check(` +import { createEffect, S } from "envio"; +import type { + Effect, + EffectArgs, + EffectCaller, + EffectChain, + EffectContext, + EffectOptions, + Logger, + RateLimit, + RateLimitDuration, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +const getBalance = createEffect( + { + name: "getBalance", + input: { address: S.address, blockNumber: S.optional(S.bigint) }, + output: S.bigint, + rateLimit: false, + }, + async ({ input, context }) => { + expectType< + TypeEqual< + typeof input, + { address: \`0x\${string}\`; blockNumber?: bigint | undefined } + > + >(true); + expectType>(true); + expectType>(true); + expectType>(true); + // @ts-expect-error - input is required for a non-undefined schema + await context.effect(getBalance, undefined); + return input.blockNumber ?? 0n; + }, +); + +expectType< + TypeEqual< + typeof getBalance, + Effect<{ address: \`0x\${string}\`; blockNumber?: bigint | undefined }, bigint> + > +>(true); + +// Ecosystem-agnostic surface aliases. +expectType>(true); +expectType["input"], number>>(true); +expectType>(true); +const _rlOff: RateLimit = false; +const _rl: RateLimit = { calls: 1, per: "second" }; +const _dur: RateLimitDuration = "minute"; +expectType(_rlOff); +expectType(_rl); +expectType(_dur); +expectType["name"], string>>(true); +expectType["rateLimit"], RateLimit>>(true); +`) + ) + + it("shapes getWhere filters, the dynamic where callback, and misc aliases", _ => + check(` +import type { + Account, + Address, + EvmOnEventWhere, + EvmOnEventWhereArgs, + EvmOnEventWhereFilter, + GetWhereFilter, + GetWhereOperator, + Logger, + SingleOrMultiple, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +// getWhere operators/filters over an entity type. +const _op: GetWhereOperator = { _gte: 1n, _in: [1n, 2n] }; +expectType>(_op); +const _filter: GetWhereFilter = { balance: { _gt: 0n }, id: { _eq: "x" } }; +expectType>(_filter); + +// The dynamic where callback form exposes the event's own contract addresses. +type Args = EvmOnEventWhereArgs<"Token">; +expectType>(true); +expectType< + TypeEqual +>(true); +const _cb: EvmOnEventWhere<{}, "Token"> = ({ chain }) => + chain.id === 1 ? true : { block: { number: { _gte: 1 } } }; +expectType>(_cb); +const _staticFilter: EvmOnEventWhereFilter<{}> = { block: { number: { _gte: 1 } } }; +expectType>(_staticFilter); + +// SingleOrMultiple accepts a value or a readonly array of it. +const _single: SingleOrMultiple
= "0x0"; +const _multi: SingleOrMultiple
= ["0x0", "0x1"]; +expectType>(_single); +expectType>(_multi); + +expectType< + TypeEqual | Error) => void> +>(true); `) ) }) diff --git a/packages/envio-tests/test/helpers/FuelAbiFixtures.res b/packages/envio-tests/test/helpers/FuelAbiFixtures.res new file mode 100644 index 0000000000..c38e03da18 --- /dev/null +++ b/packages/envio-tests/test/helpers/FuelAbiFixtures.res @@ -0,0 +1,189 @@ +let greeter = `{ + "programType": "contract", + "specVersion": "1", + "encodingVersion": "1", + "concreteTypes": [ + { + "type": "()", + "concreteTypeId": "2e38e77b22c314a449e91fafed92a43826ac6aa403ae6a8acb6cf58239fbaf5d" + }, + { + "type": "enum Error", + "concreteTypeId": "4ed298ed0be3fc65895c5d5263885191731caa9f79ff63e56d98b449e5ba4b3f", + "metadataTypeId": 1 + }, + { + "type": "enum std::option::Option", + "concreteTypeId": "6131325e56e0a740b11c011fd2f7e67e7104a223cad4ddd4c4033d9fe1fe9768", + "metadataTypeId": 2, + "typeArguments": [ + "270fc75adb9a9c47c2092f42f632ea79d1f5e558793ff90fcde067ff3a899ccf" + ] + }, + { + "type": "str[8]", + "concreteTypeId": "fd59cdd2c531ab3564f4fcc13eb876860d4d7cd4d12aa1f042a5aeceacde951f" + }, + { + "type": "struct ClearGreeting", + "concreteTypeId": "3e3ffd83393027a4af2a81a6fd031daba84882b4117a89be43a798f807322e98", + "metadataTypeId": 4 + }, + { + "type": "struct Greeting", + "concreteTypeId": "270fc75adb9a9c47c2092f42f632ea79d1f5e558793ff90fcde067ff3a899ccf", + "metadataTypeId": 5 + }, + { + "type": "struct NewGreeting", + "concreteTypeId": "4d8812009a01dfce9ead2049933d9eb736fadce2d22525e8f451c3f5c00fc4fe", + "metadataTypeId": 6 + } + ], + "metadataTypes": [ + { + "type": "b256", + "metadataTypeId": 0 + }, + { + "type": "enum Error", + "metadataTypeId": 1, + "components": [ + { + "name": "InvalidContractSender", + "typeId": "2e38e77b22c314a449e91fafed92a43826ac6aa403ae6a8acb6cf58239fbaf5d" + }, + { + "name": "ToThrow", + "typeId": "2e38e77b22c314a449e91fafed92a43826ac6aa403ae6a8acb6cf58239fbaf5d" + } + ] + }, + { + "type": "enum std::option::Option", + "metadataTypeId": 2, + "components": [ + { + "name": "None", + "typeId": "2e38e77b22c314a449e91fafed92a43826ac6aa403ae6a8acb6cf58239fbaf5d" + }, + { + "name": "Some", + "typeId": 3 + } + ], + "typeParameters": [3] + }, + { + "type": "generic T", + "metadataTypeId": 3 + }, + { + "type": "struct ClearGreeting", + "metadataTypeId": 4, + "components": [ + { + "name": "user", + "typeId": 7 + } + ] + }, + { + "type": "struct Greeting", + "metadataTypeId": 5, + "components": [ + { + "name": "value", + "typeId": "fd59cdd2c531ab3564f4fcc13eb876860d4d7cd4d12aa1f042a5aeceacde951f" + } + ] + }, + { + "type": "struct NewGreeting", + "metadataTypeId": 6, + "components": [ + { + "name": "user", + "typeId": 7 + }, + { + "name": "greeting", + "typeId": 5 + } + ] + }, + { + "type": "struct std::address::Address", + "metadataTypeId": 7, + "components": [ + { + "name": "bits", + "typeId": 0 + } + ] + } + ], + "functions": [ + { + "inputs": [], + "name": "clear_greeting", + "output": "2e38e77b22c314a449e91fafed92a43826ac6aa403ae6a8acb6cf58239fbaf5d", + "attributes": [ + { + "name": "storage", + "arguments": ["write"] + } + ] + }, + { + "inputs": [], + "name": "current_greeting", + "output": "6131325e56e0a740b11c011fd2f7e67e7104a223cad4ddd4c4033d9fe1fe9768", + "attributes": [ + { + "name": "storage", + "arguments": ["read"] + } + ] + }, + { + "inputs": [ + { + "name": "greeting", + "concreteTypeId": "fd59cdd2c531ab3564f4fcc13eb876860d4d7cd4d12aa1f042a5aeceacde951f" + } + ], + "name": "set_greeting", + "output": "2e38e77b22c314a449e91fafed92a43826ac6aa403ae6a8acb6cf58239fbaf5d", + "attributes": [ + { + "name": "storage", + "arguments": ["write"] + } + ] + }, + { + "inputs": [], + "name": "throw_error", + "output": "2e38e77b22c314a449e91fafed92a43826ac6aa403ae6a8acb6cf58239fbaf5d", + "attributes": null + } + ], + "loggedTypes": [ + { + "logId": "4485582493926303652", + "concreteTypeId": "3e3ffd83393027a4af2a81a6fd031daba84882b4117a89be43a798f807322e98" + }, + { + "logId": "5679770223941778533", + "concreteTypeId": "4ed298ed0be3fc65895c5d5263885191731caa9f79ff63e56d98b449e5ba4b3f" + }, + { + "logId": "5586735131546214350", + "concreteTypeId": "4d8812009a01dfce9ead2049933d9eb736fadce2d22525e8f451c3f5c00fc4fe" + } + ], + "messagesTypes": [], + "configurables": [] +} +` From 657131b2df00aa95b4aba5b484c727a5846d1ee3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 11:29:04 +0000 Subject: [PATCH 3/4] Split TypeScript API type tests by ecosystem; deepen Fuel/SVM Split the single TypeScriptApiTypes_test.res into per-ecosystem files (EVM/Fuel/SVM), each with its own mock config and a shared schema fixture (helpers/ApiTypesFixtures.res). The EVM file keeps the ecosystem-agnostic Effect/utility block. Deepen Fuel and SVM so every exposed type of each ecosystem is asserted: - Fuel: decoded Sway struct params (NewGreeting/ClearGreeting), the contractRegister context's absence of entity ops, the dynamic where callback form, and schema-bound Entity/Enum under a Fuel config. - SVM: instruction extras (instructionAddress, d1/d8, logs), the FieldNotSelected sentinel on unselected SvmTransaction fields, the onSlot context entity getter, a bad-instruction-name negative, and schema-bound Entity/Enum under an SVM config. All 22 Fuel and 18 SVM exposed types are now covered (86/90 overall; the remaining Global/Prettify/IndexerFromConfig/TestIndexerFromConfig are internal or exercised transitively via the Indexer/TestIndexer aliases). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N8ssQQn6qQ2WMKCTci7iGJ --- ...est.res => TypeScriptApiTypesEvm_test.res} | 460 +----------------- .../test/TypeScriptApiTypesFuel_test.res | 277 +++++++++++ .../test/TypeScriptApiTypesSvm_test.res | 220 +++++++++ .../test/helpers/ApiTypesFixtures.res | 27 + 4 files changed, 526 insertions(+), 458 deletions(-) rename packages/envio-tests/test/{TypeScriptApiTypes_test.res => TypeScriptApiTypesEvm_test.res} (59%) create mode 100644 packages/envio-tests/test/TypeScriptApiTypesFuel_test.res create mode 100644 packages/envio-tests/test/TypeScriptApiTypesSvm_test.res create mode 100644 packages/envio-tests/test/helpers/ApiTypesFixtures.res diff --git a/packages/envio-tests/test/TypeScriptApiTypes_test.res b/packages/envio-tests/test/TypeScriptApiTypesEvm_test.res similarity index 59% rename from packages/envio-tests/test/TypeScriptApiTypes_test.res rename to packages/envio-tests/test/TypeScriptApiTypesEvm_test.res index 205ae05e6a..0d5e3619e0 100644 --- a/packages/envio-tests/test/TypeScriptApiTypes_test.res +++ b/packages/envio-tests/test/TypeScriptApiTypesEvm_test.res @@ -1,34 +1,5 @@ open Vitest -// A self-contained mock indexer whose generated `envio` types back every -// assertion below. Two chains, two contracts (one event carries a custom -// `field_selection`, the rest inherit the global one), plus a schema with -// entities and enums — enough surface to pin the whole public TypeScript API. -let schema = ` -enum AccountType { - ADMIN - USER -} - -enum GravatarSize { - SMALL - MEDIUM - LARGE -} - -type Account { - id: ID! - balance: BigInt! - accountType: AccountType! - delegate: Account -} - -type Delegation { - id: ID! - amount: BigInt! -} -` - let configYaml = ` name: ts-api-types field_selection: @@ -68,59 +39,9 @@ chains: address: "0x0000000000000000000000000000000000000004" ` -// Type-checks `handlers` against the mock's generated types. A type error is -// thrown as the test failure, so each `it` only needs to hand over a snippet. -let check = handlers => InternalTestIndexer.fromUserApi(~schema, ~handlers, ~configYaml)->ignore - -// Fuel needs a real Sway ABI (parsed by the `fuels` crate), so a known-good -// greeter ABI is supplied as a virtual file. -let fuelFiles = Dict.fromArray([("abis/greeter-abi.json", FuelAbiFixtures.greeter)]) -let fuelConfig = ` -name: fuel-api-types -ecosystem: fuel -chains: - - id: 0 - start_block: 0 - contracts: - - name: Greeter - address: 0xb9bc445e5696c966dcf7e5d1237bd03c04e3ba6929bdaedfeebc7aae784c3a0b - abi_file_path: abis/greeter-abi.json - events: - - name: NewGreeting - - name: ClearGreeting -` - -let svmConfig = ` -name: svm-api-types -ecosystem: svm -chains: - - start_block: 0 - experimental: - hypersync_config: - url: https://solana.hypersync.xyz - programs: - - name: Swapper - program_id: 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 - instructions: - - name: swap - discriminator: "0x09" - args: - - { name: amountIn, type: u64 } - - { name: minAmountOut, type: u64 } - accounts: - - source - - destination - field_selection: - transaction_fields: [signatures] -` +let check = handlers => InternalTestIndexer.fromUserApi(~schema=ApiTypesFixtures.schema, ~handlers, ~configYaml)->ignore -let checkFuel = handlers => - InternalTestIndexer.fromUserApi(~schema, ~files=fuelFiles, ~handlers, ~configYaml=fuelConfig)->ignore - -let checkSvm = handlers => - InternalTestIndexer.fromUserApi(~schema, ~handlers, ~configYaml=svmConfig)->ignore - -describe("TypeScript API types", () => { +describe("EVM API types", () => { it("resolves config-bound chain/contract name and id unions", _ => check(` import type { @@ -598,383 +519,6 @@ expectType< ) }) -describe("Fuel API types", () => { - it("resolves config-bound Fuel chain/contract name and id unions", _ => - checkFuel(` -import type { FuelChainId, FuelChainName, FuelContractName } from "envio"; -import { expectType, type TypeEqual } from "ts-expect"; - -expectType>(true); -expectType>(true); -expectType>(true); - -// @ts-expect-error - "NotAContract" is not configured -const _bad: FuelContractName = "NotAContract"; -`) - ) - - it("looks up FuelEvent and its Fuel-specific block/transaction", _ => - checkFuel(` -import type { FuelChainId, FuelEvent } from "envio"; -import { expectType, type TypeEqual } from "ts-expect"; - -type AllEvents = FuelEvent; -expectType>(true); - -type NewGreeting = FuelEvent<"Greeter", "NewGreeting">; -expectType>(true); -expectType>(true); -expectType>(true); -expectType>(true); -expectType>(true); - -// Fuel blocks are keyed by height (not number) and carry an id + time. -expectType>(true); -expectType>(true); -expectType>(true); -expectType>(true); -`) - ) - - it("shapes Fuel onEvent / contractRegister options, handlers and contexts", _ => - checkFuel(` -import type { - Account, - Address, - FuelContractRegisterContext, - FuelContractRegisterHandler, - FuelContractRegisterOptions, - FuelEvent, - FuelOnEventContext, - FuelOnEventHandler, - FuelOnEventOptions, - FuelOnEventWhere, -} from "envio"; -import { expectType, type TypeEqual } from "ts-expect"; - -type NewGreeting = FuelEvent<"Greeter", "NewGreeting">; - -// Fuel has no indexed params, so the eventFilters lookup resolves to {}. -expectType< - TypeEqual< - FuelOnEventOptions, - { - readonly contract: "Greeter"; - readonly event: "NewGreeting"; - readonly wildcard?: boolean; - readonly where?: FuelOnEventWhere<{}, "Greeter">; - } - > ->(true); - -expectType< - TypeEqual< - FuelContractRegisterOptions, - FuelOnEventOptions - > ->(true); - -expectType< - TypeEqual< - FuelOnEventHandler, - (args: { event: NewGreeting; context: FuelOnEventContext }) => Promise - > ->(true); -expectType< - TypeEqual< - FuelContractRegisterHandler, - (args: { - event: NewGreeting; - context: FuelContractRegisterContext; - }) => Promise - > ->(true); - -expectType>(true); -expectType>(true); -expectType< - TypeEqual void> ->(true); - -expectType>(true); -expectType< - TypeEqual< - FuelContractRegisterContext["chain"]["Greeter"]["add"], - (address: Address) => void - > ->(true); -`) - ) - - it("keys the Fuel onBlock / where surface on block.height", _ => - checkFuel(` -import type { - Address, - FuelChainId, - FuelOnBlockContext, - FuelOnBlockFilter, - FuelOnBlockHandler, - FuelOnBlockHandlerArgs, - FuelOnBlockOptions, - FuelOnBlockWhereArgs, - FuelOnBlockWhereResult, - FuelOnEvent, - FuelOnEventContext, - FuelOnEventWhere, - FuelOnEventWhereArgs, - FuelOnEventWhereChain, - FuelOnEventWhereFilter, -} from "envio"; -import { expectType, type TypeEqual } from "ts-expect"; - -expectType>(true); - -// FuelOnEvent is the config-generic form behind the FuelEvent alias. -expectType>(true); - -const _fBlockOpts: FuelOnBlockOptions = { - name: "b", - where: ({ chain }) => (chain.id === 0 ? true : false), -}; -expectType(_fBlockOpts); - -// The Fuel where-callback surface mirrors EVM but filters on block.height. -expectType["id"], number>>(true); -expectType< - TypeEqual< - FuelOnEventWhereArgs<"Greeter">["chain"]["Greeter"]["addresses"], - readonly Address[] - > ->(true); -const _fFilter: FuelOnEventWhereFilter<{}> = { block: { height: { _gte: 1 } } }; -expectType>(_fFilter); -expectType< - TypeEqual ->(true); -expectType>(true); -expectType< - TypeEqual Promise> ->(true); -expectType>(true); -expectType>(true); - -const _ok: FuelOnBlockFilter = { - block: { height: { _gte: 1, _lte: 10, _every: 2 } }, -}; -expectType(_ok); - -// Fuel event filters narrow block.height with _gte only. -const _where: FuelOnEventWhere<{}, "Greeter"> = { - block: { height: { _gte: 1 } }, -}; -expectType>(_where); -`) - ) - - it("guards the Fuel indexer registration surface", _ => - checkFuel(` -import { indexer } from "envio"; -import { expectType, type TypeEqual } from "ts-expect"; - -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 Greeter - { contract: "Greeter", event: "BadEvent" }, - async () => {}, - ); - indexer.onEvent( - { contract: "Greeter", event: "NewGreeting" }, - async ({ event }) => { - expectType>(true); - }, - ); - indexer.contractRegister( - { contract: "Greeter", event: "ClearGreeting" }, - async ({ context }) => { - context.chain.Greeter.add("0x0"); - }, - ); - indexer.onBlock( - { name: "fuelBlock", where: ({ chain }) => (chain.id === 0 ? true : false) }, - async ({ block }) => { - expectType>(true); - }, - ); - indexer.onEvent( - { - contract: "Greeter", - event: "NewGreeting", - wildcard: true, - // @ts-expect-error - Fuel keys block by \`height\`, not \`number\`. - where: { block: { number: { _gte: 1 } } }, - }, - async () => {}, - ); -} -`) - ) -}) - -describe("SVM API types", () => { - it("resolves config-bound SVM chain name and id unions", _ => - checkSvm(` -import type { SvmChainId, SvmChainName } from "envio"; -import { expectType, type TypeEqual } from "ts-expect"; - -expectType>(true); -expectType>(true); -`) - ) - - it("shapes the onSlot surface", _ => - checkSvm(` -import type { - Account, - SvmChainId, - SvmOnSlotContext, - SvmOnSlotFilter, - SvmOnSlotHandler, - SvmOnSlotHandlerArgs, - SvmOnSlotOptions, - SvmOnSlotWhereArgs, - SvmOnSlotWhereResult, -} from "envio"; -import { expectType, type TypeEqual } from "ts-expect"; - -expectType>(true); - -const _slotOpts: SvmOnSlotOptions = { - name: "s", - where: ({ chain }) => (chain.id === 0 ? true : false), -}; -expectType(_slotOpts); -expectType>(true); -expectType< - TypeEqual void> ->(true); - -expectType>(true); -expectType>(true); -expectType< - TypeEqual Promise> ->(true); - -expectType>(true); -expectType>(true); - -const _ok: SvmOnSlotFilter = { slot: { _gte: 1, _lte: 10, _every: 2 } }; -const _empty: SvmOnSlotFilter = {}; -expectType(_ok); -expectType(_empty); -`) - ) - - it("shapes the config-independent instruction named types", _ => - checkSvm(` -import type { - SvmInstruction, - SvmInstructionBlock, - SvmInstructionParams, - SvmLog, - SvmTokenBalance, -} from "envio"; -import { expectType, type TypeEqual } from "ts-expect"; - -expectType>(true); -expectType>(true); -expectType>(true); -expectType>(true); -expectType>(true); -expectType>(true); -expectType>(true); - -expectType>(true); -expectType>(true); -expectType< - TypeEqual>> ->(true); -expectType< - TypeEqual ->(true); - -expectType>(true); -expectType>(true); -expectType>(true); - -expectType>(true); -expectType>(true); -`) - ) - - it("shapes onInstruction options / handler and narrows params from config", _ => - checkSvm(` -import type { - SvmOnInstructionHandler, - SvmOnInstructionHandlerArgs, - SvmOnInstructionOptions, - SvmOnSlotContext, - SvmTransaction, -} from "envio"; -import { expectType, type TypeEqual } from "ts-expect"; - -expectType< - TypeEqual< - SvmOnInstructionOptions<"Swapper", "swap">, - { readonly program: "Swapper"; readonly instruction: "swap" } - > ->(true); -expectType< - TypeEqual ->(true); -expectType< - TypeEqual< - SvmOnInstructionHandler, - (args: SvmOnInstructionHandlerArgs) => Promise - > ->(true); - -// The configured instruction selects the signatures transaction field. -expectType>(true); -`) - ) - - it("guards the SVM indexer registration surface", _ => - checkSvm(` -import { indexer } from "envio"; -import { expectType, type TypeEqual } from "ts-expect"; - -if (0) { - indexer.onSlot( - { name: "everySlot", where: ({ chain }) => (chain.id === 0 ? true : false) }, - async ({ slot }) => { - expectType>(true); - }, - ); - indexer.onInstruction( - // @ts-expect-error - "BadProgram" is not a configured program - { program: "BadProgram", instruction: "swap" }, - async () => {}, - ); - indexer.onInstruction( - { program: "Swapper", instruction: "swap" }, - async ({ instruction }) => { - expectType>(true); - if (instruction.params) { - expectType>(true); - expectType>(true); - } - }, - ); -} -`) - ) -}) - describe("Effect and utility types", () => { it("infers createEffect input/output and Effect handles", _ => check(` diff --git a/packages/envio-tests/test/TypeScriptApiTypesFuel_test.res b/packages/envio-tests/test/TypeScriptApiTypesFuel_test.res new file mode 100644 index 0000000000..303d0adb2c --- /dev/null +++ b/packages/envio-tests/test/TypeScriptApiTypesFuel_test.res @@ -0,0 +1,277 @@ +open Vitest + +let files = Dict.fromArray([("abis/greeter-abi.json", FuelAbiFixtures.greeter)]) + +let configYaml = ` +name: fuel-api-types +ecosystem: fuel +chains: + - id: 0 + start_block: 0 + contracts: + - name: Greeter + address: 0xb9bc445e5696c966dcf7e5d1237bd03c04e3ba6929bdaedfeebc7aae784c3a0b + abi_file_path: abis/greeter-abi.json + events: + - name: NewGreeting + - name: ClearGreeting +` + +let check = handlers => InternalTestIndexer.fromUserApi(~schema=ApiTypesFixtures.schema, ~files, ~handlers, ~configYaml)->ignore + +describe("Fuel API types", () => { + it("resolves config-bound Fuel chain/contract name and id unions", _ => + check(` +import type { FuelChainId, FuelChainName, FuelContractName } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType>(true); +expectType>(true); +expectType>(true); + +// @ts-expect-error - "NotAContract" is not configured +const _bad: FuelContractName = "NotAContract"; +`) + ) + + it("looks up FuelEvent and its Fuel-specific block/transaction", _ => + check(` +import type { FuelChainId, FuelEvent } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +type AllEvents = FuelEvent; +expectType>(true); + +type NewGreeting = FuelEvent<"Greeter", "NewGreeting">; +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); + +// Fuel blocks are keyed by height (not number) and carry an id + time. +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); + +// Log params decode to the ABI's Sway struct shapes. +expectType>(true); +expectType>(true); + +type ClearGreeting = FuelEvent<"Greeter", "ClearGreeting">; +expectType>(true); +expectType>(true); +`) + ) + + it("shapes Fuel onEvent / contractRegister options, handlers and contexts", _ => + check(` +import type { + Account, + Address, + FuelContractRegisterContext, + FuelContractRegisterHandler, + FuelContractRegisterOptions, + FuelEvent, + FuelOnEventContext, + FuelOnEventHandler, + FuelOnEventOptions, + FuelOnEventWhere, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +type NewGreeting = FuelEvent<"Greeter", "NewGreeting">; + +// Fuel has no indexed params, so the eventFilters lookup resolves to {}. +expectType< + TypeEqual< + FuelOnEventOptions, + { + readonly contract: "Greeter"; + readonly event: "NewGreeting"; + readonly wildcard?: boolean; + readonly where?: FuelOnEventWhere<{}, "Greeter">; + } + > +>(true); + +expectType< + TypeEqual< + FuelContractRegisterOptions, + FuelOnEventOptions + > +>(true); + +expectType< + TypeEqual< + FuelOnEventHandler, + (args: { event: NewGreeting; context: FuelOnEventContext }) => Promise + > +>(true); +expectType< + TypeEqual< + FuelContractRegisterHandler, + (args: { + event: NewGreeting; + context: FuelContractRegisterContext; + }) => Promise + > +>(true); + +expectType>(true); +expectType>(true); +expectType< + TypeEqual void> +>(true); + +expectType>(true); +expectType< + TypeEqual< + FuelContractRegisterContext["chain"]["Greeter"]["add"], + (address: Address) => void + > +>(true); + +// contractRegister context exposes no entity operations. +// @ts-expect-error - Account ops are not on the Fuel contractRegister context +type _accountOnCr = FuelContractRegisterContext["Account"]; +`) + ) + + it("keys the Fuel onBlock / where surface on block.height", _ => + check(` +import type { + Address, + FuelChainId, + FuelOnBlockContext, + FuelOnBlockFilter, + FuelOnBlockHandler, + FuelOnBlockHandlerArgs, + FuelOnBlockOptions, + FuelOnBlockWhereArgs, + FuelOnBlockWhereResult, + FuelOnEvent, + FuelOnEventContext, + FuelOnEventWhere, + FuelOnEventWhereArgs, + FuelOnEventWhereChain, + FuelOnEventWhereFilter, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType>(true); + +// FuelOnEvent is the config-generic form behind the FuelEvent alias. +expectType>(true); + +const _fBlockOpts: FuelOnBlockOptions = { + name: "b", + where: ({ chain }) => (chain.id === 0 ? true : false), +}; +expectType(_fBlockOpts); + +// The Fuel where-callback surface mirrors EVM but filters on block.height. +expectType["id"], number>>(true); +expectType< + TypeEqual< + FuelOnEventWhereArgs<"Greeter">["chain"]["Greeter"]["addresses"], + readonly Address[] + > +>(true); +const _fFilter: FuelOnEventWhereFilter<{}> = { block: { height: { _gte: 1 } } }; +expectType>(_fFilter); +expectType< + TypeEqual +>(true); +expectType>(true); +expectType< + TypeEqual Promise> +>(true); +expectType>(true); +expectType>(true); + +const _ok: FuelOnBlockFilter = { + block: { height: { _gte: 1, _lte: 10, _every: 2 } }, +}; +expectType(_ok); + +// Fuel event filters narrow block.height with _gte only. +const _where: FuelOnEventWhere<{}, "Greeter"> = { + block: { height: { _gte: 1 } }, +}; +expectType>(_where); + +// The dynamic callback form returns a filter or a boolean. +const _whereCb: FuelOnEventWhere<{}, "Greeter"> = ({ chain }) => + chain.id === 0 ? true : { block: { height: { _gte: 1 } } }; +expectType>(_whereCb); +`) + ) + + it("guards the Fuel indexer registration surface", _ => + check(` +import { indexer } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +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 Greeter + { contract: "Greeter", event: "BadEvent" }, + async () => {}, + ); + indexer.onEvent( + { contract: "Greeter", event: "NewGreeting" }, + async ({ event }) => { + expectType>(true); + }, + ); + indexer.contractRegister( + { contract: "Greeter", event: "ClearGreeting" }, + async ({ context }) => { + context.chain.Greeter.add("0x0"); + }, + ); + indexer.onBlock( + { name: "fuelBlock", where: ({ chain }) => (chain.id === 0 ? true : false) }, + async ({ block }) => { + expectType>(true); + }, + ); + indexer.onEvent( + { + contract: "Greeter", + event: "NewGreeting", + wildcard: true, + // @ts-expect-error - Fuel keys block by \`height\`, not \`number\`. + where: { block: { number: { _gte: 1 } } }, + }, + async () => {}, + ); +} +`) + ) + + it("binds schema entities and enums under a Fuel config", _ => + check(` +import type { Account, Entity, EntityName, Enum, EnumName } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +const _entity: EntityName = "Account"; +// @ts-expect-error - "NotAnEntity" is not in the schema +const _badEntity: EntityName = "NotAnEntity"; +expectType, Account>>(true); +expectType["accountType"], "ADMIN" | "USER">>(true); + +const _enum: EnumName = "AccountType"; +// @ts-expect-error - "NotAnEnum" is not in the schema +const _badEnum: EnumName = "NotAnEnum"; +expectType, "SMALL" | "MEDIUM" | "LARGE">>(true); +`) + ) +}) diff --git a/packages/envio-tests/test/TypeScriptApiTypesSvm_test.res b/packages/envio-tests/test/TypeScriptApiTypesSvm_test.res new file mode 100644 index 0000000000..45c6080a2f --- /dev/null +++ b/packages/envio-tests/test/TypeScriptApiTypesSvm_test.res @@ -0,0 +1,220 @@ +open Vitest + +let configYaml = ` +name: svm-api-types +ecosystem: svm +chains: + - start_block: 0 + experimental: + hypersync_config: + url: https://solana.hypersync.xyz + programs: + - name: Swapper + program_id: 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 + instructions: + - name: swap + discriminator: "0x09" + args: + - { name: amountIn, type: u64 } + - { name: minAmountOut, type: u64 } + accounts: + - source + - destination + field_selection: + transaction_fields: [signatures] +` + +let check = handlers => InternalTestIndexer.fromUserApi(~schema=ApiTypesFixtures.schema, ~handlers, ~configYaml)->ignore + +describe("SVM API types", () => { + it("resolves config-bound SVM chain name and id unions", _ => + check(` +import type { SvmChainId, SvmChainName } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType>(true); +expectType>(true); +`) + ) + + it("shapes the onSlot surface", _ => + check(` +import type { + Account, + SvmChainId, + SvmOnSlotContext, + SvmOnSlotFilter, + SvmOnSlotHandler, + SvmOnSlotHandlerArgs, + SvmOnSlotOptions, + SvmOnSlotWhereArgs, + SvmOnSlotWhereResult, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType>(true); + +const _slotOpts: SvmOnSlotOptions = { + name: "s", + where: ({ chain }) => (chain.id === 0 ? true : false), +}; +expectType(_slotOpts); +expectType>(true); +expectType< + TypeEqual void> +>(true); +expectType< + TypeEqual< + SvmOnSlotContext["Account"]["get"], + (id: string) => Promise + > +>(true); + +expectType>(true); +expectType>(true); +expectType< + TypeEqual Promise> +>(true); + +expectType>(true); +expectType>(true); + +const _ok: SvmOnSlotFilter = { slot: { _gte: 1, _lte: 10, _every: 2 } }; +const _empty: SvmOnSlotFilter = {}; +expectType(_ok); +expectType(_empty); +`) + ) + + it("shapes the config-independent instruction named types", _ => + check(` +import type { + SvmInstruction, + SvmInstructionBlock, + SvmInstructionParams, + SvmLog, + SvmTokenBalance, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); +expectType>(true); + +expectType>(true); +expectType>(true); +expectType< + TypeEqual>> +>(true); +expectType< + TypeEqual +>(true); + +expectType>(true); +expectType>(true); +expectType>(true); + +expectType>(true); +expectType>(true); +`) + ) + + it("shapes onInstruction options / handler and narrows params from config", _ => + check(` +import type { + SvmOnInstructionHandler, + SvmOnInstructionHandlerArgs, + SvmOnInstructionOptions, + SvmOnSlotContext, + SvmTransaction, +} from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +expectType< + TypeEqual< + SvmOnInstructionOptions<"Swapper", "swap">, + { readonly program: "Swapper"; readonly instruction: "swap" } + > +>(true); +expectType< + TypeEqual +>(true); +expectType< + TypeEqual< + SvmOnInstructionHandler, + (args: SvmOnInstructionHandlerArgs) => Promise + > +>(true); + +// The configured instruction selects the signatures transaction field; +// unselected fields carry the FieldNotSelected sentinel. +type IsNotSelected = T extends { readonly __fieldNotSelected: string } + ? true + : false; +expectType>(true); +expectType>(true); +`) + ) + + it("guards the SVM indexer registration surface", _ => + check(` +import { indexer } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +if (0) { + indexer.onSlot( + { name: "everySlot", where: ({ chain }) => (chain.id === 0 ? true : false) }, + async ({ slot }) => { + expectType>(true); + }, + ); + indexer.onInstruction( + // @ts-expect-error - "BadProgram" is not a configured program + { program: "BadProgram", instruction: "swap" }, + async () => {}, + ); + indexer.onInstruction( + // @ts-expect-error - "badInstr" is not an instruction of Swapper + { program: "Swapper", instruction: "badInstr" }, + async () => {}, + ); + indexer.onInstruction( + { program: "Swapper", instruction: "swap" }, + async ({ instruction }) => { + expectType>(true); + if (instruction.params) { + expectType>(true); + expectType>(true); + } + }, + ); +} +`) + ) + + it("binds schema entities and enums under an SVM config", _ => + check(` +import type { Account, Entity, EntityName, Enum, EnumName } from "envio"; +import { expectType, type TypeEqual } from "ts-expect"; + +const _entity: EntityName = "Account"; +// @ts-expect-error - "NotAnEntity" is not in the schema +const _badEntity: EntityName = "NotAnEntity"; +expectType, Account>>(true); +expectType["accountType"], "ADMIN" | "USER">>(true); + +const _enum: EnumName = "AccountType"; +// @ts-expect-error - "NotAnEnum" is not in the schema +const _badEnum: EnumName = "NotAnEnum"; +expectType, "SMALL" | "MEDIUM" | "LARGE">>(true); +`) + ) +}) diff --git a/packages/envio-tests/test/helpers/ApiTypesFixtures.res b/packages/envio-tests/test/helpers/ApiTypesFixtures.res new file mode 100644 index 0000000000..af4aae51fd --- /dev/null +++ b/packages/envio-tests/test/helpers/ApiTypesFixtures.res @@ -0,0 +1,27 @@ +// Shared GraphQL schema backing the per-ecosystem TypeScript API type tests. +// Entities and enums here let each ecosystem's generated context expose the +// same entity operations and schema-bound aliases (Entity/Enum/…). +let schema = ` +enum AccountType { + ADMIN + USER +} + +enum GravatarSize { + SMALL + MEDIUM + LARGE +} + +type Account { + id: ID! + balance: BigInt! + accountType: AccountType! + delegate: Account +} + +type Delegation { + id: ID! + amount: BigInt! +} +` From 22880a03f01c0c93547348afca7cb8c516d32c0b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 11:34:06 +0000 Subject: [PATCH 4/4] Rename ecosystem type-test files to HandlersApi_test Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N8ssQQn6qQ2WMKCTci7iGJ --- .../{TypeScriptApiTypesEvm_test.res => EvmHandlersApi_test.res} | 0 .../{TypeScriptApiTypesFuel_test.res => FuelHandlersApi_test.res} | 0 .../{TypeScriptApiTypesSvm_test.res => SvmHandlersApi_test.res} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename packages/envio-tests/test/{TypeScriptApiTypesEvm_test.res => EvmHandlersApi_test.res} (100%) rename packages/envio-tests/test/{TypeScriptApiTypesFuel_test.res => FuelHandlersApi_test.res} (100%) rename packages/envio-tests/test/{TypeScriptApiTypesSvm_test.res => SvmHandlersApi_test.res} (100%) diff --git a/packages/envio-tests/test/TypeScriptApiTypesEvm_test.res b/packages/envio-tests/test/EvmHandlersApi_test.res similarity index 100% rename from packages/envio-tests/test/TypeScriptApiTypesEvm_test.res rename to packages/envio-tests/test/EvmHandlersApi_test.res diff --git a/packages/envio-tests/test/TypeScriptApiTypesFuel_test.res b/packages/envio-tests/test/FuelHandlersApi_test.res similarity index 100% rename from packages/envio-tests/test/TypeScriptApiTypesFuel_test.res rename to packages/envio-tests/test/FuelHandlersApi_test.res diff --git a/packages/envio-tests/test/TypeScriptApiTypesSvm_test.res b/packages/envio-tests/test/SvmHandlersApi_test.res similarity index 100% rename from packages/envio-tests/test/TypeScriptApiTypesSvm_test.res rename to packages/envio-tests/test/SvmHandlersApi_test.res