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    ConstraintFragmentKind, ConstraintSourceKey, EntityFragment, EntitySourceKey, FieldFragment,
11    FieldSourceKey, FieldType, IndexSourceKey, MAX_SCHEMA_ASSIGNMENTS, MAX_SCHEMA_CAPABILITIES,
12    MAX_SCHEMA_PROPOSAL_FRAGMENTS, MAX_SCHEMA_REMOVALS, NamedTypeFragment, RelationSourceKey,
13    ScalarLiteral, ScalarType, SchemaContractError, SchemaFragment, SchemaMigrationPlan,
14    SchemaMigrationRename, SchemaMigrationTransform, SchemaProposalDigest, SchemaSubmissionKey,
15    SourceCheckExpr, SourceCheckInstruction, SourceRuleOperation, TargetDatabaseIdentity,
16    TargetStoreIdentity, TargetedRuleFragment, TypeSourceKey, check_len, encode_schema_fragment,
17    encode_schema_proposal,
18};
19
20/// Sole maintained proposal contract version.
21#[derive(
22    CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
23)]
24#[repr(transparent)]
25#[serde(transparent)]
26pub struct ProposalContractVersion(u16);
27
28impl ProposalContractVersion {
29    /// Current pre-1.0 hard-cut proposal contract version.
30    pub const CURRENT: Self = Self(2);
31
32    /// Construct a version token for decoding and incompatibility tests.
33    #[must_use]
34    pub const fn from_raw(value: u16) -> Self {
35        Self(value)
36    }
37
38    /// Return the raw version value.
39    #[must_use]
40    pub const fn get(self) -> u16 {
41        self.0
42    }
43}
44
45/// Feature required by one proposal.
46#[derive(
47    CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
48)]
49#[repr(transparent)]
50#[serde(transparent)]
51pub struct SchemaCapability(u16);
52
53impl SchemaCapability {
54    /// Exact composite record and enum contracts.
55    pub const EXACT_COMPOSITE_TYPES: Self = Self(1);
56    /// Accepted row-local constraints.
57    pub const ACCEPTED_CHECKS: Self = Self(2);
58    /// Secondary indexes.
59    pub const SECONDARY_INDEXES: Self = Self(3);
60    /// Restrictive relations.
61    pub const RESTRICTIVE_RELATIONS: Self = Self(4);
62    /// Accepted database defaults.
63    pub const INSERT_DEFAULTS: Self = Self(5);
64    /// Generated values.
65    pub const GENERATED_VALUES: Self = Self(6);
66    /// Managed created/updated timestamps.
67    pub const MANAGED_TIMESTAMPS: Self = Self(7);
68    /// Explicit versioned source migration declarations.
69    pub const VERSIONED_MIGRATIONS: Self = Self(8);
70
71    /// Construct a raw token for incompatibility testing and transport.
72    #[must_use]
73    pub const fn from_raw(value: u16) -> Self {
74        Self(value)
75    }
76
77    /// Return the raw capability number.
78    #[must_use]
79    pub const fn get(self) -> u16 {
80        self.0
81    }
82
83    const fn is_supported(self) -> bool {
84        matches!(self.0, 1..=8)
85    }
86}
87
88/// Expected accepted-schema head used for optimistic application.
89#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
90pub enum ExpectedAcceptedHead {
91    /// The target database has no accepted schema.
92    Empty,
93    /// The target must match this exact accepted head.
94    Exact {
95        /// Nonzero accepted-schema revision.
96        revision: u64,
97        /// Opaque accepted-schema fingerprint.
98        fingerprint: crate::ExpectedSchemaFingerprint,
99    },
100}
101
102impl ExpectedAcceptedHead {
103    const fn validate(&self) -> Result<(), SchemaContractError> {
104        match self {
105            Self::Exact { revision: 0, .. } => Err(SchemaContractError::InvalidReferenceList),
106            Self::Empty | Self::Exact { .. } => Ok(()),
107        }
108    }
109}
110
111/// Explicit entity-to-store routing in the target database.
112#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
113pub struct EntityStoreAssignment {
114    entity: EntitySourceKey,
115    store: TargetStoreIdentity,
116}
117
118impl EntityStoreAssignment {
119    /// Construct one opaque routing assignment.
120    #[must_use]
121    pub const fn new(entity: EntitySourceKey, store: TargetStoreIdentity) -> Self {
122        Self { entity, store }
123    }
124
125    /// Borrow the routed entity source key.
126    #[must_use]
127    pub const fn entity(&self) -> &EntitySourceKey {
128        &self.entity
129    }
130
131    /// Return the opaque target-store identity.
132    #[must_use]
133    pub const fn store(&self) -> TargetStoreIdentity {
134        self.store
135    }
136}
137
138/// Explicit hard-cut removal operation.
139#[derive(
140    CandidType, Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
141)]
142pub enum SchemaRemoval {
143    /// Remove an entity.
144    Entity(EntitySourceKey),
145    /// Remove one field from an entity.
146    Field {
147        /// Owning entity.
148        entity: EntitySourceKey,
149        /// Field identity.
150        field: FieldSourceKey,
151    },
152    /// Remove a named type.
153    Type(TypeSourceKey),
154    /// Remove one accepted constraint.
155    Constraint {
156        /// Owning entity.
157        entity: EntitySourceKey,
158        /// Constraint identity.
159        constraint: ConstraintSourceKey,
160    },
161    /// Remove one index.
162    Index {
163        /// Owning entity.
164        entity: EntitySourceKey,
165        /// Index identity.
166        index: IndexSourceKey,
167    },
168    /// Remove one relation.
169    Relation {
170        /// Owning entity.
171        entity: EntitySourceKey,
172        /// Relation identity.
173        relation: RelationSourceKey,
174    },
175}
176
177/// Canonical current-form database-scoped proposal.
178#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
179pub struct SchemaProposal {
180    version: ProposalContractVersion,
181    capabilities: Vec<SchemaCapability>,
182    target_database: TargetDatabaseIdentity,
183    submission_key: SchemaSubmissionKey,
184    expected_head: ExpectedAcceptedHead,
185    fragments: Vec<SchemaFragment>,
186    assignments: Vec<EntityStoreAssignment>,
187    removals: Vec<SchemaRemoval>,
188    migration: Option<SchemaMigrationPlan>,
189}
190
191impl SchemaProposal {
192    /// Compose the public transport form from already selected fragments.
193    ///
194    /// This validates contract-local closure only. IcyDB still treats the
195    /// result as untrusted and resolves target ownership, accepted references,
196    /// capabilities, and catalog-native mutation semantics during application.
197    ///
198    /// # Errors
199    ///
200    /// Returns a typed contract error for bounds, duplicate definitions,
201    /// ambiguous routing, removal conflicts, or malformed nested data.
202    #[expect(
203        clippy::too_many_arguments,
204        clippy::too_many_lines,
205        reason = "composition receives and validates one complete atomic public envelope"
206    )]
207    pub fn try_compose(
208        mut capabilities: Vec<SchemaCapability>,
209        target_database: TargetDatabaseIdentity,
210        submission_key: SchemaSubmissionKey,
211        expected_head: ExpectedAcceptedHead,
212        mut fragments: Vec<SchemaFragment>,
213        mut assignments: Vec<EntityStoreAssignment>,
214        mut removals: Vec<SchemaRemoval>,
215        migration: Option<SchemaMigrationPlan>,
216    ) -> Result<Self, SchemaContractError> {
217        check_len(
218            "proposal capabilities",
219            capabilities.len(),
220            MAX_SCHEMA_CAPABILITIES,
221        )?;
222        check_len(
223            "proposal fragments",
224            fragments.len(),
225            MAX_SCHEMA_PROPOSAL_FRAGMENTS,
226        )?;
227        check_len(
228            "proposal assignments",
229            assignments.len(),
230            MAX_SCHEMA_ASSIGNMENTS,
231        )?;
232        check_len("proposal removals", removals.len(), MAX_SCHEMA_REMOVALS)?;
233        expected_head.validate()?;
234        capabilities.sort_unstable();
235        ensure_no_adjacent_duplicates(&capabilities)?;
236        if capabilities
237            .iter()
238            .any(|capability| !capability.is_supported())
239        {
240            return Err(SchemaContractError::UnsupportedCapability);
241        }
242        let declares_migration_capability = capabilities
243            .binary_search(&SchemaCapability::VERSIONED_MIGRATIONS)
244            .is_ok();
245        if declares_migration_capability != migration.is_some() {
246            return Err(SchemaContractError::InvalidMigrationPlan);
247        }
248        if let Some(plan) = &migration {
249            plan.validate()?;
250        }
251        for fragment in &fragments {
252            fragment.validate()?;
253        }
254        let mut keyed_fragments = fragments
255            .into_iter()
256            .map(|fragment| encode_schema_fragment(&fragment).map(|bytes| (bytes, fragment)))
257            .collect::<Result<Vec<_>, _>>()?;
258        // Canonically equal fragment bytes describe equal fragments, while
259        // duplicate assignment/removal keys reject below; no stable tie is observable.
260        keyed_fragments.sort_unstable_by(|left, right| left.0.cmp(&right.0));
261        fragments = keyed_fragments
262            .into_iter()
263            .map(|(_, fragment)| fragment)
264            .collect();
265        assignments.sort_unstable_by(|left, right| left.entity.cmp(&right.entity));
266        ensure_no_adjacent_duplicates_by(&assignments, |assignment| &assignment.entity)?;
267        removals.sort_unstable();
268        ensure_no_adjacent_duplicates(&removals)?;
269
270        let mut entity_definitions = BTreeMap::new();
271        let mut type_definitions = BTreeMap::new();
272        let mut field_definitions = BTreeSet::new();
273        let mut constraint_definitions = BTreeSet::new();
274        let mut index_definitions = BTreeSet::new();
275        let mut relation_definitions = BTreeSet::new();
276        let mut entity_names = BTreeSet::new();
277        let mut type_names = BTreeSet::new();
278        for fragment in &fragments {
279            for entity in fragment.entities() {
280                if entity_definitions
281                    .insert(entity.source_key().clone(), entity)
282                    .is_some()
283                {
284                    return Err(SchemaContractError::DuplicateSourceKey);
285                }
286                if !entity_names.insert(entity.name()) {
287                    return Err(SchemaContractError::DuplicateName);
288                }
289                for field in entity.fields() {
290                    field_definitions
291                        .insert((entity.source_key().clone(), field.source_key().clone()));
292                }
293                for constraint in entity.constraints() {
294                    constraint_definitions
295                        .insert((entity.source_key().clone(), constraint.source_key().clone()));
296                }
297                for index in entity.indexes() {
298                    index_definitions
299                        .insert((entity.source_key().clone(), index.source_key().clone()));
300                }
301                for relation in entity.relations() {
302                    relation_definitions
303                        .insert((entity.source_key().clone(), relation.source_key().clone()));
304                }
305            }
306            for r#type in fragment.types() {
307                if type_definitions
308                    .insert(r#type.source_key().clone(), r#type)
309                    .is_some()
310                {
311                    return Err(SchemaContractError::DuplicateSourceKey);
312                }
313                if !type_names.insert(r#type.name()) {
314                    return Err(SchemaContractError::DuplicateName);
315                }
316            }
317        }
318        for assignment in &assignments {
319            if !entity_definitions.contains_key(assignment.entity()) {
320                return Err(SchemaContractError::InvalidReferenceList);
321            }
322        }
323        if assignments.len() != entity_definitions.len() {
324            return Err(SchemaContractError::MissingEntityStoreAssignment);
325        }
326        for removal in &removals {
327            let collides = match removal {
328                SchemaRemoval::Entity(entity) => entity_definitions.contains_key(entity),
329                SchemaRemoval::Field { entity, field } => {
330                    field_definitions.contains(&(entity.clone(), field.clone()))
331                }
332                SchemaRemoval::Type(r#type) => type_definitions.contains_key(r#type),
333                SchemaRemoval::Constraint { entity, constraint } => {
334                    constraint_definitions.contains(&(entity.clone(), constraint.clone()))
335                }
336                SchemaRemoval::Index { entity, index } => {
337                    index_definitions.contains(&(entity.clone(), index.clone()))
338                }
339                SchemaRemoval::Relation { entity, relation } => {
340                    relation_definitions.contains(&(entity.clone(), relation.clone()))
341                }
342            };
343            if collides {
344                return Err(SchemaContractError::DefinitionRemovalConflict);
345            }
346        }
347        validate_proposal_closure(
348            &expected_head,
349            &entity_definitions,
350            &type_definitions,
351            &removals,
352        )?;
353        if let Some(plan) = &migration {
354            validate_migration_plan(plan, &entity_definitions, &type_definitions)?;
355        }
356
357        Ok(Self {
358            version: ProposalContractVersion::CURRENT,
359            capabilities,
360            target_database,
361            submission_key,
362            expected_head,
363            fragments,
364            assignments,
365            removals,
366            migration,
367        })
368    }
369
370    /// Return the contract version.
371    #[must_use]
372    pub const fn version(&self) -> ProposalContractVersion {
373        self.version
374    }
375
376    /// Borrow required capabilities in canonical order.
377    #[must_use]
378    pub fn capabilities(&self) -> &[SchemaCapability] {
379        &self.capabilities
380    }
381
382    /// Return the target database identity.
383    #[must_use]
384    pub const fn target_database(&self) -> TargetDatabaseIdentity {
385        self.target_database
386    }
387
388    /// Borrow the submission key.
389    #[must_use]
390    pub const fn submission_key(&self) -> &SchemaSubmissionKey {
391        &self.submission_key
392    }
393
394    /// Borrow the optimistic accepted-head condition.
395    #[must_use]
396    pub const fn expected_head(&self) -> &ExpectedAcceptedHead {
397        &self.expected_head
398    }
399
400    /// Borrow reusable fragments.
401    #[must_use]
402    pub fn fragments(&self) -> &[SchemaFragment] {
403        &self.fragments
404    }
405
406    /// Borrow canonical entity-to-store assignments.
407    #[must_use]
408    pub fn assignments(&self) -> &[EntityStoreAssignment] {
409        &self.assignments
410    }
411
412    /// Borrow explicit removals.
413    #[must_use]
414    pub fn removals(&self) -> &[SchemaRemoval] {
415        &self.removals
416    }
417
418    /// Borrow the optional coordinated source migration plan.
419    #[must_use]
420    pub const fn migration(&self) -> Option<&SchemaMigrationPlan> {
421        self.migration.as_ref()
422    }
423
424    /// Compute the canonical proposal digest.
425    ///
426    /// # Errors
427    ///
428    /// Returns a typed encoding error if the proposal no longer satisfies the
429    /// current bounded contract.
430    pub fn digest(&self) -> Result<SchemaProposalDigest, SchemaContractError> {
431        let bytes = encode_schema_proposal(self)?;
432        let digest: [u8; 32] = Sha256::digest(bytes).into();
433        Ok(SchemaProposalDigest::from_bytes(digest))
434    }
435
436    pub(crate) fn validate_current(&self) -> Result<(), SchemaContractError> {
437        if self.version != ProposalContractVersion::CURRENT {
438            return Err(SchemaContractError::UnsupportedVersion {
439                found: self.version.get(),
440                supported: ProposalContractVersion::CURRENT.get(),
441            });
442        }
443        let rebuilt = Self::try_compose(
444            self.capabilities.clone(),
445            self.target_database,
446            self.submission_key.clone(),
447            self.expected_head.clone(),
448            self.fragments.clone(),
449            self.assignments.clone(),
450            self.removals.clone(),
451            self.migration.clone(),
452        )?;
453        if rebuilt != *self {
454            return Err(SchemaContractError::NonCanonical);
455        }
456        Ok(())
457    }
458}
459
460fn validate_migration_plan(
461    plan: &SchemaMigrationPlan,
462    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
463    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
464) -> Result<(), SchemaContractError> {
465    for transition in plan.transitions() {
466        let entity = entities
467            .get(transition.entity())
468            .copied()
469            .ok_or(SchemaContractError::InvalidMigrationReference)?;
470        if transition.from().get().checked_add(1) != Some(entity.version().get()) {
471            return Err(SchemaContractError::MigrationVersionGap);
472        }
473        for rename in transition.renames() {
474            validate_migration_rename_target(rename, transition, entity, types)?;
475        }
476        for transform in transition.transforms() {
477            validate_migration_transform_target(transform, entity, types)?;
478        }
479    }
480    validate_shared_type_transition_closure(plan, entities, types)?;
481    Ok(())
482}
483
484fn validate_shared_type_transition_closure(
485    plan: &SchemaMigrationPlan,
486    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
487    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
488) -> Result<(), SchemaContractError> {
489    for declaring_transition in plan.transitions() {
490        for rename in declaring_transition.renames() {
491            let Some(target_type) = shared_rename_target_type(declaring_transition, rename) else {
492                continue;
493            };
494            for (entity_source, entity) in entities {
495                if !entity_reaches_named_type(entity, &target_type, types) {
496                    continue;
497                }
498                let repeats_rename = plan.transitions().iter().any(|transition| {
499                    transition.entity() == entity_source && transition.renames().contains(rename)
500                });
501                if !repeats_rename {
502                    return Err(SchemaContractError::InvalidMigrationReference);
503                }
504            }
505        }
506    }
507    Ok(())
508}
509
510fn shared_rename_target_type(
511    transition: &crate::EntityMigration,
512    rename: &SchemaMigrationRename,
513) -> Option<TypeSourceKey> {
514    match rename {
515        SchemaMigrationRename::NamedType { to, .. } => Some(to.clone()),
516        SchemaMigrationRename::EnumVariant { named_type, .. }
517        | SchemaMigrationRename::RecordField { named_type, .. } => {
518            Some(migration_target_type(transition, named_type))
519        }
520        SchemaMigrationRename::Field { .. }
521        | SchemaMigrationRename::Relation { .. }
522        | SchemaMigrationRename::Constraint { .. }
523        | SchemaMigrationRename::Rule { .. } => None,
524    }
525}
526
527fn validate_migration_rename_target(
528    rename: &SchemaMigrationRename,
529    transition: &crate::EntityMigration,
530    entity: &EntityFragment,
531    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
532) -> Result<(), SchemaContractError> {
533    let valid = match rename {
534        SchemaMigrationRename::Field { to, .. } => {
535            entity.fields().iter().any(|field| field.source_key() == to)
536        }
537        SchemaMigrationRename::NamedType { to, .. } => entity_reaches_named_type(entity, to, types),
538        SchemaMigrationRename::EnumVariant { named_type, to, .. } => {
539            let target_type = migration_target_type(transition, named_type);
540            entity_reaches_named_type(entity, &target_type, types)
541                && matches!(
542                    types.get(&target_type),
543                    Some(NamedTypeFragment::Enum(target))
544                        if target.variants().iter().any(|variant| variant.source_key() == to)
545                )
546        }
547        SchemaMigrationRename::RecordField { named_type, to, .. } => {
548            let target_type = migration_target_type(transition, named_type);
549            entity_reaches_named_type(entity, &target_type, types)
550                && matches!(
551                    types.get(&target_type),
552                    Some(NamedTypeFragment::Record(target))
553                        if target.fields().iter().any(|field| field.source_key() == to)
554                )
555        }
556        SchemaMigrationRename::Relation { to, .. } => entity
557            .relations()
558            .iter()
559            .any(|relation| relation.source_key() == to),
560        SchemaMigrationRename::Constraint { to, .. } => entity
561            .constraints()
562            .iter()
563            .any(|constraint| constraint.source_key() == to),
564        SchemaMigrationRename::Rule { named_type, to, .. } => {
565            let target_type = migration_target_type(transition, named_type);
566            entity.constraints().iter().any(|constraint| {
567                matches!(
568                    constraint.kind(),
569                    ConstraintFragmentKind::TargetedRule(rule)
570                        if rule.target_type() == &target_type && rule.rule() == to
571                )
572            })
573        }
574    };
575    if valid {
576        Ok(())
577    } else {
578        Err(SchemaContractError::InvalidMigrationReference)
579    }
580}
581
582fn entity_reaches_named_type(
583    entity: &EntityFragment,
584    target: &TypeSourceKey,
585    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
586) -> bool {
587    let mut seen = BTreeSet::new();
588    entity
589        .fields()
590        .iter()
591        .any(|field| field_type_reaches_named_type(field.field_type(), target, types, &mut seen))
592        || entity.constraints().iter().any(|constraint| {
593            matches!(
594                constraint.kind(),
595                ConstraintFragmentKind::TargetedRule(rule) if rule.target_type() == target
596            )
597        })
598}
599
600fn field_type_reaches_named_type(
601    field_type: &FieldType,
602    target: &TypeSourceKey,
603    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
604    seen: &mut BTreeSet<TypeSourceKey>,
605) -> bool {
606    match field_type {
607        FieldType::Scalar(_) => false,
608        FieldType::List(inner) => field_type_reaches_named_type(inner, target, types, seen),
609        FieldType::Named(source) if source == target => true,
610        FieldType::Named(source) if !seen.insert(source.clone()) => false,
611        FieldType::Named(source) => match types.get(source) {
612            Some(NamedTypeFragment::Record(record)) => record.fields().iter().any(|field| {
613                field_type_reaches_named_type(field.field_type(), target, types, seen)
614            }),
615            Some(NamedTypeFragment::Enum(r#enum)) => r#enum.variants().iter().any(|variant| {
616                variant.payload().is_some_and(|payload| {
617                    field_type_reaches_named_type(payload, target, types, seen)
618                })
619            }),
620            Some(
621                NamedTypeFragment::Newtype { inner, .. }
622                | NamedTypeFragment::List { item: inner, .. }
623                | NamedTypeFragment::Set { item: inner, .. },
624            ) => field_type_reaches_named_type(inner, target, types, seen),
625            Some(NamedTypeFragment::Map { key, value, .. }) => {
626                field_type_reaches_named_type(key, target, types, seen)
627                    || field_type_reaches_named_type(value, target, types, seen)
628            }
629            Some(NamedTypeFragment::Tuple { members, .. }) => members.iter().any(|member| {
630                field_type_reaches_named_type(member.field_type(), target, types, seen)
631            }),
632            None => false,
633        },
634    }
635}
636
637fn migration_target_type(
638    transition: &crate::EntityMigration,
639    accepted_before: &TypeSourceKey,
640) -> TypeSourceKey {
641    transition
642        .renames()
643        .iter()
644        .find_map(|rename| match rename {
645            SchemaMigrationRename::NamedType { from, to } if from == accepted_before => {
646                Some(to.clone())
647            }
648            _ => None,
649        })
650        .unwrap_or_else(|| accepted_before.clone())
651}
652
653fn validate_migration_transform_target(
654    transform: &SchemaMigrationTransform,
655    entity: &EntityFragment,
656    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
657) -> Result<(), SchemaContractError> {
658    let target = entity
659        .fields()
660        .iter()
661        .find(|field| field.source_key() == transform.target())
662        .ok_or(SchemaContractError::InvalidMigrationReference)?;
663    match transform {
664        SchemaMigrationTransform::Fill { literal, .. }
665        | SchemaMigrationTransform::Coalesce { literal, .. } => {
666            validate_migration_literal_target(literal, target, types)
667        }
668        SchemaMigrationTransform::CheckedCast {
669            target: target_scalar,
670            ..
671        } if target.field_type() == &FieldType::Scalar(*target_scalar) => Ok(()),
672        SchemaMigrationTransform::Copy { .. } => Ok(()),
673        SchemaMigrationTransform::CheckedCast { .. } => {
674            Err(SchemaContractError::InvalidMigrationTransform)
675        }
676    }
677}
678
679fn validate_migration_literal_target(
680    literal: &ScalarLiteral,
681    target: &FieldFragment,
682    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
683) -> Result<(), SchemaContractError> {
684    let valid = match (target.field_type(), literal) {
685        (FieldType::Scalar(scalar), literal) => scalar.accepts_literal(literal),
686        (FieldType::Named(target_type), ScalarLiteral::EnumUnit { enum_type, variant })
687            if target_type == enum_type =>
688        {
689            matches!(
690                types.get(enum_type),
691                Some(NamedTypeFragment::Enum(target_enum))
692                    if target_enum
693                        .variants()
694                        .iter()
695                        .any(|candidate| candidate.source_key() == variant)
696            )
697        }
698        (FieldType::List(_) | FieldType::Named(_), _) => false,
699    };
700    if valid {
701        Ok(())
702    } else {
703        Err(SchemaContractError::LiteralTypeMismatch)
704    }
705}
706
707#[derive(Default)]
708struct ProposalReferences {
709    types: BTreeSet<TypeSourceKey>,
710    relation_entities: BTreeSet<EntitySourceKey>,
711    relation_fields: BTreeSet<(EntitySourceKey, FieldSourceKey)>,
712}
713
714fn validate_proposal_closure(
715    expected_head: &ExpectedAcceptedHead,
716    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
717    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
718    removals: &[SchemaRemoval],
719) -> Result<(), SchemaContractError> {
720    let mut references = ProposalReferences::default();
721    for entity in entities.values() {
722        collect_entity_references(entity, types, &mut references)?;
723        validate_local_relation_targets(entity, entities)?;
724    }
725    for r#type in types.values() {
726        collect_named_type_references(r#type, &mut references);
727    }
728    for removal in removals {
729        let removes_reference = match removal {
730            SchemaRemoval::Entity(entity) => references.relation_entities.contains(entity),
731            SchemaRemoval::Field { entity, field } => references
732                .relation_fields
733                .contains(&(entity.clone(), field.clone())),
734            SchemaRemoval::Type(r#type) => references.types.contains(r#type),
735            SchemaRemoval::Constraint { .. }
736            | SchemaRemoval::Index { .. }
737            | SchemaRemoval::Relation { .. } => false,
738        };
739        if removes_reference {
740            return Err(SchemaContractError::RemovedReference);
741        }
742    }
743    if matches!(expected_head, ExpectedAcceptedHead::Empty)
744        && (references
745            .types
746            .iter()
747            .any(|reference| !types.contains_key(reference))
748            || references
749                .relation_entities
750                .iter()
751                .any(|reference| !entities.contains_key(reference)))
752    {
753        return Err(SchemaContractError::InvalidLocalReference);
754    }
755    Ok(())
756}
757
758fn collect_entity_references(
759    entity: &EntityFragment,
760    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
761    references: &mut ProposalReferences,
762) -> Result<(), SchemaContractError> {
763    for field in entity.fields() {
764        collect_field_references(field, types, references)?;
765    }
766    for relation in entity.relations() {
767        references
768            .relation_entities
769            .insert(relation.target_entity().clone());
770        references.relation_fields.extend(
771            relation
772                .target_fields()
773                .iter()
774                .cloned()
775                .map(|field| (relation.target_entity().clone(), field)),
776        );
777    }
778    for index in entity.indexes() {
779        if let Some(predicate) = index.predicate() {
780            collect_expression_enum_references(predicate, types, references)?;
781        }
782    }
783    for constraint in entity.constraints() {
784        match constraint.kind() {
785            ConstraintFragmentKind::Check(expression) => {
786                collect_expression_enum_references(expression, types, references)?;
787            }
788            ConstraintFragmentKind::TargetedRule(rule) => {
789                references.types.insert(rule.target_type().clone());
790                validate_targeted_rule(entity, rule, types)?;
791            }
792        }
793    }
794    Ok(())
795}
796
797fn validate_targeted_rule(
798    entity: &EntityFragment,
799    rule: &TargetedRuleFragment,
800    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
801) -> Result<(), SchemaContractError> {
802    let root = entity
803        .fields()
804        .iter()
805        .find(|field| field.source_key() == rule.root())
806        .ok_or(SchemaContractError::InvalidLocalReference)?;
807    if types.contains_key(rule.target_type())
808        && !field_type_reaches_target(root.field_type(), rule.target_type(), types)
809    {
810        return Err(SchemaContractError::InvalidRuleTarget);
811    }
812    let Some(target) = types.get(rule.target_type()) else {
813        return Ok(());
814    };
815    let shape = resolve_rule_target_shape(target, types)?;
816    if operation_matches_target(rule.operation(), shape) {
817        Ok(())
818    } else {
819        Err(SchemaContractError::InvalidRuleTarget)
820    }
821}
822
823fn field_type_reaches_target(
824    root: &FieldType,
825    target: &TypeSourceKey,
826    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
827) -> bool {
828    let mut pending = vec![root];
829    let mut visited = BTreeSet::new();
830    while let Some(field_type) = pending.pop() {
831        match field_type {
832            FieldType::Scalar(_) => {}
833            FieldType::List(item) => pending.push(item),
834            FieldType::Named(source) => {
835                if source == target {
836                    return true;
837                }
838                if !visited.insert(source) {
839                    continue;
840                }
841                if let Some(definition) = types.get(source) {
842                    push_named_type_field_types(definition, &mut pending);
843                }
844            }
845        }
846    }
847    false
848}
849
850fn push_named_type_field_types<'types>(
851    r#type: &'types NamedTypeFragment,
852    pending: &mut Vec<&'types FieldType>,
853) {
854    match r#type {
855        NamedTypeFragment::Record(record) => {
856            pending.extend(
857                record
858                    .fields()
859                    .iter()
860                    .map(crate::RecordFieldFragment::field_type),
861            );
862        }
863        NamedTypeFragment::Enum(r#enum) => {
864            pending.extend(
865                r#enum
866                    .variants()
867                    .iter()
868                    .filter_map(|variant| variant.payload()),
869            );
870        }
871        NamedTypeFragment::Newtype { inner, .. }
872        | NamedTypeFragment::List { item: inner, .. }
873        | NamedTypeFragment::Set { item: inner, .. } => pending.push(inner),
874        NamedTypeFragment::Map { key, value, .. } => {
875            pending.push(key);
876            pending.push(value);
877        }
878        NamedTypeFragment::Tuple { members, .. } => {
879            pending.extend(members.iter().map(crate::TupleElementFragment::field_type));
880        }
881    }
882}
883
884#[derive(Clone, Copy)]
885enum RuleTargetShape {
886    Collection,
887    Scalar(ScalarType),
888}
889
890fn resolve_rule_target_shape(
891    target: &NamedTypeFragment,
892    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
893) -> Result<RuleTargetShape, SchemaContractError> {
894    let mut current = target;
895    let mut visited = BTreeSet::new();
896    loop {
897        if !visited.insert(current.source_key()) {
898            return Err(SchemaContractError::InvalidRuleTarget);
899        }
900        match current {
901            NamedTypeFragment::List { .. }
902            | NamedTypeFragment::Set { .. }
903            | NamedTypeFragment::Map { .. } => return Ok(RuleTargetShape::Collection),
904            NamedTypeFragment::Newtype { inner, .. } => match inner {
905                FieldType::Scalar(scalar) => return Ok(RuleTargetShape::Scalar(*scalar)),
906                FieldType::List(_) => return Ok(RuleTargetShape::Collection),
907                FieldType::Named(source) => {
908                    current = types
909                        .get(source)
910                        .copied()
911                        .ok_or(SchemaContractError::InvalidRuleTarget)?;
912                }
913            },
914            NamedTypeFragment::Record(_)
915            | NamedTypeFragment::Enum(_)
916            | NamedTypeFragment::Tuple { .. } => {
917                return Err(SchemaContractError::InvalidRuleTarget);
918            }
919        }
920    }
921}
922
923fn operation_matches_target(operation: &SourceRuleOperation, shape: RuleTargetShape) -> bool {
924    match (operation, shape) {
925        (
926            SourceRuleOperation::LengthRangeInclusive { .. },
927            RuleTargetShape::Collection
928            | RuleTargetShape::Scalar(ScalarType::Blob { .. } | ScalarType::Text { .. }),
929        ) => true,
930        (
931            SourceRuleOperation::NumericMaximumInclusive { value }
932            | SourceRuleOperation::NumericMinimumInclusive { value },
933            RuleTargetShape::Scalar(scalar),
934        ) => numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, value),
935        (
936            SourceRuleOperation::NumericRangeInclusive { min, max },
937            RuleTargetShape::Scalar(scalar),
938        ) => {
939            numeric_scalar(scalar)
940                && numeric_rule_literal_matches(scalar, min)
941                && numeric_rule_literal_matches(scalar, max)
942        }
943        (SourceRuleOperation::MultipleOf { divisor }, RuleTargetShape::Scalar(scalar)) => {
944            exact_numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, divisor)
945        }
946        _ => false,
947    }
948}
949
950fn numeric_rule_literal_matches(scalar: ScalarType, literal: &crate::ScalarLiteral) -> bool {
951    if let (ScalarType::Decimal { scale }, crate::ScalarLiteral::Decimal(value)) = (scalar, literal)
952    {
953        let value = value.normalize();
954        return value.scale() <= scale && value.scale_to_integer(scale).is_some();
955    }
956    scalar.accepts_literal(literal)
957}
958
959const fn exact_numeric_scalar(scalar: ScalarType) -> bool {
960    matches!(
961        scalar,
962        ScalarType::Decimal { .. }
963            | ScalarType::Int8
964            | ScalarType::Int16
965            | ScalarType::Int32
966            | ScalarType::Int64
967            | ScalarType::Int128
968            | ScalarType::IntBig { .. }
969            | ScalarType::Nat8
970            | ScalarType::Nat16
971            | ScalarType::Nat32
972            | ScalarType::Nat64
973            | ScalarType::Nat128
974            | ScalarType::NatBig { .. }
975    )
976}
977
978const fn numeric_scalar(scalar: ScalarType) -> bool {
979    matches!(
980        scalar,
981        ScalarType::Decimal { .. }
982            | ScalarType::Float32
983            | ScalarType::Float64
984            | ScalarType::Int8
985            | ScalarType::Int16
986            | ScalarType::Int32
987            | ScalarType::Int64
988            | ScalarType::Int128
989            | ScalarType::IntBig { .. }
990            | ScalarType::Nat8
991            | ScalarType::Nat16
992            | ScalarType::Nat32
993            | ScalarType::Nat64
994            | ScalarType::Nat128
995            | ScalarType::NatBig { .. }
996    )
997}
998
999fn collect_named_type_references(r#type: &NamedTypeFragment, references: &mut ProposalReferences) {
1000    match r#type {
1001        NamedTypeFragment::Record(record) => {
1002            for field in record.fields() {
1003                collect_field_type_reference(field.field_type(), references);
1004            }
1005        }
1006        NamedTypeFragment::Enum(r#enum) => {
1007            for variant in r#enum.variants() {
1008                if let Some(payload) = variant.payload() {
1009                    collect_field_type_reference(payload, references);
1010                }
1011            }
1012        }
1013        NamedTypeFragment::Newtype { inner, .. }
1014        | NamedTypeFragment::List { item: inner, .. }
1015        | NamedTypeFragment::Set { item: inner, .. } => {
1016            collect_field_type_reference(inner, references);
1017        }
1018        NamedTypeFragment::Map { key, value, .. } => {
1019            collect_field_type_reference(key, references);
1020            collect_field_type_reference(value, references);
1021        }
1022        NamedTypeFragment::Tuple { members, .. } => {
1023            for member in members {
1024                collect_field_type_reference(member.field_type(), references);
1025            }
1026        }
1027    }
1028}
1029
1030fn collect_field_references(
1031    field: &FieldFragment,
1032    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1033    references: &mut ProposalReferences,
1034) -> Result<(), SchemaContractError> {
1035    collect_field_type_reference(field.field_type(), references);
1036    if let crate::FieldInsertPolicy::Default(ScalarLiteral::EnumUnit { enum_type, variant }) =
1037        field.insert_policy()
1038    {
1039        let FieldType::Named(field_type) = field.field_type() else {
1040            return Err(SchemaContractError::LiteralTypeMismatch);
1041        };
1042        if field_type != enum_type {
1043            return Err(SchemaContractError::LiteralTypeMismatch);
1044        }
1045        collect_enum_literal_reference(enum_type, variant, types, references)?;
1046    }
1047    Ok(())
1048}
1049
1050fn collect_field_type_reference(field_type: &FieldType, references: &mut ProposalReferences) {
1051    match field_type {
1052        FieldType::List(item) => collect_field_type_reference(item, references),
1053        FieldType::Named(reference) => {
1054            references.types.insert(reference.clone());
1055        }
1056        FieldType::Scalar(_) => {}
1057    }
1058}
1059
1060fn collect_expression_enum_references(
1061    expression: &SourceCheckExpr,
1062    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1063    references: &mut ProposalReferences,
1064) -> Result<(), SchemaContractError> {
1065    for instruction in expression.instructions() {
1066        if let SourceCheckInstruction::Literal(ScalarLiteral::EnumUnit { enum_type, variant }) =
1067            instruction
1068        {
1069            collect_enum_literal_reference(enum_type, variant, types, references)?;
1070        }
1071    }
1072    Ok(())
1073}
1074
1075fn collect_enum_literal_reference(
1076    enum_type: &TypeSourceKey,
1077    variant: &TypeSourceKey,
1078    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1079    references: &mut ProposalReferences,
1080) -> Result<(), SchemaContractError> {
1081    references.types.insert(enum_type.clone());
1082    let Some(local) = types.get(enum_type) else {
1083        return Ok(());
1084    };
1085    let NamedTypeFragment::Enum(local) = local else {
1086        return Err(SchemaContractError::InvalidEnumLiteral);
1087    };
1088    if local
1089        .variants()
1090        .iter()
1091        .all(|candidate| candidate.source_key() != variant)
1092    {
1093        return Err(SchemaContractError::InvalidEnumLiteral);
1094    }
1095    Ok(())
1096}
1097
1098fn validate_local_relation_targets(
1099    source: &EntityFragment,
1100    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
1101) -> Result<(), SchemaContractError> {
1102    for relation in source.relations() {
1103        let Some(target) = entities.get(relation.target_entity()) else {
1104            continue;
1105        };
1106        for (source_key, target_key) in relation.local_fields().iter().zip(relation.target_fields())
1107        {
1108            let source_field = source
1109                .fields()
1110                .iter()
1111                .find(|field| field.source_key() == source_key)
1112                .ok_or(SchemaContractError::InvalidLocalReference)?;
1113            let target_field = target
1114                .fields()
1115                .iter()
1116                .find(|field| field.source_key() == target_key)
1117                .ok_or(SchemaContractError::InvalidLocalReference)?;
1118            let source_type = match source_field.field_type() {
1119                FieldType::List(item) => item.as_ref(),
1120                field_type => field_type,
1121            };
1122            if source_type != target_field.field_type() {
1123                return Err(SchemaContractError::RelationTypeMismatch);
1124            }
1125        }
1126    }
1127    Ok(())
1128}
1129
1130fn ensure_no_adjacent_duplicates<T>(values: &[T]) -> Result<(), SchemaContractError>
1131where
1132    T: Eq,
1133{
1134    if values.windows(2).any(|pair| pair[0] == pair[1]) {
1135        return Err(SchemaContractError::DuplicateSourceKey);
1136    }
1137    Ok(())
1138}
1139
1140fn ensure_no_adjacent_duplicates_by<T, K>(
1141    values: &[T],
1142    key: impl Fn(&T) -> &K,
1143) -> Result<(), SchemaContractError>
1144where
1145    K: Eq,
1146{
1147    if values.windows(2).any(|pair| key(&pair[0]) == key(&pair[1])) {
1148        return Err(SchemaContractError::DuplicateSourceKey);
1149    }
1150    Ok(())
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155    use super::*;
1156    use crate::decode_schema_proposal;
1157    use std::str::FromStr;
1158
1159    fn empty_proposal() -> SchemaProposal {
1160        SchemaProposal::try_compose(
1161            Vec::new(),
1162            TargetDatabaseIdentity::from_bytes([1; 32]),
1163            SchemaSubmissionKey::try_new("proposal-version-test")
1164                .expect("submission key should admit"),
1165            ExpectedAcceptedHead::Empty,
1166            Vec::new(),
1167            Vec::new(),
1168            Vec::new(),
1169            None,
1170        )
1171        .expect("empty proposal should compose")
1172    }
1173
1174    #[test]
1175    fn decoded_future_contract_version_fails_typed() {
1176        let mut proposal = empty_proposal();
1177        proposal.version = ProposalContractVersion::from_raw(3);
1178        let bytes = candid::encode_one(proposal).expect("raw future proposal should encode");
1179
1180        assert_eq!(
1181            decode_schema_proposal(&bytes),
1182            Err(SchemaContractError::UnsupportedVersion {
1183                found: 3,
1184                supported: 2,
1185            }),
1186        );
1187    }
1188
1189    #[test]
1190    fn decoded_unknown_capability_fails_typed() {
1191        let mut proposal = empty_proposal();
1192        proposal.capabilities = vec![SchemaCapability::from_raw(u16::MAX)];
1193        let bytes = candid::encode_one(proposal).expect("raw proposal should encode");
1194
1195        assert_eq!(
1196            decode_schema_proposal(&bytes),
1197            Err(SchemaContractError::UnsupportedCapability),
1198        );
1199    }
1200
1201    #[test]
1202    fn targeted_numeric_operations_require_exact_target_literal_admission() {
1203        let decimal = |value| {
1204            crate::ScalarLiteral::Decimal(
1205                crate::Decimal::from_str(value).expect("decimal fixture should parse"),
1206            )
1207        };
1208        assert!(operation_matches_target(
1209            &SourceRuleOperation::MultipleOf {
1210                divisor: decimal("0.25"),
1211            },
1212            RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
1213        ));
1214        assert!(!operation_matches_target(
1215            &SourceRuleOperation::MultipleOf {
1216                divisor: decimal("0.251"),
1217            },
1218            RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
1219        ));
1220        assert!(!operation_matches_target(
1221            &SourceRuleOperation::MultipleOf {
1222                divisor: crate::ScalarLiteral::Float64(
1223                    crate::Float64::try_new(2.0).expect("finite float"),
1224                ),
1225            },
1226            RuleTargetShape::Scalar(ScalarType::Float64),
1227        ));
1228        assert!(operation_matches_target(
1229            &SourceRuleOperation::NumericMaximumInclusive {
1230                value: crate::ScalarLiteral::Float64(
1231                    crate::Float64::try_new(2.0).expect("finite float"),
1232                ),
1233            },
1234            RuleTargetShape::Scalar(ScalarType::Float64),
1235        ));
1236    }
1237}