cratestack_sqlx/
migrations.rs1use crate::sqlx;
6use cratestack_core::CoolError;
7use sha2::{Digest, Sha256};
8
9pub const MIGRATIONS_TABLE_DDL: &str = r#"
10CREATE TABLE IF NOT EXISTS cratestack_migrations (
11 id TEXT PRIMARY KEY,
12 description TEXT NOT NULL,
13 checksum BYTEA NOT NULL,
14 applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15);
16"#;
17
18#[derive(Debug, Clone)]
22pub struct Migration {
23 pub id: String,
25 pub description: String,
26 pub up: String,
27 pub down: Option<String>,
28}
29
30impl Migration {
31 pub fn checksum(&self) -> [u8; 32] {
32 let mut hasher = Sha256::new();
33 hasher.update(self.id.as_bytes());
34 hasher.update(b"\0");
35 hasher.update(self.description.as_bytes());
36 hasher.update(b"\0");
37 hasher.update(self.up.as_bytes());
38 hasher.finalize().into()
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum MigrationStatus {
44 Pending,
45 Applied,
46 ChecksumMismatch,
47}
48
49#[derive(Debug, Clone)]
50pub struct MigrationState {
51 pub id: String,
52 pub status: MigrationStatus,
53}
54
55pub async fn ensure_migrations_table(pool: &sqlx::PgPool) -> Result<(), CoolError> {
56 sqlx::raw_sql(MIGRATIONS_TABLE_DDL)
60 .execute(pool)
61 .await
62 .map_err(|error| CoolError::Database(error.to_string()))?;
63 Ok(())
64}
65
66pub async fn status(
70 pool: &sqlx::PgPool,
71 migrations: &[Migration],
72) -> Result<Vec<MigrationState>, CoolError> {
73 ensure_migrations_table(pool).await?;
74 let rows = sqlx::query_as::<_, (String, Vec<u8>)>(
75 "SELECT id, checksum FROM cratestack_migrations ORDER BY id",
76 )
77 .fetch_all(pool)
78 .await
79 .map_err(|error| CoolError::Database(error.to_string()))?;
80
81 let mut applied: std::collections::HashMap<String, Vec<u8>> = std::collections::HashMap::new();
82 for (id, checksum) in rows {
83 applied.insert(id, checksum);
84 }
85
86 Ok(migrations
87 .iter()
88 .map(|m| {
89 let id = m.id.clone();
90 match applied.get(&id) {
91 Some(stored) if stored.as_slice() == m.checksum().as_slice() => MigrationState {
92 id,
93 status: MigrationStatus::Applied,
94 },
95 Some(_) => MigrationState {
96 id,
97 status: MigrationStatus::ChecksumMismatch,
98 },
99 None => MigrationState {
100 id,
101 status: MigrationStatus::Pending,
102 },
103 }
104 })
105 .collect())
106}
107
108pub async fn apply_pending(
113 pool: &sqlx::PgPool,
114 migrations: &[Migration],
115) -> Result<Vec<String>, CoolError> {
116 let states = status(pool, migrations).await?;
117 for (state, migration) in states.iter().zip(migrations) {
118 if state.status == MigrationStatus::ChecksumMismatch {
119 return Err(CoolError::Internal(format!(
120 "migration `{}` is recorded as applied but its SQL has changed; \
121 resolve drift before continuing",
122 migration.id
123 )));
124 }
125 }
126
127 let mut applied = Vec::new();
128 for (state, migration) in states.iter().zip(migrations) {
129 if state.status != MigrationStatus::Pending {
130 continue;
131 }
132 let mut tx = pool
133 .begin()
134 .await
135 .map_err(|error| CoolError::Database(error.to_string()))?;
136 sqlx::raw_sql(&migration.up)
142 .execute(&mut *tx)
143 .await
144 .map_err(|error| CoolError::Database(error.to_string()))?;
145 sqlx::query(
146 "INSERT INTO cratestack_migrations (id, description, checksum) VALUES ($1, $2, $3)",
147 )
148 .bind(&migration.id)
149 .bind(&migration.description)
150 .bind(migration.checksum().as_slice())
151 .execute(&mut *tx)
152 .await
153 .map_err(|error| CoolError::Database(error.to_string()))?;
154 tx.commit()
155 .await
156 .map_err(|error| CoolError::Database(error.to_string()))?;
157 applied.push(migration.id.clone());
158 }
159
160 Ok(applied)
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 fn migration(id: &str, up: &str) -> Migration {
168 Migration {
169 id: id.to_owned(),
170 description: format!("migration {id}"),
171 up: up.to_owned(),
172 down: None,
173 }
174 }
175
176 #[test]
177 fn checksum_changes_when_up_sql_changes() {
178 let a = migration("20260101000000_init", "CREATE TABLE a (id INT);");
179 let mut b = a.clone();
180 b.up = "CREATE TABLE a (id BIGINT);".to_owned();
181 assert_ne!(a.checksum(), b.checksum());
182 }
183
184 #[test]
185 fn checksum_is_stable_for_same_inputs() {
186 let a = migration("20260101000000_init", "CREATE TABLE a (id INT);");
187 let b = a.clone();
188 assert_eq!(a.checksum(), b.checksum());
189 }
190}