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