1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use crate::external::{ViewSpec, 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 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#[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
114fn json_string(value: &str) -> String {
118 serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
119}
120
121fn 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
252fn 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
267pub 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
280impl Migration {
282 pub fn checksum(&self) -> String {
283 migration_checksum(self.version, &self.name, &self.ops)
284 }
285}
286
287pub 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 assert_eq!(
321 migration_checksum(
322 1,
323 "init",
324 &[MigrationOp::CreateTable {
325 name: "users".into()
326 }]
327 ),
328 "fe2f521793591207bd4d8645c2631e4b7ce43e30fe7ea5691a2846c74ea71cc3"
329 );
330 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 assert_eq!(
350 migration_checksum(1, "init", &[]),
351 "6408373a4372a2c49859db2a4548ea43308e5ba7dd3609998ca376606cf09757"
352 );
353 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 assert_ne!(
378 base,
379 migration_checksum(
380 2,
381 "init",
382 &[MigrationOp::CreateTable {
383 name: "users".into()
384 }]
385 )
386 );
387 assert_ne!(
389 base,
390 migration_checksum(
391 1,
392 "other",
393 &[MigrationOp::CreateTable {
394 name: "users".into()
395 }]
396 )
397 );
398 assert_ne!(
400 base,
401 migration_checksum(
402 1,
403 "init",
404 &[MigrationOp::CreateTable {
405 name: "accounts".into()
406 }]
407 )
408 );
409 assert_ne!(
411 base,
412 migration_checksum(
413 1,
414 "init",
415 &[MigrationOp::DropTable {
416 name: "users".into()
417 }]
418 )
419 );
420 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}