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