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 (
79 "021",
80 include_str!("../../migrations/021_site_settings.sql"),
81 ),
82 (
83 "022",
84 include_str!("../../migrations/022_backup_settings.sql"),
85 ),
86 ("023", include_str!("../../migrations/023_user_profile.sql")),
87 (
88 "024",
89 include_str!("../../migrations/024_comments_user_id.sql"),
90 ),
91 ("025", include_str!("../../migrations/025_logs.sql")),
92 (
93 "026",
94 include_str!("../../migrations/026_katex_html_classes.sql"),
95 ),
96 ];
98
99#[derive(Debug)]
101pub enum MigrateError {
102 Pool(deadpool_postgres::PoolError),
104 Query(tokio_postgres::Error),
106 Apply {
108 version: String,
109 source: tokio_postgres::Error,
110 },
111 KatexHtml {
113 table: &'static str,
114 id: i64,
115 source: lol_html::errors::RewritingError,
116 },
117}
118
119impl From<deadpool_postgres::PoolError> for MigrateError {
120 fn from(e: deadpool_postgres::PoolError) -> Self {
121 MigrateError::Pool(e)
122 }
123}
124
125impl From<tokio_postgres::Error> for MigrateError {
126 fn from(e: tokio_postgres::Error) -> Self {
127 MigrateError::Query(e)
128 }
129}
130
131impl std::fmt::Display for MigrateError {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 match self {
136 MigrateError::Pool(e) => {
137 write!(
138 f,
139 "database pool error: {}",
140 crate::db::format_with_sources(e)
141 )
142 }
143 MigrateError::Query(e) => {
144 write!(
145 f,
146 "database query error: {}",
147 crate::db::format_with_sources(e)
148 )
149 }
150 MigrateError::Apply { version, source } => {
151 write!(
152 f,
153 "migration {} failed: {}",
154 version,
155 crate::db::format_with_sources(source)
156 )
157 }
158 MigrateError::KatexHtml { table, id, source } => {
159 write!(
160 f,
161 "migration 026 failed rewriting {table} row {id}: {source}"
162 )
163 }
164 }
165 }
166}
167
168impl std::error::Error for MigrateError {
169 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
170 match self {
171 MigrateError::Pool(e) => Some(e),
172 MigrateError::Query(e) => Some(e),
173 MigrateError::Apply { source, .. } => Some(source),
174 MigrateError::KatexHtml { source, .. } => Some(source),
175 }
176 }
177}
178
179pub async fn run_on_conn(conn: &mut deadpool_postgres::Object) -> Result<(), MigrateError> {
194 conn.execute("SELECT pg_advisory_lock($1)", &[&ADVISORY_LOCK_KEY])
197 .await?;
198
199 let result = run_inner(conn).await;
209
210 if let Err(unlock_err) = conn
212 .execute("SELECT pg_advisory_unlock($1)", &[&ADVISORY_LOCK_KEY])
213 .await
214 {
215 tracing::warn!("failed to release migration advisory lock: {}", unlock_err);
216 }
217
218 result
219}
220
221async fn run_inner(conn: &mut deadpool_postgres::Object) -> Result<(), MigrateError> {
223 ensure_versions_table(conn).await?;
225
226 let applied = applied_versions(conn).await?;
228
229 let mut applied_count = 0usize;
231 for (version, sql) in MIGRATIONS {
232 if applied.contains(*version) {
233 continue;
234 }
235 tracing::info!("applying migration {}", version);
236 apply_one(conn, version, sql).await?;
237 applied_count += 1;
238 }
239
240 if applied_count == 0 {
241 tracing::info!("database is up to date, 0 migrations applied");
242 } else {
243 tracing::info!("successfully applied {} migration(s)", applied_count);
244 }
245 Ok(())
246}
247
248async fn ensure_versions_table(conn: &deadpool_postgres::Object) -> Result<(), MigrateError> {
250 conn.batch_execute(
251 "CREATE TABLE IF NOT EXISTS schema_migrations (
252 version TEXT PRIMARY KEY,
253 applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
254 )",
255 )
256 .await?;
257 Ok(())
258}
259
260async fn applied_versions(
262 conn: &deadpool_postgres::Object,
263) -> Result<HashSet<String>, MigrateError> {
264 let rows = conn
265 .query("SELECT version FROM schema_migrations", &[])
266 .await?;
267 let mut set = HashSet::with_capacity(rows.len());
268 for row in rows {
269 set.insert(row.get::<_, String>(0));
270 }
271 Ok(set)
272}
273
274async fn apply_one(
276 conn: &mut deadpool_postgres::Object,
277 version: &str,
278 sql: &str,
279) -> Result<(), MigrateError> {
280 let tx = conn.transaction().await.map_err(MigrateError::Query)?;
281
282 if let Err(e) = tx.batch_execute(sql).await {
284 let _ = tx.rollback().await;
287 return Err(MigrateError::Apply {
288 version: version.to_string(),
289 source: e,
290 });
291 }
292
293 if version == "026" {
294 if let Err(error) = migrate_katex_html(&tx).await {
295 let _ = tx.rollback().await;
296 return Err(error);
297 }
298 }
299
300 if let Err(e) = tx
302 .execute(
303 "INSERT INTO schema_migrations (version) VALUES ($1)",
304 &[&version],
305 )
306 .await
307 {
308 let _ = tx.rollback().await;
309 return Err(MigrateError::Apply {
310 version: version.to_string(),
311 source: e,
312 });
313 }
314
315 tx.commit().await.map_err(|e| MigrateError::Apply {
316 version: version.to_string(),
317 source: e,
318 })?;
319 Ok(())
320}
321
322async fn migrate_katex_html(tx: &deadpool_postgres::Transaction<'_>) -> Result<(), MigrateError> {
325 let db_error = |source| MigrateError::Apply {
326 version: "026".to_string(),
327 source,
328 };
329 tx.batch_execute("ALTER TABLE comments DISABLE TRIGGER trg_comments_updated_at")
331 .await
332 .map_err(db_error)?;
333
334 for table in ["posts", "comments"] {
335 let select = tx
337 .prepare(&format!(
338 "SELECT id::bigint, content_html FROM {table} \
339 WHERE ($1::bigint IS NULL OR id > $1) AND content_html LIKE '%katex%' \
340 ORDER BY id LIMIT 100"
341 ))
342 .await
343 .map_err(db_error)?;
344 let update = tx
345 .prepare(&format!(
346 "UPDATE {table} SET content_html = $1 WHERE id = $2::bigint"
347 ))
348 .await
349 .map_err(db_error)?;
350 let mut last_id: Option<i64> = None;
351 loop {
352 let rows = tx.query(&select, &[&last_id]).await.map_err(db_error)?;
353 if rows.is_empty() {
354 break;
355 }
356 for row in rows {
357 let id: i64 = row.get(0);
358 let html: &str = row.get(1);
359 if let Some(rewritten) = migrate_katex_classes(html)
360 .map_err(|source| MigrateError::KatexHtml { table, id, source })?
361 {
362 tx.execute(&update, &[&rewritten, &id])
363 .await
364 .map_err(db_error)?;
365 }
366 last_id = Some(id);
367 }
368 }
369 }
370
371 tx.batch_execute("ALTER TABLE comments ENABLE TRIGGER trg_comments_updated_at")
372 .await
373 .map_err(db_error)?;
374 Ok(())
375}
376
377fn is_old_katex_class(class: &str) -> bool {
380 matches!(
381 class,
382 "accent"
383 | "base"
384 | "fix"
385 | "hdashline"
386 | "hline"
387 | "inner"
388 | "newline"
389 | "overlay"
390 | "overline"
391 | "root"
392 | "rule"
393 | "sizing"
394 | "smash"
395 | "sout"
396 | "stretchy"
397 | "strut"
398 | "tag"
399 | "thinbox"
400 | "underline"
401 | "vbox"
402 )
403}
404
405fn migrate_katex_classes(html: &str) -> Result<Option<String>, lol_html::errors::RewritingError> {
408 let mut changed = false;
409 let settings = lol_html::RewriteStrSettings::new().append_element_content_handler(
410 lol_html::element!("span.katex > span.katex-html span[class]", |el| {
411 let Some(classes) = el.get_attribute("class") else {
412 return Ok(());
413 };
414 if !classes.split_ascii_whitespace().any(is_old_katex_class) {
415 return Ok(());
416 }
417 let mut rewritten = String::with_capacity(classes.len() + 6);
418 for class in classes.split_ascii_whitespace() {
419 if !rewritten.is_empty() {
420 rewritten.push(' ');
421 }
422 if is_old_katex_class(class) {
423 rewritten.push_str("katex-");
424 }
425 rewritten.push_str(class);
426 }
427 el.set_attribute("class", &rewritten)?;
428 changed = true;
429 Ok(())
430 }),
431 );
432 let rewritten = lol_html::rewrite_str(html, settings)?;
433 Ok(changed.then_some(rewritten))
434}
435
436#[cfg(all(test, feature = "server"))]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn katex_migration_preserves_non_formula_html() {
442 let before = r#"<!-- base class="overline" --><p class="base underline" title="overline">base & underline</p><pre><code><span class="base"></code></pre>"#;
443 let formula = r#"<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="mord overline" title="base & overline">overline & base</span></span></span></span>"#;
444 let after = r#"<span class="overline">unchanged</span><span class="katex"><span class="base">not a rendered visual layer</span></span>"#;
445 let input = format!("{before}{formula}{after}");
446 let output = migrate_katex_classes(&input).unwrap().unwrap();
447 assert!(output.starts_with(before));
448 assert!(output.ends_with(after));
449 assert!(output.contains(r#"class="katex-base""#));
450 assert!(output.contains(
451 r#"class="mord katex-overline" title="base & overline">overline & base</span>"#
452 ));
453 assert_eq!(migrate_katex_classes(&output).unwrap(), None);
454 }
455
456 #[test]
457 fn katex_migration_renames_only_complete_layout_tokens() {
458 let input = r#"<span class="katex"><span class="katex-html"><span class="accent base fix hdashline hline inner newline overlay overline root rule sizing smash sout stretchy strut tag thinbox underline vbox mord overline-line underline-line accent-body reset-size6 size3 mtight my-base katex-base">x</span></span></span>"#;
459 let expected = r#"<span class="katex"><span class="katex-html"><span class="katex-accent katex-base katex-fix katex-hdashline katex-hline katex-inner katex-newline katex-overlay katex-overline katex-root katex-rule katex-sizing katex-smash katex-sout katex-stretchy katex-strut katex-tag katex-thinbox katex-underline katex-vbox mord overline-line underline-line accent-body reset-size6 size3 mtight my-base katex-base">x</span></span></span>"#;
460 assert_eq!(
461 migrate_katex_classes(input).unwrap().as_deref(),
462 Some(expected)
463 );
464 }
465
466 #[test]
467 fn migrations_are_sorted_ascending() {
468 let mut sorted = MIGRATIONS.iter().map(|(v, _)| *v).collect::<Vec<_>>();
469 sorted.sort_unstable();
470 let original: Vec<&str> = MIGRATIONS.iter().map(|(v, _)| *v).collect();
471 assert_eq!(
472 original, sorted,
473 "MIGRATIONS must be in ascending version order"
474 );
475 }
476
477 #[test]
478 fn migrations_have_unique_versions() {
479 let mut versions: Vec<&str> = MIGRATIONS.iter().map(|(v, _)| *v).collect();
480 let total = versions.len();
481 versions.sort_unstable();
482 versions.dedup();
483 assert_eq!(
484 versions.len(),
485 total,
486 "MIGRATIONS has duplicate version strings"
487 );
488 }
489
490 #[test]
494 fn migrations_match_files_on_disk() {
495 use std::collections::HashSet;
496 use std::fs;
497
498 let manifest_dir = env!("CARGO_MANIFEST_DIR");
500 let migrations_dir = std::path::Path::new(manifest_dir).join("migrations");
501
502 let mut files_on_disk: HashSet<String> = HashSet::new();
503 for entry in fs::read_dir(&migrations_dir)
504 .unwrap_or_else(|e| panic!("failed to read {}: {}", migrations_dir.display(), e))
505 {
506 let entry = entry.unwrap();
507 let path = entry.path();
508 if path.extension().and_then(|e| e.to_str()) == Some("sql") {
509 let filename = path
510 .file_name()
511 .and_then(|n| n.to_str())
512 .unwrap_or_else(|| panic!("non-utf8 filename: {}", path.display()));
513 let version = filename
515 .split('_')
516 .next()
517 .unwrap_or_else(|| panic!("filename has no '_' separator: {}", filename));
518 files_on_disk.insert(version.to_string());
519 }
520 }
521
522 let versions_in_array: HashSet<String> =
523 MIGRATIONS.iter().map(|(v, _)| v.to_string()).collect();
524
525 let missing_in_array: Vec<&String> = files_on_disk.difference(&versions_in_array).collect();
527 assert!(
528 missing_in_array.is_empty(),
529 "migrations/*.sql files not registered in MIGRATIONS: {:?}. \
530 Add a row for each in src/db/migrate.rs.",
531 missing_in_array
532 );
533
534 let missing_on_disk: Vec<&String> = versions_in_array.difference(&files_on_disk).collect();
536 assert!(
537 missing_on_disk.is_empty(),
538 "MIGRATIONS rows without a corresponding .sql file: {:?}",
539 missing_on_disk
540 );
541 }
542}