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    pub(crate) 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    /// A declaration without local operations is reserved for a dependency-only
221    /// entity-rename change, proved against accepted source meaning by the planner.
222    ///
223    /// # Errors
224    ///
225    /// Returns a typed migration error for a no-op
226    /// entity rename, duplicate rename ownership, duplicate transform target,
227    /// or invalid transform.
228    pub fn try_new(
229        entity: EntitySourceKey,
230        from: DeclaredEntityVersion,
231        from_name: Option<EntitySourceKey>,
232        mut renames: Vec<SchemaMigrationRename>,
233        mut transforms: Vec<SchemaMigrationTransform>,
234    ) -> Result<Self, SchemaContractError> {
235        check_len(
236            "migration renames",
237            renames.len(),
238            MAX_SCHEMA_MIGRATION_RENAMES,
239        )?;
240        check_len(
241            "migration transforms",
242            transforms.len(),
243            MAX_SCHEMA_MIGRATION_TRANSFORMS,
244        )?;
245        if from_name.as_ref() == Some(&entity) {
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        crate::compact_sort_unstable_by(&mut renames, |left, right| {
257            left.sort_key().cmp(&right.sort_key())
258        });
259        crate::compact_sort_unstable_by(&mut transforms, |left, right| {
260            left.sort_key().cmp(&right.sort_key())
261        });
262        Ok(Self {
263            entity,
264            from,
265            from_name,
266            renames,
267            transforms,
268        })
269    }
270
271    /// Borrow the current target entity key.
272    #[must_use]
273    pub const fn entity(&self) -> &EntitySourceKey {
274        &self.entity
275    }
276
277    /// Return the accepted-before declared entity version.
278    #[must_use]
279    pub const fn from(&self) -> DeclaredEntityVersion {
280        self.from
281    }
282
283    /// Borrow an optional accepted-before entity name.
284    #[must_use]
285    pub const fn from_name(&self) -> Option<&EntitySourceKey> {
286        self.from_name.as_ref()
287    }
288
289    /// Borrow canonical rename operations.
290    #[must_use]
291    pub fn renames(&self) -> &[SchemaMigrationRename] {
292        &self.renames
293    }
294
295    /// Borrow canonical historical transforms.
296    #[must_use]
297    pub fn transforms(&self) -> &[SchemaMigrationTransform] {
298        &self.transforms
299    }
300}
301
302/// One canonical database-scoped coordinated migration plan.
303#[derive(Clone, Debug, Eq, PartialEq)]
304pub struct SchemaMigrationPlan {
305    program_version: u16,
306    transitions: Vec<EntityMigration>,
307    digest: SchemaMigrationPlanDigest,
308}
309
310impl SchemaMigrationPlan {
311    /// Construct, canonicalize, bound, and digest one coordinated plan.
312    ///
313    /// # Errors
314    ///
315    /// Returns a typed migration or encoded-size error for an empty,
316    /// duplicate, ambiguous, or oversized plan.
317    pub fn try_new(mut transitions: Vec<EntityMigration>) -> Result<Self, SchemaContractError> {
318        check_len(
319            "migration entity transitions",
320            transitions.len(),
321            MAX_SCHEMA_MIGRATION_ENTITIES,
322        )?;
323        if transitions.is_empty() {
324            return Err(SchemaContractError::InvalidMigrationPlan);
325        }
326        crate::compact_sort_unstable_by(&mut transitions, |left, right| {
327            left.entity.cmp(&right.entity)
328        });
329        if transitions
330            .windows(2)
331            .any(|pair| pair[0].entity == pair[1].entity)
332        {
333            return Err(SchemaContractError::DuplicateMigrationTarget);
334        }
335        let mut predecessor_entities = BTreeSet::new();
336        for transition in &transitions {
337            let predecessor = transition.from_name.as_ref().unwrap_or(&transition.entity);
338            if !predecessor_entities.insert(predecessor.clone()) {
339                return Err(SchemaContractError::DuplicateMigrationSource);
340            }
341        }
342        let transition_bytes = crate::encode_migration_transitions_for_digest(&transitions)?;
343        let digest = digest_plan_transitions(&transition_bytes);
344        let plan = Self {
345            program_version: MIGRATION_PROGRAM_VERSION_CURRENT,
346            transitions,
347            digest,
348        };
349        Ok(plan)
350    }
351
352    /// Validate a complete authored target fragment against this migration plan.
353    ///
354    /// Uses the proposal's source-reference, closure and version rules without
355    /// requiring runtime database identities, stores or an accepted head. This
356    /// does not qualify the predecessor's retained data or authorize migration.
357    ///
358    /// # Errors
359    ///
360    /// Returns the proposal contract error for malformed inputs, missing target
361    /// definitions, version gaps, or invalid rename/transform targets.
362    pub fn validate_target_fragment(
363        &self,
364        fragment: &crate::SchemaFragment,
365    ) -> Result<(), SchemaContractError> {
366        self.validate()?;
367        fragment.validate()?;
368        let entities = fragment
369            .entities()
370            .iter()
371            .map(|entity| (entity.source_key().clone(), entity))
372            .collect();
373        let types = fragment
374            .types()
375            .iter()
376            .map(|ty| (ty.source_key().clone(), ty))
377            .collect();
378        crate::proposal::validate_proposal_closure(
379            &crate::ExpectedAcceptedHead::Empty,
380            &entities,
381            &types,
382            &[],
383        )?;
384        crate::proposal::validate_migration_plan(self, &entities, &types)
385    }
386
387    /// Return the closed migration-program version.
388    #[must_use]
389    pub const fn program_version(&self) -> u16 {
390        self.program_version
391    }
392
393    /// Borrow canonical entity transitions.
394    #[must_use]
395    pub fn transitions(&self) -> &[EntityMigration] {
396        &self.transitions
397    }
398
399    /// Return the exact canonical plan digest.
400    #[must_use]
401    pub const fn digest(&self) -> SchemaMigrationPlanDigest {
402        self.digest
403    }
404
405    pub(crate) fn validate(&self) -> Result<(), SchemaContractError> {
406        if self.program_version != MIGRATION_PROGRAM_VERSION_CURRENT {
407            return Err(SchemaContractError::UnsupportedMigrationProgramVersion {
408                found: self.program_version,
409                supported: MIGRATION_PROGRAM_VERSION_CURRENT,
410            });
411        }
412        let rebuilt = Self::try_new(self.transitions.clone())?;
413        if rebuilt != *self {
414            return Err(SchemaContractError::NonCanonical);
415        }
416        Ok(())
417    }
418}
419
420fn digest_plan_transitions(bytes: &[u8]) -> SchemaMigrationPlanDigest {
421    let mut hasher = Sha256::new();
422    hasher.update(MIGRATION_PLAN_DIGEST_PROFILE);
423    hasher.update(MIGRATION_PROGRAM_VERSION_CURRENT.to_be_bytes());
424    hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
425    hasher.update(bytes);
426    SchemaMigrationPlanDigest::from_bytes(hasher.finalize().into())
427}
428
429fn ensure_distinct_rename_ownership(
430    renames: &[SchemaMigrationRename],
431) -> Result<(), SchemaContractError> {
432    let mut sources = BTreeSet::new();
433    let mut targets = BTreeSet::new();
434    for rename in renames {
435        let (kind, owner, from, to) = rename.sort_key();
436        if !sources.insert((kind, owner, from)) {
437            return Err(SchemaContractError::DuplicateMigrationSource);
438        }
439        if !targets.insert((kind, owner, to)) {
440            return Err(SchemaContractError::DuplicateMigrationTarget);
441        }
442    }
443    Ok(())
444}
445
446fn ensure_distinct_transform_targets(
447    transforms: &[SchemaMigrationTransform],
448) -> Result<(), SchemaContractError> {
449    let mut targets = BTreeSet::new();
450    for transform in transforms {
451        if !targets.insert(transform.target()) {
452            return Err(SchemaContractError::DuplicateMigrationTarget);
453        }
454    }
455    Ok(())
456}
457
458const fn is_v1_cast_target(target: ScalarType) -> bool {
459    matches!(
460        target,
461        ScalarType::Int8
462            | ScalarType::Int16
463            | ScalarType::Int32
464            | ScalarType::Int64
465            | ScalarType::Int128
466            | ScalarType::Nat8
467            | ScalarType::Nat16
468            | ScalarType::Nat32
469            | ScalarType::Nat64
470            | ScalarType::Nat128
471            | ScalarType::Decimal { .. }
472    )
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::{
479        MAX_SCHEMA_MIGRATION_PLAN_BYTES, decode_schema_migration_plan, encode_schema_migration_plan,
480    };
481
482    fn entity(value: &str) -> EntitySourceKey {
483        EntitySourceKey::try_new(value).expect("entity source should admit")
484    }
485
486    fn field(value: &str) -> FieldSourceKey {
487        FieldSourceKey::try_new(value).expect("field source should admit")
488    }
489
490    fn transition(entity_name: &str, from_name: &str) -> EntityMigration {
491        EntityMigration::try_new(
492            entity(entity_name),
493            DeclaredEntityVersion::try_new(1).expect("version should admit"),
494            Some(entity(from_name)),
495            vec![SchemaMigrationRename::Field {
496                from: field("old_value"),
497                to: field("value"),
498            }],
499            Vec::new(),
500        )
501        .expect("transition should admit")
502    }
503
504    #[test]
505    fn declared_entity_version_is_strictly_positive() {
506        assert_eq!(
507            DeclaredEntityVersion::try_new(0),
508            Err(SchemaContractError::InvalidEntityVersion),
509        );
510        assert_eq!(
511            DeclaredEntityVersion::try_new(1)
512                .expect("version one should admit")
513                .get(),
514            1,
515        );
516    }
517
518    #[test]
519    fn plan_canonicalization_is_declaration_order_independent() {
520        let account = transition("Account", "User");
521        let article = transition("Article", "Post");
522        let first = SchemaMigrationPlan::try_new(vec![account.clone(), article.clone()])
523            .expect("plan should admit");
524        let reverse = SchemaMigrationPlan::try_new(vec![article, account])
525            .expect("reverse plan should admit");
526
527        assert_eq!(first, reverse);
528        assert_eq!(first.digest(), reverse.digest());
529        let encoded = encode_schema_migration_plan(&first).expect("plan should encode");
530        assert_eq!(
531            decode_schema_migration_plan(&encoded).expect("plan should decode"),
532            first,
533        );
534    }
535
536    #[test]
537    fn every_current_migration_operation_roundtrips_exactly() {
538        let transition = EntityMigration::try_new(
539            entity("Account"),
540            DeclaredEntityVersion::try_new(3).expect("version should admit"),
541            Some(entity("User")),
542            vec![
543                SchemaMigrationRename::Field {
544                    from: field("old_field"),
545                    to: field("new_field"),
546                },
547                SchemaMigrationRename::NamedType {
548                    from: TypeSourceKey::try_new("OldType").expect("type should admit"),
549                    to: TypeSourceKey::try_new("NewType").expect("type should admit"),
550                },
551                SchemaMigrationRename::EnumVariant {
552                    named_type: TypeSourceKey::try_new("OldEnum").expect("type should admit"),
553                    from: TypeSourceKey::try_new("OldVariant").expect("variant should admit"),
554                    to: TypeSourceKey::try_new("NewVariant").expect("variant should admit"),
555                },
556                SchemaMigrationRename::RecordField {
557                    named_type: TypeSourceKey::try_new("OldRecord").expect("type should admit"),
558                    from: field("old_member"),
559                    to: field("new_member"),
560                },
561                SchemaMigrationRename::Relation {
562                    from: RelationSourceKey::try_new("old_relation")
563                        .expect("relation should admit"),
564                    to: RelationSourceKey::try_new("new_relation").expect("relation should admit"),
565                },
566                SchemaMigrationRename::Constraint {
567                    from: ConstraintSourceKey::try_new("old_constraint")
568                        .expect("constraint should admit"),
569                    to: ConstraintSourceKey::try_new("new_constraint")
570                        .expect("constraint should admit"),
571                },
572                SchemaMigrationRename::Rule {
573                    named_type: TypeSourceKey::try_new("OldRuleOwner").expect("type should admit"),
574                    from: RuleSourceKey::try_new("old_rule").expect("rule should admit"),
575                    to: RuleSourceKey::try_new("new_rule").expect("rule should admit"),
576                },
577            ],
578            vec![
579                SchemaMigrationTransform::Fill {
580                    to: field("filled"),
581                    literal: ScalarLiteral::Nat(1),
582                },
583                SchemaMigrationTransform::Copy {
584                    from: field("copy_source"),
585                    to: field("copied"),
586                },
587                SchemaMigrationTransform::CheckedCast {
588                    from: field("cast_source"),
589                    to: field("casted"),
590                    target: ScalarType::Nat64,
591                },
592                SchemaMigrationTransform::Coalesce {
593                    from: field("nullable_source"),
594                    to: field("coalesced"),
595                    literal: ScalarLiteral::Nat(0),
596                },
597            ],
598        )
599        .expect("transition should admit");
600        let plan = SchemaMigrationPlan::try_new(vec![transition]).expect("plan should admit");
601        let encoded = encode_schema_migration_plan(&plan).expect("plan should encode");
602
603        assert_eq!(
604            decode_schema_migration_plan(&encoded).expect("plan should decode"),
605            plan,
606        );
607    }
608
609    #[test]
610    fn plan_digest_has_one_fixed_current_vector() {
611        let plan = SchemaMigrationPlan::try_new(vec![transition("Account", "User")])
612            .expect("plan should admit");
613        assert_eq!(
614            plan.digest().to_bytes(),
615            [
616                198, 22, 117, 206, 250, 126, 65, 102, 162, 77, 246, 242, 60, 127, 68, 242, 56, 105,
617                141, 56, 136, 233, 54, 180, 100, 187, 24, 63, 167, 158, 239, 93,
618            ],
619        );
620    }
621
622    #[test]
623    fn dependency_only_companion_roundtrips_exactly() {
624        let companion = EntityMigration::try_new(
625            entity("Holder"),
626            DeclaredEntityVersion::try_new(1).unwrap(),
627            None,
628            Vec::new(),
629            Vec::new(),
630        )
631        .unwrap();
632        let plan = SchemaMigrationPlan::try_new(vec![transition("CatalogItem", "Item"), companion])
633            .unwrap();
634        let encoded = crate::encode_schema_migration_plan(&plan).unwrap();
635        assert_eq!(crate::decode_schema_migration_plan(&encoded).unwrap(), plan);
636    }
637
638    #[test]
639    fn duplicate_targets_predecessors_and_transform_targets_reject() {
640        let account = transition("Account", "User");
641        assert_eq!(
642            SchemaMigrationPlan::try_new(vec![account.clone(), account]),
643            Err(SchemaContractError::DuplicateMigrationTarget),
644        );
645        assert_eq!(
646            SchemaMigrationPlan::try_new(vec![
647                transition("Account", "User"),
648                transition("Profile", "User"),
649            ]),
650            Err(SchemaContractError::DuplicateMigrationSource),
651        );
652        assert_eq!(
653            EntityMigration::try_new(
654                entity("Account"),
655                DeclaredEntityVersion::try_new(1).expect("version should admit"),
656                None,
657                Vec::new(),
658                vec![
659                    SchemaMigrationTransform::Fill {
660                        to: field("value"),
661                        literal: ScalarLiteral::Nat(1),
662                    },
663                    SchemaMigrationTransform::Copy {
664                        from: field("old_value"),
665                        to: field("value"),
666                    },
667                ],
668            ),
669            Err(SchemaContractError::DuplicateMigrationTarget),
670        );
671    }
672
673    #[test]
674    fn unsupported_program_versions_and_oversized_transport_fail_closed() {
675        let plan = SchemaMigrationPlan::try_new(vec![transition("Account", "User")])
676            .expect("plan should admit");
677        let mut bytes = encode_schema_migration_plan(&plan).expect("plan should encode");
678        bytes[5..7].copy_from_slice(&2_u16.to_be_bytes());
679        assert_eq!(
680            decode_schema_migration_plan(&bytes),
681            Err(SchemaContractError::UnsupportedMigrationProgramVersion {
682                found: 2,
683                supported: 1,
684            }),
685        );
686        assert!(matches!(
687            decode_schema_migration_plan(&vec![0; MAX_SCHEMA_MIGRATION_PLAN_BYTES + 1]),
688            Err(SchemaContractError::EncodedTooLarge { .. }),
689        ));
690    }
691}