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