Skip to main content

sim_relation_migrate/
migration.rs

1//! Migration program model, admission, derivation, and attestations.
2//!
3//! Migration programs are admitted before a provider sees them. Admission
4//! proves a single revision chain, exact schema transitions, typed backfills,
5//! and the declared final target.
6//!
7//! ```
8//! use sim_relation_migrate::{derive_lossless, OperationKind};
9//! use sim_relation_schema::{fixtures, AcceptAllValues};
10//! let schema = fixtures::document(&AcceptAllValues).unwrap();
11//! assert!(derive_lossless(&schema, &schema).unwrap().is_empty());
12//! let _: Option<OperationKind> = None;
13//! ```
14
15use sim_relation_core::{ColumnName, ConstraintName, IndexName, RelationId, TableName};
16use sim_relation_plan::CheckedMutation;
17use sim_relation_schema::{Column, Constraint, Index, Schema, Table};
18
19/// A precisely described schema edit.
20#[derive(Clone, Debug)]
21pub enum OperationKind {
22    /// Create a table.
23    CreateTable(Table),
24    /// Drop a table and its data.
25    DropTable(TableName),
26    /// Rename a table.
27    RenameTable {
28        /// Old name.
29        from: TableName,
30        /// New name.
31        to: TableName,
32    },
33    /// Add a column.
34    AddColumn {
35        /// Owning table.
36        table: TableName,
37        /// New column.
38        column: Column,
39    },
40    /// Drop a column and its data.
41    DropColumn {
42        /// Owning table.
43        table: TableName,
44        /// Removed column.
45        column: ColumnName,
46    },
47    /// Rename a column.
48    RenameColumn {
49        /// Owning table.
50        table: TableName,
51        /// Old name.
52        from: ColumnName,
53        /// New name.
54        to: ColumnName,
55    },
56    /// Alter a column domain, nullability, default, or generation rule.
57    AlterColumn {
58        /// Owning table.
59        table: TableName,
60        /// Changed column.
61        column: ColumnName,
62    },
63    /// Add a constraint.
64    AddConstraint {
65        /// Owning table.
66        table: TableName,
67        /// Added constraint.
68        constraint: Constraint,
69    },
70    /// Drop a constraint.
71    DropConstraint {
72        /// Owning table.
73        table: TableName,
74        /// Removed constraint.
75        constraint: ConstraintName,
76    },
77    /// Add an index.
78    AddIndex {
79        /// Owning table.
80        table: TableName,
81        /// Added index.
82        index: Index,
83    },
84    /// Drop an index.
85    DropIndex {
86        /// Owning table.
87        table: TableName,
88        /// Removed index.
89        index: IndexName,
90    },
91    /// Run an already admitted data mutation during the transition.
92    Backfill(Box<CheckedMutation>),
93}
94
95/// One exact state transition. The complete output snapshot makes omission
96/// impossible: the declared operation cannot claim a target it does not produce.
97#[derive(Clone, Debug)]
98pub struct Operation {
99    before: RelationId,
100    after: Schema,
101    kind: OperationKind,
102}
103impl Operation {
104    /// Creates an operation from its exact input identity and output snapshot.
105    pub fn new(before: RelationId, after: Schema, kind: OperationKind) -> Self {
106        Self {
107            before,
108            after,
109            kind,
110        }
111    }
112    /// Required input schema identity.
113    pub fn before(&self) -> &RelationId {
114        &self.before
115    }
116    /// Produced schema.
117    pub fn after(&self) -> &Schema {
118        &self.after
119    }
120    /// Described edit.
121    pub fn kind(&self) -> &OperationKind {
122        &self.kind
123    }
124}
125
126/// An authored revision in a strictly linear history.
127#[derive(Clone, Debug)]
128pub struct Revision {
129    id: RelationId,
130    parent: Option<RelationId>,
131    target: RelationId,
132    operations: Vec<Operation>,
133}
134impl Revision {
135    /// Creates a revision with a stable caller-issued id, exact parent, target,
136    /// and ordered operations.
137    pub fn new(
138        id: RelationId,
139        parent: Option<RelationId>,
140        target: RelationId,
141        operations: Vec<Operation>,
142    ) -> Self {
143        Self {
144            id,
145            parent,
146            target,
147            operations,
148        }
149    }
150    /// Revision identity.
151    pub fn id(&self) -> &RelationId {
152        &self.id
153    }
154    /// Exact predecessor revision.
155    pub fn parent(&self) -> Option<&RelationId> {
156        self.parent.as_ref()
157    }
158    /// Declared logical target.
159    pub fn target(&self) -> &RelationId {
160        &self.target
161    }
162    /// Ordered operations.
163    pub fn operations(&self) -> &[Operation] {
164        &self.operations
165    }
166}
167
168/// An authored upgrade path.
169#[derive(Clone, Debug)]
170pub struct MigrationProgram {
171    /// Identity of the revision already applied at the starting state.
172    pub base_revision: RelationId,
173    /// Exact starting logical schema.
174    pub base_schema: Schema,
175    /// Ordered, linear revisions.
176    pub revisions: Vec<Revision>,
177    /// Required final logical schema identity.
178    pub target_schema: RelationId,
179}
180
181/// Opaque proof that a migration program passed simulation.
182#[derive(Clone, Debug)]
183pub struct CheckedProgram {
184    program: MigrationProgram,
185}
186impl CheckedProgram {
187    /// Returns the admitted program for provider execution.
188    pub fn program(&self) -> &MigrationProgram {
189        &self.program
190    }
191}
192
193/// Failure to prove a migration program.
194#[derive(Clone, Debug, PartialEq, Eq)]
195pub enum MigrationError {
196    /// A revision does not name the preceding revision.
197    WrongParent,
198    /// An operation does not consume the current simulated schema.
199    StaleBefore,
200    /// A backfill was admitted against a different schema.
201    InvalidBackfill,
202    /// A revision does not produce its declared target.
203    RevisionTargetMismatch,
204    /// The program does not produce its final target.
205    ProgramTargetMismatch,
206    /// An operation's claimed edit does not match the before/after snapshots.
207    IncompleteOperationCoverage,
208    /// The requested automatic diff is destructive, narrowing, or ambiguous.
209    AuthoredOperationRequired,
210    /// Schema identity could not be calculated.
211    Identity,
212}
213
214/// Simulates and admits the whole migration program.
215pub fn admit(program: MigrationProgram) -> Result<CheckedProgram, MigrationError> {
216    let mut schema = program.base_schema.clone();
217    let mut parent = program.base_revision.clone();
218    for revision in &program.revisions {
219        if revision.parent.as_ref() != Some(&parent) {
220            return Err(MigrationError::WrongParent);
221        }
222        for operation in &revision.operations {
223            let current = schema.id().map_err(|_| MigrationError::Identity)?;
224            if operation.before != current {
225                return Err(MigrationError::StaleBefore);
226            }
227            validate_operation(&schema, operation)?;
228            if let OperationKind::Backfill(mutation) = &operation.kind
229                && mutation.schema_id() != &current
230            {
231                return Err(MigrationError::InvalidBackfill);
232            }
233            schema = operation.after.clone();
234        }
235        if schema.id().map_err(|_| MigrationError::Identity)? != revision.target {
236            return Err(MigrationError::RevisionTargetMismatch);
237        }
238        parent = revision.id.clone();
239    }
240    if schema.id().map_err(|_| MigrationError::Identity)? != program.target_schema {
241        return Err(MigrationError::ProgramTargetMismatch);
242    }
243    Ok(CheckedProgram { program })
244}
245
246fn validate_operation(before: &Schema, operation: &Operation) -> Result<(), MigrationError> {
247    let after = &operation.after;
248    let bt = before.tables();
249    let at = after.tables();
250    let ok = match &operation.kind {
251        OperationKind::CreateTable(table) => {
252            !has_table(bt, table.name())
253                && has_table(at, table.name())
254                && at.len() == bt.len() + 1
255                && bt.iter().all(|old| at.contains(old))
256        }
257        OperationKind::DropTable(name) => {
258            has_table(bt, name)
259                && !has_table(at, name)
260                && bt.len() == at.len() + 1
261                && at.iter().all(|new| bt.contains(new))
262        }
263        OperationKind::AddColumn { table, column } => {
264            table_pair(bt, at, table).is_some_and(|(b, a)| {
265                !has_column(b, column.name())
266                    && has_column(a, column.name())
267                    && a.columns().len() == b.columns().len() + 1
268                    && b.columns().iter().all(|old| a.columns().contains(old))
269                    && b.constraints() == a.constraints()
270                    && b.indexes() == a.indexes()
271                    && same_other_tables(bt, at, table)
272            })
273        }
274        OperationKind::DropColumn { table, column } => {
275            table_pair(bt, at, table).is_some_and(|(b, a)| {
276                has_column(b, column)
277                    && !has_column(a, column)
278                    && b.columns().len() == a.columns().len() + 1
279                    && a.columns().iter().all(|new| b.columns().contains(new))
280                    && b.constraints() == a.constraints()
281                    && b.indexes() == a.indexes()
282                    && same_other_tables(bt, at, table)
283            })
284        }
285        OperationKind::AddConstraint { table, constraint } => table_pair(bt, at, table)
286            .is_some_and(|(b, a)| {
287                a.constraints().len() == b.constraints().len() + 1
288                    && a.constraints().contains(constraint)
289            }),
290        OperationKind::DropConstraint { table, .. } => table_pair(bt, at, table)
291            .is_some_and(|(b, a)| b.constraints().len() == a.constraints().len() + 1),
292        OperationKind::AddIndex { table, index } => {
293            table_pair(bt, at, table).is_some_and(|(b, a)| {
294                a.indexes().len() == b.indexes().len() + 1 && a.indexes().contains(index)
295            })
296        }
297        OperationKind::DropIndex { table, .. } => table_pair(bt, at, table)
298            .is_some_and(|(b, a)| b.indexes().len() == a.indexes().len() + 1),
299        OperationKind::RenameTable { from, to } => {
300            has_table(bt, from) && !has_table(bt, to) && !has_table(at, from) && has_table(at, to)
301        }
302        OperationKind::RenameColumn { table, from, to } => {
303            table_pair(bt, at, table).is_some_and(|(b, a)| {
304                has_column(b, from)
305                    && !has_column(b, to)
306                    && !has_column(a, from)
307                    && has_column(a, to)
308            })
309        }
310        OperationKind::AlterColumn { table, column } => {
311            table_pair(bt, at, table).is_some_and(|(b, a)| {
312                has_column(b, column) && has_column(a, column) && b.columns() != a.columns()
313            })
314        }
315        OperationKind::Backfill(_) => before.id().ok() == after.id().ok(),
316    };
317    if ok {
318        Ok(())
319    } else {
320        Err(MigrationError::IncompleteOperationCoverage)
321    }
322}
323fn has_table(tables: &[Table], name: &TableName) -> bool {
324    tables.iter().any(|t| t.name() == name)
325}
326fn has_column(table: &Table, name: &ColumnName) -> bool {
327    table.columns().iter().any(|c| c.name() == name)
328}
329fn table_pair<'a>(
330    before: &'a [Table],
331    after: &'a [Table],
332    name: &TableName,
333) -> Option<(&'a Table, &'a Table)> {
334    Some((
335        before.iter().find(|t| t.name() == name)?,
336        after.iter().find(|t| t.name() == name)?,
337    ))
338}
339fn same_other_tables(before: &[Table], after: &[Table], changed: &TableName) -> bool {
340    before.len() == after.len()
341        && before
342            .iter()
343            .filter(|table| table.name() != changed)
344            .all(|table| after.contains(table))
345}
346
347/// Derives only lossless table creation and nullable column addition operations.
348/// Every other difference fails closed and requires authored intent.
349pub fn derive_lossless(before: &Schema, after: &Schema) -> Result<Vec<Operation>, MigrationError> {
350    let mut operations = Vec::new();
351    let mut current = before.clone();
352    for table in after.tables() {
353        match current.tables().iter().find(|t| t.name() == table.name()) {
354            None => {
355                if after.tables().len() != before.tables().len() + 1 {
356                    return Err(MigrationError::AuthoredOperationRequired);
357                }
358                operations.push(Operation::new(
359                    current.id().map_err(|_| MigrationError::Identity)?,
360                    after.clone(),
361                    OperationKind::CreateTable(table.clone()),
362                ));
363                current = after.clone();
364            }
365            Some(old) if old != table => {
366                let additions: Vec<_> = table
367                    .columns()
368                    .iter()
369                    .filter(|c| !has_column(old, c.name()))
370                    .collect();
371                if additions.len() != 1
372                    || !additions[0].nullable()
373                    || table.columns().len() != old.columns().len() + 1
374                    || after.tables().len() != before.tables().len()
375                {
376                    return Err(MigrationError::AuthoredOperationRequired);
377                }
378                operations.push(Operation::new(
379                    current.id().map_err(|_| MigrationError::Identity)?,
380                    after.clone(),
381                    OperationKind::AddColumn {
382                        table: table.name().clone(),
383                        column: additions[0].clone(),
384                    },
385                ));
386                current = after.clone();
387            }
388            _ => {}
389        }
390    }
391    if current.id().map_err(|_| MigrationError::Identity)?
392        != after.id().map_err(|_| MigrationError::Identity)?
393    {
394        return Err(MigrationError::AuthoredOperationRequired);
395    }
396    Ok(operations)
397}
398
399/// Provider features needed for safely applying an admitted program.
400#[derive(Clone, Copy, Debug, PartialEq, Eq)]
401pub struct MigrationCapabilities {
402    /// Provider applies all DDL and backfills atomically.
403    pub transactional_ddl: bool,
404    /// Provider can normalize and report live objects after application.
405    pub post_apply_introspection: bool,
406}
407impl MigrationCapabilities {
408    /// Requires both safety capabilities.
409    pub fn require(self) -> Result<(), CapabilityError> {
410        if self.transactional_ddl && self.post_apply_introspection {
411            Ok(())
412        } else {
413            Err(CapabilityError)
414        }
415    }
416}
417/// Missing provider migration capability.
418#[derive(Clone, Copy, Debug, PartialEq, Eq)]
419pub struct CapabilityError;
420
421/// Signed or otherwise provider-authenticated evidence of observed state.
422#[derive(Clone, Debug, PartialEq, Eq)]
423pub struct SchemaAttestation {
424    /// Admitted logical schema.
425    pub logical_schema: RelationId,
426    /// Normalized live physical-object identity.
427    pub physical_schema: RelationId,
428    /// Revision actually applied.
429    pub revision: RelationId,
430}
431
432/// Exact adoption declaration for an existing store.
433#[derive(Clone, Debug, PartialEq, Eq)]
434pub struct AdoptionManifest {
435    /// Logical schema the old file is claimed to implement.
436    pub logical_schema: RelationId,
437    /// Exact normalized identity observed from that file.
438    pub physical_schema: RelationId,
439}
440impl AdoptionManifest {
441    /// Accepts adoption only when live introspection exactly matches the
442    /// authored file identity; a metadata row cannot override drift.
443    pub fn verify(&self, live_physical_schema: &RelationId) -> Result<(), AdoptionError> {
444        if &self.physical_schema == live_physical_schema {
445            Ok(())
446        } else {
447            Err(AdoptionError::ExternalDrift)
448        }
449    }
450}
451/// Adoption verification failure.
452#[derive(Clone, Copy, Debug, PartialEq, Eq)]
453pub enum AdoptionError {
454    /// Live managed objects differ from the manifest.
455    ExternalDrift,
456}