Skip to main content

wasm_dbms_api/dbms/
migration.rs

1//! Schema migration types and traits.
2//!
3//! The migration subsystem lets compiled `#[derive(Table)]` schemas evolve
4//! across releases without manual stable-memory surgery. The flow is:
5//!
6//! 1. On boot, the DBMS compares the hash of every compiled
7//!    [`TableSchemaSnapshot`](crate::dbms::table::TableSchemaSnapshot) against
8//!    the hash stored in the schema registry.
9//! 2. If they differ, the DBMS enters drift state and refuses CRUD.
10//! 3. The user calls `Dbms::migrate(policy)`, which diffs the stored snapshots
11//!    against the compiled ones and produces a [`Vec<MigrationOp>`].
12//! 4. The ops are applied transactionally; on success the new snapshots and
13//!    schema hash are persisted and the drift flag is cleared.
14//!
15//! This module owns the *types* (`MigrationOp`, `ColumnChanges`,
16//! `MigrationPolicy`, `MigrationError`) and the per-table extension hook
17//! [`Migrate`]. The diff algorithm and apply logic live in the engine crate.
18
19use serde::{Deserialize, Deserializer, Serialize};
20use thiserror::Error;
21
22use crate::dbms::table::{
23    ColumnSnapshot, DataTypeSnapshot, ForeignKeySnapshot, IndexSnapshot, TableSchema,
24};
25use crate::dbms::value::Value;
26use crate::error::DbmsResult;
27
28/// Per-table extension hook for schema migrations.
29///
30/// The derive macro `#[derive(Table)]` emits an empty `impl Migrate for T {}`
31/// for every table by default, so callers only need to provide a manual impl
32/// when they tag the struct with `#[migrate]`. The trait extends
33/// [`TableSchema`] so implementors automatically have access to the table's
34/// column definitions and snapshot.
35pub trait Migrate
36where
37    Self: TableSchema,
38{
39    /// Dynamic default for an [`MigrationOp::AddColumn`] operation on a non-nullable column.
40    ///
41    /// Returning `None` falls back to the static `#[default = ...]` attribute
42    /// declared on the column. If neither produces a value, migration aborts
43    /// with [`MigrationError::DefaultMissing`].
44    fn default_value(_column: &str) -> Option<Value> {
45        None
46    }
47
48    /// Transform a stored value when its column changes to an incompatible
49    /// type that does not fit the framework's widening whitelist.
50    ///
51    /// - `Ok(None)` — no transform; the framework errors with
52    ///   [`MigrationError::IncompatibleType`] unless widening already applies.
53    /// - `Ok(Some(v))` — use `v` as the new value.
54    /// - `Err(_)` — abort the migration; the journaled session rolls back.
55    fn transform_column(_column: &str, _old: Value) -> DbmsResult<Option<Value>> {
56        Ok(None)
57    }
58}
59
60/// Single atomic step produced by the migration planner.
61///
62/// Ops are sorted into a deterministic apply order (creates → drops → renames
63/// → relaxations → widening/transforms → adds → tightenings → indexes →
64/// table drops) and then executed inside a single journaled session.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[cfg_attr(feature = "candid", derive(candid::CandidType))]
67pub enum MigrationOp {
68    /// Create a new table with the given snapshot.
69    CreateTable {
70        /// Name of the new table.
71        name: String,
72        /// Snapshot of the compiled schema for the new table.
73        schema: crate::dbms::table::TableSchemaSnapshot,
74    },
75    /// Drop a table and all of its data. Destructive.
76    DropTable {
77        /// Name of the table to drop.
78        name: String,
79    },
80    /// Append a new column to an existing table.
81    ///
82    /// If the column is non-nullable, the planner must have resolved a default
83    /// value (`#[default]` or [`Migrate::default_value`]) before emitting this
84    /// op.
85    AddColumn {
86        /// Table the column belongs to.
87        table: String,
88        /// Snapshot of the new column.
89        column: ColumnSnapshot,
90    },
91    /// Drop a column and discard its data. Destructive.
92    DropColumn {
93        /// Table the column belongs to.
94        table: String,
95        /// Name of the column to drop.
96        column: String,
97    },
98    /// Rename a column, preserving its data and constraints.
99    RenameColumn {
100        /// Table the column belongs to.
101        table: String,
102        /// Previous column name as it appears in the stored snapshot.
103        old: String,
104        /// New column name as it appears in the compiled snapshot.
105        new: String,
106    },
107    /// Change one or more constraint flags on an existing column.
108    AlterColumn {
109        /// Table the column belongs to.
110        table: String,
111        /// Name of the column to alter.
112        column: String,
113        /// Flag deltas to apply.
114        changes: ColumnChanges,
115    },
116    /// Widen a column to a larger compatible type (sign-extend, zero-extend,
117    /// `Float32` → `Float64`).
118    WidenColumn {
119        /// Table the column belongs to.
120        table: String,
121        /// Name of the column being widened.
122        column: String,
123        /// Stored data type before widening.
124        old_type: DataTypeSnapshot,
125        /// Compiled data type after widening.
126        new_type: DataTypeSnapshot,
127    },
128    /// Convert a column to an incompatible type using
129    /// [`Migrate::transform_column`].
130    TransformColumn {
131        /// Table the column belongs to.
132        column: String,
133        /// Name of the column being transformed.
134        table: String,
135        /// Stored data type before the transform.
136        old_type: DataTypeSnapshot,
137        /// Compiled data type after the transform.
138        new_type: DataTypeSnapshot,
139    },
140    /// Build a new secondary index.
141    AddIndex {
142        /// Table the index belongs to.
143        table: String,
144        /// Snapshot of the new index.
145        index: IndexSnapshot,
146    },
147    /// Drop an existing secondary index.
148    DropIndex {
149        /// Table the index belongs to.
150        table: String,
151        /// Snapshot of the index to drop.
152        index: IndexSnapshot,
153    },
154}
155
156/// Bundle of constraint-flag deltas for an [`MigrationOp::AlterColumn`].
157///
158/// Each field is `Some(new_value)` only when that flag changed between the
159/// stored and compiled snapshots; otherwise it stays `None`.
160#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
161#[cfg_attr(feature = "candid", derive(candid::CandidType))]
162pub struct ColumnChanges {
163    /// New value for the `nullable` flag, if changed.
164    pub nullable: Option<bool>,
165    /// New value for the `unique` flag, if changed.
166    pub unique: Option<bool>,
167    /// New value for the `auto_increment` flag, if changed.
168    pub auto_increment: Option<bool>,
169    /// New value for the `primary_key` flag, if changed.
170    pub primary_key: Option<bool>,
171    /// New foreign-key state. `Some(None)` means the foreign key was dropped;
172    /// `Some(Some(fk))` means it was added or replaced.
173    ///
174    /// The serde representation is meant for self-describing, human-readable
175    /// formats such as JSON: `None` omits the field, `Some(None)` is written as
176    /// `null`, and `Some(Some(fk))` as the snapshot. It must not be used with
177    /// non-self-describing binary serde formats, because the omitted field
178    /// shifts the positions of the following ones. Candid has its own encoding.
179    #[serde(
180        default,
181        skip_serializing_if = "Option::is_none",
182        deserialize_with = "deserialize_foreign_key_change"
183    )]
184    pub foreign_key: Option<Option<ForeignKeySnapshot>>,
185}
186
187/// Deserializes [`ColumnChanges::foreign_key`].
188///
189/// Human-readable formats only reach this function when the field is present,
190/// so a `null` value means the foreign key is removed. Binary formats such as
191/// Candid encode both option layers and use the default decoding.
192fn deserialize_foreign_key_change<'de, D>(
193    deserializer: D,
194) -> Result<Option<Option<ForeignKeySnapshot>>, D::Error>
195where
196    D: Deserializer<'de>,
197{
198    if deserializer.is_human_readable() {
199        Option::<ForeignKeySnapshot>::deserialize(deserializer).map(Some)
200    } else {
201        Option::<Option<ForeignKeySnapshot>>::deserialize(deserializer)
202    }
203}
204
205impl ColumnChanges {
206    /// Returns `true` if no flag actually changed.
207    pub fn is_empty(&self) -> bool {
208        self.nullable.is_none()
209            && self.unique.is_none()
210            && self.auto_increment.is_none()
211            && self.primary_key.is_none()
212            && self.foreign_key.is_none()
213    }
214}
215
216/// Caller-supplied policy that gates destructive migration ops.
217#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218#[cfg_attr(feature = "candid", derive(candid::CandidType))]
219pub struct MigrationPolicy {
220    /// When `false`, the planner refuses to emit `DropTable` or `DropColumn`
221    /// ops and aborts with [`MigrationError::DestructiveOpDenied`].
222    pub allow_destructive: bool,
223}
224
225/// Error variants produced by the migration planner and apply pipeline.
226#[derive(Debug, Error, Clone, PartialEq, Eq, Serialize, Deserialize)]
227#[cfg_attr(feature = "candid", derive(candid::CandidType))]
228pub enum MigrationError {
229    /// CRUD attempted while the DBMS is in drift state. Only migration
230    /// entry points are allowed until [`MigrationOp`]s are applied.
231    #[error("Schema drift: stored schema differs from compiled schema")]
232    SchemaDrift,
233    /// A column changed to a type that is neither in the widening whitelist
234    /// nor handled by [`Migrate::transform_column`].
235    #[error(
236        "Incompatible type change for column `{column}` in table `{table}`: {old:?} -> {new:?}"
237    )]
238    IncompatibleType {
239        /// Table the column belongs to.
240        table: String,
241        /// Name of the offending column.
242        column: String,
243        /// Stored data type.
244        old: DataTypeSnapshot,
245        /// Compiled data type.
246        new: DataTypeSnapshot,
247    },
248    /// `AddColumn` on a non-nullable column has neither a `#[default]`
249    /// attribute nor a [`Migrate::default_value`] override.
250    #[error("Missing default for non-nullable new column `{column}` in table `{table}`")]
251    DefaultMissing {
252        /// Table the column belongs to.
253        table: String,
254        /// Name of the offending column.
255        column: String,
256    },
257    /// A tightening `AlterColumn` op (e.g. `nullable: false`, `unique: true`,
258    /// add FK) found existing data that violates the new constraint.
259    #[error("Constraint violation for column `{column}` in table `{table}`: {reason}")]
260    ConstraintViolation {
261        /// Table the column belongs to.
262        table: String,
263        /// Name of the offending column.
264        column: String,
265        /// Human-readable description of which row(s) violated the constraint.
266        reason: String,
267    },
268    /// Planner produced a destructive op while
269    /// [`MigrationPolicy::allow_destructive`] is `false`.
270    #[error("Destructive migration op denied by policy: {op}")]
271    DestructiveOpDenied {
272        /// Short tag for the offending op (e.g. `"DropTable"`, `"DropColumn"`).
273        op: String,
274    },
275    /// User-supplied [`Migrate::transform_column`] returned `Err`.
276    #[error("Migration transform aborted for column `{column}` in table `{table}`: {reason}")]
277    TransformAborted {
278        /// Table the column belongs to.
279        table: String,
280        /// Name of the column being transformed.
281        column: String,
282        /// Reason propagated from the user transform.
283        reason: String,
284    },
285    /// Type change is outside the widening whitelist
286    /// (`IntN→IntM`, `UintN→UintM`, `UintN→IntM`, `Float32→Float64`) and no
287    /// `Migrate::transform_column` impl handled it.
288    #[error(
289        "No widening rule for column `{column}` in table `{table}`: {old_type:?} -> {new_type:?}"
290    )]
291    WideningIncompatible {
292        /// Table the column belongs to.
293        table: String,
294        /// Name of the offending column.
295        column: String,
296        /// Stored data type before widening.
297        old_type: DataTypeSnapshot,
298        /// Compiled data type after widening.
299        new_type: DataTypeSnapshot,
300    },
301    /// User `Migrate::transform_column` returned `Ok(None)` while a transform
302    /// was required (no widening rule available).
303    #[error("Migrate::transform_column returned None for column `{column}` in table `{table}`")]
304    TransformReturnedNone {
305        /// Table the column belongs to.
306        table: String,
307        /// Name of the column being transformed.
308        column: String,
309    },
310    /// Add-FK tightening found a row whose value is absent from the target
311    /// table's referenced column.
312    #[error(
313        "Foreign key violation on column `{column}` in table `{table}`: value `{value}` not present in `{target_table}`"
314    )]
315    ForeignKeyViolation {
316        /// Source table that holds the foreign key column.
317        table: String,
318        /// Source column carrying the foreign key.
319        column: String,
320        /// Target table the FK references.
321        target_table: String,
322        /// Stringified value that failed the lookup.
323        value: String,
324    },
325}
326
327#[cfg(test)]
328mod test {
329    use super::*;
330    use crate::dbms::table::{
331        ColumnSnapshot, DataTypeSnapshot, ForeignKeySnapshot, OnDeleteSnapshot,
332    };
333
334    #[test]
335    fn test_should_default_migration_policy_to_non_destructive() {
336        let policy = MigrationPolicy::default();
337        assert!(!policy.allow_destructive);
338    }
339
340    #[test]
341    fn test_should_detect_empty_column_changes() {
342        let changes = ColumnChanges::default();
343        assert!(changes.is_empty());
344
345        let nullable = ColumnChanges {
346            nullable: Some(true),
347            ..Default::default()
348        };
349        assert!(!nullable.is_empty());
350    }
351
352    #[test]
353    fn test_should_display_migration_error() {
354        let err = MigrationError::SchemaDrift;
355        assert_eq!(
356            err.to_string(),
357            "Schema drift: stored schema differs from compiled schema"
358        );
359
360        let err = MigrationError::IncompatibleType {
361            table: "users".into(),
362            column: "id".into(),
363            old: DataTypeSnapshot::Int32,
364            new: DataTypeSnapshot::Text,
365        };
366        assert!(err.to_string().contains("Incompatible type change"));
367
368        let err = MigrationError::DefaultMissing {
369            table: "users".into(),
370            column: "email".into(),
371        };
372        assert!(err.to_string().contains("Missing default"));
373
374        let err = MigrationError::ConstraintViolation {
375            table: "users".into(),
376            column: "email".into(),
377            reason: "duplicate value".into(),
378        };
379        assert!(err.to_string().contains("Constraint violation"));
380
381        let err = MigrationError::DestructiveOpDenied {
382            op: "DropTable".into(),
383        };
384        assert!(err.to_string().contains("Destructive migration op denied"));
385
386        let err = MigrationError::TransformAborted {
387            table: "users".into(),
388            column: "id".into(),
389            reason: "negative ids unsupported".into(),
390        };
391        assert!(err.to_string().contains("Migration transform aborted"));
392    }
393
394    #[test]
395    fn test_should_construct_migration_ops() {
396        let _drop = MigrationOp::DropTable { name: "old".into() };
397        let _add = MigrationOp::AddColumn {
398            table: "users".into(),
399            column: ColumnSnapshot {
400                name: "email".into(),
401                data_type: DataTypeSnapshot::Text,
402                nullable: true,
403                auto_increment: false,
404                unique: false,
405                primary_key: false,
406                foreign_key: None,
407                default: None,
408            },
409        };
410    }
411
412    fn sample_foreign_key() -> ForeignKeySnapshot {
413        ForeignKeySnapshot {
414            table: "users".into(),
415            column: "id".into(),
416            on_delete: OnDeleteSnapshot::Cascade,
417        }
418    }
419
420    /// Keep, remove, and set states of [`ColumnChanges::foreign_key`].
421    fn foreign_key_states() -> [ColumnChanges; 3] {
422        [
423            ColumnChanges {
424                nullable: Some(true),
425                ..Default::default()
426            },
427            ColumnChanges {
428                foreign_key: Some(None),
429                ..Default::default()
430            },
431            ColumnChanges {
432                foreign_key: Some(Some(sample_foreign_key())),
433                ..Default::default()
434            },
435        ]
436    }
437
438    #[test]
439    fn test_should_json_roundtrip_column_changes_foreign_key_states() {
440        for changes in foreign_key_states() {
441            let json = serde_json::to_string(&changes).expect("failed to serialize");
442            let decoded: ColumnChanges =
443                serde_json::from_str(&json).expect("failed to deserialize");
444            assert_eq!(decoded, changes, "json: {json}");
445            assert!(!decoded.is_empty());
446        }
447    }
448
449    #[test]
450    fn test_should_serialize_column_changes_foreign_key_states_distinctly() {
451        let [keep, remove, set] = foreign_key_states()
452            .map(|changes| serde_json::to_value(&changes).expect("failed to serialize"));
453        assert_ne!(keep, remove);
454        assert_ne!(remove, set);
455        assert_ne!(keep, set);
456        assert!(keep.get("foreign_key").is_none());
457        assert_eq!(remove.get("foreign_key"), Some(&serde_json::Value::Null));
458        assert!(set["foreign_key"].is_object());
459    }
460
461    #[test]
462    fn test_should_json_deserialize_column_changes_foreign_key_states() {
463        let keep: ColumnChanges = serde_json::from_str(
464            r#"{"nullable":null,"unique":null,"auto_increment":null,"primary_key":null}"#,
465        )
466        .expect("failed to deserialize keep");
467        assert_eq!(keep.foreign_key, None);
468        assert!(keep.is_empty());
469
470        let remove: ColumnChanges = serde_json::from_str(
471            r#"{"nullable":null,"unique":null,"auto_increment":null,"primary_key":null,"foreign_key":null}"#,
472        )
473        .expect("failed to deserialize remove");
474        assert_eq!(remove.foreign_key, Some(None));
475        assert!(!remove.is_empty());
476
477        let set: ColumnChanges = serde_json::from_str(
478            r#"{"nullable":null,"unique":null,"auto_increment":null,"primary_key":null,"foreign_key":{"table":"users","column":"id","on_delete":"Cascade"}}"#,
479        )
480        .expect("failed to deserialize set");
481        assert_eq!(set.foreign_key, Some(Some(sample_foreign_key())));
482    }
483
484    #[cfg(feature = "candid")]
485    #[test]
486    fn test_should_candid_roundtrip_column_changes_foreign_key_states() {
487        let mut states = foreign_key_states().to_vec();
488        states.push(ColumnChanges::default());
489        for changes in states {
490            let encoded = candid::encode_one(&changes).expect("failed to encode");
491            let decoded: ColumnChanges = candid::decode_one(&encoded).expect("failed to decode");
492            assert_eq!(decoded, changes);
493        }
494    }
495
496    #[cfg(feature = "candid")]
497    #[test]
498    fn test_should_candid_roundtrip_migration_policy() {
499        let policy = MigrationPolicy {
500            allow_destructive: true,
501        };
502        let encoded = candid::encode_one(&policy).expect("failed to encode");
503        let decoded: MigrationPolicy = candid::decode_one(&encoded).expect("failed to decode");
504        assert_eq!(policy, decoded);
505    }
506
507    #[cfg(feature = "candid")]
508    #[test]
509    fn test_should_candid_roundtrip_migration_error() {
510        let err = MigrationError::SchemaDrift;
511        let encoded = candid::encode_one(&err).expect("failed to encode");
512        let decoded: MigrationError = candid::decode_one(&encoded).expect("failed to decode");
513        assert_eq!(err, decoded);
514    }
515
516    #[cfg(feature = "candid")]
517    #[test]
518    fn test_should_candid_roundtrip_new_migration_error_variants() {
519        let cases = vec![
520            MigrationError::DefaultMissing {
521                table: "users".into(),
522                column: "email".into(),
523            },
524            MigrationError::WideningIncompatible {
525                table: "users".into(),
526                column: "id".into(),
527                old_type: DataTypeSnapshot::Int32,
528                new_type: DataTypeSnapshot::Uint8,
529            },
530            MigrationError::TransformReturnedNone {
531                table: "users".into(),
532                column: "id".into(),
533            },
534            MigrationError::ForeignKeyViolation {
535                table: "posts".into(),
536                column: "owner".into(),
537                target_table: "users".into(),
538                value: "Uint32(99)".into(),
539            },
540        ];
541        for err in cases {
542            let encoded = candid::encode_one(&err).expect("failed to encode");
543            let decoded: MigrationError = candid::decode_one(&encoded).expect("failed to decode");
544            assert_eq!(err, decoded);
545        }
546    }
547}