Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/cli/src/config_parsing/public_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ use super::{
field_types,
human_config::{self, evm::For, ColumnNameFormat},
system_config::{
self, field_type_to_arg_type, named_field_to_arg_def, Abi, Ecosystem, EventKind,
FuelEventKind, SvmAbi, SvmSchemaSource, SystemConfig,
self, field_type_to_arg_type, named_field_to_arg_def, Abi, ChainIdMode, Ecosystem,
EventKind, FuelEventKind, SvmAbi, SvmSchemaSource, SystemConfig,
},
};
use crate::{config_parsing::chain_helpers::Network, utils::text::Capitalize};
Expand All @@ -20,6 +20,13 @@ fn is_false(v: &bool) -> bool {
!v
}

// Int32 is what every config predating the field implies, so omitting it keeps
// the JSON — and therefore the persisted envio_info fingerprint — byte-identical
// for small-id projects.
fn is_default_chain_id_mode(v: &ChainIdMode) -> bool {
matches!(v, ChainIdMode::Int32)
}

#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PublicConfigJson<'a> {
Expand All @@ -39,6 +46,8 @@ pub(crate) struct PublicConfigJson<'a> {
save_full_history: bool,
#[serde(skip_serializing_if = "is_false")]
raw_events: bool,
#[serde(skip_serializing_if = "is_default_chain_id_mode")]
chain_id_mode: ChainIdMode,
storage: StorageConfig,
#[serde(skip_serializing_if = "Option::is_none")]
evm: Option<EvmConfig<'a>>,
Expand Down Expand Up @@ -842,6 +851,7 @@ impl SystemConfig {
rollback_on_reorg: cfg.rollback_on_reorg,
save_full_history: cfg.save_full_history,
raw_events: cfg.enable_raw_events,
chain_id_mode: cfg.chain_id_mode,
storage: (&cfg.storage).into(),
evm,
fuel,
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/config_parsing/system_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,12 +299,49 @@ pub fn get_envio_version(envio_package_dir: Option<&str>) -> Result<String> {
Ok(format!("file:{}", pkg.to_string_lossy()))
}

/// Widest scalar the internal chain-id columns need. Derived once from the
/// maximum active chain id and carried through the public config, so a resume
/// against a schema built for the other mode is rejected rather than silently
/// truncating ids.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ChainIdMode {
Int32,
Int64,
}

/// Chain ids cross the Rust → JSON → JS boundary as plain numbers, so an id
/// above `Number.MAX_SAFE_INTEGER` can't round-trip losslessly.
pub const MAX_SAFE_CHAIN_ID: u64 = 9_007_199_254_740_991;

impl ChainIdMode {
/// Skipped chains count: codegen emits a `chainId` case for every chain in
/// config.yaml regardless of `skip`, so a skipped wide id still has to be
/// representable. Including them also keeps the mode — and therefore the
/// physical column types — stable when a chain is skipped and unskipped.
fn resolve(chains: &ChainMap) -> Result<Self> {
let max_id = chains.values().map(|chain| chain.id).max().unwrap_or(0);
if max_id > MAX_SAFE_CHAIN_ID {
return Err(anyhow!(
"Chain id {max_id} is above the maximum supported chain id \
{MAX_SAFE_CHAIN_ID} (Number.MAX_SAFE_INTEGER)."
));
}
Ok(if max_id <= i32::MAX as u64 {
Self::Int32
} else {
Self::Int64
})
}
}

