Skip to main content

icydb_schema/
migration.rs

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