1use crate::error::MigrationError;
4use crate::loader::{MigrationGraph, is_applied};
5use crate::migration::Migration;
6use crate::operation::Operation;
7use crate::registry::MigrationRegistry;
8use crate::schema_editor;
9use crate::state::ProjectState;
10use chrono::Utc;
11use siderite_orm::types::TIMESTAMP_FORMAT;
12use siderite_orm::{BackendKind, Db, TransactionSupport, Value};
13use std::collections::{HashMap, HashSet};
14use std::future::Future;
15
16mod sqlite;
17use sqlite::{
18 load_sqlite_rebuild_extras, reject_lossy_sqlite_cast, reject_not_null_violations,
19 restore_sqlite_rebuild_extras,
20};
21mod operations;
22use operations::{apply_ops_in_order, unapply_ops_in_order};
23mod progress;
24use progress::{
25 PROGRESS_APPLY, PROGRESS_UNAPPLY, Progress, clear_progress, load_progress,
26 reset_progress_direction, save_progress,
27};
28mod catalog;
29mod intents;
30pub use intents::MigrationIntent;
31mod recovery;
32pub use recovery::{RecoveryEntry, RecoveryReport};
33mod holder;
34pub use holder::LockHolder;
35mod locks;
36use locks::release_migrate_lock;
37
38#[cfg(test)]
39mod intent_tests;
40#[cfg(test)]
41mod tests;
42
43pub const HISTORY_TABLE: &str = "siderite_migrations";
45
46#[derive(Debug, Clone, PartialEq, Eq, Default)]
48pub struct Report {
49 pub planned: Vec<String>,
51 pub sql: Vec<String>,
53 pub applied: Vec<String>,
55 pub dry_run: bool,
57}
58
59pub struct Migrator<'a> {
61 db: &'a Db,
62 graph: &'a MigrationGraph,
63 registry: MigrationRegistry,
64 lock_timeout: std::time::Duration,
65}
66
67impl<'a> Migrator<'a> {
68 pub fn new(db: &'a Db, graph: &'a MigrationGraph) -> Self {
70 Self {
71 db,
72 graph,
73 registry: MigrationRegistry::new(),
74 lock_timeout: std::time::Duration::from_secs(30),
75 }
76 }
77
78 #[must_use]
80 pub fn with_registry(mut self, registry: MigrationRegistry) -> Self {
81 self.registry = registry;
82 self
83 }
84
85 pub fn with_lock_timeout(
99 mut self,
100 timeout: std::time::Duration,
101 ) -> Result<Self, MigrationError> {
102 if timeout.is_zero() || tokio::time::Instant::now().checked_add(timeout).is_none() {
103 return Err(MigrationError::usage("invalid migration lock timeout"));
104 }
105 self.lock_timeout = timeout;
106 Ok(self)
107 }
108
109 pub async fn plan_forward(&self, target: Option<&str>) -> Result<Vec<String>, MigrationError> {
115 let history = self.load_history().await?;
116 self.verify_checksums(&history)?;
117 self.forward_plan(&history, target)
118 }
119
120 pub async fn migrate(
127 &self,
128 target: Option<&str>,
129 dry_run: bool,
130 ) -> Result<Report, MigrationError> {
131 self.require_backend()?;
132 if dry_run {
133 let history = self.load_history().await?;
134 self.verify_checksums(&history)?;
135 let plan = self.forward_plan(&history, target)?;
136 let (sql, _) = self.plan_sql(&history, &plan)?;
137 return Ok(Report {
138 planned: plan,
139 sql,
140 applied: Vec::new(),
141 dry_run: true,
142 });
143 }
144 if self.db.is_scoped() {
145 return Err(MigrationError::state(
146 "migration execution requires a pool handle; \
147 caller-owned transactions and connections are unsupported",
148 ));
149 }
150 self.with_lock(|session| {
151 let this = self;
152 async move {
153 let created_history = if !this.history_table_exists_on(&session).await? {
154 session
155 .execute_script(&create_history_sql(this.kind()))
156 .await?;
157 true
158 } else {
159 false
160 };
161 let history = this.load_history_from(&session).await?;
162 this.verify_checksums(&history)?;
163 let plan = this.forward_plan(&history, target)?;
164 let (mut sql, mut state) = this.plan_sql(&history, &plan)?;
168 if created_history {
169 sql.insert(0, create_history_sql(this.kind()));
170 }
171 let mut applied = Vec::new();
172 for id in &plan {
173 let migration = this.require_migration(id)?.clone();
174 this.apply_one(&session, &migration, &mut state).await?;
175 applied.push(id.clone());
176 }
177 Ok(Report {
178 planned: plan,
179 sql,
180 applied,
181 dry_run: false,
182 })
183 }
184 })
185 .await
186 }
187
188 pub async fn rollback(
197 &self,
198 target: Option<&str>,
199 steps: Option<u32>,
200 dry_run: bool,
201 ) -> Result<Report, MigrationError> {
202 self.require_backend()?;
203 if dry_run {
204 let history = self.load_history().await?;
205 self.verify_checksums(&history)?;
206 let plan = self.rollback_plan(&history, target, steps)?;
207 let sql = self.rollback_sql(&history, &plan)?;
208 return Ok(Report {
209 planned: plan,
210 sql,
211 applied: Vec::new(),
212 dry_run: true,
213 });
214 }
215 if self.db.is_scoped() {
216 return Err(MigrationError::state(
217 "migration execution requires a pool handle; \
218 caller-owned transactions and connections are unsupported",
219 ));
220 }
221 self.with_lock(|session| {
222 let this = self;
223 async move {
224 let created_history = if !this.history_table_exists_on(&session).await? {
225 session
226 .execute_script(&create_history_sql(this.kind()))
227 .await?;
228 true
229 } else {
230 false
231 };
232 let history = this.load_history_from(&session).await?;
233 this.verify_checksums(&history)?;
234 let plan = this.rollback_plan(&history, target, steps)?;
235 let mut sql = this.rollback_sql(&history, &plan)?;
236 if created_history {
237 sql.insert(0, create_history_sql(this.kind()));
238 }
239 let mut unapplied = Vec::new();
240 for id in &plan {
241 let migration = this.require_migration(id)?.clone();
242 let state_before = state_before_migration(this.graph, &history, id)?;
243 this.unapply_one(&session, &migration, &state_before)
244 .await?;
245 unapplied.push(id.clone());
246 }
247 Ok(Report {
248 planned: plan,
249 sql,
250 applied: unapplied,
251 dry_run: false,
252 })
253 }
254 })
255 .await
256 }
257
258 pub async fn show(&self) -> Result<Vec<(String, bool)>, MigrationError> {
263 let history = self.load_history().await?;
264 self.verify_checksums(&history)?;
265 let mut rows = Vec::new();
266 let mut seen = HashSet::new();
267 for id in &self.graph.order {
268 if let Some(m) = self.graph.get(id) {
269 rows.push((id.clone(), is_applied(m, &history.ids)));
270 seen.insert(id.clone());
271 }
272 }
273 let mut rest: Vec<&Migration> = self
274 .graph
275 .all
276 .iter()
277 .filter(|m| !seen.contains(&m.id))
278 .collect();
279 rest.sort_by(|a, b| a.id.cmp(&b.id));
280 for m in rest {
281 rows.push((m.id.clone(), is_applied(m, &history.ids)));
282 }
283 Ok(rows)
284 }
285
286 fn kind(&self) -> BackendKind {
287 self.db.capabilities().kind
288 }
289
290 async fn with_lock<F, Fut>(&self, f: F) -> Result<Report, MigrationError>
291 where
292 F: FnOnce(Db) -> Fut,
293 Fut: Future<Output = Result<Report, MigrationError>>,
294 {
295 let transactional = self.kind() == BackendKind::Sqlite;
302 self.db
303 .schema_change(transactional, |session| async move {
304 locks::acquire(&session, self.lock_timeout).await?;
305 let result = f(session.clone()).await;
306 let unlocked = release_migrate_lock(&session).await;
307 match (result, unlocked) {
308 (Ok(v), Ok(())) => Ok(v),
309 (Err(e), _) => Err(e),
310 (Ok(report), Err(e)) => Err(uncertain(report, e)),
313 }
314 })
315 .await
316 }
317
318 fn require_backend(&self) -> Result<(), MigrationError> {
319 match self.kind() {
320 BackendKind::Postgres | BackendKind::Sqlite | BackendKind::MySql => Ok(()),
321 other => Err(MigrationError::UnsupportedBackend(other)),
322 }
323 }
324
325 fn require_migration(&self, id: &str) -> Result<&Migration, MigrationError> {
326 self.graph
327 .get(id)
328 .ok_or_else(|| MigrationError::usage(format!("unknown migration `{id}`")))
329 }
330
331 fn plan_sql(
337 &self,
338 history: &History,
339 plan: &[String],
340 ) -> Result<(Vec<String>, ProjectState), MigrationError> {
341 let state = replay(self.graph, history)?;
342 let mut render = state.clone();
343 let mut sql = Vec::new();
344 if !history.table_exists {
345 sql.push(create_history_sql(self.kind()));
346 }
347 for id in plan {
348 let migration = self.require_migration(id)?;
349 sql.extend(schema_editor::statements(
350 self.kind(),
351 &render,
352 &migration.operations,
353 )?);
354 for op in &migration.operations {
355 op.apply_to_state(&mut render)?;
356 }
357 }
358 Ok((sql, state))
359 }
360
361 fn rollback_sql(
363 &self,
364 history: &History,
365 plan: &[String],
366 ) -> Result<Vec<String>, MigrationError> {
367 let mut sql = Vec::new();
368 for id in plan {
369 let migration = self.require_migration(id)?;
370 let state_before = state_before_migration(self.graph, history, id)?;
371 sql.extend(reverse_sql(self.kind(), migration, &state_before)?);
372 }
373 Ok(sql)
374 }
375
376 fn forward_plan(
377 &self,
378 history: &History,
379 target: Option<&str>,
380 ) -> Result<Vec<String>, MigrationError> {
381 if let Some(id) = target
382 && self.graph.get(id).is_none()
383 {
384 return Err(MigrationError::usage(format!("unknown migration `{id}`")));
385 }
386 let mut plan = Vec::new();
387 for id in &self.graph.order {
388 let migration = self.require_migration(id)?;
389 if is_applied(migration, &history.ids) {
390 if target == Some(id.as_str()) {
391 return Ok(plan);
392 }
393 continue;
394 }
395 plan.push(id.clone());
396 if target == Some(id.as_str()) {
397 break;
398 }
399 }
400 if let Some(id) = target
401 && !plan.iter().any(|p| p == id)
402 && !history.ids.contains(id)
403 && !self
404 .graph
405 .get(id)
406 .is_some_and(|m| is_applied(m, &history.ids))
407 {
408 if self.graph.replaced_by.contains_key(id) {
411 return Ok(plan);
412 }
413 return Err(MigrationError::usage(format!(
414 "migration `{id}` is not reachable from the current graph"
415 )));
416 }
417 Ok(plan)
418 }
419
420 fn rollback_plan(
421 &self,
422 history: &History,
423 target: Option<&str>,
424 steps: Option<u32>,
425 ) -> Result<Vec<String>, MigrationError> {
426 let applied_visible: Vec<String> = self
427 .graph
428 .order
429 .iter()
430 .filter(|id| {
431 self.graph
432 .get(id)
433 .is_some_and(|m| is_applied(m, &history.ids))
434 })
435 .cloned()
436 .collect();
437 if let Some(n) = steps {
438 let n = n as usize;
439 let start = applied_visible.len().saturating_sub(n);
440 let mut plan = applied_visible[start..].to_vec();
441 plan.reverse();
442 return Ok(plan);
443 }
444 let Some(target) = target else {
445 return Err(MigrationError::usage(
446 "rollback requires --steps N or a target id",
447 ));
448 };
449 if !applied_visible.iter().any(|id| id == target) && !history.ids.contains(target) {
450 return Err(MigrationError::usage(format!(
451 "migration `{target}` is not applied"
452 )));
453 }
454 let mut plan = Vec::new();
456 let mut after = false;
457 for id in &applied_visible {
458 if after {
459 plan.push(id.clone());
460 }
461 if id == target {
462 after = true;
463 }
464 }
465 plan.reverse();
466 Ok(plan)
467 }
468
469 fn verify_checksums(&self, history: &History) -> Result<(), MigrationError> {
470 for migration in &self.graph.all {
471 if let Some(stored) = history.checksums.get(&migration.id)
472 && stored != &migration.checksum
473 {
474 return Err(MigrationError::ChecksumMismatch {
475 id: migration.id.clone(),
476 history: stored.clone(),
477 file: migration.checksum.clone(),
478 });
479 }
480 }
481 Ok(())
482 }
483
484 async fn load_history(&self) -> Result<History, MigrationError> {
485 self.load_history_from(self.db).await
486 }
487
488 async fn load_history_from(&self, db: &Db) -> Result<History, MigrationError> {
489 if !self.history_table_exists_on(db).await? {
490 return Ok(History {
491 ids: HashSet::new(),
492 checksums: HashMap::new(),
493 table_exists: false,
494 });
495 }
496 let result = db
500 .raw_sql(
501 &format!(
502 "SELECT {} as id, {} as checksum FROM {}",
503 quote_star(self.kind(), "id"),
504 quote_star(self.kind(), "checksum"),
505 quote_star(self.kind(), HISTORY_TABLE)
506 ),
507 Vec::new(),
508 )
509 .await?;
510 let mut ids = HashSet::new();
511 let mut checksums = HashMap::new();
512 for row in result.rows {
513 let Some(Value::Text(id)) = row.get("id") else {
514 return Err(MigrationError::state(format!(
515 "`{HISTORY_TABLE}` has a row without a text `id`"
516 )));
517 };
518 let checksum = match row.get("checksum") {
519 Some(Value::Text(s)) => s.clone(),
520 _ => String::new(),
521 };
522 ids.insert(id.clone());
523 checksums.insert(id.clone(), checksum);
524 }
525 Ok(History {
526 ids,
527 checksums,
528 table_exists: true,
529 })
530 }
531
532 async fn history_table_exists_on(&self, db: &Db) -> Result<bool, MigrationError> {
535 self.table_exists_on(db, HISTORY_TABLE).await
536 }
537
538 async fn table_exists_on(&self, db: &Db, table: &str) -> Result<bool, MigrationError> {
539 catalog::table_exists(db, table).await
540 }
541
542 async fn apply_one(
543 &self,
544 db: &Db,
545 migration: &Migration,
546 state: &mut ProjectState,
547 ) -> Result<(), MigrationError> {
548 let kind = self.kind();
549 let migration = migration.clone();
550 let start_state = state.clone();
551 let run = |db: Db| {
552 let migration = migration.clone();
553 let registry = self.registry.clone();
554 let start_state = start_state.clone();
555 async move {
556 apply_ops_in_order(&db, kind, &migration, &start_state, ®istry).await?;
557 Ok::<_, MigrationError>(())
558 }
559 };
560 if db.in_transaction() {
565 run(db.clone()).await?;
566 } else if runs_in_transaction(db, &migration) {
567 db.transaction(run).await?;
568 } else {
569 run(db.clone()).await?;
570 }
571 for op in &migration.operations {
572 op.apply_to_state(state)?;
573 }
574 Ok(())
575 }
576
577 async fn unapply_one(
578 &self,
579 db: &Db,
580 migration: &Migration,
581 state_before: &ProjectState,
582 ) -> Result<(), MigrationError> {
583 let kind = self.kind();
584 let migration = migration.clone();
588 let start_state = state_before.clone();
589 let run = |db: Db| {
590 let migration = migration.clone();
591 let registry = self.registry.clone();
592 let start_state = start_state.clone();
593 async move {
594 unapply_ops_in_order(&db, kind, &migration, &start_state, ®istry).await?;
595 Ok::<_, MigrationError>(())
596 }
597 };
598 if db.in_transaction() {
601 run(db.clone()).await?;
602 } else if runs_in_transaction(db, &migration) {
603 db.transaction(run).await?;
604 } else {
605 run(db.clone()).await?;
606 }
607 Ok(())
608 }
609}
610
611struct History {
612 ids: HashSet<String>,
613 checksums: HashMap<String, String>,
614 table_exists: bool,
615}
616
617fn quote_star(kind: BackendKind, ident: &str) -> String {
618 schema_editor::quote_identifier(kind, ident)
619}
620
621fn runs_in_transaction(db: &Db, migration: &Migration) -> bool {
625 let caps = db.capabilities();
626 migration.atomic
627 && caps.transactions > TransactionSupport::None
628 && caps.kind != BackendKind::MySql
629}
630
631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
639enum OnFailure {
640 Rollback,
642 ImplicitCommit,
644 Autocommit,
647}
648
649impl OnFailure {
650 fn of(kind: BackendKind, atomic: bool) -> Self {
657 match kind {
658 BackendKind::MySql => Self::ImplicitCommit,
659 BackendKind::Postgres if !atomic => Self::Autocommit,
660 _ => Self::Rollback,
661 }
662 }
663
664 const fn resumes(self) -> bool {
666 !matches!(self, Self::Rollback)
667 }
668
669 fn prior_committed(self, committed: bool, ddl: bool) -> bool {
676 match self {
677 Self::Rollback => false,
678 Self::ImplicitCommit => ddl,
679 Self::Autocommit => committed,
680 }
681 }
682}
683
684fn op_partial(
687 failure: OnFailure,
688 id: &str,
689 index: usize,
690 total: usize,
691 summary: String,
692 source: MigrationError,
693) -> MigrationError {
694 let id = id.to_owned();
695 match failure {
696 OnFailure::ImplicitCommit => MigrationError::MysqlOpPartial {
697 id,
698 index,
699 total,
700 summary,
701 source: Box::new(source),
702 },
703 OnFailure::Autocommit => MigrationError::PostgresOpPartial {
704 id,
705 index,
706 total,
707 summary,
708 source: Box::new(source),
709 },
710 OnFailure::Rollback => source,
711 }
712}
713
714fn create_history_sql(kind: BackendKind) -> String {
715 let key_type = if kind == BackendKind::MySql {
717 "VARCHAR(255)"
718 } else {
719 "TEXT"
720 };
721 format!(
722 "CREATE TABLE IF NOT EXISTS {} (\n {} {key_type} PRIMARY KEY,\n {} TEXT NOT NULL,\n {} TEXT NOT NULL\n)",
723 quote_star(kind, HISTORY_TABLE),
724 quote_star(kind, "id"),
725 quote_star(kind, "checksum"),
726 quote_star(kind, "applied_at"),
727 )
728}
729
730async fn execute_one_sql(
731 db: &Db,
732 failure: OnFailure,
733 migration_id: &str,
734 index: usize,
735 total: usize,
736 prior_committed: bool,
737 sql: &str,
738) -> Result<(), MigrationError> {
739 if let Err(err) = db.execute_script(sql).await {
740 if prior_committed {
745 return Err(match failure {
746 OnFailure::ImplicitCommit => MigrationError::MysqlPartial {
747 id: migration_id.to_owned(),
748 index,
749 total,
750 source: err,
751 },
752 OnFailure::Autocommit => MigrationError::PostgresPartial {
753 id: migration_id.to_owned(),
754 index,
755 total,
756 source: err,
757 },
758 OnFailure::Rollback => err.into(),
759 });
760 }
761 return Err(err.into());
762 }
763 Ok(())
764}
765
766fn is_implicit_commit(sql: &str) -> bool {
770 matches!(
771 first_keyword(sql).as_str(),
772 "CREATE" | "ALTER" | "DROP" | "TRUNCATE" | "RENAME"
773 )
774}
775
776fn first_keyword(sql: &str) -> String {
777 let mut rest = sql.trim_start();
778 loop {
779 if let Some(body) = rest.strip_prefix("--") {
780 rest = body
781 .split_once('\n')
782 .map(|(_, tail)| tail)
783 .unwrap_or("")
784 .trim_start();
785 } else if let Some(body) = rest.strip_prefix("/*") {
786 rest = body
787 .split_once("*/")
788 .map(|(_, tail)| tail)
789 .unwrap_or("")
790 .trim_start();
791 } else {
792 break;
793 }
794 }
795 rest.split(|c: char| !c.is_ascii_alphabetic())
796 .next()
797 .unwrap_or("")
798 .to_ascii_uppercase()
799}
800
801fn placeholder(kind: BackendKind, index: usize) -> String {
802 match kind {
803 BackendKind::Postgres => format!("${index}"),
804 _ => "?".to_owned(),
805 }
806}
807
808async fn record_history(
809 db: &Db,
810 kind: BackendKind,
811 migration: &Migration,
812) -> Result<(), MigrationError> {
813 let sql = format!(
814 "INSERT INTO {} ({}, {}, {}) VALUES ({}, {}, {})",
815 quote_star(kind, HISTORY_TABLE),
816 quote_star(kind, "id"),
817 quote_star(kind, "checksum"),
818 quote_star(kind, "applied_at"),
819 placeholder(kind, 1),
820 placeholder(kind, 2),
821 placeholder(kind, 3),
822 );
823 let applied_at = Utc::now().format(TIMESTAMP_FORMAT).to_string();
824 db.raw_execute(
825 &sql,
826 vec![
827 Value::Text(migration.id.clone()),
828 Value::Text(migration.checksum.clone()),
829 Value::Text(applied_at),
830 ],
831 )
832 .await?;
833 Ok(())
834}
835
836async fn delete_history(db: &Db, kind: BackendKind, id: &str) -> Result<(), MigrationError> {
837 let sql = format!(
838 "DELETE FROM {} WHERE {} = {}",
839 quote_star(kind, HISTORY_TABLE),
840 quote_star(kind, "id"),
841 placeholder(kind, 1),
842 );
843 db.raw_execute(&sql, vec![Value::Text(id.to_owned())])
844 .await?;
845 Ok(())
846}
847
848fn replay(graph: &MigrationGraph, history: &History) -> Result<ProjectState, MigrationError> {
849 let mut state = ProjectState::new();
850 for id in &graph.order {
851 let Some(migration) = graph.get(id) else {
852 continue;
853 };
854 if !is_applied(migration, &history.ids) {
855 continue;
856 }
857 for op in &migration.operations {
858 op.apply_to_state(&mut state)?;
859 }
860 }
861 Ok(state)
862}
863
864fn state_before_migration(
865 graph: &MigrationGraph,
866 history: &History,
867 id: &str,
868) -> Result<ProjectState, MigrationError> {
869 let mut state = ProjectState::new();
870 for vis in &graph.order {
871 if vis == id {
872 break;
873 }
874 let Some(migration) = graph.get(vis) else {
875 continue;
876 };
877 if !is_applied(migration, &history.ids) {
878 continue;
879 }
880 for op in &migration.operations {
881 op.apply_to_state(&mut state)?;
882 }
883 }
884 Ok(state)
885}
886
887fn reverse_sql(
888 kind: BackendKind,
889 migration: &Migration,
890 state_before: &ProjectState,
891) -> Result<Vec<String>, MigrationError> {
892 let mut snapshots = vec![state_before.clone()];
893 let mut current = state_before.clone();
894 for op in &migration.operations {
895 op.apply_to_state(&mut current)?;
896 snapshots.push(current.clone());
897 }
898 let mut sql = Vec::new();
899 current = snapshots
900 .last()
901 .cloned()
902 .unwrap_or_else(|| state_before.clone());
903 for i in (0..migration.operations.len()).rev() {
904 let op = &migration.operations[i];
905 let Some(rev) = op.reverse(&snapshots[i]) else {
906 return Err(MigrationError::Irreversible {
907 id: migration.id.clone(),
908 reason: format!("{} has no reverse", op.summary()),
909 });
910 };
911 sql.extend(schema_editor::render(kind, &rev, ¤t)?);
912 rev.apply_to_state(&mut current)?;
913 }
914 Ok(sql)
915}
916
917fn uncertain(report: Report, source: MigrationError) -> MigrationError {
918 MigrationError::LockReleaseUncertain {
919 applied: report.applied,
920 source: Box::new(source),
921 }
922}