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}