Entities Schema (schema.graphql)
The schema.graphql file defines the data model for your HyperIndex indexer. Each entity type defined in this schema corresponds directly to a database table, with your event handlers responsible for creating and updating the records. HyperIndex automatically generates a GraphQL API based on these entity types, allowing easy access to the indexed data.
Defining Entity Types
Entities in your schema are defined as GraphQL object types:
Example:
type User {
id: ID!
greetings: [String!]!
latestGreeting: String!
numberOfGreetings: Int!
}
Requirements:
- Every entity must have a unique
idfield, using one of these scalar types:ID!,String!,Int!, orBigInt!
- The
idfield must be non-nullable, must not be a list, and cannot be a@derivedFromfield. - There is no
@entitydirective: everytypein the file is an entity. - An entity name is at most 63 characters. Two entities can't share a name that differs only in the case of its first letter (
userandUser), and an entity and an enum can't share a name. - Entity names, enum names and enum values can't be
enumor a built-inObject.prototypeproperty name such asconstructor,toString,valueOf,hasOwnPropertyor__proto__.envio codegenrejects them withSchema contains the following reserved keywords: ....
Numeric Entity IDs
ID is the usual choice and behaves as a string. Since v3.5, you can also key an entity on Int or BigInt, which is a better fit when the identifier is genuinely a number - a block number, an auction id, a sequential position:
type Auction {
id: BigInt! # the on-chain auction id, not a stringified copy of it
seller: String!
bids: [Bid!]! @derivedFrom(field: "auction")
}
type Bid {
id: ID!
auction: Auction! # inferred as BigInt to match Auction.id
amount: BigInt!
}
Relationship fields adopt the referenced entity's id type automatically. Codegen exposes Bid.auction above to your handlers as auction_id, typed bigint rather than string. You don't declare the foreign key type. Keep the two sides in sync by changing the referenced entity's id.
Scalar Types
Scalar types represent basic data types and map directly to JavaScript, TypeScript, or ReScript types.
| GraphQL Scalar | Description | JavaScript/TypeScript | ReScript |
|---|---|---|---|
ID | Unique identifier | string | string |
String | UTF-8 character sequence | string | string |
Int | Signed 32-bit integer | number | int |
Float | Signed floating-point number | number | float |
Boolean | true or false | boolean | bool |
Bytes | Byte string (see bytes_type) | string or Uint8Array | string |
BigInt | Signed integer (int256 in Solidity) | bigint | bigint |
BigDecimal | Arbitrary-size floating-point | BigDecimal (imported) | BigDecimal.t |
Timestamp | Timestamp with timezone | Date | Js.Date.t |
Json | JSON value | JSON (imported) | Js.Json.t |
Learn more about GraphQL scalars here.
A field without ! is optional, and its TypeScript type is T | undefined, never null. BigDecimal and JSON are exported from envio. A JSON value can't hold a bigint, so store one as its .toString().
The GraphQL scalars The Graph adds, such as Int8, are not supported. Use BigInt instead.
Lists
- Write a list as
[Type!]!(required) or[Type!](optional). Elements can't be nullable, so[Type]is rejected. - Lists of
BooleanandTimestampare not supported yet. - A list of entities, such as
[Token!]!, needs@derivedFrom. To store a list of ids without a relationship, use[String!]!.
Bytes Representation
Since v3.10, the bytes_type option in config.yaml decides how Bytes reaches handlers and storage: hex (the EVM and Fuel default) gives a 0x-prefixed string stored as text, uint8array gives a Uint8Array stored as raw bytes (BYTEA in Postgres), which roughly halves the storage taken by addresses and hashes. Solana is always Uint8Array.
Working with BigDecimal
The BigDecimal scalar type in HyperIndex is based on the bignumber.js library, which provides arbitrary-precision decimal arithmetic. This is essential for financial calculations and handling numeric values that exceed JavaScript's native number precision.
Importing BigDecimal
import { BigDecimal } from "envio";
open BigDecimal
Creating BigDecimal Instances
// From string (recommended for precision)
const price = new BigDecimal("123.456789");
// From number (may lose precision for very large values)
const amount = new BigDecimal(123.45);
// From other BigDecimal
const copy = new BigDecimal(price);
Arithmetic Operations
BigDecimal instances are immutable. Operations return new BigDecimal instances:
// Basic arithmetic
const a = new BigDecimal("123.45");
const b = new BigDecimal("67.89");
const sum = a.plus(b); // 191.34
const difference = a.minus(b); // 55.56
const product = a.times(b); // 8,381.03
const quotient = a.div(b); // 1.81839...
// Power
const squared = a.pow(2); // 15,239.9025
// Square root
const root = a.sqrt(); // 11.11...
// Absolute value
const abs = new BigDecimal("-123.45").abs(); // 123.45
Comparison Methods
const x = new BigDecimal("10.5");
const y = new BigDecimal("10.5");
const z = new BigDecimal("9.9");
x.eq(y); // true (equal)
x.gt(z); // true (greater than)
x.gte(y); // true (greater than or equal)
x.lt(z); // false (less than)
x.lte(y); // true (less than or equal)
// Check for special values
x.isZero(); // false
x.isPositive(); // true
x.isNegative(); // false
x.isFinite(); // true
Rounding and Formatting
const value = new BigDecimal("123.456789");
// Get with specific decimal places
value.dp(2); // 123.46 (rounded)
value.dp(2, 1); // 123.45 (rounded down)
// Format as string
value.toString(); // "123.456789"
value.toFixed(2); // "123.46"
value.toExponential(2); // "1.23e+2"
value.toPrecision(5); // "123.46"
Working with Schema-Defined BigDecimal Fields
When you've defined a BigDecimal field in your schema:
type TokenPair {
id: ID!
name: String!
price: BigDecimal!
volume: BigDecimal!
}
You can use it in your handlers:
// In your event handler
context.TokenPair.set({
id: event.params.pairId,
name: event.params.name,
price: new BigDecimal(event.params.price),
volume: new BigDecimal("0"), // Start with zero volume
});
// Updating a field
const tokenPair = await context.TokenPair.get(pairId);
if (tokenPair) {
const newVolume = tokenPair.volume.plus(new BigDecimal(tradeAmount));
context.TokenPair.set({
...tokenPair,
volume: newVolume,
});
}
Example: Financial Calculation
function calculateFee(amount: BigDecimal, feeRate: BigDecimal): BigDecimal {
// Calculate fee with proper rounding
return amount.times(feeRate).dp(2);
}
const tradeAmount = new BigDecimal("1250.75");
const feeRate = new BigDecimal("0.0025"); // 0.25%
const fee = calculateFee(tradeAmount, feeRate); // 3.13
Best Practices for BigDecimal
-
Always use strings for initialization when precision matters:
// Preferred
const value = new BigDecimal("123.456789");
// May lose precision
const value = new BigDecimal(123.456789); -
Set precision explicitly when doing division:
// Set to 8 decimal places for crypto prices
const price = totalValue.div(tokenAmount).dp(8); -
Handle rounding appropriately for financial calculations:
// Round down (floor) for user-favorable calculations
const userReceives = amount.dp(2, 1); // ROUND_DOWN
// Round up (ceil) for protocol-favorable calculations
const protocolFee = amount.dp(2, 0); // ROUND_UP -
Compare with equals method instead of
==or===:// Correct
if (value.eq(new BigDecimal(0))) {
/* ... */
}
// Incorrect - compares object references
if (value === new BigDecimal(0)) {
/* ... */
} -
Chain operations carefully, remembering that each operation returns a new instance:
// Calculate (a + b) * c with proper precision
const result = a.plus(b).times(c).dp(8);
Enum Types
Enums allow fields to accept only a predefined set of values.
Example:
enum AccountType {
ADMIN
USER
}
type User {
id: ID!
balance: Int!
accountType: AccountType!
}
Enums translate to string unions (TypeScript/JavaScript) or polymorphic variants (ReScript):
TypeScript Example:
import { type Enum } from "envio";
let user = {
id: event.params.id,
balance: event.params.balance,
accountType: "USER" satisfies Enum<"AccountType">, // enum as string
};
ReScript Example:
let user: Entities.User.t = {
id: event.params.id,
balance: event.params.balance,
accountType: #USER, // polymorphic variant
};
Relationships: One-to-Many (@derivedFrom)
Define relationships between entities using the @derivedFrom directive, known as reverse lookups.
Example:
type NftCollection {
id: ID!
contractAddress: Bytes!
name: String!
symbol: String!
maxSupply: BigInt!
currentSupply: Int!
tokens: [Token!]! @derivedFrom(field: "collection")
}
type Token {
id: ID!
tokenId: BigInt!
collection: NftCollection!
owner: User!
}
- The
tokensfield inNftCollectionis a virtual field, populated automatically when querying the API. It doesn't exist on the entity your handlers read, andgetWherecan't filter on it. @derivedFrom(field: "collection")names the field as written in the schema on the other entity (collection), nevercollection_id.- Set relationships in your handlers by assigning
<field>_idwith the related entity'sid. For example, create or update aTokenentity withcollection_id: collectionId. Don't add_idto the field name in the schema; codegen adds it.
Field Indexing (@index)
Add an index to a field for optimized queries and loader performance:
type Token {
id: ID!
tokenId: BigInt!
collection: NftCollection!
owner: User! @index
}
- All
idfields and fields referenced via@derivedFromare indexed automatically. - Declare
@indexfor the fields your GraphQL consumers filter and sort by. You don't need it for fields your handlers query withgetWhere- since v3.5 HyperIndex creates those indices itself. See Indices created on demand. @indexcan't go onidor on a@derivedFromfield.- For an index over several fields, put
@index(fields: [...])on the type. See Composite Indices. - A PostgreSQL index entry holds at most about 2.7 KB. If an indexed
Stringfield receives a longer value, the write fails with a PostgreSQLindex row ...error, which stops the indexer. Truncate unbounded strings, such as names taken from events, before you store them in an indexed field. The same applies to fieldsgetWherefilters on, because those get an index too.
Choosing a Storage Backend (@storage)
When you enable more than one storage backend in config.yaml, the @storage directive sets where each entity is kept:
# Queryable over GraphQL and mirrored into ClickHouse for analytics
type Transfer @storage(postgres: true, clickhouse: true) {
id: ID!
amount: BigInt!
}
# Kept in ClickHouse alone
type Snapshot @storage(clickhouse: true) {
id: ID!
from: String!
value: BigInt!
}
To skip the directive on most entities, mark a backend as default in config.yaml. See storage.
Handlers use the same entity API whichever backend holds the data. get, getOrThrow, getOrCreate, getWhere, set and deleteUnsafe all work on entities kept in ClickHouse alone:
const snapshots = await context.Snapshot.getWhere({
from: { _eq: event.params.from },
});
For entities kept in ClickHouse alone:
- GraphQL serves the entities kept in Postgres. Add
postgres: trueto any entity your API needs to query. - Postgres stays enabled for the indexer state (chains, checkpoints, addresses, effect caches and raw events), even when every entity lives in ClickHouse.
- An entity with a
ttlin its ClickHouse options is write-only from handlers, because ClickHouse drops expired rows when it merges them. Addpostgres: trueor remove thettlif a handler needs to read it. @indexdoesn't create an index in ClickHouse, sogetWhereon a field outside the sorting key scans the table. Put the fields you filter on most inorderBy(see below).
Per-Entity ClickHouse Tuning
Since v3.4, the clickhouse argument also accepts an options object that tunes that entity's ClickHouse history table:
type Transfer
@storage(
postgres: true
clickhouse: {
partitionBy: "toYYYYMM(timestamp)"
orderBy: ["timestamp"]
ttl: "timestamp + INTERVAL 2 YEAR"
skippingIndexes: [
{ name: "idx_amount", expr: "amount", type: "minmax", granularity: 4 }
]
}
) {
id: ID!
timestamp: Timestamp!
amount: BigInt!
}
| Option | Type | Description |
|---|---|---|
partitionBy | ClickHouse expression | Emitted as PARTITION BY <expr>. Keeps queries and TTL deletes inside a partition instead of scanning the whole table. |
orderBy | list of entity field names | Fields that lead the table's sorting key, ahead of the default id. |
ttl | ClickHouse expression | Emitted as TTL <expr>. Ages rows out automatically. |
skippingIndexes | list of index objects | Since v3.8. Data skipping indexes emitted into the history table DDL as INDEX <name> <expr> TYPE <type> GRANULARITY <granularity>. Each entry takes name, expr, type (e.g. minmax, bloom_filter, set) and an optional granularity, which defaults to ClickHouse's own default. |
A few constraints, all caught at envio codegen rather than at runtime:
orderBytakes entity field names, not expressions - unlikepartitionByandttl, which are ClickHouse expressions passed through as written.orderBycan't listid(already the default sorting key), nor nullable, list or@derivedFromfields, which ClickHouse doesn't allow in a sorting key.- An entity can carry only one
@storagedirective, and it must enable at least one backend. - With
disable_default_cross_chain: true,orderBycan also list the generatedchainIdcolumn (v3.9).
Sharing Entities Across Chains (@crossChain)
Entities are shared by every chain by default: one id means one row, whichever chain wrote it. Setting disable_default_cross_chain: true in config.yaml (v3.6, recommended for multichain indexers) flips that - entities become per-chain, keyed on (id, chainId) - and @crossChain marks the individual entities that should stay shared:
type Counter {
# per-chain: one row per (id, chain)
id: ID!
count: BigInt!
}
type GlobalCounter @crossChain {
# one row shared by every chain
id: ID!
count: BigInt!
}
The directive is only valid when disable_default_cross_chain: true is set - without it entities are already cross-chain, and envio codegen rejects the directive. Per-chain entities also reserve the chainId column name (chain_id under column_name_format: snake_case), so a schema field can't claim it.
Hiding Entities from the API (@internal)
Since v3.8.0, mark an entity with @internal to keep it out of the GraphQL API. The entity is stored and usable in handlers exactly as normal - set, get, getWhere and getOrThrow all work - but it is never exposed through GraphQL: no queries, no relationships, no introspection entry.
# Bookkeeping state the indexer needs, but API consumers shouldn't see
type SwapCheckpoint @internal {
id: ID!
lastProcessedLogIndex: Int!
pendingAmount: BigInt!
}
Use it for internal indexing state (cursors, checkpoints, deduplication markers, intermediate aggregates) or for data you don't want on the public API surface.
A few rules, enforced at envio codegen:
- An exposed entity can't reference an
@internalone - neither through an object reference nor a@derivedFromfield - because the relationship could never be served over GraphQL. Either mark the referencing entity@internaltoo, or store a plain id field (e.g.checkpointId: String!) instead of a relationship. - The reverse is fine: an
@internalentity may freely reference exposed entities.
Advanced: Precision and Scale (@config Directive)
Customize the precision and scale for BigInt and BigDecimal fields using @config.
Syntax:
BigInt(precision only):
amount: BigInt @config(precision: 76)
BigDecimal(precision and scale):
price: BigDecimal @config(precision: 10, scale: 2)
Example:
type Payment {
id: ID!
amount: BigInt @config(precision: 76)
price: BigDecimal @config(precision: 10, scale: 2)
}
This controls PostgreSQL storage allocation and numerical accuracy. Without @config, both types are stored as PostgreSQL NUMERIC with no fixed precision or scale. On a BigDecimal, @config needs both precision and scale. On a BigInt it takes precision only, and on any other type it is an error.
Detailed Example with Arrays
type AdvancedEntity {
id: ID!
exampleBigInt: BigInt @config(precision: 76)
exampleBigIntRequired: BigInt! @config(precision: 77)
exampleBigIntArray: [BigInt!] @config(precision: 78)
exampleBigIntArrayRequired: [BigInt!]! @config(precision: 79)
exampleBigDecimal: BigDecimal @config(precision: 10, scale: 5)
exampleBigDecimalRequired: BigDecimal! @config(precision: 12, scale: 4)
}
Documenting Entities, Fields, and Relationships
You can document your entities, fields, and relationships directly in schema.graphql using GraphQL string descriptions. These descriptions are exposed through the generated GraphQL API and appear in introspection, making your API self-documenting.
"""
A token transfer between two accounts
"""
type Transfer {
id: ID!
"The address the tokens were sent from"
from: String!
"The address the tokens were sent to"
to: String!
"The amount transferred, in wei"
value: BigInt!
}
Both single-line ("...") and multi-line ("""...""") descriptions are supported.
Only string descriptions are exposed in introspection. Hash (#) comments are ignored by the GraphQL parser and do not appear in the API. Descriptions on entities, fields, and relationships were added in HyperIndex v3.1.
Generating Types
Once you've defined your schema, run this command to generate these entity types that can be accessed in your event handlers:
pnpm envio codegen
Best Practices
- Use camelCase for field names (
latestGreeting,numberOfGreetings). - Keep entity and field names clear, descriptive, and intuitive.
You're now ready to define powerful schemas and efficiently query your indexed data with HyperIndex!