Skip to main content

mongreldb_kit_core/
migrations.rs

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