Skip to main content

mongreldb_kit_core/
migrations.rs

1//! Migration planning and checksums.
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use crate::external::{ViewSpec, VirtualTableSpec};
7use crate::procedure::ProcedureSpec;
8use crate::trigger::TriggerSpec;
9
10/// A single schema-migration operation.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum MigrationOp {
14    CreateTable {
15        name: String,
16    },
17    DropTable {
18        name: String,
19    },
20    AddColumn {
21        table: String,
22        column: String,
23    },
24    DropColumn {
25        table: String,
26        column: String,
27    },
28    AlterColumn {
29        table: String,
30        column: String,
31    },
32    AddIndex {
33        table: String,
34        index: String,
35    },
36    DropIndex {
37        table: String,
38        index: String,
39    },
40    AddUnique {
41        table: String,
42        constraint: String,
43    },
44    DropUnique {
45        table: String,
46        constraint: String,
47    },
48    AddForeignKey {
49        table: String,
50        constraint: String,
51    },
52    DropForeignKey {
53        table: String,
54        constraint: String,
55    },
56    AddCheck {
57        table: String,
58        constraint: String,
59    },
60    DropCheck {
61        table: String,
62        constraint: String,
63    },
64    CreateProcedure {
65        name: String,
66        procedure: ProcedureSpec,
67    },
68    ReplaceProcedure {
69        name: String,
70        procedure: ProcedureSpec,
71    },
72    DropProcedure {
73        name: String,
74    },
75    CreateTrigger {
76        name: String,
77        trigger: TriggerSpec,
78    },
79    ReplaceTrigger {
80        name: String,
81        trigger: TriggerSpec,
82    },
83    DropTrigger {
84        name: String,
85    },
86    CreateVirtualTable {
87        table: VirtualTableSpec,
88    },
89    DropVirtualTable {
90        name: String,
91    },
92    CreateView {
93        name: String,
94        view: ViewSpec,
95    },
96    ReplaceView {
97        name: String,
98        view: ViewSpec,
99    },
100    DropView {
101        name: String,
102    },
103    RawSql(String),
104}
105
106/// A numbered schema migration.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct Migration {
109    pub version: i64,
110    pub name: String,
111    pub ops: Vec<MigrationOp>,
112}
113
114/// JSON-encode a string exactly the way both `serde_json::to_string` and the
115/// TypeScript `JSON.stringify` do, so the canonical content below is byte
116/// identical across languages.
117fn json_string(value: &str) -> String {
118    serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
119}
120
121/// Canonical, language-neutral serialization of a single migration op.
122///
123/// The key order is fixed (`op` first, then the op's fields) and string values
124/// use standard JSON escaping, so TypeScript and Rust produce identical bytes
125/// for the same logical op. This is intentionally distinct from the serde wire
126/// format used for migration files; it exists only to feed the checksum.
127fn canonical_op(op: &MigrationOp) -> String {
128    match op {
129        MigrationOp::CreateTable { name } => {
130            format!(r#"{{"op":"create_table","name":{}}}"#, json_string(name))
131        }
132        MigrationOp::DropTable { name } => {
133            format!(r#"{{"op":"drop_table","name":{}}}"#, json_string(name))
134        }
135        MigrationOp::AddColumn { table, column } => format!(
136            r#"{{"op":"add_column","table":{},"column":{}}}"#,
137            json_string(table),
138            json_string(column)
139        ),
140        MigrationOp::DropColumn { table, column } => format!(
141            r#"{{"op":"drop_column","table":{},"column":{}}}"#,
142            json_string(table),
143            json_string(column)
144        ),
145        MigrationOp::AlterColumn { table, column } => format!(
146            r#"{{"op":"alter_column","table":{},"column":{}}}"#,
147            json_string(table),
148            json_string(column)
149        ),
150        MigrationOp::AddIndex { table, index } => format!(
151            r#"{{"op":"add_index","table":{},"index":{}}}"#,
152            json_string(table),
153            json_string(index)
154        ),
155        MigrationOp::DropIndex { table, index } => format!(
156            r#"{{"op":"drop_index","table":{},"index":{}}}"#,
157            json_string(table),
158            json_string(index)
159        ),
160        MigrationOp::AddUnique { table, constraint } => format!(
161            r#"{{"op":"add_unique","table":{},"constraint":{}}}"#,
162            json_string(table),
163            json_string(constraint)
164        ),
165        MigrationOp::DropUnique { table, constraint } => format!(
166            r#"{{"op":"drop_unique","table":{},"constraint":{}}}"#,
167            json_string(table),
168            json_string(constraint)
169        ),
170        MigrationOp::AddForeignKey { table, constraint } => format!(
171            r#"{{"op":"add_foreign_key","table":{},"constraint":{}}}"#,
172            json_string(table),
173            json_string(constraint)
174        ),
175        MigrationOp::DropForeignKey { table, constraint } => format!(
176            r#"{{"op":"drop_foreign_key","table":{},"constraint":{}}}"#,
177            json_string(table),
178            json_string(constraint)
179        ),
180        MigrationOp::AddCheck { table, constraint } => format!(
181            r#"{{"op":"add_check","table":{},"constraint":{}}}"#,
182            json_string(table),
183            json_string(constraint)
184        ),
185        MigrationOp::DropCheck { table, constraint } => format!(
186            r#"{{"op":"drop_check","table":{},"constraint":{}}}"#,
187            json_string(table),
188            json_string(constraint)
189        ),
190        MigrationOp::CreateProcedure { name, procedure } => format!(
191            r#"{{"op":"create_procedure","name":{},"procedure":{}}}"#,
192            json_string(name),
193            procedure.canonical_json()
194        ),
195        MigrationOp::ReplaceProcedure { name, procedure } => format!(
196            r#"{{"op":"replace_procedure","name":{},"procedure":{}}}"#,
197            json_string(name),
198            procedure.canonical_json()
199        ),
200        MigrationOp::DropProcedure { name } => {
201            format!(r#"{{"op":"drop_procedure","name":{}}}"#, json_string(name))
202        }
203        MigrationOp::CreateTrigger { name, trigger } => format!(
204            r#"{{"op":"create_trigger","name":{},"trigger":{}}}"#,
205            json_string(name),
206            trigger.canonical_json()
207        ),
208        MigrationOp::ReplaceTrigger { name, trigger } => format!(
209            r#"{{"op":"replace_trigger","name":{},"trigger":{}}}"#,
210            json_string(name),
211            trigger.canonical_json()
212        ),
213        MigrationOp::DropTrigger { name } => {
214            format!(r#"{{"op":"drop_trigger","name":{}}}"#, json_string(name))
215        }
216        MigrationOp::CreateVirtualTable { table } => format!(
217            r#"{{"op":"create_virtual_table","name":{},"module":{},"args":[{}]}}"#,
218            json_string(&table.name),
219            json_string(&table.module),
220            table
221                .args
222                .iter()
223                .map(|arg| json_string(arg))
224                .collect::<Vec<_>>()
225                .join(",")
226        ),
227        MigrationOp::DropVirtualTable { name } => {
228            format!(
229                r#"{{"op":"drop_virtual_table","name":{}}}"#,
230                json_string(name)
231            )
232        }
233        MigrationOp::CreateView { name, view } => format!(
234            r#"{{"op":"create_view","name":{},"sql":{}}}"#,
235            json_string(name),
236            json_string(&view.sql)
237        ),
238        MigrationOp::ReplaceView { name, view } => format!(
239            r#"{{"op":"replace_view","name":{},"sql":{}}}"#,
240            json_string(name),
241            json_string(&view.sql)
242        ),
243        MigrationOp::DropView { name } => {
244            format!(r#"{{"op":"drop_view","name":{}}}"#, json_string(name))
245        }
246        MigrationOp::RawSql(sql) => {
247            format!(r#"{{"op":"raw_sql","sql":{}}}"#, json_string(sql))
248        }
249    }
250}
251
252/// The canonical content string a migration's checksum is computed over.
253///
254/// Shape: `{"version":<n>,"name":<json>,"ops":[<op>,...]}` with no insignificant
255/// whitespace. Editing a migration's body (its ordered ops) changes this string
256/// and therefore its checksum, which is what lets drift detection notice tamper.
257fn canonical_content(version: i64, name: &str, ops: &[MigrationOp]) -> String {
258    let ops_json: Vec<String> = ops.iter().map(canonical_op).collect();
259    format!(
260        r#"{{"version":{},"name":{},"ops":[{}]}}"#,
261        version,
262        json_string(name),
263        ops_json.join(",")
264    )
265}
266
267/// Compute a deterministic, content-aware SHA-256 checksum for a migration.
268///
269/// The checksum covers the version, name, and the ordered list of ops via a
270/// single canonical serialization ([`canonical_content`]) that is byte-for-byte
271/// identical to the TypeScript kit (`packages/kit/src/migrate.ts`). The same
272/// logical migration therefore produces the same checksum in every language,
273/// and changing any op changes the checksum.
274pub fn migration_checksum(version: i64, name: &str, ops: &[MigrationOp]) -> String {
275    let mut hasher = Sha256::new();
276    hasher.update(canonical_content(version, name, ops).as_bytes());
277    hex::encode(hasher.finalize())
278}
279
280/// Migration convenience method.
281impl Migration {
282    pub fn checksum(&self) -> String {
283        migration_checksum(self.version, &self.name, &self.ops)
284    }
285}
286
287/// Plan the migrations that must be applied.
288///
289/// `applied` is the list of migrations already recorded in the database.
290/// `desired` is the complete, ordered list of migrations defined by the
291/// application. Returns references to the pending migrations in version order.
292///
293/// The function assumes `desired` is sorted by the caller; it returns a sorted
294/// subset. If `applied` is empty, all desired migrations are returned.
295pub fn plan_migrations<'a>(applied: &[Migration], desired: &'a [Migration]) -> Vec<&'a Migration> {
296    let max_applied = applied.iter().map(|m| m.version).max().unwrap_or(i64::MIN);
297    let mut pending: Vec<&'a Migration> =
298        desired.iter().filter(|m| m.version > max_applied).collect();
299    pending.sort_by_key(|m| m.version);
300    pending
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn migration(version: i64, name: &str) -> Migration {
308        Migration {
309            version,
310            name: name.into(),
311            ops: vec![MigrationOp::CreateTable { name: name.into() }],
312        }
313    }
314
315    #[test]
316    fn checksum_is_stable_and_matches_typescript() {
317        // This exact hex is also asserted by the TypeScript kit
318        // (`packages/kit/src/migrate.test.ts`) for the same logical migration,
319        // proving the canonical serialization is byte-identical cross-language.
320        assert_eq!(
321            migration_checksum(
322                1,
323                "init",
324                &[MigrationOp::CreateTable {
325                    name: "users".into()
326                }]
327            ),
328            "fe2f521793591207bd4d8645c2631e4b7ce43e30fe7ea5691a2846c74ea71cc3"
329        );
330        // A multi-op migration vector (also shared with the TypeScript test).
331        assert_eq!(
332            migration_checksum(
333                2,
334                "add_email",
335                &[
336                    MigrationOp::AddColumn {
337                        table: "users".into(),
338                        column: "email".into()
339                    },
340                    MigrationOp::AddUnique {
341                        table: "users".into(),
342                        constraint: "uq_email".into()
343                    }
344                ]
345            ),
346            "5b05a0c349b9c6091e7bd6329a64e2a0e1960a1867471896458de79ca996f2d3"
347        );
348        // No-ops vector.
349        assert_eq!(
350            migration_checksum(1, "init", &[]),
351            "6408373a4372a2c49859db2a4548ea43308e5ba7dd3609998ca376606cf09757"
352        );
353        // An alter_column op (also shared with the TypeScript test).
354        assert_eq!(
355            migration_checksum(
356                3,
357                "alter_payload_type",
358                &[MigrationOp::AlterColumn {
359                    table: "weather_cache".into(),
360                    column: "payload_json".into()
361                }]
362            ),
363            "eabab2122bc784d989e7b368e93f68d1ba1c08ec82ddd1aa132a94eaf6b5db66"
364        );
365    }
366
367    #[test]
368    fn checksum_changes_with_version_name_or_ops() {
369        let base = migration_checksum(
370            1,
371            "init",
372            &[MigrationOp::CreateTable {
373                name: "users".into(),
374            }],
375        );
376        // version
377        assert_ne!(
378            base,
379            migration_checksum(
380                2,
381                "init",
382                &[MigrationOp::CreateTable {
383                    name: "users".into()
384                }]
385            )
386        );
387        // name
388        assert_ne!(
389            base,
390            migration_checksum(
391                1,
392                "other",
393                &[MigrationOp::CreateTable {
394                    name: "users".into()
395                }]
396            )
397        );
398        // op content (table name changed)
399        assert_ne!(
400            base,
401            migration_checksum(
402                1,
403                "init",
404                &[MigrationOp::CreateTable {
405                    name: "accounts".into()
406                }]
407            )
408        );
409        // op kind changed
410        assert_ne!(
411            base,
412            migration_checksum(
413                1,
414                "init",
415                &[MigrationOp::DropTable {
416                    name: "users".into()
417                }]
418            )
419        );
420        // op count changed
421        assert_ne!(base, migration_checksum(1, "init", &[]));
422    }
423
424    #[test]
425    fn checksum_covers_trigger_and_virtual_table_ops() {
426        let trigger = TriggerSpec::new(serde_json::json!({
427            "name": "users_ai",
428            "version": 1,
429            "target": { "kind": "table", "name": "users" },
430            "timing": "after",
431            "event": "insert",
432            "update_of": [],
433            "target_columns": [],
434            "program": { "steps": [] },
435            "enabled": true,
436            "checksum": "",
437            "created_epoch": 0,
438            "updated_epoch": 0
439        }));
440        let base = migration_checksum(4, "triggers", &[]);
441        let with_trigger = migration_checksum(
442            4,
443            "triggers",
444            &[MigrationOp::CreateTrigger {
445                name: "users_ai".into(),
446                trigger,
447            }],
448        );
449        let with_virtual_table = migration_checksum(
450            4,
451            "triggers",
452            &[MigrationOp::CreateVirtualTable {
453                table: VirtualTableSpec::new("docs", "fts_docs", ["prefix=1"]),
454            }],
455        );
456
457        assert_ne!(base, with_trigger);
458        assert_ne!(base, with_virtual_table);
459        assert_ne!(with_trigger, with_virtual_table);
460    }
461
462    #[test]
463    fn plan_migrations_returns_all_when_none_applied() {
464        let desired = vec![migration(1, "a"), migration(2, "b")];
465        let pending = plan_migrations(&[], &desired);
466        assert_eq!(pending.len(), 2);
467        assert_eq!(pending[0].version, 1);
468        assert_eq!(pending[1].version, 2);
469    }
470
471    #[test]
472    fn plan_migrations_skips_applied() {
473        let applied = vec![migration(1, "a")];
474        let desired = vec![migration(1, "a"), migration(2, "b"), migration(3, "c")];
475        let pending = plan_migrations(&applied, &desired);
476        assert_eq!(pending.len(), 2);
477        assert_eq!(pending[0].version, 2);
478        assert_eq!(pending[1].version, 3);
479    }
480
481    #[test]
482    fn plan_migrations_returns_empty_when_fully_applied() {
483        let migrations = vec![migration(1, "a"), migration(2, "b")];
484        let pending = plan_migrations(&migrations, &migrations);
485        assert!(pending.is_empty());
486    }
487}