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