wasm-dbms-api 0.9.0

Runtime-agnostic API types and traits for the wasm-dbms DBMS engine.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! Schema migration types and traits.
//!
//! The migration subsystem lets compiled `#[derive(Table)]` schemas evolve
//! across releases without manual stable-memory surgery. The flow is:
//!
//! 1. On boot, the DBMS compares the hash of every compiled
//!    [`TableSchemaSnapshot`](crate::dbms::table::TableSchemaSnapshot) against
//!    the hash stored in the schema registry.
//! 2. If they differ, the DBMS enters drift state and refuses CRUD.
//! 3. The user calls `Dbms::migrate(policy)`, which diffs the stored snapshots
//!    against the compiled ones and produces a [`Vec<MigrationOp>`].
//! 4. The ops are applied transactionally; on success the new snapshots and
//!    schema hash are persisted and the drift flag is cleared.
//!
//! This module owns the *types* (`MigrationOp`, `ColumnChanges`,
//! `MigrationPolicy`, `MigrationError`) and the per-table extension hook
//! [`Migrate`]. The diff algorithm and apply logic live in the engine crate.

use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::dbms::table::{ColumnSnapshot, DataTypeSnapshot, IndexSnapshot, TableSchema};
use crate::dbms::value::Value;
use crate::error::DbmsResult;

/// Per-table extension hook for schema migrations.
///
/// The derive macro `#[derive(Table)]` emits an empty `impl Migrate for T {}`
/// for every table by default, so callers only need to provide a manual impl
/// when they tag the struct with `#[migrate]`. The trait extends
/// [`TableSchema`] so implementors automatically have access to the table's
/// column definitions and snapshot.
pub trait Migrate
where
    Self: TableSchema,
{
    /// Dynamic default for an [`MigrationOp::AddColumn`] operation on a non-nullable column.
    ///
    /// Returning `None` falls back to the static `#[default = ...]` attribute
    /// declared on the column. If neither produces a value, migration aborts
    /// with [`MigrationError::DefaultMissing`].
    fn default_value(_column: &str) -> Option<Value> {
        None
    }

    /// Transform a stored value when its column changes to an incompatible
    /// type that does not fit the framework's widening whitelist.
    ///
    /// - `Ok(None)` — no transform; the framework errors with
    ///   [`MigrationError::IncompatibleType`] unless widening already applies.
    /// - `Ok(Some(v))` — use `v` as the new value.
    /// - `Err(_)` — abort the migration; the journaled session rolls back.
    fn transform_column(_column: &str, _old: Value) -> DbmsResult<Option<Value>> {
        Ok(None)
    }
}

/// Single atomic step produced by the migration planner.
///
/// Ops are sorted into a deterministic apply order (creates → drops → renames
/// → relaxations → widening/transforms → adds → tightenings → indexes →
/// table drops) and then executed inside a single journaled session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "candid", derive(candid::CandidType))]
pub enum MigrationOp {
    /// Create a new table with the given snapshot.
    CreateTable {
        /// Name of the new table.
        name: String,
        /// Snapshot of the compiled schema for the new table.
        schema: crate::dbms::table::TableSchemaSnapshot,
    },
    /// Drop a table and all of its data. Destructive.
    DropTable {
        /// Name of the table to drop.
        name: String,
    },
    /// Append a new column to an existing table.
    ///
    /// If the column is non-nullable, the planner must have resolved a default
    /// value (`#[default]` or [`Migrate::default_value`]) before emitting this
    /// op.
    AddColumn {
        /// Table the column belongs to.
        table: String,
        /// Snapshot of the new column.
        column: ColumnSnapshot,
    },
    /// Drop a column and discard its data. Destructive.
    DropColumn {
        /// Table the column belongs to.
        table: String,
        /// Name of the column to drop.
        column: String,
    },
    /// Rename a column, preserving its data and constraints.
    RenameColumn {
        /// Table the column belongs to.
        table: String,
        /// Previous column name as it appears in the stored snapshot.
        old: String,
        /// New column name as it appears in the compiled snapshot.
        new: String,
    },
    /// Change one or more constraint flags on an existing column.
    AlterColumn {
        /// Table the column belongs to.
        table: String,
        /// Name of the column to alter.
        column: String,
        /// Flag deltas to apply.
        changes: ColumnChanges,
    },
    /// Widen a column to a larger compatible type (sign-extend, zero-extend,
    /// `Float32` → `Float64`).
    WidenColumn {
        /// Table the column belongs to.
        table: String,
        /// Name of the column being widened.
        column: String,
        /// Stored data type before widening.
        old_type: DataTypeSnapshot,
        /// Compiled data type after widening.
        new_type: DataTypeSnapshot,
    },
    /// Convert a column to an incompatible type using
    /// [`Migrate::transform_column`].
    TransformColumn {
        /// Table the column belongs to.
        column: String,
        /// Name of the column being transformed.
        table: String,
        /// Stored data type before the transform.
        old_type: DataTypeSnapshot,
        /// Compiled data type after the transform.
        new_type: DataTypeSnapshot,
    },
    /// Build a new secondary index.
    AddIndex {
        /// Table the index belongs to.
        table: String,
        /// Snapshot of the new index.
        index: IndexSnapshot,
    },
    /// Drop an existing secondary index.
    DropIndex {
        /// Table the index belongs to.
        table: String,
        /// Snapshot of the index to drop.
        index: IndexSnapshot,
    },
}

