Skip to main content

icydb_schema/
proposal.rs

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