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        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    /// Return the closed migration-program version.
353    #[must_use]
354    pub const fn program_version(&self) -> u16 {
355        self.program_version
356    }
357
358    /// Borrow canonical entity transitions.
359    #[must_use]
360    pub fn transitions(&self) -> &[EntityMigration] {
361        &self.transitions
362    }
363
364    /// Return the exact canonical plan digest.
365    #[must_use]
366    pub const fn digest(&self) -> SchemaMigrationPlanDigest {
367        self.digest
368    }
369
370    pub(crate) fn validate(&self) -> Result<(), SchemaContractError> {
371        if self.program_version != MIGRATION_PROGRAM_VERSION_CURRENT {
372            return Err(SchemaContractError::UnsupportedMigrationProgramVersion {
373                found: self.program_version,
374                supported: MIGRATION_PROGRAM_VERSION_CURRENT,
375            });
376        }
377        let rebuilt = Self::try_new(self.transitions.clone())?;
378        if rebuilt != *self {
379            return Err(SchemaContractError::NonCanonical);
380        }
381        Ok(())
382    }
383}
384
385fn digest_plan_transitions(bytes: &[u8]) -> SchemaMigrationPlanDigest {
386    let mut hasher = Sha256::new();
387    hasher.update(MIGRATION_PLAN_DIGEST_PROFILE);
388    hasher.update(MIGRATION_PROGRAM_VERSION_CURRENT.to_be_bytes());
389    hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
390    hasher.update(bytes);
391    SchemaMigrationPlanDigest::from_bytes(hasher.finalize().into())
392}
393
394fn ensure_distinct_rename_ownership(
395    renames: &[SchemaMigrationRename],
396) -> Result<(), SchemaContractError> {
397    let mut sources = BTreeSet::new();
398    let mut targets = BTreeSet::new();
399    for rename in renames {
400        let (kind, owner, from, to) = rename.sort_key();
401        if !sources.insert((kind, owner, from)) {
402            return Err(SchemaContractError::DuplicateMigrationSource);
403        }
404        if !targets.insert((kind, owner, to)) {
405            return Err(SchemaContractError::DuplicateMigrationTarget);
406        }
407    }
408    Ok(())
409}
410
411fn ensure_distinct_transform_targets(
412    transforms: &[SchemaMigrationTransform],
413) -> Result<(), SchemaContractError> {
414    let mut targets = BTreeSet::new();
415    for transform in transforms {
416        if !targets.insert(transform.target()) {
417            return Err(SchemaContractError::DuplicateMigrationTarget);
418        }
419    }
420    Ok(())
421}
422
423const fn is_v1_cast_target(target: ScalarType) -> bool {
424    matches!(
425        target,
426        ScalarType::Int8
427            | ScalarType::Int16
428            | ScalarType::Int32
429            | ScalarType::Int64
430            | ScalarType::Int128
431            | ScalarType::Nat8
432            | ScalarType::Nat16
433            | ScalarType::Nat32
434            | ScalarType::Nat64
435            | ScalarType::Nat128
436            | ScalarType::Decimal { .. }
437    )
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use crate::{
444        MAX_SCHEMA_MIGRATION_PLAN_BYTES, decode_schema_migration_plan, encode_schema_migration_plan,
445    };
446
447    fn entity(value: &str) -> EntitySourceKey {
448        EntitySourceKey::try_new(value).expect("entity source should admit")
449    }
450
451    fn field(value: &str) -> FieldSourceKey {
452        FieldSourceKey::try_new(value).expect("field source should admit")
453    }
454
455    fn transition(entity_name: &str, from_name: &str) -> EntityMigration {
456        EntityMigration::try_new(
457            entity(entity_name),
458            DeclaredEntityVersion::try_new(1).expect("version should admit"),
459            Some(entity(from_name)),
460            vec![SchemaMigrationRename::Field {
461                from: field("old_value"),
462                to: field("value"),
463            }],
464            Vec::new(),
465        )
466        .expect("transition should admit")
467    }
468
469    #[test]
470    fn declared_entity_version_is_strictly_positive() {
471        assert_eq!(
472            DeclaredEntityVersion::try_new(0),
473            Err(SchemaContractError::InvalidEntityVersion),
474        );
475        assert_eq!(
476            DeclaredEntityVersion::try_new(1)
477                .expect("version one should admit")
478                .get(),
479            1,
480        );
481    }
482
483    #[test]
484    fn plan_canonicalization_is_declaration_order_independent() {
485        let account = transition("Account", "User");
486        let article = transition("Article", "Post");
487        let first = SchemaMigrationPlan::try_new(vec![account.clone(), article.clone()])
488            .expect("plan should admit");
489        let reverse = SchemaMigrationPlan::try_new(vec![article, account])
490            .expect("reverse plan should admit");
491
492        assert_eq!(first, reverse);
493        assert_eq!(first.digest(), reverse.digest());
494        let encoded = encode_schema_migration_plan(&first).expect("plan should encode");
495        assert_eq!(
496            decode_schema_migration_plan(&encoded).expect("plan should decode"),
497            first,
498        );
499    }
500
501    #[test]
502    fn every_current_migration_operation_roundtrips_exactly() {
503        let transition = EntityMigration::try_new(
504            entity("Account"),
505            DeclaredEntityVersion::try_new(3).expect("version should admit"),
506            Some(entity("User")),
507            vec![
508                SchemaMigrationRename::Field {
509                    from: field("old_field"),
510                    to: field("new_field"),
511                },
512                SchemaMigrationRename::NamedType {
513                    from: TypeSourceKey::try_new("OldType").expect("type should admit"),
514                    to: TypeSourceKey::try_new("NewType").expect("type should admit"),
515                },
516                SchemaMigrationRename::EnumVariant {
517                    named_type: TypeSourceKey::try_new("OldEnum").expect("type should admit"),
518                    from: TypeSourceKey::try_new("OldVariant").expect("variant should admit"),
519                    to: TypeSourceKey::try_new("NewVariant").expect("variant should admit"),
520                },
521                SchemaMigrationRename::RecordField {
522                    named_type: TypeSourceKey::try_new("OldRecord").expect("type should admit"),
523                    from: field("old_member"),
524                    to: field("new_member"),
525                },
526                SchemaMigrationRename::Relation {
527                    from: RelationSourceKey::try_new("old_relation")
528                        .expect("relation should admit"),
529                    to: RelationSourceKey::try_new("new_relation").expect("relation should admit"),
530                },
531                SchemaMigrationRename::Constraint {
532                    from: ConstraintSourceKey::try_new("old_constraint")
533                        .expect("constraint should admit"),
534                    to: ConstraintSourceKey::try_new("new_constraint")
535                        .expect("constraint should admit"),
536                },
537                SchemaMigrationRename::Rule {
538                    named_type: TypeSourceKey::try_new("OldRuleOwner").expect("type should admit"),
539                    from: RuleSourceKey::try_new("old_rule").expect("rule should admit"),
540                    to: RuleSourceKey::try_new("new_rule").expect("rule should admit"),
541                },
542            ],
543            vec![
544                SchemaMigrationTransform::Fill {
545                    to: field("filled"),
546                    literal: ScalarLiteral::Nat(1),
547                },
548                SchemaMigrationTransform::Copy {
549                    from: field("copy_source"),
550                    to: field("copied"),
551                },
552                SchemaMigrationTransform::CheckedCast {
553                    from: field("cast_source"),
554                    to: field("casted"),
555                    target: ScalarType::Nat64,
556                },
557                SchemaMigrationTransform::Coalesce {
558                    from: field("nullable_source"),
559                    to: field("coalesced"),
560                    literal: ScalarLiteral::Nat(0),
561                },
562            ],
563        )
564        .expect("transition should admit");
565        let plan = SchemaMigrationPlan::try_new(vec![transition]).expect("plan should admit");
566        let encoded = encode_schema_migration_plan(&plan).expect("plan should encode");
567
568        assert_eq!(
569            decode_schema_migration_plan(&encoded).expect("plan should decode"),
570            plan,
571        );
572    }
573
574    #[test]
575    fn plan_digest_has_one_fixed_current_vector() {
576        let plan = SchemaMigrationPlan::try_new(vec![transition("Account", "User")])
577            .expect("plan should admit");
578        assert_eq!(
579            plan.digest().to_bytes(),
580            [
581                198, 22, 117, 206, 250, 126, 65, 102, 162, 77, 246, 242, 60, 127, 68, 242, 56, 105,
582                141, 56, 136, 233, 54, 180, 100, 187, 24, 63, 167, 158, 239, 93,
583            ],
584        );
585    }
586
587    #[test]
588    fn duplicate_targets_predecessors_and_transform_targets_reject() {
589        let account = transition("Account", "User");
590        assert_eq!(
591            SchemaMigrationPlan::try_new(vec![account.clone(), account]),
592            Err(SchemaContractError::DuplicateMigrationTarget),
593        );
594        assert_eq!(
595            SchemaMigrationPlan::try_new(vec![
596                transition("Account", "User"),
597                transition("Profile", "User"),
598            ]),
599            Err(SchemaContractError::DuplicateMigrationSource),
600        );
601        assert_eq!(
602            EntityMigration::try_new(
603                entity("Account"),
604                DeclaredEntityVersion::try_new(1).expect("version should admit"),
605                None,
606                Vec::new(),
607                vec![
608                    SchemaMigrationTransform::Fill {
609                        to: field("value"),
610                        literal: ScalarLiteral::Nat(1),
611                    },
612                    SchemaMigrationTransform::Copy {
613                        from: field("old_value"),
614                        to: field("value"),
615                    },
616                ],
617            ),
618            Err(SchemaContractError::DuplicateMigrationTarget),
619        );
620    }
621
622    #[test]
623    fn obsolete_programs_and_oversized_transport_fail_closed() {
624        let plan = SchemaMigrationPlan::try_new(vec![transition("Account", "User")])
625            .expect("plan should admit");
626        let mut bytes = encode_schema_migration_plan(&plan).expect("plan should encode");
627        bytes[5..7].copy_from_slice(&2_u16.to_be_bytes());
628        assert_eq!(
629            decode_schema_migration_plan(&bytes),
630            Err(SchemaContractError::UnsupportedMigrationProgramVersion {
631                found: 2,
632                supported: 1,
633            }),
634        );
635        assert!(matches!(
636            decode_schema_migration_plan(&vec![0; MAX_SCHEMA_MIGRATION_PLAN_BYTES + 1]),
637            Err(SchemaContractError::EncodedTooLarge { .. }),
638        ));
639    }
640}