/// Bundle of constraint-flag deltas for an [`MigrationOp::AlterColumn`].
///
/// Each field is `Some(new_value)` only when that flag changed between the
/// stored and compiled snapshots; otherwise it stays `None`.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "candid", derive(candid::CandidType))]
pub struct ColumnChanges {
    /// New value for the `nullable` flag, if changed.
    pub nullable: Option<bool>,
    /// New value for the `unique` flag, if changed.
    pub unique: Option<bool>,
    /// New value for the `auto_increment` flag, if changed.
    pub auto_increment: Option<bool>,
    /// New value for the `primary_key` flag, if changed.
    pub primary_key: Option<bool>,
    /// New foreign-key state. `Some(None)` means the foreign key was dropped;
    /// `Some(Some(fk))` means it was added or replaced.
    pub foreign_key: Option<Option<crate::dbms::table::ForeignKeySnapshot>>,
}

impl ColumnChanges {
    /// Returns `true` if no flag actually changed.
    pub fn is_empty(&self) -> bool {
        self.nullable.is_none()
            && self.unique.is_none()
            && self.auto_increment.is_none()
            && self.primary_key.is_none()
            && self.foreign_key.is_none()
    }
}

/// Caller-supplied policy that gates destructive migration ops.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "candid", derive(candid::CandidType))]
pub struct MigrationPolicy {
    /// When `false`, the planner refuses to emit `DropTable` or `DropColumn`
    /// ops and aborts with [`MigrationError::DestructiveOpDenied`].
    pub allow_destructive: bool,
}

/// Error variants produced by the migration planner and apply pipeline.
#[derive(Debug, Error, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "candid", derive(candid::CandidType))]
pub enum MigrationError {
    /// CRUD attempted while the DBMS is in drift state. Only ACL and
    /// migration entry points are allowed until [`MigrationOp`]s are applied.
    #[error("Schema drift: stored schema differs from compiled schema")]
    SchemaDrift,
    /// A column changed to a type that is neither in the widening whitelist
    /// nor handled by [`Migrate::transform_column`].
    #[error(
        "Incompatible type change for column `{column}` in table `{table}`: {old:?} -> {new:?}"
    )]
    IncompatibleType {
        /// Table the column belongs to.
        table: String,
        /// Name of the offending column.
        column: String,
        /// Stored data type.
        old: DataTypeSnapshot,
        /// Compiled data type.
        new: DataTypeSnapshot,
    },
    /// `AddColumn` on a non-nullable column has neither a `#[default]`
    /// attribute nor a [`Migrate::default_value`] override.
    #[error("Missing default for non-nullable new column `{column}` in table `{table}`")]
    DefaultMissing {
        /// Table the column belongs to.
        table: String,
        /// Name of the offending column.
        column: String,
    },
    /// A tightening `AlterColumn` op (e.g. `nullable: false`, `unique: true`,
    /// add FK) found existing data that violates the new constraint.
    #[error("Constraint violation for column `{column}` in table `{table}`: {reason}")]
    ConstraintViolation {
        /// Table the column belongs to.
        table: String,
        /// Name of the offending column.
        column: String,
        /// Human-readable description of which row(s) violated the constraint.
        reason: String,
    },
    /// Planner produced a destructive op while
    /// [`MigrationPolicy::allow_destructive`] is `false`.
    #[error("Destructive migration op denied by policy: {op}")]
    DestructiveOpDenied {
        /// Short tag for the offending op (e.g. `"DropTable"`, `"DropColumn"`).
        op: String,
    },
    /// User-supplied [`Migrate::transform_column`] returned `Err`.
    #[error("Migration transform aborted for column `{column}` in table `{table}`: {reason}")]
    TransformAborted {
        /// Table the column belongs to.
        table: String,
        /// Name of the column being transformed.
        column: String,
        /// Reason propagated from the user transform.
        reason: String,
    },
    /// Type change is outside the widening whitelist
    /// (`IntN→IntM`, `UintN→UintM`, `UintN→IntM`, `Float32→Float64`) and no
    /// `Migrate::transform_column` impl handled it.
    #[error(
        "No widening rule for column `{column}` in table `{table}`: {old_type:?} -> {new_type:?}"
    )]
    WideningIncompatible {
        /// Table the column belongs to.
        table: String,
        /// Name of the offending column.
        column: String,
        /// Stored data type before widening.
        old_type: DataTypeSnapshot,
        /// Compiled data type after widening.
        new_type: DataTypeSnapshot,
    },
    /// User `Migrate::transform_column` returned `Ok(None)` while a transform
    /// was required (no widening rule available).
    #[error("Migrate::transform_column returned None for column `{column}` in table `{table}`")]
    TransformReturnedNone {
        /// Table the column belongs to.
        table: String,
        /// Name of the column being transformed.
        column: String,
    },
    /// Add-FK tightening found a row whose value is absent from the target
    /// table's referenced column.
    #[error(
        "Foreign key violation on column `{column}` in table `{table}`: value `{value}` not present in `{target_table}`"
    )]
    ForeignKeyViolation {
        /// Source table that holds the foreign key column.
        table: String,
        /// Source column carrying the foreign key.
        column: String,
        /// Target table the FK references.
        target_table: String,
        /// Stringified value that failed the lookup.
        value: String,
    },
}