#[derive(Debug)]
pub struct SystemConfig {
pub name: String,
pub schema_path: String,
pub parsed_project_paths: ParsedProjectPaths,
pub chains: ChainMap,
pub chain_id_mode: ChainIdMode,
pub contracts: ContractMap,
pub rollback_on_reorg: bool,
pub save_full_history: bool,
Expand Down Expand Up @@ -1003,6 +1040,8 @@ impl SystemConfig {
has_rpc_sync_src,
)?;

let chain_id_mode = ChainIdMode::resolve(&chains)?;

Ok(SystemConfig {
name: base_config.name.clone(),
parsed_project_paths: final_project_paths,
Expand All @@ -1011,6 +1050,7 @@ impl SystemConfig {
.clone()
.unwrap_or_else(|| DEFAULT_SCHEMA_PATH.to_string()),
chains,
chain_id_mode,
contracts,
rollback_on_reorg: evm_config.rollback_on_reorg.unwrap_or(true),
save_full_history: evm_config.save_full_history.unwrap_or(false),
Expand Down Expand Up @@ -1149,6 +1189,8 @@ impl SystemConfig {
.context("Failed inserting chain at chains map")?;
}

let chain_id_mode = ChainIdMode::resolve(&chains)?;

Ok(SystemConfig {
name: base_config.name.clone(),
parsed_project_paths: final_project_paths,
Expand All @@ -1157,6 +1199,7 @@ impl SystemConfig {
.clone()
.unwrap_or_else(|| DEFAULT_SCHEMA_PATH.to_string()),
chains,
chain_id_mode,
contracts,
rollback_on_reorg: false,
save_full_history: false,
Expand Down Expand Up @@ -1290,6 +1333,8 @@ impl SystemConfig {
// keep it off for now.
let uses_hypersync = svm_config.chains.iter().any(|n| n.experimental.is_some());

let chain_id_mode = ChainIdMode::resolve(&chains)?;

Ok(SystemConfig {
name: svm_config.base.name.clone(),
parsed_project_paths: final_project_paths,
Expand All @@ -1299,6 +1344,7 @@ impl SystemConfig {
.clone()
.unwrap_or_else(|| DEFAULT_SCHEMA_PATH.to_string()),
chains,
chain_id_mode,
contracts,
rollback_on_reorg: uses_hypersync,
save_full_history: false,
Expand Down
90 changes: 63 additions & 27 deletions packages/cli/src/hbs_templating/codegen_templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ use crate::{
field_types,
human_config::HumanConfig,
system_config::{
self, Abi, Ecosystem, EventKind, FuelEventKind, SelectedField, SystemConfig,
self, Abi, ChainIdMode, Ecosystem, EventKind, FuelEventKind, SelectedField,
SystemConfig,
},
},
constants::project_paths::{ENVIO_ENV_DTS_FILE, ENVIO_TYPES_FILE},
Expand Down Expand Up @@ -976,6 +977,11 @@ impl ProjectTemplate {
&self.envio_types_dts
}

/// The generated `Indexer.res` contents (the project's ReScript surface).
pub fn indexer_code(&self) -> &str {
&self.indexer_code
}

pub fn generate_templates(&self, project_paths: &ParsedProjectPaths) -> Result<()> {
// 1. `.envio/types.d.ts` — augments `envio` with project-derived
// chains/contracts/entities/enums.
Expand Down Expand Up @@ -1336,14 +1342,20 @@ impl ProjectTemplate {
Ecosystem::Svm => "Envio.svmOnSlotArgs<handlerContext> => promise<unit>",
};

let chain_id_type = format!(
"type chainId = [{}]",
chain_id_cases
.iter()
.map(|chain_id_case| format!("#{}", chain_id_case))
.collect::<Vec<_>>()
.join(" | "),
);
// ReScript integer polyvariants (`#137`) are int32-bound, so a config
// with a wider id falls back to the opaque runtime representation.
// TypeScript keeps its numeric literal union either way.
let chain_id_type = match cfg.chain_id_mode {
ChainIdMode::Int64 => "type chainId = ChainId.t".to_string(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Generate wide-safe IDs in imported ReScript handlers

When envio contract-import generates ReScript handlers for an Int64 project, event.chainId now has this opaque ChainId.t type, but Event::get_entity_id_code in packages/cli/src/hbs_templating/contract_import_templates.rs:546 still emits (event.chainId :> int)->Belt.Int.toString. ReScript rejects that generated code because ChainId.t is not a subtype of int, so contract import produces a project that cannot compile for any wide chain. Generate the entity ID with ChainId.toString in Int64 mode, or use a representation-independent conversion.

Useful? React with 👍 / 👎.

ChainIdMode::Int32 => format!(
"type chainId = [{}]",
chain_id_cases
.iter()
.map(|chain_id_case| format!("#{}", chain_id_case))
.collect::<Vec<_>>()
.join(" | "),
),
};
Comment on lines +1345 to +1358

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Exclude skipped chains from generated Int32 cases.

Mode resolution ignores skipped chains, but chain_id_cases and the exhaustive switch include them. An active id: 1 plus skipped id: 2494104990 selects Int32 then emits #2494104990, which ReScript cannot compile. Build both generated Int32 lists from active chains, and add this skipped-wide-ID regression case.

Also applies to: 1485-1521

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/hbs_templating/codegen_templates.rs` around lines 1340 -
1353, Update the chain ID generation around chain_id_type and the exhaustive
switch to derive Int32 cases only from active, non-skipped chains, matching the
filtering used during mode resolution. Ensure skipped wide IDs are excluded from
both generated lists, and add a regression test covering an active id: 1 with
skipped id: 2494104990.


// Generate indexer types and value
let indexer_contract_type = r#"/** Contract configuration with name and ABI. */
Expand Down Expand Up @@ -1475,27 +1487,43 @@ type indexer = {{
),
};

// Generate getChainById function
let get_chain_by_id_cases = chain_configs
.iter()
.map(|chain| {
format!(
" | #{} => indexer.chains.\\\"{}\"",
chain.network_config.id, chain.network_config.id
)
})
.collect::<Vec<_>>()
.join("\n");
// Generate getChainById function. `chainId` is only a polyvariant in
// Int32 mode, so the Int64 form looks the key up on the chains record
// (whose fields are already the decimal ids) instead of matching.
let get_chain_by_id = match cfg.chain_id_mode {
ChainIdMode::Int64 => r#"/** Get chain configuration by chain ID. */
let getChainById = (indexer: indexer, chainId: chainId): indexerChain => {
switch indexer.chains
->(Utils.magic: indexerChains => dict<indexerChain>)
->Dict.get(chainId->ChainId.toString) {
| Some(chain) => chain
| None => JsError.throwWithMessage("Chain " ++ chainId->ChainId.toString ++ " is not configured.")
}
}"#
.to_string(),
ChainIdMode::Int32 => {
let get_chain_by_id_cases = chain_configs
.iter()
.map(|chain| {
format!(
" | #{} => indexer.chains.\\\"{}\"",
chain.network_config.id, chain.network_config.id
)
})
.collect::<Vec<_>>()
.join("\n");

let get_chain_by_id = format!(
r#"/** Get chain configuration by chain ID with exhaustive pattern matching. */
format!(
r#"/** Get chain configuration by chain ID with exhaustive pattern matching. */
let getChainById = (indexer: indexer, chainId: chainId): indexerChain => {{
switch chainId {{
{}
}}
}}"#,
get_chain_by_id_cases
);
get_chain_by_id_cases
)
}
};

// Generate Enums and Entities modules
let enums_module_code = indent(&generate_enums_code(&gql_enums));
Expand Down Expand Up @@ -1546,11 +1574,19 @@ type handlerEntityOperationsWithCustomId<'entity, 'id, 'getWhereFilter> = {
deleteUnsafe: string => unit,
}}{custom_id_handler_ops_code}

/** The chain the event being handled belongs to. */
type handlerChain = {{
/** The unique identifier of the blockchain network where this event occurred. */
id: chainId,
/** Whether all chains have entered real-time indexing mode (caught up to head, or reached their configured endBlock for finite-range indexers). */
isRealtime: bool,
}}

type handlerContext = {{
log: Envio.logger,
effect: 'input 'output. (Envio.effect<'input, 'output>, 'input) => promise<'output>,
isPreload: bool,
chain: Internal.chainInfo,
chain: handlerChain,
{handler_context_entity_fields}
}}"#,
);
Expand Down Expand Up @@ -1797,10 +1833,10 @@ module Entities = {{
{entities_module_code}
}}

{handler_context_code}

{chain_id_type}

{handler_context_code}

type contractRegisterContract = {{ add: Address.t => unit }}

type contractRegisterChain = {{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ module Entities = {
| @as("EmptyEntity") EmptyEntity: name<EmptyEntity.t, EmptyEntity.id>
}

type chainId = [#0]

type handlerEntityOperations<'entity, 'getWhereFilter> = {
get: string => promise<option<'entity>>,
getOrThrow: (string, ~message: string=?) => promise<'entity>,
Expand All @@ -98,16 +100,22 @@ type handlerEntityOperations<'entity, 'getWhereFilter> = {
deleteUnsafe: string => unit,
}

/** The chain the event being handled belongs to. */
type handlerChain = {
/** The unique identifier of the blockchain network where this event occurred. */
id: chainId,
/** Whether all chains have entered real-time indexing mode (caught up to head, or reached their configured endBlock for finite-range indexers). */
isRealtime: bool,
}

type handlerContext = {
log: Envio.logger,
effect: 'input 'output. (Envio.effect<'input, 'output>, 'input) => promise<'output>,
isPreload: bool,
chain: Internal.chainInfo,
chain: handlerChain,
\"EmptyEntity": handlerEntityOperations<Entities.EmptyEntity.t, Entities.EmptyEntity.getWhereFilter>,
}

type chainId = [#0]

type contractRegisterContract = { add: Address.t => unit }

type contractRegisterChain = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ module Entities = {
| @as("RelatedEntity") RelatedEntity: name<RelatedEntity.t, RelatedEntity.id>
}

type chainId = [#1]

type handlerEntityOperations<'entity, 'getWhereFilter> = {
get: string => promise<option<'entity>>,
getOrThrow: (string, ~message: string=?) => promise<'entity>,
Expand All @@ -227,17 +229,23 @@ type handlerEntityOperations<'entity, 'getWhereFilter> = {
deleteUnsafe: string => unit,
}

/** The chain the event being handled belongs to. */
type handlerChain = {
/** The unique identifier of the blockchain network where this event occurred. */
id: chainId,
/** Whether all chains have entered real-time indexing mode (caught up to head, or reached their configured endBlock for finite-range indexers). */
isRealtime: bool,
}

type handlerContext = {
log: Envio.logger,
effect: 'input 'output. (Envio.effect<'input, 'output>, 'input) => promise<'output>,
isPreload: bool,
chain: Internal.chainInfo,
chain: handlerChain,
\"EmptyEntity": handlerEntityOperations<Entities.EmptyEntity.t, Entities.EmptyEntity.getWhereFilter>,
\"RelatedEntity": handlerEntityOperations<Entities.RelatedEntity.t, Entities.RelatedEntity.getWhereFilter>,
}

type chainId = [#1]

type contractRegisterContract = { add: Address.t => unit }

type contractRegisterChain = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ module Entities = {
| @as("EmptyEntity") EmptyEntity: name<EmptyEntity.t, EmptyEntity.id>
}

type chainId = [#1 | #2]

type handlerEntityOperations<'entity, 'getWhereFilter> = {
get: string => promise<option<'entity>>,
getOrThrow: (string, ~message: string=?) => promise<'entity>,
Expand All @@ -215,16 +217,22 @@ type handlerEntityOperations<'entity, 'getWhereFilter> = {
deleteUnsafe: string => unit,
}

/** The chain the event being handled belongs to. */
type handlerChain = {
/** The unique identifier of the blockchain network where this event occurred. */
id: chainId,
/** Whether all chains have entered real-time indexing mode (caught up to head, or reached their configured endBlock for finite-range indexers). */
isRealtime: bool,
}

type handlerContext = {
log: Envio.logger,
effect: 'input 'output. (Envio.effect<'input, 'output>, 'input) => promise<'output>,
isPreload: bool,
chain: Internal.chainInfo,
chain: handlerChain,
\"EmptyEntity": handlerEntityOperations<Entities.EmptyEntity.t, Entities.EmptyEntity.getWhereFilter>,
}

type chainId = [#1 | #2]

type contractRegisterContract = { add: Address.t => unit }

type contractRegisterChain = {
Expand Down
Loading
Loading