Skip to main content

icydb_schema/
proposal.rs

1//! Canonical database-scoped proposal envelope.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use candid::CandidType;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9use crate::{
10    ConstraintSourceKey, EntityFragment, EntitySourceKey, FieldFragment, FieldSourceKey, FieldType,
11    IndexSourceKey, MAX_SCHEMA_ASSIGNMENTS, MAX_SCHEMA_CAPABILITIES, MAX_SCHEMA_PROPOSAL_FRAGMENTS,
12    MAX_SCHEMA_REMOVALS, MAX_SCHEMA_TYPE_DEPTH, NamedTypeFragment, RelationSourceKey,
13    ScalarLiteral, SchemaContractError, SchemaFragment, SchemaProposalDigest, SchemaSubmissionKey,
14    SourceCheckExpr, SourceCheckInstruction, TargetDatabaseIdentity, TargetStoreIdentity,
15    TypeSourceKey, check_len, encode_schema_fragment, encode_schema_proposal,
16};
17
18/// Sole maintained proposal contract version.
19#[derive(
20    CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
21)]
22#[repr(transparent)]
23#[serde(transparent)]
24pub struct ProposalContractVersion(u16);
25
26impl ProposalContractVersion {
27    /// Current pre-1.0 hard-cut proposal contract version.
28    pub const CURRENT: Self = Self(1);
29
30    /// Construct a version token for decoding and incompatibility tests.
31    #[must_use]
32    pub const fn from_raw(value: u16) -> Self {
33        Self(value)
34    }
35
36    /// Return the raw version value.
37    #[must_use]
38    pub const fn get(self) -> u16 {
39        self.0
40    }
41}
42
43/// Feature required by one proposal.
44#[derive(
45    CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
46)]
47#[repr(transparent)]
48#[serde(transparent)]
49pub struct SchemaCapability(u16);
50
51impl SchemaCapability {
52    /// Exact composite record and enum contracts.
53    pub const EXACT_COMPOSITE_TYPES: Self = Self(1);
54    /// Accepted check constraints.
55    pub const ACCEPTED_CHECKS: Self = Self(2);
56    /// Secondary indexes.
57    pub const SECONDARY_INDEXES: Self = Self(3);
58    /// Restrictive relations.
59    pub const RESTRICTIVE_RELATIONS: Self = Self(4);
60    /// Accepted database defaults.
61    pub const INSERT_DEFAULTS: Self = Self(5);
62    /// Generated values.
63    pub const GENERATED_VALUES: Self = Self(6);
64    /// Managed created/updated timestamps.
65    pub const MANAGED_TIMESTAMPS: Self = Self(7);
66
67    /// Construct a raw token for incompatibility testing and transport.
68    #[must_use]
69    pub const fn from_raw(value: u16) -> Self {
70        Self(value)
71    }
72
73    /// Return the raw capability number.
74    #[must_use]
75    pub const fn get(self) -> u16 {
76        self.0
77    }
78
79    const fn is_supported(self) -> bool {
80        matches!(self.0, 1..=7)
81    }
82}
83
84/// Expected accepted-schema head used for optimistic application.
85#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
86pub enum ExpectedAcceptedHead {
87    /// The target database has no accepted schema.
88    Empty,
89    /// The target must match this exact accepted head.
90    Exact {
91        /// Nonzero accepted-schema revision.
92        revision: u64,
93        /// Opaque accepted-schema fingerprint.
94        fingerprint: crate::ExpectedSchemaFingerprint,
95    },
96}
97
98impl ExpectedAcceptedHead {
99    const fn validate(&self) -> Result<(), SchemaContractError> {
100        match self {
101            Self::Exact { revision: 0, .. } => Err(SchemaContractError::InvalidReferenceList),
102            Self::Empty | Self::Exact { .. } => Ok(()),
103        }
104    }
105}
106
107/// Explicit entity-to-store routing in the target database.
108#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
109pub struct EntityStoreAssignment {
110    entity: EntitySourceKey,
111    store: TargetStoreIdentity,
112}
113
114impl EntityStoreAssignment {
115    /// Construct one opaque routing assignment.
116    #[must_use]
117    pub const fn new(entity: EntitySourceKey, store: TargetStoreIdentity) -> Self {
118        Self { entity, store }
119    }
120
121    /// Borrow the routed entity source key.
122    #[must_use]
123    pub const fn entity(&self) -> &EntitySourceKey {
124        &self.entity
125    }
126
127    /// Return the opaque target-store identity.
128    #[must_use]
129    pub const fn store(&self) -> TargetStoreIdentity {
130        self.store
131    }
132}
133
134/// Explicit hard-cut removal operation.
135#[derive(
136    CandidType, Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
137)]
138pub enum SchemaRemoval {
139    /// Remove an entity.
140    Entity(EntitySourceKey),
141    /// Remove one field from an entity.
142    Field {
143        /// Owning entity.
144        entity: EntitySourceKey,
145        /// Field identity.
146        field: FieldSourceKey,
147    },
148    /// Remove a named type.
149    Type(TypeSourceKey),
150    /// Remove one accepted constraint.
151    Constraint {
152        /// Owning entity.
153        entity: EntitySourceKey,
154        /// Constraint identity.
155        constraint: ConstraintSourceKey,
156    },
157    /// Remove one index.
158    Index {
159        /// Owning entity.
160        entity: EntitySourceKey,
161        /// Index identity.
162        index: IndexSourceKey,
163    },
164    /// Remove one relation.
165    Relation {
166        /// Owning entity.
167        entity: EntitySourceKey,
168        /// Relation identity.
169        relation: RelationSourceKey,
170    },
171}
172
173/// Canonical current-form database-scoped proposal.
174#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
175pub struct SchemaProposal {
176    version: ProposalContractVersion,
177    capabilities: Vec<SchemaCapability>,
178    target_database: TargetDatabaseIdentity,
179    submission_key: SchemaSubmissionKey,
180    expected_head: ExpectedAcceptedHead,
181    fragments: Vec<SchemaFragment>,
182    assignments: Vec<EntityStoreAssignment>,
183    removals: Vec<SchemaRemoval>,
184}
185
186impl SchemaProposal {
187    /// Compose the public transport form from already selected fragments.
188    ///
189    /// This validates contract-local closure only. IcyDB still treats the
190    /// result as untrusted and resolves target ownership, accepted references,
191    /// capabilities, and catalog-native mutation semantics during application.
192    ///
193    /// # Errors
194    ///
195    /// Returns a typed contract error for bounds, duplicate definitions,
196    /// ambiguous routing, removal conflicts, or malformed nested data.
197    #[expect(
198        clippy::too_many_lines,
199        reason = "composition validates and canonicalizes one atomic public envelope"
200    )]
201    pub fn try_compose(
202        mut capabilities: Vec<SchemaCapability>,
203        target_database: TargetDatabaseIdentity,
204        submission_key: SchemaSubmissionKey,
205        expected_head: ExpectedAcceptedHead,
206        mut fragments: Vec<SchemaFragment>,
207        mut assignments: Vec<EntityStoreAssignment>,
208        mut removals: Vec<SchemaRemoval>,
209    ) -> Result<Self, SchemaContractError> {
210        check_len(
211            "proposal capabilities",
212            capabilities.len(),
213            MAX_SCHEMA_CAPABILITIES,
214        )?;
215        check_len(
216            "proposal fragments",
217            fragments.len(),
218            MAX_SCHEMA_PROPOSAL_FRAGMENTS,
219        )?;
220        check_len(
221            "proposal assignments",
222            assignments.len(),
223            MAX_SCHEMA_ASSIGNMENTS,
224        )?;
225        check_len("proposal removals", removals.len(), MAX_SCHEMA_REMOVALS)?;
226        expected_head.validate()?;
227        capabilities.sort_unstable();
228        ensure_no_adjacent_duplicates(&capabilities)?;
229        if capabilities
230            .iter()
231            .any(|capability| !capability.is_supported())
232        {
233            return Err(SchemaContractError::UnsupportedCapability);
234        }
235        for fragment in &fragments {
236            fragment.validate()?;
237        }
238        let mut keyed_fragments = fragments
239            .into_iter()
240            .map(|fragment| encode_schema_fragment(&fragment).map(|bytes| (bytes, fragment)))
241            .collect::<Result<Vec<_>, _>>()?;
242        keyed_fragments.sort_by(|left, right| left.0.cmp(&right.0));
243        fragments = keyed_fragments
244            .into_iter()
245            .map(|(_, fragment)| fragment)
246            .collect();
247        assignments.sort_by(|left, right| left.entity.cmp(&right.entity));
248        ensure_no_adjacent_duplicates_by(&assignments, |assignment| &assignment.entity)?;
249        removals.sort();
250        ensure_no_adjacent_duplicates(&removals)?;
251
252        let mut entity_definitions = BTreeMap::new();
253        let mut type_definitions = BTreeMap::new();
254        let mut field_definitions = BTreeSet::new();
255        let mut constraint_definitions = BTreeSet::new();
256        let mut index_definitions = BTreeSet::new();
257        let mut relation_definitions = BTreeSet::new();
258        let mut entity_names = BTreeSet::new();
259        let mut type_names = BTreeSet::new();
260        for fragment in &fragments {
261            for entity in fragment.entities() {
262                if entity_definitions
263                    .insert(entity.source_key().clone(), entity)
264                    .is_some()
265                {
266                    return Err(SchemaContractError::DuplicateSourceKey);
267                }
268                if !entity_names.insert(entity.name()) {
269                    return Err(SchemaContractError::DuplicateEditableName);
270                }
271                for field in entity.fields() {
272                    field_definitions
273                        .insert((entity.source_key().clone(), field.source_key().clone()));
274                }
275                for constraint in entity.constraints() {
276                    constraint_definitions
277                        .insert((entity.source_key().clone(), constraint.source_key().clone()));
278                }
279                for index in entity.indexes() {
280                    index_definitions
281                        .insert((entity.source_key().clone(), index.source_key().clone()));
282                }
283                for relation in entity.relations() {
284                    relation_definitions
285                        .insert((entity.source_key().clone(), relation.source_key().clone()));
286                }
287            }
288            for r#type in fragment.types() {
289                if type_definitions
290                    .insert(r#type.source_key().clone(), r#type)
291                    .is_some()
292                {
293                    return Err(SchemaContractError::DuplicateSourceKey);
294                }
295                if !type_names.insert(r#type.name()) {
296                    return Err(SchemaContractError::DuplicateEditableName);
297                }
298            }
299        }
300        for assignment in &assignments {
301            if !entity_definitions.contains_key(assignment.entity()) {
302                return Err(SchemaContractError::InvalidReferenceList);
303            }
304        }
305        if assignments.len() != entity_definitions.len() {
306            return Err(SchemaContractError::MissingEntityStoreAssignment);
307        }
308        for removal in &removals {
309            let collides = match removal {
310                SchemaRemoval::Entity(entity) => entity_definitions.contains_key(entity),
311                SchemaRemoval::Field { entity, field } => {
312                    field_definitions.contains(&(entity.clone(), field.clone()))
313                }
314                SchemaRemoval::Type(r#type) => type_definitions.contains_key(r#type),
315                SchemaRemoval::Constraint { entity, constraint } => {
316                    constraint_definitions.contains(&(entity.clone(), constraint.clone()))
317                }
318                SchemaRemoval::Index { entity, index } => {
319                    index_definitions.contains(&(entity.clone(), index.clone()))
320                }
321                SchemaRemoval::Relation { entity, relation } => {
322                    relation_definitions.contains(&(entity.clone(), relation.clone()))
323                }
324            };
325            if collides {
326                return Err(SchemaContractError::DefinitionRemovalConflict);
327            }
328        }
329        validate_proposal_closure(
330            &expected_head,
331            &entity_definitions,
332            &type_definitions,
333            &removals,
334        )?;
335
336        Ok(Self {
337            version: ProposalContractVersion::CURRENT,
338            capabilities,
339            target_database,
340            submission_key,
341            expected_head,
342            fragments,
343            assignments,
344            removals,
345        })
346    }
347
348    /// Return the contract version.
349    #[must_use]
350    pub const fn version(&self) -> ProposalContractVersion {
351        self.version
352    }
353
354    /// Borrow required capabilities in canonical order.
355    #[must_use]
356    pub fn capabilities(&self) -> &[SchemaCapability] {
357        &self.capabilities
358    }
359
360    /// Return the target database identity.
361    #[must_use]
362    pub const fn target_database(&self) -> TargetDatabaseIdentity {
363        self.target_database
364    }
365
366    /// Borrow the submission key.
367    #[must_use]
368    pub const fn submission_key(&self) -> &SchemaSubmissionKey {
369        &self.submission_key
370    }
371
372    /// Borrow the optimistic accepted-head condition.
373    #[must_use]
374    pub const fn expected_head(&self) -> &ExpectedAcceptedHead {
375        &self.expected_head
376    }
377
378    /// Borrow reusable fragments.
379    #[must_use]
380    pub fn fragments(&self) -> &[SchemaFragment] {
381        &self.fragments
382    }
383
384    /// Borrow canonical entity-to-store assignments.
385    #[must_use]
386    pub fn assignments(&self) -> &[EntityStoreAssignment] {
387        &self.assignments
388    }
389
390    /// Borrow explicit removals.
391    #[must_use]
392    pub fn removals(&self) -> &[SchemaRemoval] {
393        &self.removals
394    }
395
396    /// Compute the canonical proposal digest.
397    ///
398    /// # Errors
399    ///
400    /// Returns a typed encoding error if the proposal no longer satisfies the
401    /// current bounded contract.
402    pub fn digest(&self) -> Result<SchemaProposalDigest, SchemaContractError> {
403        let bytes = encode_schema_proposal(self)?;
404        let digest: [u8; 32] = Sha256::digest(bytes).into();
405        Ok(SchemaProposalDigest::from_bytes(digest))
406    }
407
408    pub(crate) fn validate_current(&self) -> Result<(), SchemaContractError> {
409        if self.version != ProposalContractVersion::CURRENT {
410            return Err(SchemaContractError::UnsupportedVersion {
411                found: self.version.get(),
412                supported: ProposalContractVersion::CURRENT.get(),
413            });
414        }
415        let rebuilt = Self::try_compose(
416            self.capabilities.clone(),
417            self.target_database,
418            self.submission_key.clone(),
419            self.expected_head.clone(),
420            self.fragments.clone(),
421            self.assignments.clone(),
422            self.removals.clone(),
423        )?;
424        if rebuilt != *self {
425            return Err(SchemaContractError::NonCanonical);
426        }
427        Ok(())
428    }
429}
430
431#[derive(Default)]
432struct ProposalReferences {
433    types: BTreeSet<TypeSourceKey>,
434    relation_entities: BTreeSet<EntitySourceKey>,
435    relation_fields: BTreeSet<(EntitySourceKey, FieldSourceKey)>,
436}
437
438fn validate_proposal_closure(
439    expected_head: &ExpectedAcceptedHead,
440    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
441    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
442    removals: &[SchemaRemoval],
443) -> Result<(), SchemaContractError> {
444    validate_local_type_graph(types)?;
445    let mut references = ProposalReferences::default();
446    for entity in entities.values() {
447        collect_entity_references(entity, types, &mut references)?;
448        validate_local_relation_targets(entity, entities)?;
449    }
450    for r#type in types.values() {
451        collect_named_type_references(r#type, &mut references);
452    }
453    for removal in removals {
454        let removes_reference = match removal {
455            SchemaRemoval::Entity(entity) => references.relation_entities.contains(entity),
456            SchemaRemoval::Field { entity, field } => references
457                .relation_fields
458                .contains(&(entity.clone(), field.clone())),
459            SchemaRemoval::Type(r#type) => references.types.contains(r#type),
460            SchemaRemoval::Constraint { .. }
461            | SchemaRemoval::Index { .. }
462            | SchemaRemoval::Relation { .. } => false,
463        };
464        if removes_reference {
465            return Err(SchemaContractError::RemovedReference);
466        }
467    }
468    if matches!(expected_head, ExpectedAcceptedHead::Empty)
469        && (references
470            .types
471            .iter()
472            .any(|reference| !types.contains_key(reference))
473            || references
474                .relation_entities
475                .iter()
476                .any(|reference| !entities.contains_key(reference)))
477    {
478        return Err(SchemaContractError::InvalidLocalReference);
479    }
480    Ok(())
481}
482
483fn collect_entity_references(
484    entity: &EntityFragment,
485    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
486    references: &mut ProposalReferences,
487) -> Result<(), SchemaContractError> {
488    for field in entity.fields() {
489        collect_field_references(field, types, references)?;
490    }
491    for relation in entity.relations() {
492        references
493            .relation_entities
494            .insert(relation.target_entity().clone());
495        references.relation_fields.extend(
496            relation
497                .target_fields()
498                .iter()
499                .cloned()
500                .map(|field| (relation.target_entity().clone(), field)),
501        );
502    }
503    for index in entity.indexes() {
504        if let Some(predicate) = index.predicate() {
505            collect_expression_enum_references(predicate, types, references)?;
506        }
507    }
508    for constraint in entity.constraints() {
509        collect_expression_enum_references(constraint.expression(), types, references)?;
510    }
511    Ok(())
512}
513
514fn collect_named_type_references(r#type: &NamedTypeFragment, references: &mut ProposalReferences) {
515    match r#type {
516        NamedTypeFragment::Record(record) => {
517            for field in record.fields() {
518                collect_field_type_reference(field.field_type(), references);
519            }
520        }
521        NamedTypeFragment::Enum(r#enum) => {
522            for variant in r#enum.variants() {
523                if let Some(payload) = variant.payload() {
524                    collect_field_type_reference(payload, references);
525                }
526            }
527        }
528        NamedTypeFragment::Newtype { inner, .. }
529        | NamedTypeFragment::List { item: inner, .. }
530        | NamedTypeFragment::Set { item: inner, .. } => {
531            collect_field_type_reference(inner, references);
532        }
533        NamedTypeFragment::Map { key, value, .. } => {
534            collect_field_type_reference(key, references);
535            collect_field_type_reference(value, references);
536        }
537        NamedTypeFragment::Tuple { members, .. } => {
538            for member in members {
539                collect_field_type_reference(member.field_type(), references);
540            }
541        }
542    }
543}
544
545fn collect_field_references(
546    field: &FieldFragment,
547    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
548    references: &mut ProposalReferences,
549) -> Result<(), SchemaContractError> {
550    collect_field_type_reference(field.field_type(), references);
551    if let crate::FieldInsertPolicy::Default(ScalarLiteral::EnumUnit { enum_type, variant }) =
552        field.insert_policy()
553    {
554        let FieldType::Named(field_type) = field.field_type() else {
555            return Err(SchemaContractError::LiteralTypeMismatch);
556        };
557        if field_type != enum_type {
558            return Err(SchemaContractError::LiteralTypeMismatch);
559        }
560        collect_enum_literal_reference(enum_type, variant, types, references)?;
561    }
562    Ok(())
563}
564
565fn collect_field_type_reference(field_type: &FieldType, references: &mut ProposalReferences) {
566    match field_type {
567        FieldType::List(item) => collect_field_type_reference(item, references),
568        FieldType::Named(reference) => {
569            references.types.insert(reference.clone());
570        }
571        FieldType::Scalar(_) => {}
572    }
573}
574
575fn collect_expression_enum_references(
576    expression: &SourceCheckExpr,
577    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
578    references: &mut ProposalReferences,
579) -> Result<(), SchemaContractError> {
580    for instruction in expression.instructions() {
581        if let SourceCheckInstruction::Literal(ScalarLiteral::EnumUnit { enum_type, variant }) =
582            instruction
583        {
584            collect_enum_literal_reference(enum_type, variant, types, references)?;
585        }
586    }
587    Ok(())
588}
589
590fn collect_enum_literal_reference(
591    enum_type: &TypeSourceKey,
592    variant: &TypeSourceKey,
593    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
594    references: &mut ProposalReferences,
595) -> Result<(), SchemaContractError> {
596    references.types.insert(enum_type.clone());
597    let Some(local) = types.get(enum_type) else {
598        return Ok(());
599    };
600    let NamedTypeFragment::Enum(local) = local else {
601        return Err(SchemaContractError::InvalidEnumLiteral);
602    };
603    if local
604        .variants()
605        .iter()
606        .all(|candidate| candidate.source_key() != variant)
607    {
608        return Err(SchemaContractError::InvalidEnumLiteral);
609    }
610    Ok(())
611}
612
613fn validate_local_relation_targets(
614    source: &EntityFragment,
615    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
616) -> Result<(), SchemaContractError> {
617    for relation in source.relations() {
618        let Some(target) = entities.get(relation.target_entity()) else {
619            continue;
620        };
621        for (source_key, target_key) in relation.local_fields().iter().zip(relation.target_fields())
622        {
623            let source_field = source
624                .fields()
625                .iter()
626                .find(|field| field.source_key() == source_key)
627                .ok_or(SchemaContractError::InvalidLocalReference)?;
628            let target_field = target
629                .fields()
630                .iter()
631                .find(|field| field.source_key() == target_key)
632                .ok_or(SchemaContractError::InvalidLocalReference)?;
633            let source_type = match source_field.field_type() {
634                FieldType::List(item) => item.as_ref(),
635                field_type => field_type,
636            };
637            if source_type != target_field.field_type() {
638                return Err(SchemaContractError::RelationTypeMismatch);
639            }
640        }
641    }
642    Ok(())
643}
644
645fn validate_local_type_graph(
646    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
647) -> Result<(), SchemaContractError> {
648    let mut active = BTreeSet::new();
649    let mut depths = BTreeMap::new();
650    for source_key in types.keys() {
651        type_depth(source_key, types, &mut active, &mut depths)?;
652    }
653    Ok(())
654}
655
656fn type_depth(
657    source_key: &TypeSourceKey,
658    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
659    active: &mut BTreeSet<TypeSourceKey>,
660    depths: &mut BTreeMap<TypeSourceKey, usize>,
661) -> Result<usize, SchemaContractError> {
662    if let Some(depth) = depths.get(source_key) {
663        return Ok(*depth);
664    }
665    if !active.insert(source_key.clone()) || active.len() > MAX_SCHEMA_TYPE_DEPTH {
666        return Err(SchemaContractError::InvalidNamedTypeGraph);
667    }
668    let Some(r#type) = types.get(source_key) else {
669        active.remove(source_key);
670        return Ok(0);
671    };
672    let mut max_child_depth = 0usize;
673    for reference in direct_named_type_references(r#type) {
674        if types.contains_key(reference) {
675            max_child_depth = max_child_depth.max(type_depth(reference, types, active, depths)?);
676        }
677    }
678    active.remove(source_key);
679    let depth = max_child_depth
680        .checked_add(1)
681        .ok_or(SchemaContractError::InvalidNamedTypeGraph)?;
682    if depth > MAX_SCHEMA_TYPE_DEPTH {
683        return Err(SchemaContractError::InvalidNamedTypeGraph);
684    }
685    depths.insert(source_key.clone(), depth);
686    Ok(depth)
687}
688
689fn direct_named_type_references(r#type: &NamedTypeFragment) -> Vec<&TypeSourceKey> {
690    let mut references = Vec::new();
691    match r#type {
692        NamedTypeFragment::Record(record) => {
693            for field in record.fields() {
694                collect_direct_named_type_references(field.field_type(), &mut references);
695            }
696        }
697        NamedTypeFragment::Enum(r#enum) => {
698            for variant in r#enum.variants() {
699                if let Some(payload) = variant.payload() {
700                    collect_direct_named_type_references(payload, &mut references);
701                }
702            }
703        }
704        NamedTypeFragment::Newtype { inner, .. }
705        | NamedTypeFragment::List { item: inner, .. }
706        | NamedTypeFragment::Set { item: inner, .. } => {
707            collect_direct_named_type_references(inner, &mut references);
708        }
709        NamedTypeFragment::Map { key, value, .. } => {
710            collect_direct_named_type_references(key, &mut references);
711            collect_direct_named_type_references(value, &mut references);
712        }
713        NamedTypeFragment::Tuple { members, .. } => {
714            for member in members {
715                collect_direct_named_type_references(member.field_type(), &mut references);
716            }
717        }
718    }
719    references
720}
721
722fn collect_direct_named_type_references<'field>(
723    field_type: &'field FieldType,
724    references: &mut Vec<&'field TypeSourceKey>,
725) {
726    match field_type {
727        FieldType::List(item) => collect_direct_named_type_references(item, references),
728        FieldType::Named(reference) => references.push(reference),
729        FieldType::Scalar(_) => {}
730    }
731}
732
733fn ensure_no_adjacent_duplicates<T>(values: &[T]) -> Result<(), SchemaContractError>
734where
735    T: Eq,
736{
737    if values.windows(2).any(|pair| pair[0] == pair[1]) {
738        return Err(SchemaContractError::DuplicateSourceKey);
739    }
740    Ok(())
741}
742
743fn ensure_no_adjacent_duplicates_by<T, K>(
744    values: &[T],
745    key: impl Fn(&T) -> &K,
746) -> Result<(), SchemaContractError>
747where
748    K: Eq,
749{
750    if values.windows(2).any(|pair| key(&pair[0]) == key(&pair[1])) {
751        return Err(SchemaContractError::DuplicateSourceKey);
752    }
753    Ok(())
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759    use crate::decode_schema_proposal;
760
761    fn empty_proposal() -> SchemaProposal {
762        SchemaProposal::try_compose(
763            Vec::new(),
764            TargetDatabaseIdentity::from_bytes([1; 32]),
765            SchemaSubmissionKey::try_new("proposal-version-test")
766                .expect("submission key should admit"),
767            ExpectedAcceptedHead::Empty,
768            Vec::new(),
769            Vec::new(),
770            Vec::new(),
771        )
772        .expect("empty proposal should compose")
773    }
774
775    #[test]
776    fn decoded_future_contract_version_fails_typed() {
777        let mut proposal = empty_proposal();
778        proposal.version = ProposalContractVersion::from_raw(2);
779        let bytes = candid::encode_one(proposal).expect("raw future proposal should encode");
780
781        assert_eq!(
782            decode_schema_proposal(&bytes),
783            Err(SchemaContractError::UnsupportedVersion {
784                found: 2,
785                supported: 1,
786            }),
787        );
788    }
789
790    #[test]
791    fn decoded_unknown_capability_fails_typed() {
792        let mut proposal = empty_proposal();
793        proposal.capabilities = vec![SchemaCapability::from_raw(u16::MAX)];
794        let bytes = candid::encode_one(proposal).expect("raw proposal should encode");
795
796        assert_eq!(
797            decode_schema_proposal(&bytes),
798            Err(SchemaContractError::UnsupportedCapability),
799        );
800    }
801}