Skip to main content

icydb_schema/
migration.rs

1//! Canonical source-declared entity-version migration vocabulary.
2
3use std::collections::BTreeSet;
4
5use candid::CandidType;
6use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};
7use sha2::{Digest, Sha256};
8
9use crate::{
10    ConstraintSourceKey, EntitySourceKey, FieldSourceKey, MAX_SCHEMA_MIGRATION_ENTITIES,
11    MAX_SCHEMA_MIGRATION_PLAN_BYTES, MAX_SCHEMA_MIGRATION_RENAMES, MAX_SCHEMA_MIGRATION_TRANSFORMS,
12    RelationSourceKey, RuleSourceKey, ScalarLiteral, ScalarType, SchemaContractError,
13    SchemaMigrationPlanDigest, TypeSourceKey, check_len,
14};
15
16const MIGRATION_PROGRAM_VERSION_CURRENT: u16 = 1;
17const MIGRATION_PLAN_DIGEST_PROFILE: &[u8] = b"icydb.schema-migration-plan.v1";
18
19/// Positive application-declared source version for one current entity.
20#[derive(CandidType, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
21#[serde(transparent)]
22pub struct DeclaredEntityVersion(u32);
23
24impl DeclaredEntityVersion {
25    /// Construct one positive declared entity version.
26    ///
27    /// # Errors
28    ///
29    /// Returns [`SchemaContractError::InvalidEntityVersion`] for zero.
30    pub const fn try_new(value: u32) -> Result<Self, SchemaContractError> {
31        if value == 0 {
32            return Err(SchemaContractError::InvalidEntityVersion);
33        }
34        Ok(Self(value))
35    }
36
37    /// Return the authored version number.
38    #[must_use]
39    pub const fn get(self) -> u32 {
40        self.0
41    }
42}
43
44impl<'de> Deserialize<'de> for DeclaredEntityVersion {
45    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
46    where
47        D: Deserializer<'de>,
48    {
49        Self::try_new(u32::deserialize(deserializer)?).map_err(D::Error::custom)
50    }
51}
52
53/// One exact source-name correspondence applied simultaneously with its plan.
54#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
55pub enum SchemaMigrationRename {
56    /// Rename one entity-local field.
57    Field {
58        /// Accepted-before field name.
59        from: FieldSourceKey,
60        /// Current target field name.
61        to: FieldSourceKey,
62    },
63    /// Rename one named record, enum, newtype, or collection.
64    NamedType {
65        /// Accepted-before named-type name.
66        from: TypeSourceKey,
67        /// Current target named-type name.
68        to: TypeSourceKey,
69    },
70    /// Rename one unit or payload enum variant below an accepted-before enum.
71    EnumVariant {
72        /// Accepted-before owning enum name.
73        named_type: TypeSourceKey,
74        /// Accepted-before variant name.
75        from: TypeSourceKey,
76        /// Current target variant name.
77        to: TypeSourceKey,
78    },
79    /// Rename one field below an accepted-before named record.
80    RecordField {
81        /// Accepted-before owning record name.
82        named_type: TypeSourceKey,
83        /// Accepted-before record-field name.
84        from: FieldSourceKey,
85        /// Current target record-field name.
86        to: FieldSourceKey,
87    },
88    /// Rename one entity-local relation.
89    Relation {
90        /// Accepted-before relation name.
91        from: RelationSourceKey,
92        /// Current target relation name.
93        to: RelationSourceKey,
94    },
95    /// Rename one entity-local accepted constraint.
96    Constraint {
97        /// Accepted-before constraint name.
98        from: ConstraintSourceKey,
99        /// Current target constraint name.
100        to: ConstraintSourceKey,
101    },
102    /// Rename one durable rule below an accepted-before named type.
103    Rule {
104        /// Accepted-before owning named type.
105        named_type: TypeSourceKey,
106        /// Accepted-before local rule name.
107        from: RuleSourceKey,
108        /// Current target local rule name.
109        to: RuleSourceKey,
110    },
111}
112
113impl SchemaMigrationRename {
114    const fn sort_key(&self) -> (u8, &str, &str, &str) {
115        match self {
116            Self::Field { from, to } => (0, "", from.as_str(), to.as_str()),
117            Self::NamedType { from, to } => (1, "", from.as_str(), to.as_str()),
118            Self::EnumVariant {
119                named_type,
120                from,
121                to,
122            } => (2, named_type.as_str(), from.as_str(), to.as_str()),
123            Self::RecordField {
124                named_type,
125                from,
126                to,
127            } => (3, named_type.as_str(), from.as_str(), to.as_str()),
128            Self::Relation { from, to } => (4, "", from.as_str(), to.as_str()),
129            Self::Constraint { from, to } => (5, "", from.as_str(), to.as_str()),
130            Self::Rule {
131                named_type,
132                from,
133                to,
134            } => (6, named_type.as_str(), from.as_str(), to.as_str()),
135        }
136    }
137
138    fn validate(&self) -> Result<(), SchemaContractError> {
139        let (_, _, from, to) = self.sort_key();
140        if from == to {
141            return Err(SchemaContractError::InvalidMigrationPlan);
142        }
143        Ok(())
144    }
145}
146
147/// One closed deterministic historical-row transform declaration.
148#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
149pub enum SchemaMigrationTransform {
150    /// Fill one current target field with one exact typed literal.
151    Fill {
152        /// Current target field.
153        to: FieldSourceKey,
154        /// Exact target literal.
155        literal: ScalarLiteral,
156    },
157    /// Copy one accepted-before field into one distinct current target field.
158    Copy {
159        /// Accepted-before source field.
160        from: FieldSourceKey,
161        /// Current target field.
162        to: FieldSourceKey,
163    },
164    /// Convert one scalar through the closed exact checked-cast matrix.
165    CheckedCast {
166        /// Accepted-before source field.
167        from: FieldSourceKey,
168        /// Current target field.
169        to: FieldSourceKey,
170        /// Exact current target scalar contract.
171        target: ScalarType,
172    },
173    /// Preserve a non-null predecessor value or use one exact fallback.
174    Coalesce {
175        /// Accepted-before optional source field.
176        from: FieldSourceKey,
177        /// Current required target field.
178        to: FieldSourceKey,
179        /// Exact fallback literal.
180        literal: ScalarLiteral,
181    },
182}
183
184impl SchemaMigrationTransform {
185    /// Borrow the current target field.
186    #[must_use]
187    pub const fn target(&self) -> &FieldSourceKey {
188        match self {
189            Self::Fill { to, .. }
190            | Self::Copy { to, .. }
191            | Self::CheckedCast { to, .. }
192            | Self::Coalesce { to, .. } => to,
193        }
194    }
195
196    const fn sort_key(&self) -> (&str, u8, &str) {
197        match self {
198            Self::Fill { to, .. } => (to.as_str(), 0, ""),
199            Self::Copy { from, to } => (to.as_str(), 1, from.as_str()),
200            Self::CheckedCast { from, to, .. } => (to.as_str(), 2, from.as_str()),
201            Self::Coalesce { from, to, .. } => (to.as_str(), 3, from.as_str()),
202        }
203    }
204
205    fn validate(&self) -> Result<(), SchemaContractError> {
206        match self {
207            Self::Fill { literal, .. } | Self::Coalesce { literal, .. } => literal.validate(),
208            Self::Copy { from, to } if from == to => {
209                Err(SchemaContractError::InvalidMigrationTransform)
210            }
211            Self::CheckedCast { target, .. } if !is_v1_cast_target(*target) => {
212                Err(SchemaContractError::InvalidMigrationTransform)
213            }
214            Self::Copy { .. } | Self::CheckedCast { .. } => Ok(()),
215        }
216    }
217}
218
219/// One immediate-predecessor transition for a current entity declaration.
220#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
221pub struct EntityMigration {
222    entity: EntitySourceKey,
223    from: DeclaredEntityVersion,
224    from_name: Option<EntitySourceKey>,
225    renames: Vec<SchemaMigrationRename>,
226    transforms: Vec<SchemaMigrationTransform>,
227}
228
229impl EntityMigration {
230    /// Construct and canonicalize one entity transition.
231    ///
232    /// # Errors
233    ///
234    /// Returns a typed migration error for an empty transition, a no-op
235    /// entity rename, duplicate rename ownership, duplicate transform target,
236    /// or invalid transform.
237    pub fn try_new(
238        entity: EntitySourceKey,
239        from: DeclaredEntityVersion,
240        from_name: Option<EntitySourceKey>,
241        mut renames: Vec<SchemaMigrationRename>,
242        mut transforms: Vec<SchemaMigrationTransform>,
243    ) -> Result<Self, SchemaContractError> {
244        check_len(
245            "migration renames",
246            renames.len(),
247            MAX_SCHEMA_MIGRATION_RENAMES,
248        )?;
249        check_len(
250            "migration transforms",
251            transforms.len(),
252            MAX_SCHEMA_MIGRATION_TRANSFORMS,
253        )?;
254        if from_name.as_ref() == Some(&entity) {
255            return Err(SchemaContractError::InvalidMigrationPlan);
256        }
257        if from_name.is_none() && renames.is_empty() && transforms.is_empty() {
258            return Err(SchemaContractError::InvalidMigrationPlan);
259        }
260        for rename in &renames {
261            rename.validate()?;
262        }
263        for transform in &transforms {
264            transform.validate()?;
265        }
266        ensure_distinct_rename_ownership(&renames)?;
267        ensure_distinct_transform_targets(&transforms)?;
268        renames.sort_unstable_by(|left, right| left.sort_key().cmp(&right.sort_key()));
269        transforms.sort_unstable_by(|left, right| left.sort_key().cmp(&right.sort_key()));
270        Ok(Self {
271            entity,
272            from,
273            from_name,
274            renames,
275            transforms,
276        })
277    }
278
279    /// Borrow the current target entity key.
280    #[must_use]
281    pub const fn entity(&self) -> &EntitySourceKey {
282        &self.entity
283    }
284
285    /// Return the accepted-before declared entity version.
286    #[must_use]
287    pub const fn from(&self) -> DeclaredEntityVersion {
288        self.from
289    }
290
291    /// Borrow an optional accepted-before entity name.
292    #[must_use]
293    pub const fn from_name(&self) -> Option<&EntitySourceKey> {
294        self.from_name.as_ref()
295    }
296
297    /// Borrow canonical rename operations.
298    #[must_use]
299    pub fn renames(&self) -> &[SchemaMigrationRename] {
300        &self.renames
301    }
302
303    /// Borrow canonical historical transforms.
304    #[must_use]
305    pub fn transforms(&self) -> &[SchemaMigrationTransform] {
306        &self.transforms
307    }
308}
309
310/// One canonical database-scoped coordinated migration plan.
311#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
312pub struct SchemaMigrationPlan {
313    program_version: u16,
314    transitions: Vec<EntityMigration>,
315    digest: SchemaMigrationPlanDigest,
316}
317
318impl SchemaMigrationPlan {
319    /// Construct, canonicalize, bound, and digest one coordinated plan.
320    ///
321    /// # Errors
322    ///
323    /// Returns a typed migration or encoded-size error for an empty,
324    /// duplicate, ambiguous, or oversized plan.
325    pub fn try_new(mut transitions: Vec<EntityMigration>) -> Result<Self, SchemaContractError> {
326        check_len(
327            "migration entity transitions",
328            transitions.len(),
329            MAX_SCHEMA_MIGRATION_ENTITIES,
330        )?;
331        if transitions.is_empty() {
332            return Err(SchemaContractError::InvalidMigrationPlan);
333        }
334        transitions.sort_unstable_by(|left, right| left.entity.cmp(&right.entity));
335        if transitions
336            .windows(2)
337            .any(|pair| pair[0].entity == pair[1].entity)
338        {
339            return Err(SchemaContractError::DuplicateMigrationTarget);
340        }
341        let mut predecessor_entities = BTreeSet::new();
342        for transition in &transitions {
343            let predecessor = transition.from_name.as_ref().unwrap_or(&transition.entity);
344            if !predecessor_entities.insert(predecessor.clone()) {
345                return Err(SchemaContractError::DuplicateMigrationSource);
346            }
347        }
348        let transition_bytes =
349            candid::encode_one(&transitions).map_err(|_| SchemaContractError::Encode)?;
350        let digest = digest_plan_transitions(&transition_bytes);
351        let plan = Self {
352            program_version: MIGRATION_PROGRAM_VERSION_CURRENT,
353            transitions,
354            digest,
355        };
356        ensure_plan_bound(&plan)?;
357        Ok(plan)
358    }
359
360    /// Return the closed migration-program version.
361    #[must_use]
362    pub const fn program_version(&self) -> u16 {
363        self.program_version
364    }
365
366    /// Borrow canonical entity transitions.
367    #[must_use]
368    pub fn transitions(&self) -> &[EntityMigration] {
369        &self.transitions
370    }
371
372    /// Return the exact canonical plan digest.
373    #[must_use]
374    pub const fn digest(&self) -> SchemaMigrationPlanDigest {
375        self.digest
376    }
377
378    pub(crate) fn validate(&self) -> Result<(), SchemaContractError> {
379        if self.program_version != MIGRATION_PROGRAM_VERSION_CURRENT {
380            return Err(SchemaContractError::UnsupportedMigrationProgramVersion {
381                found: self.program_version,
382                supported: MIGRATION_PROGRAM_VERSION_CURRENT,
383            });
384        }
385        let rebuilt = Self::try_new(self.transitions.clone())?;
386        if rebuilt != *self {
387            return Err(SchemaContractError::NonCanonical);
388        }
389        Ok(())
390    }
391}
392
393/// Encode one bounded canonical migration plan.
394///
395/// # Errors
396///
397/// Returns a typed validation, encoding, or size error.
398pub fn encode_schema_migration_plan(
399    plan: &SchemaMigrationPlan,
400) -> Result<Vec<u8>, SchemaContractError> {
401    plan.validate()?;
402    let bytes = candid::encode_one(plan).map_err(|_| SchemaContractError::Encode)?;
403    if bytes.len() > MAX_SCHEMA_MIGRATION_PLAN_BYTES {
404        return Err(SchemaContractError::EncodedTooLarge {
405            len: bytes.len(),
406            max: MAX_SCHEMA_MIGRATION_PLAN_BYTES,
407        });
408    }
409    Ok(bytes)
410}
411
412/// Decode one bounded canonical migration plan.
413///
414/// # Errors
415///
416/// Returns a typed size, decoding, version, validation, or canonicalization
417/// error. Obsolete forms are never translated.
418pub fn decode_schema_migration_plan(
419    bytes: &[u8],
420) -> Result<SchemaMigrationPlan, SchemaContractError> {
421    if bytes.len() > MAX_SCHEMA_MIGRATION_PLAN_BYTES {
422        return Err(SchemaContractError::EncodedTooLarge {
423            len: bytes.len(),
424            max: MAX_SCHEMA_MIGRATION_PLAN_BYTES,
425        });
426    }
427    let plan = candid::decode_one::<SchemaMigrationPlan>(bytes)
428        .map_err(|_| SchemaContractError::Decode)?;
429    plan.validate()?;
430    if encode_schema_migration_plan(&plan)? != bytes {
431        return Err(SchemaContractError::NonCanonical);
432    }
433    Ok(plan)
434}
435
436fn ensure_plan_bound(plan: &SchemaMigrationPlan) -> Result<(), SchemaContractError> {
437    let bytes = candid::encode_one(plan).map_err(|_| SchemaContractError::Encode)?;
438    if bytes.len() > MAX_SCHEMA_MIGRATION_PLAN_BYTES {
439        return Err(SchemaContractError::EncodedTooLarge {
440            len: bytes.len(),
441            max: MAX_SCHEMA_MIGRATION_PLAN_BYTES,
442        });
443    }
444    Ok(())
445}
446
447fn digest_plan_transitions(bytes: &[u8]) -> SchemaMigrationPlanDigest {
448    let mut hasher = Sha256::new();
449    hasher.update(MIGRATION_PLAN_DIGEST_PROFILE);
450    hasher.update(MIGRATION_PROGRAM_VERSION_CURRENT.to_be_bytes());
451    hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
452    hasher.update(bytes);
453    SchemaMigrationPlanDigest::from_bytes(hasher.finalize().into())
454}
455
456fn ensure_distinct_rename_ownership(
457    renames: &[SchemaMigrationRename],
458) -> Result<(), SchemaContractError> {
459    let mut sources = BTreeSet::new();
460    let mut targets = BTreeSet::new();
461    for rename in renames {
462        let (kind, owner, from, to) = rename.sort_key();
463        if !sources.insert((kind, owner, from)) {
464            return Err(SchemaContractError::DuplicateMigrationSource);
465        }
466        if !targets.insert((kind, owner, to)) {
467            return Err(SchemaContractError::DuplicateMigrationTarget);
468        }
469    }
470    Ok(())
471}
472
473fn ensure_distinct_transform_targets(
474    transforms: &[SchemaMigrationTransform],
475) -> Result<(), SchemaContractError> {
476    let mut targets = BTreeSet::new();
477    for transform in transforms {
478        if !targets.insert(transform.target()) {
479            return Err(SchemaContractError::DuplicateMigrationTarget);
480        }
481    }
482    Ok(())
483}
484
485const fn is_v1_cast_target(target: ScalarType) -> bool {
486    matches!(
487        target,
488        ScalarType::Int8
489            | ScalarType::Int16
490            | ScalarType::Int32
491            | ScalarType::Int64
492            | ScalarType::Int128
493            | ScalarType::Nat8
494            | ScalarType::Nat16
495            | ScalarType::Nat32
496            | ScalarType::Nat64
497            | ScalarType::Nat128
498            | ScalarType::Decimal { .. }
499    )
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    fn entity(value: &str) -> EntitySourceKey {
507        EntitySourceKey::try_new(value).expect("entity source should admit")
508    }
509
510    fn field(value: &str) -> FieldSourceKey {
511        FieldSourceKey::try_new(value).expect("field source should admit")
512    }
513
514    fn transition(entity_name: &str, from_name: &str) -> EntityMigration {
515        EntityMigration::try_new(
516            entity(entity_name),
517            DeclaredEntityVersion::try_new(1).expect("version should admit"),
518            Some(entity(from_name)),
519            vec![SchemaMigrationRename::Field {
520                from: field("old_value"),
521                to: field("value"),
522            }],
523            Vec::new(),
524        )
525        .expect("transition should admit")
526    }
527
528    #[test]
529    fn declared_entity_version_is_strictly_positive() {
530        assert_eq!(
531            DeclaredEntityVersion::try_new(0),
532            Err(SchemaContractError::InvalidEntityVersion),
533        );
534        assert_eq!(
535            DeclaredEntityVersion::try_new(1)
536                .expect("version one should admit")
537                .get(),
538            1,
539        );
540    }
541
542    #[test]
543    fn plan_canonicalization_is_declaration_order_independent() {
544        let account = transition("Account", "User");
545        let article = transition("Article", "Post");
546        let first = SchemaMigrationPlan::try_new(vec![account.clone(), article.clone()])
547            .expect("plan should admit");
548        let reverse = SchemaMigrationPlan::try_new(vec![article, account])
549            .expect("reverse plan should admit");
550
551        assert_eq!(first, reverse);
552        assert_eq!(first.digest(), reverse.digest());
553        let encoded = encode_schema_migration_plan(&first).expect("plan should encode");
554        assert_eq!(
555            decode_schema_migration_plan(&encoded).expect("plan should decode"),
556            first,
557        );
558    }
559
560    #[test]
561    fn plan_digest_has_one_fixed_current_vector() {
562        let plan = SchemaMigrationPlan::try_new(vec![transition("Account", "User")])
563            .expect("plan should admit");
564        assert_eq!(
565            plan.digest().to_bytes(),
566            [
567                112, 32, 202, 185, 208, 245, 26, 207, 64, 171, 3, 52, 250, 32, 61, 253, 141, 53,
568                112, 54, 108, 123, 104, 157, 229, 10, 70, 161, 138, 164, 77, 16,
569            ],
570        );
571    }
572
573    #[test]
574    fn duplicate_targets_predecessors_and_transform_targets_reject() {
575        let account = transition("Account", "User");
576        assert_eq!(
577            SchemaMigrationPlan::try_new(vec![account.clone(), account]),
578            Err(SchemaContractError::DuplicateMigrationTarget),
579        );
580        assert_eq!(
581            SchemaMigrationPlan::try_new(vec![
582                transition("Account", "User"),
583                transition("Profile", "User"),
584            ]),
585            Err(SchemaContractError::DuplicateMigrationSource),
586        );
587        assert_eq!(
588            EntityMigration::try_new(
589                entity("Account"),
590                DeclaredEntityVersion::try_new(1).expect("version should admit"),
591                None,
592                Vec::new(),
593                vec![
594                    SchemaMigrationTransform::Fill {
595                        to: field("value"),
596                        literal: ScalarLiteral::Nat(1),
597                    },
598                    SchemaMigrationTransform::Copy {
599                        from: field("old_value"),
600                        to: field("value"),
601                    },
602                ],
603            ),
604            Err(SchemaContractError::DuplicateMigrationTarget),
605        );
606    }
607
608    #[test]
609    fn obsolete_programs_and_oversized_transport_fail_closed() {
610        let mut plan = SchemaMigrationPlan::try_new(vec![transition("Account", "User")])
611            .expect("plan should admit");
612        plan.program_version = 2;
613        let bytes = candid::encode_one(plan).expect("raw future plan should encode");
614        assert_eq!(
615            decode_schema_migration_plan(&bytes),
616            Err(SchemaContractError::UnsupportedMigrationProgramVersion {
617                found: 2,
618                supported: 1,
619            }),
620        );
621        assert!(matches!(
622            decode_schema_migration_plan(&vec![0; MAX_SCHEMA_MIGRATION_PLAN_BYTES + 1]),
623            Err(SchemaContractError::EncodedTooLarge { .. }),
624        ));
625    }
626}