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, Serialize};
20use thiserror::Error;
21
22use crate::dbms::table::{ColumnSnapshot, DataTypeSnapshot, IndexSnapshot, TableSchema};
23use crate::dbms::value::Value;
24use crate::error::DbmsResult;
25
26/// Per-table extension hook for schema migrations.
27///
28/// The derive macro `#[derive(Table)]` emits an empty `impl Migrate for T {}`
29/// for every table by default, so callers only need to provide a manual impl
30/// when they tag the struct with `#[migrate]`. The trait extends
31/// [`TableSchema`] so implementors automatically have access to the table's
32/// column definitions and snapshot.
33pub trait Migrate
34where
35    Self: TableSchema,
36{
37    /// Dynamic default for an [`MigrationOp::AddColumn`] operation on a non-nullable column.
38    ///
39    /// Returning `None` falls back to the static `#[default = ...]` attribute
40    /// declared on the column. If neither produces a value, migration aborts
41    /// with [`MigrationError::DefaultMissing`].
42    fn default_value(_column: &str) -> Option<Value> {
43        None
44    }
45
46    /// Transform a stored value when its column changes to an incompatible
47    /// type that does not fit the framework's widening whitelist.
48    ///
49    /// - `Ok(None)` — no transform; the framework errors with
50    ///   [`MigrationError::IncompatibleType`] unless widening already applies.
51    /// - `Ok(Some(v))` — use `v` as the new value.
52    /// - `Err(_)` — abort the migration; the journaled session rolls back.
53    fn transform_column(_column: &str, _old: Value) -> DbmsResult<Option<Value>> {
54        Ok(None)
55    }
56}
57
58/// Single atomic step produced by the migration planner.
59///
60/// Ops are sorted into a deterministic apply order (creates → drops → renames
61/// → relaxations → widening/transforms → adds → tightenings → indexes →
62/// table drops) and then executed inside a single journaled session.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[cfg_attr(feature = "candid", derive(candid::CandidType))]
65pub enum MigrationOp {
66    /// Create a new table with the given snapshot.
67    CreateTable {
68        /// Name of the new table.
69        name: String,
70        /// Snapshot of the compiled schema for the new table.
71        schema: crate::dbms::table::TableSchemaSnapshot,
72    },
73    /// Drop a table and all of its data. Destructive.
74    DropTable {
75        /// Name of the table to drop.
76        name: String,
77    },
78    /// Append a new column to an existing table.
79    ///
80    /// If the column is non-nullable, the planner must have resolved a default
81    /// value (`#[default]` or [`Migrate::default_value`]) before emitting this
82    /// op.
83    AddColumn {
84        /// Table the column belongs to.
85        table: String,
86        /// Snapshot of the new column.
87        column: ColumnSnapshot,
88    },
89    /// Drop a column and discard its data. Destructive.
90    DropColumn {
91        /// Table the column belongs to.
92        table: String,
93        /// Name of the column to drop.
94        column: String,
95    },
96    /// Rename a column, preserving its data and constraints.
97    RenameColumn {
98        /// Table the column belongs to.
99        table: String,
100        /// Previous column name as it appears in the stored snapshot.
101        old: String,
102        /// New column name as it appears in the compiled snapshot.
103        new: String,
104    },
105    /// Change one or more constraint flags on an existing column.
106    AlterColumn {
107        /// Table the column belongs to.
108        table: String,
109        /// Name of the column to alter.
110        column: String,
111        /// Flag deltas to apply.
112        changes: ColumnChanges,
113    },
114    /// Widen a column to a larger compatible type (sign-extend, zero-extend,
115    /// `Float32` → `Float64`).
116    WidenColumn {
117        /// Table the column belongs to.
118        table: String,
119        /// Name of the column being widened.
120        column: String,
121        /// Stored data type before widening.
122        old_type: DataTypeSnapshot,
123        /// Compiled data type after widening.
124        new_type: DataTypeSnapshot,
125    },
126    /// Convert a column to an incompatible type using
127    /// [`Migrate::transform_column`].
128    TransformColumn {
129        /// Table the column belongs to.
130        column: String,
131        /// Name of the column being transformed.
132        table: String,
133        /// Stored data type before the transform.
134        old_type: DataTypeSnapshot,
135        /// Compiled data type after the transform.
136        new_type: DataTypeSnapshot,
137    },
138    /// Build a new secondary index.
139    AddIndex {
140        /// Table the index belongs to.
141        table: String,
142        /// Snapshot of the new index.
143        index: IndexSnapshot,
144    },
145    /// Drop an existing secondary index.
146    DropIndex {
147        /// Table the index belongs to.
148        table: String,
149        /// Snapshot of the index to drop.
150        index: IndexSnapshot,
151    },
152}
153
154/// Bundle of constraint-flag deltas for an [`MigrationOp::AlterColumn`].
155///
156/// Each field is `Some(new_value)` only when that flag changed between the
157/// stored and compiled snapshots; otherwise it stays `None`.
158#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
159#[cfg_attr(feature = "candid", derive(candid::CandidType))]
160pub struct ColumnChanges {
161    /// New value for the `nullable` flag, if changed.
162    pub nullable: Option<bool>,
163    /// New value for the `unique` flag, if changed.
164    pub unique: Option<bool>,
165    /// New value for the `auto_increment` flag, if changed.
166    pub auto_increment: Option<bool>,
167    /// New value for the `primary_key` flag, if changed.
168    pub primary_key: Option<bool>,
169    /// New foreign-key state. `Some(None)` means the foreign key was dropped;
170    /// `Some(Some(fk))` means it was added or replaced.
171    pub foreign_key: Option<Option<crate::dbms::table::ForeignKeySnapshot>>,
172}
173
174impl ColumnChanges {
175    /// Returns `true` if no flag actually changed.
176    pub fn is_empty(&self) -> bool {
177        self.nullable.is_none()
178            && self.unique.is_none()
179            && self.auto_increment.is_none()
180            && self.primary_key.is_none()
181            && self.foreign_key.is_none()
182    }
183}
184
185/// Caller-supplied policy that gates destructive migration ops.
186#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
187#[cfg_attr(feature = "candid", derive(candid::CandidType))]
188pub struct MigrationPolicy {
189    /// When `false`, the planner refuses to emit `DropTable` or `DropColumn`
190    /// ops and aborts with [`MigrationError::DestructiveOpDenied`].
191    pub allow_destructive: bool,
192}
193
194/// Error variants produced by the migration planner and apply pipeline.
195#[derive(Debug, Error, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[cfg_attr(feature = "candid", derive(candid::CandidType))]
197pub enum MigrationError {
198    /// CRUD attempted while the DBMS is in drift state. Only ACL and
199    /// migration entry points are allowed until [`MigrationOp`]s are applied.
200    #[error("Schema drift: stored schema differs from compiled schema")]
201    SchemaDrift,
202    /// A column changed to a type that is neither in the widening whitelist
203    /// nor handled by [`Migrate::transform_column`].
204    #[error(
205        "Incompatible type change for column `{column}` in table `{table}`: {old:?} -> {new:?}"
206    )]
207    IncompatibleType {
208        /// Table the column belongs to.
209        table: String,
210        /// Name of the offending column.
211        column: String,
212        /// Stored data type.
213        old: DataTypeSnapshot,
214        /// Compiled data type.
215        new: DataTypeSnapshot,
216    },
217    /// `AddColumn` on a non-nullable column has neither a `#[default]`
218    /// attribute nor a [`Migrate::default_value`] override.
219    #[error("Missing default for non-nullable new column `{column}` in table `{table}`")]
220    DefaultMissing {
221        /// Table the column belongs to.
222        table: String,
223        /// Name of the offending column.
224        column: String,
225    },
226    /// A tightening `AlterColumn` op (e.g. `nullable: false`, `unique: true`,
227    /// add FK) found existing data that violates the new constraint.
228    #[error("Constraint violation for column `{column}` in table `{table}`: {reason}")]
229    ConstraintViolation {
230        /// Table the column belongs to.
231        table: String,
232        /// Name of the offending column.
233        column: String,
234        /// Human-readable description of which row(s) violated the constraint.
235        reason: String,
236    },
237    /// Planner produced a destructive op while
238    /// [`MigrationPolicy::allow_destructive`] is `false`.
239    #[error("Destructive migration op denied by policy: {op}")]
240    DestructiveOpDenied {
241        /// Short tag for the offending op (e.g. `"DropTable"`, `"DropColumn"`).
242        op: String,
243    },
244    /// User-supplied [`Migrate::transform_column`] returned `Err`.
245    #[error("Migration transform aborted for column `{column}` in table `{table}`: {reason}")]
246    TransformAborted {
247        /// Table the column belongs to.
248        table: String,
249        /// Name of the column being transformed.
250        column: String,
251        /// Reason propagated from the user transform.
252        reason: String,
253    },
254    /// Type change is outside the widening whitelist
255    /// (`IntN→IntM`, `UintN→UintM`, `UintN→IntM`, `Float32→Float64`) and no
256    /// `Migrate::transform_column` impl handled it.
257    #[error(
258        "No widening rule for column `{column}` in table `{table}`: {old_type:?} -> {new_type:?}"
259    )]
260    WideningIncompatible {
261        /// Table the column belongs to.
262        table: String,
263        /// Name of the offending column.
264        column: String,
265        /// Stored data type before widening.
266        old_type: DataTypeSnapshot,
267        /// Compiled data type after widening.
268        new_type: DataTypeSnapshot,
269    },
270    /// User `Migrate::transform_column` returned `Ok(None)` while a transform
271    /// was required (no widening rule available).
272    #[error("Migrate::transform_column returned None for column `{column}` in table `{table}`")]
273    TransformReturnedNone {
274        /// Table the column belongs to.
275        table: String,
276        /// Name of the column being transformed.
277        column: String,
278    },
279    /// Add-FK tightening found a row whose value is absent from the target
280    /// table's referenced column.
281    #[error(
282        "Foreign key violation on column `{column}` in table `{table}`: value `{value}` not present in `{target_table}`"
283    )]
284    ForeignKeyViolation {
285        /// Source table that holds the foreign key column.
286        table: String,
287        /// Source column carrying the foreign key.
288        column: String,
289        /// Target table the FK references.
290        target_table: String,
291        /// Stringified value that failed the lookup.
292        value: String,
293    },
294}
295
296#[cfg(test)]
297mod test {
298    use super::*;
299    use crate::dbms::table::{ColumnSnapshot, DataTypeSnapshot};
300
301    #[test]
302    fn test_should_default_migration_policy_to_non_destructive() {
303        let policy = MigrationPolicy::default();
304        assert!(!policy.allow_destructive);
305    }
306
307    #[test]
308    fn test_should_detect_empty_column_changes() {
309        let changes = ColumnChanges::default();
310        assert!(changes.is_empty());
311
312        let nullable = ColumnChanges {
313            nullable: Some(true),
314            ..Default::default()
315        };
316        assert!(!nullable.is_empty());
317    }
318
319    #[test]
320    fn test_should_display_migration_error() {
321        let err = MigrationError::SchemaDrift;
322        assert_eq!(
323            err.to_string(),
324            "Schema drift: stored schema differs from compiled schema"
325        );
326
327        let err = MigrationError::IncompatibleType {
328            table: "users".into(),
329            column: "id".into(),
330            old: DataTypeSnapshot::Int32,
331            new: DataTypeSnapshot::Text,
332        };
333        assert!(err.to_string().contains("Incompatible type change"));
334
335        let err = MigrationError::DefaultMissing {
336            table: "users".into(),
337            column: "email".into(),
338        };
339        assert!(err.to_string().contains("Missing default"));
340
341        let err = MigrationError::ConstraintViolation {
342            table: "users".into(),
343            column: "email".into(),
344            reason: "duplicate value".into(),
345        };
346        assert!(err.to_string().contains("Constraint violation"));
347
348        let err = MigrationError::DestructiveOpDenied {
349            op: "DropTable".into(),
350        };
351        assert!(err.to_string().contains("Destructive migration op denied"));
352
353        let err = MigrationError::TransformAborted {
354            table: "users".into(),
355            column: "id".into(),
356            reason: "negative ids unsupported".into(),
357        };
358        assert!(err.to_string().contains("Migration transform aborted"));
359    }
360
361    #[test]
362    fn test_should_construct_migration_ops() {
363        let _drop = MigrationOp::DropTable { name: "old".into() };
364        let _add = MigrationOp::AddColumn {
365            table: "users".into(),
366            column: ColumnSnapshot {
367                name: "email".into(),
368                data_type: DataTypeSnapshot::Text,
369                nullable: true,
370                auto_increment: false,
371                unique: false,
372                primary_key: false,
373                foreign_key: None,
374                default: None,
375            },
376        };
377    }
378
379    #[cfg(feature = "candid")]
380    #[test]
381    fn test_should_candid_roundtrip_migration_policy() {
382        let policy = MigrationPolicy {
383            allow_destructive: true,
384        };
385        let encoded = candid::encode_one(&policy).expect("failed to encode");
386        let decoded: MigrationPolicy = candid::decode_one(&encoded).expect("failed to decode");
387        assert_eq!(policy, decoded);
388    }
389
390    #[cfg(feature = "candid")]
391    #[test]
392    fn test_should_candid_roundtrip_migration_error() {
393        let err = MigrationError::SchemaDrift;
394        let encoded = candid::encode_one(&err).expect("failed to encode");
395        let decoded: MigrationError = candid::decode_one(&encoded).expect("failed to decode");
396        assert_eq!(err, decoded);
397    }
398
399    #[cfg(feature = "candid")]
400    #[test]
401    fn test_should_candid_roundtrip_new_migration_error_variants() {
402        let cases = vec![
403            MigrationError::DefaultMissing {
404                table: "users".into(),
405                column: "email".into(),
406            },
407            MigrationError::WideningIncompatible {
408                table: "users".into(),
409                column: "id".into(),
410                old_type: DataTypeSnapshot::Int32,
411                new_type: DataTypeSnapshot::Uint8,
412            },
413            MigrationError::TransformReturnedNone {
414                table: "users".into(),
415                column: "id".into(),
416            },
417            MigrationError::ForeignKeyViolation {
418                table: "posts".into(),
419                column: "owner".into(),
420                target_table: "users".into(),
421                value: "Uint32(99)".into(),
422            },
423        ];
424        for err in cases {
425            let encoded = candid::encode_one(&err).expect("failed to encode");
426            let decoded: MigrationError = candid::decode_one(&encoded).expect("failed to decode");
427            assert_eq!(err, decoded);
428        }
429    }
430}