Skip to main content

icydb_schema/
source_digest.rs

1//! Canonical per-entity generated-source meaning.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use sha2::{Digest, Sha256};
6
7use crate::{
8    DeclaredEntityVersion, EntityFragment, EntitySourceDigest, EntitySourceKey, FieldFragment,
9    FieldSourceKey, FieldType, NamedTypeFragment, RelationFragment, SchemaContractError,
10    SchemaMigrationPlan, SchemaProposal, TypeSourceKey,
11};
12
13const ENTITY_SOURCE_DIGEST_PROFILE: &[u8] = b"icydb.entity-source-meaning.v1";
14
15impl SchemaProposal {
16    /// Compute the canonical generated-owned meaning for one current entity.
17    ///
18    /// The digest excludes the declared entity version and migration plan. It
19    /// includes the complete entity contract, its reachable named-type
20    /// closure, and the exact target-field contracts referenced by relations.
21    ///
22    /// # Errors
23    ///
24    /// Returns a typed reference or encoding error when the requested entity
25    /// or one of its current relation/type dependencies is absent.
26    pub fn entity_source_digest(
27        &self,
28        source: &EntitySourceKey,
29    ) -> Result<EntitySourceDigest, SchemaContractError> {
30        self.entity_source_digest_with_targets(source, &BTreeMap::new())
31    }
32
33    /// Compute source meaning with relation targets mapped to the explicit
34    /// predecessor entity names in this proposal's migration plan.
35    ///
36    /// This comparison proof changes only target names, not target fields or
37    /// reachable type contracts. The planner compares it to accepted lineage to
38    /// admit a dependency-only transition; it is not runtime schema authority.
39    ///
40    /// # Errors
41    ///
42    /// Returns a typed reference or encoding error for invalid dependencies or
43    /// colliding predecessor target names.
44    pub fn entity_source_digest_before_entity_renames(
45        &self,
46        source: &EntitySourceKey,
47    ) -> Result<EntitySourceDigest, SchemaContractError> {
48        let targets = self
49            .migration()
50            .into_iter()
51            .flat_map(SchemaMigrationPlan::transitions)
52            .filter_map(|transition| {
53                transition
54                    .from_name()
55                    .map(|from| (transition.entity(), from))
56            })
57            .collect();
58        self.entity_source_digest_with_targets(source, &targets)
59    }
60
61    // Share the exact canonical encoding with ordinary lineage digests. Only
62    // explicit relation target correspondences may differ in the proof.
63    fn entity_source_digest_with_targets(
64        &self,
65        source: &EntitySourceKey,
66        target_names: &BTreeMap<&EntitySourceKey, &EntitySourceKey>,
67    ) -> Result<EntitySourceDigest, SchemaContractError> {
68        let mut entities = BTreeMap::new();
69        let mut types = BTreeMap::new();
70        for fragment in self.fragments() {
71            for entity in fragment.entities() {
72                entities.insert(entity.source_key().clone(), entity);
73            }
74            for definition in fragment.types() {
75                types.insert(definition.source_key().clone(), definition);
76            }
77        }
78        let entity = entities
79            .get(source)
80            .copied()
81            .ok_or(SchemaContractError::InvalidMigrationReference)?;
82        let normalized = normalized_entity(entity, target_names)?;
83
84        let mut pending_types = Vec::new();
85        for field in entity.fields() {
86            collect_field_type_sources(field.field_type(), &mut pending_types);
87        }
88        let mut relation_targets = relation_target_meanings(entity, &entities, &mut pending_types)?;
89        if !target_names.is_empty() {
90            for (target, _) in &mut relation_targets {
91                if let Some(predecessor) = target_names.get(target) {
92                    target.clone_from(predecessor);
93                }
94            }
95            // Renames can reverse lexical order. Preserve canonical ordering
96            // without merging ambiguous predecessor targets.
97            crate::compact_sort_unstable_by(&mut relation_targets, |left, right| {
98                left.0.cmp(&right.0)
99            });
100            if relation_targets
101                .windows(2)
102                .any(|pair| pair[0].0 == pair[1].0)
103            {
104                return Err(SchemaContractError::InvalidMigrationReference);
105            }
106        }
107        let reachable_types = reachable_type_meanings(&types, pending_types)?;
108        let encoded = crate::codec::encode_entity_source_meaning(
109            &normalized,
110            &relation_targets,
111            &reachable_types,
112        )?;
113
114        let mut hasher = Sha256::new();
115        hasher.update(ENTITY_SOURCE_DIGEST_PROFILE);
116        hasher.update(
117            u64::try_from(encoded.len())
118                .unwrap_or(u64::MAX)
119                .to_be_bytes(),
120        );
121        hasher.update(encoded);
122        Ok(EntitySourceDigest::from_bytes(hasher.finalize().into()))
123    }
124}
125
126fn normalized_entity(
127    entity: &EntityFragment,
128    target_names: &BTreeMap<&EntitySourceKey, &EntitySourceKey>,
129) -> Result<EntityFragment, SchemaContractError> {
130    let relations = entity
131        .relations()
132        .iter()
133        .map(|relation| {
134            let Some(predecessor) = target_names.get(relation.target_entity()) else {
135                return Ok(relation.clone());
136            };
137            RelationFragment::try_new(
138                relation.name().clone(),
139                relation.source().clone(),
140                (*predecessor).clone(),
141                relation.target_fields().to_vec(),
142                relation.on_delete(),
143            )
144        })
145        .collect::<Result<Vec<_>, _>>()?;
146    EntityFragment::try_new(
147        entity.name().clone(),
148        DeclaredEntityVersion::try_new(1)?,
149        entity.fields().to_vec(),
150        entity.primary_key().to_vec(),
151        entity.indexes().to_vec(),
152        relations,
153        entity.constraints().to_vec(),
154    )
155}
156
157type RelationTargetMeaning = (EntitySourceKey, Vec<(FieldSourceKey, FieldFragment)>);
158
159fn relation_target_meanings(
160    entity: &EntityFragment,
161    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
162    pending_types: &mut Vec<TypeSourceKey>,
163) -> Result<Vec<RelationTargetMeaning>, SchemaContractError> {
164    let mut targets = BTreeMap::<EntitySourceKey, BTreeSet<FieldSourceKey>>::new();
165    for relation in entity.relations() {
166        targets
167            .entry(relation.target_entity().clone())
168            .or_default()
169            .extend(relation.target_fields().iter().cloned());
170    }
171    targets
172        .into_iter()
173        .map(|(target_source, field_sources)| {
174            let target = entities
175                .get(&target_source)
176                .copied()
177                .ok_or(SchemaContractError::InvalidMigrationReference)?;
178            let fields = field_sources
179                .into_iter()
180                .map(|field_source| {
181                    let field = target
182                        .fields()
183                        .iter()
184                        .find(|field| field.source_key() == &field_source)
185                        .cloned()
186                        .ok_or(SchemaContractError::InvalidMigrationReference)?;
187                    collect_field_type_sources(field.field_type(), pending_types);
188                    Ok((field_source, field))
189                })
190                .collect::<Result<Vec<_>, SchemaContractError>>()?;
191            Ok((target_source, fields))
192        })
193        .collect()
194}
195
196fn reachable_type_meanings(
197    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
198    mut pending: Vec<TypeSourceKey>,
199) -> Result<Vec<NamedTypeFragment>, SchemaContractError> {
200    let mut reachable = BTreeSet::new();
201    while let Some(source) = pending.pop() {
202        if !reachable.insert(source.clone()) {
203            continue;
204        }
205        let definition = types
206            .get(&source)
207            .copied()
208            .ok_or(SchemaContractError::InvalidMigrationReference)?;
209        collect_named_type_sources(definition, &mut pending);
210    }
211    reachable
212        .into_iter()
213        .map(|source| {
214            types
215                .get(&source)
216                .copied()
217                .cloned()
218                .ok_or(SchemaContractError::InvalidMigrationReference)
219        })
220        .collect()
221}
222
223fn collect_named_type_sources(definition: &NamedTypeFragment, pending: &mut Vec<TypeSourceKey>) {
224    match definition {
225        NamedTypeFragment::Record(record) => {
226            for field in record.fields() {
227                collect_field_type_sources(field.field_type(), pending);
228            }
229        }
230        NamedTypeFragment::Enum(r#enum) => {
231            for variant in r#enum.variants() {
232                if let Some(payload) = variant.payload() {
233                    collect_field_type_sources(payload, pending);
234                }
235            }
236        }
237        NamedTypeFragment::Newtype { inner, .. }
238        | NamedTypeFragment::List { item: inner, .. }
239        | NamedTypeFragment::Set { item: inner, .. } => {
240            collect_field_type_sources(inner, pending);
241        }
242        NamedTypeFragment::Map { key, value, .. } => {
243            collect_field_type_sources(key, pending);
244            collect_field_type_sources(value, pending);
245        }
246        NamedTypeFragment::Tuple { members, .. } => {
247            for member in members {
248                collect_field_type_sources(member.field_type(), pending);
249            }
250        }
251    }
252}
253
254fn collect_field_type_sources(field_type: &FieldType, pending: &mut Vec<TypeSourceKey>) {
255    match field_type {
256        FieldType::List(inner) => collect_field_type_sources(inner, pending),
257        FieldType::Named(source) => pending.push(source.clone()),
258        FieldType::Scalar(_) => {}
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use crate::{
265        DeclaredEntityVersion, EntityFragment, EntityMigration, EntitySourceKey,
266        EntityStoreAssignment, ExpectedAcceptedHead, FieldFragment, FieldInsertPolicy,
267        FieldSourceKey, FieldType, NamedTypeFragment, RelationDeleteAction, RelationFragment,
268        RelationSourceFragment, ScalarType, SchemaCapability, SchemaFragment, SchemaMigrationPlan,
269        SchemaName, SchemaProposal, SchemaSubmissionKey, TargetDatabaseIdentity,
270        TargetStoreIdentity, TypeSourceKey,
271    };
272
273    fn proposal(version: u32, field_name: &str) -> SchemaProposal {
274        let id = FieldFragment::new(
275            SchemaName::try_new(field_name).expect("field name should admit"),
276            FieldType::Scalar(crate::ScalarType::Nat64),
277            false,
278            FieldInsertPolicy::Required,
279            None,
280        );
281        let entity = EntityFragment::try_new(
282            SchemaName::try_new("User").expect("entity name should admit"),
283            DeclaredEntityVersion::try_new(version).expect("version should admit"),
284            vec![id],
285            vec![FieldSourceKey::try_new(field_name).expect("field key should admit")],
286            Vec::new(),
287            Vec::new(),
288            Vec::new(),
289        )
290        .expect("entity should admit");
291        SchemaProposal::try_compose(
292            Vec::new(),
293            TargetDatabaseIdentity::from_bytes([1; 32]),
294            SchemaSubmissionKey::try_new("source-digest").expect("submission should admit"),
295            ExpectedAcceptedHead::Empty,
296            vec![SchemaFragment::try_new(vec![entity], Vec::new()).expect("fragment should admit")],
297            vec![EntityStoreAssignment::new(
298                EntitySourceKey::try_new("User").expect("entity key should admit"),
299                TargetStoreIdentity::from_bytes([2; 32]),
300            )],
301            Vec::new(),
302            None,
303        )
304        .expect("proposal should admit")
305    }
306
307    #[test]
308    fn source_digest_ignores_declared_version_but_not_entity_meaning() {
309        let source = EntitySourceKey::try_new("User").expect("source should admit");
310        assert_eq!(
311            proposal(1, "id")
312                .entity_source_digest(&source)
313                .expect("digest should derive"),
314            proposal(7, "id")
315                .entity_source_digest(&source)
316                .expect("digest should derive"),
317        );
318        assert_ne!(
319            proposal(1, "id")
320                .entity_source_digest(&source)
321                .expect("digest should derive"),
322            proposal(1, "other")
323                .entity_source_digest(&source)
324                .expect("digest should derive"),
325        );
326    }
327
328    // Zebra -> Alpha crosses Middle in canonical target order. Payload keeps a
329    // reachable named-type contract in the owner's source meaning.
330    #[expect(
331        clippy::too_many_lines,
332        reason = "one predecessor/successor fixture keeps the complete dependency contract visible"
333    )]
334    fn dependency_proposal(
335        renamed: bool,
336        payload: ScalarType,
337        target: FieldInsertPolicy,
338    ) -> SchemaProposal {
339        let name = |value| SchemaName::try_new(value).unwrap();
340        let key = |value| EntitySourceKey::try_new(value).unwrap();
341        let field = |value| FieldSourceKey::try_new(value).unwrap();
342        let id = |policy| {
343            FieldFragment::new(
344                name("id"),
345                FieldType::Scalar(ScalarType::Nat64),
346                false,
347                policy,
348                None,
349            )
350        };
351        let target_name = if renamed { "Alpha" } else { "Zebra" };
352        let mut entities = Vec::new();
353        for entity in [target_name, "Middle", "Holder"] {
354            let mut fields = vec![id(if entity == target_name {
355                target.clone()
356            } else {
357                FieldInsertPolicy::Required
358            })];
359            let relations = if entity == "Holder" {
360                fields.push(FieldFragment::new(
361                    name("payload"),
362                    FieldType::Named(TypeSourceKey::try_new("Payload").unwrap()),
363                    false,
364                    FieldInsertPolicy::Required,
365                    None,
366                ));
367                [target_name, "Middle"]
368                    .into_iter()
369                    .enumerate()
370                    .map(|(ordinal, target)| {
371                        RelationFragment::try_new(
372                            name(if ordinal == 0 { "first" } else { "second" }),
373                            RelationSourceFragment::direct(vec![field("id")]),
374                            key(target),
375                            vec![field("id")],
376                            RelationDeleteAction::Restrict,
377                        )
378                        .unwrap()
379                    })
380                    .collect()
381            } else {
382                Vec::new()
383            };
384            entities.push(
385                EntityFragment::try_new(
386                    name(entity),
387                    DeclaredEntityVersion::try_new(if renamed && entity != "Middle" {
388                        2
389                    } else {
390                        1
391                    })
392                    .unwrap(),
393                    fields,
394                    vec![field("id")],
395                    Vec::new(),
396                    relations,
397                    Vec::new(),
398                )
399                .unwrap(),
400            );
401        }
402        let migration = renamed.then(|| {
403            SchemaMigrationPlan::try_new(vec![
404                EntityMigration::try_new(
405                    key("Alpha"),
406                    DeclaredEntityVersion::try_new(1).unwrap(),
407                    Some(key("Zebra")),
408                    Vec::new(),
409                    Vec::new(),
410                )
411                .unwrap(),
412                EntityMigration::try_new(
413                    key("Holder"),
414                    DeclaredEntityVersion::try_new(1).unwrap(),
415                    None,
416                    Vec::new(),
417                    Vec::new(),
418                )
419                .unwrap(),
420            ])
421            .unwrap()
422        });
423        let assignments = entities
424            .iter()
425            .map(|entity| {
426                EntityStoreAssignment::new(
427                    entity.source_key().clone(),
428                    TargetStoreIdentity::from_bytes([2; 32]),
429                )
430            })
431            .collect();
432        let mut capabilities = vec![SchemaCapability::RESTRICTIVE_RELATIONS];
433        if renamed {
434            capabilities.push(SchemaCapability::VERSIONED_MIGRATIONS);
435        }
436        SchemaProposal::try_compose(
437            capabilities,
438            TargetDatabaseIdentity::from_bytes([1; 32]),
439            SchemaSubmissionKey::try_new("dependency").unwrap(),
440            ExpectedAcceptedHead::Empty,
441            vec![
442                SchemaFragment::try_new(
443                    entities,
444                    vec![NamedTypeFragment::newtype(
445                        name("Payload"),
446                        FieldType::Scalar(payload),
447                    )],
448                )
449                .unwrap(),
450            ],
451            assignments,
452            Vec::new(),
453            migration,
454        )
455        .unwrap()
456    }
457
458    #[test]
459    fn entity_rename_dependency_proof_preserves_canonical_order_and_complete_meaning() {
460        let holder = EntitySourceKey::try_new("Holder").unwrap();
461        let before = dependency_proposal(false, ScalarType::Nat64, FieldInsertPolicy::Required)
462            .entity_source_digest(&holder)
463            .unwrap();
464        let renamed = dependency_proposal(true, ScalarType::Nat64, FieldInsertPolicy::Required);
465        assert_ne!(before, renamed.entity_source_digest(&holder).unwrap());
466        assert_eq!(
467            before,
468            renamed
469                .entity_source_digest_before_entity_renames(&holder)
470                .unwrap()
471        );
472        for (payload, target) in [
473            (ScalarType::Nat32, FieldInsertPolicy::Required),
474            (
475                ScalarType::Nat64,
476                FieldInsertPolicy::Default(crate::ScalarLiteral::Nat(1)),
477            ),
478        ] {
479            assert_ne!(
480                before,
481                dependency_proposal(true, payload, target)
482                    .entity_source_digest_before_entity_renames(&holder)
483                    .unwrap()
484            );
485        }
486    }
487}