diff --git a/Cargo.lock b/Cargo.lock index ece10983369..724448f89af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7065,11 +7065,14 @@ dependencies = [ "hash-integrations-definition", "pretty_assertions", "proptest", + "serde", "serde_json", "simple-mermaid", "tempfile", "tokio", "tracing", + "type-system", + "uuid", ] [[package]] diff --git a/libs/@local/integrations/engine/Cargo.toml b/libs/@local/integrations/engine/Cargo.toml index 169ce41ea2d..b9402070afb 100644 --- a/libs/@local/integrations/engine/Cargo.toml +++ b/libs/@local/integrations/engine/Cargo.toml @@ -11,6 +11,7 @@ authors.workspace = true # Public workspace dependencies error-stack = { workspace = true, public = true, features = ["std"] } hash-integrations-definition = { workspace = true, public = true } +type-system = { workspace = true, public = true } # Public third-party dependencies duckdb = { workspace = true, public = true, features = ["bundled", "json"] } @@ -23,10 +24,12 @@ serde_json = { workspace = true } simple-mermaid = { workspace = true } tokio = { workspace = true, features = ["sync"] } tracing = { workspace = true, features = ["attributes"] } +uuid = { workspace = true, features = ["v5"] } [dev-dependencies] pretty_assertions = { workspace = true } proptest = { workspace = true, features = ["attr-macro", "std"] } +serde = { workspace = true, features = ["derive"] } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "time"] } diff --git a/libs/@local/integrations/engine/README.md b/libs/@local/integrations/engine/README.md index 7543b32fe3a..29487d7f163 100644 --- a/libs/@local/integrations/engine/README.md +++ b/libs/@local/integrations/engine/README.md @@ -3,3 +3,5 @@ Runs an integration's pipelines in DuckDB. Each run gets a `Workspace`, a DuckDB database file in which the run's queries execute. A query is a single `SELECT`, and cannot open files, load extensions or change settings. + +`derive_entity_id` derives stable entity IDs from an integration namespace, entity type and source record ID using UUID v5. diff --git a/libs/@local/integrations/engine/docs/task-dependencies.json b/libs/@local/integrations/engine/docs/task-dependencies.json index e36859a5e10..34f3568fa54 100644 --- a/libs/@local/integrations/engine/docs/task-dependencies.json +++ b/libs/@local/integrations/engine/docs/task-dependencies.json @@ -2,7 +2,8 @@ "package": "hash-integrations-engine", "dependencies": [ "error-stack", - "hash-integrations-definition" + "hash-integrations-definition", + "type-system" ], "tasks": { "doc:dependency-diagram": { diff --git a/libs/@local/integrations/engine/src/entity_id.rs b/libs/@local/integrations/engine/src/entity_id.rs new file mode 100644 index 00000000000..3ab0d624df8 --- /dev/null +++ b/libs/@local/integrations/engine/src/entity_id.rs @@ -0,0 +1,24 @@ +use type_system::{ + knowledge::entity::id::{EntityId, EntityUuid}, + ontology::id::VersionedUrl, + principal::actor_group::WebId, +}; +use uuid::Uuid; + +const NAMESPACE: Uuid = Uuid::from_u128(0xD6E2_C7A1_F84B_4E3A_9C0D_5B7F_1E3A_2D4C); + +/// Derives a live entity ID from the integration namespace, entity type, and source record ID. +#[must_use] +pub fn derive_entity_id( + web_id: WebId, + namespace: &str, + entity_type: &VersionedUrl, + source_id: &str, +) -> EntityId { + let name = format!("{namespace}::{entity_type}::{source_id}"); + EntityId { + web_id, + entity_uuid: EntityUuid::new(Uuid::new_v5(&NAMESPACE, name.as_bytes())), + draft_id: None, + } +} diff --git a/libs/@local/integrations/engine/src/lib.rs b/libs/@local/integrations/engine/src/lib.rs index fdb9775bf5a..5f00a9aafd6 100644 --- a/libs/@local/integrations/engine/src/lib.rs +++ b/libs/@local/integrations/engine/src/lib.rs @@ -16,12 +16,14 @@ extern crate alloc; mod byte_size; +mod entity_id; mod sql; mod variant_name; mod workspace; pub use self::{ byte_size::ByteSize, + entity_id::derive_entity_id, workspace::{ CloseError, DataErrorType, DiskUsageError, OpenError, QueryErrorType, Rows, Setting, SnapshotError, StatementError, Workspace, WorkspaceConfig, diff --git a/libs/@local/integrations/engine/tests/entity_id.rs b/libs/@local/integrations/engine/tests/entity_id.rs new file mode 100644 index 00000000000..92a284914ca --- /dev/null +++ b/libs/@local/integrations/engine/tests/entity_id.rs @@ -0,0 +1,46 @@ +//! Deterministic entity ID derivation. + +use hash_integrations_engine::derive_entity_id; +use pretty_assertions::assert_eq; +use serde::Deserialize; +use type_system::{ + knowledge::entity::id::EntityUuid, ontology::id::VersionedUrl, principal::actor_group::WebId, +}; +use uuid::Uuid; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Case { + ns: String, + entity_type: VersionedUrl, + entity_id: String, + uuid: EntityUuid, + composite: String, + note: String, +} + +#[test] +fn derive_entity_id_recorded_vectors() { + let web_id = WebId::new(Uuid::from_u128(0xA2C8_E6C7_485D_4B54_A248_53C9_B9B0_14A2)); + let cases: Vec = + serde_json::from_str(include_str!("fixtures/entity-ids-typescript.json")) + .expect("entity ID fixtures should be valid"); + for case in cases { + let entity_id = derive_entity_id(web_id, &case.ns, &case.entity_type, &case.entity_id); + assert_eq!( + entity_id.entity_uuid, case.uuid, + "entity UUID should match the recorded value: {}", + case.note + ); + assert_eq!( + entity_id.to_string(), + case.composite.replace("web-fixture", &web_id.to_string()), + "composite ID should match the recorded value: {}", + case.note + ); + assert!( + entity_id.draft_id.is_none(), + "derived entity ID should identify a live entity" + ); + } +} diff --git a/libs/@local/integrations/engine/tests/fixtures/README.md b/libs/@local/integrations/engine/tests/fixtures/README.md new file mode 100644 index 00000000000..532c93c736d --- /dev/null +++ b/libs/@local/integrations/engine/tests/fixtures/README.md @@ -0,0 +1,5 @@ +# Entity ID fixtures + +`entity-ids-typescript.json` contains the proof of concept's [recorded UUID vectors](https://github.com/hashintel/labs/blob/40a8d14535cee4020c2cd24c7e513bc209b59e19/pocs/integrations_rs/tests/golden/uuids.json). The two numeric source IDs are stored as text to match the derivation function's input. + +`web-fixture` is replaced with a valid web UUID when testing composite entity IDs. diff --git a/libs/@local/integrations/engine/tests/fixtures/entity-ids-typescript.json b/libs/@local/integrations/engine/tests/fixtures/entity-ids-typescript.json new file mode 100644 index 00000000000..8911c61f4fb --- /dev/null +++ b/libs/@local/integrations/engine/tests/fixtures/entity-ids-typescript.json @@ -0,0 +1,66 @@ +[ + { + "ns": "conn-a", + "entityType": "https://x/@t/types/entity-type/material/v/1", + "entityId": "M-0001", + "note": "plain string id", + "uuid": "fdb92954-9bd5-5558-ae43-40437c693f27", + "composite": "web-fixture~fdb92954-9bd5-5558-ae43-40437c693f27" + }, + { + "ns": "conn-a", + "entityType": "https://x/@t/types/entity-type/material/v/1", + "entityId": "1", + "note": "numeric ID represented as text", + "uuid": "2f37a211-5787-5474-83b3-e3d7cf6bb83a", + "composite": "web-fixture~2f37a211-5787-5474-83b3-e3d7cf6bb83a" + }, + { + "ns": "conn-a", + "entityType": "https://x/@t/types/entity-type/material/v/1", + "entityId": "1.5", + "note": "decimal ID represented as text", + "uuid": "42a6299f-2d8d-5b73-b11f-64badff46a94", + "composite": "web-fixture~42a6299f-2d8d-5b73-b11f-64badff46a94" + }, + { + "ns": "conn-a", + "entityType": "https://x/@t/types/entity-type/material/v/1", + "entityId": "Grüße-仓库", + "note": "unicode id, hashed as UTF-8 bytes", + "uuid": "5aec7f53-3989-5d44-ace4-a78b860193a5", + "composite": "web-fixture~5aec7f53-3989-5d44-ace4-a78b860193a5" + }, + { + "ns": "conn-a", + "entityType": "https://x/@t/types/entity-type/material/v/1", + "entityId": "a::b", + "note": "id containing the :: separator", + "uuid": "b0ed6b55-ea24-512b-97fe-e17e1c7f6861", + "composite": "web-fixture~b0ed6b55-ea24-512b-97fe-e17e1c7f6861" + }, + { + "ns": "conn-a", + "entityType": "https://x/@t/types/entity-type/material/v/1", + "entityId": "A|B", + "note": "composite-joined id", + "uuid": "1ecf2406-4eba-5b0e-93f2-1a08389594d7", + "composite": "web-fixture~1ecf2406-4eba-5b0e-93f2-1a08389594d7" + }, + { + "ns": "conn-a", + "entityType": "https://x/@t/types/entity-type/stored-at/v/1", + "entityId": "https://x/@t/types/entity-type/material/v/1::M-0001::https://x/@t/types/entity-type/plant/v/1::P-0100", + "note": "link uuid: entityId is sourceType::sourceId::targetType::targetId", + "uuid": "25bbb6e3-3e0a-53c2-a2f3-b8b83f7ff314", + "composite": "web-fixture~25bbb6e3-3e0a-53c2-a2f3-b8b83f7ff314" + }, + { + "ns": "conn-a", + "entityType": "https://x/@t/types/entity-type/material/v/1", + "entityId": "", + "note": "empty-string id", + "uuid": "54b1ee67-5978-54fd-81ca-18d4ed3cdb2e", + "composite": "web-fixture~54b1ee67-5978-54fd-81ca-18d4ed3cdb2e" + } +]