From 412aa4fbfd17c5ffd730aef85f18ca65c9052f30 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 14:31:32 +0000 Subject: [PATCH 1/7] Support non-string entity ids (Int!/BigInt!) with matching FK types Entity ids may now be Int! or BigInt! in addition to ID!/String!, and a relation's foreign key adopts the referenced entity's id type instead of always being text/string. Previously an Int! id column was numeric while its foreign keys stayed text, producing a type mismatch across the relation. CLI codegen: - Resolve a relation's Postgres primitive and ReScript/TS type to the referenced entity's id scalar (entity_parsing.rs), so a foreign key mirrors the id it points at. The Entity primitive variant is removed; a relation is now a normal typed field carrying linked_entity for the _id name and Hasura. - Reject entity ids outside {ID, String, Int, BigInt}, and non-nullable non-list ids, at parse time. - Allow Int/BigInt targets for @derivedFrom. - Drop the single global `type id = string`; id and _id fields render as their concrete type (string/int/bigint) in ReScript and TypeScript. Runtime (packages/envio): - Add opaque EntityId.t used by the id-generic layers (Change, InMemoryTable, entity history); concrete generated types stay string/int/bigint. Dict keys go through EntityId.toKey. - Derive id column type and value schema per table (Table.getIdPgFieldType / getIdSchema); delete-by-id, history backfill, and history-delete SQL cast to the id column type instead of text, and serialize ids via the id schema. - Thread the id schema through the Postgres and ClickHouse entity-history set and delete schemas. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DNs5ezETo32JzCmFun221s --- .../cli/src/config_parsing/entity_parsing.rs | 147 +++++++++++-- .../cli/src/config_parsing/field_types.rs | 2 - .../cli/src/config_parsing/public_config.rs | 3 - .../src/hbs_templating/codegen_templates.rs | 18 +- ...de_generates_correct_types_and_values.snap | 1 - ...s__test__indexer_code_multiple_chains.snap | 1 - ...al_config_json_code_generated_for_evm.snap | 11 +- ...nal_config_json_code_with_all_options.snap | 10 +- ...son_code_with_lowercase_contract_name.snap | 11 +- .../envio-tests/test/EntityFilter_test.res | 2 +- .../test/lib_tests/ColumnNameFormat_test.res | 3 +- packages/envio/src/Change.res | 6 +- packages/envio/src/Config.res | 4 - packages/envio/src/EntityId.res | 15 ++ packages/envio/src/InMemoryStore.res | 17 +- packages/envio/src/InMemoryTable.res | 22 +- packages/envio/src/Internal.res | 2 +- packages/envio/src/PgStorage.res | 48 +++-- packages/envio/src/TestIndexer.res | 11 +- packages/envio/src/UserContext.res | 6 +- packages/envio/src/bindings/ClickHouse.res | 8 +- packages/envio/src/db/EntityHistory.res | 23 +- packages/envio/src/db/Table.res | 49 ++++- .../test/ConcurrentWrite_test.res | 6 +- .../test_codegen/test/WriteRead_test.res | 18 +- .../test_codegen/test/helpers/MockIndexer.res | 9 +- .../test/lib_tests/EffectState_test.res | 2 +- .../test/lib_tests/EntityIdType_test.res | 197 ++++++++++++++++++ .../test/rollback/Rollback_test.res | 62 +++--- 29 files changed, 547 insertions(+), 167 deletions(-) create mode 100644 packages/envio/src/EntityId.res create mode 100644 scenarios/test_codegen/test/lib_tests/EntityIdType_test.res diff --git a/packages/cli/src/config_parsing/entity_parsing.rs b/packages/cli/src/config_parsing/entity_parsing.rs index ba23094681..f36018f907 100644 --- a/packages/cli/src/config_parsing/entity_parsing.rs +++ b/packages/cli/src/config_parsing/entity_parsing.rs @@ -261,11 +261,14 @@ impl Schema { ))?, Some(field) => match field.field_type.get_underlying_scalar() { GqlScalar::Custom(name) if name == entity.name => (), - GqlScalar::ID | GqlScalar::String => (), + GqlScalar::ID + | GqlScalar::String + | GqlScalar::Int + | GqlScalar::BigInt(_) => (), _ => Err(anyhow!( "Derived field '{derived_from_field}' on entity \ - '{name}' must either be an ID, String, or an Object \ - relationship with Entity '{}'", + '{name}' must either be an ID, String, Int, BigInt, or \ + an Object relationship with Entity '{}'", entity.name ))?, }, @@ -429,6 +432,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 +636,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() @@ -1843,7 +1899,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 +1924,9 @@ 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. + TypeDef::Entity(entity) => entity.get_id_scalar()?.to_rescript_type(schema)?, TypeDef::Enum => TypeIdent::SchemaEnum(name.to_capitalized_options()), }, }; @@ -1979,15 +2042,47 @@ 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! +} + +type NumericEntity { + id: Int! +} + "#; + let schema = Schema::from_string(schema_str).unwrap(); + let referencer = schema.entities.get("Referencer").unwrap(); - assert_eq!(rescript_type.to_string(), "option".to_owned()); + // A String-id relation renders through the shared `id` alias, a numeric + // relation renders as the concrete scalar. + 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 +2290,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 +2315,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] 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/hbs_templating/codegen_templates.rs b/packages/cli/src/hbs_templating/codegen_templates.rs index 541d24deb2..c829e1229b 100644 --- a/packages/cli/src/hbs_templating/codegen_templates.rs +++ b/packages/cli/src/hbs_templating/codegen_templates.rs @@ -88,6 +88,8 @@ fn generate_enums_code(gql_enums: &[GraphQlEnumTypeTemplate]) -> 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 { @@ -2307,18 +2309,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!( 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..7d4b178044 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 --- /** 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..86a80329b4 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 --- /** 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/envio-tests/test/EntityFilter_test.res b/packages/envio-tests/test/EntityFilter_test.res index 304fd0dc48..7c216a9c7d 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"), ], ) 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/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..493f099852 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, + }), ) } @@ -115,7 +122,7 @@ let prepareRollbackDiff = async ( entityTable->InMemoryTable.Entity.set( ~committedCheckpointId, Delete({ - entityId, + entityId: entityId->EntityId.unsafeOfString, checkpointId: rollbackDiffCheckpointId, }), ) @@ -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..ead8d3614f 100644 --- a/packages/envio/src/Internal.res +++ b/packages/envio/src/Internal.res @@ -360,7 +360,7 @@ type entityHandlerContext<'entity> = { getOrThrow: (string, ~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/PgStorage.res b/packages/envio/src/PgStorage.res index 65baa07b04..75d4b3f0b6 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, ) 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/db/EntityHistory.res b/packages/envio/src/db/EntityHistory.res index cab7a930e9..f58aca93f9 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)]->Obj.magic, ) ->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/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/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..51610b8f29 100644 --- a/scenarios/test_codegen/test/helpers/MockIndexer.res +++ b/scenarios/test_codegen/test/helpers/MockIndexer.res @@ -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, }), @@ -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/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..2d4be998b7 --- /dev/null +++ b/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res @@ -0,0 +1,197 @@ +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("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"}`), + ) + }) +}) + +// 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}], + )) + }) +}) 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", From 276a04f7c5047beaa2629d6ee858ce0091fbb7bd Mon Sep 17 00:00:00 2001 From: Dmitry Zakharov Date: Mon, 27 Jul 2026 10:12:24 +0000 Subject: [PATCH 2/7] Support numeric entity IDs (Int!, BigInt!) in codegen (#1487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Key entity operations by the real id scalar (string/int/bigint) Extend numeric-id support to the user-facing API. The generated handler context and test-indexer operations (get/getOrThrow/deleteUnsafe) now adopt each entity's id type instead of hardcoding string, on both the ReScript and TypeScript surfaces: - Each generated entity module exposes `type id`, and the operation types gain an `'id` parameter resolved from it. - `EntityOperations`/`TestIndexerEntityOperations` in index.d.ts derive the id type from the entity via an `EntityId` helper. - `Internal.entityHandlerContext` uses `EntityId.t` for get/getOrThrow to match deleteUnsafe. Add a numeric-id codegen unit test, regenerate the scenario indexers, cover the generated typed API with compile-time checks plus a ClickHouse unbounded-BigInt fallback test, and document numeric ids in the schema/ handlers skills. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KDdTB5oid2AvSy2D1k1Jqr * Keep string-id entity operations id-argument-free Address review feedback: - Emit `type id` before `type t` in each generated entity module. - Split the operation types: string-id entities use the plain `handlerEntityOperations`/`testIndexerEntityOperations` (no id type argument, id forced to string), and only non-string ids use the `...WithCustomId` variants. String-only projects regenerate to the original id-argument-free shape. - Tighten the schema/handlers skills: `ID!` is recommended (not a "default"), and drop the obvious id-type restatements. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KDdTB5oid2AvSy2D1k1Jqr * Reject unbounded BigInt id on ClickHouse entities ClickHouse stores a BigInt with no precision (or precision above its Decimal ceiling of 38) as a String, sorted lexicographically. Since `id` is ClickHouse's mandatory sort key, such an id would order wrong. Validate in `validate_entity_storage` (which sees each entity's effective ClickHouse storage, including the config-level `default: true` fallback), mirroring the existing `validate_clickhouse_order_by_fields` rejection: a BigInt id on a ClickHouse entity must set `@config(precision: N)` with N <= 38 so it stores as a numeric Decimal. Cover positive and negative flows (per-entity directive and default backend) via InternalTestIndexer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KDdTB5oid2AvSy2D1k1Jqr * Assert the full ClickHouse BigInt-id error, not a substring vitest's toThrowError(string) only checks containment. Capture the thrown message via try/catch and assert the exact, full error with toBe so the test documents the complete message and fails if any of it changes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KDdTB5oid2AvSy2D1k1Jqr * Add a strict toThrowErrorEqual vitest matcher The built-in toThrowError only checks the thrown message contains the argument. Add a strict sibling matcher, toThrowErrorEqual, that requires the whole message to match — registered via expect.extend in each test package's setup and exposed on the ReScript Vitest binding. Overriding toThrowError itself would break the ~15 existing assertions that intentionally match on a substring, so this is a separate matcher. Use it for the ClickHouse BigInt-id errors so those tests pin the full message. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KDdTB5oid2AvSy2D1k1Jqr * Make the throw matcher strict everywhere Replace toThrowError (substring) with toThrowErrorEqual (exact) across the ReScript test suites and drop the substring binding, so every throw assertion pins the complete error message. Existing assertions that had only a substring are updated to the full message. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KDdTB5oid2AvSy2D1k1Jqr --------- Co-authored-by: Claude --- .../cli/src/config_parsing/system_config.rs | 36 +++- .../src/hbs_templating/codegen_templates.rs | 163 ++++++++++++++++-- ..._test__indexer_code_generated_for_svm.snap | 2 + ...de_generates_correct_types_and_values.snap | 3 + ...s__test__indexer_code_multiple_chains.snap | 2 + .../.claude/skills/indexer-handlers/SKILL.md | 16 +- .../.claude/skills/indexer-schema/SKILL.md | 10 +- .../test/ClientAddressFilter_test.res | 12 +- packages/envio-tests/test/Config_test.res | 2 +- .../envio-tests/test/EntityFilter_test.res | 4 +- .../test/MockIndexerHandlers_test.res | 6 +- .../test/UserApiValidation_test.res | 4 +- packages/envio-tests/test/Utils_test.res | 4 +- packages/envio/index.d.ts | 14 +- packages/envio/src/Internal.res | 4 +- packages/envio/src/bindings/Vitest.res | 27 ++- scenarios/fuel_test/src/Indexer.res | 1 + scenarios/svm_test/src/Indexer.res | 1 + scenarios/test_codegen/schema.graphql | 12 ++ scenarios/test_codegen/src/Indexer.res | 59 +++++++ .../test/EventBlockFilter_test.res | 30 ++-- .../test_codegen/test/EventFilters_test.res | 4 +- .../test/HandlerRegisterLifecycle_test.res | 18 +- .../test/lib_tests/CrossChainState_test.res | 4 +- .../test/lib_tests/EntityIdType_test.res | 110 ++++++++++++ .../test/lib_tests/FetchState_test.res | 6 +- .../test/lib_tests/PgStorage_test.res | 4 +- .../test/lib_tests/SourceManager_test.res | 8 +- 28 files changed, 478 insertions(+), 88 deletions(-) diff --git a/packages/cli/src/config_parsing/system_config.rs b/packages/cli/src/config_parsing/system_config.rs index f4264a3a29..41ad87e6ca 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::{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,35 @@ 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. `id` is ClickHouse's + // mandatory default sort key, so an id like that would order wrong. Reject + // it up front, mirroring `validate_clickhouse_order_by_fields`. See the + // BigInt branch of `getClickHouseFieldType` in ClickHouse.res. + 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; + } + if let Ok(GqlScalar::BigInt(precision)) = entity.get_id_scalar() { + let stored_as_numeric = + precision.is_some_and(|p| p <= CLICKHOUSE_DECIMAL_MAX_PRECISION); + if !stored_as_numeric { + 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.", + 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 c829e1229b..4c7b39bc9f 100644 --- a/packages/cli/src/hbs_templating/codegen_templates.rs +++ b/packages/cli/src/hbs_templating/codegen_templates.rs @@ -85,6 +85,21 @@ 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(); @@ -93,8 +108,11 @@ fn generate_entities_code(entities: &[EntityRecordTypeTemplate]) -> String { 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!( @@ -1480,20 +1498,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>, @@ -1502,16 +1541,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 @@ -1802,8 +1840,10 @@ type contractRegisterContext = {{ .collect::>() .join("\n"); - // Generate entity ops fields for the testIndexer type - let test_indexer_entity_ops_type = r#"/** Entity operations for direct access outside handlers. */ + // Generate entity ops fields for the testIndexer type. String ids use + // the plain type; numeric ids use the custom-id variant. + let test_indexer_entity_ops_type = if has_custom_id_entity { + r#"/** Entity operations for direct access outside handlers. */ type testIndexerEntityOperations<'entity> = { /** Get an entity by ID. */ get: string => promise>, @@ -1813,15 +1853,43 @@ 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, +}"# + } else { + r#"/** Entity operations for direct access outside handlers. */ +type testIndexerEntityOperations<'entity> = { + /** Get an entity by ID. */ + get: string => promise>, + /** Get all entities. */ + getAll: unit => promise>, + /** Get an entity by ID or throw if not found. */ + getOrThrow: (string, ~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"); @@ -2825,6 +2893,7 @@ mod test { utils::text::Capitalize, }; use pretty_assertions::assert_eq; + use std::collections::HashMap; use std::vec; use system_config::FieldSelection; @@ -3364,6 +3433,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..6633907c3f 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 @@ -1,5 +1,6 @@ --- source: packages/cli/src/hbs_templating/codegen_templates.rs +assertion_line: 3671 expression: project_template.indexer_code --- /** @@ -79,6 +80,7 @@ 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} 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 7d4b178044..94302825db 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,5 +1,6 @@ --- source: packages/cli/src/hbs_templating/codegen_templates.rs +assertion_line: 3426 expression: project_template.indexer_code --- /** @@ -200,12 +201,14 @@ module Entities = { type id = string module EmptyEntity = { + type id = string type t = {id: id, emptyField: id, status: Enums.Status.t, optionalStatus: option, related_id: id, 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>>} } module RelatedEntity = { + type id = string type t = {id: id, name: string} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("name") name?: Envio.whereOperator} 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 86a80329b4..0160c8feb7 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,5 +1,6 @@ --- source: packages/cli/src/hbs_templating/codegen_templates.rs +assertion_line: 3433 expression: project_template.indexer_code --- /** @@ -196,6 +197,7 @@ 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} 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 7c216a9c7d..0a7cfe5592 100644 --- a/packages/envio-tests/test/EntityFilter_test.res +++ b/packages/envio-tests/test/EntityFilter_test.res @@ -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/index.d.ts b/packages/envio/index.d.ts index af5654fed9..083b97ff7d 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. */ @@ -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/Internal.res b/packages/envio/src/Internal.res index ead8d3614f..644e75c58b 100644 --- a/packages/envio/src/Internal.res +++ b/packages/envio/src/Internal.res @@ -356,8 +356,8 @@ 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: EntityId.t => unit, diff --git a/packages/envio/src/bindings/Vitest.res b/packages/envio/src/bindings/Vitest.res index d68faf4568..31a894daa7 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,29 @@ 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. +let toThrowErrorEqual = (t: testContext, fn: unit => 'a, ~message=?, expected: string) => + switch fn->messageOfThrown { + | Some(thrown) => t.expect(thrown, ~message?).toBe(expected) + | None => + t.expect( + "", + ~message=message->Option.getOr("Expected the function to throw an error, but it did not."), + ).toBe(expected) + } diff --git a/scenarios/fuel_test/src/Indexer.res b/scenarios/fuel_test/src/Indexer.res index 7c7a46d005..85c26e40ca 100644 --- a/scenarios/fuel_test/src/Indexer.res +++ b/scenarios/fuel_test/src/Indexer.res @@ -81,6 +81,7 @@ 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} diff --git a/scenarios/svm_test/src/Indexer.res b/scenarios/svm_test/src/Indexer.res index 3d8a23aee7..a578bc5031 100644 --- a/scenarios/svm_test/src/Indexer.res +++ b/scenarios/svm_test/src/Indexer.res @@ -75,6 +75,7 @@ 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} 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..38b9dd1634 100644 --- a/scenarios/test_codegen/src/Indexer.res +++ b/scenarios/test_codegen/src/Indexer.res @@ -200,114 +200,147 @@ module Entities = { type id = string module A = { + type id = string type t = {id: id, b_id: id, optionalStringToTestLinkedEntities: option} type getWhereFilter = {@as("id") id?: Envio.whereOperator, @as("b_id") b?: Envio.whereOperator, @as("optionalStringToTestLinkedEntities") optionalStringToTestLinkedEntities?: Envio.whereOperator>} } module B = { + type id = string type t = {id: id, c_id: option} 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 id = string type t = {id: id, a_id: id, stringThatIsMirroredToA: string} 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 id = string type t = {id: id, owner_id: id, 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} } + 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 id = string type t = {id: id, tokenId: bigint, collection_id: id, owner_id: id} 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 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} @@ -316,6 +349,7 @@ module Entities = { type rec name<'entity> = | @as("A") A: name | @as("B") B: name + | @as("BigIntIdEntity") BigIntIdEntity: name | @as("C") C: name | @as("CustomSelectionTestPass") CustomSelectionTestPass: name | @as("D") D: name @@ -327,6 +361,7 @@ module Entities = { | @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 @@ -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, 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/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/lib_tests/CrossChainState_test.res b/scenarios/test_codegen/test/lib_tests/CrossChainState_test.res index 186f34c791..e3472280e2 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/EntityIdType_test.res b/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res index 2d4be998b7..32a51fecb7 100644 --- a/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res +++ b/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res @@ -71,6 +71,15 @@ describe("Non-string entity id support", () => { )).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 => @@ -100,6 +109,29 @@ describe("Non-string entity id support", () => { }) }) +// 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) +} + // 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. @@ -195,3 +227,81 @@ chains: )) }) }) + +// 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." + + 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) + }) +}) diff --git a/scenarios/test_codegen/test/lib_tests/FetchState_test.res b/scenarios/test_codegen/test/lib_tests/FetchState_test.res index 218a76a038..d575f7cda8 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 53a9c4b4a0..aac1e11a15 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") }) }) From be5d5255f8f83d028d86a022c2d945c1987f49ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:30:14 +0000 Subject: [PATCH 3/7] Fix foreign-key type shadowing and rollback id parsing for numeric ids Two defects found in review of the numeric-id support: - Each entity module declares its own `type id`, which shadows the shared `type id = string` alias. A relation to a string-id entity rendered as that bare alias, so inside a numeric-id module its foreign key resolved to the owner's `int`/`bigint` id while the column stays text. Foreign keys now render the concrete id scalar. - The Postgres rollback reader parsed the row-state id with `S.string`, so a reorg on an `Int!` id entity threw before building the restore/delete diff (Postgres returns the id as a number). The schema is now built per table from the table's id schema, and `EntityId.t` is threaded through `getRollbackData` so ids keep their real type. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DNs5ezETo32JzCmFun221s --- .../cli/src/config_parsing/entity_parsing.rs | 19 +++++++++++---- ...de_generates_correct_types_and_values.snap | 5 ++-- packages/envio/src/InMemoryStore.res | 2 +- packages/envio/src/Persistence.res | 2 +- packages/envio/src/PgStorage.res | 20 +++++++++++----- scenarios/test_codegen/src/Indexer.res | 24 +++++++++---------- 6 files changed, 44 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/config_parsing/entity_parsing.rs b/packages/cli/src/config_parsing/entity_parsing.rs index f36018f907..4e5a6b3e83 100644 --- a/packages/cli/src/config_parsing/entity_parsing.rs +++ b/packages/cli/src/config_parsing/entity_parsing.rs @@ -1925,8 +1925,15 @@ impl GqlScalar { GqlScalar::Timestamp => TypeIdent::Timestamp, GqlScalar::Custom(name) => match schema.try_get_type_def(name)? { // A foreign key adopts the referenced entity's id type so the - // relation is keyed on matching types on both sides. - TypeDef::Entity(entity) => entity.get_id_scalar()?.to_rescript_type(schema)?, + // 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()), }, }; @@ -2062,8 +2069,10 @@ type NumericEntity { let schema = Schema::from_string(schema_str).unwrap(); let referencer = schema.entities.get("Referencer").unwrap(); - // A String-id relation renders through the shared `id` alias, a numeric - // relation renders as the concrete scalar. + // 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 @@ -2071,7 +2080,7 @@ type NumericEntity { .to_rescript_type(&schema) .unwrap() .to_string(), - "option".to_owned() + "option".to_owned() ); let numeric_related = referencer.get_field("numericRelated").unwrap(); 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 94302825db..e5a85e30e7 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: 3426 expression: project_template.indexer_code --- /** @@ -202,9 +201,9 @@ module Entities = { module EmptyEntity = { type id = string - type t = {id: id, emptyField: id, status: Enums.Status.t, optionalStatus: option, related_id: id, optionalRelated_id: option, tags: array, optionalTags: option>} + 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 = { diff --git a/packages/envio/src/InMemoryStore.res b/packages/envio/src/InMemoryStore.res index 493f099852..bb6cce8ef1 100644 --- a/packages/envio/src/InMemoryStore.res +++ b/packages/envio/src/InMemoryStore.res @@ -122,7 +122,7 @@ let prepareRollbackDiff = async ( entityTable->InMemoryTable.Entity.set( ~committedCheckpointId, Delete({ - entityId: entityId->EntityId.unsafeOfString, + entityId, checkpointId: rollbackDiffCheckpointId, }), ) 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 75d4b3f0b6..ff97a74ae7 100644 --- a/packages/envio/src/PgStorage.res +++ b/packages/envio/src/PgStorage.res @@ -1248,10 +1248,18 @@ 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), + )) +) let make = ( ~sql: Postgres.sql, @@ -1775,7 +1783,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( @@ -1788,7 +1796,7 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3:: let removedIds = removedIdRows->Array.map(row => row["id"]) 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/scenarios/test_codegen/src/Indexer.res b/scenarios/test_codegen/src/Indexer.res index 38b9dd1634..526992e79a 100644 --- a/scenarios/test_codegen/src/Indexer.res +++ b/scenarios/test_codegen/src/Indexer.res @@ -201,16 +201,16 @@ module Entities = { module A = { type id = string - type t = {id: id, b_id: id, optionalStringToTestLinkedEntities: option} + 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 id = string - type t = {id: id, c_id: option} + 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 = { @@ -222,9 +222,9 @@ module Entities = { module C = { type id = string - type t = {id: id, a_id: id, stringThatIsMirroredToA: 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 = { @@ -292,9 +292,9 @@ module Entities = { module Gravatar = { type id = string - type t = {id: id, owner_id: id, displayName: string, imageUrl: string, updatesCount: bigint, size: Enums.GravatarSize.t} + 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 = { @@ -334,16 +334,16 @@ module Entities = { module Token = { type id = string - type t = {id: id, tokenId: bigint, collection_id: id, owner_id: id} + 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 id = string - type t = {id: id, address: string, gravatar_id: option, updatesCountOnUserForTesting: int, accountType: Enums.AccountType.t} + 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> = From 971aba6bba13d68e89353f7aba7539762d964e0d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:46:04 +0000 Subject: [PATCH 4/7] Type deleted test-indexer ids by entity id and scope ClickHouse sort-key checks `EntityChangeValue.deleted` declared `readonly string[]`, but the test indexer reports the raw id, so a numeric-id entity returned numbers/bigints against a string type. It now derives from `EntityId`, covered by runtime tests asserting the reported ids are the raw scalars and by compile-time checks on the generated surface. ClickHouse sort-key validation ignored which columns the sorting key actually holds. `@storage(clickhouse: {orderBy: [...]})` replaces `id` in the key, so: - Fields listed in `orderBy` are now validated, resolving a relation to the id it stores (a relation's own scalar never matched the BigInt check, so a sort by a relation to an unbounded-BigInt id silently became a lexicographic String column). - The unbounded-BigInt `id` rejection now only applies when `orderBy` is absent and `id` is therefore the sorting key. Its message points at `orderBy` too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DNs5ezETo32JzCmFun221s --- .../cli/src/config_parsing/entity_parsing.rs | 16 +- .../cli/src/config_parsing/system_config.rs | 73 +++++++-- packages/envio/index.d.ts | 2 +- .../test_codegen/test/EventHandler.test.ts | 15 ++ .../test/lib_tests/EntityIdType_test.res | 140 +++++++++++++++++- 5 files changed, 227 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/config_parsing/entity_parsing.rs b/packages/cli/src/config_parsing/entity_parsing.rs index 4e5a6b3e83..e38c112d53 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)), @@ -1769,7 +1783,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() } diff --git a/packages/cli/src/config_parsing/system_config.rs b/packages/cli/src/config_parsing/system_config.rs index 41ad87e6ca..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, GqlScalar, GraphQLEnum, Schema}, + entity_parsing::{ClickHouseEntityStorage, Entity, GqlScalar, GraphQLEnum, Schema}, env_interpolation::interpolate_config_variables, human_config::{ self, @@ -427,10 +427,17 @@ 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. `id` is ClickHouse's - // mandatory default sort key, so an id like that would order wrong. Reject - // it up front, mirroring `validate_clickhouse_order_by_fields`. See the - // BigInt branch of `getClickHouseFieldType` in ClickHouse.res. + // 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()) @@ -440,17 +447,51 @@ pub fn validate_entity_storage(storage: &Storage, schema: &Schema) -> anyhow::Re if !uses_clickhouse { continue; } - if let Ok(GqlScalar::BigInt(precision)) = entity.get_id_scalar() { - let stored_as_numeric = - precision.is_some_and(|p| p <= CLICKHOUSE_DECIMAL_MAX_PRECISION); - if !stored_as_numeric { - 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.", - entity.name - )); + + 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 + )); + } + } } } } diff --git a/packages/envio/index.d.ts b/packages/envio/index.d.ts index 083b97ff7d..82528c1bdb 100644 --- a/packages/envio/index.d.ts +++ b/packages/envio/index.d.ts @@ -1514,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. */ 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/lib_tests/EntityIdType_test.res b/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res index 32a51fecb7..edb92a37f2 100644 --- a/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res +++ b/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res @@ -252,7 +252,7 @@ chains: // 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." + 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(() => @@ -305,3 +305,141 @@ chains: 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) + }) +}) From db20ebaf793990d2a9b878bbec912ebfdbed056d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:58:33 +0000 Subject: [PATCH 5/7] Parse rollback removed ids and drop the no-throw sentinel Review follow-ups: - `getRollbackData` parsed the pre-target rows with the table's id schema but cast the removed-id rows straight to `EntityId.t`. Postgres hands back a NUMERIC id as a string, so a BigInt-id entity produced string ids on one half of the rollback diff and bigints on the other, and re-serializing those strings through the id schema would fail. Both queries now parse through it. - `toThrowErrorEqual` compared a "" placeholder against the expected message, so asserting that exact string passed for a function that never threw. It compares options instead, which drops the placeholder and the branch along with it. - Annotate the backfill ids cast with `Utils.magic` per the repo convention. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DNs5ezETo32JzCmFun221s --- packages/envio/src/PgStorage.res | 11 +++++++++-- packages/envio/src/bindings/Vitest.res | 12 ++++-------- packages/envio/src/db/EntityHistory.res | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/envio/src/PgStorage.res b/packages/envio/src/PgStorage.res index ff97a74ae7..5b45d49fee 100644 --- a/packages/envio/src/PgStorage.res +++ b/packages/envio/src/PgStorage.res @@ -1261,6 +1261,13 @@ let rollbackRowStateSchema: Table.table => S.t<( )) ) +// 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, ~pgHost, @@ -1783,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( @@ -1793,7 +1800,7 @@ 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(entityConfig.table)) diff --git a/packages/envio/src/bindings/Vitest.res b/packages/envio/src/bindings/Vitest.res index 31a894daa7..9c9e4d38aa 100644 --- a/packages/envio/src/bindings/Vitest.res +++ b/packages/envio/src/bindings/Vitest.res @@ -167,12 +167,8 @@ let messageOfThrown: (unit => 'a) => option = %raw(`function (fn) { // 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) => - switch fn->messageOfThrown { - | Some(thrown) => t.expect(thrown, ~message?).toBe(expected) - | None => - t.expect( - "", - ~message=message->Option.getOr("Expected the function to throw an error, but it did not."), - ).toBe(expected) - } + 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 f58aca93f9..acba42ddb5 100644 --- a/packages/envio/src/db/EntityHistory.res +++ b/packages/envio/src/db/EntityHistory.res @@ -148,7 +148,7 @@ let backfillHistory = ( sql ->Postgres.preparedUnsafe( makeBackfillHistoryQuery(~entityName=table.tableName, ~entityIndex, ~pgSchema, ~idPgType), - [table->Table.encodeIdsToJson(ids)]->Obj.magic, + [table->Table.encodeIdsToJson(ids)]->(Utils.magic: array => unknown), ) ->Utils.Promise.ignoreValue } From 6071b916f1b7473b56cf38ee4f2f877319d825cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 12:13:57 +0000 Subject: [PATCH 6/7] Key getTestIndexerEntityOperations by the entity's id type The name-keyed test-indexer accessor returned `testIndexerEntityOperations`, whose `get`/`getOrThrow` are string-keyed, so a numeric-id entity reached through the helper form could not be looked up by its real id even though direct field access was typed correctly. `Indexer.res` is user-facing, so this was reachable from user tests. Carry the id on the entity-name GADT (`name<'entity, 'id>`) so the accessor can recover it, and return the id-aware operations. Direct fields are unchanged, so `ID!` entities keep their id-argument-free shape and the accessor resolves to the same string-keyed operations they had before. The custom-id operations type is now emitted unconditionally since the accessor always refers to it, which also drops the duplicated template branch. Note: annotating `Entities.name` explicitly now needs the extra type parameter (as MockIndexer does); using the constructors as values is unaffected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DNs5ezETo32JzCmFun221s --- .../src/hbs_templating/codegen_templates.rs | 31 +++++-------- ..._test__indexer_code_generated_for_svm.snap | 18 ++++++-- ...de_generates_correct_types_and_values.snap | 19 ++++++-- ...s__test__indexer_code_multiple_chains.snap | 18 ++++++-- scenarios/fuel_test/src/Indexer.res | 17 +++++-- scenarios/svm_test/src/Indexer.res | 17 +++++-- scenarios/test_codegen/src/Indexer.res | 46 +++++++++---------- .../test_codegen/test/helpers/MockIndexer.res | 16 +++---- .../test/lib_tests/EntityIdType_test.res | 16 +++++++ 9 files changed, 130 insertions(+), 68 deletions(-) diff --git a/packages/cli/src/hbs_templating/codegen_templates.rs b/packages/cli/src/hbs_templating/codegen_templates.rs index 4c7b39bc9f..c995da7479 100644 --- a/packages/cli/src/hbs_templating/codegen_templates.rs +++ b/packages/cli/src/hbs_templating/codegen_templates.rs @@ -126,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(); @@ -1842,8 +1845,11 @@ type contractRegisterContext = {{ // Generate entity ops fields for the testIndexer type. String ids use // the plain type; numeric ids use the custom-id variant. - let test_indexer_entity_ops_type = if has_custom_id_entity { - r#"/** Entity operations for direct access outside handlers. */ + // 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. */ get: string => promise>, @@ -1864,20 +1870,7 @@ type testIndexerEntityOperationsWithCustomId<'entity, 'id> = { getOrThrow: ('id, ~message: string=?) => promise<'entity>, /** Set (create or update) an entity. */ set: 'entity => unit, -}"# - } else { - r#"/** Entity operations for direct access outside handlers. */ -type testIndexerEntityOperations<'entity> = { - /** Get an entity by ID. */ - get: string => promise>, - /** Get all entities. */ - getAll: unit => promise>, - /** Get an entity by ID or throw if not found. */ - getOrThrow: (string, ~message: string=?) => promise<'entity>, - /** Set (create or update) an entity. */ - set: 'entity => unit, -}"# - }; +}"#; let test_indexer_entity_fields = entities .iter() @@ -1924,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); } 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 6633907c3f..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 @@ -1,6 +1,5 @@ --- source: packages/cli/src/hbs_templating/codegen_templates.rs -assertion_line: 3671 expression: project_template.indexer_code --- /** @@ -86,8 +85,8 @@ module Entities = { 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> = { @@ -251,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. */ @@ -262,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 e5a85e30e7..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 @@ -213,9 +213,9 @@ module Entities = { 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> = { @@ -434,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. */ @@ -446,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 0160c8feb7..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: 3433 expression: project_template.indexer_code --- /** @@ -203,8 +202,8 @@ module Entities = { 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> = { @@ -504,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. */ @@ -515,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/scenarios/fuel_test/src/Indexer.res b/scenarios/fuel_test/src/Indexer.res index 85c26e40ca..1400d05095 100644 --- a/scenarios/fuel_test/src/Indexer.res +++ b/scenarios/fuel_test/src/Indexer.res @@ -87,8 +87,8 @@ module Entities = { 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> = { @@ -1198,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. */ @@ -1209,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 a578bc5031..e930923057 100644 --- a/scenarios/svm_test/src/Indexer.res +++ b/scenarios/svm_test/src/Indexer.res @@ -81,8 +81,8 @@ module Entities = { 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> = { @@ -198,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. */ @@ -209,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/src/Indexer.res b/scenarios/test_codegen/src/Indexer.res index 526992e79a..a0cb21184c 100644 --- a/scenarios/test_codegen/src/Indexer.res +++ b/scenarios/test_codegen/src/Indexer.res @@ -346,28 +346,28 @@ module Entities = { 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("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 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> = { @@ -2064,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/helpers/MockIndexer.res b/scenarios/test_codegen/test/helpers/MockIndexer.res index 51610b8f29..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. @@ -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( diff --git a/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res b/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res index edb92a37f2..b8ff066eb5 100644 --- a/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res +++ b/scenarios/test_codegen/test/lib_tests/EntityIdType_test.res @@ -132,6 +132,22 @@ let _testIndexerKeysOpsByIdScalar = async (indexer: Indexer.testIndexer) => { 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. From 672a146feedb01b7c523ff6636ad50df04e46fea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 12:49:46 +0000 Subject: [PATCH 7/7] Match derivedFrom scalar keys against the deriving entity's id Allowing Int/BigInt derived-from fields left the field's type unchecked against the entity deriving from it. Hasura maps that entity's `id` onto the derived column (the `"id": relationalKey` mapping in Hasura.res), so a schema like `Parent.id: ID!` with `Child.parentId: Int!` passed validation and then produced a relationship comparing a text id to an integer column, failing when the metadata is created. Previously this could not happen: only ID/String were accepted and every id was a string, so the two sides always agreed. Scalar derived-from fields now have to share an id kind with the entity's id. Kinds are compared rather than raw scalars, since ID and String share a text column (relied on by the e2e_test schema, which derives an `id: ID!` entity from a `String!` field) and a BigInt's precision only sets the column width. A relation back to the deriving entity keeps its existing pass-through: the foreign key adopts that entity's id type by construction. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DNs5ezETo32JzCmFun221s --- .../cli/src/config_parsing/entity_parsing.rs | 135 ++++++++++++++++-- 1 file changed, 122 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/config_parsing/entity_parsing.rs b/packages/cli/src/config_parsing/entity_parsing.rs index e38c112d53..8ee719a1c8 100644 --- a/packages/cli/src/config_parsing/entity_parsing.rs +++ b/packages/cli/src/config_parsing/entity_parsing.rs @@ -249,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() { @@ -273,19 +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 - | GqlScalar::Int - | GqlScalar::BigInt(_) => (), - _ => Err(anyhow!( - "Derived field '{derived_from_field}' on entity \ - '{name}' must either be an ID, String, Int, BigInt, 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 + ))? + } + } + } + } } } } @@ -2428,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#"