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 serde::{Deserialize, Serialize};
8
9use crate::{
10    ConstraintSourceKey, EntitySourceKey, FieldSourceKey, MAX_SCHEMA_MIGRATION_ENTITIES,
11    MAX_SCHEMA_MIGRATION_RENAMES, MAX_SCHEMA_MIGRATION_TRANSFORMS, RelationSourceKey,
12    RuleSourceKey, ScalarLiteral, ScalarType, SchemaContractError, SchemaMigrationPlanDigest,
13    TypeSourceKey, check_len,
14};
15
16pub(crate) const MIGRATION_PROGRAM_VERSION_CURRENT: u16 = 1;
17const MIGRATION_PLAN_DIGEST_PROFILE: &[u8] = b"icydb.schema-migration-plan.wire.v1";
18
19/// Positive application-declared source version for one current entity.
20#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
21pub struct DeclaredEntityVersion(u32);
22
23impl DeclaredEntityVersion {
24    /// Construct one positive declared entity version.
25    ///
26    /// # Errors
27    ///
28    /// Returns [`SchemaContractError::InvalidEntityVersion`] for zero.
29    pub const fn try_new(value: u32) -> Result<Self, SchemaContractError> {
30        if value == 0 {
31            return Err(SchemaContractError::InvalidEntityVersion);
32        }
33        Ok(Self(value))
34    }
35
36    /// Return the authored version number.
37    #[must_use]
38    pub const fn get(self) -> u32 {
39        self.0
40    }
41}
42
43impl<'de> Deserialize<'de> for DeclaredEntityVersion {
44    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
45        Self::try_new(u32::deserialize(deserializer)?).map_err(serde::de::Error::custom)
46    }
47}
48
49/// One exact source-name correspondence applied simultaneously with its plan.
50#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51pub enum SchemaMigrationRename {
52    /// Rename one entity-local field.
53    Field {
54        /// Accepted-before field name.
55        #[serde(
56            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
57        )]
58        from: FieldSourceKey,
59        /// Current target field name.
60        #[serde(
61            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
62        )]
63        to: FieldSourceKey,
64    },
65    /// Rename one named record, enum, newtype, or collection.
66    NamedType {
67        /// Accepted-before named-type name.
68        #[serde(
69            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
70        )]
71        from: TypeSourceKey,
72        /// Current target named-type name.
73        #[serde(
74            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
75        )]
76        to: TypeSourceKey,
77    },
78    /// Rename one unit or payload enum variant below an accepted-before enum.
79    EnumVariant {
80        /// Accepted-before owning enum name.
81        #[serde(
82            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
83        )]
84        named_type: TypeSourceKey,
85        /// Accepted-before variant name.
86        #[serde(
87            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
88        )]
89        from: TypeSourceKey,
90        /// Current target variant name.
91        #[serde(
92            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
93        )]
94        to: TypeSourceKey,
95    },
96    /// Rename one field below an accepted-before named record.
97    RecordField {
98        /// Accepted-before owning record name.
99        #[serde(
100            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
101        )]
102        named_type: TypeSourceKey,
103        /// Accepted-before record-field name.
104        #[serde(
105            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
106        )]
107        from: FieldSourceKey,
108        /// Current target record-field name.
109        #[serde(
110            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
111        )]
112        to: FieldSourceKey,
113    },
114    /// Rename one entity-local relation.
115    Relation {
116        /// Accepted-before relation name.
117        #[serde(
118            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
119        )]
120        from: RelationSourceKey,
121        /// Current target relation name.
122        #[serde(
123            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
124        )]
125        to: RelationSourceKey,
126    },
127    /// Rename one entity-local accepted constraint.
128    Constraint {
129        /// Accepted-before constraint name.
130        #[serde(
131            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
132        )]
133        from: ConstraintSourceKey,
134        /// Current target constraint name.
135        #[serde(
136            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
137        )]
138        to: ConstraintSourceKey,
139    },
140    /// Rename one durable rule below an accepted-before named type.
141    Rule {
142        /// Accepted-before owning named type.
143        #[serde(
144            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
145        )]
146        named_type: TypeSourceKey,
147        /// Accepted-before local rule name.
148        #[serde(
149            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
150        )]
151        from: RuleSourceKey,
152        /// Current target local rule name.
153        #[serde(
154            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
155        )]
156        to: RuleSourceKey,
157    },
158}
159
160impl SchemaMigrationRename {
161    pub(crate) const fn sort_key(&self) -> (u8, &str, &str, &str) {
162        match self {
163            Self::Field { from, to } => (0, "", from.as_str(), to.as_str()),
164            Self::NamedType { from, to } => (1, "", from.as_str(), to.as_str()),
165            Self::EnumVariant {
166                named_type,
167                from,
168                to,
169            } => (2, named_type.as_str(), from.as_str(), to.as_str()),
170            Self::RecordField {
171                named_type,
172                from,
173                to,
174            } => (3, named_type.as_str(), from.as_str(), to.as_str()),
175            Self::Relation { from, to } => (4, "", from.as_str(), to.as_str()),
176            Self::Constraint { from, to } => (5, "", from.as_str(), to.as_str()),
177            Self::Rule {
178                named_type,
179                from,
180                to,
181            } => (6, named_type.as_str(), from.as_str(), to.as_str()),
182        }
183    }
184
185    fn validate(&self) -> Result<(), SchemaContractError> {
186        let (_, _, from, to) = self.sort_key();
187        if from == to {
188            return Err(SchemaContractError::InvalidMigrationPlan);
189        }
190        Ok(())
191    }
192}
193
194/// One closed deterministic historical-row transform declaration.
195#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
196pub enum SchemaMigrationTransform {
197    /// Fill one current target field with one exact typed literal.
198    Fill {
199        /// Current target field.
200        #[serde(
201            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
202        )]
203        to: FieldSourceKey,
204        /// Exact target literal.
205        literal: ScalarLiteral,
206    },
207    /// Copy one accepted-before field into one distinct current target field.
208    Copy {
209        /// Accepted-before source field.
210        #[serde(
211            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
212        )]
213        from: FieldSourceKey,
214        /// Current target field.
215        #[serde(
216            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
217        )]
218        to: FieldSourceKey,
219    },
220    /// Convert one scalar through the closed exact checked-cast matrix.
221    CheckedCast {
222        /// Accepted-before source field.
223        #[serde(
224            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
225        )]
226        from: FieldSourceKey,
227        /// Current target field.
228        #[serde(
229            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
230        )]
231        to: FieldSourceKey,
232        /// Exact current target scalar contract.
233        target: ScalarType,
234    },
235    /// Preserve a non-null predecessor value or use one exact fallback.
236    Coalesce {
237        /// Accepted-before optional source field.
238        #[serde(
239            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
240        )]
241        from: FieldSourceKey,
242        /// Current required target field.
243        #[serde(
244            deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
245        )]
246        to: FieldSourceKey,
247        /// Exact fallback literal.
248        literal: ScalarLiteral,
249    },
250}
251
252impl SchemaMigrationTransform {
253    /// Borrow the current target field.
254    #[must_use]
255    pub const fn target(&self) -> &FieldSourceKey {
256        match self {
257            Self::Fill { to, .. }
258            | Self::Copy { to, .. }
259            | Self::CheckedCast { to, .. }
260            | Self::Coalesce { to, .. } => to,
261        }
262    }
263
264    const fn sort_key(&self) -> (&str, u8, &str) {
265        match self {
266            Self::Fill { to, .. } => (to.as_str(), 0, ""),
267            Self::Copy { from, to } => (to.as_str(), 1, from.as_str()),
268            Self::CheckedCast { from, to, .. } => (to.as_str(), 2, from.as_str()),
269            Self::Coalesce { from, to, .. } => (to.as_str(), 3, from.as_str()),
270        }
271    }
272
273    fn validate(&self) -> Result<(), SchemaContractError> {
274        match self {
275            Self::Fill { literal, .. } | Self::Coalesce { literal, .. } => literal.validate(),
276            Self::Copy { from, to } if from == to => {
277                Err(SchemaContractError::InvalidMigrationTransform)
278            }
279            Self::CheckedCast { target, .. } if !is_v1_cast_target(*target) => {
280                Err(SchemaContractError::InvalidMigrationTransform)
281            }
282            Self::Copy { .. } | Self::CheckedCast { .. } => Ok(()),
283        }
284    }
285}
286
287/// One immediate-predecessor transition for a current entity declaration.
288#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
289pub struct EntityMigration {
290    #[serde(
291        deserialize_with = "crate::codec::deserialize_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
292    )]
293    entity: EntitySourceKey,
294    from: DeclaredEntityVersion,
295    #[serde(
296        deserialize_with = "crate::codec::deserialize_optional_key::<_, _, { crate::MAX_SOURCE_KEY_BYTES }>"
297    )]
298    from_name: Option<EntitySourceKey>,
299    #[serde(
300        deserialize_with = "crate::codec::deserialize_list::<_, _, { crate::MAX_SCHEMA_MIGRATION_RENAMES }>"
301    )]
302    renames: Vec<SchemaMigrationRename>,
303    #[serde(
304        deserialize_with = "crate::codec::deserialize_list::<_, _, { crate::MAX_SCHEMA_MIGRATION_TRANSFORMS }>"
305    )]
306    transforms: Vec<SchemaMigrationTransform>,
307}
308
309impl EntityMigration {
310    /// Construct and canonicalize one entity transition.
311    ///
312    /// A declaration without local operations is reserved for a dependency-only
313    /// entity-rename change, proved against accepted source meaning by the planner.
314    ///
315    /// # Errors
316    ///
317    /// Returns a typed migration error for a no-op
318    /// entity rename, duplicate rename ownership, duplicate transform target,
319    /// or invalid transform.
320    pub fn try_new(
321        entity: EntitySourceKey,
322        from: DeclaredEntityVersion,
323        from_name: Option<EntitySourceKey>,
324        mut renames: Vec<SchemaMigrationRename>,
325        mut transforms: Vec<SchemaMigrationTransform>,
326    ) -> Result<Self, SchemaContractError> {
327        check_len(
328            "migration renames",
329            renames.len(),
330            MAX_SCHEMA_MIGRATION_RENAMES,
331        )?;
332        check_len(
333            "migration transforms",
334            transforms.len(),
335            MAX_SCHEMA_MIGRATION_TRANSFORMS,
336        )?;
337        if from_name.as_ref() == Some(&entity) {
338            return Err(SchemaContractError::InvalidMigrationPlan);
339        }
340        for rename in &renames {
341            rename.validate()?;
342        }
343        for transform in &transforms {
344            transform.validate()?;
345        }
346        ensure_distinct_rename_ownership(&renames)?;
347        ensure_distinct_transform_targets(&transforms)?;
348        crate::compact_sort_unstable_by(&mut renames, |left, right| {
349            left.sort_key().cmp(&right.sort_key())
350        });
351        crate::compact_sort_unstable_by(&mut transforms, |left, right| {
352            left.sort_key().cmp(&right.sort_key())
353        });
354        Ok(Self {
355            entity,
356            from,
357            from_name,
358            renames,
359            transforms,
360        })
361    }
362
363    // Serde restores fields without calling the constructor. Keep its semantic
364    // and canonical guarantees at the plan admission boundary as well.
365    fn validate(&self) -> Result<(), SchemaContractError> {
366        let rebuilt = Self::try_new(
367            self.entity.clone(),
368            self.from,
369            self.from_name.clone(),
370            self.renames.clone(),
371            self.transforms.clone(),
372        )?;
373        if rebuilt != *self {
374            return Err(SchemaContractError::NonCanonical);
375        }
376        Ok(())
377    }
378
379    /// Borrow the current target entity key.
380    #[must_use]
381    pub const fn entity(&self) -> &EntitySourceKey {
382        &self.entity
383    }
384
385    /// Return the accepted-before declared entity version.
386    #[must_use]
387    pub const fn from(&self) -> DeclaredEntityVersion {
388        self.from
389    }
390
391    /// Borrow an optional accepted-before entity name.
392    #[must_use]
393    pub const fn from_name(&self) -> Option<&EntitySourceKey> {
394        self.from_name.as_ref()
395    }
396
397    /// Borrow canonical rename operations.
398    #[must_use]
399    pub fn renames(&self) -> &[SchemaMigrationRename] {
400        &self.renames
401    }
402
403    /// Borrow canonical historical transforms.
404    #[must_use]
405    pub fn transforms(&self) -> &[SchemaMigrationTransform] {
406        &self.transforms
407    }
408}
409
410/// One canonical database-scoped coordinated migration plan.
411#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
412pub struct SchemaMigrationPlan {
413    program_version: u16,
414    #[serde(
415        deserialize_with = "crate::codec::deserialize_list::<_, _, { crate::MAX_SCHEMA_MIGRATION_ENTITIES }>"
416    )]
417    transitions: Vec<EntityMigration>,
418    #[serde(skip)]
419    digest: SchemaMigrationPlanDigest,
420}
421
422impl SchemaMigrationPlan {
423    /// Construct, canonicalize, bound, and digest one coordinated plan.
424    ///
425    /// # Errors
426    ///
427    /// Returns a typed migration or encoded-size error for an empty,
428    /// duplicate, ambiguous, or oversized plan.
429    pub fn try_new(mut transitions: Vec<EntityMigration>) -> Result<Self, SchemaContractError> {
430        check_len(
431            "migration entity transitions",
432            transitions.len(),
433            MAX_SCHEMA_MIGRATION_ENTITIES,
434        )?;
435        if transitions.is_empty() {
436            return Err(SchemaContractError::InvalidMigrationPlan);
437        }
438        crate::compact_sort_unstable_by(&mut transitions, |left, right| {
439            left.entity.cmp(&right.entity)
440        });
441        if transitions
442            .windows(2)
443            .any(|pair| pair[0].entity == pair[1].entity)
444        {
445            return Err(SchemaContractError::DuplicateMigrationTarget);
446        }
447        let mut predecessor_entities = BTreeSet::new();
448        for transition in &transitions {
449            transition.validate()?;
450            let predecessor = transition.from_name.as_ref().unwrap_or(&transition.entity);
451            if !predecessor_entities.insert(predecessor.clone()) {
452                return Err(SchemaContractError::DuplicateMigrationSource);
453            }
454        }
455        let transition_bytes = crate::encode_migration_transitions_for_digest(&transitions)?;
456        let digest = digest_plan_transitions(&transition_bytes);
457        let plan = Self {
458            program_version: MIGRATION_PROGRAM_VERSION_CURRENT,
459            transitions,
460            digest,
461        };
462        Ok(plan)
463    }
464
465    /// Validate a complete authored target fragment against this migration plan.
466    ///
467    /// Uses the proposal's source-reference, closure and version rules without
468    /// requiring runtime database identities, stores or an accepted head. This
469    /// does not qualify the predecessor's retained data or authorize migration.
470    ///
471    /// # Errors
472    ///
473    /// Returns the proposal contract error for malformed inputs, missing target
474    /// definitions, version gaps, or invalid rename/transform targets.
475    pub fn validate_target_fragment(
476        &self,
477        fragment: &crate::SchemaFragment,
478    ) -> Result<(), SchemaContractError> {
479        self.validate()?;
480        fragment.validate()?;
481        let entities = fragment
482            .entities()
483            .iter()
484            .map(|entity| (entity.source_key().clone(), entity))
485            .collect();
486        let types = fragment
487            .types()
488            .iter()
489            .map(|ty| (ty.source_key().clone(), ty))
490            .collect();
491        crate::proposal::validate_proposal_closure(
492            &crate::ExpectedAcceptedHead::Empty,
493            &entities,
494            &types,
495            &[],
496        )?;
497        crate::proposal::validate_migration_plan(self, &entities, &types)
498    }
499
500    /// Return the closed migration-program version.
501    #[must_use]
502    pub const fn program_version(&self) -> u16 {
503        self.program_version
504    }
505
506    /// Borrow canonical entity transitions.
507    #[must_use]
508    pub fn transitions(&self) -> &[EntityMigration] {
509        &self.transitions
510    }
511
512    /// Return the exact canonical plan digest.
513    #[must_use]
514    pub const fn digest(&self) -> SchemaMigrationPlanDigest {
515        self.digest
516    }
517
518    pub(crate) fn validate(&self) -> Result<(), SchemaContractError> {
519        if self.program_version != MIGRATION_PROGRAM_VERSION_CURRENT {
520            return Err(SchemaContractError::UnsupportedMigrationProgramVersion {
521                found: self.program_version,
522                supported: MIGRATION_PROGRAM_VERSION_CURRENT,
523            });
524        }
525        let rebuilt = Self::try_new(self.transitions.clone())?;
526        if rebuilt != *self {
527            return Err(SchemaContractError::NonCanonical);
528        }
529        Ok(())
530    }
531}
532
533// Recompute the derived digest through the canonical constructor; never admit
534// an independently supplied digest as plan authority.
535impl<'de> Deserialize<'de> for SchemaMigrationPlan {
536    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
537        #[derive(Deserialize)]
538        struct Payload {
539            program_version: u16,
540            #[serde(
541                deserialize_with = "crate::codec::deserialize_list::<_, _, { crate::MAX_SCHEMA_MIGRATION_ENTITIES }>"
542            )]
543            transitions: Vec<EntityMigration>,
544        }
545        let payload = Payload::deserialize(deserializer)?;
546        let mut plan = Self::try_new(payload.transitions).map_err(serde::de::Error::custom)?;
547        plan.program_version = payload.program_version;
548        Ok(plan)
549    }
550}
551
552fn digest_plan_transitions(bytes: &[u8]) -> SchemaMigrationPlanDigest {
553    let mut hasher = Sha256::new();
554    hasher.update(MIGRATION_PLAN_DIGEST_PROFILE);
555    hasher.update(MIGRATION_PROGRAM_VERSION_CURRENT.to_be_bytes());
556    hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
557    hasher.update(bytes);
558    SchemaMigrationPlanDigest::from_bytes(hasher.finalize().into())
559}
560
561fn ensure_distinct_rename_ownership(
562    renames: &[SchemaMigrationRename],
563) -> Result<(), SchemaContractError> {
564    let mut sources = BTreeSet::new();
565    let mut targets = BTreeSet::new();
566    for rename in renames {
567        let (kind, owner, from, to) = rename.sort_key();
568        if !sources.insert((kind, owner, from)) {
569            return Err(SchemaContractError::DuplicateMigrationSource);
570        }
571        if !targets.insert((kind, owner, to)) {
572            return Err(SchemaContractError::DuplicateMigrationTarget);
573        }
574    }
575    Ok(())
576}
577
578fn ensure_distinct_transform_targets(
579    transforms: &[SchemaMigrationTransform],
580) -> Result<(), SchemaContractError> {
581    let mut targets = BTreeSet::new();
582    for transform in transforms {
583        if !targets.insert(transform.target()) {
584            return Err(SchemaContractError::DuplicateMigrationTarget);
585        }
586    }
587    Ok(())
588}
589
590const fn is_v1_cast_target(target: ScalarType) -> bool {
591    matches!(
592        target,
593        ScalarType::Int8
594            | ScalarType::Int16
595            | ScalarType::Int32
596            | ScalarType::Int64
597            | ScalarType::Int128
598            | ScalarType::Nat8
599            | ScalarType::Nat16
600            | ScalarType::Nat32
601            | ScalarType::Nat64
602            | ScalarType::Nat128
603            | ScalarType::Decimal { .. }
604    )
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use crate::{
611        MAX_SCHEMA_MIGRATION_PLAN_BYTES, decode_schema_migration_plan, encode_schema_migration_plan,
612    };
613
614    fn entity(value: &str) -> EntitySourceKey {
615        EntitySourceKey::try_new(value).expect("entity source should admit")
616    }
617
618    fn field(value: &str) -> FieldSourceKey {
619        FieldSourceKey::try_new(value).expect("field source should admit")
620    }
621
622    fn transition(entity_name: &str, from_name: &str) -> EntityMigration {
623        EntityMigration::try_new(
624            entity(entity_name),
625            DeclaredEntityVersion::try_new(1).expect("version should admit"),
626            Some(entity(from_name)),
627            vec![SchemaMigrationRename::Field {
628                from: field("old_value"),
629                to: field("value"),
630            }],
631            Vec::new(),
632        )
633        .expect("transition should admit")
634    }
635
636    #[test]
637    fn declared_entity_version_is_strictly_positive() {
638        assert_eq!(
639            DeclaredEntityVersion::try_new(0),
640            Err(SchemaContractError::InvalidEntityVersion),
641        );
642        assert_eq!(
643            DeclaredEntityVersion::try_new(1)
644                .expect("version one should admit")
645                .get(),
646            1,
647        );
648    }
649
650    #[test]
651    fn plan_canonicalization_is_declaration_order_independent() {
652        let account = transition("Account", "User");
653        let article = transition("Article", "Post");
654        let first = SchemaMigrationPlan::try_new(vec![account.clone(), article.clone()])
655            .expect("plan should admit");
656        let reverse = SchemaMigrationPlan::try_new(vec![article, account])
657            .expect("reverse plan should admit");
658
659        assert_eq!(first, reverse);
660        assert_eq!(first.digest(), reverse.digest());
661        let encoded = encode_schema_migration_plan(&first).expect("plan should encode");
662        assert_eq!(
663            decode_schema_migration_plan(&encoded).expect("plan should decode"),
664            first,
665        );
666    }
667
668    #[test]
669    fn every_current_migration_operation_roundtrips_exactly() {
670        let transition = EntityMigration::try_new(
671            entity("Account"),
672            DeclaredEntityVersion::try_new(3).expect("version should admit"),
673            Some(entity("User")),
674            vec![
675                SchemaMigrationRename::Field {
676                    from: field("old_field"),
677                    to: field("new_field"),
678                },
679                SchemaMigrationRename::NamedType {
680                    from: TypeSourceKey::try_new("OldType").expect("type should admit"),
681                    to: TypeSourceKey::try_new("NewType").expect("type should admit"),
682                },
683                SchemaMigrationRename::EnumVariant {
684                    named_type: TypeSourceKey::try_new("OldEnum").expect("type should admit"),
685                    from: TypeSourceKey::try_new("OldVariant").expect("variant should admit"),
686                    to: TypeSourceKey::try_new("NewVariant").expect("variant should admit"),
687                },
688                SchemaMigrationRename::RecordField {
689                    named_type: TypeSourceKey::try_new("OldRecord").expect("type should admit"),
690                    from: field("old_member"),
691                    to: field("new_member"),
692                },
693                SchemaMigrationRename::Relation {
694                    from: RelationSourceKey::try_new("old_relation")
695                        .expect("relation should admit"),
696                    to: RelationSourceKey::try_new("new_relation").expect("relation should admit"),
697                },
698                SchemaMigrationRename::Constraint {
699                    from: ConstraintSourceKey::try_new("old_constraint")
700                        .expect("constraint should admit"),
701                    to: ConstraintSourceKey::try_new("new_constraint")
702                        .expect("constraint should admit"),
703                },
704                SchemaMigrationRename::Rule {
705                    named_type: TypeSourceKey::try_new("OldRuleOwner").expect("type should admit"),
706                    from: RuleSourceKey::try_new("old_rule").expect("rule should admit"),
707                    to: RuleSourceKey::try_new("new_rule").expect("rule should admit"),
708                },
709            ],
710            vec![
711                SchemaMigrationTransform::Fill {
712                    to: field("filled"),
713                    literal: ScalarLiteral::Nat(1),
714                },
715                SchemaMigrationTransform::Copy {
716                    from: field("copy_source"),
717                    to: field("copied"),
718                },
719                SchemaMigrationTransform::CheckedCast {
720                    from: field("cast_source"),
721                    to: field("casted"),
722                    target: ScalarType::Nat64,
723                },
724                SchemaMigrationTransform::Coalesce {
725                    from: field("nullable_source"),
726                    to: field("coalesced"),
727                    literal: ScalarLiteral::Nat(0),
728                },
729            ],
730        )
731        .expect("transition should admit");
732        let plan = SchemaMigrationPlan::try_new(vec![transition]).expect("plan should admit");
733        let encoded = encode_schema_migration_plan(&plan).expect("plan should encode");
734
735        assert_eq!(
736            decode_schema_migration_plan(&encoded).expect("plan should decode"),
737            plan,
738        );
739    }
740
741    #[test]
742    fn plan_digest_has_one_fixed_current_vector() {
743        let plan = SchemaMigrationPlan::try_new(vec![transition("Account", "User")])
744            .expect("plan should admit");
745        assert_eq!(
746            plan.digest().to_bytes(),
747            [
748                185, 94, 228, 94, 125, 249, 118, 171, 39, 218, 171, 230, 105, 177, 195, 14, 41, 14,
749                39, 78, 248, 107, 177, 13, 16, 115, 37, 15, 145, 40, 134, 207,
750            ],
751        );
752    }
753
754    #[test]
755    fn dependency_only_companion_roundtrips_exactly() {
756        let companion = EntityMigration::try_new(
757            entity("Holder"),
758            DeclaredEntityVersion::try_new(1).unwrap(),
759            None,
760            Vec::new(),
761            Vec::new(),
762        )
763        .unwrap();
764        let plan = SchemaMigrationPlan::try_new(vec![transition("CatalogItem", "Item"), companion])
765            .unwrap();
766        let encoded = crate::encode_schema_migration_plan(&plan).unwrap();
767        assert_eq!(crate::decode_schema_migration_plan(&encoded).unwrap(), plan);
768    }
769
770    #[test]
771    fn duplicate_targets_predecessors_and_transform_targets_reject() {
772        let account = transition("Account", "User");
773        assert_eq!(
774            SchemaMigrationPlan::try_new(vec![account.clone(), account]),
775            Err(SchemaContractError::DuplicateMigrationTarget),
776        );
777        assert_eq!(
778            SchemaMigrationPlan::try_new(vec![
779                transition("Account", "User"),
780                transition("Profile", "User"),
781            ]),
782            Err(SchemaContractError::DuplicateMigrationSource),
783        );
784        assert_eq!(
785            EntityMigration::try_new(
786                entity("Account"),
787                DeclaredEntityVersion::try_new(1).expect("version should admit"),
788                None,
789                Vec::new(),
790                vec![
791                    SchemaMigrationTransform::Fill {
792                        to: field("value"),
793                        literal: ScalarLiteral::Nat(1),
794                    },
795                    SchemaMigrationTransform::Copy {
796                        from: field("old_value"),
797                        to: field("value"),
798                    },
799                ],
800            ),
801            Err(SchemaContractError::DuplicateMigrationTarget),
802        );
803    }
804
805    #[test]
806    fn wire_plan_restoration_keeps_nested_constructor_admission() {
807        let valid = transition("Account", "User");
808        let mut duplicate = valid.clone();
809        duplicate.renames.push(duplicate.renames[0].clone());
810        let mut same_name = valid.clone();
811        same_name.from_name = Some(same_name.entity.clone());
812        let mut invalid_cast = valid;
813        invalid_cast
814            .transforms
815            .push(SchemaMigrationTransform::CheckedCast {
816                from: field("old_value"),
817                to: field("value"),
818                target: ScalarType::Float64,
819            });
820        for transition in [duplicate, same_name, invalid_cast] {
821            let bytes = crate::wire::encode_one((*b"ICWM\x01", (1_u16, vec![transition]))).unwrap();
822            assert!(decode_schema_migration_plan(&bytes).is_err());
823        }
824    }
825
826    #[test]
827    fn unsupported_program_versions_and_oversized_transport_fail_closed() {
828        let plan = SchemaMigrationPlan::try_new(vec![transition("Account", "User")])
829            .expect("plan should admit");
830        let mut bytes = encode_schema_migration_plan(&plan).expect("plan should encode");
831        bytes[5] = 2;
832        assert_eq!(
833            decode_schema_migration_plan(&bytes),
834            Err(SchemaContractError::UnsupportedMigrationProgramVersion {
835                found: 2,
836                supported: 1,
837            }),
838        );
839        assert!(matches!(
840            decode_schema_migration_plan(&vec![0; MAX_SCHEMA_MIGRATION_PLAN_BYTES + 1]),
841            Err(SchemaContractError::EncodedTooLarge { .. }),
842        ));
843    }
844}