1use std::collections::HashSet;
17
18const ADVISORY_LOCK_KEY: i64 = 0x5947_4752_4153_494C;
22
23const MIGRATIONS: &[(&str, &str)] = &[
31 ("001", include_str!("../../migrations/001_init.sql")),
32 ("002", include_str!("../../migrations/002_posts.sql")),
33 ("003", include_str!("../../migrations/003_indexes.sql")),
34 ("004", include_str!("../../migrations/004_search_trgm.sql")),
35 ("005", include_str!("../../migrations/005_comments.sql")),
36 ("006", include_str!("../../migrations/006_add_toc_html.sql")),
37 ("007", include_str!("../../migrations/007_settings.sql")),
38 (
39 "008",
40 include_str!("../../migrations/008_comments_cascade.sql"),
41 ),
42 (
43 "009",
44 include_str!("../../migrations/009_cleanup_duplicate_indexes.sql"),
45 ),
46 (
47 "010",
48 include_str!("../../migrations/010_post_word_counts.sql"),
49 ),
50 ("011", include_str!("../../migrations/011_perf_indexes.sql")),
51 (
52 "012",
53 include_str!("../../migrations/012_session_generation.sql"),
54 ),
55 (
56 "013",
57 include_str!("../../migrations/013_comment_content_hash_index.sql"),
58 ),
59 (
60 "014",
61 include_str!("../../migrations/014_drop_ineffective_trgm_index.sql"),
62 ),
63 ("015", include_str!("../../migrations/015_assets.sql")),
64 (
65 "016",
66 include_str!("../../migrations/016_assets_content_hash.sql"),
67 ),
68 ("017", include_str!("../../migrations/017_mcp_tokens.sql")),
69 (
70 "018",
71 include_str!("../../migrations/018_session_generation_trigger.sql"),
72 ),
73 (
74 "019",
75 include_str!("../../migrations/019_restore_search_trgm_index.sql"),
76 ),
77 ("020", include_str!("../../migrations/020_friend_links.sql")),
78 ];
80
81#[derive(Debug)]
83pub enum MigrateError {
84 Pool(deadpool_postgres::PoolError),
86 Query(tokio_postgres::Error),
88 Apply {
90 version: String,
91 source: tokio_postgres::Error,
92 },
93}
94
95impl From<deadpool_postgres::PoolError> for MigrateError {
96 fn from(e: deadpool_postgres::PoolError) -> Self {
97 MigrateError::Pool(e)
98 }
99}
100
101impl From<tokio_postgres::Error> for MigrateError {
102 fn from(e: tokio_postgres::Error) -> Self {
103 MigrateError::Query(e)
104 }
105}
106
107impl std::fmt::Display for MigrateError {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 match self {
112 MigrateError::Pool(e) => {
113 write!(
114 f,
115 "database pool error: {}",
116 crate::db::format_with_sources(e)
117 )
118 }
119 MigrateError::Query(e) => {
120 write!(
121 f,
122 "database query error: {}",
123 crate::db::format_with_sources(e)
124 )
125 }
126 MigrateError::Apply { version, source } => {
127 write!(
128 f,
129 "migration {} failed: {}",
130 version,
131 crate::db::format_with_sources(source)
132 )
133 }
134 }
135 }
136}
137
138impl std::error::Error for MigrateError {
139 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
140 match self {
141 MigrateError::Pool(e) => Some(e),
142 MigrateError::Query(e) => Some(e),
143 MigrateError::Apply { source, .. } => Some(source),
144 }
145 }
146}
147
148pub async fn run_on_conn(conn: &mut deadpool_postgres::Object) -> Result<(), MigrateError> {
163 conn.execute("SELECT pg_advisory_lock($1)", &[&ADVISORY_LOCK_KEY])
166 .await?;
167
168 let result = run_inner(conn).await;
178
179 if let Err(unlock_err) = conn
181 .execute("SELECT pg_advisory_unlock($1)", &[&ADVISORY_LOCK_KEY])
182 .await
183 {
184 tracing::warn!("failed to release migration advisory lock: {}", unlock_err);
185 }
186
187 result
188}
189
190async fn run_inner(conn: &mut deadpool_postgres::Object) -> Result<(), MigrateError> {
192 ensure_versions_table(conn).await?;
194
195 let applied = applied_versions(conn).await?;
197
198 let mut applied_count = 0usize;
200 for (version, sql) in MIGRATIONS {
201 if applied.contains(*version) {
202 continue;
203 }
204 tracing::info!("applying migration {}", version);
205 apply_one(conn, version, sql).await?;
206 applied_count += 1;
207 }
208
209 if applied_count == 0 {
210 tracing::info!("database is up to date, 0 migrations applied");
211 } else {
212 tracing::info!("successfully applied {} migration(s)", applied_count);
213 }
214 Ok(())
215}
216
217async fn ensure_versions_table(conn: &deadpool_postgres::Object) -> Result<(), MigrateError> {
219 conn.batch_execute(
220 "CREATE TABLE IF NOT EXISTS schema_migrations (
221 version TEXT PRIMARY KEY,
222 applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
223 )",
224 )
225 .await?;
226 Ok(())
227}
228
229async fn applied_versions(
231 conn: &deadpool_postgres::Object,
232) -> Result<HashSet<String>, MigrateError> {
233 let rows = conn
234 .query("SELECT version FROM schema_migrations", &[])
235 .await?;
236 let mut set = HashSet::with_capacity(rows.len());
237 for row in rows {
238 set.insert(row.get::<_, String>(0));
239 }
240 Ok(set)
241}
242
243async fn apply_one(
245 conn: &mut deadpool_postgres::Object,
246 version: &str,
247 sql: &str,
248) -> Result<(), MigrateError> {
249 let tx = conn.transaction().await.map_err(MigrateError::Query)?;
250
251 if let Err(e) = tx.batch_execute(sql).await {
253 let _ = tx.rollback().await;
256 return Err(MigrateError::Apply {
257 version: version.to_string(),
258 source: e,
259 });
260 }
261
262 if let Err(e) = tx
264 .execute(
265 "INSERT INTO schema_migrations (version) VALUES ($1)",
266 &[&version],
267 )
268 .await
269 {
270 let _ = tx.rollback().await;
271 return Err(MigrateError::Apply {
272 version: version.to_string(),
273 source: e,
274 });
275 }
276
277 tx.commit().await.map_err(|e| MigrateError::Apply {
278 version: version.to_string(),
279 source: e,
280 })?;
281 Ok(())
282}
283
284#[cfg(all(test, feature = "server"))]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn migrations_are_sorted_ascending() {
290 let mut sorted = MIGRATIONS.iter().map(|(v, _)| *v).collect::<Vec<_>>();
291 sorted.sort_unstable();
292 let original: Vec<&str> = MIGRATIONS.iter().map(|(v, _)| *v).collect();
293 assert_eq!(
294 original, sorted,
295 "MIGRATIONS must be in ascending version order"
296 );
297 }
298
299 #[test]
300 fn migrations_have_unique_versions() {
301 let mut versions: Vec<&str> = MIGRATIONS.iter().map(|(v, _)| *v).collect();
302 let total = versions.len();
303 versions.sort_unstable();
304 versions.dedup();
305 assert_eq!(
306 versions.len(),
307 total,
308 "MIGRATIONS has duplicate version strings"
309 );
310 }
311
312 #[test]
313 fn migrations_non_empty() {
314 assert!(!MIGRATIONS.is_empty(), "MIGRATIONS must not be empty");
315 }
316
317 #[test]
321 fn migrations_match_files_on_disk() {
322 use std::collections::HashSet;
323 use std::fs;
324
325 let manifest_dir = env!("CARGO_MANIFEST_DIR");
327 let migrations_dir = std::path::Path::new(manifest_dir).join("migrations");
328
329 let mut files_on_disk: HashSet<String> = HashSet::new();
330 for entry in fs::read_dir(&migrations_dir)
331 .unwrap_or_else(|e| panic!("failed to read {}: {}", migrations_dir.display(), e))
332 {
333 let entry = entry.unwrap();
334 let path = entry.path();
335 if path.extension().and_then(|e| e.to_str()) == Some("sql") {
336 let filename = path
337 .file_name()
338 .and_then(|n| n.to_str())
339 .unwrap_or_else(|| panic!("non-utf8 filename: {}", path.display()));
340 let version = filename
342 .split('_')
343 .next()
344 .unwrap_or_else(|| panic!("filename has no '_' separator: {}", filename));
345 files_on_disk.insert(version.to_string());
346 }
347 }
348
349 let versions_in_array: HashSet<String> =
350 MIGRATIONS.iter().map(|(v, _)| v.to_string()).collect();
351
352 let missing_in_array: Vec<&String> = files_on_disk.difference(&versions_in_array).collect();
354 assert!(
355 missing_in_array.is_empty(),
356 "migrations/*.sql files not registered in MIGRATIONS: {:?}. \
357 Add a row for each in src/db/migrate.rs.",
358 missing_in_array
359 );
360
361 let missing_on_disk: Vec<&String> = versions_in_array.difference(&files_on_disk).collect();
363 assert!(
364 missing_on_disk.is_empty(),
365 "MIGRATIONS rows without a corresponding .sql file: {:?}",
366 missing_on_disk
367 );
368 }
369}