#[cfg(test)]
mod test {
    use super::*;
    use crate::dbms::table::{ColumnSnapshot, DataTypeSnapshot};

    #[test]
    fn test_should_default_migration_policy_to_non_destructive() {
        let policy = MigrationPolicy::default();
        assert!(!policy.allow_destructive);
    }

    #[test]
    fn test_should_detect_empty_column_changes() {
        let changes = ColumnChanges::default();
        assert!(changes.is_empty());

        let nullable = ColumnChanges {
            nullable: Some(true),
            ..Default::default()
        };
        assert!(!nullable.is_empty());
    }

    #[test]
    fn test_should_display_migration_error() {
        let err = MigrationError::SchemaDrift;
        assert_eq!(
            err.to_string(),
            "Schema drift: stored schema differs from compiled schema"
        );

        let err = MigrationError::IncompatibleType {
            table: "users".into(),
            column: "id".into(),
            old: DataTypeSnapshot::Int32,
            new: DataTypeSnapshot::Text,
        };
        assert!(err.to_string().contains("Incompatible type change"));

        let err = MigrationError::DefaultMissing {
            table: "users".into(),
            column: "email".into(),
        };
        assert!(err.to_string().contains("Missing default"));

        let err = MigrationError::ConstraintViolation {
            table: "users".into(),
            column: "email".into(),
            reason: "duplicate value".into(),
        };
        assert!(err.to_string().contains("Constraint violation"));

        let err = MigrationError::DestructiveOpDenied {
            op: "DropTable".into(),
        };
        assert!(err.to_string().contains("Destructive migration op denied"));

        let err = MigrationError::TransformAborted {
            table: "users".into(),
            column: "id".into(),
            reason: "negative ids unsupported".into(),
        };
        assert!(err.to_string().contains("Migration transform aborted"));
    }

    #[test]
    fn test_should_construct_migration_ops() {
        let _drop = MigrationOp::DropTable { name: "old".into() };
        let _add = MigrationOp::AddColumn {
            table: "users".into(),
            column: ColumnSnapshot {
                name: "email".into(),
                data_type: DataTypeSnapshot::Text,
                nullable: true,
                auto_increment: false,
                unique: false,
                primary_key: false,
                foreign_key: None,
                default: None,
            },
        };
    }

    #[cfg(feature = "candid")]
    #[test]
    fn test_should_candid_roundtrip_migration_policy() {
        let policy = MigrationPolicy {
            allow_destructive: true,
        };
        let encoded = candid::encode_one(&policy).expect("failed to encode");
        let decoded: MigrationPolicy = candid::decode_one(&encoded).expect("failed to decode");
        assert_eq!(policy, decoded);
    }

    #[cfg(feature = "candid")]
    #[test]
    fn test_should_candid_roundtrip_migration_error() {
        let err = MigrationError::SchemaDrift;
        let encoded = candid::encode_one(&err).expect("failed to encode");
        let decoded: MigrationError = candid::decode_one(&encoded).expect("failed to decode");
        assert_eq!(err, decoded);
    }

    #[cfg(feature = "candid")]
    #[test]
    fn test_should_candid_roundtrip_new_migration_error_variants() {
        let cases = vec![
            MigrationError::DefaultMissing {
                table: "users".into(),
                column: "email".into(),
            },
            MigrationError::WideningIncompatible {
                table: "users".into(),
                column: "id".into(),
                old_type: DataTypeSnapshot::Int32,
                new_type: DataTypeSnapshot::Uint8,
            },
            MigrationError::TransformReturnedNone {
                table: "users".into(),
                column: "id".into(),
            },
            MigrationError::ForeignKeyViolation {
                table: "posts".into(),
                column: "owner".into(),
                target_table: "users".into(),
                value: "Uint32(99)".into(),
            },
        ];
        for err in cases {
            let encoded = candid::encode_one(&err).expect("failed to encode");
            let decoded: MigrationError = candid::decode_one(&encoded).expect("failed to decode");
            assert_eq!(err, decoded);
        }
    }
}