diff --git a/packages/cli/src/config_parsing/entity_parsing.rs b/packages/cli/src/config_parsing/entity_parsing.rs index ba23094681..8ee719a1c8 100644 --- a/packages/cli/src/config_parsing/entity_parsing.rs +++ b/packages/cli/src/config_parsing/entity_parsing.rs @@ -223,6 +223,20 @@ impl Schema { } } + /// Resolves a field's scalar to what its column actually stores: a relation + /// stores the referenced entity's id, every other scalar stores itself. + /// Storage validation has to reason about the stored column type, which for + /// a relation is not the schema-level type. + pub fn resolve_stored_scalar(&self, scalar: &GqlScalar) -> anyhow::Result { + match scalar { + GqlScalar::Custom(name) => match self.try_get_type_def(name)? { + TypeDef::Entity(entity) => entity.get_id_scalar(), + TypeDef::Enum => Ok(scalar.clone()), + }, + _ => Ok(scalar.clone()), + } + } + fn try_get_type_def(&self, name: &String) -> anyhow::Result> { match (self.entities.get(name), self.enums.get(name)) { (None, None) => Err(anyhow!("No type definition '{}' exists in schema", name)), @@ -235,6 +249,19 @@ impl Schema { } } + /// The storage kind an id scalar maps to, or `None` for a scalar that can't + /// hold an id. Two ids are interchangeable when their kinds match: `ID` and + /// `String` share a text column, and a BigInt's precision only sets the + /// column width, not its type. + fn id_scalar_kind(scalar: &GqlScalar) -> Option<&'static str> { + match scalar { + GqlScalar::ID | GqlScalar::String => Some("String"), + GqlScalar::Int => Some("Int"), + GqlScalar::BigInt(_) => Some("BigInt"), + _ => None, + } + } + fn check_related_type_defs_exist(self) -> anyhow::Result { for entity in self.entities.values() { for rel in entity.get_relationships() { @@ -259,16 +286,36 @@ impl Schema { "Derived field {derived_from_field} does not exist on \ entity {name}." ))?, - Some(field) => match field.field_type.get_underlying_scalar() { - GqlScalar::Custom(name) if name == entity.name => (), - GqlScalar::ID | GqlScalar::String => (), - _ => Err(anyhow!( - "Derived field '{derived_from_field}' on entity \ - '{name}' must either be an ID, String, or an Object \ - relationship with Entity '{}'", - entity.name - ))?, - }, + Some(field) => { + let scalar = field.field_type.get_underlying_scalar(); + match &scalar { + // A relation back to this entity stores its id, so the + // two columns match by construction. + GqlScalar::Custom(related) + if related == &entity.name => {} + // Hasura maps this entity's `id` onto the derived column + // (see the `"id": relationalKey` mapping in Hasura.res), + // so a scalar column has to hold the same kind of id. + _ => { + let entity_id_scalar = entity.get_id_scalar()?; + // The entity's id is validated to an id scalar, so + // its kind is always known; a mismatch (or a field + // that isn't an id scalar at all) fails here. + if Self::id_scalar_kind(&scalar) + != Self::id_scalar_kind(&entity_id_scalar) + { + Err(anyhow!( + "Derived field '{derived_from_field}' on entity \ + '{name}' is a {scalar}, but it is matched against \ + the id of '{0}', which is a {entity_id_scalar}. \ + Give it the same type as '{0}'.id, or make it an \ + Object relationship with Entity '{0}'.", + entity.name + ))? + } + } + } + } } } } @@ -429,6 +476,42 @@ impl Entity { } } + // The `id` column and every foreign key that references it must share a + // type, and the storage/codegen layers only implement a fixed set of id + // scalars. Reject anything outside that set up front so the mismatch + // never reaches codegen. + if let Some(id_field) = fields.iter().find(|f| f.name == "id") { + match &id_field.field_type { + FieldType::DerivedFromField { .. } => { + return Err(anyhow!( + "The 'id' field on entity {name} cannot be a @derivedFrom field." + )); + } + FieldType::RegularField { field_type, .. } => { + if field_type.is_optional() { + return Err(anyhow!( + "The 'id' field on entity {name} must be non-nullable, e.g. 'id: ID!'." + )); + } + if field_type.is_array() { + return Err(anyhow!("The 'id' field on entity {name} cannot be a list.")); + } + match field_type.get_underlying_scalar() { + GqlScalar::ID + | GqlScalar::String + | GqlScalar::Int + | GqlScalar::BigInt(_) => {} + other => { + return Err(anyhow!( + "The 'id' field on entity {name} has unsupported type '{other}'. \ + An entity id must be one of: ID, String, Int, BigInt." + )); + } + } + } + } + } + let multi_field_indexes = multi_field_indexes .into_iter() .map(|multi_field_index| { @@ -597,6 +680,23 @@ impl Entity { self.fields.iter().find(|f| f.name == name) } + /// The scalar type of this entity's `id` field. Foreign keys that reference + /// this entity adopt this scalar, so the id and its `_id` columns stay the + /// same type. `Entity::new` validates the id is a supported non-derived + /// scalar, so this never resolves to a relation or derived field. + pub fn get_id_scalar(&self) -> anyhow::Result { + let id_field = self + .get_field("id") + .ok_or_else(|| anyhow!("Entity {} is missing an 'id' field", self.name))?; + match &id_field.field_type { + FieldType::RegularField { field_type, .. } => Ok(field_type.get_underlying_scalar()), + FieldType::DerivedFromField { .. } => Err(anyhow!( + "Entity {} has a derived 'id' field, which is unsupported", + self.name + )), + } + } + pub fn get_relationships(&self) -> Vec { let derived_from_fields: Vec = self .get_fields() @@ -1713,7 +1813,7 @@ impl FieldType { self.to_user_defined_field_type().to_rescript_type(schema) } - fn get_underlying_scalar(&self) -> GqlScalar { + pub fn get_underlying_scalar(&self) -> GqlScalar { self.to_user_defined_field_type().get_underlying_scalar() } @@ -1843,7 +1943,12 @@ impl GqlScalar { } GqlScalar::Timestamp => PGPrimitive::Date, GqlScalar::Custom(name) => match schema.try_get_type_def(name)? { - TypeDef::Entity(_) => PGPrimitive::Entity(name.clone()), + // A relation stores the referenced entity's id, so the foreign + // key column takes that id's Postgres type. `linked_entity` + // still marks it as a relation for the `_id` suffix and Hasura. + TypeDef::Entity(entity) => entity + .get_id_scalar()? + .to_underlying_postgres_primitive(schema)?, TypeDef::Enum => PGPrimitive::Enum(name.clone()), }, }; @@ -1863,7 +1968,16 @@ impl GqlScalar { GqlScalar::Boolean => TypeIdent::Bool, GqlScalar::Timestamp => TypeIdent::Timestamp, GqlScalar::Custom(name) => match schema.try_get_type_def(name)? { - TypeDef::Entity(_) => TypeIdent::ID, + // A foreign key adopts the referenced entity's id type so the + // relation is keyed on matching types on both sides. An `ID` + // target resolves to the concrete `string` rather than the `id` + // alias: every entity module declares its own `type id`, which + // shadows the shared alias and would silently retype a string + // foreign key as the owning entity's numeric id. + TypeDef::Entity(entity) => match entity.get_id_scalar()? { + GqlScalar::ID => TypeIdent::String, + id_scalar => id_scalar.to_rescript_type(schema)?, + }, TypeDef::Enum => TypeIdent::SchemaEnum(name.to_capitalized_options()), }, }; @@ -1979,15 +2093,49 @@ mod tests { #[test] fn gql_type_to_rescript_type_entity() { - let test_entity_string = String::from("TestEntity"); - let test_entity = - Entity::new(&test_entity_string, vec![], vec![], None, None, None).unwrap(); - let schema = Schema::new(vec![test_entity], vec![]).unwrap(); - let rescript_type = UserDefinedFieldType::Single(GqlScalar::Custom(test_entity_string)) - .to_rescript_type(&schema) - .expect("expected rescript type string"); + // A relation resolves to the referenced entity's id rescript type. A + // String-id target yields `string`, an Int-id target yields `int`. + let schema_str = r#" +type Referencer { + id: ID! + stringRelated: StringEntity + numericRelated: NumericEntity! +} + +type StringEntity { + id: ID! +} - assert_eq!(rescript_type.to_string(), "option".to_owned()); +type NumericEntity { + id: Int! +} + "#; + let schema = Schema::from_string(schema_str).unwrap(); + let referencer = schema.entities.get("Referencer").unwrap(); + + // Foreign keys render as the concrete id scalar, never the `id` alias: + // each entity module declares its own `type id`, so a numeric-id entity + // holding a relation to a string-id entity would otherwise resolve that + // foreign key to its own numeric `id` while the column stays text. + let string_related = referencer.get_field("stringRelated").unwrap(); + assert_eq!( + string_related + .field_type + .to_rescript_type(&schema) + .unwrap() + .to_string(), + "option".to_owned() + ); + + let numeric_related = referencer.get_field("numericRelated").unwrap(); + assert_eq!( + numeric_related + .field_type + .to_rescript_type(&schema) + .unwrap() + .to_string(), + "int".to_owned() + ); } #[test] @@ -2195,15 +2343,24 @@ type TestEntity { type TestEntity { id: ID! relatedEntity: RelatedEntity! + numericRelated: NumericEntity! } type RelatedEntity { id: ID! } + +type NumericEntity { + id: Int! +} "#; let gql_doc = setup_document(schema_str).unwrap(); let schema = Schema::from_document(gql_doc).unwrap(); let entity = schema.entities.get("TestEntity").unwrap(); + + // A foreign key adopts the referenced entity's id type. A String-id + // relation stays String, while an Int-id relation becomes Int32 — both + // still carry `linked_entity` for the `_id` naming and Hasura relation. let field = entity.get_field("relatedEntity").unwrap(); let pg_field = field .get_postgres_field(&schema, entity) @@ -2211,14 +2368,23 @@ type RelatedEntity { .unwrap(); assert_eq!(pg_field.field_name, "relatedEntity"); - assert_eq!( - pg_field.field_type, - PGPrimitive::Entity("RelatedEntity".to_string()) - ); + assert_eq!(pg_field.field_type, PGPrimitive::String); assert!(!pg_field.is_index); assert!(!pg_field.is_array); assert!(!pg_field.is_nullable); assert_eq!(pg_field.linked_entity, Some("RelatedEntity".to_string())); + + let numeric_field = entity.get_field("numericRelated").unwrap(); + let numeric_pg_field = numeric_field + .get_postgres_field(&schema, entity) + .expect("Failed to get postgres field") + .unwrap(); + + assert_eq!(numeric_pg_field.field_type, PGPrimitive::Int32); + assert_eq!( + numeric_pg_field.linked_entity, + Some("NumericEntity".to_string()) + ); } #[test] @@ -2292,6 +2458,85 @@ type User { id: ID! } ); } + // Hasura matches the deriving entity's `id` against the derived column, so a + // scalar derived-from field has to hold the same kind of id. + #[test] + fn rejects_derived_from_scalar_that_mismatches_the_deriving_entity_id() { + let schema_str = r#" +type Parent { + id: ID! + children: [Child!]! @derivedFrom(field: "parentId") +} +type Child { + id: ID! + parentId: Int! +} + "#; + let err = Schema::from_string(schema_str) + .expect_err("expected a derivedFrom id-type mismatch error"); + let message = format!("{err:#}"); + assert!( + message.contains("Derived field 'parentId' on entity 'Child'") + && message.contains("matched against the id of 'Parent'"), + "unexpected error: {message}" + ); + } + + #[test] + fn allows_derived_from_scalars_matching_the_deriving_entity_id() { + // Int id derived from an Int column, BigInt id from a BigInt column + // (precision only sets the width), and a String id from an `ID` column — + // `ID` and `String` share a text column, so they stay interchangeable. + let schema_str = r#" +type NumericParent { + id: Int! + children: [NumericChild!]! @derivedFrom(field: "parentId") +} +type NumericChild { + id: ID! + parentId: Int! +} + +type BigParent { + id: BigInt! + children: [BigChild!]! @derivedFrom(field: "parentId") +} +type BigChild { + id: ID! + parentId: BigInt! @config(precision: 20) +} + +type StringParent { + id: String! + children: [StringChild!]! @derivedFrom(field: "parentId") +} +type StringChild { + id: ID! + parentId: ID! +} + "#; + let schema = Schema::from_string(schema_str).unwrap(); + assert_eq!(schema.entities.len(), 6); + } + + // A relation back to the deriving entity stores that entity's id, so it + // matches by construction whatever the id scalar is. + #[test] + fn allows_derived_from_object_relationship_for_a_numeric_id() { + let schema_str = r#" +type NumericParent { + id: Int! + children: [NumericChild!]! @derivedFrom(field: "parent") +} +type NumericChild { + id: ID! + parent: NumericParent! +} + "#; + let schema = Schema::from_string(schema_str).unwrap(); + assert_eq!(schema.entities.len(), 2); + } + #[test] fn allows_entities_that_are_unique_when_capitalized() { let schema_str = r#" diff --git a/packages/cli/src/config_parsing/field_types.rs b/packages/cli/src/config_parsing/field_types.rs index b0de408767..dbc63e1eca 100644 --- a/packages/cli/src/config_parsing/field_types.rs +++ b/packages/cli/src/config_parsing/field_types.rs @@ -18,7 +18,6 @@ pub enum Primitive { Json, Date, Enum(String), - Entity(String), } impl Primitive { @@ -45,7 +44,6 @@ impl Primitive { let capitalized_enum_name = enum_name.capitalize(); format!("Enum({{config: Enums.{capitalized_enum_name}.config->Table.fromGenericEnumConfig}})") } - Self::Entity(entity_name) => format!("Entity({{name: \"{entity_name}\"}})"), } } } diff --git a/packages/cli/src/config_parsing/public_config.rs b/packages/cli/src/config_parsing/public_config.rs index 23c514bad1..f83e6f57cd 100644 --- a/packages/cli/src/config_parsing/public_config.rs +++ b/packages/cli/src/config_parsing/public_config.rs @@ -728,9 +728,6 @@ impl SystemConfig { Primitive::Enum(name) => { ("enum".into(), Some(name.clone()), None, None, None) } - Primitive::Entity(name) => { - ("entity".into(), None, Some(name.clone()), None, None) - } }; let db_name_for = |backend: Option| match backend diff --git a/packages/cli/src/config_parsing/system_config.rs b/packages/cli/src/config_parsing/system_config.rs index f4264a3a29..5c24b76913 100644 --- a/packages/cli/src/config_parsing/system_config.rs +++ b/packages/cli/src/config_parsing/system_config.rs @@ -1,6 +1,6 @@ use super::{ chain_helpers::get_max_reorg_depth_from_id, - entity_parsing::{Entity, GraphQLEnum, Schema}, + entity_parsing::{ClickHouseEntityStorage, Entity, GqlScalar, GraphQLEnum, Schema}, env_interpolation::interpolate_config_variables, human_config::{ self, @@ -380,6 +380,11 @@ impl Storage { } } +/// Largest BigInt precision ClickHouse still stores as a numeric `Decimal`; +/// above this (or with no precision) it falls back to `String`. Kept in sync +/// with the BigInt branch of `getClickHouseFieldType` in ClickHouse.res. +const CLICKHOUSE_DECIMAL_MAX_PRECISION: u32 = 38; + /// Check per-entity `@storage` directives against the resolved global storage. /// Malformed directives are raised earlier, during schema parsing. pub fn validate_entity_storage(storage: &Storage, schema: &Schema) -> anyhow::Result<()> { @@ -421,6 +426,76 @@ pub fn validate_entity_storage(storage: &Storage, schema: &Schema) -> anyhow::Re } } + // ClickHouse stores a BigInt whose precision is unset (or above its Decimal + // ceiling) as a String, which sorts lexicographically — wrong for anything + // in the sorting key. See the BigInt branch of `getClickHouseFieldType` in + // ClickHouse.res. + // + // Which column that applies to depends on `@storage(clickhouse: {orderBy})`: + // without it the sorting key is `id`, with it the listed fields replace `id` + // (see `makeCreateHistoryTableQuery`). Unlike the parse-time + // `validate_clickhouse_order_by_fields`, the schema is available here, so a + // relation in the sorting key can be resolved to the id it actually stores. + let bigint_stored_as_string = + |precision: Option| !precision.is_some_and(|p| p <= CLICKHOUSE_DECIMAL_MAX_PRECISION); + for entity in &entities { + let uses_clickhouse = if entity.has_storage_directive() { + entity.clickhouse.as_ref().is_some_and(|c| c.is_enabled()) + } else { + clickhouse_default + }; + if !uses_clickhouse { + continue; + } + + let order_by = match entity.clickhouse.as_ref() { + Some(ClickHouseEntityStorage::Options(options)) => options.order_by.as_deref(), + _ => None, + }; + + match order_by { + Some(order_by_fields) => { + for field_name in order_by_fields { + // Existence, nullability and array-ness are already rejected + // at parse time; a miss here just means nothing to resolve. + let Some(field) = entity.get_field(field_name) else { + continue; + }; + let stored = + schema.resolve_stored_scalar(&field.field_type.get_underlying_scalar())?; + if let GqlScalar::BigInt(precision) = stored { + if bigint_stored_as_string(precision) { + return Err(anyhow!( + "Invalid storage for `{}`. `clickhouse.orderBy` sorts by \ + `{field_name}`, which stores a BigInt that ClickHouse keeps as a \ + String (sorted lexicographically, not numerically) unless a \ + precision is set. Add `@config(precision: N)` with \ + N <= {CLICKHOUSE_DECIMAL_MAX_PRECISION} to the BigInt it stores \ + so it sorts as a numeric Decimal.", + entity.name + )); + } + } + } + } + // No custom orderBy, so `id` is the sorting key. + None => { + if let Ok(GqlScalar::BigInt(precision)) = entity.get_id_scalar() { + if bigint_stored_as_string(precision) { + return Err(anyhow!( + "Invalid storage for `{}`. Its `id` is a BigInt, which ClickHouse stores as a \ + String (sorted lexicographically, not numerically) unless a precision is set. \ + Since `id` is ClickHouse's sorting key, add `@config(precision: N)` with \ + N <= {CLICKHOUSE_DECIMAL_MAX_PRECISION} so the id stores as a numeric Decimal, \ + or set `@storage(clickhouse: {{orderBy: [...]}})` to sort by other fields.", + entity.name + )); + } + } + } + } + } + let unsupported: Vec<(&str, &'static str)> = entities .iter() .flat_map(|e| { diff --git a/packages/cli/src/hbs_templating/codegen_templates.rs b/packages/cli/src/hbs_templating/codegen_templates.rs index 541d24deb2..c995da7479 100644 --- a/packages/cli/src/hbs_templating/codegen_templates.rs +++ b/packages/cli/src/hbs_templating/codegen_templates.rs @@ -85,14 +85,34 @@ fn generate_enums_code(gql_enums: &[GraphQlEnumTypeTemplate]) -> String { code } +/// An entity's `id` rescript type and whether it is the default `string`. +/// Foreign keys and id-keyed operations adopt this type; `ID!`/`String!` keep +/// `string`, `Int!` is `int`, `BigInt!` is `bigint`. +fn entity_id_type(entity: &EntityRecordTypeTemplate) -> (bool, String) { + entity + .params + .iter() + .find(|param| param.field_name.uncapitalized == "id") + .map(|param| match ¶m.field_type { + TypeIdent::ID | TypeIdent::String => (true, "string".to_string()), + other => (false, other.to_string()), + }) + .unwrap_or((true, "string".to_string())) +} + fn generate_entities_code(entities: &[EntityRecordTypeTemplate]) -> String { let mut code = String::new(); + // The default id type. `ID!`/`String!` ids and the foreign keys that + // reference them render as this alias; numeric ids render as int/bigint. writeln!(code, "type id = string").unwrap(); for entity in entities { + let (_, id_type) = entity_id_type(entity); + writeln!(code).unwrap(); writeln!(code, "module {} = {{", entity.name.capitalized).unwrap(); + writeln!(code, " type id = {}", id_type).unwrap(); writeln!(code, " type t = {}", entity.type_code).unwrap(); writeln!(code).unwrap(); writeln!( @@ -106,11 +126,14 @@ fn generate_entities_code(entities: &[EntityRecordTypeTemplate]) -> String { if !entities.is_empty() { writeln!(code).unwrap(); - writeln!(code, "type rec name<'entity> =").unwrap(); + // Carries the entity's id type alongside the entity itself, so accessors + // keyed by a name (e.g. getTestIndexerEntityOperations) can type their + // by-id operations with that entity's real id scalar. + writeln!(code, "type rec name<'entity, 'id> =").unwrap(); for entity in entities { writeln!( code, - " | @as(\"{0}\") {0}: name<{0}.t>", + " | @as(\"{0}\") {0}: name<{0}.t, {0}.id>", entity.name.capitalized ) .unwrap(); @@ -1478,20 +1501,41 @@ switch chainId {{ let enums_module_code = indent(&generate_enums_code(&gql_enums)); let entities_module_code = indent(&generate_entities_code(&entities)); - // Generate handlerContext types + // Generate handlerContext types. String ids use the plain + // `handlerEntityOperations`; numeric ids use the custom-id variant. + let has_custom_id_entity = entities.iter().any(|entity| !entity_id_type(entity).0); let handler_context_entity_fields = entities .iter() .map(|entity| { - format!( - " \\\"{}\": handlerEntityOperations,", - entity.name.capitalized, - entity.name.capitalized, - entity.name.capitalized, - ) + let name = &entity.name.capitalized; + if entity_id_type(entity).0 { + format!( + " \\\"{name}\": handlerEntityOperations,", + ) + } else { + format!( + " \\\"{name}\": handlerEntityOperationsWithCustomId,", + ) + } }) .collect::>() .join("\n"); + let custom_id_handler_ops_code = if has_custom_id_entity { + r#" + +type handlerEntityOperationsWithCustomId<'entity, 'id, 'getWhereFilter> = { + get: 'id => promise>, + getOrThrow: ('id, ~message: string=?) => promise<'entity>, + getWhere: 'getWhereFilter => promise>, + getOrCreate: 'entity => promise<'entity>, + set: 'entity => unit, + deleteUnsafe: 'id => unit, +}"# + } else { + "" + }; + let handler_context_code = format!( r#"type handlerEntityOperations<'entity, 'getWhereFilter> = {{ get: string => promise>, @@ -1500,16 +1544,15 @@ switch chainId {{ getOrCreate: 'entity => promise<'entity>, set: 'entity => unit, deleteUnsafe: string => unit, -}} +}}{custom_id_handler_ops_code} type handlerContext = {{ log: Envio.logger, effect: 'input 'output. (Envio.effect<'input, 'output>, 'input) => promise<'output>, isPreload: bool, chain: Internal.chainInfo, -{} +{handler_context_entity_fields} }}"#, - handler_context_entity_fields ); // Generate contract modules with event sub-modules @@ -1800,7 +1843,12 @@ type contractRegisterContext = {{ .collect::>() .join("\n"); - // Generate entity ops fields for the testIndexer type + // Generate entity ops fields for the testIndexer type. String ids use + // the plain type; numeric ids use the custom-id variant. + // The string-id form stays the default so entities with a plain `ID!` + // keep an id-argument-free shape. The custom-id form is always emitted + // because `getTestIndexerEntityOperations` returns it for every entity, + // resolving the id through the name GADT. let test_indexer_entity_ops_type = r#"/** Entity operations for direct access outside handlers. */ type testIndexerEntityOperations<'entity> = { /** Get an entity by ID. */ @@ -1811,15 +1859,30 @@ type testIndexerEntityOperations<'entity> = { getOrThrow: (string, ~message: string=?) => promise<'entity>, /** Set (create or update) an entity. */ set: 'entity => unit, +} + +type testIndexerEntityOperationsWithCustomId<'entity, 'id> = { + /** Get an entity by ID. */ + get: 'id => promise>, + /** Get all entities. */ + getAll: unit => promise>, + /** Get an entity by ID or throw if not found. */ + getOrThrow: ('id, ~message: string=?) => promise<'entity>, + /** Set (create or update) an entity. */ + set: 'entity => unit, }"#; let test_indexer_entity_fields = entities .iter() .map(|entity| { - format!( - " \\\"{}\": testIndexerEntityOperations,", - entity.name.capitalized, entity.name.capitalized, - ) + let name = &entity.name.capitalized; + if entity_id_type(entity).0 { + format!(" \\\"{name}\": testIndexerEntityOperations,") + } else { + format!( + " \\\"{name}\": testIndexerEntityOperationsWithCustomId,", + ) + } }) .collect::>() .join("\n"); @@ -1854,7 +1917,7 @@ type testIndexer = {{ // The GADT name value compiles to a string at runtime via @as decorators, // so @get_index can use Entities.name directly as a dictionary key if !entities.is_empty() { - let get_entity_operations = r#"@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity>) => testIndexerEntityOperations<'entity> = """#; + let get_entity_operations = r#"@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity, 'id>) => testIndexerEntityOperationsWithCustomId<'entity, 'id> = """#; indexer_code = format!("{}\n\n{}", indexer_code, get_entity_operations); } @@ -2307,18 +2370,16 @@ type testIndexer = {{ .iter() .filter(|param| !param.is_derived_field) .map(|param| { + // Foreign keys take the referenced entity's id type + // (already resolved into field_type), exposed under + // the `_id` column name. let ts_type = param.field_type.to_ts_type_string(); - let (field_name, field_type) = if param.is_entity_field { - let base_type = if param.field_type.is_option() { - "string | undefined".to_string() - } else { - "string".to_string() - }; - (format!("{}_id", param.field_name.original), base_type) + let field_name = if param.is_entity_field { + format!("{}_id", param.field_name.original) } else { - (param.field_name.original.clone(), ts_type) + param.field_name.original.clone() }; - format!(" readonly \"{}\": {};", field_name, field_type) + format!(" readonly \"{}\": {};", field_name, ts_type) }) .collect(); format!( @@ -2825,6 +2886,7 @@ mod test { utils::text::Capitalize, }; use pretty_assertions::assert_eq; + use std::collections::HashMap; use std::vec; use system_config::FieldSelection; @@ -3364,6 +3426,66 @@ mod test { insta::assert_snapshot!(project_template.indexer_code); } + #[test] + fn indexer_code_exposes_numeric_entity_id_types() { + // Each entity module exposes its id scalar so handler/test-indexer + // operations are keyed by the real type: `ID!` reuses `string`, while + // `Int!`/`BigInt!` render as `int`/`bigint`. + let yaml = r#" +name: numeric-ids +chains: + - id: 1 + rpc: + url: https://rpc.example.test + for: sync + start_block: 0 + contracts: + - name: Token + address: "0x0000000000000000000000000000000000000001" + events: + - event: Transfer() +"#; + let schema = r#" +type Chain { + id: Int! +} +type BigThing { + id: BigInt! +} +type Vault { + id: ID! + chain: Chain! + big: BigThing! +} +"#; + let config = + SystemConfig::parse_yaml(yaml, Some(schema), &HashMap::new(), &HashMap::new(), false) + .expect("numeric-id config should parse"); + let indexer_code = super::ProjectTemplate::from_config(&config) + .expect("project template") + .indexer_code; + + let expectations = [ + // `type id` is emitted before `type t`. + "module Chain = {\n type id = int\n type t = {id: int}", + "module BigThing = {\n type id = bigint\n type t = {id: bigint}", + // The FK columns adopt the referenced entity's id type. + "module Vault = {\n type id = string\n type t = {id: id, chain_id: int, big_id: bigint}", + // Numeric ids use the custom-id operation variants... + "handlerEntityOperationsWithCustomId", + "testIndexerEntityOperationsWithCustomId", + // ...while string ids stay on the plain, id-argument-free variants. + "handlerEntityOperations", + "testIndexerEntityOperations", + ]; + for expected in expectations { + assert!( + indexer_code.contains(expected), + "generated indexer code missing:\n{expected}\n\n--- got ---\n{indexer_code}" + ); + } + } + #[test] fn internal_config_json_code_with_lowercase_contract_name() { let json = get_internal_config_json_helper("lowercase-contract-name.yaml"); diff --git a/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__indexer_code_generated_for_svm.snap b/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__indexer_code_generated_for_svm.snap index d6c9d6ce23..270192ae14 100644 --- a/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__indexer_code_generated_for_svm.snap +++ b/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__indexer_code_generated_for_svm.snap @@ -79,13 +79,14 @@ module Entities = { type id = string module EmptyEntity = { + type id = string type t = {id: id, emptyField: id} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("emptyField") emptyField?: Envio.whereOperator} } - type rec name<'entity> = - | @as("EmptyEntity") EmptyEntity: name + type rec name<'entity, 'id> = + | @as("EmptyEntity") EmptyEntity: name } type handlerEntityOperations<'entity, 'getWhereFilter> = { @@ -249,6 +250,17 @@ type testIndexerEntityOperations<'entity> = { set: 'entity => unit, } +type testIndexerEntityOperationsWithCustomId<'entity, 'id> = { + /** Get an entity by ID. */ + get: 'id => promise>, + /** Get all entities. */ + getAll: unit => promise>, + /** Get an entity by ID or throw if not found. */ + getOrThrow: ('id, ~message: string=?) => promise<'entity>, + /** Set (create or update) an entity. */ + set: 'entity => unit, +} + /** Test indexer type with process method, entity access, and chain info. */ type testIndexer = { /** Process blocks for the specified chains and return progress with changes. */ @@ -260,7 +272,7 @@ type testIndexer = { \"EmptyEntity": testIndexerEntityOperations, } -@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity>) => testIndexerEntityOperations<'entity> = "" +@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity, 'id>) => testIndexerEntityOperationsWithCustomId<'entity, 'id> = "" @module("envio") external indexer: indexer = "indexer" diff --git a/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__indexer_code_generates_correct_types_and_values.snap b/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__indexer_code_generates_correct_types_and_values.snap index 964c30de61..ad0a6c9e77 100644 --- a/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__indexer_code_generates_correct_types_and_values.snap +++ b/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__indexer_code_generates_correct_types_and_values.snap @@ -1,6 +1,5 @@ --- source: packages/cli/src/hbs_templating/codegen_templates.rs -assertion_line: 3081 expression: project_template.indexer_code --- /** @@ -201,20 +200,22 @@ module Entities = { type id = string module EmptyEntity = { - type t = {id: id, emptyField: id, status: Enums.Status.t, optionalStatus: option, related_id: id, optionalRelated_id: option, tags: array, optionalTags: option>} + type id = string + type t = {id: id, emptyField: id, status: Enums.Status.t, optionalStatus: option, related_id: string, optionalRelated_id: option, tags: array, optionalTags: option>} - type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("emptyField") emptyField?: Envio.whereOperator, @as("status") status?: Envio.whereOperator, @as("optionalStatus") optionalStatus?: Envio.whereOperator>, @as("related_id") related?: Envio.whereOperator, @as("optionalRelated_id") optionalRelated?: Envio.whereOperator>, @as("tags") tags?: Envio.whereOperator>, @as("optionalTags") optionalTags?: Envio.whereOperator>>} + type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("emptyField") emptyField?: Envio.whereOperator, @as("status") status?: Envio.whereOperator, @as("optionalStatus") optionalStatus?: Envio.whereOperator>, @as("related_id") related?: Envio.whereOperator, @as("optionalRelated_id") optionalRelated?: Envio.whereOperator>, @as("tags") tags?: Envio.whereOperator>, @as("optionalTags") optionalTags?: Envio.whereOperator>>} } module RelatedEntity = { + type id = string type t = {id: id, name: string} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("name") name?: Envio.whereOperator} } - type rec name<'entity> = - | @as("EmptyEntity") EmptyEntity: name - | @as("RelatedEntity") RelatedEntity: name + type rec name<'entity, 'id> = + | @as("EmptyEntity") EmptyEntity: name + | @as("RelatedEntity") RelatedEntity: name } type handlerEntityOperations<'entity, 'getWhereFilter> = { @@ -433,6 +434,17 @@ type testIndexerEntityOperations<'entity> = { set: 'entity => unit, } +type testIndexerEntityOperationsWithCustomId<'entity, 'id> = { + /** Get an entity by ID. */ + get: 'id => promise>, + /** Get all entities. */ + getAll: unit => promise>, + /** Get an entity by ID or throw if not found. */ + getOrThrow: ('id, ~message: string=?) => promise<'entity>, + /** Set (create or update) an entity. */ + set: 'entity => unit, +} + /** Test indexer type with process method, entity access, and chain info. */ type testIndexer = { /** Process blocks for the specified chains and return progress with changes. */ @@ -445,7 +457,7 @@ type testIndexer = { \"RelatedEntity": testIndexerEntityOperations, } -@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity>) => testIndexerEntityOperations<'entity> = "" +@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity, 'id>) => testIndexerEntityOperationsWithCustomId<'entity, 'id> = "" @module("envio") external indexer: indexer = "indexer" diff --git a/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__indexer_code_multiple_chains.snap b/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__indexer_code_multiple_chains.snap index d6470e1525..0c254ef8c5 100644 --- a/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__indexer_code_multiple_chains.snap +++ b/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__indexer_code_multiple_chains.snap @@ -1,6 +1,5 @@ --- source: packages/cli/src/hbs_templating/codegen_templates.rs -assertion_line: 3088 expression: project_template.indexer_code --- /** @@ -197,13 +196,14 @@ module Entities = { type id = string module EmptyEntity = { + type id = string type t = {id: id, emptyField: id} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("emptyField") emptyField?: Envio.whereOperator} } - type rec name<'entity> = - | @as("EmptyEntity") EmptyEntity: name + type rec name<'entity, 'id> = + | @as("EmptyEntity") EmptyEntity: name } type handlerEntityOperations<'entity, 'getWhereFilter> = { @@ -503,6 +503,17 @@ type testIndexerEntityOperations<'entity> = { set: 'entity => unit, } +type testIndexerEntityOperationsWithCustomId<'entity, 'id> = { + /** Get an entity by ID. */ + get: 'id => promise>, + /** Get all entities. */ + getAll: unit => promise>, + /** Get an entity by ID or throw if not found. */ + getOrThrow: ('id, ~message: string=?) => promise<'entity>, + /** Set (create or update) an entity. */ + set: 'entity => unit, +} + /** Test indexer type with process method, entity access, and chain info. */ type testIndexer = { /** Process blocks for the specified chains and return progress with changes. */ @@ -514,7 +525,7 @@ type testIndexer = { \"EmptyEntity": testIndexerEntityOperations, } -@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity>) => testIndexerEntityOperations<'entity> = "" +@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity, 'id>) => testIndexerEntityOperationsWithCustomId<'entity, 'id> = "" @module("envio") external indexer: indexer = "indexer" diff --git a/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__internal_config_json_code_generated_for_evm.snap b/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__internal_config_json_code_generated_for_evm.snap index 8295ba6e9a..0a52ef8444 100644 --- a/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__internal_config_json_code_generated_for_evm.snap +++ b/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__internal_config_json_code_generated_for_evm.snap @@ -1,6 +1,5 @@ --- source: packages/cli/src/hbs_templating/codegen_templates.rs -assertion_line: 2753 expression: json --- { @@ -121,16 +120,14 @@ expression: json }, { "name": "related", - "type": "entity", - "linkedEntity": "RelatedEntity", - "entity": "RelatedEntity" + "type": "string", + "linkedEntity": "RelatedEntity" }, { "name": "optionalRelated", - "type": "entity", + "type": "string", "isNullable": true, - "linkedEntity": "RelatedEntity", - "entity": "RelatedEntity" + "linkedEntity": "RelatedEntity" }, { "name": "tags", diff --git a/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__internal_config_json_code_with_all_options.snap b/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__internal_config_json_code_with_all_options.snap index 449fb6c9c2..245ce17511 100644 --- a/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__internal_config_json_code_with_all_options.snap +++ b/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__internal_config_json_code_with_all_options.snap @@ -150,18 +150,16 @@ expression: json }, { "name": "related", - "type": "entity", - "linkedEntity": "RelatedEntity", - "entity": "RelatedEntity" + "type": "string", + "linkedEntity": "RelatedEntity" }, { "name": "optionalRelated", "postgresDbName": "optional_related_id", "clickhouseDbName": "optional_related_id", - "type": "entity", + "type": "string", "isNullable": true, - "linkedEntity": "RelatedEntity", - "entity": "RelatedEntity" + "linkedEntity": "RelatedEntity" }, { "name": "tags", diff --git a/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__internal_config_json_code_with_lowercase_contract_name.snap b/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__internal_config_json_code_with_lowercase_contract_name.snap index b1ba3e499c..b814a47af4 100644 --- a/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__internal_config_json_code_with_lowercase_contract_name.snap +++ b/packages/cli/src/hbs_templating/snapshots/envio__hbs_templating__codegen_templates__test__internal_config_json_code_with_lowercase_contract_name.snap @@ -1,6 +1,5 @@ --- source: packages/cli/src/hbs_templating/codegen_templates.rs -assertion_line: 2813 expression: json --- { @@ -121,16 +120,14 @@ expression: json }, { "name": "related", - "type": "entity", - "linkedEntity": "RelatedEntity", - "entity": "RelatedEntity" + "type": "string", + "linkedEntity": "RelatedEntity" }, { "name": "optionalRelated", - "type": "entity", + "type": "string", "isNullable": true, - "linkedEntity": "RelatedEntity", - "entity": "RelatedEntity" + "linkedEntity": "RelatedEntity" }, { "name": "tags", diff --git a/packages/cli/templates/static/shared/.claude/skills/indexer-handlers/SKILL.md b/packages/cli/templates/static/shared/.claude/skills/indexer-handlers/SKILL.md index 8f36d8f005..3506b68521 100644 --- a/packages/cli/templates/static/shared/.claude/skills/indexer-handlers/SKILL.md +++ b/packages/cli/templates/static/shared/.claude/skills/indexer-handlers/SKILL.md @@ -108,24 +108,12 @@ indexer.chains[1].MyContract.abi; // [...] ## Common Pitfalls -**Entity IDs** — prefer `${chainId}_${blockNumber}_${logIndex}` as a unique ID: +**Entity IDs** — for a string id, `${chainId}_${blockNumber}_${logIndex}` is globally unique across chains and blocks; use it unless the entity is a singleton keyed by address: ```ts const id = `${event.chainId}_${event.block.number}_${event.logIndex}`; ``` -This is globally unique across chains and blocks. Use it as the default unless the entity is a singleton (e.g., a Token or Pool keyed by address). -**Entity relationships** — schema uses entity references; handlers use the `_id` suffix that codegen adds: -```ts -// Schema: token0: Token! ← entity reference, field name is "token0" -// Handler: { token0_id: token0.id } ← codegen adds _id; NEVER write "token0" here - -// Schema: collection: NftCollection! -// Handler: { collection_id: collectionEntity.id } - -// WRONG: { token0: token0.id } ← "token0" is not a valid TypeScript field -// WRONG: { collection_id: String! } in schema ← _id belongs in handlers, not schema -// CORRECT: { token0_id: token0.id } in handler -``` +**Entity relationships** — schema uses the entity reference (`token0: Token!`); handlers use the `_id` suffix codegen adds (`token0_id: token0.id`), typed as the referenced entity's id. Never write the bare name (`token0`) in the handler, and never put `_id` in the schema. **Optionals** — `string | undefined`, not `string | null` diff --git a/packages/cli/templates/static/shared/.claude/skills/indexer-schema/SKILL.md b/packages/cli/templates/static/shared/.claude/skills/indexer-schema/SKILL.md index 0817b1137c..6c91d65e87 100644 --- a/packages/cli/templates/static/shared/.claude/skills/indexer-schema/SKILL.md +++ b/packages/cli/templates/static/shared/.claude/skills/indexer-schema/SKILL.md @@ -13,16 +13,16 @@ metadata: ## Entity Rules - Every type is an entity — **no `@entity` decorator** (unlike TheGraph) -- Must have `id: ID!` as first field +- Must have an `id` field first — `ID!` (recommended), or `String!`, `Int!`, `BigInt!` - Names: 1-63 chars, alphanumeric + underscore, no reserved words - Relationship fields use the **entity type directly**: `collection: NftCollection!` — **never** add `_id` in the schema field name -- The `_id` suffix only appears in TypeScript handlers (added by codegen): schema field `collection` → handler field `collection_id` +- The `_id` suffix only appears in TypeScript handlers (added by codegen): schema field `collection` → handler field `collection_id`, typed as the referenced entity's id ## Scalar Types | Schema Type | TypeScript Type | Notes | |-------------|----------------|-------| -| `ID!` | `string` | Required on every entity | +| `ID!` | `string` | Recommended entity id; `String!`/`Int!`/`BigInt!` also allowed as id | | `String!` | `string` | | | `Int!` | `number` | | | `Float!` | `number` | | @@ -153,7 +153,7 @@ type Swap { } ``` -**Schema vs handler field names:** +**Schema vs handler field names** (entity refs to `ID!`-keyed entities): | Schema field | Schema type | TypeScript handler field | |---|---|---| @@ -161,6 +161,6 @@ type Swap { | `token0` | `Token!` | `token0_id: string` | | `collection` | `NftCollection!` | `collection_id: string` | -Codegen always appends `_id` to entity reference field names in the TypeScript types. Do **not** add `_id` yourself in the schema. +Codegen appends `_id` to entity reference fields (never add it in the schema), typed as the referenced entity's id. > If something is unclear, use the `envio-docs` skill to search and read the latest documentation. diff --git a/packages/envio-tests/test/ClientAddressFilter_test.res b/packages/envio-tests/test/ClientAddressFilter_test.res index bb79d479c7..4afb596c74 100644 --- a/packages/envio-tests/test/ClientAddressFilter_test.res +++ b/packages/envio-tests/test/ClientAddressFilter_test.res @@ -49,19 +49,23 @@ describe("parseWhereOrThrow — address-param detection", () => { }) it("throws when the addresses are transformed instead of passed directly", t => { - t.expect(() => + t->toThrowErrorEqual(() => parseEvm( ~eventFilters=Some(%raw(`({chain}) => ({params: {to: [...chain.ERC20.addresses]}})`)), )->ignore - ).toThrowError("must be passed directly as an indexed-param filter value") + , + "Invalid where configuration for \"ERC20\": chain.ERC20.addresses must be passed directly as an indexed-param filter value (e.g. { params: { to: chain.ERC20.addresses } }). It cannot be spread, mapped, indexed, or otherwise transformed.", + ) }) it("throws when addresses are read but not used as a param filter", t => { - t.expect(() => + t->toThrowErrorEqual(() => parseEvm( ~eventFilters=Some(%raw(`({chain}) => { const _a = chain.ERC20.addresses; return true }`)), )->ignore - ).toThrowError("doesn't use it as an indexed-param filter value") + , + "Invalid where configuration for ERC20. The callback reads `chain.ERC20.addresses` but doesn't use it as an indexed-param filter value. Use it directly, e.g. { params: { to: chain.ERC20.addresses } }.", + ) }) }) diff --git a/packages/envio-tests/test/Config_test.res b/packages/envio-tests/test/Config_test.res index 493fe374b2..8aa459f73d 100644 --- a/packages/envio-tests/test/Config_test.res +++ b/packages/envio-tests/test/Config_test.res @@ -271,7 +271,7 @@ describe("EventConfigBuilder", () => { }) it("abiTypeToSchema throws on unsupported types", t => { - t.expect(() => EventConfigBuilder.abiTypeToSchema("function")).toThrowError( + t->toThrowErrorEqual(() => EventConfigBuilder.abiTypeToSchema("function"), "Unsupported ABI type: function", ) }) diff --git a/packages/envio-tests/test/EntityFilter_test.res b/packages/envio-tests/test/EntityFilter_test.res index 304fd0dc48..0a7cfe5592 100644 --- a/packages/envio-tests/test/EntityFilter_test.res +++ b/packages/envio-tests/test/EntityFilter_test.res @@ -33,7 +33,7 @@ describe("EntityFilter.parseGetWhereOrThrow", () => { Table.mkField("id", String, ~isPrimaryKey=true, ~fieldSchema=S.string), Table.mkField("score", Int32, ~isIndex=true, ~fieldSchema=S.int), Table.mkField("name", String, ~fieldSchema=S.string), - Table.mkField("owner", Entity({name: "Owner"}), ~linkedEntity="Owner", ~fieldSchema=S.string), + Table.mkField("owner", String, ~linkedEntity="Owner", ~fieldSchema=S.string), Table.mkDerivedFromField("tokens", ~derivedFromEntity="Token", ~derivedFromField="owner"), ], ) @@ -232,12 +232,12 @@ describe("EntityFilter.merge", () => { it("Throws on a mismatched filter instead of silently dropping it", t => { let v = i => i->(Utils.magic: int => unknown) - t.expect(() => + t->toThrowErrorEqual(() => [ EntityFilter.Eq({fieldName: "a", fieldValue: v(1)}), EntityFilter.And({filters: [EntityFilter.Eq({fieldName: "a", fieldValue: v(2)})]}), ]->EntityFilter.merge - ).toThrowError( + , "Unexpected filter And(a:Eq:2) in a merged batch. Filters batched into a single query must use the same operator and field.", ) }) diff --git a/packages/envio-tests/test/MockIndexerHandlers_test.res b/packages/envio-tests/test/MockIndexerHandlers_test.res index 0f737e9795..a82fe45bac 100644 --- a/packages/envio-tests/test/MockIndexerHandlers_test.res +++ b/packages/envio-tests/test/MockIndexerHandlers_test.res @@ -97,7 +97,7 @@ indexer.onEvent({ contract: "Token", event: "Transfer" }, async ({ event, contex }) it("throws the exact diagnostic on a nonexistent event", t => { - t.expect( + t->toThrowErrorEqual( () => InternalTestIndexer.fromUserApi( ~schema, @@ -107,6 +107,8 @@ indexer.onEvent({ contract: "Token", event: "Nonexistent" }, async () => {}); `, ~configYaml=yaml, )->ignore, - ).toThrowError(`Type '"Nonexistent"' is not assignable to type '"Transfer"'`) + + "Handler type errors:\n__mock_indexer_handlers.ts(3,38): error TS2322: Type '\"Nonexistent\"' is not assignable to type '\"Transfer\"'.", + ) }) }) diff --git a/packages/envio-tests/test/UserApiValidation_test.res b/packages/envio-tests/test/UserApiValidation_test.res index beff86fa66..f97b69a030 100644 --- a/packages/envio-tests/test/UserApiValidation_test.res +++ b/packages/envio-tests/test/UserApiValidation_test.res @@ -144,8 +144,8 @@ describe("EVM config YAML", () => { ["checksum", "lowercase"]->Array.forEach(addressFormat => { it(`rejects invalid addresses with address_format: ${addressFormat}`, t => { - t.expect(() => parseAddressConfig(~addressFormat, "0xfoo")->ignore).toThrowError( - `Contract "ERC20" on chain 1 has invalid address "0xfoo"`, + t->toThrowErrorEqual(() => parseAddressConfig(~addressFormat, "0xfoo")->ignore, + `Config parse error: Contract "ERC20" on chain 1 has invalid address "0xfoo". Expected a 20-byte hex string starting with 0x.`, ) }) }) diff --git a/packages/envio-tests/test/Utils_test.res b/packages/envio-tests/test/Utils_test.res index fb117c552c..76ecda0f00 100644 --- a/packages/envio-tests/test/Utils_test.res +++ b/packages/envio-tests/test/Utils_test.res @@ -113,11 +113,11 @@ describe("Hash", () => { }) it("set", t => { - t.expect( + t->toThrowErrorEqual( () => { Utils.Hash.makeOrThrow(Utils.Set.fromArray(["1", "2"])) }, - ).toThrowError(`Failed to get hash for Set. If you're using a custom Sury schema make it based on the string type with a decoder: const myTypeSchema = S.transform(S.string, undefined, (yourType) => yourType.toString())`) + `Failed to get hash for Set. If you're using a custom Sury schema make it based on the string type with a decoder: const myTypeSchema = S.transform(S.string, undefined, (yourType) => yourType.toString())`) }) it("symbol", t => { diff --git a/packages/envio-tests/test/lib_tests/ColumnNameFormat_test.res b/packages/envio-tests/test/lib_tests/ColumnNameFormat_test.res index b47d53c886..88efdec72b 100644 --- a/packages/envio-tests/test/lib_tests/ColumnNameFormat_test.res +++ b/packages/envio-tests/test/lib_tests/ColumnNameFormat_test.res @@ -158,11 +158,12 @@ ORDER BY (id, envio_checkpoint_id)`) it("serializes ClickHouse set updates with ClickHouse column keys", t => { let setUpdateSchema = EntityHistory.makeSetUpdateSchema( + ~idSchema=snapshotEntity.table->Table.getIdSchema, ClickHouse.makeClickHouseEntitySchema(snapshotEntity.table), ) let json = Change.Set({ - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: snapshot1->(Utils.magic: snapshot => Internal.entity), checkpointId: 5n, })->S.reverseConvertToJsonOrThrow(setUpdateSchema) diff --git a/packages/envio/index.d.ts b/packages/envio/index.d.ts index af5654fed9..82528c1bdb 100644 --- a/packages/envio/index.d.ts +++ b/packages/envio/index.d.ts @@ -585,14 +585,18 @@ export type SvmOnSlotContext = BaseHandlerContext> >; +/** The entity's `id` type. `ID!`/`String!` ids are `string`; `Int!` is `number` + * and `BigInt!` is `bigint`, so id-keyed operations accept the real scalar. */ +type EntityId = Entity extends { readonly id: infer Id } ? Id : string; + /** Entity operations available in handler contexts. */ type EntityOperations = { - readonly get: (id: string) => Promise; - readonly getOrThrow: (id: string, message?: string) => Promise; + readonly get: (id: EntityId) => Promise; + readonly getOrThrow: (id: EntityId, message?: string) => Promise; readonly getWhere: (filter: GetWhereFilter) => Promise; readonly getOrCreate: (entity: Entity) => Promise; readonly set: (entity: Entity) => void; - readonly deleteUnsafe: (id: string) => void; + readonly deleteUnsafe: (id: EntityId) => void; }; /** Contract registration handle. */ @@ -1510,7 +1514,7 @@ type EntityChangeValue = { /** Entities that were created or updated. */ readonly sets?: readonly Entity[]; /** IDs of entities that were deleted. */ - readonly deleted?: readonly string[]; + readonly deleted?: readonly EntityId[]; }; /** A dynamic contract address registration. */ @@ -1528,9 +1532,9 @@ type ConfigEntities = /** Entity operations available on test indexer for direct entity manipulation. */ type TestIndexerEntityOperations = { /** Get an entity by ID. Returns undefined if not found. */ - readonly get: (id: string) => Promise; + readonly get: (id: EntityId) => Promise; /** Get an entity by ID or throw if not found. */ - readonly getOrThrow: (id: string, message?: string) => Promise; + readonly getOrThrow: (id: EntityId, message?: string) => Promise; /** Get all entities. */ readonly getAll: () => Promise; /** Set (create or update) an entity. */ diff --git a/packages/envio/src/Change.res b/packages/envio/src/Change.res index 0190372dbd..b990e333c0 100644 --- a/packages/envio/src/Change.res +++ b/packages/envio/src/Change.res @@ -1,9 +1,9 @@ @tag("type") type t<'entity> = - | @as("SET") Set({entityId: string, entity: 'entity, checkpointId: bigint}) - | @as("DELETE") Delete({entityId: string, checkpointId: bigint}) + | @as("SET") Set({entityId: EntityId.t, entity: 'entity, checkpointId: bigint}) + | @as("DELETE") Delete({entityId: EntityId.t, checkpointId: bigint}) @get -external getEntityId: t<'entity> => string = "entityId" +external getEntityId: t<'entity> => EntityId.t = "entityId" @get external getCheckpointId: t<'entity> => bigint = "checkpointId" diff --git a/packages/envio/src/Config.res b/packages/envio/src/Config.res index 2b9188d562..e215b68b8b 100644 --- a/packages/envio/src/Config.res +++ b/packages/envio/src/Config.res @@ -390,10 +390,6 @@ let getFieldTypeAndSchema = (prop, ~enumConfigsByName: dictDict.get(enumName)->Option.getOrThrow (Table.Enum({config: enumConfig}), enumConfig.schema->S.toUnknown) } - | "entity" => { - let entityName = prop["entity"]->Option.getOrThrow - (Table.Entity({name: entityName}), S.string->S.toUnknown) - } | other => JsError.throwWithMessage("Unknown field type in entity config: " ++ other) } diff --git a/packages/envio/src/EntityId.res b/packages/envio/src/EntityId.res new file mode 100644 index 0000000000..93d183ac56 --- /dev/null +++ b/packages/envio/src/EntityId.res @@ -0,0 +1,15 @@ +// Opaque representation of an entity id. The generated per-entity types expose +// the real scalar (string / int / bigint); the generic storage and in-memory +// layers work with any entity's id through this type without knowing which +// scalar backs it. The runtime value is always the real id, never a stringified +// form — `toKey` derives the string only where a JS object/dict key is needed. +type t + +external unsafeOfAny: 'a => t = "%identity" +external unsafeToAny: t => 'a = "%identity" +external unsafeOfString: string => t = "%identity" + +// Stringified id used as a JS object/dict key. `String` matches how JS coerces +// a value used as an object key, so an id indexes the same whether the raw +// value or its key form is used for lookup, across string/int/bigint. +let toKey: t => string = %raw(`String`) diff --git a/packages/envio/src/InMemoryStore.res b/packages/envio/src/InMemoryStore.res index 7702dc030c..bb6cce8ef1 100644 --- a/packages/envio/src/InMemoryStore.res +++ b/packages/envio/src/InMemoryStore.res @@ -45,7 +45,10 @@ let setEffectOutput = ( | Some(_) => () | None => inMemTable.changesCount = inMemTable.changesCount +. 1. } - inMemTable.dict->Dict.set(cacheKey, Set({entityId: cacheKey, entity: output, checkpointId})) + inMemTable.dict->Dict.set( + cacheKey, + Set({entityId: cacheKey->EntityId.unsafeOfString, entity: output, checkpointId}), + ) if shouldCache { inMemTable.idsToStore->Array.push(cacheKey)->ignore } @@ -58,7 +61,11 @@ let initEffectOutputFromDb = (inMemTable: EffectState.effectCacheInMemTable, ~ca inMemTable.changesCount = inMemTable.changesCount +. 1. inMemTable.dict->Dict.set( cacheKey, - Set({entityId: cacheKey, entity: output, checkpointId: Internal.loadedFromDbCheckpointId}), + Set({ + entityId: cacheKey->EntityId.unsafeOfString, + entity: output, + checkpointId: Internal.loadedFromDbCheckpointId, + }), ) } @@ -131,7 +138,7 @@ let prepareRollbackDiff = async ( entityTable->InMemoryTable.Entity.set( ~committedCheckpointId, Set({ - entityId: entity.id, + entityId: entity.id->EntityId.unsafeOfString, checkpointId: rollbackDiffCheckpointId, entity, }), @@ -177,7 +184,7 @@ let setBatchDcs = (state: IndexerState.t, ~batch: Batch.t) => { inMemTable->InMemoryTable.Entity.set( ~committedCheckpointId, Set({ - entityId: entity.id, + entityId: entity.id->EntityId.unsafeOfString, checkpointId, entity: entity->InternalTable.EnvioAddresses.castToInternal, }), diff --git a/packages/envio/src/InMemoryTable.res b/packages/envio/src/InMemoryTable.res index d3769845a5..67e91f2764 100644 --- a/packages/envio/src/InMemoryTable.res +++ b/packages/envio/src/InMemoryTable.res @@ -17,11 +17,13 @@ module Entity = { mutable filterIndices: filterIndices, } - // Helper to extract entity ID from any entity + // Helper to extract an entity's id as a dict key. The raw id may be a + // string/int/bigint, so it's stringified to a stable key for in-memory + // indexing. exception UnexpectedIdNotDefinedOnEntity let getEntityIdUnsafe = (entity: Internal.entity): string => - switch (entity->(Utils.magic: Internal.entity => {"id": option}))["id"] { - | Some(id) => id + switch (entity->(Utils.magic: Internal.entity => {"id": option}))["id"] { + | Some(id) => id->EntityId.toKey | None => UnexpectedIdNotDefinedOnEntity->ErrorHandling.mkLogAndRaise( ~msg="Property 'id' does not exist on expected entity object", @@ -142,8 +144,8 @@ module Entity = { } let set = (inMemTable: t, ~committedCheckpointId, change: Change.t) => { - let entityId = change->Change.getEntityId - switch inMemTable.latestEntityChangeById->Utils.Dict.dangerouslyGetNonOption(entityId) { + let entityKey = change->Change.getEntityId->EntityId.toKey + switch inMemTable.latestEntityChangeById->Utils.Dict.dangerouslyGetNonOption(entityKey) { | Some(prev) => let prevCheckpointId = prev->Change.getCheckpointId if ( @@ -158,9 +160,9 @@ module Entity = { switch change { | Set({entity}) => inMemTable->updateIndices(~entity) - | Delete({entityId}) => inMemTable->deleteEntityFromIndices(~entityId) + | Delete({entityId}) => inMemTable->deleteEntityFromIndices(~entityId=entityId->EntityId.toKey) } - inMemTable.latestEntityChangeById->Dict.set(entityId, change) + inMemTable.latestEntityChangeById->Dict.set(entityKey, change) } // Only writes when the id isn't already present, so set always takes its @@ -172,10 +174,10 @@ module Entity = { ~entity: option, ) => if inMemTable.latestEntityChangeById->Utils.Dict.dangerouslyGetNonOption(key)->Option.isNone { + let entityId = key->EntityId.unsafeOfString let change: Change.t = switch entity { - | Some(entity) => - Set({entityId: key, entity, checkpointId: Internal.loadedFromDbCheckpointId}) - | None => Delete({entityId: key, checkpointId: Internal.loadedFromDbCheckpointId}) + | Some(entity) => Set({entityId, entity, checkpointId: Internal.loadedFromDbCheckpointId}) + | None => Delete({entityId, checkpointId: Internal.loadedFromDbCheckpointId}) } inMemTable->set(~committedCheckpointId, change) } diff --git a/packages/envio/src/Internal.res b/packages/envio/src/Internal.res index cac4e71321..644e75c58b 100644 --- a/packages/envio/src/Internal.res +++ b/packages/envio/src/Internal.res @@ -356,11 +356,11 @@ type genericHandlerArgs<'event, 'context> = { type genericHandler<'args> = 'args => promise type entityHandlerContext<'entity> = { - get: string => promise>, - getOrThrow: (string, ~message: string=?) => promise<'entity>, + get: EntityId.t => promise>, + getOrThrow: (EntityId.t, ~message: string=?) => promise<'entity>, getOrCreate: 'entity => promise<'entity>, set: 'entity => unit, - deleteUnsafe: string => unit, + deleteUnsafe: EntityId.t => unit, } type chainInfo = { diff --git a/packages/envio/src/Persistence.res b/packages/envio/src/Persistence.res index 01d9410677..99a9899b31 100644 --- a/packages/envio/src/Persistence.res +++ b/packages/envio/src/Persistence.res @@ -121,7 +121,7 @@ type storage = { getRollbackData: ( ~entityConfig: Internal.entityConfig, ~rollbackTargetCheckpointId: Internal.checkpointId, - ) => promise<(array, array)>, + ) => promise<(array, array)>, // Write batch to storage writeBatch: ( ~batch: Batch.t, diff --git a/packages/envio/src/PgStorage.res b/packages/envio/src/PgStorage.res index 65baa07b04..5b45d49fee 100644 --- a/packages/envio/src/PgStorage.res +++ b/packages/envio/src/PgStorage.res @@ -158,7 +158,10 @@ let getEntityHistory = (~entityConfig: Internal.entityConfig): EntityHistory.pgE ~fields=dataFields->Array.concat([checkpointIdField, actionField]), ) - let setChangeSchema = EntityHistory.makeSetUpdateSchema(entityConfig.schema) + let setChangeSchema = EntityHistory.makeSetUpdateSchema( + ~idSchema=entityConfig.table->Table.getIdSchema, + entityConfig.schema, + ) { EntityHistory.table, @@ -355,8 +358,8 @@ let makeDeleteByIdQuery = (~pgSchema, ~tableName) => { `DELETE FROM "${pgSchema}"."${tableName}" WHERE id = $1;` } -let makeDeleteByIdsQuery = (~pgSchema, ~tableName) => { - `DELETE FROM "${pgSchema}"."${tableName}" WHERE id = ANY($1::text[]);` +let makeDeleteByIdsQuery = (~pgSchema, ~tableName, ~idPgType) => { + `DELETE FROM "${pgSchema}"."${tableName}" WHERE id = ANY($1::${idPgType}[]);` } let makeLoadAllQuery = (~pgSchema, ~tableName) => { @@ -744,18 +747,26 @@ let getConnectedPsqlExec = { } } -let deleteByIdsOrThrow = async (sql, ~pgSchema, ~ids, ~table: Table.table) => { +let deleteByIdsOrThrow = async (sql, ~pgSchema, ~ids: array, ~table: Table.table) => { + // A JSON array of the serialized ids. For a single id the query binds it as + // `$1` directly (the array is the positional-params array); for many it binds + // the whole array to `$1` behind an `ANY(...)`. + let idsJson = table->Table.encodeIdsToJson(ids) switch await ( switch ids { | [_] => sql->Postgres.preparedUnsafe( makeDeleteByIdQuery(~pgSchema, ~tableName=table.tableName), - ids->Obj.magic, + idsJson->Obj.magic, ) | _ => sql->Postgres.preparedUnsafe( - makeDeleteByIdsQuery(~pgSchema, ~tableName=table.tableName), - [ids]->Obj.magic, + makeDeleteByIdsQuery( + ~pgSchema, + ~tableName=table.tableName, + ~idPgType=table->Table.getIdPgFieldType(~pgSchema), + ), + [idsJson]->Obj.magic, ) } ) { @@ -811,9 +822,11 @@ let makeInsertDeleteUpdatesQuery = (~entityConfig: Internal.entityConfig, ~pgSch ~isNullable=false, ) + let idPgType = entityConfig.table->Table.getIdPgFieldType(~pgSchema) + `INSERT INTO "${pgSchema}"."${historyTableName}" (${allHistoryFieldNamesStr}) SELECT ${selectPartsStr} -FROM UNNEST($1::text[], $2::${checkpointIdPgType}[]) AS u(${Table.idFieldName}, ${EntityHistory.checkpointIdFieldName})` +FROM UNNEST($1::${idPgType}[], $2::${checkpointIdPgType}[]) AS u(${Table.idFieldName}, ${EntityHistory.checkpointIdFieldName})` } let executeSet = ( @@ -912,17 +925,21 @@ let rec writeBatch = async ( // Single pass over the change log: track each id's latest change (the last // one seen) and, when saving history, fan every non-diff change out to the // history-table batches. + // Keyed/deduped in memory by the id's string key (toKey), while the + // batches sent to SQL keep the real id values so they serialize with the + // id column's type. let latestChangeById = Dict.make() let orderedIds = [] changes->Array.forEach(change => { let entityId = change->Change.getEntityId - if latestChangeById->Utils.Dict.dangerouslyGetNonOption(entityId)->Option.isNone { + let entityKey = entityId->EntityId.toKey + if latestChangeById->Utils.Dict.dangerouslyGetNonOption(entityKey)->Option.isNone { orderedIds->Array.push(entityId) } - latestChangeById->Dict.set(entityId, change) + latestChangeById->Dict.set(entityKey, change) if shouldSaveHistory { if Some(change->Change.getCheckpointId) === diffCheckpointId { - idsWithDiff->Utils.Set.add(entityId)->ignore + idsWithDiff->Utils.Set.add(entityKey)->ignore } else { switch change { | Delete({entityId, checkpointId}) => @@ -936,13 +953,14 @@ let rec writeBatch = async ( let backfillHistoryIds = Utils.Set.make() orderedIds->Array.forEach(entityId => { - switch latestChangeById->Dict.getUnsafe(entityId) { + let entityKey = entityId->EntityId.toKey + switch latestChangeById->Dict.getUnsafe(entityKey) { | Set({entity}) => entitiesToSet->Array.push(entity) | Delete({entityId}) => idsToDelete->Array.push(entityId) } // An id needs a history backfill iff none of its changes is the diff. - if shouldSaveHistory && !(idsWithDiff->Utils.Set.has(entityId)) { + if shouldSaveHistory && !(idsWithDiff->Utils.Set.has(entityKey)) { backfillHistoryIds->Utils.Set.add(entityId)->ignore } }) @@ -962,7 +980,7 @@ let rec writeBatch = async ( await EntityHistory.backfillHistory( sql, ~pgSchema, - ~entityName=entityConfig.name, + ~table=entityConfig.table, ~entityIndex=entityConfig.index, ~ids=backfillHistoryIds->Utils.Set.toArray, ) @@ -974,7 +992,7 @@ let rec writeBatch = async ( ->Postgres.preparedUnsafe( makeInsertDeleteUpdatesQuery(~entityConfig, ~pgSchema), ( - batchDeleteEntityIds, + entityConfig.table->Table.encodeIdsToJson(batchDeleteEntityIds), batchDeleteCheckpointIds->Utils.BigInt.arrayToStringArray, )->Obj.magic, ) @@ -1230,10 +1248,25 @@ let makeGetRollbackRemovedIdsQuery = (~entityConfig: Internal.entityConfig, ~pgS )` } -let rollbackRowStateSchema = S.object(s => ( - s.field(Table.idFieldName, S.string), - s.field(EntityHistory.changeFieldName, EntityHistory.RowAction.schema), -)) +// Memoized per table so the id is parsed with that entity's id schema (a +// numeric id comes back from Postgres as a number, not a string) and the +// schema's operations compile once rather than per rollback row. +let rollbackRowStateSchema: Table.table => S.t<( + EntityId.t, + EntityHistory.RowAction.t, +)> = Utils.WeakMap.memoize(table => + S.object(s => ( + s.field(Table.idFieldName, table->Table.getIdSchema), + s.field(EntityHistory.changeFieldName, EntityHistory.RowAction.schema), + )) +) + +// Same reason as above for the id-only rows: both rollback queries must yield +// ids in the entity's own representation, or the two halves of the diff would +// disagree (Postgres hands back a NUMERIC id as a string, not a bigint). +let rollbackRemovedIdsSchema: Table.table => S.t> = Utils.WeakMap.memoize(table => + S.array(S.object(s => s.field(Table.idFieldName, table->Table.getIdSchema))) +) let make = ( ~sql: Postgres.sql, @@ -1757,7 +1790,7 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3:: makeGetRollbackRemovedIdsQuery(~entityConfig, ~pgSchema), [rollbackTargetCheckpointId->BigInt.toString]->(Utils.magic: array => unknown), ) - ->(Utils.magic: promise => promise>), + ->(Utils.magic: promise => promise>), // Get the latest pre-target row, including its SET or DELETE action. sql ->Postgres.preparedUnsafe( @@ -1767,10 +1800,10 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3:: ->(Utils.magic: promise => promise>), )) - let removedIds = removedIdRows->Array.map(row => row["id"]) + let removedIds = removedIdRows->S.parseOrThrow(rollbackRemovedIdsSchema(entityConfig.table)) let restoredEntitiesResult = [] rollbackRows->Array.forEach(row => { - let (entityId, action) = row->S.parseOrThrow(rollbackRowStateSchema) + let (entityId, action) = row->S.parseOrThrow(rollbackRowStateSchema(entityConfig.table)) switch action { | SET => restoredEntitiesResult->Array.push(row)->ignore | DELETE => removedIds->Array.push(entityId)->ignore diff --git a/packages/envio/src/TestIndexer.res b/packages/envio/src/TestIndexer.res index 29342fa5dd..71362679ea 100644 --- a/packages/envio/src/TestIndexer.res +++ b/packages/envio/src/TestIndexer.res @@ -22,7 +22,7 @@ type t<'processConfig> = {process: 'processConfig => promise} type entityChange = { sets: array, - deleted: array, + deleted: array, } type testIndexerState = { @@ -136,11 +136,12 @@ let handleWriteBatch = ( switch change { | Set({entityId, entity, checkpointId}) => // The store keeps decoded entities so load comparisons (bigint / - // BigDecimal) work on real values. - entityDict->Dict.set(entityId, entity) + // BigDecimal) work on real values. Ids are keyed by their string form + // since they may be string/int/bigint. + entityDict->Dict.set(entityId->EntityId.toKey, entity) entityChangeFor(checkpointId).sets->Array.push(entity->Utils.magic)->ignore | Delete({entityId, checkpointId}) => - Dict.delete(entityDict->Obj.magic, entityId) + Dict.delete(entityDict->Obj.magic, entityId->EntityId.toKey) entityChangeFor(checkpointId).deleted->Array.push(entityId)->ignore } } @@ -197,7 +198,7 @@ let handleWriteBatch = ( entityObj->Dict.set("sets", sets->(Utils.magic: array => unknown)) } if deleted->Array.length > 0 { - entityObj->Dict.set("deleted", deleted->(Utils.magic: array => unknown)) + entityObj->Dict.set("deleted", deleted->(Utils.magic: array => unknown)) } // Match the capitalized entity accessor the generated change types expose. change->Dict.set( diff --git a/packages/envio/src/UserContext.res b/packages/envio/src/UserContext.res index 5a3e4bcc26..2a24dea377 100644 --- a/packages/envio/src/UserContext.res +++ b/packages/envio/src/UserContext.res @@ -144,7 +144,7 @@ let getWhereHandler = (params: entityContextParams, filter: dict>) } let noopSet = (_entity: Internal.entity) => () -let noopDeleteUnsafe = (_entityId: string) => () +let noopDeleteUnsafe = (_entityId: EntityId.t) => () // Reads against ClickHouse-only entities have no Postgres table to hit; // surface a friendly error instead of letting the SQL layer fail with @@ -168,7 +168,7 @@ let entityTraps: Utils.Proxy.traps = { ->InMemoryTable.Entity.set( ~committedCheckpointId=params.indexerState->IndexerState.committedCheckpointId, Set({ - entityId: entity.id, + entityId: entity.id->EntityId.unsafeOfString, checkpointId: params.checkpointId, entity, }), @@ -282,7 +282,7 @@ let entityTraps: Utils.Proxy.traps = { }), ) } - }->(Utils.magic: (string => unit) => unknown) + }->(Utils.magic: (EntityId.t => unit) => unknown) | _ => JsError.throwWithMessage(`Invalid context.${params.entityConfig.name}.${prop} operation.`) } diff --git a/packages/envio/src/bindings/ClickHouse.res b/packages/envio/src/bindings/ClickHouse.res index 4cb9db7832..d1292c502e 100644 --- a/packages/envio/src/bindings/ClickHouse.res +++ b/packages/envio/src/bindings/ClickHouse.res @@ -91,7 +91,6 @@ let getClickHouseFieldType = ( ->Array.joinUnsafe(", ") `${enumType}(${enumValues})` } - | Entity(_) => "String" } let baseType = if isArray { @@ -261,11 +260,14 @@ let setUpdatesOrThrow = async ( convertOrThrow: S.compile( S.array( S.union([ - EntityHistory.makeSetUpdateSchema(makeClickHouseEntitySchema(entityConfig.table)), + EntityHistory.makeSetUpdateSchema( + ~idSchema=entityConfig.table->Table.getIdSchema, + makeClickHouseEntitySchema(entityConfig.table), + ), S.object(s => { s.tag(EntityHistory.changeFieldName, EntityHistory.RowAction.DELETE) Change.Delete({ - entityId: s.field(Table.idFieldName, S.string), + entityId: s.field(Table.idFieldName, entityConfig.table->Table.getIdSchema), checkpointId: s.field( EntityHistory.checkpointIdFieldName, EntityHistory.unsafeCheckpointIdSchema, diff --git a/packages/envio/src/bindings/Vitest.res b/packages/envio/src/bindings/Vitest.res index d68faf4568..9c9e4d38aa 100644 --- a/packages/envio/src/bindings/Vitest.res +++ b/packages/envio/src/bindings/Vitest.res @@ -34,7 +34,6 @@ type rec expectation<'a> = { toHavePropertyValue: 'b. (string, 'b) => unit, // Exception matchers toThrow: unit => unit, - toThrowError: string => unit, // Snapshot matchers toMatchSnapshot: unit => unit, // Negation @@ -151,3 +150,25 @@ module Async = { @module("vitest") external expect: ('a, ~message: string=?) => expectation<'a> = "expect" + +// Runs `fn` and returns the thrown error's message — mirroring JS's +// `e instanceof Error ? e.message : String(e)` — or `None` when nothing threw. +let messageOfThrown: (unit => 'a) => option = %raw(`function (fn) { + try { + fn(); + return undefined; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } +}`) + +// Strict counterpart to the built-in `toThrow`, which only checks that the +// thrown message *contains* the expected string. `toThrowErrorEqual` requires +// the whole message to match, so a test can pin the complete error text. +// A plain ReScript function (not a custom `expect.extend` matcher), so it needs +// no per-package vitest setup. +// Compared as options rather than through a "didn't throw" placeholder string, +// so a function that throws nothing can never match — not even when `expected` +// happens to equal the placeholder. +let toThrowErrorEqual = (t: testContext, fn: unit => 'a, ~message=?, expected: string) => + t.expect(fn->messageOfThrown, ~message?).toEqual(Some(expected)) diff --git a/packages/envio/src/db/EntityHistory.res b/packages/envio/src/db/EntityHistory.res index cab7a930e9..acba42ddb5 100644 --- a/packages/envio/src/db/EntityHistory.res +++ b/packages/envio/src/db/EntityHistory.res @@ -30,12 +30,14 @@ let unsafeCheckpointIdSchema = serializer: bigint => bigint->BigInt.toString, }) -let makeSetUpdateSchema: S.t<'entity> => S.t> = entitySchema => { +let makeSetUpdateSchema = (~idSchema: S.t, entitySchema: S.t<'entity>): S.t< + Change.t<'entity>, +> => { S.object(s => { s.tag(changeFieldName, RowAction.SET) Change.Set({ checkpointId: s.field(checkpointIdFieldName, unsafeCheckpointIdSchema), - entityId: s.field(Table.idFieldName, S.string), + entityId: s.field(Table.idFieldName, idSchema), entity: s.flatten(entitySchema), }) }) @@ -118,10 +120,10 @@ let pruneStaleEntityHistory = ( // If an entity doesn't have a history before the update // we create it automatically with envio_checkpoint_id 0 -let makeBackfillHistoryQuery = (~pgSchema, ~entityName, ~entityIndex) => { +let makeBackfillHistoryQuery = (~pgSchema, ~entityName, ~entityIndex, ~idPgType) => { let historyTableRef = `"${pgSchema}"."${historyTableName(~entityName, ~entityIndex)}"` `WITH target_ids AS ( - SELECT UNNEST($1::${(Text: Postgres.columnType :> string)}[]) AS id + SELECT UNNEST($1::${idPgType}[]) AS id ), missing_history AS ( SELECT e.* @@ -135,11 +137,18 @@ SELECT *, 0 AS ${checkpointIdFieldName}, '${(RowAction.SET :> string)}' as ${cha FROM missing_history;` } -let backfillHistory = (sql, ~pgSchema, ~entityName, ~entityIndex, ~ids: array) => { +let backfillHistory = ( + sql, + ~pgSchema, + ~table: Table.table, + ~entityIndex, + ~ids: array, +) => { + let idPgType = table->Table.getIdPgFieldType(~pgSchema) sql ->Postgres.preparedUnsafe( - makeBackfillHistoryQuery(~entityName, ~entityIndex, ~pgSchema), - [ids]->Obj.magic, + makeBackfillHistoryQuery(~entityName=table.tableName, ~entityIndex, ~pgSchema, ~idPgType), + [table->Table.encodeIdsToJson(ids)]->(Utils.magic: array => unknown), ) ->Utils.Promise.ignoreValue } diff --git a/packages/envio/src/db/Table.res b/packages/envio/src/db/Table.res index 4b5a8ba5d3..4b212e2c67 100644 --- a/packages/envio/src/db/Table.res +++ b/packages/envio/src/db/Table.res @@ -31,7 +31,6 @@ type fieldType = | Json | Date | Enum({config: enumConfig}) - | Entity({name: string}) type field = { fieldName: string, @@ -168,7 +167,6 @@ let getPgFieldType = ( | Date => (isNullable ? Postgres.TimestampWithTimezoneNull : Postgres.TimestampWithTimezone :> string) | Enum({config}) => `"${pgSchema}".${config.name}` - | Entity(_) => (Postgres.Text :> string) // FIXME: Will it work correctly if id is not a text column? } // Workaround for Hasura bug https://github.com/enviodev/hyperindex/issues/788 @@ -248,6 +246,40 @@ let getDerivedFromFields = table => let getFieldByName = (table, fieldName) => table.fields->Array.find(field => field->getUserDefinedFieldName === fieldName) +exception NoIdField(string) + +// The `id` primary-key field. Its type drives both the id column and every +// foreign key that references the entity, so id-typed SQL (delete-by-id, +// history backfill) reads the column type and value schema from here. +let getIdFieldOrThrow = (table): field => + switch table->getFieldByName(idFieldName) { + | Some(Field(field)) => field + | _ => throw(NoIdField(table.tableName)) + } + +let getIdPgFieldType = (table, ~pgSchema) => + getPgFieldType( + ~fieldType=(table->getIdFieldOrThrow).fieldType, + ~pgSchema, + ~isArray=false, + ~isNumericArrayAsText=false, + ~isNullable=false, + ) + +// Schema for a single id value, typed opaquely so id-generic code can serialize +// ids regardless of the underlying scalar. +let getIdSchema = (table): S.t => + (table->getIdFieldOrThrow).fieldSchema->(Utils.magic: S.t => S.t) + +// Serializes an array of ids to the JSON form the SQL layer binds. The array +// schema is memoized per table so its serializer compiles once, not on every +// (high-frequency) delete/history write. +let idsArraySchema: table => S.t> = Utils.WeakMap.memoize(table => + S.array(table->getIdSchema) +) +let encodeIdsToJson = (table, ids: array): JSON.t => + ids->S.reverseConvertToJsonOrThrow(table->idsArraySchema) + // TODO: Test whether it should be passed via args and match the column type let getFieldByApiName = (table, apiFieldName) => @@ -297,13 +329,12 @@ let makeRowsSchema = (table, ~rowFieldName) => S.array( S.object(s => { let dict = Dict.make() - table.fields->Array.forEach( - field => - switch field { - | Field(field) => - dict->Dict.set(field->getApiFieldName, s.field(field->rowFieldName, field.fieldSchema)) - | DerivedFrom(_) => () - }, + table.fields->Array.forEach(field => + switch field { + | Field(field) => + dict->Dict.set(field->getApiFieldName, s.field(field->rowFieldName, field.fieldSchema)) + | DerivedFrom(_) => () + } ) dict })->(Utils.magic: S.t> => S.t), diff --git a/scenarios/fuel_test/src/Indexer.res b/scenarios/fuel_test/src/Indexer.res index 7c7a46d005..1400d05095 100644 --- a/scenarios/fuel_test/src/Indexer.res +++ b/scenarios/fuel_test/src/Indexer.res @@ -81,13 +81,14 @@ module Entities = { type id = string module User = { + type id = string type t = {id: id, greetings: array, latestGreeting: string, numberOfGreetings: int} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("greetings") greetings?: Envio.whereOperator>, @as("latestGreeting") latestGreeting?: Envio.whereOperator, @as("numberOfGreetings") numberOfGreetings?: Envio.whereOperator} } - type rec name<'entity> = - | @as("User") User: name + type rec name<'entity, 'id> = + | @as("User") User: name } type handlerEntityOperations<'entity, 'getWhereFilter> = { @@ -1197,6 +1198,17 @@ type testIndexerEntityOperations<'entity> = { set: 'entity => unit, } +type testIndexerEntityOperationsWithCustomId<'entity, 'id> = { + /** Get an entity by ID. */ + get: 'id => promise>, + /** Get all entities. */ + getAll: unit => promise>, + /** Get an entity by ID or throw if not found. */ + getOrThrow: ('id, ~message: string=?) => promise<'entity>, + /** Set (create or update) an entity. */ + set: 'entity => unit, +} + /** Test indexer type with process method, entity access, and chain info. */ type testIndexer = { /** Process blocks for the specified chains and return progress with changes. */ @@ -1208,7 +1220,7 @@ type testIndexer = { \"User": testIndexerEntityOperations, } -@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity>) => testIndexerEntityOperations<'entity> = "" +@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity, 'id>) => testIndexerEntityOperationsWithCustomId<'entity, 'id> = "" @module("envio") external indexer: indexer = "indexer" diff --git a/scenarios/svm_test/src/Indexer.res b/scenarios/svm_test/src/Indexer.res index 3d8a23aee7..e930923057 100644 --- a/scenarios/svm_test/src/Indexer.res +++ b/scenarios/svm_test/src/Indexer.res @@ -75,13 +75,14 @@ module Entities = { type id = string module SlotPing = { + type id = string type t = {id: id, slot: int} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("slot") slot?: Envio.whereOperator} } - type rec name<'entity> = - | @as("SlotPing") SlotPing: name + type rec name<'entity, 'id> = + | @as("SlotPing") SlotPing: name } type handlerEntityOperations<'entity, 'getWhereFilter> = { @@ -197,6 +198,17 @@ type testIndexerEntityOperations<'entity> = { set: 'entity => unit, } +type testIndexerEntityOperationsWithCustomId<'entity, 'id> = { + /** Get an entity by ID. */ + get: 'id => promise>, + /** Get all entities. */ + getAll: unit => promise>, + /** Get an entity by ID or throw if not found. */ + getOrThrow: ('id, ~message: string=?) => promise<'entity>, + /** Set (create or update) an entity. */ + set: 'entity => unit, +} + /** Test indexer type with process method, entity access, and chain info. */ type testIndexer = { /** Process blocks for the specified chains and return progress with changes. */ @@ -208,7 +220,7 @@ type testIndexer = { \"SlotPing": testIndexerEntityOperations, } -@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity>) => testIndexerEntityOperations<'entity> = "" +@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity, 'id>) => testIndexerEntityOperationsWithCustomId<'entity, 'id> = "" @module("envio") external indexer: indexer = "indexer" diff --git a/scenarios/test_codegen/schema.graphql b/scenarios/test_codegen/schema.graphql index 1e6244300f..805fe5683d 100644 --- a/scenarios/test_codegen/schema.graphql +++ b/scenarios/test_codegen/schema.graphql @@ -176,3 +176,15 @@ type SimpleEntity { id: ID! value: String! } + +# Non-string entity ids: the id column and any foreign key referencing it adopt +# the id's scalar, and the generated get/getOrThrow/deleteUnsafe are keyed by it. +type IntIdEntity { + id: Int! + value: String! +} + +type BigIntIdEntity { + id: BigInt! + numericRef: IntIdEntity! +} diff --git a/scenarios/test_codegen/src/Indexer.res b/scenarios/test_codegen/src/Indexer.res index f90dd4856a..a0cb21184c 100644 --- a/scenarios/test_codegen/src/Indexer.res +++ b/scenarios/test_codegen/src/Indexer.res @@ -200,139 +200,174 @@ module Entities = { type id = string module A = { - type t = {id: id, b_id: id, optionalStringToTestLinkedEntities: option} + type id = string + type t = {id: id, b_id: string, optionalStringToTestLinkedEntities: option} - type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("b_id") b?: Envio.whereOperator, @as("optionalStringToTestLinkedEntities") optionalStringToTestLinkedEntities?: Envio.whereOperator>} + type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("b_id") b?: Envio.whereOperator, @as("optionalStringToTestLinkedEntities") optionalStringToTestLinkedEntities?: Envio.whereOperator>} } module B = { - type t = {id: id, c_id: option} + type id = string + type t = {id: id, c_id: option} - type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("c_id") c?: Envio.whereOperator>} + type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("c_id") c?: Envio.whereOperator>} + } + + module BigIntIdEntity = { + type id = bigint + type t = {id: bigint, numericRef_id: int} + + type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("numericRef_id") numericRef?: Envio.whereOperator} } module C = { - type t = {id: id, a_id: id, stringThatIsMirroredToA: string} + type id = string + type t = {id: id, a_id: string, stringThatIsMirroredToA: string} - type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("a_id") a?: Envio.whereOperator, @as("stringThatIsMirroredToA") stringThatIsMirroredToA?: Envio.whereOperator} + type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("a_id") a?: Envio.whereOperator, @as("stringThatIsMirroredToA") stringThatIsMirroredToA?: Envio.whereOperator} } module CustomSelectionTestPass = { + type id = string type t = {id: id} type getWhereFilter = {@as("id") id?: Envio.whereOperator} } module D = { + type id = string type t = {id: id, c: id} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("c") c?: Envio.whereOperator} } module EntityWith63LenghtName______________________________________one = { + type id = string type t = {id: id} type getWhereFilter = {@as("id") id?: Envio.whereOperator} } module EntityWith63LenghtName______________________________________two = { + type id = string type t = {id: id} type getWhereFilter = {@as("id") id?: Envio.whereOperator} } module EntityWithAllNonArrayTypes = { + type id = string type t = {id: id, string: string, optString: option, int_: int, optInt: option, float_: float, optFloat: option, bool: bool, optBool: option, bigInt: bigint, optBigInt: option, bigDecimal: BigDecimal.t, optBigDecimal: option, bigDecimalWithConfig: BigDecimal.t, enumField: Enums.AccountType.t, optEnumField: option, timestamp: Date.t, optTimestamp: option} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("string") string?: Envio.whereOperator, @as("optString") optString?: Envio.whereOperator>, @as("int_") int_?: Envio.whereOperator, @as("optInt") optInt?: Envio.whereOperator>, @as("float_") float_?: Envio.whereOperator, @as("optFloat") optFloat?: Envio.whereOperator>, @as("bool") bool?: Envio.whereOperator, @as("optBool") optBool?: Envio.whereOperator>, @as("bigInt") bigInt?: Envio.whereOperator, @as("optBigInt") optBigInt?: Envio.whereOperator>, @as("bigDecimal") bigDecimal?: Envio.whereOperator, @as("optBigDecimal") optBigDecimal?: Envio.whereOperator>, @as("bigDecimalWithConfig") bigDecimalWithConfig?: Envio.whereOperator, @as("enumField") enumField?: Envio.whereOperator, @as("optEnumField") optEnumField?: Envio.whereOperator>, @as("timestamp") timestamp?: Envio.whereOperator, @as("optTimestamp") optTimestamp?: Envio.whereOperator>} } module EntityWithAllTypes = { + type id = string type t = {id: id, string: string, optString: option, arrayOfStrings: array, int_: int, optInt: option, arrayOfInts: array, float_: float, optFloat: option, arrayOfFloats: array, bool: bool, optBool: option, bigInt: bigint, optBigInt: option, arrayOfBigInts: array, bigDecimal: BigDecimal.t, optBigDecimal: option, bigDecimalWithConfig: BigDecimal.t, arrayOfBigDecimals: array, timestamp: Date.t, optTimestamp: option, json: JSON.t, enumField: Enums.AccountType.t, optEnumField: option} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("string") string?: Envio.whereOperator, @as("optString") optString?: Envio.whereOperator>, @as("arrayOfStrings") arrayOfStrings?: Envio.whereOperator>, @as("int_") int_?: Envio.whereOperator, @as("optInt") optInt?: Envio.whereOperator>, @as("arrayOfInts") arrayOfInts?: Envio.whereOperator>, @as("float_") float_?: Envio.whereOperator, @as("optFloat") optFloat?: Envio.whereOperator>, @as("arrayOfFloats") arrayOfFloats?: Envio.whereOperator>, @as("bool") bool?: Envio.whereOperator, @as("optBool") optBool?: Envio.whereOperator>, @as("bigInt") bigInt?: Envio.whereOperator, @as("optBigInt") optBigInt?: Envio.whereOperator>, @as("arrayOfBigInts") arrayOfBigInts?: Envio.whereOperator>, @as("bigDecimal") bigDecimal?: Envio.whereOperator, @as("optBigDecimal") optBigDecimal?: Envio.whereOperator>, @as("bigDecimalWithConfig") bigDecimalWithConfig?: Envio.whereOperator, @as("arrayOfBigDecimals") arrayOfBigDecimals?: Envio.whereOperator>, @as("timestamp") timestamp?: Envio.whereOperator, @as("optTimestamp") optTimestamp?: Envio.whereOperator>, @as("json") json?: Envio.whereOperator, @as("enumField") enumField?: Envio.whereOperator, @as("optEnumField") optEnumField?: Envio.whereOperator>} } module EntityWithBigDecimal = { + type id = string type t = {id: id, bigDecimal: BigDecimal.t} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("bigDecimal") bigDecimal?: Envio.whereOperator} } module EntityWithRestrictedReScriptField = { + type id = string type t = {id: id, @as("type") type_: string} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("type") type_?: Envio.whereOperator} } module EntityWithTimestamp = { + type id = string type t = {id: id, timestamp: Date.t} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("timestamp") timestamp?: Envio.whereOperator} } module Gravatar = { - type t = {id: id, owner_id: id, displayName: string, imageUrl: string, updatesCount: bigint, size: Enums.GravatarSize.t} + type id = string + type t = {id: id, owner_id: string, displayName: string, imageUrl: string, updatesCount: bigint, size: Enums.GravatarSize.t} + + type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("owner_id") owner?: Envio.whereOperator, @as("displayName") displayName?: Envio.whereOperator, @as("imageUrl") imageUrl?: Envio.whereOperator, @as("updatesCount") updatesCount?: Envio.whereOperator, @as("size") size?: Envio.whereOperator} + } - type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("owner_id") owner?: Envio.whereOperator, @as("displayName") displayName?: Envio.whereOperator, @as("imageUrl") imageUrl?: Envio.whereOperator, @as("updatesCount") updatesCount?: Envio.whereOperator, @as("size") size?: Envio.whereOperator} + module IntIdEntity = { + type id = int + type t = {id: int, value: string} + + type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("value") value?: Envio.whereOperator} } module NftCollection = { + type id = string type t = {id: id, contractAddress: string, name: string, symbol: string, maxSupply: bigint, currentSupply: int} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("contractAddress") contractAddress?: Envio.whereOperator, @as("name") name?: Envio.whereOperator, @as("symbol") symbol?: Envio.whereOperator, @as("maxSupply") maxSupply?: Envio.whereOperator, @as("currentSupply") currentSupply?: Envio.whereOperator} } module PostgresNumericPrecisionEntityTester = { + type id = string type t = {id: id, exampleBigInt: option, exampleBigIntRequired: bigint, exampleBigIntArray: option>, exampleBigIntArrayRequired: array, exampleBigDecimal: option, exampleBigDecimalRequired: BigDecimal.t, exampleBigDecimalArray: option>, exampleBigDecimalArrayRequired: array, exampleBigDecimalOtherOrder: BigDecimal.t} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("exampleBigInt") exampleBigInt?: Envio.whereOperator>, @as("exampleBigIntRequired") exampleBigIntRequired?: Envio.whereOperator, @as("exampleBigIntArray") exampleBigIntArray?: Envio.whereOperator>>, @as("exampleBigIntArrayRequired") exampleBigIntArrayRequired?: Envio.whereOperator>, @as("exampleBigDecimal") exampleBigDecimal?: Envio.whereOperator>, @as("exampleBigDecimalRequired") exampleBigDecimalRequired?: Envio.whereOperator, @as("exampleBigDecimalArray") exampleBigDecimalArray?: Envio.whereOperator>>, @as("exampleBigDecimalArrayRequired") exampleBigDecimalArrayRequired?: Envio.whereOperator>, @as("exampleBigDecimalOtherOrder") exampleBigDecimalOtherOrder?: Envio.whereOperator} } module SimpleEntity = { + type id = string type t = {id: id, value: string} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("value") value?: Envio.whereOperator} } module SimulateTestEvent = { + type id = string type t = {id: id, blockNumber: int, logIndex: int, timestamp: int} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("blockNumber") blockNumber?: Envio.whereOperator, @as("logIndex") logIndex?: Envio.whereOperator, @as("timestamp") timestamp?: Envio.whereOperator} } module Token = { - type t = {id: id, tokenId: bigint, collection_id: id, owner_id: id} + type id = string + type t = {id: id, tokenId: bigint, collection_id: string, owner_id: string} - type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("tokenId") tokenId?: Envio.whereOperator, @as("collection_id") collection?: Envio.whereOperator, @as("owner_id") owner?: Envio.whereOperator} + type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("tokenId") tokenId?: Envio.whereOperator, @as("collection_id") collection?: Envio.whereOperator, @as("owner_id") owner?: Envio.whereOperator} } module User = { - type t = {id: id, address: string, gravatar_id: option, updatesCountOnUserForTesting: int, accountType: Enums.AccountType.t} + type id = string + type t = {id: id, address: string, gravatar_id: option, updatesCountOnUserForTesting: int, accountType: Enums.AccountType.t} - type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("address") address?: Envio.whereOperator, @as("gravatar_id") gravatar?: Envio.whereOperator>, @as("updatesCountOnUserForTesting") updatesCountOnUserForTesting?: Envio.whereOperator, @as("accountType") accountType?: Envio.whereOperator} + type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("address") address?: Envio.whereOperator, @as("gravatar_id") gravatar?: Envio.whereOperator>, @as("updatesCountOnUserForTesting") updatesCountOnUserForTesting?: Envio.whereOperator, @as("accountType") accountType?: Envio.whereOperator} } - type rec name<'entity> = - | @as("A") A: name - | @as("B") B: name - | @as("C") C: name - | @as("CustomSelectionTestPass") CustomSelectionTestPass: name - | @as("D") D: name - | @as("EntityWith63LenghtName______________________________________one") EntityWith63LenghtName______________________________________one: name - | @as("EntityWith63LenghtName______________________________________two") EntityWith63LenghtName______________________________________two: name - | @as("EntityWithAllNonArrayTypes") EntityWithAllNonArrayTypes: name - | @as("EntityWithAllTypes") EntityWithAllTypes: name - | @as("EntityWithBigDecimal") EntityWithBigDecimal: name - | @as("EntityWithRestrictedReScriptField") EntityWithRestrictedReScriptField: name - | @as("EntityWithTimestamp") EntityWithTimestamp: name - | @as("Gravatar") Gravatar: name - | @as("NftCollection") NftCollection: name - | @as("PostgresNumericPrecisionEntityTester") PostgresNumericPrecisionEntityTester: name - | @as("SimpleEntity") SimpleEntity: name - | @as("SimulateTestEvent") SimulateTestEvent: name - | @as("Token") Token: name - | @as("User") User: name + type rec name<'entity, 'id> = + | @as("A") A: name + | @as("B") B: name + | @as("BigIntIdEntity") BigIntIdEntity: name + | @as("C") C: name + | @as("CustomSelectionTestPass") CustomSelectionTestPass: name + | @as("D") D: name + | @as("EntityWith63LenghtName______________________________________one") EntityWith63LenghtName______________________________________one: name + | @as("EntityWith63LenghtName______________________________________two") EntityWith63LenghtName______________________________________two: name + | @as("EntityWithAllNonArrayTypes") EntityWithAllNonArrayTypes: name + | @as("EntityWithAllTypes") EntityWithAllTypes: name + | @as("EntityWithBigDecimal") EntityWithBigDecimal: name + | @as("EntityWithRestrictedReScriptField") EntityWithRestrictedReScriptField: name + | @as("EntityWithTimestamp") EntityWithTimestamp: name + | @as("Gravatar") Gravatar: name + | @as("IntIdEntity") IntIdEntity: name + | @as("NftCollection") NftCollection: name + | @as("PostgresNumericPrecisionEntityTester") PostgresNumericPrecisionEntityTester: name + | @as("SimpleEntity") SimpleEntity: name + | @as("SimulateTestEvent") SimulateTestEvent: name + | @as("Token") Token: name + | @as("User") User: name } type handlerEntityOperations<'entity, 'getWhereFilter> = { @@ -344,6 +379,15 @@ type handlerEntityOperations<'entity, 'getWhereFilter> = { deleteUnsafe: string => unit, } +type handlerEntityOperationsWithCustomId<'entity, 'id, 'getWhereFilter> = { + get: 'id => promise>, + getOrThrow: ('id, ~message: string=?) => promise<'entity>, + getWhere: 'getWhereFilter => promise>, + getOrCreate: 'entity => promise<'entity>, + set: 'entity => unit, + deleteUnsafe: 'id => unit, +} + type handlerContext = { log: Envio.logger, effect: 'input 'output. (Envio.effect<'input, 'output>, 'input) => promise<'output>, @@ -351,6 +395,7 @@ type handlerContext = { chain: Internal.chainInfo, \"A": handlerEntityOperations, \"B": handlerEntityOperations, + \"BigIntIdEntity": handlerEntityOperationsWithCustomId, \"C": handlerEntityOperations, \"CustomSelectionTestPass": handlerEntityOperations, \"D": handlerEntityOperations, @@ -362,6 +407,7 @@ type handlerContext = { \"EntityWithRestrictedReScriptField": handlerEntityOperations, \"EntityWithTimestamp": handlerEntityOperations, \"Gravatar": handlerEntityOperations, + \"IntIdEntity": handlerEntityOperationsWithCustomId, \"NftCollection": handlerEntityOperations, \"PostgresNumericPrecisionEntityTester": handlerEntityOperations, \"SimpleEntity": handlerEntityOperations, @@ -1976,6 +2022,17 @@ type testIndexerEntityOperations<'entity> = { set: 'entity => unit, } +type testIndexerEntityOperationsWithCustomId<'entity, 'id> = { + /** Get an entity by ID. */ + get: 'id => promise>, + /** Get all entities. */ + getAll: unit => promise>, + /** Get an entity by ID or throw if not found. */ + getOrThrow: ('id, ~message: string=?) => promise<'entity>, + /** Set (create or update) an entity. */ + set: 'entity => unit, +} + /** Test indexer type with process method, entity access, and chain info. */ type testIndexer = { /** Process blocks for the specified chains and return progress with changes. */ @@ -1986,6 +2043,7 @@ type testIndexer = { chains: indexerChains, \"A": testIndexerEntityOperations, \"B": testIndexerEntityOperations, + \"BigIntIdEntity": testIndexerEntityOperationsWithCustomId, \"C": testIndexerEntityOperations, \"CustomSelectionTestPass": testIndexerEntityOperations, \"D": testIndexerEntityOperations, @@ -1997,6 +2055,7 @@ type testIndexer = { \"EntityWithRestrictedReScriptField": testIndexerEntityOperations, \"EntityWithTimestamp": testIndexerEntityOperations, \"Gravatar": testIndexerEntityOperations, + \"IntIdEntity": testIndexerEntityOperationsWithCustomId, \"NftCollection": testIndexerEntityOperations, \"PostgresNumericPrecisionEntityTester": testIndexerEntityOperations, \"SimpleEntity": testIndexerEntityOperations, @@ -2005,7 +2064,7 @@ type testIndexer = { \"User": testIndexerEntityOperations, } -@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity>) => testIndexerEntityOperations<'entity> = "" +@get_index external getTestIndexerEntityOperations: (testIndexer, Entities.name<'entity, 'id>) => testIndexerEntityOperationsWithCustomId<'entity, 'id> = "" @module("envio") external indexer: indexer = "indexer" diff --git a/scenarios/test_codegen/test/ConcurrentWrite_test.res b/scenarios/test_codegen/test/ConcurrentWrite_test.res index 2f8632e58f..053e76b6dc 100644 --- a/scenarios/test_codegen/test/ConcurrentWrite_test.res +++ b/scenarios/test_codegen/test/ConcurrentWrite_test.res @@ -143,7 +143,7 @@ describe("Concurrent batch write and processing", () => { [ Set({ checkpointId: 2n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "created", @@ -151,11 +151,11 @@ describe("Concurrent batch write and processing", () => { }), Delete({ checkpointId: 3n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, }), Set({ checkpointId: 4n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "recreated", diff --git a/scenarios/test_codegen/test/EventBlockFilter_test.res b/scenarios/test_codegen/test/EventBlockFilter_test.res index c099237586..9389d98634 100644 --- a/scenarios/test_codegen/test/EventBlockFilter_test.res +++ b/scenarios/test_codegen/test/EventBlockFilter_test.res @@ -108,31 +108,39 @@ describe("parseWhereOrThrow — static `where` with block filter (EVM)", () => { }) it("rejects `_lte` on event filters with a helpful message", t => { - t.expect(() => + t->toThrowErrorEqual(() => parseEvm( ~eventFilters=Some(%raw(`{block: {number: {_gte: 10, _lte: 200}}}`)), )->ignore - ).toThrowError("Only `_gte` is supported on event filters") + , + "Invalid where configuration for ERC20. `block` filter is invalid: RescriptSchemaError: Failed parsing at root. Reason: Encountered disallowed excess key \"_lte\" on an object. Only `_gte` is supported on event filters — use `indexer.onBlock` for `_lte` or `_every`.", + ) }) it("rejects `_every` on event filters", t => { - t.expect(() => + t->toThrowErrorEqual(() => parseEvm( ~eventFilters=Some(%raw(`{block: {number: {_gte: 10, _every: 5}}}`)), )->ignore - ).toThrowError("Only `_gte` is supported on event filters") + , + "Invalid where configuration for ERC20. `block` filter is invalid: RescriptSchemaError: Failed parsing at root. Reason: Encountered disallowed excess key \"_every\" on an object. Only `_gte` is supported on event filters — use `indexer.onBlock` for `_lte` or `_every`.", + ) }) it("rejects unknown top-level keys (typo catches)", t => { - t.expect(() => + t->toThrowErrorEqual(() => parseEvm(~eventFilters=Some(%raw(`{blocks: {number: {_gte: 10}}}`)))->ignore - ).toThrowError(`Unknown field "blocks"`) + , + `Invalid where configuration. Unknown field "blocks". Indexed parameter filters must be nested under \`params\` and block-range filters under \`block\``, + ) }) it("rejects unknown fields inside `block` (typo catches)", t => { - t.expect(() => + t->toThrowErrorEqual(() => parseEvm(~eventFilters=Some(%raw(`{block: {numbre: {_gte: 10}}}`)))->ignore - ).toThrowError("`block` filter is invalid") + , + "Invalid where configuration for ERC20. `block` filter is invalid: RescriptSchemaError: Failed parsing at [\"block\"]. Reason: Encountered disallowed excess key \"numbre\" on an object. Only `_gte` is supported on event filters — use `indexer.onBlock` for `_lte` or `_every`.", + ) }) }) @@ -172,9 +180,11 @@ describe("parseWhereOrThrow — Fuel block.height", () => { }) it("Fuel rejects `block.number` — the block filter is keyed by height", t => { - t.expect(() => + t->toThrowErrorEqual(() => parseFuel(~eventFilters=Some(%raw(`{block: {number: {_gte: 42}}}`)))->ignore - ).toThrowError("`block` filter is invalid") + , + "Invalid where configuration for ERC20. `block` filter is invalid: RescriptSchemaError: Failed parsing at [\"block\"]. Reason: Encountered disallowed excess key \"number\" on an object. Only `_gte` is supported on event filters — use `indexer.onBlock` for `_lte` or `_every`.", + ) }) }) diff --git a/scenarios/test_codegen/test/EventFilters_test.res b/scenarios/test_codegen/test/EventFilters_test.res index 97afe794c7..8113c7370f 100644 --- a/scenarios/test_codegen/test/EventFilters_test.res +++ b/scenarios/test_codegen/test/EventFilters_test.res @@ -502,7 +502,7 @@ describe("Test eventFilters", () => { ~eventName="WithExcessField", ~chainId=137, ) - t.expect(() => + t->toThrowErrorEqual(() => EventConfigBuilder.buildEvmOnEventRegistration( ~eventConfig, ~isWildcard=true, @@ -514,7 +514,7 @@ describe("Test eventFilters", () => { ~chainId=137, ~onEventBlockFilterSchema=config.ecosystem.onEventBlockFilterSchema, ) - ).toThrowError(`Invalid where configuration. The event doesn't have an indexed parameter "to" and can't use it for filtering`) + , `Invalid where configuration. The event doesn't have an indexed parameter "to" and can't use it for filtering`) }) it("Registration path builds clientAddressFilter for address-filtered events only", t => { diff --git a/scenarios/test_codegen/test/EventHandler.test.ts b/scenarios/test_codegen/test/EventHandler.test.ts index 0ce12e2e34..37e97b632d 100644 --- a/scenarios/test_codegen/test/EventHandler.test.ts +++ b/scenarios/test_codegen/test/EventHandler.test.ts @@ -1214,6 +1214,21 @@ describe("Use Envio test framework to test event handlers", () => { TypeEqual >(true); } + + // Deleted ids follow the entity's id scalar, matching the raw values the + // test indexer reports at runtime. + const intIdChange = change.IntIdEntity; + if (intIdChange) { + expectType< + TypeEqual + >(true); + } + const bigIntIdChange = change.BigIntIdEntity; + if (bigIntIdChange) { + expectType< + TypeEqual + >(true); + } } }); diff --git a/scenarios/test_codegen/test/HandlerRegisterLifecycle_test.res b/scenarios/test_codegen/test/HandlerRegisterLifecycle_test.res index 6fe42b24fe..8890c0b2a3 100644 --- a/scenarios/test_codegen/test/HandlerRegisterLifecycle_test.res +++ b/scenarios/test_codegen/test/HandlerRegisterLifecycle_test.res @@ -56,7 +56,7 @@ describe("HandlerRegister — every onEvent registers separately", () => { }) it("an invalid where throws at the registration call site", t => { - t.expect(() => + t->toThrowErrorEqual(() => HandlerRegister.setHandler( ~contractName="EventFiltersTest", ~eventName="EmptyFiltersArray", @@ -65,7 +65,7 @@ describe("HandlerRegister — every onEvent registers separately", () => { ~where=%raw(`{params: {nonExistingParam: "0x0000000000000000000000000000000000000000"}}`), ), ) - ).toThrowError( + , `Invalid where configuration. The event doesn't have an indexed parameter "nonExistingParam" and can't use it for filtering`, ) }) @@ -85,38 +85,40 @@ describe("HandlerRegister — onBlock validation at registration", () => { ->Dict.fromArray it("throws when where is not a function", t => { - t.expect(() => + t->toThrowErrorEqual(() => HandlerRegister.registerOnBlock( ~name="badWhere", ~where=%raw(`{block: {number: {_gte: 10}}}`), ~handler=noopBlockHandler, ~getChainsObject, ) - ).toThrowError( + , `\`indexer.onBlock("badWhere")\` expected \`where\` to be a function or omitted, but got object.`, ) }) it("throws when where returns a filter with unknown fields", t => { - t.expect(() => + t->toThrowErrorEqual(() => HandlerRegister.registerOnBlock( ~name="typoFilter", ~where=%raw(`() => ({block: {number: {_gt: 10}}})`), ~handler=noopBlockHandler, ~getChainsObject, ) - ).toThrowError(`\`indexer.onBlock("typoFilter")\` \`where\` returned an invalid filter`) + , + `\`indexer.onBlock("typoFilter")\` \`where\` returned an invalid filter: RescriptSchemaError: Failed parsing at root. Reason: Encountered disallowed excess key "_gt" on an object`, + ) }) it("throws when startBlock is below the chain start block", t => { - t.expect(() => + t->toThrowErrorEqual(() => HandlerRegister.registerOnBlock( ~name="tooEarly", ~where=%raw(`({chain}) => chain.id === 137 ? {block: {number: {_gte: 0}}} : false`), ~handler=noopBlockHandler, ~getChainsObject, ) - ).toThrowError( + , `The start block for onBlock handler "tooEarly" is less than the chain start block (1). This is not supported yet.`, ) }) diff --git a/scenarios/test_codegen/test/WriteRead_test.res b/scenarios/test_codegen/test/WriteRead_test.res index 246d36e2a8..ab5e497aa9 100644 --- a/scenarios/test_codegen/test/WriteRead_test.res +++ b/scenarios/test_codegen/test/WriteRead_test.res @@ -100,7 +100,7 @@ describe("Write/read tests", () => { t.expect(await indexerMock.queryHistory(EntityWithAllTypes)).toEqual([ Set({ checkpointId: 1n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: entityWithAllTypes, }), ]) @@ -110,7 +110,7 @@ describe("Write/read tests", () => { t.expect(await indexerMock.queryHistory(EntityWithAllNonArrayTypes)).toEqual([ Set({ checkpointId: 1n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: entityWithAllNonArrayTypes, }), ]) @@ -129,7 +129,7 @@ describe("Write/read tests", () => { ).toEqual([ Set({ checkpointId: 1n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { id: "1", }, @@ -149,7 +149,7 @@ describe("Write/read tests", () => { ).toEqual([ Set({ checkpointId: 1n, - entityId: "2", + entityId: "2"->EntityId.unsafeOfString, entity: { id: "2", }, @@ -220,9 +220,9 @@ breaking precicion on big values. https://github.com/enviodev/hyperindex/issues/ await indexerMock.getBatchWritePromise() t.expect(await indexerMock.queryHistory(SimpleEntity)).toEqual([ - Set({checkpointId: 1n, entityId: "untouched", entity: {id: "untouched", value: "batch1"}}), - Set({checkpointId: 1n, entityId: "updated", entity: {id: "updated", value: "batch1"}}), - Set({checkpointId: 3n, entityId: "updated", entity: {id: "updated", value: "batch2"}}), + Set({checkpointId: 1n, entityId: "untouched"->EntityId.unsafeOfString, entity: {id: "untouched", value: "batch1"}}), + Set({checkpointId: 1n, entityId: "updated"->EntityId.unsafeOfString, entity: {id: "updated", value: "batch1"}}), + Set({checkpointId: 3n, entityId: "updated"->EntityId.unsafeOfString, entity: {id: "updated", value: "batch2"}}), ]) }, ) @@ -235,7 +235,7 @@ breaking precicion on big values. https://github.com/enviodev/hyperindex/issues/ let add = (id, checkpointId) => table->InMemoryTable.Entity.set( ~committedCheckpointId=Internal.initialCheckpointId, - Set({entityId: id, entity: makeEntity(id), checkpointId}), + Set({entityId: id->EntityId.unsafeOfString, entity: makeEntity(id), checkpointId}), ) add("loaded", Internal.loadedFromDbCheckpointId) add("committed", 5n) @@ -257,7 +257,7 @@ breaking precicion on big values. https://github.com/enviodev/hyperindex/issues/ let add = (id, checkpointId) => table->InMemoryTable.Entity.set( ~committedCheckpointId=Internal.initialCheckpointId, - Set({entityId: id, entity: makeEntity(id), checkpointId}), + Set({entityId: id->EntityId.unsafeOfString, entity: makeEntity(id), checkpointId}), ) add("loaded", Internal.loadedFromDbCheckpointId) add("committed", 5n) diff --git a/scenarios/test_codegen/test/helpers/MockIndexer.res b/scenarios/test_codegen/test/helpers/MockIndexer.res index 0b83261150..e02247d2e5 100644 --- a/scenarios/test_codegen/test/helpers/MockIndexer.res +++ b/scenarios/test_codegen/test/helpers/MockIndexer.res @@ -6,8 +6,8 @@ let config = Config.load() let entityConfigByName = (config: Config.t, name): Internal.entityConfig => config.userEntitiesByName->Dict.get(name)->Option.getOrThrow -let entityConfig = (name: Indexer.Entities.name<_>): Internal.entityConfig => - config->entityConfigByName(name->(Utils.magic: Indexer.Entities.name<_> => string)) +let entityConfig = (name: Indexer.Entities.name<_, _>): Internal.entityConfig => + config->entityConfigByName(name->(Utils.magic: Indexer.Entities.name<_, _> => string)) // The store requires a persistence/config even when the cycle never runs; reuse one. // Lazy so importing the helper doesn't open a pg client for tests that never use it. @@ -37,7 +37,7 @@ module InMemoryStore = { inMemTable->InMemoryTable.Entity.set( ~committedCheckpointId=indexerState->IndexerState.committedCheckpointId, Set({ - entityId: (entity: Internal.entity).id, + entityId: (entity: Internal.entity).id->EntityId.unsafeOfString, checkpointId: 0n, entity, }), @@ -384,8 +384,8 @@ module Indexer = { type rec t = { getBatchWritePromise: unit => promise, getRollbackReadyPromise: unit => promise, - query: 'entity. Indexer.Entities.name<'entity> => promise>, - queryHistory: 'entity. Indexer.Entities.name<'entity> => promise>>, + query: 'entity 'id. Indexer.Entities.name<'entity, 'id> => promise>, + queryHistory: 'entity 'id. Indexer.Entities.name<'entity, 'id> => promise>>, queryRaw: 'entity. Internal.entityConfig => promise>, queryCheckpoints: unit => promise>, queryEffectCache: 'input 'output. ( @@ -590,9 +590,9 @@ module Indexer = { resolve() }) }, - query: (type entity, name: Indexer.Entities.name) => { + query: (type entity id, name: Indexer.Entities.name) => { let ec = - config->entityConfigByName(name->(Utils.magic: Indexer.Entities.name => string)) + config->entityConfigByName(name->(Utils.magic: Indexer.Entities.name => string)) sql ->Postgres.unsafe(PgStorage.makeLoadAllQuery(~pgSchema, ~tableName=ec.table.tableName)) ->Promise.thenResolve(items => { @@ -600,9 +600,9 @@ module Indexer = { }) ->(Utils.magic: promise> => promise>) }, - queryHistory: (type entity, name: Indexer.Entities.name) => { + queryHistory: (type entity id, name: Indexer.Entities.name) => { let ec = - config->entityConfigByName(name->(Utils.magic: Indexer.Entities.name => string)) + config->entityConfigByName(name->(Utils.magic: Indexer.Entities.name => string)) sql ->Postgres.unsafe( PgStorage.makeLoadAllQuery( @@ -621,7 +621,7 @@ module Indexer = { S.object((s): Change.t<'entity> => { s.tag(EntityHistory.changeFieldName, EntityHistory.RowAction.DELETE) Delete({ - entityId: s.field("id", S.string), + entityId: s.field("id", ec.table->Table.getIdSchema), checkpointId: s.field( EntityHistory.checkpointIdFieldName, EntityHistory.unsafeCheckpointIdSchema, @@ -632,7 +632,10 @@ module Indexer = { ), ) ->Array.toSorted((a, b) => { - switch String.compare(a->Change.getEntityId, b->Change.getEntityId) { + switch String.compare( + (a->Change.getEntityId)->EntityId.toKey, + (b->Change.getEntityId)->EntityId.toKey, + ) { | 0. => Float.compare( a->Change.getCheckpointId->BigInt.toFloat, diff --git a/scenarios/test_codegen/test/lib_tests/CrossChainState_test.res b/scenarios/test_codegen/test/lib_tests/CrossChainState_test.res index 81f1c992d2..0cb78a5fb8 100644 --- a/scenarios/test_codegen/test/lib_tests/CrossChainState_test.res +++ b/scenarios/test_codegen/test/lib_tests/CrossChainState_test.res @@ -185,7 +185,7 @@ let makeRegistration = (~contractName, ~index): Internal.onEventRegistration => describe("ChainState event registration ownership", () => { it("rejects a registration whose index differs from its ChainState position", t => { - t.expect(() => + t->toThrowErrorEqual(() => makeChainState( ~chainId=1, ~knownHeight=10, @@ -193,7 +193,7 @@ describe("ChainState event registration ownership", () => { ~firstEventBlock=0, ~onEventRegistrations=[makeRegistration(~contractName="ContractA", ~index=4)], )->ignore - ).toThrowError( + , "Invalid onEvent registration index for chain 1: ContractA.EventWithoutFields has index 4, but its ChainState position is 0.", ) }) diff --git a/scenarios/test_codegen/test/lib_tests/EffectState_test.res b/scenarios/test_codegen/test/lib_tests/EffectState_test.res index 966b5b96bc..ce5fc44b02 100644 --- a/scenarios/test_codegen/test/lib_tests/EffectState_test.res +++ b/scenarios/test_codegen/test/lib_tests/EffectState_test.res @@ -24,7 +24,7 @@ describe("EffectState rollback", () => { // Cache-derived fields, all expected to reset. table.idsToStore = ["a"] table.changesCount = 5. - table.dict->Dict.set("a", Set({entityId: "a", entity: "out"->Obj.magic, checkpointId: 0n})) + table.dict->Dict.set("a", Set({entityId: "a"->EntityId.unsafeOfString, entity: "out"->Obj.magic, checkpointId: 0n})) self->EffectState.resetForRollback diff --git a/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res b/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res new file mode 100644 index 0000000000..b8ff066eb5 --- /dev/null +++ b/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res @@ -0,0 +1,461 @@ +open Vitest + +// A numeric-id entity referenced by a foreign key. The referenced entity's id +// type (BigInt here) must flow to the foreign key column, and every id-typed +// SQL statement must cast to the id column's Postgres type rather than text. +let bigParentTable = Table.mkTable( + "BigParent", + ~fields=[Table.mkField("id", BigInt({}), ~isPrimaryKey=true, ~fieldSchema=Utils.BigInt.schema)], +) + +let numericIdTable = Table.mkTable( + "NumericId", + ~fields=[ + Table.mkField("id", Int32, ~isPrimaryKey=true, ~fieldSchema=S.int), + Table.mkField("value", String, ~fieldSchema=S.string), + // Foreign key to BigParent: adopts BigParent's BigInt id type, stored as + // the `parent_id` column. + Table.mkField("parent", BigInt({}), ~linkedEntity="BigParent", ~fieldSchema=Utils.BigInt.schema), + ], +) + +describe("Non-string entity id support", () => { + it("resolves the id column Postgres type per entity", t => { + t.expect(( + numericIdTable->Table.getIdPgFieldType(~pgSchema="public"), + bigParentTable->Table.getIdPgFieldType(~pgSchema="public"), + )).toEqual(("INTEGER", "NUMERIC")) + }) + + it("creates id and foreign-key columns with matching numeric types", t => { + t.expect( + PgStorage.makeCreateTableQuery( + numericIdTable, + ~pgSchema="public", + ~isNumericArrayAsText=false, + ), + ).toBe( + `CREATE TABLE IF NOT EXISTS "public"."NumericId"("id" INTEGER NOT NULL, "value" TEXT NOT NULL, "parent_id" NUMERIC NOT NULL, PRIMARY KEY("id"));`, + ) + }) + + it("casts delete-by-ids to the id column type instead of text", t => { + t.expect( + PgStorage.makeDeleteByIdsQuery( + ~pgSchema="public", + ~tableName="NumericId", + ~idPgType=numericIdTable->Table.getIdPgFieldType(~pgSchema="public"), + ), + ).toBe(`DELETE FROM "public"."NumericId" WHERE id = ANY($1::INTEGER[]);`) + }) + + it("casts history backfill unnest to the id column type", t => { + t.expect( + EntityHistory.makeBackfillHistoryQuery( + ~pgSchema="public", + ~entityName="BigParent", + ~entityIndex=0, + ~idPgType=bigParentTable->Table.getIdPgFieldType(~pgSchema="public"), + )->String.includes("UNNEST($1::NUMERIC[])"), + ).toBe(true) + }) + + it("maps numeric ids to ClickHouse column types", t => { + t.expect(( + ClickHouse.getClickHouseFieldType(~fieldType=Int32, ~isNullable=false, ~isArray=false), + ClickHouse.getClickHouseFieldType( + ~fieldType=BigInt({precision: 20}), + ~isNullable=false, + ~isArray=false, + ), + )).toEqual(("Int32", "Decimal(20,0)")) + }) + + it("degrades an unbounded BigInt id to a ClickHouse String column", t => { + // A BigInt without precision has no Decimal width, so ClickHouse falls back + // to String. This is expected (lexicographic ORDER BY) — pin it so the + // fallback isn't silently changed. + t.expect( + ClickHouse.getClickHouseFieldType(~fieldType=BigInt({}), ~isNullable=false, ~isArray=false), + ).toBe("String") + }) + + it("serializes a history set update keeping the numeric id value", t => { + let entitySchema = + S.object(s => + { + "id": s.field("id", S.int), + "value": s.field("value", S.string), + } + )->(Utils.magic: S.t<{"id": int, "value": string}> => S.t) + + let setUpdateSchema = EntityHistory.makeSetUpdateSchema( + ~idSchema=numericIdTable->Table.getIdSchema, + entitySchema, + ) + + let json = + Change.Set({ + entityId: 123->EntityId.unsafeOfAny, + entity: {"id": 123, "value": "x"}->( + Utils.magic: {"id": int, "value": string} => Internal.entity + ), + checkpointId: 5n, + })->S.reverseConvertToJsonOrThrow(setUpdateSchema) + + t.expect(json).toEqual( + %raw(`{"id": 123, "value": "x", "envio_checkpoint_id": "5", "envio_change": "SET"}`), + ) + }) +}) + +// Compile-time proof that the generated user-facing API keys each entity's +// operations by its real id scalar. These functions are type-checked, never +// run: `IntIdEntity` id is `int`, `BigIntIdEntity` id is `bigint`, and its +// foreign key `numericRef_id` adopts the referenced `Int` id. Passing a string +// where a numeric id is expected would fail to compile. +let _handlerContextKeysOpsByIdScalar = async (context: Indexer.handlerContext) => { + context.\"IntIdEntity".set({id: 1, value: "x"}) + let _: option = await context.\"IntIdEntity".get(1) + let _ = await context.\"IntIdEntity".getOrThrow(1) + context.\"IntIdEntity".deleteUnsafe(1) + + context.\"BigIntIdEntity".set({id: 1n, numericRef_id: 2}) + let _ = await context.\"BigIntIdEntity".get(1n) + context.\"BigIntIdEntity".deleteUnsafe(1n) +} + +let _testIndexerKeysOpsByIdScalar = async (indexer: Indexer.testIndexer) => { + let _: option = await indexer.\"IntIdEntity".get(1) + let _ = await indexer.\"IntIdEntity".getOrThrow(1) + indexer.\"IntIdEntity".set({id: 1, value: "x"}) + let _ = await indexer.\"BigIntIdEntity".get(1n) +} + +// The name-keyed accessor resolves the id through the `name` GADT, so the helper +// form is keyed by the same scalar as direct field access above — passing a +// string id to a numeric entity here would fail to compile. +let _testIndexerHelperKeysOpsByIdScalar = async (indexer: Indexer.testIndexer) => { + let intOps = indexer->Indexer.getTestIndexerEntityOperations(IntIdEntity) + let _: option = await intOps.get(1) + let _ = await intOps.getOrThrow(1) + + let bigIntOps = indexer->Indexer.getTestIndexerEntityOperations(BigIntIdEntity) + let _: option = await bigIntOps.get(1n) + + // A plain `ID!` entity still takes a string, unchanged. + let userOps = indexer->Indexer.getTestIndexerEntityOperations(User) + let _: option = await userOps.get("u1") +} + +// End-to-end coverage through the in-process test indexer + Postgres: a schema +// with Int!/BigInt! ids and foreign keys referencing them, driven by a real +// handler, must round-trip the numeric values and delete by numeric id. +type chainEntity = {id: int} +type vaultEntity = {id: string, @as("chain_id") chainId: int, @as("big_id") bigId: bigint} + +type chainOps = {set: chainEntity => unit, deleteUnsafe: int => unit} +type bigThingOps = {set: {"id": bigint} => unit} +type vaultOps = {set: vaultEntity => unit} +type handlerContext = { + @as("Chain") chain: chainOps, + @as("BigThing") bigThing: bigThingOps, + @as("Vault") vault: vaultOps, +} + +describe("Non-string entity id — end-to-end via the in-process indexer", () => { + Async.it("round-trips Int/BigInt ids and foreign keys and deletes by numeric id", async t => { + let {config} = InternalTestIndexer.fromUserApi( + ~schema=` +type Chain { + id: Int! + vaults: [Vault!]! @derivedFrom(field: "chain") +} +type BigThing { + id: BigInt! +} +type Vault { + id: ID! + chain: Chain! + big: BigThing! +} +`, + ~configYaml=` +name: numeric-ids +chains: + - id: 1337 + rpc: + url: https://rpc.example.test + for: sync + start_block: 1 + contracts: + - name: Token + address: "0x0000000000000000000000000000000000000001" + events: + - event: Transfer() +`, + ) + + let source = MockIndexer.Source.make([#getHeightOrThrow, #getItemsOrThrow], ~chain=#1337) + let indexerMock = await MockIndexer.Indexer.make( + ~config, + ~chains=[{chain: #1337, sourceConfig: Config.CustomSources([source.source])}], + ~shouldRollbackOnReorg=false, + ) + await Utils.delay(0) + + source.resolveGetHeightOrThrow(300) + await Utils.delay(0) + await Utils.delay(0) + + source.resolveGetItemsOrThrow( + [ + { + blockNumber: 5, + logIndex: 0, + handler: async args => { + let context = + args.context->(Utils.magic: MockIndexer.handlerContext => handlerContext) + context.chain.set({id: 137}) + // Deleted below by its numeric id, exercising delete-by-id with an + // integer column instead of text. + context.chain.set({id: 10}) + context.bigThing.set({"id": 999n}) + context.vault.set({id: "v1", chainId: 137, bigId: 999n}) + context.chain.deleteUnsafe(10) + }, + }, + ], + ~latestFetchedBlockNumber=300, + ) + await indexerMock.getBatchWritePromise() + + let chains: array = await indexerMock.queryRaw( + config.userEntitiesByName->Dict.getUnsafe("Chain"), + ) + let vaults: array = await indexerMock.queryRaw( + config.userEntitiesByName->Dict.getUnsafe("Vault"), + ) + + t.expect((chains, vaults)).toEqual(( + [{id: 137}], + [{id: "v1", chainId: 137, bigId: 999n}], + )) + }) +}) + +// A ClickHouse entity keyed by a BigInt id must set a numeric precision: +// ClickHouse stores an unbounded (or over-precision) BigInt as a String, and an +// id is the mandatory sort key, so it would order lexicographically. +describe("ClickHouse BigInt id precision validation", () => { + let parseWithStorage = (~schema, ~storage) => + InternalTestIndexer.fromUserApi( + ~schema, + ~configYaml=` +name: ch-bigint-id +storage: +${storage} +chains: + - id: 1 + rpc: + url: https://eth.com + for: sync + start_block: 0 +`, + ) + + let bothBackends = " postgres:\n default: true\n clickhouse: true" + + // The full error a rejected parse throws. `toThrowErrorEqual` asserts the + // whole message (not a substring). The entity is named "Thing" in every case. + let expectedError = "Config parse error: Invalid storage for `Thing`. Its `id` is a BigInt, which ClickHouse stores as a String (sorted lexicographically, not numerically) unless a precision is set. Since `id` is ClickHouse's sorting key, add `@config(precision: N)` with N <= 38 so the id stores as a numeric Decimal, or set `@storage(clickhouse: {orderBy: [...]})` to sort by other fields." + + it("rejects an unbounded BigInt id on a clickhouse entity", t => { + t->toThrowErrorEqual(() => + parseWithStorage( + ~schema=`type Thing @storage(clickhouse: true) { id: BigInt! }`, + ~storage=bothBackends, + )->ignore + , expectedError) + }) + + it("rejects a BigInt id whose precision exceeds the ClickHouse Decimal ceiling", t => { + t->toThrowErrorEqual(() => + parseWithStorage( + ~schema=`type Thing @storage(clickhouse: true) { id: BigInt! @config(precision: 100) }`, + ~storage=bothBackends, + )->ignore + , expectedError) + }) + + it("rejects an unbounded BigInt id when clickhouse is the default backend", t => { + t->toThrowErrorEqual(() => + parseWithStorage( + ~schema=`type Thing { id: BigInt! }`, + ~storage=" postgres:\n default: true\n clickhouse:\n default: true", + )->ignore + , expectedError) + }) + + it("accepts a BigInt id with a numeric precision on a clickhouse entity", t => { + let {config} = parseWithStorage( + ~schema=`type Thing @storage(clickhouse: true) { id: BigInt! @config(precision: 20) }`, + ~storage=bothBackends, + ) + t.expect(config.userEntitiesByName->Dict.get("Thing")->Option.isSome).toBe(true) + }) + + it("accepts an Int id on a clickhouse entity", t => { + let {config} = parseWithStorage( + ~schema=`type Thing @storage(clickhouse: true) { id: Int! }`, + ~storage=bothBackends, + ) + t.expect(config.userEntitiesByName->Dict.get("Thing")->Option.isSome).toBe(true) + }) + + it("accepts an unbounded BigInt id on a postgres-only entity", t => { + let {config} = parseWithStorage( + ~schema=`type Thing { id: BigInt! }`, + ~storage=" postgres:\n default: true", + ) + t.expect(config.userEntitiesByName->Dict.get("Thing")->Option.isSome).toBe(true) + }) +}) + +// The public `EntityChangeValue.deleted` type in index.d.ts is derived from the +// entity id (`EntityId`), so the ids the test indexer actually reports +// must be the raw scalars rather than stringified ones. +describe("Test indexer reports deleted ids with the entity's id type", () => { + let makeState = (~entityConfig: Internal.entityConfig): TestIndexer.testIndexerState => { + let entityConfigs = Dict.make() + entityConfigs->Dict.set(entityConfig.name, entityConfig) + { + processInProgress: false, + progressBlockByChain: Dict.make(), + entities: Dict.make(), + entityConfigs, + processChanges: [], + } + } + + let deletedIdsOf = (~entityConfig: Internal.entityConfig, ~entityId: EntityId.t) => { + let state = makeState(~entityConfig) + state->TestIndexer.handleWriteBatch( + ~updatedEntities=[ + { + entityConfig, + changes: [Change.Delete({entityId, checkpointId: 1n})], + }, + ], + ~checkpointIds=[1n], + ~checkpointChainIds=[1337], + ~checkpointBlockNumbers=[5], + ~checkpointEventsProcessed=[1], + ) + state.processChanges + ->Array.getUnsafe(0) + ->(Utils.magic: unknown => dict>>) + ->Dict.getUnsafe(entityConfig.name) + ->Dict.getUnsafe("deleted") + } + + it("keeps an Int id a number", t => { + let deleted = deletedIdsOf( + ~entityConfig=MockIndexer.entityConfig(IntIdEntity), + ~entityId=137->EntityId.unsafeOfAny, + ) + // Compared against the raw number, so a stringified "137" fails here. + t.expect(deleted).toEqual([137->EntityId.unsafeOfAny]) + }) + + it("keeps a BigInt id a bigint", t => { + let deleted = deletedIdsOf( + ~entityConfig=MockIndexer.entityConfig(BigIntIdEntity), + ~entityId=999n->EntityId.unsafeOfAny, + ) + t.expect(deleted).toEqual([999n->EntityId.unsafeOfAny]) + }) + + it("keeps a string id a string", t => { + let deleted = deletedIdsOf( + ~entityConfig=MockIndexer.entityConfig(User), + ~entityId="u1"->EntityId.unsafeOfString, + ) + t.expect(deleted).toEqual(["u1"->EntityId.unsafeOfString]) + }) +}) + +// A custom `orderBy` replaces `id` in the ClickHouse sorting key, so the fields +// it lists are what must sort numerically — and a relation sorts by the id it +// stores, not by the entity it points at. +describe("ClickHouse orderBy sort-key validation", () => { + let parseWithClickHouse = schema => + InternalTestIndexer.fromUserApi( + ~schema, + ~configYaml=` +name: clickhouse-order-by +storage: + postgres: + default: true + clickhouse: true +chains: + - id: 1 + rpc: + url: https://rpc.example.test + for: sync + start_block: 0 +`, + ) + + it("accepts an unbounded BigInt id when orderBy replaces id in the sort key", t => { + let {config} = parseWithClickHouse(` +type Thing @storage(clickhouse: {orderBy: ["timestamp"]}) { + id: BigInt! + timestamp: Int! +} +`) + t.expect(config.userEntitiesByName->Dict.get("Thing")->Option.isSome).toBe(true) + }) + + it("rejects sorting by a relation whose target id is an unbounded BigInt", t => { + t->toThrowErrorEqual( + () => + parseWithClickHouse(` +type Parent { + id: BigInt! +} +type Thing @storage(clickhouse: {orderBy: ["parent"]}) { + id: ID! + parent: Parent! +} +`)->ignore, + "Config parse error: Invalid storage for `Thing`. `clickhouse.orderBy` sorts by `parent`, which stores a BigInt that ClickHouse keeps as a String (sorted lexicographically, not numerically) unless a precision is set. Add `@config(precision: N)` with N <= 38 to the BigInt it stores so it sorts as a numeric Decimal.", + ) + }) + + it("accepts sorting by a relation whose target id is a bounded BigInt", t => { + let {config} = parseWithClickHouse(` +type Parent { + id: BigInt! @config(precision: 20) +} +type Thing @storage(clickhouse: {orderBy: ["parent"]}) { + id: ID! + parent: Parent! +} +`) + t.expect(config.userEntitiesByName->Dict.get("Thing")->Option.isSome).toBe(true) + }) + + it("accepts sorting by a relation whose target id is an Int", t => { + let {config} = parseWithClickHouse(` +type Parent { + id: Int! +} +type Thing @storage(clickhouse: {orderBy: ["parent"]}) { + id: ID! + parent: Parent! +} +`) + t.expect(config.userEntitiesByName->Dict.get("Thing")->Option.isSome).toBe(true) + }) +}) diff --git a/scenarios/test_codegen/test/lib_tests/FetchState_test.res b/scenarios/test_codegen/test/lib_tests/FetchState_test.res index 27cb1dd160..688bc1a46f 100644 --- a/scenarios/test_codegen/test/lib_tests/FetchState_test.res +++ b/scenarios/test_codegen/test/lib_tests/FetchState_test.res @@ -265,7 +265,7 @@ describe("FetchState.make", () => { }) it("Panics with nothing to fetch", t => { - t.expect( + t->toThrowErrorEqual( () => { makeFs( ~onEventRegistrations=[baseEventConfig], @@ -279,7 +279,9 @@ describe("FetchState.make", () => { ) }, ~message=`Should panic if there's nothing to fetch`, - ).toThrowError("Invalid configuration: Nothing to fetch on chain") + + "Invalid configuration: Nothing to fetch on chain 0. addresses=0, onEventRegistrations=1, normalRegistrations=1. Make sure that you provided at least one contract address to index, or have events with Wildcard mode enabled, or have onBlock handlers.", + ) }) it( diff --git a/scenarios/test_codegen/test/lib_tests/PgStorage_test.res b/scenarios/test_codegen/test/lib_tests/PgStorage_test.res index 92c250aa24..5bdc7e39fd 100644 --- a/scenarios/test_codegen/test/lib_tests/PgStorage_test.res +++ b/scenarios/test_codegen/test/lib_tests/PgStorage_test.res @@ -271,9 +271,9 @@ CREATE TABLE IF NOT EXISTS "test_schema"."envio_history_A"("id" TEXT NOT NULL, " CREATE TABLE IF NOT EXISTS "test_schema"."B"("id" TEXT NOT NULL, "c_id" TEXT, PRIMARY KEY("id")); CREATE TABLE IF NOT EXISTS "test_schema"."envio_history_B"("id" TEXT NOT NULL, "c_id" TEXT, "envio_checkpoint_id" BIGINT NOT NULL, "envio_change" "test_schema".ENVIO_HISTORY_CHANGE NOT NULL, PRIMARY KEY("id", "envio_checkpoint_id")); CREATE TABLE IF NOT EXISTS "test_schema"."EntityWith63LenghtName______________________________________one"("id" TEXT NOT NULL, PRIMARY KEY("id")); -CREATE TABLE IF NOT EXISTS "test_schema"."envio_history_EntityWith63LenghtName__________________________5"("id" TEXT NOT NULL, "envio_checkpoint_id" BIGINT NOT NULL, "envio_change" "test_schema".ENVIO_HISTORY_CHANGE NOT NULL, PRIMARY KEY("id", "envio_checkpoint_id")); -CREATE TABLE IF NOT EXISTS "test_schema"."EntityWith63LenghtName______________________________________two"("id" TEXT NOT NULL, PRIMARY KEY("id")); CREATE TABLE IF NOT EXISTS "test_schema"."envio_history_EntityWith63LenghtName__________________________6"("id" TEXT NOT NULL, "envio_checkpoint_id" BIGINT NOT NULL, "envio_change" "test_schema".ENVIO_HISTORY_CHANGE NOT NULL, PRIMARY KEY("id", "envio_checkpoint_id")); +CREATE TABLE IF NOT EXISTS "test_schema"."EntityWith63LenghtName______________________________________two"("id" TEXT NOT NULL, PRIMARY KEY("id")); +CREATE TABLE IF NOT EXISTS "test_schema"."envio_history_EntityWith63LenghtName__________________________7"("id" TEXT NOT NULL, "envio_checkpoint_id" BIGINT NOT NULL, "envio_change" "test_schema".ENVIO_HISTORY_CHANGE NOT NULL, PRIMARY KEY("id", "envio_checkpoint_id")); CREATE TABLE IF NOT EXISTS "test_schema"."EntityWithAllTypes"("id" TEXT NOT NULL, "string" TEXT NOT NULL, "optString" TEXT, "arrayOfStrings" TEXT[] NOT NULL, "int_" INTEGER NOT NULL, "optInt" INTEGER, "arrayOfInts" INTEGER[] NOT NULL, "float_" DOUBLE PRECISION NOT NULL, "optFloat" DOUBLE PRECISION, "arrayOfFloats" DOUBLE PRECISION[] NOT NULL, "bool" BOOLEAN NOT NULL, "optBool" BOOLEAN, "bigInt" NUMERIC NOT NULL, "optBigInt" NUMERIC, "arrayOfBigInts" TEXT[] NOT NULL, "bigDecimal" NUMERIC NOT NULL, "optBigDecimal" NUMERIC, "bigDecimalWithConfig" NUMERIC(10, 8) NOT NULL, "arrayOfBigDecimals" TEXT[] NOT NULL, "timestamp" TIMESTAMP WITH TIME ZONE NOT NULL, "optTimestamp" TIMESTAMP WITH TIME ZONE NULL, "json" JSONB NOT NULL, "enumField" "test_schema".AccountType NOT NULL, "optEnumField" "test_schema".AccountType, PRIMARY KEY("id")); CREATE TABLE IF NOT EXISTS "test_schema"."envio_history_EntityWithAllTypes"("id" TEXT NOT NULL, "string" TEXT, "optString" TEXT, "arrayOfStrings" TEXT[], "int_" INTEGER, "optInt" INTEGER, "arrayOfInts" INTEGER[], "float_" DOUBLE PRECISION, "optFloat" DOUBLE PRECISION, "arrayOfFloats" DOUBLE PRECISION[], "bool" BOOLEAN, "optBool" BOOLEAN, "bigInt" NUMERIC, "optBigInt" NUMERIC, "arrayOfBigInts" TEXT[], "bigDecimal" NUMERIC, "optBigDecimal" NUMERIC, "bigDecimalWithConfig" NUMERIC(10, 8), "arrayOfBigDecimals" TEXT[], "timestamp" TIMESTAMP WITH TIME ZONE NULL, "optTimestamp" TIMESTAMP WITH TIME ZONE NULL, "json" JSONB, "enumField" "test_schema".AccountType, "optEnumField" "test_schema".AccountType, "envio_checkpoint_id" BIGINT NOT NULL, "envio_change" "test_schema".ENVIO_HISTORY_CHANGE NOT NULL, PRIMARY KEY("id", "envio_checkpoint_id")); CREATE INDEX IF NOT EXISTS "A_b_id" ON "test_schema"."A"("b_id"); diff --git a/scenarios/test_codegen/test/lib_tests/SourceManager_test.res b/scenarios/test_codegen/test/lib_tests/SourceManager_test.res index bc607b6e41..b71a788017 100644 --- a/scenarios/test_codegen/test/lib_tests/SourceManager_test.res +++ b/scenarios/test_codegen/test/lib_tests/SourceManager_test.res @@ -116,19 +116,19 @@ describe("SourceManager creation", () => { }) it("Fails to create without primary sources", t => { - t.expect( + t->toThrowErrorEqual( () => { SourceManager.make(~isRealtime=false, ~sources=[]) }, - ).toThrowError("Invalid configuration, no data-source for historical sync provided") - t.expect( + "Invalid configuration, no data-source for historical sync provided") + t->toThrowErrorEqual( () => { SourceManager.make( ~isRealtime=false, ~sources=[MockIndexer.Source.make([], ~sourceFor=Fallback).source], ) }, - ).toThrowError("Invalid configuration, no data-source for historical sync provided") + "Invalid configuration, no data-source for historical sync provided") }) }) diff --git a/scenarios/test_codegen/test/rollback/Rollback_test.res b/scenarios/test_codegen/test/rollback/Rollback_test.res index 27e48cfe49..569cbdb821 100644 --- a/scenarios/test_codegen/test/rollback/Rollback_test.res +++ b/scenarios/test_codegen/test/rollback/Rollback_test.res @@ -142,7 +142,7 @@ describe("E2E rollback tests", () => { [ Set({ checkpointId: firstHistoryCheckpointId, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "value-2", @@ -150,7 +150,7 @@ describe("E2E rollback tests", () => { }), Set({ checkpointId: firstHistoryCheckpointId, - entityId: "2", + entityId: "2"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "2", value: "value-2", @@ -158,7 +158,7 @@ describe("E2E rollback tests", () => { }), Set({ checkpointId: firstHistoryCheckpointId->BigInt.add(1n), - entityId: "3", + entityId: "3"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "3", value: "value-1", @@ -166,7 +166,7 @@ describe("E2E rollback tests", () => { }), Set({ checkpointId: firstHistoryCheckpointId, - entityId: "4", + entityId: "4"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "4", value: "value-1", @@ -174,7 +174,7 @@ describe("E2E rollback tests", () => { }), Delete({ checkpointId: firstHistoryCheckpointId->BigInt.add(1n), - entityId: "4", + entityId: "4"->EntityId.unsafeOfString, }), ], )) @@ -286,7 +286,7 @@ describe("E2E rollback tests", () => { [ Set({ checkpointId: firstHistoryCheckpointId->BigInt.add(3n), - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "value-1", @@ -294,7 +294,7 @@ describe("E2E rollback tests", () => { }), Set({ checkpointId: firstHistoryCheckpointId->BigInt.add(3n), - entityId: "2", + entityId: "2"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "2", value: "value-2", @@ -544,13 +544,13 @@ describe("E2E rollback tests", () => { [ Set({ checkpointId: 2n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: {Indexer.Entities.SimpleEntity.id: "1", value: "before-delete"}, }), - Delete({checkpointId: 3n, entityId: "1"}), + Delete({checkpointId: 3n, entityId: "1"->EntityId.unsafeOfString}), Set({ checkpointId: 4n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: {Indexer.Entities.SimpleEntity.id: "1", value: "after-recreate"}, }), ], @@ -1283,7 +1283,7 @@ This might be wrong after we start exposing a block hash for progress block.`, [ Set({ checkpointId: 3n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-0", @@ -1291,7 +1291,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 4n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-2", @@ -1299,7 +1299,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 5n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-3", @@ -1307,7 +1307,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 6n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-4", @@ -1315,7 +1315,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 7n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-5", @@ -1531,7 +1531,7 @@ This might be wrong after we start exposing a block hash for progress block.`, [ Set({ checkpointId: 3n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-0", @@ -1539,7 +1539,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 4n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-2", @@ -1547,7 +1547,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 10n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-4", @@ -1751,7 +1751,7 @@ This might be wrong after we start exposing a block hash for progress block.`, [ Set({ checkpointId: 3n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-0", @@ -1759,7 +1759,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 4n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-2", @@ -1767,7 +1767,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 5n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-3", @@ -1775,7 +1775,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 6n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-4", @@ -1783,7 +1783,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 7n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-5", @@ -1807,7 +1807,7 @@ This might be wrong after we start exposing a block hash for progress block.`, [ Set({ checkpointId: 6n, - entityId: "foo", + entityId: "foo"->EntityId.unsafeOfString, entity: { Indexer.Entities.EntityWithBigDecimal.id: "foo", bigDecimal: BigDecimal.fromFloat(0.), @@ -1946,7 +1946,7 @@ This might be wrong after we start exposing a block hash for progress block.`, [ Set({ checkpointId: 3n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-0", @@ -1954,7 +1954,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 4n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-2", @@ -1962,7 +1962,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 10n, - entityId: "1", + entityId: "1"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "1", value: "call-4", @@ -1986,7 +1986,7 @@ This might be wrong after we start exposing a block hash for progress block.`, [ Set({ checkpointId: 10n, - entityId: "foo", + entityId: "foo"->EntityId.unsafeOfString, entity: { Indexer.Entities.EntityWithBigDecimal.id: "foo", bigDecimal: BigDecimal.fromFloat(0.), @@ -3200,7 +3200,7 @@ This might be wrong after we start exposing a block hash for progress block.`, [ Set({ checkpointId: 4n, - entityId: "reorg", + entityId: "reorg"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "reorg", value: "valid", @@ -3208,7 +3208,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 3n, - entityId: "victim", + entityId: "victim"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "victim", value: "before", @@ -3216,7 +3216,7 @@ This might be wrong after we start exposing a block hash for progress block.`, }), Set({ checkpointId: 8n, - entityId: "victim", + entityId: "victim"->EntityId.unsafeOfString, entity: { Indexer.Entities.SimpleEntity.id: "victim", value: "reapplied",