1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use crate::external::VirtualTableSpec;
7use crate::procedure::ProcedureSpec;
8use crate::trigger::TriggerSpec;
9
10#[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#[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
103fn json_string(value: &str) -> String {
107 serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
108}
109
110fn 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
228fn 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
243pub 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
256impl Migration {
258 pub fn checksum(&self) -> String {
259 migration_checksum(self.version, &self.name, &self.ops)
260 }
261}
262
263pub 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 assert_eq!(
297 migration_checksum(
298 1,
299 "init",
300 &[MigrationOp::CreateTable {
301 name: "users".into()
302 }]
303 ),
304 "fe2f521793591207bd4d8645c2631e4b7ce43e30fe7ea5691a2846c74ea71cc3"
305 );
306 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 assert_eq!(
326 migration_checksum(1, "init", &[]),
327 "6408373a4372a2c49859db2a4548ea43308e5ba7dd3609998ca376606cf09757"
328 );
329 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 assert_ne!(
354 base,
355 migration_checksum(
356 2,
357 "init",
358 &[MigrationOp::CreateTable {
359 name: "users".into()
360 }]
361 )
362 );
363 assert_ne!(
365 base,
366 migration_checksum(
367 1,
368 "other",
369 &[MigrationOp::CreateTable {
370 name: "users".into()
371 }]
372 )
373 );
374 assert_ne!(
376 base,
377 migration_checksum(
378 1,
379 "init",
380 &[MigrationOp::CreateTable {
381 name: "accounts".into()
382 }]
383 )
384 );
385 assert_ne!(
387 base,
388 migration_checksum(
389 1,
390 "init",
391 &[MigrationOp::DropTable {
392 name: "users".into()
393 }]
394 )
395 );
396 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}